@boyingliu01/xp-gate 0.35.18 → 0.35.20

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 (47) hide show
  1. package/adapters/gate-3.sh +94 -9
  2. package/adapters/gate-4.sh +27 -32
  3. package/adapters/gate-8.sh +32 -3
  4. package/adapters/gate-9.sh +14 -2
  5. package/hooks/pre-commit +58 -12
  6. package/lib/__tests__/doctor-delphi.test.js +114 -0
  7. package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
  8. package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
  9. package/lib/__tests__/sprint-status-rework.test.js +32 -7
  10. package/lib/__tests__/test-alignment-java-702.test.ts +182 -0
  11. package/lib/__tests__/test-alignment.test.ts +6 -2
  12. package/lib/__tests__/update-skill.test.js +77 -0
  13. package/lib/doctor-tui.js +23 -2
  14. package/lib/doctor.js +28 -7
  15. package/lib/mattpocock-license-verdict.json +1 -1
  16. package/lib/sprint-status.js +91 -26
  17. package/lib/test-alignment.ts +152 -6
  18. package/lib/update-skill.js +59 -3
  19. package/mock-policy/AGENTS.md +3 -3
  20. package/mutation/AGENTS.md +3 -3
  21. package/package.json +1 -1
  22. package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
  23. package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
  24. package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
  25. package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
  26. package/plugins/dsh/lib/index.d.ts +24 -0
  27. package/plugins/dsh/lib/index.d.ts.map +1 -1
  28. package/plugins/dsh/lib/index.js +37 -1
  29. package/plugins/dsh/package.json +1 -1
  30. package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
  31. package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
  32. package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
  33. package/plugins/dsh/src/delphi-version-footer.test.ts +42 -0
  34. package/plugins/dsh/src/index.ts +38 -1
  35. package/plugins/opencode/package.json +1 -1
  36. package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
  37. package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
  38. package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
  39. package/plugins/qoder/plugin.json +1 -1
  40. package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
  41. package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
  42. package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
  43. package/principles/AGENTS.md +3 -3
  44. package/scripts/sync-package-content.js +32 -1
  45. package/skills/delphi-review/AGENTS.md +3 -3
  46. package/skills/sprint-flow/AGENTS.md +3 -3
  47. package/skills/test-specification-alignment/AGENTS.md +3 -3
@@ -172,8 +172,19 @@ elif [ "$PROJECT_LANG" = "powershell" ]; then
172
172
  const entries = baseline.functions || baseline.entries || baseline;
173
173
  if (Array.isArray(entries)) {
174
174
  for (const e of entries) {
175
- if (!e || !e.file || !e.name) continue;
176
- known.set(norm(e.file) + "::" + e.name, Number(e.ccn) || 0);
175
+ // #632: accept the key the baseline WRITER actually emits.
176
+ // tools/Update-ComplexityBaseline.ps1 (the tool this gate
177
+ // points at in its own fix-it message) writes "function",
178
+ // while this reader only knew "name" -- so every entry was
179
+ // dropped, "known" stayed empty, and every function above
180
+ // the threshold became a brand-new function. The ratchet
181
+ // could never pass. Both spellings are read: this is a
182
+ // reader, and the writer vocabulary is not ours to
183
+ // redefine from here.
184
+ if (!e || !e.file) continue;
185
+ const fnName = e.name || e.function;
186
+ if (!fnName) continue;
187
+ known.set(norm(e.file) + "::" + fnName, Number(e.ccn) || 0);
177
188
  }
178
189
  }
179
190
  // The measurement emits ABSOLUTE paths (pwsh resolved them), while
@@ -450,6 +461,52 @@ else
450
461
  GATE_3_DETAIL="mode=ratchet verifier=MISSING"
451
462
  exit 1
452
463
  fi
