Skip to content

Commit 1d9331f

Browse files
authored
feat: load rule metadata from YAML (#1)
Make rules/pfmea_control_plan_rules.yaml the single source of truth for rule metadata (id, severity, title, message_template, description, rationale). The checker builds each Finding from the YAML severity + message template; the per-finding-type detection logic stays in Python (not a generic rule engine). Messages and behaviour are byte-identical to v0.2, locked by parity tests. No change to scoring, matching, finding types or the JSON schema. Closes #1.
1 parent f48e15c commit 1d9331f

9 files changed

Lines changed: 322 additions & 117 deletions

File tree

CHANGELOG.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,12 @@ All notable changes to this project are documented here. The format is based on
66

77
## [Unreleased]
88

9+
### Changed
10+
- Rule metadata (id, severity, title, message template, description, rationale) is now loaded from
11+
`rules/pfmea_control_plan_rules.yaml` instead of being hardcoded; the detection logic stays in
12+
Python. Validation behaviour is unchanged (same finding types, severities, score and verdict),
13+
verified by behaviour-parity tests. ([#1](https://github.com/migmcc/quality-docs-validator/issues/1))
14+
915
## [0.2.0] - 2026-06-21
1016

1117
### Added

docs/ARCHITECTURE.md

Lines changed: 17 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -70,12 +70,23 @@ Score starts at 100 and is reduced per finding: **critical −15**, **warning
7070
conservative so warnings cannot dominate the verdict (false-positive protection, [DECISIONS.md](DECISIONS.md) D3).
7171
Full detail and the per-type rationale live in [FINDINGS.md](FINDINGS.md).
7272

73-
## Rules: code vs. YAML
74-
For the MVP the six checks are implemented in `modules/pfmea_control_plan.py`. The
75-
`rules/pfmea_control_plan_rules.yaml` file is the single source of truth for each rule's **id and
76-
severity**, and a consistency test asserts the code and YAML never drift. Driving the check *logic*
77-
from YAML (a small rule-interpretation layer) is deferred to a later iteration — it was kept out of
78-
the hardening pass to avoid a rearchitecture.
73+
## Rules: metadata in YAML, evaluation in Python
74+
`rules/pfmea_control_plan_rules.yaml` is the **single source of truth for rule metadata** — each
75+
rule's `id`, `severity`, `title`, `message_template`, `description` and `rationale`. The loader
76+
(`rules.load_rule_specs()` / `parse_rule_specs()`) reads and validates it, failing clearly on a
77+
missing id, missing required field, invalid severity, duplicate id or an empty ruleset.
78+
79+
The checker in `modules/pfmea_control_plan.py` reads that metadata — it builds each `Finding` with
80+
the severity and the formatted `message_template` from the YAML rather than hardcoding them. The
81+
deliberate split is:
82+
83+
- **YAML → rule metadata** (what a rule is, how severe it is, how it reads).
84+
- **Python → rule evaluation** (the per-finding-type detection logic stays in the module).
85+
86+
This is intentionally *not* a generic rule engine: the bespoke evaluation logic remains in code.
87+
A consistency test plus behaviour-parity tests (seeded example, clean case, warnings case) ensure
88+
the YAML and the code never drift and that finding types, severities, count, score, verdict, and
89+
the Markdown/JSON output are unchanged from v0.2.
7990

8091
## Known limitations (MVP)
8192
- **`.xlsx` only**; one worksheet is read per file (selectable by name via `--pfmea-sheet` /

docs/FINDINGS.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,11 @@ the rationale for each, and how findings are turned into a score and verdict.
66
> All findings are **potential** inconsistencies for a human to judge. The tool makes no regulatory
77
> or normative conformance claim and does not replace technical review.
88
9-
The authoritative metadata (id + severity) also lives in
10-
[`src/quality_docs_validator/rules/pfmea_control_plan_rules.yaml`](../src/quality_docs_validator/rules/pfmea_control_plan_rules.yaml);
11-
a test keeps the YAML and the code in sync. The checks themselves are implemented in
9+
The authoritative rule **metadata** (id, severity, title, message template, description, rationale)
10+
is the
11+
[`rules/pfmea_control_plan_rules.yaml`](../src/quality_docs_validator/rules/pfmea_control_plan_rules.yaml)
12+
file; the checker reads it instead of hardcoding these values, and consistency + parity tests keep
13+
the YAML and the code in sync. The **detection logic** for each finding type is implemented in
1214
[`modules/pfmea_control_plan.py`](../src/quality_docs_validator/modules/pfmea_control_plan.py).
1315

1416
## Matching

docs/ROADMAP.md

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -22,10 +22,23 @@ merged to `main`; the `v0.2.0` tag/release is a separate step.
2222
Still out of scope for v0.2: CSV input, configurable column mapping, HTML output, UI, AI, and any
2323
new document pairs.
2424

25-
## v0.3+ — Rule engine & more modules (each independent of the core)
26-
- **YAML-driven rules** ([#1](https://github.com/migmcc/quality-docs-validator/issues/1)) — make the
27-
YAML the source of the rule *logic*, not just its documented metadata. Moved out of v0.2 because it
28-
is a rule-engine refactor and must keep exact finding-type parity.
25+
## v0.3 — planned (YAML rules as source of truth)
26+
Tracked under the [v0.3 milestone](https://github.com/migmcc/quality-docs-validator/milestone/2).
27+
Deliberately small and low-risk — **no new features, no behaviour change**:
28+
29+
- **YAML-driven rule *metadata*** ([#1](https://github.com/migmcc/quality-docs-validator/issues/1)) —
30+
make `rules/pfmea_control_plan_rules.yaml` the single source of truth for each rule's **id,
31+
severity, title/message template, description and rationale**, and have
32+
`modules/pfmea_control_plan.py` read that metadata instead of hardcoding it. The **evaluation
33+
logic stays in Python**; we are *not* building a generic rule engine.
34+
- **Parity tests** — prove the synthetic examples, a clean case and a warnings case produce the
35+
exact same finding types, severities, count, score and verdict as v0.2 (Markdown + JSON unchanged).
36+
- **Rule documentation** generated/kept in sync from the YAML metadata.
37+
38+
Out of scope for v0.3: new document pairs, CSV, configurable mapping, UI, AI, PyPI, new scoring,
39+
fuzzy matching, JSON-schema changes, and any change to the finding types.
40+
41+
## v0.4+ — More modules (each independent of the core)
2942
- Process Flow ↔ PFMEA consistency.
3043
- Control Plan ↔ Work Instructions.
3144
- PPAP gap check.

src/quality_docs_validator/modules/pfmea_control_plan.py

Lines changed: 30 additions & 74 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
"""PFMEA <-> Control Plan consistency checker (MVP module).
22
3-
This is the single module shipped in v0.1.0. It parses both documents, matches rows by operation,
4-
applies the explicit checks below and returns a scored `ValidationResult`. Each check is intentionally
5-
simple and documented; the tool surfaces *potential* findings for a human to judge.
3+
This is the single module shipped in v0.1. It parses both documents, matches rows by operation,
4+
applies the explicit checks below and returns a scored `ValidationResult`. Each check is
5+
intentionally simple and documented; the tool surfaces *potential* findings for a human to judge.
66
7-
Finding types implemented:
7+
Rule **metadata** (severity + message template) is loaded from
8+
`rules/pfmea_control_plan_rules.yaml`; the **detection logic** for each finding type stays in this
9+
module (this is not a generic rule engine).
10+
11+
Finding types:
812
- UNMATCHED_PROCESS_STEP (warning) operation present in one document only
913
- MISSING_CONTROL (critical) matched operation has no control method
1014
- SPECIAL_CHARACTERISTIC_NOT_CONTROLLED (critical) PFMEA special char not marked in Control Plan
@@ -21,13 +25,9 @@
2125
from ..core.matching import MatchResult, match_rows
2226
from ..models import ControlPlanRow, Finding, PFMEARow, ValidationResult
2327
from ..parsers.excel import parse_control_plan, parse_pfmea
28+
from ..rules import load_rule_specs
2429

2530
HIGH_SEVERITY_THRESHOLD = 8
26-
27-
# Phrases that indicate a subjective / low-reliability detection method. Kept deliberately
28-
# specific (phrases, not bare words like "operator" or "manual") to limit false positives:
29-
# e.g. "manual gauge" or "operator runs CMM" are NOT weak, but "manual inspection" is.
30-
# Because these checks are the most false-positive-prone, both rules they feed are WARNINGS (D3).
3131
WEAK_METHOD_KEYWORDS = (
3232
"visual",
3333
"by eye",
@@ -39,6 +39,20 @@
3939
"manual check",
4040
)
4141

42+
# Rule metadata (severity + message template) is the single source of truth in the YAML.
43+
_RULES = load_rule_specs()
44+
45+
46+
def _make(rule_id: str, op: str, **context: object) -> Finding:
47+
"""Build a Finding using the YAML metadata for severity and the message template."""
48+
spec = _RULES[rule_id]
49+
return Finding(
50+
finding_type=rule_id,
51+
level=spec["severity"],
52+
operation_id=op,
53+
message=spec["message_template"].format(op=op, **context),
54+
)
55+
4256

4357
def _is_weak_method(method: str | None) -> bool:
4458
if not method:
@@ -68,69 +82,21 @@ def _check_operation(
6882
weak = any(_is_weak_method(m) for m in control_methods)
6983

7084
if not has_control:
71-
findings.append(
72-
Finding(
73-
finding_type="MISSING_CONTROL",
74-
level="critical",
75-
operation_id=op_label,
76-
message=(
77-
f"Operation {op_label} has PFMEA failure mode(s) but no control method "
78-
f"in the Control Plan."
79-
),
80-
)
81-
)
85+
findings.append(_make("MISSING_CONTROL", op_label))
8286

8387
if pf_special and not cp_special:
84-
findings.append(
85-
Finding(
86-
finding_type="SPECIAL_CHARACTERISTIC_NOT_CONTROLLED",
87-
level="critical",
88-
operation_id=op_label,
89-
message=(
90-
f"Operation {op_label} is flagged as a special characteristic in the PFMEA "
91-
f"but is not marked/controlled as special in the Control Plan."
92-
),
93-
)
94-
)
88+
findings.append(_make("SPECIAL_CHARACTERISTIC_NOT_CONTROLLED", op_label))
9589

9690
if has_control and not has_reaction and max_sev is not None and max_sev >= HIGH_SEVERITY_THRESHOLD:
97-
findings.append(
98-
Finding(
99-
finding_type="MISSING_REACTION_PLAN",
100-
level="critical",
101-
operation_id=op_label,
102-
message=(
103-
f"Operation {op_label} has a high-severity failure mode (S={max_sev}) "
104-
f"but the Control Plan control has no reaction plan."
105-
),
106-
)
107-
)
91+
findings.append(_make("MISSING_REACTION_PLAN", op_label, severity=max_sev))
10892

10993
if weak:
11094
findings.append(
111-
Finding(
112-
finding_type="WEAK_DETECTION_METHOD",
113-
level="warning",
114-
operation_id=op_label,
115-
message=(
116-
f"Operation {op_label} relies on a weak detection method "
117-
f"({', '.join(control_methods)})."
118-
),
119-
)
95+
_make("WEAK_DETECTION_METHOD", op_label, methods=", ".join(control_methods))
12096
)
12197

12298
if weak and max_sev is not None and max_sev >= HIGH_SEVERITY_THRESHOLD:
123-
findings.append(
124-
Finding(
125-
finding_type="HIGH_SEVERITY_WEAK_CONTROL",
126-
level="warning",
127-
operation_id=op_label,
128-
message=(
129-
f"Operation {op_label} has a high-severity failure mode (S={max_sev}) "
130-
f"paired with a weak control method."
131-
),
132-
)
133-
)
99+
findings.append(_make("HIGH_SEVERITY_WEAK_CONTROL", op_label, severity=max_sev))
134100

135101
return findings
136102

@@ -142,22 +108,12 @@ def evaluate(match: MatchResult) -> list[Finding]:
142108
for key in match.pfmea_only_ops:
143109
op = match.display_op(key)
144110
findings.append(
145-
Finding(
146-
finding_type="UNMATCHED_PROCESS_STEP",
147-
level="warning",
148-
operation_id=op,
149-
message=f"PFMEA operation {op} has no matching row in the Control Plan.",
150-
)
111+
_make("UNMATCHED_PROCESS_STEP", op, source="PFMEA", target="Control Plan")
151112
)
152113
for key in match.control_only_ops:
153114
op = match.display_op(key)
154115
findings.append(
155-
Finding(
156-
finding_type="UNMATCHED_PROCESS_STEP",
157-
level="warning",
158-
operation_id=op,
159-
message=f"Control Plan operation {op} has no matching row in the PFMEA.",
160-
)
116+
_make("UNMATCHED_PROCESS_STEP", op, source="Control Plan", target="PFMEA")
161117
)
162118

163119
for key in match.matched_ops:
Lines changed: 46 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,9 @@
11
"""Rule definitions (YAML) for the validation modules.
22
3-
Packaged so rule files ship with the wheel. In the MVP the checks are implemented in
4-
``modules/pfmea_control_plan.py``; this YAML documents each finding type (id, severity,
5-
description) and is the single source of truth for that metadata. A consistency test asserts the
6-
code and the YAML never drift. Making the module *consume* this YAML to drive logic is deferred
7-
to a later iteration (it requires a small rule-interpretation layer).
3+
Packaged so rule files ship with the wheel. The YAML is the **single source of truth for rule
4+
metadata** — id, severity, title, message_template, description and rationale. The checker in
5+
``modules/pfmea_control_plan.py`` reads this metadata instead of hardcoding it, while the
6+
per-finding-type detection logic stays in Python (this is intentionally not a generic rule engine).
87
"""
98

109
from __future__ import annotations
@@ -15,18 +14,49 @@
1514

1615
_RULES_FILE = "pfmea_control_plan_rules.yaml"
1716

17+
VALID_SEVERITIES = {"critical", "warning"}
18+
REQUIRED_FIELDS = ("severity", "message_template", "description")
1819

19-
def load_rule_specs() -> dict[str, dict]:
20-
"""Load the documented rules as ``{rule_id: {"severity": ..., "description": ...}}``."""
21-
text = resources.files(__package__).joinpath(_RULES_FILE).read_text(encoding="utf-8")
22-
data = yaml.safe_load(text) or {}
20+
21+
class RuleSpecError(ValueError):
22+
"""Raised when the rule metadata file is malformed."""
23+
24+
25+
def parse_rule_specs(data: dict) -> dict[str, dict]:
26+
"""Validate parsed YAML and return ``{rule_id: {severity, title, message_template, ...}}``.
27+
28+
Raises RuleSpecError on a missing id, missing required field, invalid severity, or duplicate id.
29+
"""
2330
specs: dict[str, dict] = {}
24-
for rule in data.get("rules", []):
25-
rule_id = rule.get("id")
26-
if rule_id:
27-
specs[rule_id] = {
28-
"severity": rule.get("severity"),
29-
"description": (rule.get("description") or "").strip(),
30-
}
31+
for rule in (data or {}).get("rules", []):
32+
rule_id = (rule.get("id") or "").strip()
33+
if not rule_id:
34+
raise RuleSpecError("Rule with a missing or empty 'id'.")
35+
if rule_id in specs:
36+
raise RuleSpecError(f"Duplicate rule id: '{rule_id}'.")
37+
for field in REQUIRED_FIELDS:
38+
value = rule.get(field)
39+
if value is None or (isinstance(value, str) and not value.strip()):
40+
raise RuleSpecError(f"Rule '{rule_id}' is missing required field '{field}'.")
41+
severity = rule["severity"]
42+
if severity not in VALID_SEVERITIES:
43+
raise RuleSpecError(
44+
f"Rule '{rule_id}' has invalid severity '{severity}' "
45+
f"(expected one of {sorted(VALID_SEVERITIES)})."
46+
)
47+
specs[rule_id] = {
48+
"severity": severity,
49+
"title": (rule.get("title") or "").strip(),
50+
"message_template": " ".join(str(rule["message_template"]).split()),
51+
"description": " ".join(str(rule["description"]).split()),
52+
"rationale": " ".join(str(rule.get("rationale") or "").split()),
53+
}
54+
if not specs:
55+
raise RuleSpecError("No rules found in the rule metadata file.")
3156
return specs
3257

58+
59+
def load_rule_specs() -> dict[str, dict]:
60+
"""Load and validate the rule metadata from the packaged YAML file."""
61+
text = resources.files(__package__).joinpath(_RULES_FILE).read_text(encoding="utf-8")
62+
return parse_rule_specs(yaml.safe_load(text))

0 commit comments

Comments
 (0)