@boyingliu01/xp-gate 0.35.15 → 0.35.17

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 (44) hide show
  1. package/bin/xp-gate.js +67 -6
  2. package/gates/gate-9.ts +26 -4
  3. package/hooks/pre-commit +33 -10
  4. package/lib/__tests__/audit-cli-contract.test.ts +117 -0
  5. package/lib/__tests__/gate-coverage-hook-floor.test.js +94 -0
  6. package/lib/__tests__/gate-entry-provenance.test.js +71 -0
  7. package/lib/gate-coverage.js +46 -3
  8. package/lib/gate-runner.js +10 -2
  9. package/lib/mattpocock-license-verdict.json +1 -1
  10. package/mock-policy/AGENTS.md +3 -3
  11. package/mutation/AGENTS.md +3 -3
  12. package/mutation/runners/mutmut-runner.ts +165 -70
  13. package/package.json +1 -1
  14. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  15. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  16. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  17. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  18. package/plugins/dsh/package.json +1 -1
  19. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  20. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  21. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  22. package/plugins/opencode/README.md +22 -1
  23. package/plugins/opencode/contract-v2.test.ts +125 -0
  24. package/plugins/opencode/exec-injection.test.ts +110 -0
  25. package/plugins/opencode/index.ts +39 -4
  26. package/plugins/opencode/package.json +2 -7
  27. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  29. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  30. package/plugins/qoder/plugin.json +1 -1
  31. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  33. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  34. package/principles/AGENTS.md +3 -3
  35. package/principles/adapters/import-clause.ts +57 -0
  36. package/principles/adapters/member-scan.ts +73 -0
  37. package/principles/adapters/position-analysis.ts +43 -0
  38. package/principles/adapters/quote-state.ts +80 -0
  39. package/principles/adapters/typescript.ts +57 -116
  40. package/principles/rules/clean-code/god-class.ts +37 -14
  41. package/principles/rules/solid/dip.ts +15 -2
  42. package/skills/delphi-review/AGENTS.md +3 -3
  43. package/skills/sprint-flow/AGENTS.md +3 -3
  44. package/skills/test-specification-alignment/AGENTS.md +3 -3
