@boyingliu01/xp-gate 0.19.2 → 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 +401 -155
- 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 +2 -2
- 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 +2 -2
- package/plugins/qoder/skills/admin-template-guidelines/SKILL.md +6 -797
- package/plugins/qoder/skills/admin-template-guidelines/references/rule-1.md +91 -0
- package/plugins/qoder/skills/admin-template-guidelines/references/rule-2.md +155 -0
- package/plugins/qoder/skills/admin-template-guidelines/references/rule-3.md +70 -0
- package/plugins/qoder/skills/admin-template-guidelines/references/rule-4.md +154 -0
- package/plugins/qoder/skills/admin-template-guidelines/references/rule-5.md +126 -0
- package/plugins/qoder/skills/admin-template-guidelines/references/rule-6.md +180 -0
- package/plugins/qoder/skills/batch-grill-me/SKILL.md +15 -0
- package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
- package/plugins/qoder/skills/clipboard-vision/describe-clipboard.ps1 +115 -0
- package/plugins/qoder/skills/clipboard-vision/describe-clipboard.sh +161 -0
- package/plugins/qoder/skills/delphi-review/.delphi-config.json.example +61 -28
- package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
- package/plugins/qoder/skills/delphi-review/INSTALL.md +97 -98
- package/plugins/qoder/skills/delphi-review/SKILL.md +170 -63
- package/plugins/qoder/skills/delphi-review/hooks.md +19 -0
- package/plugins/qoder/skills/delphi-review/opencode.json.delphi.example +6 -10
- package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
- package/plugins/qoder/skills/delphi-review/references/orchestrator-dispatch.md +64 -0
- package/plugins/qoder/skills/delphi-review/references/requirements.md +402 -0
- package/plugins/qoder/skills/delphi-review/references/round-templates.md +134 -0
- package/plugins/qoder/skills/delphi-review/scripts/verify-consensus.sh +43 -0
- package/plugins/qoder/skills/delphi-review/tools.md +29 -0
- package/plugins/qoder/skills/domain-modeling/ADR-FORMAT.md +64 -0
- package/plugins/qoder/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/plugins/qoder/skills/domain-modeling/SKILL.md +74 -0
- package/plugins/qoder/skills/grill-with-docs/SKILL.md +7 -0
- package/plugins/qoder/skills/grilling/SKILL.md +12 -0
- package/plugins/qoder/skills/ralph-loop/SKILL.md +87 -428
- package/plugins/qoder/skills/ralph-loop/references/components.md +113 -0
- package/plugins/qoder/skills/ralph-loop/references/workflow-details.md +99 -0
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +5 -5
- package/plugins/qoder/skills/sprint-flow/SKILL.md +255 -454
- package/plugins/qoder/skills/sprint-flow/__tests__/sprint-flow.test.cjs +457 -0
- package/plugins/qoder/skills/sprint-flow/hooks.md +18 -0
- package/plugins/qoder/skills/sprint-flow/references/components/memory.md +1 -1
- package/plugins/qoder/skills/sprint-flow/references/components/middleware.md +1 -1
- package/plugins/qoder/skills/sprint-flow/references/components/skill-invocations.md +21 -20
- package/plugins/qoder/skills/sprint-flow/references/components/tool-descriptions.md +22 -23
- package/plugins/qoder/skills/sprint-flow/references/force-levels.md +0 -1
- package/plugins/qoder/skills/sprint-flow/references/orchestration-rules.md +34 -27
- package/plugins/qoder/skills/sprint-flow/references/phase-1-prep.md +1 -1
- package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +160 -123
- package/plugins/qoder/skills/sprint-flow/references/phase-3-build.md +203 -2
- package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +107 -34
- package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +194 -12
- package/plugins/qoder/skills/sprint-flow/references/phase-6-close.md +139 -7
- package/plugins/qoder/skills/sprint-flow/references/phase-overview.md +486 -0
- package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +38 -0
- package/plugins/qoder/skills/sprint-flow/tools.md +29 -0
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
- package/plugins/qoder/skills/test-specification-alignment/SKILL.md +104 -601
- package/plugins/qoder/skills/to-issues/SKILL.md +1 -1
- 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
- package/plugins/qoder/skills/ralph-loop/references/components/memory.md +0 -55
- package/plugins/qoder/skills/ralph-loop/references/components/middleware.md +0 -54
- package/plugins/qoder/skills/ralph-loop/references/components/skill-invocations.md +0 -39
- package/plugins/qoder/skills/ralph-loop/references/components/system-prompt.md +0 -24
- package/plugins/qoder/skills/ralph-loop/references/components/tool-descriptions.md +0 -32
|
@@ -0,0 +1,338 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @test REQ-452 REQ-3
|
|
3
|
+
* @intent A baseline entry for a file that no longer exists must not be kept
|
|
4
|
+
* forever. Previously `classifyFiles` parsed `deleted` but nothing
|
|
5
|
+
* consumed it, so `.warnings-baseline.json` accumulated stale entries
|
|
6
|
+
* that let a re-created file inherit a bogus allowance.
|
|
7
|
+
* @covers AC-452-10, AC-452-11, AC-452-12, AC-452-13, AC-452-14
|
|
8
|
+
*
|
|
9
|
+
* The removal is deliberately DOUBLE-CONFIRMED: a path is only treated as
|
|
10
|
+
* deleted when the filesystem says it is absent AND git no longer tracks it.
|
|
11
|
+
* Either signal alone produces false positives -- a sparse checkout, a rebase
|
|
12
|
+
* mid-flight, a case-insensitive filesystem, or a symlink all make an existing
|
|
13
|
+
* tracked file look "missing".
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { execFileSync } from 'node:child_process';
|
|
17
|
+
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
18
|
+
import { tmpdir } from 'node:os';
|
|
19
|
+
import { join } from 'node:path';
|
|
20
|
+
import { afterEach, describe, expect, it } from 'vitest';
|
|
21
|
+
import { pruneBaselineEntries } from '../baseline-prune';
|
|
22
|
+
|
|
23
|
+
const tempDirs: string[] = [];
|
|
24
|
+
|
|
25
|
+
function makeTempDir(): string {
|
|
26
|
+
const dir = mkdtempSync(join(tmpdir(), 'xpgate-452-'));
|
|
27
|
+
tempDirs.push(dir);
|
|
28
|
+
return dir;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Create a git repo with one committed file, and return its root. */
|
|
32
|
+
function makeRepoWithCommit(): { root: string; tracked: string } {
|
|
33
|
+
const root = makeTempDir();
|
|
34
|
+
const tracked = 'tracked.ts';
|
|
35
|
+
const run = (args: string[]): void => {
|
|
36
|
+
try {
|
|
37
|
+
execFileSync('git', args, { cwd: root, stdio: 'ignore' });
|
|
38
|
+
} catch (error) {
|
|
39
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
40
|
+
throw new Error(`git ${args.join(' ')} failed in ${root}: ${detail}`);
|
|
41
|
+
}
|
|
42
|
+
};
|
|
43
|
+
try {
|
|
44
|
+
writeFileSync(join(root, tracked), 'export const a = 1;\n', 'utf8');
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
47
|
+
throw new Error(`could not seed repo file: ${detail}`);
|
|
48
|
+
}
|
|
49
|
+
run(['init', '-b', 'main']);
|
|
50
|
+
run(['config', 'user.email', 'test@example.com']);
|
|
51
|
+
run(['config', 'user.name', 'Test']);
|
|
52
|
+
// This fixture is scaffolding, not a real commit: the global XP-Gate hooks are
|
|
53
|
+
// installed on this machine and would otherwise block the seed commit
|
|
54
|
+
// (Gate 5a rejects an untested source file). `core.hooksPath` is set to a
|
|
55
|
+
// non-existent path rather than using --no-verify so the intent is explicit
|
|
56
|
+
// and cannot be mistaken for bypassing this repository's own gates.
|
|
57
|
+
run(['config', 'core.hooksPath', join(root, '.no-hooks')]);
|
|
58
|
+
run(['add', tracked]);
|
|
59
|
+
run(['commit', '-m', 'seed']);
|
|
60
|
+
return { root, tracked };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Stage many paths at once, to push `git ls-files` output past the 1 MiB default
|
|
65
|
+
* stdout buffer that `execFileSync` would otherwise apply.
|
|
66
|
+
*
|
|
67
|
+
* Uses `git update-index --index-info` to write the index directly rather than
|
|
68
|
+
* creating files and running `git add`: that path costs over 50s for this many
|
|
69
|
+
* entries (filesystem + index churn), whereas feeding the index in one batch takes
|
|
70
|
+
* under a second. The blob hash is git's well-known empty-blob id, so nothing needs
|
|
71
|
+
* to exist on disk -- which is fine, because the assertion is about `git ls-files`
|
|
72
|
+
* reporting the index, not about the working tree.
|
|
73
|
+
*/
|
|
74
|
+
function stageManyPaths(root: string, count: number, nameFor: (index: number) => string): void {
|
|
75
|
+
const EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391';
|
|
76
|
+
const lines: string[] = [];
|
|
77
|
+
for (let i = 0; i < count; i += 1) {
|
|
78
|
+
lines.push(`100644 ${EMPTY_BLOB}\t${nameFor(i)}`);
|
|
79
|
+
}
|
|
80
|
+
try {
|
|
81
|
+
execFileSync('git', ['update-index', '--index-info'], {
|
|
82
|
+
cwd: root,
|
|
83
|
+
input: `${lines.join('\n')}\n`,
|
|
84
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
85
|
+
});
|
|
86
|
+
} catch (error) {
|
|
87
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
88
|
+
throw new Error(`could not stage filler paths: ${detail}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* A baseline shaped like the real `.warnings-baseline.json` payload.
|
|
94
|
+
*
|
|
95
|
+
* The on-disk format is a FLAT path -> entry map, verified against the committed
|
|
96
|
+
* file; there is no `files` wrapper.
|
|
97
|
+
*/
|
|
98
|
+
function baselineWith(paths: string[]): string {
|
|
99
|
+
const files: Record<string, unknown> = {};
|
|
100
|
+
for (const path of paths) {
|
|
101
|
+
files[path] = { totalWarnings: 3, lastAnalyzed: '2026-10-03T00:00:00.000Z' };
|
|
102
|
+
}
|
|
103
|
+
return JSON.stringify(files);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Stage exact paths in the index without creating working-tree files.
|
|
108
|
+
*
|
|
109
|
+
* Index-only staging is what makes the case-sensitivity assertions portable:
|
|
110
|
+
* neither spelling exists on disk (so the `existsSync` half of the double-confirm
|
|
111
|
+
* is false everywhere), and the only signal left is the comparison against the
|
|
112
|
+
* git index -- which is exactly the behavior under test. The blob hash is git's
|
|
113
|
+
* well-known empty-blob id, so nothing needs to exist on disk.
|
|
114
|
+
*/
|
|
115
|
+
function stageIndexOnly(root: string, paths: string[]): void {
|
|
116
|
+
const EMPTY_BLOB = 'e69de29bb2d1d6434b8b29ae775ad8c2e48c5391';
|
|
117
|
+
const input = paths.map((path) => `100644 ${EMPTY_BLOB}\t${path}`).join('\n');
|
|
118
|
+
try {
|
|
119
|
+
execFileSync('git', ['update-index', '--index-info'], {
|
|
120
|
+
cwd: root,
|
|
121
|
+
input: `${input}\n`,
|
|
122
|
+
stdio: ['pipe', 'ignore', 'ignore'],
|
|
123
|
+
});
|
|
124
|
+
} catch (error) {
|
|
125
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
126
|
+
throw new Error(`could not stage index paths: ${detail}`);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
afterEach(() => {
|
|
131
|
+
for (const dir of tempDirs.splice(0)) {
|
|
132
|
+
try {
|
|
133
|
+
rmSync(dir, { recursive: true, force: true });
|
|
134
|
+
} catch {
|
|
135
|
+
/* best effort */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
describe('#452 REQ-3 stale baseline entries are pruned', () => {
|
|
141
|
+
it('AC-452-10: a path that is absent AND untracked is reported for removal', () => {
|
|
142
|
+
const { root } = makeRepoWithCommit();
|
|
143
|
+
const stale = 'gone-forever.ts';
|
|
144
|
+
|
|
145
|
+
const result = pruneBaselineEntries(baselineWith([stale]), root);
|
|
146
|
+
|
|
147
|
+
expect(result.removed).toContain(stale);
|
|
148
|
+
expect(result.kept).not.toContain(stale);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('AC-452-11: an existing tracked file is KEPT even when absent from disk', () => {
|
|
152
|
+
// Simulates a sparse checkout / mid-rebase / symlinked worktree: the file
|
|
153
|
+
// is gone from the working tree but git still tracks it. Removing the entry
|
|
154
|
+
// here would silently discard a legitimate allowance.
|
|
155
|
+
const { root, tracked } = makeRepoWithCommit();
|
|
156
|
+
rmSync(join(root, tracked));
|
|
157
|
+
|
|
158
|
+
const result = pruneBaselineEntries(baselineWith([tracked]), root);
|
|
159
|
+
|
|
160
|
+
expect(result.kept).toContain(tracked);
|
|
161
|
+
expect(result.removed).not.toContain(tracked);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it('AC-452-12: a file present on disk is kept', () => {
|
|
165
|
+
const { root, tracked } = makeRepoWithCommit();
|
|
166
|
+
|
|
167
|
+
const result = pruneBaselineEntries(baselineWith([tracked]), root);
|
|
168
|
+
|
|
169
|
+
expect(result.kept).toContain(tracked);
|
|
170
|
+
expect(result.removed).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it('AC-452-13: an untracked-but-present file is kept (git is not authoritative alone)', () => {
|
|
174
|
+
const { root } = makeRepoWithCommit();
|
|
175
|
+
const untracked = 'untracked.ts';
|
|
176
|
+
mkdirSync(root, { recursive: true });
|
|
177
|
+
try {
|
|
178
|
+
writeFileSync(join(root, untracked), 'export const b = 2;\n', 'utf8');
|
|
179
|
+
} catch (error) {
|
|
180
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
181
|
+
throw new Error(`could not write untracked file: ${detail}`);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const result = pruneBaselineEntries(baselineWith([untracked]), root);
|
|
185
|
+
|
|
186
|
+
expect(result.kept).toContain(untracked);
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('AC-452-10: pruning is non-destructive — it never mutates the input', () => {
|
|
190
|
+
const { root } = makeRepoWithCommit();
|
|
191
|
+
const input = baselineWith(['gone-forever.ts', 'also-gone.ts']);
|
|
192
|
+
|
|
193
|
+
pruneBaselineEntries(input, root);
|
|
194
|
+
|
|
195
|
+
// The caller decides when to persist; the helper must not rewrite in place.
|
|
196
|
+
const parsed = JSON.parse(input) as Record<string, unknown>;
|
|
197
|
+
expect(Object.keys(parsed)).toHaveLength(2);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
it('AC-452-11: every entry is preserved when git is unavailable', () => {
|
|
201
|
+
// A non-repo directory means `git ls-files` fails. Failing closed (keep
|
|
202
|
+
// everything) is safer than deleting entries we cannot confirm.
|
|
203
|
+
const dir = makeTempDir();
|
|
204
|
+
const paths = ['a.ts', 'b.ts'];
|
|
205
|
+
|
|
206
|
+
const result = pruneBaselineEntries(baselineWith(paths), dir);
|
|
207
|
+
|
|
208
|
+
expect(result.kept.sort()).toEqual(paths);
|
|
209
|
+
expect(result.removed).toHaveLength(0);
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
it('AC-452-13: an empty baseline yields empty kept/removed lists', () => {
|
|
213
|
+
const { root } = makeRepoWithCommit();
|
|
214
|
+
|
|
215
|
+
const result = pruneBaselineEntries(baselineWith([]), root);
|
|
216
|
+
|
|
217
|
+
expect(result.kept).toHaveLength(0);
|
|
218
|
+
expect(result.removed).toHaveLength(0);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('AC-452-12: a malformed baseline is preserved rather than silently emptied', () => {
|
|
222
|
+
// The reporter's `{}` came from this path. Keeping the entries and
|
|
223
|
+
// reporting an error is the fail-safe behaviour.
|
|
224
|
+
const { root } = makeRepoWithCommit();
|
|
225
|
+
|
|
226
|
+
const result = pruneBaselineEntries('{ not json', root);
|
|
227
|
+
|
|
228
|
+
expect(result.removed).toHaveLength(0);
|
|
229
|
+
expect(result.error).toBeDefined();
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('AC-452-10: pruning still works when the git index exceeds the default pipe buffer', () => {
|
|
233
|
+
// `execFileSync` defaults to a 1 MiB stdout buffer. A repo whose `git
|
|
234
|
+
// ls-files` output exceeds it throws, which `listTrackedFiles` would report
|
|
235
|
+
// as "git unavailable" -- silently disabling pruning at exactly the scale
|
|
236
|
+
// where a stale baseline is most likely. Committing enough paths to blow past
|
|
237
|
+
// 1 MiB proves the raised maxBuffer is actually in effect: without it every
|
|
238
|
+
// entry is merely "kept" and the stale path below would never be reported.
|
|
239
|
+
const { root } = makeRepoWithCommit();
|
|
240
|
+
// Measured: each path is ~48 bytes once `git ls-files` newline-joins it, so
|
|
241
|
+
// 1 MiB / 48 = ~21.8k entries. Use 30000 (~1.4 MiB) so the threshold is
|
|
242
|
+
// cleared with real headroom rather than marginally -- an earlier version used
|
|
243
|
+
// 19000, which came to 0.88 MiB and passed even with the buffer bug present.
|
|
244
|
+
stageManyPaths(root, 30000, (i) => `filler/s${i}/a-fairly-long-file-name-padding.ts`);
|
|
245
|
+
|
|
246
|
+
const result = pruneBaselineEntries(baselineWith(['deleted-long-ago.ts']), root);
|
|
247
|
+
|
|
248
|
+
expect(result.removed).toEqual(['deleted-long-ago.ts']);
|
|
249
|
+
expect(result.error).toBeUndefined();
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('AC-452-14: on a case-sensitive filesystem the index path is the canonical form', () => {
|
|
253
|
+
// Two tracked files whose names differ only by case are two DIFFERENT files on
|
|
254
|
+
// Linux. A baseline key that matches neither spelling exactly is a phantom:
|
|
255
|
+
// folding it to `case/alpha.ts` used to match the index and retain the entry
|
|
256
|
+
// forever -- the same stale-allowance bug this module exists to remove, since
|
|
257
|
+
// re-creating `case/ALPHA.ts` would inherit the retained allowance.
|
|
258
|
+
const { root } = makeRepoWithCommit();
|
|
259
|
+
stageIndexOnly(root, ['case/Alpha.ts', 'case/alpha.ts']);
|
|
260
|
+
|
|
261
|
+
const result = pruneBaselineEntries(
|
|
262
|
+
baselineWith(['case/ALPHA.ts']),
|
|
263
|
+
root,
|
|
264
|
+
{ caseInsensitivePaths: false },
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
expect(result.removed).toEqual(['case/ALPHA.ts']);
|
|
268
|
+
expect(result.kept).toHaveLength(0);
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it('AC-452-14: a case-sensitive filesystem does not conflate a differently-cased key with a tracked path', () => {
|
|
272
|
+
const { root } = makeRepoWithCommit();
|
|
273
|
+
stageIndexOnly(root, ['src/Tracked.ts']);
|
|
274
|
+
|
|
275
|
+
const result = pruneBaselineEntries(
|
|
276
|
+
baselineWith(['src/tracked.ts']),
|
|
277
|
+
root,
|
|
278
|
+
{ caseInsensitivePaths: false },
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
expect(result.removed).toEqual(['src/tracked.ts']);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('AC-452-14: an exact index match is kept on a case-sensitive filesystem', () => {
|
|
285
|
+
// Guards against the opposite regression: dropping the fold must not turn a
|
|
286
|
+
// legitimately tracked path into a prunable one.
|
|
287
|
+
const { root } = makeRepoWithCommit();
|
|
288
|
+
stageIndexOnly(root, ['case/alpha.ts']);
|
|
289
|
+
|
|
290
|
+
const result = pruneBaselineEntries(
|
|
291
|
+
baselineWith(['case/alpha.ts']),
|
|
292
|
+
root,
|
|
293
|
+
{ caseInsensitivePaths: false },
|
|
294
|
+
);
|
|
295
|
+
|
|
296
|
+
expect(result.kept).toContain('case/alpha.ts');
|
|
297
|
+
expect(result.removed).toHaveLength(0);
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
it('AC-452-14: a case-insensitive filesystem keeps the retain-bias for a differently-cased key', () => {
|
|
301
|
+
// On Windows (and macOS default volumes) `src/tracked.ts` and `src/Tracked.ts`
|
|
302
|
+
// are the same file, and the key can be written by a tool that reported a
|
|
303
|
+
// different casing than the index. Losing that allowance in a sparse checkout
|
|
304
|
+
// -- when `existsSync` cannot confirm either spelling -- is the failure mode
|
|
305
|
+
// the original fold was introduced to prevent, so case-folding stays enabled
|
|
306
|
+
// exactly where the filesystem actually folds.
|
|
307
|
+
const { root } = makeRepoWithCommit();
|
|
308
|
+
stageIndexOnly(root, ['src/Tracked.ts']);
|
|
309
|
+
|
|
310
|
+
const result = pruneBaselineEntries(
|
|
311
|
+
baselineWith(['src/tracked.ts']),
|
|
312
|
+
root,
|
|
313
|
+
{ caseInsensitivePaths: true },
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
expect(result.kept).toContain('src/tracked.ts');
|
|
317
|
+
expect(result.removed).toHaveLength(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
it('AC-452-14: the default follows the host filesystem, not a hardcoded assumption', () => {
|
|
321
|
+
// This repository's own tests must stay portable: Linux CI and this Windows
|
|
322
|
+
// checkout have to disagree here, because the correct answer IS platform
|
|
323
|
+
// dependent. The assertion pins the wiring (default = detection) rather than a
|
|
324
|
+
// single outcome.
|
|
325
|
+
const { root } = makeRepoWithCommit();
|
|
326
|
+
stageIndexOnly(root, ['src/Tracked.ts']);
|
|
327
|
+
|
|
328
|
+
const result = pruneBaselineEntries(baselineWith(['src/tracked.ts']), root);
|
|
329
|
+
const folded = process.platform === 'win32' || process.platform === 'darwin';
|
|
330
|
+
|
|
331
|
+
if (folded) {
|
|
332
|
+
expect(result.kept).toContain('src/tracked.ts');
|
|
333
|
+
expect(result.removed).toHaveLength(0);
|
|
334
|
+
} else {
|
|
335
|
+
expect(result.removed).toEqual(['src/tracked.ts']);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
});
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('fs/promises', async (importOriginal) => {
|
|
4
|
+
const actual = await importOriginal<typeof import('fs/promises')>();
|
|
5
|
+
return {
|
|
6
|
+
...actual,
|
|
7
|
+
readFile: vi.fn(),
|
|
8
|
+
access: vi.fn(),
|
|
9
|
+
writeFile: vi.fn(),
|
|
10
|
+
};
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
vi.mock('../analyzer', () => ({
|
|
14
|
+
analyze: vi.fn(),
|
|
15
|
+
getAdapterForFile: vi.fn(),
|
|
16
|
+
}));
|
|
17
|
+
|
|
18
|
+
vi.mock('../index', () => ({
|
|
19
|
+
getAllRules: vi.fn(() => []),
|
|
20
|
+
}));
|
|
21
|
+
|
|
22
|
+
import { writeFile, readFile } from 'fs/promises';
|
|
23
|
+
import { initBaselineCommand } from '../boy-scout';
|
|
24
|
+
import { analyze } from '../analyzer';
|
|
25
|
+
|
|
26
|
+
const mockAnalyze = vi.mocked(analyze);
|
|
27
|
+
const mockWriteFile = vi.mocked(writeFile);
|
|
28
|
+
const mockReadFile = vi.mocked(readFile);
|
|
29
|
+
|
|
30
|
+
function violationsFor(counts: Record<string, number>) {
|
|
31
|
+
const violations = [];
|
|
32
|
+
for (const [file, n] of Object.entries(counts)) {
|
|
33
|
+
for (let i = 0; i < n; i++) {
|
|
34
|
+
violations.push({
|
|
35
|
+
file,
|
|
36
|
+
line: i + 1,
|
|
37
|
+
ruleId: 'clean-code.magic-numbers',
|
|
38
|
+
message: 'm',
|
|
39
|
+
severity: 'warning' as const,
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return { violations, summary: {}, fileResults: {}, ruleResults: {}, executionTimeMs: 0, errors: [] };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A realistic ENOENT. `loadBaseline` distinguishes "no file yet" from every other
|
|
48
|
+
* I/O failure by `error.code`, so a mock that only says `new Error('ENOENT')`
|
|
49
|
+
* (no `code`) is not a real ENOENT and would be treated as unreadable (#455).
|
|
50
|
+
*/
|
|
51
|
+
function enoent(): NodeJS.ErrnoException {
|
|
52
|
+
return Object.assign(new Error('ENOENT: no such file or directory'), { code: 'ENOENT' });
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @test REQ-DSH-014
|
|
57
|
+
* @intent 验证 `--init-baseline` 是**合并**语义而非覆盖:既有基线条目必须保留,
|
|
58
|
+
* 且写入路径必须尊重 `--baseline` 参数,不得硬编码 .warnings-baseline.json
|
|
59
|
+
* (#445 —— 实测该命令会把已跟踪的基线摧毁成 {})
|
|
60
|
+
* @covers AC-DSH-014-01
|
|
61
|
+
*/
|
|
62
|
+
describe('initBaselineCommand must not destroy existing baselines (#445)', () => {
|
|
63
|
+
beforeEach(() => {
|
|
64
|
+
vi.clearAllMocks();
|
|
65
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('merges into the existing baseline instead of replacing it', async () => {
|
|
69
|
+
// Existing tracked baseline already has an entry for an unrelated file.
|
|
70
|
+
const existing = {
|
|
71
|
+
'src/legacy.ts': { totalWarnings: 3, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
|
|
72
|
+
};
|
|
73
|
+
mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
|
|
74
|
+
mockAnalyze.mockResolvedValue(violationsFor({ 'src/new.ts': 2 }) as never);
|
|
75
|
+
|
|
76
|
+
await initBaselineCommand(['src/new.ts'], '.warnings-baseline.json');
|
|
77
|
+
|
|
78
|
+
expect(mockWriteFile).toHaveBeenCalledTimes(1);
|
|
79
|
+
const [, writtenRaw] = mockWriteFile.mock.calls[0];
|
|
80
|
+
const written = JSON.parse(String(writtenRaw));
|
|
81
|
+
|
|
82
|
+
// The pre-existing entry MUST survive.
|
|
83
|
+
expect(written['src/legacy.ts']).toBeDefined();
|
|
84
|
+
expect(written['src/legacy.ts'].totalWarnings).toBe(3);
|
|
85
|
+
// And the newly analysed file must be added.
|
|
86
|
+
expect(written['src/new.ts']).toBeDefined();
|
|
87
|
+
expect(written['src/new.ts'].totalWarnings).toBe(2);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('writes to the path given by --baseline, not the hardcoded default', async () => {
|
|
91
|
+
mockReadFile.mockRejectedValue(enoent() as never);
|
|
92
|
+
mockAnalyze.mockResolvedValue(violationsFor({ 'src/a.ts': 1 }) as never);
|
|
93
|
+
|
|
94
|
+
await initBaselineCommand(['src/a.ts'], 'custom/baseline.json');
|
|
95
|
+
|
|
96
|
+
const [targetPath] = mockWriteFile.mock.calls[0];
|
|
97
|
+
expect(String(targetPath)).toBe('custom/baseline.json');
|
|
98
|
+
expect(String(targetPath)).not.toBe('.warnings-baseline.json');
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('does not persist an empty object when analysis yields no warnings', async () => {
|
|
102
|
+
const existing = {
|
|
103
|
+
'src/legacy.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
|
|
104
|
+
};
|
|
105
|
+
mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
|
|
106
|
+
// File now has zero warnings.
|
|
107
|
+
mockAnalyze.mockResolvedValue(violationsFor({}) as never);
|
|
108
|
+
|
|
109
|
+
await initBaselineCommand(['src/clean.ts'], '.warnings-baseline.json');
|
|
110
|
+
|
|
111
|
+
const [, writtenRaw] = mockWriteFile.mock.calls[0];
|
|
112
|
+
const written = JSON.parse(String(writtenRaw));
|
|
113
|
+
// Must NOT be {} -- the legacy entry has to survive.
|
|
114
|
+
expect(Object.keys(written)).toContain('src/legacy.ts');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('reports which entries were added and which were preserved', async () => {
|
|
118
|
+
const existing = {
|
|
119
|
+
'src/legacy.ts': { totalWarnings: 3, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
|
|
120
|
+
};
|
|
121
|
+
mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
|
|
122
|
+
mockAnalyze.mockResolvedValue(violationsFor({ 'src/new.ts': 1 }) as never);
|
|
123
|
+
const log = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
124
|
+
|
|
125
|
+
await initBaselineCommand(['src/new.ts'], '.warnings-baseline.json');
|
|
126
|
+
|
|
127
|
+
const output = log.mock.calls.map((c) => c.join(' ')).join('\n');
|
|
128
|
+
expect(output).toMatch(/src\/legacy\.ts/);
|
|
129
|
+
expect(output).toMatch(/src\/new\.ts/);
|
|
130
|
+
log.mockRestore();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it('signals outcome via its return value, never process.exitCode', async () => {
|
|
134
|
+
// Delphi Round 1: three experts independently flagged that this command
|
|
135
|
+
// reported failure by mutating global exit state, so an unrelated
|
|
136
|
+
// non-zero process.exitCode would be misread as this command failing.
|
|
137
|
+
mockReadFile.mockRejectedValue(enoent() as never);
|
|
138
|
+
mockAnalyze.mockResolvedValue(violationsFor({ 'src/a.ts': 1 }) as never);
|
|
139
|
+
const previousExitCode = process.exitCode;
|
|
140
|
+
process.exitCode = 0;
|
|
141
|
+
|
|
142
|
+
const result = await initBaselineCommand(['src/a.ts'], '.warnings-baseline.json');
|
|
143
|
+
|
|
144
|
+
expect(result.ok).toBe(true);
|
|
145
|
+
// Global state must be untouched by the command.
|
|
146
|
+
expect(process.exitCode).toBe(0);
|
|
147
|
+
|
|
148
|
+
process.exitCode = previousExitCode;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('can never empty a non-empty baseline, even when analysis finds nothing', async () => {
|
|
152
|
+
// The merge starts from the existing entries, so the destructive overwrite
|
|
153
|
+
// from #445 is structurally impossible rather than merely guarded.
|
|
154
|
+
const existing = {
|
|
155
|
+
'src/legacy.ts': { totalWarnings: 1, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
|
|
156
|
+
};
|
|
157
|
+
mockReadFile.mockResolvedValue(JSON.stringify(existing) as never);
|
|
158
|
+
mockAnalyze.mockResolvedValue(violationsFor({}) as never);
|
|
159
|
+
|
|
160
|
+
const result = await initBaselineCommand(['src/clean.ts'], '.warnings-baseline.json');
|
|
161
|
+
|
|
162
|
+
expect(result.ok).toBe(true);
|
|
163
|
+
const [, writtenRaw] = mockWriteFile.mock.calls[0];
|
|
164
|
+
const written = JSON.parse(String(writtenRaw));
|
|
165
|
+
expect(Object.keys(written)).toContain('src/legacy.ts');
|
|
166
|
+
});
|
|
167
|
+
});
|
|
@@ -2,6 +2,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
|
2
2
|
import { exec } from 'child_process';
|
|
3
3
|
import { promisify } from 'util';
|
|
4
4
|
import * as path from 'path';
|
|
5
|
+
import { tmpdir } from 'os';
|
|
6
|
+
import { mkdtempSync, readFileSync, writeFileSync } from 'node:fs';
|
|
5
7
|
|
|
6
8
|
const execAsync = promisify(exec);
|
|
7
9
|
|
|
@@ -147,6 +149,51 @@ describe('Boy Scout Rule - runEnforcement', () => {
|
|
|
147
149
|
expect(vi.mocked(mockWriteFile).mock.calls[0][0]).toBe('/tmp/test-baseline.json');
|
|
148
150
|
});
|
|
149
151
|
|
|
152
|
+
// #452: the auto-init path inside runEnforcement once wrote only the current
|
|
153
|
+
// commit's files, silently dropping entries for untouched files -- which then
|
|
154
|
+
// made Gate 6 BLOCK files the commit never touched. The explicit
|
|
155
|
+
// init-baseline command got merge semantics (#445); these tests pin the same
|
|
156
|
+
// guarantee on the automatic path.
|
|
157
|
+
it('REQ-452: auto-init preserves entries for files not in the current commit', async () => {
|
|
158
|
+
// 6 warnings so the entry survives the "small baselines must clear" rule;
|
|
159
|
+
// a clean file gains no entry by design (nothing to budget).
|
|
160
|
+
const warnings = Array.from({ length: 6 }, (_, i) => ({
|
|
161
|
+
file: 'plugins/dsh/src/command.ts', line: i + 1, ruleId: 'test', severity: 'warning' as const, message: 'm',
|
|
162
|
+
}));
|
|
163
|
+
mockAnalyze.mockResolvedValue(fakeAnalysis(warnings));
|
|
164
|
+
mockAccess.mockResolvedValue(undefined);
|
|
165
|
+
mockReadFile.mockResolvedValue(
|
|
166
|
+
JSON.stringify({ 'src/npm-package/lib/test-alignment.ts': { totalWarnings: 1, lastAnalyzed: '2024-01-01' } })
|
|
167
|
+
);
|
|
168
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
169
|
+
|
|
170
|
+
const result = await runEnforcement([], ['plugins/dsh/src/command.ts'], '/tmp/test-baseline.json');
|
|
171
|
+
|
|
172
|
+
expect(result.overallStatus).toBe('PASS');
|
|
173
|
+
expect(mockWriteFile).toHaveBeenCalled();
|
|
174
|
+
const saved = JSON.parse(vi.mocked(mockWriteFile).mock.calls[0][1] as string) as Record<string, BaselineEntry>;
|
|
175
|
+
expect(saved['src/npm-package/lib/test-alignment.ts']).toEqual({ totalWarnings: 1, lastAnalyzed: '2024-01-01' });
|
|
176
|
+
expect(saved['plugins/dsh/src/command.ts']?.totalWarnings).toBe(6);
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it('REQ-452: auto-init is idempotent -- a second run over the same files writes the same entries', async () => {
|
|
180
|
+
mockAnalyze.mockResolvedValue(emptyAnalysis());
|
|
181
|
+
mockAccess.mockResolvedValue(undefined);
|
|
182
|
+
mockWriteFile.mockResolvedValue(undefined);
|
|
183
|
+
const first = JSON.stringify({});
|
|
184
|
+
|
|
185
|
+
mockReadFile.mockResolvedValue(first);
|
|
186
|
+
await runEnforcement([], ['src/a.ts'], '/tmp/test-baseline.json');
|
|
187
|
+
const savedOnce = vi.mocked(mockWriteFile).mock.calls.at(-1)![1] as string;
|
|
188
|
+
|
|
189
|
+
mockReadFile.mockResolvedValue(savedOnce);
|
|
190
|
+
mockWriteFile.mockClear();
|
|
191
|
+
await runEnforcement([], ['src/a.ts'], '/tmp/test-baseline.json');
|
|
192
|
+
const savedTwice = vi.mocked(mockWriteFile).mock.calls.at(-1)![1] as string;
|
|
193
|
+
|
|
194
|
+
expect(JSON.parse(savedOnce)).toEqual(JSON.parse(savedTwice));
|
|
195
|
+
});
|
|
196
|
+
|
|
150
197
|
it('blocks modified file with <=5 baseline warnings that are not cleared', async () => {
|
|
151
198
|
mockAnalyze.mockResolvedValue(
|
|
152
199
|
fakeAnalysis([{ file: 'src/mod.ts', line: 1, ruleId: 'test', severity: 'warning' as const, message: 'm' }])
|
|
@@ -248,12 +295,56 @@ describe('CLI integration', () => {
|
|
|
248
295
|
expect(stdout).toContain('Usage: boy-scout');
|
|
249
296
|
}, CLI_TEST_TIMEOUT);
|
|
250
297
|
|
|
251
|
-
it('runs init-baseline via CLI', async () => {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
298
|
+
it('runs init-baseline via CLI against an isolated baseline', async () => {
|
|
299
|
+
try {
|
|
300
|
+
// Never point --baseline at the repo's tracked .warnings-baseline.json: this
|
|
301
|
+
// test must not mutate tracked state. Use a throwaway path instead (#445).
|
|
302
|
+
// NOTE: this file mocks 'fs/promises' (which also covers 'node:fs/promises'),
|
|
303
|
+
// so use node:fs sync APIs -- they are not intercepted.
|
|
304
|
+
const tmpBaseline = path.join(mkdtempSync(path.join(tmpdir(), 'bs-init-')), 'baseline.json');
|
|
305
|
+
const { stdout } = await execAsync(
|
|
306
|
+
`npx tsx ${BOY_SCOUT_PATH} --init-baseline src/principles/boy-scout.ts --baseline "${tmpBaseline}"`,
|
|
307
|
+
{ timeout: 30000 }
|
|
308
|
+
);
|
|
309
|
+
// The command now reports a merge summary instead of the old (misleading)
|
|
310
|
+
// "Baseline initialized successfully" — it updates rather than replaces.
|
|
311
|
+
expect(stdout).toContain('Baseline updated');
|
|
312
|
+
expect(stdout).toContain(tmpBaseline);
|
|
313
|
+
expect(typeof readFileSync(tmpBaseline, 'utf-8')).toBe('string');
|
|
314
|
+
} catch (error) {
|
|
315
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
316
|
+
}
|
|
317
|
+
}, CLI_TEST_TIMEOUT);
|
|
318
|
+
|
|
319
|
+
/**
|
|
320
|
+
* @test REQ-DSH-014
|
|
321
|
+
* @intent 验证 CLI 的 --init-baseline 是合并语义:既有条目不因初始化无关文件而丢失
|
|
322
|
+
* (#445 实测会把已跟踪基线摧毁成 {})
|
|
323
|
+
* @covers AC-DSH-014-01
|
|
324
|
+
*/
|
|
325
|
+
it('init-baseline preserves pre-existing entries (merge, not replace)', async () => {
|
|
326
|
+
try {
|
|
327
|
+
const dir = mkdtempSync(path.join(tmpdir(), 'bs-merge-'));
|
|
328
|
+
const tmpBaseline = path.join(dir, 'baseline.json');
|
|
329
|
+
writeFileSync(
|
|
330
|
+
tmpBaseline,
|
|
331
|
+
JSON.stringify({
|
|
332
|
+
'src/legacy.ts': { totalWarnings: 3, lastAnalyzed: '2026-01-01T00:00:00.000Z' },
|
|
333
|
+
}),
|
|
334
|
+
'utf-8'
|
|
335
|
+
);
|
|
336
|
+
|
|
337
|
+
await execAsync(
|
|
338
|
+
`npx tsx ${BOY_SCOUT_PATH} --init-baseline src/principles/boy-scout.ts --baseline "${tmpBaseline}"`,
|
|
339
|
+
{ timeout: 30000 }
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const written = JSON.parse(readFileSync(tmpBaseline, 'utf-8'));
|
|
343
|
+
expect(written['src/legacy.ts']).toBeDefined();
|
|
344
|
+
expect(written['src/legacy.ts'].totalWarnings).toBe(3);
|
|
345
|
+
} catch (error) {
|
|
346
|
+
throw error instanceof Error ? error : new Error(String(error));
|
|
347
|
+
}
|
|
257
348
|
}, CLI_TEST_TIMEOUT);
|
|
258
349
|
|
|
259
350
|
it('runs enforcement via CLI with empty files', async () => {
|