The host gate accepted a WATCOM environment override and skipped when nothing
was found. Both are wrong for what it is: a system-wide Open Watcom is a
different version reporting different diagnostics, and a gate that skips is a
gate that is not running, which is the exact shape of the problem this file was
added to close. It now uses .dosboxx/watcom only and fails with the setup
command when that is absent, matching how dosboxx.py already behaves.
Nothing else in the project reaches for a system install: the DOS session sets
WATCOM=W: before calling BUILD.BAT, so the C:\DEVEL\WATCOMC fallback inside it
is unreachable.
fec/test-dos.bat was tracked but dead -- the runner generates RUN.BAT and only
copies build-dos.bat -- so it goes, along with the comment claiming it drives
the build and the three fixture READMEs that still pointed at it. The registry
decides what runs now.
(.qemu/ is untracked local debris from the same era and is left alone.)
The gate was using a system-wide Open Watcom that only ships the 32-bit
compiler. The project downloads its own toolchain, and .dosboxx/watcom/binnt
has the Windows-hosted 16-bit wcl.exe -- the same compiler and the same target
as the DOS build. Run that instead, with build-dos.bat's exact command.
The difference is not academic. Compiling 16-bit immediately reported three
functions the unification had orphaned: emit_type_helpers, which the M7
program emitter replaced, and emit_drop_helpers and emit_drop_fields, which it
was the only caller of. The 32-bit check had been clean.
Those warnings were going to the DOS screen, where the runner cannot see them:
COMMAND.COM redirects handle 1 only, and Watcom writes diagnostics to handle 2.
So the suite was green while the build was not quiet. Nothing in the runner
would ever have said so.
Found while chasing W210/W107 reported from a DOS screen, which this does not
yet explain -- those are not among what the compiler build emits now.
M1-M7: 214 passed.
Unifying the two engines exposed what the split had been hiding: every rule
that lived only in the M6 body was silently dropped for units the M7 half
claimed, and since M1-M6 sources never reached that half, nothing failed until
they all did. Eleven cases across m3, m5 and m6 caught it.
Checker, all from the M6 statement and lvalue cases:
- writing a struct field needs a writable place, so `p.x = 3` on a `let` is an
error again (m3-badfield)
- `let` cannot bind a mutable slice, a var with no initializer needs a type,
and a void expression cannot initialize (m3-bad-mlet)
- a returned reference must derive from a parameter or a static, and a void
expression cannot be returned from a value function (m6-badarg, badret,
badself, badtwo, badlocsl)
- rebinding a reference must not outlive its source scope, and must release the
previous borrow (m6-badscop)
- the loop case delegates to the core, which carries the flow capture and merge
that detects a value moved on every iteration; the M7 version had none of it
(m5-bad-loop)
Emitter:
- builtins other than the print family (@size_of, @align_of) and the str alias
methods are lowered by the core, which the M7 call path never reached, so
they were emitted verbatim into the C (m3-struct)
- the trim helper is emitted from the M7 type-helper pass as well, not only the
core one, or the call has no definition to link (m6-oktrim)
M1-M7 all green: 16, 19, 50, 20, 16, 57, 42.
Compiling the merged sources with the Open Watcom install on this host
(C:\WATCOM19\binnt) found four things the merge got wrong, none of which any
amount of reading would have caught reliably:
- FE_M7_FLOW_CAP and <stdlib.h> lived in check_m7.c's preamble, above the
textual include, and were dropped with the wrapper.
- emit_error_return takes a const char *, not a FeNode *; the hand-written
forward declaration disagreed with the definition.
- emit_match was never defined by the M7 half, only called, so renaming it
alongside the other delegating pairs left a declared-but-undefined static.
- type_needs_drop and emit_lvalue are used a few hundred lines before the
declaration block, so their declarations had to be hoisted.
Also drop two locals that existed only to be cast to void.
All twelve compiler sources now compile with -za -wx -wcd=202 and produce no
warnings. That is a syntax and type check, not verification -- the DOS build
and the milestone suite remain the gate.
emitcm7.c was the same wrapper trick as the checker: nineteen #define renames,
a textual include of emit_c.c, and a per-unit feature scan choosing between two
emitters. Five of the M7 halves fell through to their M6 counterpart, so those
become the single entry point with the old body renamed to *_core; the other
thirteen never delegated at all and simply replace the M6 version.
The scan is gone from both places it was used -- the program entry and the
expression emitter, whose switch already ended in a default that delegates.
fe_emit_c_program_core went with the dispatch that was its only caller.
emitcm7.c is deleted and the build compiles emit_c.c directly. Nothing in the
compiler now selects an implementation by looking for `?` or `!` in a unit.
This is the pair to the checker commit; together they end the two-engine split
that produced four of the five defects fixed while getting M7 to pass.
check_m7.c textually included check.c, renamed three entry points aside, and
selected between two whole checkers by scanning each unit for `?`, `!`, `try`
and friends. A unit that mentioned any of them was checked by a second
implementation, so an M1-M6 rule fixed in check.c never reached it -- and the
split hid real defects, since the M7 half was reached by no existing fixture
until M7 cases were registered.
The split was cheaper to undo than it looked: every M7 dispatcher already
delegated to its M6 counterpart for nodes it did not handle. So the M7 entry
points become the single check_expr/check_stmt/check_lvalue/check_call, and
the former M6 bodies become check_expr_core/check_stmt_core/check_lvalue_core,
reached as the fallback. Recursion runs through the unified entry, which is
what makes an optional nested inside otherwise-M6 code get checked at all.
m7_check_fn and m7_check_method were identical to the M6 versions apart from
which check_stmt they called, so they are dropped. The feature scanner
(m7_type_ast, m7_node_feature, m7_program_feature) is gone with the dispatch
it fed, including the loop case that still consulted it. fe_check_program and
fe_check_expr_type keep the M7 bodies, which are supersets.
The build compiles check.c directly again.
No behaviour intended to change: the unified checker applies the union of the
rules to every unit, which for M1-M6 sources is what the M6 half already did.
Compiling okcatmov.fe's output crashed wcc386 hard enough to take DOSBox-X
down with it -- "no byte handler for write to ffffffc", emulator exit 1, the
whole suite lost rather than one case failing. `result catch fallback` in
return position lowered to `((tmp = X), tmp.e ? fallback : tmp.v)`, and with a
moved operand X is itself `(fe_live_x=0, x)`, so the compiler met a struct
assignment whose right side was a comma expression. Lower it as statements,
and clear a move flag on its own line rather than inside the assignment.
Revert core=dynamic from the previous commit. It was about 5x faster and it is
wrong: the recompiler loses abort()'s exit status, so a trapping program exits
0 and the M3 bounds cases stop reporting the trap they exist to prove. Checked
against a compiler built under core=normal, so the fault is in running the
generated program, not in building the compiler.
Also fix the build cache: BUILD.BAT puts the Watcom binaries on PATH, and
skipping it on a cache hit left the case commands without it, which silently
changed how the trap programs terminated. Reproduce that line.
Per milestone, warm cache: m1 2s, m2 5s, m3 20s, m4 7s, m5 6s, m6 14s, m7 11s.
All green, 220 cases.
Every run rebuilt fec from scratch inside DOS, about 14 seconds, even when no
source had changed. Key a cache on the hash of fec/src plus build-dos.bat and
restore FEC.EXE when it matches; the toolchain itself is pinned by
dosboxx.lock.json so it cannot drift under a hit. Only a passing build is
cached, and the batch skips BUILD.BAT on a hit because it would delete and
rebuild the executable it was just given.
Generated C included stdio.h unconditionally, but only the M4 writer runtime
reaches it. A 26-line unit was pulling in roughly 1900 lines of headers, paid
once per compile check. Emit it only when the runtime is emitted.
--only m6, 57 passed, over three changes:
31s before
17s core=dynamic
2s warm compiler cache
Cold runs still pay the build once.
Thirty-one cases only ever emitted C and never built it -- m5 owned/defer, and
every ok* acceptance fixture in m6 and m7. They asserted that fec produced a
file, not that the file was a program, so a malformed emission sat unnoticed.
Compile each one with wcl386 -c. Fixtures without a main cannot be run, so this
is the floor for them; it is a conformance check on the backend's output, not an
assertion about C, so a future backend swaps the command rather than the intent.
It found two defects in the M6 emitter immediately, both predating M7:
&s[0] took the address of an rvalue. Borrowing went through emit_expr, which
lowers an index to the bounds-checking accessor, so `&s[0]` became
`&fe_idx_slice_type_2(s, 0)`. emit_lvalue spells the same element as `s.p[0]`.
The M7 emitter already did this correctly; only the M6 path was wrong.
line.trim() was never lowered. SPEC lists trim among the built-in alias methods
on str and the checker accepts it, but no emitter case existed, so it emitted
`fe_l_line_0.trim()` -- a member access on a slice struct. Emit the helper, and
only for programs that actually trim.
Also set core=dynamic and cycles=max. core=auto uses the interpreter in real
mode, which is where the 16-bit compiler build spends its time; nothing here is
timing sensitive. m6 drops from 31s to 17s. Raise the DOSBox timeout to match a
whole-suite run, which now pays a process spawn per compile check.
Verified: --only m6, 57 passed.
SPEC.md allows `try` only inside a function returning an error union, but the
check sat in the FE_N_EXPR_STMT case, so it only ever saw a bare `try e;` and
walked past `var x = try e;` and `x = try e;`. Move it onto the try expression
in check_expr and drop the statement-level copy.
This could not land before M7: closing the hole forces m5/runtime.fe's `run` to
return an error union, and value returns from `-> !T` need contextual success
construction. That arrives with M7, and `run` is now `-> !i32`, so the rule can
be enforced. Supersedes the SPEC.AUDIT.md entry that recorded the blockage.
M1-M7: 183 passed.
m7_emit_call reimplements call emission and only carried over mem.destroy and
mem.replace, so every mem.create/mem.alloc_slice fell through to the generic
member path and emitted `fe_missing.create(0)`. wcc386 does not diagnose that
-- it terminates with exit 255, which wcl386 reports as "Unable to invoke
wcc386.exe" with no message at all.
The breakage covered all 17 allocation sites in m5/runtime.fe, and it was
invisible because m5-owned and m5-defer only emit C; m5-runtime is the one M5
fixture that compiles and links what was generated.
Also make runtime.fe legal: `run` used `try` while returning i32, which SPEC
allows only in a function returning an error union. It is `-> !i32` now, which
M7 accepts because contextual success construction lands with it, and
runtime.c takes the { error, value } struct the error union lowers to.
Found by calling wcc386 directly instead of through wcl386, which is the only
way to see a compiler crash here.
M1-M7: 183 passed.
emitcm7.c reimplements the emitter for sources that mention M7 syntax, and
three things were lost in the port. All of them only reached M4 fixtures,
because M1-M3 and M6 take the M6 fast path.
Local initializers named "0". The LET/VAR case passed the declaration node to
emit_lvalue, which matches only IDENT/MEMBER/INDEX and otherwise falls through
to the raw expression path -- a declaration node renders there as "0", so every
`var x = init;` emitted `0 = init;`. Teach emit_lvalue that a declaration names
its own storage, which also fixes the catch path that had the same call.
Aggregate initializers were not constant. A string-literal `const` lowered to a
maker call, but C89 requires a constant expression for aggregate initializers
at file scope and for automatics alike, and the build runs with -za. Restore
the braced form for both the local and the global path.
Slice helpers were never emitted. The final loop in m7_emit_type_helpers is
commented as reusing the M3 index/slice generator but only ported the index
half, so bodies called fe_slice_*/fe_full_*/fe_tail_* that no declaration
defined. Emit the three slicers for array and slice types.
The first defect masked the other two: wcc386 died on `0 = ...` before it could
reach them, and wcl386 reports that as "Unable to invoke wcc386.exe" with no
diagnostic, which is why this needed bisecting against master's output rather
than reading an error message.
M1-M7: 7 failed, 176 passed -> 3 failed, 180 passed. The remainder is m5
runtime, which is a separate fixture issue.
SPEC.md allows `try` only inside a function returning an error union, but
check.c only tests it in the FE_N_EXPR_STMT case, so `var x = try e;` and
`x = try e;` walk straight past. m5/owned.fe and m5/runtime.fe both depend on
that gap.
Moving the check onto the try expression is a four-line change and it is
correct, but it cannot land yet. runtime.fe's `run` allocates and returns a
value, so closing the hole forces it to return an error union -- and master
rejects `return <value>;` in `-> !i32` ("return type mismatch") as well as a
bare `return;` in `-> !void` ("void expression returned from value function").
Both need contextual success construction, which is M7 work. `catch` and
`@trap()`, the two spellings SPEC offers as alternatives, are also M7-only, so
there is no way to express `run` legally on master today. All three paths were
tried in DOSBox-X, not assumed.
Fix owned.fe now, since `main() -> !void` is legal today and matches
m4/try-fpr.fe, and leave the checker alone until M7 lands with the rest.
Verified: 155 passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BScg8CF1sAAM2zVHAu5zvW
Every fec error message was written to stderr, and COMMAND.COM can only
redirect handle 1 -- ">" is the whole vocabulary, "2>" is not parsed at all.
So a failing compile recorded exit code 1 and a zero-byte log, and the actual
message went to a screen nobody reads. Confirmed directly: `FEC.EXE --check`
on a fixture that must fail produced rc=1 and 0 bytes of stdout.
That is why an unexpected compiler failure was undiagnosable. It also means
the M6 reject cases have only ever asserted "exit code was nonzero" -- the
error text they nominally check has never been observable to the runner.
Add fe_diag_stream(), which resolves once to stdout when FE_DIAG_STDOUT is set
and stderr otherwise, and route diag.c and driver.c through it. The default is
unchanged, so interactive use keeps writing to stderr; the runner sets the
variable in RUN.BAT. The stderr references in check.c and emit_c.c are the
Ferro language's own std.io.stderr writer and are deliberately untouched.
Verified in DOSBox-X: 155 passed. A rejecting compile now records its message,
source excerpt and caret in RESULTS\<key>.LOG.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BScg8CF1sAAM2zVHAu5zvW
The bounds-no-checks cases emitted and built but never ran, so they only
proved that --no-checks produces compilable C -- not that it removes the
check, which is the entire point of the flag.
A run case could not simply be appended. BOUNDS.FE returns the out-of-bounds
element directly, so with checks removed its exit code is whatever sits past
the array on the stack and there is no correct status to assert. Asserting on
the generated C instead does not work either: emit_c.c defines fe_trap_bounds
unconditionally and --no-checks only suppresses the call sites.
Add NOCHK.FE, which reads one element past a [2]i32 and returns x - x. That
is 0 for whatever garbage the unchecked read produced, so the same source has
a defined outcome both ways: compiled with checks it must trap, compiled with
--no-checks it must run to completion and exit 0. Register both halves and
drop the two BOUNDS-N cases they supersede.
Verified in DOSBox-X: 155 passed, including m3-nochk-trap failing as expected
and m3-nochk-off-run succeeding.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BScg8CF1sAAM2zVHAu5zvW