464
+ RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
465
+ [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
466
+ # #617: grade exactly the population the baseline was written from.
467
+ #
468
+ # The comment below has always said so, but only as a comment: a repo whose
469
+ # baseline came from a narrower scan (a Java-only ccn-report, say) had its
470
+ # vendored minified JS graded anyway, and --csv-verified then disagreed with
471
+ # lizard on single-line code and fail-closed with "could not be
472
+ # span-verified". That BLOCK has no repair: the file has no baseline entry
473
+ # to ratchet, so there is nothing the user could add to clear it.
474
+ #
475
+ # The population filter runs BEFORE verification on purpose. Domain
476
+ # restriction is decidable from the path alone, so asking the expensive
477
+ # span re-derivation about a file that will not be graded anyway only
478
+ # manufactures failures for code the ratchet never claims to cover.
479
+ if [ -f "$RATCHET_LIB" ]; then
480
+ # shellcheck source=/dev/null
481
+ . "$RATCHET_LIB" 2>/dev/null || true
482
+ if declare -F ccn_file_in_population >/dev/null 2>&1; then
483
+ _cc_pop_kept=""
484
+ _cc_pop_skipped=""
485
+ while IFS= read -r _cc_row; do
486
+ [ -n "$_cc_row" ] || continue
487
+ _cc_row_path=$(printf '%s' "$_cc_row" | awk -F, '{print $1}')
488
+ if ccn_file_in_population ".ccn-baseline.json" "$_cc_row_path"; then
489
+ _cc_pop_kept="${_cc_pop_kept}${_cc_row}
490
+ "
491
+ else
492
+ _cc_pop_skipped="${_cc_pop_skipped}${_cc_row_path}
493
+ "
494
+ fi
495
+ done <<CC_POP_EOF
496
+ $CC_CSV
497
+ CC_POP_EOF
498
+ CC_CSV=$(printf '%s' "$_cc_pop_kept")
499
+ if [ -n "$_cc_pop_skipped" ]; then
500
+ _cc_pop_skip_n=$(printf '%s' "$_cc_pop_skipped" | grep -c . || true)
501
+ echo "ℹ️ Gate 3: ${_cc_pop_skip_n:-0} file(s) outside the baseline population — not graded, not ratcheted:"
502
+ printf '%s' "$_cc_pop_skipped" | sed 's/^/ /' | sed -n '1,5p'
503
+ if [ "${_cc_pop_skip_n:-0}" -gt 5 ]; then
504
+ echo " ... and $((_cc_pop_skip_n - 5)) more"
505
+ fi
506
+ echo " (declare or widen the population in .ccn-baseline.json to grade them)"
507
+ fi
508
+ fi
509
+ fi
453
510
  if [ -f "$CC_REPORT" ] && command -v node >/dev/null 2>&1; then
454
511
  _cc_verify_failed=0
455
512
  CC_CSV_VERIFIED=$(printf '%s\n' "$CC_CSV" | node "$CC_REPORT" --csv-verified batch --root "$(git rev-parse --show-toplevel 2>/dev/null)") || _cc_verify_failed=1
@@ -460,8 +517,6 @@ else
460
517
  fi
461
518
  CC_CSV="$CC_CSV_VERIFIED"
462
519
  fi
463
- RATCHET_LIB="$GATE_DIR/lib/ccn-ratchet.sh"
464
- [ -f "$RATCHET_LIB" ] || RATCHET_LIB="$(git rev-parse --show-toplevel 2>/dev/null)/githooks/lib/ccn-ratchet.sh"
465
520
  # Fail closed when the ratchet library is missing. Sourcing a nonexistent
466
521
  # file only prints "No such file or directory" and leaves
467
522
  # evaluate_ccn_ratchet undefined; the verdict then came back empty, every
@@ -529,13 +584,43 @@ else
529
584
  # this, a crashing or mis-invoked lizard yields 0 warnings and the gate
530
585
  # reports PASSED on zero analysed functions -- the same silent-success
531
586
  # failure this issue is about.
587
+ #
588
+ # #666: "no rows" has TWO causes and they must not share a verdict. A
589
+ # module of pure constants legitimately has zero functions, and lizard
590
+ # emits no row for it at all (measured: `lizard pure_data.py --csv` prints
591
+ # nothing and exits 0). That was reported as `lizard_output_unparsable`
592
+ # /WARN, which is the message for a BROKEN lizard -- so a healthy run on a
593
+ # data-only diff was indistinguishable from a tooling fault. The two are
594
+ # separated by a POSITIVE probe: ask lizard to describe a fixture known to
595
+ # contain a function. If it produces rows, lizard works and the input
596
+ # simply had no functions (SKIP, `no_functions_in_input`); if it does not,
597
+ # the tool really is broken (WARN, `lizard_output_unparsable`).
532
598
  if ! printf '%s\n' "$CC_CSV_FLAT" | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { found = 1; exit }
533
599
  END { exit(found ? 0 : 1) }'; then
534
- echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
535
- GATE_3_STATUS="WARN"
536
- # Consumed by the sourcing parent (pre-commit report JSON), not here.
537
- # shellcheck disable=SC2034
538
- GATE_3_REASON="lizard_output_unparsable"
600
+ _lizard_probe_dir=$(mktemp -d 2>/dev/null || echo "${TMPDIR:-/tmp}")
601
+ _lizard_probe_file="$_lizard_probe_dir/xp-gate-lizard-probe.py"
602
+ printf 'def _xp_gate_probe(x):\n if x:\n return 1\n return 0\n' > "$_lizard_probe_file" 2>/dev/null
603
+ _lizard_probe_rows=0
604
+ if [ -f "$_lizard_probe_file" ]; then
605
+ _lizard_probe_rows=$(printf '%s\n' "$_lizard_probe_file" \
606
+ | lizard_batches --csv 2>/dev/null \
607
+ | awk -F, 'NF > 1 && $2 ~ /^[0-9]+$/ { c++ } END { print c + 0 }')
608
+ fi
609
+ rm -rf "$_lizard_probe_dir" 2>/dev/null || true
610
+ if [ "${_lizard_probe_rows:-0}" -gt 0 ]; then
611
+ echo "⏭️ SKIPPED - Complexity: the changed source files define no functions (pure data module)."
612
+ echo " Verified: lizard parsed a probe fixture normally, so this is an empty result set, not a broken tool."
613
+ GATE_3_STATUS="SKIP"
614
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
615
+ # shellcheck disable=SC2034
616
+ GATE_3_REASON="no_functions_in_input"
617
+ else
618
+ echo "⚠️ WARN - lizard produced no parsable results; complexity not verified"
619
+ GATE_3_STATUS="WARN"
620
+ # Consumed by the sourcing parent (pre-commit report JSON), not here.
621
+ # shellcheck disable=SC2034
622
+ GATE_3_REASON="lizard_output_unparsable"
623
+ fi
539
624
  elif [ "$CC_WARNINGS" -gt 0 ]; then
540
625
  echo "$CC_OUTPUT"
541
626
  echo ""
@@ -46,7 +46,14 @@ java_coverage_note() {
46
46
  esac
47
47
  done
48
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)"
49
+ # #628: `docs/` is not in the npm package's `files` list, so pointing a user at
50
+ # `docs/java-principles-coverage.md` was a dead path in every installed project --
51
+ # the note told them to read a file that does not exist on their machine. Cite the
52
+ # upstream URL instead, and keep the local path only when the doc is actually
53
+ # present (i.e. when running inside this repo).
54
+ local _where="https://github.com/boyingliu01/xp-gate/blob/main/docs/java-principles-coverage.md"
55
+ [ -f "docs/java-principles-coverage.md" ] && _where="docs/java-principles-coverage.md"
56
+ echo "ℹ️ Java rule coverage: $JAVA_COVERAGE_EFFECTIVE/$JAVA_COVERAGE_TOTAL rules effective (inert on Java: $JAVA_COVERAGE_INERT -- see $_where)"
50
57
  }
