@boyingliu01/xp-gate 0.20.0 → 0.20.2

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 (90) hide show
  1. package/adapters/gate-8.sh +115 -73
  2. package/adapters/java.sh +373 -27
  3. package/adapters/powershell.sh +60 -5
  4. package/adapters/python.sh +38 -4
  5. package/build-integrity/__tests__/import-resolver.test.ts +59 -0
  6. package/build-integrity/gate-10.ts +17 -2
  7. package/gate-3.sh +51 -3
  8. package/gate-4.sh +32 -1
  9. package/gate-8.sh +115 -73
  10. package/hooks/gate-3.sh +51 -3
  11. package/hooks/gate-4.sh +32 -1
  12. package/hooks/gate-8.sh +115 -73
  13. package/hooks/pre-commit +404 -62
  14. package/lib/__tests__/doctor-spawn-capability.test.js +116 -0
  15. package/lib/__tests__/doctor-tui-skill-freshness.test.js +49 -10
  16. package/lib/doctor-tui.js +21 -3
  17. package/lib/doctor.js +111 -8
  18. package/lib/gate-audit.ts +3 -0
  19. package/lib/mattpocock-license-verdict.json +1 -1
  20. package/mock-policy/AGENTS.md +4 -4
  21. package/mutation/AGENTS.md +4 -4
  22. package/package.json +2 -2
  23. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  24. package/plugins/claude-code/skills/admin-template-guidelines/SKILL.md +1 -0
  25. package/plugins/claude-code/skills/batch-grill-me/SKILL.md +1 -0
  26. package/plugins/claude-code/skills/clipboard-vision/SKILL.md +1 -0
  27. package/plugins/claude-code/skills/delphi-review/AGENTS.md +4 -4
  28. package/plugins/claude-code/skills/domain-modeling/SKILL.md +1 -0
  29. package/plugins/claude-code/skills/grill-with-docs/SKILL.md +1 -0
  30. package/plugins/claude-code/skills/grilling/SKILL.md +1 -0
  31. package/plugins/claude-code/skills/improve-codebase-architecture/SKILL.md +1 -0
  32. package/plugins/claude-code/skills/ralph-loop/SKILL.md +1 -0
  33. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +4 -4
  34. package/plugins/claude-code/skills/test-driven-development/SKILL.md +1 -0
  35. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +4 -4
  36. package/plugins/claude-code/skills/test-specification-alignment/SKILL.md +1 -0
  37. package/plugins/claude-code/skills/to-issues/SKILL.md +1 -0
  38. package/plugins/dsh/package.json +1 -1
  39. package/plugins/dsh/skills/admin-template-guidelines/SKILL.md +1 -0
  40. package/plugins/dsh/skills/batch-grill-me/SKILL.md +1 -0
  41. package/plugins/dsh/skills/clipboard-vision/SKILL.md +1 -0
  42. package/plugins/dsh/skills/delphi-review/AGENTS.md +4 -4
  43. package/plugins/dsh/skills/domain-modeling/SKILL.md +1 -0
  44. package/plugins/dsh/skills/grill-with-docs/SKILL.md +1 -0
  45. package/plugins/dsh/skills/grilling/SKILL.md +1 -0
  46. package/plugins/dsh/skills/improve-codebase-architecture/SKILL.md +1 -0
  47. package/plugins/dsh/skills/ralph-loop/SKILL.md +1 -0
  48. package/plugins/dsh/skills/sprint-flow/AGENTS.md +4 -4
  49. package/plugins/dsh/skills/test-driven-development/SKILL.md +1 -0
  50. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +4 -4
  51. package/plugins/dsh/skills/test-specification-alignment/SKILL.md +1 -0
  52. package/plugins/dsh/skills/to-issues/SKILL.md +1 -0
  53. package/plugins/opencode/package.json +1 -1
  54. package/plugins/opencode/skills/admin-template-guidelines/SKILL.md +1 -0
  55. package/plugins/opencode/skills/batch-grill-me/SKILL.md +1 -0
  56. package/plugins/opencode/skills/clipboard-vision/SKILL.md +1 -0
  57. package/plugins/opencode/skills/delphi-review/AGENTS.md +4 -4
  58. package/plugins/opencode/skills/domain-modeling/SKILL.md +1 -0
  59. package/plugins/opencode/skills/grill-with-docs/SKILL.md +1 -0
  60. package/plugins/opencode/skills/grilling/SKILL.md +1 -0
  61. package/plugins/opencode/skills/improve-codebase-architecture/SKILL.md +1 -0
  62. package/plugins/opencode/skills/ralph-loop/SKILL.md +1 -0
  63. package/plugins/opencode/skills/sprint-flow/AGENTS.md +4 -4
  64. package/plugins/opencode/skills/test-driven-development/SKILL.md +1 -0
  65. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +4 -4
  66. package/plugins/opencode/skills/test-specification-alignment/SKILL.md +1 -0
  67. package/plugins/opencode/skills/to-issues/SKILL.md +1 -0
  68. package/plugins/qoder/plugin.json +1 -1
  69. package/plugins/qoder/skills/delphi-review/AGENTS.md +4 -4
  70. package/plugins/qoder/skills/sprint-flow/AGENTS.md +4 -4
  71. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +4 -4
  72. package/principles/AGENTS.md +4 -4
  73. package/principles/adapters/__tests__/java.test.ts +317 -0
  74. package/principles/adapters/java.ts +325 -13
  75. package/principles/boy-scout.ts +36 -6
  76. package/scripts/sync-package-content.js +13 -6
  77. package/skills/admin-template-guidelines/SKILL.md +1 -0
  78. package/skills/batch-grill-me/SKILL.md +1 -0
  79. package/skills/clipboard-vision/SKILL.md +1 -0
  80. package/skills/delphi-review/AGENTS.md +4 -4
  81. package/skills/domain-modeling/SKILL.md +1 -0
  82. package/skills/grill-with-docs/SKILL.md +1 -0
  83. package/skills/grilling/SKILL.md +1 -0
  84. package/skills/improve-codebase-architecture/SKILL.md +1 -0
  85. package/skills/ralph-loop/SKILL.md +1 -0
  86. package/skills/sprint-flow/AGENTS.md +4 -4
  87. package/skills/test-driven-development/SKILL.md +1 -0
  88. package/skills/test-specification-alignment/AGENTS.md +4 -4
  89. package/skills/test-specification-alignment/SKILL.md +1 -0
  90. package/skills/to-issues/SKILL.md +1 -0
