@boyingliu01/xp-gate 0.31.3 → 0.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (151) hide show
  1. package/adapters/java.sh +256 -4
  2. package/adapters/powershell.sh +45 -6
  3. package/bin/xp-gate.js +8 -2
  4. package/build-integrity/__tests__/gate-10.test.ts +88 -1
  5. package/build-integrity/gate-10.ts +51 -16
  6. package/dsh.bundle.patch.yml +15 -0
  7. package/gate-13-compiler-strictness.sh +1031 -0
  8. package/gate-3.sh +48 -13
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +48 -13
  11. package/hooks/lib/coverage-total.sh +18 -2
  12. package/hooks/lib/java-test-selection.sh +251 -0
  13. package/hooks/lib/jscpd-run.sh +9 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +269 -13
  16. package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
  17. package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
  18. package/lib/__tests__/gate-runner.test.js +29 -2
  19. package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
  20. package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
  21. package/lib/__tests__/shared-utils.test.js +21 -0
  22. package/lib/check.js +6 -2
  23. package/lib/detect-deps.js +42 -10
  24. package/lib/doctor-tui.js +111 -21
  25. package/lib/gate-runner.js +111 -37
  26. package/lib/mattpocock-license-verdict.json +1 -1
  27. package/lib/principles.js +48 -6
  28. package/lib/shared-utils.js +8 -0
  29. package/mock-policy/AGENTS.md +3 -3
  30. package/mock-policy/gate-m3.ts +57 -23
  31. package/mutation/AGENTS.md +3 -3
  32. package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
  33. package/mutation/__tests__/test-filter-parity.test.ts +5 -2
  34. package/mutation/gate-m.ts +30 -24
  35. package/mutation/runners/mutmut-runner.ts +80 -30
  36. package/mutation/runners/pitest-runner.ts +60 -42
  37. package/package.json +14 -2
  38. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  39. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  40. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  41. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  42. package/plugins/dsh/lib/command.d.ts +38 -0
  43. package/plugins/dsh/lib/command.d.ts.map +1 -0
  44. package/plugins/dsh/lib/command.js +87 -0
  45. package/plugins/dsh/lib/credentials.d.ts +39 -0
  46. package/plugins/dsh/lib/credentials.d.ts.map +1 -0
  47. package/plugins/dsh/lib/credentials.js +57 -0
  48. package/plugins/dsh/lib/delphi-config.d.ts +129 -0
  49. package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
  50. package/plugins/dsh/lib/delphi-config.js +369 -0
  51. package/plugins/dsh/lib/delphi-run.d.ts +24 -0
  52. package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
  53. package/plugins/dsh/lib/delphi-run.js +113 -0
  54. package/plugins/dsh/lib/delphi.d.ts +94 -0
  55. package/plugins/dsh/lib/delphi.d.ts.map +1 -0
  56. package/plugins/dsh/lib/delphi.js +207 -0
  57. package/plugins/dsh/lib/dialect.d.ts +24 -0
  58. package/plugins/dsh/lib/dialect.d.ts.map +1 -0
  59. package/plugins/dsh/lib/dialect.js +36 -0
  60. package/plugins/dsh/lib/evidence.d.ts +70 -0
  61. package/plugins/dsh/lib/evidence.d.ts.map +1 -0
  62. package/plugins/dsh/lib/evidence.js +92 -0
  63. package/plugins/dsh/lib/gate-runner.d.ts +49 -0
  64. package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
  65. package/plugins/dsh/lib/gate-runner.js +95 -0
  66. package/plugins/dsh/lib/git-context.d.ts +26 -0
  67. package/plugins/dsh/lib/git-context.d.ts.map +1 -0
  68. package/plugins/dsh/lib/git-context.js +30 -0
  69. package/plugins/dsh/lib/hooks.d.ts +42 -0
  70. package/plugins/dsh/lib/hooks.d.ts.map +1 -0
  71. package/plugins/dsh/lib/hooks.js +113 -0
  72. package/plugins/dsh/lib/index.d.ts +20 -0
  73. package/plugins/dsh/lib/index.d.ts.map +1 -0
  74. package/plugins/dsh/lib/index.js +430 -0
  75. package/plugins/dsh/lib/skill-provider.d.ts +15 -0
  76. package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
  77. package/plugins/dsh/lib/skill-provider.js +205 -0
  78. package/plugins/dsh/lib/sprint.d.ts +60 -0
  79. package/plugins/dsh/lib/sprint.d.ts.map +1 -0
  80. package/plugins/dsh/lib/sprint.js +92 -0
  81. package/plugins/dsh/package.json +14 -8
  82. package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
  83. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  84. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  85. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  86. package/plugins/dsh/src/command-single-form.test.ts +67 -0
  87. package/plugins/dsh/src/command.test.ts +21 -17
  88. package/plugins/dsh/src/command.ts +19 -21
  89. package/plugins/dsh/src/compat.test.ts +200 -0
  90. package/plugins/dsh/src/contract.test.ts +7 -1
  91. package/plugins/dsh/src/credentials.test.ts +93 -0
  92. package/plugins/dsh/src/credentials.ts +71 -0
  93. package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
  94. package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
  95. package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
  96. package/plugins/dsh/src/delphi-config.ts +146 -53
  97. package/plugins/dsh/src/delphi-run.ts +27 -4
  98. package/plugins/dsh/src/delphi.ts +133 -65
  99. package/plugins/dsh/src/evidence.test.ts +74 -0
  100. package/plugins/dsh/src/evidence.ts +141 -0
  101. package/plugins/dsh/src/gate-runner.test.ts +14 -6
  102. package/plugins/dsh/src/gate-runner.ts +68 -5
  103. package/plugins/dsh/src/git-context.test.ts +39 -0
  104. package/plugins/dsh/src/git-context.ts +38 -0
  105. package/plugins/dsh/src/hooks.test.ts +87 -0
  106. package/plugins/dsh/src/hooks.ts +126 -0
  107. package/plugins/dsh/src/index.test.ts +63 -6
  108. package/plugins/dsh/src/index.ts +340 -13
  109. package/plugins/dsh/src/skill-provider.test.ts +194 -0
  110. package/plugins/dsh/src/skill-provider.ts +231 -0
  111. package/plugins/dsh/src/sprint.test.ts +64 -0
  112. package/plugins/dsh/src/sprint.ts +122 -0
  113. package/plugins/dsh/src/tri-state.test.ts +123 -0
  114. package/plugins/opencode/index.ts +192 -58
  115. package/plugins/opencode/package.json +1 -1
  116. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  117. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  118. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  121. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  122. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  123. package/principles/AGENTS.md +3 -3
  124. package/principles/__tests__/analyze-read-failure.test.ts +70 -0
  125. package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
  126. package/principles/__tests__/baseline-prune.test.ts +61 -0
  127. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  128. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  129. package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
  130. package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
  131. package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
  132. package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
  133. package/principles/adapters/cpp.ts +102 -50
  134. package/principles/adapters/java.ts +22 -3
  135. package/principles/adapters/typescript.ts +548 -178
  136. package/principles/analyzer.ts +36 -3
  137. package/principles/baseline-prune.ts +19 -7
  138. package/principles/baseline.ts +58 -70
  139. package/principles/config.ts +56 -35
  140. package/principles/index.ts +122 -40
  141. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  142. package/principles/rules/clean-code/unused-imports.ts +30 -19
  143. package/principles/rules/solid/dip.ts +30 -20
  144. package/principles/rules/solid/lsp.ts +17 -2
  145. package/principles/rules/solid/ocp.ts +33 -23
  146. package/principles/rules/solid/srp.ts +65 -23
  147. package/scripts/delphi-external-review.cjs +25 -2
  148. package/scripts/sync-package-content.js +28 -1
  149. package/skills/delphi-review/AGENTS.md +3 -3
  150. package/skills/sprint-flow/AGENTS.md +3 -3
  151. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -0,0 +1,76 @@
