@sabaiway/agent-workflow-memory 4.1.0 → 4.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/CHANGELOG.md +57 -0
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/scripts/migrate-gates-branches.test.mjs +146 -1
- package/references/scripts/migrate-gates.mjs +381 -54
- package/references/scripts/migrate-gates.test.mjs +240 -11
- package/references/templates/gates.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,63 @@ All notable changes to the memory substrate. Versions are this **package's** npm
|
|
|
4
4
|
they are distinct from the **deployment-lineage** stamp written into a project's
|
|
5
5
|
`docs/ai/.memory-version` (which tracks the shared `agent-workflow` lineage, head `3.0.0`).
|
|
6
6
|
|
|
7
|
+
## 4.3.0 — the migration emits a destination that refuses by name, accepts the producer marker, and preserves a vendored core check (AD-092 + AD-093 + AD-094; ships with kit 5.7.0)
|
|
8
|
+
|
|
9
|
+
The `migrate-gates.mjs` canon moves in lockstep with the kit it is byte-twinned to. Three changes,
|
|
10
|
+
all in the drift-guarded shared block or the branches around it:
|
|
11
|
+
|
|
12
|
+
- **The emitted coverage destination becomes `"${AW_GIT_DIR:?exported by run-gates}/…lcov.info"`.**
|
|
13
|
+
Where the runner injects the variable, behaviour is byte-identical; where a human pastes the cmd
|
|
14
|
+
into a bare shell, bash refuses loudly by name — the old form expanded to empty and wrote the
|
|
15
|
+
lcov to the filesystem root. Recognition stays APPEND-ONLY: every previously emitted form is
|
|
16
|
+
still recognised, so an old-form declaration migrates as `keep` with zero diff.
|
|
17
|
+
- **The migration recognises the `lcovProducer` marker.** An optional gate-level boolean; only the
|
|
18
|
+
literal `true` is a producer claim, and producer-ness stays POSITIONAL — a marker on the coverage
|
|
19
|
+
checker itself never self-pairs. Strict schema validation of the key lives in the kit
|
|
20
|
+
(`gates-declaration.mjs`), which accepts it in lockstep with this release; the published 5.6.0
|
|
21
|
+
kit rejects a marker-carrying `gates.json` at exit 5 by design (forward-only; the kit's
|
|
22
|
+
Issue-016 note owns the cross-version statement).
|
|
23
|
+
- **A vendored copy of a core check is PRESERVED instead of stopping the upgrade.** The
|
|
24
|
+
three-outcome claim classifier (`canonical` / `tool-elsewhere` / `not-the-tool`) routes a
|
|
25
|
+
shape-matching copy at a non-canonical realpath into a `keep` row plus separately rendered
|
|
26
|
+
verify metadata — exit 0, nothing auto-added, a zero-diff apply, with the preview stating the
|
|
27
|
+
verification instead of
|
|
28
|
+
claiming final-run-capability the runner would refuse. A genuine id-squatter still hard-stops.
|
|
29
|
+
|
|
30
|
+
This package stays standalone — it imports nothing from the kit; the shared block is held equal by
|
|
31
|
+
the text drift guard, never by an import.
|
|
32
|
+
|
|
33
|
+
## 4.2.0 — the gates migration stops handing you a coverage checker with nothing to read (AD-089)
|
|
34
|
+
|
|
35
|
+
`migrate-gates.mjs` added the canonical `coverage-check` gate to any legacy declaration that lacked
|
|
36
|
+
one — whether or not anything in that declaration would ever WRITE the lcov the checker reads. The
|
|
37
|
+
result passes: the gate reports `skipped-no-lcov` and exits 0, so a migrated project came out with a
|
|
38
|
+
green matrix that certified nothing. This release stops the migration creating that pair, and makes
|
|
39
|
+
it say so when it finds one already there.
|
|
40
|
+
|
|
41
|
+
- **The checker is added only over a declaration that produces the lcov.** With no producer it is
|
|
42
|
+
WITHHELD, loudly, with the exact suite-gate line to declare first and an invitation to re-run the
|
|
43
|
+
migration afterwards.
|
|
44
|
+
- **An already-declared checker over no producer is reported as INERT** — same dead pair, made by an
|
|
45
|
+
earlier deployment rather than by this migration. Nothing is removed for you; the warning names
|
|
46
|
+
the remedy, and the result is no longer called final-run-capable.
|
|
47
|
+
- **`finalCapable` now means what it says.** It was computed from the review-state check alone, so a
|
|
48
|
+
withheld or inert checker still reported a final-run-capable result. The review-state warning is
|
|
49
|
+
now keyed on its own condition instead of riding that flag.
|
|
50
|
+
- **The "no canonical `unit-tests` entry" note stops firing over a working producer.** It was keyed
|
|
51
|
+
on the entry ID, but a producer is recognised under any id — the note was sending people to fix
|
|
52
|
+
something that was already correct.
|
|
53
|
+
- **The producer vocabulary is exported** (`COVERAGE_PRODUCER_BODY`, `matchesCoverageProducer`) as a
|
|
54
|
+
CLOSED set of the full command forms this family emits, never a substring probe. Its tail rule is
|
|
55
|
+
a positive path-shaped grammar rather than an operator blocklist, because
|
|
56
|
+
`node --test <flags> && rm -f <lcov>` runs the suite and then deletes the file. The scope is
|
|
57
|
+
stated in the source: recognising a producer means "configured with the reporters", never "the
|
|
58
|
+
lcov survives the command".
|
|
59
|
+
|
|
60
|
+
This package stays standalone — it imports nothing from the kit, and the kit imports nothing from
|
|
61
|
+
here. The kit carries its own byte-identical copy of the block above, held equal by a text drift
|
|
62
|
+
guard rather than by an import.
|
|
63
|
+
|
|
7
64
|
## 4.1.0 — the ADR rotation carries your inbound links with it (AD-087)
|
|
8
65
|
|
|
9
66
|
Rotating `decisions.md` used to be a link-breaking event: `archive-decisions.mjs` moved ADR
|
package/SKILL.md
CHANGED
|
@@ -3,7 +3,7 @@ name: agent-workflow-memory
|
|
|
3
3
|
description: Deploy or upgrade a portable AI-agent memory substrate in any project — an entry-point `AGENTS.md` (+ `CLAUDE.md` alias) and a structured `docs/ai/` context store with cap/archive/index enforcement. Use when the user wants to bootstrap `docs/ai/`, set up the Memory Map and session protocols, install the docs-rotation pre-commit hook, or run `/agent-workflow-memory` / `/agent-workflow-memory upgrade`. Triggers on "set up the memory system", "deploy the AI memory here", "bootstrap docs/ai", "upgrade the memory substrate". This is the substrate only — the workflow methodology (plan→execute→review, queue, Cleanup) is owned elsewhere and injected into AGENTS.md by the family composition root.
|
|
4
4
|
disable-model-invocation: true
|
|
5
5
|
metadata:
|
|
6
|
-
version: '4.
|
|
6
|
+
version: '4.3.0'
|
|
7
7
|
---
|
|
8
8
|
|
|
9
9
|
# agent-workflow-memory
|
package/capability.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@sabaiway/agent-workflow-memory",
|
|
3
|
-
"version": "4.
|
|
3
|
+
"version": "4.3.0",
|
|
4
4
|
"description": "Portable, cross-agent memory substrate for AI coding agents — an AGENTS.md entry point + docs/ai context with cap/archive/index enforcement, deployable standalone or as part of the agent-workflow family. The memory layer of the agent-workflow family.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"ai-agents",
|
|
@@ -10,10 +10,13 @@ import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstat
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import { spawnSync } from 'node:child_process';
|
|
13
|
-
import { UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, main } from './migrate-gates.mjs';
|
|
13
|
+
import { CHECKER_CLAIM, UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, checkerClaimTool, classifyCheckerClaim, main } from './migrate-gates.mjs';
|
|
14
14
|
|
|
15
|
+
// Both core checks exist as real files — canonicity is a realpath anchor, so a check whose file is
|
|
16
|
+
// absent resolves to nothing and is no claim at all (the fail-closed answer run-gates gives too).
|
|
15
17
|
const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-branches-kit-'));
|
|
16
18
|
writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
|
|
19
|
+
writeFileSync(join(KIT_TOOLS, 'review-state.mjs'), '// the installed review-state check\n');
|
|
17
20
|
|
|
18
21
|
const mkProject = (gates) => {
|
|
19
22
|
const root = mkdtempSync(join(tmpdir(), 'migrate-branches-'));
|
|
@@ -34,6 +37,8 @@ const CHECKER = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOL
|
|
|
34
37
|
const REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
|
|
35
38
|
const LEGACY = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
|
|
36
39
|
const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
|
|
40
|
+
// A suite the closed producer world cannot express, declaring itself with the optional marker.
|
|
41
|
+
const MARKED_SUITE = { id: 'suite', title: 'S', cmd: 'pnpm vitest run --coverage', lcovProducer: true };
|
|
37
42
|
|
|
38
43
|
describe('migrate-gates — refusal and no-op branches', () => {
|
|
39
44
|
it('--help prints the contract and exits 0', () => {
|
|
@@ -141,6 +146,146 @@ describe('migrate-gates — refusal and no-op branches', () => {
|
|
|
141
146
|
rmSync(root, { recursive: true, force: true });
|
|
142
147
|
});
|
|
143
148
|
|
|
149
|
+
it('a marker-carrying entry survives an apply UNCHANGED — the loader is lenient, the writer opaque', () => {
|
|
150
|
+
// The declaration this tool rewrites may carry keys it knows nothing about. The loader accepts
|
|
151
|
+
// any `{ gates: [...] }` shape and the writer re-serializes the ENTRY, not a reconstruction of
|
|
152
|
+
// it, so an upgrade over a marker-carrying deployment never silently drops the claim.
|
|
153
|
+
const root = mkProject([LEGACY, MARKED_SUITE, REVIEW_STATE]);
|
|
154
|
+
const io = quiet();
|
|
155
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 0, io.err.join('\n'));
|
|
156
|
+
const raw = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
|
|
157
|
+
const written = JSON.parse(raw).gates;
|
|
158
|
+
assert.deepEqual(written.map((g) => g.id), ['suite', 'review-state', 'coverage-check'], 'the checker is ADDED over a marker-claimed producer');
|
|
159
|
+
assert.deepEqual(written[0], MARKED_SUITE, 'the marked entry round-trips key for key');
|
|
160
|
+
assert.match(raw, /"lcovProducer": true/, 'and the marker is really in the written bytes');
|
|
161
|
+
assert.doesNotMatch(io.out.join('\n'), /WARNING/, 'nothing is withheld over a declared producer');
|
|
162
|
+
rmSync(root, { recursive: true, force: true });
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('a marker on the CHECKER ITSELF never self-pairs — the declared pair stays INERT', () => {
|
|
166
|
+
// The producer question is POSITIONAL: the checker always ends up last, so it can never be its
|
|
167
|
+
// own producer. Asking it over the whole kept set would let this declaration certify itself
|
|
168
|
+
// into final-run-capability with nothing writing the lcov.
|
|
169
|
+
const root = mkProject([{ id: 'lint', title: 'L', cmd: 'eslint .' }, REVIEW_STATE, { ...CHECKER, lcovProducer: true }]);
|
|
170
|
+
const io = quiet();
|
|
171
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
172
|
+
const text = io.out.join('\n');
|
|
173
|
+
assert.match(text, /INERT/, 'the dead pair is named');
|
|
174
|
+
assert.doesNotMatch(text, /already final-run-capable/, 'and never claimed capable');
|
|
175
|
+
rmSync(root, { recursive: true, force: true });
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it('a MARKED unit-tests entry is a zero-diff keep — never extended, never reported customized', () => {
|
|
179
|
+
// Both arms the marker settles at once: `npm test` is a cmd this tool cannot verify (customized
|
|
180
|
+
// without the marker), and rewriting a cmd whose owner declared it the producer would change
|
|
181
|
+
// bytes the byte-exact hook approval binds.
|
|
182
|
+
const marked = { id: 'unit-tests', title: 'U', cmd: 'npm test', lcovProducer: true };
|
|
183
|
+
const root = mkProject([marked, REVIEW_STATE]);
|
|
184
|
+
const io = quiet();
|
|
185
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
186
|
+
const text = io.out.join('\n');
|
|
187
|
+
assert.match(text, /ADD coverage-check/, 'the claimed producer unlocks the checker');
|
|
188
|
+
assert.doesNotMatch(text, /EXTEND unit-tests/, 'a claimed producer cmd is never rewritten');
|
|
189
|
+
assert.doesNotMatch(text, /CUSTOMIZED/, 'nor reported as a cmd the tool cannot verify');
|
|
190
|
+
rmSync(root, { recursive: true, force: true });
|
|
191
|
+
|
|
192
|
+
// The SAME entry unmarked is the customized/withheld path — the marker is what settles it.
|
|
193
|
+
const bare = mkProject([{ id: 'unit-tests', title: 'U', cmd: 'npm test' }, REVIEW_STATE]);
|
|
194
|
+
const io2 = quiet();
|
|
195
|
+
assert.equal(main(['--cwd', bare, '--kit-tools', KIT_TOOLS], io2), 0, io2.err.join('\n'));
|
|
196
|
+
const text2 = io2.out.join('\n');
|
|
197
|
+
assert.match(text2, /CUSTOMIZED/);
|
|
198
|
+
assert.doesNotMatch(text2, /ADD coverage-check/, 'the checker stays withheld with no producer');
|
|
199
|
+
rmSync(bare, { recursive: true, force: true });
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
it('a marker over an UNRUNNABLE cmd never unlocks the checker — the lenient loader has no validator', () => {
|
|
203
|
+
// This tool accepts any `{ gates: [...] }` shape, so an entry the strict validator would refuse
|
|
204
|
+
// reaches the plan builder intact. A marker on such an entry must not make the migration ADD the
|
|
205
|
+
// canonical checker: the result would be the dead pair the withhold exists to prevent, and the
|
|
206
|
+
// written declaration would then fail run-gates outright.
|
|
207
|
+
for (const cmd of [' ', 'echo a\nrm -rf b']) {
|
|
208
|
+
const root = mkProject([{ id: 'suite', title: 'S', cmd, lcovProducer: true }, REVIEW_STATE]);
|
|
209
|
+
const io = quiet();
|
|
210
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
211
|
+
const text = io.out.join('\n');
|
|
212
|
+
assert.doesNotMatch(text, /ADD coverage-check/, `an unrunnable cmd must not unlock the checker: ${JSON.stringify(cmd)}`);
|
|
213
|
+
assert.match(text, /WARNING: the canonical coverage-check gate was NOT added/, 'and the withhold is stated');
|
|
214
|
+
rmSync(root, { recursive: true, force: true });
|
|
215
|
+
}
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
it('a marker on a DUPLICATE canonical checker never produces for the other — nor claims capability', () => {
|
|
219
|
+
// `--final` accepts exactly ONE canonical checker, and a checker cannot write the lcov it reads.
|
|
220
|
+
// Excluding only the LAST checker row from the producer search let a marker on the first one pair
|
|
221
|
+
// with the second, and the preview then called the result final-run-capable over a declaration
|
|
222
|
+
// --final rejects outright, with nothing writing the file.
|
|
223
|
+
const root = mkProject([{ ...CHECKER, id: 'coverage-check', lcovProducer: true }, REVIEW_STATE, { ...CHECKER, id: 'coverage-check-2' }]);
|
|
224
|
+
const io = quiet();
|
|
225
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
226
|
+
const text = io.out.join('\n');
|
|
227
|
+
assert.doesNotMatch(text, /already final-run-capable/, 'two checkers are never a final-run-capable result');
|
|
228
|
+
assert.match(text, /2 declared gates are the canonical coverage checker/, 'the duplication is NAMED');
|
|
229
|
+
assert.match(text, /INERT/, 'and the pair is still reported inert — nothing writes the lcov');
|
|
230
|
+
rmSync(root, { recursive: true, force: true });
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('the tool-claim twin RUNS in this module — three outcomes, fail-closed on the unresolvable', () => {
|
|
234
|
+
// The text drift guard (beside the kit's own copy) proves the two owners are byte-equal; it
|
|
235
|
+
// cannot prove this copy WORKS, because the region is byte-equal inside a DIFFERENT host with
|
|
236
|
+
// different imports. Executing it here is what proves the twin resolves everything it uses.
|
|
237
|
+
const canonical = join(KIT_TOOLS, 'coverage-check.mjs');
|
|
238
|
+
const root = mkProject([]);
|
|
239
|
+
try {
|
|
240
|
+
const elsewhere = join(root, 'vendor-coverage-check.mjs');
|
|
241
|
+
writeFileSync(elsewhere, '// a vendored copy\n');
|
|
242
|
+
const tool = checkerClaimTool('coverage-check.mjs', canonical);
|
|
243
|
+
assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check`, KIT_TOOLS), CHECKER_CLAIM.CANONICAL);
|
|
244
|
+
const vendored = checkerClaimTool('vendor-coverage-check.mjs', canonical);
|
|
245
|
+
assert.equal(classifyCheckerClaim(vendored, `node "${elsewhere}" --check`, KIT_TOOLS), CHECKER_CLAIM.ELSEWHERE);
|
|
246
|
+
assert.equal(classifyCheckerClaim(tool, `node "${canonical}" --check || true`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a masked form is no claim');
|
|
247
|
+
assert.equal(classifyCheckerClaim(tool, `node "${join(KIT_TOOLS, 'nowhere', 'coverage-check.mjs')}" --check`, KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'unresolvable fails closed');
|
|
248
|
+
assert.equal(classifyCheckerClaim(tool, 'node $(pwd)/coverage-check.mjs --check', KIT_TOOLS), CHECKER_CLAIM.NOT_THE_TOOL, 'a shell-active bare token is no claim');
|
|
249
|
+
} finally {
|
|
250
|
+
rmSync(root, { recursive: true, force: true }); // every other case here cleans up; this one held its root only for a path
|
|
251
|
+
}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
it('a VENDORED deployment previews at exit 0 and its --apply writes ZERO bytes', () => {
|
|
255
|
+
// The upgrade path this fixes: every preview AND every apply over a deployment that declared the
|
|
256
|
+
// checker through its own vendored copy used to exit 1 on an id collision, so such a deployment
|
|
257
|
+
// could not be upgraded at all.
|
|
258
|
+
const vendoredTools = mkdtempSync(join(tmpdir(), 'migrate-branches-vendored-'));
|
|
259
|
+
writeFileSync(join(vendoredTools, 'coverage-check.mjs'), '// a vendored copy of the checker\n');
|
|
260
|
+
const vendored = { id: 'coverage-check', title: 'CC', cmd: `node "${join(vendoredTools, 'coverage-check.mjs')}" --check` };
|
|
261
|
+
const root = mkProject([UNIT_DONE, REVIEW_STATE, vendored]);
|
|
262
|
+
const before = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
|
|
263
|
+
|
|
264
|
+
const io = quiet();
|
|
265
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
266
|
+
const preview = io.out.join('\n');
|
|
267
|
+
assert.match(preview, /VERIFY \(preserved exactly as declared\): coverage-check/);
|
|
268
|
+
assert.doesNotMatch(preview, /ADD coverage-check/, 'nothing is added over a checker that is already declared');
|
|
269
|
+
assert.doesNotMatch(io.err.join('\n'), /id collision/, 'a vendored copy is not a squatter');
|
|
270
|
+
|
|
271
|
+
const io2 = quiet();
|
|
272
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io2), 0, io2.err.join('\n'));
|
|
273
|
+
assert.equal(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8'), before, 'the apply is a ZERO-DIFF write');
|
|
274
|
+
assert.match(io2.out.join('\n'), /NOT final-run-capable/, 'and the withheld claim survives the no-op apply');
|
|
275
|
+
rmSync(vendoredTools, { recursive: true, force: true });
|
|
276
|
+
rmSync(root, { recursive: true, force: true });
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('a vendored copy named by a RELATIVE path resolves against the PROJECT root, as the runner resolves it', () => {
|
|
280
|
+
const root = mkProject([UNIT_DONE, REVIEW_STATE, { id: 'coverage-check', title: 'CC', cmd: 'node "vendor/coverage-check.mjs" --check' }]);
|
|
281
|
+
mkdirSync(join(root, 'vendor'), { recursive: true });
|
|
282
|
+
writeFileSync(join(root, 'vendor', 'coverage-check.mjs'), '// a vendored copy inside the project\n');
|
|
283
|
+
const io = quiet();
|
|
284
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
285
|
+
assert.match(io.out.join('\n'), /VERIFY \(preserved exactly as declared\): coverage-check/, 'a relative token is resolved, not dismissed');
|
|
286
|
+
rmSync(root, { recursive: true, force: true });
|
|
287
|
+
});
|
|
288
|
+
|
|
144
289
|
it('an un-unlinkable retired store is reported LOUDLY and never fails the migration', () => {
|
|
145
290
|
const root = mkProject([LEGACY, UNIT]);
|
|
146
291
|
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
|
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
// COMPLETELY in one consented step: it REMOVES the canonical legacy entries, EXTENDS the
|
|
6
6
|
// canonical `unit-tests` cmd with the built-in lcov reporters (the D3(d) coverage source), and
|
|
7
7
|
// ADDS the coverage-check gate LAST (removal alone is not a migration — the result must satisfy
|
|
8
|
-
// `run-gates --final` and carry a working commit path).
|
|
8
|
+
// `run-gates --final` and carry a working commit path). The checker is added ONLY over a
|
|
9
|
+
// declaration that PRODUCES the lcov it reads; with no producer it is WITHHELD with a loud
|
|
10
|
+
// warning, because a checker with no producer passes (`skipped-no-lcov`) while verifying nothing.
|
|
9
11
|
//
|
|
10
12
|
// Matching is by the DOCUMENTED cmd forms ONLY (hand-wired history included — never by seed
|
|
11
13
|
// provenance): a legacy entry is `node <path>/review-ledger.mjs --check` or
|
|
@@ -45,11 +47,197 @@ export const LEGACY_FORMS = Object.freeze([
|
|
|
45
47
|
{ name: 'fold-completeness', re: legacyRe('fold-completeness\\.mjs') },
|
|
46
48
|
]);
|
|
47
49
|
|
|
48
|
-
//
|
|
49
|
-
//
|
|
50
|
-
//
|
|
50
|
+
// coverage-producer canon >>> BEGIN drift-guarded region
|
|
51
|
+
// Authored TWICE, byte-identically: in the memory substrate's references/scripts/migrate-gates.mjs
|
|
52
|
+
// and in the composition root's tools/coverage-producer.mjs. Neither side imports the other — the
|
|
53
|
+
// substrate is standalone and must not depend on the root, and the root must not import mirrored
|
|
54
|
+
// bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
|
|
55
|
+
// mirror sync.
|
|
56
|
+
//
|
|
57
|
+
// The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
|
|
58
|
+
// plain run AND on --final (AW_LCOV_FILE is --final only), so one cmd survives the unmet
|
|
59
|
+
// producer-variable preflight in both modes. The `:?` is not decoration either: this cmd is also
|
|
60
|
+
// PASTE-READY, and the required-parameter form makes bash refuse BY NAME when AW_GIT_DIR is unset
|
|
61
|
+
// or EMPTY, where a bare `$AW_GIT_DIR` expanded to empty and wrote the lcov to the filesystem ROOT.
|
|
62
|
+
// Residual, stated: `:?` says nothing about the value's ORIGIN — a STALE exported AW_GIT_DIR
|
|
63
|
+
// expands fine and the lcov lands under it; only the runner's own injection makes it the right dir.
|
|
64
|
+
// The explicit stdout reporter keeps the human stream: without it the lcov reporter swallows the
|
|
65
|
+
// TAP/spec output.
|
|
51
66
|
export const UNIT_TESTS_COVERAGE_FLAGS =
|
|
52
|
-
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
|
|
67
|
+
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="${AW_GIT_DIR:?exported by run-gates}/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout';
|
|
68
|
+
|
|
69
|
+
// Every flag set the kit has EVER emitted — APPEND-ONLY, newest first. Emission uses the head; the
|
|
70
|
+
// tail exists so a declaration written by an EARLIER kit and living on disk in a deployed project
|
|
71
|
+
// keeps reading as the producer it is. De-recognizing a prior form would silently reclassify a
|
|
72
|
+
// working suite gate as customized and withhold the checker over it.
|
|
73
|
+
export const KNOWN_COVERAGE_FLAG_SETS = Object.freeze([
|
|
74
|
+
UNIT_TESTS_COVERAGE_FLAGS,
|
|
75
|
+
'--experimental-test-coverage --test-reporter=lcov --test-reporter-destination="$AW_GIT_DIR/agent-workflow-lcov.info" --test-reporter=spec --test-reporter-destination=stdout',
|
|
76
|
+
]);
|
|
77
|
+
|
|
78
|
+
// The ONE suite body that produces that lcov with no extra dependency (the EMITTED form), beside
|
|
79
|
+
// the closed set of bodies recognition accepts.
|
|
80
|
+
export const COVERAGE_PRODUCER_BODY = `node --test ${UNIT_TESTS_COVERAGE_FLAGS}`;
|
|
81
|
+
const KNOWN_PRODUCER_BODIES = Object.freeze(KNOWN_COVERAGE_FLAG_SETS.map((flags) => `node --test ${flags}`));
|
|
82
|
+
|
|
83
|
+
// The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
|
|
84
|
+
// the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
|
|
85
|
+
// EMITTER and is bound to this list by a named acceptance test, never by a second grammar.
|
|
86
|
+
const PRODUCER_EXEC_PREFIXES = Object.freeze([
|
|
87
|
+
'COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ',
|
|
88
|
+
'COREPACK_ENABLE_NETWORK=0 pnpm exec -- ',
|
|
89
|
+
'COREPACK_ENABLE_NETWORK=0 yarn exec -- ',
|
|
90
|
+
]);
|
|
91
|
+
|
|
92
|
+
// A trailing suffix is the project's own test paths; a leading one would mean the body is not what
|
|
93
|
+
// this cmd runs. The tail passes a POSITIVE closed grammar — every whitespace-separated token must
|
|
94
|
+
// be path-shaped — never an operator blocklist: `node --test <flags> && rm -f <lcov>` runs the
|
|
95
|
+
// suite and then DELETES the file, so an open-ended tail would certify a producer that leaves
|
|
96
|
+
// nothing behind, and scanning for operator BYTES would put the incomplete-scan failure on the
|
|
97
|
+
// unsafe side (a missed operator is a dead pair) instead of the mild one (an unrecognised
|
|
98
|
+
// legitimate tail merely withholds the offer — add the gate by hand).
|
|
99
|
+
// The token set is everything that appears in a PATH or a glob and can never sequence, redirect or
|
|
100
|
+
// substitute a command — quoting and `~` included, `( ) $ ` ; & | < > # \` excluded. SCOPE, stated
|
|
101
|
+
// exactly: the screen judges each token's SOURCE bytes. Quote removal adds none, but brace SEQUENCE
|
|
102
|
+
// expansion does — `{Y..a}` yields ``[ \ ] ^ _ ` `` (probed) — so "no new bytes" would be a false
|
|
103
|
+
// claim. What holds is the property that matters: bash does not re-scan an expansion result as
|
|
104
|
+
// syntax, so a byte arriving that way is literal argument DATA, never an operator. The leading-`-`
|
|
105
|
+
// exclusion is weaker still — a FIRST-ORDER screen only, defeated by `'--flag'` and
|
|
106
|
+
// `{path,--flag}`. It is kept because the tail is the project's test PATHS and it costs only a loud
|
|
107
|
+
// withhold. Deciding an argument's post-expansion identity needs a shell lexer, which this family
|
|
108
|
+
// deliberately has NOWHERE (AD-079). So the claim is "configured with the reporters", never "the
|
|
109
|
+
// lcov survives the command"; a run that produces none is caught honestly at runtime as
|
|
110
|
+
// `skipped-no-lcov`.
|
|
111
|
+
const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
|
|
112
|
+
const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
|
|
113
|
+
const carriesProducerBody = (text) =>
|
|
114
|
+
KNOWN_PRODUCER_BODIES.some(
|
|
115
|
+
(body) => text === body || (text.startsWith(`${body} `) && pathShapedTail(text.slice(body.length).trim())),
|
|
116
|
+
);
|
|
117
|
+
|
|
118
|
+
// matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
|
|
119
|
+
// substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
|
|
120
|
+
// or the path as a bare substring must all read as NOT a producer — otherwise the checker is
|
|
121
|
+
// declared over a gate that writes nothing and then PASSES while certifying nothing.
|
|
122
|
+
export const matchesCoverageProducer = (cmd) => {
|
|
123
|
+
if (typeof cmd !== 'string') return false;
|
|
124
|
+
const trimmed = cmd.trim();
|
|
125
|
+
if (carriesProducerBody(trimmed)) return true;
|
|
126
|
+
return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
|
|
127
|
+
};
|
|
128
|
+
|
|
129
|
+
// isCoverageProducerGate(gate) → the GATE-level producer question, and the ONE predicate every
|
|
130
|
+
// consumer asks it through: does THIS declared entry write the lcov the canonical checker reads?
|
|
131
|
+
// Exactly two ways to be one — the cmd passes the closed world above, or the declaration CLAIMS
|
|
132
|
+
// production through the optional `lcovProducer` marker. The marker exists because the closed world
|
|
133
|
+
// is a `node --test` world: a project whose primary suite is another runner has NO cmd form
|
|
134
|
+
// recognition can accept, so without it the checker over such a suite reads as a dead pair forever.
|
|
135
|
+
// Recognition itself never widens (anti-squatter) — the marker is a declared claim, not a new
|
|
136
|
+
// grammar. Only the literal `true` claims: any truthy value would let the string "false" certify.
|
|
137
|
+
// And the claim is about the DECLARATION, never the run — a marked gate that produces no lcov still
|
|
138
|
+
// ends `skipped-no-lcov` / `attested=no` at run time.
|
|
139
|
+
// An entry with no RUNNABLE cmd claims nothing (fail closed): no string cmd, an empty or
|
|
140
|
+
// whitespace-only one, or one carrying an embedded newline. The strict validator already refuses all
|
|
141
|
+
// three, but this predicate has a SECOND host — the standalone migration's loader is deliberately
|
|
142
|
+
// lenient — and a marker must never make a checker pair with an entry that runs nothing there.
|
|
143
|
+
export const isCoverageProducerGate = (gate) => {
|
|
144
|
+
if (gate === null || typeof gate !== 'object' || Array.isArray(gate) || typeof gate.cmd !== 'string') return false;
|
|
145
|
+
if (gate.cmd.trim() === '' || /[\r\n]/.test(gate.cmd)) return false;
|
|
146
|
+
return matchesCoverageProducer(gate.cmd) || gate.lcovProducer === true;
|
|
147
|
+
};
|
|
148
|
+
// coverage-producer canon <<< END drift-guarded region
|
|
149
|
+
|
|
150
|
+
// checker-claim canon >>> BEGIN drift-guarded region
|
|
151
|
+
// Authored TWICE, byte-identically: in the composition root's tools/checker-claim.mjs and in the
|
|
152
|
+
// memory substrate's references/scripts/migrate-gates.mjs. Neither side imports the other — the
|
|
153
|
+
// substrate is standalone and must not depend on the root, and the root must not import mirrored
|
|
154
|
+
// bytes — so a TEXT drift guard beside the root's copy holds them equal. Edit BOTH, then re-run the
|
|
155
|
+
// mirror sync.
|
|
156
|
+
//
|
|
157
|
+
// A cmd makes exactly ONE of three claims about a given tool, and collapsing them into a boolean is
|
|
158
|
+
// what makes a VENDORED copy of the tool read as "the tool is not declared at all" — a false
|
|
159
|
+
// absence, with a remedy (adopt it) that then collides with the entry already there:
|
|
160
|
+
// • canonical — this tool's `--check` invocation, resolving to THIS copy of it
|
|
161
|
+
// • tool-elsewhere — the same invocation shape, resolving to a DIFFERENT real copy
|
|
162
|
+
// • not-the-tool — anything else: another command, a masked form, an inadmissible token, or a
|
|
163
|
+
// path nothing can resolve
|
|
164
|
+
// The realpath anchor never widens: a lookalike file that merely carries the basename is not this
|
|
165
|
+
// tool, whatever it prints. What widens is the VOCABULARY. Stated residual, unchanged by the split:
|
|
166
|
+
// nothing here reads the file's CONTENT, so a byte-swapped file at the canonical path is invisible.
|
|
167
|
+
export const CHECKER_CLAIM = Object.freeze({
|
|
168
|
+
CANONICAL: 'canonical',
|
|
169
|
+
ELSEWHERE: 'tool-elsewhere',
|
|
170
|
+
NOT_THE_TOOL: 'not-the-tool',
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// The token is screened by the rules of the quoting it actually carries, because the two halves are
|
|
174
|
+
// interpreted differently and a single screen would be wrong for one of them:
|
|
175
|
+
// • QUOTED — double quotes survive most bytes, so only what breaks OUT of them is refused.
|
|
176
|
+
// • BARE — anything the shell may split, expand or glob makes the executed command different
|
|
177
|
+
// from the string, so a bare token is admitted only from a known-safe alphabet.
|
|
178
|
+
// Either way the point is the same: a path that resolves literally here while the shell would read
|
|
179
|
+
// it differently must never be called a claim about this tool, or the screen certifies a command
|
|
180
|
+
// that never runs.
|
|
181
|
+
export const dqUnsafePath = (text) => [...text].some((ch) => {
|
|
182
|
+
const code = ch.codePointAt(0);
|
|
183
|
+
return ch === '"' || ch === '$' || code === 96 || code === 92 || code === 13 || code === 10;
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
// Stated as the bytes the shell ACTS on, not as an alphabet of blessed ones: an allow-list refuses
|
|
187
|
+
// perfectly ordinary paths (`@`, `+`, `,`, `%`, `=`, anything non-ASCII) that the shell passes
|
|
188
|
+
// through verbatim, and refusing a command that really is canonical is its own defect. Whitespace
|
|
189
|
+
// and ASCII control bytes are refused too — a bare token cannot contain them and still be one token.
|
|
190
|
+
const SHELL_ACTIVE_BARE = new Set([...'"\'\\$|&;<>(){}[]*?!#~^`']);
|
|
191
|
+
const bareTokenSafe = (text) => text.length > 0 && ![...text].some((ch) => {
|
|
192
|
+
const code = ch.codePointAt(0);
|
|
193
|
+
return code <= 0x20 || code === 0x7f || SHELL_ACTIVE_BARE.has(ch);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const RE_META = /[.*+?^${}()|[\]\\]/g;
|
|
197
|
+
|
|
198
|
+
// checkerClaimTool(basename, canonicalPath) → the screen for ONE tool. The shape is the STRICT full
|
|
199
|
+
// command — `node` + ONE (quoted or bare) path token + the exact basename + ` --check` + END — so a
|
|
200
|
+
// masked form (`--check --help`, `--check || true`, a prefix command) is never any claim at all.
|
|
201
|
+
// Separators are PLAIN SPACES, not \s: a newline between the tokens is not a command a runner would
|
|
202
|
+
// execute as written. The basename is regex-escaped here, never by the caller — a caller-escaped
|
|
203
|
+
// literal is one forgotten backslash away from a dot matching any byte.
|
|
204
|
+
export const checkerClaimTool = (basename, canonicalPath) => {
|
|
205
|
+
const safe = basename.replace(RE_META, '\\$&');
|
|
206
|
+
return Object.freeze({
|
|
207
|
+
re: new RegExp(`^node +(?:"((?:[^"]*[/\\\\])?${safe})"|((?:[^\\s"]*[/\\\\])?${safe})) +--check$`),
|
|
208
|
+
canonical: canonicalPath,
|
|
209
|
+
});
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// classifyCheckerClaim(tool, cmd, projectDir) → one CHECKER_CLAIM value. Every unresolvable side
|
|
213
|
+
// fails CLOSED to `not-the-tool`: an unresolvable path is not evidence the tool lives elsewhere, it
|
|
214
|
+
// is evidence nothing can be told about it — and `tool-elsewhere` is a claim a consumer ACTS on.
|
|
215
|
+
//
|
|
216
|
+
// Two screens beyond the shape, for the same reason the quoting screens exist — a claim must never
|
|
217
|
+
// be minted for a command that cannot run the tool as written:
|
|
218
|
+
// • a token starting with `-` is an OPTION to node, whatever it resolves to on disk. (First-order,
|
|
219
|
+
// like the producer canon's own leading-`-` rule: `{x,-y}` still defeats it, and the cost of a
|
|
220
|
+
// miss is only a withheld claim.)
|
|
221
|
+
// • the RESOLVED target must be a REGULAR FILE. A directory or a FIFO carrying the basename
|
|
222
|
+
// resolves perfectly well and is not a copy of anything; `realpathSync` succeeding proves a path
|
|
223
|
+
// exists, never that it is a tool. lstat runs AFTER realpath, so there is no link left to follow.
|
|
224
|
+
export const classifyCheckerClaim = (tool, cmd, projectDir) => {
|
|
225
|
+
if (typeof cmd !== 'string' || typeof projectDir !== 'string') return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
226
|
+
const match = tool.re.exec(cmd.trim());
|
|
227
|
+
if (!match) return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
228
|
+
const token = match[1] ?? match[2];
|
|
229
|
+
const admissible = match[1] !== undefined ? !dqUnsafePath(token) : bareTokenSafe(token);
|
|
230
|
+
if (!admissible || token.startsWith('-')) return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
231
|
+
const declared = isAbsolute(token) ? token : join(projectDir, token);
|
|
232
|
+
try {
|
|
233
|
+
const resolved = realpathSync(declared);
|
|
234
|
+
if (!lstatSync(resolved).isFile()) return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
235
|
+
return resolved === realpathSync(tool.canonical) ? CHECKER_CLAIM.CANONICAL : CHECKER_CLAIM.ELSEWHERE;
|
|
236
|
+
} catch {
|
|
237
|
+
return CHECKER_CLAIM.NOT_THE_TOOL;
|
|
238
|
+
}
|
|
239
|
+
};
|
|
240
|
+
// checker-claim canon <<< END drift-guarded region
|
|
53
241
|
|
|
54
242
|
// The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
|
|
55
243
|
// upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
|
|
@@ -75,36 +263,35 @@ export const findRetiredStores = (cwd) => {
|
|
|
75
263
|
|
|
76
264
|
const UNIT_TESTS_PREFIX = 'node --test ';
|
|
77
265
|
|
|
78
|
-
// The core
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
const
|
|
84
|
-
const
|
|
85
|
-
|
|
86
|
-
try {
|
|
87
|
-
return realpathSync(a) === realpathSync(b);
|
|
88
|
-
} catch {
|
|
89
|
-
return resolve(a) === resolve(b); // an unresolvable side falls back to the lexical compare
|
|
90
|
-
}
|
|
91
|
-
};
|
|
92
|
-
const isCanonicalCoreCheck = (name, cmd, kitToolsDir) => {
|
|
93
|
-
if (!CORE_CHECK_RE[name].test(cmd.trim())) return false;
|
|
94
|
-
const token = coreCheckToken(cmd);
|
|
95
|
-
return token !== null && isAbsolute(token) && samePath(token, join(kitToolsDir, `${name}.mjs`));
|
|
96
|
-
};
|
|
266
|
+
// The core checks the stripped core anchors on, asked through the checker-claim canon above — the
|
|
267
|
+
// SAME three outcomes, so a declared cmd is read as what it is: this copy of the tool, a DIFFERENT
|
|
268
|
+
// copy of it, or not the tool at all. Resolution is anchored on the PROJECT root, exactly as
|
|
269
|
+
// run-gates resolves a declared token (gates-declaration.mjs matchesCanonicalCheck), so the
|
|
270
|
+
// migration and the runner never disagree about which copy a cmd names.
|
|
271
|
+
const CORE_CHECK_NAMES = Object.freeze(['coverage-check', 'review-state']);
|
|
272
|
+
const coreCheckTools = (kitToolsDir) =>
|
|
273
|
+
Object.fromEntries(CORE_CHECK_NAMES.map((name) => [name, checkerClaimTool(`${name}.mjs`, join(kitToolsDir, `${name}.mjs`))]));
|
|
97
274
|
|
|
98
|
-
// buildMigrationPlan(gates, kitToolsDir) → the PURE migration plan.
|
|
275
|
+
// buildMigrationPlan(gates, kitToolsDir, projectDir) → the PURE migration plan.
|
|
99
276
|
// plan rows: { action: 'keep' | 'remove' | 'extend' | 'move' | 'add', entry, reason }.
|
|
100
277
|
// finalCapable mirrors the run-gates --final acceptance shape: the canonical review-state check
|
|
101
278
|
// must be PRESENT (the checker itself is guaranteed last by the plan) — missing means the result
|
|
102
|
-
// is NOT final-run-capable and the preview says so loudly with the paste-ready candidate.
|
|
103
|
-
|
|
279
|
+
// is NOT final-run-capable and the preview says so loudly with the paste-ready candidate. An
|
|
280
|
+
// EXTERNAL-COPY core check withholds that claim too: the runner anchors on the installed copy.
|
|
281
|
+
export const buildMigrationPlan = (gates, kitToolsDir, projectDir) => {
|
|
282
|
+
if (typeof projectDir !== 'string') {
|
|
283
|
+
throw stop('buildMigrationPlan needs the project root — a declared cmd may name a core check by a RELATIVE path, and only the project root resolves it the way the runner does');
|
|
284
|
+
}
|
|
285
|
+
const tools = coreCheckTools(kitToolsDir);
|
|
286
|
+
const claimOf = (name, cmd) => classifyCheckerClaim(tools[name], cmd, projectDir);
|
|
104
287
|
const plan = [];
|
|
105
288
|
const customized = [];
|
|
289
|
+
// EVERY canonical checker row, not just the last one seen: a duplicate is a real declaration
|
|
290
|
+
// state, and both the producer question and the final-capability claim have to see all of them.
|
|
291
|
+
const checkerRows = [];
|
|
292
|
+
// A core check declared through a DIFFERENT copy of the tool — the vendored deployment.
|
|
293
|
+
const externalCoreChecks = [];
|
|
106
294
|
let unitTestsExtended = false;
|
|
107
|
-
let checkerRow = null;
|
|
108
295
|
let hasReviewState = false;
|
|
109
296
|
const coverageCmd = `node "${join(kitToolsDir, 'coverage-check.mjs')}" --check`;
|
|
110
297
|
for (const gate of gates) {
|
|
@@ -113,19 +300,44 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
|
|
|
113
300
|
plan.push({ action: 'remove', entry: gate, reason: `the ${legacy.name} check died with its tool (strip-the-kit)` });
|
|
114
301
|
continue;
|
|
115
302
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
303
|
+
const coverageClaim = claimOf('coverage-check', gate.cmd);
|
|
304
|
+
if (coverageClaim === CHECKER_CLAIM.CANONICAL) {
|
|
305
|
+
const row = { action: 'keep', entry: gate, reason: null };
|
|
306
|
+
checkerRows.push(row);
|
|
307
|
+
plan.push(row);
|
|
119
308
|
continue;
|
|
120
309
|
}
|
|
121
|
-
|
|
310
|
+
const reviewClaim = claimOf('review-state', gate.cmd);
|
|
311
|
+
if (reviewClaim === CHECKER_CLAIM.CANONICAL) {
|
|
122
312
|
hasReviewState = true;
|
|
123
313
|
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
124
314
|
continue;
|
|
125
315
|
}
|
|
316
|
+
// The third outcome: this IS the tool, from a copy the caller did not name. A vendored
|
|
317
|
+
// deployment declared it deliberately, so it is PRESERVED as written — a plain keep row, never
|
|
318
|
+
// a new action kind (resultingGates carries only keep|extend|move|add and would silently drop
|
|
319
|
+
// one) — and it counts as DECLARED, which is what stops the checker being added on top of it
|
|
320
|
+
// and stops its id reading as a squatter. What it does not buy is the final-capability claim:
|
|
321
|
+
// run-gates --final anchors on the installed copy by realpath and would refuse this cmd.
|
|
322
|
+
const elsewhereName = coverageClaim === CHECKER_CLAIM.ELSEWHERE
|
|
323
|
+
? 'coverage-check'
|
|
324
|
+
: reviewClaim === CHECKER_CLAIM.ELSEWHERE ? 'review-state' : null;
|
|
325
|
+
if (elsewhereName !== null) {
|
|
326
|
+
const row = { action: 'keep', entry: gate, reason: null };
|
|
327
|
+
externalCoreChecks.push({ name: elsewhereName, entry: gate, installed: join(kitToolsDir, `${elsewhereName}.mjs`), row });
|
|
328
|
+
plan.push(row);
|
|
329
|
+
continue;
|
|
330
|
+
}
|
|
126
331
|
if (gate.id === 'unit-tests') {
|
|
127
|
-
|
|
128
|
-
|
|
332
|
+
// Already fully configured — decided by the CLOSED predicate, never a substring probe, and
|
|
333
|
+
// over ANY flag set the kit has emitted: a declaration written by an earlier kit stays a
|
|
334
|
+
// zero-diff keep (the constant moving must not re-read a working gate as customized), while a
|
|
335
|
+
// cmd that merely CONTAINS the bytes — `echo <flags>`, a `&& rm -f <lcov>` tail — is not a
|
|
336
|
+
// producer and must reach the CUSTOMIZED report with its recovery instead of a silent keep.
|
|
337
|
+
// A declared `lcovProducer` marker settles it the same way: the entry claims production, so
|
|
338
|
+
// there is nothing to extend and nothing to report as unverifiable.
|
|
339
|
+
if (isCoverageProducerGate(gate)) {
|
|
340
|
+
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
129
341
|
continue;
|
|
130
342
|
}
|
|
131
343
|
if (gate.cmd.startsWith(UNIT_TESTS_PREFIX) && !/--experimental-test-coverage|--test-reporter/.test(gate.cmd)) {
|
|
@@ -147,26 +359,97 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
|
|
|
147
359
|
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
148
360
|
}
|
|
149
361
|
const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
|
|
362
|
+
const checkerRow = checkerRows[checkerRows.length - 1] ?? null;
|
|
363
|
+
const externalCoverageChecks = externalCoreChecks.filter((c) => c.name === 'coverage-check');
|
|
364
|
+
// The checker READS an lcov; something has to WRITE it FIRST. Adding the checker over a
|
|
365
|
+
// declaration with no producer creates the dead pair — the gate PASSES (`skipped-no-lcov`) and
|
|
366
|
+
// certifies nothing, so the migration withholds it and says why instead.
|
|
367
|
+
// POSITIONAL, like every other producer question in the family: the checker always ends up LAST
|
|
368
|
+
// here (added last, or moved last), so the producers are exactly the rows that are not a checker.
|
|
369
|
+
// EVERY checker row is excluded, not merely the one that ends up last — a checker cannot produce
|
|
370
|
+
// the lcov it reads, so a marker on a DUPLICATE checker must not read as the producer for the
|
|
371
|
+
// other one; that pair would claim final-capability while nothing wrote the file. A VENDORED
|
|
372
|
+
// checker is excluded for the identical reason: which copy runs changes nothing about the fact
|
|
373
|
+
// that a checker consumes the lcov rather than writing it.
|
|
374
|
+
const consumerRows = new Set([...checkerRows, ...externalCoverageChecks.map((c) => c.row)]);
|
|
375
|
+
const isProducerRow = (row) => !consumerRows.has(row) && isCoverageProducerGate(row.entry);
|
|
376
|
+
const hasProducer = kept.some(isProducerRow);
|
|
150
377
|
let collision = null;
|
|
151
|
-
|
|
378
|
+
let checkerWithheld = false;
|
|
379
|
+
if (checkerRow !== null) {
|
|
380
|
+
if (kept[kept.length - 1] !== checkerRow) {
|
|
381
|
+
checkerRow.action = 'move';
|
|
382
|
+
checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
|
|
383
|
+
}
|
|
384
|
+
} else if (externalCoverageChecks.length > 0) {
|
|
385
|
+
// The checker IS declared, from another copy. Adding the canonical one beside it would create
|
|
386
|
+
// the very duplicate the collision STOP exists to prevent — and rewriting the row the
|
|
387
|
+
// deployment chose is not this tool's call. Nothing is added, nothing is moved, nothing
|
|
388
|
+
// collides; the verify warning below carries what the maintainer has to decide.
|
|
389
|
+
} else if (kept.some((r) => r.entry.id === 'coverage-check')) {
|
|
152
390
|
// A surviving NON-canonical entry already holding the checker's id blocks the add — two
|
|
153
391
|
// `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
|
|
154
392
|
// FIRST (the caller turns this into a loud STOP on preview and apply alike).
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
}
|
|
164
|
-
} else if (kept[kept.length - 1] !== checkerRow) {
|
|
165
|
-
checkerRow.action = 'move';
|
|
166
|
-
checkerRow.reason = 'the canonical checker must be the LAST declared gate (nothing may run after it consumed the lcov)';
|
|
393
|
+
collision = 'coverage-check';
|
|
394
|
+
} else if (!hasProducer) {
|
|
395
|
+
checkerWithheld = true;
|
|
396
|
+
} else {
|
|
397
|
+
plan.push({
|
|
398
|
+
action: 'add',
|
|
399
|
+
entry: { id: 'coverage-check', title: 'Changed-line coverage + red-proof verification (the final-run checker)', cmd: coverageCmd },
|
|
400
|
+
reason: 'run-gates --final requires the canonical checker as the LAST declared gate',
|
|
401
|
+
});
|
|
167
402
|
}
|
|
403
|
+
// An ALREADY-declared checker over no producer is the same dead pair the withhold prevents — an
|
|
404
|
+
// earlier deployment could have created it. The migration removes no declared gate, so it reports
|
|
405
|
+
// the inertness and refuses to call the result final-run-capable.
|
|
406
|
+
//
|
|
407
|
+
// The two checker kinds need DIFFERENT questions, and asking one question would be wrong for one
|
|
408
|
+
// of them. A canonical checker always ENDS UP LAST here — added last, or moved last — so "a
|
|
409
|
+
// producer exists at all" and "a producer runs before it" are the same fact. A VENDORED checker is
|
|
410
|
+
// deliberately left where the deployment put it, so for that row the question is POSITIONAL: a
|
|
411
|
+
// producer declared AFTER it writes the lcov the checker has already read past, and counting it
|
|
412
|
+
// would report a live pair over one that certifies nothing.
|
|
413
|
+
// Tracked PER ROW, not as one flag: the renderer has to name the edit for the row it is talking
|
|
414
|
+
// about, and a single boolean is what let one preview demand a removal and a reorder at once.
|
|
415
|
+
const inertExternalRows = new Set(
|
|
416
|
+
externalCoverageChecks.filter(({ row }) => !kept.slice(0, kept.indexOf(row)).some(isProducerRow)).map(({ row }) => row),
|
|
417
|
+
);
|
|
418
|
+
// The canonical checker's ONLY inert cause is that nothing produces at all — it always ends up
|
|
419
|
+
// last — so its sentence never has to speak about order.
|
|
420
|
+
const canonicalCheckerInert = checkerRow !== null && !hasProducer;
|
|
421
|
+
const checkerInert = canonicalCheckerInert || inertExternalRows.size > 0;
|
|
422
|
+
// `--final` accepts EXACTLY ONE canonical checker, so a declaration carrying two is not
|
|
423
|
+
// final-run-capable however healthy the rest of it looks. The migration removes no declared gate,
|
|
424
|
+
// so it names the duplication and withholds the claim instead of over-promising a green.
|
|
425
|
+
const duplicateCheckers = checkerRows.length;
|
|
168
426
|
const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
|
|
169
|
-
return {
|
|
427
|
+
return {
|
|
428
|
+
plan,
|
|
429
|
+
customized,
|
|
430
|
+
unitTestsExtended,
|
|
431
|
+
finalCapable:
|
|
432
|
+
hasReviewState && !checkerWithheld && !checkerInert && duplicateCheckers <= 1 && externalCoreChecks.length === 0,
|
|
433
|
+
hasProducer,
|
|
434
|
+
hasReviewState,
|
|
435
|
+
checkerWithheld,
|
|
436
|
+
checkerInert,
|
|
437
|
+
duplicateCheckers,
|
|
438
|
+
// The plan ROW is an internal handle (the move arm mutates it) — consumers get the facts only.
|
|
439
|
+
// `canonicalTwin` decides the RECOVERY: with the installed copy already declared, "repoint this
|
|
440
|
+
// cmd" would leave two canonical checkers, which --final refuses — a recovery that cannot
|
|
441
|
+
// converge is worse than none.
|
|
442
|
+
externalCoreChecks: externalCoreChecks.map(({ name, entry, installed, row }) => ({
|
|
443
|
+
name,
|
|
444
|
+
entry,
|
|
445
|
+
installed,
|
|
446
|
+
canonicalTwin: name === 'coverage-check' ? checkerRows.length > 0 : hasReviewState,
|
|
447
|
+
inert: inertExternalRows.has(row),
|
|
448
|
+
})),
|
|
449
|
+
canonicalCheckerInert,
|
|
450
|
+
reviewStateCandidate,
|
|
451
|
+
collision,
|
|
452
|
+
};
|
|
170
453
|
};
|
|
171
454
|
|
|
172
455
|
export const resultingGates = (plan) => {
|
|
@@ -184,7 +467,7 @@ const customizedRecovery = (gate) =>
|
|
|
184
467
|
? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
|
|
185
468
|
: 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
|
|
186
469
|
|
|
187
|
-
const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
|
|
470
|
+
const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, hasProducer = false, checkerWithheld = false, canonicalCheckerInert = false, duplicateCheckers = 0, externalCoreChecks = [], reviewStateCandidate }) => {
|
|
188
471
|
const lines = [];
|
|
189
472
|
for (const gate of customized) {
|
|
190
473
|
lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
|
|
@@ -193,7 +476,48 @@ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
|
|
|
193
476
|
if (customized.length) {
|
|
194
477
|
lines.push(' IMPORTANT: do NOT install the commit guard until every customized entry above is resolved — a declaration that cannot pass run-gates --final would block every commit.');
|
|
195
478
|
}
|
|
196
|
-
|
|
479
|
+
for (const { name, entry, installed, canonicalTwin = false, inert = false } of externalCoreChecks) {
|
|
480
|
+
lines.push(` VERIFY (preserved exactly as declared): ${entry.id}: ${entry.cmd}`);
|
|
481
|
+
lines.push(
|
|
482
|
+
` this IS the ${name} check by invocation shape, but it resolves to a DIFFERENT copy of the tool than --kit-tools names (${installed}) — a vendored deployment. Nothing was added over it and nothing was rewritten.`,
|
|
483
|
+
);
|
|
484
|
+
lines.push(
|
|
485
|
+
canonicalTwin
|
|
486
|
+
? ` the INSTALLED ${name} check is declared here too, so repointing this cmd would leave TWO — run-gates --final accepts exactly ONE canonical check. Remove THIS entry by hand and keep the canonical one.`
|
|
487
|
+
: ` run-gates --final anchors on the installed copy by realpath, so the result is NOT final-run-capable while this entry stands: either repoint the cmd at ${installed}, or upgrade through the kit that owns the copy it names.`,
|
|
488
|
+
);
|
|
489
|
+
// The inertness of THIS row, said on THIS row, with exactly one edit attached — and when the
|
|
490
|
+
// entry is already destined for removal, no second edit at all.
|
|
491
|
+
if (inert && canonicalTwin) {
|
|
492
|
+
lines.push(' it is also INERT as declared — nothing produces the lcov before it — and removing it, as above, is the ONE edit that resolves both.');
|
|
493
|
+
} else if (inert && hasProducer) {
|
|
494
|
+
lines.push(' it is also INERT as declared: a gate DOES produce the lcov, but it runs AFTER this entry, so this checker reads nothing (or stale bytes) and passes while verifying nothing — a checker belongs LAST, after its producer.');
|
|
495
|
+
} else if (inert) {
|
|
496
|
+
lines.push(` it is also INERT as declared: no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing — declare the suite gate: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (externalCoreChecks.length) {
|
|
500
|
+
// The same consequence the customized block carries, for the same reason: a declaration --final
|
|
501
|
+
// refuses mints no receipt, and the commit guard then refuses every commit.
|
|
502
|
+
lines.push(' IMPORTANT: do NOT install the commit guard while the entr(ies) above stand — a declaration that cannot pass run-gates --final would block every commit.');
|
|
503
|
+
}
|
|
504
|
+
if (checkerWithheld) {
|
|
505
|
+
lines.push(' WARNING: the canonical coverage-check gate was NOT added — no declared gate would PRODUCE the lcov it reads, and a checker with no producer passes while verifying nothing. Declare the suite gate first, then re-run this migration:');
|
|
506
|
+
lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
|
|
507
|
+
}
|
|
508
|
+
// The CANONICAL checker's inertness only. An external row's is said on the row itself above, with
|
|
509
|
+
// the edit that fits that row — this block would otherwise add a second, contradictory one.
|
|
510
|
+
if (canonicalCheckerInert) {
|
|
511
|
+
lines.push(' WARNING: the DECLARED coverage-check gate is INERT — no declared gate PRODUCES the lcov it reads, so it passes while verifying nothing. Nothing is removed for you; declare the suite gate:');
|
|
512
|
+
lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
|
|
513
|
+
}
|
|
514
|
+
if (duplicateCheckers > 1) {
|
|
515
|
+
lines.push(` WARNING: ${duplicateCheckers} declared gates are the canonical coverage checker — run-gates --final accepts EXACTLY ONE, so the result is NOT final-run-capable. Nothing is removed for you; keep a single checker and delete the rest by hand.`);
|
|
516
|
+
}
|
|
517
|
+
// A review-state declared through an external copy already has its VERIFY row above, naming the
|
|
518
|
+
// same missing capability with the RIGHT remedy — telling the maintainer to "add it" on top of an
|
|
519
|
+
// entry that is already there would advise a duplicate.
|
|
520
|
+
if (!hasReviewState && !externalCoreChecks.some((c) => c.name === 'review-state')) {
|
|
197
521
|
lines.push(' WARNING: the result is NOT final-run-capable — no canonical review-state check is declared. Add it (paste-ready), then run-gates --final can mint the receipt:');
|
|
198
522
|
lines.push(` ${reviewStateCandidate}`);
|
|
199
523
|
}
|
|
@@ -201,7 +525,7 @@ const warningLines = ({ customized, finalCapable, reviewStateCandidate }) => {
|
|
|
201
525
|
};
|
|
202
526
|
|
|
203
527
|
export const formatPreview = (analysis, applyHint) => {
|
|
204
|
-
const { plan, unitTestsExtended, finalCapable, retiredStores = [] } = analysis;
|
|
528
|
+
const { plan, unitTestsExtended, finalCapable, hasProducer = unitTestsExtended, retiredStores = [] } = analysis;
|
|
205
529
|
const lines = ['[agent-workflow] legacy gates.json migration preview (dry-run — nothing was written):'];
|
|
206
530
|
const acted = plan.filter((r) => r.action !== 'keep');
|
|
207
531
|
for (const r of acted) {
|
|
@@ -217,7 +541,9 @@ export const formatPreview = (analysis, applyHint) => {
|
|
|
217
541
|
: ' nothing to migrate mechanically — no canonical legacy entries and no retired stores; the warnings below still need a hand.',
|
|
218
542
|
);
|
|
219
543
|
}
|
|
220
|
-
|
|
544
|
+
// Keyed on the ID, but a PRODUCER is recognized under any id — repeating this advice over a
|
|
545
|
+
// working producer sends the user to fix nothing.
|
|
546
|
+
if (!hasProducer && !unitTestsExtended && !plan.some((r) => r.entry.id === 'unit-tests')) {
|
|
221
547
|
lines.push(' note: no canonical `unit-tests` entry found — declare your suite gate with the lcov reporters by hand (the coverage-check gate reads the file it produces).');
|
|
222
548
|
}
|
|
223
549
|
lines.push(...warningLines(analysis));
|
|
@@ -317,7 +643,8 @@ Usage:
|
|
|
317
643
|
Default is a dry-run PREVIEW (writes nothing). --apply rewrites ${GATES_REL} atomically:
|
|
318
644
|
canonical legacy entries (review-ledger / fold-completeness --check, matched by their documented
|
|
319
645
|
single-invocation forms) are REMOVED; the canonical unit-tests cmd gains the built-in lcov
|
|
320
|
-
reporters; the coverage-check gate is ADDED last (resolved, QUOTED path)
|
|
646
|
+
reporters; the coverage-check gate is ADDED last (resolved, QUOTED path) — and WITHHELD, loudly,
|
|
647
|
+
when no declared gate produces the lcov it reads. Customized entries are
|
|
321
648
|
NEVER auto-touched — the preview names each with a paste-ready recovery, and the commit guard
|
|
322
649
|
must not be installed until they are resolved.`;
|
|
323
650
|
|
|
@@ -349,7 +676,7 @@ export const main = (argv = process.argv.slice(2), io = {}) => {
|
|
|
349
676
|
return 0;
|
|
350
677
|
}
|
|
351
678
|
const parsed = declaration.outcome === 'loaded' ? declaration.parsed : { gates: [] };
|
|
352
|
-
const analysis = { ...buildMigrationPlan(parsed.gates, kitTools), retiredStores };
|
|
679
|
+
const analysis = { ...buildMigrationPlan(parsed.gates, kitTools, resolve(args.cwd)), retiredStores };
|
|
353
680
|
if (analysis.collision) {
|
|
354
681
|
throw stop(
|
|
355
682
|
`id collision — a NON-canonical entry already uses id "${analysis.collision}"; resolve it by hand first ` +
|
|
@@ -12,6 +12,8 @@ import { spawnSync } from 'node:child_process';
|
|
|
12
12
|
import {
|
|
13
13
|
LEGACY_FORMS,
|
|
14
14
|
UNIT_TESTS_COVERAGE_FLAGS,
|
|
15
|
+
KNOWN_COVERAGE_FLAG_SETS,
|
|
16
|
+
COVERAGE_PRODUCER_BODY,
|
|
15
17
|
RETIRED_STORE_BASENAMES,
|
|
16
18
|
findRetiredStores,
|
|
17
19
|
buildMigrationPlan,
|
|
@@ -20,8 +22,17 @@ import {
|
|
|
20
22
|
main,
|
|
21
23
|
} from './migrate-gates.mjs';
|
|
22
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.
|
|
23
28
|
const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-gates-kit-'));
|
|
24
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-'));
|
|
25
36
|
|
|
26
37
|
const mkProject = (gates) => {
|
|
27
38
|
const root = mkdtempSync(join(tmpdir(), 'migrate-gates-'));
|
|
@@ -36,6 +47,21 @@ const quiet = () => {
|
|
|
36
47
|
return { log: (l) => out.push(String(l)), error: (l) => err.push(String(l)), out, err };
|
|
37
48
|
};
|
|
38
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
|
+
|
|
39
65
|
const LEGACY_LEDGER = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
|
|
40
66
|
const LEGACY_FOLD = { id: 'fold-completeness', title: 'F', cmd: 'node /kit/tools/fold-completeness.mjs --check' };
|
|
41
67
|
const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
|
|
@@ -44,12 +70,12 @@ const CUSTOM = { id: 'my-ledger-wrap', title: 'C', cmd: 'node scripts/wrap.mjs &
|
|
|
44
70
|
describe('migrate-gates — the pure migration plan', () => {
|
|
45
71
|
it('matches BOTH documented legacy forms (quoted and bare paths) and removes them', () => {
|
|
46
72
|
for (const form of LEGACY_FORMS) assert.ok(form.re instanceof RegExp);
|
|
47
|
-
const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS);
|
|
73
|
+
const { plan } = buildMigrationPlan([LEGACY_LEDGER, LEGACY_FOLD], KIT_TOOLS, PROJECT);
|
|
48
74
|
assert.deepEqual(plan.filter((r) => r.action === 'remove').map((r) => r.entry.id), ['review-ledger', 'fold-completeness']);
|
|
49
75
|
});
|
|
50
76
|
|
|
51
77
|
it('extends the canonical unit-tests cmd with the lcov reporters (flags inserted after `node --test`)', () => {
|
|
52
|
-
const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS);
|
|
78
|
+
const { plan, unitTestsExtended } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
|
|
53
79
|
assert.ok(unitTestsExtended);
|
|
54
80
|
const extended = plan.find((r) => r.action === 'extend').entry;
|
|
55
81
|
assert.equal(extended.cmd, `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs`);
|
|
@@ -57,22 +83,95 @@ describe('migrate-gates — the pure migration plan', () => {
|
|
|
57
83
|
|
|
58
84
|
it('an already-extended unit-tests cmd is left alone (idempotent)', () => {
|
|
59
85
|
const done = { id: 'unit-tests', title: 'U', cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs` };
|
|
60
|
-
const { plan } = buildMigrationPlan([done], KIT_TOOLS);
|
|
86
|
+
const { plan } = buildMigrationPlan([done], KIT_TOOLS, PROJECT);
|
|
61
87
|
assert.equal(plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
|
|
62
88
|
});
|
|
63
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
|
+
|
|
64
127
|
it('adds the coverage-check gate LAST with the RESOLVED quoted path; never a second one', () => {
|
|
65
|
-
const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS);
|
|
128
|
+
const { plan } = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
|
|
66
129
|
const result = resultingGates(plan);
|
|
67
130
|
const last = result[result.length - 1];
|
|
68
131
|
assert.equal(last.id, 'coverage-check');
|
|
69
132
|
assert.equal(last.cmd, `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check`);
|
|
70
|
-
const again = buildMigrationPlan(result, KIT_TOOLS);
|
|
133
|
+
const again = buildMigrationPlan(result, KIT_TOOLS, PROJECT);
|
|
71
134
|
assert.ok(!again.plan.some((r) => r.action === 'add'), 'a declaration already carrying the checker gains no duplicate');
|
|
72
135
|
});
|
|
73
136
|
|
|
137
|
+
it('a declaration with NO producer never GAINS the checker — the pair is declared together or not at all', () => {
|
|
138
|
+
const npmSuite = { id: 'suite', title: 'S', cmd: 'npm test' };
|
|
139
|
+
const analysis = buildMigrationPlan([LEGACY_LEDGER, npmSuite], KIT_TOOLS, PROJECT);
|
|
140
|
+
assert.ok(!analysis.plan.some((r) => r.action === 'add'), 'no checker is added over a declaration that produces no lcov');
|
|
141
|
+
assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['suite'], 'the legacy entry still goes, nothing dead arrives');
|
|
142
|
+
assert.equal(analysis.finalCapable, false, 'a declaration with no checker is not final-run-capable');
|
|
143
|
+
const preview = formatPreview(analysis, 'APPLY');
|
|
144
|
+
assert.match(preview, /coverage-check/, 'the withheld checker is named');
|
|
145
|
+
assert.match(preview, /produce/i, 'the preview says WHY — no gate produces the lcov it would read');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('an ALREADY-declared checker over no producer is reported INERT, is never removed, and is not final-run-capable', () => {
|
|
149
|
+
const checker = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
|
|
150
|
+
const reviewState = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
|
|
151
|
+
const analysis = buildMigrationPlan([{ id: 'suite', title: 'S', cmd: 'npm test' }, reviewState, checker], KIT_TOOLS, PROJECT);
|
|
152
|
+
assert.equal(analysis.finalCapable, false, 'a review-state present must NOT make an inert pair read as final-run-capable');
|
|
153
|
+
assert.ok(resultingGates(analysis.plan).some((g) => g.id === 'coverage-check'), 'the declared checker is never removed');
|
|
154
|
+
const preview = formatPreview(analysis, 'APPLY');
|
|
155
|
+
assert.match(preview, /INERT/, 'the inert pair is named');
|
|
156
|
+
assert.match(preview, /--experimental-test-coverage/, 'the paste-ready suite cmd is carried');
|
|
157
|
+
assert.doesNotMatch(preview, /already final-run-capable/);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('a gates-init-shaped producer (the exec-wrapped offer form) is recognized — the checker IS added over it', () => {
|
|
161
|
+
const offered = {
|
|
162
|
+
id: 'test',
|
|
163
|
+
title: 'T',
|
|
164
|
+
cmd: `COREPACK_ENABLE_NETWORK=0 npm exec --offline --script-shell /bin/sh -- ${COVERAGE_PRODUCER_BODY}`,
|
|
165
|
+
};
|
|
166
|
+
const analysis = buildMigrationPlan([offered], KIT_TOOLS, PROJECT);
|
|
167
|
+
assert.deepEqual(resultingGates(analysis.plan).map((g) => g.id), ['test', 'coverage-check']);
|
|
168
|
+
// The `no canonical unit-tests entry` advice is keyed on the ID, but a producer is recognized
|
|
169
|
+
// under ANY id — repeating the advice over a working producer sends the user to fix nothing.
|
|
170
|
+
assert.doesNotMatch(formatPreview(analysis, 'APPLY'), /declare your suite gate with the lcov reporters by hand/);
|
|
171
|
+
});
|
|
172
|
+
|
|
74
173
|
it('a CUSTOMIZED dead-tool reference (compound form) is kept untouched and reported', () => {
|
|
75
|
-
const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS);
|
|
174
|
+
const analysis = buildMigrationPlan([CUSTOM], KIT_TOOLS, PROJECT);
|
|
76
175
|
assert.equal(analysis.plan.find((r) => r.entry.id === 'my-ledger-wrap').action, 'keep');
|
|
77
176
|
assert.deepEqual(analysis.customized.map((g) => g.id), ['my-ledger-wrap']);
|
|
78
177
|
const preview = formatPreview(analysis, 'APPLY');
|
|
@@ -84,7 +183,7 @@ describe('migrate-gates — the pure migration plan', () => {
|
|
|
84
183
|
describe('migrate-gates — the canonical anchor + final-capability validation (round-1 folds)', () => {
|
|
85
184
|
it('a canonical checker NOT in the last position is MOVED last (never left mid-list)', () => {
|
|
86
185
|
const canonical = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
|
|
87
|
-
const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS);
|
|
186
|
+
const { plan } = buildMigrationPlan([canonical, UNIT], KIT_TOOLS, PROJECT);
|
|
88
187
|
const result = resultingGates(plan);
|
|
89
188
|
assert.equal(result[result.length - 1].id, 'coverage-check', 'the canonical checker ends up LAST');
|
|
90
189
|
assert.ok(plan.some((r) => r.action === 'move' && r.entry.id === 'coverage-check'), 'the reorder is an explicit move action');
|
|
@@ -93,14 +192,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
|
|
|
93
192
|
|
|
94
193
|
it('a LOOKALIKE checker cmd is CUSTOMIZED (never counted canonical) and the canonical one is still added', () => {
|
|
95
194
|
const lookalike = { id: 'cov', title: 'C', cmd: 'node scripts/coverage-check.mjs --check' };
|
|
96
|
-
const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS);
|
|
195
|
+
const analysis = buildMigrationPlan([lookalike, UNIT], KIT_TOOLS, PROJECT);
|
|
97
196
|
assert.ok(analysis.customized.some((g) => g.id === 'cov'), 'the lookalike is reported customized');
|
|
98
197
|
const result = resultingGates(analysis.plan);
|
|
99
198
|
assert.equal(result[result.length - 1].id, 'coverage-check', 'the REAL canonical checker is added last');
|
|
100
199
|
});
|
|
101
200
|
|
|
102
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"', () => {
|
|
103
|
-
const analysis = buildMigrationPlan([UNIT], KIT_TOOLS);
|
|
202
|
+
const analysis = buildMigrationPlan([UNIT], KIT_TOOLS, PROJECT);
|
|
104
203
|
assert.equal(analysis.finalCapable, false, 'no review-state → not final-capable');
|
|
105
204
|
const preview = formatPreview(analysis, 'APPLY');
|
|
106
205
|
assert.match(preview, /review-state/, 'the warning names the missing core check');
|
|
@@ -109,13 +208,14 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
|
|
|
109
208
|
const withRs = buildMigrationPlan(
|
|
110
209
|
[UNIT, { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` }],
|
|
111
210
|
KIT_TOOLS,
|
|
211
|
+
PROJECT,
|
|
112
212
|
);
|
|
113
213
|
assert.equal(withRs.finalCapable, true);
|
|
114
214
|
});
|
|
115
215
|
|
|
116
216
|
it('a NON-canonical unit-tests cmd (npm test / wrapper) is CUSTOMIZED with the full flag set as the recovery', () => {
|
|
117
217
|
const npmTest = { id: 'unit-tests', title: 'U', cmd: 'npm test' };
|
|
118
|
-
const analysis = buildMigrationPlan([npmTest], KIT_TOOLS);
|
|
218
|
+
const analysis = buildMigrationPlan([npmTest], KIT_TOOLS, PROJECT);
|
|
119
219
|
assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
|
|
120
220
|
assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'a non-canonical suite cmd is customized');
|
|
121
221
|
const preview = formatPreview(analysis, 'APPLY');
|
|
@@ -124,7 +224,7 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
|
|
|
124
224
|
|
|
125
225
|
it('a PARTIALLY-flagged unit-tests cmd is CUSTOMIZED (a lone coverage flag never reads as configured)', () => {
|
|
126
226
|
const partial = { id: 'unit-tests', title: 'U', cmd: 'node --test --experimental-test-coverage tools/*.test.mjs' };
|
|
127
|
-
const analysis = buildMigrationPlan([partial], KIT_TOOLS);
|
|
227
|
+
const analysis = buildMigrationPlan([partial], KIT_TOOLS, PROJECT);
|
|
128
228
|
assert.equal(analysis.plan.find((r) => r.entry.id === 'unit-tests').action, 'keep');
|
|
129
229
|
assert.ok(analysis.customized.some((g) => g.id === 'unit-tests'), 'the half-wired cmd is customized, never silently left');
|
|
130
230
|
});
|
|
@@ -166,6 +266,135 @@ describe('migrate-gates — the canonical anchor + final-capability validation (
|
|
|
166
266
|
});
|
|
167
267
|
});
|
|
168
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
|
+
|
|
169
398
|
describe('migrate-gates — preview writes NOTHING; apply is atomic and complete', () => {
|
|
170
399
|
it('the dry-run default leaves gates.json byte-identical and prints the plan + the apply hint', () => {
|
|
171
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
|
}
|