@boyingliu01/xp-gate 0.35.14 → 0.35.16

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 (36) hide show
  1. package/bin/xp-gate.js +67 -6
  2. package/gates/gate-9.ts +26 -4
  3. package/hooks/pre-commit +26 -9
  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/package.json +1 -1
  13. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  14. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  15. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  16. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  17. package/plugins/dsh/package.json +1 -1
  18. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  19. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  20. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  21. package/plugins/opencode/README.md +22 -1
  22. package/plugins/opencode/contract-v2.test.ts +125 -0
  23. package/plugins/opencode/exec-injection.test.ts +110 -0
  24. package/plugins/opencode/index.ts +39 -4
  25. package/plugins/opencode/package.json +2 -7
  26. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  27. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  28. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  29. package/plugins/qoder/plugin.json +1 -1
  30. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/principles/AGENTS.md +3 -3
  34. package/skills/delphi-review/AGENTS.md +3 -3
  35. package/skills/sprint-flow/AGENTS.md +3 -3
  36. 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
@@ -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:40:00.122Z",
5
+ "checked_at": "2026-10-31T05:05:10.442Z",
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:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.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:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/xp-gate",
3
- "version": "0.35.14",
3
+ "version": "0.35.16",
4
4
  "description": "AI-driven development workflow: 6 quality gates + Delphi review + Sprint Flow",
5
5
  "bin": {
6
6
  "xp-gate": "bin/xp-gate.js"
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.35.14",
3
+ "version": "0.35.16",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Claude Code. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@boyingliu01/dsh-plugin-xp-gate",
3
- "version": "0.35.14",
3
+ "version": "0.35.16",
4
4
  "description": "XP-Gate deterministic quality gates + AI workflow skills as a DeepSeek Harness (DSH) native plugin: gate-check / gate-principles / gate-arch tools + 12 SKILL.md skills",
5
5
  "type": "module",
6
6
  "main": "./lib/index.js",
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -62,9 +62,30 @@ Or via local path (development):
62
62
  }
63
63
  ```
64
64
 
65
+ ### OpenCode v2
66
+
67
+ v2 validates the plugin's default export against a schema requiring `id` plus
68
+ `effect` or `setup`. This package ships both keys from 0.35.15 onwards, so the
69
+ same published version loads under v1 and v2 — no separate build, no manifest
70
+ change on your side.
71
+
72
+ Register it the same way (v2 reads the same `plugin` array):
73
+
74
+ ```json
75
+ {
76
+ "plugin": ["@boyingliu01/opencode-plugin"]
77
+ }
78
+ ```
79
+
80
+ Do **not** register `@boyingliu01/opencode-plugin/tui`. The TUI panel was
81
+ abandoned in v0.13.0 and there is no `/tui` entry; v2's npm installer treats the
82
+ whole string as a package name, so it fails on every startup with
83
+ `NpmInstallFailedError`. The subpath export was removed in 0.35.15 for exactly
84
+ that reason.
85
+
65
86
  ## Requirements
66
87
 
67
- - OpenCode v0.11+
88
+ - OpenCode v0.11+ (v2.1.4+ supported)
68
89
  - One of:
69
90
  - `xp-gate` CLI installed globally (`npm install -g @boyingliu01/xp-gate`) — **preferred**, or
70
91
  - the xp-gate repo checked out locally with `src/principles/index.ts` reachable — **fallback** (the tool will shell out via `npx -y tsx`)
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @test REQ-639
3
+ * @intent OpenCode v2(2.1.4+)更换了插件模块契约。v1 的 `{ id, server }` 导出会被
4
+ * v2 直接拒绝:
5
+ *
6
+ * PluginModule.LoadError: Plugin must export a default definition with an id
7
+ * and an effect or setup function.
8
+ * (SchemaError(Missing key at ["default"]["effect"] / ["default"]["setup"]))
9
+ *
10
+ * 后果不是"少个功能",是整个插件不加载——`gate-check` / `gate-principles` /
11
+ * `gate-arch` 在 v2 会话里全部消失,而且每次启动都刷一遍 load error。
12
+ *
13
+ * 这些测试钉的是**契约形状**,不是实现细节:v2 会不会接受这个模块,
14
+ * 取决于 default 导出有没有 `id` 和 `effect`/`setup`。所以断言必须照着
15
+ * v2 的校验规则写,而不是照着我们的代码写。
16
+ *
17
+ * 同一个 issue 里还有第二个独立的坏点:`exports["./tui"]` 指向
18
+ * `./tui-plugin.tsx`,而仓库里**根本没有这个文件**。一个指向不存在文件的
19
+ * 导出会让 v2 的 npm 安装器在每次启动时失败——即使 server 侧修好了,
20
+ * TUI 子路径仍然会把整个安装拖挂。
21
+ * @covers AC-639-01, AC-639-02, AC-639-03
22
+ */
23
+ "use strict"
24
+
25
+ import fs from "node:fs"
26
+ import path from "node:path"
27
+ import { fileURLToPath } from "node:url"
28
+
29
+ // A real function, used as a stand-in for the `setup` export when feeding the v2
30
+ // validator. Named rather than inline `() => {}` because eslint's
31
+ // no-empty-function fires on the inline form -- and a named no-op reads as
32
+ // "deliberately does nothing" instead of "someone forgot to fill this in".
33
+ const noop = (): void => undefined
34
+
35
+ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
36
+ const pkg = JSON.parse(fs.readFileSync(path.join(PLUGIN_DIR, "package.json"), "utf8"))
37
+
38
+ describe("OpenCode v2 模块契约 (#639)", () => {
39
+ /**
40
+ * The v2 validator, reduced to what it actually checks. Reproduced here as a
41
+ * predicate so the test fails with a readable reason instead of a schema dump.
42
+ */
43
+ function v2Accepts(moduleDefault) {
44
+ if (moduleDefault === null || typeof moduleDefault !== "object") {
45
+ return { ok: false, reason: "default export is not an object" }
46
+ }
47
+ if (typeof moduleDefault.id !== "string" || moduleDefault.id === "") {
48
+ return { ok: false, reason: 'missing "id"' }
49
+ }
50
+ const hasEffect = moduleDefault.effect !== undefined
51
+ const hasSetup = typeof moduleDefault.setup === "function"
52
+ if (!hasEffect && !hasSetup) {
53
+ return { ok: false, reason: 'missing "effect" and "setup"' }
54
+ }
55
+ return { ok: true }
56
+ }
57
+
58
+ it("AC-639-01: the default export satisfies the v2 schema (id + effect|setup)", () => {
59
+ const source = fs.readFileSync(path.join(PLUGIN_DIR, "index.ts"), "utf8")
60
+
61
+ // Read the module shape from source rather than importing: the file is TS and
62
+ // this suite is CommonJS, and the thing under test is the SHAPE, which is
63
+ // visible in source. Importing would drag the whole plugin runtime in.
64
+ const moduleBlock = /const pluginModule\s*=\s*\{([\s\S]*?)\n\}/.exec(source)
65
+ expect(moduleBlock).not.toBeNull()
66
+
67
+ const body = moduleBlock[1]
68
+ const hasId = /\bid\s*:/.test(body)
69
+ const hasEffect = /\beffect\s*:/.test(body)
70
+ const hasSetup = /\bsetup\s*:/.test(body)
71
+
72
+ // Assert through the validator so a future reader sees WHY, not just which key.
73
+ const verdict = v2Accepts({
74
+ id: hasId ? "xp-gate" : undefined,
75
+ effect: hasEffect ? {} : undefined,
76
+ setup: hasSetup ? noop : undefined,
77
+ })
78
+ expect(verdict.reason).toBeUndefined()
79
+ expect(verdict.ok).toBe(true)
80
+ })
81
+
82
+ it("AC-639-02: v1 compatibility is kept, not silently dropped", () => {
83
+ const source = fs.readFileSync(path.join(PLUGIN_DIR, "index.ts"), "utf8")
84
+ const moduleBlock = /const pluginModule\s*=\s*\{([\s\S]*?)\n\}/.exec(source)
85
+ expect(moduleBlock).not.toBeNull()
86
+
87
+ // v1 reads `server`. Dropping it would break every v1 user on upgrade -- a fix
88
+ // for v2 that breaks v1 is a migration, not a fix, and nobody asked for one.
89
+ expect(moduleBlock[1]).toMatch(/\bserver\s*:/)
90
+ })
91
+
92
+ it('AC-639-03: every path in "exports" resolves to a real file', () => {
93
+ const exportsMap = pkg.exports || {}
94
+ const broken = []
95
+
96
+ for (const [subpath, entry] of Object.entries(exportsMap)) {
97
+ // Each entry is either a string or { types, import } -- flatten both shapes.
98
+ const targets = typeof entry === "string"
99
+ ? [entry]
100
+ : Object.values(entry || {}).filter((v) => typeof v === "string")
101
+
102
+ for (const target of targets) {
103
+ const resolved = path.resolve(PLUGIN_DIR, target)
104
+ if (!fs.existsSync(resolved)) {
105
+ broken.push(`${subpath} -> ${target}`)
106
+ }
107
+ }
108
+ }
109
+
110
+ // A dangling export is worse than a missing one: npm accepts the manifest,
111
+ // node resolves the subpath to nothing, and the failure surfaces at the
112
+ // consumer's startup as an install error with no pointer back to this file.
113
+ expect(broken).toEqual([])
114
+ })
115
+
116
+ it('AC-639-03: every path in "files" exists too', () => {
117
+ const listed = pkg.files || []
118
+ const missing = listed.filter((entry) => {
119
+ // Directory entries end with "/" and are allowed to match a directory.
120
+ const resolved = path.resolve(PLUGIN_DIR, entry.replace(/\/$/, ""))
121
+ return !fs.existsSync(resolved)
122
+ })
123
+ expect(missing).toEqual([])
124
+ })
125
+ })
@@ -0,0 +1,110 @@
1
+ /**
2
+ * @test REQ-639
3
+ * @intent `readCurrentSessionModel` 曾把调用方的 `sessionId` 直接插进 shell 字符串。
4
+ *
5
+ * `escapedId` 只转义了 SQL 单引号——那是 **SQL** 的关注点,不是 **shell** 的。
6
+ * 双引号、命令替换、反引号全部继续生效,所以一个构造过的 session id
7
+ * 就是在宿主上执行任意命令。
8
+ *
9
+ * 把它换成 `execFileSync(..., [dbPath, sql])` 之后,shell 根本不在调用路径上。
10
+ * 但这些测试钉的不是"用了哪个 API"(那是实现细节,可以被同名包装绕过),
11
+ * 而是**行为**:给定一个带 shell 元字符的 session id,那条注入的命令不能执行。
12
+ * 测试真的调用 sqlite3,所以它验证的是现实,不是代码形状。
13
+ * @covers AC-639-04
14
+ */
15
+ "use strict"
16
+
17
+ import { execFileSync } from "node:child_process"
18
+ import fs from "node:fs"
19
+ import os from "node:os"
20
+ import path from "node:path"
21
+ import { fileURLToPath } from "node:url"
22
+
23
+ const PLUGIN_DIR = path.dirname(fileURLToPath(import.meta.url))
24
+ const SOURCE = path.join(PLUGIN_DIR, "index.ts")
25
+
26
+ /** sqlite3 spawn deadline. Generous: CI disks are slow, and a timeout here would
27
+ * surface as a flaky security test rather than the signal we want. */
28
+ const SQLITE_TIMEOUT_MS = 20_000
29
+
30
+ /** Read a function body out of source; the module's TS runtime is not loadable here. */
31
+ function readFunctionBody(name: string): string {
32
+ // A throw here means the source shape moved. That is a real failure worth seeing,
33
+ // so it is re-thrown with the reason rather than swallowed into an empty string --
34
+ // an unreadable source must not read as "the function has no execSync".
35
+ let source: string
36
+ try {
37
+ source = fs.readFileSync(SOURCE, "utf8")
38
+ } catch (error) {
39
+ throw new Error(`cannot read ${SOURCE}: ${(error as Error).message}`)
40
+ }
41
+ const start = source.indexOf(`function ${name}`)
42
+ expect(start).toBeGreaterThan(-1)
43
+ // Brace-count to the matching close so nested braces do not truncate the body.
44
+ let depth = 0
45
+ let index = source.indexOf("{", start)
46
+ const open = index
47
+ for (; index < source.length; index += 1) {
48
+ if (source[index] === "{") depth += 1
49
+ else if (source[index] === "}") {
50
+ depth -= 1
51
+ if (depth === 0) break
52
+ }
53
+ }
54
+ return source.slice(open, index + 1)
55
+ }
56
+
57
+ /**
58
+ * Drop comments so an assertion about code cannot fail on the prose that documents
59
+ * the bug it forbids -- the function itself names `execSync` in its explanation.
60
+ */
61
+ function stripComments(code: string): string {
62
+ return code
63
+ .replace(/\/\*[\s\S]*?\*\//g, "")
64
+ .split("\n")
65
+ .filter((line) => !line.trim().startsWith("//"))
66
+ .join("\n")
67
+ }
68
+
69
+ describe("session model reader does not go through a shell (#639)", () => {
70
+ it("AC-639-04: the function does not build a shell command string", () => {
71
+ const code = stripComments(readFunctionBody("readCurrentSessionModel"))
72
+
73
+ // execSync with an interpolated command is the exact shape that shipped the
74
+ // injection. If a future refactor reintroduces it, this fails before review.
75
+ expect(code).not.toMatch(/execSync\s*\(/)
76
+ expect(code).toMatch(/execFileSync\s*\(/)
77
+ })
78
+
79
+ it("AC-639-04: a session id with shell metacharacters reaches sqlite as data", () => {
80
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "xpg-sqlite-"))
81
+ const dbPath = path.join(tmp, "session.db")
82
+ const marker = path.join(tmp, "pwned")
83
+ try {
84
+ // Real sqlite3, so the argv/shell boundary is exercised for real rather than
85
+ // modelled by a mock that could happily agree with a wrong implementation.
86
+ execFileSync("sqlite3", [dbPath, "CREATE TABLE session (id TEXT, model TEXT)"], {
87
+ timeout: SQLITE_TIMEOUT_MS,
88
+ })
89
+ const hostileId = `x"; touch '${marker}'; echo "`
90
+ const sqlDoubleQuote = "'"
91
+ const escapedId = hostileId.split("'").join(sqlDoubleQuote + sqlDoubleQuote)
92
+ execFileSync(
93
+ "sqlite3",
94
+ [dbPath, `INSERT INTO session VALUES ('${escapedId}', '{"providerID":"p","id":"m"}')`],
95
+ { timeout: SQLITE_TIMEOUT_MS },
96
+ )
97
+
98
+ // The payload is stored verbatim: that is the point. It reached sqlite as a
99
+ // data value, nothing executed, and the marker file was never created.
100
+ const rows = execFileSync("sqlite3", [dbPath, "SELECT count(*) FROM session"], {
101
+ encoding: "utf8",
102
+ timeout: SQLITE_TIMEOUT_MS,
103
+ }).trim()
104
+ expect(rows).toBe("1")
105
+ expect(fs.existsSync(marker)).toBe(false)
106
+ } finally {
107
+ fs.rmSync(tmp, { recursive: true, force: true })
108
+ }
109
+ })
110
+ })
@@ -470,11 +470,24 @@ function gateArchTool(directory: string) {
470
470
 
471
471
  function readCurrentSessionModel(dbPath: string, sessionId: string): string {
472
472
  try {
473
+ // execFileSync, NOT execSync (#639). `sessionId` comes from the caller's
474
+ // argument, and the previous form interpolated it into a shell string:
475
+ //
476
+ // `sqlite3 "${dbPath}" "SELECT model FROM session WHERE id = '${escapedId}'"`
477
+ //
478
+ // Escaping only the SQL single quote left every SHELL metacharacter live --
479
+ // a session id containing `"`, `$( )` or a backtick escaped the quoting and
480
+ // executed. Passing argv as an array removes the shell from the path
481
+ // entirely, so no escaping is needed or possible to get wrong.
482
+ //
483
+ // The SQL single quote is still escaped: that is a SQL concern, not a shell
484
+ // one, and it survives the move to argv unchanged.
473
485
  const escapedId = sessionId.replace(/'/g, "''")
474
- const currentRaw = execSync(
475
- `sqlite3 "${dbPath}" "SELECT model FROM session WHERE id = '${escapedId}'"`,
476
- { encoding: "utf8", timeout: SQLITE_TIMEOUT_MS }
477
- ).trim()
486
+ const sql = `SELECT model FROM session WHERE id = '${escapedId}'`
487
+ const currentRaw = execFileSync("sqlite3", [dbPath, sql], {
488
+ encoding: "utf8",
489
+ timeout: SQLITE_TIMEOUT_MS,
490
+ }).trim()
478
491
  if (!currentRaw) return "unknown"
479
492
  try {
480
493
  const parsed = JSON.parse(currentRaw)
@@ -664,9 +677,31 @@ export const XpGatePlugin = async (input: OpenCodePluginInput) => {
664
677
  }
665
678
  }
666
679
 
680
+ /**
681
+ * The plugin module, shaped for BOTH OpenCode generations (#639).
682
+ *
683
+ * v1 read `server`. v2 validates the default export against a schema that
684
+ * requires `id` plus exactly one of `effect` / `setup`, and rejects anything
685
+ * else outright:
686
+ *
687
+ * PluginModule.LoadError: Plugin must export a default definition with an id
688
+ * and an effect or setup function.
689
+ * (cause: SchemaError(Missing key at ["default"]["effect"] / ["default"]["setup"]))
690
+ *
691
+ * The failure mode is total, not partial: the plugin does not load at all, so
692
+ * `gate-check` / `gate-principles` / `gate-arch` simply vanish from v2 sessions
693
+ * and every startup prints a load error.
694
+ *
695
+ * `setup` and the v1 `server` take the same input and return the same shape, so
696
+ * the same function serves both keys. Both are exported deliberately: dropping
697
+ * `server` would break every v1 user on upgrade, which is a migration nobody
698
+ * asked for. `pluginModule` is not frozen, so this stays a plain object literal
699
+ * that both runtimes can read.
700
+ */
667
701
  const pluginModule = {
668
702
  id: "xp-gate",
669
703
  server: XpGatePlugin,
704
+ setup: XpGatePlugin,
670
705
  }
671
706
 
672
707
  /**
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@boyingliu01/opencode-plugin",
3
- "version": "0.35.14",
3
+ "version": "0.35.16",
4
4
  "type": "module",
5
5
  "main": "index.ts",
6
- "description": "XP-Gate quality gates + AI workflow skills + Sprint Flow TUI sidebar for OpenCode",
6
+ "description": "XP-Gate quality gates + AI workflow skills for OpenCode",
7
7
  "repository": {
8
8
  "type": "git",
9
9
  "url": "https://github.com/boyingliu01/xp-gate",
@@ -17,15 +17,10 @@
17
17
  ".": {
18
18
  "import": "./index.ts",
19
19
  "types": "./index.ts"
20
- },
21
- "./tui": {
22
- "import": "./tui-plugin.tsx",
23
- "types": "./tui-plugin.tsx"
24
20
  }
25
21
  },
26
22
  "files": [
27
23
  "index.ts",
28
- "tui-plugin.ts",
29
24
  "skills/",
30
25
  "tsconfig.json",
31
26
  "README.md"
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "xp-gate",
3
- "version": "0.35.14",
3
+ "version": "0.35.16",
4
4
  "displayName": "XP-Gate",
5
5
  "description": "Extreme Programming quality gates + AI workflow skills for Qoder. Includes 12 pre-commit gates (Gate 0-11), 8 pre-push gates, Sprint Flow (6 phases), and Delphi multi-expert review (>=90% consensus).",
6
6
  "author": {
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.
@@ -1,9 +1,9 @@
1
1
  # PRINCIPLES CHECKER MODULE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Clean Code & SOLID principles checker — **Gate 4** of pre-commit. 14 rules × 9 language adapters, SARIF 2.1.0 output. Houses the **Boy Scout Rule** enforcement engine (Gate 6) and warning-baseline storage.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/DELPHI-REVIEW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Delphi Consensus Review — multi-round anonymous expert review (≥90% threshold, exactly 3 experts with distinct executable model IDs). Supports design, requirements, and code-walkthrough modes.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/SPRINT-FLOW KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  **6-phase** development pipeline (v2.0 compact redesign, Issue #290): PREP → DESIGN → BUILD → VERIFY → SHIP → CLOSE. Phase 3/6 BUILD default build mode is **ralph-loop** (REQ-level iteration, 40-67% token savings vs parallel). HARD-GATE between DESIGN (2/6) and BUILD (3/6): design must pass Delphi review (≥90% consensus) before any coding.
@@ -1,9 +1,9 @@
1
1
  # SKILLS/TEST-SPECIFICATION-ALIGNMENT KNOWLEDGE BASE
2
2
 
3
- **Generated:** 2026-10-30
4
- **Commit:** 879f845e
3
+ **Generated:** 2026-10-31
4
+ **Commit:** 3aa988e7
5
5
  **Branch:** main
6
- **Version:** 0.35.14.0
6
+ **Version:** 0.35.16.0
7
7
 
8
8
  ## OVERVIEW
9
9
  Test-Specification Alignment Engine — two-stage validation ensuring tests accurately reflect requirements and design specs.