@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.
- package/adapters/gate-3.sh +94 -9
- package/adapters/gate-4.sh +27 -32
- package/adapters/gate-8.sh +32 -3
- package/adapters/gate-9.sh +14 -2
- package/hooks/pre-commit +58 -12
- package/lib/__tests__/doctor-delphi.test.js +114 -0
- package/lib/__tests__/doctor-tui-skill-freshness.test.js +74 -0
- package/lib/__tests__/sprint-status-rework-704.test.js +149 -0
- package/lib/__tests__/sprint-status-rework.test.js +32 -7
- package/lib/__tests__/test-alignment-java-702.test.ts +182 -0
- package/lib/__tests__/test-alignment.test.ts +6 -2
- package/lib/__tests__/update-skill.test.js +77 -0
- package/lib/doctor-tui.js +23 -2
- package/lib/doctor.js +28 -7
- package/lib/mattpocock-license-verdict.json +1 -1
- package/lib/sprint-status.js +91 -26
- package/lib/test-alignment.ts +152 -6
- package/lib/update-skill.js +59 -3
- package/mock-policy/AGENTS.md +3 -3
- package/mutation/AGENTS.md +3 -3
- package/package.json +1 -1
- package/plugins/claude-code/.claude-plugin/plugin.json +1 -1
- package/plugins/claude-code/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/claude-code/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/claude-code/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/lib/index.d.ts +24 -0
- package/plugins/dsh/lib/index.d.ts.map +1 -1
- package/plugins/dsh/lib/index.js +37 -1
- package/plugins/dsh/package.json +1 -1
- package/plugins/dsh/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/dsh/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/dsh/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/dsh/src/delphi-version-footer.test.ts +42 -0
- package/plugins/dsh/src/index.ts +38 -1
- package/plugins/opencode/package.json +1 -1
- package/plugins/opencode/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/opencode/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/opencode/skills/test-specification-alignment/AGENTS.md +3 -3
- package/plugins/qoder/plugin.json +1 -1
- package/plugins/qoder/skills/delphi-review/AGENTS.md +3 -3
- package/plugins/qoder/skills/sprint-flow/AGENTS.md +3 -3
- package/plugins/qoder/skills/test-specification-alignment/AGENTS.md +3 -3
- package/principles/AGENTS.md +3 -3
- package/scripts/sync-package-content.js +32 -1
- package/skills/delphi-review/AGENTS.md +3 -3
- package/skills/sprint-flow/AGENTS.md +3 -3
- package/skills/test-specification-alignment/AGENTS.md +3 -3
package/adapters/gate-3.sh
CHANGED
|
@@ -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
|
-
|
|
176
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
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 ""
|
package/adapters/gate-4.sh
CHANGED
|
@@ -46,7 +46,14 @@ java_coverage_note() {
|
|
|
46
46
|
esac
|
|
47
47
|
done
|
|
48
48
|
[ -n "$_has_java" ] || return 0
|
|
49
|
-
|
|
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
|
-
#
|
|
169
|
-
#
|
|
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
|
-
#
|
|
172
|
-
#
|
|
173
|
-
#
|
|
174
|
-
#
|
|
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
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
echo "
|
|
184
|
-
echo "
|
|
185
|
-
echo "
|
|
186
|
-
|
|
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
|
package/adapters/gate-8.sh
CHANGED
|
@@ -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
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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
|
package/adapters/gate-9.sh
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
4317
|
-
echo " Or
|
|
4318
|
-
echo "
|
|
4319
|
-
echo "
|
|
4320
|
-
echo "
|
|
4321
|
-
echo "
|
|
4322
|
-
echo "
|
|
4323
|
-
echo "
|
|
4324
|
-
echo "
|
|
4325
|
-
|
|
4326
|
-
|
|
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
|
|
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
|
+
});
|