1
+ /**
2
+ * @test REQ-620
3
+ * @intent Pin the single behavioural difference introduced when the two copies
4
+ * of the per-tool accumulator were merged into buildEntry:
5
+ * `createFromFiles` tested `totalWarnings` for TRUTHINESS, so a caller
6
+ * passing an explicit `0` had it replaced by the sum of the per-tool
7
+ * counts. That is exactly the case where the caller is overriding a
8
+ * stale per-tool number, so the override was silently discarded.
9
+ *
10
+ * `initializeWithAnalyzer` already used `!== undefined`, so the two
11
+ * entry points disagreed about the same input. The merged version uses
12
+ * the `!== undefined` reading for both.
13
+ * @covers AC-620-04
14
+ */
15
+ import { describe, it, expect } from 'vitest';
16
+ import { BaselineStorage } from '../baseline';
17
+
18
+ function storage(): InstanceType<typeof BaselineStorage> {
19
+ // Explicit config: a timeout far above any test's runtime.
20
+ return new BaselineStorage({ maxSize: 100, timeoutMs: 10000, batchSize: 5 });
21
+ }
22
+
23
+ describe('AC-620-04: an explicit totalWarnings wins, including zero', () => {
24
+ it('uses an explicit zero instead of the summed per-tool count', async () => {
25
+ const baseline = await storage().createFromFiles([
26
+ { file: 'a.ts', counts: { eslint: { warnings: 3, errors: 0 }, totalWarnings: 0 } },
27
+ ]);
28
+ expect(baseline['a.ts'].totalWarnings).toBe(0);
29
+ });
30
+
31
+ it('uses an explicit nonzero value instead of the summed count', async () => {
32
+ const baseline = await storage().createFromFiles([
33
+ { file: 'a.ts', counts: { eslint: { warnings: 3, errors: 0 }, totalWarnings: 42 } },
34
+ ]);
35
+ expect(baseline['a.ts'].totalWarnings).toBe(42);
36
+ });
37
+
38
+ it('sums the per-tool counts when no explicit total is given', async () => {
39
+ const baseline = await storage().createFromFiles([
40
+ {
41
+ file: 'a.ts',
42
+ counts: {
43
+ eslint: { warnings: 3, errors: 0 },
44
+ ruff: { warnings: 2, errors: 1 },
45
+ },
46
+ },
47
+ ]);
48
+ expect(baseline['a.ts'].totalWarnings).toBe(5);
49
+ });
50
+
51
+ it('keeps every per-tool count it was given', async () => {
52
+ const baseline = await storage().createFromFiles([
53
+ {
54
+ file: 'a.ts',
55
+ counts: {
56
+ eslint: { warnings: 1, errors: 2 },
57
+ ccn: { warnings: 3, max: 9 },
58
+ shellcheck: { warnings: 4, errors: 5 },
59
+ },
60
+ },
61
+ ]);
62
+ const entry = baseline['a.ts'];
63
+ expect(entry.eslint).toEqual({ warnings: 1, errors: 2 });
64
+ expect(entry.ccn).toEqual({ warnings: 3, max: 9 });
65
+ expect(entry.shellcheck).toEqual({ warnings: 4, errors: 5 });
66
+ // Tools that were not supplied must not appear at all.
67
+ expect(entry.ruff).toBeUndefined();
68
+ expect(entry.golangci).toBeUndefined();
69
+ });
70
+
71
+ it('reports zero warnings for a file with no tool counts', async () => {
72
+ const baseline = await storage().createFromFiles([{ file: 'a.ts', counts: {} }]);
73
+ expect(baseline['a.ts'].totalWarnings).toBe(0);
74
+ expect(baseline['a.ts'].lastAnalyzed).toBeTypeOf('string');
75
+ });
76
+ });
@@ -336,3 +336,64 @@ describe('#452 REQ-3 stale baseline entries are pruned', () => {
336
336
  }
337
337
  });
