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…

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.
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.cd before the commit and the commit's git -C, and runs ripwire <root> --doc-drift --with-history by argv.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.
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.
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.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.
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.
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.
/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
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.
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.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.
= 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.--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.git commit is not seen.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.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
hooks/register.ts 294 lines1import 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}
294hooks/drift.ts 212 lines1/** 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