Skip to content

fix(gates): gate-53 ignored the diff scope, gate-22 printed FAIL — 0, gate-33 had never run - #139

Open
rubenvdlinde wants to merge 1 commit into
mainfrom
fix/gate-scope-and-silent-fallbacks
Open

fix(gates): gate-53 ignored the diff scope, gate-22 printed FAIL — 0, gate-33 had never run#139
rubenvdlinde wants to merge 1 commit into
mainfrom
fix/gate-scope-and-silent-fallbacks

Conversation

@rubenvdlinde

Copy link
Copy Markdown
Contributor

Three defects in conduction/hydra-gates, all of one family: a check whose absence or degradation is byte-identical to its success.

Everything below was re-measured against the running system. Two of the briefed findings did not reproduce; that is reported here too.


Defect 1 — gate-53 ignored the diff scope

gate-53 (effective-manifest-crossref) was diff-scoped at file granularity. An app's whole navigation surface lives in src/manifest.json + src/manifest.d/*, so touching any of it re-judged all of it. File granularity was indistinguishable from no scoping.

Measured on a one-line title change, before the fix:

repo full-repo diff-scoped delta
pipelinq 24 24 0
shillinq (1 line, in 1 of 80 fragments) 246 246 0

Every finding sat on a page the PR had never touched.

What changed. Answering a cross-reference genuinely needs the whole assembled manifest — you cannot resolve menu[].route → page id, or check the ADR-044 no-orphan-removal invariant, from a diff. That part is legitimately whole-repo and stays so. Blocking on the answer does not: every finding carries a JSON pointer that resolves to a page id, a menu id or a top-level block.

  • scripts/lib/manifest_diff_scope.py — maps the diff to the manifest entries it touched (page ids, menu ids incl. nested children, top-level keys). Reuses gate-55's line-tracking JSON parser so the two gates cannot disagree about where a page begins.
  • scripts/lib/manifest_scope_filter.js — shared by check_manifest.js and check_manifest_crossref.js; partitions findings into blocking / pre-existing / unscopable.

Suppressed findings are printed as PRE-EXISTING and counted on stdout, never dropped — a scoped green must not read as "the manifest is clean". Whole-manifest invariants keep blocking, and so does any PR whose scope cannot be determined (a fragment untracked at base, a changed register JSON, a parse failure). Unverifiable scope is never treated as narrow scope.

After:

repo full-repo diff-scoped (blocking) reported pre-existing
pipelinq 24 0 24
shillinq 246 0 397

Positive control, both directions, same repo and same base: adding a commit that touches the Prospects page (which carries 3 real findings) → FAIL — 3, naming /pages/51, 21 pre-existing. git revert --no-edit of that commit, verified applied by re-reading the file and by git diff --name-only no longer listing it → back to PASS, 24 pre-existing. 3 + 21 = 24.

Briefed findings that did NOT reproduce

  • pipelinq gate-34 9/9 — gate-34 scopes correctly. Full-repo 9; diff-scoped with an unrelated file touched 0; diff-scoped with one of the nine debt files touched 1.
  • opencatalogi gate-51 — scopes correctly, at property granularity. Full-repo 7 → scoped 0. On shillinq, 35 → 0.
  • openconnector gate-55 — scopes correctly, at page granularity. Full-repo 1 → scoped 0 with an unrelated page touched → 1 again with ConsumerDetail touched.

So gate-53 was the only gate ignoring scope. The others were most likely measured against a base ref that made the whole app the diff.

Adjacent finding: gate-53 is red-on-arrival without ajv

[gate-53] … FAIL — ajv not resolvable … refusing to run fail-open in every repo without node_modules. It fails closed and says so, which is the right shape — but no code change can turn it green. ajv is in every fleet app's package-lock.json, so npm ci before the gates resolves it. Documented in the README.


Defect 2 — gate-22 printed FAIL — 0

Reproduced verbatim on opencatalogi: [gate-22] manifest-validation: FAIL — 0.

