Skip to content

diag-1: type-error recovery, multiple diagnostics, caret rendering#37

Merged
vpisarev merged 5 commits into
masterfrom
diag-1
Jul 10, 2026
Merged

diag-1: type-error recovery, multiple diagnostics, caret rendering#37
vpisarev merged 5 commits into
masterfrom
diag-1

Conversation

@vpisarev

Copy link
Copy Markdown
Owner

Groundwork for an LSP: one compiler run now reports many useful type errors
instead of dying on the first, with cascade noise suppressed, gcc/clang-style
caret excerpts, and "did you mean" suggestions.

file.fx:2:9: error: the appropriate match for 'lenght' of type '<unknown>' is not found; did you mean 'length'?
 2 | val x = lenght + 1
   |         ^

What you get

  • N independent errors → N diagnostics in one run (was: die on the first).
  • A cascade → exactly one diagnostic: val x = undef; x+1; f(x) reports only
    the root undef.
  • Broken body behind a declared signature → callers stay clean; only a
    genuinely mistyped caller errors (the "annotations are firewalls" dividend).
  • Errors in several match arms / if branches are all reported in one run.
  • "did you mean 'length'?" on typo'd identifiers, constructors and fields.
  • gcc/clang caret excerpts on frontend diagnostics; sorted by source
    position; exact duplicates deduped; capped by -fmax-errors=N (default 100).

How it works (recovery lives strictly above unification)

Recovery = report + poison with TypErr + continue. It rides the machinery
that already existed (a per-item try/catch in check_eseq, one compile_err
CompileErrorpush_compile_err accumulation path) — adapted, not rebuilt.
The key insight: TypErr (the ctrlflow-1 poison type) already flows silently
through unify without binding, so poisoning a failed symbol with TypErr
gives structural cascade suppression almost for free.

  • typ_has_typerr + three choke points (lookup_id, scan_'s IdDVal,
    commit_fun) suppress a resolution whose operand/callee is already poisoned
    and propagate the TypErr to the result. They only fire on already-failed
    paths, so a clean compile is unaffected — the -pr-resolve census is
    byte-identical
    (the 7 under-constrained fallbacks unchanged).
  • DefVal: the annotation unify moved inside the recovery try; every name
    the pattern binds (tuple/record/as, via walk_pat) is poisoned — with the
    declared type when annotated (firewall), TypErr otherwise.
  • DefFun: a body failure keeps the declared signature; the return type is
    recovered from the branches that DID typecheck (an early return 42 pins
    it), poisoned to TypErr only if nothing did.
  • check_cases / ExpIf: per-arm / per-branch recovery — a poisoned arm/branch
    contributes no type, so the healthy siblings determine the result (the
    jumping-throw/ctrlflow-1 logic).
  • Templates: body errors surface at instantiation and poison correctly;
    duplicates across N instantiations collapse in the dedup.

Stage-honest caret precision

loc_excerpt draws the excerpt at raise time (inside compile_err /
compile_warning), because precision depends on the compilation stage, which
advances as the driver runs. Precision is governed by an explicit
Ast.compiler_stage (Init|Frontend|Middle|Backend), deliberately separate
from the implicit freeze_ids:

  • Frontend = lexer + parser + typecheck and K-normalization — all
    consume the AST with exact locations, so even a non-exhaustive-match error
    (raised in K-normalization) gets a caret.
  • Middle (K optimizations: inlining/fusion) and Backend (C generation)
    drop the caret: locations drift there and not all context reaches C-gen
    intact, so a confident caret would point at an innocent line.

One renderer serves errors and warnings. Legality checks were pulled the right
way across this line too: break/continue inside a @parallel loop is now a
typecheck diagnostic (exact caret, visible under -no-c, golden-able)
instead of a late C-gen error.

Test-format change (reviewers: read the golden diff as a format change)

