Files
jellytau/.gitea/workflows/traceability-check.yml
T
dtourolle b9dab56379 ci: enforce the checks the contributor rules already required
Four gates that were documented but unenforced, plus the flaky test that
made a full-suite run untrustworthy.

Rust lint/format: CLAUDE.md has required `cargo fmt` and `cargo clippy`
before every commit for as long as the rule existed, yet neither ran
anywhere in CI — the requirement rested on memory alone. Both now run in
build-and-test.yml and build-release.yml. rustfmt and clippy are already
baked into the builder image, so nothing is installed at job time.
`cargo fmt --all -- --check` is strict immediately (the tree is clean).
Clippy is advisory for now: ~51 pre-existing warnings mean `-D warnings`
would fail on unrelated work, so the step carries a TODO to flip the flag
once the backlog clears. A compile error still fails it, so it is not a
no-op.

Traceability threshold: MIN_THRESHOLD sat at 50 while real coverage was
86%, so nearly half the matrix could rot before the gate objected.
Ratcheted to 82 with the policy written down — it only ever goes up, and
is never lowered to make a red build pass. The same figure lives in
MIN_COVERAGE_PERCENT so `traces:coverage` gates locally on the same bar,
and a test fails if the two drift.

Dangling IDs: a TRACES comment could name any well-formed ID and the
extractor accepted it silently, so typos and renames that missed a call
site passed unnoticed. `bun run traces:validate` cross-checks every
traced ID against the table rows in requirements.md and fails with the
referencing files listed. It spans UT/IT as well, which the coverage
orphan list ignores by design. This currently reports DR-189 and UT-188,
which are being defined separately.

Flaky offlineCatalog test: the first dynamic import of the service paid
~1s to transform its dependency graph, charged to a test body against
vitest's 5s default. Alone it passed; under suite-wide contention it
timed out. The import is now warmed at collection time, so no test is
timing the compiler — the timeout is deliberately unchanged. The store
shim also drops subscribers from module instances discarded by
resetModules, which previously leaked across tests.
2026-08-16 22:51:44 +02:00

188 lines
6.7 KiB
YAML

