Skip to content

Commit db6f673

Browse files
PhysShellclaude
andauthored
spec(ownir): add machine-readable JSON Schema, pinned to code with no new dep (#175)
* spec(ownir): add machine-readable JSON Schema, pinned to code with no new dep OwnIR now has a machine grammar to sit alongside the prose spec (spec/OwnIR.md): spec/ownir.schema.json (JSON Schema draft 2020-12, ownir_version as const:0). It mirrors the contract the Python validator (ownlang/ownir.py::load) enforces — envelope, owned-resource records, flow bodies, DI graph, reactive effects — and is shaped for the P-022 `own-ir` Rust crate to generate serde types from: the flow-op vocabulary is a `oneOf` discriminated on `op`, and the enums (resource kinds, DI lifetimes, param effects) are closed. The schema's trigger has fired: a second language-native type set (the Rust crate in #170) is arriving, so a single source both are generated from stops the two hand-maintained validators from drifting. The core cannot import a jsonschema validator (zero-dependency constraint), so instead of validating documents this binds the schema's *vocabulary* to the code's authoritative sets in tests/test_ownir.py: - resourceKind enum == _KNOWN_RESOURCE_KINDS (the load() routing set) - diLifetime enum == di.LIFETIMES - ownir_version const == OWNIR_VERSION - every flowOp const lowers through _lower_flow without the fail-loud "unknown op" raise; a bogus op the schema omits still raises Schema and validator therefore cannot drift without a red build, no dependency added. Registered in spec/README.md; tech-debt register item 3 updated to shipped. 228/228 bridge checks pass; full suite green. * style(test): sort the new ownir imports (ruff I001) The schema-consistency imports (_KNOWN_RESOURCE_KINDS, DI_LIFETIMES) tripped ruff's import-order check on the PR; merge them into the sorted block. * fix(ownir): address Codex review — null parity + bidirectional enum binds Three P2 findings from the Codex review on the schema PR: 1. Null resource types. The reference loaders (Python load(), the Rust own-ir parity tests) accept and preserve `"type": null` — load() only rejects a non-null non-string. The schema made `type`/`source_type` string-only, so it (and generated serde types) would reject documents the pipeline accepts. Widen them to ["string","null"] — maps to Option<String>. `source` widened too (load() never constrained it). 2. paramEffect never pinned. The schema advertised a closed paramEffect enum but the drift test jumped straight from diLifetime to flow ops, so it never bound. Extract the effect tuple in load() to a named authority `_PARAM_EFFECTS` and assert schema.paramEffect == it. 3. Flow-op bind was one-directional. The test only proved schema-ops lower; a handled op the schema forgot stayed green. Extract the lowerer's vocabulary to `_FLOW_OPS` (the _lower_flow `else` now distinguishes "declared-but-unhandled" from "unknown vocabulary") and assert schema.flowOp consts == _FLOW_OPS — both directions. The drive-test still lowers every declared op so a phantom set entry fails. Both _FLOW_OPS and _PARAM_EFFECTS now mirror _KNOWN_RESOURCE_KINDS: a named frozenset used by load()/_lower_flow AND bound to the schema by equality, so schema and validator cannot drift either way. 230/230 bridge checks; ruff + mypy --strict + full suite green. * fix(ownir): add event/handler/lambda to resourceRecord schema CodeRabbit (Major): the resourceRecord schema omitted the event, handler, and lambda fields that every subscription/timer/capture fact carries — event/handler are core identifying fields (finding messages, rendered output) read as strings via sub.get(), and lambda is the bool flag (ownir.py:2171) marking an un-detachable inline-lambda handler. Without them the schema-generated Rust own-ir serde types would drop the fields and lose round-trip fidelity for real facts, undermining the schema's single-source purpose. Added additive/optional, matching code semantics (event/handler string, lambda bool). 230/230 bridge checks; full suite green. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b88f550 commit db6f673

5 files changed

Lines changed: 444 additions & 11 deletions

File tree

docs/notes/tech-debt-register.md

Lines changed: 19 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -122,14 +122,25 @@ The formalization stack, in order of actual protection delivered:
122122
normalized facts for the pinned samples, and feed the same goldens to
123123
`test_ownir.py` so the Python suite also consumes *extractor-produced*
124124
facts, not only hand-written ones.
125-
3. **`spec/OwnIR.md`** ✅ (shipped) **+ `spec/ownir.schema.json`** (JSON Schema
126-
draft 2020-12, `ownir_version` as a `const`) — **the schema's trigger has now
127-
fired** (see the box below). Validate all `tests/fixtures/ownir/*.json` and
128-
each frontend's output against it. The resource-kind enum is **closed** for
129-
*present* values — a present-but-unknown kind is rejected at load (it changes
130-
routing; a new kind bumps `OWNIR_VERSION`), while an *absent* `resource` field
131-
still defaults to `subscription`; the schema should mirror that (enum of known
132-
kinds, field optional). Its job is shape/type/enum guarantees.
125+
3. **`spec/OwnIR.md`** ✅ (shipped) **+ `spec/ownir.schema.json`***(shipped —
126+
JSON Schema draft 2020-12, `ownir_version` as a `const:0`)***the schema's
127+
trigger fired and the machine grammar now exists** (see the box below). It
128+
mirrors the prose spec: the resource-kind enum is **closed** for *present*
129+
values (a present-but-unknown kind is rejected at load — it changes routing;
130+
a new kind bumps `OWNIR_VERSION`), while an *absent* `resource` field still
131+
defaults to `subscription` (the field is optional in the schema); the flow-op
132+
`oneOf` is discriminated on `op` (typify-friendly for the Rust `own-ir`
133+
generation); DI lifetimes and param effects are enums. **The core cannot
134+
import `jsonschema`** (zero-dep constraint), so rather than validate documents
135+
against the schema, `test_ownir.py` pins the schema's *vocabulary* to the
136+
code's authoritative sets — `resourceKind` enum ≡ `_KNOWN_RESOURCE_KINDS`,
137+
`diLifetime` enum ≡ `di.LIFETIMES`, `ownir_version` const ≡ `OWNIR_VERSION`,
138+
and every `flowOp` const lowers through `_lower_flow` without the fail-loud
139+
"unknown op" raise. Schema and validator therefore cannot drift without a red
140+
build, no dependency added. *Still open:* validate the fixtures + each
141+
frontend's actual output against the schema (needs a dependency, so it belongs
142+
in CI/`audit/`, not the core suite — see item 1/N3), and generate the Rust
143+
`serde` types from it in the `own-ir` crate (#170) instead of hand-writing.
133144
4. **A written evolution policy***(now shipped in `spec/OwnIR.md` §2, rules
134145
IR1–IR6)*: additive optional fields / new resource kinds do not bump
135146
`OWNIR_VERSION`; a **new op or changed op semantics does**.

ownlang/ownir.py

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -227,6 +227,22 @@ def _esc_prop(s: str) -> str:
227227
# new kind is a vocabulary change that MUST bump OWNIR_VERSION (spec/OwnIR.md §2).
228228
_KNOWN_RESOURCE_KINDS = frozenset(_RESOURCES) | {"capture", "unresolved-subscription"}
229229

230+
# The complete flow-op vocabulary the lowerer (`_lower_flow`) handles — the single
231+
# authority the `_lower_flow` dispatch, `spec/ownir.schema.json`, and the Rust
232+
# `own-ir` crate must all agree on. Every op here has a branch in `_lower_flow`
233+
# (the `else` there rejects anything NOT in this set as vocabulary skew, and treats
234+
# an op that IS here but reaches `else` as an internal-consistency bug). Adding an
235+
# op is a vocabulary change that MUST bump OWNIR_VERSION (spec/OwnIR.md §2/§5).
236+
_FLOW_OPS = frozenset({
237+
"acquire", "release", "use", "overspan", "return",
238+
"alias_join", "call", "if", "while",
239+
})
240+
241+
# The parameter ownership-effect vocabulary (P-006/2b): a method contract's
242+
# per-parameter effect. Like `_FLOW_OPS`, a closed enum the schema and the Rust
243+
# `ParamEffect` are bound to; an absent effect is inferred from the body.
244+
_PARAM_EFFECTS = frozenset({"consume", "borrow", "borrow_mut", "plain"})
245+
230246
# --- P-004 region escape (the `capture` resource kind) ----------------------
231247
# A `capture` is a tokenless strong subscription routed NOT through the
232248
# acquire/release ownership model but through the lifetime/region engine
@@ -622,10 +638,9 @@ def load(path: str) -> dict[str, Any]:
622638
raise OwnIRError(
623639
f"parameter 'line' must be an integer, got {pl!r}")
624640
peff = p.get("effect")
625-
if peff is not None and peff not in ("consume", "borrow", "borrow_mut",
626-
"plain"):
641+
if peff is not None and peff not in _PARAM_EFFECTS:
627642
raise OwnIRError(
628-
f"parameter 'effect' must be consume/borrow/borrow_mut/plain, "
643+
f"parameter 'effect' must be one of {sorted(_PARAM_EFFECTS)}, "
629644
f"got {peff!r}")
630645
return result
631646

@@ -1868,6 +1883,14 @@ def _lower_flow(nodes: list[Any], ffile: str, fname: str,
18681883
"ever_released": result in released_vars,
18691884
"pool": False}
18701885
body.append(Let(handle, Acquire("Disposable", [], line), line))
1886+
elif op in _FLOW_OPS:
1887+
# In `_FLOW_OPS` (the declared vocabulary) but no branch above handled
1888+
# it — an internal-consistency bug: the op was added to the authority set
1889+
# and the schema without a matching lowering here. Fail loudly so the
1890+
# gap surfaces in dev, not as a silently dropped obligation.
1891+
raise OwnIRError(
1892+
f"OwnIR flow op {op!r} is declared in _FLOW_OPS but has no lowering "
1893+
f"in _lower_flow ({ffile}:{line}) — internal core inconsistency")
18711894
else:
18721895
# Fail loud on an op this core cannot lower. Silently skipping it would
18731896
# drop the acquire/release facts nested inside it — fabricating a leak (a

spec/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ stop aspirational docs from lying about the code.
1515
| [Diagnostics.md](Diagnostics.md) | every OWN code, grouped, linked to the rule that raises it |
1616
| [CodegenContract.md](CodegenContract.md) | the checker↔codegen contract C1–C4, lowering modes |
1717
| [OwnIR.md](OwnIR.md) | the frontend↔core fact seam (JSON): envelope, versioning + evolution policy, resource-kind + flow-op vocabulary, DI graph, rules IR1–IR6 |
18+
| [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` |
1819
| [CLI.md](CLI.md) | the `check` / `emit` / `cfg` / `report` commands |
1920

2021
## Spec ↔ tests (conformance)

spec/ownir.schema.json

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
{
2+
"$schema": "https://json-schema.org/draft/2020-12/schema",
3+
"$id": "https://physshell.dev/spec/ownir.schema.json",
4+
"title": "OwnIR",
5+
"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.",
6+
"type": "object",
7+
"required": ["ownir_version", "module"],
8+
"properties": {
9+
"ownir_version": {
10+
"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.",
11+
"const": 0
12+
},
13+
"module": {
14+
"description": "The extracted module/assembly name.",
15+
"type": "string"
16+
},
17+
"components": {
18+
"description": "Owned-resource records grouped by the component (class) that owns them (spec/OwnIR.md §4).",
19+
"type": "array",
20+
"items": { "$ref": "#/$defs/component" }
21+
},
22+
"functions": {
23+
"description": "Per-method intra-procedural flow bodies — the CFG facts the core lowers to acquire/use/release (spec/OwnIR.md §5).",
24+
"type": "array",
25+
"items": { "$ref": "#/$defs/function" }
26+
},
27+
"services": {
28+
"description": "The DI registration graph feeding the DI001 captive-dependency check (spec/OwnIR.md §6).",
29+
"type": "array",
30+
"items": { "$ref": "#/$defs/service" }
31+
},
32+
"effects": {
33+
"description": "The reactive-effect graph feeding the EFF001 effect-storm check (spec/OwnIR.md §7).",
34+
"type": "array",
35+
"items": { "$ref": "#/$defs/effect" }
36+
}
37+
},
38+
"$defs": {
39+
"resourceKind": {
40+
"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.",
41+
"type": "string",
42+
"enum": [
43+
"subscription",
44+
"subscribe",
45+
"timer",
46+
"disposable",
47+
"local-disposable",
48+
"pool",
49+
"capture",
50+
"unresolved-subscription"
51+
]
52+
},
53+
"diLifetime": {
54+
"description": "A DI registration lifetime (spec/OwnIR.md §6). Pinned to ownlang/di.py::LIFETIMES.",
55+
"type": "string",
56+
"enum": ["singleton", "scoped", "transient"]
57+
},
58+
"paramEffect": {
59+
"description": "A parameter's ownership effect in a method contract (P-006/2b). Pinned to the enum in ownlang/ownir.py::load().",
60+
"type": "string",
61+
"enum": ["consume", "borrow", "borrow_mut", "plain"]
62+
},
63+
"site": {
64+
"description": "A {type, file, line} call-site record (DI004/DI005 metadata).",
65+
"type": "object",
66+
"required": ["type", "file", "line"],
67+
"properties": {
68+
"type": { "type": "string" },
69+
"file": { "type": "string" },
70+
"line": { "type": "integer" }
71+
}
72+
},
73+
"component": {
74+
"type": "object",
75+
"properties": {
76+
"name": { "type": "string" },
77+
"file": { "type": "string" },
78+
"subscriptions": {
79+
"description": "The component's owned-resource records (historically keyed `subscriptions`).",
80+
"type": "array",
81+
"items": { "$ref": "#/$defs/resourceRecord" }
82+
}
83+
}
84+
},
85+
"resourceRecord": {
86+
"description": "One owned-resource record (spec/OwnIR.md §4). An unreleased record is OWN001 at `line`; a released one nets balanced and stays silent.",
87+
"type": "object",
88+
"properties": {
89+
"line": { "type": "integer" },
90+
"event": {
91+
"description": "The event/handle identifier for this owned resource (e.g. `bus.CustomerChanged`, `_timer.Tick`); carried into the finding message and rendered output.",
92+
"type": "string"
93+
},
94+
"handler": {
95+
"description": "The subscribing handler's name (e.g. `OnCustomerChanged`); may be empty for a tokenless subscribe/capture.",
96+
"type": "string"
97+
},
98+
"lambda": {
99+
"description": "Whether the handler is an inline lambda — no `-=` handle exists to detach it, so the subscription can never be released.",
100+
"type": "boolean"
101+
},
102+
"released": {
103+
"description": "Whether a matching release (`-=`, Dispose, Stop, Return) was found.",
104+
"type": "boolean"
105+
},
106+
"resource": { "$ref": "#/$defs/resourceKind" },
107+
"type": {
108+
"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<String> in the Rust `own-ir` types.",
109+
"type": ["string", "null"]
110+
},
111+
"source": {
112+
"description": "Lifetime tier of a subscription/subscribe/capture source: self (silent cycle), injected (OWN001 warning, may escalate via §6), or static/external/unknown (leak).",
113+
"type": ["string", "null"]
114+
},
115+
"source_type": {
116+
"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<String>).",
117+
"type": ["string", "null"]
118+
}
119+
}
120+
},
121+
"function": {
122+
"type": "object",
123+
"properties": {
124+
"name": { "type": "string" },
125+
"file": { "type": "string" },
126+
"params": {
127+
"description": "The method's ownership contract: its parameters and their effects (P-006/2b). Optional — an omitted contract is inferred from the body.",
128+
"type": "array",
129+
"items": { "$ref": "#/$defs/param" }
130+
},
131+
"body": {
132+
"description": "An ordered list of flow ops modelling the method's intra-procedural CFG.",
133+
"type": "array",
134+
"items": { "$ref": "#/$defs/flowOp" }
135+
}
136+
}
137+
},
138+
"param": {
139+
"type": "object",
140+
"required": ["name"],
141+
"properties": {
142+
"name": { "type": "string" },
143+
"line": { "type": "integer" },
144+
"effect": { "$ref": "#/$defs/paramEffect" }
145+
}
146+
},
147+
"flowOp": {
148+
"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.",
149+
"type": "object",
150+
"required": ["op"],
151+
"oneOf": [
152+
{
153+
"title": "acquire",
154+
"description": "A new owned local (Let+Acquire); kind:\"pool\" tags it a pooled buffer.",
155+
"properties": {
156+
"op": { "const": "acquire" },
157+
"line": { "type": "integer" },
158+
"var": { "type": "string" },
159+
"kind": { "type": "string", "enum": ["pool"] }
160+
},
161+
"required": ["op", "var"]
162+
},
163+
{
164+
"title": "release",
165+
"description": "Release of the local's handle.",
166+
"properties": {
167+
"op": { "const": "release" },
168+
"line": { "type": "integer" },
169+
"var": { "type": "string" }
170+
},
171+
"required": ["op", "var"]
172+
},
173+
{
174+
"title": "use",
175+
"description": "Use of the handle.",
176+
"properties": {
177+
"op": { "const": "use" },
178+
"line": { "type": "integer" },
179+
"var": { "type": "string" }
180+
},
181+
"required": ["op", "var"]
182+
},
183+
{
184+
"title": "overspan",
185+
"description": "Overspan (POOL005: a full-length view of a pooled buffer).",
186+
"properties": {
187+
"op": { "const": "overspan" },
188+
"line": { "type": "integer" },
189+
"var": { "type": "string" }
190+
},
191+
"required": ["op", "var"]
192+
},
193+
{
194+
"title": "return",
195+
"description": "Ownership transfer out; `var` optional (a bare return).",
196+
"properties": {
197+
"op": { "const": "return" },
198+
"line": { "type": "integer" },
199+
"var": { "type": "string" }
200+
},
201+
"required": ["op"]
202+
},
203+
{
204+
"title": "alias_join",
205+
"description": "A new owning handle joined to `src`'s alias set (wrap/adopt, D5.4).",
206+
"properties": {
207+
"op": { "const": "alias_join" },
208+
"line": { "type": "integer" },
209+
"var": { "type": "string" },
210+
"src": { "type": "string" }
211+
},
212+
"required": ["op", "var", "src"]
213+
},
214+
{
215+
"title": "call",
216+
"description": "A Call checked against the callee's contract; a fresh-returning callee mints an acquire for `result` (D5.2).",
217+
"properties": {
218+
"op": { "const": "call" },
219+
"line": { "type": "integer" },
220+
"callee": { "type": "string" },
221+
"args": { "type": "array", "items": { "type": "string" } },
222+
"result": { "type": "string" }
223+
},
224+
"required": ["op", "callee"]
225+
},
226+
{
227+
"title": "if",
228+
"description": "An If with both branches lowered.",
229+
"properties": {
230+
"op": { "const": "if" },
231+
"line": { "type": "integer" },
232+
"then": { "type": "array", "items": { "$ref": "#/$defs/flowOp" } },
233+
"else": { "type": "array", "items": { "$ref": "#/$defs/flowOp" } }
234+
},
235+
"required": ["op"]
236+
},
237+
{
238+
"title": "while",
239+
"description": "A While — a back-edge the core's worklist fixpoint converges over (A1).",
240+
"properties": {
241+
"op": { "const": "while" },
242+
"line": { "type": "integer" },
243+
"body": { "type": "array", "items": { "$ref": "#/$defs/flowOp" } }
244+
},
245+
"required": ["op"]
246+
}
247+
]
248+
},
249+
"service": {
250+
"type": "object",
251+
"required": ["name", "lifetime"],
252+
"properties": {
253+
"name": { "type": "string", "minLength": 1 },
254+
"lifetime": { "$ref": "#/$defs/diLifetime" },
255+
"deps": { "type": "array", "items": { "type": "string" } },
256+
"weak_deps": { "type": "array", "items": { "type": "string" } },
257+
"root_resolves": { "type": "array", "items": { "type": "string" } },
258+
"file": { "type": "string" },
259+
"line": { "type": "integer" },
260+
"ctor_file": { "type": "string" },
261+
"ctor_line": { "type": "integer" },
262+
"ctor_type": { "type": "string" },
263+
"root_resolve_sites": { "type": "array", "items": { "$ref": "#/$defs/site" } },
264+
"scope_cached": { "type": "array", "items": { "type": "string" } },
265+
"scope_cache_sites": { "type": "array", "items": { "$ref": "#/$defs/site" } }
266+
}
267+
},
268+
"effect": {
269+
"type": "object",
270+
"properties": {
271+
"io": {
272+
"description": "Whether the effect performs I/O (default false).",
273+
"type": "boolean"
274+
},
275+
"line": { "type": "integer" },
276+
"deps": {
277+
"description": "The effect's dependency-array names.",
278+
"type": "array",
279+
"items": { "type": "string" }
280+
},
281+
"bindings": {
282+
"description": "The render-scope binding table; the core decides identity stability, not the frontend.",
283+
"type": "array",
284+
"items": { "$ref": "#/$defs/binding" }
285+
}
286+
}
287+
},
288+
"binding": {
289+
"type": "object",
290+
"properties": {
291+
"name": { "type": "string" },
292+
"init": {
293+
"description": "How the binding is initialised (e.g. useState, useMemo); default \"unknown\".",
294+
"type": "string"
295+
},
296+
"refs": { "type": "array", "items": { "type": "string" } },
297+
"line": { "type": "integer" }
298+
}
299+
}
300+
}
301+
}

0 commit comments

Comments
 (0)