@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.
- package/bin/xp-gate.js +67 -6
- package/gates/gate-9.ts +26 -4
- package/hooks/pre-commit +33 -10
- package/lib/__tests__/audit-cli-contract.test.ts +117 -0
- package/lib/__tests__/gate-coverage-hook-floor.test.js +94 -0
- package/lib/__tests__/gate-entry-provenance.test.js +71 -0
- package/lib/gate-coverage.js +46 -3
- package/lib/gate-runner.js +10 -2
- package/lib/mattpocock-license-verdict.json +1 -1
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/mutation/runners/mutmut-runner.ts +165 -70
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/opencode/README.md +22 -1
- package/plugins/opencode/contract-v2.test.ts +125 -0
- package/plugins/opencode/exec-injection.test.ts +110 -0
- package/plugins/opencode/index.ts +39 -4
- package/plugins/opencode/package.json +2 -7
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/principles/adapters/import-clause.ts +57 -0
- package/principles/adapters/member-scan.ts +73 -0
- package/principles/adapters/position-analysis.ts +43 -0
- package/principles/adapters/quote-state.ts +80 -0
- package/principles/adapters/typescript.ts +57 -116
- package/principles/rules/clean-code/god-class.ts +37 -14
- package/principles/rules/solid/dip.ts +15 -2
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/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(
|
|
628
|
+
function handleAuditCheck(args = []) {
|
|
622
629
|
try {
|
|
623
|
-
const
|
|
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
|
|
627
|
-
|
|
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 ${
|
|
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(
|
|
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=
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2574
|
-
|
|
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
|
-
|
|
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
|
+
});
|
package/lib/gate-coverage.js
CHANGED
|
@@ -246,13 +246,55 @@ function readVitestThresholds(repoRoot) {
|
|
|
246
246
|
}
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
-
/**
|
|
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
|
-
|
|
255
|
-
|
|
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
|
};
|
package/lib/gate-runner.js
CHANGED
|
@@ -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
|
}
|
package/mock-policy/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MOCK-POLICY KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
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.
|
package/mutation/AGENTS.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
# SRC/MUTATION KNOWLEDGE BASE
|
|
2
2
|
|
|
3
|
-
**Generated:** 2026-10-
|
|
4
|
-
**Commit:**
|
|
3
|
+
**Generated:** 2026-10-31
|
|
4
|
+
**Commit:** 26a741de
|
|
5
5
|
**Branch:** main
|
|
6
|
-
**Version:** 0.35.
|
|
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.
|