@boyingliu01/xp-gate 0.19.3 → 0.20.1

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 (177) hide show
  1. package/adapters/gate-4.sh +83 -18
  2. package/adapters/gate-8.sh +115 -29
  3. package/adapters/iac.sh +1 -1
  4. package/adapters/powershell.sh +60 -5
  5. package/adapters/python.sh +38 -4
  6. package/adapters/typescript.sh +15 -60
  7. package/bin/xp-gate.js +15 -16
  8. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  9. package/build-integrity/gate-10.ts +17 -2
  10. package/gate-10.sh +35 -2
  11. package/gate-4.sh +110 -21
  12. package/gate-8.sh +115 -29
  13. package/hooks/gate-10.sh +35 -2
  14. package/hooks/gate-4.sh +110 -21
  15. package/hooks/gate-8.sh +115 -29
  16. package/hooks/lib/jscpd-run.sh +63 -0
  17. package/hooks/lib/sprint-gate-report.sh +58 -0
  18. package/hooks/lib/test-failure.sh +152 -0
  19. package/hooks/lib/typecheck.sh +141 -0
  20. package/hooks/lib/validate-code-walkthrough.cjs +43 -5
  21. package/hooks/post-merge +100 -0
  22. package/hooks/pre-commit +514 -184
  23. package/hooks/pre-push +75 -19
  24. package/lib/__tests__/bootstrap.test.js +23 -41
  25. package/lib/__tests__/check-version.test.js +8 -1
  26. package/lib/__tests__/detect-deps.test.js +16 -0
  27. package/lib/__tests__/doctor-drift-round2.test.js +212 -0
  28. package/lib/__tests__/doctor-executed-surface.test.js +209 -0
  29. package/lib/__tests__/doctor-hook-drift.test.js +329 -0
  30. package/lib/__tests__/doctor-machine-mutation.test.js +116 -0
  31. package/lib/__tests__/doctor-module-drift.test.js +206 -0
  32. package/lib/__tests__/doctor-module-resolution-parity.test.js +314 -0
  33. package/lib/__tests__/doctor-path-equivalence.test.js +59 -0
  34. package/lib/__tests__/doctor-repair-boundary.test.js +52 -0
  35. package/lib/__tests__/doctor-tui-skill-freshness.test.js +105 -0
  36. package/lib/__tests__/doctor.test.js +155 -0
  37. package/lib/__tests__/gate-runner.test.js +19 -3
  38. package/lib/__tests__/helpers/lib-sandbox.cjs +55 -0
  39. package/lib/__tests__/init.test.js +44 -17
  40. package/lib/__tests__/install-skill-full-dir.test.js +151 -0
  41. package/lib/__tests__/install-skill.test.js +51 -132
  42. package/lib/__tests__/language-tools-detect.test.js +158 -0
  43. package/lib/__tests__/phase-transition.test.js +15 -1
  44. package/lib/__tests__/qoder-delphi-agents.test.js +285 -0
  45. package/lib/__tests__/shared-paths.test.js +20 -0
  46. package/lib/__tests__/shared-utils.test.js +69 -0
  47. package/lib/__tests__/skill-flags.test.js +48 -0
  48. package/lib/__tests__/ui-detector.test.ts +6 -2
  49. package/lib/__tests__/uninstall.test.js +20 -0
  50. package/lib/__tests__/update-hooks.test.js +12 -0
  51. package/lib/__tests__/update-skill.test.js +7 -4
  52. package/lib/detect-deps.js +3 -20
  53. package/lib/doctor-tui.js +22 -5
  54. package/lib/doctor.js +1178 -31
  55. package/lib/init.js +184 -33
  56. package/lib/install-cmd.js +7 -4
  57. package/lib/install-skill.js +38 -95
  58. package/lib/language-tools.js +83 -4
  59. package/lib/mattpocock-license-verdict.json +1 -1
  60. package/lib/shared-paths.js +5 -2
  61. package/lib/shared-utils.js +47 -3
  62. package/lib/uninstall.js +12 -0
  63. package/lib/update-hooks.js +4 -4
  64. package/lib/update-skill.js +6 -7
  65. package/mock-policy/AGENTS.md +4 -4
  66. package/mutation/AGENTS.md +4 -4
  67. package/mutation/__tests__/gate-m.test.ts +66 -14
  68. package/mutation/__tests__/mutmut-runner.test.ts +91 -42
  69. package/mutation/__tests__/test-filter-parity.test.ts +37 -0
  70. package/mutation/gate-m.ts +21 -8
  71. package/package.json +2 -2
  72. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  73. package/plugins/claude-code/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  74. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  75. package/plugins/claude-code/skills/delphi-review/SKILL.md +98 -14
  76. package/plugins/claude-code/skills/delphi-review/references/code-walkthrough.md +36 -3
  77. package/plugins/claude-code/skills/delphi-review/references/requirements.md +6 -6
  78. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  79. package/plugins/claude-code/skills/sprint-flow/references/phase-2-design.md +3 -3
  80. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  81. package/plugins/dsh/README.md +169 -4
  82. package/plugins/dsh/dsh-overlay.patch.yml +67 -0
  83. package/plugins/dsh/package.json +1 -1
  84. package/plugins/dsh/scripts/prepack.cjs +24 -19
  85. package/plugins/dsh/setup-dsh.ps1 +160 -0
  86. package/plugins/dsh/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  87. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  88. package/plugins/dsh/skills/delphi-review/SKILL.md +98 -14
  89. package/plugins/dsh/skills/delphi-review/references/code-walkthrough.md +36 -3
  90. package/plugins/dsh/skills/delphi-review/references/requirements.md +6 -6
  91. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  92. package/plugins/dsh/skills/sprint-flow/references/phase-2-design.md +3 -3
  93. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  94. package/plugins/dsh/src/command.test.ts +66 -2
  95. package/plugins/dsh/src/command.ts +36 -14
  96. package/plugins/dsh/src/contract.test.ts +31 -1
  97. package/plugins/dsh/src/delphi-config.test.ts +190 -0
  98. package/plugins/dsh/src/delphi-config.ts +223 -0
  99. package/plugins/dsh/src/delphi-run.test.ts +181 -0
  100. package/plugins/dsh/src/delphi-run.ts +129 -0
  101. package/plugins/dsh/src/delphi.test.ts +222 -0
  102. package/plugins/dsh/src/delphi.ts +247 -0
  103. package/plugins/dsh/src/dialect.test.ts +59 -0
  104. package/plugins/dsh/src/dialect.ts +40 -0
  105. package/plugins/dsh/src/index.ts +70 -4
  106. package/plugins/dsh/src/skills.test.ts +26 -19
  107. package/plugins/opencode/package.json +1 -1
  108. package/plugins/opencode/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  109. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  110. package/plugins/opencode/skills/delphi-review/SKILL.md +98 -14
  111. package/plugins/opencode/skills/delphi-review/references/code-walkthrough.md +36 -3
  112. package/plugins/opencode/skills/delphi-review/references/requirements.md +6 -6
  113. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  114. package/plugins/opencode/skills/sprint-flow/references/phase-2-design.md +3 -3
  115. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  116. package/plugins/qoder/agents/delphi-architecture.md +1 -1
  117. package/plugins/qoder/agents/delphi-feasibility.md +1 -1
  118. package/plugins/qoder/agents/delphi-technical.md +1 -1
  119. package/plugins/qoder/plugin.json +1 -1
  120. package/plugins/qoder/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  121. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  122. package/plugins/qoder/skills/delphi-review/SKILL.md +98 -14
  123. package/plugins/qoder/skills/delphi-review/references/code-walkthrough.md +36 -3
  124. package/plugins/qoder/skills/delphi-review/references/requirements.md +6 -6
  125. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  126. package/plugins/qoder/skills/sprint-flow/references/phase-2-design.md +3 -3
  127. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  128. package/principles/AGENTS.md +9 -8
  129. package/principles/__tests__/adapter-metrics.test.ts +292 -0
  130. package/principles/__tests__/analyzer.test.ts +29 -6
  131. package/principles/__tests__/baseline-prune-timeout.test.ts +71 -0
  132. package/principles/__tests__/baseline-prune.test.ts +338 -0
  133. package/principles/__tests__/boy-scout-init-baseline.test.ts +167 -0
  134. package/principles/__tests__/boy-scout-integration.test.ts +97 -6
  135. package/principles/__tests__/boy-scout.test.ts +253 -7
  136. package/principles/__tests__/config-enforcement.test.ts +469 -0
  137. package/principles/__tests__/config-git-timeout.test.ts +66 -0
  138. package/principles/__tests__/direct-execution.test.ts +187 -0
  139. package/principles/__tests__/index.test.ts +16 -3
  140. package/principles/__tests__/warnings-baseline-budgets.test.ts +74 -0
  141. package/principles/adapters/typescript.ts +613 -13
  142. package/principles/analyzer.ts +22 -0
  143. package/principles/baseline-prune.ts +217 -0
  144. package/principles/boy-scout.ts +516 -56
  145. package/principles/config.ts +362 -54
  146. package/principles/direct-execution.ts +60 -0
  147. package/principles/index.ts +40 -17
  148. package/principles/rules/__tests__/clean-code/many-exports.test.ts +16 -10
  149. package/principles/rules/clean-code/code-duplication.ts +8 -8
  150. package/principles/rules/clean-code/deep-nesting.ts +8 -8
  151. package/principles/rules/clean-code/god-class.ts +8 -8
  152. package/principles/rules/clean-code/large-file.ts +15 -11
  153. package/principles/rules/clean-code/long-function.ts +8 -8
  154. package/principles/rules/clean-code/magic-numbers.ts +10 -7
  155. package/principles/rules/clean-code/many-exports.ts +7 -7
  156. package/principles/rules/clean-code/missing-error-handling.ts +5 -5
  157. package/principles/rules/clean-code/too-many-params.ts +8 -8
  158. package/principles/rules/clean-code/unused-imports.ts +5 -5
  159. package/principles/rules/solid/dip.ts +18 -8
  160. package/principles/rules/solid/isp.ts +9 -8
  161. package/principles/rules/solid/lsp.ts +6 -6
  162. package/principles/rules/solid/ocp.ts +6 -5
  163. package/principles/rules/solid/srp.ts +7 -8
  164. package/principles/types.ts +15 -0
  165. package/scripts/__tests__/sync-package-content.test.js +28 -0
  166. package/scripts/delphi-external-review.cjs +193 -10
  167. package/scripts/sync-package-content.js +20 -10
  168. package/skills/clipboard-vision/__tests__/clipboard-vision-distribution.test.cjs +0 -1
  169. package/skills/delphi-review/AGENTS.md +4 -4
  170. package/skills/delphi-review/SKILL.md +98 -14
  171. package/skills/delphi-review/references/code-walkthrough.md +36 -3
  172. package/skills/delphi-review/references/requirements.md +6 -6
  173. package/skills/sprint-flow/AGENTS.md +4 -4
  174. package/skills/sprint-flow/references/phase-2-design.md +3 -3
  175. package/skills/test-specification-alignment/AGENTS.md +4 -4
  176. package/lib/__tests__/download-skill.test.js +0 -281
  177. package/lib/download-skill.js +0 -96
