cloudronin's picture
push build context (uofa source + packs + space app)
a28ec65 verified
Raw
History Blame Contribute Delete
25.4 kB
"""uofa rules β€” run the Jena rule engine for weakener detection.
Spec v0.4 Β§4.1: `run_structured(args)` returns a typed `RulesResult` so the
interpretation pipeline can consume firings in-process. `run(args)` is a thin
shell that prints from the structured result. The three existing I/O paths
(`--output`, `--raw`, default) are preserved bit-for-bit.
"""
from __future__ import annotations
import json
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass, field
from pathlib import Path
from uofa_cli.output import step_header, error, info, color, severity_badge
from uofa_cli import paths
HELP = "detect quality gaps with Jena rule engine (C3)"
# Patterns to colorize in Jena output
_SEVERITY_RE = re.compile(r'\[(Critical|High|Medium|Low)\]')
_COMPOUND_RE = re.compile(r'(⚑\s*COMPOUND-\d+)')
_PATTERN_RE = re.compile(r'(⚠\s*W-[A-Z]+-\d{2})')
_SUMMARY_LINE_RE = re.compile(r'^(\s*)(Critical|High|Medium|Low):\s+(\d+)$')
# Firings parser shared with diff.py (lifted here as the canonical owner).
# Matches engine summary lines like "⚠ W-EP-04 [High] β€” 6 hit(s)",
# "⚑ COMPOUND-01 [Critical] β€” 1 hit(s)", and the iso42001 pack's
# descriptive pattern names like "⚠ W-AIMS-AUDIT-STALE [High] β€” 1 hit(s)"
# or "⚠ W-AIMS-MODEL-EVAL-STALE [High] β€” 1 hit(s)" (any number of
# UPPERCASE-letter segments after the W-XX prefix).
_FIRING_RE = re.compile(
r'[⚠⚑]\s+((?:W-[A-Z]{2,}-\d{2}|W-[A-Z]{2,}(?:-[A-Z0-9]+)+|COMPOUND-\d{2}))\s+'
r'\[(Critical|High|Medium|Low)\]\s+β€”\s+(\d+)\s+hit'
)
@dataclass(frozen=True)
class RulesResult:
"""Structured result of a Jena rule engine invocation.
`firings` is populated only when the engine ran in summary format AND
its output was captured (i.e. not `--output` and not `--raw`). When the
user requests a different `--format` (jsonld / turtle / ntriples), the
engine emits RDF instead of the firings list and `firings` is empty β€”
consumers that need structured firings should re-run with format=summary
or parse `stdout` themselves.
`raw_stdout` is empty in `--output` mode (engine wrote to file) and in
`--raw` mode (engine printed directly without capture).
"""
file: Path
returncode: int
format: str # "summary" | "jsonld" | "turtle" | "ntriples"
raw_stdout: str = ""
raw_stderr: str = ""
firings: list[dict] = field(default_factory=list)
output_path: Path | None = None # set when --output was used
@property
def exit_code(self) -> int:
return self.returncode
def _ensure_java() -> str:
"""Return the path to a usable java binary (bundled JRE preferred)."""
try:
return paths.java_executable()
except FileNotFoundError:
raise FileNotFoundError(
"Java not found. Install Java 17+: https://adoptium.net/\n"
" Or skip the rule engine: uofa check FILE --skip-rules"
)
def _ensure_jar(build: bool):
jar = paths.jar_path()
if jar.exists():
return jar
if not build:
raise FileNotFoundError(
f"Jena engine not built: {jar}\n"
" Run: uofa rules FILE --build\n"
" Or: cd weakener-engine && mvn package"
)
if not shutil.which("mvn"):
raise FileNotFoundError(
"Maven not found. Install Maven 3.8+ to build the rule engine.\n"
" Or build manually: cd weakener-engine && mvn package"
)
info("Building Jena rule engine...")
result = subprocess.run(
["mvn", "package", "-q"],
cwd=str(paths.engine_dir()),
capture_output=True, text=True,
)
if result.returncode != 0:
error(f"Maven build failed:\n{result.stderr}")
raise RuntimeError("Failed to build Jena rule engine")
return jar
def _colorize_line(line: str) -> str:
"""Apply severity coloring to a single line of rule engine output."""
# Colorize summary counts: " Critical: 16"
m = _SUMMARY_LINE_RE.match(line)
if m:
indent, sev, count = m.group(1), m.group(2), m.group(3)
sev_colors = {"Critical": "red", "High": "yellow", "Medium": "cyan", "Low": "dim"}
c = sev_colors.get(sev, "dim")
return f"{indent}{color(sev + ':', c)} {color(count, c)}"
# Colorize [Critical], [High], etc. inline
def _replace_severity(match):
sev = match.group(1)
return severity_badge(sev)
line = _SEVERITY_RE.sub(_replace_severity, line)
# Colorize compound pattern IDs
line = _COMPOUND_RE.sub(lambda m: color(m.group(1), "red"), line)
# Colorize core pattern IDs
line = _PATTERN_RE.sub(lambda m: color(m.group(1), "yellow"), line)
return line
def _combine_rules_files(rules_paths: list[Path]) -> Path:
"""Concatenate multiple rules files into a single temp file."""
if len(rules_paths) == 1:
return rules_paths[0]
tmp = tempfile.NamedTemporaryFile(mode="w", suffix=".rules", delete=False)
seen_prefixes = set()
for rp in rules_paths:
content = rp.read_text()
# Deduplicate @prefix declarations
lines = []
for line in content.splitlines():
if line.strip().startswith("@prefix"):
if line.strip() not in seen_prefixes:
seen_prefixes.add(line.strip())
lines.append(line)
else:
lines.append(line)
tmp.write("\n".join(lines))
tmp.write("\n\n")
tmp.close()
return Path(tmp.name)
def parse_firings(stdout_text: str) -> list[dict]:
"""Parse Jena engine summary output into a deduplicated firings list.
Each firing dict has ``patternId``, ``severity``, ``hits``. Order
preserved by first occurrence (matches the engine's output order).
Public so the interpretation pipeline can re-parse stdout from a
pre-existing rules invocation (the standalone ``uofa explain --from-file``
case in spec Β§3.3).
"""
seen: dict[str, dict] = {}
for m in _FIRING_RE.finditer(stdout_text):
pid, sev, hits = m.group(1), m.group(2), int(m.group(3))
if pid not in seen:
seen[pid] = {"patternId": pid, "severity": sev, "hits": hits}
return list(seen.values())
def attribute_firings(firings: list[dict], root: Path | None = None) -> list[dict]:
"""Stamp each firing with the detection pack that owns its patternId (Β§5/Β§7.3).
Adds a ``pack`` key β€” the owning pack name, or ``None`` for an unrecognized
patternId β€” so the reasoned output records *which detection pack fired which
weakener* (the Β§7.3 auditability requirement). Uses the manifest-built index
(``paths.patternid_pack_index``), the same data the loader uses. Mutates and
returns ``firings``.
"""
index = paths.patternid_pack_index(root)
for firing in firings:
pid = firing.get("patternId")
if pid:
firing["pack"] = index.get(pid)
return firings
# Pattern descriptions live in .rules files as `# W-XX-NN: <description>`
# header comments preceding each rule block (or descriptive form like
# `# W-AIMS-AUDIT-STALE: <description>` for the iso42001 pack). Parsing
# them gives the interpretation pipeline the human-readable name without
# forcing the engine to round-trip them through JSON-LD.
_PATTERN_DESC_RE = re.compile(
r"^#\s*((?:W-[A-Z]{2,}-\d{2}|W-[A-Z]{2,}(?:-[A-Z0-9]+)+|COMPOUND-\d{2}))\s*:\s*(.+?)\s*$",
re.MULTILINE,
)
# Engine vocab IRIs used in jsonld output. Defined as constants so the
# parser breaks loudly if the engine schema changes vs silently producing
# empty firings.
_VOCAB = "https://uofa.net/vocab#"
_TYPE_WEAKENER_ANNOTATION = f"{_VOCAB}WeakenerAnnotation"
_PROP_PATTERN_ID = f"{_VOCAB}patternId"
_PROP_SEVERITY = f"{_VOCAB}severity"
_PROP_AFFECTED_NODE = f"{_VOCAB}affectedNode"
_PROP_ESCALATION_SOURCE = f"{_VOCAB}escalationSource"
_PROP_DESCRIPTION = "https://schema.org/description"
def parse_firings_jsonld(jsonld_text: str) -> list[dict]:
"""Parse the engine's `--format jsonld` output into rich firing dicts.
Returns one dict per *patternId* (aggregated across hits, mirroring
`parse_firings`) but with the data the summary-mode parser loses:
{
"patternId": "W-EP-04",
"severity": "High",
"hits": 6,
"description": "Credibility factor is not assessed but ...",
"affected_nodes": ["https://...factor/use-error", ...],
"escalation_sources": [], # populated only for compound patterns
}
Used by the `--explain` pipeline (spec Β§4.3) to give the LLM the actual
affected-node IRIs so explanations can ground in specific evidence.
Public so the standalone `uofa explain --from-file` path can re-parse
cached engine output.
The shape is per-patternId (not per-individual-firing) so the
interpretation pipeline runs one LLM call per pattern, not one per hit
β€” a Critical pattern firing 7 times still produces a single
explanation that lists all seven affected nodes.
Args:
jsonld_text: Stdout from `uofa rules <file> --format jsonld`. Must
be a JSON-LD document with a top-level `@graph` array.
Returns:
List of firing dicts in first-occurrence order (matches the
engine's emit order). Empty when the document has no
WeakenerAnnotations.
"""
try:
doc = json.loads(jsonld_text)
except json.JSONDecodeError:
return []
graph = doc.get("@graph") if isinstance(doc, dict) else None
if not isinstance(graph, list):
return []
aggregated: dict[str, dict] = {}
order: list[str] = []
for node in graph:
if not isinstance(node, dict):
continue
if node.get("@type") != _TYPE_WEAKENER_ANNOTATION:
continue
pid = _str_or_empty(node.get(_PROP_PATTERN_ID))
if not pid:
continue
if pid not in aggregated:
aggregated[pid] = {
"patternId": pid,
"severity": _str_or_empty(node.get(_PROP_SEVERITY)) or "Medium",
"hits": 0,
"description": _str_or_empty(node.get(_PROP_DESCRIPTION)),
"affected_nodes": [],
"escalation_sources": [],
}
order.append(pid)
agg = aggregated[pid]
agg["hits"] += 1
affected = node.get(_PROP_AFFECTED_NODE)
affected_iri = _extract_iri(affected)
if affected_iri and affected_iri not in agg["affected_nodes"]:
agg["affected_nodes"].append(affected_iri)
# escalationSource may be a single object or a list of objects;
# each is either {"@id": "..."} (IRI/blank-node ref) or a nested
# dict. We collect the @id strings for resolution by the context
# extractor.
sources = node.get(_PROP_ESCALATION_SOURCE)
for src_iri in _extract_iri_list(sources):
if src_iri not in agg["escalation_sources"]:
agg["escalation_sources"].append(src_iri)
return [aggregated[pid] for pid in order]
def parse_individual_annotations(jsonld_text: str) -> list[dict]:
"""Per-annotation parse (no aggregation by patternId).
Returns one dict per WeakenerAnnotation in the engine's jsonld output,
keyed by the annotation's `@id` so callers can resolve compound
`escalation_sources` blank-node references back to the constituent
firings. Each dict carries:
{
"id": "_:b1", # blank-node id from engine
"patternId": "W-AL-01",
"severity": "High",
"affected_node": "https://...factor/abc", # the IRI
"description": "...", # from schema:description
}
Used by the interpretation pipeline to pretty-print "COMPOUND-01 fires
because W-AL-01 (Missing UQ) and W-EP-04 (Unassessed Factor) both
fired" β€” the per-pattern aggregation in `parse_firings_jsonld` loses
the blank-node identity needed for that mapping.
"""
try:
doc = json.loads(jsonld_text)
except json.JSONDecodeError:
return []
graph = doc.get("@graph") if isinstance(doc, dict) else None
if not isinstance(graph, list):
return []
out: list[dict] = []
for node in graph:
if not isinstance(node, dict):
continue
if node.get("@type") != _TYPE_WEAKENER_ANNOTATION:
continue
out.append({
"id": _str_or_empty(node.get("@id")),
"patternId": _str_or_empty(node.get(_PROP_PATTERN_ID)),
"severity": _str_or_empty(node.get(_PROP_SEVERITY)) or "Medium",
"affected_node": _extract_iri(node.get(_PROP_AFFECTED_NODE)),
"description": _str_or_empty(node.get(_PROP_DESCRIPTION)),
})
return out
def _str_or_empty(value) -> str:
"""Coerce a JSON-LD literal to plain str. Handles `{"@value": "..."}` form
plus bare strings. Returns empty string on anything unexpected."""
if isinstance(value, str):
return value
if isinstance(value, dict):
v = value.get("@value")
if isinstance(v, str):
return v
return ""
def _extract_iri(value) -> str:
"""Pull `@id` out of a `{"@id": "..."}` dict; handle list-of-one."""
if isinstance(value, dict):
iri = value.get("@id")
if isinstance(iri, str):
return iri
if isinstance(value, list) and value:
return _extract_iri(value[0])
if isinstance(value, str):
return value
return ""
def _extract_iri_list(value) -> list[str]:
"""Same as `_extract_iri` but always returns a list (single, list, or empty)."""
if value is None:
return []
if isinstance(value, list):
out = []
for item in value:
iri = _extract_iri(item)
if iri:
out.append(iri)
return out
iri = _extract_iri(value)
return [iri] if iri else []
def parse_pattern_descriptions(rules_text: str) -> dict[str, str]:
"""Extract pattern descriptions from a .rules file's comment headers.
Returns a dict mapping patternId β†’ human-readable description (e.g.
`{"W-EP-04": "Unassessed Factor at Elevated Risk"}`).
Accepts the file *content* rather than a path so callers can compose
multiple files without re-reading.
"""
return {m.group(1): m.group(2).strip() for m in _PATTERN_DESC_RE.finditer(rules_text)}
def load_pattern_descriptions(pack_name: str | None = None) -> dict[str, str]:
"""Load pattern descriptions from every .rules file in scope.
Walks the active pack chain via `paths.all_rules_files()`. When
`pack_name` is given, also walks that pack's `rules/` dir directly
so descriptions from non-active packs (e.g. `nasa-7009b` when
interpreting an NASA package while `vv40` is active) still resolve.
Returns a merged dict; on duplicate patternIds, last write wins (no
current pack defines duplicates).
Used by `context.extract_firing_contexts` to enrich each
FiringContext with its pattern's description so the LLM doesn't
fall back to "the specific nature of W-EP-04 cannot be determined
from the provided input."
"""
descriptions: dict[str, str] = {}
# Active pack's rules (uses paths' default chain β€” typically core + vv40).
try:
for rp in paths.all_rules_files():
try:
descriptions.update(parse_pattern_descriptions(rp.read_text(encoding="utf-8")))
except OSError:
pass
except (FileNotFoundError, KeyError):
pass
# Pack-specific rules dir (covers non-active packs).
if pack_name:
try:
pack_root = paths.pack_dir(pack_name)
rules_dir = pack_root / "rules"
if rules_dir.is_dir():
for rp in sorted(rules_dir.glob("*.rules")):
try:
descriptions.update(parse_pattern_descriptions(rp.read_text(encoding="utf-8")))
except OSError:
pass
except (FileNotFoundError, KeyError):
pass
return descriptions
def add_arguments(parser):
parser.add_argument("file", type=Path, help="UofA JSON-LD file to analyze")
parser.add_argument("--rules", "-r", type=Path, help="path to .rules file")
parser.add_argument("--context", "-c", type=Path, help="JSON-LD context file")
parser.add_argument("--build", action="store_true", help="auto-build the Jena JAR if missing")
parser.add_argument("--raw", action="store_true", help="show raw output without coloring")
parser.add_argument("--format", "-f", default="summary",
choices=["summary", "turtle", "ntriples", "jsonld", "json"],
help="output format (default: summary). 'json' is the parsed-firings "
"shape suitable for snapshot tests; 'jsonld' is the raw RDF.")
parser.add_argument("--output", "-o", type=Path,
help="write reasoned output to a file (default: stdout)")
# --explain* flag set (spec Β§3.2) β€” shared across the four target commands.
from uofa_cli.interpretation.cli import add_explain_arguments
add_explain_arguments(parser)
def run_structured(args) -> RulesResult:
"""Run the Jena rule engine and return a typed result.
Does NOT print β€” `run()` is the I/O shell. For `--output` and `--raw`
modes the engine writes/prints directly (capture would change behavior),
so the returned `raw_stdout` is empty in those modes.
"""
if not args.file.exists():
raise FileNotFoundError(f"File not found: {args.file}")
java = _ensure_java()
jar = _ensure_jar(args.build)
if args.rules:
rules = args.rules
else:
rules_list = paths.all_rules_files(args.file, active=paths.resolve_active_packs(args))
rules = _combine_rules_files(rules_list)
ctx = args.context or paths.context_file()
cmd = [java, "-jar", str(jar), str(args.file), "--rules", str(rules), "--context", str(ctx)]
fmt = args.format or "summary"
if fmt and fmt != "summary":
cmd += ["--format", fmt]
if args.output:
cmd += ["--output", str(args.output)]
# If writing to a file, the engine produces no stdout content for the
# caller to colorize β€” just pipe through.
if args.output or args.raw:
result = subprocess.run(cmd, capture_output=False)
return RulesResult(
file=args.file,
returncode=result.returncode,
format=fmt,
output_path=args.output if args.output else None,
)
# Capture and (later) colorize output. Force UTF-8 decoding so the Java
# subprocess's box-drawing/severity glyphs (`══`, `⚠`, `⚑`, `βœ“`, `βœ—`)
# round-trip cleanly through the parent's stdout regardless of the
# caller's locale. Windows defaults to cp1252, which would mojibake
# those bytes into `?`. errors='replace' is the belt-and-suspenders
# fallback.
completed = subprocess.run(
cmd, capture_output=True, text=True,
encoding="utf-8", errors="replace",
)
# Firings are NOT pack-attributed here: run_structured().firings feeds the C3
# check report, whose serialization is a byte-stable backward-compat contract
# (tests/oos/test_production_oos.py::test_55). Pack provenance (Β§5/Β§7.3) is
# applied at the evidence/action boundary by the consumer that records it β€”
# the guardrail β€” via attribute_firings, not ambiently injected into every report.
firings = parse_firings(completed.stdout) if fmt == "summary" else []
return RulesResult(
file=args.file,
returncode=completed.returncode,
format=fmt,
raw_stdout=completed.stdout,
raw_stderr=completed.stderr,
firings=firings,
)
def run(args) -> int:
# `--format json`: clean parsed-firings shape for snapshot tests / external
# tooling. Runs the engine in jsonld mode internally, parses with the
# existing parse_firings_jsonld helper, and emits a stable JSON document.
# No coloring, no headers β€” pure data on stdout.
if getattr(args, "format", None) == "json":
original_format = args.format
args.format = "jsonld"
try:
result = run_structured(args)
finally:
args.format = original_format
firings = parse_firings_jsonld(result.raw_stdout)
severity_counts: dict[str, int] = {}
for f in firings:
severity_counts[f["severity"]] = severity_counts.get(f["severity"], 0) + f["hits"]
document = {
"file": str(args.file),
"summary": {
"total_firings": sum(f["hits"] for f in firings),
"patterns": len(firings),
"by_severity": severity_counts,
},
"firings": firings,
}
print(json.dumps(document, indent=2))
return result.returncode
step_header("C3: Jena rule engine β€” weakener detection")
sys.stdout.flush()
result = run_structured(args)
# --output / --raw paths: engine already wrote the data; nothing more to do.
if args.output or args.raw:
return result.returncode
for line in result.raw_stdout.splitlines():
print(_colorize_line(line))
if result.raw_stderr:
print(result.raw_stderr, file=sys.stderr, end="")
# ── --explain pipeline (spec Β§3.1) ────────────────────────
if getattr(args, "explain", False) and result.returncode == 0:
_run_explain(args, result)
return result.returncode
def _run_explain(args, result: RulesResult) -> None:
"""Invoke the interpretation pipeline and print the result.
Graceful degradation per spec Β§3.7: any LLMError β†’ notice + exit 0
(the analysis succeeded; interpretation is opt-in). Engineered to
never raise to the caller.
Round 1 (P-B iteration): re-invokes the rule engine in jsonld mode
to capture rich firing data (affected node IRIs + escalation sources
for compounds). This costs one extra subprocess call when --explain
is set, in exchange for the LLM seeing actual evidence labels rather
than just patternId+severity+hits. See [round1_audit.md].
"""
import json as _json
from uofa_cli.interpretation import interpret_rules_output
from uofa_cli.interpretation.cli import (
args_to_options, print_degradation, print_envelope,
)
from uofa_cli.llm.errors import LLMError
try:
package_doc = _json.loads(args.file.read_text(encoding="utf-8"))
except (OSError, ValueError) as exc:
# Couldn't parse the package β€” degrade.
print_degradation(
LLMError(f"Could not load package for interpretation: {exc}"),
mode="explain",
format=args.explain_format or "text",
command="rules",
structured_output={"firings": result.firings},
)
return
# Re-invoke the engine in jsonld mode for rich firing data. Reuse the
# same args namespace but flip format. If this fails (Java not
# installed, etc.), fall back gracefully β€” interpretation runs in
# legacy mode without enrichment, which is no worse than Round 0.
jsonld_firings = None
individual_annotations = None
try:
import argparse as _ap
jsonld_args = _ap.Namespace(
file=args.file,
rules=getattr(args, "rules", None),
context=getattr(args, "context", None),
build=getattr(args, "build", False),
raw=False, format="jsonld", output=None,
active_packs=getattr(args, "active_packs", None),
)
jsonld_result = run_structured(jsonld_args)
if jsonld_result.returncode == 0 and jsonld_result.raw_stdout:
jsonld_firings = parse_firings_jsonld(jsonld_result.raw_stdout)
individual_annotations = parse_individual_annotations(jsonld_result.raw_stdout)
except (FileNotFoundError, RuntimeError):
# Engine unavailable for the second invocation β€” proceed without
# rich data. Pre-Round-1 behavior.
pass
try:
env = interpret_rules_output(
structured_output={"firings": result.firings},
package_doc=package_doc,
firings=result.firings,
jsonld_firings=jsonld_firings,
individual_annotations=individual_annotations,
options=args_to_options(args, pack_name=_active_pack_name(args)),
)
except LLMError as exc:
print_degradation(
exc, mode="explain", format=args.explain_format or "text",
command="rules", structured_output={"firings": result.firings},
)
return
print_envelope(env, format=args.explain_format or "text")
def _active_pack_name(args) -> str:
"""Return the first active pack name; defaults to 'vv40'."""
try:
active = paths.resolve_active_packs(args)
return active[0] if active else "vv40"
except Exception: # noqa: BLE001
return "vv40"