Latest Results
feat(daemon): discovery and a socket listener the daemon owns
A client has to find a running daemon for a workspace root, or start one, and a
crashed daemon must not leave a client waiting on a socket nobody holds. Discovery
is a registration file per root under the XDG cache directory, recording pid,
socket path, and contract version. A registration whose pid is not alive is
treated as absent and replaced — liveness is checked rather than the socket
tried, because a stale socket can accept a connection that is then never answered
and no timeout is right for both a cold start and a busy pass. Writes go through a
rename, so a concurrent reader sees the old registration or the new one and never
a half-written file.
The daemon owns its accept loop because it has to: the transport crate's listen
helper binds and accepts exactly once, and its socket transport is crate-private
and TCP-only. What is public is the part that matters — message read and write,
and the connection's channel fields — so the framing and the request ids are
reused and only the socket type differs. A thread per client, over a Unix socket,
where filesystem permissions do the access control.
A second daemon on one root is refused rather than allowed to steal the socket: a
socket file whose owner is alive belongs to that daemon, and unlinking it would
leave the running one unreachable while this one served a second index over the
same workspace — the exact duplication the daemon exists to prevent.
Handlers now receive a session host rather than a mutable session map, which puts
the locking rule where it can be read: take the lock to write or to clone a
snapshot, then release it before querying. A handler that queries under the lock
serialises the whole daemon, and that is the difference between one client's slow
answer costing that client and costing everyone.
**Unfinished, and the tests say so.** The per-client framing threads and the accept
loop do not tear down cleanly, so `tests/multi_client.rs` blocks rather than
failing. Every test there is a U7 acceptance scenario and all nine are `#[ignore]`d
with that reason, because a hang stalls CI indefinitely instead of reporting.
Discovery is fully covered and green. The launchable `oxabl daemon` artifact is not
wired yet either. test(parity): hold every client to the same dependency edges
The parity table observed diagnostics and the format decision, so a reverse
dependency query was invisible to it: two clients could disagree about what a
file depends on and every leg would stay green. The table now carries an
expected-edge channel, and the pipeline leg asserts it exactly.
Each claim states what withholding the fixture's siblings does, because the
withheld half is the load-bearing one — a row that only pinned the supplied
answer would pass just as well if the edges arrived unconditionally. Two new
rows cover the kinds no existing fixture reached: real nested includes, where
direct and transitive must not be confused, and a `RUN` target beside the file
whose `DEFINE NEW SHARED` the root consumes. All six kinds are now claimed by
some row, and a test says so, so a kind cannot quietly become unobservable.
Two claims deliberately pin a *gap* rather than the answer we would prefer.
Withholding a class named only by `USING`, `NEW`, or a declared type leaves no
unresolved row, and neither does a missed `RUN` target or `SHARED` producer:
those links are recorded only when the index answers. The findings still reach
the diagnostic channel, so nothing is under-reported to a user — but the edge
set is silent, and writing that down is what will make the fix visible when
edge-kind fidelity work lands.
A cross-file row can now need a capability the browser lacks, so the browser's
cross-file comparisons filter on comparability the way its other ones already do.feat/reverse-dependency-edges feat: judge the cross-file population, and drain the top of the unmodelled-statement suppression (#153)
* fix(lint): name ABL types in type-mismatch messages instead of internal 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
* feat(semantic)!: judge an inherited member's declared type
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
* feat(semantic)!: type a class-typed declaration from the workspace
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
* test(lint): state the cross-file contract as answers, not as silence
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
* feat(analyze): announce the judged cross-file type, and pin it across 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
* refactor(semantic)!: split the overloaded cross-file unresolved reason
`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
* feat(lint): report a name absent from every configured search path
`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
* feat(parser)!: head-parse DELETE OBJECT so its handle is a real read
`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
* feat(parser): stop COMPILE harvesting its own file path
`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
* docs: record the judged population, the drained suppression, and #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
* docs(parser,index): use synthetic names in the new examples
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.
* fix(lexer): accept UNIX backslash escapes outside string literals
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.
* fix(semantic): stop four cross-file shapes reporting or erasing wrongly
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.
* docs: record the cross-file corrections and the repository agent guides
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.
* fix(scripts): measure LINT0004, and refuse to diff two incomparable sides
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.
* docs: retract an A/B ratio whose config could not be recovered
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. Latest Branches
-14%
feat/reverse-dependency-edges 0%
0%
fix/fragment-root-analysis © 2026 CodSpeed Technology