Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 19 additions & 8 deletions docs/notes/tech-debt-register.md
Original file line number Diff line number Diff line change
Expand Up @@ -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**.
Expand Down
29 changes: 26 additions & 3 deletions ownlang/ownir.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions spec/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
301 changes: 301 additions & 0 deletions spec/ownir.schema.json
Original file line number Diff line number Diff line change
@@ -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<String> 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<String>).",
"type": ["string", "null"]
}
}
},
Comment thread
coderabbitai[bot] marked this conversation as resolved.
"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" }
}
}
}
}
Loading
Loading