name: Traceability Validation
on:
push:
branches:
- master
- main
- develop
pull_request:
branches:
- master
- main
- develop
jobs:
validate-traces:
runs-on: linux/amd64
name: Check Requirement Traces
container:
image: gitea.tourolle.paris/dtourolle/jellytau-builder:latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
fetch-depth: 0
# bun is baked into jellytau-builder (see Dockerfile.builder); no setup-bun
# action needed — fetching it stalls on this Gitea runner.
- name: Install dependencies
run: bun install
- name: Extract traces
run: |
echo "🔍 Extracting requirement traces..."
bun run traces:json > traces-report.json
- name: Validate traces
run: |
set -e
echo "📊 Validating requirement traceability..."
echo ""
# Denominators come from docs/requirements.md at run time — NEVER
# hardcode them here. This step previously divided by frozen literals
# (UR/39, IR/24, DR/48, JA/3, total 114) while the file had grown to
# 211 requirements, so it reported 158% coverage and the threshold
# below could never trip. See docs/specs/traceability-gate-repair.md.
TOTAL_TRACES=$(jq '.totalTraces' traces-report.json)
COVERED=$(jq '.coverage.covered' traces-report.json)
TOTAL_REQS=$(jq '.coverage.total' traces-report.json)
COVERAGE=$(jq '.coverage.percent' traces-report.json)
echo "✅ TRACES Found: $TOTAL_TRACES"
echo ""
echo "📋 Coverage Summary (traced / defined):"
for T in UR IR DR JA; do
TRACED=$(jq --arg t "$T" '[.byType[$t][] | select(. != null)] | length' traces-report.json)
DEFINED=$(jq --arg t "$T" '.defined[$t]' traces-report.json)
echo " $T: $TRACED / $DEFINED"
done
echo ""
echo "📈 Overall Coverage: $COVERED / $TOTAL_REQS ($COVERAGE%)"
echo ""
# Traced IDs that requirements.md does not define (typo, or a deleted
# requirement). These do not count toward coverage.
ORPHANED=$(jq -c '.coverage.orphaned' traces-report.json)
if [ "$ORPHANED" != "[]" ]; then
echo "⚠️ Traced but not defined in requirements.md: $ORPHANED"
echo ""
fi
# A ratio above 100% means the computation is broken — the exact
# condition that hid the stale-denominator bug. Fail loudly.
if [ "$COVERAGE" -gt 100 ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) exceeds 100% — the gate is miscomputing."
echo " Orphaned IDs: $ORPHANED"
exit 1
fi
# Minimum coverage. RATCHET POLICY: this number only ever goes UP.
#
# It sits a few points under the coverage actually achieved, so a real
# regression trips it. It was 50 while true coverage was 86%, which
# meant nearly half the matrix could rot before CI said a word — a
# gate that cannot fail is not a gate.
#
# When coverage rises durably, raise this to just under the new figure
# (`bun run traces:coverage` prints it). Never lower it to make a red
# build pass — add the missing TRACES comments instead.
#
# Keep in sync with MIN_COVERAGE_PERCENT in scripts/extract-traces.ts;
# scripts/extract-traces.test.ts fails if the two drift apart.
MIN_THRESHOLD=82
if [ "$COVERAGE" -lt "$MIN_THRESHOLD" ]; then
echo "❌ ERROR: Coverage ($COVERAGE%) is below minimum threshold ($MIN_THRESHOLD%)"
exit 1
fi
echo "✅ Coverage is acceptable ($COVERAGE% >= $MIN_THRESHOLD%)"
# Every ID named by a TRACES comment must be defined as a table row in
# docs/requirements.md. The extractor used to accept any well-formed ID
# silently, so a typo or a rename that missed a call site passed CI
# unnoticed (DR-189 and UT-188 lived in three source files, defined
# nowhere, for months). This covers UT/IT too, which the coverage
# orphan list above deliberately ignores.
- name: Validate requirement IDs
run: bun run traces:validate
- name: Check modified files
if: github.event_name == 'pull_request'
run: |
echo "🔍 Checking modified files for traces..."
echo ""
# Get changed files
CHANGED=$(git diff --name-only origin/${{ github.base_ref }}...HEAD | grep -E '\.(ts|tsx|svelte|rs)$' || echo "")
if [ -z "$CHANGED" ]; then
echo "✅ No TypeScript/Rust files changed"
exit 0
fi
echo "📝 Changed files:"
echo "$CHANGED" | sed 's/^/ /'
echo ""
# Check each file
# Pipe into the loop instead of a here-string (<<<) so this step works
# under POSIX sh/dash, not just bash. Use `case` instead of `[[ == ]]`
# for the same reason. The loop runs in a subshell (so a counter var
# wouldn't survive), so we record warnings in a temp file and count it
# afterwards.
MISSING_FILE=$(mktemp)
echo "$CHANGED" | while IFS= read -r file; do
# Skip test files
case "$file" in
*.test.*) continue ;;
esac
if [ -f "$file" ]; then
if ! grep -q "TRACES:" "$file"; then
echo "⚠️ Missing TRACES: $file"
echo "$file" >> "$MISSING_FILE"
fi
fi
done
MISSING_TRACES=$(wc -l < "$MISSING_FILE" | tr -d ' ')
rm -f "$MISSING_FILE"
if [ "$MISSING_TRACES" -gt 0 ]; then
echo ""
echo "📝 Recommendation: Add TRACES comments to new/modified code"
echo " Format: // TRACES: UR-001, UR-002 | DR-003"
echo ""
echo "💡 For more info, see: scripts/README.md"
fi
- name: Generate full report
if: always()
run: |
echo "📄 Generating full traceability report..."
bun run traces:markdown
- name: Display report summary
if: always()
run: |
echo ""
echo "📊 Full Report Generated"
echo "📁 Location: docs/traceability.md"
echo ""
head -50 docs/traceability.md || true
- name: Save artifacts
if: always()
uses: actions/upload-artifact@v3
with:
name: traceability-reports
path: |
traces-report.json
docs/traceability.md
retention-days: 30