diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index b08d249..57b65ef 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,15 +69,18 @@ jobs: python-version: "3.12" # GitHub-friendly SARIF (warning+), plus metrics.json / report.md. + # Runs against the committed synthetic fixture, not the stand-only sts_audit + # corpus, so CI stays green (and leaks nothing) once sts_audit is removed. - name: Export audit artifacts - run: PYTHONPATH=. python3 -m report.cli --min-level warning + run: PYTHONPATH=. python3 -m report.cli --min-level warning --findings viz/fixtures/findings.json # Catches Python/JS-template regressions in the dashboards (no unit tests there). + # Points at viz/fixtures (synthetic golden set) for the same reason as above. # The arch dashboard renders empty-state here (graph.json/runtime.json are stand-only). - name: Build dashboards (smoke) run: | set -euo pipefail - python3 viz/build_dashboard.py + python3 viz/build_dashboard.py viz/fixtures python3 viz/build_arch_dashboard.py - name: Upload artifacts diff --git a/.gitignore b/.gitignore index 8ee9426..1ba46f5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,6 +23,7 @@ __pycache__/ # viz generated/vendored artifacts (reproducible via viz/build_dashboard.py) viz/plotly.min.js viz/sts-dashboard.html +viz/arch-dashboard.html viz/preview.png viz/theme-*.png diff --git a/viz/build_dashboard.py b/viz/build_dashboard.py index 831890e..ba00b79 100644 --- a/viz/build_dashboard.py +++ b/viz/build_dashboard.py @@ -603,13 +603,25 @@ def main(argv=None): global STS ap = argparse.ArgumentParser( description="Build the interactive audit dashboard from an audit-output folder.") - ap.add_argument("data_dir", nargs="?", default="artifacts", + ap.add_argument("data_dir", nargs="?", default=None, help="folder with findings.json + health-report.md (default: artifacts). " "A relative path resolves from the repo root.") args = ap.parse_args(argv) - STS = args.data_dir if os.path.isabs(args.data_dir) else os.path.join(ROOT, args.data_dir) - if not os.path.isdir(STS): - ap.error(f"data dir not found: {STS}") + # An EXPLICIT dir must have its inputs (a caller asked to render *that* folder — + # missing files there is an error, so CI's `build_dashboard.py viz/fixtures` fails + # loudly if the fixture is gone). The IMPLICIT default `artifacts/` is empty until a + # stand run populates it, so a missing input there is "no work" — skip cleanly. + explicit = args.data_dir is not None + data_dir = args.data_dir if explicit else "artifacts" + STS = data_dir if os.path.isabs(data_dir) else os.path.join(ROOT, data_dir) + missing = [f for f in ("findings.json", "health-report.md") + if not os.path.isfile(os.path.join(STS, f))] + if missing: + msg = f"{STS} is missing {', '.join(missing)}" + if explicit: + ap.error(msg) + print(f"build_dashboard: {msg} — nothing to render, skipping.") + return data = collect() out = HTML.replace("%PLOTLY%", _plotly_tag()) \ .replace("%DATA%", _json_for_script(data)) \ diff --git a/viz/fixtures/findings.json b/viz/fixtures/findings.json new file mode 100644 index 0000000..8bb24f9 --- /dev/null +++ b/viz/fixtures/findings.json @@ -0,0 +1,30 @@ +{ + "_note": "GOLDEN smoke fixture for the dashboard/report CI (viz/build_dashboard.py + report.cli). Fully SYNTHETIC — invented module/file/handler names, not from any real audited project. Its only job is to exercise the render/export code paths (every tool, tier, source family, category, and OWN leak-shape) so CI catches Python/JS-template regressions without depending on the stand-only sts_audit corpus. Keep it small and stable.", + "coverage": { + "tools": ["own-check", "roslyn", "codeql", "infersharp"], + "total": 14, + "kept": 14, + "suppressed": 0, + "suppressed_by": {}, + "analysis_skipped": 0, + "analysis_skipped_by": {}, + "by_category": {"1": 3, "2": 2, "3": 2, "4": 3, "5": 1, "6": 2, "7": 1}, + "uncategorized_rules": {} + }, + "findings": [ + {"tool": "own-check", "path": "Acme.Portfolio/ViewModels/PortfolioViewModel.cs", "line": 42, "rule": "OWN001", "category": 2, "category_name": "subscription-leak", "resource": "subscription token", "suppressed": false, "suppress_reason": "", "message": "event 'quotes.Updated' is subscribed (handler '(s, e) => Refresh()') but never unsubscribed; its source is an injected dependency whose lifetime is unknown, so it may outlive and keep 'PortfolioViewModel' alive (possible leak) [resource: subscription token]"}, + {"tool": "own-check", "path": "Acme.Portfolio/ViewModels/PortfolioViewModel.cs", "line": 58, "rule": "OWN001", "category": 2, "category_name": "subscription-leak", "resource": "subscription token", "suppressed": false, "suppress_reason": "", "message": "event 'service.Ticked' is subscribed (handler 'OnTicked') but never unsubscribed; its source outlives the view model, so it may keep 'PortfolioViewModel' alive (possible leak) [resource: subscription token]"}, + {"tool": "own-check", "path": "Acme.Portfolio/Data/PortfolioRepository.cs", "line": 19, "rule": "OWN020", "category": 1, "category_name": "idisposable-leak", "resource": "connection", "suppressed": false, "suppress_reason": "", "message": "field '_conn' (DbConnection) is created but never disposed; it holds an unmanaged handle for the lifetime of 'PortfolioRepository' (possible leak) [resource: connection]"}, + {"tool": "own-check", "path": "Acme.Market/Services/QuoteFeed.cs", "line": 77, "rule": "OWN021", "category": 1, "category_name": "idisposable-leak", "resource": "stream", "suppressed": false, "suppress_reason": "", "message": "local 'buffer' (MemoryStream) is created but never disposed; wrap it in a using statement (possible leak) [resource: stream]"}, + {"tool": "own-check", "path": "Acme.Portfolio/Reports/PdfExporter.cs", "line": 61, "rule": "OWN-REGION", "category": 7, "category_name": "region-escape", "resource": "region handle", "suppressed": false, "suppress_reason": "", "message": "a rented buffer escapes its region via the returned closure; ownership is ambiguous and it may never be released (possible leak) [resource: region handle]"}, + {"tool": "roslyn", "path": "Acme.Portfolio/ViewModels/PortfolioViewModel.cs", "line": 33, "rule": "INPC020", "category": 3, "category_name": "inpc-correctness", "resource": "", "suppressed": false, "suppress_reason": "", "message": "prefer a nameof() expression over the string literal for the raised property name"}, + {"tool": "roslyn", "path": "Acme.Portfolio/ViewModels/PositionViewModel.cs", "line": 14, "rule": "INPC003", "category": 3, "category_name": "inpc-correctness", "resource": "", "suppressed": false, "suppress_reason": "", "message": "notify when the 'Quantity' property value changes"}, + {"tool": "roslyn", "path": "Acme.Market/Services/QuoteFeed.cs", "line": 90, "rule": "MA0006", "category": 4, "category_name": "general-quality", "resource": "", "suppressed": false, "suppress_reason": "", "message": "use String.Equals instead of the equality operator for culture-aware comparison"}, + {"tool": "roslyn", "path": "Acme.UI/Controls/GaugeControl.cs", "line": 120, "rule": "WPF0041", "category": 5, "category_name": "wpf-freezable", "resource": "", "suppressed": false, "suppress_reason": "", "message": "set the DataContext in the constructor rather than in a loaded handler"}, + {"tool": "roslyn", "path": "Acme.Portfolio/Data/PortfolioRepository.cs", "line": 48, "rule": "CA2000", "category": 1, "category_name": "idisposable-leak", "resource": "", "suppressed": false, "suppress_reason": "", "message": "call System.IDisposable.Dispose on the object before all references to it are out of scope"}, + {"tool": "roslyn", "path": "Acme.UI/Controls/GaugeControl.cs", "line": 7, "rule": "RCS1037", "category": 4, "category_name": "general-quality", "resource": "", "suppressed": false, "suppress_reason": "", "message": "remove trailing white-space"}, + {"tool": "roslyn", "path": "Acme.Portfolio/Reports/PdfExporter.cs", "line": 25, "rule": "RCS1090", "category": 6, "category_name": "architecture", "resource": "", "suppressed": false, "suppress_reason": "", "message": "add a ConfigureAwait(false) call to the awaited task"}, + {"tool": "codeql", "path": "Acme.Market/Services/QuoteFeed.cs", "line": 132, "rule": "cs/dereferenced-value-may-be-null", "category": 6, "category_name": "architecture", "resource": "", "suppressed": false, "suppress_reason": "", "message": "this expression may be null at this access and would raise a NullReferenceException"}, + {"tool": "infersharp", "path": "Acme.Portfolio/Reports/PdfExporter.cs", "line": 88, "rule": "NULL_DEREFERENCE", "category": 4, "category_name": "general-quality", "resource": "", "suppressed": false, "suppress_reason": "", "message": "object returned by the factory could be null and is dereferenced without a guard"} + ] +} diff --git a/viz/fixtures/health-report.md b/viz/fixtures/health-report.md new file mode 100644 index 0000000..4c0f194 --- /dev/null +++ b/viz/fixtures/health-report.md @@ -0,0 +1,19 @@ +# Acme (synthetic) — health report + +> GOLDEN smoke fixture for `viz/build_dashboard.py`. Fully synthetic; not a real +> project. Mirrors the shape `build_dashboard.py` parses (the summary line below and +> the module pain table) so the dashboard renders in CI without the stand-only +> `sts_audit` corpus. Numbers are illustrative, not a real audit. + +## Summary + +**14 findings** (5 high-confidence, 9 candidate). High-confidence = flagged by ≥2 independent tools at the same spot. + +## Module pain + +| module | pain | findings | high-conf | top category | +|---|---:|---:|---:|---| +| `Acme.Portfolio` | 128.0 | 6 | 3 | subscription-leak | +| `Acme.Portfolio/Reports` | 74.0 | 3 | 1 | architecture | +| `Acme.Market` | 63.0 | 3 | 1 | general-quality | +| `Acme.UI` | 21.0 | 2 | 0 | wpf-freezable |