@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.
Files changed (70) hide show
  1. package/adapter-common.sh +55 -2
  2. package/adapters/csharp.sh +245 -0
  3. package/adapters/gate-3.sh +1 -1
  4. package/adapters/gate-4.sh +1 -1
  5. package/adapters/gate-9.sh +37 -2
  6. package/adapters/iac.sh +45 -5
  7. package/bin/xp-gate.js +18 -11
  8. package/gate-10.sh +18 -3
  9. package/gate-13-compiler-strictness.sh +181 -0
  10. package/gate-3.sh +1 -1
  11. package/gate-4.sh +1 -1
  12. package/gate-9.sh +37 -2
  13. package/gates/common.ts +9 -2
  14. package/hooks/adapter-common.sh +55 -2
  15. package/hooks/gate-10.sh +18 -3
  16. package/hooks/gate-13-compiler-strictness.sh +181 -0
  17. package/hooks/gate-3.sh +1 -1
  18. package/hooks/gate-4.sh +1 -1
  19. package/hooks/gate-9.sh +37 -2
  20. package/hooks/gate-registry.sh +161 -0
  21. package/hooks/lib/test-failure.sh +29 -4
  22. package/hooks/pre-commit +122 -3
  23. package/lib/__tests__/agent-runtime-journal.test.js +42 -18
  24. package/lib/__tests__/check-single-file.test.js +55 -4
  25. package/lib/__tests__/detect-deps-dsh-profile.test.js +43 -0
  26. package/lib/__tests__/doctor-delphi.test.js +175 -0
  27. package/lib/__tests__/install-skill-full-dir.test.js +45 -2
  28. package/lib/__tests__/install-skill.test.js +84 -3
  29. package/lib/__tests__/mirror-sync.test.js +23 -5
  30. package/lib/__tests__/principles-csharp-coverage.test.js +227 -0
  31. package/lib/__tests__/uninstall-global-residue.test.js +192 -28
  32. package/lib/__tests__/update-skill-flags.test.js +78 -0
  33. package/lib/__tests__/update-skill.test.js +156 -3
  34. package/lib/detect-deps.js +24 -0
  35. package/lib/doctor.js +80 -12
  36. package/lib/install-skill.js +67 -18
  37. package/lib/mattpocock-license-verdict.json +1 -1
  38. package/lib/principles.js +9 -1
  39. package/lib/uninstall.js +96 -2
  40. package/lib/update-skill.js +143 -2
  41. package/mock-policy/AGENTS.md +4 -4
  42. package/mutation/AGENTS.md +4 -4
  43. package/package.json +1 -1
  44. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  45. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  46. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  47. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  48. package/plugins/dsh/lib/index.d.ts.map +1 -1
  49. package/plugins/dsh/lib/index.js +7 -4
  50. package/plugins/dsh/package.json +1 -1
  51. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  52. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  53. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  54. package/plugins/dsh/src/index.test.ts +25 -0
  55. package/plugins/dsh/src/index.ts +7 -4
  56. package/plugins/opencode/package.json +1 -1
  57. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  58. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  59. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  60. package/plugins/qoder/plugin.json +1 -1
  61. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  62. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  63. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  64. package/principles/AGENTS.md +4 -4
  65. package/principles/adapters/csharp.ts +309 -0
  66. package/principles/analyzer.ts +5 -0
  67. package/scripts/delphi-external-review.cjs +130 -101
  68. package/skills/delphi-review/AGENTS.md +4 -4
  69. package/skills/sprint-flow/AGENTS.md +4 -4
  70. 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(listFiles(path.join(BUNDLED_SKILLS, 'ralph-loop')));
130
- expect(fs.readFileSync(path.join(opencodeSkills, 'SKILL.md'), 'utf8')).toBe('untouched');
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 install-skill
3
- * @intent Verify installSkill() handles deps check, bundle lookup, config, backup, and errors
4
- * @covers AC-416-01, AC-416-05
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
- fs.mkdirSync(path.dirname(full), { recursive: true });
53
- fs.writeFileSync(full, content);
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
- write(TMP_REPO, 'mirror/hook.sh', '#!/bin/sh\necho a\n');
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
- const mode = fs.statSync(path.join(TMP_REPO, 'mirror/hook.sh')).mode;
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
+ });