SLOPSHOPPER

lockfile-sync

After each commit the model makes, names the manifests whose dependencies it changed without their lockfile (npm, Composer, Cargo, Go, Python, Bundler, Dart…

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

lockfile-sync

The model adds a dependency to package.json, commits that file alone, and the lockfile stays behind. The next npm ci fails in CI, or a teammate installs a different version than the one you tested. This mod tells the model when a commit changes the dependencies of a manifest but not its lockfile. After each git commit the model runs, the mod adds the manifests whose lockfile the commit left out to the commit's result. The commit itself is never stopped.

What it does

  1. The mod hooks the Bash tool. A command that runs git commit (also git -C <dir> commit and the forms with git's global flags in front, not --dry-run, --help or -h) is checked.
  2. Before the command runs, it finds the repository root from the session's directory, the last cd before the commit and the commit's git -C, and records HEAD.
  3. After a successful command that moved HEAD, it lists the commit's added and modified files with git show --name-status HEAD, and pairs each manifest with its lockfile:
ManifestLockfile
package.jsonpackage-lock.json, yarn.lock, pnpm-lock.yaml, bun.lock, bun.lockb
composer.jsoncomposer.lock
Cargo.tomlCargo.lock
go.modgo.sum
pyproject.tomlpoetry.lock, uv.lock, pdm.lock
PipfilePipfile.lock
GemfileGemfile.lock
pubspec.yamlpubspec.lock
mix.exsmix.lock

The lockfile is the first one on disk from the manifest's directory up to the repository root, so a workspace package pairs with the root lockfile. A manifest without a lockfile on disk is left alone: the project does not keep one.

  1. When the commit leaves that lockfile out, the mod first asks the lockfile's own package manager whether the lockfile still fits the manifest. It runs the check by argv in the lockfile's directory, with a 60 s limit:
LockfileCheckBehind when
Cargo.lockcargo metadata --locked --format-version 1 --manifest-path <manifest>cannot update the lock file
package-lock.jsonnpm ci --dry-run --ignore-scriptsare in sync in the failure
pnpm-lock.yamlpnpm install --frozen-lockfile --lockfile-only --ignore-pnpmfile --ignore-scriptsdon't match specifiers
bun.lock, bun.lockbbun install --frozen-lockfile --dry-run --ignore-scriptslockfile had changes
yarn.lock (v1)yarn checkLockfile does not contain pattern
composer.lockcomposer validate --no-check-all --no-check-publish --check-lock --no-pluginslock file is not up to date
go.sumgo mod tidy -diffthe diff holds a go.sum hunk
uv.lockuv lock --checkneeds to be updated
poetry.lockpoetry check --lockchanged significantly
pdm.lockpdm lock --checksatisfy the project requirements
Pipfile.lockpipenv verifyout-of-date
Gemfile.lockbundle lock --printthe printed lockfile differs, platforms and the Bundler version aside
pubspec.lockdart pub get --enforce-lockfile --dry-runUnable to satisfy
mix.lockmix deps.get --check-locked, with MIX_DEPS_PATH in $TMPDIR/lockfile-syncmix.lock is out of date

Each check was measured to write nothing into the repository. A pass means the lockfile fits and no finding opens: a features change in Cargo.toml that pulls in no new crate opens nothing, and a features = ["derive"] that pulls in serde_derive does. A failure that says the lockfile is behind opens the finding. Any other answer proves nothing: the tool is not installed, it ran past the limit, it failed for another reason, the lockfile is a Yarn 2+ yarn.lock, or the manifest or the lockfile differs from HEAD in the working tree. The check reads the working tree and the finding speaks of the commit, so a lockfile written but left out of the commit would read as in step. A tool that did not start is logged once:

lockfile-sync: cargo did not run: <reason>; the manifest's diff decides

Then the mod reads the manifest's diff (git show --unified=20 HEAD -- <manifest>) and checks where the changed lines sit. Only a change that can change the lockfile counts:

ManifestCountsDoes not count
package.json, composer.jsondependencies, devDependencies, peerDependencies, optionalDependencies, overrides, resolutions, require, require-dev and the likescripts, version, other keys
Cargo.toml, pyproject.toml, Pipfile[dependencies], [dev-dependencies], [target.*.dependencies], [project], [tool.poetry.dependencies], [packages] and the like[package], [tool.ruff], other tables
go.modrequire, replace, exclude lines and blocksgo 1.22, module
Gemfilegem, source, gemspec, group linescomments
pubspec.yamldependencies, dev_dependencies, dependency_overridesother keys
mix.exsevery change

The section of a changed line is read from the whole manifest (git show HEAD:<manifest>), not from the diff's own 20 lines of context: a change 40 lines into a package.json never reaches the root { inside the hunk, and every root-level key would read as a dependency. A key or table the manifest itself does not place counts, so a file that cannot be read still gets the note.

  1. The model reads this note after the commit's result:

lockfile-sync: this commit changes package.json but not package-lock.json · go.mod but not go.sum. Run the package manager's install so the lockfile matches, and commit it.

  1. At the same moment one line reaches the transcript, so you see what the model was told. The line holds the pairs alone, without the instruction:

lockfile-sync: this commit changes package.json but not package-lock.json · go.mod but not go.sum

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

  1. While the sidebar is open, those pairs go there instead, one line per pair (the lockfile red, but not faint), as an entry in its stream, and the transcript stays clean. The entry stays until newer ones push it off the pane. With the sidebar closed, or without that mod installed, the transcript line is written as above.
  1. Each commit that leaves a lockfile out opens its own finding, with its own sidebar entry keyed by its manifests. A later commit adds its finding beside the open ones and never writes over one; a pair an open finding already names is not opened twice. Every finding closes on its own measure.

A finding is never a remembered answer. Each measure, after every later commit, at the end of each main-loop turn and before a guarded git command in deny mode, asks git and the package manager again, so a finding closes three ways:

  • the lockfile was written: a later commit changed it, or git status --porcelain shows it changed in the working tree;
  • the package manager reads the lockfile as in step with the manifest (the check of step 4);
  • the check proves nothing and the manifest asks for no lockfile change any more: git log -1 -- <lockfile> names the commit that last wrote the lockfile, and the manifest's diff against that commit touches no dependency. A change that was reverted reads this way. A lockfile the package manager reads as behind stays open whatever the diff says.

The entry is cleared and a green one says which of the three it was:

lockfile-sync: a later change brought the lockfiles along: package-lock.json lockfile-sync: cargo reads Cargo.lock as in step with Cargo.toml lockfile-sync: the dependencies match the lockfile again: package.json

With the sidebar closed the same text is one transcript line. The model reads nothing of this: the finding closed by its own work, so a note would only repeat what it just did.

  1. A finding the model did not close is measured again at the end of each main-loop turn, and what is left reaches the model as one note with its next prompt:

lockfile-sync: 1 lockfile(s) are still behind their manifest: package-lock.json behind package.json. Run the package manager's install so the lockfile is written, or take the dependency change back.

One note per turn, not one per prompt. Without this the finding would be said once, at the commit, and then stand in the pane while the model forgot it. You read nothing new: the pane already carries the same finding.

  1. In deny mode the mod also stops git commit, git push and git merge while a lockfile is behind. Before it stops one it runs both measures, so a lockfile the package manager just wrote, and a dependency change that was taken back, each open the gate themselves. A git commit answers for its own files alone: the mod reads the index (git diff --cached --name-only -z) and lets the commit run when it holds none of the open manifests, with one line to you naming how many still stand. A push and a merge hold no index to read, so every pair stands there. There is no bypass; only you turn the gate off, with /lockfile-sync mode note. note mode is the default and stops nothing.

A git error is written as a yellow entry (the transcript line with the sidebar closed), once until a different one comes, and the commit's result stays as it was.

In the live check the model raised a package.json dependency, committed only that file, and quoted the note word for word.

Command

/lockfile-sync on or off, the mode, and the lockfiles still behind /lockfile-sync on | off on by default /lockfile-sync mode note note only; the default /lockfile-sync mode deny a commit, a push and a merge also stop while a lockfile is behind

Install

claude plugin marketplace add KilimcininKorOglu/claude-code-mods claude plugin install lockfile-sync@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. 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=lockfile-sync}, turn.complete, prompt.submit, tool.call{tool=Bash} ❯ ./register.ts calls: $.command.register, $.env.get (via tmpDir), $.fs.exists (via lockOnDisk), $.fs.read (via treeText), $.process.run (via git, lockVerdict), $.session.cwd (via beforeCommit), $.sidebar.clear (via dropEntry), $.sidebar.set (via toPerson), $.store.get (via readSettings), $.store.set (via runCommand, setMode), $.ui.log (via denyFor, toPerson, toolFailed)

Reach L3, runs processes that reach the network.

  1. Reads: the Bash command text; whether lockfiles exist in the repository; each open finding's manifest and lockfile in the working tree; through git, the commit's file list, manifest diffs and each manifest at HEAD; TMPDIR
  2. Runs: git rev-parse, git show, git status, git log and git diff, read-only, by argv; and the lockfile's package manager check of step 4, once per manifest without its lockfile at a commit and once per open pair at each measure, also at the turn's end
  3. Sends: a note to the model after the commit's result, one more with the next prompt while a finding stands, and one line to the transcript; the package manager may ask its registry for the package metadata it resolves against
  4. Persists: in $.store, the on/off setting and the mode; the package managers keep their own caches, and mix fetches into $TMPDIR/lockfile-sync/mix-deps
  5. Hostile input: the directory comes from the command text and reaches git and the package manager only as the working directory, never through a shell; manifest paths reach them as one argv entry. The check runs code the project holds: a Gemfile is Ruby and a mix.exs is Elixir, and both are evaluated. npm, pnpm and bun run with --ignore-scripts, pnpm with --ignore-pnpmfile, and composer with --no-plugins, so their project scripts and plugins do not run

Limits

  • Where the package manager check proves nothing, the mod compares file names and diff sections alone, and does not check that the lockfile's content matches the manifest.
  • The verdict is the package manager's own: npm ci does not compare the root package's version, and yarn check reads a lockfile that still lists a removed dependency as in step.
  • A Yarn 2+ yarn.lock has no check here: yarn install --immutable links node_modules into the project, and --mode=update-lockfile does not combine with --immutable.
  • While a finding stands, its check runs again at every main-loop turn's end, up to 60 s per pair.
  • A lockfile no commit ever wrote has nothing to compare the manifest against, so only the first measure can close its finding.
  • Two lockfiles of one manager in one directory (a yarn.lock beside a package-lock.json) pair with the first in the table.
  • A commit through a script or an alias that hides git commit is not seen.
  • A cd or git -C whose directory the shell expands first (cd $D, cd ~/x, a backquote) names no directory the mod can tell. That commit is not checked, and the yellow line names the word, for example the commit's directory is not known: cd $D. A word in single quotes stays as written.
  • A merge commit's combined diff is not read.
  • The deny mode has no bypass. When a finding cannot be fixed, you turn the gate off with /lockfile-sync mode note.
  • The gate reads any change to the lockfile in the working tree as the fix; it does not check what that change holds.
  • 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 pair stands 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 3 files
hooks/register.ts 397 lines
1import type { EngineInterface, Register, ToolCallResult } from 'claude-code'
2import { checkFor, lockDir, type Verdict } from './checks.ts'
3import { changedFiles, commitDir, denyText, doneLines, doneLog, doneTitle, isCommit, isGuarded, isManifest, isNarrowable, lockCandidates, logText, modeOf, noteText, openNote, sectionKey, sidebarLines, touchesDependencies, type Line, type Mode, type Settled, type Stale } from './pairs.ts'
4
5const ENABLED_KEY = 'enabled'
6const MODE_KEY = 'mode'
7
8const USAGE = 'expects nothing (the status), on, off or mode note | deny'
9
10/** A finding still open: the sidebar key it was written under, and the pairs it named. */
11type Open = { key: string; stale: Stale[] }
12
13/**
14 * The on/off setting and the mode as the store held them at the last read, and the last error logged, so
15 * the same one is logged once. `open` holds every finding still standing, one per commit that left a lockfile out, so a
16 * later commit that brings its lockfiles along closes it, and in `deny` mode it also holds the gate shut.
17 * A later commit adds its own finding beside them and never writes over one. `owed` says the model is
18 * owed a note for the findings that stood at the turn's end. `lastToolError` is the last package manager
19 * failure logged, so the same one is logged once.
20 */
21type State = { enabled: boolean; mode: Mode; lastError?: string; lastToolError?: string; open: Open[]; owed: boolean }
22
23/**
24 * Reads the on/off setting and the mode from the store, which every window shares, so a change made in
25 * another window applies here at the next hook that acts on it.
26 */
27async function readSettings($: EngineInterface, state: State): Promise<void> {
28  state.enabled = (await $.store.get(ENABLED_KEY)) !== false
29  state.mode = modeOf(String(await $.store.get(MODE_KEY))) ?? 'note'
30}
31
32/** Every pair the open findings name. */
33const pairsOf = (state: State): Stale[] => state.open.flatMap(o => o.stale)
34
35/** The repository and its HEAD before the commit; `head` is empty before the first commit. */
36type Before = { root: string; head: string }
37
38function errorText(err: unknown): string {
39  return err instanceof Error ? err.message : String(err)
40}
41
42/**
43 * Writes an error once until a different one comes: a yellow entry in the sidebar's stream while it is
44 * open, else the transcript line.
45 */
46async function report($: EngineInterface, state: State, err: unknown): Promise<void> {
47  const text = errorText(err)
48  if (text === state.lastError) return
49  state.lastError = text
50  const line = `the commit's lockfiles were not checked: ${text}`
51  await toPerson($, 'unchecked', 'not checked', [{ text: line, kind: 'warn' }], line)
52}
53
54async function git($: EngineInterface, root: string, args: string[]): Promise<{ ok: boolean; out: string }> {
55  const r = await $.process.run(['git', ...args], { cwd: root, timeoutMs: 10_000 })
56  return { ok: r.exitCode === 0, out: r.stdout }
57}
58
59/** The repository root and HEAD, or undefined outside a repository. */
60async function beforeCommit($: EngineInterface, state: State, command: string): Promise<Before | undefined> {
61  try {
62    const top = await git($, commitDir(command, await $.session.cwd()), ['rev-parse', '--show-toplevel'])
63    if (!top.ok) return undefined
64    const root = top.out.trim()
65    const head = await git($, root, ['rev-parse', 'HEAD'])
66    return { root, head: head.ok ? head.out.trim() : '' }
67  } catch (err) {
68    await report($, state, err)
69    return undefined
70  }
71}
72
73/** A manifest or lockfile in the working tree; an empty answer leaves every changed line counted. */
74async function treeText($: EngineInterface, root: string, path: string): Promise<string> {
75  try {
76    return await $.fs.read(`${root}/${path}`)
77  } catch {
78    return ''
79  }
80}
81
82/** Logs a package manager that did not run once until a different failure comes. */
83function toolFailed($: EngineInterface, state: State, tool: string, err: unknown): void {
84  const text = `${tool} did not run: ${errorText(err)}; the manifest's diff decides`
85  if (text !== state.lastToolError) $.ui.log(text)
86  state.lastToolError = text
87}
88
89/** The mod's own temporary directory, where a check that must fetch keeps what it fetched. */
90async function tmpDir($: EngineInterface): Promise<string> {
91  return `${((await $.env.get('TMPDIR')) ?? '/tmp').replace(/\/+$/, '')}/lockfile-sync`
92}
93
94/** What a pair's package manager says of its lockfile, and which one said it. */
95type Checked = { verdict: Verdict; tool?: string }
96
97/**
98 * Asks the pair's package manager whether the lockfile fits the manifest. The tool reads the working tree
99 * and a finding speaks of HEAD, so it runs only while both files read as they do at HEAD: a lockfile written
100 * but left out of the commit would read as in step. No check, a changed file, or a tool that did not run
101 * proves nothing, and the manifest's diff decides.
102 */
103async function lockVerdict($: EngineInterface, state: State, root: string, pair: Stale): Promise<Checked> {
104  if (!(await git($, root, ['diff', '--quiet', 'HEAD', '--', pair.manifest, pair.lock])).ok) return { verdict: 'unknown' }
105  const lockText = await treeText($, root, pair.lock)
106  const check = checkFor(pair.lock, lockText)
107  if (check === undefined) return { verdict: 'unknown' }
108  const dir = lockDir(pair.lock)
109  const init = { cwd: dir === '' ? root : `${root}/${dir}`, timeoutMs: 60_000, env: check.env === undefined ? undefined : check.env(await tmpDir($)) }
110  try {
111    const ran = await $.process.run(check.argv(`${root}/${pair.manifest}`), init)
112    return { verdict: check.judge(ran, lockText), tool: check.tool }
113  } catch (err) {
114    toolFailed($, state, check.tool, err)
115    return { verdict: 'unknown' }
116  }
117}
118
119/** The nearest lockfile on disk for a manifest, or undefined when the project keeps none. */
120async function lockOnDisk($: EngineInterface, root: string, manifest: string): Promise<string | undefined> {
121  for (const lock of lockCandidates(manifest)) if (await $.fs.exists(`${root}/${lock}`)) return lock
122  return undefined
123}
124
125/**
126 * The manifest's pairing when the commit left its lockfile alone and the lockfile no longer fits: the package
127 * manager says so, or, when it proves nothing, the manifest's diff changed a dependency.
128 */
129async function staleLock($: EngineInterface, state: State, root: string, manifest: string, changed: Set<string>): Promise<Stale | undefined> {
130  const lock = await lockOnDisk($, root, manifest)
131  if (lock === undefined || changed.has(lock)) return undefined
132  const { verdict } = await lockVerdict($, state, root, { manifest, lock })
133  if (verdict !== 'unknown') return verdict === 'behind' ? { manifest, lock } : undefined
134  const diff = await git($, root, ['show', '--format=', '--unified=20', '--no-color', '--no-ext-diff', 'HEAD', '--', manifest])
135  if (!diff.ok) throw new Error(`git show HEAD -- ${manifest} failed`)
136  // The section of a changed line is read from the whole manifest, not from the diff's own context.
137  const text = await git($, root, ['show', `HEAD:${manifest}`])
138  return touchesDependencies(manifest, diff.out, text.ok ? text.out : '') ? { manifest, lock } : undefined
139}
140
141/**
142 * The finding the person reads: an entry in the shared sidebar's stream while it is open, else the
143 * transcript line, as before. The model's note is another channel and does not change here.
144 */
145async function toPerson($: EngineInterface, key: string, title: string, lines: Line[], line: string): Promise<void> {
146  try {
147    const taken = await $.sidebar.set({ consumer: 'lockfile-sync', key, title, lines, until: 'stream' })
148    if (taken) return
149  } catch {
150    // The sidebar mod is not installed.
151  }
152  $.ui.log(line)
153}
154
155/** Drops the sidebar entries of one finding, so a lockfile that caught up leaves no warning behind. */
156async function dropEntry($: EngineInterface, key: string): Promise<void> {
157  try {
158    await $.sidebar.clear({ consumer: 'lockfile-sync', key })
159  } catch {
160    // The sidebar mod is not installed.
161  }
162}
163
164/**
165 * Whether the manifest's dependencies read as they did at the commit that last wrote the lockfile, so the
166 * change that opened the finding is gone. A lockfile no commit ever wrote has nothing to compare against.
167 */
168async function reverted($: EngineInterface, root: string, s: Stale): Promise<boolean> {
169  const at = await git($, root, ['log', '-1', '--format=%H', '--', s.lock])
170  const base = at.ok ? at.out.trim() : ''
171  if (base === '') return false
172  const diff = await git($, root, ['diff', '--unified=20', '--no-color', '--no-ext-diff', base, '--', s.manifest])
173  // `git diff <base>` compares against the working tree, so that is the file the sections are read from.
174  const text = await treeText($, root, s.manifest)
175  return diff.ok && !touchesDependencies(s.manifest, diff.out, text)
176}
177
178/**
179 * The lockfiles of the open finding that no longer fall behind, each with the package manager that read
180 * it as in step, or undefined when the manifest's dependency change was taken back. A lockfile its package
181 * manager reads as behind stays open whatever the diff says.
182 */
183async function settled($: EngineInterface, state: State, root: string, stale: readonly Stale[]): Promise<Map<string, string | undefined>> {
184  const out = new Map<string, string | undefined>()
185  for (const s of stale) {
186    const checked = await lockVerdict($, state, root, s)
187    if (checked.verdict === 'in-sync') out.set(s.lock, checked.tool)
188    else if (checked.verdict === 'unknown' && (await reverted($, root, s))) out.set(s.lock, undefined)
189  }
190  return out
191}
192
193/**
194 * Closes each open finding when nothing it named stands: the lockfile was written, or the manifest no
195 * longer asks for one. Both are measured from git, never remembered, so a change that was reverted
196 * closes the finding as well as a lockfile that caught up. A finding that keeps some of its pairs stays
197 * open with those alone.
198 */
199async function closeResolved($: EngineInterface, state: State, changed: ReadonlySet<string>, back: ReadonlyMap<string, string | undefined>): Promise<void> {
200  const kept: Open[] = []
201  for (const open of state.open) {
202    const left = open.stale.filter(s => !changed.has(s.lock) && !back.has(s.lock))
203    if (left.length > 0) {
204      kept.push({ key: open.key, stale: left })
205      continue
206    }
207    const updated = open.stale.filter(s => changed.has(s.lock))
208    const settledPairs: Settled[] = open.stale.filter(s => !changed.has(s.lock)).map(s => ({ ...s, tool: back.get(s.lock) }))
209    await dropEntry($, open.key)
210    await toPerson($, open.key, doneTitle(updated, settledPairs), doneLines(updated, settledPairs), doneLog(updated, settledPairs))
211  }
212  state.open = kept
213}
214
215/** The note for the commit that moved HEAD, or undefined when every changed manifest has its lockfile along. */
216async function commitNote($: EngineInterface, state: State, before: Before): Promise<string | undefined> {
217  const head = await git($, before.root, ['rev-parse', 'HEAD'])
218  if (!head.ok || head.out.trim() === before.head) return undefined
219  const names = await git($, before.root, ['show', '--format=', '--name-status', '--no-renames', 'HEAD'])
220  if (!names.ok) throw new Error('git show --name-status HEAD failed')
221  const files = changedFiles(names.out)
222  const changed = new Set(files)
223  await closeResolved($, state, changed, await settled($, state, before.root, pairsOf(state)))
224  const stale: Stale[] = []
225  for (const manifest of files.filter(isManifest)) {
226    const s = await staleLock($, state, before.root, manifest, changed)
227    if (s !== undefined) stale.push(s)
228  }
229  if (stale.length === 0) return undefined
230  // A pair an earlier finding still holds stays there, so the person reads it once; the model reads this commit whole.
231  const pairKey = (s: Stale): string => `${s.manifest}\0${s.lock}`
232  const held = new Set(pairsOf(state).map(pairKey))
233  const fresh = stale.filter(s => !held.has(pairKey(s)))
234  if (fresh.length > 0) {
235    state.open.push({ key: sectionKey(fresh), stale: fresh })
236    // The note goes to the model, the finding to the person: neither reads the other's channel.
237    await toPerson($, sectionKey(fresh), 'lockfiles the commit left out', sidebarLines(fresh), logText(fresh))
238  }
239  return noteText(stale)
240}
241
242async function afterCommit($: EngineInterface, state: State, before: Before, r: ToolCallResult): Promise<ToolCallResult> {
243  if (r.deny !== undefined || r.isError === true) return r
244  try {
245    const note = await commitNote($, state, before)
246    state.lastError = undefined
247    return note === undefined ? r : { ...r, context: [...(r.context ?? []), note] }
248  } catch (err) {
249    await report($, state, err)
250    return r
251  }
252}
253
254/** The lockfiles of the open finding that the working tree has changed since the commit that left them out. */
255async function caughtUp($: EngineInterface, root: string, stale: readonly Stale[]): Promise<Set<string>> {
256  const changed = new Set<string>()
257  for (const s of stale) {
258    const status = await git($, root, ['status', '--porcelain', '--', s.lock])
259    if (status.ok && status.out.trim() !== '') changed.add(s.lock)
260  }
261  return changed
262}
263
264/**
265 * Measures the open finding outside a commit, from the working tree and from git, and answers whether it
266 * still stands. A directory no repository holds, and a git error, leave the finding as it was.
267 */
268async function recheckNow($: EngineInterface, state: State): Promise<boolean> {
269  const stale = pairsOf(state)
270  if (stale.length === 0) return false
271  try {
272    const before = await beforeCommit($, state, '')
273    if (before === undefined) return true
274    await closeResolved($, state, await caughtUp($, before.root, stale), await settled($, state, before.root, stale))
275  } catch (err) {
276    await report($, state, err)
277  }
278  return state.open.length > 0
279}
280
281/**
282 * The pairs this command answers for. A `git commit` answers for its own files alone, so a manifest the
283 * commit does not hold lets it run. A `push` or a `merge` holds no index to read, so every pair stands
284 * there. The index is read before the command runs, as the commit will take it.
285 */
286async function scopeOf($: EngineInterface, stale: readonly Stale[], root: string, command: string): Promise<Stale[]> {
287  if (!isCommit(command) || !isNarrowable(command)) return [...stale]
288  try {
289    const staged = await git($, root, ['diff', '--cached', '--name-only', '-z'])
290    if (!staged.ok) return [...stale]
291    const held = new Set(staged.out.split('\0').filter(Boolean))
292    return stale.filter(s => held.has(s.manifest))
293  } catch {
294    // git did not run: the pairs are not narrowed.
295    return [...stale]
296  }
297}
298
299/** The deny of the pairs this command answers for, or undefined when it holds none of their manifests. */
300async function denyFor($: EngineInterface, stale: readonly Stale[], root: string, command: string): Promise<{ deny: string } | undefined> {
301  const scoped = await scopeOf($, stale, root, command)
302  if (scoped.length > 0) return { deny: denyText(scoped) }
303  $.ui.log(`${stale.length} lockfile(s) are still behind their manifest, and this command holds none of those manifests`)
304  return undefined
305}
306
307/**
308 * The gate: in deny mode a commit, push or merge waits while a lockfile is still behind its manifest.
309 * The working tree is read again first, so a lockfile the model updated opens the gate itself.
310 */
311async function gate($: EngineInterface, state: State, command: string): Promise<{ deny: string } | undefined> {
312  const stale = pairsOf(state)
313  if (!state.enabled || state.mode !== 'deny' || stale.length === 0 || !isGuarded(command)) return undefined
314  try {
315    const before = await beforeCommit($, state, command)
316    if (before === undefined) return undefined
317    await closeResolved($, state, await caughtUp($, before.root, stale), await settled($, state, before.root, stale))
318    return state.open.length === 0 ? undefined : denyFor($, pairsOf(state), before.root, command)
319  } catch (err) {
320    await report($, state, err)
321    return undefined
322  }
323}
324
325async function setMode($: EngineInterface, state: State, arg: string): Promise<string> {
326  const mode = modeOf(arg)
327  if (mode === undefined) return 'mode expects note or deny'
328  await $.store.set(MODE_KEY, mode)
329  state.mode = mode
330  return mode === 'deny'
331    ? 'mode deny: git commit, push and merge stop while a lockfile is behind its manifest'
332    : 'mode note: nothing is stopped, the finding reaches the model as a note'
333}
334
335async function runCommand($: EngineInterface, state: State, args: string): Promise<string> {
336  const [first = '', second = ''] = args.trim().split(/\s+/)
337  if (first === 'mode') return setMode($, state, second)
338  const word = args.trim()
339  if (word === 'on' || word === 'off') {
340    await $.store.set(ENABLED_KEY, word === 'on')
341    state.enabled = word === 'on'
342    return word === 'on' ? 'on: each commit is checked for manifests whose lockfile it left out' : 'off: commits are not checked'
343  }
344  if (word !== '') return USAGE
345  await readSettings($, state)
346  const stale = pairsOf(state)
347  const open = stale.length === 0 ? 'no lockfile is open' : `${stale.map(s => s.lock).join(' · ')} still behind`
348  return `${state.enabled ? 'on' : 'off'} · mode ${state.mode} · ${open}`
349}
350
351export const register: Register = on => {
352  const state: State = { enabled: true, mode: 'note', open: [], owed: false }
353
354  on('session.start', async ($, e, next) => {
355    const r = await next(e)
356    await $.command.register({ name: 'lockfile-sync', description: 'Manifests a commit changes without their lockfile: status, on, off, mode note | deny (lockfile-sync)', argumentHint: '[on | off | mode note | mode deny]' })
357    await readSettings($, state)
358    return r
359  })
360
361  // The engine prints the plugin name in front of command text and log lines, so the texts do not repeat it.
362  on('command.run', { command: 'lockfile-sync' }, async ($, e) => ({ text: await runCommand($, state, String(e.args ?? '')) }))
363
364  /*
365   * The turn's end measures the open finding again and owes the model a note while it stands, because a
366   * finding it did not close would otherwise stand in the pane and reach it never again.
367   */
368  on('turn.complete', async ($, e, next) => {
369    const r = await next(e)
370    if (e.agentId !== undefined) return r
371    if (state.open.length > 0) await readSettings($, state)
372    if (!state.enabled) return r
373    state.owed = await recheckNow($, state)
374    return r
375  })
376
377  // The note goes to the model alone; the person reads the pane, which carries the same finding.
378  on('prompt.submit', async (_, e, next) => {
379    const stale = pairsOf(state)
380    if (!state.owed || stale.length === 0) return next(e)
381    state.owed = false
382    return next({ ...e, context: [...(e.context ?? []), openNote(stale)] })
383  })
384
385  on('tool.call', { tool: 'Bash' }, async ($, e, next) => {
386    // Only a commit, or a guarded command while a finding is open, acts on a setting.
387    if (!isCommit(e.command) && (state.open.length === 0 || !isGuarded(e.command))) return next(e)
388    await readSettings($, state)
389    const stopped = await gate($, state, e.command)
390    if (stopped !== undefined) return stopped
391    if (!state.enabled || !isCommit(e.command)) return next(e)
392    const before = await beforeCommit($, state, e.command)
393    const r = await next(e)
394    return before === undefined ? r : afterCommit($, state, before, r)
395  })
396}
397
hooks/checks.ts 102 lines
1/**
2 * Which package manager command says whether a lockfile still fits its manifest, and how its answer reads.
3 * Every command here was measured to write no file into the repository: a check that installs, links or
4 * rewrites the lockfile is not listed, and its lockfile stays with the manifest's diff alone.
5 */
6
7/** What a package manager says of a lockfile: it fits, it is behind, or the answer proves nothing. */
8export type Verdict = 'in-sync' | 'behind' | 'unknown'
9
10/** A finished command, as `$.process.run` answers it. */
11export type Ran = { exitCode: number; stdout: string; stderr: string }
12
13/** How to ask one package manager: the tool's name, its argv, its environment, and how its answer reads. */
14export type LockCheck = {
15  tool: string
16  argv: (manifestPath: string) => string[]
17  /** Variables for the run; `tmp` is the mod's own temporary directory. */
18  env?: (tmp: string) => Record<string, string>
19  judge: (ran: Ran, lockText: string) => Verdict
20}
21
22const outputOf = (ran: Ran): string => `${ran.stdout}\n${ran.stderr}`
23
24/** A check that passes with exit 0 and says `behind` in its failure text; any other failure proves nothing. */
25function failsWith(pattern: RegExp): (ran: Ran) => Verdict {
26  return ran => {
27    if (ran.exitCode === 0) return 'in-sync'
28    return pattern.test(outputOf(ran)) ? 'behind' : 'unknown'
29  }
30}
31
32/** `go mod tidy -diff` fails for an untidy go.mod too; only a go.sum hunk means the lockfile is behind. */
33function judgeGo(ran: Ran): Verdict {
34  if (ran.exitCode === 0) return 'in-sync'
35  if (ran.stdout.includes('--- current/go.sum')) return 'behind'
36  return ran.stdout.includes('--- current/go.mod') ? 'in-sync' : 'unknown'
37}
38
39/** A `yarn check` error that speaks of node_modules, not of the lockfile. */
40const YARN_INSTALL_ERROR = /^error ("[^"]+" not installed|Found \d+ errors?\.)$/
41
42/** Yarn v1 also reports every package missing from node_modules; only a missing pattern is the lockfile's. */
43function judgeYarn(ran: Ran): Verdict {
44  const output = outputOf(ran)
45  if (output.includes('Lockfile does not contain pattern')) return 'behind'
46  if (ran.exitCode === 0) return 'in-sync'
47  const errors = output.split('\n').map(l => l.trim()).filter(l => l.startsWith('error '))
48  return errors.length > 0 && errors.every(l => YARN_INSTALL_ERROR.test(l)) ? 'in-sync' : 'unknown'
49}
50
51/** Gemfile.lock blocks that follow the machine or the Bundler version rather than the Gemfile. */
52const GEM_LOCAL = /^(PLATFORMS|CHECKSUMS|BUNDLED WITH|RUBY VERSION)$/
53
54/** The lockfile's blocks that the Gemfile decides, in order. */
55export function gemCore(text: string): string {
56  return text.replace(/\r\n/g, '\n').split(/\n\s*\n/).map(b => b.trim()).filter(b => b !== '' && !GEM_LOCAL.test(b.split('\n')[0] ?? '')).join('\n\n')
57}
58
59/** `bundle lock --print` prints the lockfile the Gemfile asks for; a cut or failed print proves nothing. */
60function judgeBundle(ran: Ran, lockText: string): Verdict {
61  if (ran.exitCode !== 0 || !ran.stdout.includes('\nDEPENDENCIES\n')) return 'unknown'
62  return gemCore(ran.stdout) === gemCore(lockText) ? 'in-sync' : 'behind'
63}
64
65const NPM: LockCheck = { tool: 'npm', argv: () => ['npm', 'ci', '--dry-run', '--ignore-scripts'], judge: failsWith(/are in sync/) }
66const BUN: LockCheck = { tool: 'bun', argv: () => ['bun', 'install', '--frozen-lockfile', '--dry-run', '--ignore-scripts'], judge: failsWith(/lockfile had changes/) }
67
68/** The check per lockfile name. A lockfile without an entry is judged by the manifest's diff alone. */
69const LOCK_CHECK: Record<string, LockCheck> = {
70  'Cargo.lock': { tool: 'cargo', argv: m => ['cargo', 'metadata', '--locked', '--format-version', '1', '--manifest-path', m], judge: failsWith(/cannot update the lock file/) },
71  'package-lock.json': NPM,
72  'pnpm-lock.yaml': { tool: 'pnpm', argv: () => ['pnpm', 'install', '--frozen-lockfile', '--lockfile-only', '--ignore-pnpmfile', '--ignore-scripts'], judge: failsWith(/don't match specifiers/) },
73  'bun.lock': BUN,
74  'bun.lockb': BUN,
75  'yarn.lock': { tool: 'yarn', argv: () => ['yarn', 'check'], judge: judgeYarn },
76  'composer.lock': { tool: 'composer', argv: () => ['composer', 'validate', '--no-check-all', '--no-check-publish', '--check-lock', '--no-plugins'], judge: failsWith(/lock file is not up to date/) },
77  'go.sum': { tool: 'go', argv: () => ['go', 'mod', 'tidy', '-diff'], judge: judgeGo },
78  'uv.lock': { tool: 'uv', argv: () => ['uv', 'lock', '--check'], judge: failsWith(/needs to be updated/) },
79  'poetry.lock': { tool: 'poetry', argv: () => ['poetry', 'check', '--lock'], judge: failsWith(/changed significantly/) },
80  'pdm.lock': { tool: 'pdm', argv: () => ['pdm', 'lock', '--check'], judge: failsWith(/satisfy the project requirements/) },
81  'Pipfile.lock': { tool: 'pipenv', argv: () => ['pipenv', 'verify'], judge: failsWith(/out-of-date/) },
82  'Gemfile.lock': { tool: 'bundle', argv: () => ['bundle', 'lock', '--print'], judge: judgeBundle },
83  'pubspec.lock': { tool: 'dart', argv: () => ['dart', 'pub', 'get', '--enforce-lockfile', '--dry-run'], judge: failsWith(/Unable to satisfy/) },
84  // Without its own deps path, `mix deps.get` fetches into the project's deps/.
85  'mix.lock': { tool: 'mix', argv: () => ['mix', 'deps.get', '--check-locked'], env: tmp => ({ MIX_DEPS_PATH: `${tmp}/mix-deps` }), judge: failsWith(/mix\.lock is out of date/) },
86}
87
88/** A Yarn v1 lockfile's first comment; Yarn 2 and later write none, and their check links node_modules. */
89const YARN_V1 = /^# yarn lockfile v1/m
90
91/** The check for a lockfile, or undefined when none reads it without writing into the repository. */
92export function checkFor(lock: string, lockText: string): LockCheck | undefined {
93  const name = lock.split('/').at(-1) ?? lock
94  if (name === 'yarn.lock' && !YARN_V1.test(lockText)) return undefined
95  return LOCK_CHECK[name]
96}
97
98/** The directory a lockfile sits in, relative to the repository root; `''` for the root. */
99export function lockDir(lock: string): string {
100  return lock.split('/').slice(0, -1).join('/')
101}
102
hooks/pairs.ts 313 lines
1/** Which commands commit, which manifests pair with which lockfiles, and whether a manifest diff touches dependencies. */
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 gate stops while a finding is open. */
15const GUARDED = new RegExp(String.raw`(^|[\s;&|(])git(?:${GIT_FLAG})*\s+(commit|push|merge)\b`)
16
17export function isGuarded(command: string): boolean {
18  return GUARDED.test(command) && !NOT_A_COMMIT.test(command)
19}
20
21/**
22 * Whether the index alone says what this commit holds. A `-a` or `-am` commit stages the tracked files
23 * as it runs, and a pathspec after `--` commits paths the index does not hold, so neither is narrowed.
24 */
25export function isNarrowable(command: string): boolean {
26  const words = command.split(/\s+/)
27  return !words.includes('--') && !words.some(w => w === '--all' || /^-[A-Za-z]*a/.test(w))
28}
29
30/** The mode of the mod: a note only, or a note and a gate on git commit, push and merge. */
31export type Mode = 'note' | 'deny'
32
33/** The mode a `/lockfile-sync mode <word>` argument names, or undefined when it is not one. */
34export function modeOf(arg: string): Mode | undefined {
35  return arg === 'note' || arg === 'deny' ? arg : undefined
36}
37
38const unquote = (word: string): string => word.replace(/^(["'])(.*)\1$/, '$2')
39
40/**
41 * A directory word joined to the one before it. A word the shell expands first (`$D`, `~`, a backquote,
42 * outside single quotes) names no directory this text can tell, so it throws rather than run git in a
43 * directory that is not there.
44 */
45function joinDir(base: string, word: string, how: string): string {
46  const expands = !word.startsWith("'") && (/[$`]/.test(word) || word.startsWith('~'))
47  if (expands) throw new Error(`the commit's directory is not known: ${how} ${word}`)
48  const dir = unquote(word)
49  return dir.startsWith('/') ? dir : `${base.replace(/\/+$/, '')}/${dir}`
50}
51
52/**
53 * The directory the commit runs in: the session's directory, moved by each `cd` before the commit in turn (`cd -`
54 * back to the directory before it) and by its `git -C`, because the hook reads the repository before the
55 * command's own `cd` has run.
56 */
57export function commitDir(command: string, cwd: string): string {
58  const commit = COMMIT.exec(command)
59  if (commit === null) return cwd
60  const cds = [...command.slice(0, commit.index).matchAll(/(?:^|[;&|(]\s*)cd\s+("[^"]*"|'[^']*'|[^\s;&|)]+)/g)]
61  const afterCd = cds.reduce(
62    (at, cd) => (cd[1] === '-' ? { dir: at.prev, prev: at.dir } : { dir: joinDir(at.dir, cd[1] ?? '.', 'cd'), prev: at.dir }),
63    { dir: cwd, prev: cwd },
64  ).dir
65  return [...commit[0].matchAll(/-C\s+(\S+)/g)].reduce((dir, c) => joinDir(dir, c[1] ?? '.', 'git -C'), afterCd)
66}
67
68/** The lockfiles each manifest's package manager writes. */
69export const LOCKS: Record<string, string[]> = {
70  'package.json': ['package-lock.json', 'yarn.lock', 'pnpm-lock.yaml', 'bun.lock', 'bun.lockb'],
71  'composer.json': ['composer.lock'],
72  'Cargo.toml': ['Cargo.lock'],
73  'go.mod': ['go.sum'],
74  'pyproject.toml': ['poetry.lock', 'uv.lock', 'pdm.lock'],
75  Pipfile: ['Pipfile.lock'],
76  Gemfile: ['Gemfile.lock'],
77  'pubspec.yaml': ['pubspec.lock'],
78  'mix.exs': ['mix.lock'],
79}
80
81const baseName = (path: string): string => path.split('/').at(-1) ?? path
82
83/** The files a `git show --name-status` lists as added or modified; a deleted file is left out. */
84export function changedFiles(nameStatus: string): string[] {
85  return nameStatus.split('\n').flatMap(line => {
86    const [status = '', ...paths] = line.split('\t')
87    return status === '' || status.startsWith('D') ? [] : [paths.at(-1) ?? '']
88  }).filter(p => p !== '')
89}
90
91export function isManifest(path: string): boolean {
92  return baseName(path) in LOCKS
93}
94
95/** The lockfile paths a manifest may use, nearest directory first up to the repository root (a workspace keeps one at the root). */
96export function lockCandidates(manifest: string): string[] {
97  const dirs = manifest.split('/').slice(0, -1)
98  const locks = LOCKS[baseName(manifest)] ?? []
99  const out: string[] = []
100  for (let n = dirs.length; n >= 0; n--) {
101    const prefix = dirs.slice(0, n).join('/')
102    for (const lock of locks) out.push(prefix === '' ? lock : `${prefix}/${lock}`)
103  }
104  return out
105}
106
107const JSON_DEPS = /^(dependencies|devDependencies|peerDependencies|optionalDependencies|bundledDependencies|overrides|resolutions|require|require-dev|replace|conflict|provide)$/
108const TOML_DEPS = /(^|\.)(dependencies|dev-dependencies|build-dependencies|dev-packages|packages|dependency-groups|optional-dependencies|patch(\..+)?|project|group\.[^.]+\.dependencies)$/
109const YAML_DEPS = /^(dependencies|dev_dependencies|dependency_overrides)$/
110
111const indentOf = (line: string): number => (/^\s*/.exec(line)?.[0] ?? '').length
112
113/** A JSON object's first line: `"key": {`, or a bare `{` for the root object. */
114const JSON_OPEN = /^\s*(?:"([^"]+)"\s*:\s*)?\{\s*$/
115const YAML_OPEN = /^\s*([\w-]+):\s*$/
116
117/** The key of the nearest object that encloses `lines[i]` by indent; `''` for the root object. */
118function enclosingKey(lines: string[], i: number, opener: RegExp): string | undefined {
119  const indent = indentOf(lines[i] ?? '')
120  for (let j = i - 1; j >= 0; j--) {
121    const m = opener.exec(lines[j] ?? '')
122    if (m !== null && indentOf(lines[j] ?? '') < indent) return m[1] ?? ''
123  }
124  return undefined
125}
126
127/** The TOML table `lines[i]` sits in. */
128function tomlTable(lines: string[], i: number): string | undefined {
129  for (let j = i - 1; j >= 0; j--) {
130    const m = /^\s*\[\[?\s*([^\]]+?)\s*\]\]?\s*$/.exec(lines[j] ?? '')
131    if (m !== null) return m[1]
132  }
133  return undefined
134}
135
136/** Whether a go.mod line is a `require`, `replace` or `exclude` line or sits in such a block. */
137function goDependency(lines: string[], i: number): boolean {
138  if (/^\s*(require|replace|exclude|retract)\b/.test(lines[i] ?? '')) return true
139  for (let j = i - 1; j >= 0; j--) {
140    if (/^\s*\)/.test(lines[j] ?? '')) return false
141    if (/^\s*(require|replace|exclude)\s*\(/.test(lines[j] ?? '')) return true
142  }
143  return false
144}
145
146// A key or table the FILE does not show counts as a dependency, so a manifest that cannot be read
147// still gets the note. The lookup reads the whole file, not the diff's own context: a hunk 20 lines
148// deep in package.json never reaches the root `{`, and every root-level key then read as a dependency.
149
150function jsonDependency(lines: string[], i: number): boolean {
151  const key = enclosingKey(lines, i, JSON_OPEN)
152  return key === undefined || JSON_DEPS.test(key)
153}
154
155function yamlDependency(lines: string[], i: number): boolean {
156  const key = enclosingKey(lines, i, YAML_OPEN)
157  return key === undefined || YAML_DEPS.test(key)
158}
159
160function tomlDependency(lines: string[], i: number): boolean {
161  const table = tomlTable(lines, i)
162  return table === undefined || TOML_DEPS.test(table)
163}
164
165const gemDependency = (lines: string[], i: number): boolean => /^\s*(gem|source|gemspec|git|path|group|platforms?)\b/.test(lines[i] ?? '')
166
167/** Per manifest, whether its changed line `lines[i]` can change the lockfile; a manifest without an entry always can. */
168const DEPENDENCY_LINE: Record<string, (lines: string[], i: number) => boolean> = {
169  'package.json': jsonDependency,
170  'composer.json': jsonDependency,
171  'Cargo.toml': tomlDependency,
172  'pyproject.toml': tomlDependency,
173  Pipfile: tomlDependency,
174  'go.mod': goDependency,
175  Gemfile: gemDependency,
176  'pubspec.yaml': yamlDependency,
177}
178
179function isDependencyLine(manifest: string, lines: string[], i: number): boolean {
180  const check = DEPENDENCY_LINE[baseName(manifest)]
181  return check === undefined || check(lines, i)
182}
183
184/** A hunk header; the capture is the 1-based first line of its new side. */
185const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/
186
187/**
188 * Where each changed line of the diff sits in the file as it stands after the change, as an index into
189 * its lines. A removed line takes the index of the line that now stands in its place, which is the
190 * position its own section is read from.
191 */
192function changedAt(diff: string): number[] {
193  const out: number[] = []
194  // Below zero until the first hunk header, so the `--- a/x` and `+++ b/x` of the file header count as nothing.
195  let at = -1
196  for (const raw of diff.split('\n')) {
197    const header = HUNK.exec(raw)
198    if (header !== null) {
199      at = Number(header[1]) - 1
200      continue
201    }
202    if (at < 0) continue
203    const body = raw.slice(1)
204    if (raw.startsWith('-')) {
205      if (body.trim() !== '') out.push(at)
206      continue
207    }
208    if (!raw.startsWith('+') && !raw.startsWith(' ')) continue
209    if (raw.startsWith('+') && body.trim() !== '') out.push(at)
210    at += 1
211  }
212  return out
213}
214
215/**
216 * Whether a diff of one manifest changes a line that can change its lockfile. `fileText` is the manifest
217 * as it stands on the diff's new side; an empty one leaves every changed line counted, because a file
218 * that cannot be read proves nothing.
219 */
220export function touchesDependencies(manifest: string, diff: string, fileText: string): boolean {
221  const file = fileText.split('\n')
222  return changedAt(diff).some(i => isDependencyLine(manifest, file, i))
223}
224
225/** A manifest the commit changed and the lockfile it left unchanged. */
226export type Stale = { manifest: string; lock: string }
227
228function namedPairs(stale: readonly Stale[]): string {
229  return stale.map(s => `${s.manifest} but not ${s.lock}`).join(' · ')
230}
231
232export function noteText(stale: readonly Stale[]): string {
233  return `lockfile-sync: this commit changes ${namedPairs(stale)}. Run the package manager's install so the lockfile matches, and commit it.`
234}
235
236/** The transcript line: the pairs alone, without the instruction the model reads. The engine adds the mod name. */
237export function logText(stale: readonly Stale[]): string {
238  return `this commit changes ${namedPairs(stale)}`
239}
240
241/** What the deny says: why the command stopped, and the one setting that turns the gate off. */
242export function denyText(stale: readonly Stale[]): string {
243  const pairs = stale.map(s => `${s.lock} behind ${s.manifest}`).join(' · ')
244  return `stopped: ${stale.length} lockfile(s) are behind their manifest: ${pairs}. Install the dependencies so the lockfile is written, 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(stale: readonly Stale[]): string {
252  const pairs = stale.map(s => `${s.lock} behind ${s.manifest}`).join(' · ')
253  return `lockfile-sync: ${stale.length} lockfile(s) are still behind their manifest: ${pairs}. Run the package manager's install so the lockfile is written, or take the dependency change back.`
254}
255
256/** How the sidebar colours a line or a part of one. */
257type Tone = 'ok' | 'warn' | 'error' | 'dim'
258export type Part = { text: string; kind?: Tone }
259/** A sidebar line; `parts` colour pieces of it, and `text` holds the whole line for a sidebar that draws no parts. */
260export type Line = { text: string; kind?: Tone; parts?: Part[] }
261
262const part = (text: string, kind: Tone | undefined): Part => (kind === undefined ? { text } : { text, kind })
263
264/** A line made of parts, its `text` their texts joined. */
265const partsLine = (parts: Part[]): Line => ({ text: parts.map(p => p.text).join(''), parts })
266
267/** One sidebar line per pair, so the section reads as a list: the manifest default, the lockfile left behind red. */
268export function sidebarLines(stale: readonly Stale[]): Line[] {
269  return stale.map(s => partsLine([part(s.manifest, undefined), part(' but not ', 'dim'), part(s.lock, 'error')]))
270}
271
272/** The title of a closed finding, by what closed it. */
273export function doneTitle(updated: readonly Stale[], settled: readonly Stale[]): string {
274  if (settled.length === 0) return 'lockfiles updated'
275  return updated.length === 0 ? 'manifests back in step' : 'lockfiles settled'
276}
277
278/** A pair that closed without a new lockfile; `tool` names the package manager that read it as in step. */
279export type Settled = Stale & { tool?: string }
280
281/**
282 * The transcript line of a finding that closed: the lockfiles a later change brought along, the
283 * manifests whose dependencies match the lockfile's own commit again, and the lockfiles their package
284 * manager reads as in step.
285 */
286export function doneLog(updated: readonly Stale[], settled: readonly Settled[]): string {
287  const parts: string[] = []
288  const reverted = settled.filter(s => s.tool === undefined)
289  if (updated.length > 0) parts.push(`a later change brought the lockfiles along: ${updated.map(s => s.lock).join(' · ')}`)
290  if (reverted.length > 0) parts.push(`the dependencies match the lockfile again: ${reverted.map(s => s.manifest).join(' · ')}`)
291  for (const s of settled) if (s.tool !== undefined) parts.push(`${s.tool} reads ${s.lock} as in step with ${s.manifest}`)
292  return parts.join(' · ')
293}
294
295/** A settled pair's line: the lockfile green, or the manifest where no lockfile is named, the rest faint. */
296function settledLine(s: Settled): Line {
297  if (s.tool === undefined) return partsLine([part(s.manifest, 'ok'), part(' asks for no lockfile change any more', 'dim')])
298  return partsLine([part(`${s.tool} reads `, 'dim'), part(s.lock, 'ok'), part(` as in step with ${s.manifest}`, 'dim')])
299}
300
301/** One sidebar line per pair that closed, with what closed it. */
302export function doneLines(updated: readonly Stale[], settled: readonly Settled[]): Line[] {
303  return [
304    ...updated.map(s => partsLine([part(s.lock, 'ok'), part(` now matches ${s.manifest}`, 'dim')])),
305    ...settled.map(settledLine),
306  ]
307}
308
309/** A sidebar section key: the manifests of this commit, cut to what the sidebar takes. */
310export function sectionKey(stale: readonly Stale[]): string {
311  return stale.map(s => s.manifest).join('-').replace(/[^A-Za-z0-9._:-]+/g, '-').slice(0, 64) || 'note'
312}
313