From 892cdd1246a47287e350573a0103a4e0c16b8ee3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 26 Jun 2026 11:12:20 +0000 Subject: [PATCH 1/3] audit(xaml): emit the Phase-2 facts layer (xaml-facts.json) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Builds the Phase-1 -> Phase-2 seam from the design note: the markup pass now emits a structured fact document alongside its SARIF, so the existing Roslyn extractor (frontend/roslyn/OwnSharp.Extractor -> OwnIR) and the binding-path join have something to read instead of re-parsing XAML. - xaml_facts.py: extracts the two fact families from the parsed tree — XamlResourceGraph (resources, merged_dictionaries) and XamlBindingFacts (bindings with a parsed binding-markup parser: path / mode / UpdateSourceTrigger / converter / Delay / RelativeSource, plus event_handlers, converters_used, and the file's x:Class). Envelope mirrors OwnIR's *.facts.json ({xaml_facts_version, module, documents, totals}). Pure stdlib. - xaml_check: split analyze_text into analyze_root + analyze_text so run_xaml_check parses each file once and feeds the same tree to both the rules and the facts extractor; it now writes xaml-facts.json next to xaml-check.sarif. - selftest (16 checks): the binding-markup parser (positional path, nested Converter, RelativeSource, TemplateBinding, non-binding rejection, brace-aware comma split), document facts on a representative view, EventSetter handlers, and the OwnIR-parallel envelope. Wired into CI. - design note + README: document the seam and that Phase 2 consumes (not re-derives) xaml-facts.json. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_015zoG4YNCUf7RA5r8GAXMmb --- .github/workflows/ci.yml | 1 + audit/README.md | 8 +- audit/static/tools/xaml_check.py | 46 +++- audit/static/tools/xaml_facts.py | 344 +++++++++++++++++++++++++++++ docs/notes/xaml-analyzer-design.md | 11 + 5 files changed, 398 insertions(+), 12 deletions(-) create mode 100644 audit/static/tools/xaml_facts.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 34d2c0cb..d2320f3e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,6 +62,7 @@ jobs: python audit/aggregate/score.py --selftest python audit/aggregate/report.py --selftest python audit/static/tools/xaml_check.py --selftest + python audit/static/tools/xaml_facts.py --selftest python audit/static/run_static.py --selftest python audit/runtime/ingest.py --selftest diff --git a/audit/README.md b/audit/README.md index a8a4fe09..60b1b503 100644 --- a/audit/README.md +++ b/audit/README.md @@ -48,6 +48,7 @@ audit/ owncheck.py # build-free runner: own-check.sh --format sarif (needs dotnet) codeql.sh # build-free runner: CodeQL build-mode=none, security-and-quality xaml_check.py # build-free runner: markup-only XAML perf/lifetime pass (stdlib XML, no SDK) + xaml_facts.py # XAML facts extractor (resource graph + binding facts) -> xaml-facts.json (Phase-2 seam) roslyn_pack.ps1 # build-required runner (local Windows): NetAnalyzers/Roslynator/... infersharp.sh # build-required runner: Infer# over built binaries inject/ # OwnAudit.Directory.Build.props/.targets (analyzer injection, gated) @@ -110,6 +111,7 @@ python audit/aggregate/normalize.py --selftest python audit/aggregate/score.py --selftest python audit/aggregate/report.py --selftest python audit/static/tools/xaml_check.py --selftest # XAML rules + line preservation + SARIF round-trip +python audit/static/tools/xaml_facts.py --selftest # XAML facts: binding parser + resource graph python audit/static/run_static.py --selftest # full pipeline end-to-end on fixtures ``` @@ -133,7 +135,11 @@ python audit/static/run_static.py --selftest # full pipeline end-to-end on fix Freezable duplication). This makes category 8 (broken virtualization) statically covered, not NO-TOOL. Design + the full rule catalogue, phasing, and the Phase-2 binding-path join: [`../docs/notes/xaml-analyzer-design.md`](../docs/notes/xaml-analyzer-design.md). - Phase 2 (Roslyn-linked XAML2xx) and Phase 3 (runtime correlation) are deferred. +- **XAML Phase-2 seam — done:** `static/tools/xaml_facts.py` emits `xaml-facts.json` (resource graph + + binding facts: parsed binding paths / converters / handlers + the file's `x:Class`) from the same + parsed tree, in an OwnIR-parallel envelope. This is the structured input the binding-path join reads + next to the `OwnSharp.Extractor` OwnIR facts. The join itself (Roslyn-linked XAML2xx) and Phase 3 + (runtime correlation) remain deferred. - **Runtime (Phase 2) — started:** the runtime→pipeline bridge (`runtime/ingest.py`, CI-gated), the leak-harness scenario schema + one scenario, runtime rule mappings in the taxonomy (categories 2/3/4/11), and the C# leak-harness skeleton. See diff --git a/audit/static/tools/xaml_check.py b/audit/static/tools/xaml_check.py index 5b0cf2ab..bbf67699 100644 --- a/audit/static/tools/xaml_check.py +++ b/audit/static/tools/xaml_check.py @@ -643,17 +643,24 @@ def _rule_layout_transform(root: Node, avalonia: bool) -> list[XamlFinding]: ] +def analyze_root(root: Node) -> list[XamlFinding]: + """All Phase-1 rules over an already-parsed tree. Split out from ``analyze_text`` + so a caller (e.g. ``run_xaml_check``) can parse a file once and feed the same + tree to both the rules and the facts extractor (``xaml_facts``).""" + avalonia = _is_avalonia(root) + out: list[XamlFinding] = [] + for rule in RULES: + out.extend(rule(root, avalonia)) + return out + + def analyze_text(text: str | bytes) -> list[XamlFinding]: """All Phase-1 rules over one markup document (``str`` or raw ``bytes``). Malformed markup -> no findings.""" root = parse_xaml(text) if root is None: return [] - avalonia = _is_avalonia(root) - out: list[XamlFinding] = [] - for rule in RULES: - out.extend(rule(root, avalonia)) - return out + return analyze_root(root) def _to_sarif(results: list[tuple[str, XamlFinding]]) -> dict[str, Any]: @@ -679,11 +686,19 @@ def _to_sarif(results: list[tuple[str, XamlFinding]]) -> dict[str, Any]: def run_xaml_check(target: str, out_dir: Path) -> dict[str, Any]: - """Scan every ``.xaml`` / ``.axaml`` under ``target`` and write SARIF to - ``out_dir/xaml-check.sarif``. Always best-effort: a missing target or zero - markup files yields ``available=False`` with a reason, never a crash.""" + """Scan every ``.xaml`` / ``.axaml`` under ``target`` and write two artifacts to + ``out_dir``: ``xaml-check.sarif`` (the Phase-1 rule findings, into the audit + pipeline) and ``xaml-facts.json`` (the structured resource-graph + binding facts + for the Phase-2 binding-path join — see ``xaml_facts``). Each file is parsed once + and the same tree feeds both the rules and the facts extractor. Always + best-effort: a missing target or zero markup files yields ``available=False`` with + a reason, never a crash.""" + # Local import keeps the module pair decoupled (xaml_facts imports from here). + from xaml_facts import document_facts, module_facts + out_dir.mkdir(parents=True, exist_ok=True) sarif_path = out_dir / "xaml-check.sarif" + facts_path = out_dir / "xaml-facts.json" status: dict[str, Any] = {"tool": "xaml", "tier": "build-free", "available": False, "sarif": None, "reason": ""} @@ -698,6 +713,7 @@ def run_xaml_check(target: str, out_dir: Path) -> dict[str, Any]: return status results: list[tuple[str, XamlFinding]] = [] + documents: list[dict[str, Any]] = [] scanned = 0 for fp in files: try: @@ -709,12 +725,20 @@ def run_xaml_check(target: str, out_dir: Path) -> dict[str, Any]: continue scanned += 1 rel = fp.relative_to(root).as_posix() - for f in analyze_text(data): + tree = parse_xaml(data) + if tree is None: + continue # malformed markup: skipped (no findings, no facts) + for f in analyze_root(tree): results.append((rel, f)) + documents.append(document_facts(tree, rel)) sarif_path.write_text(json.dumps(_to_sarif(results), indent=2), encoding="utf-8") - status.update(available=True, sarif=str(sarif_path), - findings=len(results), files_scanned=scanned) + facts = module_facts(documents, module=Path(target).name or "target") + facts_path.write_text(json.dumps(facts, indent=2), encoding="utf-8") + status.update(available=True, sarif=str(sarif_path), facts=str(facts_path), + findings=len(results), files_scanned=scanned, + bindings=sum(len(d["bindings"]) for d in documents), + resources=sum(len(d["resources"]) for d in documents)) return status diff --git a/audit/static/tools/xaml_facts.py b/audit/static/tools/xaml_facts.py new file mode 100644 index 00000000..514f7415 --- /dev/null +++ b/audit/static/tools/xaml_facts.py @@ -0,0 +1,344 @@ +#!/usr/bin/env python3 +""" +Own.NET Audit — XAML facts extractor (Phase-2 seam). + +The Phase-1 runner (``xaml_check.py``) turns markup into *rule findings* (SARIF). +This module turns the **same parsed tree** into structured *facts* — the seam the +Phase-2 binding-path join needs (``docs/notes/xaml-analyzer-design.md`` → +"Phase 2 mechanics"). It does **not** evaluate rules and emits **no** findings; it +emits a fact document per ``.xaml`` so the existing Roslyn extractor +(``frontend/roslyn/OwnSharp.Extractor`` → OwnIR) has something to join against. + +Two fact families, exactly the design note's split: + +* **XamlResourceGraph** — ``resources`` (keyed type + scope + line) and + ``merged_dictionaries`` (include sources). Self-contained in markup. +* **XamlBindingFacts** — ``bindings`` (element, property, parsed binding path / + mode / UpdateSourceTrigger / converter / Delay / RelativeSource), ``event_handlers`` + (``Click=``/``EventSetter``) and ``converters_used``. These are *pointers into C#*: + on their own they are inert; the value is the join (binding ``path`` resolved + against the ``x:Class`` / DataContext type by Roslyn → getter/converter/setter / + PropertyChanged cascade). That resolution is the Roslyn step, not this one. + +The envelope mirrors OwnIR's ``*.facts.json`` (``{ownir_version, module, +components}``) so the two fact sources read alike: + + {"xaml_facts_version": 0, "module": "...", "documents": [ {file, x_class, + framework, resources[], merged_dictionaries[], bindings[], event_handlers[], + converters_used[]} ], "totals": {...}} + +Pure stdlib, build-free — it rides the same no-toolchain tier as ``xaml_check``. + +Usage: + xaml_facts.py --target /path/to/legacy/src --out artifacts/own-audit + xaml_facts.py --selftest +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from pathlib import Path +from typing import Any + +from xaml_check import Node, _is_avalonia, _keyed_resources, _resource_dictionaries, parse_xaml + +# Common WPF/XAML routed events: an attribute with one of these names whose value is +# a bare identifier (not a markup extension) is a code-behind handler. Not exhaustive +# by design — facts are honest about what they captured; the join can be refined. +COMMON_EVENTS = { + "Click", "Loaded", "Unloaded", "Initialized", "SelectionChanged", "TextChanged", + "Checked", "Unchecked", "Closing", "Closed", "MouseDown", "MouseUp", + "MouseDoubleClick", "PreviewMouseDown", "KeyDown", "KeyUp", "PreviewKeyDown", + "GotFocus", "LostFocus", "DataContextChanged", "SizeChanged", "Drop", "DragEnter", + "Expanded", "Collapsed", "ValueChanged", "Scroll", +} + +_RESOURCE_REF_RE = re.compile(r"\{\s*(?:Static|Dynamic)Resource\s+([^}]+)\}", re.IGNORECASE) +_RELSOURCE_RE = re.compile(r"\{\s*RelativeSource\s+([^},]+)", re.IGNORECASE) + + +def _split_top_level(s: str) -> list[str]: + """Split on commas at brace-depth 0, so a nested ``Converter={StaticResource c}`` + is not chopped at its inner comma.""" + out: list[str] = [] + depth = 0 + cur: list[str] = [] + for ch in s: + if ch == "{": + depth += 1 + elif ch == "}": + depth = max(0, depth - 1) + if ch == "," and depth == 0: + out.append("".join(cur)) + cur = [] + else: + cur.append(ch) + if cur: + out.append("".join(cur)) + return out + + +def _resource_key(val: str) -> str: + """``{StaticResource boolToVis}`` -> ``boolToVis``; otherwise the raw value.""" + m = _RESOURCE_REF_RE.search(val) + return m.group(1).strip() if m else val.strip() + + +def parse_binding(value: str) -> dict[str, Any] | None: + """Parse a ``{Binding ...}`` / ``{TemplateBinding ...}`` markup extension into the + fields the join cares about, or ``None`` if ``value`` is not a binding. The first + positional token of a ``Binding`` is its ``Path`` (the ``{Binding Qty}`` form); + ``{TemplateBinding Prop}`` is a binding to the templated parent.""" + v = value.strip() + if not (v.startswith("{") and v.endswith("}")): + return None + inner = v[1:-1].strip() + head, _, rest = inner.partition(" ") + kind = head.lower() + if kind not in ("binding", "templatebinding"): + return None + fact: dict[str, Any] = { + "kind": "TemplateBinding" if kind == "templatebinding" else "Binding", + "path": None, "mode": None, "update_source_trigger": None, "converter": None, + "delay": None, "relative_source": None, "element_name": None, "source": None, + } + if kind == "templatebinding": + fact["path"] = rest.strip() or None + fact["relative_source"] = "TemplatedParent" + return fact + for i, raw in enumerate(_split_top_level(rest)): + part = raw.strip() + if not part: + continue + if "=" not in part: + if i == 0: + fact["path"] = part # positional Path + continue + key, _, val = part.partition("=") + key = key.strip().lower() + val = val.strip() + if key == "path": + fact["path"] = val + elif key == "mode": + fact["mode"] = val + elif key == "updatesourcetrigger": + fact["update_source_trigger"] = val + elif key == "converter": + fact["converter"] = _resource_key(val) + elif key == "delay": + fact["delay"] = val + elif key == "relativesource": + m = _RELSOURCE_RE.search(val) + fact["relative_source"] = m.group(1).strip() if m else val + elif key == "elementname": + fact["element_name"] = val + elif key == "source": + fact["source"] = val + return fact + + +def document_facts(root: Node, rel_path: str) -> dict[str, Any]: + """The fact document for one parsed ``.xaml`` tree: its resource graph, binding + facts, event handlers and the converter keys it references.""" + resources: list[dict[str, Any]] = [] + for rd in _resource_dictionaries(root): + scope = rd.type_name() if rd.is_property_element() else "root" + for key, c in _keyed_resources(rd): + resources.append({"key": key, "type": c.type_name(), + "scope": scope, "line": c.line}) + + merged: list[dict[str, Any]] = [] + bindings: list[dict[str, Any]] = [] + handlers: list[dict[str, Any]] = [] + converters: set[str] = set() + + for n in root.walk(): + if n.local() == "MergedDictionaries" and n.is_property_element(): + for c in n.children: + src = c.attr("Source") + if src: + merged.append({"source": src.strip(), "line": c.line}) + if n.type_name() == "EventSetter": + ev, h = n.attr("Event"), n.attr("Handler") + if ev and h: + handlers.append({"element": "EventSetter", "event": ev.strip(), + "handler": h.strip(), "line": n.line}) + for k, v in n.attrib.items(): + b = parse_binding(v) + if b is not None: + prop = k.split(":", 1)[-1].rsplit(".", 1)[-1] + bindings.append({"element": n.type_name(), "property": prop, + "line": n.line, **b}) + if b["converter"]: + converters.add(b["converter"]) + continue + ev = k.split(":", 1)[-1].rsplit(".", 1)[-1] + if ev in COMMON_EVENTS and v.strip() and not v.strip().startswith("{"): + handlers.append({"element": n.type_name(), "event": ev, + "handler": v.strip(), "line": n.line}) + + return { + "file": rel_path, + "x_class": root.attr("Class"), # x:Class on the root, or None + "framework": "avalonia" if _is_avalonia(root) else "wpf", + "resources": resources, + "merged_dictionaries": merged, + "bindings": bindings, + "event_handlers": handlers, + "converters_used": sorted(converters), + } + + +def module_facts(documents: list[dict[str, Any]], module: str = "target") -> dict[str, Any]: + """Wrap per-document facts in the OwnIR-parallel envelope, with roll-up totals.""" + def total(field: str) -> int: + return sum(len(d[field]) for d in documents) + + return { + "xaml_facts_version": 0, + "module": module, + "documents": documents, + "totals": { + "documents": len(documents), + "resources": total("resources"), + "merged_dictionaries": total("merged_dictionaries"), + "bindings": total("bindings"), + "event_handlers": total("event_handlers"), + }, + } + + +def build_facts(target: str) -> dict[str, Any]: + """Parse every ``.xaml`` / ``.axaml`` under ``target`` into the module fact doc.""" + root = Path(target) + documents: list[dict[str, Any]] = [] + if root.exists(): + for fp in sorted(p for p in root.rglob("*") + if p.suffix.lower() in (".xaml", ".axaml") and p.is_file()): + try: + data = fp.read_bytes() + except OSError: + continue + tree = parse_xaml(data) + if tree is None: + continue + documents.append(document_facts(tree, fp.relative_to(root).as_posix())) + return module_facts(documents, module=root.name or "target") + + +def main(argv: list[str] | None = None) -> int: + ap = argparse.ArgumentParser(description="Extract XAML facts (Phase-2 seam) -> JSON.") + ap.add_argument("--target", help="path to the target source tree") + ap.add_argument("--out", default="artifacts/own-audit", help="output directory") + ap.add_argument("--selftest", action="store_true", help="run built-in checks and exit") + args = ap.parse_args(argv) + + if args.selftest: + return _selftest() + if not args.target: + ap.error("--target is required (or use --selftest)") + + out_dir = Path(args.out) + out_dir.mkdir(parents=True, exist_ok=True) + facts = build_facts(args.target) + (out_dir / "xaml-facts.json").write_text(json.dumps(facts, indent=2), encoding="utf-8") + print(json.dumps(facts["totals"], indent=2)) + return 0 + + +# --------------------------------------------------------------------------- # +# Selftest — embedded fixtures; gates on Linux CI like the other build-free # +# modules (no .NET, nothing on disk). # +# --------------------------------------------------------------------------- # + +_WPF_NS = ('xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" ' + 'xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"') + + +def _selftest() -> int: + checks: list[str] = [] + + def check(ok: bool, msg: str) -> None: + checks.append("" if ok else msg) + + # --- binding markup parser --- + b = parse_binding("{Binding Qty, Mode=TwoWay, UpdateSourceTrigger=PropertyChanged}") + check(b is not None and b["path"] == "Qty" and b["mode"] == "TwoWay" + and b["update_source_trigger"] == "PropertyChanged", + f"positional path + mode + UST parse wrong: {b}") + b = parse_binding("{Binding Path=Total, Converter={StaticResource money}}") + check(b is not None and b["path"] == "Total" and b["converter"] == "money", + f"Path= + nested Converter resource key parse wrong: {b}") + b = parse_binding("{Binding Background, RelativeSource={RelativeSource TemplatedParent}}") + check(b is not None and b["relative_source"] == "TemplatedParent", + f"RelativeSource parse wrong: {b}") + b = parse_binding("{TemplateBinding Padding}") + check(b is not None and b["kind"] == "TemplateBinding" and b["path"] == "Padding" + and b["relative_source"] == "TemplatedParent", f"TemplateBinding parse wrong: {b}") + check(parse_binding("Hello") is None and parse_binding("{StaticResource x}") is None, + "non-binding values must not parse as bindings") + # the nested-comma split must not chop the Converter argument + check(len(_split_top_level("Qty, Converter={StaticResource a,b}, Mode=OneWay")) == 3, + "top-level comma split must respect nested braces") + + # --- document facts on a representative view --- + doc_xaml = (f'\n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + ' \n' + '