@@ -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-3.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 3: Cyclomatic Complexity Check
3
4
  # Uses lizard - checks cyclomatic complexity of changed source files
@@ -17,7 +18,45 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
17
18
  GATE_3_STATUS="SKIP"
18
19
 
19
20
  else
20
- CCN_THRESHOLD=5
21
+ # S2 (#507): three-level CCN threshold override --
22
+ # .xp-gate/ccn-threshold (positive integer) > XP_GATE_CCN_THRESHOLD > 5.
23
+ # Invalid values WARN on stderr and fall back to the default; the resolved
24
+ # value reaches the gate-3 audit record via the --detail argument below.
25
+ # Implemented with bash builtins only (no tr/grep spawns): every process
26
+ # spawn is expensive on Windows under AV filter drivers.
27
+ resolve_ccn_threshold() {
28
+ local default=5 val
29
+ if [ -f ".xp-gate/ccn-threshold" ]; then
30
+ val=$(<".xp-gate/ccn-threshold")
31
+ val="${val//[[:space:]]/}"
32
+ case "$val" in
33
+ ''|*[!0-9]*|0)
34
+ echo "WARN - invalid .xp-gate/ccn-threshold value '${val}' (expected a positive integer), falling back to ${default}" >&2
35
+ ;;
36
+ *)
37
+ echo "$val"
38
+ return 0
39
+ ;;
40
+ esac
41
+ echo "$default"
42
+ return 0
43
+ fi
44
+ val=${XP_GATE_CCN_THRESHOLD:-}
45
+ if [ -n "$val" ]; then
46
+ case "$val" in
47
+ ''|*[!0-9]*|0)
48
+ echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '${val}' (expected a positive integer), falling back to ${default}" >&2
49
+ ;;
50
+ *)
51
+ echo "$val"
52
+ return 0
53
+ ;;
54
+ esac
55
+ fi
56
+ echo "$default"
57
+ }
58
+
59
+ CCN_THRESHOLD=$(resolve_ccn_threshold)
21
60
 
