@boyingliu01/xp-gate 0.35.24 → 0.36.1
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 +55 -2
- package/adapters/csharp.sh +245 -0
- package/adapters/gate-3.sh +1 -1
- package/adapters/gate-4.sh +1 -1
- package/adapters/gate-9.sh +37 -2
- package/adapters/iac.sh +45 -5
- package/bin/xp-gate.js +18 -11
- package/gate-10.sh +18 -3
- package/gate-13-compiler-strictness.sh +181 -0
- package/gate-3.sh +1 -1
- package/gate-4.sh +1 -1
- package/gate-9.sh +37 -2
- package/gates/common.ts +9 -2
- package/hooks/adapter-common.sh +55 -2
- package/hooks/gate-10.sh +18 -3
- package/hooks/gate-13-compiler-strictness.sh +181 -0
- package/hooks/gate-3.sh +1 -1
- package/hooks/gate-4.sh +1 -1
- package/hooks/gate-9.sh +37 -2
- package/hooks/gate-registry.sh +161 -0
- package/hooks/lib/test-failure.sh +29 -4
- package/hooks/pre-commit +122 -3
- package/lib/__tests__/agent-runtime-journal.test.js +42 -18
- package/lib/__tests__/check-single-file.test.js +55 -4
- package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
- package/lib/__tests__/doctor-delphi.test.js +175 -0
- package/lib/__tests__/install-skill-full-dir.test.js +45 -2
- package/lib/__tests__/install-skill.test.js +84 -3
- package/lib/__tests__/mirror-sync.test.js +23 -5
- package/lib/__tests__/principles-csharp-coverage.test.js +227 -0
- package/lib/__tests__/uninstall-global-residue.test.js +192 -28
- package/lib/__tests__/update-skill-flags.test.js +78 -0
- package/lib/__tests__/update-skill.test.js +156 -3
- package/lib/detect-deps.js +24 -0
- package/lib/doctor.js +80 -12
- package/lib/install-skill.js +67 -18
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +9 -1
- package/lib/uninstall.js +96 -2
- package/lib/update-skill.js +143 -2
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +7 -4
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/index.test.ts +25 -0
- package/plugins/dsh/src/index.ts +7 -4
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +4 -4
- package/principles/adapters/csharp.ts +309 -0
- package/principles/analyzer.ts +5 -0
- package/scripts/delphi-external-review.cjs +130 -101
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/test-specification-alignment/AGENTS.md +4 -4
|
@@ -451,3 +451,178 @@ describe('doctor delphi #684 global config fallback', () => {
|
|
|
451
451
|
expect(cfgLine).not.toMatch(/global/i);
|
|
452
452
|
});
|
|
453
453
|
});
|
|
454
|
+
|
|
455
|
+
describe('doctor delphi #725 multi-platform skill parity', () => {
|
|
456
|
+
let tmpDirs;
|
|
457
|
+
let tmpHome;
|
|
458
|
+
let originalHome;
|
|
459
|
+
let originalUserProfile;
|
|
460
|
+
|
|
461
|
+
beforeEach(() => {
|
|
462
|
+
tmpDirs = makeTempDirs(['doctor-725-home']);
|
|
463
|
+
tmpHome = tmpDirs['doctor-725-home'];
|
|
464
|
+
originalHome = process.env.HOME;
|
|
465
|
+
originalUserProfile = process.env.USERPROFILE;
|
|
466
|
+
process.env.HOME = tmpHome;
|
|
467
|
+
process.env.USERPROFILE = tmpHome;
|
|
468
|
+
resetLibModules(vi, LIB_MODULES);
|
|
469
|
+
muteConsole(vi);
|
|
470
|
+
});
|
|
471
|
+
|
|
472
|
+
afterEach(() => {
|
|
473
|
+
if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
|
|
474
|
+
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
|
475
|
+
else process.env.USERPROFILE = originalUserProfile;
|
|
476
|
+
removeTempDirs(tmpDirs);
|
|
477
|
+
vi.restoreAllMocks();
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
/** Copy the shipped bundle into \`<home>/<rel>/delphi-review\`. */
|
|
481
|
+
function deploySkillAt(rel, mutate) {
|
|
482
|
+
const dest = path.join(tmpHome, rel, 'delphi-review');
|
|
483
|
+
fs.mkdirSync(dest, { recursive: true });
|
|
484
|
+
const src = bundledSkillDir();
|
|
485
|
+
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
|
|
486
|
+
const from = path.join(src, entry.name);
|
|
487
|
+
const to = path.join(dest, entry.name);
|
|
488
|
+
if (entry.isDirectory()) {
|
|
489
|
+
fs.mkdirSync(to, { recursive: true });
|
|
490
|
+
for (const inner of fs.readdirSync(from)) {
|
|
491
|
+
if (fs.statSync(path.join(from, inner)).isFile()) {
|
|
492
|
+
fs.copyFileSync(path.join(from, inner), path.join(to, inner));
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
} else {
|
|
496
|
+
fs.copyFileSync(from, to);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (mutate) mutate(dest);
|
|
500
|
+
return dest;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
/** Minimal agent set so only skill parity is under test. */
|
|
504
|
+
function seedAgents() {
|
|
505
|
+
fs.mkdirSync(path.join(tmpHome, '.qoder', 'agents'), { recursive: true });
|
|
506
|
+
for (const role of ['architecture', 'technical', 'feasibility']) {
|
|
507
|
+
fs.copyFileSync(
|
|
508
|
+
path.join(repoRoot(), 'plugins', 'qoder', 'agents', `delphi-${role}.md`),
|
|
509
|
+
path.join(tmpHome, '.qoder', 'agents', `delphi-${role}.md`),
|
|
510
|
+
);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function captureResults() {
|
|
515
|
+
const lines = [];
|
|
516
|
+
const spy = vi.spyOn(console, 'log').mockImplementation((...args) => lines.push(args.join(' ')));
|
|
517
|
+
return { lines, spy };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
/**
|
|
521
|
+
* @test REQ-725 Multi-platform skill parity
|
|
522
|
+
* @intent A second platform whose copy drifted must fail the parity check instead of being invisible
|
|
523
|
+
* @covers AC-725-01
|
|
524
|
+
*/
|
|
525
|
+
it('AC-725-01: a drifted OpenCode copy FAILs parity even though Qoder is clean', async () => {
|
|
526
|
+
seedAgents();
|
|
527
|
+
deploySkillAt(path.join('.qoder', 'skills'));
|
|
528
|
+
// Qoder is selected by detectPlatform(), so this is the copy the check used
|
|
529
|
+
// to look at -- and it is clean. The OpenCode copy is the one that rotted.
|
|
530
|
+
deploySkillAt(path.join('.config', 'opencode', 'skills'), (dest) => {
|
|
531
|
+
fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale copy from 0.32.0.0\n');
|
|
532
|
+
});
|
|
533
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
534
|
+
fs.writeFileSync(
|
|
535
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
536
|
+
JSON.stringify({ installedSkills: {} }),
|
|
537
|
+
);
|
|
538
|
+
|
|
539
|
+
const { doctorDelphi } = require('../doctor');
|
|
540
|
+
const { lines, spy } = captureResults();
|
|
541
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
542
|
+
spy.mockRestore();
|
|
543
|
+
|
|
544
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
545
|
+
expect(parity, 'a stale second-platform copy must be reported').toMatch(/✗/);
|
|
546
|
+
expect(parity).toMatch(/opencode/i);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* @test REQ-725 Multi-platform skill parity
|
|
551
|
+
* @intent Both platforms matching must still PASS, and say how many were compared
|
|
552
|
+
* @covers AC-725-02
|
|
553
|
+
*/
|
|
554
|
+
it('AC-725-02: two clean platform copies PASS together', async () => {
|
|
555
|
+
seedAgents();
|
|
556
|
+
deploySkillAt(path.join('.qoder', 'skills'));
|
|
557
|
+
deploySkillAt(path.join('.config', 'opencode', 'skills'));
|
|
558
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
559
|
+
fs.writeFileSync(
|
|
560
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
561
|
+
JSON.stringify({ installedSkills: {} }),
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
const { doctorDelphi } = require('../doctor');
|
|
565
|
+
const { lines, spy } = captureResults();
|
|
566
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
567
|
+
spy.mockRestore();
|
|
568
|
+
|
|
569
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
570
|
+
expect(parity).toMatch(/✓/);
|
|
571
|
+
expect(parity).toMatch(/2 platform/i);
|
|
572
|
+
});
|
|
573
|
+
|
|
574
|
+
/**
|
|
575
|
+
* @test REQ-725 Multi-platform skill parity
|
|
576
|
+
* @intent dsh is a first-class deploy target but detectPlatform() never returns
|
|
577
|
+
* it, so a drifted dsh copy must fail parity exactly like opencode
|
|
578
|
+
* @covers AC-725-01
|
|
579
|
+
*/
|
|
580
|
+
it('AC-725-01: a drifted dsh copy FAILs parity even though Qoder is clean', async () => {
|
|
581
|
+
seedAgents();
|
|
582
|
+
deploySkillAt(path.join('.qoder', 'skills'));
|
|
583
|
+
deploySkillAt(path.join('.dsh', 'skills'), (dest) => {
|
|
584
|
+
fs.writeFileSync(path.join(dest, 'AGENTS.md'), '# stale dsh copy\n');
|
|
585
|
+
});
|
|
586
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
587
|
+
fs.writeFileSync(
|
|
588
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
589
|
+
JSON.stringify({ installedSkills: {} }),
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const { doctorDelphi } = require('../doctor');
|
|
593
|
+
const { lines, spy } = captureResults();
|
|
594
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
595
|
+
spy.mockRestore();
|
|
596
|
+
|
|
597
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
598
|
+
expect(parity, 'a stale dsh copy must be reported').toMatch(/✗/);
|
|
599
|
+
expect(parity).toMatch(/dsh/i);
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
/**
|
|
603
|
+
* @test REQ-725 Multi-platform skill parity
|
|
604
|
+
* @intent a machine whose only installed agent is dsh has no marker detectPlatform()
|
|
605
|
+
* keys on, so the opencode fallback falsely FAILs "not installed" even though
|
|
606
|
+
* the skill IS deployed under ~/.dsh/skills
|
|
607
|
+
* @covers AC-725-01
|
|
608
|
+
*/
|
|
609
|
+
it('AC-725-01: a dsh-only machine PASSes parity instead of "not installed at opencode"', async () => {
|
|
610
|
+
// No seedAgents(): keep .qoder/.claude/.config-opencode absent so detectPlatform()
|
|
611
|
+
// falls back to opencode while dsh is the only live tree.
|
|
612
|
+
deploySkillAt(path.join('.dsh', 'skills'));
|
|
613
|
+
fs.mkdirSync(path.join(tmpHome, '.config', 'xp-gate'), { recursive: true });
|
|
614
|
+
fs.writeFileSync(
|
|
615
|
+
path.join(tmpHome, '.config', 'xp-gate', 'xp-gate.json'),
|
|
616
|
+
JSON.stringify({ installedSkills: {} }),
|
|
617
|
+
);
|
|
618
|
+
|
|
619
|
+
const { doctorDelphi } = require('../doctor');
|
|
620
|
+
const { lines, spy } = captureResults();
|
|
621
|
+
await doctorDelphi({ skipLatestProbe: true });
|
|
622
|
+
spy.mockRestore();
|
|
623
|
+
|
|
624
|
+
const parity = lines.filter((l) => /parity/i.test(l)).join('\n');
|
|
625
|
+
expect(parity, 'a deployed dsh copy must be found and verified').toMatch(/✓/);
|
|
626
|
+
expect(parity).not.toMatch(/not installed/i);
|
|
627
|
+
});
|
|
628
|
+
});
|
|
@@ -102,6 +102,11 @@ describe('install-skill full skill directory (#416)', () => {
|
|
|
102
102
|
it('updates the skills directory of the platform actually in use', async () => {
|
|
103
103
|
// update-skill used to hardcode the opencode path while install-skill wrote
|
|
104
104
|
// to the platform dir, so on Qoder it deleted nothing (or the wrong copy).
|
|
105
|
+
//
|
|
106
|
+
// #725 changed the other half of this: a platform that ALSO has the skill
|
|
107
|
+
// deployed is updated too, because leaving it behind froze it at an old
|
|
108
|
+
// version while the command reported success. Both trees below are live, so
|
|
109
|
+
// both are updated -- "untouched" is no longer the expected end state.
|
|
105
110
|
for (const name of ['superpowers', 'gstack']) {
|
|
106
111
|
const dir = path.join(tmpHome, '.qoder', 'skills', name);
|
|
107
112
|
fs.mkdirSync(dir, { recursive: true });
|
|
@@ -125,9 +130,47 @@ describe('install-skill full skill directory (#416)', () => {
|
|
|
125
130
|
const { updateSkill } = require('../update-skill');
|
|
126
131
|
expect(await updateSkill('ralph-loop', {})).toBe(0);
|
|
127
132
|
|
|
133
|
+
const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
|
|
134
|
+
|
|
135
|
+
// qoder keeps its #416 guarantee: stale files removed, full bundle written.
|
|
128
136
|
expect(fs.existsSync(path.join(qoderSkills, 'obsolete.md'))).toBe(false);
|
|
129
|
-
expect(listFiles(qoderSkills)).toEqual(
|
|
130
|
-
|
|
137
|
+
expect(listFiles(qoderSkills)).toEqual(bundled);
|
|
138
|
+
|
|
139
|
+
// #725: the opencode copy is a second live deployment, so it is updated too
|
|
140
|
+
// rather than silently frozen at `untouched`.
|
|
141
|
+
expect(listFiles(opencodeSkills)).toEqual(bundled);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('updates a skill deployed under ~/.dsh too (#725 AC-725-06)', async () => {
|
|
145
|
+
// Qoder keeps the dep gate green AND is the platform detectPlatform() picks.
|
|
146
|
+
for (const name of ['superpowers', 'gstack']) {
|
|
147
|
+
const dir = path.join(tmpHome, '.qoder', 'skills', name);
|
|
148
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
149
|
+
fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ version: '2.0.0' }));
|
|
150
|
+
}
|
|
151
|
+
const qoderSkills = path.join(tmpHome, '.qoder', 'skills', 'ralph-loop');
|
|
152
|
+
fs.mkdirSync(qoderSkills, { recursive: true });
|
|
153
|
+
fs.writeFileSync(path.join(qoderSkills, 'SKILL.md'), '# old qoder copy');
|
|
154
|
+
|
|
155
|
+
// DSH is a live second deployment that detectPlatform() never returns.
|
|
156
|
+
const dshSkills = path.join(tmpHome, '.dsh', 'skills', 'ralph-loop');
|
|
157
|
+
fs.mkdirSync(dshSkills, { recursive: true });
|
|
158
|
+
fs.writeFileSync(path.join(dshSkills, 'SKILL.md'), '# old dsh copy');
|
|
159
|
+
fs.writeFileSync(path.join(dshSkills, 'obsolete.md'), 'stale');
|
|
160
|
+
|
|
161
|
+
const configDir = path.join(tmpHome, '.config', 'xp-gate');
|
|
162
|
+
fs.mkdirSync(configDir, { recursive: true });
|
|
163
|
+
fs.writeFileSync(
|
|
164
|
+
path.join(configDir, 'xp-gate.json'),
|
|
165
|
+
JSON.stringify({ installedSkills: { 'ralph-loop': { version: '0.0.1' } } })
|
|
166
|
+
);
|
|
167
|
+
|
|
168
|
+
const { updateSkill } = require('../update-skill');
|
|
169
|
+
expect(await updateSkill('ralph-loop', {})).toBe(0);
|
|
170
|
+
|
|
171
|
+
const bundled = listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop'));
|
|
172
|
+
expect(listFiles(dshSkills)).toEqual(bundled);
|
|
173
|
+
expect(fs.existsSync(path.join(dshSkills, 'obsolete.md'))).toBe(false);
|
|
131
174
|
});
|
|
132
175
|
|
|
133
176
|
it('keeps the installed directory when the package no longer bundles the skill', async () => {
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* @test
|
|
3
|
-
* @intent Verify installSkill() handles deps check, bundle lookup, config, backup, and errors
|
|
4
|
-
*
|
|
2
|
+
* @test REQ-416, REQ-725
|
|
3
|
+
* @intent Verify installSkill() handles deps check, bundle lookup, config, backup, and errors,
|
|
4
|
+
* and that it writes EVERY platform already carrying the skill rather than only the
|
|
5
|
+
* one detectPlatform() short-circuits to.
|
|
6
|
+
* @covers AC-416-01, AC-416-05, AC-725-06
|
|
5
7
|
*/
|
|
6
8
|
const fs = require('fs');
|
|
7
9
|
const path = require('path');
|
|
@@ -332,4 +334,83 @@ describe('install-skill #713 CLI version reporting', () => {
|
|
|
332
334
|
expect(said).not.toContain('0.0.0');
|
|
333
335
|
expect(said).toContain(`xp-gate ${pkg.version} bundles:`);
|
|
334
336
|
});
|
|
337
|
+
|
|
338
|
+
/**
|
|
339
|
+
* AC-725-06: a machine carrying the same skill on more than one platform must
|
|
340
|
+
* have every copy updated. `detectPlatform()` short-circuits to the first
|
|
341
|
+
* marker it finds, so the single-target path froze the other copy while
|
|
342
|
+
* reporting success -- and `doctor` then agreed, because both read the same
|
|
343
|
+
* short-circuited answer. Migration-era machines with Qoder AND OpenCode are
|
|
344
|
+
* the normal case, not an edge one.
|
|
345
|
+
*/
|
|
346
|
+
it('AC-725-06: updates every deployed platform, not just the detected one', async () => {
|
|
347
|
+
// Two platforms carry the skill; detectPlatform() would only ever see one.
|
|
348
|
+
const qoderSkills = path.join(fx.tmpHome, '.qoder', 'skills');
|
|
349
|
+
const opencodeSkills = path.join(fx.tmpHome, '.config', 'opencode', 'skills');
|
|
350
|
+
const SKILL = 'sprint-flow';
|
|
351
|
+
for (const root of [qoderSkills, opencodeSkills]) {
|
|
352
|
+
fs.mkdirSync(path.join(root, SKILL), { recursive: true });
|
|
353
|
+
fs.writeFileSync(path.join(root, SKILL, 'SKILL.md'), '# stale copy\n');
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
const detectPath = require.resolve('../detect-deps.js');
|
|
357
|
+
require.cache[detectPath] = {
|
|
358
|
+
id: detectPath,
|
|
359
|
+
filename: detectPath,
|
|
360
|
+
loaded: true,
|
|
361
|
+
exports: {
|
|
362
|
+
...require('../detect-deps.js'),
|
|
363
|
+
checkDeps: async () => ({ ok: true }),
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
|
|
367
|
+
const { installSkill } = require('../install-skill');
|
|
368
|
+
const code = await installSkill(SKILL, { force: true });
|
|
369
|
+
expect(code).toBe(0);
|
|
370
|
+
|
|
371
|
+
// Both copies must now carry the shipped content, not the stale fixture.
|
|
372
|
+
for (const root of [qoderSkills, opencodeSkills]) {
|
|
373
|
+
const installed = path.join(root, SKILL, 'SKILL.md');
|
|
374
|
+
expect(fs.existsSync(installed)).toBe(true);
|
|
375
|
+
const body = fs.readFileSync(installed, 'utf8');
|
|
376
|
+
expect(body).not.toBe('# stale copy\n');
|
|
377
|
+
expect(body.length).toBeGreaterThan(100);
|
|
378
|
+
}
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
it('AC-725-06: a platform whose copy cannot be read does not abort the others', async () => {
|
|
382
|
+
// Qoder's skills root is a FILE, so readdir throws ENOTDIR for it. That
|
|
383
|
+
// platform must be skipped, and OpenCode -- which is readable -- must still
|
|
384
|
+
// be updated. The prior single-target code never reached this case because
|
|
385
|
+
// detectPlatform() would have picked one platform and ignored the other.
|
|
386
|
+
const qoderSkills = path.join(fx.tmpHome, '.qoder', 'skills');
|
|
387
|
+
const opencodeSkills = path.join(fx.tmpHome, '.config', 'opencode', 'skills');
|
|
388
|
+
const SKILL = 'sprint-flow';
|
|
389
|
+
fs.mkdirSync(path.dirname(qoderSkills), { recursive: true });
|
|
390
|
+
fs.writeFileSync(qoderSkills, 'not a directory');
|
|
391
|
+
fs.mkdirSync(path.join(opencodeSkills, SKILL), { recursive: true });
|
|
392
|
+
fs.writeFileSync(path.join(opencodeSkills, SKILL, 'SKILL.md'), '# stale copy\n');
|
|
393
|
+
|
|
394
|
+
const detectPath = require.resolve('../detect-deps.js');
|
|
395
|
+
require.cache[detectPath] = {
|
|
396
|
+
id: detectPath,
|
|
397
|
+
filename: detectPath,
|
|
398
|
+
loaded: true,
|
|
399
|
+
exports: {
|
|
400
|
+
...require('../detect-deps.js'),
|
|
401
|
+
checkDeps: async () => ({ ok: true }),
|
|
402
|
+
},
|
|
403
|
+
};
|
|
404
|
+
|
|
405
|
+
const { installSkill } = require('../install-skill');
|
|
406
|
+
const code = await installSkill(SKILL, { force: true });
|
|
407
|
+
|
|
408
|
+
// The readable platform must still have been written.
|
|
409
|
+
const healthy = path.join(opencodeSkills, SKILL, 'SKILL.md');
|
|
410
|
+
expect(fs.existsSync(healthy)).toBe(true);
|
|
411
|
+
expect(fs.readFileSync(healthy, 'utf8')).not.toBe('# stale copy\n');
|
|
412
|
+
// The unreadable root must not have been silently "updated" into a file.
|
|
413
|
+
expect(fs.statSync(qoderSkills).isFile()).toBe(true);
|
|
414
|
+
expect(code).toBe(0);
|
|
415
|
+
});
|
|
335
416
|
});
|
|
@@ -49,8 +49,15 @@ afterEach(() => {
|
|
|
49
49
|
*/
|
|
50
50
|
function write(root, rel, content) {
|
|
51
51
|
const full = path.join(root, rel);
|
|
52
|
-
|
|
53
|
-
|
|
52
|
+
try {
|
|
53
|
+
fs.mkdirSync(path.dirname(full), { recursive: true });
|
|
54
|
+
fs.writeFileSync(full, content);
|
|
55
|
+
} catch (err) {
|
|
56
|
+
// Swallowing this would turn a broken fixture into a confusing assertion
|
|
57
|
+
// failure further down ("expected undefined"), so rethrow with the path that
|
|
58
|
+
// actually failed. The rule wants the IO handled, not hidden.
|
|
59
|
+
throw new Error(`failed to write fixture ${rel}: ${err instanceof Error ? err.message : String(err)}`);
|
|
60
|
+
}
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
/**
|
|
@@ -264,13 +271,24 @@ describe('syncMirrors — AC-713-04 safety', () => {
|
|
|
264
271
|
it('preserves the executable bit so hooks stay runnable', () => {
|
|
265
272
|
write(TMP_REPO, 'canonical/hook.sh', '#!/bin/sh\necho a\n');
|
|
266
273
|
fs.chmodSync(path.join(TMP_REPO, 'canonical/hook.sh'), 0o755);
|
|
267
|
-
|
|
274
|
+
// The mirror must start as *drift*. The sync loop only touches files that
|
|
275
|
+
// are `missing` or `differing`, so identical content is skipped and the mode
|
|
276
|
+
// never gets rewritten. An earlier version gave both sides the same bytes,
|
|
277
|
+
// so the file never entered `toWrite`; on Linux the assertion then failed
|
|
278
|
+
// ("expected +0 to be truthy") while Windows skipped the whole block, which
|
|
279
|
+
// is how it stayed invisible locally. The test was measuring nothing.
|
|
280
|
+
write(TMP_REPO, 'mirror/hook.sh', '#!/bin/sh\necho stale\n');
|
|
268
281
|
trackAll(TMP_REPO);
|
|
269
282
|
|
|
270
|
-
syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
|
|
271
|
-
|
|
283
|
+
const result = syncMirrors(TMP_REPO, [{ canonical: 'canonical', mirror: 'mirror' }]);
|
|
284
|
+
// Prove the copy actually happened, so a green result cannot come from
|
|
285
|
+
// "nothing was touched".
|
|
286
|
+
expect(result.copied).toContain('hook.sh');
|
|
287
|
+
expect(fs.readFileSync(path.join(TMP_REPO, 'mirror/hook.sh'), 'utf8')).toBe('#!/bin/sh\necho a\n');
|
|
288
|
+
|
|
272
289
|
// On Windows the exec bit is not meaningful; only assert where it applies.
|
|
273
290
|
if (process.platform !== 'win32') {
|
|
291
|
+
const mode = fs.statSync(path.join(TMP_REPO, 'mirror/hook.sh')).mode;
|
|
274
292
|
expect(mode & 0o111).toBeTruthy();
|
|
275
293
|
}
|
|
276
294
|
});
|
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-758 `xp-gate principles <dir>` must reach every file the engine has an adapter for
|
|
3
|
+
* @intent `principles.js` keeps its own literal `SOURCE_EXTENSIONS` because the file must
|
|
4
|
+
* stay loadable by plain Node with no transpile step. Its own comment said the
|
|
5
|
+
* list "Must match `getAdapterForFile`'s adapterMap" — and it did not.
|
|
6
|
+
* `analyzer.ts` registered `.cs` and `.csx` since #717, but this walk never
|
|
7
|
+
* offered those files to the checker, so a directory of C# sources reported
|
|
8
|
+
* `Files checked: 0`, which reads exactly like "no violations".
|
|
9
|
+
*
|
|
10
|
+
* How the expected set is derived matters more than the assertion, because two
|
|
11
|
+
* weaker designs both shipped a guard that could pass while the defect lived:
|
|
12
|
+
*
|
|
13
|
+
* 1. A regex over analyzer.ts. Measured: changing `'.ts'` to `".ts"` — cosmetic,
|
|
14
|
+
* same semantics — dropped the parsed set from 21 to 20 and the test still
|
|
15
|
+
* passed. A guard that weakens under a legal refactor is the same "covered
|
|
16
|
+
* but cannot block" shape as the bug.
|
|
17
|
+
* 2. A hand-written candidate list probed through `getAdapterForFile`. Measured:
|
|
18
|
+
* adding `.fs` to BOTH adapterMap and the walk left every test green, because
|
|
19
|
+
* the candidate list simply did not contain `.fs`.
|
|
20
|
+
*
|
|
21
|
+
* So: parse `adapterMap` with the TypeScript compiler API (syntax-aware — key
|
|
22
|
+
* quoting, comments and line breaks do not change the result; verified against
|
|
23
|
+
* three cosmetic refactors), and separately confirm through `getAdapterForFile`
|
|
24
|
+
* that each such extension is genuinely analysable. No hand-maintained list
|
|
25
|
+
* bounds the expectation, so an extension added to the analyzer alone fails here
|
|
26
|
+
* by name.
|
|
27
|
+
* @covers AC-758-01, AC-758-02, AC-758-03, AC-758-04, AC-758-05
|
|
28
|
+
*/
|
|
29
|
+
const ts = require('typescript');
|
|
30
|
+
const fs = require('fs');
|
|
31
|
+
const path = require('path');
|
|
32
|
+
const os = require('os');
|
|
33
|
+
const { pathToFileURL } = require('url');
|
|
34
|
+
|
|
35
|
+
const { collectSourceFiles } = require('../principles');
|
|
36
|
+
|
|
37
|
+
const REPO_ROOT = path.resolve(__dirname, '..', '..', '..', '..');
|
|
38
|
+
const ANALYZER = path.join(REPO_ROOT, 'src', 'principles', 'analyzer.ts');
|
|
39
|
+
|
|
40
|
+
let TMP;
|
|
41
|
+
let getAdapterForFile;
|
|
42
|
+
|
|
43
|
+
beforeAll(async () => {
|
|
44
|
+
TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'principles-csharp-'));
|
|
45
|
+
const mod = await import(pathToFileURL(ANALYZER).href);
|
|
46
|
+
getAdapterForFile = mod.getAdapterForFile;
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
afterAll(() => {
|
|
50
|
+
fs.rmSync(TMP, { recursive: true, force: true });
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Write a file inside the temp tree.
|
|
55
|
+
* @param {string} rel - path relative to TMP
|
|
56
|
+
* @param {string} content - file body
|
|
57
|
+
*/
|
|
58
|
+
function touch(rel, content) {
|
|
59
|
+
const abs = path.join(TMP, rel);
|
|
60
|
+
try {
|
|
61
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
62
|
+
fs.writeFileSync(abs, content);
|
|
63
|
+
} catch (err) {
|
|
64
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
65
|
+
throw new Error(`fixture setup failed writing ${rel}: ${msg}`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Keys of the `adapterMap` object literal in a TypeScript source string.
|
|
71
|
+
*
|
|
72
|
+
* Uses the compiler API rather than a regex so that formatting is irrelevant: quoted
|
|
73
|
+
* any way, reordered, commented or reformatted, the key set is the same. Throws when
|
|
74
|
+
* the map cannot be found at all — a silently empty result would make every assertion
|
|
75
|
+
* below pass vacuously.
|
|
76
|
+
*
|
|
77
|
+
* @param {string} source - TypeScript source text
|
|
78
|
+
* @returns {string[]} extensions including the leading dot
|
|
79
|
+
*/
|
|
80
|
+
function adapterMapKeys(source) {
|
|
81
|
+
const sf = ts.createSourceFile('analyzer.ts', source, ts.ScriptTarget.Latest, true);
|
|
82
|
+
const keys = [];
|
|
83
|
+
/**
|
|
84
|
+
* @param {import('typescript').Node} node
|
|
85
|
+
*/
|
|
86
|
+
function visit(node) {
|
|
87
|
+
if (
|
|
88
|
+
ts.isVariableDeclaration(node) &&
|
|
89
|
+
node.name.getText(sf) === 'adapterMap' &&
|
|
90
|
+
node.initializer &&
|
|
91
|
+
ts.isObjectLiteralExpression(node.initializer)
|
|
92
|
+
) {
|
|
93
|
+
for (const prop of node.initializer.properties) {
|
|
94
|
+
if (
|
|
95
|
+
(ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop)) &&
|
|
96
|
+
prop.name &&
|
|
97
|
+
ts.isStringLiteralLike(prop.name)
|
|
98
|
+
) {
|
|
99
|
+
keys.push(prop.name.text);
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
// A spread or a computed key would make this extraction return FEWER extensions
|
|
103
|
+
// than the map really holds: the derived expectation shrinks, every assertion
|
|
104
|
+
// still passes, and the guard quietly stops covering what it claims to cover.
|
|
105
|
+
// That is the exact failure shape this file was written to prevent, so fail
|
|
106
|
+
// loudly instead of under-reporting.
|
|
107
|
+
if (ts.isSpreadAssignment(prop) || ts.isComputedPropertyName(prop.name)) {
|
|
108
|
+
throw new Error(
|
|
109
|
+
'adapterMap contains a spread or computed key, so this guard cannot derive ' +
|
|
110
|
+
'the full extension set from it. Extend adapterMapKeys() rather than ' +
|
|
111
|
+
'letting it silently under-report.',
|
|
112
|
+
);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
ts.forEachChild(node, visit);
|
|
117
|
+
}
|
|
118
|
+
visit(sf);
|
|
119
|
+
if (keys.length === 0) {
|
|
120
|
+
throw new Error('adapterMap not found (or holds no string keys) in analyzer.ts');
|
|
121
|
+
}
|
|
122
|
+
return keys;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
describe('principles walk covers every language the engine has an adapter for', () => {
|
|
126
|
+
test('AC-758-01: a .cs file in the target directory is offered to the checker', () => {
|
|
127
|
+
touch('cs-only/Program.cs', 'namespace Aot { class P { static void Main() {} } }\n');
|
|
128
|
+
|
|
129
|
+
const found = collectSourceFiles(path.join(TMP, 'cs-only'));
|
|
130
|
+
|
|
131
|
+
expect(found.map(f => path.basename(f))).toContain('Program.cs');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('AC-758-02: every extension in adapterMap is collected by the walk', () => {
|
|
135
|
+
const expected = adapterMapKeys(fs.readFileSync(ANALYZER, 'utf8'));
|
|
136
|
+
|
|
137
|
+
// The C# entries are what this file exists for; assert them explicitly so a
|
|
138
|
+
// regression names the extension rather than just shrinking a count.
|
|
139
|
+
expect(expected).toContain('.cs');
|
|
140
|
+
expect(expected).toContain('.csx');
|
|
141
|
+
|
|
142
|
+
for (const ext of expected) {
|
|
143
|
+
touch(`all-ext/file${ext}`, '// fixture\n');
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const names = new Set(
|
|
147
|
+
collectSourceFiles(path.join(TMP, 'all-ext')).map(f => path.basename(f)),
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
const missing = expected
|
|
151
|
+
.filter(ext => !names.has(`file${ext}`))
|
|
152
|
+
.map(ext => `${ext} is registered in adapterMap but the walk never collected file${ext}`);
|
|
153
|
+
|
|
154
|
+
expect(missing).toEqual([]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test('AC-758-04: each adapterMap extension is genuinely analysable, not just listed', () => {
|
|
158
|
+
// adapterMap could name an extension whose adapter is broken. Confirm through the
|
|
159
|
+
// real entry point that a file with that extension produces an adapter at all.
|
|
160
|
+
//
|
|
161
|
+
// The function is asserted to BE a function first. Without that, a broken dynamic
|
|
162
|
+
// import (interop change, undefined export) would make every probe throw a
|
|
163
|
+
// TypeError, the catch below would swallow each one, `notAnalysable` would stay
|
|
164
|
+
// empty, and this test would report success while asserting nothing. A guard that
|
|
165
|
+
// goes green when its own instrument is missing is the defect class this file is
|
|
166
|
+
// about.
|
|
167
|
+
expect(typeof getAdapterForFile).toBe('function');
|
|
168
|
+
|
|
169
|
+
const expected = adapterMapKeys(fs.readFileSync(ANALYZER, 'utf8'));
|
|
170
|
+
const notAnalysable = [];
|
|
171
|
+
let answered = 0;
|
|
172
|
+
for (const ext of expected) {
|
|
173
|
+
touch(path.join('analysable', `probe${ext}`), '// fixture\n');
|
|
174
|
+
const abs = path.join(TMP, 'analysable', `probe${ext}`);
|
|
175
|
+
try {
|
|
176
|
+
const adapter = getAdapterForFile(abs);
|
|
177
|
+
answered += 1;
|
|
178
|
+
if (adapter === null) notAnalysable.push(ext);
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// Adapters read their file in the constructor, so content the adapter rejects
|
|
181
|
+
// still proves registration. Anything else (a TypeError from a missing
|
|
182
|
+
// function) must not be mistaken for that, so the error type is checked.
|
|
183
|
+
const name = err && err.constructor ? err.constructor.name : '';
|
|
184
|
+
if (name === 'TypeError') throw err;
|
|
185
|
+
if (!/Could not read file|ENOENT|parse/i.test(String(err && err.message))) throw err;
|
|
186
|
+
answered += 1;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Every extension must have produced a real answer, otherwise the loop above
|
|
190
|
+
// proved nothing.
|
|
191
|
+
expect(answered).toBe(expected.length);
|
|
192
|
+
expect(notAnalysable).toEqual([]);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
test('AC-758-03: a directory of ONLY C# sources is not reported as empty', () => {
|
|
196
|
+
touch('only-cs/Alpha.cs', 'class A {}\n');
|
|
197
|
+
touch('only-cs/sub/Beta.cs', 'class B {}\n');
|
|
198
|
+
|
|
199
|
+
const found = collectSourceFiles(path.join(TMP, 'only-cs'));
|
|
200
|
+
|
|
201
|
+
expect(found.length).toBe(2);
|
|
202
|
+
expect(found.map(f => path.basename(f)).sort()).toEqual(['Alpha.cs', 'Beta.cs']);
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test('AC-758-05: .cs files survive upper/lower case variants, end to end', () => {
|
|
206
|
+
// Both halves of the claim in the acceptance criterion, because either one alone
|
|
207
|
+
// leaves the original symptom reachable:
|
|
208
|
+
// - the WALK must collect `Program.CS`, and
|
|
209
|
+
// - `getAdapterForFile` must accept it, since it lowercases the extension before
|
|
210
|
+
// lookup. If that normalisation regressed while the walk still collected the
|
|
211
|
+
// file, the adapter would be null, the file would be skipped, and a directory of
|
|
212
|
+
// all-uppercase C# would once again report Files checked: 0.
|
|
213
|
+
touch('mixed-case/Upper.CS', 'class U {}\n');
|
|
214
|
+
touch('mixed-case/Mixed.Cs', 'class M {}\n');
|
|
215
|
+
|
|
216
|
+
const collected = collectSourceFiles(path.join(TMP, 'mixed-case'));
|
|
217
|
+
const names = collected.map(f => path.basename(f));
|
|
218
|
+
|
|
219
|
+
expect(names.sort()).toEqual(['Mixed.Cs', 'Upper.CS']);
|
|
220
|
+
|
|
221
|
+
// The end-to-end half: every file the walk collected must also resolve to an
|
|
222
|
+
// adapter, so "collected" cannot silently degrade into "collected then dropped".
|
|
223
|
+
for (const file of collected) {
|
|
224
|
+
expect(getAdapterForFile(file)).not.toBeNull();
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
});
|