@sabaiway/agent-workflow-kit 5.5.0 → 5.7.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 +122 -0
- package/README.md +1 -1
- package/SKILL.md +1 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/hooks/gate-approve.mjs +7 -1
- package/references/modes/doc-parity.md +1 -1
- package/references/modes/gates.md +20 -4
- package/references/modes/procedures.md +2 -0
- package/references/modes/recommendations.md +4 -1
- package/references/modes/review-state.md +1 -1
- package/references/modes/setup.md +18 -2
- package/references/modes/upgrade.md +38 -18
- package/references/modes/velocity.md +1 -0
- 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/shared/deploy-tail.md +1 -1
- package/references/templates/gates.json +1 -1
- package/tools/ack-write.mjs +20 -11
- package/tools/atomic-write.mjs +71 -18
- package/tools/checker-claim.mjs +100 -0
- package/tools/coverage-producer.mjs +43 -6
- package/tools/direct-run.mjs +76 -0
- package/tools/doc-parity.mjs +34 -3
- package/tools/engine-source.mjs +12 -8
- package/tools/ensure-configs.mjs +141 -0
- package/tools/ensure-ops.mjs +284 -0
- package/tools/ensure-vocabulary.mjs +71 -0
- package/tools/flow-check-cores.mjs +253 -0
- package/tools/flow-check-git-lane.mjs +56 -0
- package/tools/flow-check-rungs.mjs +330 -0
- package/tools/flow-check.mjs +23 -611
- package/tools/gates-declaration.mjs +36 -11
- package/tools/gates-init.mjs +140 -25
- package/tools/hide-footprint.mjs +21 -3
- package/tools/lens-region.mjs +74 -23
- package/tools/orchestration-config.mjs +5 -3
- package/tools/orchestration-write.mjs +7 -0
- package/tools/procedures.mjs +64 -5
- package/tools/recommendations.mjs +384 -34
- package/tools/refresh-parity.mjs +263 -0
- package/tools/run-gates.mjs +8 -5
- package/tools/setup-backends.mjs +88 -77
- package/tools/source-size-check.mjs +310 -0
- package/tools/source-size-config.mjs +244 -0
- package/tools/source-size-core.mjs +59 -0
- package/tools/source-size-gate-cmd.mjs +27 -0
- package/tools/source-size-judge.mjs +114 -0
- package/tools/source-size-refusal.mjs +70 -0
- package/tools/source-size-report.mjs +254 -0
- package/tools/source-size-scope.mjs +145 -0
- package/tools/tracked-tree-census.mjs +102 -0
- package/tools/upgrade-runlist.mjs +92 -0
- package/tools/velocity-profile.mjs +24 -3
|
@@ -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]);
|
|
@@ -12,7 +12,7 @@ The non-obvious traps — scan these before bootstrapping or upgrading. Each is
|
|
|
12
12
|
- **No Node runtime → skip enforcement.** If the project has no Node (recon step 1), skip bootstrap steps 8–9 (scripts + hook) and follow the cap/archive/index policy manually, or port the scripts to the project's language.
|
|
13
13
|
- **Conversational language never translates artifacts.** It governs *dialogue only*. Code, identifiers, paths, commands, log output, abbreviations, and every deployed `docs/ai/` / `AGENTS.md` file stay in their source language. See [Communication contract](${CLAUDE_SKILL_DIR}/references/contracts.md#communication-contract).
|
|
14
14
|
- **Never auto-commit.** Report quality-gate results and wait for explicit approval — in both modes.
|
|
15
|
-
- **Never leak kit internals to the user.** No ADR ids, tool / function / operation names (`reconcile`, `inject`, `ensureSlot`), marker / slot / fragment / anchor terminology, or verbatim tool stderr
|
|
15
|
+
- **Never leak kit internals to the user — and a tool-COMPOSED user-facing line holds the same bar at the source.** No ADR ids, tool / function / operation names (`reconcile`, `inject`, `ensureSlot`), marker / slot / fragment / anchor terminology, or verbatim tool stderr **inside the human sentence** of anything the user reads. Translate every tool outcome into plain language a third-party user — who has never read this `SKILL.md` — can understand and act on (e.g. the cap-refusal report in `${CLAUDE_SKILL_DIR}/references/modes/upgrade.md` step 3). The composed lines themselves are **user-grade** language: machine tokens and tool self-labels belong to the **machine-line channel** — the `[run-gates] status=…` grammar (`${CLAUDE_SKILL_DIR}/references/modes/gates.md`), a line's leading self-label/prefix, or a runnable command/path the user can act on — never mid-sentence; alarm words (`PARTIALLY`, `incomplete`, `failed`, `broken`, `persists`) render only in outcomes gated on a **detected abnormal condition**. ONE designed exception, stated not implied: the configuration ensures' LEADING outcome token — one closed-vocabulary token, a failure's closed cause word opening its detail line — is that contract's own machine slot, not a leak. The **verbatim**-paste contract stays: the agent pastes tool-composed outcome lines as written and never re-composes their facts — the lines are user-grade at the source, so pasting them verbatim IS the plain language.
|
|
16
16
|
- **Uninstall never deletes user-authored content, and dry-runs first.** `/agent-workflow-kit uninstall` removes only what is **provably ours** (a managed skill dir / wrapper symlink / fenced block / marker hook) and **prints — never runs** the `rm` / `git rm --cached` for `docs/ai` and the entry-point docs, and an **edit** instruction (not an `rm`) for `.claude/settings.json`. Always run `--dry-run` first, show the plan, get consent, then `--yes`. A skill dir or symlink that is not provably ours is a STOP, never a clobber (the `setup` posture, inverted). Removing a shared global (memory/engine/a bridge) may affect another project — say so.
|
|
17
17
|
|
|
18
18
|
---
|
|
@@ -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
|
}
|
package/tools/ack-write.mjs
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// ack-write.mjs — the consent-gated writer for the family-owned neutral ack store
|
|
3
|
-
// (docs/ai/acks.json, AD-055 Part I).
|
|
4
|
-
// tool's PREVIEW one-liner; the preview prints the exact `--apply` command;
|
|
5
|
-
// only after the mode doc's §3 informed-consent confirmation. It records a
|
|
6
|
-
// acknowledgement — never a security key; the kit never writes sandbox
|
|
3
|
+
// (docs/ai/acks.json, AD-055 Part I). An upgrade Recommendations item whose state can only be
|
|
4
|
+
// ANSWERED renders THIS tool's PREVIEW one-liner; the preview prints the exact `--apply` command;
|
|
5
|
+
// the agent runs `--apply` only after the mode doc's §3 informed-consent confirmation. It records a
|
|
6
|
+
// NEUTRAL fingerprint acknowledgement — never a security key; the kit never writes sandbox
|
|
7
|
+
// network/filesystem allowances.
|
|
8
|
+
//
|
|
9
|
+
// The wording is per-LANE or genuinely neutral, never "recipe": the sandbox lane acknowledges a
|
|
10
|
+
// session-sandbox recipe, but the coverage-domain lane acknowledges a census FACT and the
|
|
11
|
+
// source-size-copy lane a set of declared tool claims. One lane's noun stated over all of them was
|
|
12
|
+
// simply false about the others.
|
|
7
13
|
//
|
|
8
14
|
// Family writer discipline (velocity / orchestration-write / gate-hook), verbatim:
|
|
9
15
|
// • preview-then-mutate — `--dry-run` is the DEFAULT and writes nothing; `--apply` writes;
|
|
@@ -34,8 +40,9 @@ const EXIT_OK = 0;
|
|
|
34
40
|
const EXIT_PRECONDITION = 1;
|
|
35
41
|
const EXIT_USAGE = 2;
|
|
36
42
|
const JSON_INDENT = 2;
|
|
37
|
-
// The
|
|
38
|
-
//
|
|
43
|
+
// The shape every advisor fingerprint carries (recommendations.mjs: sha256 hex sliced to 16, whether
|
|
44
|
+
// minted over a recipe or over a canonical fact string) — a fail-closed guard so the store never
|
|
45
|
+
// records a malformed or injected value.
|
|
39
46
|
export const FINGERPRINT_PATTERN = /^[0-9a-f]{16}$/u;
|
|
40
47
|
// The lane this writer records when none is named — the original single-lane contract, so every
|
|
41
48
|
// pre-existing sandbox-lane invocation and its rendered one-liner stay byte-identical.
|
|
@@ -126,13 +133,13 @@ export const formatResult = (result) => {
|
|
|
126
133
|
if (result.dryRun) {
|
|
127
134
|
if (result.alreadyAcked) {
|
|
128
135
|
return [
|
|
129
|
-
`agent-workflow ack — DRY RUN: ${ACKS_FILE} already records this
|
|
136
|
+
`agent-workflow ack — DRY RUN: ${ACKS_FILE} already records this ${result.lane} fingerprint (${result.fingerprint}) — nothing to do.`,
|
|
130
137
|
].join('\n');
|
|
131
138
|
}
|
|
132
139
|
return [
|
|
133
140
|
`agent-workflow ack — DRY RUN (no changes; re-run with --apply)`,
|
|
134
141
|
` - would ${result.existed ? 'set' : 'create'} ${ACKS_FILE} "${result.ackKey}" = "${result.fingerprint}"${merge}`,
|
|
135
|
-
` - this is a NEUTRAL
|
|
142
|
+
` - this is a NEUTRAL ${result.lane} acknowledgement, never a security key.`,
|
|
136
143
|
` to apply: ${applyCommand(result.root, result.fingerprint, result.lane)}`,
|
|
137
144
|
].join('\n');
|
|
138
145
|
}
|
|
@@ -145,9 +152,11 @@ export const formatResult = (result) => {
|
|
|
145
152
|
// ── CLI ─────────────────────────────────────────────────────────────────────────────────
|
|
146
153
|
const USAGE = `usage: ack-write --fingerprint <16-hex> [--lane <${Object.keys(ACK_LANES).join('|')}>] [--dry-run | --apply] [--cwd <dir>] [--help]
|
|
147
154
|
|
|
148
|
-
Records a NEUTRAL
|
|
149
|
-
|
|
150
|
-
"${DEFAULT_ACK_LANE}")
|
|
155
|
+
Records a NEUTRAL acknowledgement into ${ACKS_FILE} (the family-owned ack store — no host settings
|
|
156
|
+
validator guards it). --lane names which advisor item is being acknowledged (default
|
|
157
|
+
"${DEFAULT_ACK_LANE}") and what the fingerprint stands for — a session-sandbox recipe, a worktrees
|
|
158
|
+
probe dir, a tracked-tree census fact, a set of declared tool claims; each lane owns one top-level
|
|
159
|
+
key. Default is --dry-run (a preview; writes
|
|
151
160
|
nothing) and prints the exact --apply command. --apply merges that ONE key into ${ACKS_FILE},
|
|
152
161
|
preserving every existing key. Refuses an absent docs/ai deployment; never a security key; never
|
|
153
162
|
commits.`;
|
package/tools/atomic-write.mjs
CHANGED
|
@@ -8,6 +8,8 @@
|
|
|
8
8
|
// (bridges 2.3.0, D6): ${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf. The
|
|
9
9
|
// host dir is CREATED if absent (a host config SHOULD materialize), unlike the docs/ai gate
|
|
10
10
|
// which REFUSES an absent deployment.
|
|
11
|
+
// • writeProjectFileCreateOnly — a SEED under a project subdirectory (the ensure CLI's enforcement
|
|
12
|
+
// scripts): the parent dir is created + verified, and the write is create-only.
|
|
11
13
|
//
|
|
12
14
|
// The discipline (verbatim from the source implementation) — writeContainedFileAtomic(root, dst, …):
|
|
13
15
|
// - a per-consumer GATE runs first (deployment gate for docs/ai; create+verify for the host dir).
|
|
@@ -19,12 +21,19 @@
|
|
|
19
21
|
// - tmp cleaned up on any failure after its creation.
|
|
20
22
|
// - LAST-WRITER-WINS: local, single-user; no cross-process lock (documented, not silently assumed).
|
|
21
23
|
//
|
|
24
|
+
// `opts.createOnly` swaps the LAST step only: `link(tmp, dst)` instead of `rename(tmp, dst)`. A rename
|
|
25
|
+
// replaces whatever is there — correct for a refresh, wrong for a SEED, where a file that appeared
|
|
26
|
+
// between the probe and the write must survive. link() is the primitive that both keeps the content
|
|
27
|
+
// atomic and fails EEXIST instead of clobbering; the caller learns which happened from `created`,
|
|
28
|
+
// never from a second probe (which would be the same race again). A filesystem that cannot hard-link
|
|
29
|
+
// surfaces its own error — there is no silent fallback to a clobbering write.
|
|
30
|
+
//
|
|
22
31
|
// Dependency-free, Node >= 22. Every fs primitive is injectable (deps.*) so the guards are
|
|
23
32
|
// unit-testable. NEVER imported by a read-only module (procedures.mjs — pinned by an import guard).
|
|
24
33
|
|
|
25
|
-
import { lstatSync, writeFileSync, renameSync, rmSync, mkdirSync } from 'node:fs';
|
|
34
|
+
import { lstatSync, writeFileSync, renameSync, linkSync, rmSync, mkdirSync } from 'node:fs';
|
|
26
35
|
import { randomBytes } from 'node:crypto';
|
|
27
|
-
import { join } from 'node:path';
|
|
36
|
+
import { dirname, join } from 'node:path';
|
|
28
37
|
import { assertContainedRealPath } from './fs-safe.mjs';
|
|
29
38
|
|
|
30
39
|
export const ATOMIC_WRITE_STOP = 'ATOMIC_WRITE_STOP';
|
|
@@ -78,11 +87,12 @@ export const assertDocsAiDeployment = (cwd, deps = {}, opts = {}) => {
|
|
|
78
87
|
}
|
|
79
88
|
};
|
|
80
89
|
|
|
81
|
-
//
|
|
82
|
-
// the opposite of the docs/ai deployment gate), then refuse a symlinked /
|
|
83
|
-
// write THROUGH (a rename into a symlinked dir would land outside where the
|
|
84
|
-
// what the caller writes so each consumer's STOP message stays exactly as
|
|
85
|
-
|
|
90
|
+
// Creatable-dir gate — CREATE the dir if absent (a host config / a project's scripts dir SHOULD
|
|
91
|
+
// materialize on first write, the opposite of the docs/ai deployment gate), then refuse a symlinked /
|
|
92
|
+
// non-directory dir we would write THROUGH (a rename into a symlinked dir would land outside where the
|
|
93
|
+
// user thinks). `noun` names what the caller writes so each consumer's STOP message stays exactly as
|
|
94
|
+
// its own tests pinned it.
|
|
95
|
+
export const assertCreatableDirSafe = (dir, deps = {}, opts = {}) => {
|
|
86
96
|
const lstat = deps.lstat ?? lstatSync;
|
|
87
97
|
const mkdir = deps.mkdir ?? ((p) => mkdirSync(p, { recursive: true }));
|
|
88
98
|
const stop = opts.stop ?? defaultStop;
|
|
@@ -95,12 +105,17 @@ export const assertHostConfigDirSafe = (dir, deps = {}, opts = {}) => {
|
|
|
95
105
|
};
|
|
96
106
|
|
|
97
107
|
// The hardened atomic flow, parameterized by containment ROOT + an already-passed gate. `dst` is the
|
|
98
|
-
// ABSOLUTE target under `root`; `opts.label` names it in a symlink-refusal message.
|
|
99
|
-
//
|
|
108
|
+
// ABSOLUTE target under `root`; `opts.label` names it in a symlink-refusal message; `opts.createOnly`
|
|
109
|
+
// makes the last step a no-clobber link (above). Returns { writtenPath: dst, created, tmpLeftBehind }
|
|
110
|
+
// — `created` is false ONLY under createOnly, when a regular file already stood there, and
|
|
111
|
+
// `tmpLeftBehind` names the temp file when the post-publication cleanup failed (the write STANDS;
|
|
112
|
+
// the leftover is reported, never swallowed). THROWS the caller's typed STOP (opts.stop) or a native
|
|
113
|
+
// fs error.
|
|
100
114
|
export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {}) => {
|
|
101
115
|
const lstat = deps.lstat ?? lstatSync;
|
|
102
116
|
const writeFile = deps.writeFile ?? writeFileSync;
|
|
103
117
|
const rename = deps.rename ?? renameSync;
|
|
118
|
+
const link = deps.link ?? linkSync;
|
|
104
119
|
const rm = deps.rm ?? ((p) => rmSync(p, { force: true }));
|
|
105
120
|
const rand = deps.rand ?? (() => randomBytes(6).toString('hex'));
|
|
106
121
|
const stop = opts.stop ?? defaultStop;
|
|
@@ -121,6 +136,8 @@ export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {})
|
|
|
121
136
|
// Exclusive-create (wx): never clobber a leftover tmp (a stray collision is surfaced, not silently
|
|
122
137
|
// overwritten). The random suffix makes a collision effectively impossible; wx makes it impossible-loud.
|
|
123
138
|
writeFile(tmp, body, { encoding: 'utf8', flag: 'wx' });
|
|
139
|
+
let created = true;
|
|
140
|
+
let tmpLeftBehind = null;
|
|
124
141
|
try {
|
|
125
142
|
// TOCTOU re-check: the parent chain + the leaf may have changed since the pre-checks above.
|
|
126
143
|
guard(dst);
|
|
@@ -128,30 +145,66 @@ export const writeContainedFileAtomic = (root, dst, body, deps = {}, opts = {})
|
|
|
128
145
|
if (leafAgain && leafAgain.isSymbolicLink()) {
|
|
129
146
|
throw stop(`${label} became a symlink — refusing to replace it`);
|
|
130
147
|
}
|
|
131
|
-
|
|
148
|
+
if (opts.createOnly) {
|
|
149
|
+
try {
|
|
150
|
+
link(tmp, dst);
|
|
151
|
+
} catch (err) {
|
|
152
|
+
if (!err || err.code !== 'EEXIST') throw err;
|
|
153
|
+
// EEXIST is not proof that a REGULAR FILE stands there. Re-check no-follow: a symlink that
|
|
154
|
+
// appeared inside the race window meets the same refusal as one that was there all along, a
|
|
155
|
+
// non-file is refused rather than reported as a preserved seed, and a dst that is already
|
|
156
|
+
// gone again is a loud conflict — never a silent "it was already there".
|
|
157
|
+
const raced = lstatNoFollow(dst, lstat);
|
|
158
|
+
if (raced === null) throw stop(`${label} could not be created, and nothing is there now — something is creating and removing it underneath this write; re-run when the tree is settled`);
|
|
159
|
+
if (raced.isSymbolicLink()) throw stop(`${label} became a symlink — refusing to seed through it`);
|
|
160
|
+
if (!raced.isFile()) throw stop(`${label} exists but is not a regular file — refusing to seed through it`);
|
|
161
|
+
created = false; // it appeared under us — the existing file stands, byte-for-byte
|
|
162
|
+
}
|
|
163
|
+
// The publication is DONE (or the dst already stood): dropping our tmp name may not turn a
|
|
164
|
+
// completed write into a failure. A cleanup that fails is reported instead — never swallowed.
|
|
165
|
+
try {
|
|
166
|
+
rm(tmp);
|
|
167
|
+
} catch {
|
|
168
|
+
tmpLeftBehind = tmp;
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
rename(tmp, dst);
|
|
172
|
+
}
|
|
132
173
|
} catch (err) {
|
|
133
174
|
rm(tmp); // never leave a temp file behind on failure
|
|
134
175
|
throw err;
|
|
135
176
|
}
|
|
136
|
-
return { writtenPath: dst };
|
|
177
|
+
return { writtenPath: dst, created, tmpLeftBehind };
|
|
137
178
|
};
|
|
138
179
|
|
|
139
|
-
// writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel } on success;
|
|
140
|
-
// caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives
|
|
141
|
-
// (each consumer owns its canonical serialization). Thin wrapper over the core: gate =
|
|
142
|
-
// gate, root = cwd, target = cwd/rel; the label + return stay `rel` so its public API is
|
|
180
|
+
// writeDocsAiFileAtomic(cwd, rel, body, deps, opts) → { writtenPath: rel, created } on success;
|
|
181
|
+
// THROWS the caller's typed STOP (via opts.stop) or a native fs error otherwise. `body` arrives
|
|
182
|
+
// pre-serialized (each consumer owns its canonical serialization). Thin wrapper over the core: gate =
|
|
183
|
+
// deployment gate, root = cwd, target = cwd/rel; the label + return stay `rel` so its public API is
|
|
184
|
+
// unchanged (`created` is additive — always true unless the caller asked for createOnly).
|
|
143
185
|
export const writeDocsAiFileAtomic = (cwd, rel, body, deps = {}, opts = {}) => {
|
|
144
186
|
assertDocsAiDeployment(cwd, deps, { ...opts, rel });
|
|
145
187
|
const dst = join(cwd, rel);
|
|
146
|
-
writeContainedFileAtomic(cwd, dst, body, deps, { ...opts, label: rel });
|
|
147
|
-
return { writtenPath: rel };
|
|
188
|
+
const { created, tmpLeftBehind } = writeContainedFileAtomic(cwd, dst, body, deps, { ...opts, label: rel });
|
|
189
|
+
return { writtenPath: rel, created, tmpLeftBehind };
|
|
148
190
|
};
|
|
149
191
|
|
|
150
192
|
// writeHostConfigFileAtomic(dir, filename, body, deps, opts) → { writtenPath: dir/filename }. Gate =
|
|
151
193
|
// create+verify the host dir; root = that dir; target = dir/filename. For the out-of-tree host config
|
|
152
194
|
// surface (bridge-settings.conf) that no project deployment owns.
|
|
153
195
|
export const writeHostConfigFileAtomic = (dir, filename, body, deps = {}, opts = {}) => {
|
|
154
|
-
|
|
196
|
+
assertCreatableDirSafe(dir, deps, opts);
|
|
155
197
|
const dst = join(dir, filename);
|
|
156
198
|
return writeContainedFileAtomic(dir, dst, body, deps, { ...opts, label: opts.label ?? dst });
|
|
157
199
|
};
|
|
200
|
+
|
|
201
|
+
// writeProjectFileCreateOnly(root, rel, body, deps, opts) → { writtenPath: rel, created }. Gate =
|
|
202
|
+
// create+verify the target's PARENT dir inside the project (a symlinked `scripts/` is a STOP, not a
|
|
203
|
+
// write through it); root = the project dir; the write is create-only, so an existing file is left
|
|
204
|
+
// exactly as it is and `created` says so.
|
|
205
|
+
export const writeProjectFileCreateOnly = (root, rel, body, deps = {}, opts = {}) => {
|
|
206
|
+
const dst = join(root, rel);
|
|
207
|
+
assertCreatableDirSafe(dirname(dst), deps, opts);
|
|
208
|
+
const { created, tmpLeftBehind } = writeContainedFileAtomic(root, dst, body, deps, { ...opts, createOnly: true, label: rel });
|
|
209
|
+
return { writtenPath: rel, created, tmpLeftBehind };
|
|
210
|
+
};
|