@@ -12,6 +12,10 @@ import path from 'path';
12
12
  import os from 'os';
13
13
  import { extractImports, resolveImportPath, runImportCheck } from '../gate-10';
14
14
 
15
+ // Named timeout for the runImportCheck calls that assert verdicts; keeps the
16
+ // Boy Scout magic-number count from growing with each new test case.
17
+ const IMPORT_CHECK_TIMEOUT_MS = 30000;
18
+
15
19
  /**
16
20
  * ─── extractImports ────────────────────────────────────────────────────────────
17
21
  */
@@ -321,6 +325,61 @@ describe('runImportCheck', () => {
321
325
  expect(result.violations[0].reason).toMatch(/not found|does not exist|missing/i);
322
326
  });
323
327
 
328
+ it('passes when projectRoot spelling differs in drive case/slashes (Windows, #508)', async () => {
329
+ // git rev-parse --show-toplevel reports "D:/repo" while path.resolve of a
330
+ // repo-relative changed file inherits process.cwd()'s "d:\repo" drive case.
331
+ // The case-sensitive startsWith judged every import as boundary-escaping,
332
+ // blocking any TS-bearing push. The same root spelled the other way must
333
+ // pass on Windows; drive letters do not exist on POSIX so skip there.
334
+ if (process.platform !== 'win32') return;
335
+
336
+ let fooFile: string;
337
+ try {
338
+ const srcDir = path.join(tmpDir, 'src');
339
+ await fs.mkdir(srcDir);
340
+
341
+ const barFile = path.join(srcDir, 'bar.ts');
342
+ await fs.writeFile(barFile, 'export const x = 1;');
343
+
344
+ fooFile = path.join(srcDir, 'foo.ts');
345
+ await fs.writeFile(fooFile, `import { x } from './bar';\nconsole.log(x);\n`);
346
+ } catch (error) {
347
+ throw new Error(`Fixture setup failed: ${error}`);
348
+ }
349
+
350
+ const altRoot = tmpDir.replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
351
+ expect(altRoot).not.toBe(tmpDir);
352
+
353
+ const result = await runImportCheck([fooFile], altRoot, IMPORT_CHECK_TIMEOUT_MS);
354
+ expect(result.status).toBe('pass');
355
+ expect(result.violations).toEqual([]);
356
+ });
357
+
358
+ it('still fails a true boundary escape after case normalization (#508 follow-up)', async () => {
359
+ // Normalization must only absorb case/spelling differences, never widen the
360
+ // boundary: an import that genuinely leaves the project root keeps failing
361
+ // even when the root is spelled with a different drive-letter case.
362
+ if (process.platform !== 'win32') return;
363
+
364
+ let badFile: string;
365
+ try {
366
+ const nestedDir = path.join(tmpDir, 'pkg', 'dist', 'sub');
367
+ await fs.mkdir(nestedDir, { recursive: true });
368
+
369
+ badFile = path.join(nestedDir, 'file.ts');
370
+ await fs.writeFile(badFile, `import { x } from '../../../../outside';\n`);
371
+ } catch (error) {
372
+ throw new Error(`Fixture setup failed: ${error}`);
373
+ }
374
+
375
+ const altRoot = tmpDir.replace(/\\/g, '/').replace(/^[a-zA-Z]:/, (m) => m.toLowerCase());
376
+
377
+ const result = await runImportCheck([badFile], altRoot, IMPORT_CHECK_TIMEOUT_MS);
378
+ expect(result.status).toBe('fail');
379
+ expect(result.violations.length).toBeGreaterThan(0);
380
+ expect(result.violations[0].reason).toMatch(/escapes|outside|boundary/i);
381
+ });
382
+
324
383
  it('skips YAML files containing JavaScript-like require text', async () => {
325
384
  const workflowDir = path.join(tmpDir, '.github', 'workflows');
326
385
  await fs.mkdir(workflowDir, { recursive: true });
@@ -307,6 +307,8 @@ export async function runImportCheck(
307
307
 
308
308
  const fsSync = fsNative;
309
309
 
310
+ const normalizedRoot = path.resolve(projectRoot);
311
+
310
312
  for (const file of changedFiles) {
311
313
  if (!isSupportedModuleFile(file)) continue;
312
314
 
@@ -327,14 +329,27 @@ export async function runImportCheck(
327
329
  continue;
328
330
  }
329
331
 
332
+ // Windows paths are case-insensitive but the two sides of the boundary
333
+ // comparison get their case from different sources: path.resolve of a
334
+ // repo-relative file inherits process.cwd()'s drive-letter case (often
335
+ // lowercase), while projectRoot comes from `git rev-parse --show-toplevel`
336
+ // (uppercase drive). A case-sensitive startsWith then judged EVERY import
337
+ // as escaping the boundary — pre-push Gate 10 blocked any TS-bearing push
338
+ // from a shell whose drive-letter case disagreed with git's (#508).
339
+ const normalizeForBoundary = (p: string): string => {
340
+ const resolved = path.resolve(p);
341
+ return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
342
+ };
343
+ const boundaryRoot = normalizeForBoundary(normalizedRoot);
344
+
330
345
  const imports = extractImports(content);
331
346
 
332
347
  for (const imp of imports) {
333
348
  const rawResolved = path.resolve(path.dirname(file), imp.path);
334
- const normalizedRoot = path.resolve(projectRoot);
349
+ const boundaryTarget = normalizeForBoundary(rawResolved);
335
350
 
336
351
  const escapesBoundary =
337
- !rawResolved.startsWith(normalizedRoot + path.sep) && rawResolved !== normalizedRoot;
352
+ !boundaryTarget.startsWith(boundaryRoot + path.sep) && boundaryTarget !== boundaryRoot;
338
353
 
339
354
  if (escapesBoundary) {
340
355
  violations.push({
package/gate-10.sh CHANGED
@@ -33,15 +33,39 @@ else
33
33
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
34
34
  GATE_10_STATUS="SKIP"
35
35
  else
36
+ # One path per line into an array. Quoting the newline-joined list would hand
37
+ # semgrep ONE argument containing newlines -> "Invalid scanning root" once
38
+ # >=2 files are staged (#490). Same defect class as the Gate 4 --files fix
39
+ # (#457): an array is the only form that keeps spaces intact AND splits files.
40
+ #
41
+ # Each element gets a ./ prefix: a tracked path may legitimately start with
42
+ # '-' (`git add -- ./-o.ts`), and bare in argv that element reads as an option
43
+ # and rewrites the scan it was meant to constrain. ./ is a path to every
44
+ # argument parser.
45
+ #
46
+ # The unquoted heredoc below expands $SEMGREP_FILES once, then feeds literal
47
+ # lines to `read`; bash never rescans the result for separators. Its correct
48
+ # reading rests on one property of the producer above, not of the loop:
49
+ # `git diff --cached --name-only` terminates paths with LF and quotes control
50
+ # characters (a CR in a name arrives as the four bytes \215, never as a bare
51
+ # CR), so one line is exactly one path. Asserted for spaces and a leading '-'
52
+ # by AC-490-04.
53
+ SEMGREP_ARGS=()
54
+ while IFS= read -r _semgrep_file; do
55
+ [ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
56
+ done <<EOF
57
+ $SEMGREP_FILES
58
+ EOF
59
+
36
60
  # Run semgrep with JSON output
37
61
  # --config=p/security-audit: explicit security ruleset
38
62
  # --json: machine-readable output
39
63
  # --disable-version-check: skip network call
40
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "$SEMGREP_FILES" 2>&1)
64
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1)
41
65
  SEMGREP_EXIT=$?
42
66
 
43
67
  if [ "$SEMGREP_EXIT" -eq 0 ]; then
44
- echo " ✅ PASSED - No security vulnerabilities found."
68
+ echo " ✅ PASSED - No security vulnerabilities found (files scanned: ${#SEMGREP_ARGS[@]})."
45
69
  GATE_10_STATUS="PASS"
46
70
  elif [ "$SEMGREP_EXIT" -eq 1 ]; then
47
71
  # Findings detected - parse JSON to categorize
@@ -96,7 +120,16 @@ console.log(' ['+severity+'] '+ruleId);console.log(' '+path+':'+line+' → '+m
96
120
  fi
97
121
  else
98
122
  # semgrep runtime error (timeout, config error, etc.)
123
+ #
124
+ # SEMGREP_OUTPUT is captured above and was never shown here, so an intermittent
125
+ # ruleset-fetch failure looked identical to a broken install: the gate printed
126
+ # "runtime error" and kept the reason to itself (#475). The verdict stays SKIP --
127
+ # an operational failure must not block the commit, and must not be called a pass.
99
128
  echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
129
+ if [ -n "$SEMGREP_OUTPUT" ]; then
130
+ echo " ── what semgrep reported ──"
131
+ printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
132
+ fi
100
133
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
101
134
  GATE_10_STATUS="SKIP"
102
135
  fi
package/gate-4.sh CHANGED
@@ -3,6 +3,26 @@
3
3
  # Reuses existing principles checker logic
4
4
  # ============================================================================
5
5
 
6
+ # Run a TypeScript entrypoint under tsx without routing arguments through the
7
+ # npx shim: Windows cmd.exe caps command lines at 8191 chars and long staged
8
+ # file lists overflow it, turning a healthy run into a false BLOCK. Prefer the
9
+ # repo-local tsx CLI driven by node directly; keep npx as fallback for consumer
10
+ # repos relying on a global install. Defined here as well so this module also
11
+ # works when sourced outside pre-commit.
12
+ if ! declare -F run_tsx >/dev/null 2>&1; then
13
+ run_tsx() {
14
+ local _root="${PROJECT_ROOT:-$(pwd)}" _timeout=()
15
+ if [ -n "${RUN_TSX_TIMEOUT:-}" ]; then
16
+ _timeout=(timeout "$RUN_TSX_TIMEOUT")
17
+ fi
18
+ if [ -f "$_root/node_modules/tsx/dist/cli.mjs" ] && command -v node >/dev/null 2>&1; then
19
+ "${_timeout[@]}" node "$_root/node_modules/tsx/dist/cli.mjs" "$@"
20
+ else
21
+ "${_timeout[@]}" npx tsx "$@"
22
+ fi
23
+ }
24
+ fi
25
+
6
26
  2>&1 echo ""
7
27
  2>&1 echo "→ Gate 4: Principles checker (Clean Code + SOLID)..."
8
28
  GATE_4_START=$(gate_start_ms)
@@ -16,29 +36,101 @@ if [ "$PROJECT_LANG" = "documentation-only" ]; then
16
36
  else
17
37
  # Get source files to check against principles
18
38
  PRINCIPLES_FILES=$(echo "$CHANGED_FILES" | grep -E '\.(ts|tsx|js|jsx|py|go|java|kt|dart|swift|cpp|c|hpp|h|m|mm)$' || true)
39
+
40
+ # One path per line, into an array. Passing $PRINCIPLES_FILES unquoted relies on
41
+ # word splitting to turn the list into separate argv entries, which also splits
42
+ # any path containing a space -- handing the checker two bogus paths and checking
43
+ # neither. Quoting it would instead collapse the list into one argument. An array
44
+ # is the only form that does both. Same defect class as the --config fix (#457).
45
+ PRINCIPLES_ARGS=()
46
+ while IFS= read -r _principles_file; do
47
+ [ -n "$_principles_file" ] && PRINCIPLES_ARGS+=("$_principles_file")
48
+ done <<< "$PRINCIPLES_FILES"
19
49
 
20
50
  if [ -n "$PRINCIPLES_FILES" ]; then
21
- # Check for principles checker in installed modules first, then project src/
51
+ # The checker resolves every path it is given -- and every path in CHANGED_FILES
52
+ # comes from `git diff`, so it is repo-root relative -- against its own working
53
+ # directory. Resolving against the hook's CWD broke in single-language subdir
54
+ # mode, where pre-commit has already cd'd into $PROJECT_SUBDIR: web/src/a.ts was
55
+ # looked up as web/web/src/a.ts, the probes below missed the checker that was
56
+ # installed one level up, and a working gate degraded to SKIPPED (#478).
57
+ # Everything this gate reads is therefore anchored at the repo root.
58
+ PRINCIPLES_BASE="${PROJECT_ROOT:-$(pwd)}"
59
+
22
60
  PRINCIPLES_DIR=""
23
- if [ -d ".xp-gate/modules/principles" ]; then
24
- PRINCIPLES_DIR=".xp-gate/modules/principles"
25
- elif [ -f "src/principles/index.ts" ]; then
26
- PRINCIPLES_DIR="src/principles"
27
- elif [ -d "$HOME/.config/xp-gate/modules/principles" ]; then
28
- PRINCIPLES_DIR="$HOME/.config/xp-gate/modules/principles"
29
- fi
61
+ for _principles_candidate in \
62
+ "$PRINCIPLES_BASE/.xp-gate/modules/principles" \
63
+ "$PRINCIPLES_BASE/src/principles" \
64
+ "$(pwd)/.xp-gate/modules/principles" \
65
+ "$(pwd)/src/principles" \
66
+ "$HOME/.config/xp-gate/modules/principles"; do
67
+ if [ -f "$_principles_candidate/index.ts" ]; then
68
+ PRINCIPLES_DIR="$_principles_candidate"
69
+ break
70
+ fi
71
+ done
30
72
 
31
73
  if [ -n "$PRINCIPLES_DIR" ]; then
32
74
  echo "Checking Clean Code + SOLID principles..."
33
75
 
34
- if command -v npx > /dev/null 2>&1; then
35
- # Run principles checker and store results
36
- if npx tsx $PRINCIPLES_DIR/index.ts --files $PRINCIPLES_FILES --format json > /tmp/principles-output.json 2>/dev/null; then
37
- # Check severity levels
38
- ERROR_COUNT=$(grep -c '"severity":"error"' /tmp/principles-output.json 2>/dev/null || true)
76
+ if command -v npx > /dev/null 2>&1 || [ -f "$PRINCIPLES_BASE/node_modules/tsx/dist/cli.mjs" ]; then
77
+ # Run principles checker and store results.
78
+ #
79
+ # Do NOT wrap this in `if ...; then`: the checker's exit codes are
80
+ # deliberately distinct (0 clean, 1 ERROR-severity findings, >=2 tool
81
+ # failure), and an `if` collapses 1 and 2 into "failed". That sent real
82
+ # error-severity findings down the crash branch, which SKIPs and prints
83
+ # PASSED -- releasing exactly the most serious violations.
84
+ # The trailing `|| PRINCIPLES_EXIT=$?` captures the exit status without
85
+ # letting a non-zero return abort the hook under `set -e`. pre-commit itself
86
+ # runs with only `set -o pipefail` (no `set -e`, pinned by
87
+ # scripts/__tests__/gate-5-runner-error.test.ts AC-454-08), but this module is
88
+ # also sourced by consumer hooks that may set it, and a bare call followed by
89
+ # `PRINCIPLES_EXIT=$?` never reaches the assignment under `set -e`. `|| true`
90
+ # would erase the status -- which is exactly the distinction this gate depends
91
+ # on.
92
+ PRINCIPLES_EXIT=0
93
+ # Keep stderr: it carries the checker's configuration warnings -- a rejected
94
+ # threshold or an out-of-vocabulary severity says so here and nowhere else.
95
+ # Sending it to /dev/null made an exit-2 SKIP undiagnosable and hid every
96
+ # .principlesrc typo (Delphi walkthrough MC-03). Captured to a file so a clean
97
+ # run stays quiet, then replayed only when the checker had a problem.
98
+ #
99
+ # `cd || exit 2`: a base we cannot enter means the checker never ran, which is
100
+ # this module's tool-failure code. Falling through would return the exit status
101
+ # of the failed cd (1), and 1 asserts "I examined files and found errors" -- an
102
+ # empty report at 1 reads as a clean pass.
103
+ #
104
+ # The report is a PER-INVOCATION mktemp, not a shared `/tmp/principles-output.json`:
105
+ # two commits racing on one machine (two worktrees, a hook and a CI job) overwrote
106
+ # each other's report before it was counted, so a run with ERROR findings could
107
+ # read a clean report and PASS (#457). PRINCIPLES_JSON is exported so a caller can
108
+ # point a harness at the same file.
109
+ PRINCIPLES_STDERR=$(mktemp)
110
+ PRINCIPLES_JSON=$(mktemp)
111
+ export PRINCIPLES_JSON
112
+ ( cd "$PRINCIPLES_BASE" 2>/dev/null || exit 2
113
+ run_tsx "$PRINCIPLES_DIR/index.ts" --files ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"} --format json ) > "$PRINCIPLES_JSON" 2>"$PRINCIPLES_STDERR" || PRINCIPLES_EXIT=$?
114
+
115
+ if [ -s "$PRINCIPLES_STDERR" ]; then
116
+ sed 's/^/ /' "$PRINCIPLES_STDERR"
117
+ fi
118
+ rm -f "$PRINCIPLES_STDERR"
119
+
120
+ if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
121
+ echo "⚠️ Warning: Principles checker execution failed"
122
+ echo "⏭️ SKIPPED - Principles check (execution issue)"
123
+ GATE_4_STATUS="SKIP"
124
+ rm -f "$PRINCIPLES_JSON"
125
+ else
126
+ # Check severity levels. The reporter emits JSON.stringify(out, null, 2),
127
+ # i.e. `"severity": "warning"` WITH a space; tolerate any whitespace so a
128
+ # future minified format cannot silently zero these counts again (#444).
129
+ ERROR_COUNT=$(grep -cE '"severity"[[:space:]]*:[[:space:]]*"error"' "$PRINCIPLES_JSON" 2>/dev/null || true)
39
130
  ERROR_COUNT=${ERROR_COUNT:-0}
40
- WARNING_COUNT=$(grep -c '"severity":"warning"' /tmp/principles-output.json 2>/dev/null || true)
131
+ WARNING_COUNT=$(grep -cE '"severity"[[:space:]]*:[[:space:]]*"warning"' "$PRINCIPLES_JSON" 2>/dev/null || true)
41
132
  WARNING_COUNT=${WARNING_COUNT:-0}
133
+ rm -f "$PRINCIPLES_JSON"
42
134
 
43
135
  if [ "$ERROR_COUNT" -gt 0 ]; then
44
136
  echo ""
@@ -47,7 +139,8 @@ else
47
139
  echo " - error-handling violations"
48
140
  echo " - SOLID principle violations"
49
141
  echo " - architectural violations"
50
- npx tsx $PRINCIPLES_DIR/index.ts --files $PRINCIPLES_FILES --format console
142
+ ( cd "$PRINCIPLES_BASE" 2>/dev/null || exit 2
143
+ run_tsx "$PRINCIPLES_DIR/index.ts" --files ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"} --format console )
51
144
  GATE_4_STATUS="FAIL"
52
145
  exit 1
53
146
  fi
@@ -57,14 +150,10 @@ else
57
150
  if [ "$WARNING_COUNT" -gt 0 ]; then
58
151
  echo "ℹ️ $WARNING_COUNT warnings found (will be handled by Boy Scout Rule)."
59
152
  fi
60
- else
61
- echo "⚠️ Warning: Principles checker execution failed"
62
- echo "⏭️ SKIPPED - Principles check (execution issue)"
63
- GATE_4_STATUS="SKIP"
64
153
  fi
65
154
  else
66
- echo "ℹ️ npx not available - skipping principles check"
67
- echo "⏭️ SKIPPED - Principles check (no Node.js/npx)"
155
+ echo "ℹ️ npx/tsx not available - skipping principles check"
156
+ echo "⏭️ SKIPPED - Principles check (no Node.js/tsx)"
68
157
  GATE_4_STATUS="SKIP"
69
158
  fi
70
159
  else
package/gate-8.sh CHANGED
@@ -1,11 +1,14 @@
1
1
  # ============================================================================
2
- # GATE 8: Secret Scanning (gitleaks)
3
- # Detects secrets (API keys, passwords, tokens) in staged files
4
- # Tool: gitleaks -- https://github.com/gitleaks/gitleaks
2
+ # GATE 8: Secret Scanning (gitleaks + detect-secrets)
3
+ # Detects secrets (API keys, passwords, tokens) in staged files.
4
+ # Tools:
5
+ # gitleaks -- primary, fail-closed (missing or non-0/1 exit => BLOCK, #499)
6
+ # detect-secrets -- secondary, baseline-diff: only blocks on secrets NOT in
7
+ # .secrets.baseline; silently does nothing when unavailable
5
8
  # ============================================================================
6
9
 
7
10
  2>&1 echo ""
8
- 2>&1 echo "→ Gate 8: Secret scanning (gitleaks)..."
11
+ 2>&1 echo "→ Gate 8: Secret scanning (gitleaks + detect-secrets)..."
9
12
  GATE_8_START=$(gate_start_ms)
10
13
 
11
14
  # Gitleaks availability check
@@ -16,41 +19,124 @@ elif [ -f "$HOME/.local/bin/gitleaks" ]; then
16
19
  GITLEAKS_CMD="$HOME/.local/bin/gitleaks"
17
20
  fi
18
21
 
19
- if [ -n "$GITLEAKS_CMD" ]; then
20
- GITLEAKS_CONFIG=""
21
- if [ -f ".gitleaks.toml" ]; then
22
- GITLEAKS_CONFIG="--config=.gitleaks.toml"
22
+ if [ -z "$GITLEAKS_CMD" ]; then
23
+ # fail-closed (#499): an uninstalled gitleaks used to SKIP the gate, so any
24
+ # contributor without it shipped commits with zero secret protection and no
25
+ # signal. A security gate that cannot run must not report success.
26
+ echo " ❌ BLOCKED - gitleaks not installed (fail-closed, #499)."
27
+ echo " Secret scanning is mandatory before commit. Install gitleaks then retry:"
28
+ echo " macOS: brew install gitleaks"
29
+ echo " Windows: winget install gitleaks"
30
+ echo " Linux: scripts/install-gitleaks.sh (or https://github.com/gitleaks/gitleaks)"
31
+ exit 1
32
+ fi
33
+
34
+ GITLEAKS_CONFIG=""
35
+ if [ -f ".gitleaks.toml" ]; then
36
+ # gitleaks is a native binary and may not understand MSYS paths, so pass an
37
+ # absolute path in the host's own format when we can derive one (#449).
38
+ if command -v cygpath >/dev/null 2>&1; then
39
+ GITLEAKS_CONFIG="--config=$(cygpath -w "$(pwd)/.gitleaks.toml")"
40
+ else
41
+ GITLEAKS_CONFIG="--config=$(pwd)/.gitleaks.toml"
23
42
  fi
43
+ fi
24
44
 
25
- # Run gitleaks on staged changes only (pre-commit mode for speed)
26
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --pre-commit --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path=/tmp/gitleaks-report.json 2>&1)
27
- GITLEAKS_EXIT=$?
45
+ # Scan the INDEX, not history. gitleaks 8.x `git --pre-commit` walks committed
46
+ # revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
47
+ # secret was never seen and Gate 8 always passed. `git --staged` scans
48
+ # `git diff --cached`, which is exactly the pre-commit payload (#449).
49
+ GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
50
+ rm -f "$GITLEAKS_REPORT"
51
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
52
+ GITLEAKS_EXIT=$?
28
53
 
29
- if [ "$GITLEAKS_EXIT" -eq 0 ]; then
54
+ if [ "$GITLEAKS_EXIT" -eq 0 ]; then
55
+ # Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
56
+ # meant a gitleaks message change would silently turn a no-evidence run into
57
+ # a PASS -- the same fail-open class this gate just fixed (#449).
58
+ # The report exists and is a JSON array of findings; `[]` == clean scan.
59
+ GITLEAKS_FINDINGS="?"
60
+ if [ -f "$GITLEAKS_REPORT" ]; then
61
+ # A report that is not shaped like a JSON array is no evidence: leave the
62
+ # '?' sentinel so the gate reports SKIP, never a PASS it cannot support
63
+ # (the fail-closed-on-the-claim rule #449 established).
64
+ if grep -q '^\[' "$GITLEAKS_REPORT" 2>/dev/null; then
65
+ # Count occurrences, not matching lines: `grep -c` prints its own zero AND
66
+ # exits 1 on a clean report, so the old `|| echo 0` handed the verdict table
67
+ # $'0\n0' -- no branch matched and a clean scan was BLOCKED as if it leaked
68
+ # (#493). wc -l emits exactly one value, and `|| true` keeps the assignment
69
+ # errexit/pipefail-safe (#489).
70
+ GITLEAKS_FINDINGS=$(grep -o '"RuleID"' "$GITLEAKS_REPORT" 2>/dev/null | wc -l | tr -d '[:space:]') || true
71
+ fi
72
+ fi
73
+
74
+ if [ "$GITLEAKS_FINDINGS" = "0" ]; then
30
75
  echo " ✅ PASSED - No secrets detected."
31
76
  GATE_8_STATUS="PASS"
32
- elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
33
- # Secrets found — output details
77
+ elif [ "$GITLEAKS_FINDINGS" = "?" ]; then
78
+ # Exit 0 but no readable report: we have no evidence the scan happened.
79
+ # Fail closed on the *claim*: report SKIP, never PASS.
80
+ echo " ⚠️ gitleaks exited 0 but produced no readable report"
81
+ echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
82
+ GATE_8_STATUS="SKIP"
83
+ else
84
+ echo " ❌ BLOCKED - gitleaks reported $GITLEAKS_FINDINGS finding(s)"
34
85
  echo "$GITLEAKS_OUTPUT"
35
86
  echo ""
36
- echo "❌ BLOCKED - Secrets detected in staged files."
37
- echo ""
38
- echo "Remediation options:"
39
- echo " 1. Remove the secret and use environment variables instead"
40
- echo " 2. Add a false positive to .gitleaks.toml allowlist"
41
- echo " 3. Use git secret or vault for sensitive data"
42
- echo ""
43
- echo "See: https://github.com/gitleaks/gitleaks"
87
+ echo "Remediation: remove the secret, or allowlist a false positive in .gitleaks.toml."
44
88
  exit 1
89
+ fi
90
+ elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
91
+ # Secrets found — output details
92
+ echo "$GITLEAKS_OUTPUT"
93
+ echo ""
94
+ echo " ❌ BLOCKED - Secrets detected in staged files."
95
+ echo ""
96
+ echo "Remediation options:"
97
+ echo " 1. Remove the secret and use environment variables instead"
98
+ echo " 2. Add a false positive to .gitleaks.toml allowlist"
99
+ echo " 3. Use git secret or vault for sensitive data"
100
+ echo ""
101
+ echo "See: https://github.com/gitleaks/gitleaks"
102
+ exit 1
103
+ else
104
+ # fail-closed (#499): a non-0/1 exit (config error, crash) used to SKIP, which
105
+ # let a broken scanner wave every commit through without any scan.
106
+ echo " ❌ BLOCKED - gitleaks exited with code $GITLEAKS_EXIT (fail-closed, #499)."
107
+ echo "$GITLEAKS_OUTPUT"
108
+ echo " Secret scanning could not complete; fix the gitleaks error above and retry."
109
+ exit 1
110
+ fi
111
+
112
+ # ---- detect-secrets(次要防线,基线比对)----
113
+ # detect-secrets-hook comes from the project's Python dev extra; it blocks only
114
+ # on secrets NOT yet registered in .secrets.baseline, so test-fixture dummy keys
115
+ # recorded in the baseline never trip it.
116
+ #
117
+ # The file list is piped NUL-delimited STRAIGHT into xargs -0 — never through a
118
+ # command substitution. `var=$(cmd -z)` strips every NUL byte (bash cannot hold
119
+ # NULs in a variable), which concatenated all filenames into one giant
120
+ # nonexistent path and would have BLOCKED every commit once a project shipped
121
+ # both detect-secrets-hook and .secrets.baseline. The pipeline lives inside an
122
+ # if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
123
+ if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
124
+ DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
125
+ if git diff --cached --name-only --diff-filter=ACM -z \
126
+ | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
127
+ echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
45
128
  else
46
- echo " ⚠️ gitleaks exited with code $GITLEAKS_EXIT - skipping gate"
47
- echo " ✅ Secret Scanning (SKIP, gitleaks error)"
48
- GATE_8_STATUS="SKIP"
129
+ cat "$DS_TMP"
130
+ echo ""
131
+ echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
132
+ echo " If these are false positives, regenerate the baseline (cross-platform):"
133
+ echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
134
+ rm -f "$DS_TMP"
135
+ exit 1
49
136
  fi
137
+ rm -f "$DS_TMP"
50
138
  else
51
- echo " ℹ️ gitleaks not installed — secret scanning unavailable"
52
- echo " Install: brew install gitleaks (macOS) | winget install gitleaks (Windows) | scripts/install-gitleaks.sh (Linux)"
53
- echo " ⏭️ SKIPPED - Secret scanning (gitleaks not installed)"
54
- GATE_8_STATUS="SKIP"
139
+ echo " ⚠️ detect-secrets-hook unavailable (install: pip install -e \".[dev]\") — gitleaks only."
55
140
  fi
141
+
56
142
  record_gate_audit "gate-8" "secret-scanning" "$GATE_8_STATUS" "0" "$GATE_8_START"
package/hooks/gate-10.sh CHANGED
@@ -33,15 +33,39 @@ else
33
33
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
34
34
  GATE_10_STATUS="SKIP"
35
35
  else
36
+ # One path per line into an array. Quoting the newline-joined list would hand
37
+ # semgrep ONE argument containing newlines -> "Invalid scanning root" once
38
+ # >=2 files are staged (#490). Same defect class as the Gate 4 --files fix
39
+ # (#457): an array is the only form that keeps spaces intact AND splits files.
40
+ #
41
+ # Each element gets a ./ prefix: a tracked path may legitimately start with
42
+ # '-' (`git add -- ./-o.ts`), and bare in argv that element reads as an option
43
+ # and rewrites the scan it was meant to constrain. ./ is a path to every
44
+ # argument parser.
45
+ #
46
+ # The unquoted heredoc below expands $SEMGREP_FILES once, then feeds literal
47
+ # lines to `read`; bash never rescans the result for separators. Its correct
48
+ # reading rests on one property of the producer above, not of the loop:
49
+ # `git diff --cached --name-only` terminates paths with LF and quotes control
50
+ # characters (a CR in a name arrives as the four bytes \215, never as a bare
51
+ # CR), so one line is exactly one path. Asserted for spaces and a leading '-'
52
+ # by AC-490-04.
53
+ SEMGREP_ARGS=()
54
+ while IFS= read -r _semgrep_file; do
55
+ [ -n "$_semgrep_file" ] && SEMGREP_ARGS+=("./$_semgrep_file")
56
+ done <<EOF
57
+ $SEMGREP_FILES
58
+ EOF
59
+
36
60
  # Run semgrep with JSON output
37
61
  # --config=p/security-audit: explicit security ruleset
38
62
  # --json: machine-readable output
39
63
  # --disable-version-check: skip network call
40
- SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "$SEMGREP_FILES" 2>&1)
64
+ SEMGREP_OUTPUT=$("$SEMGREP_CMD" scan --config=p/security-audit --json --disable-version-check "${SEMGREP_ARGS[@]}" 2>&1)
41
65
  SEMGREP_EXIT=$?
42
66
 
43
67
  if [ "$SEMGREP_EXIT" -eq 0 ]; then
44
- echo " ✅ PASSED - No security vulnerabilities found."
68
+ echo " ✅ PASSED - No security vulnerabilities found (files scanned: ${#SEMGREP_ARGS[@]})."
45
69
  GATE_10_STATUS="PASS"
46
70
  elif [ "$SEMGREP_EXIT" -eq 1 ]; then
47
71
  # Findings detected - parse JSON to categorize
@@ -96,7 +120,16 @@ console.log(' ['+severity+'] '+ruleId);console.log(' '+path+':'+line+' → '+m
96
120
  fi
97
121
  else
98
122
  # semgrep runtime error (timeout, config error, etc.)
123
+ #
124
+ # SEMGREP_OUTPUT is captured above and was never shown here, so an intermittent
125
+ # ruleset-fetch failure looked identical to a broken install: the gate printed
126
+ # "runtime error" and kept the reason to itself (#475). The verdict stays SKIP --
127
+ # an operational failure must not block the commit, and must not be called a pass.
99
128
  echo " ⚠️ semgrep exited with code ${SEMGREP_EXIT} — skipping gate"
129
+ if [ -n "$SEMGREP_OUTPUT" ]; then
130
+ echo " ── what semgrep reported ──"
131
+ printf '%s\n' "$SEMGREP_OUTPUT" | tail -10 | sed 's/^/ /'
132
+ fi
100
133
  echo " ⏭️ SKIPPED - SAST (semgrep runtime error)"
101
134
  GATE_10_STATUS="SKIP"
102
135
  fi