After the model changes a function signature with Edit, adds the callers ripwire finds to the Edit's result, so the model fixes them before the build does.

The model adds a parameter to a function, the edit looks fine, and three callers elsewhere in the code are now broken; you find out when the build or a test fails. This mod catches it at the edit: when an Edit changes a function's parameters, it asks ripwire who calls that function and puts the callers right into the Edit's result.
old_string and new_string: Go func, JS and TS functions, arrow functions and class methods, Python def, Rust fn, Java methods and PHP functions.ripwire <repo root> --edit-check=<file>:<name> by argv. ripwire compares the definition with git HEAD and lists the callers.status="contract-change", the model reads this note right after the Edit's result:contract-watch: parse changed from 1 to 2 parameter(s) since the last commit; check each caller: main (main.go:5), other (main.go:9).
Each caller is named with the definition it sits in; at most 10 are named and the rest are counted. When ripwire marks a caller incompatible="1", every folded definition it sees disagrees with the new arity. Those callers come first, under a sentence of their own:
contract-watch: parse changed from 1 to 2 parameter(s) since the last commit; these callers do not match the new arity: main (main.go:5). Other callers of that name, which the call graph binds by name and may belong to another type: other (lib.go:9). Check each.
The second group matters in a codebase where several types define a method with the same name: the call graph binds a call by its name, so Messaging::sendAlert reads the same as SNMP_Monitor::sendAlert. Neither group is dropped.
contract-watch: parse changed from 1 to 2 parameter(s); do not match: main (main.go:5); same name: other (lib.go:9)
The note and the line are separate channels: the model never reads the line, and you never read the note.
same name, may be another type line, each with its (file:line) faint. The entry stays until newer ones push it off the pane. Without the sidebar, the line lands in the transcript as above.The note lists every caller, not only the ones ripwire proves incompatible: in a live check on Go, ripwire reported incompatible="0" while both callers still passed one argument (measured with ripwire on 2.1.278).
git commit, git push or git merge the model runs, and before that command runs, ripwire measures each open symbol again. A symbol that no caller misses any more closes with a green line, and its sidebar entry is dropped:contract-watch: every caller matches parse again
That line comes when the contract reads the same as the last commit again. When the contract still differs but no caller carries ripwire's incompatible mark any more, the closing line names that narrower measure instead, because a call graph that binds by name cannot prove every caller right:
contract-watch: no caller of parse carries the mismatch mark any more
The measurement runs before the command, not after it. --edit-check compares the working tree against git HEAD, so once a commit has landed there is nothing left to compare and every finding would look closed. For the same reason an open symbol is held by ripwire's incompatible mark alone, not by the contract status: after a commit took the change, the contract reads as HEAD, while a caller left on the old arity still carries the mark, so the finding stays open at the turn's end until the mark is gone.
contract-watch: 1 changed signature(s) still leave a caller behind: parse changed from 1 to 2 parameter(s), 1 caller(s) do not match. Bring each caller to the new signature, or take the signature change back.
That is one note per turn, not one per prompt. Without it the finding would be said once, at the edit, and then sit in the pane while the model forgot about it. You read nothing new, because the pane already shows the same finding.
deny mode that same moment also stops the command while a changed signature leaves a caller behind. The gate uses a narrower measure than the note: only a check whose incompatible count is above zero holds it, meaning the callers ripwire names on fixed-arity evidence. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z, once per repository) and lets the commit run when it holds none of the files those signatures live in, with one line telling you how many still stand. A push and a merge have no index to read, so every finding counts there. There is no bypass; only you turn the gate off, with /contract-watch mode note. note mode is the default and stops nothing.In the live check the model read the note after its Edit and said that the two callers would not compile until they were updated.
/contract-watch on or off, the mode, and the signatures that leave a caller behind /contract-watch on | off on by default /contract-watch mode note note only; the default /contract-watch mode deny a commit, a push and a merge also stop while a caller does not match
claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install contract-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 callers were not checked: ... as a yellow entry (a transcript line with the sidebar closed), once until a different error comes, and the edit goes through as before.Validated with claude plugin validate on Claude Code 2.1.283:
❯ ./register.ts hooks: session.start, command.run{command=contract-watch}, turn.complete, prompt.submit, tool.call{tool=Bash}, tool.call{tool=Edit} ❯ ./register.ts calls: $.command.register, $.process.run (via askRipwire, locate, stagedIn), $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via isEnabled, readSettings), $.store.set (via runCommand, setMode), $.ui.log (via atGitCommand, toPerson)
Reach L2: it runs processes.
<script> block, is not checked. The sidebar shows one faint line, <name>: ripwire does not index it, its callers were not checked, and the model reads nothing. An open symbol ripwire no longer indexes was removed or renamed, and its finding closes.incompatible count, which is itself a floor: a caller ripwire cannot bind by name does not hold the gate. The note remains the wider measure.deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /contract-watch mode note.git commit is not stopped; the finding is then measured at the next turn's end instead.git commit -a, a -am and a commit with a pathspec after -- are not narrowed to the index, because they commit files the index does not hold yet. Every open finding counts for those.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 288 lines1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { blockingLine, changedSignatures, denyText, doneLines, doneLog, isBlocking, isCommit, isGuarded, isNarrowable, isNotIndexed, isReported, logText, modeOf, notIndexedLine, noteText, openNote, parseCheck, sectionKey, sidebarLines, type Check, type Line, type Mode } from './signature.ts'
3
4const ENABLED_KEY = 'enabled'
5const MODE_KEY = 'mode'
6
7const USAGE = 'expects nothing (the status), on, off or mode note | deny'
8
9/** One symbol whose callers do not match it: where it lives, so the gate can measure it again. */
10type Open = { root: string; rel: string; sym: string }
11
12/** One symbol a caller still misses: the file it lives in, so a commit can be narrowed, and its line. */
13type Blocking = { root: string; rel: string; line: string }
14
15/**
16 * The last error logged, so the same one is logged once, the mode as the store held it at the last read,
17 * the symbols the gate holds, and the lines the model is owed a note for, measured at the turn's end.
18 */
19type State = { lastError?: string; mode: Mode; open: Map<string, Open>; owed: string[] }
20
21/**
22 * Reads the mode from the store, which every window shares, so a change made in another window applies
23 * here at the next hook that acts on it. The on/off setting is read fresh by `isEnabled`.
24 */
25async function readSettings($: EngineInterface, state: State): Promise<void> {
26 state.mode = (await $.store.get(MODE_KEY)) === 'deny' ? 'deny' : 'note'
27}
28
29function errorText(err: unknown): string {
30 return err instanceof Error ? err.message : String(err)
31}
32
33async function isEnabled($: EngineInterface): Promise<boolean> {
34 return (await $.store.get(ENABLED_KEY)) !== false
35}
36
37function dirOf(path: string): string {
38 const cut = path.lastIndexOf('/')
39 return cut <= 0 ? '/' : path.slice(0, cut)
40}
41
42/** The repository root and the file's path inside it, or undefined outside git. */
43async function locate($: EngineInterface, file: string): Promise<{ root: string; rel: string } | undefined> {
44 const r = await $.process.run(['git', 'rev-parse', '--show-toplevel', '--show-prefix'], { cwd: dirOf(file), timeoutMs: 10_000 })
45 if (r.exitCode !== 0) return undefined
46 const [root = '', prefix = ''] = r.stdout.split('\n')
47 return root === '' ? undefined : { root, rel: `${prefix}${file.slice(file.lastIndexOf('/') + 1)}` }
48}
49
50/**
51 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
52 * transcript line, as before. The model's note is another channel and does not change here.
53 */
54async function toPerson($: EngineInterface, key: string, title: string, lines: readonly Line[], line: string): Promise<void> {
55 try {
56 const taken = await $.sidebar.set({ consumer: 'contract-watch', key: sectionKey(key), title, lines, until: 'stream' })
57 if (taken) return
58 } catch {
59 // The sidebar mod is not installed.
60 }
61 $.ui.log(line)
62}
63
64/** Drops the sidebar entries of one finding, so a signature whose callers caught up leaves no warning behind. */
65async function dropEntry($: EngineInterface, key: string): Promise<void> {
66 try {
67 await $.sidebar.clear({ consumer: 'contract-watch', key: sectionKey(key) })
68 } catch {
69 // The sidebar mod is not installed.
70 }
71}
72
73/**
74 * Asks ripwire about one symbol; undefined when its output has no edit-check element, and `not-indexed`
75 * when ripwire's index holds no such symbol.
76 */
77async function askRipwire($: EngineInterface, root: string, rel: string, sym: string): Promise<Check | 'not-indexed' | undefined> {
78 const r = await $.process.run(['ripwire', root, `--edit-check=${rel}:${sym}`], { cwd: root, timeoutMs: 20_000 })
79 if (r.exitCode === 0) return parseCheck(r.stdout)
80 const out = (r.stderr || r.stdout).trim()
81 if (isNotIndexed(out)) return 'not-indexed'
82 throw new Error(`ripwire --edit-check failed: ${out.slice(0, 200)}`)
83}
84
85/** Asks ripwire about one changed function and answers the note, if its callers need a look. */
86async function checkOne($: EngineInterface, state: State, place: { root: string; rel: string }, name: string): Promise<string | undefined> {
87 const check = await askRipwire($, place.root, place.rel, name)
88 if (check === 'not-indexed') {
89 const line = notIndexedLine(name)
90 await toPerson($, name, 'not checked', [{ text: line, kind: 'dim' }], line)
91 return undefined
92 }
93 if (check === undefined) return undefined
94 // The finding the person reads is the one the mod holds, so every reported symbol is closed later too.
95 if (isReported(check)) state.open.set(`${place.rel}:${check.sym}`, { root: place.root, rel: place.rel, sym: check.sym })
96 // The note goes to the model, the line to the person: neither reads the other's channel.
97 const line = logText(check)
98 if (line !== undefined) await toPerson($, check.sym, 'changed signatures', sidebarLines(check), line)
99 return noteText(check)
100}
101
102async function notesFor($: EngineInterface, state: State, file: string, names: readonly string[]): Promise<string[]> {
103 const place = await locate($, file)
104 if (place === undefined) return []
105 const notes: string[] = []
106 for (const name of names) {
107 const note = await checkOne($, state, place, name)
108 if (note !== undefined) notes.push(note)
109 }
110 return notes
111}
112
113/**
114 * Asks ripwire about each open symbol again, closes the ones no caller misses any more, and answers the
115 * lines of the ones that still do. A symbol ripwire marks as incompatible stays open; one it no longer
116 * marks closes, and the closing line says which of the two measures closed it.
117 */
118async function recheckOpen($: EngineInterface, state: State): Promise<Blocking[]> {
119 const lines: Blocking[] = []
120 for (const [key, held] of [...state.open]) {
121 // A symbol ripwire no longer indexes was removed or renamed, so nothing calls it by this name any more.
122 const answer = await askRipwire($, held.root, held.rel, held.sym)
123 const check = answer === 'not-indexed' ? undefined : answer
124 if (check !== undefined && isBlocking(check)) {
125 lines.push({ root: held.root, rel: held.rel, line: blockingLine(check) })
126 continue
127 }
128 const matched = check === undefined || !isReported(check)
129 state.open.delete(key)
130 await dropEntry($, held.sym)
131 await toPerson($, held.sym, 'callers caught up', doneLines(held.sym, matched), doneLog(held.sym, matched))
132 }
133 return lines
134}
135
136/** The files the index of one repository holds, repo-relative, or undefined when git did not answer. */
137async function stagedIn($: EngineInterface, root: string): Promise<Set<string> | undefined> {
138 try {
139 const staged = await $.process.run(['git', 'diff', '--cached', '--name-only', '-z'], { cwd: root, timeoutMs: 10_000 })
140 if (staged.exitCode !== 0) return undefined
141 return new Set(staged.stdout.split('\0').filter(Boolean))
142 } catch {
143 // No git here, or the command did not run: the findings are not narrowed.
144 return undefined
145 }
146}
147
148/**
149 * The findings this command answers for. A `git commit` answers for its own files alone, so a signature in
150 * a file the commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every
151 * finding stands there. The index is read once per repository.
152 */
153async function scopeOf($: EngineInterface, blocking: readonly Blocking[], command: string): Promise<Blocking[]> {
154 if (!isCommit(command) || !isNarrowable(command)) return [...blocking]
155 const seen = new Map<string, Set<string> | undefined>()
156 const out: Blocking[] = []
157 for (const b of blocking) {
158 if (!seen.has(b.root)) seen.set(b.root, await stagedIn($, b.root))
159 const staged = seen.get(b.root)
160 if (staged === undefined || staged.has(b.rel)) out.push(b)
161 }
162 return out
163}
164
165/**
166 * Writes an error once until a different one comes: a yellow entry in the sidebar's stream while it is
167 * open, else the transcript line.
168 */
169async function report($: EngineInterface, state: State, err: unknown): Promise<void> {
170 const text = errorText(err)
171 if (text === state.lastError) return
172 state.lastError = text
173 const line = `the callers were not checked: ${text}`
174 await toPerson($, 'unchecked', 'not checked', [{ text: line, kind: 'warn' }], line)
175}
176
177function withNotes(r: ToolCallResult, notes: readonly string[]): ToolCallResult {
178 if (notes.length === 0 || r.deny !== undefined || r.isError === true) return r
179 return { ...r, context: [...(r.context ?? []), ...notes] }
180}
181
182/**
183 * What a `git commit`, `push` or `merge` attempt does, in both modes: ripwire measures each open symbol
184 * again, so a finding the model fixed closes itself with a green line, as in the other finding mods. The
185 * measurement runs before the command, because `--edit-check` compares the working tree against HEAD and
186 * a commit leaves it nothing to compare. In `deny` mode a symbol a caller still misses stops the command.
187 */
188async function atGitCommand($: EngineInterface, state: State, command: string): Promise<string | undefined> {
189 if (state.open.size === 0 || !isGuarded(command) || !(await isEnabled($))) return undefined
190 await readSettings($, state)
191 const lines = await recheckOpen($, state)
192 if (state.mode !== 'deny' || lines.length === 0) return undefined
193 const scoped = await scopeOf($, lines, command)
194 if (scoped.length === 0) {
195 $.ui.log(`${lines.length} changed signature(s) still leave a caller behind, and this command holds none of their files`)
196 return undefined
197 }
198 return denyText(scoped.map(b => b.line))
199}
200
201async function setMode($: EngineInterface, state: State, word: string): Promise<string> {
202 const mode = modeOf(word)
203 if (mode === undefined) return 'mode expects note or deny'
204 await $.store.set(MODE_KEY, mode)
205 state.mode = mode
206 return mode === 'deny' ? 'mode deny: git commit, push and merge stop while a caller does not match a changed signature' : 'mode note: the callers are only reported'
207}
208
209async function statusText($: EngineInterface, state: State): Promise<string> {
210 const open = state.open.size === 0 ? 'no signature is open' : `${state.open.size} signature(s) have callers to check`
211 return `${(await isEnabled($)) ? 'on' : 'off'} · mode ${state.mode} · ${open}; it needs ripwire on PATH`
212}
213
214async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
215 const word = args.trim()
216 if (word === 'on' || word === 'off') {
217 await $.store.set(ENABLED_KEY, word === 'on')
218 return word === 'on' ? 'on: a changed signature brings its callers to the model' : 'off: signatures are not checked'
219 }
220 if (word.startsWith('mode')) return setMode($, state, word.slice(4).trim())
221 if (word !== '') return USAGE
222 await readSettings($, state)
223 return statusText($, state)
224}
225
226export const register: Register = on => {
227 const state: State = { mode: 'note', open: new Map(), owed: [] }
228
229 on('session.start', async ($, e, next) => {
230 const r = await next(e)
231 await $.command.register({ name: 'contract-watch', description: 'Callers of a changed signature: status, on, off, mode (contract-watch)', argumentHint: '[on | off | mode note | deny]' })
232 await readSettings($, state)
233 return r
234 })
235
236 // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
237 on('command.run', { command: 'contract-watch' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
238
239 /*
240 * The turn's end asks ripwire about each open symbol again and owes the model a note for the ones a
241 * caller still misses, because a finding it did not close would otherwise stand in the pane and reach
242 * it never again. ripwire runs on this machine alone, once per open symbol.
243 */
244 on('turn.complete', async ($, e, next) => {
245 const r = await next(e)
246 if (e.agentId !== undefined || state.open.size === 0 || !(await isEnabled($))) return r
247 try {
248 state.owed = (await recheckOpen($, state)).map(b => b.line)
249 } catch (err) {
250 await report($, state, err)
251 }
252 return r
253 })
254
255 // The note goes to the model alone; the person reads the pane, which carries the same finding.
256 on('prompt.submit', async (_, e, next) => {
257 if (state.owed.length === 0) return next(e)
258 const note = openNote(state.owed)
259 state.owed = []
260 return next({ ...e, context: [...(e.context ?? []), note] })
261 })
262
263 on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
264 try {
265 const stop = await atGitCommand($, state, e.command)
266 if (stop !== undefined) return { deny: stop }
267 } catch (err) {
268 await report($, state, err)
269 }
270 return next(e)
271 })
272
273 on('tool.call', { tool: 'Edit' }, async ($, e, next) => {
274 const r = await next(e)
275 if (r.deny !== undefined || r.isError === true || !(await isEnabled($))) return r
276 const names = changedSignatures(e.old_string, e.new_string)
277 if (names.length === 0) return r
278 try {
279 const notes = await notesFor($, state, e.file_path, names)
280 state.lastError = undefined
281 return withNotes(r, notes)
282 } catch (err) {
283 await report($, state, err)
284 return r
285 }
286 })
287}
288hooks/signature.ts 259 lines1/** Which function signatures an edit changes, and the note the model reads about their callers. */
2
3/** A one-line function definition: its name and its parameter text. */
4export type Signature = { name: string; params: string }
5
6/** Words that open a statement, not a method, before a parenthesis. */
7const NOT_METHODS: ReadonlySet<string> = new Set(['if', 'for', 'while', 'switch', 'catch', 'return', 'function', 'else', 'do', 'with', 'new', 'await', 'typeof'])
8
9/** One-line definitions, per language; group 1 is the name, group 2 the parameters. */
10const PATTERNS: readonly RegExp[] = [
11 // Go, with an optional receiver and type parameters.
12 /^\s*func\s+(?:\([^)]*\)\s*)?([A-Za-z_]\w*)\s*(?:\[[^\]]*\])?\(([^)]*)\)/,
13 // JS and TS function declarations; PHP functions and methods.
14 /^\s*(?:export\s+)?(?:default\s+)?(?:(?:public|private|protected|static|final|abstract)\s+)*(?:async\s+)?function\s*\*?\s*&?([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(([^)]*)\)/,
15 // JS and TS arrow functions bound to a name.
16 /^\s*(?:export\s+)?(?:const|let)\s+([A-Za-z_$][\w$]*)\s*(?::[^=]+)?=\s*(?:async\s+)?(?:<[^>]*>)?\(([^)]*)\)\s*(?::[^=]+)?=>/,
17 // Python.
18 /^\s*(?:async\s+)?def\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/,
19 // Rust.
20 /^\s*(?:pub(?:\([^)]*\))?\s+)?(?:const\s+)?(?:async\s+)?(?:unsafe\s+)?fn\s+([A-Za-z_]\w*)\s*(?:<[^>]*>)?\(([^)]*)\)/,
21 // Java methods, which carry a modifier and a return type.
22 /^\s*(?:(?:public|private|protected|static|final|abstract|synchronized)\s+)+[\w<>[\],.?\s]+?\s+([A-Za-z_]\w*)\s*\(([^)]*)\)/,
23 // TS and JS class methods: a name, parameters, an optional return type, then the body's brace.
24 /^\s*(?:(?:public|private|protected|static|async|override|readonly)\s+)*([A-Za-z_$][\w$]*)\s*(?:<[^>]*>)?\(([^)]*)\)\s*(?::\s*[^{=]+)?\{\s*$/,
25]
26
27function signatureOf(line: string): Signature | undefined {
28 for (const pattern of PATTERNS) {
29 const m = pattern.exec(line)
30 const name = m?.[1]
31 if (name !== undefined && !NOT_METHODS.has(name)) return { name, params: (m?.[2] ?? '').replace(/\s+/g, ' ').trim() }
32 }
33 return undefined
34}
35
36/** The parameters of each function the text defines on one line, by name. */
37export function signaturesIn(text: string): Map<string, string> {
38 const found = new Map<string, string>()
39 for (const line of text.split('\n')) {
40 const s = signatureOf(line)
41 if (s !== undefined) found.set(s.name, s.params)
42 }
43 return found
44}
45
46/** The functions both texts define whose parameters differ. */
47export function changedSignatures(before: string, after: string): string[] {
48 const old = signaturesIn(before)
49 return [...signaturesIn(after)].filter(([name, params]) => old.has(name) && old.get(name) !== params).map(([name]) => name)
50}
51
52/**
53 * One caller `ripwire --edit-check` names. `mismatch` is ripwire's own mark on that caller: every folded
54 * definition it sees disagrees with the new arity. Without it the caller only shares the symbol's name,
55 * and a call of another type's method of that name reads the same, because the call graph binds by name.
56 */
57export type Caller = { name: string; at: string; mismatch: boolean }
58
59/** What `ripwire --edit-check` says of one symbol: whether its contract changed, and who calls it. */
60export type Check = { sym: string; status: string; paramsWas?: number; paramsNow?: number; incompatible: number; callers: Caller[] }
61
62function attr(tag: string, name: string): string | undefined {
63 return new RegExp(`\\b${name}="([^"]*)"`).exec(tag)?.[1]
64}
65
66function count(tag: string, name: string): number | undefined {
67 const v = attr(tag, name)
68 return v === undefined ? undefined : Number(v)
69}
70
71/** Reads `ripwire --edit-check` output; undefined when it has no edit-check element. */
72export function parseCheck(output: string): Check | undefined {
73 const text = output.replace(/<!--[\s\S]*?-->/g, '')
74 const head = /<edit-check\b[^>]*>/.exec(text)?.[0]
75 if (head === undefined) return undefined
76 const callers = [...text.matchAll(/<c\b[^>]*\/>/g)].map(m => ({ name: attr(m[0], 'n') ?? '?', at: attr(m[0], 'p') ?? '?', mismatch: attr(m[0], 'incompatible') === '1' }))
77 return { sym: attr(head, 'sym') ?? '?', status: attr(head, 'status') ?? '', paramsWas: count(head, 'params_was'), paramsNow: count(head, 'params_now'), incompatible: count(head, 'incompatible') ?? 0, callers }
78}
79
80/**
81 * Whether ripwire refused the check because its index holds no such symbol, as for a JavaScript function
82 * inside a PHP file's script block. Nothing is known about its callers then, and nothing failed.
83 */
84export function isNotIndexed(output: string): boolean {
85 return /--edit-check symbol not found/.test(output)
86}
87
88/** The faint line for a changed function ripwire does not index: its callers were not checked. */
89export function notIndexedLine(sym: string): string {
90 return `${sym}: ripwire does not index it, its callers were not checked`
91}
92
93/** At most this many callers are named; the rest are counted. */
94const MAX_CALLERS = 10
95
96function paramsText(c: Check): string {
97 if (c.paramsWas === undefined || c.paramsNow === undefined || c.paramsWas === c.paramsNow) return 'changed its parameters'
98 return `changed from ${c.paramsWas} to ${c.paramsNow} parameter(s)`
99}
100
101function listed(rows: readonly Caller[]): string {
102 const named = rows.slice(0, MAX_CALLERS).map(x => `${x.name} (${x.at})`).join(', ')
103 return rows.length > MAX_CALLERS ? `${named} and ${rows.length - MAX_CALLERS} more` : named
104}
105
106/** The callers ripwire marks as not matching the new arity, and the ones that only share the name. */
107function split(c: Check): { bad: Caller[]; same: Caller[] } {
108 return { bad: c.callers.filter(x => x.mismatch), same: c.callers.filter(x => !x.mismatch) }
109}
110
111/** The caller part of the model's note: the marked callers first, the same-named ones after them. */
112function namedCallers(c: Check): string {
113 const { bad, same } = split(c)
114 if (bad.length === 0) return `check each caller: ${listed(same)}`
115 const rest = same.length === 0 ? '' : ` Other callers of that name, which the call graph binds by name and may belong to another type: ${listed(same)}.`
116 return `these callers do not match the new arity: ${listed(bad)}.${rest} Check each`
117}
118
119/** Whether this check has something to report: a changed contract that something calls. */
120export function isReported(c: Check): boolean {
121 return c.status === 'contract-change' && c.callers.length > 0
122}
123
124/** The note for one changed contract, or undefined when nothing calls it. */
125export function noteText(c: Check): string | undefined {
126 if (!isReported(c)) return undefined
127 return `contract-watch: ${c.sym} ${paramsText(c)} since the last commit; ${namedCallers(c)}.`
128}
129
130/**
131 * The transcript line for one changed contract: the finding alone, without the instruction the model
132 * reads, or undefined when nothing calls it. The engine adds the mod name.
133 */
134export function logText(c: Check): string | undefined {
135 if (!isReported(c)) return undefined
136 const { bad, same } = split(c)
137 if (bad.length === 0) return `${c.sym} ${paramsText(c)}; callers: ${listed(same)}`
138 const rest = same.length === 0 ? '' : `; same name: ${listed(same)}`
139 return `${c.sym} ${paramsText(c)}; do not match: ${listed(bad)}${rest}`
140}
141
142/** How the sidebar colours a line or a part of one. */
143type Tone = 'ok' | 'warn' | 'error' | 'dim'
144export type Part = { text: string; kind?: Tone }
145/** A line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
146export type Line = { text: string; kind?: Tone; parts?: Part[] }
147
148const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
149
150/** A line made of parts, its `text` their texts joined. */
151const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
152
153/** The change in parts: the old parameter count faint, the new one yellow; a change of another kind yellow whole. */
154function paramsParts(c: Check): Part[] {
155 if (c.paramsWas === undefined || c.paramsNow === undefined || c.paramsWas === c.paramsNow) return [part(paramsText(c), 'warn')]
156 return [part('changed from ', undefined), part(String(c.paramsWas), 'dim'), part(' to ', undefined), part(String(c.paramsNow), 'warn'), part(' parameter(s)', undefined)]
157}
158
159/** One caller per line, its name in the row's colour and where it is faint; the rest past the tenth counted, faint. */
160function rowsOf(callers: readonly Caller[], kind: 'error' | 'dim'): Line[] {
161 const out: Line[] = callers.slice(0, MAX_CALLERS).map(x => partsLine([part(x.name, kind), part(` (${x.at})`, 'dim')]))
162 const rest = callers.length - MAX_CALLERS
163 if (rest > 0) out.push({ text: `${rest} more`, kind: 'dim' })
164 return out
165}
166
167/** The change on the first line, then the marked callers in red and the same-named ones faint under them. */
168export function sidebarLines(c: Check): Line[] {
169 const { bad, same } = split(c)
170 const head = partsLine([part(`${c.sym} `, undefined), ...paramsParts(c)])
171 if (bad.length === 0) return [head, ...rowsOf(same, 'dim')]
172 const tail: Line[] = same.length === 0 ? [] : [{ text: 'same name, may be another type', kind: 'dim' }, ...rowsOf(same, 'dim')]
173 return [head, ...rowsOf(bad, 'error'), ...tail]
174}
175
176/**
177 * Whether the gate stops a command for this check. The note names every caller of a changed contract,
178 * because a caller on the old arity was measured while `incompatible` read 0; the gate takes the narrower
179 * measure, the callers ripwire calls incompatible by fixed-arity evidence, because that count falls again
180 * once the model fixes them and a gate that never opens is a gate nobody can pass. The status is not read:
181 * after a commit takes the change the contract reads as HEAD (`unchanged`), and ripwire still marks a
182 * caller left on the old arity, so an open symbol stays open until the mark is gone.
183 */
184export function isBlocking(c: Check): boolean {
185 return c.incompatible > 0
186}
187
188/** The finding line of a blocking check: the symbol and how many callers do not match it. */
189export function blockingLine(c: Check): string {
190 return `${c.sym} ${paramsText(c)}, ${c.incompatible} caller(s) do not match`
191}
192
193/**
194 * The transcript line of a finding a later check closed. `matched` says which measure closed it: the
195 * contract reads the same as the last commit again, or it still differs and no caller carries ripwire's
196 * mismatch mark any more. The second text names what was measured, because a call graph that binds by
197 * name cannot prove every caller right.
198 */
199export function doneLog(sym: string, matched: boolean): string {
200 if (matched) return `every caller matches ${sym} again`
201 return `no caller of ${sym} carries the mismatch mark any more`
202}
203
204/** The sidebar lines of a closed finding. */
205export function doneLines(sym: string, matched: boolean): Line[] {
206 return [{ text: doneLog(sym, matched), kind: 'ok' }]
207}
208
209/** The global flags git takes before the subcommand, so `git -c user.name=x commit` is still a commit. */
210const 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))`
211
212/** A `git commit`, `git push` or `git merge` the gate stops while a finding is open. */
213const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
214const ASKING = /\s(--dry-run|--help|-h)(\s|$)/
215
216export function isGuarded(command: string): boolean {
217 return GUARDED.test(command) && !ASKING.test(command)
218}
219
220/** Whether the command is a `git commit`, the one guarded command whose own files can be measured. */
221export function isCommit(command: string): boolean {
222 return GUARDED.exec(command)?.[2] === 'commit'
223}
224
225/**
226 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
227 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
228 */
229export function isNarrowable(command: string): boolean {
230 const words = command.split(/\s+/)
231 return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
232}
233
234/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
235export type Mode = 'note' | 'deny'
236
237/** The mode a `/contract-watch mode <word>` argument names, or undefined when it is not one. */
238export function modeOf(arg: string): Mode | undefined {
239 return arg === 'note' || arg === 'deny' ? arg : undefined
240}
241
242/** The deny text both the model and the person read: which callers do not match, and the one way out. */
243export function denyText(lines: readonly string[]): string {
244 return `stopped: ${lines.length} changed signature(s) leave a caller behind: ${lines.join(' · ')}. Bring each caller to the new signature, then run the command again; there is no way around this gate.`
245}
246
247/**
248 * The note the model reads at the next prompt while a finding stands, so a finding it did not close
249 * reaches it again instead of standing in the pane alone. The person reads the pane and needs no line.
250 */
251export function openNote(lines: readonly string[]): string {
252 return `contract-watch: ${lines.length} changed signature(s) still leave a caller behind: ${lines.join(' · ')}. Bring each caller to the new signature, or take the signature change back.`
253}
254
255/** A sidebar section key: the subject cut to what the sidebar takes, so one symbol keeps one section. */
256export function sectionKey(text: string): string {
257 return text.replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
258}
259