Every test/negative/*.err golden now carries the source line + caret. The T3
harness (tools/fxtest/negative.py) was made caret-aware: excerpt lines
( N | src / | ^) are compared verbatim (their spacing is the contract);
all other lines keep the whitespace-collapse. New directed goldens:
multi-error, cascade, firewall, match-arm recovery, -fmax-errors truncation,
did-you-mean, and the lifted @parallel check.

Verification

  • test_all 147; fxtest all (unit + negative 85 + ir + cfold + corpus O0/O3);
    determinism 3/3; sanitize clean.
  • -pr-resolve census: byte-identical resolution — the extra sites are new
    diagnostic code only (recovery choke points fire solely on poisoned types).
  • Bootstrap fixpoint holds. The scope_t/ScLoop layout change (for the
    @parallel lift) reorders many bootstrap modules — a compile-time-only type
    (like options_t); generated C for user programs is unchanged.

Not in scope (follow-ups, noted in docs/diag1_report.md)

  • ^~~~ column spans (most locs are single-point today).
  • Actually SUPPORTING break/continue in @parallel loops (currently
    rejected); when that lands, relax the lifted check rather than reject.

Full write-up: docs/diag1_report.md.

vpisarev and others added 5 commits July 10, 2026 16:10
…ion)

One compiler run now reports many independent type errors instead of dying on
the first, with cascade noise suppressed. Recovery lives strictly above
unification and rides the existing TypErr poison type (ctrlflow-1) and the
existing per-item try/catch in check_eseq -- adapted, not rebuilt.

Structural cascade suppression (the primary mechanism):
- typ_has_typerr(t): detects a type already contaminated by an upstream error.
- lookup_id: if the expected type has TypErr, suppress the resolution and pin
  the result to TypErr (poison_result_typ) instead of reporting -- the genuine
  error was already reported where the TypErr was produced.
- scan_ IdDVal / commit_fun: a value/function whose stored type is TypErr
  propagates that poison to the use site (unify leaves the use-site var free
  because TypErr never binds). Only ever runs on already-failed paths, so a
  clean compile is unaffected: -pr-resolve census byte-identical (351 sites).

Per-definition and per-branch recovery (report + poison + continue):
- DefVal: annotation unify moved inside the recovery try; every name the
  pattern binds (tuple/record/as, via walk_pat) is poisoned -- with the
  declared type when annotated (the "annotations are firewalls" dividend),
  TypErr otherwise.
- DefFun: body failure keeps the declared signature (pre-registration is the
  firewall); the return type is recovered from the branches that DID typecheck
  (e.g. an early `return 42`), poisoned to TypErr only if nothing pinned it.
- check_cases / ExpIf: a failed match arm / if branch is reported and its
  siblings are still checked, so errors in several arms/branches surface in one
  run; a poisoned arm/branch does not constrain the result type -- the healthy
  siblings determine it, same logic as a jumping throw/ctrlflow-1 sibling.

Template functions: body errors surface at instantiation and propagate/poison
correctly; duplicate reports across N distinct instantiations are left for the
Phase E (loc,msg) dedup.

Verification: test_all 147, fxtest all, -pr-resolve census byte-identical,
bootstrap fixpoint (only Ast_typecheck.c changed). Six negative goldens updated
-- each dropped a spurious cascade line, now reporting exactly the root error.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…rected tests

Makes multi-error runs readable and deterministic:
- print_all_compile_errs now sorts diagnostics by (module, line, col) so the
  report reads top-to-bottom regardless of the order definitions were checked,
  and drops exact-duplicate messages keyed on the PRIMARY error line (a generic
  function body error is otherwise reported once per distinct instantiation --
  same bug, one line, with whichever "when instantiating ..." context came
  first).
- -fmax-errors=N (default 100): print at most N diagnostics, then a "further
  diagnostics suppressed" summary. Structural suppression keeps real runs well
  under the cap; it is a backstop for pathological inputs.

Directed negative goldens (test/negative/230-234): three independent errors ->
three diagnostics; a cascade -> exactly one; a broken body behind a declared
signature -> the correct caller stays clean and only the genuinely mistyped
caller errors; errors in two match arms -> both; -fmax-errors truncation line.

Bootstrap: 9 compiler modules regenerate from the options_t layout change (new
max_errors field) -- the same propagation as annotate-2; generated C for user
programs is unaffected (options_t is compile-time only). test_all 147, fxtest
all, negative 83, -pr-resolve census 351 unchanged, fixpoint holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A not-found identifier / constructor / field that is close (Levenshtein) to a
visible name now gets a gcc/clang-style suggestion, e.g.
  error: ... 'lenght' ... is not found; did you mean 'length'?

- edit_distance: two-row Levenshtein DP (error-path only, cost irrelevant).
- suggest_similar: up to two closest visible env names within a length-scaled
  threshold (<=1 for short names, <=2 longer), skipping the name itself and
  compiler-internal '_' names; "; did you mean 'a' [or 'b']?" tail, empty when
  nothing is close.
- report_not_found_typed gains an `env` param and appends the suggestion ONLY
  when there are no candidates (a genuinely unknown name); when overloads exist
  but the call does not match, the candidate listing is shown as before.

Only runs when a diagnostic is already being built, so clean compiles are
unaffected: the -pr-resolve census grows by exactly 2 ranked sites (the two
`min` calls in edit_distance) with the 7 under-constrained fallbacks unchanged
-- no existing resolution altered. test_all 147, fxtest all, negative 84 (new
235_did_you_mean; 402_unknown_constructor now suggests 'A'/'B'), fixpoint holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ision

A frontend diagnostic now shows the offending source line with a caret under
the column:

  file.fx:2:9: error: ... 'lenght' ... is not found; did you mean 'length'?
   2 | val x = lenght + 1
     |         ^

- loc_excerpt(loc): reads the source line lazily (cached per module via
  src_lines_cache, reset in init_all), expands tabs 1:1 so the caret stays
  aligned, and draws '^' (or '^~~~' when loc carries a real span).
- Built at RAISE time inside compile_err / compile_warning, not at print time:
  precision depends on the current stage, which advances as the driver runs, so
  by end-of-run every error would otherwise look like a backend error.
- Stage-honest precision via a new explicit Ast.compiler_stage
  (CompilerInit|Frontend|Middle|Backend), separate from freeze_ids: the FRONTEND
  spans lexer + parser + typecheck AND K-normalization (all consume the AST with
  exact locations -- e.g. non-exhaustive-match errors now get a caret), while
  the MIDDLE end (K optimizations: inlining/fusion) and BACKEND (C generation)
  drop the caret because locations drift there and not all context reaches
  C-gen intact. Compiler.process_all sets Frontend before parse_all, Middle
  after k_normalize_all, Backend before k2c_all.
- One renderer serves errors and warnings.
- fxtest T3 harness made caret-aware: excerpt lines ("  N | src" / "    | ^")
  are compared verbatim (exact spacing is the contract); all other lines keep
  the whitespace-collapse. 60 negative goldens migrated to the new format.

Full ladder: test_all 147, fxtest all, negative 84, determinism, sanitize,
-pr-resolve census 356 (349 ranked + 7 fallback -- the 7 unchanged; the extra
ranked sites are the new diagnostic code, no existing resolution altered),
bootstrap fixpoint holds.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…+ CLAUDE.md

- Lift the "break/continue inside a @parallel loop" legality check from C-gen
  (finalize_loop_body) to the type checker. Whether a loop is @parallel is a
  pure nesting fact, so ScLoop now carries a `parallel` flag
  ((nested, parallel, block_idx), set from flags.for_flag_parallel), and
  check_inside_for rejects break/continue at the innermost parallel loop with an
  exact caret -- now visible under -no-c and golden-able
  (test/negative/236_parallel_continue). A break targeting a nested
  non-parallel loop inside a @parallel loop stays legal. The old C-gen check
  remains as an unreachable backstop. (A future WP may actually SUPPORT
  break/continue in parallel loops; then relax the check rather than reject.)
  The scope_t layout change reorders many bootstrap modules -- compile-time-only
  type (like options_t), generated C for programs unchanged, fixpoint holds.
- docs/diag1_report.md: the Phase A map, tier decisions, before/after, the
  stage-honest precision model, and open follow-ups.
- CLAUDE.md: a Diagnostics note (recovery + TypErr poison + structural
  suppression, sort/dedup/-fmax-errors, did-you-mean, caret gated on
  Ast.compiler_stage, and that every negative golden now carries a caret).

Full ladder: test_all 147, fxtest all, negative 85, determinism 3/3, sanitize
clean, -pr-resolve census 356 (349 ranked + 7 fallback, unchanged), fixpoint.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@vpisarev vpisarev merged commit ef5973e into master Jul 10, 2026
3 checks passed
@vpisarev vpisarev deleted the diag-1 branch July 10, 2026 15:05
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant