Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ jobs:
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/tools/xaml_join.py --selftest
python audit/static/run_static.py --selftest
python audit/runtime/ingest.py --selftest

Expand Down
16 changes: 12 additions & 4 deletions audit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,7 @@ audit/
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)
xaml_join.py # XAML<->C# Phase-2 join: xaml-facts.json + OwnIR -> XAML203 link findings (build-free)
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)
Expand Down Expand Up @@ -112,6 +113,7 @@ 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/tools/xaml_join.py --selftest # XAML<->C# join: XAML203 view-subscription leak
python audit/static/run_static.py --selftest # full pipeline end-to-end on fixtures
```

Expand All @@ -136,10 +138,16 @@ python audit/static/run_static.py --selftest # full pipeline end-to-end on fix
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).
- **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.
+ binding facts: parsed binding paths / converters / handlers + `x:Class` + `x:Name`) from the same
parsed tree, in an OwnIR-parallel envelope.
- **XAML Phase-2 join (first slice) — done:** `static/tools/xaml_join.py` links `xaml-facts.json` to the
OwnIR facts own-check now persists (`--emit-facts` → `own-check.facts.json`) by the deterministic XAML
naming convention (`x:Class`→type, handler→method) — **build-free, no `.g.cs`/build needed**. First
rule **XAML203** (view subscribes from a load-lifecycle handler but the OwnIR verdict is
`released=false` → closed view retained), anchored at the XAML site and naming the C# subscription.
`run_static.py` runs the join whenever both fact sources are present and folds its SARIF into the
pipeline. Binding-path-hotness rules (XAML200/204, need the DataContext type) and an optional `.g.cs`
ground-truth cross-check are documented build-tier follow-ups. Phase 3 (runtime correlation) 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
Expand Down
82 changes: 82 additions & 0 deletions audit/static/run_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
from report import render_html, render_json, render_markdown, render_sarif # noqa: E402
from score import score # noqa: E402
from xaml_check import run_xaml_check # noqa: E402
from xaml_join import run_join # noqa: E402

try:
from oracle_compare import parse_sarif
Expand Down Expand Up @@ -114,11 +115,14 @@ def run(target: str, profile: dict[str, Any], out_dir: Path, target_name: str =

tiers: list[dict[str, Any]] = []
sarif_inputs: list[tuple[str, str]] = []
ownir_facts: str | None = None # OwnIR facts produced by own-check THIS run
xaml_facts: str | None = None # xaml-facts.json produced by the xaml pass THIS run
if "own-check" in build_free:
st = run_own_check(target, out_dir, severity)
tiers.append(st)
if st["available"] and st["sarif"]:
sarif_inputs.append(("own-check", st["sarif"]))
ownir_facts = st.get("facts")
if "codeql" in build_free:
st = _run_codeql(target, out_dir)
tiers.append(st)
Expand All @@ -132,6 +136,20 @@ def run(target: str, profile: dict[str, Any], out_dir: Path, target_name: str =
tiers.append(st)
if st["available"] and st["sarif"]:
sarif_inputs.append(("xaml", st["sarif"]))
xaml_facts = st.get("facts")

# XAML Phase-2 join (docs/notes/xaml-analyzer-design.md → "Phase 2 mechanics"):
# link xaml-facts.json to the OwnIR facts own-check emitted (--emit-facts) and
# fold the XAML2xx link findings into the same pipeline. Gate on the fact files
# THIS run produced (the statuses' `facts` paths), never on bare existence — a
# re-run into an existing --out, or a profile without xaml/own-check (or no SDK),
# must not join a previous target's stale facts.
if (xaml_facts and Path(xaml_facts).exists()
and ownir_facts and Path(ownir_facts).exists()):
st = run_join(Path(xaml_facts), Path(ownir_facts), out_dir)
tiers.append(st)
if st["available"] and st["sarif"]:
sarif_inputs.append(("xaml-join", st["sarif"]))

# Pick up any build-required SARIFs already dropped here by the Windows runners.
# Roslyn writes ONE SARIF PER PROJECT under roslyn/ (see the injected props's
Expand Down Expand Up @@ -335,6 +353,70 @@ def check(ok: bool, msg: str) -> None: # total derives from the call count
check(res3["totals"]["candidates"] >= 1,
"a XAML107 markup finding must flow through to a scored cluster")

# The XAML Phase-2 join must wire in when BOTH fact sources are produced THIS run:
# a view with a Loaded handler + an OwnIR component with an unreleased subscription
# -> a XAML203 cluster. own-check needs an SDK we don't have on Linux CI, so stub it
# to drop fresh OwnIR facts the way an --emit-facts run would (this also exercises
# the freshness gate: the join keys off the status's `facts` path, not bare
# existence).
_self = sys.modules[__name__] # the running module (own-check is a module global)

def _fake_own_check(target_, out_dir_, severity_, root=None):
facts = Path(out_dir_) / "own-check.facts.json"
facts.write_text(json.dumps({"ownir_version": 0, "module": "App", "components": [
{"name": "CustomerView", "file": "Views/CustomerView.xaml.cs", "subscriptions": [
{"event": "_bus.Changed", "handler": "OnChanged", "line": 21,
"released": False}]}]}), encoding="utf-8")
sarif = Path(out_dir_) / "own-check.sarif"
sarif.write_text('{"version":"2.1.0","runs":[{"results":[]}]}', encoding="utf-8")
return {"tool": "own-check", "tier": "build-free", "available": True,
"sarif": str(sarif), "facts": str(facts), "findings": 0, "reason": ""}

with tempfile.TemporaryDirectory() as td4:
out4 = Path(td4) / "out"
src4 = Path(td4) / "src" / "Views"
src4.mkdir(parents=True)
(src4 / "CustomerView.xaml").write_text(
'<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"\n'
' xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"\n'
' x:Class="App.Views.CustomerView" Loaded="OnLoaded" />\n',
encoding="utf-8")
profile4 = {"name": "t", "severity_floor": "warning",
"tiers": {"build_free": ["own-check", "xaml"]}}
real_own_check = _self.run_own_check
_self.run_own_check = _fake_own_check
try:
res4 = run(str(Path(td4) / "src"), profile4, out4, target_name="t/p")
finally:
_self.run_own_check = real_own_check
check(any(t["tool"] == "xaml-join" and t["available"] for t in res4["tiers"]),
"xaml-join tier must run when this run produced both fact sources")
check(res4["totals"]["clusters"] >= 1,
"a XAML203 join finding must flow through to a scored cluster")

# The join must NOT run on stale facts: a re-run with own-check NOT a producer this
# time (and no SDK) must skip the join even though own-check.facts.json is on disk.
with tempfile.TemporaryDirectory() as td5:
out5 = Path(td5) / "out"
out5.mkdir(parents=True)
src5 = Path(td5) / "src" / "Views"
src5.mkdir(parents=True)
(src5 / "CustomerView.xaml").write_text(
'<UserControl xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"\n'
' xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"\n'
' x:Class="App.Views.CustomerView" Loaded="OnLoaded" />\n',
encoding="utf-8")
(out5 / "own-check.facts.json").write_text(json.dumps({
"ownir_version": 0, "module": "Stale", "components": [
{"name": "CustomerView", "file": "Views/CustomerView.xaml.cs",
"subscriptions": [{"event": "_bus.Changed", "handler": "OnChanged",
"line": 21, "released": False}]}]}), encoding="utf-8")
res5 = run(str(Path(td5) / "src"),
{"name": "t", "severity_floor": "warning", "tiers": {"build_free": ["xaml"]}},
out5, target_name="t/p")
check(not any(t["tool"] == "xaml-join" for t in res5["tiers"]),
"xaml-join must NOT run on a stale own-check.facts.json this run did not produce")

fails = [c for c in checks if c]
for f in fails:
print(f"RUN_STATIC SELFTEST FAIL: {f}")
Expand Down
6 changes: 6 additions & 0 deletions audit/static/taxonomy/categories.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,12 @@ rules:
OWN014: {category: 2, name: region-escape} # escape to a longer-lived region
# (vm->App, SystemEvents) — NOT a
# generic subscription-leak
# XAML Phase-2 join: a view (x:Class) that subscribes from a load-lifecycle handler
# but never releases (OwnIR released=false). Categorized as a subscription leak so
# it scores as the P1 leak it is; the join already stitches the markup site to the
# C# subscription it names (the leak lives in the .xaml.cs, a different basename, so
# it does not spatially cluster with the own-check OWN001 on that file).
"XAML203": {category: 2, name: xaml-subscription-leak}

# ── Category 5: INPC correctness ─────────────────────────────────────────────
"INPC0*": {category: 5, name: inpc-correctness} # PropertyChangedAnalyzers
Expand Down
12 changes: 11 additions & 1 deletion audit/static/tools/owncheck.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,11 @@ def run_own_check(target: str, out_dir: Path, severity: str = "warning",
unavailable toolchain."""
out_dir.mkdir(parents=True, exist_ok=True)
sarif_path = out_dir / "own-check.sarif"
facts_path = out_dir / "own-check.facts.json"
Comment thread
coderabbitai[bot] marked this conversation as resolved.
# Clear any prior run's facts up front: own-check.sh writes this fixed path only on
# a successful extraction, so after a failed/unavailable run the file is simply
# absent — the XAML join can never pick up stale facts from another target/commit.
facts_path.unlink(missing_ok=True)
status: dict[str, Any] = {"tool": "own-check", "tier": "build-free",
"available": False, "sarif": None, "reason": ""}

Expand All @@ -49,7 +54,10 @@ def run_own_check(target: str, out_dir: Path, severity: str = "warning",
status["reason"] = "dotnet SDK not on PATH (needed by the C# fact extractor)"
return status

cmd = [str(OWN_CHECK_SH), "--format", "sarif", "--severity", severity, "--", target]
# Persist the OwnIR facts too (--emit-facts): the XAML Phase-2 join consumes them
# alongside xaml-facts.json. Harmless if unused.
cmd = [str(OWN_CHECK_SH), "--format", "sarif", "--severity", severity,
"--emit-facts", str(facts_path), "--", target]
if root is not None:
cmd[1:1] = ["--root", str(root)]
try:
Expand All @@ -70,6 +78,8 @@ def run_own_check(target: str, out_dir: Path, severity: str = "warning",
except json.JSONDecodeError:
n = 0
status.update(available=True, sarif=str(sarif_path), findings=n)
if facts_path.exists():
status["facts"] = str(facts_path)
return status


Expand Down
15 changes: 14 additions & 1 deletion audit/static/tools/xaml_facts.py
Original file line number Diff line number Diff line change
Expand Up @@ -205,9 +205,17 @@ def document_facts(root: Node, rel_path: str) -> dict[str, Any]:
merged: list[dict[str, Any]] = []
bindings: list[dict[str, Any]] = []
handlers: list[dict[str, Any]] = []
named: list[dict[str, Any]] = []
converters: set[str] = set()

for n in root.walk():
# x:Name / Name -> a generated field in the code-behind partial: a named
# element the code-behind can reference / subscribe / retain. The Phase-2
# join uses these to link XAML elements to code-behind symbols.
if not n.is_property_element() and n.type_name() not in ("Binding", "TemplateBinding"):
nm = n.attr("Name")
if nm:
named.append({"name": nm.strip(), "type": n.type_name(), "line": n.line})
if n.local() == "MergedDictionaries" and n.is_property_element():
for c in n.children:
src = c.attr("Source")
Expand Down Expand Up @@ -249,6 +257,7 @@ def document_facts(root: Node, rel_path: str) -> dict[str, Any]:
"merged_dictionaries": merged,
"bindings": bindings,
"event_handlers": handlers,
"named_elements": named,
"converters_used": sorted(converters),
}

Expand All @@ -268,6 +277,7 @@ def total(field: str) -> int:
"merged_dictionaries": total("merged_dictionaries"),
"bindings": total("bindings"),
"event_handlers": total("event_handlers"),
"named_elements": total("named_elements"),
},
}

Expand Down Expand Up @@ -357,7 +367,7 @@ def check(ok: bool, msg: str) -> None:
' <TextBox Text="{Binding Name, Mode=TwoWay, '
'UpdateSourceTrigger=PropertyChanged}" />\n'
' <TextBlock Text="{Binding Total, Converter={StaticResource money}}" />\n'
' <Button Click="OnSave" Content="Save" />\n'
' <Button x:Name="SaveButton" Click="OnSave" Content="Save" />\n'
' </StackPanel>\n'
'</UserControl>\n')
root = parse_xaml(doc_xaml)
Expand All @@ -379,6 +389,9 @@ def check(ok: bool, msg: str) -> None:
check(any(h["event"] == "Click" and h["handler"] == "OnSave"
and h["element"] == "Button" for h in d["event_handlers"]),
f"event handler not captured: {d['event_handlers']}")
check(any(nm["name"] == "SaveButton" and nm["type"] == "Button"
for nm in d["named_elements"]),
f"x:Name not captured: {d['named_elements']}")

# Explicit-wrapper resource block must keep its view-local scope, not "root".
explicit = (f'<UserControl {_WPF_NS}>\n'
Expand Down
Loading
Loading