@isonimus/stele 0.1.2 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,262 @@
1
+ #!/usr/bin/env node
2
+ // Mutation check over the linter's pure predicates (ADR-0004's probe idea, made standing).
3
+ //
4
+ // node scripts/check-mutants.mjs [--quiet]
5
+ //
6
+ // Each mutant is a small, deliberate behaviour change. The suite is run against it: a
7
+ // mutant that dies proves a test was watching, one that SURVIVES proves the behaviour is
8
+ // unpinned and a refactor could reverse it in silence. Exit 1 if any non-exempt mutant
9
+ // survives.
10
+ //
11
+ // Scope is deliberately narrow: the total, IO-free predicates in lint-docs.mjs and
12
+ // check-immutable.mjs. Every subtle-logic defect this repo has had lived in that class.
13
+ // init-method.mjs is excluded — its behaviour is pinned by real-filesystem fixtures, where
14
+ // a mutant mostly proves the filesystem still works — and the hook is shell, not JS.
15
+ //
16
+ // What this does NOT buy, stated so a green run is not read as more than it is: none of the
17
+ // three defects this repo has actually suffered would have been caught. Two were missing
18
+ // inputs and one was a wrong specification; mutation testing perturbs code and asks whether
19
+ // tests notice, so it is blind to a case nobody wrote and to a rule that was wrong from the
20
+ // start. It measures regression durability, not correctness (LEDGER).
21
+
22
+ import { cpSync, mkdtempSync, readFileSync, readdirSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
23
+ import { execFileSync } from 'node:child_process';
24
+ import { tmpdir } from 'node:os';
25
+ import { dirname, join } from 'node:path';
26
+ import { fileURLToPath } from 'node:url';
27
+
28
+ const ROOT = dirname(dirname(fileURLToPath(import.meta.url)));
29
+
30
+ const LINTER = 'scripts/lint-docs.mjs';
31
+ const IMMUTABLE = 'scripts/check-immutable.mjs';
32
+
33
+ /** ADR-0022 excluded init-method.mjs wholesale, reasoning that a mutant there mostly proves
34
+ * the filesystem still works. `classifyCommand` (ADR-0023) is the first total, IO-free
35
+ * predicate in that file, so it falls inside the scope ADR-0022 actually described rather
36
+ * than the file it named. Recorded as a refinement, never a silent exception. */
37
+ const INIT = 'scripts/init-method.mjs';
38
+
39
+ /**
40
+ * The curated mutant list. Each entry names one behaviour and the smallest edit that
41
+ * reverses it.
42
+ *
43
+ * Hand-picked rather than generated: a generator emits hundreds of mutants over these files
44
+ * and most are equivalent or trivial, so the triage cost — not the runtime — is what makes
45
+ * a framework a bad trade here. Curating is the work; the runner is twenty lines.
46
+ *
47
+ * `equivalent` marks a mutant that cannot be killed because the mutated code means the same
48
+ * thing. It is kept rather than deleted, so nobody re-adds it as a "gap", and it must carry
49
+ * the reason it is exempt.
50
+ */
51
+ export const MUTANTS = [
52
+ {
53
+ label: 'normId: pad ids to 3 digits instead of 4',
54
+ file: LINTER,
55
+ find: "String(v).trim().padStart(4, '0')",
56
+ replace: "String(v).trim().padStart(3, '0')",
57
+ },
58
+ {
59
+ label: 'isCalendarDate: drop the round-trip check',
60
+ file: LINTER,
61
+ find: 'return parsed.toISOString().startsWith(text);',
62
+ replace: 'return true;',
63
+ },
64
+ {
65
+ label: 'citableText: stop stripping URLs before matching citations',
66
+ file: LINTER,
67
+ find: "text.replace(/\\]\\([^)]*\\)/g, ']()').replace(/\\S*:\\/\\/\\S*/g, '')",
68
+ replace: "text.replace(/\\]\\([^)]*\\)/g, ']()')",
69
+ },
70
+ {
71
+ label: 'inReadScope: drop the exact-match branch, keeping only the prefix',
72
+ file: LINTER,
73
+ find: 'rootRelative === entry || rootRelative.startsWith(`${entry}/`)',
74
+ replace: 'rootRelative.startsWith(`${entry}/`)',
75
+ },
76
+ {
77
+ label: 'withoutFences: stop blanking lines inside a code fence',
78
+ file: LINTER,
79
+ find: "return open === null ? line : '';",
80
+ replace: 'return line;',
81
+ },
82
+ {
83
+ label: 'sectionText: read to end of body instead of stopping at the next heading',
84
+ file: LINTER,
85
+ find: "(end === -1 ? rest : rest.slice(0, end)).join('\\n')",
86
+ replace: "rest.join('\\n')",
87
+ },
88
+ {
89
+ label: 'hasGherkinTriad: require any step kind rather than all three',
90
+ file: LINTER,
91
+ find: "steps.has('given') && steps.has('when') && steps.has('then')",
92
+ replace: "steps.has('given') || steps.has('when') || steps.has('then')",
93
+ },
94
+ {
95
+ label: 'R6: ignore a superseded_by list of exactly one target',
96
+ file: LINTER,
97
+ find: 'if (!isSuperseded && supersededBy.length > 0) {',
98
+ replace: 'if (!isSuperseded && supersededBy.length > 1) {',
99
+ },
100
+ {
101
+ label: 'classifyCommand: treat an unrecorded command as stale rather than unknown',
102
+ file: INIT,
103
+ find: "if (recordedDigest === undefined) return 'unknown';",
104
+ replace: "if (recordedDigest === undefined) return 'stale';",
105
+ },
106
+ {
107
+ label: 'classifyCommand: swap the stale/adapted verdict',
108
+ file: INIT,
109
+ find: "return digest(targetText) === recordedDigest ? 'stale' : 'adapted';",
110
+ replace: "return digest(targetText) === recordedDigest ? 'adapted' : 'stale';",
111
+ },
112
+ {
113
+ label: 'firstLostLine: weaken the exhausted-scan guard',
114
+ file: IMMUTABLE,
115
+ find: 'if (cursor === now.length) return i;',
116
+ replace: 'if (cursor > now.length) return i;',
117
+ },
118
+ {
119
+ label: 'firstLostLine: compare body lines loosely',
120
+ file: IMMUTABLE,
121
+ find: 'while (cursor < now.length && now[cursor] !== was[i]) cursor++;',
122
+ replace: 'while (cursor < now.length && now[cursor] != was[i]) cursor++;',
123
+ equivalent: 'both operands are strings, so != and !== are the same comparison',
124
+ },
125
+ ];
126
+
127
+ /**
128
+ * A disposable copy of the working tree. The check never edits the operator's files: a run
129
+ * interrupted midway would otherwise leave mutated source behind, and "my linter is subtly
130
+ * wrong and I do not know why" is an expensive afternoon.
131
+ *
132
+ * `.git` is copied with everything else, because the copy has to behave identically or the
133
+ * baseline below fails for reasons that have nothing to do with any mutant. `migrate-adrs`
134
+ * reads commit dates and falls back silently when git cannot answer, so a hollow `.git`
135
+ * changed its plan and failed five tests. At 3.2MB the copy is not worth being clever about.
136
+ */
137
+ function scratchCopy() {
138
+ const dir = mkdtempSync(join(tmpdir(), 'stele-mutants-'));
139
+ cpSync(ROOT, dir, { recursive: true, filter: (src) => !src.endsWith('/node_modules') });
140
+ return dir;
141
+ }
142
+
143
+ /**
144
+ * `test/mutants.test.mjs` guards this script's own list by asserting every anchor matches
145
+ * exactly once. Applying a mutant is precisely what stops an anchor matching, so under a
146
+ * mutated tree that test fails — and every mutant would be scored as killed by the guard
147
+ * rather than by any regression test, including the equivalent one that cannot be killed
148
+ * at all. It runs in the ordinary suite, where it belongs, and is excluded here.
149
+ */
150
+ const SELF_REFERENTIAL = 'mutants.test.mjs';
151
+
152
+ /** The suite's files, expanded here rather than by a shell: `node --test test/` reads that
153
+ * argument as a module path and dies, which is not a test failure but looks exactly like
154
+ * one from the outside. */
155
+ function testFiles(cwd) {
156
+ const dir = join(cwd, 'test');
157
+ const files = readdirSync(dir)
158
+ .filter((f) => f.endsWith('.test.mjs') && f !== SELF_REFERENTIAL)
159
+ .sort();
160
+ if (files.length === 0) throw new Error(`no test files under ${dir} — nothing to run mutants against`);
161
+ return files.map((f) => join('test', f));
162
+ }
163
+
164
+ /** True when the suite passes — i.e. the mutant went unnoticed. */
165
+ function suitePasses(cwd, files) {
166
+ try {
167
+ execFileSync(process.execPath, ['--test', ...files], { cwd, stdio: 'ignore' });
168
+ return true;
169
+ } catch {
170
+ return false;
171
+ }
172
+ }
173
+
174
+ export function checkMutants({ quiet = false } = {}) {
175
+ const dir = scratchCopy();
176
+ const survivors = [];
177
+ try {
178
+ const files = testFiles(dir);
179
+
180
+ // The load-bearing guard. Every verdict here is "the suite failed, so something was
181
+ // watching" — which is worthless if the suite fails for its own reasons, and reports a
182
+ // perfect score while testing nothing. The first draft of this script did exactly that:
183
+ // it ran `node --test test/`, which is not a directory glob, so all ten mutants "died"
184
+ // in 621ms against a suite that never started.
185
+ if (!suitePasses(dir, files)) {
186
+ throw new Error(
187
+ 'the unmutated suite does not pass — every mutant would report as killed for the\n' +
188
+ 'wrong reason. Fix the suite first, then re-run this check.',
189
+ );
190
+ }
191
+
192
+ for (const mutant of MUTANTS) {
193
+ const path = join(dir, mutant.file);
194
+ const pristine = readFileSync(path, 'utf8');
195
+
196
+ // A rotted anchor is a failure, never a skip. The alternative is a probe that quietly
197
+ // stops testing what it claims to — the exact false green this repo exists to remove.
198
+ const hits = pristine.split(mutant.find).length - 1;
199
+ if (hits !== 1) {
200
+ throw new Error(
201
+ `mutant anchor matched ${hits} times in ${mutant.file}, expected exactly 1:\n` +
202
+ ` ${mutant.label}\n` +
203
+ ` The code moved. Re-aim the mutant at the behaviour it was written for, or ` +
204
+ `delete it if that behaviour is gone.`,
205
+ );
206
+ }
207
+
208
+ writeFileSync(path, pristine.replace(mutant.find, mutant.replace));
209
+ const survived = suitePasses(dir, files);
210
+ writeFileSync(path, pristine);
211
+
212
+ // An equivalent mutant that dies is the canary, not a bonus. It cannot be detected by
213
+ // any honest test, so a kill means the suite failed for a reason unrelated to the
214
+ // mutation and every other verdict in this run is worthless. Both times this harness
215
+ // scored wrongly — the crashing test command, then the self-referential guard — this
216
+ // is the line that showed it.
217
+ if (mutant.equivalent && !survived) {
218
+ throw new Error(
219
+ `an equivalent mutant was killed, which cannot happen honestly:\n` +
220
+ ` ${mutant.label}\n` +
221
+ ` exempt because: ${mutant.equivalent}\n` +
222
+ ` The suite is failing for a reason unrelated to the mutation, so every other\n` +
223
+ ` verdict in this run is meaningless. Run the suite by hand and find out why.`,
224
+ );
225
+ }
226
+
227
+ if (survived && !mutant.equivalent) survivors.push(mutant);
228
+ if (!quiet) {
229
+ const verdict = mutant.equivalent ? 'exempt (equivalent)' : (survived ? 'SURVIVED' : 'killed');
230
+ console.log(` ${verdict.padEnd(24)} ${mutant.label}`);
231
+ }
232
+ }
233
+ } finally {
234
+ rmSync(dir, { recursive: true, force: true });
235
+ }
236
+ return survivors;
237
+ }
238
+
239
+ function main(argv) {
240
+ const quiet = argv.includes('--quiet');
241
+ if (!quiet) console.log(`\n${ROOT} — ${MUTANTS.length} mutant(s)`);
242
+
243
+ const survivors = checkMutants({ quiet });
244
+ const exempt = MUTANTS.filter((m) => m.equivalent).length;
245
+
246
+ if (survivors.length === 0) {
247
+ console.log(`\nall ${MUTANTS.length - exempt} non-exempt mutant(s) killed, ${exempt} exempt`);
248
+ return 0;
249
+ }
250
+
251
+ console.log(`\n${survivors.length} mutant(s) survived — the behaviour below is unpinned:`);
252
+ for (const m of survivors) console.log(` ${m.file}: ${m.label}`);
253
+ console.log(
254
+ '\nEach needs a regression test, or — if the mutated code genuinely means the same thing\n' +
255
+ '— an `equivalent` note on the mutant saying why it cannot be killed.',
256
+ );
257
+ return 1;
258
+ }
259
+
260
+ if (process.argv[1] && realpathSync(process.argv[1]) === fileURLToPath(import.meta.url)) {
261
+ process.exit(main(process.argv.slice(2)));
262
+ }
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // Installs the method kit into a git repo (ADR-0006). Dry-run by default.
3
3
  //
4
- // node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update]
4
+ // node scripts/init-method.mjs <repo-root> [--apply] [--check] [--update [--force]]
5
5
  //
6
6
  // The load-bearing rule lives in installHook(): the pre-commit hook is linked ONLY
7
7
  // against a corpus the linter calls clean. An unwired scripts/*-verify.mjs is an R11
@@ -13,6 +13,7 @@
13
13
  // an install that checks nothing.
14
14
 
15
15
  import { existsSync, readFileSync, writeFileSync, mkdirSync, copyFileSync, lstatSync, readlinkSync, symlinkSync, unlinkSync, readdirSync, realpathSync } from 'node:fs';
16
+ import { createHash } from 'node:crypto';
16
17
  import { join, dirname, relative } from 'node:path';
17
18
  import { fileURLToPath } from 'node:url';
18
19
 
@@ -28,13 +29,14 @@ const TOOLKIT = dirname(dirname(fileURLToPath(import.meta.url)));
28
29
  const VENDORED = [
29
30
  ['scripts/lint-docs.mjs', 'scripts/lint-docs.mjs'],
30
31
  ['scripts/build-index.mjs', 'scripts/build-index.mjs'],
32
+ ['scripts/check-immutable.mjs', 'scripts/check-immutable.mjs'],
31
33
  ['.claude/hooks/pre-commit', '.claude/hooks/pre-commit'],
32
34
  ];
33
35
 
34
36
  const COMMANDS_DIR = '.claude/commands';
35
37
 
36
38
  /**
37
- * The slash commands, vendored too (ADR-0007) — same target path as toolkit path.
39
+ * The slash commands, vendored too (ADR-0023) — same target path as toolkit path.
38
40
  *
39
41
  * Read from disk rather than listed, so a new command reaches installed repos without
40
42
  * anyone remembering to extend an array here.
@@ -45,6 +47,23 @@ const commandFiles = (toolkit) =>
45
47
  .sort()
46
48
  .map((name) => `${COMMANDS_DIR}/${name}`);
47
49
 
50
+ /**
51
+ * What the toolkit last handed this repo: command path → SHA-256 of the content written
52
+ * there (ADR-0023).
53
+ *
54
+ * Without it an update sees two states where three are needed — a stale copy of an older
55
+ * release and a deliberate local adaptation are the same observation, "differs from the
56
+ * toolkit", and the only safe reading of that is the destructive one. Committed, not
57
+ * ignored: a fresh clone missing it classifies every command as unreconciled.
58
+ */
59
+ const PROVENANCE = '.claude/.stele-vendored.json';
60
+
61
+ /** Bumped only when the record's shape changes; an unrecognised version is treated as no
62
+ * record at all, which keeps every command rather than overwriting it. */
63
+ const PROVENANCE_VERSION = 1;
64
+
65
+ const digest = (text) => createHash('sha256').update(text).digest('hex');
66
+
48
67
  /** Scaffolded once and never overwritten: target path ← template path. */
49
68
  const SCAFFOLD = [
50
69
  ['CLAUDE.md', 'templates/CLAUDE.md'],
@@ -61,7 +80,16 @@ const FRAMEWORK_CONFIG = '.pre-commit-config.yaml';
61
80
  /** Identifies our block on re-runs, so composing is idempotent. */
62
81
  const FRAMEWORK_HOOK_ID = 'stele-docs';
63
82
 
64
- /** Appended verbatim. Mirrors .claude/hooks/pre-commit — the same two commands. */
83
+ /**
84
+ * Appended verbatim. Mirrors .claude/hooks/pre-commit — the same two checks.
85
+ *
86
+ * These deliberately run against the working tree (`.`), where the hook materialises the
87
+ * staged tree first (ADR-0018). It is not an oversight and must not be "fixed" into a
88
+ * copy of that machinery: the framework stashes unstaged changes before dispatching, so
89
+ * by the time these entries run the working tree already *is* the index. Reproducing the
90
+ * archive dance here would duplicate what the framework provides — which is the whole
91
+ * reason ADR-0008 chose to compose with it rather than fight it for the file.
92
+ */
65
93
  const FRAMEWORK_BLOCK = `
66
94
  # Doc invariants (stele:ADR-0003, composed by /init-method per stele:ADR-0008).
67
95
  # Zero-dependency and language: system, so there is nothing to install but node.
@@ -79,6 +107,12 @@ const FRAMEWORK_BLOCK = `
79
107
  language: system
80
108
  pass_filenames: false
81
109
  always_run: true
110
+ - id: stele-immutable
111
+ name: immutable documents only grow
112
+ entry: node scripts/check-immutable.mjs
113
+ language: system
114
+ pass_filenames: false
115
+ always_run: true
82
116
  `;
83
117
 
84
118
  const read = (path) => readFileSync(path, 'utf8');
@@ -122,33 +156,118 @@ function vendor({ target, toolkit, apply, report }) {
122
156
  }
123
157
 
124
158
  /**
125
- * Slash commands, which are prose and therefore adaptable (ADR-0007).
159
+ * The recorded digests, or `{}` when there is no usable record.
160
+ *
161
+ * A record we cannot read is reported and then treated as absent. That is the safe
162
+ * direction and not a silenced error: every command classifies as `unknown`, so nothing is
163
+ * overwritten and the operator sees why (ADR-0023).
164
+ */
165
+ export function readProvenance(target, report) {
166
+ const path = join(target, PROVENANCE);
167
+ if (!existsSync(path)) return {};
168
+
169
+ let parsed;
170
+ try {
171
+ parsed = JSON.parse(read(path));
172
+ } catch (error) {
173
+ report('problem', path, `unreadable (${error.message}) — treating every command as unreconciled, so none will be overwritten. Delete it to start a fresh record.`);
174
+ return {};
175
+ }
176
+
177
+ const commands = parsed?.commands;
178
+ if (parsed?.version !== PROVENANCE_VERSION || typeof commands !== 'object' || commands === null) {
179
+ report('problem', path, `unrecognised shape (expected version ${PROVENANCE_VERSION}) — treating every command as unreconciled, so none will be overwritten.`);
180
+ return {};
181
+ }
182
+ return commands;
183
+ }
184
+
185
+ function writeProvenance(target, commands) {
186
+ const path = join(target, PROVENANCE);
187
+ const ordered = Object.fromEntries(Object.entries(commands).sort(([a], [b]) => a.localeCompare(b)));
188
+ mkdirSync(dirname(path), { recursive: true });
189
+ writeFileSync(path, `${JSON.stringify({ version: PROVENANCE_VERSION, commands: ordered }, null, 2)}\n`);
190
+ }
191
+
192
+ /**
193
+ * What an installed command is, relative to the toolkit and to what we last handed over.
194
+ *
195
+ * `stale` and `adapted` are the two states the old boolean could not tell apart, and the
196
+ * whole of ADR-0023 is the ability to name them separately. `unknown` is a differing file
197
+ * with no record — an install predating the record — which is kept, because assuming
198
+ * permission to overwrite is exactly the incident.
199
+ *
200
+ * @returns {'absent'|'current'|'stale'|'adapted'|'unknown'}
201
+ */
202
+ export function classifyCommand({ targetText, toolkitText, recordedDigest }) {
203
+ if (targetText === null) return 'absent';
204
+ if (targetText === toolkitText) return 'current';
205
+ if (recordedDigest === undefined) return 'unknown';
206
+ return digest(targetText) === recordedDigest ? 'stale' : 'adapted';
207
+ }
208
+
209
+ const KEPT_REASON = {
210
+ stale: 'behind the toolkit but unmodified here — `--update` takes the new version',
211
+ adapted: 'adapted locally — kept; `--update --force` discards the adaptation',
212
+ unknown: 'differs from the toolkit and predates the vendoring record, so it cannot be told from a local adaptation — kept. Reconcile it once by hand, or `--update --force` to take the toolkit version',
213
+ };
214
+
215
+ /**
216
+ * Slash commands, which are prose and therefore adaptable (ADR-0023).
126
217
  *
127
218
  * Copy-if-absent, unlike vendor(): a repo that has tailored `/slice` to its own workflow
128
- * must not have that overwritten by an install. `--update` is the explicit way to take
129
- * the toolkit's version back.
219
+ * must not have that overwritten. `--update` additionally refreshes anything the repo has
220
+ * not touched, and only `--force` discards an adaptation (ADR-0023).
130
221
  */
131
- function vendorCommands({ target, toolkit, apply, force, report }) {
222
+ function vendorCommands({ target, toolkit, apply, update, force, report }) {
223
+ const recorded = readProvenance(target, report);
224
+ const learned = { ...recorded };
225
+ let changed = false;
226
+
132
227
  for (const path of commandFiles(toolkit)) {
133
228
  const to = join(target, path);
134
- const from = join(toolkit, path);
135
- if (matches(to, from)) {
229
+ const toolkitText = read(join(toolkit, path));
230
+ const state = classifyCommand({
231
+ targetText: existsSync(to) ? read(to) : null,
232
+ toolkitText,
233
+ recordedDigest: recorded[path],
234
+ });
235
+
236
+ // An up-to-date command is how an install predating the record acquires one: its bytes
237
+ // ARE the toolkit's, so the digest is known without having written anything.
238
+ if (state === 'current') {
136
239
  report('ok', to, 'current');
240
+ if (recorded[path] !== digest(toolkitText)) {
241
+ learned[path] = digest(toolkitText);
242
+ changed = true;
243
+ }
137
244
  continue;
138
245
  }
139
- if (existsSync(to) && !force) {
140
- report('keep', to, 'differs from the toolkit left as it is; `--update` takes the toolkit version');
246
+
247
+ const takeover = state === 'absent' || (update && (state === 'stale' || force));
248
+ if (!takeover) {
249
+ report('keep', to, KEPT_REASON[state]);
141
250
  continue;
142
251
  }
143
- const verb = existsSync(to) ? 'overwrite' : 'copy';
252
+
253
+ const verb = state === 'absent' ? 'copy' : 'overwrite';
144
254
  if (!apply) {
145
255
  report('would', to, `${verb} from toolkit`);
146
256
  continue;
147
257
  }
148
258
  mkdirSync(dirname(to), { recursive: true });
149
- copyFileSync(from, to);
259
+ writeFileSync(to, toolkitText);
260
+ learned[path] = digest(toolkitText);
261
+ changed = true;
150
262
  report('wrote', to, `${verb === 'copy' ? 'copied' : 'overwritten'} from toolkit`);
151
263
  }
264
+
265
+ // Never on a dry run: the record describes what is on disk, and writing it while writing
266
+ // nothing else would claim we handed over files we did not.
267
+ if (apply && changed) {
268
+ writeProvenance(target, learned);
269
+ report('wrote', join(target, PROVENANCE), 'recorded what was vendored, so a later --update can tell a stale command from an adapted one');
270
+ }
152
271
  }
153
272
 
154
273
  function scaffold({ target, toolkit, apply, report }) {
@@ -256,6 +375,28 @@ function frameworkInstalled({ target, report }) {
256
375
  else report('problem', hook, 'the pre-commit framework is configured but never installed — no hook runs at all, including its own. Run `pre-commit install`.');
257
376
  }
258
377
 
378
+ /**
379
+ * Reports a corpus the freshly vendored linter calls red.
380
+ *
381
+ * `--apply` refuses to *install* a hook on a red corpus, because a hook that blocks every
382
+ * commit is the tool bricking the repo it protects. `--update` reaches the same state by
383
+ * the other door and had no equivalent guard: a release that adds an error-severity rule
384
+ * lands a stricter linter behind a hook that is already live, and the next commit fails
385
+ * with no hint that an update caused it.
386
+ *
387
+ * It reports rather than refuses. By the time the copy is written the old linter is gone,
388
+ * so there is nothing to decline into — and rolling back would leave the repo on machinery
389
+ * the operator explicitly asked to replace. Loud and accurate beats a silent half-update.
390
+ */
391
+ function lintAfterUpdate({ target, report }) {
392
+ const errors = lintErrors(target);
393
+ if (errors.length === 0) {
394
+ return report('ok', target, 'corpus still clean under the updated linter');
395
+ }
396
+ for (const f of errors) report('problem', f.path, f.message);
397
+ report('problem', target, `${errors.length} lint error(s) under the updated linter — the hook is live, so every commit is blocked until these are fixed. \`git commit --no-verify\` is the escape hatch while you do.`);
398
+ }
399
+
259
400
  function check({ target, toolkit, report }) {
260
401
  for (const [dest, src] of VENDORED) {
261
402
  const to = join(target, dest);
@@ -264,12 +405,22 @@ function check({ target, toolkit, report }) {
264
405
  else report('ok', to, 'current');
265
406
  }
266
407
 
267
- // Commands are prose a repo may legitimately adapt, so their drift is informational
268
- // (ADR-0007) reported so it is visible, never counted against a clean check.
408
+ // An *adaptation* is prose a repo may legitimately own, so it stays informational. A
409
+ // command merely behind and unmodified is a repo missing a fix, which is a problem — the
410
+ // record is what lets --check tell those two apart at all (ADR-0023, superseding ADR-0007,
411
+ // under which no command difference could be counted and so a shipped defect in one was
412
+ // invisible in every installed repo).
413
+ const recorded = readProvenance(target, report);
269
414
  for (const path of commandFiles(toolkit)) {
270
415
  const to = join(target, path);
271
- if (!existsSync(to)) report('missing', to, 'not installed — run /init-method --apply');
272
- else if (!matches(to, join(toolkit, path))) report('local', to, 'differs from the toolkit — kept; `--update` takes the toolkit version');
416
+ const state = classifyCommand({
417
+ targetText: existsSync(to) ? read(to) : null,
418
+ toolkitText: read(join(toolkit, path)),
419
+ recordedDigest: recorded[path],
420
+ });
421
+ if (state === 'absent') report('missing', to, 'not installed — run /init-method --apply');
422
+ else if (state === 'stale') report('problem', to, 'behind the toolkit and unmodified here — run /init-method --update');
423
+ else if (state !== 'current') report('local', to, KEPT_REASON[state]);
273
424
  else report('ok', to, 'current');
274
425
  }
275
426
 
@@ -302,9 +453,10 @@ function check({ target, toolkit, report }) {
302
453
  * @param {string} [options.toolkit] this kit's root (overridable for tests)
303
454
  * @param {'install'|'check'|'update'} [options.mode]
304
455
  * @param {boolean} [options.apply] false = dry run, the default
456
+ * @param {boolean} [options.force] update only: discard local command adaptations too
305
457
  * @returns {{actions: Array<{status: string, path: string, message: string}>, problems: number}}
306
458
  */
307
- export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false }) {
459
+ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply = false, force = false }) {
308
460
  const actions = [];
309
461
  const report = (status, path, message) => actions.push({ status, path, message });
310
462
 
@@ -317,11 +469,12 @@ export function initMethod({ target, toolkit = TOOLKIT, mode = 'install', apply
317
469
  check({ target, toolkit, report });
318
470
  } else if (mode === 'update') {
319
471
  vendor({ target, toolkit, apply, report });
320
- vendorCommands({ target, toolkit, apply, force: true, report });
472
+ vendorCommands({ target, toolkit, apply, update: true, force, report });
473
+ if (apply) lintAfterUpdate({ target, report });
321
474
  } else {
322
475
  scaffold({ target, toolkit, apply, report });
323
476
  vendor({ target, toolkit, apply, report });
324
- vendorCommands({ target, toolkit, apply, force: false, report });
477
+ vendorCommands({ target, toolkit, apply, update: false, force: false, report });
325
478
  buildIndex({ target, apply, report });
326
479
  installHook({ target, apply, report });
327
480
  }
@@ -335,8 +488,14 @@ function main(argv) {
335
488
  const target = positional[0] ?? process.cwd();
336
489
  const mode = flags.has('--check') ? 'check' : flags.has('--update') ? 'update' : 'install';
337
490
  const apply = flags.has('--apply');
491
+ const force = flags.has('--force');
492
+
493
+ if (force && mode !== 'update') {
494
+ console.error('--force only means anything with --update: it discards local command adaptations.');
495
+ return 1;
496
+ }
338
497
 
339
- const { actions, problems } = initMethod({ target, mode, apply });
498
+ const { actions, problems } = initMethod({ target, mode, apply, force });
340
499
 
341
500
  console.log(`\n${target} — /init-method ${mode}${apply || mode === 'check' ? '' : ' (dry run)'}`);
342
501
  for (const a of actions) {