Skip to content

Commit f95130b

Browse files
fix(ci): exclude k9 contracts from the Nickel gate (#985)
Closes #982. Cures the Nickel half of #976. ## The defect **A suffix is not a format.** `*.k9.ncl` files are **k9 contracts**, not Nickel source — they open with a `K9!` sentinel on line 1, so `nickel typecheck` dies at `1:3` on the `!`. The gate globbed `*.ncl` and swallowed all of them. Measured in the one pilot repo that actually ran the pipeline (`cicd-squabbler`): | bucket | count | can it ever pass? | |---|---:|---| | `*.k9.ncl` — a different format | 16 | no | | `.ncl` importing build-time JSON (correctly uncommitted) | 2 | no, standalone | | genuine, standalone-checkable Nickel | 9 | yes | | **tracked `.ncl` total** | **27** | **18 could never pass** | That repo went red **entirely on boilerplate**, never on its own source. `detect` counted the same inflated glob at line 132, so `has_nickel` went true on repos holding **no Nickel at all** — installing a toolchain in order to fail. ## The fix - **One shared pathspec.** `detect` and the job both use `'*.ncl' ':!*.k9.ncl'`. *A guard that counts a different set than its consumer checks is how this defect arose*, so the cure is that they cannot diverge. - **k9 contracts are disclosed, not hidden** — counted as `N_K9`, printed in the detection table, and explicitly **excluded from the Total** (the same shape as the existing "detected but unsupported" disclosure). - **Typecheck is skipped only where an `import` target is untracked in git**, and **every skip is disclosed by name with its reason** via `::notice` and in the step summary. A skip is never a pass. Comments are stripped before scanning — `_base.ncl` documents its own usage in a `#` comment containing a literal `import "../_base.ncl"`, and matching that would have skipped a file that typechecks perfectly well. - **Format is still checked over every genuine `.ncl`, with no exemption.** This narrows the file set; it does not weaken the check. - **Narrowing cannot manufacture a pass.** The job prints its denominators and **refuses** when zero files survive, or when every file was skipped. Still read-only: `--check` only, no `--fix`, no `nickel format` in CI. ## Verification — the mutants, not just the greens Run with **the workflow's own pinned nickel 1.18.0** (sha256-verified), and the embedded step body extracted from this YAML and diffed **byte-identical** to the script under test, so these results are about the artefact, not a paraphrase. | control | expected | got | |---|---|---| | unmodified tree | exit 1, 11 format errors, 2 named skips | ✅ | | formatted tree | exit 0 — 11 checked / 9 typechecked / 2 skipped / 16 k9 excluded | ✅ | | injected type error | exit 1 | ✅ | | one file un-formatted | exit 1 | ✅ | | every file skipped | **exit 1, refuses** — does not report a pass | ✅ | | k9-only tree | `has_nickel=false`; job refuses if reached | ✅ | Format-safety control for the follow-up commits: `nickel pprint-ast` is **byte-identical for 11/11 files** before and after `nickel format`, so the reformatting is provably semantics-preserving. ## Consequence, stated plainly Repos that were **vacuously green** because their only `.ncl` were k9 contracts will now correctly report no Nickel. That is #976's intended cure, not a regression. This alone does **not** turn `cicd-squabbler` green: all 11 of its genuine `.ncl` genuinely fail `format --check`. That is a real defect in that repo and is fixed in its own PR, together with the matching fix in `rsr-template-repo` so future seeded repos do not inherit it. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01Ji1bq3TypfycfUPAR7hSxR Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent de68103 commit f95130b

1 file changed

Lines changed: 72 additions & 7 deletions

File tree

‎.github/workflows/ci-pipeline.yml‎

Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -129,7 +129,14 @@ jobs:
129129
# Every probe here has a CONSUMING job later in this file. That is
130130
# the entry requirement for this list, not a coincidence.
131131
N_RUST=$(count 'Cargo.toml' '*/Cargo.toml')
132-
N_NICKEL=$(count '*.ncl')
132+
# A suffix is not a format. `*.k9.ncl` files open with a `K9!` sentinel on
133+
# line 1 and are k9 contracts, not Nickel source — `nickel typecheck` dies
134+
# at 1:3 on the `!`. They are excluded here so `has_nickel` cannot go true
135+
# on a repo holding no Nickel at all, and excluded by the SAME pathspec in
136+
# the `nickel` job: a guard that counts a different set than its consumer
137+
# checks is how a gate comes to fail over files the repo cannot fix.
138+
N_NICKEL=$(count '*.ncl' ':!*.k9.ncl')
139+
N_K9=$(count '*.k9.ncl')
133140
N_RESCRIPT=$(count 'rescript.json' '*/rescript.json' 'bsconfig.json' '*/bsconfig.json' '*.res')
134141
# V is gated on `v.mod` ALONE. The `.v` extension is shared with Coq
135142
# proof scripts and Verilog sources, so globbing `*.v` would install
@@ -224,13 +231,17 @@ jobs:
224231
echo "| Ecosystem | Marker | Tracked files matched |"
225232
echo "|---|---|---|"
226233
echo "| Rust | \`Cargo.toml\` | ${N_RUST} |"
227-
echo "| Nickel | \`*.ncl\` | ${N_NICKEL} |"
234+
echo "| Nickel | \`*.ncl\` excluding \`*.k9.ncl\` | ${N_NICKEL} |"
228235
echo "| ReScript | \`rescript.json\`, \`bsconfig.json\`, \`*.res\` | ${N_RESCRIPT} |"
229236
echo "| V | \`v.mod\` only | ${N_V} |"
230237
echo "| Haskell | \`*.cabal\`, \`stack.yaml\`, \`*.hs\` | ${N_HASKELL} |"
231238
echo "| Deno | \`deno.json(c)\` | ${N_DENO} |"
232239
echo "| **Total checkable** | | **${TOTAL}** |"
233240
echo ""
241+
if [ "$N_K9" -gt 0 ]; then
242+
echo "> **${N_K9} \`*.k9.ncl\` excluded.** These are k9 contracts (a \`K9!\` sentinel on line 1), not Nickel source. They are neither typechecked nor counted above, and they cannot raise the Total."
243+
echo ""
244+
fi
234245
if [ "$N_UNSUPPORTED" -gt 0 ]; then
235246
echo "#### Detected, but this pipeline has no gate for it"
236247
echo ""
@@ -437,25 +448,79 @@ jobs:
437448
shell: bash
438449
run: |
439450
set -euo pipefail
440-
mapfile -d '' FILES < <(git ls-files -z -- '*.ncl')
441-
echo "Nickel files: ${#FILES[@]}"
451+
452+
# Same pathspec as `detect`. k9 contracts are a DIFFERENT format that
453+
# merely shares the suffix; typechecking them reports a parse error the
454+
# repo has no way to fix.
455+
mapfile -d '' FILES < <(git ls-files -z -- '*.ncl' ':!*.k9.ncl')
456+
N_K9=$(git ls-files -z -- '*.k9.ncl' | tr -cd '\0' | wc -c)
457+
458+
echo "Nickel files: ${#FILES[@]} (k9 contracts excluded: ${N_K9})"
442459
if [ "${#FILES[@]}" -eq 0 ]; then
443-
echo "::error::detect said Nickel was present but zero .ncl files are tracked here. Refusing to report a pass over an empty set."
460+
echo "::error::detect said Nickel was present but zero Nickel files are tracked here (${N_K9} *.k9.ncl were excluded as k9 contracts). Refusing to report a pass over an empty set."
444461
exit 1
445462
fi
463+
446464
FAILED=0
465+
TYPECHECKED=0
466+
SKIPPED=()
467+
447468
for f in "${FILES[@]}"; do
448469
# --check is read-only: it reports, it does not rewrite the file.
470+
# Format is checked over EVERY genuine Nickel file, with no exemption.
449471
nickel format --check "$f" || { echo "::error file=$f::not formatted (nickel format --check)"; FAILED=1; }
472+
473+
# A file importing a build artefact cannot be typechecked standalone:
474+
# the input is generated immediately before use and is correctly
475+
# uncommitted. Detect that by asking git whether the import target is
476+
# tracked, and DISCLOSE every skip by name. A skip is not a pass.
477+
MISSING=""
478+
while IFS= read -r imp; do
479+
[ -n "$imp" ] || continue
480+
cand="$(dirname "$f")/$imp"
481+
cand="$(realpath -m --relative-to=. "$cand" 2>/dev/null || echo "$cand")"
482+
git ls-files --error-unmatch -- "$cand" >/dev/null 2>&1 \
483+
|| MISSING="${MISSING}${MISSING:+, }${imp}"
484+
# Comments are stripped first: `_base.ncl` documents its own usage
485+
# in a `#` comment containing a literal `import "../_base.ncl"`, and
486+
# matching that would skip a file that typechecks perfectly well.
487+
done < <(sed 's/#.*//' "$f" | grep -oE 'import[[:space:]]+"[^"]+"' | sed -E 's/.*"([^"]+)".*/\1/' || true)
488+
489+
if [ -n "$MISSING" ]; then
490+
echo "::notice file=$f::nickel typecheck skipped - import target not tracked in git: ${MISSING}"
491+
SKIPPED+=("$f (${MISSING})")
492+
continue
493+
fi
494+
450495
nickel typecheck "$f" || { echo "::error file=$f::failed nickel typecheck"; FAILED=1; }
496+
TYPECHECKED=$((TYPECHECKED + 1))
451497
done
498+
499+
# Narrowing a file set must never be able to manufacture a pass.
500+
if [ "$TYPECHECKED" -eq 0 ]; then
501+
echo "::error::all ${#FILES[@]} Nickel files were skipped for untracked imports, so nothing was typechecked. Refusing to report a pass over an empty set."
502+
FAILED=1
503+
fi
504+
452505
{
453506
echo "### Nickel"
454507
echo ""
455-
echo "- files checked: **${#FILES[@]}**"
508+
echo "- format checked: **${#FILES[@]}**"
509+
echo "- typechecked: **${TYPECHECKED}**"
510+
echo "- typecheck skipped (untracked import): **${#SKIPPED[@]}**"
511+
echo "- k9 contracts excluded (\`*.k9.ncl\`, not Nickel): **${N_K9}**"
512+
if [ "${#SKIPPED[@]}" -gt 0 ]; then
513+
echo ""
514+
echo "Skipped, and why:"
515+
echo ""
516+
for s in "${SKIPPED[@]}"; do echo "- \`${s}\`"; done
517+
fi
518+
echo ""
456519
echo "- result: $( [ "$FAILED" -eq 0 ] && echo 'pass' || echo '**FAIL**' )"
457520
echo ""
458521
} >> "$GITHUB_STEP_SUMMARY"
522+
523+
exit "$FAILED"
459524
exit "$FAILED"
460525
461526
# ───────────────────────────────────────────────────────────────────────
@@ -833,7 +898,7 @@ jobs:
833898
row "Secret scanning" "$R_SECRET" "n/a"
834899
row "SAST (semgrep)" "$R_SAST" "disabled via enable_sast: false"
835900
row "Rust" "$R_RUST" "no Cargo.toml tracked (has_rust=${H_RUST})"
836-
row "Nickel" "$R_NICKEL" "no *.ncl tracked (has_nickel=${H_NICKEL})"
901+
row "Nickel" "$R_NICKEL" "no Nickel tracked, *.k9.ncl excluded (has_nickel=${H_NICKEL})"
837902
row "Deno" "$R_DENO" "BANNED — refusal gate; skipped means no deno.json(c) tracked (has_deno=${H_DENO})"
838903
row "ReScript" "$R_RESCRIPT" "no rescript.json/bsconfig.json/*.res tracked (has_rescript=${H_RESCRIPT})"
839904
row "V" "$R_V" "no v.mod tracked (has_v=${H_V})"

0 commit comments

Comments
 (0)