check-decision-refs.mjs

Decision log reference check

Tier 2designengineeringproductWarns only

A decision that names a file somebody has since renamed or deleted, so the reasoning can no longer be followed to the code and gets decided again. Also checks the session notes and the build log.

CHECK-DECISION-REFS.MJSPRINTS, DOES NOT BLOCK DECISIONS SESSION NOTES FILES ON DISK STILL POINTS THERE RENAMED DELETED
Add this to your project
Add "Decision log reference check" from ntent to this repo.

Fetch https://ntent.app/r/f/check-decision-refs as plain text. Write it verbatim to scripts/check-decision-refs.mjs. Then make it executable. Then make exactly these edits and no others: Point CONFIG.decisionLogs, CONFIG.stateFiles and CONFIG.buildLogDir at where this repo keeps its notes, if they are not DECISIONS.md, STATE.md and docs/build-log. Add to CONFIG.roots any top-level source directory it does not list. It needs DECISIONS.md. If this repo does not have it, stop and tell me.

Then check it: Name a real file in backticks in a decision, then rename that file with git mv and commit. The check names the entry, the commit that renamed it and the new path, and prints the Moved line to add. Add that line under the entry and it passes, with the entry itself unchanged. It says nothing about whether the decision is still right.

Reads https://ntent.app/r/f/check-decision-refs

Read the code
check-decision-refs.mjsscripts/check-decision-refs.mjs · make it executable
523 lines
#!/usr/bin/env node
/**
 * Memory drift check: a note that names a file should still be pointing at one.
 *
 * A decision log is written once and read for years, and the code it talks
 * about keeps moving underneath it. Somebody renames OverdueBanner.tsx, every
 * gate is green, and the entry that explains why the banner exists now points
 * at nothing. The entry is not wrong yet. It is unfindable, which is how it
 * becomes wrong: the next person cannot connect the reasoning to the code, so
 * they re-decide it.
 *
 * WHAT COUNTS AS A REFERENCE. Only text in `backticks`, and only three shapes:
 *
 *   a path        `src/components/OverdueBanner.tsx:31`   the :31 is dropped
 *   a file name   `OverdueBanner.tsx`
 *   a symbol      `OverdueBanner`
 *
 * Prose is never read. Backticks are the writer saying "this is a real thing in
 * the repo", and that is the only claim a script can check. Fenced code blocks
 * and HTML comments are skipped: they hold examples, not references.
 *
 * THREE KINDS OF FILE, read differently because they age differently.
 *
 *   The decision log. Every entry is checked until a later entry supersedes
 *   it, because a decision stays in force however old it is.
 *
 *   The session state. Only the live part: the resume block, or everything
 *   above the archive. It is meant to be current, so the fix is to edit it.
 *
 *   The build log. Only the narrative. A section whose heading starts with a
 *   date is a record of that day, and a record is allowed to name a file that
 *   has since gone.
 *
 * A PATH IS CHECKED AGAINST THE DISK. A NAME IS CHECKED AGAINST HISTORY. A
 * missing path is a finding whatever else is true: it says where something is
 * and nothing is there. A bare file name or a symbol is different, because
 * `React` and `Response.json` are in backticks too and were never in this repo.
 * So a missing name is a finding only when git says it used to be here. Without
 * history the script cannot tell, says how many it could not check, and flags
 * none of them. A nag that cries wolf gets switched off.
 *
 * CLEARING A FINDING, without editing the entry. The log is append-only, so
 * there are two moves and neither rewrites what was decided:
 *
 *   Renamed. Add one line under the entry. The old words stay as they were.
 *     **Moved:** `src/old/Path.tsx` is now `src/new/Path.tsx`. 2026-09-29
 *
 *   Deleted. The thing the decision is about is gone, so the decision has
 *   changed. Write a new entry, and name the one it replaces:
 *     **Supersedes:** 2026-08-30 | invoices | Overdue is a persistent banner
 *
 * The new path on a Moved line is checked like any other, so a second rename
 * is caught too. A Supersedes line has to name an entry that exists.
 *
 * WHAT IT DOES NOT PROVE. That the decision is still right, that two entries
 * do not contradict each other, or anything about an entry that names nothing
 * in backticks. It proves that what the notes point at is still there.
 *
 * Usage:
 *   node scripts/check-decision-refs.mjs           report, always exit 0
 *   node scripts/check-decision-refs.mjs --check   exit 1 on any finding
 *   node scripts/check-decision-refs.mjs --fast    paths only, no git, no walk
 *   node scripts/check-decision-refs.mjs --count   print the number and nothing else
 *
 * --fast is for the session banner, which has a second for everything. Exit 2
 * when an IGNORE entry has no reason, or under --check when there is no memory
 * file to read at all: both are the check being broken, not a finding.
 */
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs'
import { join, basename } from 'node:path'
import { execFileSync } from 'node:child_process'
import { pathToFileURL } from 'node:url'

