SLOPSHOPPER

doc-drift-watch

After each commit the model makes, adds the doc lines that commit made stale (file:line references, symbol names) to the commit's result, from ripwire…

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · doc-drift-watch
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /doc-drift-watch ⎿ doc-drift-watch: on · mode note · no doc is open; it needs ripwire on PATH ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

doc-drift-watch

The model deletes a file or renames a function, commits, and a README somewhere still points at other.go:3. Nobody notices until a reader follows the link. This mod catches it at the commit: after each git commit the model runs, it asks ripwire which markdown anchors no longer hold, and adds the ones the commit broke to the commit's result.

What it does

  1. It watches the Bash tool. A command that runs git commit is checked, also git -C <dir> commit or with git's global flags in front, but not with --dry-run, --help or -h.
  2. Before the command runs, it finds the repository root from the session's directory, the last cd before the commit and the commit's git -C, and runs ripwire <root> --doc-drift --with-history by argv.
  3. After a successful commit it runs the same command again and compares the two runs. A stale anchor counts as the same one when its doc, kind, reason and reference match; the line number is left out, because an edit above it moves it.
  4. Only the anchors the commit added are reported. The model reads this note right after the commit's result:

doc-drift-watch: this commit made 1 doc line(s) stale: README.md:7 points at other.go:3, a file that no longer exists. Update them in a follow-up commit, or tell the user why a line stays.

At most 8 lines are named, the rest are counted.

  1. At the same moment you get one line in the transcript, so you see what the model was told. It holds the stale lines alone, without the instruction:

doc-drift-watch: 1 doc line(s) stale: README.md:7 points at other.go:3, a file that no longer exists

The note and the line are separate channels: the model never reads the line, and you never read the note.

  1. With the sidebar open, the lines go into its stream instead, one entry per doc, and the transcript stays clean. The doc:line part is faint, the stale reference red, and what now sits at a moved line yellow. Without the sidebar, the line lands in the transcript as above.
  2. The finding then stays open, one per doc. At each main-loop turn's end the mod measures every open doc again with ripwire <root> --doc-drift=<doc> --with-history, a run narrowed to that one doc. A doc whose anchors all hold again is closed: its entry is cleared and a green line takes its place.

doc-drift-watch: README.md: 1 doc line(s) hold again

A doc that is gone closes the finding from the other side, and the line says so: README.md is gone, and its 1 stale line(s) with it. A doc that has fixed only some of its stale lines stays open, because partly fixed is not fixed. A later commit that makes more lines of an open doc stale adds them to that doc's finding; the lines it already held stay as long as the drift after the commit still reports them.

  1. Whatever is left reaches the model as one note with your next prompt, one note per turn:

doc-drift-watch: 1 doc(s) still hold stale lines: README.md (1). Update them.

An anchor that was already stale before the commit is not repeated, so an example path in a README does not come back on every commit. An anchor its author dated (ripwire kind="dated-record") is not reported either, because it records what was true back then.

In the live check the model deleted a file that a README pointed at with other.go:3, read the note after the commit, and repeated it word for word. An older stale anchor in the same README was not in the note.

The two modes

note is the default: the mod reports and stops nothing.

In deny mode a git commit, git push or git merge is stopped while a doc still holds a stale line. The mod measures each open doc again before it answers, so a doc the model fixed opens the gate by itself and no gate stays closed for good:

doc-drift-watch: stopped: 1 doc(s) still hold stale lines: README.md (1). Update them and run the command again; there is no way around this gate.

A git commit answers for its own files alone: the mod reads the index (git rev-parse --show-toplevel and git diff --cached --name-only -z) and lets the commit run when it holds none of the open docs, with one line saying how many still stand. A git commit -a, a -am and a commit with a pathspec after -- are not narrowed, because the index alone does not say what they commit. A push and a merge read no index, so every finding counts there.

There is no bypass and no one-time pass. The gate opens when the docs hold again, or when you set /doc-drift-watch mode note.

Command

/doc-drift-watch the status: on or off, the mode, and the docs still stale /doc-drift-watch on | off on by default /doc-drift-watch mode note report only, the default /doc-drift-watch mode deny also stop git commit, push and merge while a doc is stale

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install doc-drift-watch@kilimcininkoroglu-mods

Function hooks are early access. Claude Code 2.1.288 and later load them by default, so there is nothing to switch on.

After installing

  1. Install ripwire and put it on PATH. Without it a commit writes the docs were not checked: ... as a yellow entry (a transcript line with the sidebar closed), once until a different error comes, and the commit runs as before.
  2. Restart Claude Code.

What it can reach

Validated with claude plugin validate on Claude Code 2.1.283:

❯ ./register.ts hooks: session.start, command.run{command=doc-drift-watch}, tool.call{tool=Bash}, turn.complete, prompt.submit ❯ ./register.ts calls: $.command.register, $.fs.read (via isGone), $.process.run (via driftNow, repoRoot, stagedPaths), $.session.cwd (via beforeCommit, stagedPaths), $.sidebar.clear (via closeOne), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via gate, toPerson)

Reach L2: it runs processes.

  1. Reads: the Bash command text, each open doc's own file; through ripwire, the repository's markdown, source and git history
  2. Runs: git rev-parse, git diff --cached and ripwire --doc-drift, read-only, by argv: twice per commit, once per open doc at each turn's end and at a guarded command
  3. Sends: a note to the model after the commit's result and at the next prompt, and one line to the transcript or the sidebar; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the mode
  5. Hostile input: the directory comes from the command text and reaches git only as the working directory, never through a shell; a doc path comes from ripwire's own output and reaches ripwire as one argv value

Limits

  • ripwire checks file:line references, backticked symbol names, = N constants and [N] array extents. Prose is not checked, and ripwire under-reports on purpose: a renamed symbol whose name still occurs elsewhere is not reported.
  • ripwire runs twice per commit, and once per open doc at each turn's end. In this repository one whole-repository run took 0.1 to 0.2 s, and a narrowed one less.
  • A turn's end measures only the open docs. A doc no commit touched that went stale some other way is found at the next commit, not at the turn's end.
  • --doc-drift=<doc> filters by path substring, so a run narrowed to one doc also reads any doc whose path contains that one. The answer is filtered by the doc's own path afterwards.
  • A commit through a script or an alias that hides git commit is not seen.
  • A cd or git -C whose directory the shell expands first (cd $D, cd ~/x, a backquote) names no directory the mod can tell. That commit is not checked, and the yellow line names the word, for example the commit's directory is not known: cd $D. A single-quoted word stays literal.

Development

make install # eslint, typescript-eslint, typescript make lint # complexity limit 10, the build fails above it make typecheck # needs .claude/types/ from /plugin-types make validate make test # claude plugin test

Source 2 files
hooks/register.ts 294 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { addedBy, byDoc, commitDir, denyText, doneLines, doneLog, identity, isCommit, isGuarded, isNarrowable, logText, modeOf, noteText, openNote, parseDrift, sectionKey, sidebarLines, type Closing, type Line, type Mode, type Stale } from './drift.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const CONSUMER = 'doc-drift-watch'
8
9const USAGE = 'expects nothing (the status), on, off or mode note | deny'
10
11/** One open finding: the repository it was measured in, and the stale anchors of one doc. */
12type Open = { root: string; doc: string; stale: Stale[] }
13
14/**
15 * The on/off setting and the mode as the store held them at the last read, the open findings by the
16 * doc's path on disk, whether the model is owed a note for them, and the last error logged, so the same
17 * one is logged once.
18 */
19type State = { enabled: boolean; mode: Mode; open: Map<string, Open>; owed: boolean; lastError?: string }
20
21/**
22 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
23 * another window applies here at the next hook that acts on it.
24 */
25async function readSettings($: EngineInterface, state: State): Promise<void> {
26  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
27  state.mode = modeOf(String(await $.store.get(MODE_KEY))) ?? 'note'
28}
29
30function errorText(err: unknown): string {
31  return err instanceof Error ? err.message : String(err)
32}
33
34async function repoRoot($: EngineInterface, cwd: string): Promise<string | undefined> {
35  const r = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd, timeoutMs: 10_000 })
36  return r.exitCode === 0 ? r.stdout.trim() : undefined
37}
38
39/**
40 * The repository's stale doc anchors now; `--with-history` tells a deleted name from one never defined.
41 * `doc` narrows the run to the docs whose path holds it, so a re-measure reads one doc, not the repository.
42 */
43async function driftNow($: EngineInterface, root: string, doc?: string): Promise<Stale[]> {
44  const flag = doc === undefined ? '--doc-drift' : `--doc-drift=${doc}`
45  const r = await $.process.run(['ripwire', root, flag, '--with-history'], { cwd: root, timeoutMs: 30_000 })
46  if (r.exitCode !== 0) throw new Error(`ripwire --doc-drift failed: ${(r.stderr || r.stdout).trim().slice(0, 200)}`)
47  return parseDrift(r.stdout)
48}
49
50/**
51 * Writes an error once until a different one comes: a yellow entry in the sidebar's stream while it is
52 * open, else the transcript line.
53 */
54async function report($: EngineInterface, state: State, err: unknown): Promise<void> {
55  const text = errorText(err)
56  if (text === state.lastError) return
57  state.lastError = text
58  const line = `the docs were not checked: ${text}`
59  await toPerson($, 'unchecked', 'not checked', [{ text: line, kind: 'warn' }], line)
60}
61
62function withNote(r: ToolCallResult, note: string): ToolCallResult {
63  if (r.deny !== undefined || r.isError === true) return r
64  return { ...r, context: [...(r.context ?? []), note] }
65}
66
67/** The drift before the commit, or undefined when the commit is not checked. */
68async function beforeCommit($: EngineInterface, state: State, command: string): Promise<{ root: string; stale: Stale[] } | undefined> {
69  try {
70    const root = await repoRoot($, commitDir(command, await $.session.cwd()))
71    return root === undefined ? undefined : { root, stale: await driftNow($, root) }
72  } catch (err) {
73    await report($, state, err)
74    return undefined
75  }
76}
77
78/**
79 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
80 * transcript line, as before. The model's note is another channel and does not change here.
81 */
82async function toPerson($: EngineInterface, doc: string, title: string, lines: readonly Line[], line: string): Promise<void> {
83  try {
84    const taken = await $.sidebar.set({ consumer: CONSUMER, key: sectionKey(doc), title, lines, until: 'stream' })
85    if (taken) return
86  } catch {
87    // The sidebar mod is not installed.
88  }
89  $.ui.log(line)
90}
91
92/**
93 * Holds this commit's stale lines open, one finding per doc, and writes each to the person. A doc that
94 * already has a finding keeps the lines of it that `now`, the drift after the commit, still reports, and
95 * this commit's lines join them; a line no longer reported leaves the record.
96 */
97async function openFindings($: EngineInterface, state: State, root: string, added: readonly Stale[], now: readonly Stale[]): Promise<void> {
98  const reported = new Set(now.map(identity))
99  for (const [doc, stale] of byDoc(added)) {
100    const key = `${root}/${doc}`
101    const kept = (state.open.get(key)?.stale ?? []).filter(s => reported.has(identity(s)))
102    const fresh = new Set(stale.map(identity))
103    state.open.set(key, { root, doc, stale: [...kept.filter(s => !fresh.has(identity(s))), ...stale] })
104    await toPerson($, doc, 'doc lines the commit made stale', sidebarLines(stale), logText(stale))
105  }
106}
107
108async function afterCommit($: EngineInterface, state: State, before: { root: string; stale: Stale[] }, r: ToolCallResult): Promise<ToolCallResult> {
109  try {
110    const now = await driftNow($, before.root)
111    const added = addedBy(before.stale, now)
112    state.lastError = undefined
113    if (added.length === 0) return r
114    // The note goes to the model, the finding to the person: neither reads the other's channel.
115    await openFindings($, state, before.root, added, now)
116    return withNote(r, noteText(added))
117  } catch (err) {
118    await report($, state, err)
119    return r
120  }
121}
122
123/** Says the doc holds again, once, and drops the standing finding. */
124async function closeOne($: EngineInterface, state: State, key: string, open: Open, side: Closing): Promise<void> {
125  state.open.delete(key)
126  try {
127    await $.sidebar.clear({ consumer: CONSUMER, key: sectionKey(open.doc) })
128  } catch {
129    // The sidebar mod is not installed.
130  }
131  const count = open.stale.length
132  await toPerson($, open.doc, 'doc lines hold again', doneLines(open.doc, count, side), doneLog(open.doc, count, side))
133}
134
135/** Whether the doc itself is gone, which closes a finding from the other side. */
136async function isGone($: EngineInterface, key: string): Promise<boolean> {
137  try {
138    await $.fs.read(key)
139    return false
140  } catch {
141    return true
142  }
143}
144
145/**
146 * Measures one open finding again over its own doc alone. A finding whose measure cannot be read stays
147 * open, because a claim this mod cannot check is not a claim it may drop.
148 */
149async function recheckOne($: EngineInterface, state: State, key: string, open: Open): Promise<void> {
150  let now: Stale[]
151  try {
152    now = await driftNow($, open.root, open.doc)
153    state.lastError = undefined
154  } catch (err) {
155    await report($, state, err)
156    return
157  }
158  const stale = new Set(now.filter(s => s.doc === open.doc).map(identity))
159  const left = open.stale.filter(s => stale.has(identity(s)))
160  if (left.length === open.stale.length) return
161  if (left.length > 0) return void state.open.set(key, { ...open, stale: left })
162  await closeOne($, state, key, open, (await isGone($, key)) ? 'gone' : 'holds')
163}
164
165/** Measures every open finding again, so neither the pane nor the gate holds a finding the docs dropped. */
166async function recheckOpen($: EngineInterface, state: State): Promise<void> {
167  for (const [key, open] of [...state.open]) await recheckOne($, state, key, open)
168}
169
170/** How many stale lines each open doc still holds, by the path shown to the person. */
171function openCounts(state: State): Map<string, number> {
172  return new Map([...state.open.values()].map(o => [o.doc, o.stale.length]))
173}
174
175/**
176 * The files this commit holds, by absolute path, or undefined when git did not answer. Read before the
177 * command runs, so it is the index as the commit will take it.
178 */
179async function stagedPaths($: EngineInterface, command: string): Promise<Set<string> | undefined> {
180  try {
181    const cwd = commitDir(command, await $.session.cwd())
182    const top = await $.process.run(['git', 'rev-parse', '--show-toplevel'], { cwd })
183    const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd })
184    if (top.exitCode !== 0 || staged.exitCode !== 0) return undefined
185    const base = top.stdout.trim()
186    return new Set(staged.stdout.split('\0').filter(Boolean).map(p => `${base}/${p}`))
187  } catch {
188    // No git here, or the command did not run: the findings are not narrowed.
189    return undefined
190  }
191}
192
193/**
194 * The findings this command answers for. A `git commit` answers for its own files alone, so a stale doc
195 * the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every finding
196 * stands there.
197 */
198async function scopeOf($: EngineInterface, state: State, command: string): Promise<Map<string, number>> {
199  if (!isCommit(command) || !isNarrowable(command)) return openCounts(state)
200  const staged = await stagedPaths($, command)
201  if (staged === undefined) return openCounts(state)
202  return new Map([...state.open].filter(([key]) => staged.has(key)).map(([, o]) => [o.doc, o.stale.length]))
203}
204
205/**
206 * The gate: in deny mode a commit, push or merge waits until every open doc holds again. Each finding is
207 * measured over its own doc first, so one the model fixed opens the gate itself.
208 */
209async function gate($: EngineInterface, state: State, command: string): Promise<ToolCallResult | undefined> {
210  if (state.mode !== 'deny' || state.open.size === 0 || !isGuarded(command)) return undefined
211  await recheckOpen($, state)
212  if (state.open.size === 0) return undefined
213  const scoped = await scopeOf($, state, command)
214  if (scoped.size === 0) {
215    $.ui.log(`${state.open.size} doc(s) still hold stale lines, and this command holds none of them`)
216    return undefined
217  }
218  return { deny: denyText(scoped) }
219}
220
221async function setMode($: EngineInterface, state: State, arg: string): Promise<string> {
222  const mode = modeOf(arg)
223  if (mode === undefined) return 'mode expects note or deny'
224  await $.store.set(MODE_KEY, mode)
225  state.mode = mode
226  return mode === 'deny'
227    ? 'mode deny: git commit, push and merge stop while a doc line stays stale'
228    : 'mode note: nothing is stopped, the finding reaches the model as a note'
229}
230
231async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
232  const [first = '', second = ''] = args.trim().split(/\s+/)
233  if (first === 'mode') return setMode($, state, second)
234  const word = args.trim()
235  if (word === 'on' || word === 'off') {
236    await $.store.set(ENABLED_KEY, word === 'on')
237    state.enabled = word === 'on'
238    return word === 'on' ? 'on: each commit is checked for doc lines it made stale' : 'off: commits are not checked'
239  }
240  if (word !== '') return USAGE
241  await readSettings($, state)
242  const open = state.open.size === 0 ? 'no doc is open' : `${[...openCounts(state)].map(([doc, n]) => `${doc} (${n})`).join(' · ')} still stale`
243  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}; it needs ripwire on PATH`
244}
245
246export const register: Register = on => {
247  const state: State = { enabled: true, mode: 'note', open: new Map(), owed: false }
248
249  on('session.start', async ($, e, next) => {
250    const r = await next(e)
251    await $.command.register({ name: 'doc-drift-watch', description: 'Doc lines a commit made stale: status, on, off, mode note | deny (doc-drift-watch)', argumentHint: '[on | off | mode note | mode deny]' })
252    await readSettings($, state)
253    return r
254  })
255
256  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
257  on('command.run', { command: 'doc-drift-watch' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
258
259  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
260    // Only a commit, or a guarded command while a finding is open, acts on a setting.
261    if (!isCommit(e.command) && (state.open.size === 0 || !isGuarded(e.command))) return next(e)
262    await readSettings($, state)
263    if (!state.enabled) return next(e)
264    const stopped = await gate($, state, e.command)
265    if (stopped !== undefined) return stopped
266    if (!isCommit(e.command)) return next(e)
267    const before = await beforeCommit($, state, e.command)
268    const r = await next(e)
269    if (before === undefined || r.deny !== undefined || r.isError === true) return r
270    return afterCommit($, state, before, r)
271  })
272
273  /*
274   * The turn's end measures every open finding again and owes the model a note for what is left, because
275   * a finding it did not close would otherwise stand in the pane and reach it never again.
276   */
277  on('turn.complete', async ($, e, next) => {
278    const r = await next(e)
279    if (e.agentId !== undefined || state.open.size === 0) return r
280    await readSettings($, state)
281    if (!state.enabled) return r
282    await recheckOpen($, state)
283    state.owed = state.open.size > 0
284    return r
285  })
286
287  // The note goes to the model alone; the person reads the pane, which carries the same finding.
288  on('prompt.submit', async (_, e, next) => {
289    if (!state.owed || state.open.size === 0) return next(e)
290    state.owed = false
291    return next({ ...e, context: [...(e.context ?? []), openNote(openCounts(state))] })
292  })
293}
294
hooks/drift.ts 212 lines
1/** Which commands commit, the doc lines `ripwire --doc-drift` calls stale, and which of them a commit added. */
2
3/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
4const GIT_FLAG = String.raw`(?:\s+-[cC]\s+\S+|\s+--(?:git-dir|work-tree|namespace)=\S+|\s+--(?:no-pager|no-replace-objects|bare|literal-pathspecs|paginate))`
5
6/** A `git commit` the model runs, not one it only asks about. */
7const COMMIT = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+commit\b`)
8const NOT_A_COMMIT = /\s(--dry-run|--help|-h)(\s|$)/
9
10export function isCommit(command: string): boolean {
11  return COMMIT.test(command) && !NOT_A_COMMIT.test(command)
12}
13
14/** A `git commit`, `git push` or `git merge` the model runs, the three the gate stops. */
15const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
16
17/** Whether the gate stops this command while a finding is open. */
18export function isGuarded(command: string): boolean {
19  return GUARDED.test(command) && !NOT_A_COMMIT.test(command)
20}
21
22/**
23 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
24 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
25 */
26export function isNarrowable(command: string): boolean {
27  const words = command.split(/\s+/)
28  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
29}
30
31/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
32export type Mode = 'note' | 'deny'
33
34/** The mode a `/doc-drift-watch mode <word>` argument names, or undefined when it is not one. */
35export function modeOf(arg: string): Mode | undefined {
36  return arg === 'note' || arg === 'deny' ? arg : undefined
37}
38
39const unquote = (word: string): string => word.replace(/^(["'])(.*)\1$/, '$2')
40
41/**
42 * A directory word joined to the one before it. A word the shell expands first (`$D`, `~`, a backquote,
43 * outside single quotes) names no directory this text can tell, so it throws rather than run git in a
44 * directory that is not there.
45 */
46function joinDir(base: string, word: string, how: string): string {
47  const expands = !word.startsWith("'") && (/[$`]/.test(word) || word.startsWith('~'))
48  if (expands) throw new Error(`the commit's directory is not known: ${how} ${word}`)
49  const dir = unquote(word)
50  return dir.startsWith('/') ? dir : `${base.replace(/\/+$/, '')}/${dir}`
51}
52
53/**
54 * The directory the commit runs in: the session's directory, moved by each `cd` before the commit in turn (`cd -`
55 * back to the directory before it) and by its `git -C`, because the hook reads the repository before the
56 * command's own `cd` has run.
57 */
58export function commitDir(command: string, cwd: string): string {
59  const commit = COMMIT.exec(command)
60  if (commit === null) return cwd
61  const cds = [...command.slice(0, commit.index).matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]*"|'[^']*'|[^\s;&|)]+)/g)]
62  const afterCd = cds.reduce(
63    (at, cd) => (cd[1] === '-' ? { dir: at.prev, prev: at.dir } : { dir: joinDir(at.dir, cd[1] ?? '.', 'cd'), prev: at.dir }),
64    { dir: cwd, prev: cwd },
65  ).dir
66  return [...commit[0].matchAll(/-C\s+(\S+)/g)].reduce((dir, c) => joinDir(dir, c[1] ?? '.', 'git -C'), afterCd)
67}
68
69/** One anchor that no longer holds: the doc, its line, and what ripwire says of it. */
70export type Stale = { doc: string; line: string; kind: string; why: string; ref: string; got?: string }
71
72function attr(tag: string, name: string): string | undefined {
73  return new RegExp(`\\s${name}="([^"]*)"`).exec(tag)?.[1]
74}
75
76function anchor(doc: string, tag: string): Stale {
77  const got = attr(tag, 'got')
78  const ref = attr(tag, 'ref') ?? attr(tag, 'sym') ?? '?'
79  return { doc, line: attr(tag, 'l') ?? '?', kind: attr(tag, 'k') ?? '', why: attr(tag, 'why') ?? '', ref, ...(got === undefined ? {} : { got }) }
80}
81
82/** Anchors whose author dated them record what was true then, so a commit cannot make them stale. */
83const isLive = (tag: string): boolean => attr(tag, 'kind') !== 'dated-record'
84
85/** The live stale anchors in `ripwire --doc-drift` output, each under the doc that holds it. */
86export function parseDrift(output: string): Stale[] {
87  const text = output.replace(/<!--[\s\S]*?-->/g, '')
88  const stale: Stale[] = []
89  for (const doc of text.matchAll(/<doc\s[^>]*>([\s\S]*?)<\/doc>/g)) {
90    const path = attr(doc[0], 'p') ?? '?'
91    for (const a of (doc[1] ?? '').matchAll(/<a\s[^>]*\/>/g)) if (isLive(a[0])) stale.push(anchor(path, a[0]))
92  }
93  return stale
94}
95
96/** The same claim across two runs; the line is left out, because an edit above it moves it. */
97export function identity(s: Stale): string {
98  return `${s.doc}\0${s.kind}\0${s.why}\0${s.ref}`
99}
100
101/** The stale anchors of a run, grouped under the doc that holds them. */
102export function byDoc(stale: readonly Stale[]): Map<string, Stale[]> {
103  const docs = new Map<string, Stale[]>()
104  for (const s of stale) docs.set(s.doc, [...(docs.get(s.doc) ?? []), s])
105  return docs
106}
107
108/** The stale anchors after a commit that were not stale before it. */
109export function addedBy(before: readonly Stale[], after: readonly Stale[]): Stale[] {
110  const known = new Set(before.map(identity))
111  return after.filter(s => !known.has(identity(s)))
112}
113
114/** How the sidebar colours a line or a part of one. */
115type Tone = 'ok' | 'warn' | 'error' | 'dim'
116export type Part = { text: string; kind?: Tone }
117/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
118export type Line = { text: string; kind?: Tone; parts?: Part[] }
119
120const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
121
122/** A line made of parts, its `text` their texts joined. */
123const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
124
125/** What is wrong with one anchor, in parts: the stale reference red, and what now sits at a moved line yellow. */
126function describeParts(s: Stale): Part[] {
127  const ref = part(s.ref, 'error')
128  switch (s.why) {
129    case 'missing-file': return [part('points at ', undefined), ref, part(', a file that no longer exists', undefined)]
130    case 'past-eof': return [part('points at ', undefined), ref, part(', past the end of that file', undefined)]
131    case 'line-moved': return [part('points at ', undefined), ref, part(', where ', undefined), part(s.got ?? 'another symbol', 'warn'), part(' now sits', undefined)]
132    case 'undefined': return [part('names `', undefined), ref, part('`, which the code no longer defines', undefined)]
133    case 'deleted': return [part('names `', undefined), ref, part(`\`, which ${s.got ?? 'a commit'} deleted`, undefined)]
134    default: return [part(`${s.why || s.kind}: `, undefined), ref]
135  }
136}
137
138function describe(s: Stale): string {
139  return describeParts(s).map(p => p.text).join('')
140}
141
142/** At most this many lines are named; the rest are counted. */
143const MAX_NAMED = 8
144
145function namedLines(added: readonly Stale[]): string {
146  const named = added.slice(0, MAX_NAMED).map(s => `${s.doc}:${s.line} ${describe(s)}`).join(' · ')
147  return added.length > MAX_NAMED ? `${named} · and ${added.length - MAX_NAMED} more` : named
148}
149
150/** The note the model reads after a commit that made doc lines stale. */
151export function noteText(added: readonly Stale[]): string {
152  return `doc-drift-watch: this commit made ${added.length} doc line(s) stale: ${namedLines(added)}. Update them in a follow-up commit, or tell the user why a line stays.`
153}
154
155/** The transcript line: the stale lines alone, without the instruction the model reads. The engine adds the mod name. */
156export function logText(added: readonly Stale[]): string {
157  return `${added.length} doc line(s) stale: ${namedLines(added)}`
158}
159
160/** One sidebar line per stale anchor, so the section reads as a list: where it is faint, what is stale in colour. */
161export function sidebarLines(added: readonly Stale[]): Line[] {
162  const lines = added.slice(0, MAX_NAMED).map(s => partsLine([part(`${s.doc}:${s.line} `, 'dim'), ...describeParts(s)]))
163  const rest = added.length - MAX_NAMED
164  if (rest > 0) lines.push({ text: `and ${rest} more`, kind: 'dim' })
165  return lines
166}
167
168/** A sidebar section key: the doc the finding belongs to, cut to what the sidebar takes. */
169export function sectionKey(doc: string): string {
170  return doc.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
171}
172
173/** Which side closed a finding: the doc was updated, or the doc itself is gone. */
174export type Closing = 'holds' | 'gone'
175
176function closingText(doc: string, count: number, side: Closing): string {
177  return side === 'gone'
178    ? `${doc} is gone, and its ${count} stale line(s) with it`
179    : `${doc}: ${count} doc line(s) hold again`
180}
181
182/** The transcript line of a closed finding. The engine adds the mod name. */
183export function doneLog(doc: string, count: number, side: Closing): string {
184  return closingText(doc, count, side)
185}
186
187/** The green sidebar line of a closed finding. */
188export function doneLines(doc: string, count: number, side: Closing): Line[] {
189  return [{ text: closingText(doc, count, side), kind: 'ok' }]
190}
191
192/** One `<doc>: <n>` pair per open finding, at most `MAX_NAMED` of them named and the rest counted. */
193function namedDocs(open: ReadonlyMap<string, number>): string {
194  const pairs = [...open].map(([doc, count]) => `${doc} (${count})`)
195  const named = pairs.slice(0, MAX_NAMED)
196  if (pairs.length > MAX_NAMED) named.push(`${pairs.length - MAX_NAMED} more`)
197  return named.join(' · ')
198}
199
200/** What the deny says: why the command stopped. There is no bypass. */
201export function denyText(open: ReadonlyMap<string, number>): string {
202  return `stopped: ${open.size} doc(s) still hold stale lines: ${namedDocs(open)}. Update them and run the command again; there is no way around this gate.`
203}
204
205/**
206 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
207 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
208 */
209export function openNote(open: ReadonlyMap<string, number>): string {
210  return `doc-drift-watch: ${open.size} doc(s) still hold stale lines: ${namedDocs(open)}. Update them.`
211}
212