package/bin/xp-gate.js CHANGED
@@ -617,16 +617,38 @@ function printAnomalies(a, since) {
617
617
  * CI gate: exit 1 when the audit ledger carries anomalies (implausible
618
618
  * durations or unattributed commits). This turns both telemetry bugs into a
619
619
  * build failure instead of something a human has to notice in a chart.
620
+ *
621
+ * `--ledger <path>` grades that file instead of `<cwd>/.xp-gate/audit.jsonl`.
622
+ * CI needs it: the BATS suites run before this check and execute the real hook
623
+ * from the repo root, so the live ledger contains fixture rows that were never
624
+ * given a commit hash. Grading them as "unattributed commits" reports a
625
+ * test-isolation problem as a telemetry bug -- and the CI step's job is the
626
+ * committed ledger, not the scratch one the tests left behind.
620
627
  */
621
- function handleAuditCheck(auditPath) {
628
+ function handleAuditCheck(args = []) {
622
629
  try {
623
- const { computeAnomalies, AUDIT_ANOMALY_SINCE } = require(auditPath);
630
+ const ledgerIdx = args.indexOf('--ledger');
631
+ let repoRoot = process.cwd();
632
+ if (ledgerIdx !== -1 && args[ledgerIdx + 1]) {
633
+ // computeAnomalies() reads <repoRoot>/.xp-gate/audit.jsonl, so point it at
634
+ // a directory whose .xp-gate/audit.jsonl is the file we were asked about.
635
+ // A temp dir keeps the caller's tree untouched.
636
+ const ledgerPath = path.resolve(args[ledgerIdx + 1]);
637
+ if (!require('node:fs').existsSync(ledgerPath)) {
638
+ console.error(`Error: --ledger file not found: ${ledgerPath}`);
639
+ process.exit(1);
640
+ }
641
+ repoRoot = require('node:fs').mkdtempSync(path.join(require('node:os').tmpdir(), 'xpg-ledger-'));
642
+ require('node:fs').mkdirSync(path.join(repoRoot, '.xp-gate'), { recursive: true });
643
+ require('node:fs').copyFileSync(ledgerPath, path.join(repoRoot, '.xp-gate', 'audit.jsonl'));
644
+ }
624
645
  // Scope to entries after the fix: historical anomalies are grandfathered
625
646
  // (they cannot be re-measured). A future epoch/blank row fails the build.
626
- const a = computeAnomalies(process.cwd(), AUDIT_ANOMALY_SINCE);
627
- printAnomalies(a, AUDIT_ANOMALY_SINCE);
647
+ const since = evalGateAuditModule('m.AUDIT_ANOMALY_SINCE');
648
+ const a = evalGateAuditModule(`m.computeAnomalies(${JSON.stringify(repoRoot)}, ${JSON.stringify(since)})`);
649
+ printAnomalies(a, since);
628
650
  if (a.total > 0) {
629
- console.error(`FAIL: ${a.total} audit anomal${a.total === 1 ? 'y' : 'ies'} found since ${AUDIT_ANOMALY_SINCE} (must be 0).`);
651
+ console.error(`FAIL: ${a.total} audit anomal${a.total === 1 ? 'y' : 'ies'} found since ${since} (must be 0).`);
630
652
  process.exit(1);
631
653
  }
632
654
  console.log('OK: no new audit anomalies.');
@@ -665,6 +687,45 @@ function handleAuditRecord(args, auditPath) {
665
687
  }
666
688
  }
667
689
 
690
+ /**
691
+ * Evaluate `lib/gate-audit.ts` under tsx and return whatever the little script
692
+ * writes to stdout, JSON-decoded.
693
+ *
694
+ * `gate-audit.ts` is TypeScript, and Node cannot `require()` a `.ts` file -- so
695
+ * every `xp-gate audit` subcommand used to die on
696
+ *
697
+ * Error checking audit health: require is not defined in ES module scope
698
+ *
699
+ * (that message is Node trying to interpret the .ts file as ESM). `--tail`,
700
+ * `--stats`, `--check` and `record` were ALL unusable, and it stayed invisible
701
+ * because nothing automated called them: CI's Gate 5c runs
702
+ * `xp-gate audit --check || npx tsx ... audit --check`, so the npx fallback is
703
+ * what actually ran and the broken path was never exercised.
704
+ *
705
+ * Per this package's own convention (AGENTS.md: these `.ts` lib files are
706
+ * "loaded via npx tsx on demand", never compiled at install time), evaluate the
707
+ * module in a child process under tsx. The caller supplies the expression, and
708
+ * all values travel as JSON so nothing is ever interpolated into code.
709
+ *
710
+ * @param {string} expr an expression evaluated with `m` bound to the module
711
+ * @returns {*} the JSON-decoded result
712
+ */
713
+ function evalGateAuditModule(expr) {
714
+ const { execFileSync } = require('node:child_process');
715
+ const modulePath = path.join(__dirname, '..', 'lib', 'gate-audit.ts');
716
+ const tsxCli = path.join(__dirname, '..', '..', '..', 'node_modules', 'tsx', 'dist', 'cli.mjs');
717
+ const script = [
718
+ `import * as m from ${JSON.stringify(modulePath)};`,
719
+ `process.stdout.write(JSON.stringify(${expr}));`,
720
+ ].join('\n');
721
+ const raw = execFileSync(process.execPath, [tsxCli, '--eval', script], {
722
+ cwd: process.cwd(),
723
+ encoding: 'utf8',
724
+ timeout: 120000,
725
+ });
726
+ return JSON.parse(raw);
727
+ }
728
+
668
729
  function handleAudit(args) {
669
730
  const auditPath = path.join(__dirname, '..', 'lib', 'gate-audit.ts');
670
731
 
@@ -679,7 +740,7 @@ function handleAudit(args) {
679
740
  }
680
741
 
681
742
  if (args.includes('--check')) {
682
- handleAuditCheck(auditPath);
743
+ handleAuditCheck(args);
683
744
  return;
684
745
  }
685
746
 
package/gates/gate-9.ts CHANGED
@@ -10,6 +10,27 @@ import {
10
10
  getChangedFiles, detectProjectLang,
11
11
  } from './common';
12
12
 
13
+ // The semgrep ruleset, in ONE place (#640). It appears in the scan invocation and in
14
+ // two messages that tell the user which command to re-run by hand; three copies of a
15
+ // string that decides what gets scanned is a drift waiting to happen.
16
+ export const SEMGREP_RULESET = 'p/security-audit';
17
+
18
+ /**
19
+ * Fixed flags every Gate 9 semgrep run carries (#640).
20
+ *
21
+ * `--metrics=off` is the important one. Semgrep's usage upload is pure overhead for
22
+ * a gate: it reports statistics to the registry and contributes nothing to the
23
+ * verdict. Measured on one file on this machine, twice each way: 11.7s / 17.2s with
24
+ * metrics, 4.7s / 4.9s without -- a 58-72% cut on a gate whose actual scan work is
25
+ * a fraction of a second (#640 measured 0.45s of work inside 30.8s of wall time).
26
+ * The privacy answer is the same shape: a quality gate should not phone home about
27
+ * the files it just read.
28
+ *
29
+ * `--disable-version-check` removes the second network round trip. Neither flag
30
+ * touches the JSON on stdout, which is what carries the verdict (#518).
31
+ */
32
+ export const SEMGREP_EXTRA_ARGS = ['--disable-version-check', '--metrics=off'] as const;
33
+
13
34
  export function runGate9(input: GateInput): GateOutput {
14
35
  const startMs = Date.now();
15
36
  const messages: string[] = [];
@@ -27,7 +48,7 @@ export function runGate9(input: GateInput): GateOutput {
27
48
  if (!tool.available) {
28
49
  messages.push(' ⚠️ WARN - semgrep not installed — SAST scanning unavailable');
29
50
  messages.push(' Install: brew install semgrep (macOS) | pip install semgrep (Linux/Windows)');
30
- messages.push(' Pre-cache rules: semgrep --config=p/security-audit');
51
+ messages.push(' Pre-cache rules: semgrep --config=' + SEMGREP_RULESET);
31
52
  messages.push(' Gate 9: SAST Security (WARN, semgrep not installed)');
32
53
  recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
33
54
  return { status: 'WARN', messages, exitCode: 0 };
@@ -44,8 +65,9 @@ export function runGate9(input: GateInput): GateOutput {
44
65
  return { status: 'SKIP', messages, exitCode: 0 };
45
66
  }
46
67
 
47
- // Run semgrep with JSON output
48
- semgrepArgs.push('scan', '--config=p/security-audit', '--json', '--disable-version-check', ...semgrepFiles);
68
+ // Run semgrep with JSON output. Fixed overhead per run is the point of #640 --
69
+ // see SEMGREP_EXTRA_ARGS.
70
+ semgrepArgs.push('scan', `--config=${SEMGREP_RULESET}`, '--json', ...SEMGREP_EXTRA_ARGS, ...semgrepFiles);
49
71
 
50
72
  const result = runTool(semgrepCmd, semgrepArgs, { cwd: input.cwd });
51
73
 
@@ -125,7 +147,7 @@ function renderSemgrepBlock(
125
147
  messages.push(' ❌ BLOCKED — blocking-severity vulnerability found');
126
148
  messages.push('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
127
149
  messages.push(...findingLines);
128
- messages.push(" Run 'semgrep scan --config=p/security-audit' to review all findings.");
150
+ messages.push(` Run 'semgrep scan --config=${SEMGREP_RULESET}' to review all findings.`);
129
151
  recordAudit('gate-9', 'sast-security', 'FAIL', blocking, startMs);
130
152
  return { status: 'FAIL', messages, exitCode: 1 };
131
153
  }
package/hooks/pre-commit CHANGED
@@ -851,18 +851,32 @@ done
851
851
  has_version_change=false
852
852
  has_changelog_change=false
853
853
 
854
+ # #640: a fork costs ~90ms on Windows (measured: 100 `git rev-parse` = 9456ms),
855
+ # and this exact query is re-run by later gates. Cache it once; consumers below
856
+ # read $XP_STAGED_ACM. Subprocess COUNT, not scan duration, is the bottleneck.
857
+ #
858
+ # Computed HERE, once, and outside the protected-branch test below. It used to
859
+ # live inside that test, which meant the variable was only ever assigned when
860
+ # committing to master/main/develop/trunk/mainline. On a feature branch -- where
861
+ # essentially all work happens, and where CI fixtures commit -- it stayed EMPTY,
862
+ # and every #640 consumer that asks "did this commit touch a source file?" then
863
+ # matched nothing:
864
+ # - Gate 6 skipped archlint on every feature-branch commit that DID change
865
+ # source ("no source files changed"), silently dropping the architecture
866
+ # check;
867
+ # - Gate 0's own hint fired as though nothing were staged.
868
+ # A cache that is only warm on some branches is not a cache, it is a branch-wide
869
+ # behaviour difference. Assign it before anything can branch.
870
+ STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
871
+ XP_STAGED_ACM="$STAGED_FILES"
872
+ export XP_STAGED_ACM
873
+
854
874
  if [ "$is_protected" = "true" ]; then
855
875
  # Env var bypass: SKIP_VERSION_CHECK=1 skips Gate 0 entirely
856
876
  # Useful for: SKIP_VERSION_CHECK=1 git commit -m "chore: ..."
857
877
  if [ "${SKIP_VERSION_CHECK:-}" = "1" ]; then
858
878
  echo "⏭️ SKIPPED - Gate 0: Version consistency (SKIP_VERSION_CHECK=1 env var)"
859
879
  else
860
- # Check if staged changes include VERSION or CHANGELOG.md
861
- STAGED_FILES=$(git diff --cached --name-only --diff-filter=ACM)
862
- # #640: a fork costs ~90ms on Windows (measured: 100 `git rev-parse` = 9456ms),
863
- # and this exact query is re-run by later gates. Cache it once; consumers below
864
- # read $XP_STAGED_ACM. Subprocess COUNT, not scan duration, is the bottleneck.
865
- XP_STAGED_ACM="$STAGED_FILES"
866
880
  for file in $STAGED_FILES; do
867
881
  case "$file" in
868
882
  VERSION) has_version_change=true ;;
@@ -2450,7 +2464,7 @@ else
2450
2464
  GATE_5_DELETED_TESTS=""
2451
2465
  if declare -f classify_test_focus >/dev/null 2>&1; then
2452
2466
  STAGED_TEST_FILES=$(printf '%s\n' "${XP_STAGED_ACM:-$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null)}" \
2453
- | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|cs|scala|ps1)$|(^|/)(test_[^/]*\.py|[^/]*_test\.(py|go))$' || true)
2467
+ | filter_project_paths | grep -E '\.(test|spec)\.(ts|tsx|js|jsx|mjs|cjs|py|go|java|kt|cs|scala|ps1)$|(^|/)(test_[^/]*\.py|[^/]*_test\.(py|go))$' || true)
2454
2468
  if [ -n "$STAGED_TEST_FILES" ]; then
2455
2469
  while IFS= read -r _tf; do
2456
2470
  [ -n "$_tf" ] || continue
@@ -2570,8 +2584,11 @@ else
2570
2584
  # --- deleted test files: WARN ---
2571
2585
  DELETED_LIST=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/xp-deleted-$$")
2572
2586
  ADDED_LIST=$(mktemp 2>/dev/null || echo "${TMPDIR:-/tmp}/xp-added-$$")
2573
- git diff --cached --name-only --diff-filter=D > "$DELETED_LIST" 2>/dev/null || true
2574
- git diff --cached --name-only --diff-filter=A > "$ADDED_LIST" 2>/dev/null || true
2587
+ # Routed through the same filter as every other staged-file scan (#649):
2588
+ # a deleted/added file under node_modules/ or vendor/ is not project code,
2589
+ # and treating it as one reports deletions that mean nothing.
2590
+ git diff --cached --name-only --diff-filter=D 2>/dev/null | filter_project_paths > "$DELETED_LIST" || true
2591
+ git diff --cached --name-only --diff-filter=A 2>/dev/null | filter_project_paths > "$ADDED_LIST" || true
2575
2592
  GATE_5_DELETED_TESTS=$(classify_deleted_tests "$DELETED_LIST" "$ADDED_LIST" || true)
2576
2593
  rm -f "$DELETED_LIST" "$ADDED_LIST"
2577
2594
  if [ -n "$GATE_5_DELETED_TESTS" ]; then
@@ -2642,7 +2659,13 @@ else
2642
2659
  # Exclusions come from filter_project_paths (#649). The hand-maintained list
2643
2660
  # this replaced omitted vendor/target/coverage/out, so a staged vendor/x.ts
2644
2661
  # was reported as new source with no test.
2645
- NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
2662
+ #
2663
+ # Byte-identical mirrors under src/npm-package/ are excluded the same way
2664
+ # lint (#494) and Boy Scout (#649) already exclude them. A mirror cannot
2665
+ # carry its own test: the next resync overwrites the directory, so the
2666
+ # requirement is structurally unsatisfiable and blocked every commit that
2667
+ # added a file to a mirrored module. The canonical source keeps its test.
2668
+ NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -Ev "$MIRROR_PATH_FILTER" | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
2646
2669
 
2647
2670
  # Strip PROJECT_SUBDIR prefix from new-TS-file paths ONLY when the hook
2648
2671
  # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1; single-language
@@ -0,0 +1,117 @@
1
+ /**
2
+ * @test audit CLI runs at all
3
+ * @intent The four `xp-gate audit` subcommands used to require() a .ts file, which
4
+ * Node cannot do, so every one of them died. This pins that they execute,
5
+ * that --check still FAILS on a real anomaly (so "it runs" is not mistaken
6
+ * for "it passes"), and that --ledger grades the file it was given.
7
+ * @covers AC-673-01, AC-673-02, AC-673-03
8
+ */
9
+ import { describe, it, expect, beforeAll, afterAll } from 'vitest';
10
+ import { spawnSync } from 'node:child_process';
11
+ import { mkdtempSync, writeFileSync, rmSync } from 'node:fs';
12
+ import { tmpdir } from 'node:os';
13
+ import { join } from 'node:path';
14
+ import { fileURLToPath } from 'node:url';
15
+ import path from 'node:path';
16
+
17
+ const HERE = path.dirname(fileURLToPath(import.meta.url));
18
+ const CLI = path.resolve(HERE, '..', '..', 'bin', 'xp-gate.js');
19
+ const REPO_ROOT = path.resolve(HERE, '..', '..', '..', '..');
20
+
21
+ // The audit CLI builds a tsx child process on every call, so the first one pays
22
+ // cold-start cost; the per-test timeout has to clear that.
23
+ const TEST_TIMEOUT_MS = 200_000;
24
+ const CLI_TIMEOUT_MS = 180_000;
25
+ const SAMPLE_DURATION_MS = 1200;
26
+
27
+ let dir: string;
28
+
29
+ beforeAll(() => {
30
+ dir = mkdtempSync(join(tmpdir(), 'xpg-audit-cli-'));
31
+ });
32
+ afterAll(() => {
33
+ rmSync(dir, { recursive: true, force: true });
34
+ });
35
+
36
+ function runAudit(args: string[], cwd: string): ReturnType<typeof spawnSync> {
37
+ try {
38
+ return spawnSync(process.execPath, [CLI, 'audit', ...args], {
39
+ cwd,
40
+ encoding: 'utf8',
41
+ timeout: CLI_TIMEOUT_MS,
42
+ });
43
+ } catch (err) {
44
+ // A spawn failure (missing binary, EACCES) must surface as itself, not as a
45
+ // mysterious "status was undefined" several assertions later.
46
+ throw new Error(`failed to run audit ${args.join(' ')}: ${(err as Error).message}`);
47
+ }
48
+ }
49
+
50
+ function ledgerWith(rows: object[]): string {
51
+ const file = join(dir, `ledger-${Math.random().toString(36).slice(2)}.jsonl`);
52
+ try {
53
+ writeFileSync(file, rows.map((r) => JSON.stringify(r)).join('\n') + '\n');
54
+ } catch (err) {
55
+ throw new Error(`failed to write ledger ${file}: ${(err as Error).message}`);
56
+ }
57
+ return file;
58
+ }
59
+
60
+ const ATTRIBUTED = {
61
+ timestamp: '2026-12-01T00:00:00.000Z',
62
+ gate_id: 'gate-1',
63
+ gate_name: 'code-quality',
64
+ passed: true,
65
+ issues_found: 0,
66
+ duration_ms: SAMPLE_DURATION_MS,
67
+ trigger: 'commit',
68
+ commit_hash: 'abc123def456',
69
+ };
70
+
71
+ const UNATTRIBUTED = { ...ATTRIBUTED, commit_hash: '' };
72
+
73
+ describe('xp-gate audit CLI', () => {
74
+ it('AC-673-01: --check runs the TypeScript implementation instead of dying on require()', () => {
75
+ const ledger = ledgerWith([ATTRIBUTED]);
76
+ const r = runAudit(['--check', '--ledger', ledger], REPO_ROOT);
77
+
78
+ // The failure this guards: Node cannot require() a .ts file, so the command
79
+ // printed "require is not defined in ES module scope" and exited 1 no matter
80
+ // what the ledger held. Assert the command produced a verdict at all.
81
+ expect(r.stderr).not.toContain('require is not defined');
82
+ expect(r.stdout).toContain('Audit health');
83
+ expect(r.stdout).toContain('OK: no new audit anomalies.');
84
+ expect(r.status).toBe(0);
85
+ }, TEST_TIMEOUT_MS);
86
+
87
+ it('AC-673-02: an unattributed commit still fails the check', () => {
88
+ // Anti-vacuity for AC-673-01: proving the command runs must not be mistaken
89
+ // for proving it passes. A blank commit_hash is the exact defect the check
90
+ // exists to catch, so it has to exit non-zero.
91
+ const ledger = ledgerWith([ATTRIBUTED, UNATTRIBUTED]);
92
+ const r = runAudit(['--check', '--ledger', ledger], REPO_ROOT);
93
+
94
+ expect(r.status).not.toBe(0);
95
+ expect(r.stdout).toContain('missing commit : 1');
96
+ expect(r.stderr).toContain('FAIL: 1 audit anomaly');
97
+ }, TEST_TIMEOUT_MS);
98
+
99
+ it('AC-673-03: --ledger grades the named file, not the cwd ledger', () => {
100
+ // Run from a directory with no .xp-gate/ at all: without --ledger the check
101
+ // would scan 0 entries and pass, which would make the flag look harmless
102
+ // while testing nothing.
103
+ const emptyCwd = mkdtempSync(join(dir, 'cwd-'));
104
+ const ledger = ledgerWith([UNATTRIBUTED]);
105
+ const r = runAudit(['--check', '--ledger', ledger], emptyCwd);
106
+
107
+ expect(r.stdout).toContain('entries scanned : 1');
108
+ expect(r.status).not.toBe(0);
109
+ }, TEST_TIMEOUT_MS);
110
+
111
+ it('AC-673-03: --ledger rejects a missing file instead of scanning nothing', () => {
112
+ const r = runAudit(['--check', '--ledger', join(dir, 'does-not-exist.jsonl')], REPO_ROOT);
113
+
114
+ expect(r.status).not.toBe(0);
115
+ expect(r.stderr).toContain('--ledger file not found');
116
+ }, TEST_TIMEOUT_MS);
117
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * @test REQ-605
3
+ * @intent `gate-coverage` used to announce a COVERAGE THRESHOLD CONFLICT in this
4
+ * repo that did not exist: "pre-commit enforces 80% while vitest.config.ts
5
+ * enforces 70%".
6
+ *
7
+ * The hook was actually enforcing 70 — it RESOLVES the number at runtime
8
+ * from `vitest.config.ts` (#605's own fix). The reporter read the hook's
9
+ * SOURCE and matched the first `COVERAGE_THRESHOLD` number it could find,
10
+ * which is `${COVERAGE_THRESHOLD:-80}` inside a *log message*. That literal
11
+ * renders only when the variable is empty, which the defensive guard above
12
+ * it prevents.
13
+ *
14
+ * A false conflict is worse than no conflict: it sends someone to change a
15
+ * declaration that is already correct, and it teaches them to distrust the
16
+ * report. These tests pin the distinction between "a number appears in the
17
+ * hook file" and "the hook enforces that number".
18
+ * @covers AC-605-07
19
+ */
20
+ 'use strict';
21
+
22
+ const fs = require('node:fs');
23
+ const os = require('node:os');
24
+ const path = require('node:path');
25
+
26
+ const { readHookCoverageFloor } = require('../gate-coverage.js');
27
+
28
+ /** Write a throwaway repo root holding `githooks/pre-commit` with `body`. */
29
+ function repoWithHook(body) {
30
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-hookfloor-'));
31
+ fs.mkdirSync(path.join(root, 'githooks'), { recursive: true });
32
+ fs.writeFileSync(path.join(root, 'githooks', 'pre-commit'), body);
33
+ return root;
34
+ }
35
+
36
+ describe('pre-commit coverage floor 读取 (#605)', () => {
37
+ it('AC-605-07: a message template is not mistaken for the enforced floor', () => {
38
+ const root = repoWithHook([
39
+ '#!/usr/bin/env bash',
40
+ 'echo "below ${COVERAGE_THRESHOLD:-80}% threshold"',
41
+ ].join('\n'));
42
+ try {
43
+ // This is the exact line that produced the phantom 80.
44
+ expect(readHookCoverageFloor(root)).toBeNull();
45
+ } finally {
46
+ fs.rmSync(root, { recursive: true, force: true });
47
+ }
48
+ });
49
+
50
+ it('AC-605-07: the defensive guard fallback alone reports "no declared floor"', () => {
51
+ const root = repoWithHook([
52
+ '#!/usr/bin/env bash',
53
+ 'if ! [[ "${COVERAGE_THRESHOLD:-}" =~ ^[0-9]+$ ]]; then',
54
+ ' COVERAGE_THRESHOLD=80',
55
+ 'fi',
56
+ ].join('\n'));
57
+ try {
58
+ // The guard fires only when nothing was declared; when a project DOES declare
59
+ // a threshold the hook uses that, so reporting 80 here would invert reality.
60
+ expect(readHookCoverageFloor(root)).toBeNull();
61
+ } finally {
62
+ fs.rmSync(root, { recursive: true, force: true });
63
+ }
64
+ });
65
+
66
+ it('AC-605-07: a genuinely assigned non-default floor is reported', () => {
67
+ const root = repoWithHook([
68
+ '#!/usr/bin/env bash',
69
+ 'COVERAGE_THRESHOLD=72',
70
+ 'if [ "$pct" -lt "$COVERAGE_THRESHOLD" ]; then echo blocked; fi',
71
+ ].join('\n'));
72
+ try {
73
+ expect(readHookCoverageFloor(root)).toBe(72);
74
+ } finally {
75
+ fs.rmSync(root, { recursive: true, force: true });
76
+ }
77
+ });
78
+
79
+ it('AC-605-07: a hook with no threshold at all reports null, not a default', () => {
80
+ const root = repoWithHook('#!/usr/bin/env bash\necho hello\n');
81
+ try {
82
+ expect(readHookCoverageFloor(root)).toBeNull();
83
+ } finally {
84
+ fs.rmSync(root, { recursive: true, force: true });
85
+ }
86
+ });
87
+
88
+ it('AC-605-07: the real repo hook does not claim a floor it does not enforce', () => {
89
+ const repoRoot = path.resolve(__dirname, '..', '..', '..', '..');
90
+ // The real hook resolves from vitest.config.ts, so it declares no literal.
91
+ // Asserting null here is the regression guard for the phantom-conflict report.
92
+ expect(readHookCoverageFloor(repoRoot)).toBeNull();
93
+ });
94
+ });
@@ -0,0 +1,71 @@
1
+ /**
2
+ * @test REQ-672
3
+ * @intent 门禁的 TypeScript 实现必须**随包走**,不能随被检项目的 cwd 走。
4
+ *
5
+ * `findTsGateEntry` 曾把 `process.cwd()/src/gates/<gate>.ts` 也列进候选。
6
+ * 在开发者布局里这条永远轮不到(前三个候选先命中),所以在本仓跑任何
7
+ * 断言都是绿的——**这正是这个缺陷能活下来的原因**。必须在 npm 安装布局
8
+ * 下测:前三个候选都不存在,第四条的 cwd 候选才会浮上来。
9
+ *
10
+ * 后果不是"用错了文件",是**被检方决定门禁怎么判**:那个文件可以是
11
+ * `process.exit(0)`,于是任何一个自带该路径的项目都能让自己通过任何门禁。
12
+ * 一个能被被检对象改写的门禁不是门禁。
13
+ *
14
+ * 夹具把 `lib/` 复制到临时目录并**故意不放** `gates/` 兄弟目录,以此复现
15
+ * npm 包的布局;被检项目则种下一个 `src/gates/gate-9.ts`。
16
+ * @covers AC-672-01
17
+ */
18
+ 'use strict';
19
+
20
+ const { spawnSync } = require('node:child_process');
21
+ const fs = require('node:fs');
22
+ const os = require('node:os');
23
+ const path = require('node:path');
24
+
25
+ const LIB_DIR = path.resolve(__dirname, '..');
26
+
27
+ /**
28
+ * Build an npm-install-shaped fixture and ask it to resolve gate-9.
29
+ *
30
+ * @returns {{ resolved: string, consumer: string, planted: string }} paths for assertions
31
+ */
32
+ function resolveInNpmLayout() {
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'xpg-provenance-'));
34
+ // npm package layout: `lib/` exists, and there is NO `src/gates/` sibling three
35
+ // levels up (that is the developer layout, which always wins when it is present).
36
+ const pkg = path.join(root, 'node_modules', 'xp-gate');
37
+ fs.mkdirSync(path.join(pkg, 'lib'), { recursive: true });
38
+ fs.cpSync(LIB_DIR, path.join(pkg, 'lib'), { recursive: true });
39
+
40
+ // The party being checked: it plants its own gate implementation.
41
+ const consumer = path.join(root, 'consumer');
42
+ const planted = path.join(consumer, 'src', 'gates', 'gate-9.ts');
43
+ fs.mkdirSync(path.dirname(planted), { recursive: true });
44
+ fs.writeFileSync(planted, 'process.exit(0)\n');
45
+
46
+ const runner = path.join(pkg, 'lib', 'gate-runner.js');
47
+ const script = [
48
+ `const { findTsGateEntry } = require(${JSON.stringify(runner)});`,
49
+ 'process.stdout.write(String(findTsGateEntry("gate-9")));',
50
+ ].join('\n');
51
+ const result = spawnSync(process.execPath, ['-e', script], {
52
+ cwd: consumer,
53
+ encoding: 'utf8',
54
+ timeout: 30000,
55
+ });
56
+ return { resolved: (result.stdout || '').trim(), consumer, planted, root };
57
+ }
58
+
59
+ describe('findTsGateEntry 不从消费项目解析实现 (#672)', () => {
60
+ it('AC-672-01: a project that plants src/gates/gate-9.ts cannot own the gate implementation', () => {
61
+ const { resolved, consumer, root } = resolveInNpmLayout();
62
+ try {
63
+ // Whatever it resolves to (an installed package normally has no gates/ dir at
64
+ // all, so `null` is fine), it must never be a file under the checked project:
65
+ // that file is data supplied by the party being checked.
66
+ expect(resolved.startsWith(consumer)).toBe(false);
67
+ } finally {
68
+ fs.rmSync(root, { recursive: true, force: true });
69
+ }
70
+ });
71
+ });
@@ -246,13 +246,55 @@ function readVitestThresholds(repoRoot) {
246
246
  }
247
247
  }
248
248
 
249
- /** The flat floor the pre-commit hook applies, or null when it applies none. */
249
+ /**
250
+ * The floor the pre-commit hook applies, or null when the hook applies none.
251
+ *
252
+ * The hook RESOLVES its floor at runtime (`_resolve_coverage_threshold`) from the
253
+ * project's own declaration -- pyproject / .coveragerc / vitest.config.ts -- and
254
+ * only falls back to a literal in the defensive guard. So a literal found in the
255
+ * file is usually NOT the enforced number:
256
+ *
257
+ * - `${COVERAGE_THRESHOLD:-80}` is the default inside *log messages*; it renders
258
+ * only if the variable is empty, which the guard above prevents.
259
+ * - `COVERAGE_THRESHOLD=80` is the guard itself, reached only when nothing was
260
+ * declared.
261
+ *
262
+ * Matching either one as "the hook's floor" is how this report used to announce a
263
+ * 80-vs-70 CONFLICT that did not exist: the hook was enforcing 70 (read from
264
+ * vitest.config.ts) while the reporter read a message literal and compared the two.
265
+ * A false conflict is worse than no conflict -- it sends someone to change a
266
+ * declaration that was already correct (#605).
267
+ *
268
+ * Both literals are therefore treated as "no declared floor": the reporter then
269
+ * defers to the declaration, which is exactly what the hook does.
270
+ */
250
271
  function readHookCoverageFloor(repoRoot) {
251
272
  try {
252
273
  const raw = fs.readFileSync(path.join(repoRoot, 'githooks', 'pre-commit'), 'utf8');
253
274
  if (!/COVERAGE_THRESHOLD/.test(raw)) return null;
254
- const m = raw.match(/COVERAGE_THRESHOLD:-?(\d+)/) || raw.match(/COVERAGE_THRESHOLD=(\d+)/);
255
- return m ? Number(m[1]) : null;
275
+
276
+ // Strip shell message templates (`${VAR:-NN}`) before scanning: those are
277
+ // display defaults, not enforcement.
278
+ const withoutTemplates = raw.replace(/\$\{COVERAGE_THRESHOLD:-?\d+\}/g, '');
279
+
280
+ // Assignment forms only, and only outside the defensive guard. The guard is
281
+ // recognisable by its comment-free shape: `COVERAGE_THRESHOLD=NN` sitting
282
+ // directly after the `-z`/regex test that precedes it in every version.
283
+ const matches = [...withoutTemplates.matchAll(
284
+ /(?:^[ \t]*(?:export[ \t]+)?COVERAGE_THRESHOLD=(\d+)[ \t]*$)/gm,
285
+ )].map((m) => Number(m[1]));
286
+ if (matches.length === 0) return null;
287
+
288
+ // The runtime resolver writes `COVERAGE_THRESHOLD="$_rct_value"`, which the
289
+ // numeric pattern above deliberately does not match. Any remaining numeric
290
+ // assignment other than the documented 80 fallback is the real floor.
291
+ const nonFallback = matches.filter((value) => value !== 80);
292
+ if (nonFallback.length > 0) return nonFallback[nonFallback.length - 1];
293
+
294
+ // Only the guard's fallback remains. The guard exists for a variable that
295
+ // arrived empty; when a project declares a threshold it never fires. Report
296
+ // "no declared floor" so the reporter uses the declaration, as the hook does.
297
+ return null;
256
298
  } catch {
257
299
  return null;
258
300
  }
@@ -457,5 +499,6 @@ module.exports = {
457
499
  exitCodeFor,
458
500
  readQualityStatus,
459
501
  readBoyScoutDebt,
502
+ readHookCoverageFloor,
460
503
  main,
461
504
  };
@@ -416,9 +416,17 @@ function findTsGateEntry(gateModule) {
416
416
  path.resolve(__dirname, '..', 'gates', `${gateModule}.ts`),
417
417
  // node_modules install
418
418
  path.resolve(__dirname, '..', '..', 'src', 'gates', `${gateModule}.ts`),
419
- // Repo root
420
- path.resolve(process.cwd(), 'src', 'gates', `${gateModule}.ts`),
421
419
  ];
420
+ // #672: there used to be a fourth candidate, `process.cwd()/src/gates/<gate>.ts`.
421
+ // It never won in this repo -- the developer layouts above always matched first --
422
+ // so every test here stayed green while the candidate was live. In an npm INSTALL
423
+ // the first three do not exist, and it became the gate implementation: a project
424
+ // that happened to have `src/gates/gate-9.ts` could ship `process.exit(0)` and
425
+ // pass every gate, security ones included. A gate whose implementation is chosen
426
+ // by the party being checked is not a gate.
427
+ //
428
+ // The implementation travels with the package. When none of the three are present
429
+ // the gate falls back to the bash adapter (or SKIPs), which is the designed path.
422
430
  for (const c of candidates) {
423
431
  if (fs.existsSync(c)) return c;
424
432
  }
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-30T22:49:35.688Z",
5
+ "checked_at": "2026-10-31T07:40:57.523Z",
6
6
  "source": "network"
7
7
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 6091e238
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 26a741de
5
5
  **Branch:** main
6
- **Version:** 0.35.15.0
6
+ **Version:** 0.35.17.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Mock layering policy enforcement — Gate M3 of pre-push hook. Ensures integration tests use real implementations for internal dependencies, mock external dependencies, and annotate pending mocks with removal plans. Combines project scope scanning, mock decision engine, and per-file validation into a single pipeline.
@@ -1,9 +1,9 @@
1
1
  # SRC/MUTATION KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 6091e238
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 26a741de
5
5
  **Branch:** main
6
- **Version:** 0.35.15.0
6
+ **Version:** 0.35.17.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **Gate M** (incremental mutation testing) + **Gate M2** helpers (test-layer detection used by `src/mock-policy/`). Pre-push quality gate. TypeScript-only; uses Stryker.