feat(compiler): doctrine reject rows — WO-E105 (iter 5 strictness)
The verdict table's reject half is enforced: a Haxe habit fails loudly at
its own position with the doctrine reason, instead of a generic syntax
error — or, worst, compiling clean: `return super.f()` used to exit 0 (the
unresolved ident placeholder swallowed it).
- parser.ml: doctrine_reject_reason maps each rejected word to its spec
reason (inheritance quartet -> principle 4; cast; Dynamic/untyped ->
principle 13; macro; extern -> principle 10; operator). Fired at three
chokepoints: `class B extends A` (with skip-to-brace recovery so the body
still parses), an expression head (`super`, `cast 3`, `untyped x`), and a
top-level declaration head (`macro fn`, `extern fn`).
- types.ml: `Dynamic`/`untyped` as a TYPE name keep their WO-E225 site but
carry the doctrine message.
- corpus: compile-fail/{reject-inheritance,reject-cast,reject-dynamic}.
- catalog WO-E105 row; plan 8 Task 8 reject half ticked (#if still open);
board updated.
Verified: woc-test 540/0 + test_diag 14/0; oop-e2e 86/0; log-watcher 7/0;
employee 8/0; legit identifiers (`extended`) untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8f6ff1e865
commit
a43232dc72
11 changed files with 101 additions and 6 deletions
|
|
@ -127,6 +127,29 @@ let fail (st : state) (site : Ast.pos) (code : string) (message : string) : 'a =
|
|||
let syntax_code = Diag.parsing_prefix ^ "01" (* WO-E101: generic syntax error *)
|
||||
let table_code = Diag.parsing_prefix ^ "02" (* WO-E102: invalid @table(...) configuration *)
|
||||
let gc_removed_code = Diag.parsing_prefix ^ "04" (* WO-E104: `@gc` — GC-ness is inferred *)
|
||||
let doctrine_reject_code = Diag.parsing_prefix ^ "05" (* WO-E105: rejected Haxe keyword *)
|
||||
|
||||
(* The systems-track spec's reject rows (Part 1 verdict table), each with its
|
||||
doctrine reason — these words never parse as ordinary identifiers, so a
|
||||
Haxe habit fails loudly at its own position instead of misparsing into a
|
||||
generic syntax error (or, worst, compiling clean: `super.f()` used to). *)
|
||||
let doctrine_reject_reason (w : string) : string option =
|
||||
match w with
|
||||
| "extends" | "implements" | "super" | "override" ->
|
||||
Some "no inheritance, ever — is-a is a tagged union, has-a is composition, polymorphism is structural interfaces (principle 4)"
|
||||
| "cast" ->
|
||||
Some "no unsafe casts — conversions are typed; `as` exists only in the json.decode target position"
|
||||
| "Dynamic" | "untyped" ->
|
||||
Some "static typing all the way to the register — typed `json.decode … as T -> ?T` covers the real use (principle 13)"
|
||||
| "macro" -> Some "macros kill the fast-compile promise — codegen belongs to tooling"
|
||||
| "extern" -> Some "one FFI hole voids the whole memory-safety story — capabilities are audited typed builtins (principle 10)"
|
||||
| "operator" -> Some "one name, one signature — no operator overloading"
|
||||
| _ -> None
|
||||
|
||||
let reject_doctrine_word (st : state) (pos : Ast.pos) (w : string) (reason : string) : unit =
|
||||
Diag.Collector.add st.collector
|
||||
(Diag.error ~code:doctrine_reject_code ~file:st.file ~line:pos.Ast.line ~col:pos.Ast.col
|
||||
~message:(Printf.sprintf "`%s` is rejected: %s" w reason) ())
|
||||
|
||||
(* haxe-parity Task 2: the haxe keyword verdict table's `inline` row —
|
||||
"adopt (values): const compile-time values; inline *functions*
|
||||
|
|
@ -1130,6 +1153,16 @@ and parse_query_expr (st : state) : Ast.expr =
|
|||
|
||||
and parse_primary (st : state) : Ast.expr =
|
||||
match peek st with
|
||||
| Token.Ident w when Option.is_some (doctrine_reject_reason w) ->
|
||||
let pos = peek_pos st in
|
||||
let id = fresh_id st in
|
||||
(match doctrine_reject_reason w with
|
||||
| Some r -> reject_doctrine_word st pos w r
|
||||
| None -> ());
|
||||
ignore (advance st);
|
||||
(* recovery value so downstream parsing continues; the error above is
|
||||
already fatal to the compile *)
|
||||
{ Ast.id; pos; kind = Ast.IntLit 0 }
|
||||
| _ when is_query_trigger st -> parse_query_expr st
|
||||
| Token.Ident "delete" when (match (tok_at st (st.pos + 1)).kind with
|
||||
| Token.Newline | Token.Semicolon | Token.Eof -> false | _ -> true) ->
|
||||
|
|
@ -1591,6 +1624,18 @@ let parse_class_or_type ?(pub = false) (st : state) (ann : type_annotations) : A
|
|||
let is_class = peek st = Token.KwClass in
|
||||
if is_class then ignore (advance st) else expect st Token.KwType "`type` or `class`";
|
||||
let name = expect_ident st "type/class name" in
|
||||
(* reject rows at their most habitual site: `class B extends A` *)
|
||||
(match peek st with
|
||||
| Token.Ident (("extends" | "implements") as w) ->
|
||||
let wpos = peek_pos st in
|
||||
(match doctrine_reject_reason w with
|
||||
| Some r -> reject_doctrine_word st wpos w r
|
||||
| None -> ());
|
||||
(* recovery: skip to the '{' so the body still parses *)
|
||||
while (match peek st with Token.LBrace | Token.Eof -> false | _ -> true) do
|
||||
ignore (advance st)
|
||||
done
|
||||
| _ -> ());
|
||||
expect st Token.LBrace "'{'";
|
||||
let id = fresh_id st in
|
||||
let fields = ref [] in
|
||||
|
|
@ -1873,6 +1918,14 @@ let parse_program (st : state) : Ast.program =
|
|||
| Token.KwType | Token.KwClass ->
|
||||
decls := Ast.Class (parse_class_or_type st ann) :: !decls
|
||||
| _ -> unexpected st "`type` or `class` after annotation")
|
||||
| Token.Ident w when Option.is_some (doctrine_reject_reason w) ->
|
||||
(* `macro fn …` / `extern fn …` at top level: the doctrine
|
||||
reason, not a generic syntax error *)
|
||||
let wpos = peek_pos st in
|
||||
(match doctrine_reject_reason w with
|
||||
| Some r -> reject_doctrine_word st wpos w r
|
||||
| None -> ());
|
||||
raise Parse_error
|
||||
| _ -> unexpected st "a top-level declaration (type/class/interface/fn/@annotation)"
|
||||
with Parse_error -> sync_to_next_top_level st)
|
||||
end
|
||||
|
|
|
|||
|
|
@ -614,6 +614,14 @@ let rec scalar_name_of (ft : field_ty) : string option =
|
|||
class declared later in the same file is not a false positive. *)
|
||||
let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
|
||||
(prog : program) : unit =
|
||||
let unknown_type_msg (name : string) : string =
|
||||
match name with
|
||||
| "Dynamic" | "untyped" ->
|
||||
Printf.sprintf
|
||||
"`%s` is rejected: static typing all the way to the register — typed `json.decode … as T -> ?T` covers the real use (principle 13)"
|
||||
name
|
||||
| _ -> Printf.sprintf "unknown type `%s`" name
|
||||
in
|
||||
List.iter (function
|
||||
| Ast.Class c ->
|
||||
List.iter (fun (f : Ast.field) ->
|
||||
|
|
@ -622,7 +630,7 @@ let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
|
|||
Diag.Collector.add collector
|
||||
(Diag.error ~code:unknown_type_name_code ~file
|
||||
~line:f.pos.line ~col:f.pos.col
|
||||
~message:(Printf.sprintf "unknown type `%s`" name) ())
|
||||
~message:(unknown_type_msg name) ())
|
||||
| _ -> ()
|
||||
) c.fields
|
||||
| Ast.Union u ->
|
||||
|
|
@ -636,7 +644,7 @@ let check_field_types ~file (syms : symbols) (collector : Diag.Collector.t)
|
|||
Diag.Collector.add collector
|
||||
(Diag.error ~code:unknown_type_name_code ~file
|
||||
~line:v.v_pos.line ~col:v.v_pos.col
|
||||
~message:(Printf.sprintf "unknown type `%s`" name) ())
|
||||
~message:(unknown_type_msg name) ())
|
||||
| _ -> ()
|
||||
) v.v_fields
|
||||
) u.variants
|
||||
|
|
|
|||
|
|
@ -83,8 +83,9 @@ Story slice: [`docs/stories/language-runtime-database/07-logwatcher-proof.md`](s
|
|||
|
||||
- Iteration 5's *strictness* half — **`?T` forced handling landed 2026-08-18**
|
||||
(WO-E211/212/213 + local narrowing; the samples were updated to the
|
||||
bind-then-narrow idiom and stay green). Still open: `pub(read)` write
|
||||
enforcement, `using`, `#if`, reject rows. Plan 8 stays open for those.
|
||||
bind-then-narrow idiom and stay green), **reject rows landed 2026-08-18**
|
||||
(WO-E105 doctrine diagnostics — `super.f()` used to compile clean). Still
|
||||
open: `pub(read)` write enforcement, `using`, `#if`. Plan 8 stays open.
|
||||
- Everything `@gc`: iteration 7b, `set`'s `@gc` retention gap, iteration 4's
|
||||
`gc/held-cycle` leak. The sample declares **no `@gc` class** — 35 classes,
|
||||
none with the gc flag, 0 `RC_INC`/`RC_DEC` against 78 `DROP`s — so none of it
|
||||
|
|
@ -112,7 +113,7 @@ that sequences its tasks. Read one, approve, then the next starts.
|
|||
| 2 | [VM core (`wovm`)](stories/language-runtime-database/02-vm-core.md) | ✅ |
|
||||
| 3 | [Compiler front (`woc`)](stories/language-runtime-database/03-compiler-front.md) | ✅ (known gaps below) |
|
||||
| 4 | [Single binary end-to-end](stories/language-runtime-database/04-single-binary-e2e.md) | ✅ (known gaps below) |
|
||||
| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | 🔄 grammar done; **`?T` forced handling ✅ 2026-08-18** (WO-E211/212/213 + narrowing); `pub(read)`/`using`/`#if`/reject rows still ⏸ |
|
||||
| 5 | [Language surface](stories/language-runtime-database/05-language-surface.md) | 🔄 grammar done; **`?T` forced handling ✅ + reject rows ✅ + WO-E205 ✅ (2026-08-18)**; `pub(read)`/`using`/`#if` still ⏸ |
|
||||
| 6 | [Program mode + stdlib](stories/language-runtime-database/06-program-mode-stdlib.md) | ✅ (the surface log-watcher uses) |
|
||||
| 7 | [log-watcher proof](stories/language-runtime-database/07-logwatcher-proof.md) | 🔄 **runs; executable in progress** |
|
||||
| 7b | [Inferred GC + mark-sweep](stories/language-runtime-database/07b-inferred-gc-mark-sweep.md) | ✅ **landed 2026-08-18** — `@gc` gone (WO-E104), GC-ness inferred, RC replaced by incremental mark-sweep, `.wob` v4; supersedes iteration 2's RC memory model |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Haxe-Parity Language Adoptions Implementation Plan
|
||||
|
||||
> **Status: ⏸ on hold behind [the executable plan](2026-08-14-logwatcher-executable.md)** (story iteration 5) — the grammar half of this plan is what let the driving workload compile, and the rest of it makes the language *refuse* more rather than making that program *run*, so it waits. Original status follows. — Tasks 1–4 ✅ shipped and review-verified: modules (`use`/`pub`), language surface (`and`/`or`, `${}` interpolation, `const`, break/continue, do-while), switch expressions, typedef records + enum payload variants. Task 5 (try/catch over the trap system) ✅ shipped 2026-08-14 — VM catch frames, expression and block catch arms, the `{code, line, method, msg}` record. Task 6 (`?T`) ✅ complete 2026-08-18 (branch `nullable-enforcement`): forced handling enforced — WO-E211/E212/E213 emit, locals narrow via `!= nil` guards / diverging early-return / `and`-chains / `while`; field places bind to a local first. Boxed scalar cells were superseded by `WO_NIL_SCALAR` before this task ran. Task 7 🔶 half: `static` members and `pub(read)` syntax landed, `using` and the `pub(read)` write check did not. Task 8 (`#if` + reject rows) ⬜. Board: [00-status.md](../../00-status.md)
|
||||
> **Status: ⏸ on hold behind [the executable plan](2026-08-14-logwatcher-executable.md)** (story iteration 5) — the grammar half of this plan is what let the driving workload compile, and the rest of it makes the language *refuse* more rather than making that program *run*, so it waits. Original status follows. — Tasks 1–4 ✅ shipped and review-verified: modules (`use`/`pub`), language surface (`and`/`or`, `${}` interpolation, `const`, break/continue, do-while), switch expressions, typedef records + enum payload variants. Task 5 (try/catch over the trap system) ✅ shipped 2026-08-14 — VM catch frames, expression and block catch arms, the `{code, line, method, msg}` record. Task 6 (`?T`) ✅ complete 2026-08-18 (branch `nullable-enforcement`): forced handling enforced — WO-E211/E212/E213 emit, locals narrow via `!= nil` guards / diverging early-return / `and`-chains / `while`; field places bind to a local first. Boxed scalar cells were superseded by `WO_NIL_SCALAR` before this task ran. Task 7 🔶 half: `static` members and `pub(read)` syntax landed, `using` and the `pub(read)` write check did not. Task 8: reject rows ✅ 2026-08-18 (WO-E105 doctrine diagnostics at class headers, expression heads, top-level heads; `Dynamic`/`untyped` type names via WO-E225's doctrine message; corpus `compile-fail/reject-*`); `#if` build flags still ⬜. Board: [00-status.md](../../00-status.md)
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
>
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ half of the story ("moved here" / "borrowed here" / etc.).
|
|||
| WO-E101 | generic syntax error: an unexpected token where the grammar expected something else, including running off the end of the file inside an unclosed block/type/interface body. Declaration-level recovery syncs to the next top-level keyword so one bad declaration yields one diagnostic, not a cascade. | `expected ')' or ',', got NEWLINE` |
|
||||
| WO-E102 | an invalid `@table(...)` configuration: `name` given twice, an `index` with no columns, or an argument key other than `name`/`index`. | `@table(name: ...) given twice` |
|
||||
| WO-E103 | haxe-parity Task 2. `inline fn ...` — the haxe keyword verdict table's own reject half of the `inline` row (`const` values are the adopted half). The whole declaration is discarded by the usual top-level recovery, same as any other bad declaration. | `` `inline fn` is rejected — optimization is the compiler's job `` |
|
||||
| WO-E105 | iteration 5 strictness (2026-08-18). A rejected Haxe keyword used where it would otherwise misparse — or, worst, compile clean (`return super.f()` used to): `extends`/`implements` after a class name, and `extends`/`implements`/`super`/`override`/`cast`/`Dynamic`/`untyped`/`macro`/`extern`/`operator` as an expression head or a top-level declaration head. Each cites the systems-track verdict table's doctrine reason. (`Dynamic`/`untyped` as a *type name* fire WO-E225 with the same doctrine message.) | `` `extends` is rejected: no inheritance, ever — is-a is a tagged union, has-a is composition, polymorphism is structural interfaces (principle 4) `` |
|
||||
| WO-E104 | iteration 7b. `@gc` on a class — the annotation is gone: GC-ness is inferred (structural cycles + demand promotion; `woc --dump-gc`). The annotation is skipped for recovery and the class classifies by inference. | `` `@gc` is not a valid annotation: GC-ness is inferred by the compiler (run `woc --dump-gc`). Remove it. `` |
|
||||
|
||||
## WO-E2xx / WO-W2xx — types (Task 6, `compiler/src/types.ml`; WO-E214 Task 8, `compiler/bin/main.ml`; WO-E215 plan 3 Task 2, `compiler/src/types.ml`; WO-E210/E216–E218/W202 haxe-parity Task 1, `compiler/src/types.ml`; WO-E201 haxe-parity Task 2, `compiler/src/types.ml`; WO-E208 haxe-parity Task 3, `compiler/src/types.ml`; WO-E203 haxe-parity Task 4, `compiler/src/types.ml`)
|
||||
|
|
|
|||
1
tests/corpus/compile-fail/reject-cast/fixture.code
Normal file
1
tests/corpus/compile-fail/reject-cast/fixture.code
Normal file
|
|
@ -0,0 +1 @@
|
|||
WO-E105
|
||||
6
tests/corpus/compile-fail/reject-cast/fixture.wo
Normal file
6
tests/corpus/compile-fail/reject-cast/fixture.wo
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
-- Doctrine reject (WO-E105): no unsafe casts — `as` exists only in the
|
||||
-- json.decode target position.
|
||||
fn main() -> Int {
|
||||
let x = cast 3;
|
||||
return 0
|
||||
}
|
||||
1
tests/corpus/compile-fail/reject-dynamic/fixture.code
Normal file
1
tests/corpus/compile-fail/reject-dynamic/fixture.code
Normal file
|
|
@ -0,0 +1 @@
|
|||
WO-E225
|
||||
9
tests/corpus/compile-fail/reject-dynamic/fixture.wo
Normal file
9
tests/corpus/compile-fail/reject-dynamic/fixture.wo
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
-- Doctrine reject: `Dynamic` as a type name — static typing to the register
|
||||
-- (principle 13). Fires as WO-E225 with the doctrine-citing message.
|
||||
class C {
|
||||
v: Dynamic
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
WO-E105
|
||||
14
tests/corpus/compile-fail/reject-inheritance/fixture.wo
Normal file
14
tests/corpus/compile-fail/reject-inheritance/fixture.wo
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
-- Doctrine reject (WO-E105): no inheritance, ever. `extends` fails at its
|
||||
-- own position with the doctrine reason (principle 4), never a generic
|
||||
-- syntax error. `super`/`override`/`implements` share the row.
|
||||
class A {
|
||||
n: Int
|
||||
}
|
||||
|
||||
class B extends A {
|
||||
m: Int
|
||||
}
|
||||
|
||||
fn main() -> Int {
|
||||
return 0
|
||||
}
|
||||
Loading…
Reference in a new issue