51
58
 
52
59
  2>&1 echo ""
@@ -165,42 +172,30 @@ else
165
172
  rm -f "$PRINCIPLES_STDERR"
166
173
 
167
174
  if [ "$PRINCIPLES_EXIT" -ge 2 ]; then
168
- # Fail-closed (#661), same rule as Gate 8 (#499): a gate that cannot run
169
- # must not report success.
175
+ # #661: exit>=2 means the checker did not RUN (crash, module not found,
176
+ # bad invocation). This used to record SKIP, and pre-commit treats SKIP
177
+ # as non-blocking, so a tool failure was absorbed into the green light --
178
+ # the reproduction printed a module-not-found stack trace and then
179
+ # "✅ PASSED - Principles checker (no errors found)".
170
180
  #
171
- # This branch used to settle on SKIP, which the caller reads as
172
- # non-blocking -- so a checker that crashed on startup
173
- # (`Cannot find module 'C:\c\Users\...'`, the POSIX-path-to-native-node
174
- # bug) still printed "PASSED - Principles checker (no errors found)".
175
- # The path conversion fixed that one cause; any other way of breaking
176
- # the launch (missing tsx, corrupt module, OOM) lands here too, and all
177
- # of them would have been absorbed into a green light.
181
+ # The distinction the gate must keep:
182
+ # - tool NOT INSTALLED -> SKIP is honest; nothing is being claimed.
183
+ # - tool PRESENT, CRASH -> WARN. Not a pass (nothing was checked) and
184
+ # not a silent skip (the user must know their code went unaudited).
178
185
  #
