@boyingliu01/xp-gate 0.31.4 → 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 (144) 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 +149 -2
  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-prune.test.ts +61 -0
  126. package/principles/__tests__/cli-files-separators.test.ts +69 -0
  127. package/principles/__tests__/config-merge-warnings.test.ts +148 -0
  128. package/principles/adapters/java.ts +22 -3
  129. package/principles/adapters/typescript.ts +70 -29
  130. package/principles/analyzer.ts +36 -3
  131. package/principles/baseline-prune.ts +19 -7
  132. package/principles/config.ts +56 -35
  133. package/principles/index.ts +122 -40
  134. package/principles/rules/clean-code/deep-nesting.ts +36 -22
  135. package/principles/rules/clean-code/unused-imports.ts +30 -19
  136. package/principles/rules/solid/dip.ts +30 -20
  137. package/principles/rules/solid/lsp.ts +17 -2
  138. package/principles/rules/solid/ocp.ts +33 -23
  139. package/principles/rules/solid/srp.ts +65 -23
  140. package/scripts/delphi-external-review.cjs +25 -2
  141. package/scripts/sync-package-content.js +28 -1
  142. package/skills/delphi-review/AGENTS.md +3 -3
  143. package/skills/sprint-flow/AGENTS.md +3 -3
  144. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -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
+ });
@@ -1,6 +1,19 @@
1
1
  import { BaseAdapter } from './base';
2
2
  import { Adapter } from '../types';
3
3
 
4
+ /**
5
+ * Depth change per nesting delimiter in a Java parameter list.
6
+ *
7
+ * Angular and square brackets nest exactly like parentheses here: the only thing
8
+ * that matters is whether a comma sits at depth 0, because that is the only
9
+ * place it separates parameters.
10
+ */
11
+ const PARAM_NESTING: Record<string, number> = {
12
+ '(': 1, ')': -1,
13
+ '<': 1, '>': -1,
14
+ '[': 1, ']': -1,
15
+ };
16
+
4
17
  /**
5
18
  * Structural fields the clean-code rules consume (mirror of the TypeScript
6
19
  * adapter's function shape). Before #507 this adapter returned only
@@ -213,12 +226,18 @@ export const javaParse = {
213
226
  countParams(paramList: string): number {
214
227
  const trimmed = paramList.trim();
215
228
  if (!trimmed) return 0;
229
+ // Generic and array brackets nest too: `Map<String, List<Integer>>` is ONE
230
+ // parameter, so the commas inside its arguments must not be counted.
231
+ // Which characters change depth and which separate is data, not control
232
+ // flow -- table lookups keep the scan to a single conditional.
216
233
  let depth = 0;
217
234
  let count = 1;
218
235
  for (const ch of trimmed) {
219
- if (ch === '(' || ch === '<' || ch === '[') depth += 1;
220
- else if (ch === ')' || ch === '>' || ch === ']') depth -= 1;
221
- else if (ch === ',' && depth === 0) count += 1;
236
+ if (ch === ',') {
237
+ if (depth === 0) count += 1;
238
+ continue;
239
+ }
240
+ depth += PARAM_NESTING[ch] ?? 0;
222
241
  }
223
242
  return count;
224
243
  },
@@ -195,6 +195,13 @@ function inQuotedState(state: QuoteState): boolean {
195
195
  return state.inSingle || state.inDouble || state.inTemplate;
196
196
  }
197
197
 
198
+ /** The three quote kinds tracked by QuoteState, with the flag that marks each. */
199
+ const QUOTE_KINDS: ReadonlyArray<{ quote: string; flag: 'inSingle' | 'inDouble' | 'inTemplate' }> = [
200
+ { quote: "'", flag: 'inSingle' },
201
+ { quote: '"', flag: 'inDouble' },
202
+ { quote: '`', flag: 'inTemplate' },
203
+ ];
204
+
198
205
  /**
199
206
  * Advance one character of quote tracking.
200
207
  *
@@ -202,28 +209,25 @@ function inQuotedState(state: QuoteState): boolean {
202
209
  * six-branch block verbatim (#602): both must ignore delimiters that appear
203
210
  * inside a string, and neither may treat an escape as a closing quote.
204
211
  *
212
+ * Table-driven (#620): all three quote kinds behave identically -- an escape
213
+ * skips two characters, the matching quote closes the literal -- so they are one
214
+ * rule applied to a list rather than three copies of it.
215
+ *
205
216
  * @returns the number of characters consumed (2 when the next char is escaped).
206
217
  */
207
218
  function advanceQuoteState(src: string, i: number, state: QuoteState): number {
208
219
  const c = src[i];
209
- if (state.inSingle) {
210
- if (c === '\\') return 2;
211
- if (c === "'") state.inSingle = false;
212
- return 1;
213
- }
214
- if (state.inDouble) {
215
- if (c === '\\') return 2;
216
- if (c === '"') state.inDouble = false;
217
- return 1;
218
- }
219
- if (state.inTemplate) {
220
+
221
+ for (const kind of QUOTE_KINDS) {
222
+ if (!state[kind.flag]) continue;
220
223
  if (c === '\\') return 2;
221
- if (c === '`') state.inTemplate = false;
224
+ if (c === kind.quote) state[kind.flag] = false;
222
225
  return 1;
223
226
  }
224
- if (c === "'") state.inSingle = true;
225
- else if (c === '"') state.inDouble = true;
226
- else if (c === '`') state.inTemplate = true;
227
+
228
+ // Not inside any literal: open the one this character starts, if any.
229
+ const opener = QUOTE_KINDS.find(kind => kind.quote === c);
230
+ if (opener) state[opener.flag] = true;
227
231
  return 1;
228
232
  }
229
233
 
@@ -273,29 +277,66 @@ function countParams(rawList: string): number {
273
277
  return count;
274
278
  }
275
279
 
280
+ /** What one step of the paren-group scan did, so the caller can react. */
281
+ type ParenStep =
282
+ | { kind: 'consumed'; next: number }
283
+ | { kind: 'closed'; end: number };
284
+
285
+ /**
286
+ * Advance one step through a paren group.
287
+ *
288
+ * Extracted from `readParenGroup` (#620) so the three-way decision -- inside a
289
+ * literal, opening a literal, or structural bracket -- reads as one rule instead
290
+ * of being folded into the loop that also owns depth and substring slicing.
291
+ *
292
+ * A literal is entered through `advanceQuoteState` at the SAME index rather than
293
+ * consumed: that call flips the state on the opening quote and reports one
294
+ * character consumed, so re-scans resume one past the quote with the state set.
295
+ *
296
+ * @param depth running paren depth, updated in place for structural brackets
297
+ * @returns the next index to read, or the offset just past the closing paren
298
+ */
299
+ function stepParenGroup(
300
+ src: string,
301
+ i: number,
302
+ state: QuoteState,
303
+ depth: { value: number },
304
+ ): ParenStep {
305
+ if (inQuotedState(state)) {
306
+ return { kind: 'consumed', next: i + advanceQuoteState(src, i, state) };
307
+ }
308
+
309
+ const c = src[i];
310
+ if (isQuote(c)) {
311
+ // Entering a literal: flip state here, resume at the next character.
312
+ advanceQuoteState(src, i, state);
313
+ return { kind: 'consumed', next: i + 1 };
314
+ }
315
+ if (c === '(') {
316
+ depth.value++;
317
+ return { kind: 'consumed', next: i + 1 };
318
+ }
319
+ if (c === ')') {
320
+ depth.value--;
321
+ if (depth.value === 0) return { kind: 'closed', end: i + 1 };
322
+ }
323
+ return { kind: 'consumed', next: i + 1 };
324
+ }
325
+
276
326
  /**
277
327
  * Extract the balanced `( ... )` group that starts at `open`.
278
328
  * Returns the inner text and the index just past the closing paren.
279
329
  */
280
330
  function readParenGroup(src: string, open: number): { inner: string; end: number } {
281
- let depth = 0;
282
331
  const state = newQuoteState();
332
+ const depth = { value: 0 };
283
333
 
284
334
  for (let i = open; i < src.length;) {
285
- if (inQuotedState(state)) {
286
- i += advanceQuoteState(src, i, state);
287
- continue;
335
+ const step = stepParenGroup(src, i, state, depth);
336
+ if (step.kind === 'closed') {
337
+ return { inner: src.substring(open + 1, step.end - 1), end: step.end };
288
338
  }
289
- const c = src[i];
290
- if (c === "'" || c === '"' || c === '`') {
291
- advanceQuoteState(src, i, state);
292
- } else if (c === '(') {
293
- depth++;
294
- } else if (c === ')') {
295
- depth--;
296
- if (depth === 0) return { inner: src.substring(open + 1, i), end: i + 1 };
297
- }
298
- i++;
339
+ i = step.next;
299
340
  }
300
341
  return { inner: src.substring(open + 1), end: src.length };
301
342
  }
@@ -131,7 +131,11 @@ function runRuleOnFile(ctx: RunRuleContext): void {
131
131
  }
132
132
  } catch (err) {
133
133
  const msg = err instanceof Error ? err.message : String(err);
134
- errors.push(`Rule ${rule.id} failed on ${file}: ${msg}`);
134
+ // A file that cannot be READ failed for every rule that touched it, so the
135
+ // same message would arrive once per rule. Dedupe to one entry per file: the
136
+ // caller acts on "this file was not examined", not on how many rules noticed.
137
+ const entry = `Rule ${rule.id} failed on ${file}: ${msg}`;
138
+ if (!errors.includes(entry)) errors.push(entry);
135
139
  }
136
140
  }
137
141
 
@@ -160,6 +164,35 @@ interface RunRuleContext {
160
164
  errors: string[];
161
165
  }
162
166
 
167
+ /**
168
+ * Resolve the adapter for one path, reporting rather than throwing on failure.
169
+ *
170
+ * Resolving and probing an adapter can itself READ the file (adapters parse on
171
+ * construction), so an unreadable path throws here -- before any rule runs and
172
+ * outside runRuleOnFile's guard. Recorded as an error rather than allowed to
173
+ * abort the whole run: one bad path must not hide the findings for the good
174
+ * ones, but it must never be invisible either (#623).
175
+ *
176
+ * @returns the adapter, or null when the file cannot be examined at all
177
+ */
178
+ function resolveAdapterReporting(
179
+ file: string,
180
+ adapterOrFactory: Adapter | AdapterFactory,
181
+ errors: string[],
182
+ ): Adapter | null {
183
+ try {
184
+ const adapter = resolveAdapter(file, adapterOrFactory);
185
+ if (!adapter || adapter.detectLanguage() === 'unknown') return null;
186
+ return adapter;
187
+ } catch (err) {
188
+ const msg = err instanceof Error ? err.message : String(err);
189
+ // One entry per file: the failure belongs to the file, not to however many
190
+ // rules happened to touch it.
191
+ if (!errors.includes(msg)) errors.push(msg);
192
+ return null;
193
+ }
194
+ }
195
+
163
196
  export async function analyze(
164
197
  files: string[],
165
198
  rules: Rule[],
@@ -180,8 +213,8 @@ export async function analyze(
180
213
  }
181
214
 
182
215
  for (const file of files) {
183
- const adapter = resolveAdapter(file, adapterOrFactory);
184
- if (!adapter || adapter.detectLanguage() === 'unknown') continue;
216
+ const adapter = resolveAdapterReporting(file, adapterOrFactory, errors);
217
+ if (!adapter) continue;
185
218
 
186
219
  const fileResult: FileResult = { violations: [], ruleIds: [] };
187
220
  fileResults[file] = fileResult;
@@ -107,6 +107,19 @@ export function pruneBaselineEntries(
107
107
  return { kept, removed };
108
108
  }
109
109
 
110
+ /**
111
+ * A non-null, non-array object — the only shape a baseline map or a nested
112
+ * `files` wrapper can legally take.
113
+ *
114
+ * The three checks in `parseBaselineFiles` were the same predicate written out
115
+ * three times (once positively for the wrapper, once negated for the root, once
116
+ * positively per entry), which is most of its CCN. Naming it once removes the
117
+ * repetition and makes the intent legible.
118
+ */
119
+ function isJsonObject(value: unknown): value is Record<string, unknown> {
120
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
121
+ }
122
+
110
123
  /**
111
124
  * Extract the path -> entry map, or a string describing why it is unusable.
112
125
  *
@@ -125,22 +138,21 @@ function parseBaselineFiles(baselineJson: string): Record<string, unknown> | str
125
138
  return `could not parse baseline: ${detail}`;
126
139
  }
127
140
 
128
- if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
141
+ if (!isJsonObject(parsed)) {
129
142
  return 'baseline is not a JSON object';
130
143
  }
131
144
 
132
- const record = parsed as Record<string, unknown>;
133
- const nested = record.files;
134
- if (nested !== null && typeof nested === 'object' && !Array.isArray(nested)) {
135
- return nested as Record<string, unknown>;
145
+ // A `files` wrapper is unwrapped rather than merged, matching the old shape.
146
+ if (isJsonObject(parsed.files)) {
147
+ return parsed.files;
136
148
  }
137
149
 
138
150
  // Keep only object-valued keys: scalars at the top level are metadata, not
139
151
  // path entries, and a path whose entry is a bare number is the malformed shape
140
152
  // that assertValidEntry rejects elsewhere (#455).
141
153
  const entries: Record<string, unknown> = {};
142
- for (const [key, value] of Object.entries(record)) {
143
- if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
154
+ for (const [key, value] of Object.entries(parsed)) {
155
+ if (isJsonObject(value)) {
144
156
  entries[key] = value;
145
157
  }
146
158
  }
@@ -283,6 +283,43 @@ function mergeGroup<T extends Record<string, RuleConfig>>(
283
283
  return result;
284
284
  }
285
285
 
286
+ /**
287
+ * How each key category is type-checked, and what the warning says when it is not.
288
+ *
289
+ * Every category follows the same shape -- skip when undefined, assign when the
290
+ * value passes its guard, warn otherwise (#620). Stating the guard and the
291
+ * expectation once per category lets one loop enforce all of them, instead of
292
+ * four near-identical loops that each have to be kept in step by hand.
293
+ */
294
+ type ValueGuard = {
295
+ accepts: (value: unknown) => boolean;
296
+ expectation: string;
297
+ /** Coerce the accepted value before assignment (arrays are copied to detach). */
298
+ coerce?: (value: unknown) => unknown;
299
+ };
300
+
301
+ const NUMERIC_GUARD: ValueGuard = {
302
+ accepts: value => typeof value === 'number' && Number.isFinite(value),
303
+ expectation: 'a finite number',
304
+ };
305
+
306
+ const ARRAY_GUARD: ValueGuard = {
307
+ // Arrays REPLACE rather than concatenate, matching how .principlesrc is
308
+ // written today (an exclude list is the project's complete list).
309
+ accepts: value => Array.isArray(value),
310
+ expectation: 'an array',
311
+ coerce: value => [...(value as unknown[])],
312
+ };
313
+
314
+ function enumGuard(allowed: readonly string[]): ValueGuard {
315
+ return {
316
+ accepts: value => typeof value === 'string' && allowed.includes(value),
317
+ // Loud on purpose: silently keeping the default would let a project believe
318
+ // it raised a rule to blocking when it did not.
319
+ expectation: `one of ${allowed.join(', ')}`,
320
+ };
321
+ }
322
+
286
323
  /** Merge one rule, keeping the default for any key whose override is mistyped. */
287
324
  function mergeRule(
288
325
  base: RuleConfig,
@@ -290,48 +327,32 @@ function mergeRule(
290
327
  ruleId: string
291
328
  ): RuleConfig {
292
329
  const merged: RuleConfig = { ...base };
330
+ const guarded: Array<[string, ValueGuard]> = [
331
+ ...NUMERIC_KEYS.map((key): [string, ValueGuard] => [key, NUMERIC_GUARD]),
332
+ ...ARRAY_KEYS.map((key): [string, ValueGuard] => [key, ARRAY_GUARD]),
333
+ ...Object.entries(ENUM_KEYS).map(([key, allowed]): [string, ValueGuard] => [
334
+ key,
335
+ enumGuard(allowed as readonly string[]),
336
+ ]),
337
+ ];
338
+
339
+ for (const [key, guard] of guarded) {
340
+ const value = override[key as keyof RuleConfig];
341
+ if (value === undefined) continue;
342
+ if (!guard.accepts(value)) {
343
+ warn(`rules.${ruleId}.${key} must be ${guard.expectation} — keeping default`);
344
+ continue;
345
+ }
346
+ merged[key as keyof RuleConfig] = (guard.coerce ? guard.coerce(value) : value) as never;
347
+ }
293
348
 
349
+ // `enabled` is a bare boolean, not one of the value categories above.
294
350
  if (typeof override.enabled === 'boolean') {
295
351
  merged.enabled = override.enabled;
296
352
  } else if (override.enabled !== undefined) {
297
353
  warn(`rules.${ruleId}.enabled must be a boolean — keeping default`);
298
354
  }
299
355
 
300
- for (const key of NUMERIC_KEYS) {
301
- const value = override[key];
302
- if (value === undefined) continue;
303
- if (typeof value === 'number' && Number.isFinite(value)) {
304
- merged[key] = value;
305
- } else {
306
- warn(`rules.${ruleId}.${key} must be a finite number — keeping default`);
307
- }
308
- }
309
-
310
- for (const key of ARRAY_KEYS) {
311
- const value = override[key];
312
- if (value === undefined) continue;
313
- // Arrays REPLACE rather than concatenate, matching how .principlesrc is
314
- // written today (an exclude list is the project's complete list).
315
- if (Array.isArray(value)) {
316
- merged[key] = [...value] as RuleConfig[typeof key];
317
- } else {
318
- warn(`rules.${ruleId}.${key} must be an array — keeping default`);
319
- }
320
- }
321
-
322
- for (const key of Object.keys(ENUM_KEYS) as (keyof typeof ENUM_KEYS)[]) {
323
- const allowed: readonly string[] = ENUM_KEYS[key];
324
- const value = override[key];
325
- if (value === undefined) continue;
326
- if (typeof value === 'string' && allowed.includes(value)) {
327
- merged[key] = value;
328
- } else {
329
- // Loud on purpose: silently keeping the default would let a project believe
330
- // it raised a rule to blocking when it did not.
331
- warn(`rules.${ruleId}.${key} must be one of ${allowed.join(', ')} — keeping default`);
332
- }
333
- }
334
-
335
356
  return merged;
336
357
  }
337
358