@boyingliu01/xp-gate 0.19.3 → 0.20.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/adapters/gate-4.sh +83 -18
- package/adapters/gate-8.sh +49 -5
- package/adapters/iac.sh +1 -1
- package/adapters/typescript.sh +15 -60
- package/bin/xp-gate.js +15 -16
- package/gate-10.sh +35 -2
- package/gate-4.sh +110 -21
- package/gate-8.sh +49 -5
- package/hooks/gate-10.sh +35 -2
- package/hooks/gate-4.sh +110 -21
- package/hooks/gate-8.sh +49 -5
- package/hooks/lib/jscpd-run.sh +63 -0
- package/hooks/lib/sprint-gate-report.sh +58 -0
- package/hooks/lib/test-failure.sh +152 -0
- package/hooks/lib/typecheck.sh +141 -0
- package/hooks/lib/validate-code-walkthrough.cjs +43 -5
- package/hooks/post-merge +100 -0
- package/hooks/pre-commit +389 -162
- package/hooks/pre-push +75 -19
- package/lib/__tests__/bootstrap.test.js +23 -41
- package/lib/__tests__/check-version.test.js +8 -1
- package/lib/__tests__/detect-deps.test.js +16 -0
- package/lib/__tests__/doctor-drift-round2.test.js +212 -0
- package/lib/__tests__/doctor-executed-surface.test.js +209 -0
- package/lib/__tests__/doctor-hook-drift.test.js +329 -0
- package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
- package/lib/__tests__/doctor-module-drift.test.js +206 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
- package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
- package/lib/__tests__/doctor.test.js +155 -0
- package/lib/__tests__/gate-runner.test.js +19 -3
- package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
- package/lib/__tests__/init.test.js +44 -17
- package/lib/__tests__/install-skill-full-dir.test.js +151 -0
- package/lib/__tests__/install-skill.test.js +51 -132
- package/lib/__tests__/language-tools-detect.test.js +158 -0
- package/lib/__tests__/phase-transition.test.js +15 -1
- package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
- package/lib/__tests__/shared-paths.test.js +20 -0
- package/lib/__tests__/shared-utils.test.js +69 -0
- package/lib/__tests__/skill-flags.test.js +48 -0
- package/lib/__tests__/ui-detector.test.ts +6 -2
- package/lib/__tests__/uninstall.test.js +20 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/update-skill.test.js +7 -4
- package/lib/detect-deps.js +3 -20
- package/lib/doctor-tui.js +22 -5
- package/lib/doctor.js +1178 -31
- package/lib/init.js +184 -33
- package/lib/install-cmd.js +7 -4
- package/lib/install-skill.js +38 -95
- package/lib/language-tools.js +83 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-paths.js +5 -2
- package/lib/shared-utils.js +47 -3
- package/lib/uninstall.js +12 -0
- package/lib/update-hooks.js +4 -4
- package/lib/update-skill.js +6 -7
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/gate-m.test.ts +66 -14
- package/mutation/__tests__/mutmut-runner.test.ts +91 -42
- package/mutation/__tests__/test-filter-parity.test.ts +37 -0
- package/mutation/gate-m.ts +21 -8
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/README.md +169 -4
- package/plugins/dsh/dsh-overlay.patch.yml +67 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +24 -19
- package/plugins/dsh/setup-dsh.ps1 +160 -0
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
- package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/command.test.ts +66 -2
- package/plugins/dsh/src/command.ts +36 -14
- package/plugins/dsh/src/contract.test.ts +31 -1
- package/plugins/dsh/src/delphi-config.test.ts +190 -0
- package/plugins/dsh/src/delphi-config.ts +223 -0
- package/plugins/dsh/src/delphi-run.test.ts +181 -0
- package/plugins/dsh/src/delphi-run.ts +129 -0
- package/plugins/dsh/src/delphi.test.ts +222 -0
- package/plugins/dsh/src/delphi.ts +247 -0
- package/plugins/dsh/src/dialect.test.ts +59 -0
- package/plugins/dsh/src/dialect.ts +40 -0
- package/plugins/dsh/src/index.ts +70 -4
- package/plugins/dsh/src/skills.test.ts +26 -19
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/agents/delphi-architecture.md +1 -1
- package/plugins/qoder/agents/delphi-feasibility.md +1 -1
- package/plugins/qoder/agents/delphi-technical.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +9 -8
- package/principles/__tests__/adapter-metrics.test.ts +292 -0
- package/principles/__tests__/analyzer.test.ts +29 -6
- package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
- package/principles/__tests__/baseline-prune.test.ts +338 -0
- package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
- package/principles/__tests__/boy-scout-integration.test.ts +97 -6
- package/principles/__tests__/boy-scout.test.ts +253 -7
- package/principles/__tests__/config-enforcement.test.ts +469 -0
- package/principles/__tests__/config-git-timeout.test.ts +66 -0
- package/principles/__tests__/direct-execution.test.ts +187 -0
- package/principles/__tests__/index.test.ts +16 -3
- package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
- package/principles/adapters/typescript.ts +613 -13
- package/principles/analyzer.ts +22 -0
- package/principles/baseline-prune.ts +217 -0
- package/principles/boy-scout.ts +481 -51
- package/principles/config.ts +362 -54
- package/principles/direct-execution.ts +60 -0
- package/principles/index.ts +40 -17
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
- package/principles/rules/clean-code/code-duplication.ts +8 -8
- package/principles/rules/clean-code/deep-nesting.ts +8 -8
- package/principles/rules/clean-code/god-class.ts +8 -8
- package/principles/rules/clean-code/large-file.ts +15 -11
- package/principles/rules/clean-code/long-function.ts +8 -8
- package/principles/rules/clean-code/magic-numbers.ts +10 -7
- package/principles/rules/clean-code/many-exports.ts +7 -7
- package/principles/rules/clean-code/missing-error-handling.ts +5 -5
- package/principles/rules/clean-code/too-many-params.ts +8 -8
- package/principles/rules/clean-code/unused-imports.ts +5 -5
- package/principles/rules/solid/dip.ts +18 -8
- package/principles/rules/solid/isp.ts +9 -8
- package/principles/rules/solid/lsp.ts +6 -6
- package/principles/rules/solid/ocp.ts +6 -5
- package/principles/rules/solid/srp.ts +7 -8
- package/principles/types.ts +15 -0
- package/scripts/__tests__/sync-package-content.test.js +28 -0
- package/scripts/delphi-external-review.cjs +193 -10
- package/scripts/sync-package-content.js +7 -4
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/delphi-review/SKILL.md +98 -14
- package/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/skills/delphi-review/references/requirements.md +6 -6
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +4 -4
- package/lib/__tests__/download-skill.test.js +0 -281
- package/lib/download-skill.js +0 -96
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-495 xp-gate doctor sees the gate modules that actually execute
|
|
3
|
+
* @intent doctor 对全局目录只做存在性检查(checkAdapters),drift 检查只看
|
|
4
|
+
* pre-commit/pre-push/post-merge + lib/;而 pre-commit 把 GATE_DIR 解析到
|
|
5
|
+
* ~/.config/xp-gate/adapters(`if [ -f "$ADAPTER_DIR/gate-3.sh" ]`),
|
|
6
|
+
* 门禁模块全部在那里执行。于是本仓库里 gate-4/gate-8/typescript.sh 与机器
|
|
7
|
+
* 副本三份互异,doctor 却报全绿,#493 那类仓库侧修复在本地根本不会被执行
|
|
8
|
+
* (#495)。更糟的是任何单向同步都会销毁另一侧的机器独有加固,所以判定
|
|
9
|
+
* 必须给出"双向发散"这一态并拒绝静默覆盖。
|
|
10
|
+
* @covers AC-495-01, AC-495-02, AC-495-03, AC-495-04, AC-495-05, AC-495-06
|
|
11
|
+
*/
|
|
12
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, utimesSync, existsSync } from 'node:fs';
|
|
13
|
+
import { tmpdir } from 'node:os';
|
|
14
|
+
import { join } from 'node:path';
|
|
15
|
+
import { describe, expect, it } from 'vitest';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Build a repo + global-install pair. `spec` names each module's repo copy and
|
|
19
|
+
* installed copy; null means "not present on that side".
|
|
20
|
+
*/
|
|
21
|
+
function fixture(spec) {
|
|
22
|
+
const root = mkdtempSync(join(tmpdir(), 'xp-gate-drift-'));
|
|
23
|
+
const repoHooks = join(root, 'githooks');
|
|
24
|
+
const installedHooks = join(root, 'hooks');
|
|
25
|
+
const installedAdapters = join(root, 'adapters');
|
|
26
|
+
mkdirSync(join(repoHooks, 'adapters'), { recursive: true });
|
|
27
|
+
mkdirSync(installedHooks, { recursive: true });
|
|
28
|
+
mkdirSync(installedAdapters, { recursive: true });
|
|
29
|
+
|
|
30
|
+
const write = (file, text) => {
|
|
31
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
32
|
+
writeFileSync(file, text);
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
for (const [name, sides] of Object.entries(spec)) {
|
|
36
|
+
if (sides.repo !== null) {
|
|
37
|
+
// gate-*/adapter-common.sh live at the githooks root; language adapters
|
|
38
|
+
// live under githooks/adapters/ -- the same split the installer uses.
|
|
39
|
+
const rootLevel = name.startsWith('gate-') || name === 'adapter-common.sh' || name.startsWith('lib/');
|
|
40
|
+
write(join(repoHooks, rootLevel ? name : join('adapters', name)), sides.repo);
|
|
41
|
+
}
|
|
42
|
+
if (sides.installed !== null) {
|
|
43
|
+
write(join(installedAdapters, sides.nested ? join('adapters', name) : name), sides.installed);
|
|
44
|
+
}
|
|
45
|
+
if (sides.repoMtime) utimesSync(join(repoHooks, name), sides.repoMtime, sides.repoMtime);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// A canonical hook source is what makes the drift surface apply at all.
|
|
49
|
+
write(join(repoHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
|
|
50
|
+
write(join(installedHooks, 'pre-commit'), '#!/bin/bash\necho repo\n');
|
|
51
|
+
|
|
52
|
+
// Resolution anchors: pre-commit only makes the installed dir the ADAPTER_DIR
|
|
53
|
+
// when `adapter-common.sh` is in it (:34), and only reads gate modules from it
|
|
54
|
+
// when `gate-3.sh` is (:80). Without both, the fixture describes a tier the hook
|
|
55
|
+
// never resolves to, and the comparison silently targets some other directory
|
|
56
|
+
// (#495 Round 3 -- the tier list is no longer an assumption).
|
|
57
|
+
const ANCHOR = '#!/bin/bash\nanchor\n';
|
|
58
|
+
for (const anchor of ['adapter-common.sh', 'gate-3.sh']) {
|
|
59
|
+
if (spec[anchor]) continue;
|
|
60
|
+
write(join(repoHooks, anchor), ANCHOR);
|
|
61
|
+
write(join(installedAdapters, anchor), ANCHOR);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return { repoHooks, installedHooks, installedAdapters, root, write };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function drift(ctx) {
|
|
68
|
+
const { diagnoseModuleDrift } = require('../doctor');
|
|
69
|
+
expect(typeof diagnoseModuleDrift).toBe('function');
|
|
70
|
+
return diagnoseModuleDrift(ctx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Name every tier the hook can resolve into, so a test can never end up
|
|
75
|
+
* comparing against this machine's real `~/.config/xp-gate` (#495 Round 3).
|
|
76
|
+
*/
|
|
77
|
+
function ctxFor(f, extra = {}) {
|
|
78
|
+
return {
|
|
79
|
+
repoRoot: f.root,
|
|
80
|
+
repoHooks: f.repoHooks,
|
|
81
|
+
installedAdapters: f.installedAdapters,
|
|
82
|
+
scriptDir: f.installedHooks,
|
|
83
|
+
...extra,
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function checkOf(result, needle) {
|
|
88
|
+
const hit = result.checks.find((c) => c.name.includes(needle));
|
|
89
|
+
expect(hit, `no check named like ${needle} in ${JSON.stringify(result.checks)}`).toBeDefined();
|
|
90
|
+
return hit;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
describe('doctor module drift (#495)', () => {
|
|
94
|
+
it('AC-495-01: a stale executed gate module is reported, not waved through on presence', () => {
|
|
95
|
+
const f = fixture({
|
|
96
|
+
'gate-8.sh': { repo: '#!/bin/bash\n# repo version\n', installed: '#!/bin/bash\n# installed version\n' },
|
|
97
|
+
});
|
|
98
|
+
const result = drift(ctxFor(f));
|
|
99
|
+
expect(result.issues).toBeGreaterThanOrEqual(1);
|
|
100
|
+
expect(checkOf(result, 'gate-8.sh').status).toBe('FAIL');
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('AC-495-02: language adapters are compared through the hook\'s own lookup order', () => {
|
|
104
|
+
// Flat installed copy wins at runtime, so the repo's nested source is the
|
|
105
|
+
// right counterpart for it.
|
|
106
|
+
const flat = fixture({
|
|
107
|
+
'typescript.sh': { repo: 'run_tests() { echo repo; }\n', installed: 'run_tests() { echo stale; }\n' },
|
|
108
|
+
});
|
|
109
|
+
const flatResult = drift(ctxFor(flat));
|
|
110
|
+
expect(checkOf(flatResult, 'typescript.sh').status).toBe('FAIL');
|
|
111
|
+
|
|
112
|
+
// When the flat copy is absent the hook falls back to adapters/<name>.sh,
|
|
113
|
+
// and that nested file is the one that executes -- it must be checked too.
|
|
114
|
+
const nested = fixture({
|
|
115
|
+
'python.sh': { repo: 'run_tests() { echo repo; }\n', installed: 'run_tests() { echo stale; }\n', nested: true },
|
|
116
|
+
});
|
|
117
|
+
expect(checkOf(drift(ctxFor(nested)), 'python.sh').status).toBe('FAIL');
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('AC-495-03: equal content passes and a module missing on either side stays silent', () => {
|
|
121
|
+
const same = 'run_tests() { echo same; }\n';
|
|
122
|
+
const f = fixture({
|
|
123
|
+
'python.sh': { repo: same, installed: same },
|
|
124
|
+
'gate-3.sh': { repo: '#!/bin/bash\nv1\n', installed: '#!/bin/bash\nv1\n' },
|
|
125
|
+
// Repo-only and install-only modules have no counterpart to compare with.
|
|
126
|
+
'go.sh': { repo: 'echo repo only\n', installed: null },
|
|
127
|
+
'swift.sh': { repo: null, installed: 'echo installed only\n' },
|
|
128
|
+
});
|
|
129
|
+
const result = drift(ctxFor(f));
|
|
130
|
+
expect(result.issues).toBe(0);
|
|
131
|
+
expect(checkOf(result, 'python.sh').status).toBe('PASS');
|
|
132
|
+
expect(result.checks.filter((c) => c.name.includes('go.sh'))).toHaveLength(0);
|
|
133
|
+
expect(result.checks.filter((c) => c.name.includes('swift.sh'))).toHaveLength(0);
|
|
134
|
+
|
|
135
|
+
// A consumer project has no githooks/ at all -- the whole surface must stay
|
|
136
|
+
// completely quiet there, exactly like the hook drift check does.
|
|
137
|
+
const absent = drift(ctxFor(f, { repoHooks: join(f.root, 'nope') }));
|
|
138
|
+
expect(absent.checks).toHaveLength(0);
|
|
139
|
+
expect(absent.issues).toBe(0);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('AC-495-04: bidirectional divergence is a distinct verdict from one-way staleness', () => {
|
|
143
|
+
// Installed carries lines the repo lacks (the machine-only detect-secrets
|
|
144
|
+
// hardening) AND the repo carries lines the install lacks (#449 verdict).
|
|
145
|
+
const f = fixture({
|
|
146
|
+
'gate-8.sh': {
|
|
147
|
+
repo: '#!/bin/bash\nrepo_has_report_artifact_verdict\n',
|
|
148
|
+
installed: '#!/bin/bash\ninstalled_has_detect_secrets_fallback\n',
|
|
149
|
+
},
|
|
150
|
+
'gate-4.sh': { repo: '#!/bin/bash\nline_a\nline_b\n', installed: '#!/bin/bash\nline_a\n' },
|
|
151
|
+
});
|
|
152
|
+
const result = drift(ctxFor(f));
|
|
153
|
+
|
|
154
|
+
const diverged = checkOf(result, 'gate-8.sh');
|
|
155
|
+
expect(diverged.status).toBe('FAIL');
|
|
156
|
+
expect(diverged.detail).toMatch(/both sides|diverged/i);
|
|
157
|
+
|
|
158
|
+
// One-way staleness must NOT be described as divergence, or the guard fires
|
|
159
|
+
// on every routine upgrade and the real case stops standing out.
|
|
160
|
+
const stale = checkOf(result, 'gate-4.sh');
|
|
161
|
+
expect(stale.status).toBe('FAIL');
|
|
162
|
+
expect(stale.detail).not.toMatch(/both sides|diverged/i);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
it('AC-495-05: sync copies stale modules and refuses to overwrite diverged ones', () => {
|
|
166
|
+
const { syncModulesFromRepo } = require('../doctor');
|
|
167
|
+
expect(typeof syncModulesFromRepo).toBe('function');
|
|
168
|
+
|
|
169
|
+
const f = fixture({
|
|
170
|
+
'gate-8.sh': {
|
|
171
|
+
repo: '#!/bin/bash\nrepo_has_report_artifact_verdict\n',
|
|
172
|
+
installed: '#!/bin/bash\ninstalled_has_detect_secrets_fallback\n',
|
|
173
|
+
},
|
|
174
|
+
'gate-4.sh': { repo: '#!/bin/bash\nline_a\nline_b\n', installed: '#!/bin/bash\nline_a\n' },
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const first = syncModulesFromRepo(ctxFor(f));
|
|
178
|
+
expect(first.synced).toContain('gate-4.sh');
|
|
179
|
+
expect(readFileSync(join(f.installedAdapters, 'gate-4.sh'), 'utf8')).toContain('line_b');
|
|
180
|
+
// The guard is the point: the machine-only hardening survives untouched.
|
|
181
|
+
expect(first.refused.map((r) => r.name)).toContain('gate-8.sh');
|
|
182
|
+
expect(readFileSync(join(f.installedAdapters, 'gate-8.sh'), 'utf8')).toContain('detect_secrets');
|
|
183
|
+
|
|
184
|
+
// An explicit decision still wins, because the developer may have judged the
|
|
185
|
+
// repo copy to be the correct one.
|
|
186
|
+
const forced = syncModulesFromRepo(ctxFor(f, { force: true }));
|
|
187
|
+
expect(forced.synced).toContain('gate-8.sh');
|
|
188
|
+
expect(readFileSync(join(f.installedAdapters, 'gate-8.sh'), 'utf8')).toContain('report_artifact_verdict');
|
|
189
|
+
expect(existsSync(join(f.installedAdapters, 'gate-8.sh'))).toBe(true);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('AC-495-06: anti-vacuity -- the surface is wired into the report and the repair', () => {
|
|
193
|
+
const source = readFileSync(join(__dirname, '..', 'doctor.js'), 'utf8');
|
|
194
|
+
|
|
195
|
+
// Detection must run in the real diagnosis, not only in the exported helper.
|
|
196
|
+
expect(/diagnoseModuleDrift\(/.test(source)).toBe(true);
|
|
197
|
+
expect(/diagnoseModuleDrift\(\)/.test(source)).toBe(true);
|
|
198
|
+
// Repair must be reachable from the doctor command surface.
|
|
199
|
+
expect(/syncModulesFromRepo\(/.test(source)).toBe(true);
|
|
200
|
+
// The developer has to be able to see WHICH directory executes (#495's
|
|
201
|
+
// "print the resolved GATE_DIR/ADAPTER_DIR" requirement).
|
|
202
|
+
expect(source).toMatch(/printEffectiveModules|Effective (hooks|modules)/);
|
|
203
|
+
// The presence-only verdict that hid this is gone for gate scripts.
|
|
204
|
+
expect(source).not.toMatch(/status: 'PASS', detail: `\$\{EXPECTED_GATE_SCRIPTS\.length\} gate script\(s\)`/);
|
|
205
|
+
});
|
|
206
|
+
});
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-495 the JS mirror of the hook's module lookup must be reconciled against the bash original
|
|
3
|
+
* @intent #495 的修复把 pre-commit 的 flat-优先-nested 解析顺序用 JavaScript 重写了一份
|
|
4
|
+
* (doctor.js listExecutedModules)。本仓库在本批次已经示范过正确范式:
|
|
5
|
+
* src/mutation/__tests__/test-filter-parity.test.ts(AC-480-04)把 bash 与 TS
|
|
6
|
+
* 两份"非生产代码定义"钉成同一文本。跨语言的解析顺序同样不能靠注释宣称一致:
|
|
7
|
+
* hook 侧一旦演化,doctor 就会镜像旧秩序,"doctor 全绿、执行面另起炉灶"重现。
|
|
8
|
+
* 因此这里直接抽取 pre-commit 的 resolve_adapter_path 函数体,交给 bash 执行,
|
|
9
|
+
* 与 doctor 的 listExecutedModules 在同一棵合成目录树上逐模块对账。第三轮把它
|
|
10
|
+
* 扩到四层全填充的场景树:ADAPTER_DIR 链、GATE_DIR 链、resolve_adapter_path 三段
|
|
11
|
+
* 全部从 pre-commit 原文抽取后交给 bash 执行,与 doctor 的
|
|
12
|
+
* resolveExecutedModuleDirs()+executedModulePath() 对同一棵树逐一比对;hook 侧
|
|
13
|
+
* 一旦改动解析顺序,这里立刻失配,而不是让 doctor 继续镜像旧秩序。
|
|
14
|
+
* @covers AC-495-11, AC-495-12, AC-495-21, AC-495-22
|
|
15
|
+
*/
|
|
16
|
+
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join, resolve } from 'node:path';
|
|
19
|
+
import { spawnSync } from 'node:child_process';
|
|
20
|
+
import { describe, expect, it } from 'vitest';
|
|
21
|
+
|
|
22
|
+
const PRE_COMMIT = readFileSync(resolve(__dirname, '..', '..', '..', '..', 'githooks', 'pre-commit'), 'utf8');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Extract one bash function's full text, brace-balanced from the declaration.
|
|
26
|
+
*/
|
|
27
|
+
function extractFunction(source, signature) {
|
|
28
|
+
const start = source.indexOf(signature);
|
|
29
|
+
expect(start, `pre-commit no longer declares ${signature}`).toBeGreaterThan(-1);
|
|
30
|
+
let depth = 0;
|
|
31
|
+
let index = source.indexOf('{', start);
|
|
32
|
+
const bodyStart = index;
|
|
33
|
+
for (; index < source.length; index++) {
|
|
34
|
+
const ch = source[index];
|
|
35
|
+
if (ch === '{') depth++;
|
|
36
|
+
else if (ch === '}') {
|
|
37
|
+
depth--;
|
|
38
|
+
if (depth === 0) return source.slice(start, index + 1);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
throw new Error(`unbalanced braces in ${signature}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Grab one `if`-chain verbatim from the hook, up to and including its `fi`.
|
|
46
|
+
*/
|
|
47
|
+
function matchBlock(source, pattern, label) {
|
|
48
|
+
const hit = source.match(pattern);
|
|
49
|
+
expect(hit, `pre-commit no longer carries ${label}`).not.toBeNull();
|
|
50
|
+
return hit[0];
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function bashBin() {
|
|
54
|
+
const candidate = process.env.XP_GATE_BASH_BIN || 'bash';
|
|
55
|
+
const probe = spawnSync(candidate, ['-c', 'echo ok'], { encoding: 'utf8' });
|
|
56
|
+
// A missing runner must fail loudly, never read as a passing guard.
|
|
57
|
+
if (probe.status !== 0 || probe.error) {
|
|
58
|
+
throw new Error(`bash runner unavailable (${candidate}): ${probe.error ? probe.error.message : `exit ${probe.status}`}`);
|
|
59
|
+
}
|
|
60
|
+
return candidate;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Compare a path the bash resolver printed with one Node built. Bash emits the
|
|
65
|
+
* separators it was handed and mixes them (`$ADAPTER_DIR/adapters/x.sh`), so the
|
|
66
|
+
* comparison is on a single separator view, not on either side's spelling.
|
|
67
|
+
*/
|
|
68
|
+
function samePath(a, b) {
|
|
69
|
+
const normalize = (p) => p.replace(/\\/g, '/').replace(/\/+/g, '/');
|
|
70
|
+
return normalize(a) === normalize(b);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Build an ADAPTER_DIR holding the requested flat and nested modules.
|
|
75
|
+
*/
|
|
76
|
+
function adapterTree(spec) {
|
|
77
|
+
const root = mkdtempSync(join(tmpdir(), 'xp-gate-parity-'));
|
|
78
|
+
const adapterDir = join(root, 'adapters');
|
|
79
|
+
mkdirSync(join(adapterDir, 'adapters'), { recursive: true });
|
|
80
|
+
// Empty PROJECT_GITHOOKS / SCRIPT_DIR tiers so only tiers 1-2 can answer.
|
|
81
|
+
mkdirSync(join(root, 'project'), { recursive: true });
|
|
82
|
+
mkdirSync(join(root, 'script'), { recursive: true });
|
|
83
|
+
for (const [name, sides] of Object.entries(spec)) {
|
|
84
|
+
if (sides.flat) writeFileSync(join(adapterDir, name), 'flat\n');
|
|
85
|
+
if (sides.nested) writeFileSync(join(adapterDir, 'adapters', name), 'nested\n');
|
|
86
|
+
}
|
|
87
|
+
return { root, adapterDir };
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Where a module copy can sit across all four tiers the hook looks at. Each key
|
|
92
|
+
* is a location name, so a scenario reads as a statement about the tree.
|
|
93
|
+
*/
|
|
94
|
+
const LAYOUTS = {
|
|
95
|
+
globalFlat: (t, name) => join(t.globalAdapters, name),
|
|
96
|
+
globalNested: (t, name) => join(t.globalAdapters, 'adapters', name),
|
|
97
|
+
projectFlat: (t, name) => join(t.projectGithooks, name),
|
|
98
|
+
projectNested: (t, name) => join(t.projectGithooks, 'adapters', name),
|
|
99
|
+
scriptFlat: (t, name) => join(t.scriptDir, name),
|
|
100
|
+
scriptNested: (t, name) => join(t.scriptDir, 'adapters', name),
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* A tree with all tiers present, including their resolution anchors, so tier
|
|
105
|
+
* precedence is decided by the hook's own text rather than by the fixture's
|
|
106
|
+
* assumptions (AC-495-13's project-tier pair depends on this too).
|
|
107
|
+
*/
|
|
108
|
+
function fourTierTree(layouts) {
|
|
109
|
+
const root = mkdtempSync(join(tmpdir(), 'xp-gate-parity4-'));
|
|
110
|
+
const t = {
|
|
111
|
+
root,
|
|
112
|
+
globalAdapters: join(root, 'global', 'adapters'),
|
|
113
|
+
projectGithooks: join(root, 'repo', 'githooks'),
|
|
114
|
+
scriptDir: join(root, 'installed', 'hooks'),
|
|
115
|
+
};
|
|
116
|
+
for (const dir of [t.globalAdapters, t.projectGithooks, t.scriptDir]) {
|
|
117
|
+
mkdirSync(join(dir, 'adapters'), { recursive: true });
|
|
118
|
+
}
|
|
119
|
+
for (const [name, places] of Object.entries(layouts)) {
|
|
120
|
+
for (const place of places) {
|
|
121
|
+
const file = LAYOUTS[place](t, name);
|
|
122
|
+
mkdirSync(join(file, '..'), { recursive: true });
|
|
123
|
+
writeFileSync(file, `${place}\n`);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return t;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Run the hook's own ADAPTER_DIR chain, GATE_DIR chain and resolve_adapter_path
|
|
131
|
+
* over one tree, and report all three answers.
|
|
132
|
+
*/
|
|
133
|
+
function hookResolvesAll(blocks, resolverText, t, lang, gateModule) {
|
|
134
|
+
// Bash assignment uses single quotes below; a temp path containing one would
|
|
135
|
+
// change the tree the two sides are looking at, so refuse rather than lie.
|
|
136
|
+
for (const dir of [t.globalAdapters, t.projectGithooks, t.scriptDir]) {
|
|
137
|
+
expect(dir, `fixture path contains a quote: ${dir}`).not.toMatch(/'/);
|
|
138
|
+
}
|
|
139
|
+
const script = [
|
|
140
|
+
`GLOBAL_ADAPTER_DIR='${t.globalAdapters}'`,
|
|
141
|
+
`PROJECT_GITHOOKS='${t.projectGithooks}'`,
|
|
142
|
+
`SCRIPT_DIR='${t.scriptDir}'`,
|
|
143
|
+
blocks.adapterDir,
|
|
144
|
+
blocks.gateDir,
|
|
145
|
+
'echo "ADAPTER=$ADAPTER_DIR"',
|
|
146
|
+
'echo "GATE=$GATE_DIR"',
|
|
147
|
+
`if [ -f "$ADAPTER_DIR/adapter-common.sh" ]; then echo "COMMON=$ADAPTER_DIR/adapter-common.sh"; else echo "COMMON="; fi`,
|
|
148
|
+
`if [ -f "$GATE_DIR/${gateModule}" ]; then echo "MODULE=$GATE_DIR/${gateModule}"; else echo "MODULE="; fi`,
|
|
149
|
+
resolverText,
|
|
150
|
+
// Captured by substitution rather than read off the last stdout line: the
|
|
151
|
+
// script ends with a newline, so "last line" is always the empty one.
|
|
152
|
+
`echo "LANG=$(resolve_adapter_path ${lang} || true)"`,
|
|
153
|
+
].join('\n');
|
|
154
|
+
const run = spawnSync(bashBin(), ['-c', script], { encoding: 'utf8' });
|
|
155
|
+
expect(run.status, `resolver run failed: ${run.stderr}`).toBe(0);
|
|
156
|
+
const lines = run.stdout.split(/\r?\n/);
|
|
157
|
+
const named = (prefix) => {
|
|
158
|
+
const hit = lines.find((l) => l.startsWith(`${prefix}=`));
|
|
159
|
+
expect(hit, `bash printed no ${prefix}= line:\n${run.stdout}`).toBeDefined();
|
|
160
|
+
return hit ? hit.slice(prefix.length + 1) : '';
|
|
161
|
+
};
|
|
162
|
+
return {
|
|
163
|
+
adapterDir: named('ADAPTER'),
|
|
164
|
+
gateDir: named('GATE'),
|
|
165
|
+
common: named('COMMON'),
|
|
166
|
+
gateModule: named('MODULE'),
|
|
167
|
+
langPath: named('LANG'),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Ask the hook's own resolver which file executes for one language.
|
|
173
|
+
*/
|
|
174
|
+
function hookResolves(fnText, tree, lang) {
|
|
175
|
+
const script = [
|
|
176
|
+
`ADAPTER_DIR='${tree.adapterDir}'`,
|
|
177
|
+
`PROJECT_GITHOOKS='${join(tree.root, 'project')}'`,
|
|
178
|
+
`SCRIPT_DIR='${join(tree.root, 'script')}'`,
|
|
179
|
+
fnText,
|
|
180
|
+
`resolve_adapter_path ${lang}`,
|
|
181
|
+
].join('\n');
|
|
182
|
+
const run = spawnSync(bashBin(), ['-c', script], { encoding: 'utf8' });
|
|
183
|
+
expect(run.status, `resolver run failed: ${run.stderr}`).toBe(0);
|
|
184
|
+
return run.stdout.trim();
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
describe('doctor module resolution parity with pre-commit (#495)', () => {
|
|
188
|
+
const resolverText = extractFunction(PRE_COMMIT, 'resolve_adapter_path() {');
|
|
189
|
+
// The two tier chains the hook runs before it ever calls the resolver. Read
|
|
190
|
+
// from the hook's text, not restated here: #495 Round 2's project-tier label
|
|
191
|
+
// was wrong precisely because doctor carried a second, hand-written tier list.
|
|
192
|
+
const blocks = {
|
|
193
|
+
adapterDir: matchBlock(PRE_COMMIT, /if \[ -f "\$GLOBAL_ADAPTER_DIR\/adapter-common\.sh" \]; then[\s\S]*?\nfi/, 'the ADAPTER_DIR tier chain'),
|
|
194
|
+
gateDir: matchBlock(PRE_COMMIT, /if \[ -f "\$ADAPTER_DIR\/gate-3\.sh" \]; then[\s\S]*?\nfi/, 'the GATE_DIR tier chain'),
|
|
195
|
+
};
|
|
196
|
+
|
|
197
|
+
it('AC-495-11: flat and nested present -> both languages pick the flat copy', () => {
|
|
198
|
+
const { listExecutedModules } = require('../doctor');
|
|
199
|
+
const tree = adapterTree({ 'typescript.sh': { flat: true, nested: true } });
|
|
200
|
+
|
|
201
|
+
const hookChoice = hookResolves(resolverText, tree, 'typescript');
|
|
202
|
+
expect(samePath(hookChoice, join(tree.adapterDir, 'typescript.sh'))).toBe(true);
|
|
203
|
+
|
|
204
|
+
const doctorChoice = listExecutedModules(tree.adapterDir).find((m) => m.name === 'typescript.sh');
|
|
205
|
+
expect(doctorChoice).toBeDefined();
|
|
206
|
+
expect(samePath(doctorChoice.file, hookChoice)).toBe(true);
|
|
207
|
+
});
|
|
208
|
+
|
|
209
|
+
it('AC-495-12: every module the hook can resolve in the executed dir is in doctor\'s surface, and agrees on each path', () => {
|
|
210
|
+
const { listExecutedModules } = require('../doctor');
|
|
211
|
+
const tree = adapterTree({
|
|
212
|
+
'typescript.sh': { nested: true },
|
|
213
|
+
'python.sh': { flat: true, nested: true },
|
|
214
|
+
'gate-4.sh': { flat: true },
|
|
215
|
+
'go.sh': { nested: true },
|
|
216
|
+
});
|
|
217
|
+
|
|
218
|
+
const doctorModules = listExecutedModules(tree.adapterDir);
|
|
219
|
+
const doctorByName = new Map(doctorModules.map((m) => [m.name, m.file]));
|
|
220
|
+
|
|
221
|
+
// Same set of module names, no extras, no omissions.
|
|
222
|
+
expect([...doctorByName.keys()].sort()).toEqual(['gate-4.sh', 'go.sh', 'python.sh', 'typescript.sh']);
|
|
223
|
+
|
|
224
|
+
for (const lang of ['typescript', 'python', 'go']) {
|
|
225
|
+
const hookChoice = hookResolves(resolverText, tree, lang);
|
|
226
|
+
expect(existsSync(hookChoice), `hook resolved a missing file: ${hookChoice}`).toBe(true);
|
|
227
|
+
expect(doctorByName.has(`${lang}.sh`), `doctor does not see ${lang}.sh at all`).toBe(true);
|
|
228
|
+
expect(
|
|
229
|
+
samePath(doctorByName.get(`${lang}.sh`), hookChoice),
|
|
230
|
+
`doctor disagrees with the hook for ${lang}.sh`,
|
|
231
|
+
).toBe(true);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Anti-vacuity: the guard executed the hook's real text, not a paraphrase.
|
|
235
|
+
expect(resolverText).toMatch(/\$ADAPTER_DIR\/\$\{lang\}\.sh/);
|
|
236
|
+
expect(resolverText).toMatch(/\$ADAPTER_DIR\/adapters\/\$\{lang\}\.sh/);
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
// The scenarios below put a copy of the same module in EVERY tier the hook can
|
|
240
|
+
// reach, so agreement can only come from following the real precedence. Round 2
|
|
241
|
+
// had a hand-written two-tier list in doctor and called whatever it found
|
|
242
|
+
// "executed" -- which both mislabelled files nothing runs and left a FAIL no
|
|
243
|
+
// repair could clear (#495 Round 3).
|
|
244
|
+
const SCENARIOS = [
|
|
245
|
+
{
|
|
246
|
+
name: 'global install present: the global flat copy wins over every other tier',
|
|
247
|
+
layouts: {
|
|
248
|
+
'adapter-common.sh': ['globalFlat', 'projectFlat', 'scriptFlat'],
|
|
249
|
+
'gate-3.sh': ['globalFlat', 'projectFlat', 'scriptFlat'],
|
|
250
|
+
'gate-4.sh': ['globalFlat', 'projectFlat', 'scriptFlat'],
|
|
251
|
+
'typescript.sh': ['globalFlat', 'globalNested', 'projectFlat', 'projectNested', 'scriptNested'],
|
|
252
|
+
},
|
|
253
|
+
},
|
|
254
|
+
{
|
|
255
|
+
name: 'no global install: ADAPTER_DIR resolves to the project tier and its flat copy runs',
|
|
256
|
+
layouts: {
|
|
257
|
+
'adapter-common.sh': ['projectFlat', 'scriptFlat'],
|
|
258
|
+
'gate-3.sh': ['projectFlat', 'scriptFlat'],
|
|
259
|
+
'gate-4.sh': ['projectFlat', 'scriptFlat'],
|
|
260
|
+
'typescript.sh': ['projectFlat', 'projectNested', 'scriptNested'],
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
{
|
|
264
|
+
name: 'project tier carries no anchor: GATE_DIR falls through to the installed hooks dir',
|
|
265
|
+
layouts: {
|
|
266
|
+
'adapter-common.sh': ['globalFlat'],
|
|
267
|
+
'gate-3.sh': ['scriptFlat'],
|
|
268
|
+
'gate-4.sh': ['globalFlat', 'projectFlat', 'scriptFlat'],
|
|
269
|
+
'typescript.sh': ['globalNested', 'projectNested', 'scriptNested'],
|
|
270
|
+
},
|
|
271
|
+
},
|
|
272
|
+
];
|
|
273
|
+
|
|
274
|
+
it('AC-495-21: the JS resolution picks the same file as the hook for every tier scenario', () => {
|
|
275
|
+
const { resolveExecutedModuleDirs, executedModulePath } = require('../doctor');
|
|
276
|
+
for (const scenario of SCENARIOS) {
|
|
277
|
+
const t = fourTierTree(scenario.layouts);
|
|
278
|
+
const hook = hookResolvesAll(blocks, resolverText, t, 'typescript', 'gate-4.sh');
|
|
279
|
+
const dirs = resolveExecutedModuleDirs({
|
|
280
|
+
globalAdapterDir: t.globalAdapters,
|
|
281
|
+
projectGithooks: t.projectGithooks,
|
|
282
|
+
scriptDir: t.scriptDir,
|
|
283
|
+
});
|
|
284
|
+
|
|
285
|
+
expect(samePath(dirs.adapterDir, hook.adapterDir), `${scenario.name} (ADAPTER_DIR)`).toBe(true);
|
|
286
|
+
expect(samePath(dirs.gateDir, hook.gateDir), `${scenario.name} (GATE_DIR)`).toBe(true);
|
|
287
|
+
|
|
288
|
+
const jsLang = executedModulePath('typescript.sh', dirs);
|
|
289
|
+
expect(jsLang, `${scenario.name}: doctor finds no executed copy the hook resolves`).not.toBeNull();
|
|
290
|
+
expect(samePath(jsLang, hook.langPath), `${scenario.name} (typescript.sh)`).toBe(true);
|
|
291
|
+
|
|
292
|
+
// Gate modules come from GATE_DIR, language adapters from the resolver chain.
|
|
293
|
+
expect(samePath(executedModulePath('gate-4.sh', dirs), hook.gateModule), `${scenario.name} (gate-4.sh)`).toBe(true);
|
|
294
|
+
expect(samePath(executedModulePath('adapter-common.sh', dirs), hook.common), `${scenario.name} (adapter-common.sh)`).toBe(true);
|
|
295
|
+
}
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
it('AC-495-22: a module no tier installs resolves to nothing on both sides', () => {
|
|
299
|
+
const { resolveExecutedModuleDirs, executedModulePath } = require('../doctor');
|
|
300
|
+
const t = fourTierTree({
|
|
301
|
+
'adapter-common.sh': ['globalFlat'],
|
|
302
|
+
'gate-3.sh': ['globalFlat'],
|
|
303
|
+
});
|
|
304
|
+
const dirs = resolveExecutedModuleDirs({
|
|
305
|
+
globalAdapterDir: t.globalAdapters,
|
|
306
|
+
projectGithooks: t.projectGithooks,
|
|
307
|
+
scriptDir: t.scriptDir,
|
|
308
|
+
});
|
|
309
|
+
const hook = hookResolvesAll(blocks, resolverText, t, 'rust', 'gate-9.sh');
|
|
310
|
+
expect(hook.langPath).toBe('');
|
|
311
|
+
expect(executedModulePath('rust.sh', dirs)).toBeNull();
|
|
312
|
+
expect(executedModulePath('gate-9.sh', dirs)).toBeNull();
|
|
313
|
+
});
|
|
314
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-496 xp-gate doctor compares configured paths the way the OS does
|
|
3
|
+
* @intent doctor 用 `git config core.hooksPath` 的字符串直接 !== path.join 生成的常量:
|
|
4
|
+
* Windows 上 git 回读的是正斜杠形式,常量是反斜杠形式,两者指向同一目录却被
|
|
5
|
+
* 判为不一致,于是每台配置正确的机器都恒报 FAIL,而 --fix 写入后回读仍是斜杠
|
|
6
|
+
* 形式、永远消不掉(#496)。本套件把"同一目录"的判定收敛到一个比较器上。
|
|
7
|
+
* @covers AC-496-01, AC-496-02, AC-496-03
|
|
8
|
+
*/
|
|
9
|
+
const fs = require('fs');
|
|
10
|
+
const path = require('path');
|
|
11
|
+
|
|
12
|
+
describe('doctor path equivalence (#496)', () => {
|
|
13
|
+
function comparator() {
|
|
14
|
+
const { pathsEquivalent } = require('../doctor');
|
|
15
|
+
return pathsEquivalent;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
it('AC-496-01: the same directory spelled with either separator is equivalent', () => {
|
|
19
|
+
const same = comparator();
|
|
20
|
+
expect(typeof same).toBe('function');
|
|
21
|
+
const back = 'C:\\Users\\think\\.config\\xp-gate\\hooks';
|
|
22
|
+
const slash = 'C:/Users/think/.config/xp-gate/hooks';
|
|
23
|
+
expect(same(slash, back)).toBe(true);
|
|
24
|
+
expect(same(back, slash)).toBe(true);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
it('AC-496-02: drive-letter case and a trailing separator do not break equivalence', () => {
|
|
28
|
+
const same = comparator();
|
|
29
|
+
expect(same('c:/Users/think/.config/xp-gate/hooks', 'C:\\Users\\think\\.config\\xp-gate\\hooks')).toBe(true);
|
|
30
|
+
expect(same('C:/Users/think/.config/xp-gate/hooks/', 'C:\\Users\\think\\.config\\xp-gate\\hooks')).toBe(true);
|
|
31
|
+
// POSIX paths must keep working (no drive letter, leading slash is content).
|
|
32
|
+
expect(same('/home/u/.config/xp-gate/hooks', '/home/u/.config/xp-gate/hooks')).toBe(true);
|
|
33
|
+
expect(same('/home/u/.config/xp-gate/hooks/', '/home/u/.config/xp-gate/hooks')).toBe(true);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('AC-496-03: different directories still compare unequal, including near misses', () => {
|
|
37
|
+
const same = comparator();
|
|
38
|
+
expect(same('C:/Users/think/.config/xp-gate/hooks', 'C:/Users/think/.config/xp-gate/hooks-v2')).toBe(false);
|
|
39
|
+
expect(same('C:/Users/other/.config/xp-gate/hooks', 'C:/Users/think/.config/xp-gate/hooks')).toBe(false);
|
|
40
|
+
expect(same('/home/u/.config/xp-gate/hooks', '/home/u/.config/xp-gate/adapters')).toBe(false);
|
|
41
|
+
// A path that is a prefix of another is not the same directory.
|
|
42
|
+
expect(same('/home/u/.config/xp-gate', '/home/u/.config/xp-gate/hooks')).toBe(false);
|
|
43
|
+
expect(same('', 'C:/Users/think/.config/xp-gate/hooks')).toBe(false);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('AC-496-04: anti-vacuity -- the comparator is what the hooksPath check uses', () => {
|
|
47
|
+
const source = fs.readFileSync(
|
|
48
|
+
path.join(__dirname, '..', 'doctor.js'),
|
|
49
|
+
'utf8'
|
|
50
|
+
);
|
|
51
|
+
// The two comparison sites that made the Windows call fail must both route
|
|
52
|
+
// through the equivalence check rather than a byte comparison.
|
|
53
|
+
const callSites = source
|
|
54
|
+
.split('\n')
|
|
55
|
+
.filter((line) => /hooksPath\s*!==\s*GLOBAL_HOOKS_DIR/.test(line));
|
|
56
|
+
expect(callSites).toHaveProperty('length', 0);
|
|
57
|
+
expect(source).toMatch(/pathsEquivalent\(hooksPath, GLOBAL_HOOKS_DIR\)/);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-502 the repair surface of doctor must not mutate the machine unasked
|
|
3
|
+
* @intent Delphi code-walkthrough Round 1 给出两条修复面越界:(1) --fix 在
|
|
4
|
+
* langIssues>0 时直接 execSync 包管理器安装,#468 扩大检出语言后触发面变宽,
|
|
5
|
+
* 一次 doctor --fix(或 xp-gate install 末尾自动跑的 doctor --fix)就会给
|
|
6
|
+
* 机器装上新语言的工具;(2) install-cmd 把 doctor 的诊断退出码原样当作安装
|
|
7
|
+
* 结果,#495 的 Module drift FAIL 会让"装成功却 exit 1",训练脚本误判失败。
|
|
8
|
+
* 另外 bin 的 usage 行没同步 --force/--install-tools,未知 flag 已被严格拒绝,
|
|
9
|
+
* 帮助文本比实现更旧就会把人引向失败命令。Round 3 给 KNOWN_FLAGS 加了
|
|
10
|
+
* --json/--format,同一条"帮助比实现旧"的缺口会原样复现,故断言改为直接
|
|
11
|
+
* 读解析器的白名单,不再在本文件里维护第二张表。
|
|
12
|
+
* @covers AC-502-01, AC-502-02, AC-502-03
|
|
13
|
+
*/
|
|
14
|
+
import { readFileSync } from 'node:fs';
|
|
15
|
+
import { join } from 'node:path';
|
|
16
|
+
import { describe, expect, it } from 'vitest';
|
|
17
|
+
|
|
18
|
+
const DOCTOR = readFileSync(join(__dirname, '..', 'doctor.js'), 'utf8');
|
|
19
|
+
const INSTALL_CMD = readFileSync(join(__dirname, '..', 'install-cmd.js'), 'utf8');
|
|
20
|
+
const BIN = readFileSync(join(__dirname, '..', '..', 'bin', 'xp-gate.js'), 'utf8');
|
|
21
|
+
|
|
22
|
+
describe('doctor/install repair boundary (#502)', () => {
|
|
23
|
+
it('AC-502-01: language tool auto-install requires an explicit opt-in flag', () => {
|
|
24
|
+
expect(DOCTOR).toMatch(/KNOWN_FLAGS\s*=\s*\[[^\]]*'--install-tools'/);
|
|
25
|
+
// The execSync-backed installer must be gated by the flag, not just by --fix.
|
|
26
|
+
expect(DOCTOR).toMatch(/if \(fixMode && installToolsFlag && langIssues > 0\)/);
|
|
27
|
+
expect(DOCTOR).not.toMatch(/if \(fixMode && langIssues > 0\)\s*\{/);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('AC-502-02: xp-gate install reports doctor findings without adopting its exit code', () => {
|
|
31
|
+
expect(INSTALL_CMD).not.toMatch(/return doctorCode/);
|
|
32
|
+
// The install action's own failure still has to fail the command.
|
|
33
|
+
expect(INSTALL_CMD).toMatch(/return code/);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('AC-502-03: the doctor usage line advertises every flag the parser accepts', () => {
|
|
37
|
+
const usage = BIN.match(/usage: 'xp-gate doctor[^']*'/);
|
|
38
|
+
expect(usage, 'bin/xp-gate.js has no doctor usage line').not.toBeNull();
|
|
39
|
+
// Read the accepted set from the parser instead of repeating a list here:
|
|
40
|
+
// a hardcoded list goes stale exactly when a flag is added, which is the
|
|
41
|
+
// defect this assertion exists to prevent (#488 Round 3 did it for the
|
|
42
|
+
// release notes; the CLI help is the same command surface).
|
|
43
|
+
const accepted = DOCTOR.match(/const KNOWN_FLAGS\s*=\s*\[([^\]]*)\]/)[1]
|
|
44
|
+
.split(',')
|
|
45
|
+
.map((entry) => entry.trim().replace(/^'|'$/g, ''))
|
|
46
|
+
.filter(Boolean);
|
|
47
|
+
expect(accepted.length).toBeGreaterThan(4);
|
|
48
|
+
for (const flag of accepted) {
|
|
49
|
+
expect(usage[0], `usage line does not mention ${flag}`).toContain(flag);
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
});
|