179
- # SKIP stays reserved for "language not applicable / tool not
180
- # installed", which is not what this is: the checker exists and failed.
181
- echo ""
182
- echo "❌ BLOCKED - Principles checker failed to execute (exit $PRINCIPLES_EXIT)"
183
- echo "This is a tool fault, NOT a clean result. A gate that cannot run must not pass."
184
- echo " Fix the checker, or set PRINCIPLES_SKIP_TOOL_FAULT=1 to downgrade to a warning."
185
- echo " (downgrade is audit-logged and never allowed on main/master)"
186
- GATE_4_STATUS="FAIL"
186
+ # WARN rather than FAIL on purpose: the commit itself is not necessarily
187
+ # bad, the TOOL is broken, and blocking every commit until node/tsx is
188
+ # repaired would blame the change for an environment fault. What must
189
+ # never happen is the green light.
190
+ echo "⚠️ Principles checker did not run (exit ${PRINCIPLES_EXIT}) — this code is NOT verified."
191
+ echo " Gate 4 cannot claim a pass for a check that never executed."
192
+ echo " Fix the checker invocation above (or run 'npx xp-gate check principles .')."
193
+ echo "⏭️ WARN - Principles check (checker execution error)"
194
+ GATE_4_STATUS="WARN"
195
+ # Consumed by the sourcing parent, not here (see #548).
187
196
  # shellcheck disable=SC2034
188
197
  GATE_4_REASON="checker_execution_error"
189
198
  rm -f "$PRINCIPLES_JSON"
190
- if [ "${PRINCIPLES_SKIP_TOOL_FAULT:-0}" = "1" ]; then
191
- echo "⚠️ ESCAPE VALVE: PRINCIPLES_SKIP_TOOL_FAULT=1 — downgrading to SKIP"
192
- # Same asymmetry as Gate 5a: a downgrade is tolerable on a feature
193
- # branch, never on main/master, where it would green-light the trunk.
194
- _g4_branch="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo unknown)"
195
- if [ "$_g4_branch" = "main" ] || [ "$_g4_branch" = "master" ]; then
196
- echo "❌ ESCAPE VALVE BLOCKED: PRINCIPLES_SKIP_TOOL_FAULT not allowed on $_g4_branch"
197
- GATE_4_STATUS="FAIL"
198
- exit 1
199
- fi
200
- GATE_4_STATUS="SKIP"
201
- else
202
- exit 1
203
- fi
204
199
  else
205
200
  # Check severity levels. The reporter emits JSON.stringify(out, null, 2),
206
201
  # i.e. `"severity": "warning"` WITH a space; tolerate any whitespace so a
@@ -59,10 +59,24 @@ fi
59
59
  # revisions and reports "0 commits scanned" on a pre-commit run -- so a staged
60
60
  # secret was never seen and Gate 8 always passed. `git --staged` scans
61
61
  # `git diff --cached`, which is exactly the pre-commit payload (#449).
62
+ #
63
+ # #616: `--all` is a different question, and the index is the wrong place to ask
64
+ # it. Under `xp-gate check . --all` the user asked "is the whole tree clean", the
65
+ # index is usually empty, `git --staged` legitimately found nothing, and that
66
+ # nothing was counted as PASSED -- a secret sitting in a tracked file was
67
+ # invisible to the one command meant to find it. The runner already exports
68
+ # GATE_SCOPE (#589); this fragment just has to read it. `dir --no-git` scans the
69
+ # filesystem so tracked-but-unmodified files are covered.
62
70
  GITLEAKS_REPORT="${TMPDIR:-/tmp}/gitleaks-report.json"
63
71
  rm -f "$GITLEAKS_REPORT"
64
- GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
65
- GITLEAKS_EXIT=$?
72
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
73
+ echo " Scope: --all (scanning the working tree, not the index)"
74
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD dir --no-git --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
75
+ GITLEAKS_EXIT=$?
76
+ else
77
+ GITLEAKS_OUTPUT=$($GITLEAKS_CMD git --staged --redact --no-banner $GITLEAKS_CONFIG --report-format=json --report-path="$GITLEAKS_REPORT" 2>&1)
78
+ GITLEAKS_EXIT=$?
79
+ fi
66
80
 
67
81
  if [ "$GITLEAKS_EXIT" -eq 0 ]; then
68
82
  # Decide SKIP from the REPORT ARTIFACT, not from log wording. Parsing stdout
@@ -183,7 +197,22 @@ fi
183
197
  # if-condition (errexit-exempt); a non-zero hook exit lands in the else branch.
184
198
  if command -v detect-secrets-hook >/dev/null 2>&1 && [ -f ".secrets.baseline" ]; then
185
199
  DS_TMP="$(dirname "$(mktemp -u)")/ds-output.$$"