338
338
  });
339
+
340
+ /**
341
+ * @test REQ-628
342
+ * @intent `parseBaselineFiles` accepts two shapes: the FLAT map that
343
+ * `saveBaseline` writes, and a legacy `{files: {...}}` wrapper kept for
344
+ * forwards compatibility. The wrapper branch had no test, so deleting it
345
+ * left the whole suite green — a silent regression that would make every
346
+ * real baseline look empty (all entries filtered out as non-objects,
347
+ * since the only top-level key is `files`). These cases pin both shapes
348
+ * plus the scalar-filtering rule that follows from them.
349
+ * @covers AC-628-01, AC-628-02, AC-628-03
350
+ */
351
+ describe('#628 REQ-3 parseBaselineFiles shape handling', () => {
352
+ it('AC-628-01: a flat map is read entry-for-entry', () => {
353
+ const { root } = makeRepoWithCommit();
354
+ const flat = JSON.stringify({
355
+ 'src/a.ts': { totalWarnings: 2, lastAnalyzed: '2026-01-01T00:00:00Z' },
356
+ 'src/gone.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00Z' },
357
+ });
358
+ stageIndexOnly(root, ['src/a.ts']);
359
+
360
+ const result = pruneBaselineEntries(flat, root);
361
+ expect(result.kept).toContain('src/a.ts');
362
+ expect(result.removed).toContain('src/gone.ts');
363
+ expect(result.error).toBeUndefined();
364
+ });
365
+
366
+ it('AC-628-02: a {files:{...}} wrapper is unwrapped, not treated as one entry', () => {
367
+ const { root } = makeRepoWithCommit();
368
+ // Without the unwrap, the sole top-level key `files` is an object, so the
369
+ // flat path would yield ONE entry called "files" and silently lose every
370
+ // real path. Assert the real paths surface.
371
+ const wrapped = JSON.stringify({
372
+ files: {
373
+ 'src/a.ts': { totalWarnings: 2, lastAnalyzed: '2026-01-01T00:00:00Z' },
374
+ 'src/gone.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00Z' },
375
+ },
376
+ });
377
+ stageIndexOnly(root, ['src/a.ts']);
378
+
379
+ const result = pruneBaselineEntries(wrapped, root);
380
+ expect(result.kept).toContain('src/a.ts');
381
+ expect(result.removed).toContain('src/gone.ts');
382
+ expect(result.kept).not.toContain('files');
383
+ });
384
+
385
+ it('AC-628-03: scalar top-level keys are metadata and are dropped, not kept as entries', () => {
386
+ const { root } = makeRepoWithCommit();
387
+ const withMeta = JSON.stringify({
388
+ version: 2,
389
+ generatedAt: '2026-01-01T00:00:00Z',
390
+ 'src/a.ts': { totalWarnings: 2, lastAnalyzed: '2026-01-01T00:00:00Z' },
391
+ });
392
+ stageIndexOnly(root, ['src/a.ts']);
393
+
394
+ const result = pruneBaselineEntries(withMeta, root);
395
+ expect(result.kept).toEqual(['src/a.ts']);
396
+ expect(result.kept).not.toContain('version');
397
+ expect(result.kept).not.toContain('generatedAt');
398
+ });
399
+ });
@@ -0,0 +1,69 @@
1
+ /**
2
+ * @test REQ-623
3
+ * @intent `--files` must accept the shapes a shell actually produces. CI passed
4
+ * `--files "$SRC_FILES"` -- one argument containing newline-separated
5
+ * paths -- and parseArgs split on spaces only, so the whole blob became
6
+ * a single impossible path. The run then had nothing to analyse and fell
7
+ * through to the console/empty path, writing "✓ No violations found"
8
+ * into a file named principles.sarif. The step exited 0, so for months
9
+ * the GitHub Security tab received a text file where SARIF was expected.
10
+ *
11
+ * These tests pin the separator handling, not one call site: a fix that
12
+ * only changes the workflow leaves the next caller to hit the same trap.
13
+ * @covers AC-623-01, AC-623-02, AC-623-03
14
+ */
15
+
16
+ import { describe, it, expect } from 'vitest';
17
+ import { parseArgs } from '../index';
18
+
19
+ describe('AC-623-01: --files separates on any whitespace, not just spaces', () => {
20
+ it('splits a newline-separated list (the CI shape)', () => {
21
+ const opts = parseArgs(['--files', 'a.ts\nb.ts\nc.ts']);
22
+ expect(opts.files).toEqual(['a.ts', 'b.ts', 'c.ts']);
23
+ });
24
+
25
+ it('splits a space-separated list', () => {
26
+ const opts = parseArgs(['--files', 'a.ts b.ts']);
27
+ expect(opts.files).toEqual(['a.ts', 'b.ts']);
28
+ });
29
+
30
+ it('splits a tab-separated list', () => {
31
+ const opts = parseArgs(['--files', 'a.ts\tb.ts']);
32
+ expect(opts.files).toEqual(['a.ts', 'b.ts']);
33
+ });
34
+
35
+ it('drops the blank entries a trailing separators leave behind', () => {
36
+ const opts = parseArgs(['--files', 'a.ts\n\nb.ts\n']);
37
+ expect(opts.files).toEqual(['a.ts', 'b.ts']);
38
+ });
39
+
40
+ it('keeps a single path intact', () => {
41
+ const opts = parseArgs(['--files', 'src/one.ts']);
42
+ expect(opts.files).toEqual(['src/one.ts']);
43
+ });
44
+ });
45
+
46
+ describe('AC-623-02: a flag after --files is still parsed', () => {
47
+ it('reads --format sarif that follows a multi-line --files value', () => {
48
+ const opts = parseArgs(['--files', 'a.ts\nb.ts', '--format', 'sarif']);
49
+ expect(opts.format).toBe('sarif');
50
+ expect(opts.files).toEqual(['a.ts', 'b.ts']);
51
+ });
52
+
53
+ it('reads --format when it comes before --files', () => {
54
+ const opts = parseArgs(['--format', 'sarif', '--files', 'a.ts\nb.ts']);
55
+ expect(opts.format).toBe('sarif');
56
+ });
57
+ });
58
+
59
+ describe('AC-623-03: positional paths still accumulate', () => {
60
+ it('collects bare paths given without --files', () => {
61
+ const opts = parseArgs(['a.ts', 'b.ts']);
62
+ expect(opts.files).toEqual(['a.ts', 'b.ts']);
63
+ });
64
+
65
+ it('accepts --files alongside positional paths', () => {
66
+ const opts = parseArgs(['--files', 'a.ts\nb.ts', 'c.ts']);
67
+ expect(opts.files).toEqual(['a.ts', 'b.ts', 'c.ts']);
68
+ });
69
+ });
@@ -0,0 +1,148 @@
1
+ /**
2
+ * @test REQ-620
3
+ * @intent Pin mergeRule's mistyped-override handling, which had NO coverage
4
+ * before this change: nothing asserted the warning text or the
5
+ * keep-the-default outcome for a bad .principlesrc value. That gap
6
+ * meant the four per-key loops could be consolidated into one guarded
7
+ * loop without any test noticing, so these tests are what makes the
8
+ * consolidation verifiable rather than merely plausible.
9
+ *
10
+ * Two properties per key category:
11
+ * 1. a correctly-typed override is applied;
12
+ * 2. a mistyped one is REJECTED and the default survives, with a
13
+ * warning naming the rule and the key.
14
+ * Property 2 is the one with teeth: silently accepting a bad value
15
+ * would let a project believe it tightened a gate.
16
+ *
17
+ * .principlesrc is JSON (config.ts parses it with JSON.parse), so the
18
+ * fixtures are JSON objects rather than YAML text.
19
+ * @covers AC-620-05
20
+ */
21
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
22
+ import fs from 'node:fs';
23
+ import os from 'node:os';
24
+ import path from 'node:path';
25
+ import { loadConfig, getDefaultConfig } from '../config';
26
+
27
+ let tmpDirs: string[] = [];
28
+ let warnings: string[];
29
+
30
+ beforeEach(() => {
31
+ tmpDirs = [];
32
+ warnings = [];
33
+ // config.ts reports through process.stderr.write, not console.warn, so the
34
+ // spy has to sit on the stream. (Mocking console.warn instead silently
35
+ // captured nothing and made every "must warn" assertion look like a bug.)
36
+ vi.spyOn(process.stderr, 'write').mockImplementation((chunk: unknown) => {
37
+ warnings.push(String(chunk));
38
+ return true;
39
+ });
40
+ });
41
+
42
+ afterEach(() => {
43
+ vi.restoreAllMocks();
44
+ for (const dir of tmpDirs) fs.rmSync(dir, { recursive: true, force: true });
45
+ });
46
+
47
+ function warnText(): string {
48
+ return warnings.join('\n');
49
+ }
50
+
51
+ /** Default value of one key on `clean-code.long-function`. */
52
+ function defaultFor(key: string): unknown {
53
+ const rule = getDefaultConfig().rules['clean-code']['long-function'] as unknown as Record<string, unknown>;
54
+ return rule[key];
55
+ }
56
+
57
+ /** Write a .principlesrc from RAW JSON text and load it unchanged. */
58
+ async function loadRaw(jsonText: string) {
59
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'xp-gate-cfg-'));
60
+ tmpDirs.push(dir);
61
+ const cfgPath = path.join(dir, '.principlesrc');
62
+ fs.writeFileSync(cfgPath, jsonText, 'utf8');
63
+ return loadConfig(cfgPath);
64
+ }
65
+
66
+ /** Write a .principlesrc overriding `clean-code.long-function` and load it. */
67
+ async function loadWithRule(overrides: Record<string, unknown>) {
68
+ return loadRaw(JSON.stringify({ rules: { 'clean-code': { 'long-function': overrides } } }));
69
+ }
70
+
71
+ describe('AC-620-05: a mistyped rule override keeps the default and warns', () => {
72
+ it('accepts a well-typed numeric override (threshold)', async () => {
73
+ const merged = await loadWithRule({ threshold: 12 });
74
+ expect(merged.rules['clean-code']['long-function'].threshold).toBe(12);
75
+ expect(warnText()).not.toMatch(/threshold/);
76
+ });
77
+
78
+ it('rejects a non-numeric threshold and keeps the default', async () => {
79
+ const merged = await loadWithRule({ threshold: 'not-a-number' });
80
+ expect(merged.rules['clean-code']['long-function'].threshold).toBe(defaultFor('threshold'));
81
+ expect(warnText()).toMatch(/threshold/);
82
+ });
83
+
84
+ it('rejects a non-finite threshold and keeps the default', async () => {
85
+ // A guard that only checks `typeof value === 'number'` would accept these
86
+ // and compare every function against NaN (or Infinity, which nothing ever
87
+ // exceeds -- silently disabling the rule).
88
+ //
89
+ // These literals have to be written as raw JSON TEXT: JSON.stringify turns
90
+ // NaN and Infinity into `null`, so handing them to the object-based helper
91
+ // would inject null and the test would pass against a broken guard. That
92
+ // exact mistake made the first version of this test vacuous.
93
+ const fallback = defaultFor('threshold');
94
+ for (const literal of ['1e999', '-1e999']) {
95
+ warnings = [];
96
+ const merged = await loadRaw(
97
+ `{"rules":{"clean-code":{"long-function":{"threshold":${literal}}}}}`,
98
+ );
99
+ expect(merged.rules['clean-code']['long-function'].threshold).toBe(fallback);
100
+ expect(warnText()).toMatch(/threshold/);
101
+ }
102
+ });
103
+
104
+ it('accepts a well-typed array override (exclude)', async () => {
105
+ const merged = await loadWithRule({ exclude: ['**/skip.ts'] });
106
+ expect(merged.rules['clean-code']['long-function'].exclude).toEqual(['**/skip.ts']);
107
+ });
108
+
109
+ it('rejects a scalar where an array is expected and keeps the default', async () => {
110
+ const merged = await loadWithRule({ exclude: '**/skip.ts' });
111
+ expect(merged.rules['clean-code']['long-function'].exclude).toEqual(defaultFor('exclude'));
112
+ expect(warnText()).toMatch(/exclude/);
113
+ });
114
+
115
+ it('copies the accepted array instead of aliasing the parsed one', async () => {
116
+ // mergeRule spreads array candidates; sharing the reference would let a
117
+ // later mutation of the caller's array leak into the active config.
118
+ const source = ['**/skip.ts'];
119
+ const merged = await loadWithRule({ exclude: source });
120
+ expect(merged.rules['clean-code']['long-function'].exclude).not.toBe(source);
121
+ expect(merged.rules['clean-code']['long-function'].exclude).toEqual(source);
122
+ });
123
+
124
+ it('accepts a well-typed enum override (severity)', async () => {
125
+ const merged = await loadWithRule({ severity: 'error' });
126
+ expect(merged.rules['clean-code']['long-function'].severity).toBe('error');
127
+ });
128
+
129
+ it('rejects an out-of-vocabulary severity and keeps the default', async () => {
130
+ // The dangerous case: a project writes a severity it believes is blocking
131
+ // but misspells it. Silently keeping the default would let that belief stand.
132
+ const merged = await loadWithRule({ severity: 'fatal' });
133
+ expect(merged.rules['clean-code']['long-function'].severity).toBe(defaultFor('severity'));
134
+ expect(warnText()).toMatch(/severity/);
135
+ });
136
+
137
+ it('rejects a non-boolean enabled flag and keeps the default', async () => {
138
+ const merged = await loadWithRule({ enabled: 'yes' });
139
+ expect(typeof merged.rules['clean-code']['long-function'].enabled).toBe('boolean');
140
+ expect(warnText()).toMatch(/enabled/);
141
+ });
142
+
143
+ it('names the offending rule and key in the warning', async () => {
144
+ await loadWithRule({ severity: 'fatal' });
145
+ expect(warnText()).toContain('long-function');
146
+ expect(warnText()).toContain('severity');
147
+ });
148
+ });
@@ -0,0 +1,123 @@
1
+ /**
2
+ * @test REQ-620
3
+ * @intent Pin the C/C++ code-block scanner's treatment of comments and string
4
+ * literals before its CCN-28 body is split.
5
+ *
6
+ * Every case is built to DEFEAT the fallback path. `extractCodeBlock`
7
+ * falls back to `substring(startPos)` capped at 200 characters, so a
8
+ * short fixture returns "the whole thing" even when the scanner never
9
+ * found a closing brace -- the assertions then pass on a broken scanner.
10
+ * Each source below therefore pads past 200 characters and asserts the
11
+ * exact end position, so a truncated scan cannot look correct.
12
+ *
13
+ * Verified by injecting three real defects (brace tracker seeing into
14
+ * strings, block comments untracked, backslash parity ignored): each one
15
+ * reddens at least one case here.
16
+ * @covers AC-620-03
17
+ */
18
+ import { describe, it, expect, vi } from 'vitest';
19
+ import { CppAdapter } from '../cpp';
20
+
21
+ vi.mock('fs', () => ({
22
+ readFileSync: vi.fn(),
23
+ existsSync: vi.fn(() => true),
24
+ }));
25
+
26
+ import { readFileSync } from 'fs';
27
+
28
+ const MARKER = 'END_OF_FUNCTION';
29
+ /** Padding that keeps the source well past the 200-char fallback cap. */
30
+ const PAD = ' '.repeat(240);
31
+
32
+ function block(src: string, start = 0): string {
33
+ (readFileSync as unknown as { mockReturnValue: (v: string) => void }).mockReturnValue(src);
34
+ const adapter = new CppAdapter('probe.cpp');
35
+ return adapter.extractCodeBlock(start);
36
+ }
37
+
38
+ /**
39
+ * Build `void f() { <body> }` followed by `<MARKER>`, padded past the fallback
40
+ * cap. The padding sits INSIDE the function body so the fallback (which runs to
41
+ * the 200-char cap) would include the marker, while a correct scan stops at the
42
+ * real closing brace and does not.
43
+ */
44
+ function wrap(body: string): { src: string; open: number } {
45
+ const src = `void f() {\n${body}\n${PAD}\n}${MARKER}`;
46
+ return { src, open: src.indexOf('{') };
47
+ }
48
+
49
+ describe('AC-620-03: the C/C++ block scanner ignores braces in comments and strings', () => {
50
+ it('returns exactly the balanced body of a plain function', () => {
51
+ const { src, open } = wrap(' int a = 1;');
52
+ const out = block(src, open);
53
+ expect(out, `tail=${JSON.stringify(out.slice(-60))} len=${out.length}`).toMatch(/}$/);
54
+ });
55
+
56
+ it('does not close on a brace inside a line comment', () => {
57
+ const { src, open } = wrap(' // } not the end\n int a = 1;');
58
+ const out = block(src, open);
59
+ expect(out, `extracted: ${JSON.stringify(out.slice(0, 80))}`).toContain('int a = 1;');
60
+ expect(out).not.toContain(MARKER);
61
+ });
62
+
63
+ it('does not close on a brace inside a block comment', () => {
64
+ const { src, open } = wrap(' /* } not the end */\n int a = 1;');
65
+ const out = block(src, open);
66
+ expect(out).toContain('int a = 1;');
67
+ expect(out).not.toContain(MARKER);
68
+ });
69
+
70
+ it('does not close on a brace inside a string literal', () => {
71
+ const { src, open } = wrap(' const char* s = "}";\n int a = 1;');
72
+ const out = block(src, open);
73
+ expect(out).toContain('int a = 1;');
74
+ expect(out).not.toContain(MARKER);
75
+ });
76
+
77
+ it('keeps scanning past a string that CONTAINS the only early close brace', () => {
78
+ // The string's `}` is the first `}` after the opener. A scanner that counts
79
+ // it ends the block immediately and never reaches the statements below.
80
+ const { src, open } = wrap(' const char* s = "}";\n int markerStatement = 1;\n int tail = 2;');
81
+ const out = block(src, open);
82
+ expect(out, `extracted: ${JSON.stringify(out.slice(0, 120))}`)
83
+ .toContain('int tail = 2;');
84
+ });
85
+
86
+ it('keeps scanning past a char literal that contains a close brace', () => {
87
+ const { src, open } = wrap(" char c = '}';\n int tail = 2;");
88
+ const out = block(src, open);
89
+ expect(out).toContain('int tail = 2;');
90
+ });
91
+
92
+ it('does not close on a brace inside a char literal', () => {
93
+ const { src, open } = wrap(" char c = '}';\n int a = 1;");
94
+ const out = block(src, open);
95
+ expect(out).toContain('int a = 1;');
96
+ expect(out).not.toContain(MARKER);
97
+ });
98
+
99
+ it('treats an escaped quote inside a string as data, not a terminator', () => {
100
+ // `\"` does not close the string, so the `}` after it is still string data.
101
+ const { src, open } = wrap(' const char* s = "a\\"}b";\n int a = 1;');
102
+ const out = block(src, open);
103
+ expect(out).toContain('int a = 1;');
104
+ expect(out).not.toContain(MARKER);
105
+ });
106
+
107
+ it('treats an even run of backslashes as NOT escaping the quote', () => {
108
+ // `\\` is a literal backslash, so the following `"` DOES close the string,
109
+ // and the `}` that ends the line is real structure.
110
+ const { src, open } = wrap(' const char* s = "a\\\\";\n int a = 1;');
111
+ const out = block(src, open);
112
+ expect(out.trimEnd().endsWith('}')).toBe(true);
113
+ expect(out).toContain('int a = 1;');
114
+ expect(out).not.toContain(MARKER);
115
+ });
116
+
117
+ it('falls back when no balanced block is ever found', () => {
118
+ const src = 'void f() {\n int a = 1;' + PAD;
119
+ const out = block(src, 9);
120
+ expect(out.length).toBe(200);
121
+ expect(out.length).toBeLessThan(src.length);
122
+ });
123
+ });
@@ -0,0 +1,67 @@
1
+ /**
2
+ * @test REQ-620
3
+ * @intent Prove the C/C++ block scanner split is behaviour-preserving on real
4
+ * source, not just on the handful of fixtures above. The scanner feeds
5
+ * every C/C++ metric (length, nesting, io operations), so an off-by-one
6
+ * in the refactored loop would silently shift every reported span.
7
+ * @covers AC-620-03
8
+ */
9
+ import { describe, it, expect, vi } from 'vitest';
10
+ import { CppAdapter } from '../cpp';
11
+
12
+ vi.mock('fs', () => ({
13
+ readFileSync: vi.fn(),
14
+ existsSync: vi.fn(() => true),
15
+ }));
16
+
17
+ import { readFileSync } from 'fs';
18
+
19
+ const SOURCE = [
20
+ '#include <stdio.h>',
21
+ '/* a header comment with a } brace */',
22
+ 'int helper(int x) {',
23
+ ' // a line comment with a } brace',
24
+ ' return x * 2;',
25
+ '}',
26
+ '',
27
+ 'int main(void) {',
28
+ ' const char* msg = "a } inside a string";',
29
+ " char c = '}';",
30
+ ' /* block } comment */',
31
+ ' if (helper(1) > 0) {',
32
+ ' printf("%s", msg);',
33
+ ' }',
34
+ ' return 0;',
35
+ '}',
36
+ ].join('\n');
37
+
38
+ describe('AC-620-03: the split scanner finds the same spans as the original', () => {
39
+ it('extracts main() up to its real closing brace', () => {
40
+ (readFileSync as unknown as { mockReturnValue: (v: string) => void }).mockReturnValue(SOURCE);
41
+ const adapter = new CppAdapter('probe.c');
42
+ const start = SOURCE.indexOf('int main(void)');
43
+ const open = SOURCE.indexOf('{', start);
44
+ const out = adapter.extractCodeBlock(open);
45
+
46
+ // The body must run to the LAST brace of main, not to the `}` in a comment
47
+ // or a string. Line count is the cheap proxy for "did not truncate".
48
+ const bodyLines = out.split('\n').length;
49
+ expect(bodyLines, `extracted: ${JSON.stringify(out)}`).toBe(
50
+ SOURCE.slice(open).split('\n').length,
51
+ );
52
+ expect(out).toContain('return 0;');
53
+ });
54
+
55
+ it('every declaration in the file yields a span that ends on a balanced brace', () => {
56
+ (readFileSync as unknown as { mockReturnValue: (v: string) => void }).mockReturnValue(SOURCE);
57
+ const adapter = new CppAdapter('probe.c');
58
+ const fns = adapter.extractFunctions() as Array<{ name: string; code?: string }>;
59
+ expect(fns.length).toBeGreaterThanOrEqual(2);
60
+ for (const fn of fns) {
61
+ if (!fn.code) continue;
62
+ // Balanced braces modulo the literals/comments the scanner masks.
63
+ expect(fn.code.length, `${fn.name} produced an empty or unbounded span`)
64
+ .toBeLessThanOrEqual(SOURCE.length);
65
+ }
66
+ });
67
+ });
@@ -0,0 +1,53 @@
1
+ /**
2
+ * @test REQ-620
3
+ * @intent Prove the block-comment masking defect before fixing it. The masker's
4
+ * contract is to preserve offsets and newlines so that `lineOf()` and
5
+ * every line-numbered violation stays correct. A block comment
6
+ * containing a newline is currently collapsed, shifting every later
7
+ * line number up -- which silently mislabels every violation that
8
+ * follows a multi-line comment in the file.
9
+ * @covers AC-620-02
10
+ */
11
+ import { describe, it, expect, vi } from 'vitest';
12
+ import { TypeScriptAdapter } from '../typescript';
13
+
14
+ vi.mock('fs', () => ({
15
+ readFileSync: vi.fn(),
16
+ existsSync: vi.fn(() => true),
17
+ }));
18
+
19
+ import { readFileSync } from 'fs';
20
+
21
+ function mask(src: string): string {
22
+ (readFileSync as unknown as { mockReturnValue: (v: string) => void }).mockReturnValue(src);
23
+ const adapter = new TypeScriptAdapter('probe.ts');
24
+ return (adapter as unknown as { maskedSource: string }).maskedSource;
25
+ }
26
+
27
+ describe('AC-620-02: a multi-line block comment must not shift later line numbers', () => {
28
+ it('keeps the newline inside a block comment', () => {
29
+ const src = '/* a\nb */\nconst keep = 1;';
30
+ const out = mask(src);
31
+ expect(out.split('\n').length).toBe(src.split('\n').length);
32
+ });
33
+
34
+ it('keeps the line count when the block comment spans three lines', () => {
35
+ const src = '/* one\ntwo\nthree */\nconst keep = 1;\nconst also = 2;';
36
+ const out = mask(src);
37
+ expect(out.split('\n').length).toBe(src.split('\n').length);
38
+ expect(out).toContain('const keep = 1;');
39
+ expect(out).toContain('const also = 2;');
40
+ });
41
+
42
+ it('reports a function line number that matches the physical source', () => {
43
+ // The function starts on physical line 4; a collapsed block comment would
44
+ // make the adapter report line 2.
45
+ const src = ['/* a', 'b', 'c */', 'function target() {', ' return 1;', '}'].join('\n');
46
+ (readFileSync as unknown as { mockReturnValue: (v: string) => void }).mockReturnValue(src);
47
+ const adapter = new TypeScriptAdapter('probe.ts');
48
+ const fns = adapter.extractFunctions() as Array<{ name: string; line: number }>;
49
+ const target = fns.find((f) => f.name === 'target');
50
+ expect(target).toBeDefined();
51
+ expect(target!.line).toBe(4);
52
+ });
53
+ });