@boyingliu01/xp-gate 0.35.16 → 0.35.18

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 (75) hide show
  1. package/gate-12-file-hygiene.sh +26 -4
  2. package/gate-13-compiler-strictness.sh +12 -0
  3. package/gates/gate-3.ts +77 -21
  4. package/gates/gate-8.ts +39 -26
  5. package/gates/gate-9.ts +4 -4
  6. package/hooks/gate-12-file-hygiene.sh +26 -4
  7. package/hooks/gate-13-compiler-strictness.sh +12 -0
  8. package/hooks/pre-commit +82 -12
  9. package/hooks/pre-push +11 -2
  10. package/lib/__tests__/doctor.test.js +9 -0
  11. package/lib/__tests__/gate-numbering.test.js +52 -0
  12. package/lib/__tests__/gate-runner.test.js +59 -14
  13. package/lib/__tests__/shared-utils.test.js +47 -0
  14. package/lib/gate-runner.js +98 -18
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/lib/shared-utils.js +27 -1
  17. package/mock-policy/AGENTS.md +2 -2
  18. package/mutation/AGENTS.md +2 -2
  19. package/mutation/runners/mutmut-runner.ts +165 -70
  20. package/package.json +1 -1
  21. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  22. package/plugins/claude-code/skills/delphi-review/AGENTS.md +2 -2
  23. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +2 -2
  24. package/plugins/claude-code/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  25. package/plugins/claude-code/skills/sprint-flow/references/definition-of-done.md +47 -0
  26. package/plugins/claude-code/skills/sprint-flow/references/phase-4-verify.md +16 -0
  27. package/plugins/claude-code/skills/sprint-flow/references/phase-5-ship.md +14 -0
  28. package/plugins/claude-code/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  29. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +2 -2
  30. package/plugins/dsh/package.json +1 -1
  31. package/plugins/dsh/skills/delphi-review/AGENTS.md +2 -2
  32. package/plugins/dsh/skills/sprint-flow/AGENTS.md +2 -2
  33. package/plugins/dsh/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  34. package/plugins/dsh/skills/sprint-flow/references/definition-of-done.md +47 -0
  35. package/plugins/dsh/skills/sprint-flow/references/phase-4-verify.md +16 -0
  36. package/plugins/dsh/skills/sprint-flow/references/phase-5-ship.md +14 -0
  37. package/plugins/dsh/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  38. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +2 -2
  39. package/plugins/dsh/src/delphi-findings.test.ts +32 -6
  40. package/plugins/dsh/src/exit-code.test.ts +6 -0
  41. package/plugins/opencode/package.json +1 -1
  42. package/plugins/opencode/skills/delphi-review/AGENTS.md +2 -2
  43. package/plugins/opencode/skills/sprint-flow/AGENTS.md +2 -2
  44. package/plugins/opencode/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  45. package/plugins/opencode/skills/sprint-flow/references/definition-of-done.md +47 -0
  46. package/plugins/opencode/skills/sprint-flow/references/phase-4-verify.md +16 -0
  47. package/plugins/opencode/skills/sprint-flow/references/phase-5-ship.md +14 -0
  48. package/plugins/opencode/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  49. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +2 -2
  50. package/plugins/qoder/plugin.json +1 -1
  51. package/plugins/qoder/skills/delphi-review/AGENTS.md +2 -2
  52. package/plugins/qoder/skills/sprint-flow/AGENTS.md +2 -2
  53. package/plugins/qoder/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  54. package/plugins/qoder/skills/sprint-flow/references/definition-of-done.md +47 -0
  55. package/plugins/qoder/skills/sprint-flow/references/phase-4-verify.md +16 -0
  56. package/plugins/qoder/skills/sprint-flow/references/phase-5-ship.md +14 -0
  57. package/plugins/qoder/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  58. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +2 -2
  59. package/principles/AGENTS.md +2 -2
  60. package/principles/adapters/import-clause.ts +57 -0
  61. package/principles/adapters/member-scan.ts +73 -0
  62. package/principles/adapters/position-analysis.ts +43 -0
  63. package/principles/adapters/quote-state.ts +80 -0
  64. package/principles/adapters/typescript.ts +57 -116
  65. package/principles/rules/clean-code/god-class.ts +37 -14
  66. package/principles/rules/solid/dip.ts +15 -2
  67. package/scripts/__tests__/sync-package-content.test.js +114 -46
  68. package/skills/delphi-review/AGENTS.md +2 -2
  69. package/skills/sprint-flow/AGENTS.md +2 -2
  70. package/skills/sprint-flow/__tests__/verify-sprint-state.test.bats +48 -0
  71. package/skills/sprint-flow/references/definition-of-done.md +47 -0
  72. package/skills/sprint-flow/references/phase-4-verify.md +16 -0
  73. package/skills/sprint-flow/references/phase-5-ship.md +14 -0
  74. package/skills/sprint-flow/scripts/verify-sprint-state.sh +14 -1
  75. package/skills/test-specification-alignment/AGENTS.md +2 -2
@@ -36,13 +36,35 @@ fi
36
36
  HYGIENE_ISSUES=0
37
37
  HYGIENE_FILES=""
38
38
 
