diff --git a/docs/notes/tech-debt-register.md b/docs/notes/tech-debt-register.md index 82232b16..e519d7d8 100644 --- a/docs/notes/tech-debt-register.md +++ b/docs/notes/tech-debt-register.md @@ -122,14 +122,25 @@ The formalization stack, in order of actual protection delivered: normalized facts for the pinned samples, and feed the same goldens to `test_ownir.py` so the Python suite also consumes *extractor-produced* facts, not only hand-written ones. -3. **`spec/OwnIR.md`** ✅ (shipped) **+ `spec/ownir.schema.json`** (JSON Schema - draft 2020-12, `ownir_version` as a `const`) — **the schema's trigger has now - fired** (see the box below). Validate all `tests/fixtures/ownir/*.json` and - each frontend's output against it. The resource-kind enum is **closed** for - *present* values — a present-but-unknown kind is rejected at load (it changes - routing; a new kind bumps `OWNIR_VERSION`), while an *absent* `resource` field - still defaults to `subscription`; the schema should mirror that (enum of known - kinds, field optional). Its job is shape/type/enum guarantees. +3. **`spec/OwnIR.md`** ✅ (shipped) **+ `spec/ownir.schema.json`** ✅ *(shipped — + JSON Schema draft 2020-12, `ownir_version` as a `const:0`)* — **the schema's + trigger fired and the machine grammar now exists** (see the box below). It + mirrors the prose spec: the resource-kind enum is **closed** for *present* + values (a present-but-unknown kind is rejected at load — it changes routing; + a new kind bumps `OWNIR_VERSION`), while an *absent* `resource` field still + defaults to `subscription` (the field is optional in the schema); the flow-op + `oneOf` is discriminated on `op` (typify-friendly for the Rust `own-ir` + generation); DI lifetimes and param effects are enums. **The core cannot + import `jsonschema`** (zero-dep constraint), so rather than validate documents + against the schema, `test_ownir.py` pins the schema's *vocabulary* to the + code's authoritative sets — `resourceKind` enum ≡ `_KNOWN_RESOURCE_KINDS`, + `diLifetime` enum ≡ `di.LIFETIMES`, `ownir_version` const ≡ `OWNIR_VERSION`, + and every `flowOp` const lowers through `_lower_flow` without the fail-loud + "unknown op" raise. Schema and validator therefore cannot drift without a red + build, no dependency added. *Still open:* validate the fixtures + each + frontend's actual output against the schema (needs a dependency, so it belongs + in CI/`audit/`, not the core suite — see item 1/N3), and generate the Rust + `serde` types from it in the `own-ir` crate (#170) instead of hand-writing. 4. **A written evolution policy** ✅ *(now shipped in `spec/OwnIR.md` §2, rules IR1–IR6)*: additive optional fields / new resource kinds do not bump `OWNIR_VERSION`; a **new op or changed op semantics does**. diff --git a/ownlang/ownir.py b/ownlang/ownir.py index f8e755cd..ff6cac15 100644 --- a/ownlang/ownir.py +++ b/ownlang/ownir.py @@ -227,6 +227,22 @@ def _esc_prop(s: str) -> str: # new kind is a vocabulary change that MUST bump OWNIR_VERSION (spec/OwnIR.md §2). _KNOWN_RESOURCE_KINDS = frozenset(_RESOURCES) | {"capture", "unresolved-subscription"} +# The complete flow-op vocabulary the lowerer (`_lower_flow`) handles — the single +# authority the `_lower_flow` dispatch, `spec/ownir.schema.json`, and the Rust +# `own-ir` crate must all agree on. Every op here has a branch in `_lower_flow` +# (the `else` there rejects anything NOT in this set as vocabulary skew, and treats +# an op that IS here but reaches `else` as an internal-consistency bug). Adding an +# op is a vocabulary change that MUST bump OWNIR_VERSION (spec/OwnIR.md §2/§5). +_FLOW_OPS = frozenset({ + "acquire", "release", "use", "overspan", "return", + "alias_join", "call", "if", "while", +}) + +# The parameter ownership-effect vocabulary (P-006/2b): a method contract's +# per-parameter effect. Like `_FLOW_OPS`, a closed enum the schema and the Rust +# `ParamEffect` are bound to; an absent effect is inferred from the body. +_PARAM_EFFECTS = frozenset({"consume", "borrow", "borrow_mut", "plain"}) + # --- P-004 region escape (the `capture` resource kind) ---------------------- # A `capture` is a tokenless strong subscription routed NOT through the # acquire/release ownership model but through the lifetime/region engine @@ -622,10 +638,9 @@ def load(path: str) -> dict[str, Any]: raise OwnIRError( f"parameter 'line' must be an integer, got {pl!r}") peff = p.get("effect") - if peff is not None and peff not in ("consume", "borrow", "borrow_mut", - "plain"): + if peff is not None and peff not in _PARAM_EFFECTS: raise OwnIRError( - f"parameter 'effect' must be consume/borrow/borrow_mut/plain, " + f"parameter 'effect' must be one of {sorted(_PARAM_EFFECTS)}, " f"got {peff!r}") return result @@ -1868,6 +1883,14 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str, "ever_released": result in released_vars, "pool": False} body.append(Let(handle, Acquire("Disposable", [], line), line)) + elif op in _FLOW_OPS: + # In `_FLOW_OPS` (the declared vocabulary) but no branch above handled + # it — an internal-consistency bug: the op was added to the authority set + # and the schema without a matching lowering here. Fail loudly so the + # gap surfaces in dev, not as a silently dropped obligation. + raise OwnIRError( + f"OwnIR flow op {op!r} is declared in _FLOW_OPS but has no lowering " + f"in _lower_flow ({ffile}:{line}) — internal core inconsistency") else: # Fail loud on an op this core cannot lower. Silently skipping it would # drop the acquire/release facts nested inside it — fabricating a leak (a diff --git a/spec/README.md b/spec/README.md index 38602c91..ceef0d6c 100644 --- a/spec/README.md +++ b/spec/README.md @@ -15,6 +15,7 @@ stop aspirational docs from lying about the code. | [Diagnostics.md](Diagnostics.md) | every OWN code, grouped, linked to the rule that raises it | | [CodegenContract.md](CodegenContract.md) | the checker↔codegen contract C1–C4, lowering modes | | [OwnIR.md](OwnIR.md) | the frontend↔core fact seam (JSON): envelope, versioning + evolution policy, resource-kind + flow-op vocabulary, DI graph, rules IR1–IR6 | +| [ownir.schema.json](ownir.schema.json) | the machine-readable OwnIR schema (JSON Schema 2020-12) — the single source the Python core and the Rust `own-ir` crate are checked against; its enums are pinned to the code's authoritative sets by `tests/test_ownir.py` | | [CLI.md](CLI.md) | the `check` / `emit` / `cfg` / `report` commands | ## Spec ↔ tests (conformance) diff --git a/spec/ownir.schema.json b/spec/ownir.schema.json new file mode 100644 index 00000000..9e500b3b --- /dev/null +++ b/spec/ownir.schema.json @@ -0,0 +1,301 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://physshell.dev/spec/ownir.schema.json", + "title": "OwnIR", + "description": "The OwnIR fact contract: the JSON seam between a language frontend (Roslyn C# extractor, OwnTS, hand-written fixtures) and the OwnLang core. Normative prose lives in spec/OwnIR.md; the Python validator is ownlang/ownir.py::load(). This schema is the single source both the Python core and the Rust `own-ir` crate are checked against — the enums here are pinned to the code's authoritative sets by tests/test_ownir.py (no jsonschema dependency), so schema and code cannot drift.", + "type": "object", + "required": ["ownir_version", "module"], + "properties": { + "ownir_version": { + "description": "OwnIR fact-vocabulary version. Every producer stamps the same integer (OWNIR_VERSION in ownlang/ownir.py); a document whose version differs from the core's is rejected at load (spec/OwnIR.md IR1/IR2). An absent field is read as the current version by the core, but this schema requires it.", + "const": 0 + }, + "module": { + "description": "The extracted module/assembly name.", + "type": "string" + }, + "components": { + "description": "Owned-resource records grouped by the component (class) that owns them (spec/OwnIR.md §4).", + "type": "array", + "items": { "$ref": "#/$defs/component" } + }, + "functions": { + "description": "Per-method intra-procedural flow bodies — the CFG facts the core lowers to acquire/use/release (spec/OwnIR.md §5).", + "type": "array", + "items": { "$ref": "#/$defs/function" } + }, + "services": { + "description": "The DI registration graph feeding the DI001 captive-dependency check (spec/OwnIR.md §6).", + "type": "array", + "items": { "$ref": "#/$defs/service" } + }, + "effects": { + "description": "The reactive-effect graph feeding the EFF001 effect-storm check (spec/OwnIR.md §7).", + "type": "array", + "items": { "$ref": "#/$defs/effect" } + } + }, + "$defs": { + "resourceKind": { + "description": "The resource-kind discriminator (spec/OwnIR.md §4). It selects the analysis path, so a present-but-unknown value is rejected at load (fail-loud) and a new kind must bump OWNIR_VERSION. Pinned to ownlang/ownir.py::_KNOWN_RESOURCE_KINDS.", + "type": "string", + "enum": [ + "subscription", + "subscribe", + "timer", + "disposable", + "local-disposable", + "pool", + "capture", + "unresolved-subscription" + ] + }, + "diLifetime": { + "description": "A DI registration lifetime (spec/OwnIR.md §6). Pinned to ownlang/di.py::LIFETIMES.", + "type": "string", + "enum": ["singleton", "scoped", "transient"] + }, + "paramEffect": { + "description": "A parameter's ownership effect in a method contract (P-006/2b). Pinned to the enum in ownlang/ownir.py::load().", + "type": "string", + "enum": ["consume", "borrow", "borrow_mut", "plain"] + }, + "site": { + "description": "A {type, file, line} call-site record (DI004/DI005 metadata).", + "type": "object", + "required": ["type", "file", "line"], + "properties": { + "type": { "type": "string" }, + "file": { "type": "string" }, + "line": { "type": "integer" } + } + }, + "component": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "file": { "type": "string" }, + "subscriptions": { + "description": "The component's owned-resource records (historically keyed `subscriptions`).", + "type": "array", + "items": { "$ref": "#/$defs/resourceRecord" } + } + } + }, + "resourceRecord": { + "description": "One owned-resource record (spec/OwnIR.md §4). An unreleased record is OWN001 at `line`; a released one nets balanced and stays silent.", + "type": "object", + "properties": { + "line": { "type": "integer" }, + "event": { + "description": "The event/handle identifier for this owned resource (e.g. `bus.CustomerChanged`, `_timer.Tick`); carried into the finding message and rendered output.", + "type": "string" + }, + "handler": { + "description": "The subscribing handler's name (e.g. `OnCustomerChanged`); may be empty for a tokenless subscribe/capture.", + "type": "string" + }, + "lambda": { + "description": "Whether the handler is an inline lambda — no `-=` handle exists to detach it, so the subscription can never be released.", + "type": "boolean" + }, + "released": { + "description": "Whether a matching release (`-=`, Dispose, Stop, Return) was found.", + "type": "boolean" + }, + "resource": { "$ref": "#/$defs/resourceKind" }, + "type": { + "description": "The concrete resource type (optional, additive — an older core reads the record without it). Explicit null is accepted and preserved (ownlang/ownir.py::load only rejects a non-null non-string), so it maps to Option in the Rust `own-ir` types.", + "type": ["string", "null"] + }, + "source": { + "description": "Lifetime tier of a subscription/subscribe/capture source: self (silent cycle), injected (OWN001 warning, may escalate via §6), or static/external/unknown (leak).", + "type": ["string", "null"] + }, + "source_type": { + "description": "The declared type of an injected event source, cross-referenced against `services` to derive its DI lifetime/region (P-006 + P-004). Additive/optional. Explicit null is accepted and preserved (Option).", + "type": ["string", "null"] + } + } + }, + "function": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "file": { "type": "string" }, + "params": { + "description": "The method's ownership contract: its parameters and their effects (P-006/2b). Optional — an omitted contract is inferred from the body.", + "type": "array", + "items": { "$ref": "#/$defs/param" } + }, + "body": { + "description": "An ordered list of flow ops modelling the method's intra-procedural CFG.", + "type": "array", + "items": { "$ref": "#/$defs/flowOp" } + } + } + }, + "param": { + "type": "object", + "required": ["name"], + "properties": { + "name": { "type": "string" }, + "line": { "type": "integer" }, + "effect": { "$ref": "#/$defs/paramEffect" } + } + }, + "flowOp": { + "description": "One flow op (spec/OwnIR.md §5). The `op` discriminator is the complete vocabulary the lowerer (_lower_flow) handles; any other value is rejected at load (fail-loud). Pinned to the branch set in ownlang/ownir.py::_lower_flow.", + "type": "object", + "required": ["op"], + "oneOf": [ + { + "title": "acquire", + "description": "A new owned local (Let+Acquire); kind:\"pool\" tags it a pooled buffer.", + "properties": { + "op": { "const": "acquire" }, + "line": { "type": "integer" }, + "var": { "type": "string" }, + "kind": { "type": "string", "enum": ["pool"] } + }, + "required": ["op", "var"] + }, + { + "title": "release", + "description": "Release of the local's handle.", + "properties": { + "op": { "const": "release" }, + "line": { "type": "integer" }, + "var": { "type": "string" } + }, + "required": ["op", "var"] + }, + { + "title": "use", + "description": "Use of the handle.", + "properties": { + "op": { "const": "use" }, + "line": { "type": "integer" }, + "var": { "type": "string" } + }, + "required": ["op", "var"] + }, + { + "title": "overspan", + "description": "Overspan (POOL005: a full-length view of a pooled buffer).", + "properties": { + "op": { "const": "overspan" }, + "line": { "type": "integer" }, + "var": { "type": "string" } + }, + "required": ["op", "var"] + }, + { + "title": "return", + "description": "Ownership transfer out; `var` optional (a bare return).", + "properties": { + "op": { "const": "return" }, + "line": { "type": "integer" }, + "var": { "type": "string" } + }, + "required": ["op"] + }, + { + "title": "alias_join", + "description": "A new owning handle joined to `src`'s alias set (wrap/adopt, D5.4).", + "properties": { + "op": { "const": "alias_join" }, + "line": { "type": "integer" }, + "var": { "type": "string" }, + "src": { "type": "string" } + }, + "required": ["op", "var", "src"] + }, + { + "title": "call", + "description": "A Call checked against the callee's contract; a fresh-returning callee mints an acquire for `result` (D5.2).", + "properties": { + "op": { "const": "call" }, + "line": { "type": "integer" }, + "callee": { "type": "string" }, + "args": { "type": "array", "items": { "type": "string" } }, + "result": { "type": "string" } + }, + "required": ["op", "callee"] + }, + { + "title": "if", + "description": "An If with both branches lowered.", + "properties": { + "op": { "const": "if" }, + "line": { "type": "integer" }, + "then": { "type": "array", "items": { "$ref": "#/$defs/flowOp" } }, + "else": { "type": "array", "items": { "$ref": "#/$defs/flowOp" } } + }, + "required": ["op"] + }, + { + "title": "while", + "description": "A While — a back-edge the core's worklist fixpoint converges over (A1).", + "properties": { + "op": { "const": "while" }, + "line": { "type": "integer" }, + "body": { "type": "array", "items": { "$ref": "#/$defs/flowOp" } } + }, + "required": ["op"] + } + ] + }, + "service": { + "type": "object", + "required": ["name", "lifetime"], + "properties": { + "name": { "type": "string", "minLength": 1 }, + "lifetime": { "$ref": "#/$defs/diLifetime" }, + "deps": { "type": "array", "items": { "type": "string" } }, + "weak_deps": { "type": "array", "items": { "type": "string" } }, + "root_resolves": { "type": "array", "items": { "type": "string" } }, + "file": { "type": "string" }, + "line": { "type": "integer" }, + "ctor_file": { "type": "string" }, + "ctor_line": { "type": "integer" }, + "ctor_type": { "type": "string" }, + "root_resolve_sites": { "type": "array", "items": { "$ref": "#/$defs/site" } }, + "scope_cached": { "type": "array", "items": { "type": "string" } }, + "scope_cache_sites": { "type": "array", "items": { "$ref": "#/$defs/site" } } + } + }, + "effect": { + "type": "object", + "properties": { + "io": { + "description": "Whether the effect performs I/O (default false).", + "type": "boolean" + }, + "line": { "type": "integer" }, + "deps": { + "description": "The effect's dependency-array names.", + "type": "array", + "items": { "type": "string" } + }, + "bindings": { + "description": "The render-scope binding table; the core decides identity stability, not the frontend.", + "type": "array", + "items": { "$ref": "#/$defs/binding" } + } + } + }, + "binding": { + "type": "object", + "properties": { + "name": { "type": "string" }, + "init": { + "description": "How the binding is initialised (e.g. useState, useMemo); default \"unknown\".", + "type": "string" + }, + "refs": { "type": "array", "items": { "type": "string" } }, + "line": { "type": "integer" } + } + } + } +} diff --git a/tests/test_ownir.py b/tests/test_ownir.py index ad41320d..55f996f1 100644 --- a/tests/test_ownir.py +++ b/tests/test_ownir.py @@ -26,8 +26,12 @@ import tempfile +from ownlang.di import LIFETIMES as DI_LIFETIMES from ownlang.diagnostics import TITLES from ownlang.ownir import ( + _FLOW_OPS, + _KNOWN_RESOURCE_KINDS, + _PARAM_EFFECTS, OWNIR_VERSION, Finding, OwnIRError, @@ -289,6 +293,99 @@ def _sub(source: str | None) -> list[Finding]: fails.append(f"{_label}: ownir_version {_m.group(1)} != core OWNIR_VERSION " f"{OWNIR_VERSION} — bump every producer together") + # --- Schema <-> code binding (spec/ownir.schema.json). The JSON Schema is the + # single source the Python core and the Rust `own-ir` crate (P-022) are both + # checked against, but the core cannot import a jsonschema validator (the + # zero-dependency constraint). So instead of validating documents against the + # schema, we pin the schema's *vocabulary* to the code's authoritative sets: + # the enums and the version const cannot drift out from under the validator + # (ownlang/ownir.py::load) without reddening this build. When the schema grows + # a new enum value the code doesn't know — or vice-versa — this fires. + _schema_path = os.path.join(_repo, "spec", "ownir.schema.json") + checks += 1 + try: + with open(_schema_path, encoding="utf-8") as _f: + _schema = json.load(_f) + except (OSError, json.JSONDecodeError) as _e: + fails.append(f"spec/ownir.schema.json unreadable/invalid: {_e}") + _schema = None + if _schema is not None: + _defs = _schema.get("$defs", {}) + # 1) ownir_version const == core OWNIR_VERSION + checks += 1 + _sv = _schema.get("properties", {}).get("ownir_version", {}).get("const") + if _sv != OWNIR_VERSION: + fails.append(f"schema ownir_version const {_sv!r} != core OWNIR_VERSION " + f"{OWNIR_VERSION}") + # 2) resourceKind enum == _KNOWN_RESOURCE_KINDS (the load() routing authority) + checks += 1 + _sk = set(_defs.get("resourceKind", {}).get("enum", [])) + if _sk != set(_KNOWN_RESOURCE_KINDS): + fails.append(f"schema resourceKind enum {sorted(_sk)} != code " + f"_KNOWN_RESOURCE_KINDS {sorted(_KNOWN_RESOURCE_KINDS)}") + # 3) diLifetime enum == di.LIFETIMES (the service-lifetime authority) + checks += 1 + _sl = set(_defs.get("diLifetime", {}).get("enum", [])) + if _sl != set(DI_LIFETIMES): + fails.append(f"schema diLifetime enum {sorted(_sl)} != code " + f"di.LIFETIMES {sorted(DI_LIFETIMES)}") + # 3b) paramEffect enum == _PARAM_EFFECTS (the load() contract-effect authority) + checks += 1 + _se = set(_defs.get("paramEffect", {}).get("enum", [])) + if _se != set(_PARAM_EFFECTS): + fails.append(f"schema paramEffect enum {sorted(_se)} != code " + f"_PARAM_EFFECTS {sorted(_PARAM_EFFECTS)}") + # 4) flowOp discriminator consts. `_FLOW_OPS` is the lowerer's authoritative + # op set (the _lower_flow `else` rejects anything outside it as vocabulary + # skew). Bind the schema to it BOTH ways: (a) the schema's oneOf consts must + # EQUAL _FLOW_OPS — so a handled op the schema forgot, or a schema op the + # lowerer never gained, both redden this; and (b) drive every op through the + # lowerer so a phantom set entry (declared but unlowered) still fails. The + # two together close the direction Codex flagged: the schema cannot lag the + # lowerer's op vocabulary. + _ops = [b.get("properties", {}).get("op", {}).get("const") + for b in _defs.get("flowOp", {}).get("oneOf", [])] + checks += 1 + if None in _ops or len(_ops) != len(set(_ops)): + fails.append(f"schema flowOp oneOf has missing/duplicate op consts: {_ops}") + checks += 1 + if set(_ops) != set(_FLOW_OPS): + fails.append(f"schema flowOp consts {sorted(x for x in _ops if x)} != code " + f"_FLOW_OPS {sorted(_FLOW_OPS)} — op-vocabulary drift") + for _op in sorted(_FLOW_OPS): + checks += 1 + # a minimal, self-consistent body for each op (compound ops carry empty + # sub-bodies; value ops carry a var/callee). A declared op that fails to + # lower (unknown-op OR the declared-but-unhandled internal raise) is a + # phantom authority entry — the set claims an op the lowerer cannot handle. + _node = {"op": _op, "line": 1} + if _op in ("acquire", "release", "use", "overspan", "alias_join"): + _node["var"] = "x" + if _op == "alias_join": + _node["src"] = "x" + if _op == "call": + _node["callee"] = "f" + _facts = {"ownir_version": OWNIR_VERSION, "module": "S", + "functions": [{"name": "m", "file": "m.cs", "body": [_node]}]} + try: + check_facts(_facts) + except OwnIRError as _e: + if ("unknown OwnIR flow op" in str(_e) + or "no lowering in _lower_flow" in str(_e)): + fails.append(f"_FLOW_OPS lists {_op!r} but _lower_flow does not " + f"handle it ({_e})") + # the guard is live: an op NOT in _FLOW_OPS is rejected as unknown vocabulary + # (the raise fires during lowering, so drive it through check_facts). + checks += 1 + _bogus = {"ownir_version": OWNIR_VERSION, "module": "S", + "functions": [{"name": "m", "file": "m.cs", + "body": [{"op": "try", "line": 1}]}]} + try: + check_facts(_bogus) + fails.append("an unknown flow op ('try') was not rejected — fail-loud guard dead") + except OwnIRError: + pass + # --- WPF002 timer profile: a started timer never stopped/detached leaks, # a stopped one stays silent, and the finding is tagged [resource: timer]. with open(_TIMER_FIXTURE, encoding="utf-8") as f: