@boyingliu01/xp-gate 0.35.17 → 0.35.19
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-3.sh +94 -9
- package/adapters/gate-4.sh +27 -32
- package/adapters/gate-8.sh +32 -3
- package/adapters/gate-9.sh +14 -2
- package/gate-12-file-hygiene.sh +26 -4
- package/gate-13-compiler-strictness.sh +12 -0
- package/gates/gate-3.ts +77 -21
- package/gates/gate-8.ts +39 -26
- package/gates/gate-9.ts +4 -4
- package/hooks/gate-12-file-hygiene.sh +26 -4
- package/hooks/gate-13-compiler-strictness.sh +12 -0
- package/hooks/pre-commit +75 -11
- package/hooks/pre-push +11 -2
- package/lib/__tests__/doctor-delphi.test.js +114 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
- package/lib/__tests__/doctor.test.js +9 -0
- package/lib/__tests__/gate-numbering.test.js +52 -0
- package/lib/__tests__/gate-runner.test.js +59 -14
- package/lib/__tests__/shared-utils.test.js +47 -0
- package/lib/__tests__/update-skill.test.js +77 -0
- package/lib/doctor-tui.js +23 -2
- package/lib/doctor.js +28 -7
- package/lib/gate-runner.js +98 -18
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-utils.js +27 -1
- package/lib/update-skill.js +59 -3
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +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/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
- package/plugins/claude-code/skills/sprint-flow/references/definition-of-done.md +47 -0
- package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +16 -0
- package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +14 -0
- package/plugins/claude-code/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
- 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/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
- package/plugins/dsh/skills/sprint-flow/references/definition-of-done.md +47 -0
- package/plugins/dsh/skills/sprint-flow/references/phase-4-verify.md +16 -0
- package/plugins/dsh/skills/sprint-flow/references/phase-5-ship.md +14 -0
- package/plugins/dsh/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-findings.test.ts +32 -6
- package/plugins/dsh/src/exit-code.test.ts +6 -0
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
- package/plugins/opencode/skills/sprint-flow/references/definition-of-done.md +47 -0
- package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +16 -0
- package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +14 -0
- package/plugins/opencode/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
- 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/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
- package/plugins/qoder/skills/sprint-flow/references/definition-of-done.md +47 -0
- package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +16 -0
- package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +14 -0
- package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/__tests__/sync-package-content.test.js +114 -46
- package/scripts/sync-package-content.js +32 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
- package/skills/sprint-flow/references/definition-of-done.md +47 -0
- package/skills/sprint-flow/references/phase-4-verify.md +16 -0
- package/skills/sprint-flow/references/phase-5-ship.md +14 -0
- package/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
- package/skills/test-specification-alignment/AGENTS.md +3 -3
|
@@ -9,6 +9,7 @@ const fs = require('node:fs');
|
|
|
9
9
|
const path = require('node:path');
|
|
10
10
|
const os = require('node:os');
|
|
11
11
|
const { execFileSync, spawnSync } = require('node:child_process');
|
|
12
|
+
const { findConsumerGateVariant } = require('../gate-runner');
|
|
12
13
|
|
|
13
14
|
let tmpProject;
|
|
14
15
|
let tmpAdapters;
|
|
@@ -25,15 +26,26 @@ afterEach(() => {
|
|
|
25
26
|
}
|
|
26
27
|
});
|
|
27
28
|
|
|
29
|
+
// Fixture writers run in every `beforeAll`; swallowing their failure would turn a
|
|
30
|
+
// broken fixture into "the gate under test misbehaved", which is the opposite of
|
|
31
|
+
// what the assertion is checking. Report which file could not be written.
|
|
32
|
+
function writeFixture(filePath, content) {
|
|
33
|
+
try {
|
|
34
|
+
fs.writeFileSync(filePath, content);
|
|
35
|
+
} catch (err) {
|
|
36
|
+
throw new Error(`Cannot write test fixture ${filePath}: ${err.message}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
28
40
|
function writeAdapterCommon() {
|
|
29
|
-
|
|
41
|
+
writeFixture(
|
|
30
42
|
path.join(tmpAdapters, 'adapter-common.sh'),
|
|
31
43
|
'detect_project_lang() { echo "typescript"; }'
|
|
32
44
|
);
|
|
33
45
|
}
|
|
34
46
|
|
|
35
47
|
function writeGateFragment(fragmentLines) {
|
|
36
|
-
|
|
48
|
+
writeFixture(
|
|
37
49
|
path.join(tmpAdapters, 'gate-9.sh'),
|
|
38
50
|
['# gate-9 fragment', ...fragmentLines, 'exit 0'].join('\n')
|
|
39
51
|
);
|
|
@@ -55,14 +67,21 @@ function hostilePathSegment() {
|
|
|
55
67
|
}
|
|
56
68
|
|
|
57
69
|
function initializeGitProject() {
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
70
|
+
try {
|
|
71
|
+
execFileSync('git', ['init', '-q'], { cwd: tmpProject });
|
|
72
|
+
execFileSync('git', ['config', 'user.email', 'test@example.com'], { cwd: tmpProject });
|
|
73
|
+
execFileSync('git', ['config', 'user.name', 'XP-Gate Test'], { cwd: tmpProject });
|
|
74
|
+
writeFixture(path.join(tmpProject, 'tracked.ts'), 'const value = 1;\n');
|
|
75
|
+
execFileSync('git', ['add', 'tracked.ts'], { cwd: tmpProject });
|
|
76
|
+
execFileSync('git', ['-c', 'core.hooksPath=/dev/null', 'commit', '-qm', 'initial'], {
|
|
77
|
+
cwd: tmpProject,
|
|
78
|
+
});
|
|
79
|
+
} catch (err) {
|
|
80
|
+
// Without this the failure surfaces as a downstream assertion on the gate's
|
|
81
|
+
// output. Name the setup step instead: a repo fixture that never initialised
|
|
82
|
+
// is not a gate defect.
|
|
83
|
+
throw new Error(`Cannot initialise the fixture git repo at ${tmpProject}: ${err.message}`);
|
|
84
|
+
}
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
// Runs runGateAdapter in a child node process (worker threads forbid process
|
|
@@ -188,7 +207,7 @@ describe('gate-runner.js — runGateAdapter', () => {
|
|
|
188
207
|
].join('\n')
|
|
189
208
|
);
|
|
190
209
|
|
|
191
|
-
const result = spawnSync(process.execPath, [cliPath, 'check', '
|
|
210
|
+
const result = spawnSync(process.execPath, [cliPath, 'check', '10'], {
|
|
192
211
|
cwd: tmpProject,
|
|
193
212
|
encoding: 'utf8',
|
|
194
213
|
timeout: 30000,
|
|
@@ -225,7 +244,7 @@ describe('gate-runner.js — runGateAdapter', () => {
|
|
|
225
244
|
|
|
226
245
|
const result = spawnSync(
|
|
227
246
|
process.execPath,
|
|
228
|
-
[path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '
|
|
247
|
+
[path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '10', targetProject],
|
|
229
248
|
{
|
|
230
249
|
cwd: callerProject,
|
|
231
250
|
encoding: 'utf8',
|
|
@@ -283,7 +302,7 @@ describe('gate-runner.js — runGateAdapter', () => {
|
|
|
283
302
|
|
|
284
303
|
const result = spawnSync(
|
|
285
304
|
process.execPath,
|
|
286
|
-
[path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '
|
|
305
|
+
[path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '10'],
|
|
287
306
|
{ cwd: tmpProject, encoding: 'utf8', timeout: 30000 }
|
|
288
307
|
);
|
|
289
308
|
|
|
@@ -301,10 +320,36 @@ describe('gate-runner.js — runGateAdapter', () => {
|
|
|
301
320
|
|
|
302
321
|
const result = spawnSync(
|
|
303
322
|
process.execPath,
|
|
304
|
-
[path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '
|
|
323
|
+
[path.join(packageFixture, 'bin', 'xp-gate.js'), 'check', '10', targetProject],
|
|
305
324
|
{ cwd: tmpProject, encoding: 'utf8', timeout: 30000 }
|
|
306
325
|
);
|
|
307
326
|
|
|
308
327
|
expect(result.status).toBe(1);
|
|
309
328
|
}, 30000);
|
|
329
|
+
|
|
330
|
+
// The mirror image of the tests above, asserted on the resolver rather than on
|
|
331
|
+
// CLI output. A project that declares no variant must resolve to nothing, so
|
|
332
|
+
// the built-in implementation still runs.
|
|
333
|
+
//
|
|
334
|
+
// Asserted here, not through `check 9`: BOTH implementations print "Semgrep"
|
|
335
|
+
// (githooks/gate-9.sh says it five times too), and a bash fallback that finds
|
|
336
|
+
// no script prints neither banner, so every stdout-based assertion passed even
|
|
337
|
+
// with the TypeScript module hard-disabled -- the guard proved nothing.
|
|
338
|
+
it('resolves no variant when the project declares none (REQ-standalone-gates-05)', () => {
|
|
339
|
+
const plainProject = path.join(tmpProject, 'plain-target');
|
|
340
|
+
fs.mkdirSync(plainProject, { recursive: true });
|
|
341
|
+
|
|
342
|
+
expect(findConsumerGateVariant('gate-9', plainProject)).toBe(null);
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('resolves the declared variant when the project has one (REQ-standalone-gates-05)', () => {
|
|
346
|
+
const variantProject = path.join(tmpProject, 'variant-target');
|
|
347
|
+
const gatesDir = path.join(variantProject, 'githooks', 'gates');
|
|
348
|
+
fs.mkdirSync(gatesDir, { recursive: true });
|
|
349
|
+
const variantPath = path.join(gatesDir, 'gate-9-project-choice.sh');
|
|
350
|
+
fs.writeFileSync(variantPath, 'exit 0\n');
|
|
351
|
+
|
|
352
|
+
const resolved = findConsumerGateVariant('gate-9', variantProject);
|
|
353
|
+
expect(resolved).toBe(variantPath);
|
|
354
|
+
});
|
|
310
355
|
});
|
|
@@ -130,4 +130,51 @@ describe('shared-utils', () => {
|
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
132
|
|
|
133
|
+
describe('copyDirRecursive (#640 concurrency)', () => {
|
|
134
|
+
// The mirrored trees this walks are rewritten in place by
|
|
135
|
+
// scripts/sync-package-content.js. A concurrent init enumerates the
|
|
136
|
+
// directory, then finds an entry gone -- ENOENT on a file that reappears
|
|
137
|
+
// microseconds later. That must not abort the install.
|
|
138
|
+
it('skips a file that disappears between enumeration and copy', () => {
|
|
139
|
+
const src = path.join(tmpRoot, 'src');
|
|
140
|
+
const dest = path.join(tmpRoot, 'dest');
|
|
141
|
+
fs.mkdirSync(src, { recursive: true });
|
|
142
|
+
fs.writeFileSync(path.join(src, 'stable.sh'), 'stable');
|
|
143
|
+
fs.writeFileSync(path.join(src, 'vanishing.sh'), 'vanishing');
|
|
144
|
+
|
|
145
|
+
const realReadFileSync = fs.readFileSync;
|
|
146
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation((p, ...rest) => {
|
|
147
|
+
if (String(p).endsWith('vanishing.sh')) {
|
|
148
|
+
const err = new Error('ENOENT: no such file or directory');
|
|
149
|
+
err.code = 'ENOENT';
|
|
150
|
+
throw err;
|
|
151
|
+
}
|
|
152
|
+
return realReadFileSync(p, ...rest);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const { copyDirRecursive } = require('../shared-utils');
|
|
156
|
+
expect(() => copyDirRecursive(src, dest)).not.toThrow();
|
|
157
|
+
expect(fs.existsSync(path.join(dest, 'stable.sh'))).toBe(true);
|
|
158
|
+
expect(fs.existsSync(path.join(dest, 'vanishing.sh'))).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('still surfaces a real read error instead of silently skipping', () => {
|
|
162
|
+
// Only ENOENT means "being replaced". A permission or disk error is a real
|
|
163
|
+
// failure, and swallowing it would ship a half-installed hook set.
|
|
164
|
+
const src = path.join(tmpRoot, 'src2');
|
|
165
|
+
const dest = path.join(tmpRoot, 'dest2');
|
|
166
|
+
fs.mkdirSync(src, { recursive: true });
|
|
167
|
+
fs.writeFileSync(path.join(src, 'locked.sh'), 'locked');
|
|
168
|
+
|
|
169
|
+
vi.spyOn(fs, 'readFileSync').mockImplementation(() => {
|
|
170
|
+
const err = new Error('EACCES: permission denied');
|
|
171
|
+
err.code = 'EACCES';
|
|
172
|
+
throw err;
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
const { copyDirRecursive } = require('../shared-utils');
|
|
176
|
+
expect(() => copyDirRecursive(src, dest)).toThrow(/EACCES/);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
133
180
|
});
|
|
@@ -288,3 +288,80 @@ describe('update-skill', () => {
|
|
|
288
288
|
});
|
|
289
289
|
});
|
|
290
290
|
});
|
|
291
|
+
|
|
292
|
+
// ── #682: `--all` must enumerate what is on disk, not the config registry ────
|
|
293
|
+
// `xp-gate init` / `setup-global` deploy skills to disk but never write
|
|
294
|
+
// `config.installedSkills`, so `--all` iterated an always-empty object: it
|
|
295
|
+
// printed "Updating all skills..." and exited 0 having touched nothing. doctor
|
|
296
|
+
// solved the identical problem in #635 by enumerating the filesystem; this
|
|
297
|
+
// block pins the same contract for update-skill, plus the honesty requirement
|
|
298
|
+
// that a run which updates nothing must not look like a successful one.
|
|
299
|
+
describe('update-skill #682 filesystem enumeration', () => {
|
|
300
|
+
let tmpHome;
|
|
301
|
+
let originalHome;
|
|
302
|
+
let installSkillMock;
|
|
303
|
+
let logSpy;
|
|
304
|
+
let errorSpy;
|
|
305
|
+
|
|
306
|
+
beforeEach(() => {
|
|
307
|
+
originalHome = process.env.HOME;
|
|
308
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-up682-'));
|
|
309
|
+
process.env.HOME = tmpHome;
|
|
310
|
+
vi.resetModules();
|
|
311
|
+
delete require.cache[require.resolve('../update-skill')];
|
|
312
|
+
delete require.cache[require.resolve('../install-skill.js')];
|
|
313
|
+
installSkillMock = vi.fn().mockResolvedValue(0);
|
|
314
|
+
const p = require.resolve('../install-skill.js');
|
|
315
|
+
require.cache[p] = { id: p, filename: p, loaded: true, exports: { installSkill: installSkillMock } };
|
|
316
|
+
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
|
317
|
+
errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
afterEach(() => {
|
|
321
|
+
process.env.HOME = originalHome;
|
|
322
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
323
|
+
delete require.cache[require.resolve('../install-skill.js')];
|
|
324
|
+
delete require.cache[require.resolve('../update-skill')];
|
|
325
|
+
vi.restoreAllMocks();
|
|
326
|
+
});
|
|
327
|
+
|
|
328
|
+
function deploySkill(platform, name) {
|
|
329
|
+
const dir = path.join(tmpHome, '.config', platform, 'skills', name);
|
|
330
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
331
|
+
fs.writeFileSync(path.join(dir, 'SKILL.md'), 'old content');
|
|
332
|
+
return dir;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
it('AC-682-01: --all updates skills found on disk even when config.installedSkills is absent', async () => {
|
|
336
|
+
deploySkill('opencode', 'sprint-flow');
|
|
337
|
+
deploySkill('opencode', 'delphi-review');
|
|
338
|
+
const { updateSkill } = require('../update-skill');
|
|
339
|
+
const code = await updateSkill(null, { all: true });
|
|
340
|
+
expect(code).toBe(0);
|
|
341
|
+
const names = installSkillMock.mock.calls.map((c) => c[0]);
|
|
342
|
+
expect(names).toContain('sprint-flow');
|
|
343
|
+
expect(names).toContain('delphi-review');
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
it('AC-682-02: --all does not update the same skill twice for the same platform', async () => {
|
|
347
|
+
deploySkill('opencode', 'sprint-flow');
|
|
348
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
349
|
+
fs.writeFileSync(
|
|
350
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
351
|
+
JSON.stringify({ installedSkills: { 'sprint-flow': { version: '1.0.0' } } })
|
|
352
|
+
);
|
|
353
|
+
const { updateSkill } = require('../update-skill');
|
|
354
|
+
await updateSkill(null, { all: true });
|
|
355
|
+
const sprintCalls = installSkillMock.mock.calls.filter((c) => c[0] === 'sprint-flow');
|
|
356
|
+
expect(sprintCalls.length).toBe(1);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it('AC-682-03: --all with nothing installed says so instead of silently succeeding', async () => {
|
|
360
|
+
const { updateSkill } = require('../update-skill');
|
|
361
|
+
const code = await updateSkill(null, { all: true });
|
|
362
|
+
const said = [...logSpy.mock.calls, ...errorSpy.mock.calls].map((c) => c.join(' ')).join('\n');
|
|
363
|
+
expect(said).toMatch(/nothing to update|no skills installed|not installed/i);
|
|
364
|
+
expect(installSkillMock).not.toHaveBeenCalled();
|
|
365
|
+
expect(code).toBe(0);
|
|
366
|
+
});
|
|
367
|
+
});
|
package/lib/doctor-tui.js
CHANGED
|
@@ -280,8 +280,29 @@ function diagnoseSkillsInDir(skillNames, userSkillsDir, bundledSkillsDir, checks
|
|
|
280
280
|
});
|
|
281
281
|
issues++;
|
|
282
282
|
} else {
|
|
283
|
-
// cmp >= 0: installed version is same or newer than bundled
|
|
284
|
-
|
|
283
|
+
// cmp >= 0: installed version is same or newer than bundled.
|
|
284
|
+
//
|
|
285
|
+
// #681: an EQUAL version is a claim, not a fact. A skill that gained a
|
|
286
|
+
// whole chapter without a version bump reported "Up to date" while the
|
|
287
|
+
// installed copy was ~20k characters behind, and `update-skill` agreed.
|
|
288
|
+
// So when the versions tie, still compare the bodies — and do NOT
|
|
289
|
+
// prescribe `update-skill` on a difference: the installed copy may be
|
|
290
|
+
// NEWER than main (the same asymmetry the versionless branch below
|
|
291
|
+
// already respects), so the advice is "compare manually".
|
|
292
|
+
const sameBody = sameContentIgnoringLineEndings(
|
|
293
|
+
withoutVersionLine(bundledContent),
|
|
294
|
+
withoutVersionLine(userContent)
|
|
295
|
+
);
|
|
296
|
+
if (!sameBody) {
|
|
297
|
+
checks.push({
|
|
298
|
+
name: label(name),
|
|
299
|
+
status: 'WARN',
|
|
300
|
+
detail: `Content differs at the same version ${userVersion} — the version was not bumped when the skill changed; compare manually before running update-skill`,
|
|
301
|
+
});
|
|
302
|
+
issues++;
|
|
303
|
+
} else {
|
|
304
|
+
checks.push({ name: label(name), status: 'PASS', detail: 'Up to date' });
|
|
305
|
+
}
|
|
285
306
|
}
|
|
286
307
|
} else {
|
|
287
308
|
// No version in at least one file — compare content, but not byte-for-byte:
|
package/lib/doctor.js
CHANGED
|
@@ -3394,9 +3394,26 @@ async function doctorDelphi(options = {}) {
|
|
|
3394
3394
|
|
|
3395
3395
|
// 5. Config ping: parseable, three distinct callable models. A duplicate
|
|
3396
3396
|
// model here is a review that will BLOCK later — say so now.
|
|
3397
|
-
|
|
3397
|
+
//
|
|
3398
|
+
// #684: resolve the SAME way the runner does. A project-local config wins; a
|
|
3399
|
+
// machine-level one under XDG_CONFIG_HOME (or ~/.config) is the documented
|
|
3400
|
+
// fallback, and a review keeps working on it. Checking only `cwd` reported
|
|
3401
|
+
// FAIL on the layout SKILL.md recommends and told the user to copy the example
|
|
3402
|
+
// into the project — manufacturing the drifted duplicate #541 warns about.
|
|
3403
|
+
//
|
|
3404
|
+
// An explicit `configPath` is an ANSWER, not a hint: a caller that names a
|
|
3405
|
+
// file means that file, so it disables the fallback. Otherwise a test (or a
|
|
3406
|
+
// caller) pinning an absent path silently reads the developer's real global
|
|
3407
|
+
// config and reports PASS — the same vacuous-pass shape this batch is fixing.
|
|
3408
|
+
const projectCfgPath = configPath
|
|
3409
|
+
|| path.join(options.projectDir || process.cwd(), '.delphi-config.json');
|
|
3410
|
+
const usingGlobalConfig = !configPath
|
|
3411
|
+
&& !fs.existsSync(projectCfgPath)
|
|
3412
|
+
&& fs.existsSync(globalConfigPath);
|
|
3413
|
+
const cfgPath = usingGlobalConfig ? globalConfigPath : projectCfgPath;
|
|
3414
|
+
const cfgLabel = 'Delphi config (.delphi-config.json)';
|
|
3398
3415
|
if (!fs.existsSync(cfgPath)) {
|
|
3399
|
-
add(
|
|
3416
|
+
add(cfgLabel, 'FAIL',
|
|
3400
3417
|
`${cfgPath} not found (copy .delphi-config.json.example from the skill directory)`);
|
|
3401
3418
|
} else {
|
|
3402
3419
|
try {
|
|
@@ -3404,7 +3421,7 @@ async function doctorDelphi(options = {}) {
|
|
|
3404
3421
|
const profileName = raw.active_profile || 'default';
|
|
3405
3422
|
const profile = raw.profiles && raw.profiles[profileName];
|
|
3406
3423
|
if (!profile) {
|
|
3407
|
-
add(
|
|
3424
|
+
add(cfgLabel, 'FAIL', `profile "${profileName}" not found`);
|
|
3408
3425
|
} else {
|
|
3409
3426
|
const experts = profile.experts || {};
|
|
3410
3427
|
const models = roles.map((r) => experts[r] && experts[r].model);
|
|
@@ -3422,10 +3439,14 @@ async function doctorDelphi(options = {}) {
|
|
|
3422
3439
|
if (missingRoles.length > 0) problems.push(`missing experts: ${missingRoles.join(', ')}`);
|
|
3423
3440
|
if (duplicates.length > 0) problems.push(`duplicate models: ${[...new Set(duplicates)].join(', ')}`);
|
|
3424
3441
|
if (badProviders.length > 0) problems.push(`unusable provider binding: ${badProviders.join(', ')}`);
|
|
3425
|
-
add(
|
|
3442
|
+
add(cfgLabel, 'FAIL', problems.join('; '));
|
|
3426
3443
|
} else {
|
|
3427
|
-
|
|
3428
|
-
|
|
3444
|
+
// Always name the file that was actually read. Without it a user with
|
|
3445
|
+
// both scopes present cannot tell which one the review will use — the
|
|
3446
|
+
// exact ambiguity that made #684 hard to see.
|
|
3447
|
+
const scopeNote = usingGlobalConfig ? ` [global: ${cfgPath}]` : ` [project: ${cfgPath}]`;
|
|
3448
|
+
add(cfgLabel, 'PASS',
|
|
3449
|
+
`profile "${profileName}": ${models.join(' / ')}${scopeNote}`);
|
|
3429
3450
|
// #574: non-local seats are reported, not blocked. The local-only rule
|
|
3430
3451
|
// is an operator preference (the 2026-10-03 quota burn), so doctor
|
|
3431
3452
|
// surfaces the cost exposure without failing a deliberately hosted
|
|
@@ -3483,7 +3504,7 @@ async function doctorDelphi(options = {}) {
|
|
|
3483
3504
|
}
|
|
3484
3505
|
}
|
|
3485
3506
|
} catch (err) {
|
|
3486
|
-
add(
|
|
3507
|
+
add(cfgLabel, 'FAIL', `unparseable: ${err.message}`);
|
|
3487
3508
|
}
|
|
3488
3509
|
}
|
|
3489
3510
|
|
package/lib/gate-runner.js
CHANGED
|
@@ -361,16 +361,69 @@ function resolveNpxInvocation(args) {
|
|
|
361
361
|
return ['npx', args];
|
|
362
362
|
}
|
|
363
363
|
|
|
364
|
+
/**
|
|
365
|
+
* A project-local gate variant, if the project declares one.
|
|
366
|
+
*
|
|
367
|
+
* `<consumer cwd>/githooks/gates/gate-<n>-<name>.sh` is where a PROJECT puts its
|
|
368
|
+
* own variant of a gate: the hyphenated name is the point, because it lets one
|
|
369
|
+
* project keep several variants where a flat `gate-<n>.sh` cannot express the
|
|
370
|
+
* choice (#672, restored by 7b43666a). `xp-gate init` never creates that
|
|
371
|
+
* directory, so a stock project simply falls through to the built-in
|
|
372
|
+
* implementation and nothing changes for it.
|
|
373
|
+
*
|
|
374
|
+
* This is deliberately NOT the same as `<project>/src/gates/<gate>.ts`, which
|
|
375
|
+
* `findTsGateEntry` refuses to read (#672): that path let the party being
|
|
376
|
+
* checked supply the implementation, so `process.exit(0)` passed every gate.
|
|
377
|
+
* Declaring a variant under `githooks/gates/` is an explicit, named opt-in;
|
|
378
|
+
* dropping a file into `src/gates/` is not.
|
|
379
|
+
*
|
|
380
|
+
* @param {string} gateModule e.g. 'gate-9'
|
|
381
|
+
* @param {string} cwd project root to search
|
|
382
|
+
* @returns {string|null}
|
|
383
|
+
*/
|
|
384
|
+
function findConsumerGateVariant(gateModule, cwd) {
|
|
385
|
+
const gateNum = gateModule.replace('gate-', '');
|
|
386
|
+
// Strictly the project's own declaration. `resolveGateScript` additionally
|
|
387
|
+
// probes the flat `githooks/gate-<n>.sh` and the global install
|
|
388
|
+
// (~/.config/xp-gate/adapters/); treating either as "the project declared a
|
|
389
|
+
// variant" would hand every gate to whichever bash copy happens to be
|
|
390
|
+
// installed -- on a machine with a global install that is *always*. A variant
|
|
391
|
+
// is a file the project deliberately named, so only `githooks/gates/` counts.
|
|
392
|
+
const gatesDir = path.join(cwd, 'githooks', 'gates');
|
|
393
|
+
const declared = matchGateFileIn(gatesDir, gateNum);
|
|
394
|
+
return declared ? path.join(gatesDir, declared) : null;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
/**
|
|
398
|
+
* Which implementation runs this gate: a project-declared variant, the built-in
|
|
399
|
+
* TypeScript module, or a bash fallback.
|
|
400
|
+
*
|
|
401
|
+
* Split out of `runTsGate` so that function stays about running the gate. The
|
|
402
|
+
* precedence is a contract with two sides -- `githooks/gates/` is the project's
|
|
403
|
+
* opt-in, and the built-in module must remain reachable when nobody opted in --
|
|
404
|
+
* so it is named here rather than inlined in the runner.
|
|
405
|
+
*
|
|
406
|
+
* @returns {{kind: 'variant', script: string} | {kind: 'module', entry: string} | {kind: 'fallback', script: string} | {kind: 'none'}}
|
|
407
|
+
*/
|
|
408
|
+
function resolveGateImplementation(gateModule, targetCwd) {
|
|
409
|
+
const variant = findConsumerGateVariant(gateModule, targetCwd);
|
|
410
|
+
if (variant) return { kind: 'variant', script: variant };
|
|
411
|
+
|
|
412
|
+
const entry = findTsGateEntry(gateModule);
|
|
413
|
+
if (entry) return { kind: 'module', entry };
|
|
414
|
+
|
|
415
|
+
const script = resolveGateScript(gateModule.replace('gate-', ''), targetCwd);
|
|
416
|
+
return script ? { kind: 'fallback', script } : { kind: 'none' };
|
|
417
|
+
}
|
|
418
|
+
|
|
364
419
|
function runTsGate(gateModule, targetPath, opts = {}) {
|
|
365
420
|
const targetCwd = resolveGateCwd(targetPath);
|
|
366
|
-
const
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
return runGateAdapter(script, targetCwd, opts);
|
|
373
|
-
}
|
|
421
|
+
const impl = resolveGateImplementation(gateModule, targetCwd);
|
|
422
|
+
|
|
423
|
+
if (impl.kind === 'variant' || impl.kind === 'fallback') {
|
|
424
|
+
return runGateAdapter(impl.script, targetCwd, opts);
|
|
425
|
+
}
|
|
426
|
+
if (impl.kind === 'none') {
|
|
374
427
|
// #618: nothing ran. Before this, the function fell through to `return 0`
|
|
375
428
|
// and the --all summary counted the gate as passed.
|
|
376
429
|
console.log(`Gate ${gateModule}: TypeScript module not found and no bash fallback available.`);
|
|
@@ -378,7 +431,7 @@ function runTsGate(gateModule, targetPath, opts = {}) {
|
|
|
378
431
|
return GATE_NOT_RUN;
|
|
379
432
|
}
|
|
380
433
|
|
|
381
|
-
const args = ['-y', 'tsx', entry, '--run'];
|
|
434
|
+
const args = ['-y', 'tsx', impl.entry, '--run'];
|
|
382
435
|
if (targetPath) args.push('--cwd', targetPath);
|
|
383
436
|
|
|
384
437
|
// spawnSync reports a spawn failure (npx missing) on `error`, throws only on a
|
|
@@ -508,21 +561,28 @@ const GATE_REGISTRY = {
|
|
|
508
561
|
return runTsGate('gate-8', targetPath);
|
|
509
562
|
},
|
|
510
563
|
},
|
|
564
|
+
// Numbering follows the HOOK, which is where gates actually execute (#677).
|
|
565
|
+
// These two were swapped here: the CLI called 9 "SAST" and 10 "Build Integrity"
|
|
566
|
+
// while the hook runs 9=Build integrity and 10=Semgrep SAST. A user reading
|
|
567
|
+
// "❌ BLOCKED - Gate 9: Build integrity check failed" in their commit output and
|
|
568
|
+
// then running `xp-gate check --gates 9` got semgrep -- a different gate, reported
|
|
569
|
+
// green. The hook is the majority implementation and the one the error message
|
|
570
|
+
// names, so its numbering wins.
|
|
511
571
|
'9': {
|
|
572
|
+
name: 'Build Integrity',
|
|
573
|
+
description: 'TypeScript compilation, npm pack, and import validation',
|
|
574
|
+
aliases: ['build', '9'],
|
|
575
|
+
preCommitOnly: true,
|
|
576
|
+
reason: 'Build integrity checks require language detection and build tool context',
|
|
577
|
+
},
|
|
578
|
+
'10': {
|
|
512
579
|
name: 'SAST Security',
|
|
513
580
|
description: 'semgrep static application security testing',
|
|
514
|
-
aliases: ['sast', 'semgrep', '
|
|
581
|
+
aliases: ['sast', 'semgrep', '10'],
|
|
515
582
|
run: (targetPath) => {
|
|
516
583
|
return runTsGate('gate-9', targetPath);
|
|
517
584
|
},
|
|
518
585
|
},
|
|
519
|
-
'10': {
|
|
520
|
-
name: 'Build Integrity',
|
|
521
|
-
description: 'TypeScript compilation, npm pack, and import validation',
|
|
522
|
-
aliases: ['build', '10'],
|
|
523
|
-
preCommitOnly: true,
|
|
524
|
-
reason: 'Build integrity checks require language detection and build tool context',
|
|
525
|
-
},
|
|
526
586
|
'11': {
|
|
527
587
|
name: 'Sprint Flow Enforcement',
|
|
528
588
|
description: 'Sprint state and delphi-review validation',
|
|
@@ -530,6 +590,26 @@ const GATE_REGISTRY = {
|
|
|
530
590
|
preCommitOnly: true,
|
|
531
591
|
reason: 'Requires sprint state (.sprint-state/) and git branch context',
|
|
532
592
|
},
|
|
593
|
+
// Gates 12 and 13 block commits in the hook but were absent here entirely, so
|
|
594
|
+
// `xp-gate check --gates 12` answered "Unknown gate(s)" for a gate that had just
|
|
595
|
+
// blocked the user (#677). Registered with an explicit reason rather than
|
|
596
|
+
// pretending to re-implement them: both need the staged-diff and per-language
|
|
597
|
+
// build context the hook assembles, and a half-correct reimplementation that
|
|
598
|
+
// answered PASS would be worse than saying so.
|
|
599
|
+
'12': {
|
|
600
|
+
name: 'File Hygiene',
|
|
601
|
+
description: 'conflict markers (blocking) + YAML/JSON syntax (blocking) + whitespace warnings',
|
|
602
|
+
aliases: ['hygiene', 'file-hygiene', '12'],
|
|
603
|
+
preCommitOnly: true,
|
|
604
|
+
reason: 'File hygiene inspects the staged diff, which only exists inside the hook',
|
|
605
|
+
},
|
|
606
|
+
'13': {
|
|
607
|
+
name: 'Compiler Strictness',
|
|
608
|
+
description: 'gcc/g++ -Wall -Wextra, javac -Xlint, Maven/Gradle warnings on changed files',
|
|
609
|
+
aliases: ['compiler', 'strictness', '13'],
|
|
610
|
+
preCommitOnly: true,
|
|
611
|
+
reason: 'Compiler strictness needs the changed-file set and a discovered toolchain',
|
|
612
|
+
},
|
|
533
613
|
'python-health': {
|
|
534
614
|
name: 'Python Environment Health',
|
|
535
615
|
description: 'Comprehensive Python environment diagnostics',
|
|
@@ -718,4 +798,4 @@ async function runGate(gateId, targetPath, opts = {}) {
|
|
|
718
798
|
return 1;
|
|
719
799
|
}
|
|
720
800
|
|
|
721
|
-
module.exports = { GATE_REGISTRY, GATE_NOT_RUN, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
|
|
801
|
+
module.exports = { findConsumerGateVariant, GATE_REGISTRY, GATE_NOT_RUN, getGateInfo, getAllGates, runGate, resolveAlias, getAliases, runGateAdapter, findTsGateEntry, resolveGateScript, SOURCE_FILE_EXTENSIONS, resolveAllModeChangedFiles, describeEmptyChangedFiles, describeChangedFilesScope };
|
package/lib/shared-utils.js
CHANGED
|
@@ -8,6 +8,19 @@ const path = require('path');
|
|
|
8
8
|
/**
|
|
9
9
|
* Recursively copy a directory.
|
|
10
10
|
* Pure function: only uses fs/path params, no global config.
|
|
11
|
+
*
|
|
12
|
+
* A file that disappears between `readdirSync` and `copyFileSync` is skipped
|
|
13
|
+
* rather than crashing the whole copy. That is not defensive padding: the
|
|
14
|
+
* mirrored trees this walks (`hooks/`, `adapters/`) are rewritten in place by
|
|
15
|
+
* `scripts/sync-package-content.js`, which unlinks and recreates each entry. A
|
|
16
|
+
* concurrent `xp-gate init` (or a test suite that runs init while another test
|
|
17
|
+
* invokes the sync) enumerates the directory, then finds the entry gone --
|
|
18
|
+
* `ENOENT` on a file that will exist again microseconds later. Aborting the
|
|
19
|
+
* install there would report a broken package for a transient race.
|
|
20
|
+
*
|
|
21
|
+
* Copies the CONTENT instead of `copyFileSync` so the read and the write both
|
|
22
|
+
* see a consistent snapshot, and only ENOENT is tolerated -- permission and
|
|
23
|
+
* disk errors still surface, because those are real failures.
|
|
11
24
|
*/
|
|
12
25
|
function copyDirRecursive(src, dest) {
|
|
13
26
|
fs.mkdirSync(dest, { recursive: true });
|
|
@@ -18,11 +31,24 @@ function copyDirRecursive(src, dest) {
|
|
|
18
31
|
if (entry.isDirectory()) {
|
|
19
32
|
copyDirRecursive(srcPath, destPath);
|
|
20
33
|
} else {
|
|
21
|
-
|
|
34
|
+
copyFileContents(srcPath, destPath);
|
|
22
35
|
}
|
|
23
36
|
}
|
|
24
37
|
}
|
|
25
38
|
|
|
39
|
+
/** Copy one file, treating "vanished mid-copy" as a skip. @returns {boolean} whether it was copied */
|
|
40
|
+
function copyFileContents(srcPath, destPath) {
|
|
41
|
+
let data;
|
|
42
|
+
try {
|
|
43
|
+
data = fs.readFileSync(srcPath);
|
|
44
|
+
} catch (err) {
|
|
45
|
+
if (err.code === 'ENOENT') return false;
|
|
46
|
+
throw err;
|
|
47
|
+
}
|
|
48
|
+
fs.writeFileSync(destPath, data);
|
|
49
|
+
return true;
|
|
50
|
+
}
|
|
51
|
+
|
|
26
52
|
function readXpGateConfig() {
|
|
27
53
|
const cfgPath = path.join(require('os').homedir(), '.xp-gate', 'config.json');
|
|
28
54
|
try {
|
package/lib/update-skill.js
CHANGED
|
@@ -7,6 +7,57 @@ const HOME = process.env.HOME || process.env.USERPROFILE || os.homedir();
|
|
|
7
7
|
|
|
8
8
|
const CONFIG_DIR = path.join(HOME, '.config', 'xp-gate');
|
|
9
9
|
|
|
10
|
+
/**
|
|
11
|
+
* The skill names that are actually deployed on this machine.
|
|
12
|
+
*
|
|
13
|
+
* `xp-gate init` / `setup-global` copy skills onto disk but never write
|
|
14
|
+
* `config.installedSkills`, so the config registry is empty on a fully
|
|
15
|
+
* deployed machine. Iterating it made `update-skill --all` print
|
|
16
|
+
* "Updating all skills..." and exit 0 having touched nothing (#682) -- the
|
|
17
|
+
* same defect #635 fixed for `doctor`, with the same fix: ask the filesystem.
|
|
18
|
+
*
|
|
19
|
+
* The explicit registry is still unioned in, so a skill that is merely
|
|
20
|
+
* registered (or was installed on a platform this scan cannot see) keeps
|
|
21
|
+
* working for callers that rely on it.
|
|
22
|
+
*
|
|
23
|
+
* @returns {string[]} de-duplicated skill names, in a stable order
|
|
24
|
+
*/
|
|
25
|
+
function installedSkillNames() {
|
|
26
|
+
const names = new Set();
|
|
27
|
+
const config = getConfig();
|
|
28
|
+
for (const name of Object.keys(config.installedSkills || {})) names.add(name);
|
|
29
|
+
|
|
30
|
+
let platforms = [];
|
|
31
|
+
try {
|
|
32
|
+
platforms = Object.keys(require('./detect-deps.js').PLATFORM_PROFILES);
|
|
33
|
+
} catch {
|
|
34
|
+
// detect-deps unavailable: fall back to the registry only rather than
|
|
35
|
+
// reporting "nothing installed" for a machine that has skills on disk.
|
|
36
|
+
platforms = [];
|
|
37
|
+
}
|
|
38
|
+
const detect = require('./detect-deps.js');
|
|
39
|
+
for (const platform of platforms) {
|
|
40
|
+
let dir;
|
|
41
|
+
try {
|
|
42
|
+
dir = detect.getSkillsDirs(platform)[0];
|
|
43
|
+
} catch {
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
let entries;
|
|
47
|
+
try {
|
|
48
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
49
|
+
} catch {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
for (const entry of entries) {
|
|
53
|
+
if (!entry.isDirectory()) continue;
|
|
54
|
+
// A skill is `<dir>/<name>/SKILL.md` — the shape install-skill writes.
|
|
55
|
+
if (fs.existsSync(path.join(dir, entry.name, 'SKILL.md'))) names.add(entry.name);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return [...names].sort();
|
|
59
|
+
}
|
|
60
|
+
|
|
10
61
|
function handleCheckMode() {
|
|
11
62
|
console.log('Checking for updates...');
|
|
12
63
|
const config = getConfig();
|
|
@@ -19,11 +70,16 @@ function handleCheckMode() {
|
|
|
19
70
|
}
|
|
20
71
|
|
|
21
72
|
async function handleAllMode(verbose) {
|
|
22
|
-
const
|
|
23
|
-
const skills = config.installedSkills || {};
|
|
73
|
+
const names = installedSkillNames();
|
|
24
74
|
console.log('Updating all skills...');
|
|
75
|
+
// #635/#682: an empty run must say so. Exiting 0 after "Updating all skills..."
|
|
76
|
+
// with no further output reads as success, and was how this stayed broken.
|
|
77
|
+
if (names.length === 0) {
|
|
78
|
+
console.log('No skills installed — nothing to update.');
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
25
81
|
let hasErrors = false;
|
|
26
|
-
for (const skillName of
|
|
82
|
+
for (const skillName of names) {
|
|
27
83
|
try {
|
|
28
84
|
await updateSingleSkill(skillName, verbose);
|
|
29
85
|
} catch (err) {
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-11-01
|
|
4
|
+
**Commit:** effea31d
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
6
|
+
**Version:** 0.35.19.0
|
|
7
7
|
|
|
8
8
|
## OVERVIEW
|
|
9
9
|
Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
|