@boyingliu01/xp-gate 0.19.3 → 0.20.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/gate-4.sh +83 -18
- package/adapters/gate-8.sh +49 -5
- package/adapters/iac.sh +1 -1
- package/adapters/typescript.sh +15 -60
- package/bin/xp-gate.js +15 -16
- package/gate-10.sh +35 -2
- package/gate-4.sh +110 -21
- package/gate-8.sh +49 -5
- package/hooks/gate-10.sh +35 -2
- package/hooks/gate-4.sh +110 -21
- package/hooks/gate-8.sh +49 -5
- package/hooks/lib/jscpd-run.sh +63 -0
- package/hooks/lib/sprint-gate-report.sh +58 -0
- package/hooks/lib/test-failure.sh +152 -0
- package/hooks/lib/typecheck.sh +141 -0
- package/hooks/lib/validate-code-walkthrough.cjs +43 -5
- package/hooks/post-merge +100 -0
- package/hooks/pre-commit +389 -162
- package/hooks/pre-push +75 -19
- package/lib/__tests__/bootstrap.test.js +23 -41
- package/lib/__tests__/check-version.test.js +8 -1
- package/lib/__tests__/detect-deps.test.js +16 -0
- package/lib/__tests__/doctor-drift-round2.test.js +212 -0
- package/lib/__tests__/doctor-executed-surface.test.js +209 -0
- package/lib/__tests__/doctor-hook-drift.test.js +329 -0
- package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
- package/lib/__tests__/doctor-module-drift.test.js +206 -0
- package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
- package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
- package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
- package/lib/__tests__/doctor.test.js +155 -0
- package/lib/__tests__/gate-runner.test.js +19 -3
- package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
- package/lib/__tests__/init.test.js +44 -17
- package/lib/__tests__/install-skill-full-dir.test.js +151 -0
- package/lib/__tests__/install-skill.test.js +51 -132
- package/lib/__tests__/language-tools-detect.test.js +158 -0
- package/lib/__tests__/phase-transition.test.js +15 -1
- package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
- package/lib/__tests__/shared-paths.test.js +20 -0
- package/lib/__tests__/shared-utils.test.js +69 -0
- package/lib/__tests__/skill-flags.test.js +48 -0
- package/lib/__tests__/ui-detector.test.ts +6 -2
- package/lib/__tests__/uninstall.test.js +20 -0
- package/lib/__tests__/update-hooks.test.js +12 -0
- package/lib/__tests__/update-skill.test.js +7 -4
- package/lib/detect-deps.js +3 -20
- package/lib/doctor-tui.js +22 -5
- package/lib/doctor.js +1178 -31
- package/lib/init.js +184 -33
- package/lib/install-cmd.js +7 -4
- package/lib/install-skill.js +38 -95
- package/lib/language-tools.js +83 -4
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/shared-paths.js +5 -2
- package/lib/shared-utils.js +47 -3
- package/lib/uninstall.js +12 -0
- package/lib/update-hooks.js +4 -4
- package/lib/update-skill.js +6 -7
- package/mock-policy/AGENTS.md +4 -4
- package/mutation/AGENTS.md +4 -4
- package/mutation/__tests__/gate-m.test.ts +66 -14
- package/mutation/__tests__/mutmut-runner.test.ts +91 -42
- package/mutation/__tests__/test-filter-parity.test.ts +37 -0
- package/mutation/gate-m.ts +21 -8
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
- package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/README.md +169 -4
- package/plugins/dsh/dsh-overlay.patch.yml +67 -0
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/scripts/prepack.cjs +24 -19
- package/plugins/dsh/setup-dsh.ps1 +160 -0
- package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
- package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/dsh/src/command.test.ts +66 -2
- package/plugins/dsh/src/command.ts +36 -14
- package/plugins/dsh/src/contract.test.ts +31 -1
- package/plugins/dsh/src/delphi-config.test.ts +190 -0
- package/plugins/dsh/src/delphi-config.ts +223 -0
- package/plugins/dsh/src/delphi-run.test.ts +181 -0
- package/plugins/dsh/src/delphi-run.ts +129 -0
- package/plugins/dsh/src/delphi.test.ts +222 -0
- package/plugins/dsh/src/delphi.ts +247 -0
- package/plugins/dsh/src/dialect.test.ts +59 -0
- package/plugins/dsh/src/dialect.ts +40 -0
- package/plugins/dsh/src/index.ts +70 -4
- package/plugins/dsh/src/skills.test.ts +26 -19
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
- package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/agents/delphi-architecture.md +1 -1
- package/plugins/qoder/agents/delphi-feasibility.md +1 -1
- package/plugins/qoder/agents/delphi-technical.md +1 -1
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/principles/AGENTS.md +9 -8
- package/principles/__tests__/adapter-metrics.test.ts +292 -0
- package/principles/__tests__/analyzer.test.ts +29 -6
- package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
- package/principles/__tests__/baseline-prune.test.ts +338 -0
- package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
- package/principles/__tests__/boy-scout-integration.test.ts +97 -6
- package/principles/__tests__/boy-scout.test.ts +253 -7
- package/principles/__tests__/config-enforcement.test.ts +469 -0
- package/principles/__tests__/config-git-timeout.test.ts +66 -0
- package/principles/__tests__/direct-execution.test.ts +187 -0
- package/principles/__tests__/index.test.ts +16 -3
- package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
- package/principles/adapters/typescript.ts +613 -13
- package/principles/analyzer.ts +22 -0
- package/principles/baseline-prune.ts +217 -0
- package/principles/boy-scout.ts +481 -51
- package/principles/config.ts +362 -54
- package/principles/direct-execution.ts +60 -0
- package/principles/index.ts +40 -17
- package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
- package/principles/rules/clean-code/code-duplication.ts +8 -8
- package/principles/rules/clean-code/deep-nesting.ts +8 -8
- package/principles/rules/clean-code/god-class.ts +8 -8
- package/principles/rules/clean-code/large-file.ts +15 -11
- package/principles/rules/clean-code/long-function.ts +8 -8
- package/principles/rules/clean-code/magic-numbers.ts +10 -7
- package/principles/rules/clean-code/many-exports.ts +7 -7
- package/principles/rules/clean-code/missing-error-handling.ts +5 -5
- package/principles/rules/clean-code/too-many-params.ts +8 -8
- package/principles/rules/clean-code/unused-imports.ts +5 -5
- package/principles/rules/solid/dip.ts +18 -8
- package/principles/rules/solid/isp.ts +9 -8
- package/principles/rules/solid/lsp.ts +6 -6
- package/principles/rules/solid/ocp.ts +6 -5
- package/principles/rules/solid/srp.ts +7 -8
- package/principles/types.ts +15 -0
- package/scripts/__tests__/sync-package-content.test.js +28 -0
- package/scripts/delphi-external-review.cjs +193 -10
- package/scripts/sync-package-content.js +7 -4
- package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/skills/delphi-review/AGENTS.md +4 -4
- package/skills/delphi-review/SKILL.md +98 -14
- package/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/skills/delphi-review/references/requirements.md +6 -6
- package/skills/sprint-flow/AGENTS.md +4 -4
- package/skills/sprint-flow/references/phase-2-design.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +4 -4
- package/lib/__tests__/download-skill.test.js +0 -281
- package/lib/download-skill.js +0 -96
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-457
|
|
3
|
+
* @intent `.principlesrc` must actually change what Gate 4 enforces. The rule
|
|
4
|
+
* modules used to snapshot config at module load, so a project's
|
|
5
|
+
* threshold was silently ignored and the checker enforced the built-in
|
|
6
|
+
* default instead -- a fail-open gate.
|
|
7
|
+
* @covers AC-457-01, AC-457-02, AC-457-03, AC-457-04, AC-457-05, AC-457-06, AC-457-07, AC-457-14, AC-457-15, AC-457-16
|
|
8
|
+
*
|
|
9
|
+
* The pre-existing `config.test.ts` asserted only `expect(config).toBeDefined()`
|
|
10
|
+
* for `loadConfig`, which is why the defect survived: the loader returned the
|
|
11
|
+
* right object while nothing consumed it.
|
|
12
|
+
*
|
|
13
|
+
* These tests assert the *end-to-end* effect (an enforcement threshold changes),
|
|
14
|
+
* not merely that a function returns a value.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { spawnSync } from 'node:child_process';
|
|
19
|
+
import { tmpdir } from 'node:os';
|
|
20
|
+
import { join } from 'node:path';
|
|
21
|
+
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
|
22
|
+
import {
|
|
23
|
+
getActiveConfig,
|
|
24
|
+
getDefaultConfig,
|
|
25
|
+
getEffectiveConfigFor,
|
|
26
|
+
loadConfig,
|
|
27
|
+
resetActiveConfig,
|
|
28
|
+
setActiveConfig,
|
|
29
|
+
} from '../config';
|
|
30
|
+
import { getAllRules } from '../index';
|
|
31
|
+
import type { Adapter, Violation } from '../types';
|
|
32
|
+
|
|
33
|
+
const tempDirs: string[] = [];
|
|
34
|
+
|
|
35
|
+
/** Built-in default for large-file, per config.ts. */
|
|
36
|
+
const DEFAULT_LARGE_FILE_THRESHOLD = 1150;
|
|
37
|
+
/** A threshold far below the default, so a short stub violates it. */
|
|
38
|
+
const TIGHT_THRESHOLD = 10;
|
|
39
|
+
/** A threshold far above any stub, so nothing violates it. */
|
|
40
|
+
const LOOSE_THRESHOLD = 100000;
|
|
41
|
+
/** The line count our adapter stub reports. */
|
|
42
|
+
const STUB_LINE_COUNT = 50;
|
|
43
|
+
/** A small override for long-function, distinct from its default of 50. */
|
|
44
|
+
const SHORT_THRESHOLD = 12;
|
|
45
|
+
/** Built-in default for solid.srp's methodThreshold. */
|
|
46
|
+
const DEFAULT_SRP_METHOD_THRESHOLD = 15;
|
|
47
|
+
|
|
48
|
+
function makeTempDir(): string {
|
|
49
|
+
const dir = mkdtempSync(join(tmpdir(), 'xpgate-457-'));
|
|
50
|
+
tempDirs.push(dir);
|
|
51
|
+
return dir;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/** Write a `.principlesrc` fixture into a temp dir and return its path. */
|
|
55
|
+
function writeConfig(dir: string, contents: string, name = '.principlesrc'): string {
|
|
56
|
+
const configPath = join(dir, name);
|
|
57
|
+
try {
|
|
58
|
+
writeFileSync(configPath, contents, 'utf8');
|
|
59
|
+
} catch (error) {
|
|
60
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
61
|
+
throw new Error(`could not write fixture ${configPath}: ${detail}`);
|
|
62
|
+
}
|
|
63
|
+
return configPath;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Run the real CLI in a child process and report both its exit status and its
|
|
68
|
+
* parsed JSON. The exit code is part of this module's contract -- Gate 4 branches
|
|
69
|
+
* on it -- so it can only be asserted through an actual process, not by calling
|
|
70
|
+
* `main()` in-process.
|
|
71
|
+
*/
|
|
72
|
+
function runChecker(args: string[], cwd = process.cwd()): { status: number; summary: Record<string, number> } {
|
|
73
|
+
const result = spawnSync(
|
|
74
|
+
process.execPath,
|
|
75
|
+
[join(process.cwd(), 'node_modules', 'tsx', 'dist', 'cli.mjs'),
|
|
76
|
+
join(process.cwd(), 'src', 'principles', 'index.ts'), ...args],
|
|
77
|
+
{ encoding: 'utf8', cwd, timeout: 120000 },
|
|
78
|
+
);
|
|
79
|
+
const stdout = result.stdout ?? '';
|
|
80
|
+
let summary: Record<string, number> = {};
|
|
81
|
+
try {
|
|
82
|
+
summary = JSON.parse(stdout.slice(stdout.indexOf('{'))).summary;
|
|
83
|
+
} catch {
|
|
84
|
+
// A crash emits no parseable report; the caller asserts on `status`.
|
|
85
|
+
}
|
|
86
|
+
return { status: result.status ?? -1, summary };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** A `.principlesrc` that sets one numeric threshold for one rule. */
|
|
90
|
+
function thresholdConfig(ruleId: string, threshold: number): string {
|
|
91
|
+
const rule = { [ruleId]: { threshold } };
|
|
92
|
+
return JSON.stringify({ rules: { 'clean-code': rule } });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The same threshold, already merged into a config object (no disk involved). */
|
|
96
|
+
function thresholdConfigObject(
|
|
97
|
+
ruleId: keyof ReturnType<typeof getDefaultConfig>['rules']['clean-code'],
|
|
98
|
+
threshold: number,
|
|
99
|
+
): ReturnType<typeof getDefaultConfig> {
|
|
100
|
+
const config = getDefaultConfig();
|
|
101
|
+
config.rules['clean-code'][ruleId].threshold = threshold;
|
|
102
|
+
return config;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Run `body` with `[principles]` diagnostics captured instead of printed. */
|
|
106
|
+
function capturePrinciplesWarnings(body: () => void): string[] {
|
|
107
|
+
const captured: string[] = [];
|
|
108
|
+
const original = process.stderr.write;
|
|
109
|
+
process.stderr.write = ((chunk: unknown) => {
|
|
110
|
+
const text = String(chunk);
|
|
111
|
+
if (text.includes('[principles]')) captured.push(text.trim());
|
|
112
|
+
return true;
|
|
113
|
+
}) as typeof process.stderr.write;
|
|
114
|
+
try {
|
|
115
|
+
body();
|
|
116
|
+
} finally {
|
|
117
|
+
process.stderr.write = original;
|
|
118
|
+
}
|
|
119
|
+
return captured;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A `.principlesrc` that sets one array-valued key for one rule. */
|
|
123
|
+
function arrayConfig(ruleId: string, key: string, values: number[]): string {
|
|
124
|
+
const rule = { [ruleId]: { [key]: values } };
|
|
125
|
+
return JSON.stringify({ rules: { 'clean-code': rule } });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Minimal adapter stub: reports a fixed line count so large-file is decidable. */
|
|
129
|
+
function adapterReportingLines(totalLines: number): Adapter {
|
|
130
|
+
return {
|
|
131
|
+
detectLanguage: () => 'typescript',
|
|
132
|
+
parseAST: () => ({}),
|
|
133
|
+
extractFunctions: () => [],
|
|
134
|
+
extractClasses: () => [],
|
|
135
|
+
extractExports: () => [],
|
|
136
|
+
countLines: () => totalLines,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Run the real large-file rule against a stub reporting `lines` lines. */
|
|
141
|
+
function checkLargeFile(lines: number): Violation[] {
|
|
142
|
+
const rule = getAllRules().find(candidate => candidate.id === 'clean-code.large-file');
|
|
143
|
+
if (!rule) throw new Error('clean-code.large-file rule is not registered');
|
|
144
|
+
return rule.check('sample.ts', adapterReportingLines(lines));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
afterEach(() => {
|
|
148
|
+
resetActiveConfig();
|
|
149
|
+
for (const dir of tempDirs.splice(0)) {
|
|
150
|
+
try {
|
|
151
|
+
rmSync(dir, { recursive: true, force: true });
|
|
152
|
+
} catch {
|
|
153
|
+
/* best effort */
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
resetActiveConfig();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
describe('#457 .principlesrc actually changes enforcement', () => {
|
|
163
|
+
it('AC-457-01: getActiveConfig/setActiveConfig expose the effective config', () => {
|
|
164
|
+
const custom = getDefaultConfig();
|
|
165
|
+
custom.rules['clean-code']['large-file'].threshold = TIGHT_THRESHOLD;
|
|
166
|
+
setActiveConfig(custom);
|
|
167
|
+
expect(getActiveConfig().rules['clean-code']['large-file'].threshold).toBe(TIGHT_THRESHOLD);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('AC-457-01: resetActiveConfig restores the built-in defaults', () => {
|
|
171
|
+
const custom = getDefaultConfig();
|
|
172
|
+
custom.rules['clean-code']['large-file'].threshold = TIGHT_THRESHOLD;
|
|
173
|
+
setActiveConfig(custom);
|
|
174
|
+
resetActiveConfig();
|
|
175
|
+
expect(getActiveConfig().rules['clean-code']['large-file'].threshold).toBe(
|
|
176
|
+
DEFAULT_LARGE_FILE_THRESHOLD
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('AC-457-01: getDefaultConfig returns a fresh copy each call', () => {
|
|
181
|
+
// Regression guard: hoisting the defaults to module constants made
|
|
182
|
+
// getDefaultConfig() hand out SHARED objects, so one caller's mutation
|
|
183
|
+
// corrupted the defaults for every later caller in the process.
|
|
184
|
+
const first = getDefaultConfig();
|
|
185
|
+
first.rules['clean-code']['large-file'].threshold = TIGHT_THRESHOLD;
|
|
186
|
+
first.rules['clean-code']['magic-numbers'].exclude = [0];
|
|
187
|
+
|
|
188
|
+
const second = getDefaultConfig();
|
|
189
|
+
expect(second.rules['clean-code']['large-file'].threshold).toBe(
|
|
190
|
+
DEFAULT_LARGE_FILE_THRESHOLD
|
|
191
|
+
);
|
|
192
|
+
expect(second.rules['clean-code']['magic-numbers'].exclude).toContain(1024);
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('AC-457-02: the large-file rule honours the ACTIVE threshold, not the default', () => {
|
|
196
|
+
// This is the defect's core: the module captured the default at import
|
|
197
|
+
// time, so a lower project threshold had no effect.
|
|
198
|
+
const strict = getDefaultConfig();
|
|
199
|
+
strict.rules['clean-code']['large-file'].threshold = TIGHT_THRESHOLD;
|
|
200
|
+
setActiveConfig(strict);
|
|
201
|
+
|
|
202
|
+
// Below the default (1150) but above the configured 10.
|
|
203
|
+
const violations = checkLargeFile(STUB_LINE_COUNT);
|
|
204
|
+
expect(violations.length).toBeGreaterThan(0);
|
|
205
|
+
expect(violations[0].message).toContain(String(TIGHT_THRESHOLD));
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it('AC-457-02: raising the threshold suppresses violations the default would report', () => {
|
|
209
|
+
const relaxed = getDefaultConfig();
|
|
210
|
+
relaxed.rules['clean-code']['large-file'].threshold = LOOSE_THRESHOLD;
|
|
211
|
+
setActiveConfig(relaxed);
|
|
212
|
+
|
|
213
|
+
expect(checkLargeFile(STUB_LINE_COUNT)).toHaveLength(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
it('AC-457-04: `enabled: false` disables a rule (previously ignored everywhere)', () => {
|
|
217
|
+
const disabled = getDefaultConfig();
|
|
218
|
+
disabled.rules['clean-code']['large-file'].enabled = false;
|
|
219
|
+
setActiveConfig(disabled);
|
|
220
|
+
|
|
221
|
+
expect(checkLargeFile(STUB_LINE_COUNT)).toHaveLength(0);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('AC-457-06: loadConfig reads a config from an explicit path', async () => {
|
|
225
|
+
const configPath = writeConfig(makeTempDir(), thresholdConfig('large-file', 300));
|
|
226
|
+
|
|
227
|
+
const config = await loadConfig(configPath);
|
|
228
|
+
expect(config.rules['clean-code']['large-file'].threshold).toBe(300);
|
|
229
|
+
// Unspecified fields keep their defaults (deep merge, not replacement).
|
|
230
|
+
expect(config.rules['clean-code']['large-file'].severity).toBe('warning');
|
|
231
|
+
expect(config.rules['clean-code']['long-function'].threshold).toBe(50);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('AC-457-05: a partial override preserves the untouched rule group', async () => {
|
|
235
|
+
// Regression guard: a shallow group-level spread would drop `solid`
|
|
236
|
+
// entirely when the file only mentions `clean-code`.
|
|
237
|
+
const raw = thresholdConfig('long-function', SHORT_THRESHOLD);
|
|
238
|
+
const config = await loadConfig(writeConfig(makeTempDir(), raw));
|
|
239
|
+
|
|
240
|
+
expect(config.rules['solid']['srp'].methodThreshold).toBe(DEFAULT_SRP_METHOD_THRESHOLD);
|
|
241
|
+
expect(config.rules['solid']['dip'].enabled).toBe(true);
|
|
242
|
+
});
|
|
243
|
+
|
|
244
|
+
it('AC-457-10: a partial output block keeps the sibling output defaults', async () => {
|
|
245
|
+
// `rules` was deep-merged but the other nested sections were not, so naming
|
|
246
|
+
// one key under `output` discarded its siblings -- the same fail-open shape
|
|
247
|
+
// #457 was raised to eliminate. Raised by the Delphi walkthrough (MC-02).
|
|
248
|
+
const config = await loadConfig(
|
|
249
|
+
writeConfig(makeTempDir(), JSON.stringify({ output: { format: 'json' } }), 'out.json'),
|
|
250
|
+
);
|
|
251
|
+
|
|
252
|
+
expect(config.output?.format).toBe('json');
|
|
253
|
+
expect(config.output?.['show-score']).toBe(true);
|
|
254
|
+
expect(config.output?.colorize).toBe(true);
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it('AC-457-10: a partial performance block keeps the sibling defaults', async () => {
|
|
258
|
+
const config = await loadConfig(
|
|
259
|
+
writeConfig(makeTempDir(), JSON.stringify({ performance: { mode: 'all-files' } }), 'perf.json'),
|
|
260
|
+
);
|
|
261
|
+
|
|
262
|
+
expect(config.performance?.mode).toBe('all-files');
|
|
263
|
+
expect(config.performance?.mediumProjectDefinition).toBeDefined();
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
it('AC-457-05: an array override replaces rather than concatenates', async () => {
|
|
267
|
+
const raw = arrayConfig('magic-numbers', 'exclude', [0, 1]);
|
|
268
|
+
const config = await loadConfig(writeConfig(makeTempDir(), raw));
|
|
269
|
+
|
|
270
|
+
expect(config.rules['clean-code']['magic-numbers'].exclude).toEqual([0, 1]);
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
it('AC-457-07: a malformed config falls back to defaults without throwing', async () => {
|
|
274
|
+
const config = await loadConfig(writeConfig(makeTempDir(), '{ this is not json'));
|
|
275
|
+
|
|
276
|
+
expect(config.rules['clean-code']['large-file'].threshold).toBe(DEFAULT_LARGE_FILE_THRESHOLD);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it('AC-457-02: an absent auto-resolved config falls back to built-in defaults', () => {
|
|
280
|
+
// No --config was given, so "this project has no .principlesrc" is a normal
|
|
281
|
+
// state and must not fail. Run from a temp directory: the repo's own
|
|
282
|
+
// .principlesrc would otherwise answer the question before the code does.
|
|
283
|
+
const root = makeTempDir();
|
|
284
|
+
const target = join(root, 'target.ts');
|
|
285
|
+
writeFileSync(target, 'export const x = 1;\n', 'utf8');
|
|
286
|
+
|
|
287
|
+
const clean = runChecker(['--files', target, '--format', 'json'], root);
|
|
288
|
+
|
|
289
|
+
expect(clean.status).toBe(0);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it('AC-457-14: an explicitly requested config that cannot be read fails loudly', async () => {
|
|
293
|
+
// Raised by the Delphi walkthrough (feasibility seat, FC-08). The caller
|
|
294
|
+
// declared this file; silently enforcing built-in defaults instead is the
|
|
295
|
+
// same fail-open shape #457 exists to remove, just reached from the other
|
|
296
|
+
// direction -- a typo'd or deleted `.principlesrc` would keep the gate green.
|
|
297
|
+
const missing = join(makeTempDir(), 'does-not-exist.json');
|
|
298
|
+
|
|
299
|
+
await expect(loadConfig(missing)).rejects.toThrow(/config file not found/);
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it('AC-457-14: the CLI reports that failure as a tool error (exit 2), not as findings', () => {
|
|
303
|
+
// Exit 1 means "the checker ran and found ERROR-severity violations". A gate
|
|
304
|
+
// that reads 1 with an empty report announces PASSED. Exit 2 is this repo's
|
|
305
|
+
// runtime-error convention, which Gate 4 downgrades to SKIP -- loud, visible,
|
|
306
|
+
// and never mistaken for a clean run.
|
|
307
|
+
const missing = join(makeTempDir(), 'does-not-exist.json');
|
|
308
|
+
const target = join(makeTempDir(), 'target.ts');
|
|
309
|
+
writeFileSync(target, 'export const x = 1;\n', 'utf8');
|
|
310
|
+
|
|
311
|
+
const failed = runChecker(['--files', target, '--format', 'json', '--config', missing]);
|
|
312
|
+
|
|
313
|
+
expect(failed.status).toBe(2);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('AC-457-07: a type-mismatched value keeps the default instead of poisoning it', async () => {
|
|
317
|
+
const bad = thresholdConfig('large-file', 'not-a-number' as unknown as number);
|
|
318
|
+
const config = await loadConfig(writeConfig(makeTempDir(), bad));
|
|
319
|
+
|
|
320
|
+
expect(config.rules['clean-code']['large-file'].threshold).toBe(DEFAULT_LARGE_FILE_THRESHOLD);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it('AC-457-04: config read from disk is what the rule then enforces', async () => {
|
|
324
|
+
// The end-to-end contract: load -> activate -> enforce.
|
|
325
|
+
const configPath = writeConfig(makeTempDir(), thresholdConfig('large-file', 5));
|
|
326
|
+
setActiveConfig(await loadConfig(configPath));
|
|
327
|
+
|
|
328
|
+
expect(checkLargeFile(STUB_LINE_COUNT).length).toBeGreaterThan(0);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
// AC-457-08/09: the exit code has to separate "violations found" from "the
|
|
332
|
+
// checker crashed". Gate 4 decides by testing the exit status (`if run_tsx ...`),
|
|
333
|
+
// so a crash and an error-severity finding that share exit 1 are indistinguishable.
|
|
334
|
+
// Fixing #457 alone moved the hole rather than closing it: warnings no longer look
|
|
335
|
+
// like crashes, but ERROR-severity violations now do -- and the gate SKIPs,
|
|
336
|
+
// releasing exactly the most serious findings. Found by the Delphi walkthrough.
|
|
337
|
+
it('AC-457-08: exits 1 for an ERROR-severity violation and 0 without one', () => {
|
|
338
|
+
const root = makeTempDir();
|
|
339
|
+
const target = join(root, 'target.ts');
|
|
340
|
+
writeFileSync(target, 'export const x = 1;\n', 'utf8');
|
|
341
|
+
const strict = writeConfig(root, '{"rules":{"clean-code":{"large-file":{"enabled":true,"threshold":1,"severity":"error"}}}}');
|
|
342
|
+
|
|
343
|
+
const blocking = runChecker(['--files', target, '--format', 'json', '--config', strict]);
|
|
344
|
+
expect(blocking.summary.errorCount).toBeGreaterThan(0);
|
|
345
|
+
expect(blocking.status).toBe(1);
|
|
346
|
+
|
|
347
|
+
const loose = writeConfig(root, '{"rules":{"clean-code":{"large-file":{"enabled":true,"threshold":100000}}}}', 'loose.json');
|
|
348
|
+
const clean = runChecker(['--files', target, '--format', 'json', '--config', loose]);
|
|
349
|
+
expect(clean.status).toBe(0);
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
it('AC-457-09: a checker crash exits 2, not 1, so gates can tell it from violations', () => {
|
|
353
|
+
// Exit 2 is this repo's existing convention for "tool ran into a runtime
|
|
354
|
+
// error" (see src/gates/gate-8.ts and gate-9.ts, which SKIP on exit >= 2 and
|
|
355
|
+
// treat 1 as a real finding).
|
|
356
|
+
const missing = join(makeTempDir(), 'never-written.ts');
|
|
357
|
+
|
|
358
|
+
const crashed = runChecker(['--files', missing, '--format', 'json']);
|
|
359
|
+
|
|
360
|
+
expect(crashed.status).toBe(2);
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
it('AC-457-09: a usage error also exits 2, never the findings code', () => {
|
|
364
|
+
// Raised by the Delphi walkthrough (architecture seat, MC-01). Returning 1
|
|
365
|
+
// here means "I found ERROR-severity violations" under the contract this
|
|
366
|
+
// change introduces. Gate 4 branches on >=2 for the crash path, so exit 1 plus
|
|
367
|
+
// an empty report reads as "checked everything, found nothing": the gate would
|
|
368
|
+
// announce PASSED having examined no files at all.
|
|
369
|
+
const misused = runChecker(['--format', 'json']);
|
|
370
|
+
|
|
371
|
+
expect(misused.status).toBe(2);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
// Severity override fixture. Hoisted so both cases below read as a loop over
|
|
375
|
+
// values rather than a loop over nested literals.
|
|
376
|
+
const severityConfig = (severity: string) => JSON.stringify({
|
|
377
|
+
rules: { 'clean-code': { 'large-file': { enabled: true, threshold: 1, severity } } },
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
it('AC-457-11: a severity outside the vocabulary is rejected, not silently obeyed', async () => {
|
|
381
|
+
// Raised by the Delphi walkthrough (architecture seat, MJ-01). `severity` was
|
|
382
|
+
// validated only as "non-empty string", but both summary.errorCount and Gate 4's
|
|
383
|
+
// grep match the exact lowercase literal `error`. Measured with severity "Error":
|
|
384
|
+
// the rule still runs, still reports the violation, yet errorCount is 0, exit is
|
|
385
|
+
// 0, and the gate passes -- a typo that silently un-blocks a rule with no warning
|
|
386
|
+
// anywhere. Same fail-open shape #457 exists to remove.
|
|
387
|
+
const dir = makeTempDir();
|
|
388
|
+
|
|
389
|
+
for (const bad of ['Error', 'ERROR', 'errorX', 'fatal']) {
|
|
390
|
+
const config = await loadConfig(writeConfig(dir, severityConfig(bad)));
|
|
391
|
+
expect(config.rules['clean-code']['large-file'].severity).toBe('warning');
|
|
392
|
+
}
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
it('AC-457-11: the three documented severities are all accepted', async () => {
|
|
396
|
+
const dir = makeTempDir();
|
|
397
|
+
for (const good of ['error', 'warning', 'info']) {
|
|
398
|
+
const config = await loadConfig(writeConfig(dir, severityConfig(good)));
|
|
399
|
+
expect(config.rules['clean-code']['large-file'].severity).toBe(good);
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
|
|
403
|
+
it('AC-457-13: every rule id has exactly one dot, as config lookup requires', () => {
|
|
404
|
+
// isRuleEnabled splits on the FIRST dot to get group and name. A future id like
|
|
405
|
+
// `clean-code.sub.rule` would resolve to group "clean-code", name "sub.rule",
|
|
406
|
+
// find no config entry, and default to enabled with built-in thresholds --
|
|
407
|
+
// silently ignoring whatever the project configured. The invariant held by
|
|
408
|
+
// inspection until this test said so (Delphi walkthrough FC-04).
|
|
409
|
+
const ids = getAllRules().map((rule) => rule.id);
|
|
410
|
+
|
|
411
|
+
expect(ids.length).toBeGreaterThan(0);
|
|
412
|
+
for (const id of ids) {
|
|
413
|
+
expect(id.split('.')).toHaveLength(2);
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
it('AC-457-13: every rule id resolves to a real group in the config', () => {
|
|
418
|
+
const groups = Object.keys(getDefaultConfig().rules);
|
|
419
|
+
for (const rule of getAllRules()) {
|
|
420
|
+
expect(groups).toContain(rule.id.slice(0, rule.id.indexOf('.')));
|
|
421
|
+
}
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
it('AC-457-16: getEffectiveConfigFor reports the config a rule actually enforces', () => {
|
|
425
|
+
// Promised by the R2 design review (walkthrough MAJ-03 / FC-05) and never
|
|
426
|
+
// delivered: `Rule.threshold` on the rule object is a deprecated snapshot, so
|
|
427
|
+
// a consumer that reads it sees a number that may differ from what `check()`
|
|
428
|
+
// compared against. This is the one accessor that cannot drift.
|
|
429
|
+
setActiveConfig(thresholdConfigObject('large-file', TIGHT_THRESHOLD));
|
|
430
|
+
|
|
431
|
+
expect(getEffectiveConfigFor('clean-code.large-file').threshold).toBe(TIGHT_THRESHOLD);
|
|
432
|
+
expect(getDefaultConfig().rules['clean-code']['large-file'].threshold).toBe(
|
|
433
|
+
DEFAULT_LARGE_FILE_THRESHOLD
|
|
434
|
+
);
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('AC-457-16: an unknown or malformed rule id is refused, not answered with defaults', () => {
|
|
438
|
+
// Silently handing back built-in defaults would recreate the fail-open shape
|
|
439
|
+
// for the *reporting* path: a caller would believe it read the effective
|
|
440
|
+
// threshold for a rule that does not exist.
|
|
441
|
+
expect(() => getEffectiveConfigFor('clean-code.not-a-rule')).toThrow(/no rule/);
|
|
442
|
+
expect(() => getEffectiveConfigFor('large-file')).toThrow(/group.name/);
|
|
443
|
+
});
|
|
444
|
+
|
|
445
|
+
it('AC-457-15: replacing the active config without resetting it is reported', () => {
|
|
446
|
+
// The concurrency contract used to live only in a JSDoc comment (walkthrough
|
|
447
|
+
// FC-02). A caller that evaluates two projects in one process would silently
|
|
448
|
+
// enforce whichever config happened to be installed last.
|
|
449
|
+
const warnings = capturePrinciplesWarnings(() => {
|
|
450
|
+
setActiveConfig(thresholdConfigObject('large-file', TIGHT_THRESHOLD));
|
|
451
|
+
setActiveConfig(thresholdConfigObject('large-file', LOOSE_THRESHOLD));
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
expect(warnings.join('\n')).toMatch(/replaced without reset/);
|
|
455
|
+
});
|
|
456
|
+
|
|
457
|
+
it('AC-457-15: the documented single-set lifecycle stays quiet', () => {
|
|
458
|
+
// The guardrail must not turn the normal CLI path (load -> set once -> run)
|
|
459
|
+
// into warning noise, otherwise Gate 4's stderr becomes unreadable.
|
|
460
|
+
const warnings = capturePrinciplesWarnings(() => {
|
|
461
|
+
resetActiveConfig();
|
|
462
|
+
setActiveConfig(thresholdConfigObject('large-file', TIGHT_THRESHOLD));
|
|
463
|
+
resetActiveConfig();
|
|
464
|
+
setActiveConfig(thresholdConfigObject('large-file', LOOSE_THRESHOLD));
|
|
465
|
+
});
|
|
466
|
+
|
|
467
|
+
expect(warnings).toEqual([]);
|
|
468
|
+
});
|
|
469
|
+
});
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-457
|
|
3
|
+
* @intent Config resolution shells out to git on every principles run, inside a git
|
|
4
|
+
* hook. Every such call must be bounded: an unbounded one turns a wedged git
|
|
5
|
+
* into a commit that never returns. Covers gitToplevel(), which sits on the
|
|
6
|
+
* default path because gate-4.sh passes no --config.
|
|
7
|
+
* @covers AC-457-12
|
|
8
|
+
*
|
|
9
|
+
* Mocks child_process rather than shimming git on PATH -- execFileSync resolves PATH
|
|
10
|
+
* without PATHEXT unless shell is set, so a stub binary is silently ignored and the
|
|
11
|
+
* test would pass while proving nothing.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
15
|
+
|
|
16
|
+
const execFileSyncMock = vi.fn();
|
|
17
|
+
|
|
18
|
+
vi.mock('child_process', () => ({
|
|
19
|
+
execFileSync: (...args: unknown[]) => execFileSyncMock(...args),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { loadConfig } from '../config';
|
|
23
|
+
|
|
24
|
+
/** Long enough for a healthy `git rev-parse`, short enough not to wedge a hook. */
|
|
25
|
+
const MAX_ACCEPTABLE_GIT_TIMEOUT_MS = 30_000;
|
|
26
|
+
|
|
27
|
+
describe('config loading git invocation (#457)', () => {
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
execFileSyncMock.mockReset();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('AC-457-12: bounds every git call with a timeout', async () => {
|
|
33
|
+
execFileSyncMock.mockReturnValue('/tmp/project\n');
|
|
34
|
+
|
|
35
|
+
await loadConfig();
|
|
36
|
+
|
|
37
|
+
expect(execFileSyncMock).toHaveBeenCalled();
|
|
38
|
+
for (const call of execFileSyncMock.mock.calls) {
|
|
39
|
+
const options = call[2] as { timeout?: number };
|
|
40
|
+
expect(typeof options.timeout).toBe('number');
|
|
41
|
+
expect(options.timeout).toBeGreaterThan(0);
|
|
42
|
+
expect(options.timeout).toBeLessThanOrEqual(MAX_ACCEPTABLE_GIT_TIMEOUT_MS);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('AC-457-12: a hung git falls back to defaults instead of hanging the hook', async () => {
|
|
47
|
+
execFileSyncMock.mockImplementation(() => {
|
|
48
|
+
const error = new Error('spawnSync git ETIMEDOUT') as Error & { signal?: string };
|
|
49
|
+
error.signal = 'SIGTERM';
|
|
50
|
+
throw error;
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
// Must resolve. If this rejects or blocks, the pre-commit hook hangs with it.
|
|
54
|
+
// Compared against the same resolution with a working git so the assertion does
|
|
55
|
+
// not hard-code a threshold the repo's own .principlesrc may override.
|
|
56
|
+
execFileSyncMock.mockReturnValue('/tmp/project\n');
|
|
57
|
+
const baseline = await loadConfig();
|
|
58
|
+
execFileSyncMock.mockImplementation(() => {
|
|
59
|
+
throw Object.assign(new Error('spawnSync git ETIMEDOUT'), { signal: 'SIGTERM' });
|
|
60
|
+
});
|
|
61
|
+
const degraded = await loadConfig();
|
|
62
|
+
|
|
63
|
+
expect(degraded.rules['clean-code']['large-file'].threshold)
|
|
64
|
+
.toBe(baseline.rules['clean-code']['large-file'].threshold);
|
|
65
|
+
});
|
|
66
|
+
});
|