@boyingliu01/xp-gate 0.31.1 → 0.31.3
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/adapter-common.sh +41 -3
- package/adapters/gate-3.sh +8 -1
- package/adapters/gate-8.sh +67 -6
- package/adapters/go.sh +18 -1
- package/adapters/iac.sh +18 -1
- package/adapters/powershell.sh +37 -1
- package/adapters/python.sh +18 -4
- package/gate-3.sh +254 -10
- package/gate-8.sh +67 -6
- package/hooks/adapter-common.sh +41 -3
- package/hooks/gate-3.sh +254 -10
- package/hooks/gate-8.sh +67 -6
- package/hooks/lib/Measure-PowerShellComplexity.ps1 +186 -0
- package/hooks/lib/coverage-total.sh +20 -5
- package/hooks/lib/gate-5c-alignment.sh +145 -0
- package/hooks/lib/jscpd-run.sh +40 -1
- package/hooks/lib/native-path.sh +106 -0
- package/hooks/lib/prepush-score.py +143 -0
- package/hooks/lib/sprint-gate-report.sh +23 -0
- package/hooks/lib/toolchain-path.sh +370 -0
- package/hooks/lib/validate-code-walkthrough.cjs +224 -23
- package/hooks/pre-commit +403 -165
- package/hooks/pre-push +192 -74
- package/hooks/sprint-gate.sh +14 -3
- package/lib/__tests__/check-alignment-cli.test.mjs +20 -3
- package/lib/__tests__/check-alignment-reasons.test.mjs +91 -0
- package/lib/__tests__/doctor-delphi.test.js +46 -0
- package/lib/__tests__/doctor-exec-retry.test.js +20 -10
- package/lib/__tests__/doctor-hook-signatures.test.js +65 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +12 -1
- package/lib/__tests__/doctor.test.js +7 -1
- package/lib/__tests__/hook-health-path.test.js +9 -1
- package/lib/__tests__/issue-566-sync-hooks-backup.test.js +221 -0
- package/lib/__tests__/issue-583-hook-deploy-log.test.js +121 -0
- package/lib/__tests__/issue-586-principles-cmdline.test.js +183 -0
- package/lib/__tests__/issue-589-check-all-gate3.test.js +190 -0
- package/lib/__tests__/issue-590-archlint-vacuous-pass.test.js +138 -0
- package/lib/__tests__/shared-paths-lazy-home.test.js +67 -0
- package/lib/arch.js +261 -44
- package/lib/check-alignment-runner.cjs +23 -0
- package/lib/check.js +4 -1
- package/lib/doctor.js +194 -49
- package/lib/gate-runner.js +265 -58
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +228 -6
- package/lib/shared-paths.js +53 -19
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +50 -0
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +50 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-config-env-diagnostics.test.ts +210 -0
- package/plugins/dsh/src/delphi-config.ts +151 -9
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +50 -0
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +50 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/issue-587-java-member-count.test.ts +279 -0
- package/principles/__tests__/issue-588-lsp-java-false-positives.test.ts +213 -0
- package/principles/adapters/java.ts +127 -11
- package/principles/rules/__tests__/solid/issue-588-lsp-base-contract.test.ts +252 -0
- package/principles/rules/__tests__/solid/lsp.test.ts +26 -22
- package/principles/rules/solid/lsp.ts +348 -23
- package/scripts/__tests__/sync-package-content.test.js +32 -1
- package/scripts/sync-package-content.js +6 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +50 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
- package/sprint-gate.sh +14 -3
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-2 xp-gate doctor
|
|
3
|
+
* @intent Prove that the SHIPPED hook files still carry the exact signature
|
|
4
|
+
* strings doctor uses to decide "this file is an xp-gate hook".
|
|
5
|
+
* @covers AC-05, AC-10
|
|
6
|
+
*
|
|
7
|
+
* Why this exists (#599): doctor identifies an installed hook by substring
|
|
8
|
+
* (`SIGNATURES`). The signatures are prose taken from each hook's own banner, so
|
|
9
|
+
* an innocuous editorial change to a banner line — replacing the em dash in
|
|
10
|
+
* `# Pre-push Hook — 8 gates:` with an ASCII hyphen, say — silently makes every
|
|
11
|
+
* installed pre-push hook unrecognisable. doctor then reports
|
|
12
|
+
* `Hooks: pre-push: Missing or not xp-gate` on a perfectly healthy install and
|
|
13
|
+
* `--fix` cannot clear it, because there is nothing wrong to fix.
|
|
14
|
+
*
|
|
15
|
+
* That is exactly what happened while rewriting the CRLF self-check in #546: the
|
|
16
|
+
* banner was retyped, the dash changed, and 9 doctor `--fix` cases went red with
|
|
17
|
+
* "expected 1 to be +0" — an assertion far from the actual cause. This test
|
|
18
|
+
* turns that class of failure into a one-line diagnostic naming the file and the
|
|
19
|
+
* missing string.
|
|
20
|
+
*/
|
|
21
|
+
const fs = require('fs');
|
|
22
|
+
const path = require('path');
|
|
23
|
+
|
|
24
|
+
describe('shipped hooks carry doctor SIGNATURES', () => {
|
|
25
|
+
const repoRoot = path.resolve(__dirname, '../../../..');
|
|
26
|
+
const { SIGNATURES } = require('../doctor');
|
|
27
|
+
|
|
28
|
+
// The hook files that this repository actually ships, in both mirrors.
|
|
29
|
+
const SHIPPED_HOOKS = [
|
|
30
|
+
{ sig: 'pre-commit', file: 'githooks/pre-commit' },
|
|
31
|
+
{ sig: 'pre-commit', file: 'src/npm-package/hooks/pre-commit' },
|
|
32
|
+
{ sig: 'pre-push', file: 'githooks/pre-push' },
|
|
33
|
+
{ sig: 'pre-push', file: 'src/npm-package/hooks/pre-push' },
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
for (const { sig, file } of SHIPPED_HOOKS) {
|
|
37
|
+
it(`${file} contains SIGNATURES['${sig}']`, () => {
|
|
38
|
+
const abs = path.join(repoRoot, file);
|
|
39
|
+
expect(fs.existsSync(abs), `${file} does not exist`).toBe(true);
|
|
40
|
+
const content = fs.readFileSync(abs, 'utf8');
|
|
41
|
+
|
|
42
|
+
expect(
|
|
43
|
+
content.includes(SIGNATURES[sig]),
|
|
44
|
+
`${file} does not contain ${JSON.stringify(SIGNATURES[sig])}.\n`
|
|
45
|
+
+ 'doctor identifies an xp-gate hook by this exact substring, so an '
|
|
46
|
+
+ 'edit to the banner line silently makes every installed hook look '
|
|
47
|
+
+ 'foreign. Restore the string, or update SIGNATURES in doctor.js in '
|
|
48
|
+
+ 'the same commit (and keep the mirrors byte-identical).'
|
|
49
|
+
).toBe(true);
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
it('the two hook mirrors are byte-identical', () => {
|
|
54
|
+
for (const name of ['pre-commit', 'pre-push']) {
|
|
55
|
+
const a = path.join(repoRoot, 'githooks', name);
|
|
56
|
+
const b = path.join(repoRoot, 'src/npm-package/hooks', name);
|
|
57
|
+
expect(fs.existsSync(a), a).toBe(true);
|
|
58
|
+
expect(fs.existsSync(b), b).toBe(true);
|
|
59
|
+
expect(
|
|
60
|
+
fs.readFileSync(a, 'utf8') === fs.readFileSync(b, 'utf8'),
|
|
61
|
+
`githooks/${name} and src/npm-package/hooks/${name} differ`
|
|
62
|
+
).toBe(true);
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -50,10 +50,21 @@ describe('doctor path equivalence (#496)', () => {
|
|
|
50
50
|
);
|
|
51
51
|
// The two comparison sites that made the Windows call fail must both route
|
|
52
52
|
// through the equivalence check rather than a byte comparison.
|
|
53
|
+
//
|
|
54
|
+
// #599: this used to match the literal `pathsEquivalent(hooksPath,
|
|
55
|
+
// GLOBAL_HOOKS_DIR)`. GLOBAL_HOOKS_DIR became a function when the path
|
|
56
|
+
// constants stopped being captured at require() time (they follow
|
|
57
|
+
// process.env.HOME now), so the call site reads `GLOBAL_HOOKS_DIR()`. A
|
|
58
|
+
// literal match made that a test failure instead of a grammar change.
|
|
59
|
+
// The invariant is "the hooksPath comparison goes through pathsEquivalent
|
|
60
|
+
// and passes the global hooks dir to it" — match that, not its spelling.
|
|
53
61
|
const callSites = source
|
|
54
62
|
.split('\n')
|
|
55
63
|
.filter((line) => /hooksPath\s*!==\s*GLOBAL_HOOKS_DIR/.test(line));
|
|
56
64
|
expect(callSites).toHaveProperty('length', 0);
|
|
57
|
-
|
|
65
|
+
|
|
66
|
+
expect(source).toMatch(
|
|
67
|
+
/pathsEquivalent\(\s*hooksPath\s*,\s*GLOBAL_HOOKS_DIR(?:\(\))?\s*\)/
|
|
68
|
+
);
|
|
58
69
|
});
|
|
59
70
|
});
|
|
@@ -951,7 +951,13 @@ describe('doctor', () => {
|
|
|
951
951
|
|
|
952
952
|
// === --fix TUI registration ===
|
|
953
953
|
|
|
954
|
-
|
|
954
|
+
// 15s, not vitest's 5s default (#588). This case runs the full `doctor(['--fix'])`
|
|
955
|
+
// pipeline over a real temp install: it writes hooks, adapters and tui.json and
|
|
956
|
+
// re-diagnoses. Measured in isolation it takes ~3.8s, which is already 76% of the
|
|
957
|
+
// default budget — so under the full suite (where 200 files share the machine) it
|
|
958
|
+
// crossed 5s and failed with "Test timed out in 5000ms". The work is legitimate;
|
|
959
|
+
// the budget was simply too tight for it. Similar cases in this file take 2-4s.
|
|
960
|
+
it('--fix: diagnoses TUI not registered then re-diagnoses after fix', { timeout: 15000 }, async () => {
|
|
955
961
|
setupLocalInstall();
|
|
956
962
|
seedVersionCache();
|
|
957
963
|
mockExecSuccess();
|
|
@@ -72,7 +72,15 @@ describe('bash -n path handling', () => {
|
|
|
72
72
|
);
|
|
73
73
|
});
|
|
74
74
|
|
|
75
|
-
|
|
75
|
+
// Windows-only by construction: the assertion below requires path.join to
|
|
76
|
+
// produce a backslash path (`C:\...`), which only happens on win32. On Linux
|
|
77
|
+
// path.join yields `/tmp/...` and the test fails with
|
|
78
|
+
// expected '/tmp/hook-posix-XiaI9b/pre-push' to contain '\'
|
|
79
|
+
// That is a platform premise, not a regression (#586). Skipping keeps the gap
|
|
80
|
+
// visible in the run summary instead of painting CI red on every build.
|
|
81
|
+
const onWindows = process.platform === 'win32';
|
|
82
|
+
|
|
83
|
+
test.skipIf(!onWindows)('AC-566-02: a healthy hook under a native Windows path is parseable', () => {
|
|
76
84
|
// The defect, end to end. The path MUST come from path.join, not from a
|
|
77
85
|
// string literal: path.join produces the single-backslash form that Node
|
|
78
86
|
// hands to spawnSync, and that is the form bash mangles. A hand-written
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-599
|
|
3
|
+
* @intent #566 was a hook that existed on exactly one machine and in no commit:
|
|
4
|
+
* the installed `~/.config/xp-gate/hooks/pre-push` carried a local
|
|
5
|
+
* `_score()` helper plus `gates_skipped` that no branch had ever held,
|
|
6
|
+
* and `xp-gate doctor --sync-hooks` replaced it without a word. The repo
|
|
7
|
+
* side then looked correct and the only copy of that work was gone.
|
|
8
|
+
*
|
|
9
|
+
* doctor already REFUSES to overwrite a file whose content diverges, and
|
|
10
|
+
* says "there is no automatic backup ... nothing records the previous
|
|
11
|
+
* version". That refusal covers divergence; it does not cover the
|
|
12
|
+
* legitimate overwrites that also destroy a previous version -- `--force`,
|
|
13
|
+
* and files that differ only in whitespace. So the loss #566 describes
|
|
14
|
+
* was still possible after the refusal landed.
|
|
15
|
+
*
|
|
16
|
+
* These cases assert that every overwrite keeps the previous version,
|
|
17
|
+
* in a `backups/` directory beside the installed hooks (not in the user's
|
|
18
|
+
* work tree), timestamped so a later sync cannot destroy an earlier
|
|
19
|
+
* backup.
|
|
20
|
+
* @covers AC-599-13
|
|
21
|
+
*/
|
|
22
|
+
const fs = require('fs');
|
|
23
|
+
const path = require('path');
|
|
24
|
+
const os = require('os');
|
|
25
|
+
|
|
26
|
+
const DESCRIBE_ID = String(Math.random().toString(36).slice(2, 8));
|
|
27
|
+
|
|
28
|
+
describe('doctor --sync-hooks keeps the previous version (#566)', () => {
|
|
29
|
+
let tmpHome;
|
|
30
|
+
let originalHome;
|
|
31
|
+
let originalUserProfile;
|
|
32
|
+
|
|
33
|
+
beforeAll(() => {
|
|
34
|
+
originalHome = process.env.HOME;
|
|
35
|
+
originalUserProfile = process.env.USERPROFILE;
|
|
36
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), `xp-gate-566-home-${DESCRIBE_ID}-`));
|
|
37
|
+
process.env.HOME = tmpHome;
|
|
38
|
+
process.env.USERPROFILE = tmpHome;
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
afterAll(() => {
|
|
42
|
+
process.env.HOME = originalHome;
|
|
43
|
+
process.env.USERPROFILE = originalUserProfile;
|
|
44
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
function loadDoctor() {
|
|
48
|
+
return require('../doctor');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Build a repo-like githooks/ plus an installed hooks dir.
|
|
53
|
+
* @param {{installedPrePush?: string}} [opts] content for the installed pre-push
|
|
54
|
+
*/
|
|
55
|
+
function makeLayout(opts = {}) {
|
|
56
|
+
const root = fs.mkdtempSync(path.join(tmpHome, `layout-${DESCRIBE_ID}-`));
|
|
57
|
+
const repoRoot = path.join(root, 'repo');
|
|
58
|
+
const repoHooks = path.join(repoRoot, 'githooks');
|
|
59
|
+
const installedHooks = path.join(root, 'installed-hooks');
|
|
60
|
+
fs.mkdirSync(repoHooks, { recursive: true });
|
|
61
|
+
fs.mkdirSync(installedHooks, { recursive: true });
|
|
62
|
+
|
|
63
|
+
// The canonical hook carries the xp-gate signature doctor looks for.
|
|
64
|
+
fs.writeFileSync(
|
|
65
|
+
path.join(repoHooks, 'pre-push'),
|
|
66
|
+
'#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# Pre-push Hook — 8 gates: Gate 10\necho canonical\n',
|
|
67
|
+
);
|
|
68
|
+
if (opts.installedPrePush !== undefined) {
|
|
69
|
+
fs.writeFileSync(path.join(installedHooks, 'pre-push'), opts.installedPrePush);
|
|
70
|
+
}
|
|
71
|
+
return { root, repoRoot, repoHooks, installedHooks };
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
it('AC-599-13: overwriting an existing hook keeps a copy of the previous version', () => {
|
|
75
|
+
const { repoHooks, installedHooks } = makeLayout({
|
|
76
|
+
installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# GHOST LOCAL EDIT\n',
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
80
|
+
const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
|
|
81
|
+
|
|
82
|
+
expect(result.synced).toContain('pre-push');
|
|
83
|
+
expect(result.backups.length).toBeGreaterThan(0);
|
|
84
|
+
|
|
85
|
+
const backup = result.backups.find((b) => path.basename(b).startsWith('pre-push.'));
|
|
86
|
+
expect(backup).toBeTruthy();
|
|
87
|
+
expect(fs.readFileSync(backup, 'utf8')).toContain('GHOST LOCAL EDIT');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('AC-599-13: the backup lives beside the installed hooks, not in a work tree', () => {
|
|
91
|
+
const { repoRoot, repoHooks, installedHooks } = makeLayout({
|
|
92
|
+
installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# local only\n',
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
96
|
+
syncGlobalHooksFromRepo({ repoHooks, installedHooks });
|
|
97
|
+
|
|
98
|
+
const backupsDir = path.join(installedHooks, 'backups');
|
|
99
|
+
expect(fs.existsSync(backupsDir)).toBe(true);
|
|
100
|
+
// Nothing was written into the repository: a backup must not become the
|
|
101
|
+
// user's problem to clean out of `git status`.
|
|
102
|
+
const repoEntries = fs.readdirSync(repoRoot).sort();
|
|
103
|
+
expect(repoEntries).toEqual(['githooks']);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
it('AC-599-13: installing into an empty hooks dir creates no backup', () => {
|
|
107
|
+
// Nothing was overwritten, so there is nothing to keep -- a backup directory
|
|
108
|
+
// of empty files would be noise that trains the operator to ignore it.
|
|
109
|
+
const { repoHooks, installedHooks } = makeLayout();
|
|
110
|
+
|
|
111
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
112
|
+
const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
|
|
113
|
+
|
|
114
|
+
expect(result.synced).toContain('pre-push');
|
|
115
|
+
expect(result.backups).toEqual([]);
|
|
116
|
+
expect(fs.existsSync(path.join(installedHooks, 'backups'))).toBe(false);
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('AC-599-13: two successive overwrites keep both previous versions', () => {
|
|
120
|
+
// A timestamped name is the point: the second sync must not destroy the
|
|
121
|
+
// backup the first sync made, or the work it was protecting is lost anyway.
|
|
122
|
+
const { repoHooks, installedHooks } = makeLayout({
|
|
123
|
+
installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# EDIT ONE\n',
|
|
124
|
+
});
|
|
125
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
126
|
+
syncGlobalHooksFromRepo({ repoHooks, installedHooks });
|
|
127
|
+
|
|
128
|
+
fs.writeFileSync(path.join(installedHooks, 'pre-push'), '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# EDIT TWO\n');
|
|
129
|
+
const second = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
|
|
130
|
+
|
|
131
|
+
const backupsDir = path.join(installedHooks, 'backups');
|
|
132
|
+
const files = fs.readdirSync(backupsDir).filter((f) => f.startsWith('pre-push.'));
|
|
133
|
+
expect(files.length).toBe(2);
|
|
134
|
+
const contents = files.map((f) => fs.readFileSync(path.join(backupsDir, f), 'utf8'));
|
|
135
|
+
expect(contents.some((c) => c.includes('EDIT ONE'))).toBe(true);
|
|
136
|
+
expect(contents.some((c) => c.includes('EDIT TWO'))).toBe(true);
|
|
137
|
+
expect(second.backups.length).toBeGreaterThan(0);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('AC-599-13: an identical file is not re-copied and leaves no backup', () => {
|
|
141
|
+
const canonical = '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# Pre-push Hook — 8 gates: Gate 10\necho canonical\n';
|
|
142
|
+
const { repoHooks, installedHooks } = makeLayout({ installedPrePush: canonical });
|
|
143
|
+
|
|
144
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
145
|
+
const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks });
|
|
146
|
+
|
|
147
|
+
expect(result.synced).not.toContain('pre-push');
|
|
148
|
+
expect(result.skipped).toContain('pre-push');
|
|
149
|
+
expect(result.backups).toEqual([]);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('AC-599-13: shared lib/ libraries are backed up before being replaced too', () => {
|
|
153
|
+
// The lib/ channel overwrites unconditionally unless it refuses, so it is the
|
|
154
|
+
// channel most likely to erase machine-only hardening.
|
|
155
|
+
const { repoRoot, repoHooks, installedHooks } = makeLayout();
|
|
156
|
+
fs.mkdirSync(path.join(repoHooks, 'lib'), { recursive: true });
|
|
157
|
+
fs.writeFileSync(path.join(repoHooks, 'lib', 'coverage-total.sh'), 'echo canonical-lib\n');
|
|
158
|
+
fs.mkdirSync(path.join(installedHooks, 'lib'), { recursive: true });
|
|
159
|
+
fs.writeFileSync(path.join(installedHooks, 'lib', 'coverage-total.sh'), 'echo LOCAL-LIB-EDIT\n');
|
|
160
|
+
|
|
161
|
+
// Force, because otherwise the divergence guard refuses before the overwrite.
|
|
162
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
163
|
+
const result = syncGlobalHooksFromRepo({ repoRoot, repoHooks, installedHooks, force: true });
|
|
164
|
+
|
|
165
|
+
const backup = result.backups.find((b) => path.basename(b).startsWith('coverage-total.sh.'));
|
|
166
|
+
expect(backup).toBeTruthy();
|
|
167
|
+
expect(fs.readFileSync(backup, 'utf8')).toContain('LOCAL-LIB-EDIT');
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('AC-599-13: two syncs inside the same millisecond both keep their version', () => {
|
|
171
|
+
// The CI failure that motivated this case: "two successive overwrites keep
|
|
172
|
+
// both previous versions" passed locally and failed on a fast runner with
|
|
173
|
+
// `expected 1 to be 2`. The stamp has millisecond resolution, so when two
|
|
174
|
+
// syncs land in the SAME millisecond they compute the SAME backup name and
|
|
175
|
+
// the second copy silently destroys the first. Local runs usually straddled
|
|
176
|
+
// a millisecond boundary and never saw it.
|
|
177
|
+
//
|
|
178
|
+
// Pin Date so the collision is guaranteed rather than hoped for: asserting a
|
|
179
|
+
// bug that only appears under a timing race is how the suite went green while
|
|
180
|
+
// the defect shipped.
|
|
181
|
+
const { repoHooks, installedHooks } = makeLayout({
|
|
182
|
+
installedPrePush: '#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# SAME-MS-ONE\n',
|
|
183
|
+
});
|
|
184
|
+
const { syncGlobalHooksFromRepo } = loadDoctor();
|
|
185
|
+
|
|
186
|
+
// Freeze ONLY the clock the stamp reads. Nothing else is replaced: an earlier
|
|
187
|
+
// draft overrode Date.prototype.toISOString, which made the backup helper
|
|
188
|
+
// recurse into itself and threw "Maximum call stack size exceeded" -- the
|
|
189
|
+
// case then appeared to fail for a reason that had nothing to do with backups.
|
|
190
|
+
const RealDate = Date;
|
|
191
|
+
const frozen = 1767225600123; // any instant; only that it never advances
|
|
192
|
+
global.Date = class extends RealDate {
|
|
193
|
+
constructor(...args) {
|
|
194
|
+
if (args.length === 0) super(frozen);
|
|
195
|
+
else super(...args);
|
|
196
|
+
}
|
|
197
|
+
static now() {
|
|
198
|
+
return frozen;
|
|
199
|
+
}
|
|
200
|
+
};
|
|
201
|
+
try {
|
|
202
|
+
syncGlobalHooksFromRepo({ repoHooks, installedHooks, force: true });
|
|
203
|
+
|
|
204
|
+
fs.writeFileSync(
|
|
205
|
+
path.join(installedHooks, 'pre-push'),
|
|
206
|
+
'#!/usr/bin/env bash\n# XP-Gate Pre-Push Hook\n# SAME-MS-TWO\n',
|
|
207
|
+
);
|
|
208
|
+
syncGlobalHooksFromRepo({ repoHooks, installedHooks, force: true });
|
|
209
|
+
} finally {
|
|
210
|
+
global.Date = RealDate;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const backupsDir = path.join(installedHooks, 'backups');
|
|
214
|
+
const files = fs.readdirSync(backupsDir).filter((f) => f.startsWith('pre-push.'));
|
|
215
|
+
expect(files.length).toBe(2);
|
|
216
|
+
|
|
217
|
+
const contents = files.map((f) => fs.readFileSync(path.join(backupsDir, f), 'utf8'));
|
|
218
|
+
expect(contents.some((c) => c.includes('SAME-MS-ONE'))).toBe(true);
|
|
219
|
+
expect(contents.some((c) => c.includes('SAME-MS-TWO'))).toBe(true);
|
|
220
|
+
});
|
|
221
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-604
|
|
3
|
+
* @intent A deploy that overwrites an installed hook must leave a record of what
|
|
4
|
+
* it replaced and which version did it.
|
|
5
|
+
*
|
|
6
|
+
* #583: `xp-gate doctor --sync-hooks` overwrote `~/.config/xp-gate/hooks/
|
|
7
|
+
* pre-commit` three times in one day, each time silently. A downstream
|
|
8
|
+
* that had hardened its hooks (and kept the vendored copy in sync, with a
|
|
9
|
+
* consistency test guarding it) discovered it only by running its own
|
|
10
|
+
* SHA256 comparison. The consequences were not cosmetic: one overwrite
|
|
11
|
+
* reverted a fixed fail-open in Gate 5's coverage arm, so commits started
|
|
12
|
+
* passing on unmeasurable coverage again — a defect that had already been
|
|
13
|
+
* filed, fixed and tested.
|
|
14
|
+
*
|
|
15
|
+
* #566 added the backup, which makes the damaged file recoverable. It
|
|
16
|
+
* does not answer the question the downstream actually has: "was my patch
|
|
17
|
+
* eaten, and by which version?" A directory of timestamped `.bak` files
|
|
18
|
+
* can be read, but only by someone who already suspects an overwrite.
|
|
19
|
+
*
|
|
20
|
+
* So the invariant here is narrow and provable without a network, a
|
|
21
|
+
* registry, or a real install: when sync-hooks overwrites file X, one
|
|
22
|
+
* line is appended to a deploy log naming X, the version that did it, and
|
|
23
|
+
* the backup that holds the previous content. Nothing more.
|
|
24
|
+
*
|
|
25
|
+
* Deliberately NOT in scope (the issue's option 2): refusing to overwrite
|
|
26
|
+
* a locally-modified hook without `--force`. That changes the deploy
|
|
27
|
+
* contract for every consumer and needs a product decision, not a bug fix.
|
|
28
|
+
* @covers AC-604-01, AC-604-02, AC-604-03
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
const { syncGlobalHooksFromRepo } = require('../doctor.js');
|
|
36
|
+
|
|
37
|
+
function writeHook(dir, name, body) {
|
|
38
|
+
const p = path.join(dir, name);
|
|
39
|
+
try {
|
|
40
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
41
|
+
fs.writeFileSync(p, body, { mode: 0o755 });
|
|
42
|
+
} catch (err) {
|
|
43
|
+
// A fixture that cannot create its file must fail the case loudly, not leave
|
|
44
|
+
// a partially-built directory for the assertions to interpret (#583).
|
|
45
|
+
throw new Error(`fixture setup failed writing ${p}: ${err.message}`);
|
|
46
|
+
}
|
|
47
|
+
return p;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('REQ-604: sync-hooks leaves a deploy record', () => {
|
|
51
|
+
let tmp;
|
|
52
|
+
let repoHooks;
|
|
53
|
+
let installedHooks;
|
|
54
|
+
|
|
55
|
+
beforeEach(() => {
|
|
56
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-deploy-log-'));
|
|
57
|
+
repoHooks = path.join(tmp, 'repo', 'githooks');
|
|
58
|
+
installedHooks = path.join(tmp, 'installed', 'hooks');
|
|
59
|
+
fs.mkdirSync(installedHooks, { recursive: true });
|
|
60
|
+
// The banner line is the signature doctor uses to recognise an xp-gate hook.
|
|
61
|
+
writeHook(repoHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.31.1.0\necho new\n');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
afterEach(() => {
|
|
65
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('AC-604-01: an overwrite appends one line naming the file, the version and the backup', () => {
|
|
69
|
+
// The downstream's hardened copy: different from upstream, so it IS replaced.
|
|
70
|
+
writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.30.0.0\n# downstream hardening\necho old\n');
|
|
71
|
+
|
|
72
|
+
const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
|
|
73
|
+
|
|
74
|
+
expect(result.errors).toEqual([]);
|
|
75
|
+
expect(result.synced).toContain('pre-commit');
|
|
76
|
+
|
|
77
|
+
const logPath = path.join(installedHooks, 'deploy.log');
|
|
78
|
+
expect(fs.existsSync(logPath)).toBe(true);
|
|
79
|
+
|
|
80
|
+
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter(Boolean);
|
|
81
|
+
const entry = lines.find((l) => l.includes('pre-commit'));
|
|
82
|
+
expect(entry, `deploy log must name pre-commit, got:\n${lines.join('\n')}`).toBeTruthy();
|
|
83
|
+
|
|
84
|
+
// The three facts the downstream needs, all in that one line: which file,
|
|
85
|
+
// which version replaced it, and where the previous content went.
|
|
86
|
+
expect(entry).toMatch(/pre-commit/);
|
|
87
|
+
expect(entry).toMatch(/0\.31\.1\.0/);
|
|
88
|
+
expect(entry, `names the backup holding the old content, got: ${entry}`).toMatch(/\.bak/);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('AC-604-02: an unchanged file produces no deploy entry', () => {
|
|
92
|
+
// Identical content: sync skips it, so there is nothing to report. A log that
|
|
93
|
+
// records non-events is noise, and noise is what makes real overwrites
|
|
94
|
+
// invisible.
|
|
95
|
+
writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.31.1.0\necho new\n');
|
|
96
|
+
|
|
97
|
+
const result = syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
|
|
98
|
+
expect(result.skipped).toContain('pre-commit');
|
|
99
|
+
|
|
100
|
+
const logPath = path.join(installedHooks, 'deploy.log');
|
|
101
|
+
if (fs.existsSync(logPath)) {
|
|
102
|
+
const content = fs.readFileSync(logPath, 'utf8');
|
|
103
|
+
expect(content).not.toContain('pre-commit');
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it('AC-604-03: repeated overwrites accumulate rather than replacing history', () => {
|
|
108
|
+
// The whole point of the log is answering "how many times, and when" — one
|
|
109
|
+
// day saw three overwrites. Overwriting the log each run would destroy
|
|
110
|
+
// exactly the evidence the downstream needs.
|
|
111
|
+
writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.30.0.0\necho v1\n');
|
|
112
|
+
syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
|
|
113
|
+
|
|
114
|
+
writeHook(installedHooks, 'pre-commit', '#!/bin/bash\n# XP-GATE QUALITY GATES v0.30.0.0\necho v1-patched-by-downstream\n');
|
|
115
|
+
syncGlobalHooksFromRepo({ repoHooks, installedHooks, version: '0.31.1.0' });
|
|
116
|
+
|
|
117
|
+
const logPath = path.join(installedHooks, 'deploy.log');
|
|
118
|
+
const lines = fs.readFileSync(logPath, 'utf8').trim().split('\n').filter((l) => l.includes('pre-commit'));
|
|
119
|
+
expect(lines).toHaveLength(2);
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-613
|
|
3
|
+
* @intent `xp-gate check . --all` could never run Gate 4 on Windows:
|
|
4
|
+
*
|
|
5
|
+
* Failed to run principles check: The command line is too long.
|
|
6
|
+
*
|
|
7
|
+
* Two causes added together in `lib/principles.js`:
|
|
8
|
+
*
|
|
9
|
+
* 1. `shell: process.platform === 'win32'` routes through cmd.exe, whose
|
|
10
|
+
* command line is capped at 8191 characters (CreateProcess allows
|
|
11
|
+
* 32767).
|
|
12
|
+
* 2. every path is joined into ONE argv element — `files.join(' ')` — so
|
|
13
|
+
* the whole file list becomes part of that command line. Measured in
|
|
14
|
+
* the issue: 135 Java files came to 12944 characters before the
|
|
15
|
+
* `npx -y tsx <entry> --files --format json` prefix was added.
|
|
16
|
+
*
|
|
17
|
+
* This is not "the occasional huge project"; it is a mid-sized project on
|
|
18
|
+
* Windows, every time.
|
|
19
|
+
*
|
|
20
|
+
* Splitting the argv was explicitly ruled out in the issue and the reasoning
|
|
21
|
+
* holds: the bottleneck is total character count, not quoting, and `npx` on
|
|
22
|
+
* Windows still needs `shell: true` to be found at all.
|
|
23
|
+
*
|
|
24
|
+
* The other half of the issue is what the failure is CALLED. "Failed to run
|
|
25
|
+
* principles check" reads like a principles violation. It is not — the tool
|
|
26
|
+
* never ran. #596 fixed the same confusion in the shell toolchain
|
|
27
|
+
* (`classify_tool_failure`); this pins it for the JS side.
|
|
28
|
+
* @covers AC-613-01, AC-613-02, AC-613-03
|
|
29
|
+
*/
|
|
30
|
+
|
|
31
|
+
const fs = require('fs');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const path = require('path');
|
|
34
|
+
|
|
35
|
+
const principles = require('../principles.js');
|
|
36
|
+
|
|
37
|
+
// cmd.exe's command-line ceiling. Named rather than repeated: it is the number the
|
|
38
|
+
// whole issue is about, and a bare 8191 in several places reads as unrelated facts.
|
|
39
|
+
const CMD_EXE_LIMIT = 8191;
|
|
40
|
+
|
|
41
|
+
// The measured case from the issue: 135 Java files => 12944 characters, which blocked
|
|
42
|
+
// every `--all` run on Windows.
|
|
43
|
+
const ISSUE_FILE_COUNT = 135;
|
|
44
|
+
|
|
45
|
+
// Comfortably past the limit, used where the exact count does not matter — only that
|
|
46
|
+
// the list is too long to inline.
|
|
47
|
+
const LARGE_FILE_COUNT = 400;
|
|
48
|
+
|
|
49
|
+
/** A path-only string of roughly `len` characters (the join() payload). */
|
|
50
|
+
function longFileList(count) {
|
|
51
|
+
// Paths must be long enough that `count` of them realistically exceeds the
|
|
52
|
+
// Windows limit; the issue's own measurement was ~90 chars per path.
|
|
53
|
+
return Array.from(
|
|
54
|
+
{ length: count },
|
|
55
|
+
(_, i) =>
|
|
56
|
+
`src/main/java/com/example/deeply/nested/module${i}/service/implementation/File${i}ImplementationImpl.java`
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
describe('REQ-613: Gate 4 must not blow the Windows command-line limit', () => {
|
|
61
|
+
it('AC-613-01: the command line for a mid-sized Java project stays under cmd.exe\\u2019s character limit', () => {
|
|
62
|
+
// The measured case from the issue: 135 files, 12944 characters, blocked.
|
|
63
|
+
const files = longFileList(ISSUE_FILE_COUNT);
|
|
64
|
+
const joined = files.join(' ');
|
|
65
|
+
expect(joined.length).toBeGreaterThan(CMD_EXE_LIMIT);
|
|
66
|
+
|
|
67
|
+
const plan = principles.buildPrinciplesInvocation(files, 'json');
|
|
68
|
+
// The contract: whatever is handed to the OS is a single argument list, and the
|
|
69
|
+
// total of that list's characters is what must fit. A plan that still carries the
|
|
70
|
+
// whole list in one argument has not solved anything.
|
|
71
|
+
const longest = Math.max(...plan.args.map((a) => String(a).length));
|
|
72
|
+
expect(longest, `longest single argument is ${longest} chars`).toBeLessThan(CMD_EXE_LIMIT);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('AC-613-01: a large file list is passed by reference, not by value', () => {
|
|
76
|
+
// The fix that the issue recommends first: use the project's own tsx through
|
|
77
|
+
// node.exe (an absolute path, no shim, no shell) and keep the argv bounded. If a
|
|
78
|
+
// future change puts every path back on the command line, this fails.
|
|
79
|
+
const files = longFileList(LARGE_FILE_COUNT);
|
|
80
|
+
const plan = principles.buildPrinciplesInvocation(files, 'json');
|
|
81
|
+
|
|
82
|
+
const argvChars = plan.args.reduce((n, a) => n + String(a).length + 1, 0);
|
|
83
|
+
expect(argvChars, `argv carries ${argvChars} chars`).toBeLessThan(CMD_EXE_LIMIT);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('AC-613-02: the invocation never opts into a shell on any platform', () => {
|
|
87
|
+
// `shell: true` is what routes through cmd.exe and its 8191 limit. It must not be
|
|
88
|
+
// requested at all: the correct target is node.exe, which needs no shim.
|
|
89
|
+
const plan = principles.buildPrinciplesInvocation(['a.ts'], 'json');
|
|
90
|
+
expect(plan.useShell).toBe(false);
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('AC-613-02: the plan names an executable that needs no shell shim', () => {
|
|
94
|
+
// Either node.exe (preferred — absolute, no shim) or a resolved tool path. A bare
|
|
95
|
+
// 'npx' string would force the caller back to shell:true on Windows.
|
|
96
|
+
const plan = principles.buildPrinciplesInvocation(['a.ts'], 'json');
|
|
97
|
+
expect(plan.command.length).toBeGreaterThan(0);
|
|
98
|
+
expect(plan.command).not.toBe('npx');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('AC-613-03: a command-line-too-long failure is classified as a toolchain problem, not a finding', () => {
|
|
102
|
+
// The message used to be "Failed to run principles check", which reads as a code
|
|
103
|
+
// verdict. It never was one.
|
|
104
|
+
expect(principles.classifyPrinciplesFailure({ error: new Error('E2BIG') })).toBe('TOOLCHAIN');
|
|
105
|
+
expect(principles.classifyPrinciplesFailure({ error: new Error('spawn ENAMETOOLONG') })).toBe('TOOLCHAIN');
|
|
106
|
+
expect(
|
|
107
|
+
principles.classifyPrinciplesFailure({
|
|
108
|
+
stdout: 'The command line is too long.',
|
|
109
|
+
status: 1,
|
|
110
|
+
})
|
|
111
|
+
).toBe('TOOLCHAIN');
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it('AC-613-03: a real principles violation is still classified as a finding', () => {
|
|
115
|
+
// The control. If everything classified as TOOLCHAIN, Gate 4 could never block on
|
|
116
|
+
// an actual violation again — worse than the bug being fixed.
|
|
117
|
+
expect(principles.classifyPrinciplesFailure({ stdout: '1 violations total', status: 1 })).toBe('FINDING');
|
|
118
|
+
expect(principles.classifyPrinciplesFailure({ status: 1 })).toBe('FINDING');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('AC-613-01: a file list that fits is still passed directly', () => {
|
|
122
|
+
// Small lists must not be needlessly diverted; the direct path is well tested and
|
|
123
|
+
// has different failure semantics.
|
|
124
|
+
const plan = principles.buildPrinciplesInvocation(['src/a.ts', 'src/b.ts'], 'json');
|
|
125
|
+
const flat = plan.args.join(' ');
|
|
126
|
+
expect(flat).toContain('src/a.ts');
|
|
127
|
+
expect(flat).toContain('src/b.ts');
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
it('AC-613-01: the by-reference handoff writes the list somewhere the child can read', () => {
|
|
131
|
+
// Sanity on the mechanism itself: if the plan says "by reference", the reference
|
|
132
|
+
// has to be a real file path the child will be given, not a placeholder.
|
|
133
|
+
const files = longFileList(LARGE_FILE_COUNT);
|
|
134
|
+
const plan = principles.buildPrinciplesInvocation(files, 'json');
|
|
135
|
+
const refArg = plan.args.find((a) => String(a).startsWith('@'));
|
|
136
|
+
expect(refArg, 'no @file argument in the plan').toBeTruthy();
|
|
137
|
+
const refPath = refArg.slice(1);
|
|
138
|
+
expect(path.isAbsolute(refPath) || refPath.includes('tmp') || refPath.includes('TEMP') || refPath.toLowerCase().includes('temp')).toBe(true);
|
|
139
|
+
// And the file must not be left behind by merely planning.
|
|
140
|
+
expect(fs.existsSync(refPath) && plan.cleanup === false).toBe(false);
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it('AC-613-02: the WIRE test \u2014 the call site actually uses the plan it builds', () => {
|
|
144
|
+
// This case exists because the first version of this suite had a hole: every
|
|
145
|
+
// other case asserts on `buildPrinciplesInvocation`'s OUTPUT, and reverting the
|
|
146
|
+
// call site to the old `spawnSync('npx', [...files.join(' ') ...], { shell: true })`
|
|
147
|
+
// left all 8 green. A perfectly correct planner that nobody consults.
|
|
148
|
+
//
|
|
149
|
+
// Intercepting child_process.spawnSync does NOT work here: principles.js
|
|
150
|
+
// destructures `{ spawnSync }` at load time, so replacing the property afterwards
|
|
151
|
+
// never reaches the bound local. (I tried that first and the case failed with
|
|
152
|
+
// "spawnSync was never called" — which is itself a useful demonstration that an
|
|
153
|
+
// indirect assertion can fail for a reason unrelated to the defect.)
|
|
154
|
+
//
|
|
155
|
+
// So this asserts the wiring structurally, plus the one property that is cheap to
|
|
156
|
+
// check at runtime: the module must expose the planner, meaning the call site has
|
|
157
|
+
// something to consume.
|
|
158
|
+
const source = fs.readFileSync(path.join(__dirname, '..', 'principles.js'), 'utf8');
|
|
159
|
+
|
|
160
|
+
// The shell:true on the call site, verbatim.
|
|
161
|
+
expect(source, 'the shell:true spawn is back at the call site').not.toContain(
|
|
162
|
+
"shell: process.platform === 'win32'"
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
// The wiring: spawnSync must be fed the plan, and the plan must control the shell.
|
|
166
|
+
expect(source).toContain('spawnSync(plan.command, plan.args');
|
|
167
|
+
expect(source).toContain('shell: plan.useShell');
|
|
168
|
+
|
|
169
|
+
// The planner bounds the list; the join() is still allowed for SHORT lists inside
|
|
170
|
+
// buildPrinciplesInvocation (that is the correct path for small inputs), but it
|
|
171
|
+
// must never appear in the spawnSync argument position any more.
|
|
172
|
+
const spawnIdx = source.indexOf('spawnSync(plan.command, plan.args');
|
|
173
|
+
const joinAfterSpawn = source.indexOf('files.join(', spawnIdx);
|
|
174
|
+
expect(joinAfterSpawn, 'the spawn call still joins the whole list inline').toBe(-1);
|
|
175
|
+
|
|
176
|
+
// And the planner must really exist and be exported, so the call site cannot be
|
|
177
|
+
// referencing a name that only looks right.
|
|
178
|
+
expect(typeof principles.buildPrinciplesInvocation).toBe('function');
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
// Keep the imports referenced so linting does not flag them when the module grows.
|
|
183
|
+
void os;
|