39
+ # Is this staged file binary (so line-based checks do not apply)?
40
+ #
41
+ # This replaces `grep -q $'\x00'`, which was a no-op guard: bash strips NUL from
42
+ # `$'\x00'`, so the pattern was the EMPTY string and `grep -q ""` matches any
43
+ # non-empty input. Every staged file was therefore judged binary and `continue`d,
44
+ # silently disabling Check 1 (trailing whitespace), Check 2 (missing EOF newline)
45
+ # and -- worst -- Check 3 (merge conflict markers, a BLOCK-level check). Checks 4
46
+ # and 5 lacked the guard, which is why the gate looked half-working.
47
+ #
48
+ # Ask git instead of guessing: `--numstat` reports a binary entry as `-\t-\t<path>`
49
+ # because git already made that determination. A byte-level NUL test cannot be
50
+ # written with a shell pattern at all (variables and command substitution both drop
51
+ # NUL), and `grep -I` only suppresses output on a binary input -- it does not make
52
+ # `grep -q` fail, which is why that shortcut still reported text.
53
+ is_binary_staged_file() {
54
+ local _file="$1"
55
+ case "$(git diff --cached --numstat -- "$_file" 2>/dev/null)" in
56
+ -[[:space:]]-*) return 0 ;; # binary: git could not count lines
57
+ *) return 1 ;; # text (or empty -- nothing for the line checks)
58
+ esac
59
+ }
60
+
39
61
  # --- Check 1: Trailing whitespace ---
40
62
  TRAILING_WS_FILES=""
41
63
  if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
42
64
  while IFS= read -r file; do
43
65
  [ -z "$file" ] && continue
44
- # Skip binary files by checking if file contains null bytes
45
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
66
+ # Skip binary files : they have no lines to check
67
+ if is_binary_staged_file "$file"; then
46
68
  continue
47
69
  fi
48
70
  # Check staged content for trailing whitespace (spaces/tabs at end of lines)
@@ -69,7 +91,7 @@ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
69
91
  while IFS= read -r file; do
70
92
  [ -z "$file" ] && continue
71
93
  # Skip binary files
72
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
94
+ if is_binary_staged_file "$file"; then
73
95
  continue
74
96
  fi
75
97
  # Check if file content ends with newline
@@ -108,7 +130,7 @@ else
108
130
  while IFS= read -r file; do
109
131
  [ -z "$file" ] && continue
110
132
  # Skip binary files
111
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
133
+ if is_binary_staged_file "$file"; then
112
134
  continue
113
135
  fi
114
136
  # Check for conflict markers in staged content
