Skip to content

Commit 47724d1

Browse files
HYP-S rule-definition loader (increment 1): parse standards rule files (#581)
## What & why The seven `standards/hypatia-rules/*.a2ml` rule definitions (HYP-S001..S007) declare each rule's identity, severity, scanner globs, router strategy caps, emitted signal, and recipe. But they were **specified in standards and ghost in hypatia** — the README says "Rules live as `.a2ml` files… consumed by Hypatia's rule loader," yet **no loader existed**. So the audit's central ask — *"standards is the single source of truth; hypatia rules are compiled from it"* — was unwired. This is **HYP-S increment 1**: the loader that closes that gap. ## What it does `Hypatia.Rules.RuleLoader` + `RuleDef` parse the A2ML markup block dialect (`@block(attrs): key: value / - list @end`) into structured rule definitions, extracting the **declarative, safe-to-source** parts: - identity / severity / category / auto_fixable / source - scanner globs (`@scanner` → `find:`/`glob:`) - routing (`@router` → `default_strategy`, `recipe`, and **`strategy_caps`**) — including the safety-critical **Manual-Only licence cap** (any SPDX/licence-overlapping finding demoted to `:review`); `licence_caps/1` surfaces these for audit - action (`@action` → `emit_signal`, `recipe`) The `@logic` step-DSL is **preserved verbatim** (`logic_raw`) but deliberately **not interpreted** in this increment — the loader does not invent detection behaviour it cannot yet verify. Executing `@logic` (by delegating to existing hypatia capabilities or a rule's own `command:`, à la `scorecard_ingestor`) is a follow-up. ## Verification (toolchain installed in-session) Built and tested against **Elixir 1.17.3 / OTP 25** (hypatia CI targets 1.17): - `mix compile` clean — **zero new warnings** from this module (hypatia CI does not use `--warnings-as-errors`; every existing warning is in pre-existing files) - **10 new unit tests, 0 failures**, using the three real rule files (`registry-staleness`, `rsr-self-compliance`, `profile-drift-detector`) as **hermetic fixtures** under `test/fixtures/hypatia_rules/` (`test/` is scanner-exempt per the repo's scanner-hygiene note) - **No regression**: rules domain 160 → 170 tests, the *same* 3 pre-existing failures (2 vlang, 1 chapel), none in `rule_loader` - Tests assert the licence cap is parsed and demotes to `:review`, and that `licence_caps/1` surfaces exactly the licence-overlapping caps ## Scope / follow-ups - Execute `@logic` per rule (delegate to existing capabilities / `command:`, not a general DSL interpreter). - Consume the RSR v2.0 criteria SSOT (`hyperpolymath/standards#465`, merged) — same loader family, record-dialect variant. - Wire loaded `strategy_caps` into `TriangleRouter`/`FleetDispatcher` so the file-sourced licence cap governs live routing. ## Guardrails New `lib/*.ex` carries `MPL-2.0` SPDX matching the existing `lib/` convention; fixtures are verbatim copies of standards rule files (no licence content authored or altered). The licence cap this loader extracts is the estate's Manual-Only licence guardrail — this PR makes it *machine-auditable*, never auto-editing. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_0112RkdER2wtwHdNmbEhThUz --- _Generated by [Claude Code](https://claude.ai/code/session_0112RkdER2wtwHdNmbEhThUz)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent be760de commit 47724d1

10 files changed

Lines changed: 715 additions & 5 deletions

File tree

.github/workflows/governance.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,4 +31,4 @@ permissions:
3131

3232
jobs:
3333
governance:
34-
uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@e9c8888769a703924cc3c0d717900960d78aea00
34+
uses: hyperpolymath/standards/.github/workflows/governance-reusable.yml@830b0f6178cd190cbe9afee3c48c97c2cbbbc04a

.github/workflows/hypatia-scan.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,5 +27,5 @@ permissions:
2727

2828
jobs:
2929
hypatia:
30-
uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@e9c8888769a703924cc3c0d717900960d78aea00
30+
uses: hyperpolymath/standards/.github/workflows/hypatia-scan-reusable.yml@830b0f6178cd190cbe9afee3c48c97c2cbbbc04a
3131
secrets: inherit

.github/workflows/mirror.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,5 +11,5 @@ permissions:
1111

1212
jobs:
1313
mirror:
14-
uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@e9c8888769a703924cc3c0d717900960d78aea00
14+
uses: hyperpolymath/standards/.github/workflows/mirror-reusable.yml@830b0f6178cd190cbe9afee3c48c97c2cbbbc04a
1515
secrets: inherit

.github/workflows/scorecard.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ jobs:
1515
permissions:
1616
security-events: write
1717
id-token: write
18-
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@e9c8888769a703924cc3c0d717900960d78aea00
18+
uses: hyperpolymath/standards/.github/workflows/scorecard-reusable.yml@830b0f6178cd190cbe9afee3c48c97c2cbbbc04a
1919
secrets: inherit

.github/workflows/secret-scanner.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,5 +15,5 @@ permissions:
1515

1616
jobs:
1717
scan:
18-
uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@e9c8888769a703924cc3c0d717900960d78aea00
18+
uses: hyperpolymath/standards/.github/workflows/secret-scanner-reusable.yml@830b0f6178cd190cbe9afee3c48c97c2cbbbc04a
1919
secrets: inherit

lib/rules/rule_loader.ex

Lines changed: 306 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,306 @@
1+
# SPDX-License-Identifier: MPL-2.0
2+
# Copyright (c) 2026 Jonathan D.A. Jewell (hyperpolymath) <j.d.a.jewell@open.ac.uk>
3+
4+
defmodule Hypatia.Rules.RuleLoader do
5+
@moduledoc """
6+
Loads standards-authored rule definitions (`.a2ml` files) into structured
7+
`RuleDef`s so the rule *catalogue and routing* are sourced from
8+
`hyperpolymath/standards` rather than hand-ported into Elixir.
9+
10+
This is HYP-S increment 1: the "single source of truth" wiring the audit asked
11+
for. The seven `standards/hypatia-rules/*.a2ml` files (HYP-S001..S007) declare
12+
each rule's identity, severity, scanner globs, router strategy + strategy caps,
13+
emitted signal, and recipe. Historically those were specified in standards but
14+
*ghost* in hypatia (no loader existed). This module parses them.
15+
16+
## Scope of this increment
17+
18+
This loader extracts the **declarative** parts of a rule definition — the parts
19+
that are safe to source from files and route on:
20+
21+
* identity/metadata (`id`, `name`, `description`, `severity`, `category`,
22+
`auto_fixable`, `source`)
23+
* scanner globs (`@scanner` → `find:` / `glob:`)
24+
* routing (`@router` → `default_strategy`, `recipe`, and the
25+
**`strategy_caps`** — the safety-critical part, e.g. the Manual-Only
26+
licence cap that demotes any licence/SPDX-overlapping finding to `:review`)
27+
* action (`@action` → `emit_signal`, `recipe`)
28+
* the raw `@logic` block text, preserved verbatim for a later increment that
29+
executes it (this loader does NOT interpret `@logic` — it does not invent
30+
detection behaviour it cannot yet verify).
31+
32+
The rule files use the A2ML *markup* block dialect (`@block(attrs): … @end` with
33+
YAML-flavoured `key: value` and `- ` list items), distinct from the record
34+
dialect used by descriptile/criteria files. This parser is deliberately narrow
35+
to that block shape rather than a general YAML engine.
36+
"""
37+
38+
defmodule RuleDef do
39+
@moduledoc "One parsed standards rule definition."
40+
@enforce_keys [:id]
41+
defstruct id: nil,
42+
name: nil,
43+
description: nil,
44+
severity: nil,
45+
category: nil,
46+
auto_fixable: nil,
47+
source: nil,
48+
scanner_globs: [],
49+
router_default_strategy: nil,
50+
router_recipe: nil,
51+
# Each cap: %{when: pattern, cap: strategy_atom, reason: text}
52+
strategy_caps: [],
53+
action_signal: nil,
54+
action_recipe: nil,
55+
# Verbatim @logic block body — NOT interpreted in this increment.
56+
logic_raw: nil
57+
58+
@type t :: %__MODULE__{}
59+
end
60+
61+
@strategies ~w(auto_execute review report_only)a
62+
63+
@doc """
64+
Load and parse every `*.a2ml` rule definition in `dir`.
65+
66+
Returns `{:ok, [RuleDef.t()]}` sorted by `id`, or `{:error, reason}` if the
67+
directory is unreadable. Individual files that fail to parse are returned in
68+
the error list rather than silently dropped (fail loudly).
69+
"""
70+
@spec load_dir(String.t()) :: {:ok, [RuleDef.t()]} | {:error, term()}
71+
def load_dir(dir) do
72+
case File.ls(dir) do
73+
{:ok, entries} ->
74+
{rules, errors} =
75+
entries
76+
|> Enum.filter(&String.ends_with?(&1, ".a2ml"))
77+
|> Enum.sort()
78+
|> Enum.reduce({[], []}, fn file, {ok, bad} ->
79+
path = Path.join(dir, file)
80+
81+
case File.read(path) do
82+
{:ok, text} ->
83+
case parse(text) do
84+
{:ok, rule} -> {[rule | ok], bad}
85+
{:error, why} -> {ok, [{file, why} | bad]}
86+
end
87+
88+
{:error, why} ->
89+
{ok, [{file, why} | bad]}
90+
end
91+
end)
92+
93+
if errors == [] do
94+
{:ok, Enum.sort_by(rules, & &1.id)}
95+
else
96+
{:error, {:parse_failures, Enum.reverse(errors)}}
97+
end
98+
99+
{:error, reason} ->
100+
{:error, {:cannot_read_dir, dir, reason}}
101+
end
102+
end
103+
104+
@doc """
105+
Parse one rule-definition file's text into a `RuleDef`.
106+
107+
Returns `{:error, :no_rule_block}` when the text carries no `@rule` block or
108+
`{:error, {:missing_id, ...}}` when the mandatory `id` is absent — the loader
109+
refuses to fabricate an identity.
110+
"""
111+
@spec parse(String.t()) :: {:ok, RuleDef.t()} | {:error, term()}
112+
def parse(text) when is_binary(text) do
113+
blocks = tokenize_blocks(text)
114+
115+
with %{body: rule_body} <- find_block(blocks, "rule"),
116+
id when is_binary(id) <- scalar(rule_body, "id") do
117+
router = find_block(blocks, "router")
118+
action = find_block(blocks, "action")
119+
scanner = find_block(blocks, "scanner")
120+
logic = find_block(blocks, "logic")
121+
122+
{:ok,
123+
%RuleDef{
124+
id: id,
125+
name: scalar(rule_body, "name"),
126+
description: scalar(rule_body, "description"),
127+
severity: scalar(rule_body, "severity") |> to_atom_or_nil(),
128+
category: scalar(rule_body, "category"),
129+
auto_fixable: scalar(rule_body, "auto_fixable") |> to_bool_or_nil(),
130+
source: scalar(rule_body, "source"),
131+
scanner_globs: scanner |> body_of() |> extract_globs(),
132+
router_default_strategy:
133+
router |> body_of() |> scalar("default_strategy") |> to_strategy_or_nil(),
134+
router_recipe: router |> body_of() |> scalar("recipe"),
135+
strategy_caps: router |> body_of() |> extract_strategy_caps(),
136+
action_signal: action |> body_of() |> scalar("emit_signal"),
137+
action_recipe: action |> body_of() |> scalar("recipe"),
138+
logic_raw: logic |> body_of()
139+
}}
140+
else
141+
# find_block/2 returned nil (no @rule block) or scalar/2 returned nil (no
142+
# `id`); either way we refuse to fabricate a rule identity.
143+
_ -> {:error, :no_rule_or_id}
144+
end
145+
end
146+
147+
@doc """
148+
The subset of `strategy_caps` across all rules that pin a finding to `:review`
149+
because it overlaps licence/SPDX content. Surfacing this explicitly makes the
150+
Manual-Only licence guardrail auditable: downstream routing MUST honour these.
151+
"""
152+
@spec licence_caps([RuleDef.t()]) :: [map()]
153+
def licence_caps(rules) when is_list(rules) do
154+
rules
155+
|> Enum.flat_map(& &1.strategy_caps)
156+
|> Enum.filter(fn cap ->
157+
cap[:cap] == :review and licence_related?(cap[:when] || "")
158+
end)
159+
end
160+
161+
# --- internal parsing helpers ---------------------------------------------
162+
163+
# A block is %{name: "rule", attrs: "version=\"1.0\"", body: "…lines…"}.
164+
# Blocks open on a line like `@name(attrs):` or `@name:` and close on `@end`.
165+
defp tokenize_blocks(text) do
166+
lines = String.split(text, "\n")
167+
168+
{blocks, _open} =
169+
Enum.reduce(lines, {[], nil}, fn raw, {acc, open} ->
170+
line = raw
171+
172+
cond do
173+
# close current block
174+
Regex.match?(~r/^\s*@end\s*$/, line) and open != nil ->
175+
{[finalize(open) | acc], nil}
176+
177+
# open a new block: @name(...)?:? (but not @end)
178+
match = Regex.run(~r/^\s*@([a-zA-Z][\w-]*)\s*(?:\((.*)\))?\s*:?\s*$/, line) ->
179+
[_, name, attrs] = normalize_match(match)
180+
181+
if name == "end" do
182+
{acc, open}
183+
else
184+
# starting a new block implicitly closes a dangling one
185+
acc = if open, do: [finalize(open) | acc], else: acc
186+
{acc, %{name: name, attrs: attrs, lines: []}}
187+
end
188+
189+
# a body line inside an open block
190+
open != nil ->
191+
{acc, %{open | lines: [line | open.lines]}}
192+
193+
# preamble / stray line outside any block — ignore
194+
true ->
195+
{acc, open}
196+
end
197+
end)
198+
199+
Enum.reverse(blocks)
200+
end
201+
202+
defp normalize_match([_, name]), do: [nil, name, ""]
203+
defp normalize_match([_, name, attrs]), do: [nil, name, attrs || ""]
204+
205+
defp finalize(%{name: name, attrs: attrs, lines: lines}) do
206+
%{name: name, attrs: attrs, body: lines |> Enum.reverse() |> Enum.join("\n")}
207+
end
208+
209+
defp find_block(blocks, name), do: Enum.find(blocks, &(&1.name == name))
210+
211+
defp body_of(nil), do: ""
212+
defp body_of(%{body: body}), do: body
213+
214+
# Extract a flat scalar `key: value` from a block body. Strips a trailing
215+
# inline comment, surrounding quotes, and whitespace. Ignores list items.
216+
defp scalar(body, key) when is_binary(body) do
217+
regex = ~r/^\s*#{Regex.escape(key)}\s*:\s*(.+?)\s*$/m
218+
219+
case Regex.run(regex, body) do
220+
[_, val] -> val |> strip_inline_comment() |> unquote_val()
221+
_ -> nil
222+
end
223+
end
224+
225+
defp scalar(_, _), do: nil
226+
227+
defp extract_globs(body) when is_binary(body) do
228+
~r/glob\s*:\s*"?([^"\n]+?)"?\s*$/m
229+
|> Regex.scan(body)
230+
|> Enum.map(fn [_, g] -> String.trim(g) end)
231+
end
232+
233+
defp extract_globs(_), do: []
234+
235+
# Parse the `strategy_caps:` list of `- when:/cap:/reason:` entries in a
236+
# @router body. This is the safety-critical part: the licence cap lives here.
237+
defp extract_strategy_caps(body) when is_binary(body) do
238+
lines = String.split(body, "\n")
239+
240+
{caps, cur} =
241+
Enum.reduce(lines, {[], nil}, fn line, {acc, cur} ->
242+
# Compute matches up front so the bindings are always in scope in the
243+
# branch bodies (assigning inside a `cond` condition does not reliably
244+
# bind in the body).
245+
when_m = Regex.run(~r/^\s*-\s*when\s*:\s*(.+?)\s*$/, line)
246+
cap_m = Regex.run(~r/^\s*cap\s*:\s*(.+?)\s*$/, line)
247+
reason_m = Regex.run(~r/^\s*reason\s*:\s*(.+?)\s*$/, line)
248+
249+
cond do
250+
# new cap entry begins with `- when:`
251+
when_m ->
252+
acc = if cur, do: [cur | acc], else: acc
253+
{acc, %{when: unquote_val(strip_inline_comment(Enum.at(when_m, 1)))}}
254+
255+
cur && cap_m ->
256+
{acc, Map.put(cur, :cap, to_strategy_or_nil(unquote_val(Enum.at(cap_m, 1))))}
257+
258+
cur && reason_m ->
259+
{acc, Map.put(cur, :reason, unquote_val(strip_inline_comment(Enum.at(reason_m, 1))))}
260+
261+
true ->
262+
{acc, cur}
263+
end
264+
end)
265+
266+
if(cur, do: [cur | caps], else: caps) |> Enum.reverse()
267+
end
268+
269+
defp extract_strategy_caps(_), do: []
270+
271+
defp licence_related?(text) do
272+
String.match?(text, ~r/SPDX|licen[cs]e|PMPL|MPL-2|AGPL|Palimpsest/i)
273+
end
274+
275+
defp strip_inline_comment(val) do
276+
# Drop a trailing ` # comment`, but never split inside a quoted string.
277+
if String.contains?(val, "\"") do
278+
val
279+
else
280+
val |> String.split(~r/\s+#/, parts: 2) |> hd() |> String.trim()
281+
end
282+
end
283+
284+
defp unquote_val(nil), do: nil
285+
286+
defp unquote_val(val) do
287+
val
288+
|> String.trim()
289+
|> String.replace(~r/^["']|["']$/, "")
290+
|> String.trim()
291+
end
292+
293+
defp to_atom_or_nil(nil), do: nil
294+
defp to_atom_or_nil(s), do: String.to_atom(s)
295+
296+
defp to_bool_or_nil("true"), do: true
297+
defp to_bool_or_nil("false"), do: false
298+
defp to_bool_or_nil(_), do: nil
299+
300+
defp to_strategy_or_nil(nil), do: nil
301+
302+
defp to_strategy_or_nil(s) do
303+
a = String.to_atom(s)
304+
if a in @strategies, do: a, else: nil
305+
end
306+
end

0 commit comments

Comments
 (0)