@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.
- 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 +65 -16
- package/hooks/gate-13-compiler-strictness.sh +1031 -0
- package/hooks/gate-3.sh +65 -16
- package/hooks/lib/coverage-total.sh +18 -2
- package/hooks/lib/java-test-selection.sh +251 -0
- package/hooks/lib/jscpd-run.sh +101 -0
- package/hooks/lib/validate-code-walkthrough.cjs +186 -29
- package/hooks/pre-commit +259 -57
- 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-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/java.ts +22 -3
- package/principles/adapters/typescript.ts +70 -29
- package/principles/analyzer.ts +36 -3
- package/principles/baseline-prune.ts +19 -7
- 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/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
|
+
};
|
|
@@ -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
|
-
|
|
15
|
-
const imports =
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
const
|
|
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
|
-
|
|
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
|
|
14
|
-
|
|
15
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
20
|
-
const
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
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
|
-
|
|
156
|
-
const globalPath =
|
|
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
|
+
}
|