@@ -527,6 +527,18 @@ gate13_count_lint_findings() {
527
527
  [ -n "$src" ] || src=$(cat)
528
528
  printf '%s\n' "$src" | awk '
529
529
  BEGIN { n = 0 }
530
+ # (#673) Maven 3.9.7+ reactor summary appends the packaging type per module:
531
+ # [INFO] whaleaudit-core [jar]
532
+ # `[pom]`/`[jar]` match the open-category rule below, so a 3-module reactor
533
+ # reported 4 javac findings on code with ZERO warnings -- and got worse with
534
+ # every module added. The marker set is closed (packaging types are an enum in
535
+ # Maven itself), so excluding it does not reopen the #659 fail-open hole that
536
+ # the open-category rule was introduced to close.
537
+ #
538
+ # Scoped to `[INFO]` lines: a javac diagnostic is never prefixed with the
539
+ # Maven [INFO] banner, so this cannot mask a real finding even when a category
540
+ # name collides with a packaging type.
541
+ /^\[INFO\].*\[(pom|jar|war|ear|rar|ejb|maven-plugin|par|test-jar|java-source|javadoc|zip|tar\.gz|tar\.bz2)\][[:space:]]*$/ { next }
530
542
  # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
543
  # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
544
  # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
package/gates/gate-3.ts CHANGED
@@ -53,29 +53,85 @@ export function runGate3(input: GateInput): GateOutput {
53
53
 
54
54
  messages.push('Checking complexity for source files...');
55
55
 
56
- // Run lizard with CCN threshold
57
- const result = runTool(lizardCmd, [...lizardArgs, '-C', String(CCN_THRESHOLD), ...ccFiles], {
58
- cwd: input.cwd,
59
- });
60
-
61
- const combinedOutput = result.stdout + result.stderr;
62
-
63
- // Parse warning count from summary table: "Warning cnt 8"
64
- const warningMatch = combinedOutput.match(/^Warning cnt\s+(\d+)/m);
65
- const warnings = warningMatch ? parseInt(warningMatch[1], 10) : 0;
66
-
67
- if (warnings > 0) {
68
- messages.push(combinedOutput);
69
- messages.push('');
70
- messages.push(`❌ BLOCKED - ${warnings} functions with CCN > ${CCN_THRESHOLD} found.`);
71
- messages.push(`Refactor high-complexity functions to keep below ${CCN_THRESHOLD} complexity.`);
72
- recordAudit('gate-3', 'complexity', 'FAIL', warnings, startMs);
73
- return { status: 'FAIL', messages, exitCode: 1 };
56
+ // CSV mode: the verdict comes from the numbers lizard uses, not its table
57
+ // formatting (#674). See countCcnWarnings() for why the old parser never matched.
58
+ const rawOutput = runLizardCsv(lizardCmd, lizardArgs, ccFiles, input.cwd);
59
+ const verdict = buildComplexityVerdict(countCcnWarnings(rawOutput), rawOutput);
60
+ messages.push(...verdict.messages);
61
+ recordAudit('gate-3', 'complexity', verdict.status, verdict.warnings, startMs);
62
+ return { status: verdict.status, messages, exitCode: verdict.exitCode };
63
+ }
64
+
65
+ /** Run lizard in CSV mode and return its raw output (stdout + stderr). */
66
+ function runLizardCsv(cmd: string, baseArgs: string[], files: string[], cwd: string | undefined): string {
67
+ const result = runTool(cmd, [...baseArgs, '--csv', '-C', String(CCN_THRESHOLD), ...files], { cwd });
68
+ return result.stdout + result.stderr;
69
+ }
70
+
71
+ /**
72
+ * Turn a CCN count into the gate's verdict and messages.
73
+ *
74
+ * Split from runGate3 so the three-way outcome (WARN on unparsable output, FAIL
75
+ * on violations, PASS) is visible in one place and testable without driving
76
+ * lizard. Notably `!analysed` must NOT fall through to PASS: a lizard that
77
+ * produced no parsable row would otherwise report success on zero analysed
78
+ * functions -- the same silent-success failure as #674.
79
+ */
80
+ export function buildComplexityVerdict(
81
+ counts: { warnings: number; analysed: boolean },
82
+ rawOutput: string
83
+ ): { status: 'PASS' | 'FAIL' | 'WARN'; messages: string[]; exitCode: number; warnings: number } {
84
+ if (!counts.analysed) {
85
+ return {
86
+ status: 'WARN',
87
+ messages: ['⚠️ WARN - lizard produced no parsable results; complexity not verified'],
88
+ exitCode: 0,
89
+ warnings: 0,
90
+ };
91
+ }
92
+ if (counts.warnings > 0) {
93
+ return {
94
+ status: 'FAIL',
95
+ messages: [
96
+ rawOutput,
97
+ '',
98
+ `❌ BLOCKED - ${counts.warnings} functions with CCN > ${CCN_THRESHOLD} found.`,
99
+ `Refactor high-complexity functions to keep below ${CCN_THRESHOLD} complexity.`,
100
+ ],
101
+ exitCode: 1,
102
+ warnings: counts.warnings,
103
+ };
74
104
  }
105
+ return {
106
+ status: 'PASS',
107
+ messages: [`✅ PASSED - All functions within complexity threshold (${CCN_THRESHOLD}).`],
108
+ exitCode: 0,
109
+ warnings: 0,
110
+ };
111
+ }
75
112
 
76
- messages.push(`✅ PASSED - All functions within complexity threshold (${CCN_THRESHOLD}).`);
77
- recordAudit('gate-3', 'complexity', 'PASS', 0, startMs);
78
- return { status: 'PASS', messages, exitCode: 0 };
113
+ /**
114
+ * Count functions whose CCN exceeds the threshold from lizard's --csv output.
115
+ *
116
+ * Split out so the parse can be tested against literal lizard output instead of
117
+ * by driving the tool: a parser bug here is invisible in exactly the same way
118
+ * (#674) -- it returns "clean" and the gate reports PASS.
119
+ *
120
+ * `analysed` distinguishes "lizard found nothing over the threshold" from
121
+ * "lizard produced nothing at all". Only the first is a pass.
122
+ */
123
+ export function countCcnWarnings(csv: string): { warnings: number; analysed: boolean } {
124
+ let warnings = 0;
125
+ let analysed = false;
126
+ for (const line of csv.split('\n')) {
127
+ const columns = line.split(',');
128
+ if (columns.length < 2) continue;
129
+ // Location column carries ":name" style text, so only trust numeric CCN.
130
+ if (!/^\s*\d+\s*$/.test(columns[1])) continue;
131
+ analysed = true;
132
+ if (parseInt(columns[1], 10) > CCN_THRESHOLD) warnings++;
133
+ }
134
+ return { warnings, analysed };
79
135
  }
80
136
 
81
137
  // ── CLI Entry Point ──
package/gates/gate-8.ts CHANGED
@@ -2,6 +2,13 @@
2
2
  * Gate 8: Secret Scanning (gitleaks)
3
3
  * Detects secrets (API keys, passwords, tokens) in staged files.
4
4
  * TypeScript rewrite of githooks/adapters/gate-8.sh for cross-platform Node.js CLI.
5
+ *
6
+ * Scans the INDEX (`git --staged`), not committed history (#675, mirroring the
7
+ * adapter fix in #449). gitleaks `git --pre-commit` walks committed revisions; on a
8
+ * pre-commit run it reports "0 commits scanned" and exits 0. A staged secret
9
+ * therefore passed the CLI while the hook blocked it -- and the CLI is the path CI
10
+ * and the plugins use. `--staged` scans `git diff --cached`, exactly the
11
+ * pre-commit payload. Same tool, same payload, one word different, opposite verdict.
5
12
  */
6
13
  import path from 'path';
7
14
  import fs from 'fs';
@@ -29,20 +36,7 @@ export function runGate8(input: GateInput): GateOutput {
29
36
  }
30
37
 
31
38
  const gitleaksCmd = tool.via === 'npx' ? 'npx' : tool.path;
32
- const gitleaksArgs = tool.via === 'npx' ? ['-y', 'gitleaks'] : [];
33
-
34
- // Config detection
35
- const configPath = findGitleaksConfig(cwd);
36
- if (configPath) {
37
- gitleaksArgs.push(`--config=${configPath}`);
38
- }
39
-
40
- // Cross-platform temp path (fixes /tmp issue on Windows)
41
- const reportPath = path.join(getTempDir(), 'gitleaks-report.json');
42
-
43
- // Run gitleaks on staged changes
44
- gitleaksArgs.push('git', '--pre-commit', '--redact', '--no-banner',
45
- '--report-format=json', `--report-path=${reportPath}`);
39
+ const gitleaksArgs = buildGitleaksArgs(tool.via === 'npx', findGitleaksConfig(cwd));
46
40
 
47
41
  const result = runTool(gitleaksCmd, gitleaksArgs, { cwd });
48
42
 
@@ -53,33 +47,52 @@ export function runGate8(input: GateInput): GateOutput {
53
47
  }
54
48
 
55
49
  if (result.exitCode === 1) {
56
- // Secrets found
57
- messages.push(result.stdout);
58
- messages.push('');
59
- messages.push('❌ BLOCKED - Secrets detected in staged files.');
60
- messages.push('');
61
- messages.push('Remediation options:');
62
- messages.push(' 1. Remove the secret and use environment variables instead');
63
- messages.push(' 2. Add a false positive to .gitleaks.toml allowlist');
64
- messages.push(' 3. Use git secret or vault for sensitive data');
65
- messages.push('');
66
- messages.push('See: https://github.com/gitleaks/gitleaks');
50
+ messages.push(...secretsFoundMessages(result.stdout));
67
51
  recordAudit('gate-8', 'secret-scanning', 'FAIL', 1, startMs);
68
52
  return { status: 'FAIL', messages, exitCode: 1 };
69
53
  }
70
54
 
71
- // Runtime error
72
55
  messages.push(` ⚠️ gitleaks exited with code ${result.exitCode} - skipping gate`);
73
56
  messages.push(' ✅ Secret Scanning (SKIP, gitleaks error)');
74
57
  recordAudit('gate-8', 'secret-scanning', 'SKIP', result.exitCode, startMs);
75
58
  return { status: 'SKIP', messages, exitCode: 0 };
76
59
  }
77
60
 
61
+ /** The report + remediation shown when gitleaks finds secrets (exit code 1). */
62
+ function secretsFoundMessages(stdout: string): string[] {
63
+ return [
64
+ stdout,
65
+ '',
66
+ '❌ BLOCKED - Secrets detected in staged files.',
67
+ '',
68
+ 'Remediation options:',
69
+ ' 1. Remove the secret and use environment variables instead',
70
+ ' 2. Add a false positive to .gitleaks.toml allowlist',
71
+ ' 3. Use git secret or vault for sensitive data',
72
+ '',
73
+ 'See: https://github.com/gitleaks/gitleaks',
74
+ ];
75
+ }
76
+
78
77
  function findGitleaksConfig(cwd: string): string | null {
79
78
  const configPath = path.join(cwd, '.gitleaks.toml');
80
79
  return fs.existsSync(configPath) ? configPath : null;
81
80
  }
82
81
 
82
+ /**
83
+ * Assemble the gitleaks argument vector.
84
+ *
85
+ * Report goes to a cross-platform temp path (the old hardcoded `/tmp` broke on
86
+ * Windows), and the scan reads the INDEX -- see the file header for why (#675).
87
+ */
88
+ function buildGitleaksArgs(viaNpx: boolean, configPath: string | null): string[] {
89
+ const args = viaNpx ? ['-y', 'gitleaks'] : [];
90
+ if (configPath) args.push(`--config=${configPath}`);
91
+ args.push('git', '--staged', '--redact', '--no-banner',
92
+ '--report-format=json', `--report-path=${path.join(getTempDir(), 'gitleaks-report.json')}`);
93
+ return args;
94
+ }
95
+
83
96
  // ── CLI Entry Point ──
84
97
  if (process.argv.includes('--run')) {
85
98
  const cwdIdx = process.argv.indexOf('--cwd');
package/gates/gate-9.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * Gate 9: Semgrep SAST Security Scan
2
+ * Gate 10: Semgrep SAST Security Scan (file name predates the #677 renumber)
3
3
  * Detects security vulnerabilities in staged files.
4
4
  * For PowerShell projects: uses PSScriptAnalyzer security rules as alternative.
5
5
  * TypeScript rewrite of githooks/adapters/gate-9.sh for cross-platform Node.js CLI.
@@ -36,7 +36,7 @@ export function runGate9(input: GateInput): GateOutput {
36
36
  const messages: string[] = [];
37
37
 
38
38
  messages.push('');
39
- messages.push('→ Gate 9: Semgrep SAST Security Scan...');
39
+ messages.push('→ Gate 10: Semgrep SAST Security Scan...');
40
40
 
41
41
  // PowerShell project: use PSScriptAnalyzer security rules
42
42
  if (isPowerShellProject(input.projectLang)) {
@@ -49,7 +49,7 @@ export function runGate9(input: GateInput): GateOutput {
49
49
  messages.push(' ⚠️ WARN - semgrep not installed — SAST scanning unavailable');
50
50
  messages.push(' Install: brew install semgrep (macOS) | pip install semgrep (Linux/Windows)');
51
51
  messages.push(' Pre-cache rules: semgrep --config=' + SEMGREP_RULESET);
52
- messages.push(' Gate 9: SAST Security (WARN, semgrep not installed)');
52
+ messages.push(' Gate 10: SAST Security (WARN, semgrep not installed)');
53
53
  recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
54
54
  return { status: 'WARN', messages, exitCode: 0 };
55
55
  }
@@ -242,7 +242,7 @@ function runPowerShellSAST(
242
242
  const ps = detectPowerShell();
243
243
  if (!ps.available) {
244
244
  messages.push(' ⚠️ WARN - PowerShell not available — SAST scanning unavailable');
245
- messages.push(' Gate 9: SAST Security (WARN, PowerShell not installed)');
245
+ messages.push(' Gate 10: SAST Security (WARN, PowerShell not installed)');
246
246
  recordAudit('gate-9', 'sast-security', 'WARN', 0, startMs);
247
247
  return { status: 'WARN', messages, exitCode: 0 };
248
248
  }
@@ -36,13 +36,35 @@ fi
36
36
  HYGIENE_ISSUES=0
37
37
  HYGIENE_FILES=""
38
38
 
39
+ # Is this staged file binary (so line-based checks do not apply)?
40
+ #
41
+ # This replaces `grep -q $'\x00'`, which was a no-op guard: bash strips NUL from
42
+ # `$'\x00'`, so the pattern was the EMPTY string and `grep -q ""` matches any
43
+ # non-empty input. Every staged file was therefore judged binary and `continue`d,
44
+ # silently disabling Check 1 (trailing whitespace), Check 2 (missing EOF newline)
45
+ # and -- worst -- Check 3 (merge conflict markers, a BLOCK-level check). Checks 4
46
+ # and 5 lacked the guard, which is why the gate looked half-working.
47
+ #
48
+ # Ask git instead of guessing: `--numstat` reports a binary entry as `-\t-\t<path>`
49
+ # because git already made that determination. A byte-level NUL test cannot be
50
+ # written with a shell pattern at all (variables and command substitution both drop
51
+ # NUL), and `grep -I` only suppresses output on a binary input -- it does not make
52
+ # `grep -q` fail, which is why that shortcut still reported text.
53
+ is_binary_staged_file() {
54
+ local _file="$1"
55
+ case "$(git diff --cached --numstat -- "$_file" 2>/dev/null)" in
56
+ -[[:space:]]-*) return 0 ;; # binary: git could not count lines
57
+ *) return 1 ;; # text (or empty -- nothing for the line checks)
58
+ esac
59
+ }
60
+
39
61
  # --- Check 1: Trailing whitespace ---
40
62
  TRAILING_WS_FILES=""
41
63
  if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
42
64
  while IFS= read -r file; do
43
65
  [ -z "$file" ] && continue
44
- # Skip binary files by checking if file contains null bytes
45
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
66
+ # Skip binary files : they have no lines to check
67
+ if is_binary_staged_file "$file"; then
46
68
  continue
47
69
  fi
48
70
  # Check staged content for trailing whitespace (spaces/tabs at end of lines)
@@ -69,7 +91,7 @@ if [ "${GATE_12_DOCS_ONLY:-false}" != true ]; then
69
91
  while IFS= read -r file; do
70
92
  [ -z "$file" ] && continue
71
93
  # Skip binary files
72
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
94
+ if is_binary_staged_file "$file"; then
73
95
  continue
74
96
  fi
75
97
  # Check if file content ends with newline
@@ -108,7 +130,7 @@ else
108
130
  while IFS= read -r file; do
109
131
  [ -z "$file" ] && continue
110
132
  # Skip binary files
111
- if git diff --cached --no-color -- "$file" 2>/dev/null | grep -q $'\x00'; then
133
+ if is_binary_staged_file "$file"; then
112
134
  continue
113
135
  fi
114
136
  # Check for conflict markers in staged content
@@ -527,6 +527,18 @@ gate13_count_lint_findings() {
527
527
  [ -n "$src" ] || src=$(cat)
528
528
  printf '%s\n' "$src" | awk '
529
529
  BEGIN { n = 0 }
530
+ # (#673) Maven 3.9.7+ reactor summary appends the packaging type per module:
531
+ # [INFO] whaleaudit-core [jar]
532
+ # `[pom]`/`[jar]` match the open-category rule below, so a 3-module reactor
533
+ # reported 4 javac findings on code with ZERO warnings -- and got worse with
534
+ # every module added. The marker set is closed (packaging types are an enum in
535
+ # Maven itself), so excluding it does not reopen the #659 fail-open hole that
536
+ # the open-category rule was introduced to close.
537
+ #
538
+ # Scoped to `[INFO]` lines: a javac diagnostic is never prefixed with the
539
+ # Maven [INFO] banner, so this cannot mask a real finding even when a category
540
+ # name collides with a packaging type.
541
+ /^\[INFO\].*\[(pom|jar|war|ear|rar|ejb|maven-plugin|par|test-jar|java-source|javadoc|zip|tar\.gz|tar\.bz2)\][[:space:]]*$/ { next }
530
542
  # Any bracketed lowercase category: covers the whole javac -Xlint enum and any
531
543
  # later addition to it. Restricted to lowercase/digit/hyphen so a Maven
532
544
  # `[WARNING]` with no category, or a `[INFO]` banner, is not counted.
package/hooks/pre-commit CHANGED
@@ -319,6 +319,32 @@ if ! declare -F run_tsx >/dev/null 2>&1; then
319
319
  }
320
320
  fi
321
321
 
322
+ # Run a repo-local Node CLI (eslint, vitest, biome, ...) without npx when the
323
+ # binary is already in this repo's node_modules.
324
+ #
325
+ # `npx <tool>` costs ~1.4-1.5 s more than the tool itself on this machine
326
+ # (measured: eslint --version 1654 ms via npx vs 348 ms direct; vitest 1799 ms vs
327
+ # 250 ms). That is npm resolving the package before it ever runs: on a cold
328
+ # cache it may reach the network, which a quality gate must never depend on.
329
+ # pre-commit invokes these tools a handful of times per commit, so the wrapper
330
+ # was paying several seconds to start processes that were already installed.
331
+ #
332
+ # `run_node_bin <tool> <node-entry> [args...]`. Falls back to npx when the local
333
+ # entry is absent (a globally-installed toolchain is still a valid setup).
334
+ if ! declare -F run_node_bin >/dev/null 2>&1; then
335
+ run_node_bin() {
336
+ local _tool="$1" _entry="$2"
337
+ shift 2
338
+ local _root="${PROJECT_ROOT:-$(pwd)}"
339
+ local _local="$_root/node_modules/$_entry"
340
+ if [ -f "$_local" ] && command -v node >/dev/null 2>&1; then
341
+ node "$_local" "$@"
342
+ else
343
+ npx "$_tool" "$@"
344
+ fi
345
+ }
346
+ fi
347
+
322
348
  # Gate 5's vitest exit-code judgement (is_runner_infrastructure_error,
323
349
  # is_partial_threshold_only_exit, handle_test_failure) is sourced from
324
350
  # lib/test-failure.sh below, next to the other hook libraries.
@@ -1677,9 +1703,9 @@ else
1677
1703
  # each linted file (found in the subdirectory automatically); flat
1678
1704
  # configs are discovered from the CWD, so point at them explicitly.
1679
1705
  if [ "$TS_PROJECT_DIR" != "." ] && [ -f "$TS_PROJECT_DIR/eslint.config.js" ]; then
1680
- ESLINT_OUTPUT=$(npx eslint --config "$TS_PROJECT_DIR/eslint.config.js" $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1706
+ ESLINT_OUTPUT=$(run_node_bin eslint eslint/bin/eslint.js --config "$TS_PROJECT_DIR/eslint.config.js" $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1681
1707
  else
1682
- ESLINT_OUTPUT=$(npx eslint $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1708
+ ESLINT_OUTPUT=$(run_node_bin eslint eslint/bin/eslint.js $ESLINT_FILES -f json --no-warn-ignored 2>&1)
1683
1709
  fi
1684
1710
  ESLINT_EXIT=$?
1685
1711
  if [ "$ESLINT_EXIT" -ne 0 ]; then
@@ -2659,7 +2685,13 @@ else
2659
2685
  # Exclusions come from filter_project_paths (#649). The hand-maintained list
2660
2686
  # this replaced omitted vendor/target/coverage/out, so a staged vendor/x.ts
2661
2687
  # was reported as new source with no test.
2662
- NEW_TS_FILES=$(git diff --cached --name-only --diff-filter=A | filter_project_paths | grep -E '\.(ts|tsx)$' | grep -v '\.d\.ts$' | grep -v '__tests__' | grep -v '\.test\.' | grep -v '\.spec\.' | grep -v '__snapshots__' | grep -Ev '(^|/)vitest\.config\.' | grep -Ev '(^|/)vite\.config\.' | grep -Ev '(^|/)jest\.config\.' | grep -Ev '(^|/)tsconfig\.' | grep -Ev '(^|/)eslint\.' | grep -Ev '(^|/)prettier\.' | grep -Ev '(^|/)tailwind\.' || true)
2688
+ #
2689
+ # Byte-identical mirrors under src/npm-package/ are excluded the same way
2690
+ # lint (#494) and Boy Scout (#649) already exclude them. A mirror cannot
2691
+ # carry its own test: the next resync overwrites the directory, so the
2692
+ # requirement is structurally unsatisfiable and blocked every commit that
2693
+ # added a file to a mirrored module. The canonical source keeps its test.
2694
+ 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)
2663
2695
 
2664
2696
  # Strip PROJECT_SUBDIR prefix from new-TS-file paths ONLY when the hook
2665
2697
  # actually cd'd into the subdirectory (IN_PROJECT_SUBDIR=1; single-language
@@ -3002,7 +3034,7 @@ else
3002
3034
  if source "$ADAPTER_FILE" 2>/dev/null; then
3003
3035
  # TypeScript: vitest with smart test selection
3004
3036
  if [ "$CURRENT_LANG" = "typescript" ] && { [ -f "package.json" ] || [ -f "$PROJECT_ROOT/package.json" ]; }; then
3005
- if run_without_git_context npx vitest --version >/dev/null 2>&1; then
3037
+ if run_without_git_context run_node_bin vitest vitest/vitest.mjs --version >/dev/null 2>&1; then
3006
3038
  # Count changed test files to decide strategy. `grep -c '.'` prints its
3007
3039
  # own zero AND exits 1 on an empty list, so `|| echo "0"` used to yield
3008
3040
  # $'0\n0' -- the same two-value bug that false-blocked Gate 8 (#493).
@@ -3049,7 +3081,7 @@ else
3049
3081
  if [ "$CHANGED_TEST_FILE_COUNT" -gt 20 ] 2>/dev/null; then
3050
3082
  # Too many test files changed → full run
3051
3083
  echo "Too many test files changed ($CHANGED_TEST_FILE_COUNT), running full test suite..."
3052
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage 2>&1)
3084
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage 2>&1)
3053
3085
  TESTS_EXIT_CODE=$?
3054
3086
  echo "$TESTS_OUTPUT" | head -60
3055
3087
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3063,7 +3095,7 @@ else
3063
3095
  SMART_TARGETS=$(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')
3064
3096
  echo "Running ${CHANGED_TEST_FILE_COUNT} changed test file(s) with coverage..."
3065
3097
  echo " Target(s): $(echo "$SELECTED_TEST_FILES" | tr '\n' ' ')"
3066
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $SMART_TARGETS 2>&1)
3098
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage $SMART_TARGETS 2>&1)
3067
3099
  TESTS_EXIT_CODE=$?
3068
3100
  echo "$TESTS_OUTPUT" | head -60
3069
3101
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3108,7 +3140,7 @@ else
3108
3140
  if [ -n "$RELATED_TESTS" ]; then
3109
3141
  echo " Found related test(s): $(echo "$RELATED_TESTS" | tr '\n' ' ')"
3110
3142
  PARTIAL_TEST_RUN=true # Mark as partial run for coverage check
3111
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage $RELATED_TESTS 2>&1)
3143
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage $RELATED_TESTS 2>&1)
3112
3144
  TESTS_EXIT_CODE=$?
3113
3145
  echo "$TESTS_OUTPUT" | head -60
3114
3146
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3119,7 +3151,7 @@ else
3119
3151
  else
3120
3152
  # No related tests found → full run is safest
3121
3153
  echo " No related test files found, running full test suite..."
3122
- TESTS_OUTPUT=$(run_without_git_context npx vitest run --coverage 2>&1)
3154
+ TESTS_OUTPUT=$(run_without_git_context run_node_bin vitest vitest/vitest.mjs run --coverage 2>&1)
3123
3155
  TESTS_EXIT_CODE=$?
3124
3156
  echo "$TESTS_OUTPUT" | head -60
3125
3157
  if [ "$TESTS_EXIT_CODE" -ne 0 ]; then
@@ -3976,6 +4008,8 @@ else
3976
4008
  cd "$PROJECT_ROOT" || exit 1
3977
4009
  if [ "$ARCHLINT_KIND" = "npx" ]; then
3978
4010
  npx @archlinter/cli "$@"
4011
+ elif [ "${ARCHLINT_NODE:-0}" = "1" ]; then
4012
+ node "$ARCHLINT_BIN" "$@"
3979
4013
  else
3980
4014
  "$ARCHLINT_BIN" "$@"
3981
4015
  fi
@@ -3996,8 +4030,17 @@ else
3996
4030
  for CURRENT_LANG in $PROJECT_LANGS; do
3997
4031
  case "$CURRENT_LANG" in
3998
4032
  "typescript")
3999
- # Try local npx install first (devDependencies), fall back to global
4000
- if [ -x "$PROJECT_ROOT/node_modules/.bin/archlint" ]; then
4033
+ # Prefer node on the package's own entry over the .bin shim: the shim's
4034
+ # exec bit and extension vary by platform and package manager (npm writes
4035
+ # archlint, archlint.cmd and archlint.ps1 on Windows), so `-x .bin/...`
4036
+ # is true here while the no-extension shim is what Git Bash would run.
4037
+ # `node <entry>` sidesteps all of that and also avoids npx's ~1.6 s
4038
+ # package resolution (measured: 1909 ms via npx vs 319 ms direct).
4039
+ if [ -f "$PROJECT_ROOT/node_modules/@archlinter/cli/bin/archlint.js" ] && command -v node >/dev/null 2>&1; then
4040
+ ARCHLINT_KIND="binary"
4041
+ ARCHLINT_BIN="$PROJECT_ROOT/node_modules/@archlinter/cli/bin/archlint.js"
4042
+ ARCHLINT_NODE=1
4043
+ elif [ -x "$PROJECT_ROOT/node_modules/.bin/archlint" ]; then
4001
4044
  ARCHLINT_KIND="binary"
4002
4045
  ARCHLINT_BIN="$PROJECT_ROOT/node_modules/.bin/archlint"
4003
4046
  elif command -v archlint >/dev/null 2>&1; then
@@ -4758,7 +4801,12 @@ if [ -n "$MIRROR_STAGED" ] && printf '%s\n' "$MIRROR_STAGED" | grep -qE '^(githo
4758
4801
  MIRROR_FAIL=0
4759
4802
  while IFS= read -r _mf; do
4760
4803
  [ -n "$_mf" ] || continue
4804
+ # `__tests__/` has no mirror BY DESIGN: sync-package-content.js lists it in
4805
+ # SKIP_DIRS so test suites never ship to consumers. Demanding a mirror for
4806
+ # them makes every new BATS file un-committable -- the two rules contradicted
4807
+ # each other, and the only way past was to edit a generated file by hand.
4761
4808
  case "$_mf" in
4809
+ */__tests__/*) continue ;;
4762
4810
  githooks/*) _mm="src/npm-package/hooks/${_mf#githooks/}" ;;
4763
4811
  plugins/*/src/*) _mm=$(printf '%s' "$_mf" | sed 's|^plugins/\([^/]*\)/src/|src/npm-package/plugins/\1/src/|') ;;
4764
4812
  *) continue ;;
@@ -4874,7 +4922,22 @@ generate_quality_report() {
4874
4922
  local TIMESTAMP
4875
4923
  local BRANCH
4876
4924
  local PASSED_COUNT=0
4877
- local TOTAL_GATES=12
4925
+ # #680: the gate list is the single source of truth. TOTAL_GATES, the scoring
4926
+ # loop, the console table and the JSON `overall` block all derive from it.
4927
+ #
4928
+ # Gate 13 was added in v0.35.x with only its execution and JSON write -- these
4929
+ # three sites kept saying "12". A machine with no C/C++ compiler therefore saw
4930
+ # `10.0/10 effective`, `gatesWarned: 0` and a table that stopped at Gate 12,
4931
+ # while the JSON `gates` object held 13 keys. "Compiler strictness was never
4932
+ # checked" and "compiler reported zero warnings" rendered identically, which is
4933
+ # the same silent-success pattern as #561/#570/#585.
4934
+ #
4935
+ # Adding a gate now means editing ALL_GATE_NUMBERS + ONE table row. Nothing
4936
+ # else carries a count.
4937
+ local ALL_GATE_NUMBERS="1 2 3 4 5 6 7 8 9 10 11 12 13"
4938
+ local TOTAL_GATES=0
4939
+ local _g
4940
+ for _g in $ALL_GATE_NUMBERS; do TOTAL_GATES=$((TOTAL_GATES + 1)); done
4878
4941
  local SKIP_COUNT=0
4879
4942
  local WARN_COUNT=0
4880
4943
  local NONPASS_COUNT=0
@@ -4902,7 +4965,7 @@ generate_quality_report() {
4902
4965
  local DEGRADED_JSON=""
4903
4966
  local FAIL_COUNT=0
4904
4967
  local FAILED_JSON=""
4905
- for gate in 1 2 3 4 5 6 7 8 9 10 11 12; do
4968
+ for gate in $ALL_GATE_NUMBERS; do
4906
4969
  local status_var="GATE_${gate}_STATUS"
4907
4970
  # Default mirrors the JSON report rendering below EXACTLY (gates 1-11
4908
4971
  # PASS, gate 12 WARN): a gate that never assigned a status is "not
@@ -4912,6 +4975,10 @@ generate_quality_report() {
4912
4975
  # Default direction is "did not run", never "passed": an unassigned status
4913
4976
  # is a gate that never reached a verdict, and scoring it PASS both claims a
4914
4977
  # success that did not happen and hides the gap in the effective rate.
4978
+ #
4979
+ # Gate 13 defaults to SKIP like the rest: it is only WARN when the toolchain
4980
+ # is missing and the gate explicitly says so, and Gate 12 is the one gate
4981
+ # whose "no verdict" is still a warning rather than "not applicable".
4915
4982
  if [ "$gate" -eq 12 ]; then gate_default="WARN"; else gate_default="SKIP"; fi
4916
4983
  gate_status="${!status_var:-$gate_default}"
4917
4984
  case "$gate_status" in
@@ -5258,6 +5325,9 @@ ENDJSON
5258
5325
  printf " %-45s %s\n" "Gate 10: SAST Security" "${GATE_10_STATUS:-SKIP}"
5259
5326
  printf " %-45s %s\n" "Gate 11: Sprint Flow" "${GATE_11_STATUS:-SKIP}"
5260
5327
  printf " %-45s %s\n" "Gate 12: File Hygiene" "${GATE_12_STATUS:-WARN}"
5328
+ # #680: Gate 13 ran and could block, but had no row here -- the table is what a
5329
+ # human reads, so a gate missing from it reads as a gate that does not exist.
5330
+ printf " %-45s %s\n" "Gate 13: Compiler Strictness" "${GATE_13_STATUS:-SKIP}"
5261
5331
  echo ""
5262
5332
  # #564: both views in one line. The effective score answers "of the gates that
5263
5333
  # could run, how many passed"; the all-gates score counts a skip against the
package/hooks/pre-push CHANGED
@@ -426,7 +426,16 @@ SPRINT_GATE_SCRIPT=""
426
426
  # ~/.config/xp-gate/hooks -- a correctly installed machine was told the file was
427
427
  # missing here too. Nothing is sourced for this: pre-push resolves its libraries
428
428
  # further down (ADAPTER_COMMON), and this gate must decide before that.
429
- for _sprint_gate_dir in "$PROJECT_ROOT/githooks" "$(dirname "$0")" "$HOME/.config/xp-gate/hooks"; do
429
+ #
430
+ # `$HOME/.config/xp-gate/adapters` is the tier #678 found missing: `xp-gate
431
+ # setup-global` runs copyAdapters(), which ships every `gate-*.sh` AND
432
+ # `sprint-gate.sh` into the ADAPTERS directory -- never into hooks/. So a globally
433
+ # installed machine had sprint-gate.sh present under adapters/ while this loop
434
+ # searched only hooks/, and Gate MS skipped on every push without complaint. Both
435
+ # tiers are searched now: which directory `init` chose is an install detail this
436
+ # gate should not depend on.
437
+ for _sprint_gate_dir in "$PROJECT_ROOT/githooks" "$(dirname "$0")" \
438
+ "$HOME/.config/xp-gate/hooks" "$HOME/.config/xp-gate/adapters"; do
430
439
  if [ -n "$_sprint_gate_dir" ] && [ -f "$_sprint_gate_dir/sprint-gate.sh" ]; then
431
440
  SPRINT_GATE_SCRIPT="$_sprint_gate_dir/sprint-gate.sh"
432
441
  break
@@ -440,7 +449,7 @@ if [ -n "$SPRINT_GATE_SCRIPT" ]; then
440
449
  exit 1
441
450
  fi
442
451
  else
443
- echo "⏭️ SKIPPED - Gate MS: Sprint Flow (sprint-gate.sh not found; searched: $PROJECT_ROOT/githooks, $(dirname "$0"), $HOME/.config/xp-gate/hooks)"
452
+ echo "⏭️ SKIPPED - Gate MS: Sprint Flow (sprint-gate.sh not found; searched: $PROJECT_ROOT/githooks, $(dirname "$0"), $HOME/.config/xp-gate/hooks, $HOME/.config/xp-gate/adapters)"
444
453
  GATE_S_STATUS="SKIP"
445
454
  fi
446
455
  echo ""
@@ -13,7 +13,16 @@ const util = require('util');
13
13
  // tests run in parallel across worker threads.
14
14
  const DESCRIBE_ID = String(Math.random().toString(36).slice(2, 8));
15
15
 
16
+ // Budget for every case in this file. `doctor --fix` walks the whole install
17
+ // surface (hooks, adapters, config, version cache) and the default 5 s budget is
18
+ // a measurement of an idle machine, not of one running a full suite: under
19
+ // concurrent load the same passing case times out, which reads as a doctor
20
+ // defect. 30 s keeps real hangs bounded while leaving the CPU contention room.
21
+ const DOCTOR_TIMEOUT_MS = 30000;
22
+
16
23
  describe('doctor', () => {
24
+ // Applies to every `it` below (the per-test arg is only needed when overriding).
25
+ vi.setConfig({ testTimeout: DOCTOR_TIMEOUT_MS });
17
26
  let tmpHome;
18
27
  let tmpProject;
19
28
  let originalHome;