186
- if git diff --cached --name-only --diff-filter=ACM -z \
200
+ # #616: same scope split as the gitleaks call above. Under `--all` the index is
201
+ # usually empty, so piping `git diff --cached -z` into xargs fed the hook zero
202
+ # files and it reported success without reading anything.
203
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
204
+ if git ls-files -z | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
205
+ echo " ✅ detect-secrets PASSED - no new secrets beyond baseline (scope: --all)."
206
+ else
207
+ cat "$DS_TMP"
208
+ echo ""
209
+ echo " ❌ BLOCKED - detect-secrets found secrets not in .secrets.baseline."
210
+ echo " If these are false positives, regenerate the baseline (cross-platform):"
211
+ echo " detect-secrets scan --update .secrets.baseline && git add .secrets.baseline"
212
+ rm -f "$DS_TMP"
213
+ exit 1
214
+ fi
215
+ elif git diff --cached --name-only --diff-filter=ACM -z \
187
216
  | xargs -0 detect-secrets-hook --baseline .secrets.baseline > "$DS_TMP" 2>&1; then
188
217
  echo " ✅ detect-secrets PASSED - no new secrets beyond baseline."
189
218
  else
@@ -32,8 +32,20 @@ if [ -z "$SEMGREP_CMD" ]; then
32
32
  echo " Gate 9: SAST Security (WARN, semgrep not installed)"
33
33
  GATE_9_STATUS="WARN"
34
34
  else
35
- # Get staged files filtered to Semgrep-supported languages
36
- SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
35
+ # Get staged files filtered to Semgrep-supported languages.
36
+ #
37
+ # #616: under `--all` the index is usually empty, so the staged diff yielded no
38
+ # files and the gate reported SKIPPED -- "no supported language files changed"
39
+ # -- for a run whose whole purpose was to scan the tree. Same fail-open shape
40
+ # #589 fixed in Gate 3, one layer down: the CLI says the tree was checked and
41
+ # the fragment checks a diff that does not exist. The runner exports GATE_SCOPE;
42
+ # read it and enumerate tracked files instead.
43
+ if [ "${GATE_SCOPE:-diff}" = "all" ]; then
44
+ echo " Scope: --all (scanning tracked files, not the index)"
45
+ SEMGREP_FILES=$(git ls-files 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
46
+ else
47
+ SEMGREP_FILES=$(git diff --cached --name-only --diff-filter=ACM 2>/dev/null | grep -E '\.(ts|tsx|js|jsx|py|go|java|c|cpp|cs|rb|php|scala|swift)$' || true)
48
+ fi
37
49
 
38
50
  if [ -z "$SEMGREP_FILES" ]; then
39
51
  echo " ⏭️ SKIPPED - SAST (no supported language files changed)"
package/hooks/pre-commit CHANGED
@@ -4017,6 +4017,30 @@ else
4017
4017
 
4018
4018
  # Check if architecture config exists (use $PROJECT_ROOT — hook may have cd'd to a subdir via ARCH-03)
4019
4019
  if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ] || [ -f "$PROJECT_ROOT/.archlint.yaml" ]; then
4020
+ # #686: an `architecture.yaml` is NOT evidence that layer boundaries are
4021
+ # checked. Gate 6 only ever tested for the file's existence; archlint is
4022
+ # invoked without --config, and even with a config it cannot enforce layers:
4023
+ # @archlinter/cli 0.16.0 lists a `layer_violation` detector but produces no
4024
+ # results for it under any configuration (-A, -d layer_violation, enabled:
4025
+ # true, globs, exact paths — all exit 0 with zero layer findings). See
4026
+ # docs/plans/2026-11-02-arch-01-gate6-layers-not-enforced-design.md §2.2.
4027
+ #
4028
+ # So a repo that declares layers gets a generic-smell check and a green
4029
+ # verdict, which reads as "layers are clean". It is not: nothing looked.
4030
+ # This block records that fact as a per-language reason so the quality
4031
+ # report cannot score the dimension as covered, and emits a marker the
4032
+ # terminal output can be asserted against.
4033
+ #
4034
+ # Deliberately NOT a capability probe: archlint silently tolerates unknown
4035
+ # config keys, so "it did not complain" is a tautological guard — it would
4036
+ # return "supported" no matter what. File presence is the only honest input.
4037
+ GATE_6_LAYERS_UNCHECKED=0
4038
+ if [ -f "$PROJECT_ROOT/architecture.yaml" ] || [ -f "$PROJECT_ROOT/.architecturerc" ]; then
4039
+ GATE_6_LAYERS_UNCHECKED=1
4040
+ echo " ⚠️ [WARN:LAYERS_UNCHECKED] 分层规则未校验:architecture.yaml 声明的层边界当前不被任何检测器消费"
4041
+ echo " (实测 @archlinter/cli 0.16.0 的 layer_violation 启用后亦无输出;见 #686)"
4042
+ echo " 本次仅校验通用架构坏味道(循环依赖 / 上帝类 / 死代码)。"
4043
+ fi
4020
4044
  # #537: per-language reason bookkeeping. Gate 3/4 are single-language gates
