@sabaiway/agent-workflow-kit 2.0.0 → 3.0.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 +74 -0
- package/README.md +6 -5
- package/SKILL.md +12 -8
- package/bin/install.mjs +1 -1
- package/bridges/antigravity-cli-bridge/SKILL.md +16 -4
- package/bridges/antigravity-cli-bridge/bin/agy-review-honesty.test.mjs +150 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review-model-screen.test.mjs +76 -0
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +131 -23
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +193 -15
- package/bridges/antigravity-cli-bridge/capability.json +12 -8
- package/bridges/antigravity-cli-bridge/references/driving-agy.md +6 -2
- package/bridges/antigravity-cli-bridge/references/models-and-flags.md +3 -2
- package/bridges/antigravity-cli-bridge/references/review-prompt.md +1 -1
- package/bridges/codex-cli-bridge/SKILL.md +8 -1
- package/bridges/codex-cli-bridge/bin/codex-review-honesty.test.mjs +143 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +71 -10
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +93 -10
- package/bridges/codex-cli-bridge/capability.json +3 -2
- package/capability.json +1 -1
- package/migrations/3.0.0-hardened-core-loop.md +44 -0
- package/migrations/README.md +1 -1
- package/package.json +2 -2
- package/references/hooks/gate-approve.mjs +1 -1
- package/references/modes/bootstrap.md +3 -3
- package/references/modes/commit-guard.md +19 -0
- package/references/modes/core-evidence.md +15 -0
- package/references/modes/coverage-check.md +12 -0
- package/references/modes/doc-parity.md +7 -6
- package/references/modes/gates.md +7 -6
- package/references/modes/grounding.md +1 -2
- package/references/modes/hook.md +1 -1
- package/references/modes/review-state.md +3 -3
- package/references/modes/upgrade.md +6 -6
- package/references/modes/velocity.md +2 -2
- package/references/scripts/archive-decisions.mjs +1 -1
- package/references/scripts/install-git-hooks-repo-exec.test.mjs +82 -0
- package/references/scripts/install-git-hooks.mjs +90 -18
- package/references/scripts/install-git-hooks.test.mjs +102 -0
- package/references/scripts/migrate-gates-branches.test.mjs +157 -0
- package/references/scripts/migrate-gates.mjs +395 -0
- package/references/scripts/migrate-gates.test.mjs +284 -0
- package/references/templates/agent_rules.md +2 -2
- package/tools/ack-write.mjs +1 -1
- package/tools/atomic-write.mjs +2 -2
- package/tools/autonomy-config.mjs +1 -1
- package/tools/autonomy-doctor.mjs +1 -1
- package/tools/autonomy-write.mjs +1 -1
- package/tools/bridge-settings-read.mjs +1 -1
- package/tools/bridge-settings.mjs +1 -1
- package/tools/changed-surface.mjs +8 -69
- package/tools/cheap-agents.mjs +2 -2
- package/tools/commands.mjs +17 -10
- package/tools/commit-guard.mjs +167 -0
- package/tools/core-evidence.mjs +914 -0
- package/tools/coverage-check.mjs +260 -0
- package/tools/delegation.mjs +1 -1
- package/tools/detect-backends.mjs +7 -6
- package/tools/doc-parity.mjs +11 -27
- package/tools/engine-source.mjs +1 -1
- package/tools/family-members.mjs +1 -1
- package/tools/family-registry.mjs +1 -1
- package/tools/fs-safe.mjs +1 -1
- package/tools/gate-hook.mjs +1 -1
- package/tools/{seed-gates.mjs → gates-init.mjs} +58 -52
- package/tools/grounding.mjs +6 -83
- package/tools/hide-footprint.mjs +1 -1
- package/tools/inject-methodology.mjs +1 -1
- package/tools/known-footprint.mjs +1 -1
- package/tools/labels.mjs +1 -1
- package/tools/lcov.mjs +6 -10
- package/tools/lens-region.mjs +1 -1
- package/tools/manifest/validate.mjs +19 -0
- package/tools/migrate-adr-store.mjs +2 -2
- package/tools/orchestration-config.mjs +1 -1
- package/tools/orchestration-write.mjs +1 -1
- package/tools/presentation.mjs +1 -1
- package/tools/procedures.mjs +2 -2
- package/tools/recipes.mjs +60 -9
- package/tools/recommendations.mjs +63 -7
- package/tools/release-scan.mjs +1 -1
- package/tools/renderers.mjs +1 -1
- package/tools/review-state.mjs +288 -340
- package/tools/run-gates.mjs +216 -92
- package/tools/sandbox-masks.mjs +1 -1
- package/tools/semver-lite.mjs +1 -1
- package/tools/set-autonomy.mjs +1 -1
- package/tools/set-recipe.mjs +1 -1
- package/tools/setup-backends.mjs +1 -1
- package/tools/surface.mjs +1 -1
- package/tools/uninstall.mjs +1 -1
- package/tools/velocity-profile.mjs +2 -2
- package/tools/view-model.mjs +1 -1
- package/references/modes/fold-completeness.md +0 -30
- package/references/modes/review-ledger.md +0 -34
- package/references/templates/verification-profile.json +0 -10
- package/tools/fold-completeness-run.mjs +0 -1120
- package/tools/fold-completeness.mjs +0 -672
- package/tools/review-ledger-core.mjs +0 -428
- package/tools/review-ledger-write.mjs +0 -647
- package/tools/review-ledger.mjs +0 -630
- package/tools/sarif.mjs +0 -52
- package/tools/verification-profile.mjs +0 -219
|
@@ -1,12 +1,25 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// Idempotent installer for the project's git hooks.
|
|
3
3
|
//
|
|
4
|
-
// Installs
|
|
4
|
+
// Installs the `pre-commit` hook running the docs cap-validator + index-freshness
|
|
5
5
|
// gate + changelog/issues rotation-freshness checks + the `scripts/` test
|
|
6
6
|
// suite, so docs files cannot drift over their declared `maxLines`, the auto-generated
|
|
7
7
|
// `index.md` navigator cannot silently fall out of sync, stale archive entries never
|
|
8
8
|
// reach a commit, and regressions to the scripts themselves are caught at commit time.
|
|
9
9
|
//
|
|
10
|
+
// The hooks location comes from git's OWN answer (`git rev-parse --git-path hooks`) — a linked
|
|
11
|
+
// worktree resolves ITS OWN hooks path (its `.git` is a FILE; a hardcoded `.git/hooks` would
|
|
12
|
+
// miss or mis-write).
|
|
13
|
+
//
|
|
14
|
+
// COMMIT GUARD (D10, optional): `--commit-guard <path>` names the composition root's read-only
|
|
15
|
+
// commit-guard tool explicitly — the hook then gains its `--check` line with the RESOLVED,
|
|
16
|
+
// quoted path written at install time (a portable contract: no runtime guessing; this substrate
|
|
17
|
+
// knows no sibling by name, so the path always arrives from the caller). The guard binds the
|
|
18
|
+
// latest final-run receipt to the exact commit tree. An armed guard line SURVIVES flagless
|
|
19
|
+
// re-runs (carried forward from the managed hook — exactly ONE strictly-parsed line; an
|
|
20
|
+
// ambiguous duplicate fails closed); `--no-commit-guard` is the one consented disable; passing
|
|
21
|
+
// both flags together is a usage error.
|
|
22
|
+
//
|
|
10
23
|
// Package-manager-agnostic: the hook calls the scripts via `node` directly (no pnpm/npm
|
|
11
24
|
// assumption). Re-running is safe — the script detects a previously installed hook via
|
|
12
25
|
// the MAGIC_MARKER comment and rewrites only that file.
|
|
@@ -17,12 +30,11 @@ import { writeFile, readFile, mkdir, chmod } from 'node:fs/promises';
|
|
|
17
30
|
import { existsSync, readFileSync } from 'node:fs';
|
|
18
31
|
import { dirname, resolve, basename } from 'node:path';
|
|
19
32
|
import { fileURLToPath } from 'node:url';
|
|
33
|
+
import { spawnSync } from 'node:child_process';
|
|
20
34
|
|
|
21
35
|
const __filename = fileURLToPath(import.meta.url);
|
|
22
36
|
const __dirname = dirname(__filename);
|
|
23
37
|
const ROOT = resolve(__dirname, '..');
|
|
24
|
-
const HOOKS_DIR = resolve(ROOT, '.git/hooks');
|
|
25
|
-
const PRE_COMMIT_PATH = resolve(HOOKS_DIR, 'pre-commit');
|
|
26
38
|
|
|
27
39
|
const readProjectName = () => {
|
|
28
40
|
try {
|
|
@@ -36,7 +48,58 @@ const readProjectName = () => {
|
|
|
36
48
|
|
|
37
49
|
const MAGIC_MARKER = `# ${readProjectName()}:install-git-hooks.mjs`;
|
|
38
50
|
|
|
39
|
-
|
|
51
|
+
// git's own hooks location (worktree-correct), or null outside a git checkout.
|
|
52
|
+
const gitHooksDir = () => {
|
|
53
|
+
const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: ROOT, encoding: 'utf8', windowsHide: true });
|
|
54
|
+
if (r.error || r.status !== 0) return null;
|
|
55
|
+
return resolve(ROOT, r.stdout.replace(/\r?\n$/, ''));
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
// The exact shape this installer writes — the strict carry-forward parse anchors on it
|
|
59
|
+
// (quoted path + a bare ` --check` tail; nothing else in the managed hook matches).
|
|
60
|
+
const GUARD_LINE_RE = /^node "([^"]+)" --check$/;
|
|
61
|
+
|
|
62
|
+
const ARGV = process.argv.slice(2);
|
|
63
|
+
|
|
64
|
+
// The optional commit-guard line: an explicit --commit-guard <path> arms it (resolved at install
|
|
65
|
+
// time); --no-commit-guard is the one consented disable; both together are a usage error —
|
|
66
|
+
// never an order-dependent pick.
|
|
67
|
+
const resolveGuardFlags = (argv) => {
|
|
68
|
+
const at = argv.indexOf('--commit-guard');
|
|
69
|
+
const noGuard = argv.includes('--no-commit-guard');
|
|
70
|
+
if (at !== -1 && noGuard) {
|
|
71
|
+
console.error('[install-git-hooks] --commit-guard and --no-commit-guard contradict each other — pass exactly one');
|
|
72
|
+
process.exit(2);
|
|
73
|
+
}
|
|
74
|
+
if (noGuard) return { mode: 'disable' };
|
|
75
|
+
if (at === -1) return { mode: 'carry' };
|
|
76
|
+
const given = argv[at + 1];
|
|
77
|
+
if (!given) {
|
|
78
|
+
console.error('[install-git-hooks] --commit-guard needs a path');
|
|
79
|
+
process.exit(2);
|
|
80
|
+
}
|
|
81
|
+
const abs = resolve(ROOT, given);
|
|
82
|
+
if (!existsSync(abs)) {
|
|
83
|
+
console.error(`[install-git-hooks] --commit-guard ${abs} does not exist`);
|
|
84
|
+
process.exit(1);
|
|
85
|
+
}
|
|
86
|
+
return { mode: 'arm', path: abs };
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
// Flagless re-runs carry the armed guard forward from the MANAGED hook — strictly parsed:
|
|
90
|
+
// exactly one guard-shaped line keeps its path; none keeps the hook plain; more than one is
|
|
91
|
+
// ambiguous and fails CLOSED (an installer must never guess which line was the real guard).
|
|
92
|
+
const carriedGuardPath = (existingHook) => {
|
|
93
|
+
if (existingHook === null) return null;
|
|
94
|
+
const matches = existingHook.split('\n').map((l) => GUARD_LINE_RE.exec(l)).filter(Boolean);
|
|
95
|
+
if (matches.length > 1) {
|
|
96
|
+
console.error(`[install-git-hooks] the managed pre-commit hook carries ${matches.length} commit-guard --check lines — ambiguous; remove the duplicates by hand and re-run (refusing to guess)`);
|
|
97
|
+
process.exit(1);
|
|
98
|
+
}
|
|
99
|
+
return matches.length === 1 ? matches[0][1] : null;
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const preCommitContent = (guardPath) => `#!/usr/bin/env bash
|
|
40
103
|
${MAGIC_MARKER}
|
|
41
104
|
# Auto-installed by scripts/install-git-hooks.mjs (run it from "prepare" or by hand).
|
|
42
105
|
# Runs the docs cap-validator + index-freshness gate + rotation-freshness checks
|
|
@@ -50,32 +113,41 @@ node scripts/archive-changelog.mjs --check
|
|
|
50
113
|
node scripts/archive-issues.mjs --check
|
|
51
114
|
node scripts/archive-decisions.mjs --check
|
|
52
115
|
node --test scripts/*.test.mjs
|
|
53
|
-
`;
|
|
116
|
+
${guardPath ? `node "${guardPath}" --check\n` : ''}`;
|
|
54
117
|
|
|
55
118
|
const main = async () => {
|
|
56
|
-
|
|
57
|
-
|
|
119
|
+
const flags = resolveGuardFlags(ARGV);
|
|
120
|
+
const hooksDir = gitHooksDir();
|
|
121
|
+
if (hooksDir === null) {
|
|
122
|
+
console.log('[install-git-hooks] git cannot resolve a hooks path here — skipping (not a git checkout).');
|
|
58
123
|
return;
|
|
59
124
|
}
|
|
60
|
-
|
|
125
|
+
const preCommitPath = resolve(hooksDir, 'pre-commit');
|
|
61
126
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
console.log('[install-git-hooks] pre-commit already up to date.');
|
|
66
|
-
return;
|
|
67
|
-
}
|
|
127
|
+
let existing = null;
|
|
128
|
+
if (existsSync(preCommitPath)) {
|
|
129
|
+
existing = await readFile(preCommitPath, 'utf8');
|
|
68
130
|
if (!existing.includes(MAGIC_MARKER)) {
|
|
69
131
|
console.warn(
|
|
70
|
-
|
|
132
|
+
`[install-git-hooks] WARNING: ${preCommitPath} exists and was not installed by this script. Refusing to overwrite — remove or merge it manually.`,
|
|
71
133
|
);
|
|
72
134
|
process.exit(1);
|
|
73
135
|
}
|
|
74
136
|
}
|
|
75
137
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
138
|
+
const guardPath = flags.mode === 'arm' ? flags.path : flags.mode === 'disable' ? null : carriedGuardPath(existing);
|
|
139
|
+
const content = preCommitContent(guardPath);
|
|
140
|
+
if (existing === content) {
|
|
141
|
+
console.log('[install-git-hooks] pre-commit already up to date.');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
await mkdir(hooksDir, { recursive: true });
|
|
146
|
+
await writeFile(preCommitPath, content, 'utf8');
|
|
147
|
+
await chmod(preCommitPath, 0o755);
|
|
148
|
+
console.log(
|
|
149
|
+
`[install-git-hooks] installed ${preCommitPath} (docs caps + index freshness + archive checks + scripts/ tests${guardPath ? ' + commit guard' : ''}).`,
|
|
150
|
+
);
|
|
79
151
|
};
|
|
80
152
|
|
|
81
153
|
main().catch((err) => {
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
// install-git-hooks.test.mjs — spec for the pre-commit installer (strip-the-kit round-1 folds):
|
|
2
|
+
// the hooks path comes from git plumbing (a linked worktree resolves ITS OWN hooks path, never a
|
|
3
|
+
// hardcoded `.git/hooks`), and an armed commit-guard line SURVIVES a flagless re-run (exactly one
|
|
4
|
+
// strictly-parsed canonical guard line carries forward; --no-commit-guard is the only consented
|
|
5
|
+
// disable; both flags together are a usage error; a duplicated guard line fails closed).
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, cpSync, existsSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join, dirname, resolve } from 'node:path';
|
|
12
|
+
import { fileURLToPath } from 'node:url';
|
|
13
|
+
import { spawnSync } from 'node:child_process';
|
|
14
|
+
|
|
15
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
const INSTALLER_SRC = join(HERE, 'install-git-hooks.mjs');
|
|
17
|
+
|
|
18
|
+
// The installer anchors ROOT at <its dir>/.. — deploy it into a fixture's scripts/ like a consumer.
|
|
19
|
+
const mkProject = () => {
|
|
20
|
+
const root = mkdtempSync(join(tmpdir(), 'install-hooks-'));
|
|
21
|
+
const g = (...a) => spawnSync('git', a, { cwd: root, encoding: 'utf8' });
|
|
22
|
+
g('init', '-q');
|
|
23
|
+
g('config', 'user.email', 'p@e');
|
|
24
|
+
g('config', 'user.name', 'p');
|
|
25
|
+
mkdirSync(join(root, 'scripts'), { recursive: true });
|
|
26
|
+
cpSync(INSTALLER_SRC, join(root, 'scripts', 'install-git-hooks.mjs'));
|
|
27
|
+
writeFileSync(join(root, 'guard.mjs'), '// a stand-in commit-guard tool\n');
|
|
28
|
+
writeFileSync(join(root, 'base.txt'), 'base\n');
|
|
29
|
+
g('add', '-A');
|
|
30
|
+
g('commit', '-qm', 'base');
|
|
31
|
+
return { root, g };
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const runInstaller = (root, args = []) =>
|
|
35
|
+
spawnSync('node', [join(root, 'scripts', 'install-git-hooks.mjs'), ...args], { cwd: root, encoding: 'utf8' });
|
|
36
|
+
|
|
37
|
+
const hooksPathOf = (root) => {
|
|
38
|
+
const r = spawnSync('git', ['rev-parse', '--git-path', 'hooks'], { cwd: root, encoding: 'utf8' });
|
|
39
|
+
return resolve(root, r.stdout.trim());
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
describe('install-git-hooks — the hooks path comes from git plumbing (C7)', () => {
|
|
43
|
+
it('a normal repo installs at the git-path hooks location', () => {
|
|
44
|
+
const { root } = mkProject();
|
|
45
|
+
const r = runInstaller(root);
|
|
46
|
+
assert.equal(r.status, 0, r.stderr);
|
|
47
|
+
assert.ok(existsSync(join(hooksPathOf(root), 'pre-commit')), 'the hook lands where git says hooks live');
|
|
48
|
+
rmSync(root, { recursive: true, force: true });
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('a LINKED WORKTREE installs at ITS OWN git-path hooks (never a hardcoded <worktree>/.git/hooks)', () => {
|
|
52
|
+
const { root, g } = mkProject();
|
|
53
|
+
const wt = join(root, '..', `wt-${Date.now() % 1e6}`);
|
|
54
|
+
g('worktree', 'add', '-q', wt);
|
|
55
|
+
mkdirSync(join(wt, 'scripts'), { recursive: true });
|
|
56
|
+
cpSync(INSTALLER_SRC, join(wt, 'scripts', 'install-git-hooks.mjs'));
|
|
57
|
+
const r = runInstaller(wt);
|
|
58
|
+
assert.equal(r.status, 0, r.stderr);
|
|
59
|
+
const expected = join(hooksPathOf(wt), 'pre-commit');
|
|
60
|
+
assert.ok(existsSync(expected), `the worktree hook lands at git's own answer (${expected})`);
|
|
61
|
+
assert.ok(!existsSync(join(wt, '.git', 'hooks', 'pre-commit')), 'never a literal <worktree>/.git/hooks (a worktree .git is a FILE)');
|
|
62
|
+
rmSync(wt, { recursive: true, force: true });
|
|
63
|
+
rmSync(root, { recursive: true, force: true });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('install-git-hooks — the commit-guard arm persists (C8)', () => {
|
|
68
|
+
it('an armed guard SURVIVES a later flagless re-run (carried forward from the managed hook)', () => {
|
|
69
|
+
const { root } = mkProject();
|
|
70
|
+
assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
|
|
71
|
+
const armed = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
|
|
72
|
+
assert.match(armed, /guard\.mjs" --check/, 'the guard line is written armed');
|
|
73
|
+
assert.equal(runInstaller(root).status, 0, 'the flagless canonical re-run succeeds');
|
|
74
|
+
const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
|
|
75
|
+
assert.match(after, /guard\.mjs" --check/, 'the armed guard line survives a flagless re-run');
|
|
76
|
+
rmSync(root, { recursive: true, force: true });
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it('--no-commit-guard is the ONLY consented disable; combining both flags is a usage error', () => {
|
|
80
|
+
const { root } = mkProject();
|
|
81
|
+
assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
|
|
82
|
+
assert.equal(runInstaller(root, ['--no-commit-guard']).status, 0);
|
|
83
|
+
const after = readFileSync(join(hooksPathOf(root), 'pre-commit'), 'utf8');
|
|
84
|
+
assert.doesNotMatch(after, /" --check$/m, 'the guard line is removed on the explicit disable');
|
|
85
|
+
const both = runInstaller(root, ['--commit-guard', 'guard.mjs', '--no-commit-guard']);
|
|
86
|
+
assert.equal(both.status, 2, 'both flags together are a usage error, never an order-dependent pick');
|
|
87
|
+
rmSync(root, { recursive: true, force: true });
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('a DUPLICATED/malformed guard line in the managed hook fails CLOSED on a flagless re-run', () => {
|
|
91
|
+
const { root } = mkProject();
|
|
92
|
+
assert.equal(runInstaller(root, ['--commit-guard', 'guard.mjs']).status, 0);
|
|
93
|
+
const hookPath = join(hooksPathOf(root), 'pre-commit');
|
|
94
|
+
const armed = readFileSync(hookPath, 'utf8');
|
|
95
|
+
const guardLine = armed.split('\n').find((l) => /" --check$/.test(l));
|
|
96
|
+
writeFileSync(hookPath, `${armed}${guardLine}\n`); // duplicate the guard line
|
|
97
|
+
const r = runInstaller(root);
|
|
98
|
+
assert.equal(r.status, 1, 'two guard lines are ambiguous — fail closed, never guess');
|
|
99
|
+
assert.match(r.stderr, /guard/i);
|
|
100
|
+
rmSync(root, { recursive: true, force: true });
|
|
101
|
+
});
|
|
102
|
+
});
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
// migrate-gates-branches.test.mjs — the refusal/no-op branch pins the main spec file leaves
|
|
2
|
+
// unexercised (colocated so the D3(d) changed-line check reads real executions, not intentions):
|
|
3
|
+
// the --help arm, the missing-declaration no-op, the invalid-shape STOP, the honest no-op split
|
|
4
|
+
// on final-run-capability, the mid-write parent-verification failure (fail closed + tmp cleanup),
|
|
5
|
+
// and the loud-but-non-fatal retired-store cleanup error.
|
|
6
|
+
|
|
7
|
+
import { describe, it } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, readdirSync, lstatSync, rmSync, symlinkSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { spawnSync } from 'node:child_process';
|
|
13
|
+
import { UNIT_TESTS_COVERAGE_FLAGS, RETIRED_STORE_BASENAMES, main } from './migrate-gates.mjs';
|
|
14
|
+
|
|
15
|
+
const KIT_TOOLS = mkdtempSync(join(tmpdir(), 'migrate-branches-kit-'));
|
|
16
|
+
writeFileSync(join(KIT_TOOLS, 'coverage-check.mjs'), '// the installed checker the migration points at\n');
|
|
17
|
+
|
|
18
|
+
const mkProject = (gates) => {
|
|
19
|
+
const root = mkdtempSync(join(tmpdir(), 'migrate-branches-'));
|
|
20
|
+
mkdirSync(join(root, 'docs', 'ai'), { recursive: true });
|
|
21
|
+
if (gates !== undefined) {
|
|
22
|
+
writeFileSync(join(root, 'docs', 'ai', 'gates.json'), typeof gates === 'string' ? gates : `${JSON.stringify({ gates }, null, 2)}\n`);
|
|
23
|
+
}
|
|
24
|
+
return root;
|
|
25
|
+
};
|
|
26
|
+
const quiet = () => {
|
|
27
|
+
const out = [];
|
|
28
|
+
const err = [];
|
|
29
|
+
return { log: (l) => out.push(String(l)), error: (l) => err.push(String(l)), out, err };
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const UNIT_DONE = { id: 'unit-tests', title: 'U', cmd: `node --test ${UNIT_TESTS_COVERAGE_FLAGS} tools/*.test.mjs` };
|
|
33
|
+
const CHECKER = { id: 'coverage-check', title: 'CC', cmd: `node "${join(KIT_TOOLS, 'coverage-check.mjs')}" --check` };
|
|
34
|
+
const REVIEW_STATE = { id: 'review-state', title: 'RS', cmd: `node "${join(KIT_TOOLS, 'review-state.mjs')}" --check` };
|
|
35
|
+
const LEGACY = { id: 'review-ledger', title: 'L', cmd: 'node "/kit/tools/review-ledger.mjs" --check' };
|
|
36
|
+
const UNIT = { id: 'unit-tests', title: 'U', cmd: 'node --test tools/*.test.mjs' };
|
|
37
|
+
|
|
38
|
+
describe('migrate-gates — refusal and no-op branches', () => {
|
|
39
|
+
it('--help prints the contract and exits 0', () => {
|
|
40
|
+
const io = quiet();
|
|
41
|
+
assert.equal(main(['--help'], io), 0);
|
|
42
|
+
assert.match(io.out.join('\n'), /Usage:/);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('the no-op split is HONEST about final-run-capability (capable names it; incapable never does)', () => {
|
|
46
|
+
const capable = mkProject([UNIT_DONE, REVIEW_STATE, CHECKER]);
|
|
47
|
+
const io = quiet();
|
|
48
|
+
assert.equal(main(['--cwd', capable, '--kit-tools', KIT_TOOLS], io), 0);
|
|
49
|
+
assert.match(io.out.join('\n'), /already final-run-capable/);
|
|
50
|
+
rmSync(capable, { recursive: true, force: true });
|
|
51
|
+
|
|
52
|
+
const incapable = mkProject([UNIT_DONE, CHECKER]);
|
|
53
|
+
const io2 = quiet();
|
|
54
|
+
assert.equal(main(['--cwd', incapable, '--kit-tools', KIT_TOOLS], io2), 0);
|
|
55
|
+
const text = io2.out.join('\n');
|
|
56
|
+
assert.match(text, /nothing to migrate mechanically/);
|
|
57
|
+
assert.match(text, /NOT final-run-capable/);
|
|
58
|
+
assert.doesNotMatch(text, /already final-run-capable/);
|
|
59
|
+
rmSync(incapable, { recursive: true, force: true });
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('docs/ai WITHOUT a gates.json (and no stores) is a stated no-op, never a crash', () => {
|
|
63
|
+
const root = mkProject(undefined);
|
|
64
|
+
const io = quiet();
|
|
65
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0);
|
|
66
|
+
assert.match(io.out.join('\n'), /nothing to migrate/);
|
|
67
|
+
rmSync(root, { recursive: true, force: true });
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('a parseable-but-invalid declaration ({ gates: <non-array> }) is a loud STOP', () => {
|
|
71
|
+
const root = mkProject('{ "gates": 5 }\n');
|
|
72
|
+
const io = quiet();
|
|
73
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 1);
|
|
74
|
+
assert.match(io.err.join('\n'), /not a \{ gates/);
|
|
75
|
+
rmSync(root, { recursive: true, force: true });
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it('an lstat failure DURING the write fails CLOSED — nothing written, the tmp cleaned', () => {
|
|
79
|
+
const root = mkProject([LEGACY, UNIT]);
|
|
80
|
+
const before = readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8');
|
|
81
|
+
const tmpExists = () => readdirSync(join(root, 'docs', 'ai')).some((f) => f.endsWith('.tmp'));
|
|
82
|
+
const io = {
|
|
83
|
+
...quiet(),
|
|
84
|
+
// Fail the parent verification the moment the tmp file exists — that is exactly the
|
|
85
|
+
// PRE-RENAME re-check, whatever the lstat call count is (count-anchored injection went
|
|
86
|
+
// silently stale once loadDeclaration became an lstat consumer too).
|
|
87
|
+
lstat: (p) => {
|
|
88
|
+
if (tmpExists()) throw Object.assign(new Error('injected EACCES'), { code: 'EACCES' });
|
|
89
|
+
return lstatSync(p);
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 1);
|
|
93
|
+
assert.match(io.err.join('\n'), /cannot verify the declaration parent/);
|
|
94
|
+
assert.equal(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8'), before, 'the declaration is untouched');
|
|
95
|
+
assert.deepEqual(readdirSync(join(root, 'docs', 'ai')).filter((f) => f.endsWith('.tmp')), [], 'no tmp litter survives (the writer catch cleaned it)');
|
|
96
|
+
rmSync(root, { recursive: true, force: true });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('a NON-canonical entry squatting the checker id is a loud STOP on preview AND apply (never a duplicate row)', () => {
|
|
100
|
+
const squatter = { id: 'coverage-check', title: 'C', cmd: 'node scripts/coverage-check.mjs --check' };
|
|
101
|
+
const root = mkProject([squatter, UNIT]);
|
|
102
|
+
for (const argv of [['--cwd', root, '--kit-tools', KIT_TOOLS], ['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply']]) {
|
|
103
|
+
const io = quiet();
|
|
104
|
+
assert.equal(main(argv, io), 1, `must STOP: ${argv.join(' ')}`);
|
|
105
|
+
assert.match(io.err.join('\n'), /id collision/);
|
|
106
|
+
}
|
|
107
|
+
const after = JSON.parse(readFileSync(join(root, 'docs', 'ai', 'gates.json'), 'utf8')).gates;
|
|
108
|
+
assert.equal(after.filter((g) => g.id === 'coverage-check').length, 1, 'nothing was written');
|
|
109
|
+
rmSync(root, { recursive: true, force: true });
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('a canonical checker addressed THROUGH A SYMLINKED kit dir is recognized (realpath, never lexical)', () => {
|
|
113
|
+
const linked = join(tmpdir(), `migrate-branches-link-${process.pid}`);
|
|
114
|
+
rmSync(linked, { force: true });
|
|
115
|
+
symlinkSync(KIT_TOOLS, linked);
|
|
116
|
+
const throughLink = { id: 'coverage-check', title: 'CC', cmd: `node "${join(linked, 'coverage-check.mjs')}" --check` };
|
|
117
|
+
const root = mkProject([throughLink, UNIT]);
|
|
118
|
+
const io = quiet();
|
|
119
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS], io), 0, io.err.join('\n'));
|
|
120
|
+
const text = io.out.join('\n');
|
|
121
|
+
assert.doesNotMatch(text, /id collision/, 'the symlinked canonical checker is never a collision');
|
|
122
|
+
assert.doesNotMatch(text, /ADD coverage-check/, 'no duplicate checker is added');
|
|
123
|
+
rmSync(linked, { force: true });
|
|
124
|
+
rmSync(root, { recursive: true, force: true });
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('a NON-ENOENT lstat failure on the gates.json LEAF is a loud STOP — never read as "missing"', () => {
|
|
128
|
+
const root = mkProject([LEGACY, UNIT]);
|
|
129
|
+
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
|
|
130
|
+
writeFileSync(join(root, '.git', RETIRED_STORE_BASENAMES[0]), '{"dead":1}\n');
|
|
131
|
+
const io = {
|
|
132
|
+
...quiet(),
|
|
133
|
+
lstat: (p) => {
|
|
134
|
+
if (p.endsWith('gates.json')) throw Object.assign(new Error('injected EACCES'), { code: 'EACCES' });
|
|
135
|
+
return lstatSync(p);
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 1, 'an unreadable declaration must STOP the apply');
|
|
139
|
+
assert.match(io.err.join('\n'), /EACCES/);
|
|
140
|
+
assert.ok(lstatSync(join(root, '.git', RETIRED_STORE_BASENAMES[0])).isFile(), 'the retired store is untouched on the STOP');
|
|
141
|
+
rmSync(root, { recursive: true, force: true });
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('an un-unlinkable retired store is reported LOUDLY and never fails the migration', () => {
|
|
145
|
+
const root = mkProject([LEGACY, UNIT]);
|
|
146
|
+
spawnSync('git', ['init', '-q'], { cwd: root, encoding: 'utf8' });
|
|
147
|
+
// A DIRECTORY at the store path: existsSync sees it, unlink refuses (EISDIR/EPERM) — the
|
|
148
|
+
// deterministic un-unlinkable shape (permission bits vary by runner; a dir does not).
|
|
149
|
+
mkdirSync(join(root, '.git', RETIRED_STORE_BASENAMES[0]), { recursive: true });
|
|
150
|
+
const io = quiet();
|
|
151
|
+
assert.equal(main(['--cwd', root, '--kit-tools', KIT_TOOLS, '--apply'], io), 0);
|
|
152
|
+
const text = io.out.join('\n');
|
|
153
|
+
assert.match(text, /could not clean .*review-ledger\.jsonl/, 'the cleanup failure is named');
|
|
154
|
+
assert.match(text, /migrated .*gates\.json/, 'the migration itself still landed');
|
|
155
|
+
rmSync(root, { recursive: true, force: true });
|
|
156
|
+
});
|
|
157
|
+
});
|