22
61
  # Check lizard availability
23
62
  LIZARD_CMD=""
@@ -37,7 +76,16 @@ else
37
76
  echo "Checking complexity for source files..."
38
77
 
39
78
  # Run lizard with CCN threshold
40
- CC_OUTPUT=$($LIZARD_PATH -C $CCN_THRESHOLD $CC_FILES 2>&1 || true)
79
+ # One path per line, into an array. Unquoted $CC_FILES relied on word
80
+ # splitting, which also splits any path containing a space -- handing
81
+ # lizard two bogus paths and checking neither (#457 defect class, same
82
+ # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
83
+ # collapse the list into one argument. An array does both.
84
+ CC_FILES_ARGS=()
85
+ while IFS= read -r _cc_file; do
86
+ [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
87
+ done <<< "$CC_FILES"
88
+ CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
41
89
 
42
90
  # Parse warning count from the summary table: "Warning cnt 8"
43
91
  # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
@@ -65,4 +113,4 @@ else
65
113
  GATE_3_STATUS="WARN"
66
114
  fi
67
115
  fi
68
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START"
116
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"
package/gate-4.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 4: Principles Checker (Clean Code + SOLID)
3
4
  # Reuses existing principles checker logic
@@ -23,6 +24,31 @@ if ! declare -F run_tsx >/dev/null 2>&1; then
23
24
  }
24
25
  fi
25
26
 
27
+ # Java rule coverage annotation (#507 S4, AC-507-04-02). The normative matrix
28
+ # lives in docs/java-principles-coverage.md; these constants mirror it. Before
29
+ # #507 the Java adapter fed rules only {name,type,line,code}, so the 12 rules
30
+ # reading structured metrics silently saw `undefined` and a Java commit got a
31
+ # green "checked" with zero effective coverage. 12/15 now fire; the three
32
+ # inert ones are rule-side gaps documented in the coverage doc -- they must
33
+ # NOT be announced as covered.
34
+ JAVA_COVERAGE_EFFECTIVE=12
35
+ JAVA_COVERAGE_TOTAL=15
36
+ JAVA_COVERAGE_INERT="lsp, many-exports, code-duplication"
37
+
38
+ # Print the coverage annotation when (and only when) the staged file list
39
+ # contains Java sources. Non-Java commits keep the original output byte for
40
+ # byte. Pure shell builtins: no subprocess per file.
41
+ java_coverage_note() {
42
+ local _f _has_java=""
43
+ for _f in "$@"; do
44
+ case "$_f" in
45
+ *.java|*.Java|*.JAVA) _has_java=1; break ;;
46
+ esac
47
+ done
48
+ [ -n "$_has_java" ] || return 0
49
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
50
+ }
51
+
26
52
  2>&1 echo ""
27
53
  2>&1 echo "→ Gate 4: Principles checker (Clean Code + SOLID)..."
28
54
  GATE_4_START=$(gate_start_ms)
@@ -72,7 +98,12 @@ else
72
98
 
73
99
  if [ -n "$PRINCIPLES_DIR" ]; then
74
100
  echo "Checking Clean Code + SOLID principles..."
75
-
101
+
102
+ # AC-507-04-02: say what a Java commit is actually covered for, instead
103
+ # of implying a full 15-rule check (#507). Printed before the run so it
104
+ # appears on both the PASS and the FAIL path.
105
+ java_coverage_note ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"}
106
+
76
107
  if command -v npx > /dev/null 2>&1 || [ -f "$PRINCIPLES_BASE/node_modules/tsx/dist/cli.mjs" ]; then
77
108
  # Run principles checker and store results.
78
109
  #
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,85 +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 is a native binary and may not understand MSYS paths, so pass an
23
- # absolute path in the host's own format when we can derive one (#449).
24
- if command -v cygpath >/dev/null 2>&1; then
25
- GITLEAKS_CONFIG="--config=$(cygpath -w "$(pwd)/.gitleaks.toml")"
26
- else
27
- GITLEAKS_CONFIG="--config=$(pwd)/.gitleaks.toml"
28
- fi
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"
29
42
  fi
43
+ fi
30
44
 
31
- # Scan the INDEX, not history. gitleaks 8.x `git --pre-commit` walks committed
32
- # revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
33
- # secret was never seen and Gate 8 always passed. `git --staged` scans
34
- # `git diff --cached`, which is exactly the pre-commit payload (#449).
35
- GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
36
- rm -f "$GITLEAKS_REPORT"
37
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
38
- 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=$?
39
53
 
40
- if [ "$GITLEAKS_EXIT" -eq 0 ]; then
41
- # Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
42
- # meant a gitleaks message change would silently turn a no-evidence run into
43
- # a PASS -- the same fail-open class this gate just fixed (#449).
44
- # The report exists and is a JSON array of findings; `[]` == clean scan.
45
- GITLEAKS_FINDINGS="?"
46
- if [ -f "$GITLEAKS_REPORT" ]; then
47
- # A report that is not shaped like a JSON array is no evidence: leave the
48
- # '?' sentinel so the gate reports SKIP, never a PASS it cannot support
49
- # (the fail-closed-on-the-claim rule #449 established).
50
- if grep -q '^\[' "$GITLEAKS_REPORT" 2>/dev/null; then
51
- # Count occurrences, not matching lines: `grep -c` prints its own zero AND
52
- # exits 1 on a clean report, so the old `|| echo 0` handed the verdict table
53
- # $'0\n0' -- no branch matched and a clean scan was BLOCKED as if it leaked
54
- # (#493). wc -l emits exactly one value, and `|| true` keeps the assignment
55
- # errexit/pipefail-safe (#489).
56
- GITLEAKS_FINDINGS=$(grep -o '"RuleID"' "$GITLEAKS_REPORT" 2>/dev/null | wc -l | tr -d '[:space:]') || true
57
- fi
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
58
71
  fi
72
+ fi
59
73
 
60
- if [ "$GITLEAKS_FINDINGS" = "0" ]; then
61
- echo " ✅ PASSED - No secrets detected."
62
- GATE_8_STATUS="PASS"
63
- elif [ "$GITLEAKS_FINDINGS" = "?" ]; then
64
- # Exit 0 but no readable report: we have no evidence the scan happened.
65
- # Fail closed on the *claim*: report SKIP, never PASS.
66
- echo " ⚠️ gitleaks exited 0 but produced no readable report"
67
- echo " ⏭️ SKIPPED - secret scanning produced no evidence (not a pass)"
68
- GATE_8_STATUS="SKIP"
69
- else
70
- echo " ❌ BLOCKED - gitleaks reported $GITLEAKS_FINDINGS finding(s)"
71
- echo "$GITLEAKS_OUTPUT"
72
- echo ""
73
- echo "Remediation: remove the secret, or allowlist a false positive in .gitleaks.toml."
74
- exit 1
75
- fi
76
- elif [ "$GITLEAKS_EXIT" -eq 1 ]; then
77
- # Secrets found — output details
74
+ if [ "$GITLEAKS_FINDINGS" = "0" ]; then
75
+ echo " ✅ PASSED - No secrets detected."
76
+ GATE_8_STATUS="PASS"
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)"
78
85
  echo "$GITLEAKS_OUTPUT"
79
86
  echo ""
80
- echo "❌ BLOCKED - Secrets detected in staged files."
81
- echo ""
82
- echo "Remediation options:"
83
- echo " 1. Remove the secret and use environment variables instead"
84
- echo " 2. Add a false positive to .gitleaks.toml allowlist"
85
- echo " 3. Use git secret or vault for sensitive data"
86
- echo ""
87
- echo "See: https://github.com/gitleaks/gitleaks"
87
+ echo "Remediation: remove the secret, or allowlist a false positive in .gitleaks.toml."
88
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."
89
128
  else
90
- echo " ⚠️ gitleaks exited with code $GITLEAKS_EXIT - skipping gate"
91
- echo " ✅ Secret Scanning (SKIP, gitleaks error)"
92
- 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
93
136
  fi
137
+ rm -f "$DS_TMP"
94
138
  else
95
- echo " ℹ️ gitleaks not installed — secret scanning unavailable"
96
- echo " Install: brew install gitleaks (macOS) | winget install gitleaks (Windows) | scripts/install-gitleaks.sh (Linux)"
97
- echo " ⏭️ SKIPPED - Secret scanning (gitleaks not installed)"
98
- GATE_8_STATUS="SKIP"
139
+ echo " ⚠️ detect-secrets-hook unavailable (install: pip install -e \".[dev]\") — gitleaks only."
99
140
  fi
141
+
100
142
  record_gate_audit "gate-8" "secret-scanning" "$GATE_8_STATUS" "0" "$GATE_8_START"
package/hooks/gate-3.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 3: Cyclomatic Complexity Check
3
4
  # Uses lizard - checks cyclomatic complexity of changed source files
@@ -17,7 +18,45 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
17
18
  GATE_3_STATUS="SKIP"
18
19
 
19
20
  else
20
- CCN_THRESHOLD=5
21
+ # S2 (#507): three-level CCN threshold override --
22
+ # .xp-gate/ccn-threshold (positive integer) > XP_GATE_CCN_THRESHOLD > 5.
23
+ # Invalid values WARN on stderr and fall back to the default; the resolved
24
+ # value reaches the gate-3 audit record via the --detail argument below.
25
+ # Implemented with bash builtins only (no tr/grep spawns): every process
26
+ # spawn is expensive on Windows under AV filter drivers.
27
+ resolve_ccn_threshold() {
28
+ local default=5 val
29
+ if [ -f ".xp-gate/ccn-threshold" ]; then
30
+ val=$(<".xp-gate/ccn-threshold")
31
+ val="${val//[[:space:]]/}"
32
+ case "$val" in
33
+ ''|*[!0-9]*|0)
34
+ echo "WARN - invalid .xp-gate/ccn-threshold value '${val}' (expected a positive integer), falling back to ${default}" >&2
35
+ ;;
36
+ *)
37
+ echo "$val"
38
+ return 0
39
+ ;;
40
+ esac
41
+ echo "$default"
42
+ return 0
43
+ fi
44
+ val=${XP_GATE_CCN_THRESHOLD:-}
45
+ if [ -n "$val" ]; then
46
+ case "$val" in
47
+ ''|*[!0-9]*|0)
48
+ echo "WARN - invalid XP_GATE_CCN_THRESHOLD value '${val}' (expected a positive integer), falling back to ${default}" >&2
49
+ ;;
50
+ *)
51
+ echo "$val"
52
+ return 0
53
+ ;;
54
+ esac
55
+ fi
56
+ echo "$default"
57
+ }
58
+
59
+ CCN_THRESHOLD=$(resolve_ccn_threshold)
21
60
 
22
61
  # Check lizard availability
23
62
  LIZARD_CMD=""
@@ -37,7 +76,16 @@ else
37
76
  echo "Checking complexity for source files..."
38
77
 
39
78
  # Run lizard with CCN threshold
40
- CC_OUTPUT=$($LIZARD_PATH -C $CCN_THRESHOLD $CC_FILES 2>&1 || true)
79
+ # One path per line, into an array. Unquoted $CC_FILES relied on word
80
+ # splitting, which also splits any path containing a space -- handing
81
+ # lizard two bogus paths and checking neither (#457 defect class, same
82
+ # fix as gate-4's PRINCIPLES_ARGS). Quoting the scalar would instead
83
+ # collapse the list into one argument. An array does both.
84
+ CC_FILES_ARGS=()
85
+ while IFS= read -r _cc_file; do
86
+ [ -n "$_cc_file" ] && CC_FILES_ARGS+=("$_cc_file")
87
+ done <<< "$CC_FILES"
88
+ CC_OUTPUT=$("$LIZARD_PATH" -C "$CCN_THRESHOLD" ${CC_FILES_ARGS[@]+"${CC_FILES_ARGS[@]}"} 2>&1 || true)
41
89
 
42
90
  # Parse warning count from the summary table: "Warning cnt 8"
43
91
  # Use anchored grep to avoid matching lizard table headers (e.g. "Rt" column)
@@ -65,4 +113,4 @@ else
65
113
  GATE_3_STATUS="WARN"
66
114
  fi
67
115
  fi
68
- record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START"
116
+ record_gate_audit "gate-3" "complexity" "$GATE_3_STATUS" "${CC_WARNINGS:-0}" "$GATE_3_START" "ccn_threshold=${CCN_THRESHOLD:-5}"
package/hooks/gate-4.sh CHANGED
@@ -1,3 +1,4 @@
1
+ # shellcheck shell=bash
1
2
  # ============================================================================
2
3
  # GATE 4: Principles Checker (Clean Code + SOLID)
3
4
  # Reuses existing principles checker logic
@@ -23,6 +24,31 @@ if ! declare -F run_tsx >/dev/null 2>&1; then
23
24
  }
24
25
  fi
25
26
 
27
+ # Java rule coverage annotation (#507 S4, AC-507-04-02). The normative matrix
28
+ # lives in docs/java-principles-coverage.md; these constants mirror it. Before
29
+ # #507 the Java adapter fed rules only {name,type,line,code}, so the 12 rules
30
+ # reading structured metrics silently saw `undefined` and a Java commit got a
31
+ # green "checked" with zero effective coverage. 12/15 now fire; the three
32
+ # inert ones are rule-side gaps documented in the coverage doc -- they must
33
+ # NOT be announced as covered.
34
+ JAVA_COVERAGE_EFFECTIVE=12
35
+ JAVA_COVERAGE_TOTAL=15
36
+ JAVA_COVERAGE_INERT="lsp, many-exports, code-duplication"
37
+
38
+ # Print the coverage annotation when (and only when) the staged file list
39
+ # contains Java sources. Non-Java commits keep the original output byte for
40
+ # byte. Pure shell builtins: no subprocess per file.
41
+ java_coverage_note() {
42
+ local _f _has_java=""
43
+ for _f in "$@"; do
44
+ case "$_f" in
45
+ *.java|*.Java|*.JAVA) _has_java=1; break ;;
46
+ esac
47
+ done
48
+ [ -n "$_has_java" ] || return 0
49
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see docs/java-principles-coverage.md)"
50
+ }
51
+
26
52
  2>&1 echo ""
27
53
  2>&1 echo "→ Gate 4: Principles checker (Clean Code + SOLID)..."
28
54
  GATE_4_START=$(gate_start_ms)
@@ -72,7 +98,12 @@ else
72
98
 
73
99
  if [ -n "$PRINCIPLES_DIR" ]; then
74
100
  echo "Checking Clean Code + SOLID principles..."
75
-
101
+
102
+ # AC-507-04-02: say what a Java commit is actually covered for, instead
103
+ # of implying a full 15-rule check (#507). Printed before the run so it
104
+ # appears on both the PASS and the FAIL path.
105
+ java_coverage_note ${PRINCIPLES_ARGS[@]+"${PRINCIPLES_ARGS[@]}"}
106
+
76
107
  if command -v npx > /dev/null 2>&1 || [ -f "$PRINCIPLES_BASE/node_modules/tsx/dist/cli.mjs" ]; then
77
108
  # Run principles checker and store results.
78
109
  #