@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.
- package/.claude/commands/adr.md +5 -4
- package/.claude/commands/init-method.md +27 -8
- package/.claude/commands/remember.md +4 -4
- package/.claude/commands/slice.md +5 -5
- package/.claude/commands/wrap-up.md +41 -4
- package/.claude/hooks/pre-commit +52 -2
- package/README.md +77 -11
- package/package.json +3 -1
- package/scripts/check-immutable.mjs +145 -0
- package/scripts/check-mutants.mjs +262 -0
- package/scripts/init-method.mjs +180 -21
- package/scripts/lint-docs.mjs +201 -11
- package/templates/CLAUDE.md +16 -2
|
@@ -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
|
+
}
|
package/scripts/init-method.mjs
CHANGED
|
@@ -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-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
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
|
|
129
|
-
*
|
|
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
|
|
135
|
-
|
|
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
|
-
|
|
140
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
268
|
-
//
|
|
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
|
-
|
|
272
|
-
|
|
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,
|
|
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) {
|