SLOPSHOPPER

contract-watch

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.

newguardcommandpromptprocess
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · contract-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 › /contract-watch ⎿ contract-watch: on · mode note · no signature is open; it needs ripwire on PATH ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

contract-watch

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.

What it does

  1. It watches the Edit tool. After a successful edit it compares the one-line function definitions in 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.
  2. A function that both strings define with different parameters is a changed signature. An edit that only touches a body runs nothing.
  3. For each changed signature it runs ripwire <repo root> --edit-check=<file>:<name> by argv. ripwire compares the definition with git HEAD and lists the callers.
  4. When ripwire reports 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.

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

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.

  1. With the sidebar open, the finding goes into its stream instead and the transcript stays clean. The first line shows the change (the old parameter count faint, the new one yellow). Under it come the marked callers' names in red, then the same-named ones in faint text after a 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).

  1. The mod holds every reported signature open and closes it itself, in both modes. At the next 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.

  1. A finding the model did not close is measured the same way at the end of each main-loop turn, and whatever is left reaches the model as one note with your next prompt. ripwire runs on this machine, once per open symbol:

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.

  1. In 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.

Command

/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

Install

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.

After installing

  1. Install ripwire and put it on PATH. Without it, a changed signature writes 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.
  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=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.

  1. Reads: the old and new text of each Edit; the Bash command text; through ripwire, the repository's source and git HEAD
  2. Runs: git rev-parse, git diff --cached --name-only -z and ripwire --edit-check, read-only, by argv, after an edit that changed a signature, and once per open symbol before a git commit, push or merge and at each turn's end
  3. Sends: a note to the model after the Edit's result, one more with the next prompt while a finding stands, and one line to the transcript; nothing leaves the machine
  4. Persists: in $.store, the on/off setting and the mode
  5. Hostile input: a function name comes from the edited text and reaches ripwire as one argv item, never through a shell

Limits

  • Only a definition on one line is read. A signature whose parameters span several lines is not seen.
  • A renamed function is not checked: the old name is gone, so ripwire has nothing to compare.
  • The comparison is against git HEAD. A second signature edit of the same function before a commit repeats the note.
  • Only the Edit tool is watched. A Write that replaces a whole file is not.
  • Outside a git repository nothing runs.
  • A function ripwire does not index, such as a JavaScript function inside a PHP file's <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.
  • The gate follows ripwire's 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.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /contract-watch mode note.
  • The gate reads the command text. A commit through a script or an alias that hides git commit is not stopped; the finding is then measured at the next turn's end instead.
  • A 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.

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 288 lines
1import 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}
288
hooks/signature.ts 259 lines
1/** 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