Skip to content

design: one parsed workflow model for every workflow rule #971

Open

Description

@hyperpolymath

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; folded run: >-;
  • bare - steps; an env: key named run;
  • step env overriding job env;
  • 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 validate accepts 137/137 workflow files across hypatia (34), metadatastician/standards (68) and hyperpolymath/echidna (35).
  • parseKyaml returns a positioned AST, with line:column on every node.
  • The AST keeps on as a string (Core Schema, so no YAML 1.1 on → 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)

  1. Prototype the model for research_extensions only, behind a function that returns the same finding maps. Use route B first, since it is cheapest.
  2. 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.
  3. 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.exs and test/kyaml_workflow_test.exs). It must pass unchanged.
  4. 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

Beyond this repo

  • kyq: hypatia becomes a consumer of the KYAML spec, so verdict changes in kyq/conformance become hypatia version bumps. Worth recording as a docs/UPSTREAM_HANDOFFS.adoc entry 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

  1. hyperpolymath commented on Oct 27, 2026

    @hyperpolymath
    OwnerAuthor

    Experiment results: route B (kyq reference gate), 2026-10-27

    The prototype has two parts, and neither is shipped:

    1. Differential on real trees. Line reader vs model:

    tree files kyq parse errors line reader model differences
    hypatia main (7c10e5d) 34 0 0 0 none
    metadatastician/standards 68 0 2 2 none (same line and severity)
    hyperpolymath/echidna 35 0 0 0 none

    hypatia is at 0 because #967 and #968 have merged.

    2. Edge-case corpus replay. Every RE001 fixture in test/research_extensions_test.exs and test/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 kyaml output. 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 name yq -o kyaml output 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/fixtures as 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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions