@boyingliu01/xp-gate 0.35.5 → 0.35.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/bin/xp-gate.js +61 -1
  2. package/hooks/lib/assertion-quality.sh +47 -0
  3. package/hooks/pre-commit +54 -8
  4. package/lib/__tests__/assertion-debt.test.js +99 -0
  5. package/lib/__tests__/check-single-file.test.js +63 -0
  6. package/lib/__tests__/doctor-brief.test.js +59 -0
  7. package/lib/__tests__/gate-rules.test.js +58 -0
  8. package/lib/assertion-debt.js +173 -0
  9. package/lib/doctor-brief.js +159 -0
  10. package/lib/doctor.js +23 -3
  11. package/lib/gate-rules.js +149 -0
  12. package/lib/gate-runner.js +49 -3
  13. package/lib/mattpocock-license-verdict.json +1 -1
  14. package/mock-policy/AGENTS.md +2 -2
  15. package/mutation/AGENTS.md +2 -2
  16. package/package.json +1 -1
  17. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  18. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  19. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  20. package/plugins/claude-code/skills/sprint-flow/references/phase-3-build.md +14 -1
  21. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  22. package/plugins/dsh/package.json +1 -1
  23. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  24. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  25. package/plugins/dsh/skills/sprint-flow/references/phase-3-build.md +14 -1
  26. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  27. package/plugins/opencode/package.json +1 -1
  28. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  29. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  30. package/plugins/opencode/skills/sprint-flow/references/phase-3-build.md +14 -1
  31. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  32. package/plugins/qoder/plugin.json +1 -1
  33. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  34. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  35. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  36. package/principles/AGENTS.md +2 -2
  37. package/skills/delphi-review/AGENTS.md +2 -2
  38. package/skills/sprint-flow/AGENTS.md +2 -2
  39. package/skills/sprint-flow/references/phase-3-build.md +14 -1
  40. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -0,0 +1,173 @@
1
+ /**
2
+ * `xp-gate audit assertions` — make pre-existing assertion debt visible (#668).
3
+ *
4
+ * Gate 5d deliberately checks only the files in the diff: a full-repo scan on
5
+ * every commit is too slow. That has an expensive side effect. Pre-existing debt
6
+ * blocks unrelated changes — the author who fixed `writeReport()`'s error handling
7
+ * was stopped by four long-standing `expect(true).toBe(true)` lines in a file they
8
+ * had merely touched — and the block reads as "this change introduced a problem".
9
+ *
10
+ * The worst consequence is not that one false attribution. It is what it does to
11
+ * behaviour: noise in the attribution makes people reach for
12
+ * `@assertion-justified` instead of fixing the test. A gate that teaches people to
13
+ * annotate their way past it is worse than no gate. Hence a batch work queue.
14
+ *
15
+ * Scope: only the two shapes Gate 5d BLOCKS (tautological, empty catch). Weak-only
16
+ * assertions and mock echoes WARN there because each can be legitimate; putting them
17
+ * in a "debt to clear" list would invent work.
18
+ */
19
+ 'use strict';
20
+
21
+ const fs = require('node:fs');
22
+ const path = require('node:path');
23
+
24
+ /** Directories that never contain hand-written assertions worth auditing. */
25
+ const SKIP_DIRS = new Set([
26
+ 'node_modules',
27
+ '.git',
28
+ 'dist',
29
+ 'build',
30
+ 'coverage',
31
+ '.stryker-tmp',
32
+ '.agent-tmp',
33
+ // Build output mirrored from src/ — listing it again would double-count debt and
34
+ // send a batch fixer to a file that regenerates.
35
+ '.xp-gate',
36
+ ]);
37
+
38
+ /** Test-file patterns across the ecosystems XP-Gate supports. */
39
+ function isTestFile(name) {
40
+ return (
41
+ /\.(test|spec)\.[cm]?[jt]sx?$/.test(name) ||
42
+ /(^|\/)(__tests__)\//.test(name) ||
43
+ /\.test\.bats$/.test(name) ||
44
+ /^test_.*\.py$/.test(name) ||
45
+ /_test\.(go|rs|py)$/.test(name)
46
+ );
47
+ }
48
+
49
+ /**
50
+ * Tautological assertions: the assertion cannot fail.
51
+ *
52
+ * Mirrors `classify_tautological_assertions` in githooks/lib/assertion-quality.sh.
53
+ * The two implementations must agree, because a debt list that disagrees with the
54
+ * gate it exists to serve sends people to fix lines that are not blocking and miss
55
+ * the ones that are.
56
+ */
57
+ const TAUTOLOGY_PATTERNS = [
58
+ // (a) identical literal on both sides.
59
+ {
60
+ re: /expect\s*\(\s*(true|false|null|undefined|-?\d+|"[^"]*"|'[^']*')\s*\)\s*\.\s*to(?:Be|Equal|StrictEqual)\s*\(\s*\1\s*\)/,
61
+ },
62
+ // (b) a literal tested against the truthiness it already has.
63
+ { re: /expect\s*\(\s*(?:true|1|-1|"[^"]+"|'[^']+')\s*\)\s*\.\s*toBeTruthy\s*\(/ },
64
+ { re: /expect\s*\(\s*(?:false|0|null|undefined|""|'')\s*\)\s*\.\s*toBeFalsy\s*\(/ },
65
+ ];
66
+
67
+ /** An empty catch body: the failure it guards is discarded. */
68
+ const EMPTY_CATCH_RE = /catch\s*(?:\([^)]*\))?\s*\{\s*\}/;
69
+
70
+ /**
71
+ * Is this line a fixture rather than a real assertion?
72
+ *
73
+ * A `.bats` test that exercises the Gate 5d classifier necessarily contains
74
+ * `'expect(true).toBe(true);'` as a quoted INPUT. Reporting it as debt would send a
75
+ * batch fixer to delete the very strings that pin the classifier — turning the
76
+ * "fix debt" instruction into an instruction to remove test coverage.
77
+ *
78
+ * The distinguishing feature is quoting: in a fixture the shape is wrapped in
79
+ * quotes and usually sits inside a list/assignment, where a real assertion is bare
80
+ * executable code.
81
+ */
82
+ function isFixtureLine(trimmed) {
83
+ // A leading quote before the assertion shape: `'expect(...)...'` or "expect(...)".
84
+ if (/^['"`]/.test(trimmed)) return true;
85
+ // Assertion shape appearing inside a quoted string on the line.
86
+ if (/['"][^'"`]*expect\s*\(/.test(trimmed)) return true;
87
+ return false;
88
+ }
89
+
90
+ /** Classify one source line, or null when it is fine. */
91
+ function classifyLine(text) {
92
+ const trimmed = text.trim();
93
+ if (trimmed === '') return null;
94
+ // A commented-out assertion is not an assertion.
95
+ if (/^(\/\/|\*|\/\*|#)/.test(trimmed)) return null;
96
+ if (isFixtureLine(trimmed)) return null;
97
+ for (const { re } of TAUTOLOGY_PATTERNS) {
98
+ if (re.test(trimmed)) return 'tautological';
99
+ }
100
+ if (EMPTY_CATCH_RE.test(trimmed)) return 'swallowed-error';
101
+ return null;
102
+ }
103
+
104
+ /** Find debt in one file's text. */
105
+ function scanSource(text, file) {
106
+ const findings = [];
107
+ const lines = text.split(/\r?\n/);
108
+ for (let i = 0; i < lines.length; i += 1) {
109
+ const kind = classifyLine(lines[i]);
110
+ if (kind) findings.push({ line: i + 1, kind, text: lines[i].trim() });
111
+ }
112
+ return findings.length > 0 ? [{ file, findings }] : [];
113
+ }
114
+
115
+ /** Walk the tree collecting test files, bounded to avoid node_modules. */
116
+ function listTestFiles(root, out = []) {
117
+ let entries;
118
+ try {
119
+ entries = fs.readdirSync(root, { withFileTypes: true });
120
+ } catch {
121
+ return out;
122
+ }
123
+ if (!Array.isArray(entries)) return out;
124
+ for (const entry of entries) {
125
+ if (entry.isDirectory()) {
126
+ if (SKIP_DIRS.has(entry.name)) continue;
127
+ listTestFiles(path.join(root, entry.name), out);
128
+ continue;
129
+ }
130
+ if (isTestFile(entry.name)) out.push(path.join(root, entry.name));
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /**
136
+ * Scan for assertion debt.
137
+ *
138
+ * @param {{cwd?: string, source?: string, file?: string}} opts
139
+ * @returns {Array<{file: string, findings: Array<{line: number, kind: string, text: string}>}>}
140
+ */
141
+ function scanForAssertionDebt(opts = {}) {
142
+ if (typeof opts.source === 'string') {
143
+ return scanSource(opts.source, opts.file || '<inline>');
144
+ }
145
+ const root = opts.cwd || process.cwd();
146
+ const results = [];
147
+ for (const abs of listTestFiles(root)) {
148
+ let text;
149
+ try {
150
+ text = fs.readFileSync(abs, 'utf8');
151
+ } catch {
152
+ continue;
153
+ }
154
+ const rel = path.relative(root, abs).split(path.sep).join('/');
155
+ results.push(...scanSource(text, rel));
156
+ }
157
+ return results.sort((a, b) => b.findings.length - a.findings.length || a.file.localeCompare(b.file));
158
+ }
159
+
160
+ /** Totals for a debt run: per-kind counts plus the file count to batch over. */
161
+ function summarizeDebt(results) {
162
+ const byKind = { tautological: 0, 'swallowed-error': 0 };
163
+ let total = 0;
164
+ for (const entry of results) {
165
+ for (const finding of entry.findings) {
166
+ byKind[finding.kind] = (byKind[finding.kind] || 0) + 1;
167
+ total += 1;
168
+ }
169
+ }
170
+ return { files: results.length, total, byKind };
171
+ }
172
+
173
+ module.exports = { classifyLine, scanForAssertionDebt, summarizeDebt, isTestFile };
@@ -0,0 +1,159 @@
1
+ /**
2
+ * `xp-gate doctor --brief` — one command that answers every common environment
3
+ * probe (#653).
4
+ *
5
+ * Why this exists: 32.7% of shell calls in a working session were pure probing
6
+ * (`where gcc`, `Test-Path`, per-file `Get-Content`, version checks), at ~16s
7
+ * each. The common thread is that the answers barely change, yet every question
8
+ * costs a full round trip. The fix is not to print less — it is to ask once.
9
+ *
10
+ * Two rules this module holds itself to:
11
+ *
12
+ * 1. **Read-only.** `--brief` is meant to be called often precisely because it is
13
+ * cheap. Anything that writes would make frequent calling compound state
14
+ * changes instead of only latency.
15
+ * 2. **Structured first, summary second.** A model that has to regex a sentence
16
+ * has not saved a round trip. `facts` carries the data; `summary` is a
17
+ * convenience for humans and nothing depends on it.
18
+ *
19
+ * No runtime dependencies: this ships inside the zero-install package.
20
+ */
21
+ 'use strict';
22
+
23
+ const fs = require('node:fs');
24
+ const path = require('node:path');
25
+ const { execFileSync } = require('node:child_process');
26
+
27
+ /** The fact groups `--brief` promises. Tests pin this list; adding a group here
28
+ * without teaching the model to use it only adds output, so keep it deliberate. */
29
+ const BRIEF_FACTS = ['hooks', 'language', 'adapters', 'coverage', 'gateState'];
30
+
31
+ /** Run a command, returning trimmed stdout or null. Never throws. */
32
+ function tryExec(cmd, args, cwd) {
33
+ try {
34
+ const out = execFileSync(cmd, args, {
35
+ cwd,
36
+ encoding: 'utf8',
37
+ timeout: 5000,
38
+ stdio: ['ignore', 'pipe', 'ignore'],
39
+ });
40
+ return out.trim();
41
+ } catch {
42
+ return null;
43
+ }
44
+ }
45
+
46
+ /** Which hooks actually execute, and where they live. */
47
+ function readHooks(cwd) {
48
+ const hooksPath = tryExec('git', ['config', '--get', 'core.hooksPath'], cwd);
49
+ const effective = hooksPath || '.git/hooks';
50
+ const dir = path.isAbsolute(effective) ? effective : path.join(cwd, effective);
51
+ let installed = [];
52
+ try {
53
+ installed = fs.readdirSync(dir).filter((name) => !name.endsWith('.sample'));
54
+ } catch {
55
+ installed = [];
56
+ }
57
+ return {
58
+ hooksPath: hooksPath || null,
59
+ effectivePath: dir,
60
+ installed,
61
+ // #451: when core.hooksPath is set, the repo copy is NOT what runs.
62
+ globalOverride: Boolean(hooksPath),
63
+ };
64
+ }
65
+
66
+ /** Project language markers, cheapest signal first. */
67
+ const LANGUAGE_MARKERS = [
68
+ ['typescript', ['tsconfig.json']],
69
+ ['python', ['pyproject.toml', 'setup.py', 'requirements.txt']],
70
+ ['go', ['go.mod']],
71
+ ['java', ['pom.xml', 'build.gradle', 'build.gradle.kts']],
72
+ ['kotlin', ['build.gradle.kts']],
73
+ ['dart', ['pubspec.yaml']],
74
+ ['rust', ['Cargo.toml']],
75
+ ];
76
+
77
+ function detectLanguages(cwd) {
78
+ const found = [];
79
+ for (const [lang, markers] of LANGUAGE_MARKERS) {
80
+ if (markers.some((m) => fs.existsSync(path.join(cwd, m)))) found.push(lang);
81
+ }
82
+ return { detected: found, primary: found[0] || null };
83
+ }
84
+
85
+ /** Which adapters are reachable via the documented 3-tier resolution. */
86
+ function detectAdapters(cwd) {
87
+ const tiers = [
88
+ path.join(process.env.HOME || process.env.USERPROFILE || '', '.config', 'xp-gate', 'adapters'),
89
+ path.join(cwd, 'githooks', 'adapters'),
90
+ ];
91
+ const seen = new Set();
92
+ const sources = [];
93
+ for (const dir of tiers) {
94
+ let names = [];
95
+ try {
96
+ names = fs.readdirSync(dir).filter((n) => n.endsWith('.sh'));
97
+ } catch {
98
+ continue;
99
+ }
100
+ for (const n of names) seen.add(n);
101
+ sources.push({ dir, count: names.length });
102
+ }
103
+ return { count: seen.size, sources };
104
+ }
105
+
106
+ /** Where coverage would come from, if anywhere. */
107
+ const COVERAGE_CANDIDATES = [
108
+ 'coverage/coverage-final.json',
109
+ 'coverage/lcov.info',
110
+ 'target/site/jacoco/jacoco.xml',
111
+ 'build/reports/jacoco/test/jacocoTestReport.xml',
112
+ ];
113
+
114
+ function detectCoverage(cwd) {
115
+ const hits = COVERAGE_CANDIDATES.filter((rel) => fs.existsSync(path.join(cwd, rel)));
116
+ return { found: hits.length > 0, files: hits };
117
+ }
118
+
119
+ /** Last gate artifact, so callers stop re-reading `.xp-gate/` by hand. */
120
+ function readGateState(cwd) {
121
+ const artifact = path.join(cwd, '.xp-gate', 'quality-status', 'main.json');
122
+ try {
123
+ const parsed = JSON.parse(fs.readFileSync(artifact, 'utf8'));
124
+ return {
125
+ artifact,
126
+ commit: parsed.commit || null,
127
+ gates: Object.keys(parsed).filter((k) => k.startsWith('gate')).length,
128
+ };
129
+ } catch {
130
+ // No artifact is a fact worth stating, not an error: "never run" and
131
+ // "failed to read" would otherwise be indistinguishable.
132
+ return { artifact, commit: null, gates: 0 };
133
+ }
134
+ }
135
+
136
+ /**
137
+ * Build the brief report.
138
+ *
139
+ * @param {{cwd?: string}} opts
140
+ * @returns {{facts: object, summary: string}}
141
+ */
142
+ function buildBriefReport(opts = {}) {
143
+ const cwd = opts.cwd || process.cwd();
144
+ const facts = {
145
+ hooks: readHooks(cwd),
146
+ language: detectLanguages(cwd),
147
+ adapters: detectAdapters(cwd),
148
+ coverage: detectCoverage(cwd),
149
+ gateState: readGateState(cwd),
150
+ };
151
+ const summary = [
152
+ `lang=${facts.language.primary || 'unknown'} adapters=${facts.adapters.count} ` +
153
+ `coverage=${facts.coverage.found ? 'yes' : 'no'} hooks=${facts.hooks.globalOverride ? 'global' : 'local'}`,
154
+ `hooksPath=${facts.hooks.effectivePath}`,
155
+ ].join('\n');
156
+ return { facts, summary };
157
+ }
158
+
159
+ module.exports = { BRIEF_FACTS, buildBriefReport };
package/lib/doctor.js CHANGED
@@ -2873,7 +2873,7 @@ function diagnoseLanguageTools() {
2873
2873
  // #488: an unrecognised flag used to be silently ignored — the npm release
2874
2874
  // that predates --sync-hooks made "doctor --sync-hooks" a silent no-op and the
2875
2875
  // user believed a sync had happened. Fail loudly instead.
2876
- const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format', '--refresh-env'];
2876
+ const KNOWN_FLAGS = ['--fix', '--sync-hooks', '--force', '--install-tools', '--json', '--format', '--refresh-env', '--brief'];
2877
2877
 
2878
2878
  /**
2879
2879
  * The accepted flag surface of `doctor`, as a pure function.
@@ -2931,11 +2931,31 @@ function doctorFlagContract(args) {
2931
2931
  errors.push('--install-tools is only read by --fix, and it is the only flag that installs toolchains onto this machine');
2932
2932
  errors.push('Pass --fix --install-tools, or drop --install-tools');
2933
2933
  }
2934
- return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv };
2934
+ // #653: --brief exists to collapse a class of exploratory probes (`where gcc`,
2935
+ // Test-Path, per-file reads) into one cheap read. It is read-only by contract:
2936
+ // users call it often precisely because it is cheap, so a write smuggled in
2937
+ // would compound state changes instead of only latency.
2938
+ const briefFlag = args.includes('--brief');
2939
+ if (briefFlag && (fixMode || syncHooks || installToolsFlag || force)) {
2940
+ errors.push('--brief is a read-only fact dump and cannot be combined with --fix, --sync-hooks, --install-tools or --force');
2941
+ }
2942
+ return { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv, briefFlag };
2935
2943
  }
2936
2944
 
2937
2945
  async function doctor(args) {
2938
- const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv } = doctorFlagContract(args);
2946
+ const { errors, fixMode, syncHooks, force, installToolsFlag, jsonMode, refreshEnv, briefFlag } = doctorFlagContract(args);
2947
+ if (briefFlag && errors.length === 0) {
2948
+ // Answers the whole "what is this machine set up like" question in one shot.
2949
+ const { buildBriefReport } = require('./doctor-brief');
2950
+ const report = buildBriefReport({ cwd: process.cwd() });
2951
+ if (jsonMode) {
2952
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`);
2953
+ } else {
2954
+ process.stdout.write(`${report.summary}\n`);
2955
+ }
2956
+ process.exitCode = 0;
2957
+ return;
2958
+ }
2939
2959
  if (errors.length > 0) {
2940
2960
  for (const error of errors) console.error(error);
2941
2961
  return 1;
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Collect the gate thresholds that actually block a commit, from the files that
3
+ * define them (#652).
4
+ *
5
+ * Why generated rather than hand-written: BUILD-phase docs used to say "there are
6
+ * gates 0-11" without saying what the numbers are. The numbers live in
7
+ * `.principlesrc`, `jscpd.conf.json`, `.ccn-baseline.json` and the hook itself. A
8
+ * hand-copied table is correct on the day it is written and wrong on the day
9
+ * someone tunes a threshold — and a self-check list that lies is worse than none,
10
+ * because it teaches the wrong rule and then contradicts it at commit time.
11
+ *
12
+ * Every rule therefore carries an `evidence` pointer, so "the limit is 50" can be
13
+ * checked or argued with instead of being indistinguishable from a guess.
14
+ */
15
+ 'use strict';
16
+
17
+ const fs = require('node:fs');
18
+ const path = require('node:path');
19
+
20
+ /** Read and parse a JSON file, or null when absent/unreadable. */
21
+ function readJson(file) {
22
+ try {
23
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Normalise severity spellings.
31
+ *
32
+ * The config files and the hook do not agree: `.principlesrc` says `warning`,
33
+ * Gate 3's boundary is `warn`, and both mean the same thing. Carrying two spellings
34
+ * into one list would make it look like two different severities exist.
35
+ */
36
+ function normaliseSeverity(value) {
37
+ if (value === 'warning') return 'warn';
38
+ if (value === 'block' || value === 'warn' || value === 'info') return value;
39
+ return 'warn';
40
+ }
41
+
42
+ /** Flatten `.principlesrc` rules into entries with source + evidence. */
43
+ function principlesRules(cwd) {
44
+ const file = path.join(cwd, '.principlesrc');
45
+ const cfg = readJson(file);
46
+ if (!cfg || !cfg.rules) return [];
47
+ const out = [];
48
+ for (const group of Object.keys(cfg.rules)) {
49
+ for (const [name, rule] of Object.entries(cfg.rules[group] || {})) {
50
+ out.push({
51
+ id: `${group}.${name}`,
52
+ source: 'principles',
53
+ severity: normaliseSeverity(rule.severity),
54
+ // Not every rule is threshold-shaped (e.g. unused-imports); report the
55
+ // one that exists rather than inventing a 0.
56
+ threshold: typeof rule.threshold === 'number' ? rule.threshold : null,
57
+ evidence: '.principlesrc',
58
+ });
59
+ }
60
+ }
61
+ return out;
62
+ }
63
+
64
+ /**
65
+ * Gate 2's duplicate-code rule.
66
+ *
67
+ * `githooks/pre-commit` prints "jscpd found duplicated code (warning, not blocking
68
+ * by default)" -- this gate reports, it does not block. The BUILD-phase doc used to
69
+ * claim "jscpd similarity >5% BLOCK", which sent people to refactor for a verdict
70
+ * that never fires. A generated list must record what the hook does, not what the
71
+ * old prose said.
72
+ */
73
+ function jscpdRules(cwd) {
74
+ const cfg = readJson(path.join(cwd, 'jscpd.conf.json'));
75
+ // jscpd itself has no similarity threshold in this project's config and the hook
76
+ // never compares a percentage. Reporting null says "no limit declared" instead of
77
+ // inventing a number the hook does not enforce.
78
+ const threshold = cfg && typeof cfg.threshold === 'number' ? cfg.threshold : null;
79
+ return [
80
+ {
81
+ id: 'duplication.jscpd',
82
+ source: 'jscpd',
83
+ severity: 'info',
84
+ threshold,
85
+ evidence: 'githooks/pre-commit (Gate 2, advisory)',
86
+ },
87
+ ];
88
+ }
89
+
90
+ /** Gate 3 warn/block boundaries, which the hook itself owns. */
91
+ function ccnRules(cwd) {
92
+ return [
93
+ { id: 'ccn.warn', source: 'ccn', severity: 'warn', threshold: 5, evidence: 'githooks/pre-commit (Gate 3)' },
94
+ { id: 'ccn.block', source: 'ccn', severity: 'block', threshold: 10, evidence: 'githooks/pre-commit (Gate 3)' },
95
+ ];
96
+ }
97
+
98
+ /** Boy Scout's structural limits, read from wherever they are configured. */
99
+ function boyScoutRules(cwd) {
100
+ const out = [
101
+ {
102
+ id: 'boy-scout.new-file',
103
+ source: 'boy-scout',
104
+ severity: 'block',
105
+ threshold: 0,
106
+ evidence: 'src/principles/boy-scout.ts',
107
+ },
108
+ ];
109
+ // The per-function line and nesting limits are principles rules; when
110
+ // .principlesrc exists they are already listed by principlesRules, so only add
111
+ // here when it does not (so the BUILD list never silently loses them).
112
+ const cfg = readJson(path.join(cwd, '.principlesrc'));
113
+ if (!cfg || !cfg.rules || !cfg.rules['clean-code']) {
114
+ out.push(
115
+ { id: 'clean-code.long-function', source: 'boy-scout', severity: 'warn', threshold: 50, evidence: 'defaults' },
116
+ { id: 'clean-code.deep-nesting', source: 'boy-scout', severity: 'warn', threshold: 4, evidence: 'defaults' },
117
+ );
118
+ }
119
+ return out;
120
+ }
121
+
122
+ /**
123
+ * Collect every rule that can block or warn a commit.
124
+ *
125
+ * @param {{cwd?: string}} opts
126
+ * @returns {Array<{id: string, source: string, severity: string, threshold: number|null, evidence: string}>}
127
+ */
128
+ function collectGateRules(opts = {}) {
129
+ const cwd = opts.cwd || process.cwd();
130
+ const rules = [...principlesRules(cwd), ...jscpdRules(cwd), ...ccnRules(cwd), ...boyScoutRules(cwd)];
131
+ // Stable order: blocking first, then warn/info, so a reader stops at what hurts.
132
+ const rank = { block: 0, warn: 1, info: 2 };
133
+ return rules.sort(
134
+ (a, b) => (rank[a.severity] ?? 3) - (rank[b.severity] ?? 3) || a.id.localeCompare(b.id),
135
+ );
136
+ }
137
+
138
+ /** Render the checklist as markdown with an evidence column. */
139
+ function renderGateRules(rules) {
140
+ const lines = ['| Rule | Severity | Limit | Evidence |', '|---|---|---|---|'];
141
+ for (const rule of rules) {
142
+ lines.push(
143
+ `| \`${rule.id}\` | ${rule.severity} | ${rule.threshold === null ? '—' : rule.threshold} | \`${rule.evidence}\` |`,
144
+ );
145
+ }
146
+ return lines.join('\n');
147
+ }
148
+
149
+ module.exports = { collectGateRules, renderGateRules };
@@ -320,8 +320,49 @@ const GATE_NOT_RUN = 'NOT_RUN';
320
320
  * Finds the .ts entry point, spawns tsx with --run flag.
321
321
  * Falls back to bash script if TypeScript module not found.
322
322
  */
323
+ /**
324
+ * Resolve the working directory for a gate run.
325
+ *
326
+ * `targetPath` is documented as "file or directory" (that is what
327
+ * `xp-gate check <file>` passes), but spawnSync's `cwd` must be a directory.
328
+ * Resolving a FILE path into cwd made spawnSync fail with a bare ENOENT that read
329
+ * as "Gate 3 failed to run npx tsx" — a tool failure reported under the label of a
330
+ * code verdict. The file's own directory is the meaningful cwd in that case.
331
+ */
332
+ function resolveGateCwd(targetPath) {
333
+ if (!targetPath) return process.cwd();
334
+ const resolved = path.resolve(targetPath);
335
+ try {
336
+ if (fs.statSync(resolved).isFile()) return path.dirname(resolved);
337
+ } catch {
338
+ // Nonexistent path: leave it for the gate itself to report.
339
+ }
340
+ return resolved;
341
+ }
342
+
343
+ /**
344
+ * Resolve how to invoke npx without a shell.
345
+ *
346
+ * `shell: true` on Windows looked like the right fix for resolving npx.cmd, but
347
+ * node passes the argv to CreateProcess as one concatenated string without
348
+ * escaping (DEP0190) — so any entry path with a backslash or space produced a
349
+ * malformed command line and spawnSync failed with a bare ENOENT. The gate then
350
+ * reported FAIL while nothing had actually run: a tool failure wearing the verdict
351
+ * "your code is broken".
352
+ *
353
+ * Calling npx.cmd directly is not the fix either: since the CVE-2024-27980
354
+ * hardening, node refuses .cmd/.bat from spawnSync without a shell and returns
355
+ * EINVAL. Running npx's own cli.js with the current node binary sidesteps both —
356
+ * plain argv, no shell, no batch wrapper.
357
+ */
358
+ function resolveNpxInvocation(args) {
359
+ const npxCli = path.join(path.dirname(process.execPath), 'node_modules', 'npm', 'bin', 'npx-cli.js');
360
+ if (fs.existsSync(npxCli)) return [process.execPath, [npxCli, ...args]];
361
+ return ['npx', args];
362
+ }
363
+
323
364
  function runTsGate(gateModule, targetPath, opts = {}) {
324
- const targetCwd = targetPath ? path.resolve(targetPath) : process.cwd();
365
+ const targetCwd = resolveGateCwd(targetPath);
325
366
  const entry = findTsGateEntry(gateModule);
326
367
  if (!entry) {
327
368
  // Fallback to bash script
@@ -344,10 +385,15 @@ function runTsGate(gateModule, targetPath, opts = {}) {
344
385
  // malformed argv, and reports a killed child (timeout, signal) as a null status.
345
386
  // All three mean "the gate did not report a verdict", which must not be read as
346
387
  // success: a gate that never ran is not a gate that passed.
388
+ //
389
+ // spawnSync reports a spawn failure (npx missing) on `error`, throws only on a
390
+ // malformed argv, and reports a killed child (timeout, signal) as a null status.
391
+ // All three mean "the gate did not report a verdict", which must not be read as
392
+ // success: a gate that never ran is not a gate that passed.
393
+ const [command, commandArgs] = resolveNpxInvocation(args);
347
394
  try {
348
- const result = spawnSync('npx', args, {
395
+ const result = spawnSync(command, commandArgs, {
349
396
  stdio: 'inherit',
350
- shell: process.platform === 'win32',
351
397
  cwd: targetCwd,
352
398
  timeout: TS_GATE_TIMEOUT_MS,
353
399
  });
@@ -2,6 +2,6 @@
2
2
  "verdict": "PASS",
3
3
  "license_type": "MIT",
4
4
  "sha256": "0e7ac423bf2c6e223b7c5b156f8cf72da49d748e56a1641402c31f22ad07dbb5",
5
- "checked_at": "2026-10-30T18:00:25.743Z",
5
+ "checked_at": "2026-10-30T18:41:08.730Z",
6
6
  "source": "network"
7
7
  }
@@ -1,9 +1,9 @@
1
1
  # SRC/MOCK-POLICY KNOWLEDGE BASE
2
2
 
3
3
  **Generated:** 2026-10-30
4
- **Commit:** 4776dc7f
4
+ **Commit:** 9f8b24d8
5
5
  **Branch:** main
6
- **Version:** 0.35.5.0
6
+ **Version:** 0.35.7.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
3
  **Generated:** 2026-10-30
4
- **Commit:** 4776dc7f
4
+ **Commit:** 9f8b24d8
5
5
  **Branch:** main
6
- **Version:** 0.35.5.0
6
+ **Version:** 0.35.7.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.5",
3
+ "version": "0.35.7",
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.5",
3
+ "version": "0.35.7",
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
3
  **Generated:** 2026-10-30
4
- **Commit:** 4776dc7f
4
+ **Commit:** 9f8b24d8
5
5
  **Branch:** main
6
- **Version:** 0.35.5.0
6
+ **Version:** 0.35.7.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
3
  **Generated:** 2026-10-30
4
- **Commit:** 4776dc7f
4
+ **Commit:** 9f8b24d8
5
5
  **Branch:** main
6
- **Version:** 0.35.5.0
6
+ **Version:** 0.35.7.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.