@boyingliu01/xp-gate 0.35.5 → 0.35.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/bin/xp-gate.js +61 -1
  2. package/hooks/lib/assertion-quality.sh +47 -0
  3. package/hooks/pre-commit +54 -8
  4. package/lib/__tests__/assertion-debt.test.js +99 -0
  5. package/lib/__tests__/check-single-file.test.js +63 -0
  6. package/lib/__tests__/doctor-brief.test.js +59 -0
  7. package/lib/__tests__/gate-rules.test.js +58 -0
  8. package/lib/assertion-debt.js +173 -0
  9. package/lib/doctor-brief.js +159 -0
  10. package/lib/doctor.js +23 -3
  11. package/lib/gate-rules.js +149 -0
  12. package/lib/gate-runner.js +49 -3
  13. package/lib/mattpocock-license-verdict.json +1 -1
  14. package/mock-policy/AGENTS.md +2 -2
  15. package/mutation/AGENTS.md +2 -2
  16. package/package.json +1 -1
  17. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  18. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  19. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  20. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +14 -1
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  25. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +14 -1
  26. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  27. package/plugins/opencode/package.json +1 -1
  28. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +14 -1
  31. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  32. package/plugins/qoder/plugin.json +1 -1
  33. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/principles/AGENTS.md +2 -2
  37. package/skills/delphi-review/AGENTS.md +2 -2
  38. package/skills/sprint-flow/AGENTS.md +2 -2
  39. package/skills/sprint-flow/references/phase-3-build.md +14 -1
  40. package/skills/test-specification-alignment/AGENTS.md +2 -2
package/bin/xp-gate.js CHANGED
@@ -277,7 +277,7 @@ const COMMANDS = {
277
277
  }
278
278
  return doctor(subargs).then(code => process.exit(code));
279
279
  },
280
- usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force] | --refresh-env] | --json | --format json (the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks; --refresh-env is read-only so it combines with them)'
280
+ usage: 'xp-gate doctor [--fix [--install-tools] | --sync-hooks [--force] | --refresh-env | --brief] | --json | --format json (--brief is a read-only one-shot fact dump and refuses --fix/--sync-hooks/--install-tools/--force; the two machine-readable modes print one JSON document and refuse --fix/--sync-hooks; --refresh-env is read-only so it combines with them)'
281
281
  },
282
282
  'ui-review': {
283
283
  description: 'Run UI review for non-sprint developers (generates .ui-gate-result.json)',
@@ -365,6 +365,21 @@ const COMMANDS = {
365
365
  },
366
366
  usage: 'xp-gate gate-coverage [--json] [--debt] [--strict] [--branch <name>]'
367
367
  },
368
+ 'gate-rules': {
369
+ description: 'Print the gate limits that can block a commit, generated from the files that define them (#652)',
370
+ run: subargs => {
371
+ const { collectGateRules, renderGateRules } = require('../lib/gate-rules.js');
372
+ const rules = collectGateRules({ cwd: process.cwd() });
373
+ if (subargs.includes('--json')) {
374
+ process.stdout.write(`${JSON.stringify(rules, null, 2)}\n`);
375
+ return;
376
+ }
377
+ // Generated, never hand-copied: a checklist that drifts from the config it
378
+ // describes teaches the wrong rule and then contradicts it at commit time.
379
+ process.stdout.write(`${renderGateRules(rules)}\n`);
380
+ },
381
+ usage: 'xp-gate gate-rules [--json]'
382
+ },
368
383
  'rework': {
369
384
  description: 'Report rework: first-pass rate, rounds before landing, top blocking reasons (#651)',
370
385
  run: subargs => {
@@ -673,15 +688,60 @@ function handleAudit(args) {
673
688
  return;
674
689
  }
675
690
 
691
+ // #668: pre-existing assertion debt, so it can be cleared in batches instead of
692
+ // being discovered one unrelated commit at a time.
693
+ if (args[0] === 'assertions') {
694
+ handleAuditAssertions(args);
695
+ return;
696
+ }
697
+
676
698
  console.error('Error: Unknown audit subcommand or missing flag');
677
699
  console.error('Usage:');
678
700
  console.error(' xp-gate audit record --gate-id X --gate-name Y --passed true/false --issues-found N --duration-ms N --trigger commit');
679
701
  console.error(' xp-gate audit --tail [N] (default: 20)');
680
702
  console.error(' xp-gate audit --stats (per-gate stats + anomaly summary)');
681
703
  console.error(' xp-gate audit --check (exit 1 if the ledger has anomalies)');
704
+ console.error(' xp-gate audit assertions (list pre-existing assertion debt across the repo; --json)');
682
705
  process.exit(1);
683
706
  }
684
707
 
708
+ /**
709
+ * `xp-gate audit assertions` (#668) — list pre-existing vacuous assertions.
710
+ *
711
+ * Gate 5d only inspects files in the diff, so standing debt surfaces during
712
+ * unrelated commits and reads as if that change introduced it. This makes the debt
713
+ * a work queue instead of a series of false accusations.
714
+ *
715
+ * Advisory: always exits 0. A debt list that fails builds becomes a number people
716
+ * learn to ignore.
717
+ */
718
+ function handleAuditAssertions(args) {
719
+ const { scanForAssertionDebt, summarizeDebt } = require(path.join(__dirname, '..', 'lib', 'assertion-debt.js'));
720
+ const results = scanForAssertionDebt({ cwd: process.cwd() });
721
+ const summary = summarizeDebt(results);
722
+ if (args.includes('--json')) {
723
+ process.stdout.write(`${JSON.stringify({ summary, files: results }, null, 2)}\n`);
724
+ return;
725
+ }
726
+ if (summary.total === 0) {
727
+ console.log('✅ No pre-existing assertion debt found.');
728
+ return;
729
+ }
730
+ console.log(`Found ${summary.total} pre-existing assertion issue(s) in ${summary.files} file(s):`);
731
+ console.log(` tautological ${summary.byKind.tautological}`);
732
+ console.log(` swallowed-error ${summary.byKind['swallowed-error']}`);
733
+ console.log('');
734
+ for (const entry of results) {
735
+ console.log(`${entry.file} (${entry.findings.length})`);
736
+ for (const finding of entry.findings) {
737
+ console.log(` ${finding.line}: [${finding.kind}] ${finding.text}`);
738
+ }
739
+ }
740
+ console.log('');
741
+ console.log('These predate the current diff and would be reported as "introduced by this change"');
742
+ console.log('if the file is touched. See skills/test-driven-development/testing-anti-patterns.md');
743
+ }
744
+
685
745
  function printAuditTable(entries) {
686
746
  console.log('');
687
747
  console.log('Recent Audit Entries:');
@@ -178,6 +178,53 @@ classify_swallowed_errors() {
178
178
  grep -nE 'catch([[:space:]]*\([^)]*\))?[[:space:]]*\{[[:space:]]*\}' || true
179
179
  }
180
180
 
181
+ # #668: is the flagged line part of THIS change, or merely carried in by the file?
182
+ #
183
+ # Gate 5d runs over whole files, so a file that was edited anywhere gets all of its
184
+ # assertions re-examined. Pre-existing debt then blocks an unrelated commit and
185
+ # reads as "this change introduced it". The author who fixed `writeReport()`'s error
186
+ # handling was stopped by four `expect(true).toBe(true)` lines that had been there
187
+ # for months.
188
+ #
189
+ # The cost is not the one false block. It is that the noise teaches people to add
190
+ # `@assertion-justified` rather than fix the test -- a gate that trains people to
191
+ # annotate past it is worse than no gate.
192
+ #
193
+ # Args: $1 = file path, $2 = line number, $3 = added-line ranges ("start-end start-end")
194
+ # Returns 0 when the line is inside an added hunk (i.e. genuinely introduced here).
195
+ assertion_line_is_new() {
196
+ local _aq_file="$1" _aq_line="$2" _aq_ranges="$3"
197
+ local _aq_range _aq_start _aq_end
198
+ for _aq_range in $_aq_ranges; do
199
+ _aq_start="${_aq_range%%-*}"
200
+ _aq_end="${_aq_range##*-}"
201
+ if [ "$_aq_line" -ge "$_aq_start" ] 2>/dev/null && [ "$_aq_line" -le "$_aq_end" ] 2>/dev/null; then
202
+ return 0
203
+ fi
204
+ done
205
+ return 1
206
+ }
207
+
208
+ # Emit "start-end" for each added hunk in the staged diff of $1.
209
+ # Empty output means the file has no added lines at all, in which case every finding
210
+ # is pre-existing by construction.
211
+ staged_added_line_ranges() {
212
+ local _aq_file="$1"
213
+ git diff --cached -U0 -- "$_aq_file" 2>/dev/null \
214
+ | awk '
215
+ /^@@/ {
216
+ # @@ -a,b +c,d @@ -- the +side is the new-file line numbering.
217
+ if (match($0, /\+[0-9]+(,[0-9]+)?/)) {
218
+ spec = substr($0, RSTART + 1, RLENGTH - 1)
219
+ n = split(spec, parts, ",")
220
+ start = parts[1] + 0
221
+ count = (n > 1) ? parts[2] + 0 : 1
222
+ if (count > 0) printf "%d-%d ", start, start + count - 1
223
+ }
224
+ }
225
+ ' || true
226
+ }
227
+
181
228
  # Anti-patterns 3-5 (WARNING tier): weak-only assertions, mock-echo assertions, and
182
229
  # call-order over-verification. These can be legitimate, so each is reported but
183
230
  # never blocks.
package/hooks/pre-commit CHANGED
@@ -2395,17 +2395,63 @@ else
2395
2395
  _tauto=$(printf '%s\n' "$_tf_content" | classify_tautological_assertions || true)
2396
2396
  _swallow=$(printf '%s\n' "$_tf_content" | classify_swallowed_errors || true)
2397
2397
  _weak=$(printf '%s\n' "$_tf_content" | classify_weak_assertions || true)
2398
+ # #668: split the findings into "added by this change" and
2399
+ # "pre-existing". Gate 5d examines whole files, so editing a file
2400
+ # anywhere re-examines all of its assertions; presenting standing debt
2401
+ # under an unrelated commit is a false attribution, and false
2402
+ # attribution is what drives people to @assertion-justified instead of
2403
+ # a real fix.
2404
+ _new_ranges=$(staged_added_line_ranges "$_tf" 2>/dev/null || true)
2398
2405
  if [ -n "$_tauto" ]; then
2399
- echo "❌ BLOCKED - tautological assertion(s) in $_tf (cannot fail):"
2400
- printf '%s\n' "$_tauto" | sed 's/^/ /'
2401
- echo " See skills/test-driven-development/testing-anti-patterns.md"
2402
- GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2406
+ _tauto_new=""
2407
+ _tauto_old=""
2408
+ while IFS= read -r _tline; do
2409
+ [ -n "$_tline" ] || continue
2410
+ _tnum="${_tline%%:*}"
2411
+ if assertion_line_is_new "$_tf" "$_tnum" "$_new_ranges"; then
2412
+ _tauto_new="${_tauto_new}${_tline}"$'\n'
2413
+ else
2414
+ _tauto_old="${_tauto_old}${_tline}"$'\n'
2415
+ fi
2416
+ done <<< "$_tauto"
2417
+ if [ -n "$_tauto_new" ]; then
2418
+ echo "❌ BLOCKED - tautological assertion(s) added by this change in $_tf (cannot fail):"
2419
+ printf '%s' "$_tauto_new" | sed 's/^/ /'
2420
+ echo " See skills/test-driven-development/testing-anti-patterns.md"
2421
+ GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2422
+ fi
2423
+ if [ -n "$_tauto_old" ]; then
2424
+ echo "⚠️ WARN - PRE-EXISTING tautological assertion(s) in $_tf (NOT introduced by this change):"
2425
+ printf '%s' "$_tauto_old" | sed 's/^/ /'
2426
+ echo " These predate this commit. Two options:"
2427
+ echo " fix them now (see 'xp-gate audit assertions'), or"
2428
+ echo " leave them - they are not blocking you."
2429
+ fi
2403
2430
  fi
2404
2431
  if [ -n "$_swallow" ]; then
2405
- echo "❌ BLOCKED - empty catch in $_tf swallows the failure it guards:"
2406
- printf '%s\n' "$_swallow" | sed 's/^/ /'
2407
- echo " See skills/test-driven-development/testing-anti-patterns.md"
2408
- GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2432
+ # Same #668 split as the tautology branch above.
2433
+ _sw_new=""
2434
+ _sw_old=""
2435
+ while IFS= read -r _sline; do
2436
+ [ -n "$_sline" ] || continue
2437
+ _snum="${_sline%%:*}"
2438
+ if assertion_line_is_new "$_tf" "$_snum" "$_new_ranges"; then
2439
+ _sw_new="${_sw_new}${_sline}"$'\n'
2440
+ else
2441
+ _sw_old="${_sw_old}${_sline}"$'\n'
2442
+ fi
2443
+ done <<< "$_swallow"
2444
+ if [ -n "$_sw_new" ]; then
2445
+ echo "❌ BLOCKED - empty catch added by this change in $_tf swallows the failure it guards:"
2446
+ printf '%s' "$_sw_new" | sed 's/^/ /'
2447
+ echo " See skills/test-driven-development/testing-anti-patterns.md"
2448
+ GATE_5_FOCUS_BLOCKED=$((GATE_5_FOCUS_BLOCKED + 1))
2449
+ fi
2450
+ if [ -n "$_sw_old" ]; then
2451
+ echo "⚠️ WARN - PRE-EXISTING empty catch(es) in $_tf (NOT introduced by this change):"
2452
+ printf '%s' "$_sw_old" | sed 's/^/ /'
2453
+ echo " These predate this commit; see 'xp-gate audit assertions'."
2454
+ fi
2409
2455
  fi
2410
2456
  if [ -n "$_weak" ]; then
2411
2457
  echo "⚠️ WARN - test block(s) in $_tf assert only weak properties:"
@@ -0,0 +1,99 @@
1
+ /**
2
+ * @test REQ-668 `xp-gate audit assertions` 必须让存量断言债务可见
3
+ * @intent Gate 5d 只检查 **diff 范围内** 的文件。这是有意的——全仓扫描每次提交都跑
4
+ * 太慢——但它有个昂贵的副作用:存量债务会在 **无关改动** 时突然阻断,
5
+ * 制造错误的因果归因。原作者改了 `writeReport()` 的错误处理,却被
6
+ * `test-alignment.test.ts` 里 4 处既有的 `expect(true).toBe(true)` 拦下。
7
+ *
8
+ * 这个副作用最坏的后果不是那一次误阻断,而是它改变修复者的行为:归因噪音
9
+ * 让人以为"先绕过再说",于是倾向加 `@assertion-justified` 而不是真修掉。
10
+ * 一个让人更愿意豁免的门禁,比没有门禁更糟。
11
+ *
12
+ * 所以 `audit assertions` 的价值在于把"存量"变成可批处理的工作队列,
13
+ * 而不是让人在无关提交里逐个撞见。
14
+ * @covers AC-668-01, AC-668-02, AC-668-03
15
+ */
16
+ 'use strict';
17
+
18
+ const { scanForAssertionDebt, summarizeDebt } = require('../assertion-debt');
19
+
20
+ describe('xp-gate audit assertions (#668)', () => {
21
+ it('AC-668-01: 列出全仓存量问题,按文件分组', () => {
22
+ const results = scanForAssertionDebt({ cwd: process.cwd() });
23
+ expect(Array.isArray(results)).toBe(true);
24
+ // Grouped by file so a batch fixer can work one file at a time.
25
+ for (const entry of results) {
26
+ expect(typeof entry.file).toBe('string');
27
+ expect(Array.isArray(entry.findings)).toBe(true);
28
+ for (const f of entry.findings) {
29
+ expect(typeof f.line).toBe('number');
30
+ expect(['tautological', 'swallowed-error']).toContain(f.kind);
31
+ expect(typeof f.text).toBe('string');
32
+ }
33
+ }
34
+ });
35
+
36
+ it('AC-668-02: 识别两类确定性 vacuous 形态', () => {
37
+ // These are the two shapes Gate 5d blocks. Both are the ABSENCE of an assertion
38
+ // wearing assertion syntax, which is why they are the ones worth a debt list.
39
+ const fixture = [
40
+ 'test("a", () => {',
41
+ ' expect(true).toBe(true);',
42
+ ' try { go(); } catch {}',
43
+ '});',
44
+ 'test("b", () => {',
45
+ ' expect(x).toBe(1);',
46
+ '});',
47
+ ].join('\n');
48
+ const findings = scanForAssertionDebt({ source: fixture, file: 'fixture.test.ts' });
49
+ const flat = findings.flatMap((f) => f.findings);
50
+ expect(flat.some((f) => f.kind === 'tautological')).toBe(true);
51
+ expect(flat.some((f) => f.kind === 'swallowed-error')).toBe(true);
52
+ // A real assertion must not be reported -- a debt list that flags `expect(x).toBe(1)`
53
+ // is a list nobody finishes.
54
+ const realLine = flat.find((f) => f.text.includes('expect(x).toBe(1)'));
55
+ expect(realLine).toBeUndefined();
56
+ });
57
+
58
+ it('AC-668-03: 摘要给出可批次清零的数量,而非只是行数', () => {
59
+ const fixture = ['expect(true).toBe(true);', 'expect(1).toBe(1);', 'try { x(); } catch {}'].join('\n');
60
+ const results = scanForAssertionDebt({ source: fixture, file: 'a.test.ts' });
61
+ const summary = summarizeDebt(results);
62
+ expect(summary.files).toBe(1);
63
+ expect(summary.total).toBe(3);
64
+ // Two tautologies and one empty catch: the split matters because they are fixed
65
+ // by different edits (rewrite the assertion vs. handle the error).
66
+ expect(summary.byKind.tautological).toBe(2);
67
+ expect(summary.byKind['swallowed-error']).toBe(1);
68
+ });
69
+ });
70
+
71
+ /**
72
+ * @test REQ-668 fixture 不能被当作存量债务
73
+ * @intent 这条测试防的是一个**修债务反而删掉测试覆盖**的失败模式。
74
+ *
75
+ * 测试 Gate 5d 分类器的那批 .bats / .test.ts 文件必然包含
76
+ * `'expect(true).toBe(true);'` —— 那是被校验的**输入**,不是断言。
77
+ * 若它们被列进"去修掉"的清单,执行清单的人会删掉钉住分类器行为的字符串,
78
+ * 于是债务清单本身成了覆盖率的破坏者。
79
+ *
80
+ * 反例同样重要:真正的裸断言 `expect(true).toBe(true);` 必须仍然被报出来。
81
+ * 只做"少报"很容易,难的是**同时**不误报也不漏报。
82
+ * @covers AC-668-04
83
+ */
84
+ describe('fixture 与真实断言的区分 (#668)', () => {
85
+ it('AC-668-04: 带引号的 fixture 不报,裸断言照报', () => {
86
+ const { classifyLine } = require('../assertion-debt');
87
+ // Real code: must be reported.
88
+ expect(classifyLine('expect(true).toBe(true);')).toBe('tautological');
89
+ expect(classifyLine(' expect(1).toBe(1);')).toBe('tautological');
90
+ expect(classifyLine('try { x(); } catch {}')).toBe('swallowed-error');
91
+ // Fixture inputs: must not be reported.
92
+ expect(classifyLine("' expect(true).toBe(true);',")).toBeNull();
93
+ expect(classifyLine('const s = "expect(true).toBe(true)";')).toBeNull();
94
+ // Neither is debt.
95
+ expect(classifyLine('// expect(true).toBe(true);')).toBeNull();
96
+ expect(classifyLine('expect(x).toBe(1);')).toBeNull();
97
+ expect(classifyLine('expect(value).toBeTruthy();')).toBeNull();
98
+ });
99
+ });
@@ -0,0 +1,63 @@
1
+ /**
2
+ * @test REQ-653 `check --gates` 单文件自检必须真的跑起来,且 < 2 秒
3
+ * @intent #653 要求提供一条"提交前自检"的快速路径,目标是让模型在写完代码后立刻
4
+ * 自查,而不是等一轮完整 pre-commit(Gate 5 单项就 32.5s)。
5
+ *
6
+ * 实现过程中挖出两个真 bug,它们都属于同一类:**工具没跑起来,却被报成
7
+ * "你的代码有问题"**。
8
+ *
9
+ * 1. `xp-gate check <file>` 把文件路径直接当成 spawnSync 的 `cwd`。cwd 必须是
10
+ * 目录,指向文件时 spawnSync 返回裸 ENOENT,上层打印
11
+ * "Gate 3: failed to run npx tsx" 然后计作 FAIL。
12
+ * 2. Windows 上曾用 `shell: true` 解析 npx.cmd,但 node 把 argv 拼成一整条
13
+ * 未转义的命令行(DEP0190),路径里有反斜杠或空格就会变形;改调 npx.cmd
14
+ * 本身又撞上 CVE-2024-27980 的 EINVAL 加固。
15
+ *
16
+ * 两个修复都要求"文件路径能跑通",而不是"错误信息更好看"。所以断言打在
17
+ * 真实的 passed 计数与退出码上。
18
+ * @covers AC-653-04, AC-653-05
19
+ */
20
+ 'use strict';
21
+
22
+ const { execFileSync } = require('node:child_process');
23
+ const path = require('node:path');
24
+ const fs = require('node:fs');
25
+
26
+ const CLI = path.join(__dirname, '..', '..', 'bin', 'xp-gate.js');
27
+ const REPO = path.join(__dirname, '..', '..', '..', '..');
28
+
29
+ function runCheck(relPath) {
30
+ const started = Date.now();
31
+ let stdout = '';
32
+ let status = 0;
33
+ try {
34
+ stdout = execFileSync(process.execPath, [CLI, 'check', relPath, '--gates', 'principles'], {
35
+ cwd: REPO,
36
+ encoding: 'utf8',
37
+ timeout: 120000,
38
+ });
39
+ } catch (err) {
40
+ stdout = `${err.stdout || ''}${err.stderr || ''}`;
41
+ status = err.status || 1;
42
+ }
43
+ return { stdout, status, elapsed: Date.now() - started };
44
+ }
45
+
46
+ describe('check --gates 单文件自检 (#653)', () => {
47
+ it('AC-653-04: 传文件路径时 gate 真的执行,而不是报 spawn 失败', () => {
48
+ const target = 'src/npm-package/lib/doctor-brief.js';
49
+ expect(fs.existsSync(path.join(REPO, target))).toBe(true);
50
+ const { stdout } = runCheck(target);
51
+ // The pre-fix symptom was exactly this string followed by a FAIL verdict.
52
+ expect(stdout).not.toMatch(/failed to run npx tsx/);
53
+ expect(stdout).not.toMatch(/ENOENT|EINVAL/);
54
+ expect(stdout).toMatch(/principles/i);
55
+ });
56
+
57
+ it('AC-653-05: 单文件自检在 2 秒内给出结论', () => {
58
+ const { elapsed } = runCheck('src/npm-package/lib/doctor-brief.js');
59
+ // #653's whole value proposition: self-check before committing beats waiting
60
+ // for a 3-minute pre-commit. A slow "fast path" is not a fast path.
61
+ expect(elapsed).toBeLessThan(2000);
62
+ });
63
+ });
@@ -0,0 +1,59 @@
1
+ /**
2
+ * @test REQ-653 `doctor --brief` 必须一条命令给出全部环境事实
3
+ * @intent 一个会话里 32.7% 的 shell 调用是纯探测(`where gcc`、`Test-Path`、
4
+ * 逐文件 `Get-Content`、版本探测),每次 16s 上下。这类探测的共同点是
5
+ * **答案几乎不变**,却要为每个问题付一次往返。
6
+ *
7
+ * `--brief` 的目标不是"少打印",而是"一次问完":模型拿到一屏结构化事实后
8
+ * 不需要再探任何东西。所以验收标准是**覆盖率**,不是行数——90% 的常见
9
+ * 探测问题必须能在这一屏里找到答案。
10
+ *
11
+ * 一个容易做错的地方:`--brief` 必须是**纯读取**。用户会因为"快"而频繁调用它,
12
+ * 若它顺手修了什么(比如 `--fix` 的行为),频繁调用的代价就从延迟变成了状态。
13
+ * @covers AC-653-01, AC-653-02, AC-653-03
14
+ */
15
+ 'use strict';
16
+
17
+ const { buildBriefReport, BRIEF_FACTS } = require('../doctor-brief');
18
+
19
+ describe('doctor --brief (#653)', () => {
20
+ it('AC-653-01: 覆盖常见环境探测——语言工具链、hooks、门禁、覆盖率', () => {
21
+ const report = buildBriefReport({ cwd: process.cwd() });
22
+ const keys = Object.keys(report.facts);
23
+ // Each of these replaces a class of exploratory probe that used to cost a
24
+ // round trip. If one is missing, models go back to probing for it manually.
25
+ for (const required of ['hooks', 'language', 'adapters', 'coverage', 'gateState']) {
26
+ expect(keys).toContain(required);
27
+ }
28
+ expect(BRIEF_FACTS.length).toBeGreaterThanOrEqual(5);
29
+ });
30
+
31
+ it('AC-653-02: 输出必须是可直接消费的结构,不是需要再解析的散文', () => {
32
+ const report = buildBriefReport({ cwd: process.cwd() });
33
+ // A model that has to regex a sentence has not saved a round trip.
34
+ expect(typeof report.facts.hooks.effectivePath).toBe('string');
35
+ expect(Array.isArray(report.facts.language.detected)).toBe(true);
36
+ expect(typeof report.facts.coverage.found).toBe('boolean');
37
+ // One-line summary for humans, but the structure carries the facts.
38
+ expect(typeof report.summary).toBe('string');
39
+ expect(report.summary.split('\n').length).toBeLessThanOrEqual(3);
40
+ });
41
+
42
+ it('AC-653-03: --brief 纯读取,不修改任何状态', () => {
43
+ const { execFileSync } = require('node:child_process');
44
+ const fs = require('node:fs');
45
+ const path = require('node:path');
46
+ const before = fs.readdirSync(process.cwd()).sort().join('\n');
47
+ try {
48
+ execFileSync(process.execPath, [
49
+ path.join(__dirname, '..', '..', 'bin', 'xp-gate.js'), 'doctor', '--brief', '--json',
50
+ ], { cwd: process.cwd(), encoding: 'utf8', timeout: 120000 });
51
+ } catch {
52
+ // Exit code is not the assertion here; mutation is.
53
+ }
54
+ const after = fs.readdirSync(process.cwd()).sort().join('\n');
55
+ // The real cost of a --fix sneaking in is not one bad run: users call --brief
56
+ // often BECAUSE it is cheap, so an unintended write compounds every session.
57
+ expect(after).toBe(before);
58
+ });
59
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * @test REQ-652 BUILD 阶段的门禁清单必须从真相源生成,而非手抄
3
+ * @intent #652 的实证是:BUILD 阶段生成代码时,模型对 Boy Scout / CCN / jscpd /
4
+ * archlint 完全不可知——不是"知道却不遵守",是**根本不知道规则存在**。
5
+ * 后果是 27% 的门禁失败属于静态规则违规,即唯一 100% 可在生成前规避的类别。
6
+ *
7
+ * 补一页手写的阈值表能解决今天的问题,但会制造明天的问题:`.principlesrc`
8
+ * 里 `long-function` 是 50、`deep-nesting` 是 4,一旦有人调成 60/5,那一页
9
+ * 手抄的表就开始说谎。**一个说谎的自检清单比没有清单更糟**——它会让人按错的
10
+ * 规则写代码,然后在提交时被告知另一套。
11
+ *
12
+ * 所以这个清单必须从配置文件生成。真相源有几个就在几个里读,不复制。
13
+ * @covers AC-652-01, AC-652-02, AC-652-03
14
+ */
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+ const { collectGateRules } = require('../gate-rules');
20
+
21
+ const REPO = path.join(__dirname, '..', '..', '..', '..');
22
+
23
+ describe('BUILD 阶段门禁清单 (#652)', () => {
24
+ it('AC-652-01: 从 .principlesrc 读出真实阈值,不是写死的数字', () => {
25
+ const rules = collectGateRules({ cwd: REPO });
26
+ const longFn = rules.find((r) => r.id === 'clean-code.long-function');
27
+ const nesting = rules.find((r) => r.id === 'clean-code.deep-nesting');
28
+ expect(longFn).toBeDefined();
29
+ expect(nesting).toBeDefined();
30
+ // The value must come from the config file. Reading .principlesrc here keeps the
31
+ // test honest: if the config changes and the generator does not follow, this fails.
32
+ const cfg = JSON.parse(fs.readFileSync(path.join(REPO, '.principlesrc'), 'utf8'));
33
+ expect(longFn.threshold).toBe(cfg.rules['clean-code']['long-function'].threshold);
34
+ expect(nesting.threshold).toBe(cfg.rules['clean-code']['deep-nesting'].threshold);
35
+ });
36
+
37
+ it('AC-652-02: 覆盖会阻断提交的每一类规则,而不只是 principles', () => {
38
+ const rules = collectGateRules({ cwd: REPO });
39
+ const sources = new Set(rules.map((r) => r.source));
40
+ // #652 named exactly these four as absent from BUILD docs.
41
+ for (const required of ['principles', 'ccn', 'jscpd', 'boy-scout']) {
42
+ expect(sources).toContain(required);
43
+ }
44
+ });
45
+
46
+ it('AC-652-03: 生成结果可渲染成一份自检清单,含阈值与出处', () => {
47
+ const rules = collectGateRules({ cwd: REPO });
48
+ for (const rule of rules) {
49
+ expect(typeof rule.id).toBe('string');
50
+ expect(typeof rule.source).toBe('string');
51
+ // Without the evidence pointer, "the threshold is 50" cannot be checked or
52
+ // argued with -- it is indistinguishable from a guess.
53
+ expect(typeof rule.evidence).toBe('string');
54
+ expect(rule.evidence).not.toBe('');
55
+ expect(['block', 'warn', 'info']).toContain(rule.severity);
56
+ }
57
+ });
58
+ });