4021
4045
  # and get away with one scalar reason; Gate 6 loops, so a scalar would lose
4022
4046
  # information ("TS passed, PS has no detector" cannot be one string).
@@ -4265,6 +4289,20 @@ JAVAARCHROOTS
4265
4289
  GATE_6_LANG_REASONS=""
4266
4290
  _g6_seen=""
4267
4291
  _g6_mixed=0
4292
+ # #686: a language that archlint actually measured and passed still carries a
4293
+ # reason when the repo declared layer boundaries, because the layer dimension
4294
+ # itself was never measured. Only languages WITHOUT a reason get this stamp —
4295
+ # an existing reason (tool missing, tests failed, ...) is more specific and
4296
+ # must not be overwritten. The `-z` test is what keeps this honest: stamping
4297
+ # unconditionally would erase the real reason and make every language look
4298
+ # alike in the report.
4299
+ if [ "${GATE_6_LAYERS_UNCHECKED:-0}" = "1" ] && [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
4300
+ for _g6_lang in $PROJECT_LANGS; do
4301
+ if [ -z "${GATE_6_REASON_BY_LANG[$_g6_lang]:-}" ]; then
4302
+ GATE_6_REASON_BY_LANG["$_g6_lang"]="layers_config_not_enforced"
4303
+ fi
4304
+ done
4305
+ fi
4268
4306
  for _g6_lang in $PROJECT_LANGS; do
4269
4307
  _g6_r=""
4270
4308
  if [ "${BASH_VERSINFO[0]:-0}" -ge 4 ]; then
@@ -4313,19 +4351,27 @@ JAVAARCHROOTS
4313
4351
  echo " Required configuration file (.archlint.yaml / architecture.yaml) is NOT found."
4314
4352
  echo " Architecture Quality Gate requires explicit architecture constraints."
4315
4353
  echo ""
4316
- echo " Quick fix: create .archlint.yaml with: npx archlint init --no-interactive"
4317
- echo " Or architecture.yaml for custom Clean Architecture layer definitions:"
4318
- echo " layers:"
4319
- echo " - name: api"
4320
- echo " paths: [\"src/api/**\"]"
4321
- echo " - name: domain"
4322
- echo " paths: [\"src/domain/**\"]"
4323
- echo " allowed_imports: [domain]"
4324
- echo " - name: infrastructure"
4325
- echo " paths: [\"src/infrastructure/**\"]"
4326
- echo " allowed_imports: [domain, infrastructure]"
4354
+ echo " Quick fix: create .archlint.yaml with: npx archlint init --no-interactive"
4355
+ echo " Or write one by hand — this is archlint's own config format, and the"
4356
+ echo " only one it actually reads:"
4357
+ echo " rules:"
4358
+ echo " cyclic_dependency:"
4359
+ echo " severity: high"
4360
+ echo " god_module:"
4361
+ echo " severity: high"
4362
+ echo ""
4363
+ # #686: this used to recommend a top-level `layers:` block. That format is
4364
+ # silently ignored by @archlinter/cli 0.16.0 (verified against a fixture
4365
+ # with a textbook domain->infra violation: the layer_violation detector is
4366
+ # listed by `archlint detectors list` but produces no results under any
4367
+ # configuration, including -A and an explicit -d layer_violation). The hint
4368
+ # therefore taught users to write a file that bought them nothing while
4369
+ # Gate 6 reported green — the worst possible combination.
4370
+ echo " NOTE: layer-boundary rules are NOT enforced by archlint 0.16.0."
4371
+ echo " Do not write a top-level 'layers:' block expecting it to be checked;"
4372
+ echo " Gate 6 will tell you when that changes. See #686 / #690 / #692."
4327
4373
  echo ""
4328
- echo " After creating architecture.yaml, retry the commit."
4374
+ echo " After creating the config, retry the commit."
4329
4375
  echo " (To skip with warning, create .architecture-skip file)"
4330
4376
  exit 1
4331
4377
  fi
@@ -337,3 +337,117 @@ describe('doctor delphi (#414 REQ-3/REQ-6)', () => {
337
337
  }
338
338
  });
339
339
  });