// ---------------------------------------------------------------- CONFIG ---

export const CONFIG = {
  // First one that exists wins, so a repo keeps its log wherever it already is.
  decisionLogs: ['DECISIONS.md', 'docs/DECISIONS.md'],
  stateFiles: ['STATE.md', 'docs/PROGRESS.md'],
  // Every .md in here is a build log, except the queues the hook writes to:
  // one per committer, docs/build-log/_pending-<author>.md.
  buildLogDir: 'docs/build-log',
  buildLogSkipPrefix: '_pending',
  // A backticked `a/b` is a path when `a` is one of these, or when it ends in
  // a known extension. Anything else with a slash in it is a Tailwind class, a
  // branch name or a fraction.
  roots: ['src', 'app', 'apps', 'packages', 'scripts', 'docs', 'public', 'tests', 'test', 'e2e', '.github'],
  extensions: [
    'ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'json', 'md', 'mdx', 'css', 'scss',
    'sh', 'yml', 'yaml', 'sql', 'html', 'svg', 'toml', 'prisma',
  ],
  // Where a symbol has to appear to count as alive.
  codeExtensions: ['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs'],
  ignoreDirs: ['node_modules', '.next', 'dist', 'build', 'out', 'coverage', '.git', '.turbo', '.vercel'],
}

/**
 * References that are allowed to point at nothing, each with the reason. An
 * entry with no reason stops the script, every one that matched prints on every
 * run, and one that matched nothing is named so it can be deleted.
 */
const IGNORE = {
  // 'src/generated/schema.ts': {
  //   reason: 'Built by pnpm codegen and not committed, so it is missing in a fresh clone.',
  // },
}

const ISO_DATE = /\b\d{4}-\d{2}-\d{2}\b/

// --------------------------------------------------------------- PARSING ---

/**
 * Empty out fenced blocks and HTML comments, keeping every newline so the line
 * numbers in a finding are the line numbers in the file.
 */
export function blank(text) {
  const keepLines = (s) => s.replace(/[^\n]/g, '')
  return text
    .replace(/<!--[\s\S]*?-->/g, keepLines)
    .replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, keepLines)
}

/**
 * What a backticked span refers to, or null when it is not a reference.
 * Returns { kind: 'path' | 'file' | 'symbol', ref }.
 */
export function classify(raw) {
  let s = raw.trim()
  // A command, a phrase, a glob, a call, a placeholder or a URL.
  if (!s || /\s/.test(s) || /[*<>{}()$=,;|'"]/.test(s) || s.includes('://')) return null
  // A route, a package or an alias, a flag, a home or parent path.
  if (/^[/@~-]/.test(s) || s.startsWith('..')) return null
  s = s.replace(/(:\d+(-\d+)?|#L\d+(-L?\d+)?)$/, '').replace(/^\.\//, '').replace(/\/$/, '')
  const ext = /\.([A-Za-z0-9]+)$/.exec(s)?.[1]?.toLowerCase()
  const known = Boolean(ext) && CONFIG.extensions.includes(ext)
  if (s.includes('/')) {
    return known || CONFIG.roots.includes(s.split('/')[0]) ? { kind: 'path', ref: s } : null
  }
  if (known) return { kind: 'file', ref: s }
  // PascalCase with a lowercase letter in it, so API and JSON are not symbols.
  if (/^[A-Z][A-Za-z0-9]*$/.test(s) && /[a-z]/.test(s)) return { kind: 'symbol', ref: s }
  return null
}

const spansIn = (line) => [...line.matchAll(/`([^`\n]+)`/g)].map((m) => m[1])
const directive = (word) => new RegExp(`^\\s*(?:[-*] )?\\*{0,2}${word}\\*{0,2}:\\*{0,2}\\s*(.*)$`)
const MOVED = directive('Moved')
const SUPERSEDES = directive('Supersedes')
const tidyTitle = (s) => s.replace(/^#+\s*/, '').replace(/[`"“”]/g, '').replace(/\.$/, '').trim()

/** Split a file into sections, one per heading. Lines are 0-based. */
export function sectionsOf(text) {
  const sections = []
  let cur = { title: '(top of file)', line: 0, lines: [] }
  sections.push(cur)
  blank(text)
    .split('\n')
    .forEach((line, i) => {
      const h = /^#{1,6}\s+(.*\S)\s*$/.exec(line)
      if (h) {
        cur = { title: h[1], line: i, lines: [] }
        sections.push(cur)
      } else {
        cur.lines.push({ text: line, line: i })
      }
    })
  return sections
}

/**
 * The part of a state file that is printed into sessions: the resume block if
 * it has one, otherwise everything above the archive.
 */
export function liveStateOf(text) {
  const lines = text.split('\n')
  const start = lines.findIndex((l) => l.includes('<!-- RESUME:START -->'))
  const end = lines.findIndex((l) => l.includes('<!-- RESUME:END -->'))
  const archive = lines.findIndex((l) => /^#{1,6}\s+Archive\b/i.test(l))
  const keep =
    start !== -1 && end > start
      ? (i) => i > start && i < end
      : (i) => archive === -1 || i < archive
  return lines.map((l, i) => (keep(i) ? l : '')).join('\n')
}

/**
 * Read one memory file into references and problems with the notes themselves.
 * `kind` is 'decisions', 'state' or 'build-log'.
 */
export function readNotes(file, text, kind) {
  const refs = []
  const problems = []
  let moved = 0
  let sections = sectionsOf(kind === 'state' ? liveStateOf(text) : text)
  if (kind === 'build-log') sections = sections.filter((s) => !/^\d{4}-\d{2}-\d{2}\b/.test(s.title))

  const superseded = new Set()
  if (kind === 'decisions') {
    for (const section of sections) {
      for (const { text: line, line: n } of section.lines) {
        const m = SUPERSEDES.exec(line)
        if (!m) continue
        const want = tidyTitle(m[1])
        const others = sections.filter((s) => s !== section)
        const hit =
          others.filter((s) => tidyTitle(s.title) === want)[0] ??
          (others.filter((s) => tidyTitle(s.title).startsWith(want)).length === 1
            ? others.find((s) => tidyTitle(s.title).startsWith(want))
            : null)
        if (want && hit) superseded.add(hit)
        else
          problems.push({
            kind: 'BAD-SUPERSEDES',
            file,
            line: n,
            section: section.title,
            detail: want
              ? `no entry, or more than one, is titled "${want}"`
              : 'the line names no entry',
            fix: 'copy the heading of the entry it replaces, without the ###',
          })
      }
    }
  }

  for (const section of sections) {
    if (superseded.has(section)) continue
    // Old references a Moved line has already accounted for, in this entry only.
    const excused = new Set()
    if (kind === 'decisions') {
      for (const { text: line, line: n } of section.lines) {
        const m = MOVED.exec(line)
        if (!m) continue
        const spans = spansIn(m[1])
        if (spans.length < 2 || !ISO_DATE.test(m[1])) {
          problems.push({
            kind: 'BAD-MOVED',
            file,
            line: n,
            section: section.title,
            detail: 'a Moved line needs the old name, the new name and a date',
            fix: '**Moved:** `old/path.tsx` is now `new/path.tsx`. 2026-09-29',
          })
          continue
        }
        excused.add(classify(spans[0])?.ref ?? spans[0].trim())
        moved++
      }
    }
    for (const { text: line, line: n } of section.lines) {
      for (const span of spansIn(line)) {
        const ref = classify(span)
        if (!ref || excused.has(ref.ref)) continue
        refs.push({ ...ref, file, line: n, section: section.title, notes: kind })
      }
    }
  }
  return { refs, problems, moved }
}

// ------------------------------------------------------------------ DISK ---

/** Every file name in the repo, and the text of every code file, read once. */
function readRepo() {
  const names = new Set()
  const code = []
  const walk = (dir) => {
    let entries
    try {
      entries = readdirSync(dir)
    } catch {
      return
    }
    for (const entry of entries) {
      if (CONFIG.ignoreDirs.includes(entry)) continue
      const full = dir === '.' ? entry : join(dir, entry)
      let stat
      try {
        stat = statSync(full)
      } catch {
        continue
      }
      if (stat.isDirectory()) {
        walk(full)
        continue
      }
      names.add(entry)
      const ext = /\.([A-Za-z0-9]+)$/.exec(entry)?.[1]?.toLowerCase()
      if (ext && CONFIG.codeExtensions.includes(ext) && stat.size < 1_000_000) {
        code.push(readFileSync(full, 'utf8'))
      }
    }
  }
  walk('.')
  return { names, code: code.join('\n') }
}

function isAlive(ref, repo) {
  if (ref.kind === 'path') return existsSync(ref.ref)
  if (ref.kind === 'file') return existsSync(ref.ref) || repo.names.has(ref.ref)
  if (new RegExp(`\\b${ref.ref}\\b`).test(repo.code)) return true
  return [...repo.names].some((n) => n.startsWith(`${ref.ref}.`))
}

// ------------------------------------------------------------------- GIT ---

function git(args) {
  try {
    return execFileSync('git', args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }).trim()
  } catch {
    return null
  }
}

/** 'full', 'shallow' or 'none'. A shallow clone has history, just not all of it. */
function historyDepth() {
  if (git(['rev-parse', '--verify', '--quiet', 'HEAD']) === null) return 'none'
  return git(['rev-parse', '--is-shallow-repository']) === 'true' ? 'shallow' : 'full'
}

/** The commit that removed a path, and where it went if that commit renamed it. */
function removalOf(pathspec, matches) {
  const sha = git(['log', '-1', '--diff-filter=D', '--format=%h', '--', pathspec])
  if (!sha) return null
  const rows = (git(['show', '-M', '--name-status', '--format=', sha]) ?? '')
    .split('\n')
    .map((l) => l.split('\t'))
  const renamed = rows.find((r) => /^R/.test(r[0]) && matches(r[1]))
  return { sha, to: renamed?.[2] }
}

/**
 * What history knows about a reference that is not on disk. Null means history
 * has never heard of it. A rename is followed until it lands on something that
 * exists, because a file renamed twice should be reported at its third name.
 */
function traceOf(ref) {
  if (ref.kind === 'symbol') {
    const sha = git(['log', '-1', '-S', ref.ref, '--format=%h', '--', '.', ':(exclude,glob)**/*.md'])
    return sha ? { sha } : null
  }
  let first =
    ref.kind === 'path'
      ? removalOf(ref.ref, (p) => p === ref.ref)
      : removalOf(`:(glob)**/${ref.ref}`, (p) => basename(p) === ref.ref)
  if (!first) return null
  let to = first.to
  for (let hop = 0; to && !existsSync(to) && hop < 5; hop++) {
    const next = removalOf(to, (p) => p === to)
    to = next?.to
    if (!next) break
  }
  return { sha: first.sha, to: to && existsSync(to) ? to : undefined, lost: Boolean(first.to) && !to }
}

// ---------------------------------------------------------------- REPORT ---

const today = () => new Date().toISOString().slice(0, 10)

function adviceFor(ref, trace) {
  const lines = []
  if (trace?.to) lines.push(`renamed in ${trace.sha}, now ${trace.to}`)
  else if (trace?.lost) lines.push(`renamed in ${trace.sha}, and what it became has gone too`)
  else if (trace && ref.kind === 'symbol') lines.push(`last seen in code at ${trace.sha}`)
  else if (trace) lines.push(`deleted in ${trace.sha}`)
  else lines.push('git history does not say where it went')

  if (ref.notes !== 'decisions') {
    lines.push('fix: edit the note. This part of the file is meant to be current.')
  } else if (trace?.to) {
    lines.push('fix: leave the entry as it is, and add this line under it:')
    lines.push(`     **Moved:** \`${ref.ref}\` is now \`${trace.to}\`. ${today()}`)
  } else {
    lines.push('fix: if it was renamed, add under the entry:')
    lines.push(`     **Moved:** \`${ref.ref}\` is now \`<where it is>\`. ${today()}`)
    lines.push('     if it is gone, the decision has changed. Write a new entry with:')
    lines.push(`     **Supersedes:** ${tidyTitle(ref.section)}`)
  }
  return lines
}

function validateIgnore() {
  for (const [name, rec] of Object.entries(IGNORE)) {
    if (rec && rec.reason) continue
    console.error(`\n  IGNORE entry "${name}" has no reason.`)
    console.error(`  An exception nobody can explain is one nobody can remove. Add the`)
    console.error(`  reason, or delete the entry and fix the note.\n`)
    process.exit(2)
  }
}

// ------------------------------------------------------------------ MAIN ---

function memoryFiles() {
  const found = []
  const decisions = CONFIG.decisionLogs.find((f) => existsSync(f))
  if (decisions) found.push({ file: decisions, kind: 'decisions' })
  const state = CONFIG.stateFiles.find((f) => existsSync(f))
  if (state) found.push({ file: state, kind: 'state' })
  if (existsSync(CONFIG.buildLogDir)) {
    for (const entry of readdirSync(CONFIG.buildLogDir).sort()) {
      if (!entry.endsWith('.md') || entry.startsWith(CONFIG.buildLogSkipPrefix)) continue
      found.push({ file: join(CONFIG.buildLogDir, entry), kind: 'build-log' })
    }
  }
  return found
}

function main() {
  const flags = new Set(process.argv.slice(2))
  const strict = flags.has('--check')
  const fast = flags.has('--fast')
  const countOnly = flags.has('--count')
  const say = countOnly ? () => {} : (s = '') => console.log(s)
  validateIgnore()

  const files = memoryFiles()
  if (!files.length) {
    if (countOnly) console.log(0)
    else if (strict) {
      console.error(`\n  refs: no memory file to read. Looked for ${CONFIG.decisionLogs.join(', ')},`)
      console.error(`  ${CONFIG.stateFiles.join(', ')} and ${CONFIG.buildLogDir}/. Point CONFIG at where`)
      console.error(`  this repo keeps them. A check with nothing to read passes for ever.\n`)
      process.exit(2)
    } else say('refs: nothing to check. No decision log, state file or build log here.')
    process.exit(0)
  }

  const refs = []
  const problems = []
  let moved = 0
  for (const { file, kind } of files) {
    const read = readNotes(file, readFileSync(file, 'utf8'), kind)
    refs.push(...read.refs)
    // --fast answers one question, whether a path is there. The rest waits for a full run.
    if (!fast) problems.push(...read.problems)
    moved += read.moved
  }

  const repo = fast ? null : readRepo()
  const depth = fast ? 'none' : historyDepth()
  const findings = []
  const known = []
  let unchecked = 0
  let checked = 0
  for (const ref of refs) {
    if (fast && ref.kind !== 'path') continue
    checked++
    if (isAlive(ref, repo)) continue
    if (IGNORE[ref.ref]) {
      known.push(ref)
      continue
    }
    const trace = depth === 'none' ? null : traceOf(ref)
    // A name history has never heard of was never a reference to this repo.
    // With only part of the history, that cannot be told from a name that was.
    if (ref.kind !== 'path' && !trace) {
      if (depth !== 'full') unchecked++
      continue
    }
    findings.push({ ref, trace })
  }

  const total = findings.length + problems.length
  if (countOnly) {
    console.log(total)
    process.exit(strict && total ? 1 : 0)
  }

  if (known.length) {
    say(`\n  Allowed to point at nothing, each with a reason:\n`)
    for (const ref of known) {
      say(`  [known] \`${ref.ref}\`   ${ref.file}:${ref.line + 1}`)
      say(`          ${IGNORE[ref.ref].reason}`)
      say()
    }
  }
  const unused = fast ? [] : Object.keys(IGNORE).filter((name) => !known.some((r) => r.ref === name))
  if (unused.length) {
    say(`  IGNORE entries that matched nothing this run:\n`)
    for (const name of unused) {
      say(`  [stale] ${name}`)
      say(`          It exists again, or no note names it any more. Delete the entry.`)
      say()
    }
  }

  if (unchecked) {
    const why = depth === 'none' ? 'there is no git history here' : 'this is a shallow clone'
    say(`  refs: ${unchecked} name(s) not on disk could not be checked, because ${why}.`)
    say(`        A name is only a finding when history says it used to be here.\n`)
  }

  if (!total) {
    const followed = moved ? ` ${moved} Moved line(s) followed.` : ''
    say(`refs: ok. ${checked} reference(s) in ${files.length} file(s) still point at something.${followed}`)
    process.exit(0)
  }

  say(`\n  refs: ${total} finding(s)\n`)
  for (const { ref, trace } of findings) {
    say(`  STALE-REF  ${ref.file}:${ref.line + 1}  \`${ref.ref}\``)
    say(`    in: ${ref.section}`)
    for (const line of adviceFor(ref, trace)) say(`    ${line}`)
    say()
  }
  for (const p of problems) {
    say(`  ${p.kind}  ${p.file}:${p.line + 1}`)
    say(`    in: ${p.section}`)
    say(`    ${p.detail}`)
    say(`    fix: ${p.fix}`)
    say()
  }
  say(`  None of this says a decision is wrong. It says the note can no longer be`)
  say(`  followed to the code, which is the step before nobody trusts it.\n`)

  process.exit(strict ? 1 : 0)
}

if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
  main()
}

How to tell it works: Name a real file in backticks in a decision, then rename that file with git mv and commit. The check names the entry, the commit that renamed it and the new path, and prints the Moved line to add. Add that line under the entry and it passes, with the entry itself unchanged. It says nothing about whether the decision is still right.

Included in