@sabaiway/agent-workflow-memory 4.2.0 → 4.4.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.
@@ -12,6 +12,7 @@ import { spawnSync } from 'node:child_process';
12
12
  import {
13
13
  LEGACY_FORMS,
14
14
  UNIT_TESTS_COVERAGE_FLAGS,
15
+ KNOWN_COVERAGE_FLAG_SETS,
15
16
  COVERAGE_PRODUCER_BODY,
16
17
  RETIRED_STORE_BASENAMES,
17
18
  findRetiredStores,
@@ -21,8 +22,17 @@ import {
21
22
  main,
22
23
  } from './migrate-gates.mjs';
23
24
 
25
+ // An INSTALLED kit tools dir carries both core checks as real files. That is a fixture
26
+ // requirement, not decoration: canonicity is a realpath anchor, so a core check whose file is not
27
+ // there resolves to nothing and is no claim at all — the same fail-closed answer run-gates gives.
24
28
  const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-kit-'));
25
29
  writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
30
+ writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
31
+ // The project root the plan builder resolves declared RELATIVE tokens against — the same anchor
32
+ // run-gates uses. Empty on purpose for the pure-plan cases: a relative lookalike resolves to
33
+ // nothing there, which is exactly the "no claim can be made" outcome those rows assert. The
34
+ // vendored rows below build their own project and pass it explicitly.
35
+ const PROJECT = mkdtempSync(join(tmpdir(), 'migrate-gates-project-'));
26
36
 
27
37
  const mkProject = (gates) => {
28
38
  const root = mkdtempSync(join(tmpdir(), 'migrate-gates-'));
@@ -37,6 +47,21 @@ const quiet = () => {
37
47
  return { log: (l) => out.push(String(l)), error: (l) => err.push(String(l)), out, err };
38
48
  };
39
49
 
50
+ // The FIRST flag set the kit ever emitted, frozen here as literal bytes — never read back out of
51
+ // KNOWN_COVERAGE_FLAG_SETS. Deployed declarations on disk carry exactly these bytes, so the
52
+ // append-only promise needs a checker that goes red if they are edited or dropped; deriving the
53
+ // "prior" from the set under test would keep this green while real deployments broke.
54
+ const PRIOR_FLAG_SET_V1 =
55
+ '--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
56
+
57
+ // A VENDORED deployment: the SAME tools, from a copy `--kit-tools` does not name. Recognition is a
58
+ // realpath anchor, so these are real files — a fake path would be the unresolvable case instead.
59
+ const VENDORED_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-vendored-'));
60
+ writeFileSync(join(VENDORED_TOOLS, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
61
+ writeFileSync(join(VENDORED_TOOLS, 'review-state.mjs'), '// a vendored copy of the review-state check\n');
62
+ const vendoredCmd = (name) => `node "${join(VENDORED_TOOLS, `${name}.mjs`)}" --check`;
63
+ const installedCmd = (name) => `node "${join(KIT_TOOLS, `${name}.mjs`)}" --check`;
64
+
40
65
  const LEGACY_LEDGER = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
41
66
  const LEGACY_FOLD = { id: 'fold-completeness', title: 'F', cmd: 'node /kit/tools/fold-completeness.mjs --check' };
42
67
  const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
@@ -45,12 +70,12 @@ const CUSTOM = { id: 'my-ledger-wrap', title: 'C', cmd: 'node scripts/wrap.mjs &
45
70
  describe('migrate-gates — the pure migration plan', () => {
46
71
  it('matches BOTH documented legacy forms (quoted and bare paths) and removes them', () => {
47
72
  for (const form of LEGACY_FORMS) assert.ok(form.re instanceof RegExp);
48
- const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS);
73
+ const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS, PROJECT);
49
74
  assert.deepEqual(plan.filter((r) => r.action === 'remove').map((r) => r.entry.id), ['review-ledger', 'fold-completeness']);
50
75
  });
51
76
 
52
77
  it('extends the canonical unit-tests cmd with the lcov reporters (flags inserted after `node --test`)', () => {
53
- const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS);
78
+ const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
54
79
  assert.ok(unitTestsExtended);
55
80
  const extended = plan.find((r) => r.action === 'extend').entry;
56
81
  assert.equal(extended.cmd, `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs`);
@@ -58,23 +83,60 @@ describe('migrate-gates — the pure migration plan', () => {
58
83
 
59
84
  it('an already-extended unit-tests cmd is left alone (idempotent)', () => {
60
85
  const done = { id: 'unit-tests', title: 'U', cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs` };
61
- const { plan } = buildMigrationPlan([done], KIT_TOOLS);
86
+ const { plan } = buildMigrationPlan([done], KIT_TOOLS, PROJECT);
62
87
  assert.equal(plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
63
88
  });
64
89
 
90
+ it('a declaration carrying a PRIOR emitted flag set reads as already-configured — keep, zero diff, no warning', () => {
91
+ // The canonical flag set moved (the destination became a required-parameter expansion). A
92
+ // deployment written by the earlier kit must not suddenly read as customized: that would send
93
+ // the maintainer to hand-fix a gate which already produces the lcov the checker reads.
94
+ assert.notEqual(PRIOR_FLAG_SET_V1, UNIT_TESTS_COVERAGE_FLAGS, 'the v1 bytes are a form the kit no longer emits');
95
+ assert.ok(KNOWN_COVERAGE_FLAG_SETS.includes(PRIOR_FLAG_SET_V1), 'and the append-only set still carries them');
96
+ const deployed = [
97
+ { id: 'unit-tests', title: 'U', cmd: `node --test ${PRIOR_FLAG_SET_V1} tools/*.test.mjs` },
98
+ { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` },
99
+ { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` },
100
+ ];
101
+ const analysis = buildMigrationPlan(deployed, KIT_TOOLS, PROJECT);
102
+ assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
103
+ assert.deepEqual(analysis.customized, [], 'a prior emitted form is never reported customized');
104
+ assert.equal(analysis.hasProducer, true, 'it still counts as the producer the checker reads');
105
+ assert.equal(analysis.checkerInert, false);
106
+ assert.equal(analysis.finalCapable, true);
107
+ assert.deepEqual(resultingGates(analysis.plan), deployed, 'zero diff — nothing is rewritten');
108
+ const preview = formatPreview(analysis, 'APPLY');
109
+ assert.match(preview, /nothing to migrate/, 'the preview says there is nothing to do');
110
+ assert.doesNotMatch(preview, /CUSTOMIZED|INERT|WARNING/, 'and warns about nothing');
111
+ });
112
+
113
+ it('a unit-tests entry that merely CONTAINS the prior bytes is CUSTOMIZED, never a silent keep', () => {
114
+ // The already-configured decision runs through the closed producer predicate. A substring probe
115
+ // would call `echo <prior flags>` already configured and say nothing, leaving the maintainer
116
+ // with an entry the tool cannot verify and no recovery line.
117
+ const nearMiss = { id: 'unit-tests', title: 'U', cmd: `echo ${PRIOR_FLAG_SET_V1}` };
118
+ const analysis = buildMigrationPlan([nearMiss], KIT_TOOLS, PROJECT);
119
+ assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep', 'nothing is rewritten');
120
+ assert.deepEqual(analysis.customized.map((g) => g.id), ['unit-tests'], 'but it IS reported customized');
121
+ assert.equal(analysis.hasProducer, false, 'and it never counts as the producer the checker would read');
122
+ const preview = formatPreview(analysis, 'APPLY');
123
+ assert.match(preview, /CUSTOMIZED \(untouched\): unit-tests/);
124
+ assert.match(preview, /declare the canonical suite gate by hand/, 'the paste-ready recovery rides along');
125
+ });
126
+
65
127
  it('adds the coverage-check gate LAST with the RESOLVED quoted path; never a second one', () => {
66
- const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS);
128
+ const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
67
129
  const result = resultingGates(plan);
68
130
  const last = result[result.length - 1];
69
131
  assert.equal(last.id, 'coverage-check');
70
132
  assert.equal(last.cmd, `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check`);
71
- const again = buildMigrationPlan(result, KIT_TOOLS);
133
+ const again = buildMigrationPlan(result, KIT_TOOLS, PROJECT);
72
134
  assert.ok(!again.plan.some((r) => r.action === 'add'), 'a declaration already carrying the checker gains no duplicate');
73
135
  });
74
136
 
75
137
  it('a declaration with NO producer never GAINS the checker — the pair is declared together or not at all', () => {
76
138
  const npmSuite = { id: 'suite', title: 'S', cmd: 'npm test' };
77
- const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS);
139
+ const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS, PROJECT);
78
140
  assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'no checker is added over a declaration that produces no lcov');
79
141
  assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['suite'], 'the legacy entry still goes, nothing dead arrives');
80
142
  assert.equal(analysis.finalCapable, false, 'a declaration with no checker is not final-run-capable');
@@ -86,7 +148,7 @@ describe('migrate-gates — the pure migration plan', () => {
86
148
  it('an ALREADY-declared checker over no producer is reported INERT, is never removed, and is not final-run-capable', () => {
87
149
  const checker = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
88
150
  const reviewState = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
89
- const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS);
151
+ const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS, PROJECT);
90
152
  assert.equal(analysis.finalCapable, false, 'a review-state present must NOT make an inert pair read as final-run-capable');
91
153
  assert.ok(resultingGates(analysis.plan).some((g) => g.id === 'coverage-check'), 'the declared checker is never removed');
92
154
  const preview = formatPreview(analysis, 'APPLY');
@@ -101,7 +163,7 @@ describe('migrate-gates — the pure migration plan', () => {
101
163
  title: 'T',
102
164
  cmd: `COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ${COVERAGE_PRODUCER_BODY}`,
103
165
  };
104
- const analysis = buildMigrationPlan([offered], KIT_TOOLS);
166
+ const analysis = buildMigrationPlan([offered], KIT_TOOLS, PROJECT);
105
167
  assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['test', 'coverage-check']);
106
168
  // The `no canonical unit-tests entry` advice is keyed on the ID, but a producer is recognized
107
169
  // under ANY id — repeating the advice over a working producer sends the user to fix nothing.
@@ -109,7 +171,7 @@ describe('migrate-gates — the pure migration plan', () => {
109
171
  });
110
172
 
111
173
  it('a CUSTOMIZED dead-tool reference (compound form) is kept untouched and reported', () => {
112
- const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS);
174
+ const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS, PROJECT);
113
175
  assert.equal(analysis.plan.find((r) => r.entry.id === 'my-ledger-wrap').action, 'keep');
114
176
  assert.deepEqual(analysis.customized.map((g) => g.id), ['my-ledger-wrap']);
115
177
  const preview = formatPreview(analysis, 'APPLY');
@@ -121,7 +183,7 @@ describe('migrate-gates — the pure migration plan', () => {
121
183
  describe('migrate-gates — the canonical anchor + final-capability validation (round-1 folds)', () => {
122
184
  it('a canonical checker NOT in the last position is MOVED last (never left mid-list)', () => {
123
185
  const canonical = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
124
- const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS);
186
+ const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS, PROJECT);
125
187
  const result = resultingGates(plan);
126
188
  assert.equal(result[result.length - 1].id, 'coverage-check', 'the canonical checker ends up LAST');
127
189
  assert.ok(plan.some((r) => r.action === 'move' && r.entry.id === 'coverage-check'), 'the reorder is an explicit move action');
@@ -130,14 +192,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
130
192
 
131
193
  it('a LOOKALIKE checker cmd is CUSTOMIZED (never counted canonical) and the canonical one is still added', () => {
132
194
  const lookalike = { id: 'cov', title: 'C', cmd: 'node scripts/coverage-check.mjs --check' };
133
- const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS);
195
+ const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS, PROJECT);
134
196
  assert.ok(analysis.customized.some((g) => g.id === 'cov'), 'the lookalike is reported customized');
135
197
  const result = resultingGates(analysis.plan);
136
198
  assert.equal(result[result.length - 1].id, 'coverage-check', 'the REAL canonical checker is added last');
137
199
  });
138
200
 
139
201
  it('the result is judged final-capable ONLY with a canonical review-state present; missing → a LOUD warning with the candidate line, never "final-run-capable"', () => {
140
- const analysis = buildMigrationPlan([UNIT], KIT_TOOLS);
202
+ const analysis = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
141
203
  assert.equal(analysis.finalCapable, false, 'no review-state → not final-capable');
142
204
  const preview = formatPreview(analysis, 'APPLY');
143
205
  assert.match(preview, /review-state/, 'the warning names the missing core check');
@@ -146,13 +208,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
146
208
  const withRs = buildMigrationPlan(
147
209
  [UNIT, { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` }],
148
210
  KIT_TOOLS,
211
+ PROJECT,
149
212
  );
150
213
  assert.equal(withRs.finalCapable, true);
151
214
  });
152
215
 
153
216
  it('a NON-canonical unit-tests cmd (npm test / wrapper) is CUSTOMIZED with the full flag set as the recovery', () => {
154
217
  const npmTest = { id: 'unit-tests', title: 'U', cmd: 'npm test' };
155
- const analysis = buildMigrationPlan([npmTest], KIT_TOOLS);
218
+ const analysis = buildMigrationPlan([npmTest], KIT_TOOLS, PROJECT);
156
219
  assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
157
220
  assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'a non-canonical suite cmd is customized');
158
221
  const preview = formatPreview(analysis, 'APPLY');
@@ -161,7 +224,7 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
161
224
 
162
225
  it('a PARTIALLY-flagged unit-tests cmd is CUSTOMIZED (a lone coverage flag never reads as configured)', () => {
163
226
  const partial = { id: 'unit-tests', title: 'U', cmd: 'node --test --experimental-test-coverage tools/*.test.mjs' };
164
- const analysis = buildMigrationPlan([partial], KIT_TOOLS);
227
+ const analysis = buildMigrationPlan([partial], KIT_TOOLS, PROJECT);
165
228
  assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
166
229
  assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'the half-wired cmd is customized, never silently left');
167
230
  });
@@ -203,6 +266,135 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
203
266
  });
204
267
  });
205
268
 
269
+ describe('migrate-gates — a VENDORED core check is the tool, from a copy --kit-tools does not name (D6)', () => {
270
+ const UNIT_DONE = { id: 'unit-tests', title: 'U', cmd: `${COVERAGE_PRODUCER_BODY} tools/*.test.mjs` };
271
+ const INSTALLED_REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: installedCmd('review-state') };
272
+
273
+ it('a vendored coverage-check is PRESERVED exactly as declared — declared, never added over, never a collision', () => {
274
+ // Before the split this entry was a LOOKALIKE holding the checker's id, which made the whole
275
+ // upgrade a hard STOP: every preview and every apply over a vendored deployment failed.
276
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
277
+ const declaration = [UNIT_DONE, INSTALLED_REVIEW_STATE, vendored];
278
+ const analysis = buildMigrationPlan(declaration, KIT_TOOLS, PROJECT);
279
+ assert.equal(analysis.collision, null, 'a vendored copy carries the tool\'s own id legitimately — it is no squatter');
280
+ assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'the checker IS declared, so a second one is never added');
281
+ assert.deepEqual(analysis.plan.map((r) => r.action), ['keep', 'keep', 'keep'], 'nothing is rewritten or reordered');
282
+ assert.deepEqual(resultingGates(analysis.plan), declaration, 'the declaration comes out byte-for-byte as it went in');
283
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.name), ['coverage-check']);
284
+ assert.deepEqual(analysis.customized, [], 'a real copy of the tool is not an entry the tool cannot verify');
285
+ assert.equal(analysis.finalCapable, false, '--final anchors on the INSTALLED copy, so the capability claim is withheld');
286
+ const preview = formatPreview(analysis, 'APPLY');
287
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/, 'the outcome is NAMED');
288
+ assert.match(preview, /DIFFERENT copy of the tool/, 'and says what it actually found');
289
+ assert.match(preview, /NOT final-run-capable/);
290
+ assert.doesNotMatch(preview, /already final-run-capable/);
291
+ });
292
+
293
+ it('a vendored review-state is preserved too — and is never advised to "add it" on top of itself', () => {
294
+ const vendored = { id: 'review-state', title: 'RS', cmd: vendoredCmd('review-state') };
295
+ const analysis = buildMigrationPlan([UNIT_DONE, vendored], KIT_TOOLS, PROJECT);
296
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.name), ['review-state']);
297
+ assert.equal(analysis.hasReviewState, false, 'the INSTALLED review-state is still not declared');
298
+ assert.equal(analysis.finalCapable, false);
299
+ const preview = formatPreview(analysis, 'APPLY');
300
+ assert.match(preview, /VERIFY \(preserved exactly as declared\): review-state/);
301
+ assert.doesNotMatch(preview, /Add it \(paste-ready\)/, 'a second review-state entry is the ambiguity, not the remedy');
302
+ });
303
+
304
+ it('a core check naming a path nothing resolves is NO claim — fail-closed exactly where --final is', () => {
305
+ // A lexical path compare called this canonical: the file need not exist to compare equal after
306
+ // resolve(). The migration then promised final-run-capability over a cmd --final cannot run.
307
+ const ghost = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'nowhere', 'review-state.mjs')}" --check` };
308
+ const analysis = buildMigrationPlan([UNIT_DONE, ghost], KIT_TOOLS, PROJECT);
309
+ assert.equal(analysis.hasReviewState, false, 'an unresolvable path is never the installed tool');
310
+ assert.deepEqual(analysis.externalCoreChecks, [], 'nor evidence that the tool lives somewhere else');
311
+ assert.ok(analysis.customized.some((g) => g.id === 'review-state'), 'it is reported as an entry the tool cannot verify');
312
+ assert.equal(analysis.finalCapable, false);
313
+ });
314
+
315
+ it('a vendored checker never produces the lcov it reads — the declared pair stays INERT', () => {
316
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check'), lcovProducer: true };
317
+ const analysis = buildMigrationPlan(
318
+ [{ id: 'lint', title: 'L', cmd: 'eslint .' }, INSTALLED_REVIEW_STATE, vendored],
319
+ KIT_TOOLS,
320
+ PROJECT,
321
+ );
322
+ assert.equal(analysis.hasProducer, false, 'a marker on the checker itself never self-pairs, whichever copy it is');
323
+ assert.equal(analysis.checkerInert, true, 'a checker over nothing that writes the lcov passes verifying nothing');
324
+ assert.match(formatPreview(analysis, 'APPLY'), /INERT/);
325
+ });
326
+
327
+ it('producer-after-vendored-checker: a producer declared AFTER a vendored checker never covers it', () => {
328
+ // A canonical checker is always MOVED last, so "a producer exists" answers "a producer runs
329
+ // first" for it. A vendored checker is left where the deployment put it, so the position-blind
330
+ // answer reported a live pair over a checker that reads the lcov before anything writes one.
331
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
332
+ const after = buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE], KIT_TOOLS, PROJECT);
333
+ assert.equal(after.hasProducer, true, 'a producer IS declared somewhere');
334
+ assert.equal(after.checkerInert, true, 'but not before the checker that reads what it writes');
335
+ assert.equal(after.finalCapable, false);
336
+ assert.match(formatPreview(after, 'APPLY'), /INERT/);
337
+
338
+ const before = buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT);
339
+ assert.equal(before.checkerInert, false, 'the same entries in producer-first order are a live pair');
340
+ assert.doesNotMatch(formatPreview(before, 'APPLY'), /INERT/);
341
+ });
342
+
343
+ it('the INERT warning names the edit the reader must actually make — order, not a missing gate', () => {
344
+ // The two ways to be inert need two sentences. Telling someone whose suite gate is already
345
+ // declared to declare it again sends them to fix nothing while the real defect stays.
346
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
347
+ const misordered = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE], KIT_TOOLS, PROJECT), 'APPLY');
348
+ assert.match(misordered, /runs AFTER this entry/, 'the ORDER is named as the defect, on the row it belongs to');
349
+ assert.match(misordered, /a checker belongs LAST, after its producer/, 'and the remedy is the reorder');
350
+ assert.doesNotMatch(misordered, /declare the suite gate/, 'never advise declaring a gate that is already there');
351
+
352
+ const absent = formatPreview(buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
353
+ assert.match(absent, /no declared gate PRODUCES the lcov/, 'with nothing producing, the old sentence still holds');
354
+ assert.match(absent, /declare the suite gate/);
355
+ });
356
+
357
+ it('a mixed declaration renders ONE edit, never a removal and a reorder at once', () => {
358
+ // The two folds met here: canonicalTwin asks for a removal, the inert arm asked for a reorder,
359
+ // and a preview carrying both leaves the reader with no unambiguous next step.
360
+ const canonical = { id: 'coverage-check', title: 'CC', cmd: installedCmd('coverage-check') };
361
+ const vendored = { id: 'coverage-check-vendor', title: 'CCV', cmd: vendoredCmd('coverage-check') };
362
+ const analysis = buildMigrationPlan([INSTALLED_REVIEW_STATE, vendored, UNIT_DONE, canonical], KIT_TOOLS, PROJECT);
363
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => [c.canonicalTwin, c.inert]), [[true, true]]);
364
+ assert.equal(analysis.canonicalCheckerInert, false, 'the canonical checker ends up last, after the producer');
365
+ const preview = formatPreview(analysis, 'APPLY');
366
+ assert.match(preview, /Remove THIS entry by hand/);
367
+ assert.match(preview, /the ONE edit that resolves both/);
368
+ assert.doesNotMatch(preview, /belongs LAST, after its producer/, 'no second, contradictory edit');
369
+ assert.doesNotMatch(preview, /declare the suite gate/);
370
+ });
371
+
372
+ it('a canonical checker declared BESIDE the vendored one changes the recovery — remove, never repoint', () => {
373
+ // Repointing the vendored cmd at the installed copy would leave TWO canonical checkers, and
374
+ // --final accepts exactly one; a recovery that cannot converge is worse than none.
375
+ const canonical = { id: 'coverage-check', title: 'CC', cmd: installedCmd('coverage-check') };
376
+ const vendored = { id: 'coverage-check-vendor', title: 'CCV', cmd: vendoredCmd('coverage-check') };
377
+ const analysis = buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored, canonical], KIT_TOOLS, PROJECT);
378
+ assert.deepEqual(analysis.externalCoreChecks.map((c) => c.canonicalTwin), [true]);
379
+ const preview = formatPreview(analysis, 'APPLY');
380
+ assert.match(preview, /accepts exactly ONE canonical check/);
381
+ assert.match(preview, /Remove THIS entry by hand/);
382
+ assert.doesNotMatch(preview, /either repoint the cmd/, 'the non-convergent recovery is not offered here');
383
+ });
384
+
385
+ it('a vendored deployment carries the SAME commit-guard consequence a customized one does', () => {
386
+ // Both end in a declaration --final refuses, which mints no receipt, which makes the guard
387
+ // refuse every commit. Naming the consequence for one and not the other is a false asymmetry.
388
+ const vendored = { id: 'coverage-check', title: 'CC', cmd: vendoredCmd('coverage-check') };
389
+ const preview = formatPreview(buildMigrationPlan([UNIT_DONE, INSTALLED_REVIEW_STATE, vendored], KIT_TOOLS, PROJECT), 'APPLY');
390
+ assert.match(preview, /do NOT install the commit guard/);
391
+ });
392
+
393
+ it('the plan builder REFUSES without the project root — a relative cmd cannot be resolved without it', () => {
394
+ assert.throws(() => buildMigrationPlan([UNIT_DONE], KIT_TOOLS), /project root/);
395
+ });
396
+ });
397
+
206
398
  describe('migrate-gates — preview writes NOTHING; apply is atomic and complete', () => {
207
399
  it('the dry-run default leaves gates.json byte-identical and prints the plan + the apply hint', () => {
208
400
  const root = mkProject([LEGACY_LEDGER, UNIT]);
@@ -1,4 +1,4 @@
1
1
  {
2
- "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd }: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell). This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
2
+ "_README": "Per-project gate declaration: the ordered list of verification commands (tests, validators, scanners, docs checks) that must be green before a commit. Run them all in one batch with the family gate runner (the composition root's `gates` command); re-run one with `--only <id>`. Each entry is { id, title, cmd } plus ONE optional key: `id` = a unique kebab-case handle, `title` = a short human label, `cmd` = ONE bash command line — gates are spawned via bash (brace/glob expansion works; a host without bash gets a loud preflight error, never a silent reinterpretation under another shell) — and `lcovProducer` (boolean, optional) DECLARES that this gate writes the lcov the coverage checker reads, for a suite the kit's closed `node --test` recognition cannot read on its own. Only the literal true claims it, and it widens what the declaration may CLAIM, never what a run CERTIFIES: a marked gate that produces no lcov still ends `skipped-no-lcov` at run time. This file declares WHAT to check, never who executes it — the schema has no lane/model/routing fields and rejects unknown keys loudly. Trust posture: the runner executes this project's OWN declared commands with the caller's privileges — a batching convenience over commands the project already runs by hand, not a sandbox. Strict JSON — no comments.",
3
3
  "gates": []
4
4
  }