@boyingliu01/xp-gate 0.23.0 → 0.25.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/adapter-common.sh +51 -2
- package/adapters/python.sh +10 -2
- package/bin/xp-gate.js +15 -1
- package/hooks/adapter-common.sh +51 -2
- package/hooks/lib/coverage-total.sh +20 -0
- package/hooks/lib/python-test-selection.sh +132 -0
- package/hooks/pre-commit +100 -11
- package/lib/__tests__/delphi-setup.test.js +203 -0
- package/lib/__tests__/doctor-delphi.test.js +244 -0
- package/lib/__tests__/init-gitleaks-notice.test.js +57 -0
- package/lib/__tests__/issue-519-phase-transition-guard.test.js +233 -0
- package/lib/__tests__/qoder-delphi-agents.test.js +31 -0
- package/lib/delphi-setup.js +155 -0
- package/lib/doctor.js +269 -1
- package/lib/init.js +49 -1
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/phase-transition.js +61 -0
- package/lib/sprint-state-manager.js +86 -39
- package/lib/sprint-state-migrator.js +60 -22
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/gremlins-runner.test.ts +167 -0
- package/mutation/__tests__/mutmut-runner.test.ts +20 -22
- package/mutation/__tests__/runners-index.test.ts +31 -3
- package/mutation/gate-m.ts +22 -4
- package/mutation/runners/gremlins-runner.ts +238 -0
- package/mutation/runners/index.ts +9 -1
- package/mutation/runners/mutmut-runner.ts +18 -12
- package/mutation/runners/types.ts +20 -0
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/delphi-review/SKILL.md +43 -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/.delphi-config.json.example +24 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/delphi-review/SKILL.md +43 -0
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/.delphi-config.json.example +24 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/delphi-review/SKILL.md +43 -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/.delphi-config.json.example +24 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/delphi-review/SKILL.md +43 -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/scripts/delphi-external-review.cjs +166 -14
- package/skills/delphi-review/.delphi-config.json.example +24 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/delphi-review/SKILL.md +43 -0
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test delphi-setup
|
|
3
|
+
* @intent Guard #414 REQ-4: `xp-gate delphi-setup` is the repair path for
|
|
4
|
+
* drifted Qoder agent bindings. `xp-gate init` deliberately never overwrites
|
|
5
|
+
* an existing agent file, so a stale binding could previously never be fixed
|
|
6
|
+
* without hand-editing. delphi-setup must write three distinct, quoted model
|
|
7
|
+
* bindings, refuse duplicates and junk, and always demand an IDE reload —
|
|
8
|
+
* Qoder caches agent definitions at session start, so a skipped reload looks
|
|
9
|
+
* exactly like a fix that did not work.
|
|
10
|
+
*/
|
|
11
|
+
const fs = require('fs');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
const { resetLibModules, muteConsole, makeTempDirs, removeTempDirs } = require('./helpers/lib-sandbox.cjs');
|
|
14
|
+
|
|
15
|
+
const LIB_MODULES = ['../init', '../detect-deps.js', '../shared-paths', '../delphi-setup'].map(name => require.resolve(name));
|
|
16
|
+
|
|
17
|
+
function repoRoot() {
|
|
18
|
+
return path.join(__dirname, '..', '..', '..', '..');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
describe('delphi-setup (#414 REQ-4)', () => {
|
|
22
|
+
let tmpDirs;
|
|
23
|
+
let tmpHome;
|
|
24
|
+
let originalHome;
|
|
25
|
+
|
|
26
|
+
beforeEach(() => {
|
|
27
|
+
tmpDirs = makeTempDirs(['delphi-setup-home']);
|
|
28
|
+
tmpHome = tmpDirs['delphi-setup-home'];
|
|
29
|
+
originalHome = process.env.HOME;
|
|
30
|
+
process.env.HOME = tmpHome;
|
|
31
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
|
|
32
|
+
resetLibModules(vi, LIB_MODULES);
|
|
33
|
+
muteConsole(vi);
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
afterEach(() => {
|
|
37
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
38
|
+
removeTempDirs(tmpDirs);
|
|
39
|
+
vi.restoreAllMocks();
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
function agentsDir() {
|
|
43
|
+
return path.join(tmpHome, '.qoder', 'agents');
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
it('writes three agent files with the requested distinct bindings', async () => {
|
|
47
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
48
|
+
const code = await delphiSetup([
|
|
49
|
+
'--model', 'architecture=qwen-max',
|
|
50
|
+
'--model', 'technical=deepseek-v3.2',
|
|
51
|
+
'--model', 'feasibility=glm-5.2',
|
|
52
|
+
]);
|
|
53
|
+
expect(code).toBe(0);
|
|
54
|
+
const { readQoderModelId } = require('../init');
|
|
55
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-architecture.md'))).toBe('qwen-max');
|
|
56
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-technical.md'))).toBe('deepseek-v3.2');
|
|
57
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-feasibility.md'))).toBe('glm-5.2');
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
it('preserves the rest of the bundled template body when rebinding', async () => {
|
|
61
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
62
|
+
await delphiSetup([
|
|
63
|
+
'--model', 'architecture=qwen-max',
|
|
64
|
+
'--model', 'technical=deepseek-v3.2',
|
|
65
|
+
'--model', 'feasibility=glm-5.2',
|
|
66
|
+
]);
|
|
67
|
+
const body = fs.readFileSync(path.join(agentsDir(), 'delphi-architecture.md'), 'utf8');
|
|
68
|
+
const shippedBody = fs.readFileSync(path.join(repoRoot(), 'plugins', 'qoder', 'agents', 'delphi-architecture.md'), 'utf8');
|
|
69
|
+
// The name line is template identity; only the binding may change.
|
|
70
|
+
expect(body).toMatch(/^name: delphi-architecture$/m);
|
|
71
|
+
expect(body).not.toBe('');
|
|
72
|
+
expect(shippedBody).toMatch(/^name: delphi-architecture$/m);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('repairs a stale binding by overwriting it (this is the point of the command)', async () => {
|
|
76
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
77
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(agentsDir(), 'delphi-architecture.md'),
|
|
80
|
+
'---\nname: delphi-architecture\nmodel: "[Retired-Model](dead-model-id)"\n---\n# body\n',
|
|
81
|
+
);
|
|
82
|
+
const code = await delphiSetup([
|
|
83
|
+
'--model', 'architecture=qwen-max',
|
|
84
|
+
'--model', 'technical=deepseek-v3.2',
|
|
85
|
+
'--model', 'feasibility=glm-5.2',
|
|
86
|
+
]);
|
|
87
|
+
expect(code).toBe(0);
|
|
88
|
+
const { readQoderModelId } = require('../init');
|
|
89
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-architecture.md'))).toBe('qwen-max');
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// R1 FC-04: a repair without a rollback is a second way to lose a working
|
|
93
|
+
// binding — existing files are backed up before the first write.
|
|
94
|
+
it('backs up existing agent files before overwriting them', async () => {
|
|
95
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
96
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
97
|
+
const staleBody = '---\nname: delphi-architecture\nmodel: "[Retired-Model](dead-model-id)"\n---\n# body\n';
|
|
98
|
+
fs.writeFileSync(path.join(agentsDir(), 'delphi-architecture.md'), staleBody);
|
|
99
|
+
const code = await delphiSetup([
|
|
100
|
+
'--model', 'architecture=qwen-max',
|
|
101
|
+
'--model', 'technical=deepseek-v3.2',
|
|
102
|
+
'--model', 'feasibility=glm-5.2',
|
|
103
|
+
]);
|
|
104
|
+
expect(code).toBe(0);
|
|
105
|
+
// Some backup copy of the stale file must exist somewhere under the
|
|
106
|
+
// sandbox HOME config dir; the exact timestamped dir is intentional.
|
|
107
|
+
const backupRoot = path.join(tmpHome, '.config', 'xp-gate', 'backup');
|
|
108
|
+
const found = (function find(dir) {
|
|
109
|
+
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
110
|
+
const p = path.join(dir, e.name);
|
|
111
|
+
if (e.isDirectory()) {
|
|
112
|
+
const hit = find(p);
|
|
113
|
+
if (hit) return hit;
|
|
114
|
+
} else if (e.name === 'delphi-architecture.md') {
|
|
115
|
+
return p;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
return null;
|
|
119
|
+
})(backupRoot);
|
|
120
|
+
expect(found).not.toBeNull();
|
|
121
|
+
expect(fs.readFileSync(found, 'utf8')).toBe(staleBody);
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// R1 MN-05: a BOM in front of '---' must not read as a missing frontmatter.
|
|
125
|
+
it('rebinds a template carrying a UTF-8 BOM', async () => {
|
|
126
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
127
|
+
fs.mkdirSync(agentsDir(), { recursive: true });
|
|
128
|
+
fs.writeFileSync(
|
|
129
|
+
path.join(agentsDir(), 'delphi-architecture.md'),
|
|
130
|
+
'\uFEFF---\nname: delphi-architecture\nmodel: "[Old](old-id)"\n---\n# body\n',
|
|
131
|
+
);
|
|
132
|
+
const code = await delphiSetup([
|
|
133
|
+
'--model', 'architecture=qwen-max',
|
|
134
|
+
'--model', 'technical=deepseek-v3.2',
|
|
135
|
+
'--model', 'feasibility=glm-5.2',
|
|
136
|
+
]);
|
|
137
|
+
expect(code).toBe(0);
|
|
138
|
+
const { readQoderModelId } = require('../init');
|
|
139
|
+
expect(readQoderModelId(path.join(agentsDir(), 'delphi-architecture.md'))).toBe('qwen-max');
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('rejects duplicate model IDs — three distinct models are the invariant being set up', async () => {
|
|
143
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
144
|
+
const code = await delphiSetup([
|
|
145
|
+
'--model', 'architecture=same-model',
|
|
146
|
+
'--model', 'technical=same-model',
|
|
147
|
+
'--model', 'feasibility=other',
|
|
148
|
+
]);
|
|
149
|
+
expect(code).toBe(1);
|
|
150
|
+
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
it('rejects an unknown role and a bare model id without a role=', async () => {
|
|
154
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
155
|
+
expect(await delphiSetup(['--model', 'critic=x'])).toBe(1);
|
|
156
|
+
expect(await delphiSetup(['--model', 'just-a-model'])).toBe(1);
|
|
157
|
+
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('rejects a model id outside the platform binding charset', async () => {
|
|
161
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
162
|
+
const code = await delphiSetup([
|
|
163
|
+
'--model', 'architecture=has space',
|
|
164
|
+
'--model', 'technical=deepseek-v3.2',
|
|
165
|
+
'--model', 'feasibility=glm-5.2',
|
|
166
|
+
]);
|
|
167
|
+
expect(code).toBe(1);
|
|
168
|
+
expect(fs.existsSync(agentsDir())).toBe(false);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it('demands an IDE reload on success — the registry only loads at session start', async () => {
|
|
172
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
173
|
+
const logs = [];
|
|
174
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
175
|
+
await delphiSetup([
|
|
176
|
+
'--model', 'architecture=qwen-max',
|
|
177
|
+
'--model', 'technical=deepseek-v3.2',
|
|
178
|
+
'--model', 'feasibility=glm-5.2',
|
|
179
|
+
]);
|
|
180
|
+
logSpy.mockRestore();
|
|
181
|
+
const out = logs.join('\n');
|
|
182
|
+
expect(out).toMatch(/重载|reload|新会话|restart/i);
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
it('--local --project targets the project .qoder/agents instead of HOME', async () => {
|
|
186
|
+
const { delphiSetup } = require('../delphi-setup');
|
|
187
|
+
const projectDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'delphi-setup-proj-'));
|
|
188
|
+
try {
|
|
189
|
+
const code = await delphiSetup([
|
|
190
|
+
'--local', '--project', projectDir,
|
|
191
|
+
'--model', 'architecture=qwen-max',
|
|
192
|
+
'--model', 'technical=deepseek-v3.2',
|
|
193
|
+
'--model', 'feasibility=glm-5.2',
|
|
194
|
+
]);
|
|
195
|
+
expect(code).toBe(0);
|
|
196
|
+
const { readQoderModelId } = require('../init');
|
|
197
|
+
expect(readQoderModelId(path.join(projectDir, '.qoder', 'agents', 'delphi-architecture.md'))).toBe('qwen-max');
|
|
198
|
+
expect(fs.existsSync(path.join(tmpHome, '.qoder', 'agents'))).toBe(false);
|
|
199
|
+
} finally {
|
|
200
|
+
fs.rmSync(projectDir, { recursive: true, force: true });
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
});
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test doctor-delphi
|
|
3
|
+
* @intent Guard #414 REQ-3/REQ-6: `xp-gate doctor delphi` turns the failure
|
|
4
|
+
* modes measured on real machines into diagnosable checks — drifted agent
|
|
5
|
+
* bindings (B2), a skill install that silently lagged the published package
|
|
6
|
+
* (B4), a runner script nobody can find (B3), and a config that cannot serve
|
|
7
|
+
* three distinct models. Every check must be assertable in isolation, and the
|
|
8
|
+
* npm-latest probe must be skippable so the doctor never needs the network.
|
|
9
|
+
*/
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const path = require('path');
|
|
12
|
+
const { resetLibModules, muteConsole, makeTempDirs, removeTempDirs } = require('./helpers/lib-sandbox.cjs');
|
|
13
|
+
|
|
14
|
+
const LIB_MODULES = ['../init', '../detect-deps.js', '../shared-paths', '../doctor', '../delphi-setup'].map(name => require.resolve(name));
|
|
15
|
+
|
|
16
|
+
function repoRoot() {
|
|
17
|
+
return path.join(__dirname, '..', '..', '..', '..');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function bundledSkillDir() {
|
|
21
|
+
return path.join(repoRoot(), 'src', 'npm-package', 'skills', 'delphi-review');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
describe('doctor delphi (#414 REQ-3/REQ-6)', () => {
|
|
25
|
+
let tmpDirs;
|
|
26
|
+
let tmpHome;
|
|
27
|
+
let originalHome;
|
|
28
|
+
|
|
29
|
+
beforeEach(() => {
|
|
30
|
+
tmpDirs = makeTempDirs(['doctor-delphi-home']);
|
|
31
|
+
tmpHome = tmpDirs['doctor-delphi-home'];
|
|
32
|
+
originalHome = process.env.HOME;
|
|
33
|
+
process.env.HOME = tmpHome;
|
|
34
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
|
|
35
|
+
resetLibModules(vi, LIB_MODULES);
|
|
36
|
+
muteConsole(vi);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
afterEach(() => {
|
|
40
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
41
|
+
removeTempDirs(tmpDirs);
|
|
42
|
+
vi.restoreAllMocks();
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
function healthyHome() {
|
|
46
|
+
// A home that would pass the agent + skill-parity checks.
|
|
47
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
|
|
48
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills', 'delphi-review'), { recursive: true });
|
|
49
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
50
|
+
fs.copyFileSync(
|
|
51
|
+
path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
|
|
52
|
+
path.join(tmpHome, '.qoder', 'agents', `delphi-${role}.md`),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
for (const f of fs.readdirSync(bundledSkillDir(), { withFileTypes: true })) {
|
|
56
|
+
const dest = path.join(tmpHome, '.qoder', 'skills', 'delphi-review', f.name);
|
|
57
|
+
if (f.isDirectory()) {
|
|
58
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
59
|
+
for (const inner of fs.readdirSync(path.join(bundledSkillDir(), f.name))) {
|
|
60
|
+
if (fs.statSync(path.join(bundledSkillDir(), f.name, inner)).isFile()) {
|
|
61
|
+
fs.copyFileSync(path.join(bundledSkillDir(), f.name, inner), path.join(dest, inner));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
} else if (f.isFile()) {
|
|
65
|
+
fs.copyFileSync(path.join(bundledSkillDir(), f.name), dest);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function writeConfig(configPath) {
|
|
71
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
72
|
+
active_profile: 'default',
|
|
73
|
+
profiles: {
|
|
74
|
+
default: {
|
|
75
|
+
providers: {
|
|
76
|
+
gw: { base_url: 'https://gw.example/v1', api_key: 'k' },
|
|
77
|
+
},
|
|
78
|
+
experts: {
|
|
79
|
+
architecture: { provider: 'gw', model: 'model-a' },
|
|
80
|
+
technical: { provider: 'gw', model: 'model-b' },
|
|
81
|
+
feasibility: { provider: 'gw', model: 'model-c' },
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
}));
|
|
86
|
+
return configPath;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
it('a fully healthy environment PASSes every check and exits 0', async () => {
|
|
90
|
+
healthyHome();
|
|
91
|
+
const { doctorDelphi } = require('../doctor');
|
|
92
|
+
const configPath = writeConfig(path.join(tmpDirs['doctor-delphi-home'], '.delphi-config.json'));
|
|
93
|
+
const localVersion = require('../../package.json').version;
|
|
94
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: localVersion });
|
|
95
|
+
expect(code).toBe(0);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('a stale agent binding FAILs and names delphi-setup as the repair path', async () => {
|
|
99
|
+
healthyHome();
|
|
100
|
+
fs.writeFileSync(
|
|
101
|
+
path.join(tmpHome, '.qoder', 'agents', 'delphi-technical.md'),
|
|
102
|
+
'---\nname: delphi-technical\nmodel: "[Retired](retired-id)"\n---\n# body\n',
|
|
103
|
+
);
|
|
104
|
+
const { doctorDelphi } = require('../doctor');
|
|
105
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
|
|
106
|
+
expect(code).toBe(1);
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
it('a skill install that drifted from the bundled copy FAILs with a reinstall hint', async () => {
|
|
110
|
+
healthyHome();
|
|
111
|
+
const installed = path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'SKILL.md');
|
|
112
|
+
fs.appendFileSync(installed, '\ndrifted line\n');
|
|
113
|
+
const logs = [];
|
|
114
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
115
|
+
const { doctorDelphi } = require('../doctor');
|
|
116
|
+
await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
|
|
117
|
+
logSpy.mockRestore();
|
|
118
|
+
expect(logs.join('\n')).toMatch(/install-skill|reinstall/i);
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
it('a duplicate-model config FAILs instead of promising a review that will BLOCK', async () => {
|
|
122
|
+
healthyHome();
|
|
123
|
+
const configPath = path.join(tmpHome, '.delphi-config.json');
|
|
124
|
+
fs.writeFileSync(configPath, JSON.stringify({
|
|
125
|
+
active_profile: 'default',
|
|
126
|
+
profiles: {
|
|
127
|
+
default: {
|
|
128
|
+
providers: { gw: { base_url: 'https://gw.example/v1', api_key: 'k' } },
|
|
129
|
+
experts: {
|
|
130
|
+
architecture: { provider: 'gw', model: 'same' },
|
|
131
|
+
technical: { provider: 'gw', model: 'same' },
|
|
132
|
+
feasibility: { provider: 'gw', model: 'model-c' },
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
}));
|
|
137
|
+
const { doctorDelphi } = require('../doctor');
|
|
138
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
139
|
+
expect(code).toBe(1);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it('a missing config FAILs with the copy-the-example hint', async () => {
|
|
143
|
+
healthyHome();
|
|
144
|
+
const logs = [];
|
|
145
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
146
|
+
const { doctorDelphi } = require('../doctor');
|
|
147
|
+
await doctorDelphi({ projectDir: tmpHome, configPath: path.join(tmpHome, 'absent.json'), npmLatest: null });
|
|
148
|
+
logSpy.mockRestore();
|
|
149
|
+
expect(logs.join('\n')).toMatch(/\.delphi-config\.json/);
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
it('npmLatest=null skips the version check without failing the run', async () => {
|
|
153
|
+
healthyHome();
|
|
154
|
+
const { doctorDelphi } = require('../doctor');
|
|
155
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
156
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
157
|
+
expect(code).toBe(0);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// R1 FC-07: the switch shipping off is the platform default, not a defect —
|
|
161
|
+
// only enabled-but-underprovisioned is a real FAIL.
|
|
162
|
+
it('DSH with subagentModelSelection absent is SKIP, not FAIL', async () => {
|
|
163
|
+
healthyHome();
|
|
164
|
+
fs.mkdirSync(path.join(tmpHome, '.dsh'), { recursive: true });
|
|
165
|
+
fs.writeFileSync(path.join(tmpHome, '.dsh', 'settings.json'), JSON.stringify({ other: true }));
|
|
166
|
+
const { doctorDelphi } = require('../doctor');
|
|
167
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
168
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
169
|
+
expect(code).toBe(0);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
// R1 FC-09: a project-level agent set (delphi-setup --local --project) is
|
|
173
|
+
// what the doctor reads, so the repair loop closes.
|
|
174
|
+
it('a complete project-level agent set takes precedence over the HOME set', async () => {
|
|
175
|
+
healthyHome();
|
|
176
|
+
const projDir = fs.mkdtempSync(path.join(require('os').tmpdir(), 'doctor-delphi-proj-'));
|
|
177
|
+
try {
|
|
178
|
+
fs.mkdirSync(path.join(projDir, '.qoder', 'agents'), { recursive: true });
|
|
179
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
180
|
+
fs.copyFileSync(
|
|
181
|
+
path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
|
|
182
|
+
path.join(projDir, '.qoder', 'agents', `delphi-${role}.md`),
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
// Corrupt the HOME set; the project set must win and the run stays green.
|
|
186
|
+
fs.writeFileSync(
|
|
187
|
+
path.join(tmpHome, '.qoder', 'agents', 'delphi-technical.md'),
|
|
188
|
+
'---\nname: delphi-technical\nmodel: "[Retired](retired-id)"\n---\n# body\n',
|
|
189
|
+
);
|
|
190
|
+
const { doctorDelphi } = require('../doctor');
|
|
191
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
192
|
+
const code = await doctorDelphi({
|
|
193
|
+
projectDir: projDir, configPath, npmLatest: null,
|
|
194
|
+
});
|
|
195
|
+
expect(code).toBe(0);
|
|
196
|
+
} finally {
|
|
197
|
+
fs.rmSync(projDir, { recursive: true, force: true });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
// R1 MI-03: parity covers the whole bundled file set, so a stale references/
|
|
202
|
+
// copy cannot hide behind a matching SKILL.md.
|
|
203
|
+
it('a drifted references file FAILs parity even when SKILL.md matches', async () => {
|
|
204
|
+
healthyHome();
|
|
205
|
+
// R2 MJ-R2-01: the assertion must be the ONLY way this test can fail. An
|
|
206
|
+
// absent config would exit 1 regardless of parity, so the fixture supplies
|
|
207
|
+
// a valid one and lets the drifted file carry the verdict.
|
|
208
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
209
|
+
const refsDir = path.join(bundledSkillDir(), 'references');
|
|
210
|
+
const refFile = fs.existsSync(refsDir)
|
|
211
|
+
? fs.readdirSync(refsDir).find((f) => f.endsWith('.md'))
|
|
212
|
+
: null;
|
|
213
|
+
expect(refFile).toBeTruthy(); // explicit: without a bundled reference this test has nothing to say
|
|
214
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'references'), { recursive: true });
|
|
215
|
+
fs.appendFileSync(
|
|
216
|
+
path.join(tmpHome, '.qoder', 'skills', 'delphi-review', 'references', refFile),
|
|
217
|
+
'\ndrift\n',
|
|
218
|
+
);
|
|
219
|
+
const { doctorDelphi } = require('../doctor');
|
|
220
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null });
|
|
221
|
+
expect(code).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
// R3 FC-01/FC-02: the empty-bundle branch is driven directly through the
|
|
225
|
+
// injectable bundledSkillDir — the healthy fixture cannot exercise it, and
|
|
226
|
+
// an indirect output-shape assertion would pass even without the fix.
|
|
227
|
+
it('an empty bundled skill directory FAILs parity instead of passing vacuously', async () => {
|
|
228
|
+
healthyHome();
|
|
229
|
+
const emptyBundle = fs.mkdtempSync(path.join(require('os').tmpdir(), 'doctor-empty-bundle-'));
|
|
230
|
+
try {
|
|
231
|
+
const logs = [];
|
|
232
|
+
const logSpy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
|
|
233
|
+
const { doctorDelphi } = require('../doctor');
|
|
234
|
+
const configPath = writeConfig(path.join(tmpHome, '.delphi-config.json'));
|
|
235
|
+
const code = await doctorDelphi({ projectDir: tmpHome, configPath, npmLatest: null, bundledSkillDir: emptyBundle });
|
|
236
|
+
logSpy.mockRestore();
|
|
237
|
+
expect(code).toBe(1);
|
|
238
|
+
const parityLine = logs.find((l) => l.includes('Delphi skill install parity'));
|
|
239
|
+
expect(parityLine).toContain('missing or empty');
|
|
240
|
+
} finally {
|
|
241
|
+
fs.rmSync(emptyBundle, { recursive: true, force: true });
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @intent #511: Gate 8 is fail-closed since #499 (missing gitleaks BLOCKS the
|
|
3
|
+
* commit). The npm init path never surfaced that fact, so an upgrading
|
|
4
|
+
* user met a hard BLOCK with no prior signal. `probeGitleaksNotice`
|
|
5
|
+
* prints a migration notice and never installs anything.
|
|
6
|
+
* @covers probeGitleaksNotice() in lib/init.js
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { describe, it, expect, vi } from 'vitest';
|
|
10
|
+
import { createRequire } from 'module';
|
|
11
|
+
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
const { probeGitleaksNotice } = require('../init.js');
|
|
14
|
+
|
|
15
|
+
describe('probeGitleaksNotice (#511)', () => {
|
|
16
|
+
it('reports present and stays quiet when gitleaks is available', () => {
|
|
17
|
+
const lines = [];
|
|
18
|
+
const result = probeGitleaksNotice(
|
|
19
|
+
() => ({ available: true, version: '8.24.2' }),
|
|
20
|
+
l => lines.push(l),
|
|
21
|
+
);
|
|
22
|
+
expect(result.available).toBe(true);
|
|
23
|
+
const joined = lines.join('\n');
|
|
24
|
+
expect(joined).toContain('gitleaks present');
|
|
25
|
+
expect(joined).toContain('8.24.2');
|
|
26
|
+
expect(joined).not.toContain('FAIL-CLOSED');
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
it('prints the fail-closed migration notice when gitleaks is missing', () => {
|
|
30
|
+
const lines = [];
|
|
31
|
+
const result = probeGitleaksNotice(() => ({ available: false }), l => lines.push(l));
|
|
32
|
+
expect(result.available).toBe(false);
|
|
33
|
+
const joined = lines.join('\n');
|
|
34
|
+
expect(joined).toContain('FAIL-CLOSED');
|
|
35
|
+
expect(joined).toContain('Gate 8');
|
|
36
|
+
expect(joined).toContain('install-gitleaks.sh');
|
|
37
|
+
expect(joined).toContain('brew install gitleaks');
|
|
38
|
+
expect(joined).toContain('winget install gitleaks');
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it('probes exactly the gitleaks tool name', () => {
|
|
42
|
+
const checkTool = vi.fn(() => ({ available: true }));
|
|
43
|
+
probeGitleaksNotice(checkTool, () => {});
|
|
44
|
+
expect(checkTool).toHaveBeenCalledWith('gitleaks');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('never installs anything — it is a pure text probe', () => {
|
|
48
|
+
// The helper must not shell out: no exec/spawn import is exercised here,
|
|
49
|
+
// and the only observable effect is log lines. A missing tool returns the
|
|
50
|
+
// notice; the caller decides whether to install.
|
|
51
|
+
const lines = [];
|
|
52
|
+
const before = process.env.PATH;
|
|
53
|
+
probeGitleaksNotice(() => ({ available: false }), l => lines.push(l));
|
|
54
|
+
expect(process.env.PATH).toBe(before);
|
|
55
|
+
expect(lines.some(l => /dev\/null|execSync|spawn/.test(l))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
});
|