Repository navigation
design: one parsed workflow model for every workflow rule #971
Description
Problem
Hypatia's workflow rules read GitHub Actions YAML as text lines, and each module carries its own job/step parser:
| module | hand-written structure helpers |
|---|---|
lib/rules/research_extensions.ex |
workflow_job_lines, step_blocks, job_steps, step_run_lines, workflow_events, flow_to_block (KYAML) |
lib/rules/workflow_hardening.ex |
writing_job_bodies, step_bodies, extract_job_blocks |
lib/rules/workflow_audit.ex |
extract_steps_using_known_actions |
lib/rules/pin_sites.ex |
steps_owner, job_key |
Evidence. Four Codex review rounds on #966 (RE001's risk-scoped rewrite) raised 22 findings. Every one was a YAML shape that the line reader got wrong:
- flow maps spanning lines;
on: &anchor; foldedrun: >-; - bare
-steps; anenv:key namedrun; - step
envoverriding jobenv; with: {ref: …}; and more.
Each fix closed one shape and left the class open. #950 and #947 show the same pattern in WH013/RE005.
Proposal
Parse each workflow once into a small typed model, and have every workflow rule query that model:
Workflow { path, triggers: [event], env, permissions, jobs: [Job] }
Job { id, runs_on?, uses?, if?, env, permissions, steps: [Step], loc }
Step { uses?, run?, run_style (:plain | :quoted | :literal | :folded), with, env, if?, loc }
The model keeps source line numbers, so findings still point at the source. Step.run is the scalar's value after folding. The effective step environment is Workflow.env ⊕ Job.env ⊕ Step.env.
Reader: metadatastician/kyq
The estate already has a normative reader for this: metadatastician/kyq, docs/KYAML_SPEC.adoc v0.1.0. It defines a YAML 1.2 subset with block and flow collections, anchors and aliases, folded and literal scalars, the Core Schema, and duplicate keys rejected with located diagnostics. Its executable gate is conformance/ (kyq-conformance, pinned by conformance/VERSION and the tag kyq-conformance-v<version>).
Measured 2026-10-27 at kyq 9a44686:
kyq-conformance validateaccepts 137/137 workflow files across hypatia (34), metadatastician/standards (68) and hyperpolymath/echidna (35).parseKyamlreturns a positioned AST, withline:columnon every node.- The AST keeps
onas a string (Core Schema, so no YAML 1.1on→true). - Block scalars come back with their folded/chomped
value.
That removes the need for a third-party parser such as yamerl, and the estate gets one definition of "what this YAML means" shared by kyq and hypatia.
How hypatia consumes it. This is the decision to make; the experiment informs it.
| route | how | cost | state |
|---|---|---|---|
| A. Port to Elixir, kyq's fixtures as the gate | Implement the spec in lib/hypatia/kyaml/, and run kyq/conformance/fixtures (pinned to a kyq-conformance tag) in hypatia's test suite. A change of verdict is a kyq version change. |
~1.6k lines (the JS reference is 1,600). No runtime dependency; the escript stays pure BEAM. | possible now |
| B. Call the reference gate | Run node kyq-conformance from a pinned kyq checkout, with a small entry point that emits the positioned AST as JSON; hypatia decodes it with Jason. |
Adds Node and a kyq checkout to the scanner's runtime and to every governance run. Fastest to prototype. | possible now; a prototype exists in this session |
| C. kyq's own build (AffineScript → Wasm) | Call the kyq Wasm module from the BEAM through a Wasm runtime. | Single implementation, but blocked: AffineScript 0.1.1 does not parse kyq's src/ (docs/status/ROADMAP.adoc). |
blocked upstream |
flow_to_block (KYAML normalisation) would be retired by any route.
Experiment before building (no rule changes)
- Prototype the model for
research_extensionsonly, behind a function that returns the same finding maps. Use route B first, since it is cheapest. - Differential run on 3 repos (hypatia, metadatastician/standards, hyperpolymath/echidna): today's RE001–RE010 versus the model-backed versions. Report every difference; each one is either a bug the parser fixes or a regression.
- Replay the edge-case corpus: every fixture added for fix(rules): scope RE001 to credentials exposed to code the job cannot vouch for #966's Codex rounds (in
test/research_extensions_test.exsandtest/kyaml_workflow_test.exs). It must pass unchanged. - Measure cold-start and per-file parse time for route B, and estimate route A's size. The gate builds the escript on every governance run, so runtime dependencies cost every consumer.
The decision point is after step 4: route A, route B, or keep the line reader until route C unblocks.
Out of scope / known limits that survive the model
- Command position inside shell (
echo 'run npm ci'vsnpm ci) needs a shell tokenizer, not YAML. For now RE001 keeps reporting quoted install words; the maintainer chose that on fix(rules): scope RE001 to credentials exposed to code the job cannot vouch for #966. git clone X && ./X/scriptas fetched code.
Beyond this repo
- kyq: hypatia becomes a consumer of the KYAML spec, so verdict changes in
kyq/conformancebecome hypatia version bumps. Worth recording as adocs/UPSTREAM_HANDOFFS.adocentry in kyq. - Estate: every estate repo runs these rules through the governance gate. Precision gained here shows up estate-wide, and so does a regression.
Activity
hyperpolymath commentedon Oct 27, 2026
Experiment results: route B (kyq reference gate), 2026-10-27
The prototype has two parts, and neither is shipped:
conformance/src/kyaml.js(kyq9a44686) and emits the workflow model, with source lines on every env/with/run entry;eb6c4f5.1. Differential on real trees. Line reader vs model:
7c10e5d)hypatia is at 0 because #967 and #968 have merged.
2. Edge-case corpus replay. Every RE001 fixture in
test/research_extensions_test.exsandtest/kyaml_workflow_test.exs(including all #966 Codex rounds) went through both readers. They agree on all of them except 2, and both of those are kyq parse errors (below).3. Timing per tree. The kyq parse takes 101–349 ms, including Node start-up. The model rule takes 29–112 ms. The line reader takes 58–172 ms.
4. Finding for kyq. The two disagreements are hypatia's KYAML fixtures, which the test file describes as literal
yq -p yaml -o kyamloutput. That output writes a trailing comma after every flow entry, and kyq's spec v0.1.0 rejects trailing commas (FLOW_TRAILING_COMMA,KYAML_SPEC.adoc§ Flow collections). So kyq would refuse the estate's declared KYAML base form (YAML-POLICY Y-3) if workflows moved to it. No real workflow uses that form today. This is a spec decision for kyq (accept a trailing comma inside flow collections, or nameyq -o kyamloutput non-conforming), not a hypatia bug. It should be decided before route A ports the spec.Conclusion so far. On today's estate, the kyq-backed model reproduces the line reader exactly, and it reads the shapes the line reader needed 22 fixes for. Next: route A (Elixir port with kyq's pinned
conformance/fixturesas the gate), once the trailing-comma question is settled. Not yet decided: how RE001's scope composes with ADR-009 / #964.Generated by Claude Code
fix(rules): scope RE001 to credentials exposed to code the job cannot…
RE001: accept a deed egress declaration; stop counting secrets.GITHUB…
RE001: read workflows as YAML does (quoted keys, aliases, with: input…