@boyingliu01/xp-gate 0.31.4 → 0.32.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 (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 +65 -16
  9. package/hooks/gate-13-compiler-strictness.sh +1031 -0
  10. package/hooks/gate-3.sh +65 -16
  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 +101 -0
  14. package/hooks/lib/validate-code-walkthrough.cjs +186 -29
  15. package/hooks/pre-commit +259 -57
  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
@@ -15,6 +15,49 @@ interface CLIOptions {
15
15
 
16
16
  const VALID_FORMATS: readonly string[] = ['json', 'console', 'sarif'] as const;
17
17
 
18
+ /**
19
+ * Split a `--files` value into paths.
20
+ *
21
+ * Splits on ANY whitespace, not just spaces. `find ... | head -50` produces a
22
+ * newline-separated list, and CI passed it quoted, so the whole blob arrived as
23
+ * one argument. `.split(' ')` kept it whole, the path could never resolve,
24
+ * nothing was analysed, and the run fell through to the console/empty path --
25
+ * writing "No violations found" into a file the workflow called
26
+ * principles.sarif (#623).
27
+ */
28
+ function splitFileList(value: string): string[] {
29
+ return value.split(/\s+/).filter(f => f.trim());
30
+ }
31
+
32
+ /**
33
+ * The options that take a value, and what to do with it.
34
+ *
35
+ * Table-driven so adding a flag is a row rather than another `case` arm: the
36
+ * switch this replaces carried one branch per option, which is what pushed the
37
+ * measured complexity of the function past the Gate 3 threshold.
38
+ */
39
+ const VALUE_OPTIONS: Record<string, (options: CLIOptions, value: string) => void> = {
40
+ '--files': (options, value) => {
41
+ options.files = splitFileList(value);
42
+ },
43
+ // An unrecognised format is ignored rather than rejected, so a typo degrades
44
+ // to the default console output instead of an empty SARIF file.
45
+ '--format': (options, value) => {
46
+ if (VALID_FORMATS.includes(value)) options.format = value as CLIOptions['format'];
47
+ },
48
+ // Previously absent, so `--config .principlesrc` was silently filed into
49
+ // `files` and the path was analysed as if it were source (#457).
50
+ '--config': (options, value) => {
51
+ options.configPath = value;
52
+ },
53
+ };
54
+
55
+ /** The boolean flags, and the field each one sets. */
56
+ const FLAG_OPTIONS: Record<string, keyof CLIOptions> = {
57
+ '--changed-only': 'changedOnly',
58
+ '--show-score': 'showScore',
59
+ };
60
+
18
61
  export function parseArgs(args: string[]): CLIOptions {
19
62
  const options: CLIOptions = {
20
63
  files: [],
@@ -22,34 +65,27 @@ export function parseArgs(args: string[]): CLIOptions {
22
65
  changedOnly: false,
23
66
  showScore: false
24
67
  };
25
-
68
+
26
69
  for (let i = 0; i < args.length; i++) {
27
- switch (args[i]) {
28
- case '--files':
29
- const next = args[++i];
30
- if (next) options.files = next.split(' ').filter(f => f.trim());
31
- break;
32
- case '--format':
33
- const fmt = args[++i];
34
- if (fmt && VALID_FORMATS.includes(fmt)) options.format = fmt as 'json' | 'console' | 'sarif';
35
- break;
36
- case '--changed-only':
37
- options.changedOnly = true;
38
- break;
39
- case '--show-score':
40
- options.showScore = true;
41
- break;
42
- // Previously absent, so `--config .principlesrc` was silently filed into
43
- // `files` and the path was analysed as if it were source (#457).
44
- case '--config':
45
- const cfg = args[++i];
46
- if (cfg) options.configPath = cfg;
47
- break;
48
- default:
49
- if (!args[i].startsWith('--')) options.files.push(args[i]);
70
+ const arg = args[i];
71
+
72
+ const valueOption = VALUE_OPTIONS[arg];
73
+ if (valueOption) {
74
+ const value = args[++i];
75
+ if (value !== undefined) valueOption(options, value);
76
+ continue;
50
77
  }
78
+
79
+ const flagField = FLAG_OPTIONS[arg];
80
+ if (flagField) {
81
+ (options[flagField] as boolean) = true;
82
+ continue;
83
+ }
84
+
85
+ // A bare path accumulates instead of being dropped.
86
+ if (!arg.startsWith('--')) options.files.push(arg);
51
87
  }
52
-
88
+
53
89
  return options;
54
90
  }
55
91
 
@@ -59,18 +95,68 @@ function getAllRules() {
59
95
 
60
96
  export { getAllRules };
61
97
 
98
+ /**
99
+ * Report a run that could not examine everything it was asked to.
100
+ *
101
+ * A file the checker could not read is not a file it found clean. Before #623,
102
+ * `analyze` filed read failures in `result.errors` and nothing ever read that
103
+ * array: the run exited 0 and printed a clean report, which CI's SARIF step then
104
+ * archived. That is how `Analysis failed: Could not read file: ...` appeared in
105
+ * the log while the step still produced a report.
106
+ *
107
+ * Naming the paths matters as much as the exit status -- an operator needs to
108
+ * know WHICH files were never looked at.
109
+ */
110
+ function reportIncomplete(errors: string[]): void {
111
+ console.error(`Analysis incomplete: ${errors.length} file(s) could not be examined`);
112
+ for (const err of errors) console.error(` ${err}`);
113
+ }
114
+
115
+ /**
116
+ * The usage error for a run that was given nothing to check.
117
+ *
118
+ * Returns 2, not 1. Under this module's contract exit 1 asserts "I examined
119
+ * files and found ERROR-severity violations". A gate that sees 1 with an empty
120
+ * report reads it as a clean pass, so returning 1 here would let a miswired hook
121
+ * announce PASSED having checked nothing at all.
122
+ */
123
+ const USAGE_EXIT = 2;
124
+
125
+ function usageMessage(): void {
126
+ console.error('Usage: principles-checker --files <file1> <file2> ... [--format console|json|sarif] [--changed-only] [--config <path>]');
127
+ }
128
+
129
+ /**
130
+ * Decide the exit status for a completed analysis.
131
+ *
132
+ * Split out of `main` so the status contract lives in one place and can be read
133
+ * without following the run:
134
+ *
135
+ * 2 - a file could not be examined (this module's "could not examine")
136
+ * 1 - the analysis completed and found ERROR-severity violations
137
+ * 0 - the analysis completed and found none
138
+ *
139
+ * 1 is deliberately narrow. Returning 1 for any violation made info/warning
140
+ * noise (e.g. magic-numbers `info`) look like a tool failure: Gate 4 treats a
141
+ * non-zero exit as "checker execution failed" and downgrades itself to SKIPPED,
142
+ * silently disabling the gate.
143
+ */
144
+ function exitStatusFor(result: { errors: string[]; summary: { errorCount: number } }): number {
145
+ if (result.errors.length > 0) {
146
+ reportIncomplete(result.errors);
147
+ return 2;
148
+ }
149
+ return result.summary.errorCount > 0 ? 1 : 0;
150
+ }
151
+
62
152
  export async function main(args: string[]): Promise<number> {
63
153
  const options = parseArgs(args);
64
-
154
+
65
155
  if (options.files.length === 0) {
66
- console.error('Usage: principles-checker --files <file1> <file2> ... [--format console|json|sarif] [--changed-only] [--config <path>]');
67
- // 2, not 1. Under this module's contract exit 1 asserts "I examined files and
68
- // found ERROR-severity violations". A gate that sees 1 with an empty report
69
- // reads it as a clean pass, so returning 1 here would let a miswired hook
70
- // announce PASSED having checked nothing at all.
71
- return 2;
156
+ usageMessage();
157
+ return USAGE_EXIT;
72
158
  }
73
-
159
+
74
160
  // Install the loaded config so rules read the PROJECT's thresholds. The
75
161
  // previous `await loadConfig();` discarded the result, so `.principlesrc` was
76
162
  // parsed and thrown away while the built-in defaults were enforced (#457).
@@ -78,19 +164,15 @@ export async function main(args: string[]): Promise<number> {
78
164
 
79
165
  const rules = getAllRules();
80
166
  const result = await analyze(options.files, rules, getAdapterForFile);
81
-
167
+
82
168
  const formatters: Record<string, (r: typeof result) => string> = {
83
169
  json: formatJSON,
84
170
  sarif: formatSARIF,
85
171
  console: formatConsole,
86
172
  };
87
173
  console.log(formatters[options.format](result));
88
-
89
- // Exit 1 only for ERROR-severity violations. Returning 1 for any violation
90
- // made info/warning noise (e.g. magic-numbers `info`) look like a tool
91
- // failure: Gate 4 treats a non-zero exit as "checker execution failed" and
92
- // downgrades itself to SKIPPED, silently disabling the gate.
93
- return result.summary.errorCount > 0 ? 1 : 0;
174
+
175
+ return exitStatusFor(result);
94
176
  }
95
177
 
96
178
  // Support both CJS (require.main === module) and ESM (import.meta.url) runtimes.
@@ -1,36 +1,50 @@
1
1
  import { Rule, Violation, Severity } from '../../types';
2
2
  import { getActiveConfig } from '../../config';
3
3
 
4
+ /** One function as the adapter reports it. */
5
+ interface FunctionFacts {
6
+ name?: string;
7
+ nestingDepth?: number;
8
+ startLine?: number;
9
+ line?: number;
10
+ }
11
+
12
+ /** The shape of the adapter we consult. */
13
+ interface TypedAdapter {
14
+ extractFunctions?: () => FunctionFacts[] | undefined;
15
+ }
16
+
17
+ /** A violation when one function nests deeper than the threshold, else null. */
18
+ function nestingViolation(
19
+ func: FunctionFacts, maxDepth: number, file: string, severity: Severity,
20
+ ): Violation | null {
21
+ const depth = func.nestingDepth;
22
+ if (!depth || depth <= maxDepth) return null;
23
+ return {
24
+ file,
25
+ line: func.startLine ?? func.line ?? 1,
26
+ ruleId: 'clean-code.deep-nesting',
27
+ severity,
28
+ message: `Function "${func.name}" has deep nesting: ${depth} levels (maximum: ${maxDepth})`,
29
+ };
30
+ }
31
+
4
32
  export const deepNestingRule: Rule = {
5
33
  id: 'clean-code.deep-nesting',
6
34
  name: 'Deep Nesting Rule',
7
35
  threshold: 4,
8
36
  severity: 'warning',
9
37
  check: (file: string, adapter: unknown): Violation[] => {
10
- const violations: Violation[] = [];
11
38
  // Read the ACTIVE config per invocation; a module-load snapshot was #457.
12
39
  const settings = getActiveConfig().rules['clean-code']['deep-nesting'];
13
-
14
- interface TypedAdapter {
15
- extractFunctions?: () => Array<{name?: string, nestingDepth?: number, startLine?: number, line?: number}> | undefined;
16
- }
17
- const typedAdapter = adapter as TypedAdapter;
18
- const functions = typedAdapter.extractFunctions?.() || [];
19
-
20
- for (const func of functions) {
21
- if (func.nestingDepth && func.nestingDepth > (settings.threshold ?? 0)) {
22
- violations.push({
23
- file,
24
- line: func.startLine ?? func.line ?? 1,
25
- ruleId: 'clean-code.deep-nesting',
26
- message: `Function "${func.name}" has deep nesting: ${func.nestingDepth} levels (maximum: ${
27
- settings.threshold
28
- })`,
29
- severity: (settings.severity as Severity) ?? 'warning'
30
- });
31
- }
40
+ const maxDepth = settings.threshold ?? 0;
41
+ const severity = (settings.severity as Severity) ?? 'warning';
42
+
43
+ const violations: Violation[] = [];
44
+ for (const func of (adapter as TypedAdapter).extractFunctions?.() || []) {
45
+ const found = nestingViolation(func, maxDepth, file, severity);
46
+ if (found) violations.push(found);
32
47
  }
33
-
34
48
  return violations;
35
49
  }
36
- };
50
+ };
@@ -1,31 +1,42 @@
1
1
  import { Rule, Violation, Severity } from '../../types';
2
2
  import { getActiveConfig } from '../../config';
3
3
 
4
+ /** One import as the adapter reports it. */
5
+ interface ImportFacts {
6
+ name?: string;
7
+ line?: number;
8
+ used?: boolean;
9
+ type?: string;
10
+ }
11
+
12
+ /**
13
+ * An import is unused when nothing references it. A `import type` is exempt: it
14
+ * is erased at compile time, so an unused type import is not dead code in the
15
+ * shipped output.
16
+ */
17
+ function isUnusedValueImport(imp: ImportFacts): boolean {
18
+ return !imp.used && imp.type !== 'type';
19
+ }
20
+
4
21
  export const unusedImportsRule: Rule = {
5
22
  id: 'clean-code.unused-imports',
6
23
  name: 'Unused Imports Rule',
7
24
  threshold: 1,
8
25
  severity: 'info',
9
26
  check: (file: string, adapter: unknown): Violation[] => {
10
- const violations: Violation[] = [];
11
27
  // Read the ACTIVE config per invocation; a module-load snapshot was #457.
12
28
  const settings = getActiveConfig().rules['clean-code']['unused-imports'];
13
-
14
- const typedAdapter = adapter as { imports?: Array<{name?: string; line?: number; used?: boolean; type?: string;}> | undefined };
15
- const imports = typedAdapter.imports || [];
16
-
17
- for (const imp of imports) {
18
- if (!imp.used && imp.type !== 'type') {
19
- violations.push({
20
- file,
21
- line: imp.line ?? 1,
22
- ruleId: 'clean-code.unused-imports',
23
- message: `Unused import "${imp.name}" - consider removing`,
24
- severity: (settings.severity as Severity) ?? 'info'
25
- });
26
- }
27
- }
28
-
29
- return violations;
29
+ const severity = (settings.severity as Severity) ?? 'info';
30
+
31
+ const imports = (adapter as { imports?: ImportFacts[] }).imports || [];
32
+ return imports
33
+ .filter(isUnusedValueImport)
34
+ .map((imp) => ({
35
+ file,
36
+ line: imp.line ?? 1,
37
+ ruleId: 'clean-code.unused-imports',
38
+ message: `Unused import "${imp.name}" - consider removing`,
39
+ severity,
40
+ }));
30
41
  }
31
- };
42
+ };
@@ -21,35 +21,45 @@ function shouldSkipInstantiation(className: string): boolean {
21
21
  return false;
22
22
  }
23
23
 
24
+ /** `new Foo(` -> `Foo`. */
25
+ function classNameFromNewMatch(match: string): string {
26
+ return match.replace(/new\s+/, '').replace(/\s*\(/, '');
27
+ }
28
+
29
+ /** Violations for one class body: each `new X(` that is not exempt. */
30
+ function instantiationViolations(
31
+ code: string, line: number, file: string, severity: Severity,
32
+ ): Violation[] {
33
+ const violations: Violation[] = [];
34
+ for (const match of code.match(/new\s+(\w+)\s*\(/g) || []) {
35
+ const className = classNameFromNewMatch(match);
36
+ if (shouldSkipInstantiation(className)) continue;
37
+ violations.push({
38
+ file,
39
+ line,
40
+ ruleId: 'solid.dip',
41
+ severity,
42
+ message: `Direct instantiation detected: new ${className}(). Prefer dependency injection for flexibility.`,
43
+ });
44
+ }
45
+ return violations;
46
+ }
47
+
24
48
  export const dipRule: Rule = {
25
49
  id: 'solid.dip',
26
50
  name: 'Dependency Inversion Principle Rule',
27
51
  threshold: 0,
28
52
  severity: 'warning',
29
53
  check: (file: string, adapter: import('../../types').Adapter): Violation[] => {
30
- const violations: Violation[] = [];
31
54
  const settings = getActiveConfig().rules['solid']['dip'];
32
55
  const severity = (settings.severity as Severity) ?? 'warning';
33
56
 
34
- for (const cls_any of adapter.extractClasses() || []) {
35
- const cls = cls_any as {code?: string; line?: number};
36
- const code = cls.code;
37
- if (!code) continue;
38
-
39
- const newMatches = code.match(/new\s+(\w+)\s*\(/g) || [];
40
- for (const match of newMatches) {
41
- const className = match.replace(/new\s+/, '').replace(/\s*\(/, '');
42
- if (shouldSkipInstantiation(className)) continue;
43
-
44
- violations.push({
45
- file,
46
- line: cls.line || 0,
47
- ruleId: 'solid.dip',
48
- severity,
49
- message: `Direct instantiation detected: new ${className}(). Prefer dependency injection for flexibility.`,
50
- });
51
- }
57
+ const violations: Violation[] = [];
58
+ for (const clsAny of adapter.extractClasses() || []) {
59
+ const cls = clsAny as { code?: string; line?: number };
60
+ if (!cls.code) continue;
61
+ violations.push(...instantiationViolations(cls.code, cls.line || 0, file, severity));
52
62
  }
53
63
  return violations;
54
64
  }
55
- };
65
+ };
@@ -170,13 +170,28 @@ function undetermined(): Violation[] {
170
170
  * @param paramsText parameter list without surrounding parens
171
171
  * @returns parameter fragments
172
172
  */
173
+ /** Delimiters that nest inside a parameter list (generics included). */
174
+ const PARAM_OPENERS = '<([{';
175
+ const PARAM_CLOSERS = '>)]}';
176
+
177
+ /**
178
+ * Depth delta contributed by one character.
179
+ *
180
+ * Kept separate from the splitter so the nesting rule is stated once instead of
181
+ * being folded into the loop's condition (#620).
182
+ */
183
+ function paramDepthDelta(ch: string): number {
184
+ if (PARAM_OPENERS.includes(ch)) return 1;
185
+ if (PARAM_CLOSERS.includes(ch)) return -1;
186
+ return 0;
187
+ }
188
+
173
189
  function splitParams(paramsText: string): string[] {
174
190
  const parts: string[] = [];
175
191
  let depth = 0;
176
192
  let current = '';
177
193
  for (const ch of paramsText) {
178
- if (ch === '<' || ch === '(' || ch === '[' || ch === '{') depth += 1;
179
- else if (ch === '>' || ch === ')' || ch === ']' || ch === '}') depth -= 1;
194
+ depth += paramDepthDelta(ch);
180
195
  if (ch === ',' && depth <= 0) {
181
196
  parts.push(current);
182
197
  current = '';
@@ -1,39 +1,49 @@
1
1
  import { Rule, Violation } from '../../types';
2
2
  import { getActiveConfig } from '../../config';
3
3
 
4
+ /** The base class named in `extends X`, or null when the class extends nothing. */
5
+ function baseClassOf(code: string): string | null {
6
+ const match = code.match(/extends\s+(\w+)/);
7
+ return match ? match[1] : null;
8
+ }
9
+
10
+ /**
11
+ * OCP is violated when a class, in the same body, both extends a base class and
12
+ * redeclares it — a local extension that had to reach back into the base.
13
+ */
14
+ function ocpViolation(
15
+ code: string, name: string | undefined, line: number, file: string,
16
+ severity: 'error' | 'warning' | 'info',
17
+ ): Violation | null {
18
+ const baseClass = baseClassOf(code);
19
+ if (baseClass === null) return null;
20
+ if (!code.includes(`class ${baseClass}`)) return null;
21
+ return {
22
+ file,
23
+ line,
24
+ ruleId: 'solid.ocp',
25
+ message: `Possible modification of base class "${baseClass}" while extending. Extension should not require modifying the base.`,
26
+ severity,
27
+ };
28
+ }
29
+
4
30
  export const ocpRule: Rule = {
5
31
  id: 'solid.ocp',
6
32
  name: 'Open/Closed Principle Rule',
7
33
  threshold: 0,
8
34
  severity: 'info',
9
35
  check: (file: string, adapter: import('../../types').Adapter): Violation[] => {
10
- const violations: Violation[] = [];
11
36
  // Read the ACTIVE config per invocation; a module-load snapshot was #457.
12
37
  const settings = getActiveConfig().rules['solid']['ocp'];
13
- const severity = (settings.severity as "error" | "warning" | "info") ?? 'info';
14
-
15
- const classes = adapter.extractClasses() || [];
16
-
17
- for (const raw of classes) {
38
+ const severity = (settings.severity as 'error' | 'warning' | 'info') ?? 'info';
39
+
40
+ const violations: Violation[] = [];
41
+ for (const raw of adapter.extractClasses() || []) {
18
42
  const cls = raw as { code?: string; line?: number; name?: string };
19
43
  if (!cls.code) continue;
20
-
21
- const extendsMatch = cls.code.match(/extends\s+(\w+)/);
22
- if (extendsMatch) {
23
- const baseClass = extendsMatch[1];
24
-
25
- if (cls.code.includes(`class ${baseClass}`)) {
26
- violations.push({
27
- file,
28
- line: cls.line ?? 1,
29
- ruleId: 'solid.ocp',
30
- message: `Possible modification of base class "${baseClass}" while extending. Extension should not require modifying the base.`,
31
- severity: severity
32
- });
33
- }
34
- }
44
+ const found = ocpViolation(cls.code, cls.name, cls.line ?? 1, file, severity);
45
+ if (found) violations.push(found);
35
46
  }
36
-
37
47
  return violations;
38
48
  }
39
- };
49
+ };
@@ -1,41 +1,83 @@
1
1
  import { Rule, Violation } from '../../types';
2
2
  import { getActiveConfig } from '../../config';
3
3
 
4
+ /** The shape the adapter hands us for one class. */
5
+ interface RawClass {
6
+ code?: string;
7
+ line?: number;
8
+ name?: string;
9
+ methodCount?: number;
10
+ imports?: Record<string, unknown>;
11
+ }
12
+
13
+ /** A class, normalized for the checks below. */
14
+ interface ClassFacts {
15
+ name: string | undefined;
16
+ line: number;
17
+ methodCount: number;
18
+ importCategories: number;
19
+ }
20
+
21
+ /** Read the (optional) fields of a raw class once, with sane defaults. */
22
+ function readClass(raw: unknown): ClassFacts {
23
+ const cls = raw as RawClass;
24
+ return {
25
+ name: cls.name,
26
+ line: cls.line ?? 1,
27
+ methodCount: cls.methodCount || 0,
28
+ importCategories: cls.imports ? Object.keys(cls.imports).length : 0,
29
+ };
30
+ }
31
+
32
+ /** Too many methods in one class. */
33
+ function methodViolation(
34
+ facts: ClassFacts, file: string, maxMethods: number, severity: 'error' | 'warning' | 'info',
35
+ ): Violation | null {
36
+ if (facts.methodCount <= maxMethods) return null;
37
+ return {
38
+ file,
39
+ line: facts.line,
40
+ ruleId: 'solid.srp',
41
+ severity,
42
+ message: `Class "${facts.name}" has too many methods: ${facts.methodCount} (maximum: ${maxMethods}). Consider splitting into focused classes.`,
43
+ };
44
+ }
45
+
46
+ /** Imports reaching across too many domains. */
47
+ function importViolation(
48
+ facts: ClassFacts, file: string, severity: 'error' | 'warning' | 'info',
49
+ ): Violation | null {
50
+ if (facts.importCategories <= 3) return null;
51
+ return {
52
+ file,
53
+ line: facts.line,
54
+ ruleId: 'solid.srp',
55
+ severity,
56
+ message: `Class "${facts.name}" imports from ${facts.importCategories} different domains. Each class should focus on one responsibility.`,
57
+ };
58
+ }
59
+
4
60
  export const srpRule: Rule = {
5
61
  id: 'solid.srp',
6
62
  name: 'Single Responsibility Principle Rule',
7
63
  threshold: 15,
8
64
  severity: 'warning',
9
65
  check: (file: string, adapter: import('../../types').Adapter): Violation[] => {
10
- const violations: Violation[] = [];
11
66
  // Read the ACTIVE config per invocation; a module-load snapshot was #457.
12
67
  const settings = getActiveConfig().rules['solid']['srp'];
13
-
14
- const classes = adapter.extractClasses() || [];
15
68
  const maxMethods = settings.methodThreshold as number;
16
69
  const sev = (settings.severity as 'error' | 'warning' | 'info') ?? 'warning';
17
70
 
18
- for (const raw of classes) {
19
- const cls = raw as { code?: string; line?: number; name?: string; methodCount?: number; imports?: Record<string, unknown> };
20
- const methodCount = cls.methodCount || 0;
21
- const importCategories = cls.imports ? Object.keys(cls.imports).length : 0;
22
- const line = cls.line ?? 1;
23
-
24
- if (methodCount > maxMethods) {
25
- violations.push({
26
- file, line, ruleId: 'solid.srp', severity: sev,
27
- message: `Class "${cls.name}" has too many methods: ${methodCount} (maximum: ${maxMethods}). Consider splitting into focused classes.`,
28
- });
29
- }
30
-
31
- if (importCategories > 3) {
32
- violations.push({
33
- file, line, ruleId: 'solid.srp', severity: sev,
34
- message: `Class "${cls.name}" imports from ${importCategories} different domains. Each class should focus on one responsibility.`,
35
- });
71
+ const violations: Violation[] = [];
72
+ for (const raw of adapter.extractClasses() || []) {
73
+ const facts = readClass(raw);
74
+ for (const found of [
75
+ methodViolation(facts, file, maxMethods, sev),
76
+ importViolation(facts, file, sev),
77
+ ]) {
78
+ if (found) violations.push(found);
36
79
  }
37
80
  }
38
-
39
81
  return violations;
40
82
  },
41
- };
83
+ };
@@ -152,8 +152,8 @@ function readConfig(configPath, profileOverride) {
152
152
  // pins ~/.config/xp-gate — overriding only this loader would make the two
153
153
  // disagree. On Linux, $HOME/.config IS the XDG default, so behaviour there
154
154
  // is identical; the env var only matters for non-default setups.
155
- const configHome = process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
156
- const globalPath = path.join(configHome, 'xp-gate', '.delphi-config.json');
155
+ // Extracted so all three resolution points share one definition (REQ-636).
156
+ const globalPath = globalConfigPath(process.env);
157
157
  if (fs.existsSync(globalPath)) {
158
158
  console.error(`[delphi-review] ${configPath} not found here — using global config ${globalPath}`);
159
159
  configPath = globalPath;
@@ -958,6 +958,7 @@ if (require.main !== module) {
958
958
  module.exports = {
959
959
  parseArgs,
960
960
  readConfig,
961
+ globalConfigPath,
961
962
  validateDistinctModels,
962
963
  extractJsonFromResponse,
963
964
  buildSystemPrompt,
@@ -985,3 +986,25 @@ if (require.main !== module) {
985
986
  process.exit(1);
986
987
  });
987
988
  }
989
+
990
+ /**
991
+ * Machine-wide `.delphi-config.json` path: `$XDG_CONFIG_HOME/xp-gate/`, or
992
+ * `~/.config/xp-gate/` when the variable is unset.
993
+ *
994
+ * Exported so the path-parity test can hold this loader, the Gate MW validator
995
+ * and the DSH plugin to one definition instead of three restatements.
996
+ *
997
+ * Deliberately declared at the END of the file. Function declarations hoist, so
998
+ * readConfig above can call it, but placing it here keeps every line before the
999
+ * old EOF byte-identical — which matters because `.ccn-baseline.json` anchors
1000
+ * anonymous functions by line number (`file::(anonymous)@657`). Adding these
1001
+ * lines near the top shifted that anchor to 676 and Gate 3 then read a
1002
+ * pre-existing CCN=24 function as a brand-new violation.
1003
+ *
1004
+ * @param {NodeJS.ProcessEnv} env
1005
+ * @returns {string}
1006
+ */
1007
+ function globalConfigPath(env) {
1008
+ const configHome = env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
1009
+ return path.join(configHome, 'xp-gate', '.delphi-config.json');
1010
+ }