Files
Matthew Knutson 953fd7d5f8 Typed parameter contracts and JSON Schemas for solid.box and solid.extrude (#2829) (#2611)
* Spike: typed parameter contracts and JSON Schemas for solid.box and solid.extrude

The schema RFC's spike (plan/schema-spike.md). solid.box and solid.extrude get
typed contracts with generated JSON Schemas (2020-12), checked in front of the
unchanged handlers by a per-session level: off (the default, which changes
nothing), warn (the call runs and gains param_warnings) or strict (the call is
refused and the design left alone). Also engine.describe and the MCP tool
describe_command, `solidcraft-cli mcp --param-check off|warn|strict`, a
FeatureResult type for feature replies, and an env-var audit recorder
(SOLIDCRAFT_PARAM_AUDIT) used to check the contracts against every test.

Local spike branch; not for upstream as is (see the results report).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Parameter contracts: put the audit recorder behind a feature; drop two ignored test keys

The audit recorder is now on only in a build with the engine's `param-audit` feature
and `SOLIDCRAFT_PARAM_AUDIT` set. Its code is still compiled, linted and tested without
the feature. `scripts/param_audit.py` summarises a test run's audit logs, and the schema
module's docs give the steps for converting a further command.

Two tests passed keys their command ignores, which the audit found: `position` on
`solid.box` (`hole_face_tests.rs`) and `symmetric` on `solid.extrude` (`face_tests.rs`).
Both are removed, so the tests build what they always did. Comments no longer call
the contracts a spike.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: bherbruck <brennen.herbruck@gmail.com>
2026-10-20 10:45:08 +00:00

89 lines
3.6 KiB
Python

#!/usr/bin/env python3
"""Summarise a parameter-contract audit: the calls that break a command's contract, by issue.
Usage: param_audit.py AUDIT_DIR [OUT_JSON]
AUDIT_DIR is the directory `SOLIDCRAFT_PARAM_AUDIT` named for a test run built with the engine's
`param-audit` feature (see crates/engine/src/schema/record.rs). Each process writes one
audit-<pid>.jsonl, repeating a signature's line as its count grows, so this keeps the highest
count per signature in each file and adds the files up.
The report has three parts:
SUCCEEDS TODAY calls that work now and that strict would refuse: a form the contract is
missing (fix the contract) or a mistake the handler ignores (fix the caller)
ALREADY FAILS calls the handler rejects anyway
REFUSED calls strict refused (tests that run with strict on)
OUT_JSON, when given, receives every signature with its summed count.
"""
import json
import sys
from collections import Counter, defaultdict
from pathlib import Path
def load(audit_dir):
signatures = {}
malformed = []
files = sorted(audit_dir.glob("audit-*.jsonl"))
for f in files:
per_file = {}
for n, line in enumerate(f.read_text(encoding="utf-8", errors="replace").splitlines(), 1):
if not line.strip():
continue
try:
r = json.loads(line)
key = json.dumps([r["command"], r["issues"], r["outcome"], r["source"]])
except (json.JSONDecodeError, KeyError) as e:
malformed.append(f"{f.name}:{n}: {e}")
continue
if key not in per_file or r["count"] > per_file[key]["count"]:
per_file[key] = r
for key, r in per_file.items():
if key in signatures:
signatures[key] = {**signatures[key], "count": signatures[key]["count"] + r["count"]}
else:
signatures[key] = dict(r)
return files, signatures, malformed
def by_issue(records):
counts = Counter()
examples = defaultdict(list)
for r in records:
for issue in r["issues"]:
counts[(r["command"], issue)] += r["count"]
if len(examples[(r["command"], issue)]) < 3:
examples[(r["command"], issue)].append(f"{r['source']}: {r['sample_params'][:300]}")
return counts, examples
def main():
if len(sys.argv) not in (2, 3):
sys.exit(__doc__.split("\n\n")[1])
files, signatures, malformed = load(Path(sys.argv[1]))
records = list(signatures.values())
parts = [
("SUCCEEDS TODAY, STRICT WOULD REFUSE", [r for r in records if r["outcome"] == "ok"], True),
("ALREADY FAILS TODAY", [r for r in records if r["outcome"] not in ("ok", "refused")], False),
("REFUSED BY STRICT", [r for r in records if r["outcome"] == "refused"], False),
]
print(f"files: {len(files)} signatures: {len(signatures)} malformed lines: {len(malformed)}")
for m in malformed[:10]:
print(" malformed:", m)
for label, part, show_examples in parts:
counts, examples = by_issue(part)
print(f"\n== {label}: {len(part)} signatures, at least {sum(r['count'] for r in part)} calls")
for (command, issue), n in counts.most_common():
print(f" {n:>7} {command:14} {issue}")
if show_examples:
for e in examples[(command, issue)]:
print(f" e.g. {e}")
if len(sys.argv) == 3:
out = {"signatures": records, "malformed": malformed}
Path(sys.argv[2]).write_text(json.dumps(out, indent=1), encoding="utf-8")
if __name__ == "__main__":
main()