@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.
- package/adapters/java.sh +256 -4
- package/adapters/powershell.sh +45 -6
- package/bin/xp-gate.js +8 -2
- package/build-integrity/__tests__/gate-10.test.ts +88 -1
- package/build-integrity/gate-10.ts +51 -16
- package/dsh.bundle.patch.yml +15 -0
- package/gate-13-compiler-strictness.sh +1031 -0
- package/gate-3.sh +48 -13
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +48 -13
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +9 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +269 -13
- package/lib/__tests__/detect-deps-dsh-profile.test.js +106 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +140 -0
- package/lib/__tests__/gate-runner.test.js +29 -2
- package/lib/__tests__/issue-586-principles-cmdline.test.js +35 -0
- package/lib/__tests__/issue-618-gate-not-run.test.js +100 -0
- package/lib/__tests__/shared-utils.test.js +21 -0
- package/lib/check.js +6 -2
- package/lib/detect-deps.js +42 -10
- package/lib/doctor-tui.js +111 -21
- package/lib/gate-runner.js +111 -37
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/principles.js +48 -6
- package/lib/shared-utils.js +8 -0
- package/mock-policy/AGENTS.md +3 -3
- package/mock-policy/gate-m3.ts +57 -23
- package/mutation/AGENTS.md +3 -3
- package/mutation/__tests__/pitest-gradle-availability.test.ts +74 -0
- package/mutation/__tests__/test-filter-parity.test.ts +5 -2
- package/mutation/gate-m.ts +30 -24
- package/mutation/runners/mutmut-runner.ts +80 -30
- package/mutation/runners/pitest-runner.ts +60 -42
- package/package.json +14 -2
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/command.d.ts +38 -0
- package/plugins/dsh/lib/command.d.ts.map +1 -0
- package/plugins/dsh/lib/command.js +87 -0
- package/plugins/dsh/lib/credentials.d.ts +39 -0
- package/plugins/dsh/lib/credentials.d.ts.map +1 -0
- package/plugins/dsh/lib/credentials.js +57 -0
- package/plugins/dsh/lib/delphi-config.d.ts +129 -0
- package/plugins/dsh/lib/delphi-config.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-config.js +369 -0
- package/plugins/dsh/lib/delphi-run.d.ts +24 -0
- package/plugins/dsh/lib/delphi-run.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi-run.js +113 -0
- package/plugins/dsh/lib/delphi.d.ts +94 -0
- package/plugins/dsh/lib/delphi.d.ts.map +1 -0
- package/plugins/dsh/lib/delphi.js +207 -0
- package/plugins/dsh/lib/dialect.d.ts +24 -0
- package/plugins/dsh/lib/dialect.d.ts.map +1 -0
- package/plugins/dsh/lib/dialect.js +36 -0
- package/plugins/dsh/lib/evidence.d.ts +70 -0
- package/plugins/dsh/lib/evidence.d.ts.map +1 -0
- package/plugins/dsh/lib/evidence.js +92 -0
- package/plugins/dsh/lib/gate-runner.d.ts +49 -0
- package/plugins/dsh/lib/gate-runner.d.ts.map +1 -0
- package/plugins/dsh/lib/gate-runner.js +95 -0
- package/plugins/dsh/lib/git-context.d.ts +26 -0
- package/plugins/dsh/lib/git-context.d.ts.map +1 -0
- package/plugins/dsh/lib/git-context.js +30 -0
- package/plugins/dsh/lib/hooks.d.ts +42 -0
- package/plugins/dsh/lib/hooks.d.ts.map +1 -0
- package/plugins/dsh/lib/hooks.js +113 -0
- package/plugins/dsh/lib/index.d.ts +20 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -0
- package/plugins/dsh/lib/index.js +430 -0
- package/plugins/dsh/lib/skill-provider.d.ts +15 -0
- package/plugins/dsh/lib/skill-provider.d.ts.map +1 -0
- package/plugins/dsh/lib/skill-provider.js +205 -0
- package/plugins/dsh/lib/sprint.d.ts +60 -0
- package/plugins/dsh/lib/sprint.d.ts.map +1 -0
- package/plugins/dsh/lib/sprint.js +92 -0
- package/plugins/dsh/package.json +14 -8
- package/plugins/dsh/scripts/verify-skill-provider.mjs +113 -0
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/command-single-form.test.ts +67 -0
- package/plugins/dsh/src/command.test.ts +21 -17
- package/plugins/dsh/src/command.ts +19 -21
- package/plugins/dsh/src/compat.test.ts +200 -0
- package/plugins/dsh/src/contract.test.ts +7 -1
- package/plugins/dsh/src/credentials.test.ts +93 -0
- package/plugins/dsh/src/credentials.ts +71 -0
- package/plugins/dsh/src/delphi-cancellation.test.ts +71 -0
- package/plugins/dsh/src/delphi-config-apikey-override.test.ts +94 -0
- package/plugins/dsh/src/delphi-config-credential-refs.test.ts +75 -0
- package/plugins/dsh/src/delphi-config.ts +146 -53
- package/plugins/dsh/src/delphi-run.ts +27 -4
- package/plugins/dsh/src/delphi.ts +133 -65
- package/plugins/dsh/src/evidence.test.ts +74 -0
- package/plugins/dsh/src/evidence.ts +141 -0
- package/plugins/dsh/src/gate-runner.test.ts +14 -6
- package/plugins/dsh/src/gate-runner.ts +68 -5
- package/plugins/dsh/src/git-context.test.ts +39 -0
- package/plugins/dsh/src/git-context.ts +38 -0
- package/plugins/dsh/src/hooks.test.ts +87 -0
- package/plugins/dsh/src/hooks.ts +126 -0
- package/plugins/dsh/src/index.test.ts +63 -6
- package/plugins/dsh/src/index.ts +340 -13
- package/plugins/dsh/src/skill-provider.test.ts +194 -0
- package/plugins/dsh/src/skill-provider.ts +231 -0
- package/plugins/dsh/src/sprint.test.ts +64 -0
- package/plugins/dsh/src/sprint.ts +122 -0
- package/plugins/dsh/src/tri-state.test.ts +123 -0
- package/plugins/opencode/index.ts +192 -58
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/__tests__/analyze-read-failure.test.ts +70 -0
- package/principles/__tests__/baseline-entry-total-warnings.test.ts +76 -0
- package/principles/__tests__/baseline-prune.test.ts +61 -0
- package/principles/__tests__/cli-files-separators.test.ts +69 -0
- package/principles/__tests__/config-merge-warnings.test.ts +148 -0
- package/principles/adapters/__tests__/cpp-code-block.test.ts +123 -0
- package/principles/adapters/__tests__/cpp-scanner-equivalence.test.ts +67 -0
- package/principles/adapters/__tests__/typescript-block-comment-lines.test.ts +53 -0
- package/principles/adapters/__tests__/typescript-masking.test.ts +96 -0
- package/principles/adapters/cpp.ts +102 -50
- package/principles/adapters/java.ts +22 -3
- package/principles/adapters/typescript.ts +548 -178
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- package/principles/baseline.ts +58 -70
- package/principles/config.ts +56 -35
- package/principles/index.ts +122 -40
- package/principles/rules/clean-code/deep-nesting.ts +36 -22
- package/principles/rules/clean-code/unused-imports.ts +30 -19
- package/principles/rules/solid/dip.ts +30 -20
- package/principles/rules/solid/lsp.ts +17 -2
- package/principles/rules/solid/ocp.ts +33 -23
- package/principles/rules/solid/srp.ts +65 -23
- package/scripts/delphi-external-review.cjs +25 -2
- package/scripts/sync-package-content.js +28 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/principles/analyzer.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
184
|
-
if (!adapter
|
|
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 (
|
|
141
|
+
if (!isJsonObject(parsed)) {
|
|
129
142
|
return 'baseline is not a JSON object';
|
|
130
143
|
}
|
|
131
144
|
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
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(
|
|
143
|
-
if (
|
|
154
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
155
|
+
if (isJsonObject(value)) {
|
|
144
156
|
entries[key] = value;
|
|
145
157
|
}
|
|
146
158
|
}
|
package/principles/baseline.ts
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
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) {
|
package/principles/config.ts
CHANGED
|
@@ -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
|
|
package/principles/index.ts
CHANGED
|
@@ -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
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
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
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
|
|
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
|
+
};
|