Skip to content

feat: judge the cross-file population, and drain the top of the unmodelled-statement suppression - #153

Merged
evanbrobertson merged 16 commits into
masterfrom
feat/judge-cross-file-population
Aug 4, 2026
Merged

feat: judge the cross-file population, and drain the top of the unmodelled-statement suppression#153
evanbrobertson merged 16 commits into
masterfrom
feat/judge-cross-file-population

Conversation

@evanbrobertson

@evanbrobertson evanbrobertson commented Jul 31, 2026

Copy link
Copy Markdown
Contributor

Cross-file resolution landed silent by design (#146#148): an inherited member resolved but its type was parked in a side map, a class-typed declaration resolved but kept Unknown, and a name searched for across every configured path was skip-listed beside names nobody had looked for. Every one of those valves existed so that attaching a workspace index could be merged on evidence that it changed no diagnostic. That evidence was collected. This opens the valves, judges what comes through, and drains the top of the unmodelled-statement suppression that was blinding the count-gated rules.

Ten commits, four phases, each phase gated on an A/B over a large real-world ABL codebase kept outside this repo. Findings keyed on path, line, column, and code; message compared separately, since a message-only change must not read as one removal plus one addition.

What changed, phase by phase

Phase 0 — make the messages readable first. LINT0004 interpolated a ResolvedType with {:?}, so a buffer mismatch reached the user as Buffer(SymbolId(7)). ResolvedType::display_abl pairs a type with the symbol table and the schema — a class or buffer carries a SymbolId only the former resolves, a table a TableId only the latter does — and renders CHARACTER, DECIMAL EXTENT 3, buffer Customer. This had to be first: a symbol id in a message shifts whenever symbol counts change, and this PR changes them throughout. Closes #152.

A/B: zero behavior deltas. Every rule ×1.000, no location added or removed, every LINT0004 message re-rendered in place.

Phase A — the type valves. An inherited member's declared type goes onto Symbol::data_type; a declaration whose AS CLASS name resolves through the index carries ResolvedType::Class; check.rs types an index-synthesized class as its class. All three side channels are deleted, not bypassed. One addition was needed to make that safe: a synthesized class symbol now records the supertypes the index read from its header, because ClassLattice climbs through SymbolTable::supertypes, and a class with no entry looks like a class that inherits nothing — which reported a subclass assigned into its parent-typed variable, a false positive on the one shape inheritance widening exists to allow.

A/B: one group, no singleton tail — LONGCHAR into CHARACTER through an inherited method, spread over ~0.5% of files. Adjudicated by reading members: each is an inherited LONGCHAR-returning method called unqualified in a subclass and assigned into a local CHARACTER, where the widening ladder makes the value LONGCHAR and the target truncates. True positives, one reason, at warn — no file newly carries an error. LINT0004 ×1.429; every other rule ×1.000.

Phase A2 — reporting a genuinely absent name. Two commits on purpose. First a pure refactor: NotFoundInWorkspace named four situations and only one was "absent", which is harmless while every rule skip-lists a reason and false the instant one reports it. It splits into AbsentFromWorkspace (a genuine path-search miss) and PresentButUnusable (an inaccessible inherited member, a member lookup that failed against a class the index did answer for, a file that could not be read or parsed, a file that does not visibly declare its class, and the analysed file itself). Telling a broken file from a missing one required the seam to say so, so IndexAnswer gains Unusable, answered from the parsed flag both backends already had. R17 rides along: an index with no configured path answers NotFound to everything without having looked anywhere, so a miss there stays External — derived beside index_loaded from a defaulted WorkspaceIndex::searches_any_path, which also keeps the browser's file-less export from diverging from the CLI. The A/B across that commit alone is empty, by construction.

Then the rule flip: undefined-symbol fires on AbsentFromWorkspace, at error severity, with a help line naming [workspace.sources].include_paths. The reason match is exhaustive and wildcard-free, so the next variant is a compile error in the rule that would have to judge it.

The first cut over-reported by more than twenty times, and every one of those findings was correct ABL. Rather than ship the wave, four narrowings landed with it — each a case where a path search is not the mechanism the AVM itself would use, so a miss proves nothing:

  • RUN name IN handle reaches an entry point in another running program → Unknowable.
  • An extension-less RUN name means an internal procedure first, including any registered SUPER PROCEDURE's. oxabl models neither → External.
  • A bare class name is checked against the spellings the search actually tried, not its own: a bare name reached through a wildcard import of a shipped namespace is searched for qualified and recorded bare, so a carve-out that only looked at the reference name let the whole AVM class library through as absent.
  • A located file that does not visibly declare its class answers Unusable — its declaration is usually spliced in from an {include}, which the index does not expand.

Two real defects surfaced on the way and are fixed rather than worked around: a literal RUN target accepted a dotted second segment anywhere on the same line, so RUN write-header. RUN build-list. produced the target write-header. RUN (adjacency to the period now separates a dotted name from a terminator); and the name search applied the walk's extension set to a path the author spelled out, so a .pp program that plainly exists was reported absent (search::ExtensionPolicy — a RUN target may carry any extension but .i). An index's self-exclusion answers Unusable too, so a program that RUNs itself persistently is not told its own path is missing.

A/B: LINT0001 ×1.019, every other rule ×1.000. All 202 additions were adjudicated, not sampled, by checking whether each named target resolves at its spelled path, exists elsewhere in the tree, or exists nowhere. Zero resolve at their spelled path — there is no case of the search failing to find code that a configured path supplies. 190 of 202 (94%) name code that exists nowhere in the tree: a literal RUN target in 196 of the 202 sites, a NEW of an undeclared class in 5. The remaining 12 name code that exists at a different path than the one written — some a bare name whose file sits one directory below a configured root, some a directory prefix that does not match where the file lives (a stale target, or a PROPATH entry that remaps it). Those are the diagnosable-misconfiguration shape the help line exists for, not mis-resolution. Second leg, one real source root removed from the search paths: the shape is diagnosable — findings cluster by the first path segment of the missing name, so a whole absent directory is visible in the grouping. That leg also exposed that check --json dropped help, so a machine consumer saw half the diagnostic; added, version 3.

Phase B — draining the suppression. DELETE OBJECT is head-parsed into StatementKind::DeleteObject { target: Expression, no_error } — its own variant because the operand may be ttbl:HANDLE or hArray[i], which is exactly why it skipped, and note DELETE PROCEDURE/WIDGET/SERVER never did. COMPILE keeps emitting Skipped with an empty name list: its operand is a file path, so head-parsing would credit reads that do not exist, while harvesting suppressed real variables whose names collided with a path segment or with SAVE.

A/B: unjudged symbols fell by 55%. Finding deltas confined to LINT0002/LINT0005/LINT0006 — 28 findings, every one a symbol that was previously unjudgeable — with no LINT0004 movement, which would have meant a type valve moved, and no LINT0001 movement.

What is deliberately still silent

Each of these is pinned by a test that names the reason, so the day one opens the suite says what changed:

  • :-qualified member and method-call expressions. check.rs types every MethodCall and MemberAccess as Unknown regardless of what it resolved to, so a cross-file type reaches the lattice only through an unqualified reference. That is the larger half of the population and wants its own evidence — folding it in here would have made this PR's A/Bs unreadable.
  • The AS CLASS pkg.Missing declaration spelling. DataType::Class is a bare String with no span, so there is nothing to underline.
  • Everything but AbsentFromWorkspace. External, PresentButUnusable, and Unknowable stay skip-listed by every rule.

Evidence and docs

  • Parity: CrossFileEffect gains Judged (a finding cross-file resolution produces — the withheld answer is no longer a superset of the supplied one) and ResolvedFromWorkspaceMiss (a finding that exists only because a path was searched, and so does not arrive on the browser's export). A finding may appear only where a Judged effect declares it, and a declared one that fails to arrive is also a failure. All four legs agree on the new rows, browser included.
  • Envelope: symbols → 4 with no new keydata_type changed meaning for a cross-file row, from reliably absent to populated, so a consumer detecting cross-file rows by that absence must branch on data_type_source instead. dependencies → 2 for the split reason strings.
  • docs/design/ast-invariants.md §8 records the new node and the two-shape taxonomy in the same commit as the AST change.
  • README documents the new error severity and how to configure the paths, since users upgrade into it.
  • parser: head-parse the unmodelled statement forms (retires #128's suppression flag) #136 is re-scoped on measurement: a ranked table of the remaining forms (PUT ≈ 73% of what is left) plus the taxonomy that decides each one's fix — symbol-shaped operands earn a head-parse, path-shaped ones earn a deleted harvest.
  • CLAUDE.md and HANDOFF.md updated.

Gates: cargo test (2017 tests), cargo fmt --check, cargo clippy -D warnings all clean. Warm language-server single-edit cycle 2.83ms against the 50ms interactivity gate.

Closes #152. Refs #102, #136.

…al ids

LINT0004 interpolated a `ResolvedType` with `{:?}`, so a buffer mismatch
reached the user as `Buffer(SymbolId(7))` and a narrowing warning as
`Primitive(Decimal)`. Neither is a type a reader can look up in their own
source, and a symbol id shifts whenever symbol counts change.

Add `ResolvedType::display_abl`, a wrapper that borrows the symbol table and
the schema — the type alone cannot name itself, since a class or buffer carries
a symbol id and a table carries a schema id no symbol table maps. Primitives
render their ABL keyword, a buffer renders the table it is over, an array
renders its element type and extent, and the two lattice bottoms get named
renderings so a future interpolation cannot fall back to a debug print.

Both message sites in the rule go through one helper, and a new test file pins
the property two ways: a battery of sources whose messages must contain no
internal id, and a scan asserting no rule debug-formats a value outside its own
test module.

Closes #152
An inherited member's type was parked in `SymbolTable::inherited_member_types`
so that attaching a workspace index could be merged on evidence that it changed
no diagnostic. That evidence has been collected; the valve has outlived its
purpose.

The type now goes onto `Symbol::data_type` at the point the resolve pass
synthesizes the member, exactly as a local declaration's would, and the side map
and both its accessors are deleted rather than bypassed. A cross-file resolution
was already shape-identical to a local one in `references`/`symbols`; it is now
shape-identical in the one place the rules read, so an assignment through an
inherited member is type-checked like any other.

The analyze envelope keeps `data_type_source`, which answers what the type alone
cannot — whether the declaration that supplied it is in this file or another one
the index reached — and derives it from `symbol_origin` so the two fields cannot
disagree about a row.

Three suites asserted the silence and now state the answer: the inheritance
sweep becomes an enumeration of which scenarios gain a finding and which stay
silent, the mismatch test becomes the positive assertion, and the pipeline's
firewall becomes the same enumeration one client down. New tests pin the
promotion's edges — a `VOID` member stays untyped, a property's type travels the
same path as a return type, a three-level chain carries the grandparent's type,
and a `:`-qualified call stays deliberately unjudged.

BREAKING CHANGE: `SymbolTable::inherited_member_type` and
`record_inherited_member_type` are removed; read `Symbol::data_type`.

Refs #102
A declaration whose `AS CLASS` name only the workspace index could find kept
`ResolvedType::Unknown`, with the resolved class parked in
`CrossFileState::indexed_receiver_class` where member resolution could read it
and the type lattice could not. `check.rs` did the same job one level up, typing
any index-synthesized class symbol as lattice bottom. Both existed so attaching
an index could add no finding; both are deleted.

The `Foreign` branch of `upgrade_class_types` now writes
`ResolvedType::Class` exactly as the `Local` branch does, and
`receiver_indexed_class` reaches the same answer through the declaration's own
type — one channel, not two. So a primitive assigned into a workspace-class-typed
variable, and a `NEW` of a workspace class assigned into a primitive, are the
mismatches they always were.

A synthesized class symbol also records the supertypes the index read from its
header. Without that, `ClassLattice` sees a class that inherits nothing and
reports a subclass assigned into its parent-typed variable — a false positive
minted by attaching an index, on the one shape inheritance widening exists to
allow. Its supertype spans point at the use site in this file, the header being
in another one.

The assignability suite's two cross-file tests asserted the mechanism, not just
the silence, so both now state the mechanism that replaced it: each is still
silent, and the lattice is why.

BREAKING CHANGE: an index-synthesized class symbol types as
`ResolvedType::Class` rather than `ResolvedType::Unknown`.

Refs #102
The cross-file suites existed to pin what changed on the day the rules turned
onto the cross-file population. Collecting that payoff: the `RUN`/`SHARED` sweep
keeps its zero but compares exact codes and spans instead of per-rule counts, so
a finding moving between codes can no longer be absorbed; the reason suite gains
producer tests that assert which situation mints which reason through a real
index, so "searched and absent", "not statically knowable", and "we did not
look" cannot collapse into each other.

`nothing_produces_the_new_reasons_yet` is renamed
`no_index_produces_no_cross_file_reason` and keeps its body. It was misnamed
rather than obsolete: it runs with no index attached and asserts that ordinary
ABL mints no cross-file reason, which is the invariant the `External` case rests
on and outlives the index landing.

Module docs say what each suite now asserts, so a reader arriving at an
enumeration does not have to reconstruct why it used to be a zero.

Refs #102
… all four clients

The `symbols` section goes to 4. No row key was added — `data_type` changed
meaning for a cross-file row. Its absence used to be a reliable marker for "this
row is a cross-file member", because the type was held off the symbol to keep it
out of the type lattice; it is populated now, and a consumer branching on the
absence would silently change behavior. `data_type_source` is the field to branch
on instead, and the module docs say so.

The parity table gains a row whose siblings make a finding **appear**. Every
cross-file row so far could only get quieter when its siblings arrived — the
removed `undefined-symbol` false positive — so `CrossFileEffect` gains a
`Judged` arm and `expected_without_siblings` stops being a superset of
`expected`. The new row declares both directions on one name: withheld, the
inherited call is an `undefined-symbol`; supplied, that finding goes away and the
parent's declared `INTEGER` assigned into a `LOGICAL` is a type mismatch. A
resolver that stopped resolving trips the first arm; one that resolved without
typing trips the second.

The containment check keeps its teeth. An addition is allowed only where a
`Judged` resolution declares it, and a declared addition that fails to arrive is
also a failure — so a finding conjured out of a cross-file resolution nobody
wrote down still fails the suite. All four legs, browser included, agree on the
new row.

Refs #102
`NotFoundInWorkspace` named four situations and only one of them was "absent":
a genuine path-search miss, an inherited member the class declares but does not
expose here, a member lookup that came up empty against a class the index did
answer for, and a file that was located and could not be parsed. That was
harmless while every rule skip-listed the reason. It stops being harmless the
moment `undefined-symbol` reports an absent name, because the rule would render
"no such symbol" over three situations where the symbol demonstrably exists —
including one that is a gap in oxabl's own parser rather than anything about the
user's code.

So the reason splits at the producer: `AbsentFromWorkspace` for the searches that
came back empty, `PresentButUnusable` for the other three. A rule then fires on a
situation rather than on an enum variant, and no per-rule exception list is
needed.

Telling a broken file from a missing one needs the index seam to say so, which it
could not: `IndexAnswer::NotFound` folded "located but unusable" in by
documented design. It gains `Unusable`, and both backends answer it from the
`parsed` flag they already had — the batch cache and the language server's salsa
queries alike. `ClassLookup` gains the same distinction so the analyze envelope
stops reporting a file that is sitting on disk as absent, and the `dependencies`
section goes to 2 for the changed reason strings.

R17 rides along, derived where `index_loaded` is: an index with no configured
search path answers NotFound to everything without having looked anywhere, so a
miss stays `External`. That is the truthful answer, and it keeps the browser —
which has no filesystem — from disagreeing with the CLI about a diagnostic.

Behavior-preserving by construction: both new reasons are skip-listed everywhere,
and the A/B across this commit alone is empty.

BREAKING CHANGE: `UnresolvedReason::NotFoundInWorkspace` is replaced by
`AbsentFromWorkspace` and `PresentButUnusable`; `IndexAnswer` gains `Unusable`
and `WorkspaceIndex` gains a defaulted `searches_any_path`.

Refs #102
`undefined-symbol` now fires on `AbsentFromWorkspace` as well as `NotInScope`.
ABL cannot reference a symbol or a procedure whose code is not on the PROPATH, so
a `USING`, a `NEW`, or a literal `RUN` target that no configured path supplies is
genuinely undefined rather than merely unseen. The rule's positive `if let` on the
one reason it reported becomes an exhaustive wildcard-free match, so the next
reason has to be decided about here rather than absorbed silently, and the finding
carries a help line naming the search-path configuration — a missing source root
produces the same finding as missing code, and that line is its whole remediation.

The corpus said the first cut over-reported by more than twenty times, in classes
that are all correct ABL, so four narrowings landed with it. Each is a case where
a path search is not the mechanism the AVM would have used, which makes a miss
prove nothing:

- `RUN name IN handle` resolves an entry point in another *running* program;
  no file could supply it, so it is `Unknowable`.
- An extension-less `RUN name` means an internal procedure first, including any
  registered `SUPER PROCEDURE`'s. oxabl models neither, so the miss is
  inconclusive and stays `External`.
- A bare class name searched under a shipped namespace — `USING
  Progress.Json.ObjectModel.*` and `NEW SomeShippedClass()` — is checked against the
  spellings actually *tried*, not the reference's own, so the AVM's class library
  is carved out wherever it is reached from.
- A located file that does not visibly declare the class is `Unusable`, not
  absent: the declaration may be spliced in from an `{include}` the index does
  not expand, which is an ordinary ABL idiom.

Two real defects surfaced on the way and are fixed here rather than worked
around. A literal `RUN` target accepted a dotted second segment anywhere on the
same line, so `RUN write-header. RUN build-list.` produced the target
`write-header. RUN`; adjacency to the period now distinguishes a dotted name from
a statement terminator. And the name search applied the *walk's* extension set to
a target the author spelled out, so `RUN util/row-count.pp` reported a file
that plainly exists as absent — a `RUN` target may now carry any extension but
`.i`. An index's self-exclusion answers `Unusable` too, so a program that runs
itself persistently is not told its own path is missing.

`check --json` goes to 3: a diagnostic row carries its `help`.

Refs #102
`DELETE OBJECT` was one of the ~30 recognized-but-unmodelled forms: matched by
keyword, skipped to the statement end, and every identifier it passed over
harvested lexically and marked `TOUCHED_BY_UNMODELLED_STATEMENT`. That mark is
per-symbol and file-wide, so one `DELETE OBJECT` silenced the three count-gated
rules for names elsewhere in the file — measured across a large real-world
codebase, it is one of the two forms that dominate the suppression.

It skipped for a real reason: the operand is an expression, so
`Delete { buffer: Identifier }` could not hold `DELETE OBJECT ttbl:HANDLE.` or
`DELETE OBJECT hArray[i].`. Hence a new `StatementKind::DeleteObject` carrying
the target as an `Expression` plus the `NO-ERROR` flag. The resolve pass walks it
like any other expression, so the handle is credited an ordinary read, and
nothing in the statement is marked.

Worth noting what this removes: `DELETE PROCEDURE`, `DELETE WIDGET`, and
`DELETE SERVER` already fell through to a real `Delete` node. Only the `OBJECT`
spelling skipped.

`docs/design/ast-invariants.md` §8 describes the new node in the same commit, per
the repo's standing obligation for a public `oxabl_ast` change.

BREAKING CHANGE: `StatementKind` gains a `DeleteObject` variant; an exhaustive
match over it must handle the new arm.

Refs #102, #136
`COMPILE some/path.p SAVE.` was skipped like any other unmodelled form, which
harvested every identifier-shaped token it passed over — the path segments and
`SAVE` — and marked each one `TOUCHED_BY_UNMODELLED_STATEMENT`. None of them is a
symbol reference: the operand is a file path. So the harvest credited nothing true
and suppressed the count-gated rules for any real variable whose name collided
with a path word or with `SAVE`, file-wide. Measured over a large real-world
codebase, this is the second of the two forms that dominate the suppression, and
the one whose share of it is entirely spurious.

Head-parsing it would be worse than deleting the harvest: crediting reads inside a
file path would invent references that do not exist. So the form keeps emitting
`Skipped` — it *was* recognized, which is a different fact from a parse failure —
with an empty name list, through a narrow `skipped_stmt_no_names` constructor
beside the existing helpers rather than an `Option`-ised parameter at thirty call
sites. The `#[must_use]` discipline on the skip helpers is intact.

`ast-invariants.md` §8 now records both shapes #136 chooses between:
symbol-shaped operands earn a head-parse, path-shaped ones earn an empty name
list.

Refs #102, #136
…s measured tail

The status section and the handoff both described a resolver deliberately walled
off from the rules. That wall is gone: three valves deleted, `undefined-symbol`
reporting names absent from the configured search paths, and two statement forms
drained out of the unmodelled-statement suppression. `CLAUDE.md` now says what the
four unresolved reasons license a consumer to claim, which envelope sections moved
and why (`symbols` 4 for a *changed meaning* rather than a new key, `dependencies`
2 for split reason strings), and which single valve stays closed — `:`-qualified
member access, the larger half of the population and its own piece of work.

`HANDOFF.md` gains the session's own section, including the part most worth
carrying forward: the first cut of the absent-name rule over-reported by more than
twenty times, and every one of those findings was correct ABL. The four narrowings
that fixed it are each a case where a path search is not the mechanism the AVM
would have used, and the two real defects found on the way are named so nobody
re-derives them.

#136 keeps its scope but loses its guesswork — the issue carries a measured
ranking of the remaining forms and the two-shape taxonomy that decides each one's
fix.

Refs #102, #136
The comment, test fixture, and handoff examples added alongside the `RUN` target
and extension-policy fixes carried incidental names rather than obviously
synthetic ones. Swapped for neutral placeholders, which is what every other
fixture in these crates uses.

No behavior change.
ABL on UNIX takes backslash as an alternative escape character, and it is
legal outside a quoted literal — so `ab\cd` names the identifier `abcd`.
The lexer treated a backslash there as `Invalid`, which turned an
ordinary identifier into a parse error and cost the whole statement.

Escape markers are removed from the compiler-visible name while the
authored span is preserved, so a diagnostic still underlines what the
author wrote. The existing tilde escape and the backslash line
continuation are untouched.
Four corrections to the newly-judged cross-file population, each a case
where a name was judged on evidence that did not apply to it.

A wildcard import of a namespace the AVM ships no longer exempts every
type name in the file. The carve-out exists because a *bare* name under
such an import could be a shipped class, so a path-search miss proves
nothing; a qualified spelling can never be supplied by a wildcard, so one
`USING Progress.Lang.*` was silently exempting unrelated qualified typos.

`USING ... FROM ASSEMBLY` and `FROM PROPATH` are carried on the AST
instead of being parsed and discarded. An assembly-supplied type has no
source on any path, so searching for one and reporting the miss made
`undefined-symbol` fire at error severity on code whose author had already
said where the type comes from.

An imported supertype resolves by symbol identity rather than by the
spelling in the header, so `CLASS child INHERITS base` under
`USING pkg.base` reaches the same class the reference does. Matching by
name meant the inheritance edge was missing, and a legal widening
assignment was reported as a type mismatch.

An overload set is judged by the return type its members agree on. Only a
genuine disagreement goes to the lattice bottom. The previous rule erased
the type whenever a name appeared more than once in the reachable
surface, which is an `OVERRIDE` or an inherited interface contract far
more often than an overload — so it unjudged most real OO-ABL, including
the inherited-member typing this population was opened to reach.
CLAUDE.md picks up the corrected cross-file behavior, and the agent
guides referenced from it (issue tracker, triage labels, domain) are
added so those references resolve.
…ides

The A/B instrument collected five rules. LINT0004 was not among them —
the script was written for suppression work, before cross-file typing
existed — so the rule that cross-file type resolution moves reported no
delta rather than reporting zero. Adding it surfaced both a 44% increase
from the type valves and four findings that were being judged on an
arbitrary overload's return type, neither of which was visible before.

The second half is about inputs. A collection's most consequential input
is the `oxabl.toml` the run discovers, which need not be named on the
command line and, for a corpus kept outside the repo, is typically
untracked. Two collections days apart can therefore be driven by
different configs, and nothing in the resulting JSONL shows it: the ratio
still computes, still looks plausible, and attributes a config change to
the code change.

Each `collect` now writes a manifest of the inputs it ran under — rule
set, config hash, an environment-override hash, corpus revision — and
`diff` exits 5 rather than comparing two sides that disagree. A side with
no manifest still diffs, with a warning, so an older collection is not
stranded. The manifest carries only hashes, counts and revisions, never a
path or a corpus fact, so it can be published beside a ratio to make that
ratio reproducible.

Same failure class as the defaulted coverage-key lookup this script
already guards: silence that reads as a measurement.
The recorded LINT0001 ratio came from a run driven by an out-of-repo
`oxabl.toml` that is discovered by walking up from each analyzed file, so
it never appeared on the command line, and was untracked. Two runs days
apart used different include paths and nothing in the output said so.

Re-measured against a pinned config, and the section now says which
figure to cite and why the old one is gone. The instrument enforces this
now rather than relying on the reader.
@evanbrobertson
evanbrobertson merged commit a1e03e6 into master Aug 4, 2026
10 checks passed
@evanbrobertson
evanbrobertson deleted the feat/judge-cross-file-population branch August 4, 2026 18:02
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.

lint: LINT0004 prints ResolvedType with {:?}, leaking an unstable internal SymbolId into the message

1 participant