grep -c … || echo 1 captures both the 0 that grep -c prints on no-match and the fallback 1, giving "0\n1". [ "0\n1" -eq 0 ] is not an integer comparison — it errors and returns 2, so the "clamp to at least 1" guard never fired and the message was emitted with an embedded newline. The visible line read FAIL — 0; the rest was orphaned onto a second line no ^\[gate- consumer parses.

Diagnosed once before at gate-17 and fixed there only. Eight other call sites still carried it. One at gate-48 (csrf-cochange) was fail-open: zero frontend co-change signals produced "0\n0", the -eq 0 test errored into the else branch, and a CSRF-protection removal with no frontend counterpart passed. All replaced with a single _count helper.

Underneath the count sat a worse problem: gate-22's verdict was app-owned. It preferred npm run check:manifest:

  • pipelinq's is a 50-line structural guard asserting four top-level keys exist — it certifies any manifest. An app could turn a fleet gate green by writing a weaker checker.
  • opencatalogi's and shillinq's run a per-app vendored schema copy which, without Ajv, falls back to a hardcoded "v1.x enum" predating ADR-040 and rejects type: "roadmap" — a page type the canonical schema has accepted since 2.x. The gate was failing apps for conforming to the standard. That was the "real finding in the log": a false positive from a stale app-local lint.
  • Those scripts announce their own downgrade (no schema candidate resolved; falling back to structural lint) on a line the gate neither surfaced nor acted on.

The vendored canonical validator now decides. The app script still runs; its output is captured and surfaced as an explicit advisory that cannot set the verdict.

Loud fallback. check_manifest.js now exits 3 — DEGRADED when Ajv is unresolvable and it found nothing, on both channels (SCHEMA VALIDATION DID NOT HAPPEN, "status":"degraded"). A real finding still exits 1, so degradation never masks a violation. gate-22 reports that as a named FAIL rather than a pass, mirroring gate-53's existing stance.

Corrected output on opencatalogi:

[gate-22] manifest-validation: FAIL — 3 schema violation(s) in src/manifest.json — see /tmp/… [advisory: the app's own check:manifest also exits 1 — …]
    at /pages/3: must have required property '_note' (keyword=required)
    at /pages/3: must match "else" schema (keyword=if)
    at /pages/3: must match "then" schema (keyword=if)

and without ajv:

[gate-22] NOTE: an app-local manifest checker announced a fallback to a weaker structural lint. That line is advisory only — this gate's verdict comes from the vendored canonical validator.
[gate-22] manifest-validation: FAIL — SCHEMA VALIDATION DID NOT HAPPEN — Ajv is not resolvable, so the vendored validator could only run its AppHost structural lint. A weaker check reported as a pass is not a pass. Run `npm ci` … 

Defect 3 — gate-33 (axe-core) has never run, anywhere

Established cause. The gate consumes tests/axe/report.json, documented as the output of scripts/run-browser-tests.sh. Checked across pipelinq, shillinq, opencatalogi and openconnector: neither the report nor the producing script exists in any of them. Meanwhile axe-core is a declared devDependency in every one, so the prerequisite looks wired. Nothing anywhere writes that file (grep -rn 'axe/report.json\|AxeBuilder\|axe.run' → no hits outside node_modules).

It was never wired. And because a gate whose prerequisite is absent emitted nothing at all — no line, no count, no trace — while the runner closed with a hardcoded ALL 63 GATES GREEN, every green this fleet has ever produced excluded accessibility runtime checking and nothing said so. gate-24 (integration-parity) was absent in most repos for the same structural reason.

Both now state their own absence:

[gate-33] axe-core: SKIPPED — no tests/axe/report.json in this repo — axe-core never ran
          against a rendered DOM, so contrast / landmark / ARIA-validity / live-region
          accessibility is UNVERIFIED. Produce the report from the Playwright suite
          (@axe-core/playwright) or add scripts/run-browser-tests.sh.

and the runner now performs the declared-vs-reported accounting its wrapper already did — most of the fleet invokes run-hydra-gates.sh directly and was blind to it:

[hydra-gates] COVERAGE: 60 of 63 declared gates reported a result.
[hydra-gates] GATES THAT DID NOT RUN — they inspected NOTHING, and their subject
[hydra-gates] matter is UNVERIFIED by this run:
[hydra-gates]   gate-4 composer-audit
[hydra-gates]   gate-24 integration-parity
[hydra-gates]   gate-33 axe-core
[hydra-gates] 60 GATE(S) GREEN — but 3 of 63 DID NOT RUN (named above).
[hydra-gates] This is NOT 'all 63 gates green'. It says nothing about the gates that skipped.

A SKIPPED line is not counted as coverage in either layer — bin/hydra-gates excludes it explicitly, so the fix cannot become the bug. --require-full-coverage (now accepted by the runner too) exits 98. The inventory is read out of the runner itself, so adding gate 64 cannot leave the check measuring a stale 63.


Pre-existing defect found while working: a dead test suite

scripts/lib/test_check_manifest.sh pointed at scripts/test-fixtures/manifest-validation/a directory that has never existed in this repository. node check_manifest.js <missing-path> prints "Tier 0, skipping" and exits 0, which is exactly what two of its three assertions expected. It had been green its whole life while validating nothing, and it was in no CI job.

Fixtures now ship, the suite refuses to run if they go missing again, and three suites are wired into CI (with ajv installed, so the schema paths are exercised for real rather than skipped):

  • test_check_manifest.sh — 14 assertions incl. the degraded contract and the scope filter, both directions
  • test_manifest_scope_filter.js — 18 assertions on pointer attribution and partitioning, every "suppressed" paired with a "survived"
  • test_manifest_diff_scope.py — 10 assertions in a throwaway git repo

test_check_manifest_crossref.js has the same problem (scripts/test-fixtures/effective-manifest/{good,broken} never shipped) and is left as-is — out of scope here, and its subject is covered live by the pipelinq/shillinq measurements above. Worth a follow-up.

The package's own suite goes 13 → 19 assertions, adding: gate-33 states its absence; the summary names unrun gates; no ALL N GATES GREEN banner while a gate skipped; skipped gates excluded from the coverage tally; --require-full-coverage exits 98; no gate verdict wraps onto a second line.


Consumer impact

v1.0.1 behaviour changes for consumers who pin it. A new tag is needed. Once tagged:

  1. Run npm ci before the gates (or set NODE_PATH). gate-22 now FAILS rather than passing when it cannot resolve ajv — it will not certify a manifest it never schema-validated. ajv is already in every fleet app's lockfile.
  2. Expect gate-22 verdicts to move, in both directions. Apps whose check:manifest was weaker than the canonical schema will surface real findings; apps failed by a stale app-local roadmap enum will go green.
  3. Expect SKIPPED lines and a COVERAGE: block in run-hydra-gates.sh output. Anything parsing ^\[gate-N\] must treat : SKIPPED as not run, not as a pass.
  4. gate-53 gets usable under --scope-to-diff — it was previously unenablable on any repo with manifest debt.

Full-repo runs are unchanged in strictness (verified on pipelinq: 24 / 8 / 9 before and after).


Verification

  • Package suite: 19/19.
  • Helper suites: 14/14, 18/18, 10/10.
  • ShellCheck clean on every *.sh in the repo (baseline was clean; one new SC2154 was introduced by removing an incidental _rc assignment and is fixed properly, not suppressed).
  • Live gate runs on pipelinq, shillinq, opencatalogi, openconnector.

🤖 Generated with Claude Code

… gate-33 had never run

Three defects, all of the same family: a check whose absence or degradation is
byte-identical to its success.

gate-53 (effective-manifest-crossref) was diff-scoped at FILE granularity. An
app's whole navigation surface lives in src/manifest.json + src/manifest.d/*,
so touching any of it re-judged all of it. Measured on a one-line `title`
change: pipelinq 24 full-repo / 24 diff-scoped, shillinq 246/246 — every
finding on a page the PR had never touched. Enabling the gate would have
blocked every manifest-touching PR on inherited debt.

Answering a cross-reference genuinely needs the whole assembled manifest — you
cannot resolve menu[].route to a page id from a diff — but BLOCKING on the
answer does not. Findings carry a JSON pointer that resolves to a page id, a
menu id or a top-level block; a new scope computer (manifest_diff_scope.py) maps
the diff to those entries and a shared filter (manifest_scope_filter.js) blocks
only on entries the PR touched. Suppressed findings are printed as
PRE-EXISTING and counted on stdout, never dropped. Whole-manifest invariants,
and any PR whose scope cannot be determined (new fragment untracked at base,
changed register JSON, parse failure), still block: unverifiable scope is not
narrow scope. After: pipelinq 0 blocking / 24 pre-existing, shillinq 0 / 397;
touching an offending page brings its 3 findings straight back.

gate-22 printed "FAIL — 0" on opencatalogi with the rest of its message
orphaned onto an unparseable second line. `grep -c … || echo 1` captures BOTH
the "0" grep prints and the fallback "1", and `[ "0\n1" -eq 0 ]` errors instead
of comparing, so the clamp never fired. Diagnosed once at gate-17 and fixed
there only; eight other sites still carried it, including gate-48's, where it
was fail-OPEN — zero frontend co-change signals read as "co-change found" and
a CSRF-protection removal passed. One `_count` helper now, used everywhere.

Underneath that count sat a worse problem: gate-22's verdict was app-owned. It
preferred `npm run check:manifest`, which on pipelinq is a 50-line guard that
certifies any manifest, and on opencatalogi/shillinq is a per-app schema copy
that without Ajv falls back to a hardcoded "v1.x enum" predating ADR-040 and
rejects `type: "roadmap"` — a page type the canonical schema has accepted since
2.x. The gate was failing apps for conforming to the standard, and announcing
its own downgrade ("no schema candidate resolved; falling back to structural
lint") on a line nothing acted on. The vendored canonical validator now decides;
the app script still runs, as a surfaced advisory. And a validation that could
not apply the schema exits 3 DEGRADED rather than reporting a pass.

gate-33 (axe-core) has never run in any of the 13 fleet repos. The
tests/axe/report.json it consumes is produced by a scripts/run-browser-tests.sh
that exists in no app, while axe-core sits in every package.json so the
prerequisite looks wired. It emitted nothing at all when absent, and the runner
closed with "ALL 63 GATES GREEN" regardless — so every green this fleet has
produced excluded accessibility runtime checking. gate-24 was absent in most
repos for the same structural reason. Both now state their own absence, the
runner performs the declared-vs-reported accounting its wrapper already did
(most of the fleet invokes the runner directly), a SKIPPED line is not counted
as coverage, and --require-full-coverage exits 98.

Also: test_check_manifest.sh had been green its whole life while pointing at a
fixture directory that has never existed — a missing manifest path makes the
validator print "Tier 0, skipping" and exit 0, which is what two of its three
assertions expected. Fixtures now ship, the suite refuses to run without them,
and it plus two new suites run in CI.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants