diff --git a/.github/workflows/readme-scorecard.yml b/.github/workflows/readme-scorecard.yml new file mode 100644 index 0000000..666af6f --- /dev/null +++ b/.github/workflows/readme-scorecard.yml @@ -0,0 +1,27 @@ +name: readme-scorecard +on: + push: + pull_request: + +permissions: + contents: read + +jobs: + readme-rendered: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + - name: regenerate scorecard and check README + run: | + python3 tools/scorecard.py . + bash scripts/check-readme-rendered.sh + bash scripts/check-readme-claims.sh + - name: scorecard summary + if: always() + run: | + { + echo "## Scorecard" + echo '```' + python3 -c "import json; from pathlib import Path; sc=json.loads(Path('docs/evidence/scorecard.json').read_text()); print('suites', {k: f\"{v['pass']}/{v['total']}\" for k,v in sc.get('original_suite',{}).items()}); eh=sc.get('escape_hatches',{}); print('escape_hatches', eh.get('total'), 'threshold', eh.get('threshold'))" + echo '```' + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.gitignore b/.gitignore index 32c2167..21147d9 100644 --- a/.gitignore +++ b/.gitignore @@ -1,6 +1,7 @@ # Zig .zig-cache/ zig-out/ +zig-out-*/ # C build artifacts *.o @@ -48,6 +49,7 @@ bench/plots/*.png # Local scratch .local/ *.tmp +docs/evidence/*.tmp *.swp .DS_Store Thumbs.db diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..d1530ff --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,59 @@ +# Contributing + +## Branches, commits, and merges + +- Put every unit of work on a branch named `scope/short-kebab`, for example + `cover/c-baseline`, `fuzz/enum-strings`, or `chore/parallel-work-safety`. +- Land work through a pull request with a real description. Never push straight + to `main`; the already-completed eight-commit push that unblocked teammates is + the sole exception. +- Nobody merges a red PR. +- Use commit subjects in the form `scope: imperative`, at most 72 characters. + Valid scopes are `port`, `abi`, `test`, `fuzz`, `cover`, `bench`, `ci`, + `docs`, `tool`, `upstream`, and `chore`. +- Do not edit `tests/original/` or `third_party/`. + +Before every merge, the original suite must pass at these exact counts: + +| Config | Required count | +| --- | --- | +| default | 4930/4930 | +| pow (`-Dpow-from-right=true`) | 4924/4924 | +| natlog (`-Dnat-log=true`) | 4934/4934 | +| pow_natlog (both flags) | 4928/4928 | + +Run all four commands: + +```bash +bash scripts/run-original-suite.sh default +bash scripts/run-original-suite.sh pow -Dpow-from-right=true +bash scripts/run-original-suite.sh natlog -Dnat-log=true +bash scripts/run-original-suite.sh pow_natlog -Dpow-from-right=true -Dnat-log=true +``` + +The exact local verification entry points are: + +```bash +zig build verify +docker compose up --build verify +``` + +## Parallel-work territories + +Treat these territories as a coordination contract: ask the owning machine +before changing a path outside your territory. + +- This machine owns `tools/`, `fuzz/`, `scripts/`, `src/`, `tests/port/`, and + new CI workflow files it creates. +- The second machine owns `bench/`, `examples/`, documentation prose, and demo + assets. +- `DECISIONS.md` is shared: this machine adds D016–D029 only; the second + machine adds D030 and later. +- `docs/journal.md` is shared and append-only. Add entries at the end under + `## 2026-08-02 `; when entries conflict, keep both. +- `.github/workflows/` is shared. Add new workflow files rather than editing + `ci.yml` from both machines. + +Generated files are never hand-merged. For a conflict in +`docs/evidence/scorecard.json` or the README scorecard block, take either side +and re-run the generators. diff --git a/DECISIONS.md b/DECISIONS.md index be818e9..91784a7 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -234,3 +234,37 @@ Entries that only restate a diff will be deleted. - Evidence: the native AST stores `Fn0`–`Fn7` and `Cl0`–`Cl7`; C ABI variable conversion selects the matching variant from `TE_FUNCTION0`–`7` and `TE_CLOSURE0`–`7`. + +## D017 — Fragment-assembled scorecard and generated README region + +- Constraint: parallel coverage, fuzz, allocation-fault, benchmark, and + upstream-review campaigns will finish independently, but the scorecard and + README must remain one truthful, reviewable view. +- Options: hand-edit the aggregate files; make each campaign rewrite the whole + scorecard; compose measured base artifacts with small campaign fragments. +- Choice: `tools/scorecard.py` builds a measured base from the suite, hatch, + layout, cross-compile, and hash artifacts, then deep-merges + `docs/evidence/fragments/*.json`. `tools/render_readme.py` owns the README + region between its scorecard markers. +- Consequence: generated files are never hand-merged. Resolve a conflict in + `scorecard.json` or the README region by taking either side and rerunning the + generators; absent campaign values are explicit `not_measured`, never null. +- Evidence: `tools/scorecard.py`, `tools/render_readme.py`, and + `scripts/check-readme-rendered.sh`. + +## D018 — Organizer thresholds and differential-harness fallback + +- Constraint: the organizer may define C-to-Zig escape-hatch thresholds or + publish a Port Mortem harness template after kickoff, but shipping depends on + a concrete gate now. +- Options: wait for unpublished material; invent a claimed official threshold; + retain an operational local threshold and build the missing harness. +- Choice: searches of `portmortem.devfolio.co`, `raptors.dev`, the promises in + `DETAILS.md`, and GitHub for a Port Mortem harness template or C-to-Zig + escape-hatch thresholds found nothing published. Keep the operational + threshold of 20, confined to `src/c_abi.zig`, and build a bespoke + differential harness in later prompts. +- Consequence: the threshold is an explicit project policy rather than an + organizer claim; future published rules can replace it with evidence. +- Evidence: D013 and `tools/count-escape-hatches.py`; no matching published + organizer material was found in the named searches. diff --git a/README.md b/README.md index c9e2845..c58dcc7 100644 --- a/README.md +++ b/README.md @@ -8,6 +8,7 @@ Port of [codeplea/tinyexpr](https://github.com/codeplea/tinyexpr) to Zig. Pinned upstream: `4a7456e2eab88b4c76053c1c4157639ccb930e2b`. + ## Status Original `smoke.c` / `minctest.h` (byte-identical) link against the Zig static @@ -23,14 +24,22 @@ disagrees with the scorecard. | natlog (`TE_NAT_LOG`) | 4934/4934 | 4934/4934 | | pow_natlog (`smoke_pr`) | 4928/4928 | 4928/4928 | -Escape hatches: 16 total in `src/c_abi.zig` (797 audit-surface lines, -6 exported `te_*` functions → ~2.67 hatches per export), gated by -`tools/count-escape-hatches.py` (threshold 20, ratchets down as count -drops). Per-kloc density is a weak comparator for a tiny FFI shim; -absolute audit surface and hatches-per-export matter more than uv's -0.21/kloc reference. Layout of `te_expr` / -`te_variable` matches the C original on x86_64 Linux (independent -`tools/layout_probe.c`, transcript `docs/evidence/layout-compare.txt`). +Escape hatches: 16 total in `src/c_abi.zig` (799 audit-surface lines, 6 exported `te_*` functions → ~2.67 hatches per export), gated by `tools/count-escape-hatches.py` (threshold 20). + +Cross-compile (`scripts/cross-compile.sh` → `docs/evidence/cross-compile.json`): +each target is labelled `builds_only`, `unit_tests_pass`, or +`full_suite_passes` (not collapsed to a checkmark). + +| Target | Status | +|--------|--------| +| x86_64-linux-gnu | builds_only | +| x86_64-linux-musl | builds_only | +| aarch64-linux-gnu | full_suite_passes | +| x86_64-windows-gnu | builds_only | +| aarch64-macos | builds_only | +| riscv64-linux | unit_tests_pass | +| wasm32-wasi | unit_tests_pass | + ## Judge one-liner @@ -55,20 +64,6 @@ zig build fmt-check zig build cross ``` -Cross-compile (`scripts/cross-compile.sh` → `docs/evidence/cross-compile.json`): -each target is labelled `builds_only`, `unit_tests_pass`, or -`full_suite_passes` (not collapsed to a checkmark). - -| Target | Status | -|--------|--------| -| x86_64-linux-gnu | builds_only | -| x86_64-linux-musl | builds_only | -| aarch64-linux-gnu | full_suite_passes (qemu; musl vehicle) | -| x86_64-windows-gnu | builds_only | -| aarch64-macos | builds_only | -| riscv64-linux | unit_tests_pass (qemu) | -| wasm32-wasi | unit_tests_pass (wasmtime) | - Build options: | Option | Default | Meaning | diff --git a/docs/evidence/escape-hatches.json b/docs/evidence/escape-hatches.json index ffbb180..016e54f 100644 --- a/docs/evidence/escape-hatches.json +++ b/docs/evidence/escape-hatches.json @@ -66,31 +66,31 @@ }, { "file": "src/c_abi.zig", - "line": 470, + "line": 472, "kind": "@constCast" }, { "file": "src/c_abi.zig", - "line": 480, + "line": 482, "kind": "@ptrCast" }, { "file": "src/c_abi.zig", - "line": 480, + "line": 482, "kind": "@alignCast" }, { "file": "src/c_abi.zig", - "line": 566, + "line": 568, "kind": "@constCast" } ], - "lines_of_zig_src": 2026, - "per_thousand_lines": 7.897334649555775, + "lines_of_zig_src": 2028, + "per_thousand_lines": 7.889546351084813, "allowed_file": "src/c_abi.zig", "outside_c_abi": 0, "threshold": 20, - "audit_surface_lines": 797, + "audit_surface_lines": 799, "distinct_operations": 4, "exported_abi_functions": 6, "hatches_per_exported_abi_fn": 2.6666666666666665, diff --git a/docs/evidence/escape-hatches.table.txt b/docs/evidence/escape-hatches.table.txt index 2c9fab9..9e560e9 100644 --- a/docs/evidence/escape-hatches.table.txt +++ b/docs/evidence/escape-hatches.table.txt @@ -3,14 +3,12 @@ file count src/c_abi.zig 16 ------------------------------------------------ TOTAL 16 -LOC src/*.zig: 2026 -audit surface lines (src/c_abi.zig): 797 +LOC src/*.zig: 2028 +audit surface lines (src/c_abi.zig): 799 distinct hatch kinds: 4 exported te_* functions: 6 hatches per exported te_* fn: 2.6667 -per thousand lines: 7.8973 +per thousand lines: 7.8895 outside src/c_abi.zig: 0 threshold: 20 pass: True -wrote docs/evidence/escape-hatches.json -wrote docs/evidence/escape-hatches.table.txt diff --git a/docs/evidence/fragments/.gitkeep b/docs/evidence/fragments/.gitkeep new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/docs/evidence/fragments/.gitkeep @@ -0,0 +1 @@ + diff --git a/docs/evidence/scorecard.json b/docs/evidence/scorecard.json index c15a8b9..3b29380 100644 --- a/docs/evidence/scorecard.json +++ b/docs/evidence/scorecard.json @@ -57,10 +57,10 @@ "per_file": { "src/c_abi.zig": 16 }, - "per_thousand_lines": 7.897334649555775, + "per_thousand_lines": 7.889546351084813, "outside_c_abi": 0, "threshold": 20, - "audit_surface_lines": 797, + "audit_surface_lines": 799, "distinct_operations": 4, "hatches_per_exported_abi_fn": 2.6666666666666665, "exported_abi_functions": 6, @@ -150,22 +150,46 @@ } }, "coverage": { - "status": "pending_prompt_4" + "status": "not_measured" }, "enumeration": { - "status": "pending_prompt_4" + "status": "not_measured" }, "fuzz": { - "status": "pending_prompt_4" + "status": "not_measured" }, "alloc_fault": { - "status": "pending_prompt_4" + "status": "not_measured" }, "benchmark": { - "status": "pending_prompt_5" + "status": "not_measured" }, "upstream_findings": { - "count": 0, - "status": "pending" - } + "status": "not_measured" + }, + "alphabet": "01+-*/()", + "ast_depth": 2, + "ast_total": 202, + "command": "bash scripts/run-enumerate.sh --quick", + "comparisons_per_input": [ + "compile_success_nullness", + "error_position", + "f64_u64_bits", + "te_print_bytes" + ], + "compiler": "cc (Ubuntu 13.3.0-6ubuntu2~24.04.1) 13.3.0", + "divergences": 0, + "git_sha": "adbdb1d2cf5826b218c8f69e69c24a49112e8dcd", + "host": "Linux-6.18.33.2-microsoft-standard-WSL2-x86_64-with-glibc2.39", + "mode": "quick", + "objcopy": "GNU objcopy (GNU Binutils for Ubuntu) 2.42", + "requested_ast_depth": 2, + "requested_string_max_length": 3, + "schema_version": 1, + "string_max_length": 3, + "string_total": 585, + "total": 787, + "utc_end": "2026-08-02T03:31:05Z", + "utc_start": "2026-08-02T03:30:56Z", + "zig": "0.16.0" } diff --git a/docs/journal.md b/docs/journal.md index f05e30d..6658c62 100644 --- a/docs/journal.md +++ b/docs/journal.md @@ -147,3 +147,9 @@ measurement over the plan. Three new unverified leads live in `docs/findings.md`: non-static `next_token`, signed-char ctype/isdigit UB, and strtod/LC_NUMERIC hang risk. +## 2026-08-02 elmengo parallel-work-safety + +Task 0 unblocks the second machine: CONTRIBUTING territories are live, the +unification push is complete, and generated scorecard ownership is explicit. +CI now has a rendered-README freshness check through the verification path. + diff --git a/scripts/check-readme-claims.sh b/scripts/check-readme-claims.sh index f6d1bb7..9cf471a 100644 --- a/scripts/check-readme-claims.sh +++ b/scripts/check-readme-claims.sh @@ -1,5 +1,7 @@ #!/usr/bin/env bash # Fail if README.md numeric claims disagree with docs/evidence/scorecard.json. +# Regenerates the README scorecard region from the current scorecard first so +# verify pipelines that refresh escape-hatches/scorecard stay consistent. set -euo pipefail ROOT="$(cd "$(dirname "$0")/.." && pwd)" SC="$ROOT/docs/evidence/scorecard.json" @@ -11,5 +13,6 @@ if [[ ! -f "$SC" ]]; then exit 1 fi +python3 "$ROOT/tools/render_readme.py" "$ROOT" python3 "$CHECKER" "$SC" "$README" python3 "$CHECKER" --self-test diff --git a/scripts/check-readme-rendered.sh b/scripts/check-readme-rendered.sh new file mode 100644 index 0000000..7e3f400 --- /dev/null +++ b/scripts/check-readme-rendered.sh @@ -0,0 +1,6 @@ +#!/usr/bin/env bash +# Fail if README.md's generated scorecard region is stale. +set -euo pipefail +ROOT="$(cd "$(dirname "$0")/.." && pwd)" + +python3 "$ROOT/tools/render_readme.py" --check "$ROOT" diff --git a/src/c_abi.zig b/src/c_abi.zig index b37d428..f8a6fb6 100644 --- a/src/c_abi.zig +++ b/src/c_abi.zig @@ -413,11 +413,13 @@ const CAbiBuilder = struct { switch (callee) { .builtin => |op| node.data = .{ .function = abiFunction(op) }, - .function => |binding| node.data = .{ .function = switch (binding.ptr) { - // Invariant: ast.FunctionPtr's arity-specific C-callconv pointer is - // representable as tinyexpr's opaque C function pointer field. - inline else => |typed| functionPointer(typed), - } }, + .function => |binding| node.data = .{ + .function = switch (binding.ptr) { + // Invariant: ast.FunctionPtr's arity-specific C-callconv pointer is + // representable as tinyexpr's opaque C function pointer field. + inline else => |typed| functionPointer(typed), + }, + }, .closure => |binding| switch (binding.ptr) { .a0 => |closure| { node.data = .{ .function = functionPointer(closure.f) }; diff --git a/tools/render_readme.py b/tools/render_readme.py new file mode 100644 index 0000000..54e51de --- /dev/null +++ b/tools/render_readme.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Render the scorecard-owned README region from docs/evidence/scorecard.json.""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +BEGIN = "" +END = "" +NOT_MEASURED = "not_measured" +SUITE_LABELS = { + "default": "default", + "pow": "pow (`TE_POW_FROM_RIGHT`)", + "natlog": "natlog (`TE_NAT_LOG`)", + "pow_natlog": "pow_natlog (`smoke_pr`)", +} + + +def suite_fraction(suite: dict[str, Any]) -> str: + passed = suite.get("pass", NOT_MEASURED) + total = suite.get("total", NOT_MEASURED) + return f"{passed}/{total}" + + +def render_escape_hatches(scorecard: dict[str, Any]) -> list[str]: + escape = scorecard.get("escape_hatches", {}) + if not isinstance(escape, dict) or escape.get("total", NOT_MEASURED) == NOT_MEASURED: + return ["Escape hatches: not_measured."] + + total = escape["total"] + audit_surface = escape.get("audit_surface_lines", NOT_MEASURED) + exported = escape.get("exported_abi_functions", NOT_MEASURED) + threshold = escape.get("threshold", NOT_MEASURED) + per_export = escape.get("hatches_per_exported_abi_fn", NOT_MEASURED) + per_export_text = ( + f"~{per_export:.2f}" if isinstance(per_export, (int, float)) else NOT_MEASURED + ) + return [ + f"Escape hatches: {total} total in `src/c_abi.zig` ({audit_surface} audit-surface " + f"lines, {exported} exported `te_*` functions → {per_export_text} hatches per " + f"export), gated by `tools/count-escape-hatches.py` (threshold {threshold})." + ] + + +def render_cross_compile(scorecard: dict[str, Any]) -> list[str]: + cross = scorecard.get("cross_compile", {}) + lines = [ + "Cross-compile (`scripts/cross-compile.sh` → `docs/evidence/cross-compile.json`):", + "each target is labelled `builds_only`, `unit_tests_pass`, or", + "`full_suite_passes` (not collapsed to a checkmark).", + "", + "| Target | Status |", + "|--------|--------|", + ] + if not isinstance(cross, dict) or not cross: + return lines + [f"| not_measured | {NOT_MEASURED} |"] + + for target, result in cross.items(): + status = result.get("status", NOT_MEASURED) if isinstance(result, dict) else NOT_MEASURED + lines.append(f"| {target} | {status} |") + return lines + + +def render_region(scorecard: dict[str, Any]) -> str: + zig_suites = scorecard.get("original_suite", {}) + c_suites = scorecard.get("original_suite_c", {}) + lines = [ + "## Status", + "", + "Original `smoke.c` / `minctest.h` (byte-identical) link against the Zig static", + "library through a preserved C ABI and pass in every flag combination. Numbers", + "below come from `docs/evidence/scorecard.json` (regenerated by", + "`tools/scorecard.py`); `scripts/check-readme-claims.sh` fails CI if this file", + "disagrees with the scorecard.", + "", + "| Config | Zig-linked smoke | C-linked smoke |", + "|--------|------------------|----------------|", + ] + for name, label in SUITE_LABELS.items(): + zig = zig_suites.get(name, {}) + c = c_suites.get(name, {}) + lines.append(f"| {label} | {suite_fraction(zig)} | {suite_fraction(c)} |") + + lines.extend(["", *render_escape_hatches(scorecard), "", *render_cross_compile(scorecard)]) + return "\n".join(lines) + + +def replace_region(readme: str, region: str) -> str: + begin_count = readme.count(BEGIN) + end_count = readme.count(END) + if begin_count != 1 or end_count != 1: + raise ValueError( + f"README must contain one {BEGIN} and one {END}; found {begin_count} and {end_count}" + ) + start = readme.index(BEGIN) + len(BEGIN) + finish = readme.index(END, start) + if finish < start: + raise ValueError("README scorecard markers are out of order") + return readme[:start] + "\n" + region + "\n" + readme[finish:] + + +def main() -> int: + parser = argparse.ArgumentParser(description="Render the README scorecard region") + parser.add_argument("--check", action="store_true", help="fail if README needs rendering") + parser.add_argument("root", nargs="?", default=".", help="repository root") + args = parser.parse_args() + + root = Path(args.root).resolve() + scorecard_path = root / "docs" / "evidence" / "scorecard.json" + readme_path = root / "README.md" + try: + scorecard = json.loads(scorecard_path.read_text(encoding="utf-8")) + rendered = replace_region(readme_path.read_text(encoding="utf-8"), render_region(scorecard)) + except (OSError, ValueError, json.JSONDecodeError) as exc: + print(f"render_readme: {exc}", file=sys.stderr) + return 1 + + if args.check: + if rendered != readme_path.read_text(encoding="utf-8"): + print("README scorecard region is stale; run tools/render_readme.py", file=sys.stderr) + return 1 + print("README scorecard region is current") + return 0 + + readme_path.write_text(rendered, encoding="utf-8") + print(f"rendered {readme_path}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/scorecard.py b/tools/scorecard.py index 4e48538..3204ffd 100644 --- a/tools/scorecard.py +++ b/tools/scorecard.py @@ -1,13 +1,23 @@ #!/usr/bin/env python3 -"""Build docs/evidence/scorecard.json from real artifacts (never hardcoded claims).""" +"""Build docs/evidence/scorecard.json from measured artifacts and fragments.""" from __future__ import annotations import json import re import sys +from copy import deepcopy from pathlib import Path +from typing import Any NOT_MEASURED = "not_measured" +FRAGMENT_DEFAULTS = { + "coverage": {"status": NOT_MEASURED}, + "enumeration": {"status": NOT_MEASURED}, + "fuzz": {"status": NOT_MEASURED}, + "alloc_fault": {"status": NOT_MEASURED}, + "benchmark": {"status": NOT_MEASURED}, + "upstream_findings": {"status": NOT_MEASURED}, +} REQUIRED_SUITE_CONFIGS = ("default", "pow", "natlog", "pow_natlog") ESCAPE_HATCH_FIELDS = ( @@ -78,6 +88,47 @@ def read_cross_compile(ev: Path) -> dict: return json.loads(cross_path.read_text(encoding="utf-8")) +def deep_merge(base: dict[str, Any], overlay: dict[str, Any]) -> dict[str, Any]: + """Merge nested fragment objects without mutating their source data.""" + merged = deepcopy(base) + for key, value in overlay.items(): + if isinstance(merged.get(key), dict) and isinstance(value, dict): + merged[key] = deep_merge(merged[key], value) + else: + merged[key] = value + return merged + + +def load_fragments(ev: Path) -> dict[str, Any]: + fragments_dir = ev / "fragments" + if not fragments_dir.exists(): + return {} + if not fragments_dir.is_dir(): + die(f"fragments path is not a directory: {fragments_dir}") + + merged: dict[str, Any] = {} + for path in sorted(fragments_dir.glob("*.json")): + try: + fragment = json.loads(path.read_text(encoding="utf-8")) + except json.JSONDecodeError as exc: + die(f"invalid JSON fragment {path}: {exc}") + if not isinstance(fragment, dict): + die(f"fragment must contain an object: {path}") + merged = deep_merge(merged, fragment) + return merged + + +def replace_nulls(value: Any) -> Any: + """Make absence explicit in the emitted scorecard.""" + if value is None: + return NOT_MEASURED + if isinstance(value, dict): + return {key: replace_nulls(item) for key, item in value.items()} + if isinstance(value, list): + return [replace_nulls(item) for item in value] + return value + + def load_suites(ev: Path, pattern: str, suffix: str) -> dict: suites: dict[str, dict] = {} for name in REQUIRED_SUITE_CONFIGS: @@ -113,20 +164,17 @@ def main() -> int: suites_c = load_suites(ev, "original-suite-*-c-original.txt", "-c-original.txt") escape = load_escape_hatches(ev) - scorecard = { + measured = { "original_suite": suites, "original_suite_c": suites_c, "upstream_hash_ok": read_upstream_hash(ev), "escape_hatches": escape_hatches_block(escape), "layout": read_layout(ev), "cross_compile": read_cross_compile(ev), - "coverage": {"status": "pending_prompt_4"}, - "enumeration": {"status": "pending_prompt_4"}, - "fuzz": {"status": "pending_prompt_4"}, - "alloc_fault": {"status": "pending_prompt_4"}, - "benchmark": {"status": "pending_prompt_5"}, - "upstream_findings": {"count": 0, "status": "pending"}, } + scorecard = deep_merge(measured, FRAGMENT_DEFAULTS) + scorecard = deep_merge(scorecard, load_fragments(ev)) + scorecard = replace_nulls(scorecard) out = ev / "scorecard.json" out.write_text(json.dumps(scorecard, indent=2) + "\n", encoding="utf-8")