340
+
341
+ // ── #684: the documented global-config fallback ──────────────────────────────
342
+ // SKILL.md tells users to keep ONE machine-level config at
343
+ // `~/.config/xp-gate/.delphi-config.json` and carry no repo-local copy. The
344
+ // runner resolves that fallback, so a review works; the doctor did not, and
345
+ // reported FAIL on a layout its own documentation recommends. Worse, the FAIL
346
+ // detail told the user to copy the example into the project — which manufactures
347
+ // exactly the drifted duplicate that #541 added a shadow warning for.
348
+ //
349
+ // The distinction this batch pins: "no config anywhere" is still a FAIL
350
+ // (that is a genuinely broken install); "no project config but a valid global
351
+ // one" is not.
352
+ describe('doctor delphi #684 global config fallback', () => {
353
+ let tmpDirs;
354
+ let tmpHome;
355
+ let originalHome;
356
+
357
+ beforeEach(() => {
358
+ tmpDirs = makeTempDirs(['doctor-684-home']);
359
+ tmpHome = tmpDirs['doctor-684-home'];
360
+ originalHome = process.env.HOME;
361
+ process.env.HOME = tmpHome;
362
+ fs.mkdirSync(path.join(tmpHome, '.qoder'), { recursive: true });
363
+ resetLibModules(vi, LIB_MODULES);
364
+ muteConsole(vi);
365
+ });
366
+
367
+ afterEach(() => {
368
+ if (originalHome === undefined) delete process.env.HOME; else process.env.HOME = originalHome;
369
+ removeTempDirs(tmpDirs);
370
+ vi.restoreAllMocks();
371
+ });
372
+
373
+ // Local copy: the suite above keeps its own writeConfig inside its describe
374
+ // scope, so this block cannot reach it.
375
+ function writeConfig(configPath) {
376
+ fs.writeFileSync(configPath, JSON.stringify({
377
+ active_profile: 'default',
378
+ profiles: {
379
+ default: {
380
+ providers: {
381
+ gw: { base_url: 'https://gw.example/v1', api_key: 'k' },
382
+ },
383
+ experts: {
384
+ architecture: { provider: 'gw', model: 'model-a' },
385
+ technical: { provider: 'gw', model: 'model-b' },
386
+ feasibility: { provider: 'gw', model: 'model-c' },
387
+ },
388
+ },
389
+ },
390
+ }));
391
+ return configPath;
392
+ }
393
+
394
+ function writeGlobalConfig() {
395
+ const dir = path.join(tmpHome, '.config', 'xp-gate');
396
+ fs.mkdirSync(dir, { recursive: true });
397
+ return writeConfig(path.join(dir, '.delphi-config.json'));
398
+ }
399
+
400
+ function captureLogs() {
401
+ const logs = [];
402
+ const spy = vi.spyOn(console, 'log').mockImplementation((...args) => logs.push(args.join(' ')));
403
+ return { logs, restore: () => spy.mockRestore() };
404
+ }
405
+
406
+ it('AC-684-01: a valid global config with no project copy PASSes instead of failing', async () => {
407
+ const globalPath = writeGlobalConfig();
408
+ const project = path.join(tmpHome, 'a-project-with-no-config');
409
+ fs.mkdirSync(project, { recursive: true });
410
+ const { logs, restore } = captureLogs();
411
+ const { doctorDelphi } = require('../doctor');
412
+ await doctorDelphi({ projectDir: project, globalConfigPath: globalPath, npmLatest: null });
413
+ restore();
414
+ const cfgLine = logs.find((l) => l.includes('Delphi config'));
415
+ expect(cfgLine).toBeDefined();
416
+ expect(cfgLine).not.toMatch(/FAIL/);
417
+ expect(cfgLine).toMatch(/global/i);
418
+ });
419
+
420
+ it('AC-684-02: no config in either scope still FAILs (fallback must not mask a broken install)', async () => {
421
+ const project = path.join(tmpHome, 'bare-project');
422
+ fs.mkdirSync(project, { recursive: true });
423
+ const { logs, restore } = captureLogs();
424
+ const { doctorDelphi } = require('../doctor');
425
+ const code = await doctorDelphi({
426
+ projectDir: project,
427
+ globalConfigPath: path.join(tmpHome, 'no-such-global.json'),
428
+ npmLatest: null,
429
+ });
430
+ restore();
431
+ expect(code).toBe(1);
432
+ // The rendered line carries a ✗ marker, not the literal word FAIL.
433
+ const cfgLine = logs.find((l) => l.includes('Delphi config (.delphi-config.json)'));
434
+ expect(cfgLine).toBeDefined();
435
+ expect(cfgLine).toContain('✗');
436
+ expect(cfgLine).toMatch(/not found/);
437
+ });
438
+
439
+ it('AC-684-03: a project config still wins over the global one', async () => {
440
+ const globalPath = writeGlobalConfig();
441
+ const project = path.join(tmpHome, 'project-with-own-config');
442
+ fs.mkdirSync(project, { recursive: true });
443
+ const localPath = writeConfig(path.join(project, '.delphi-config.json'));
444
+ const { logs, restore } = captureLogs();
445
+ const { doctorDelphi } = require('../doctor');
446
+ await doctorDelphi({ projectDir: project, globalConfigPath: globalPath, npmLatest: null });
447
+ restore();
448
+ const cfgLine = logs.find((l) => l.includes('Delphi config'));
449
+ expect(cfgLine).toBeDefined();
450
+ expect(cfgLine).toContain(localPath);
451
+ expect(cfgLine).not.toMatch(/global/i);
452
+ });
453
+ });
@@ -294,3 +294,77 @@ describe('doctor-tui installed skills enumerate the filesystem (#635)', () => {
294
294
  expect(row.status).toBe('PASS');
295
295
  });
