Skip to content

Commit 3285ac1

Browse files
fix(baseline): file_pattern glob matching + jq scoping bugs (#180)
## Summary Two latent jq scoping bugs in baseline `file_pattern` matching: 1. **`f.file` re-evaluates the identity** inside `map(select(...))` — `.` rebinds to each baseline entry, so `f.file` returned the baseline entry's `.file` field instead of the finding's. Fix: `f as $finding | ...` before entering the map. 2. **`.file_pattern` inside `test(arg)`** — `test`'s argument is evaluated in the input's scope (the file string), so `.file_pattern` errored with "Cannot index string", silently swallowed by `select()` and treated truthy. This made `file_pattern` entries always-match. Fix: `(.file_pattern? // null) as $pat | ...` capture before `test()`. Both `scripts/apply-baseline.sh` and the inline `in_baseline()` helper in `governance-reusable.yml` had these bugs. Both now mirror the same corrected `**` → `.*` / `*` → `[^/]*` glob → regex translation. Adds `scripts/tests/apply-baseline-test.sh` — six regression cases that pin both bugs so they can't recur (exact match, glob match, over-match guard, single-* segment, slash-crossing, empty baseline). ## Why this matters The absolute-zero language-demo repo carries ~30 legitimate banned-language example files under `examples/` (Java, Kotlin, Swift, Dart, Cobol, Erlang, Fortran, Lisp, …). Its `main` branch currently fails governance on every push because `.hypatia-baseline.json` `file_pattern` doesn't actually exempt anything. Same blocker: maa-framework #69 (Dependabot rust-toolchain bump) — vendored `absolute-zero/examples/{java,kotlin}/*.{java,kt}` files trip the language-policy step. After this lands, both can use a single `file_pattern: "examples/**"` entry instead of per-file `.hypatia-ignore` enumeration. ## Test plan - [x] `scripts/tests/apply-baseline-test.sh` — 6/6 pass locally - [x] End-to-end simulation of language-policy step against a fixture with `examples/` baseline-exempted (Java enforce fails on `src/RealCode.java`, Swift enforce passes on `examples/swift/*`) - [ ] CI on this PR - [ ] Downstream: file follow-up PRs to absolute-zero (`.hypatia-baseline.json` with `file_pattern`) and maa-framework 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 3e4bd4c commit 3285ac1

3 files changed

Lines changed: 138 additions & 14 deletions

File tree

.github/workflows/governance-reusable.yml

Lines changed: 28 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -244,20 +244,42 @@ jobs:
244244
245245
# Baseline lookup: returns 0 (exempt) if the file appears in
246246
# .hypatia-baseline.json with a matching rule_module + type.
247-
# `file_pattern` glob match is intentionally NOT implemented
248-
# here; the advisory-rollout window only honours exact `file`
249-
# matches. Pattern support arrives with the blocking-mode
250-
# flip and the apply-baseline.sh upgrade (see
251-
# standards/scripts/apply-baseline.sh).
247+
# Honours both `file` (exact) and `file_pattern` (glob) entries.
248+
# The glob → regex translation mirrors apply-baseline.sh exactly:
249+
# `**` matches any depth (incl. `/`); `*` matches one segment.
250+
# Pattern support unblocks language-demo repos (absolute-zero
251+
# carries ~30 banned-language example files under `examples/`)
252+
# and any repo that vendors such subtrees, replacing per-file
253+
# `.hypatia-ignore` enumeration with one `file_pattern` entry.
252254
in_baseline() {
253255
local target="$1"
254256
[ -f .hypatia-baseline.json ] || return 1
255257
command -v jq >/dev/null 2>&1 || return 1
258+
# Note: the `as $pat` capture is essential — inside `test(...)`
259+
# the dot rebinds to test's input ($f, a string), so
260+
# `.file_pattern` would error with "Cannot index string". We
261+
# capture file_pattern in $pat first, then reference it inside
262+
# the test() argument.
256263
jq -e \
257264
--arg rm "$rule_module" \
258265
--arg rt "$rule_type" \
259266
--arg f "$target" \
260-
'any(.[]; .rule_module == $rm and .type == $rt and .file == $f)' \
267+
'any(.[];
268+
.rule_module == $rm and .type == $rt
269+
and (
270+
(.file? // null) == $f
271+
or (
272+
(.file_pattern? // null) as $pat
273+
| $pat != null
274+
and ($f | test(
275+
$pat
276+
| gsub("\\*\\*"; "DOUBLESTAR")
277+
| gsub("\\*"; "[^/]*")
278+
| gsub("DOUBLESTAR"; ".*")
279+
| "^" + . + "$"
280+
))
281+
)
282+
))' \
261283
.hypatia-baseline.json >/dev/null 2>&1
262284
}
263285

scripts/apply-baseline.sh

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -74,18 +74,31 @@ EXPIRED_COUNT=$((EXPIRED_COUNT - ACTIVE_COUNT))
7474
ANNOTATED="$(jq -n \
7575
--argjson findings "$FINDINGS_JSON" \
7676
--argjson baseline "$ACTIVE_BASELINE" '
77+
# Two captures are essential here:
78+
# `f as $finding` — without this, references like `f.file` inside the
79+
# select() get re-evaluated against the current baseline entry (the
80+
# re-bound `.`), not the finding. Binding $finding once captures the
81+
# finding before we enter the map(select()) over the baseline.
82+
#
83+
# `(.file_pattern? // null) as $pat` — inside `test(arg)` the dot
84+
# rebinds to the input of test ($finding.file, a string), so
85+
# referencing `.file_pattern` there would error with "Cannot index
86+
# string". Capture the entry pattern first, then reference $pat
87+
# inside the test() regex argument.
7788
def match_entry(f):
78-
$baseline
89+
f as $finding
90+
| $baseline
7991
| map(select(
80-
.severity == f.severity
81-
and .rule_module == f.rule_module
82-
and .type == f.type
92+
.severity == $finding.severity
93+
and .rule_module == $finding.rule_module
94+
and .type == $finding.type
8395
and (
84-
(.file? // null) == f.file
96+
(.file? // null) == $finding.file
8597
or (
86-
.file_pattern? != null
87-
and (f.file | test(
88-
.file_pattern
98+
(.file_pattern? // null) as $pat
99+
| $pat != null
100+
and ($finding.file | test(
101+
$pat
89102
| gsub("\\*\\*"; "DOUBLESTAR")
90103
| gsub("\\*"; "[^/]*")
91104
| gsub("DOUBLESTAR"; ".*")
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
#!/usr/bin/env bash
2+
# SPDX-License-Identifier: PMPL-1.0-or-later
3+
#
4+
# apply-baseline-test.sh — regression test for apply-baseline.sh's
5+
# file_pattern glob handling.
6+
#
7+
# The original implementation referenced `.file_pattern` inside `test(...)`,
8+
# where jq rebinds `.` to test's input (a string), causing a "Cannot index
9+
# string" error that was silently masked by `select(...)`'s error-tolerance
10+
# and produced an always-matches result. This test pins both the exact-file
11+
# and glob paths so the regression cannot recur.
12+
#
13+
# Run: bash scripts/tests/apply-baseline-test.sh
14+
15+
set -euo pipefail
16+
17+
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
18+
APPLY="$SCRIPT_DIR/../apply-baseline.sh"
19+
WORK="$(mktemp -d)"
20+
trap 'rm -rf "$WORK"' EXIT
21+
22+
pass=0
23+
fail=0
24+
25+
assert_status() {
26+
local label="$1" findings="$2" baseline="$3" expected="$4"
27+
local got
28+
got=$("$APPLY" "$findings" "$baseline" advisory 2>/dev/null \
29+
| jq -r '"\(.findings_suppressed | length),\(.findings_kept | length)"')
30+
if [ "$got" = "$expected" ]; then
31+
echo "PASS: $label (suppressed,kept=$got)"
32+
pass=$((pass + 1))
33+
else
34+
echo "FAIL: $label expected=$expected got=$got"
35+
fail=$((fail + 1))
36+
fi
37+
}
38+
39+
# === Case 1: exact `file` match ===
40+
cat > "$WORK/findings1.json" <<'EOF'
41+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt"}]
42+
EOF
43+
cat > "$WORK/baseline1.json" <<'EOF'
44+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Legacy.kt"}]
45+
EOF
46+
assert_status "exact file match suppresses" \
47+
"$WORK/findings1.json" "$WORK/baseline1.json" "1,0"
48+
49+
# === Case 2: file_pattern `examples/**` matches nested file ===
50+
cat > "$WORK/findings2.json" <<'EOF'
51+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"examples/kotlin/Nop.kt"}]
52+
EOF
53+
cat > "$WORK/baseline2.json" <<'EOF'
54+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file_pattern":"examples/**"}]
55+
EOF
56+
assert_status "file_pattern matches nested file" \
57+
"$WORK/findings2.json" "$WORK/baseline2.json" "1,0"
58+
59+
# === Case 3: file_pattern MUST NOT match unrelated file (regression
60+
# against the always-matches bug from `.file_pattern` inside test()) ===
61+
cat > "$WORK/findings3.json" <<'EOF'
62+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"src/Unrelated.kt"}]
63+
EOF
64+
assert_status "file_pattern does not over-match" \
65+
"$WORK/findings3.json" "$WORK/baseline2.json" "0,1"
66+
67+
# === Case 4: single-segment * does not cross / ===
68+
cat > "$WORK/findings4a.json" <<'EOF'
69+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"vendor/acme/legacy.java"}]
70+
EOF
71+
cat > "$WORK/findings4b.json" <<'EOF'
72+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file":"vendor/acme/deep/legacy.java"}]
73+
EOF
74+
cat > "$WORK/baseline4.json" <<'EOF'
75+
[{"severity":"high","rule_module":"cicd_rules","type":"banned_language_file","file_pattern":"vendor/*/legacy.java"}]
76+
EOF
77+
assert_status "single * matches one segment" \
78+
"$WORK/findings4a.json" "$WORK/baseline4.json" "1,0"
79+
assert_status "single * does not cross slash" \
80+
"$WORK/findings4b.json" "$WORK/baseline4.json" "0,1"
81+
82+
# === Case 5: empty baseline keeps the finding ===
83+
echo '[]' > "$WORK/empty.json"
84+
assert_status "empty baseline keeps finding" \
85+
"$WORK/findings2.json" "$WORK/empty.json" "0,1"
86+
87+
echo
88+
echo "Total: $pass passed, $fail failed"
89+
[ "$fail" -eq 0 ]

0 commit comments

Comments
 (0)