@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
@@ -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
  }
@@ -19,6 +19,41 @@ interface BaselineStorageConfig {
19
19
 
20
20
  type ToolValidator = (entry: BaselineEntry, file: string) => void;
21
21
 
22
+ /** The per-tool counters a baseline entry can carry, in report order. */
23
+ const TOOL_KEYS = ['eslint', 'principles', 'ccn', 'ruff', 'golangci', 'shellcheck'] as const;
24
+ type ToolKey = typeof TOOL_KEYS[number];
25
+
26
+ /**
27
+ * Build one baseline entry from a file's per-tool counts.
28
+ *
29
+ * `createFromFiles` and `initializeWithAnalyzer` each carried this six-`if`
30
+ * accumulator plus its six-spread object literal verbatim (#620). Beyond the
31
+ * duplication, the copy-paste is what made both methods read as CCN 15-18: the
32
+ * complexity was not in what they did, it was in how many tools they listed.
33
+ *
34
+ * The two copies differed in ONE way, and this function keeps the stricter
35
+ * reading: an explicit `totalWarnings` wins when it is a number (including 0).
36
+ * `createFromFiles` tested truthiness, so a deliberate `totalWarnings: 0` was
37
+ * silently replaced by the sum -- the one case where the caller was trying to
38
+ * override a stale per-tool count.
39
+ */
40
+ function buildEntry(counts: Partial<BaselineEntry>): BaselineEntry {
41
+ const entry: Partial<Record<ToolKey, unknown>> = {};
42
+ let summed = 0;
43
+ for (const key of TOOL_KEYS) {
44
+ const tool = counts[key];
45
+ if (!tool) continue;
46
+ entry[key] = tool;
47
+ summed += tool.warnings;
48
+ }
49
+ const totalWarnings = typeof counts.totalWarnings === 'number' ? counts.totalWarnings : summed;
50
+ return {
51
+ ...entry,
52
+ totalWarnings,
53
+ lastAnalyzed: new Date().toISOString(),
54
+ } as BaselineEntry;
55
+ }
56
+
22
57
  function computeSummaryStats(baseline: Record<string, BaselineEntry>) {
23
58
  let totalFiles = 0;
24
59
  let totalWarnings = 0;
@@ -48,18 +83,27 @@ function computeSummaryStats(baseline: Record<string, BaselineEntry>) {
48
83
  };
49
84
  }
50
85
 
51
- function aggregateTool(stats: Record<string, { totalWarnings: number; totalErrors: number }>, entry: BaselineEntry) {
52
- const tools: Array<{ key: string; warnings: number; errors: number }> = [
53
- { key: 'eslint', warnings: entry.eslint?.warnings ?? 0, errors: entry.eslint?.errors ?? 0 },
54
- { key: 'principles', warnings: entry.principles?.warnings ?? 0, errors: entry.principles?.errors ?? 0 },
55
- { key: 'ruff', warnings: entry.ruff?.warnings ?? 0, errors: entry.ruff?.errors ?? 0 },
56
- { key: 'golangci', warnings: entry.golangci?.warnings ?? 0, errors: entry.golangci?.errors ?? 0 },
57
- { key: 'shellcheck', warnings: entry.shellcheck?.warnings ?? 0, errors: entry.shellcheck?.errors ?? 0 },
58
- ];
59
- for (const t of tools) {
60
- if (t.warnings > 0 || t.errors > 0) {
61
- stats[t.key] = { totalWarnings: (stats[t.key]?.totalWarnings || 0) + t.warnings, totalErrors: (stats[t.key]?.totalErrors || 0) + t.errors };
62
- }
86
+ /** Tools whose counts are summed, and which shape their counter has. */
87
+ const AGGREGATED_TOOLS = ['eslint', 'principles', 'ruff', 'golangci', 'shellcheck'] as const;
88
+
89
+ function toolCounters(entry: BaselineEntry, key: typeof AGGREGATED_TOOLS[number]): { warnings: number; errors: number } {
90
+ const tool = entry[key];
91
+ if (!tool) return { warnings: 0, errors: 0 };
92
+ return { warnings: tool.warnings, errors: tool.errors };
93
+ }
94
+
95
+ function aggregateTool(
96
+ stats: Record<string, { totalWarnings: number; totalErrors: number }>,
97
+ entry: BaselineEntry,
98
+ ): void {
99
+ for (const key of AGGREGATED_TOOLS) {
100
+ const { warnings, errors } = toolCounters(entry, key);
101
+ if (warnings <= 0 && errors <= 0) continue;
102
+ const running = stats[key] ?? { totalWarnings: 0, totalErrors: 0 };
103
+ stats[key] = {
104
+ totalWarnings: running.totalWarnings + warnings,
105
+ totalErrors: running.totalErrors + errors,
106
+ };
63
107
  }
64
108
  }
65
109
 
@@ -148,44 +192,9 @@ class BaselineStorage {
148
192
 
149
193
  async createFromFiles(warningData: Array<{ file: string; counts: Partial<BaselineEntry> }>): Promise<Record<string, BaselineEntry>> {
150
194
  const baseline: Record<string, BaselineEntry> = {};
151
-
152
195
  for (const item of warningData) {
153
- let totalWarnings = 0;
154
-
155
- if (item.counts.eslint?.warnings) {
156
- totalWarnings += item.counts.eslint.warnings;
157
- }
158
- if (item.counts.principles?.warnings) {
159
- totalWarnings += item.counts.principles.warnings;
160
- }
161
- if (item.counts.ccn?.warnings) {
162
- totalWarnings += item.counts.ccn.warnings;
163
- }
164
- if (item.counts.ruff?.warnings) {
165
- totalWarnings += item.counts.ruff.warnings;
166
- }
167
- if (item.counts.golangci?.warnings) {
168
- totalWarnings += item.counts.golangci.warnings;
169
- }
170
- if (item.counts.shellcheck?.warnings) {
171
- totalWarnings += item.counts.shellcheck.warnings;
172
- }
173
- if (item.counts.totalWarnings) {
174
- totalWarnings = item.counts.totalWarnings;
175
- }
176
-
177
- baseline[item.file] = {
178
- ...(item.counts.eslint && { eslint: item.counts.eslint }),
179
- ...(item.counts.principles && { principles: item.counts.principles }),
180
- ...(item.counts.ccn && { ccn: item.counts.ccn }),
181
- ...(item.counts.ruff && { ruff: item.counts.ruff }),
182
- ...(item.counts.golangci && { golangci: item.counts.golangci }),
183
- ...(item.counts.shellcheck && { shellcheck: item.counts.shellcheck }),
184
- totalWarnings,
185
- lastAnalyzed: new Date().toISOString()
186
- };
196
+ baseline[item.file] = buildEntry(item.counts);
187
197
  }
188
-
189
198
  return baseline;
190
199
  }
191
200
 
@@ -217,28 +226,7 @@ class BaselineStorage {
217
226
  const promiseResults = await Promise.allSettled(
218
227
  batch.map(async (file) => {
219
228
  const counts = await warningCountFunction(file);
220
-
221
- let totalWarnings = 0;
222
- if (counts.eslint?.warnings) totalWarnings += counts.eslint.warnings;
223
- if (counts.principles?.warnings) totalWarnings += counts.principles.warnings;
224
- if (counts.ccn?.warnings) totalWarnings += counts.ccn.warnings;
225
- if (counts.ruff?.warnings) totalWarnings += counts.ruff.warnings;
226
- if (counts.golangci?.warnings) totalWarnings += counts.golangci.warnings;
227
- if (counts.shellcheck?.warnings) totalWarnings += counts.shellcheck.warnings;
228
- if (counts.totalWarnings !== undefined) totalWarnings = counts.totalWarnings;
229
-
230
- const baselineEntry: BaselineEntry = {
231
- ...(counts.eslint && { eslint: counts.eslint }),
232
- ...(counts.principles && { principles: counts.principles }),
233
- ...(counts.ccn && { ccn: counts.ccn }),
234
- ...(counts.ruff && { ruff: counts.ruff }),
235
- ...(counts.golangci && { golangci: counts.golangci }),
236
- ...(counts.shellcheck && { shellcheck: counts.shellcheck }),
237
- totalWarnings,
238
- lastAnalyzed: new Date().toISOString()
239
- };
240
-
241
- baseline[file] = baselineEntry;
229
+ baseline[file] = buildEntry(counts);
242
230
  completed.push(file);
243
231
 
244
232
  if (onProgress) {
@@ -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
 
@@ -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
+ };