296
296
  });
297
+
298
+ // ── #681: equal version is not proof of equal content ────────────────────────
299
+ // The freshness check short-circuits on `compareSemver(...) === 0` and answers
300
+ // "Up to date" without ever reading the bodies. A skill that gained a whole
301
+ // chapter without a version bump therefore reported healthy while the installed
302
+ // copy was ~20k characters behind — and `update-skill` agreed with it.
303
+ //
304
+ // The version number is a claim the publisher makes; the content is the fact.
305
+ // When the claim says "same" the fact still has to be checked.
306
+ describe('doctor-tui skill freshness #681 equal version, different content', () => {
307
+ let tmpHome;
308
+ let originalHome;
309
+ const bundledSkillsDir = path.join(__dirname, '..', '..', 'skills');
310
+
311
+ beforeEach(() => {
312
+ originalHome = process.env.HOME;
313
+ tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'xpgate-fresh681-'));
314
+ process.env.HOME = tmpHome;
315
+ vi.resetModules();
316
+ delete require.cache[require.resolve('../doctor-tui')];
317
+ delete require.cache[require.resolve('../shared-paths')];
318
+ });
319
+
320
+ afterEach(() => {
321
+ process.env.HOME = originalHome;
322
+ fs.rmSync(tmpHome, { recursive: true, force: true });
323
+ vi.restoreAllMocks();
324
+ });
325
+
326
+ function installSkill(name, content) {
327
+ const dir = path.join(tmpHome, '.config', 'opencode', 'skills', name);
328
+ fs.mkdirSync(dir, { recursive: true });
329
+ fs.writeFileSync(path.join(dir, 'SKILL.md'), content);
330
+ }
331
+
332
+ function runFreshness(name) {
333
+ const { diagnoseInstalledSkills } = require('../doctor-tui');
334
+ const checks = [];
335
+ const issues = diagnoseInstalledSkills({ installedSkills: { [name]: {} } }, checks);
336
+ return { issues, check: checks.find((c) => c.name === `Skill: ${name}`) };
337
+ }
338
+
339
+ it('AC-681-01: same version but a missing chapter is WARN, not "Up to date"', () => {
340
+ const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
341
+ // Keep the frontmatter (version included), drop a large slice of the body.
342
+ const idx = bundled.indexOf('\n---', 4);
343
+ const head = bundled.slice(0, idx + 4);
344
+ const body = bundled.slice(idx + 4);
345
+ const truncated = head + body.slice(0, Math.floor(body.length / 2));
346
+ installSkill('delphi-review', truncated);
347
+
348
+ const { issues, check } = runFreshness('delphi-review');
349
+ expect(check).toBeDefined();
350
+ expect(check.detail).not.toMatch(/Up to date/);
351
+ expect(check.status).toBe('WARN');
352
+ expect(issues).toBe(1);
353
+ });
354
+
355
+ it('AC-681-02: same version and same content is still "Up to date" (no false alarm)', () => {
356
+ const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
357
+ installSkill('delphi-review', bundled);
358
+ const { issues, check } = runFreshness('delphi-review');
359
+ expect(check.detail).toMatch(/Up to date/);
360
+ expect(issues).toBe(0);
361
+ });
362
+
363
+ it('AC-681-03: line endings alone still do not count as a difference', () => {
364
+ const bundled = fs.readFileSync(path.join(bundledSkillsDir, 'delphi-review', 'SKILL.md'), 'utf8');
365
+ installSkill('delphi-review', bundled.replace(/\r\n/g, '\n').replace(/\n/g, '\r\n'));
366
+ const { issues, check } = runFreshness('delphi-review');
367
+ expect(check.detail).toMatch(/Up to date/);
368
+ expect(issues).toBe(0);
369
+ });
370
+ });