@sabaiway/agent-workflow-memory 4.2.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 +26 -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 +295 -60
- package/references/scripts/migrate-gates.test.mjs +206 -14
- package/references/templates/gates.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,32 @@ 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
|
+
|
|
7
33
|
## 4.2.0 — the gates migration stops handing you a coverage checker with nothing to read (AD-089)
|
|
8
34
|
|
|
9
35
|
`migrate-gates.mjs` added the canonical `coverage-check` gate to any legacy declaration that lacked
|
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' });
|
|
@@ -56,13 +56,29 @@ export const LEGACY_FORMS = Object.freeze([
|
|
|
56
56
|
//
|
|
57
57
|
// The destination is written against AW_GIT_DIR, which run-gates exports to every gate child on a
|
|
58
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
|
|
60
|
-
//
|
|
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.
|
|
61
66
|
export const UNIT_TESTS_COVERAGE_FLAGS =
|
|
62
|
-
'--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';
|
|
63
68
|
|
|
64
|
-
//
|
|
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.
|
|
65
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}`));
|
|
66
82
|
|
|
67
83
|
// The per-PM exec wrappers a fill offer puts that body behind. Recognition must cover every form
|
|
68
84
|
// the kit has EMITTED, so the prefixes are matched literally; gates-init's execCmdFor stays the one
|
|
@@ -95,8 +111,9 @@ const PRODUCER_EXEC_PREFIXES = Object.freeze([
|
|
|
95
111
|
const PRODUCER_PATH_TOKEN = /^(?!-)[A-Za-z0-9_./*{},:@+=~?[\]!'"-]+$/;
|
|
96
112
|
const pathShapedTail = (tail) => tail === '' || tail.split(/[ \t]+/).every((token) => PRODUCER_PATH_TOKEN.test(token));
|
|
97
113
|
const carriesProducerBody = (text) =>
|
|
98
|
-
|
|
99
|
-
|
|
114
|
+
KNOWN_PRODUCER_BODIES.some(
|
|
115
|
+
(body) => text === body || (text.startsWith(`${body} `) && pathShapedTail(text.slice(body.length).trim())),
|
|
116
|
+
);
|
|
100
117
|
|
|
101
118
|
// matchesCoverageProducer(cmd) → CLOSED-WORLD over the full command forms the kit emits, never a
|
|
102
119
|
// substring probe: `echo "$AW_GIT_DIR/agent-workflow-lcov.info"`, a half-written reporter flag set,
|
|
@@ -108,8 +125,120 @@ export const matchesCoverageProducer = (cmd) => {
|
|
|
108
125
|
if (carriesProducerBody(trimmed)) return true;
|
|
109
126
|
return PRODUCER_EXEC_PREFIXES.some((prefix) => trimmed.startsWith(prefix) && carriesProducerBody(trimmed.slice(prefix.length)));
|
|
110
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
|
+
};
|
|
111
148
|
// coverage-producer canon <<< END drift-guarded region
|
|
112
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
|
|
241
|
+
|
|
113
242
|
// The RETIRED kit-owned git-dir stores the deleted machinery wrote — dead data a consumer's
|
|
114
243
|
// upgrade would otherwise strand forever. The migration cleans them (consented via the preview;
|
|
115
244
|
// ENOENT is a silent no-op; any other unlink error is reported loudly but never fails the
|
|
@@ -134,36 +263,35 @@ export const findRetiredStores = (cwd) => {
|
|
|
134
263
|
|
|
135
264
|
const UNIT_TESTS_PREFIX = 'node --test ';
|
|
136
265
|
|
|
137
|
-
// The core
|
|
138
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
//
|
|
142
|
-
const
|
|
143
|
-
const
|
|
144
|
-
|
|
145
|
-
try {
|
|
146
|
-
return realpathSync(a) === realpathSync(b);
|
|
147
|
-
} catch {
|
|
148
|
-
return resolve(a) === resolve(b); // an unresolvable side falls back to the lexical compare
|
|
149
|
-
}
|
|
150
|
-
};
|
|
151
|
-
const isCanonicalCoreCheck = (name, cmd, kitToolsDir) => {
|
|
152
|
-
if (!CORE_CHECK_RE[name].test(cmd.trim())) return false;
|
|
153
|
-
const token = coreCheckToken(cmd);
|
|
154
|
-
return token !== null && isAbsolute(token) && samePath(token, join(kitToolsDir, `${name}.mjs`));
|
|
155
|
-
};
|
|
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`))]));
|
|
156
274
|
|
|
157
|
-
// buildMigrationPlan(gates, kitToolsDir) → the PURE migration plan.
|
|
275
|
+
// buildMigrationPlan(gates, kitToolsDir, projectDir) → the PURE migration plan.
|
|
158
276
|
// plan rows: { action: 'keep' | 'remove' | 'extend' | 'move' | 'add', entry, reason }.
|
|
159
277
|
// finalCapable mirrors the run-gates --final acceptance shape: the canonical review-state check
|
|
160
278
|
// must be PRESENT (the checker itself is guaranteed last by the plan) — missing means the result
|
|
161
|
-
// is NOT final-run-capable and the preview says so loudly with the paste-ready candidate.
|
|
162
|
-
|
|
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);
|
|
163
287
|
const plan = [];
|
|
164
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 = [];
|
|
165
294
|
let unitTestsExtended = false;
|
|
166
|
-
let checkerRow = null;
|
|
167
295
|
let hasReviewState = false;
|
|
168
296
|
const coverageCmd = `node "${join(kitToolsDir, 'coverage-check.mjs')}" --check`;
|
|
169
297
|
for (const gate of gates) {
|
|
@@ -172,19 +300,44 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
|
|
|
172
300
|
plan.push({ action: 'remove', entry: gate, reason: `the ${legacy.name} check died with its tool (strip-the-kit)` });
|
|
173
301
|
continue;
|
|
174
302
|
}
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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);
|
|
178
308
|
continue;
|
|
179
309
|
}
|
|
180
|
-
|
|
310
|
+
const reviewClaim = claimOf('review-state', gate.cmd);
|
|
311
|
+
if (reviewClaim === CHECKER_CLAIM.CANONICAL) {
|
|
181
312
|
hasReviewState = true;
|
|
182
313
|
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
183
314
|
continue;
|
|
184
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
|
+
}
|
|
185
331
|
if (gate.id === 'unit-tests') {
|
|
186
|
-
|
|
187
|
-
|
|
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 });
|
|
188
341
|
continue;
|
|
189
342
|
}
|
|
190
343
|
if (gate.cmd.startsWith(UNIT_TESTS_PREFIX) && !/--experimental-test-coverage|--test-reporter/.test(gate.cmd)) {
|
|
@@ -206,45 +359,94 @@ export const buildMigrationPlan = (gates, kitToolsDir) => {
|
|
|
206
359
|
plan.push({ action: 'keep', entry: gate, reason: null });
|
|
207
360
|
}
|
|
208
361
|
const kept = plan.filter((r) => r.action === 'keep' || r.action === 'extend');
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
//
|
|
212
|
-
|
|
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);
|
|
213
377
|
let collision = null;
|
|
214
378
|
let checkerWithheld = false;
|
|
215
|
-
if (checkerRow
|
|
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')) {
|
|
216
390
|
// A surviving NON-canonical entry already holding the checker's id blocks the add — two
|
|
217
391
|
// `coverage-check` rows would be ambiguous; the customized entry must be resolved by hand
|
|
218
392
|
// FIRST (the caller turns this into a loud STOP on preview and apply alike).
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
} else if (kept[kept.length - 1] !== checkerRow) {
|
|
231
|
-
checkerRow.action = 'move';
|
|
232
|
-
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
|
+
});
|
|
233
402
|
}
|
|
234
403
|
// An ALREADY-declared checker over no producer is the same dead pair the withhold prevents — an
|
|
235
404
|
// earlier deployment could have created it. The migration removes no declared gate, so it reports
|
|
236
405
|
// the inertness and refuses to call the result final-run-capable.
|
|
237
|
-
|
|
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;
|
|
238
426
|
const reviewStateCandidate = `{ "id": "review-state", "title": "Review receipts converged (D3(b))", "cmd": "node \\"${join(kitToolsDir, 'review-state.mjs')}\\" --check" }`;
|
|
239
427
|
return {
|
|
240
428
|
plan,
|
|
241
429
|
customized,
|
|
242
430
|
unitTestsExtended,
|
|
243
|
-
finalCapable:
|
|
431
|
+
finalCapable:
|
|
432
|
+
hasReviewState && !checkerWithheld && !checkerInert && duplicateCheckers <= 1 && externalCoreChecks.length === 0,
|
|
244
433
|
hasProducer,
|
|
245
434
|
hasReviewState,
|
|
246
435
|
checkerWithheld,
|
|
247
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,
|
|
248
450
|
reviewStateCandidate,
|
|
249
451
|
collision,
|
|
250
452
|
};
|
|
@@ -265,7 +467,7 @@ const customizedRecovery = (gate) =>
|
|
|
265
467
|
? `declare the canonical suite gate by hand so the coverage contract is verifiable: node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`
|
|
266
468
|
: 'remove the entry, or repoint it at a living check — the review-ledger / fold-completeness tools no longer exist.';
|
|
267
469
|
|
|
268
|
-
const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, checkerWithheld = false,
|
|
470
|
+
const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable, hasProducer = false, checkerWithheld = false, canonicalCheckerInert = false, duplicateCheckers = 0, externalCoreChecks = [], reviewStateCandidate }) => {
|
|
269
471
|
const lines = [];
|
|
270
472
|
for (const gate of customized) {
|
|
271
473
|
lines.push(` CUSTOMIZED (untouched): ${gate.id}: ${gate.cmd}`);
|
|
@@ -274,15 +476,48 @@ const warningLines = ({ customized, finalCapable, hasReviewState = finalCapable,
|
|
|
274
476
|
if (customized.length) {
|
|
275
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.');
|
|
276
478
|
}
|
|
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
|
+
}
|
|
277
504
|
if (checkerWithheld) {
|
|
278
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:');
|
|
279
506
|
lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
|
|
280
507
|
}
|
|
281
|
-
|
|
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) {
|
|
282
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:');
|
|
283
512
|
lines.push(` node --test ${UNIT_TESTS_COVERAGE_FLAGS} <your test paths>`);
|
|
284
513
|
}
|
|
285
|
-
if (
|
|
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')) {
|
|
286
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:');
|
|
287
522
|
lines.push(` ${reviewStateCandidate}`);
|
|
288
523
|
}
|
|
@@ -441,7 +676,7 @@ export const main = (argv = process.argv.slice(2), io = {}) => {
|
|
|
441
676
|
return 0;
|
|
442
677
|
}
|
|
443
678
|
const parsed = declaration.outcome === 'loaded' ? declaration.parsed : { gates: [] };
|
|
444
|
-
const analysis = { ...buildMigrationPlan(parsed.gates, kitTools), retiredStores };
|
|
679
|
+
const analysis = { ...buildMigrationPlan(parsed.gates, kitTools, resolve(args.cwd)), retiredStores };
|
|
445
680
|
if (analysis.collision) {
|
|
446
681
|
throw stop(
|
|
447
682
|
`id collision — a NON-canonical entry already uses id "${analysis.collision}"; resolve it by hand first ` +
|
|
@@ -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
|
}
|