Skip to content

Gradual mode: publish where type information reaches - #211

Merged
philiplindberg merged 27 commits into
mainfrom
gradual-mode
Aug 5, 2026
Merged

philiplindberg merged 27 commits into
mainfrom
gradual-mode

Conversation

@philiplindberg

@philiplindberg philiplindberg commented Aug 5, 2026 •

Copy link
Copy Markdown
Collaborator

Gradual mode: publish where type information reaches

rip check packages on main reports thousands of errors across most of the repo. On this branch it reports dozens — a list short enough to read, where each entry is a decision worth making rather than noise to wade through. Run it both ways; the change in kind is the claim, not any particular number.

Nothing was suppressed to get there. The old model filtered by diagnostic code — a fixed list of families to hide — and that cannot hold a line: the same code means "the author asked for this" in one file and "nobody wrote a type here" in the next, so the list either drowns real defects or hides them. main's count is what that looks like once a repo grows.

The rule

Gradual mode publishes a diagnostic where type information actually reaches the line: an annotation in the declaration's header, the compiler's own types (schemas, components), flow along assignment, or an import of a typed export.

Inference alone never publishes, and that is the one part worth arguing about. Measured across the corpus, what inference produces over unannotated Rip is overwhelmingly confident errors about correct code: a parameter typed from its = {} default, so every legitimate opts.foo is "does not exist on type {}"; an object built by spread and later read for another key; Bun APIs flagged because @types/bun is not installed. The case it would genuinely catch — answer = 42 later misused as a string — does not occur anywhere in this repo. So the trade is not free inference against a tidy rule: it is a large volume of wrongness about code whose author declined to annotate, against a class we could not find a single instance of. An annotation remains how you ask for more.

Names and modules that don't resolve, and definition cycles, publish in every mode. No annotation answers a typo.

rip.strict publishes everything, unchanged.

The gate lives in packages/vscode/src/scopes.js, shared verbatim by the editor and rip check, and it fails open: a source the lexer refuses publishes everything.

What it found

The point of a readable corpus is that real defects stop hiding in it. Getting there turned up:

  • Three crash bugs in packages/db (08c1ccc). exec, listTables, and describeTable each returned { success: false, error, … } — shorthand for a binding named error that doesn't exist; the catch binds err. Every error path threw a ReferenceError from inside its own error handler. Runtime-confirmed before fixing.
  • A language gap in packages/stamp (9c5e962). globalThis.sh ??= sh was already the idiom; the compiler just didn't read it as a declaration. It does now — typed, and scoped to the declaring package via an automatic project boundary, so a non-importing neighbor still gets its cannot-find. The package went quiet with no edits to it.
  • TypeScript 7 turns strict on by default (cc890e7). Probed head-to-head against 5.9: an empty config flags let x: string = null on 7 and passes on 5.9. Gradual now rides that default and subtracts three named loosenings rather than guessing at sub-flags, so a future strict-family member arrives visible instead of silently degrading inference.
  • A silent wrong-code bug in the lexer (98220cd). A generic cast ending an indented block (return g(x) as D<T>) had its trailing > read as a comparison awaiting an operand, which swallowed the dedent and nested the next statement inside the block as dead code after the return. Cast heads now end their line like every other type head.
  • An annotation that had already been retired (d436007). 9275b58 deliberately removed the provisional : anys from the server reconstruction; the next day's burn-down rewrote the line and carried one back in. That single annotation was manager.rip's only one, and it opened most of the file.

How it's gated

test/audit/corpus/gradual/ is a suppression matrix, not a snapshot:

  • held.rip carries every family gradual holds — code-suppressed, gate-held, posture-held, floored, install-advised — with no directives, so it doubles as an in-tree canary: any hold regressing turns rip check red on its own.
  • The same text is measured again under strict, where every family must publish. A family quiet in both modes is reported as vacuous, never a pass — that is what stops the matrix from certifying whatever the checker happens to do today.
  • published.rip pins the other side: reach by annotation, by flow, by construction, and the always-reported defects.
  • rip check runs over the same pair and must agree with the editor, which is the seam where the two surfaces previously drifted.

Two contract invariants gate it. Teeth verified by sabotage: reverting the posture reds gradual.held with the exact families that leaked.

Also here

  • A check answers for the paths it was given (9d6ffff). The closure is still compiled and checked whole, but a dependency's diagnostics report through its own check — previously a package couldn't go green while anything it imported was red.
  • test/corpus declares itself a no-check zone (7d426e2). It's a compilation fixture set whose free names are deliberate; its own gates enforce a syntax floor and designated clean rows. Files stay in the program, so hover and completions keep working.
  • A member's type answers for the member, not the component (e6b1ea5). A component member's type is rendered twice — the class declare and the same-name companion interface — and the companion flattened its segments, so the unmapped bytes fell to the component's cover. One unresolved type name painted every line of its component red while the Problems count still read one, in every component in the repo. Member lines now carry their own cover with the annotation nested inside, so a computed cycle still anchors per computed while an annotation fault anchors on the annotation.

Found, and fixed

Porting examples/cart from v3 surfaced three v3→v4 regressions, each verified head-to-head against v3 on identical — in one case byte-identical — source. All three are fixed here, and cart now checks clean.

  1. Type-only imports were no longer elided. v4 emitted a real JS import for a name used only in type positions, so the module died at load with SyntaxError: Export named 'X' not found — while rip check reported "No type errors". A green checker on a program that cannot load. Elision is local analysis, as it was in v3: a name that appears in a type-role span and nowhere in the value tree. Pinned by writing both modules and importing one, because nothing short of loading the emitted module can see this — the face type-checks and the JS looks reasonable either way.

  2. Schema derivations got no type companion. A declared schema emits a value and a same-name type; UserPublic = User.pick(…) emitted only the value, so annotating with it drew TS2749. The companion is now the resolved shape, projected by the folder that already backs the browser bundle — so a derivation types as what the runtime builds rather than as a second spelling of the algebra, and reads as a shape rather than a Pick<…> to apply. The two computations are checked against each other: a folder drifting from projectableFields fails at the checker instead of shipping a shape nothing computed.

  3. The reactive container's face type omitted touch. The <=> lowering calls .touch?.() on a chain's root container, which the type declarations didn't admit. The seam now has two spellings, chosen by how the slot got its container: a slot holding one __state minted says touch(): void, so a consumer calls it unguarded; a slot that accepts a container says touch?(): void, because the sharing contract admits a caller-supplied { value, read } that the runtime treats as a container but which has no touch. Spelling the first optional was measured, not assumed — under rip.strict the guardless call draws TS2722 on a notify that cannot be absent.

Still open from the same port: dynamic import! targets don't join the closure, so a consumer sees a phantom TS2307 inside a dependency that the dependency's own check can't reproduce.

Deliberately not fixed here

The corpus still reports diagnostics in packages/app and packages/server. They are not oversights — each is a decision rather than an edit, and the decisions travel in their own PR. The largest is one shape, not a list: an implicit return reaching a slot typed void | T, where TypeScript's void-return exception does not apply through the union. That wants a compiler-level answer, which is why no site is patched here.

Both packages are under active work, so read the current output rather than anything quoted here.

Review notes

  • Docs are in docs/TYPES.md (§ Diagnostic publishing, § Declared globals, § Project configuration). There is no standalone design doc on purpose: the design lives in the code it governs, the doc, and the tests.
  • main was briefly force-rewound during this work to undo commits that leaked onto it. If you fetched on 2026-08-05, re-fetch.

Suppressing by diagnostic code cannot be gradual: a missing annotation
is born in the implicit-any family (suppressed) and dies fifty lines
later as an ordinary type error (published) on a line the author never
annotated. The honest axis is the declaration, so the gate answers one
question per line: does type information reach it? Inward from an
annotated header, outward from an annotation in a declaration's header,
sideways along assignment to a fixpoint, and across an import of a
typed export — an annotated .rip export, a relative .ts module, or a
bare workspace .rip package, resolved the way the runtime resolves it
(node_modules walk-up, manifest exports, realpath; the generated
tsconfig maps each package name onto its mirror face, and rip check
anchors the mirror at the nearest workspaces-declaring root).

Schemas and components are typed by construction — the face
materializes their member types whether or not the author spelled one —
and two defect families publish in every mode because no annotation
answers them: names and modules that do not resolve, and definition
cycles. Inference alone never publishes: the misused unannotated export
stays silent until someone writes a type, which keeps the rule statable
— you get diagnostics where type information reaches — and rip.strict
reports everything. The gate fails open, and the posture is pinned in
both directions. Gradual also supplies strictNullChecks: false, yielding
to any strictness the project's own tsconfig chain sets.

Two compiler fixes ride along on their own merits: the face's class
field declarations now come from every instance method body, not the
constructor's alone (a constructor that delegates field setup to a
helper establishes those fields just as surely), and the trailing run
of bare unannotated parameters emits a TS-only optional marker, since
calling with fewer arguments is legal in rip as in JavaScript — with
carve-outs wherever something else already types the parameter.

Editor: the three gate construction sites share one resolution and one
plain-compile memo (the cold-open double compile measured, then
removed); a dependency touched on disk refreshes its open importers,
whose gates can change; a drifted mirror heals by re-materialization
instead of stranding cross-file asks (a pinned face outliving its
session was unreproducible by design); and go-to-definition from inside
an import specifier names the whole string literal as its origin, both
quote spellings, where the word pattern underlined one path segment.

rip check packages: 1899 diagnostics before, 274 after, measured at
this branch's base — the survivors readable one by one, cannot-finds
down to genuinely-uninstalled modules. All four suites green: root
6091, audit 34, extended 54, editor 166.
Main's CI runs the extended tier over every test file; the branch had
only ever run it over check.test.js, and six never-executed tests met
the gate. Two exposed real gaps, now fixed in scopes.js: assigning to
an imported binding (TS2632) is a runtime TypeError no annotation
answers, so it joins the always-reported family beside the module
boundary's other cannot-finds (2305, 2613, 2614, 2724 — importing a
member that does not exist is a typo'd name spelled at the import); and
exportedNamesOf never listed the reactive kinds, so an annotated
`export count: number := 0` silently failed to carry its type to any
importer. The other four asserted diagnostics on unannotated fixtures —
each now rides an annotation so it still pins its real subject: the
reactive-cell limit publishes its TS2365 through a typed store, the
await-hint positions light their sugar lines, and the host-floor tests
read their probes through annotated bindings, since an ambient global
never opens the lines that merely mention it.

The hidden-diagnostics summary now says one thing one way: both lines
that offer strict spell the remedy identically, the missing-types
advisory names the declarations it is about (`fs`, `describe` — not
"install the @types package" with no noun), and when the hiding happens
in a dependency the target does not govern, the line names that
project — config is per file, so a strict consumer's check still hides
its gradual dependencies' counts, and pointing at the wrong
package.json read as the flag being broken.

`rip check --build` prints the build identity — the same content hash
over the compiler and server trees the editor computes for its cache
key and now logs in its ready block — so when the CLI and the editor
disagree, one glance says whether the installed extension is stale.
Both outputs are one aligned fact per line, home shortened to `~`, the
mirror shown workspace-relative: the single-line forms wrapped
illegibly the moment real paths landed in them.
.rip/check now follows .rip/editor's doctrine: left in place between
runs (self-gitignored, inspectable), with correctness carried by the
start-of-run wipe rather than exit-time deletion. Only the tmpdir
fallback is still removed on exit. This also stops the .rip dir from
flickering in and out of editor file trees on every run. --keep-mirror
described the new default, so it is gone; passing it now fails as an
unknown option.
Stamp's directives read `sh`, `ok`, and `run` bare — the DSL's design
says handlers import nothing, and the runtime installs the vocabulary
with `globalThis.sh ??= sh` at module load. The checker could not see a
runtime assignment, so every use drew the cannot-find defect: 144
diagnostics, half the corpus, all one pattern. The pattern is repo-wide
(ui's focus tracker, app's launch globals, fetch mocks in tests), and it
splits into two species exactly at the operator: `??=` says "install
unless someone already did" — a declaration wearing runtime clothes —
while plain `=` is an overwrite (a mocked fetch must not redeclare the
host's) and a non-top-level install is lifecycle state (app's guarded
__ripApp, deleted on destroy, is not ever-present vocabulary).

So the face now reads the declaration where Steve already wrote it: a
top-level `globalThis.NAME ??= expr` emits a TS-only declare-global
block, typed through a module-level alias — inside `declare global` the
bare name resolves to the global being declared (TS2502, driven against
real tsc), so `typeof` is taken where the name still means the module
binding. Identifier initializers carry their type; keyword literals
(`??= null`) declare `any`. Strip identity holds.

The declaring package becomes its own program — an automatic project
boundary anchored at its package.json, built by the same wrapper
machinery nested tsconfigs use, now able to anchor on the workspace
root's config when the package owns none. Scoping is runtime-honest:
the vocabulary reaches the package and anyone who imports it (an import
runs the installer), while a non-importing neighbor keeps its
cannot-find — the typo protection ambient-everywhere would have spent.
The editor builds the boundary three ways (the cold stub scan, a
dependency's face materializing, an open buffer's compile) and its
declaration-only stubs carry an any-typed twin of the block, so a
cold-open handler resolves the vocabulary before anything compiles.

packages/stamp: 144 diagnostics to zero. The corpus halves, 292 to 148.
db's exec/listTables/describeTable catch blocks returned
`{ success: false, error, … }` — shorthand for a binding named `error`
that does not exist; the catch binds `err`. Every error path those
envelopes exist for threw ReferenceError from inside its own handler
(driven: the compiled catch emits `error` bare and Bun throws on first
use). The checker had said so all along — TS2552, "Did you mean 'err'?",
the always-reported family gradual never hides — but the report sat in a
1,899-diagnostic corpus nobody could read. At 148, someone read it.

`error:` now carries `err.message or String(err)`, the string shape
every sibling envelope in the file already uses. db's own suite: 164
passing before and after.
Two halves of one ruling about who owes what to make a squiggle move.
Gradual owes forgiveness: `import { Database } from 'bun:sqlite'` is
ordinary Bun code, and without installed host types it drew the
cannot-find defect on a module the runtime demonstrably serves — nobody
should install @types/bun just to quiet an import that runs. One
bodyless wildcard (`declare module "bun:*"`) floors the builtin modules
as `any`, with the floor's existing gates: an exact declaration from
installed types outranks a wildcard by TS's own pattern rules, and the
whole floor vanishes the moment the package is present.

Strict owes complaints — and was not getting them: floors and null
posture are per-PROGRAM, so a nested package flipping rip.strict kept
receiving the root program's gradual floor (driven: a strict
packages/ai showed no squiggle on bun:sqlite). A nested rip.strict
package now becomes its own program, the same automatic boundary a
globals-declaring package gets, so its own strictness governs its
floors and its nulls. The gradual sibling beside it stays floored.

Also: every comment in mirror.js swept to its constraint — the driven
findings stay, one clause each; the mechanism narrations repeated per
entry are gone (155 lines).
The check BFS no longer marks the run incomplete when a queued import's
module does not exist as specified — ENOENT, ENOTDIR, ELOOP — because
tsgo already owns that report: TS2307 on the importing line, or silence
under @ts-nocheck, whose writ covers the file's imports. The predicate
(missingModuleRead) lives in mirror.js beside ripImportsOf, the producer
of the unchecked edges. Explicit targets and imports that exist but
cannot be read stay loud: skipping those would buy the importer a
"cannot find module" that misstates the problem.

Review fixes riding along: the mirror wipe is unconditional (a run
whose targets all fail to parse still clears stale faces), the tree is
stamped with the compiler build identity in .build, the tmpdir fallback
announces itself (tsconfig/@types fidelity degrades), the fallback
cleanup and unwritable-workspace path gained coverage, and the
unreadable-file tests share one withUnreadable helper.
…not a typo

csv's bench quarantines its competitor parsers behind its own manifest —
by design, never in @rip-lang/csv — and installing them changed nothing:
the face's module walk lives in the MIRROR tree, which never passes
through nested source directories, so bun ran imports tsgo called
cannot-finds. The mirror now plants a node_modules symlink at every dir
whose source twin carries one (both surfaces, best-effort), and the
generated excludes harden to **/node_modules so the linked trees never
join the program as files.

That alone made rip check depend on optional install state — a fresh
clone would go red until someone installed a benchmark's competitors.
So the absent half gets its own posture: a bare import DECLARED in the
governing package.json but not installed is the manifest's stated
intent, and gradual holds its 2307 while the summary names the remedy
("run bun install in packages/csv/bench"). Strict publishes it —
complaints mode, like the floors — and undeclared-and-uninstalled stays
a published defect everywhere: that one really is a typo's shape. Both
halves driven live: installed, the typed benchmark checks clean;
removed, the corpus stays green with the advisory.
# Conflicts:
#	packages/vscode/src/mirror.js
#	src/check.js
The gate read the face's TS-only regions as type information, and every
region is emitter output: swarm.rip carries not one annotation, yet
three bang-defs' lowered `: void` return types made their names typed
bindings and the sideways cascade opened most of the file — ten
diagnostics on code nobody asked to check, the exact posture violation
the mode exists to prevent. Pin annotations are regions too, so the
probe pass would have re-admitted held inference through the same door.

The region pass is gone. Author annotations are counted from their TYPE
tokens (bare forwards included — the old claim that they lex as object
literals is stale), wholesale-typed constructs by KIND, and nothing the
emitter writes counts as intent. swarm: ten diagnostics to zero with no
annotation added; the corpus drops 140 to 130. Comment narration
stripped from the files this touched: comments state constraints, and
the discovery story lives here instead.
tsgo flips the strict default — an empty config now flags
`let x: string = null` and hands catch bindings `unknown`, where
TypeScript 5.9 accepts both. Gradual's posture had been patching the
leakage one sub-flag at a time (strictNullChecks); the whole strict
family was riding in behind it: useUnknownInCatchVariables demanded
narrowing ceremony on unannotated `catch err`, and noImplicitThis
published member reads inside object-literal methods.

The posture now says what it means: `strict: false`, whole. The
generated config's explicit `noImplicitAny: true` outranks the umbrella,
so evolving-array and evolving-null inference keep working (probed on
tsgo directly), and a chain that sets its own strictness is still
yielded to whole — pinned both ways: gradual catch bindings publish
nothing, an author's strictNullChecks keeps the unknown (TS18046).

19 corpus errors were this leakage, not code: packages 130 -> 111.
9275b58 ("Keep the reconstruction dynamically typed") removed the
provisional `: any` annotations from the server reconstruction on
purpose — dynamically typed until the shapes settle. The next day's
clean-room burn-down (9d6e9ca) rewrote the `args = {...}` line with the
richer argument set and carried the annotation back in from the older
copy.

That one `: any` was manager.rip's only annotation, and an annotation —
any annotation — is how a declaration asks for checking: it seeded
`args`, and the assignment-flow fixpoint opened 924 of 1,571 lines, all
27 of the file's published diagnostics among them. Removing it restores
9275b58's intent and the file goes silent: packages 111 -> 84.
The scanner classifies a line-ending `>` as either a generic close (the
line is finished) or a comparison operator (the next line continues
it), by the head the angle run hangs off: return-type and declaration
colons, alias `=`, interface heads. Postfix casts were not on the list,
so `return g(x) as D<T>` ending an indented block kept the comparison
reading — the dedent was swallowed and the next statement nested into
the block as dead code after the return, silently. A sibling `if` at
least failed loudly as a stray postfix-if.

Cast heads now record the same answer the other heads do
(syncTypeGenericMemo), using rewriteTypes' own cast trigger. Battery
pins both shapes, the silent one by its exact emitted structure.

Found writing the first block-final generic cast in the repo — the
packages/app brand-cast cleanup in the next commit.
45 of app's 71 gradual diagnostics were two declarations narrower than
their own runtime. Passport spelled `compiled` (and `source`) required
when populate accepts their absence — `| undefined` still demands the
key; only `?` makes it omittable, and asset passports never carry
source. ApplyOpts demanded Promises from callbacks absorb bang-awaits
anyway; remountDirty now admits sync verdicts, and escape is exactly
`=> void` — the union spelling would forfeit TypeScript's void-return
exception, which IS the contract: result ignored, possibly awaited.

The implementation seams state their nature where they sit: the
__ripSource phantom brand is cast on at source()'s returns, the family
function-with-members is `any`, unwrapStash casts past its RAW escape
hatch, createStash defaults `{} as D`, and the watcher Sets carry their
callback signature. The resetSources SIGNALS guard is gone — for own
lowers to for..in, where a symbol key can never appear.

Deliberate-garbage tests (`ws.read(42)`, `createApply({})`) carry
`# @ts-expect-error`: every directive absorbs a live error today, and
TS2578 flags any that go stale. packages corpus: 84 -> 13, app 0.
test/corpus is a compilation fixture set, not a type-correct program:
free names are its idiom, and its own gates enforce a TS1xxx syntax
floor plus designated clean rows. A repo-root `rip check` (and an
editor opening a fixture) should therefore see silence, not 177
deliberate unresolveds. The marker package.json carries rip.noCheck
for the whole directory — config living with the thing it describes,
same as test/audit's own package.json — and the files stay in the
program, so hover and completions keep answering.

Root `rip check .`: 206 -> 29, every remainder a real program
(examples/cart, parked packages/server, the disposable tmp-consumer).
corpus/gradual is the Diagnostics Audit's second mode: held.rip carries
every family gradual holds — code-suppressed, gate-held, posture-held,
floored, and the declared-but-uninstalled hold — one section each, with
no directives, so the file doubles as an in-tree canary the moment any
hold regresses. The same text re-measured under the corpus's strict
config must publish every family gradual-pins.json pins: a family quiet
in both modes is `vacuous`, never a pass, which is what makes the pair
proof against a toolchain default flip rather than a photo of today's
suppressions. published.rip pins the other side — reach by annotation,
by flow, by compiler-typed construction, and the always-reported
defects — and `rip check` runs over a stripped copy so the CLI and the
editor prove they share the gate. Two contract invariants gate it all.

Building the pair surfaced two defects the same day.

The held fixture leaked on its first run: a gradual package nested in a
strict workspace rode the strict program's posture and refused floors —
the mirror image of the nested-rip.strict case. The auto boundary now
follows the MODE FLIP in either direction (check.js, server.js), and a
tsconfig above the flipped package no longer swallows the boundary,
since a wrapper's posture is the wrapper's, not the package's.

The vscode suite then caught `strict: false` collapsing every type the
face routes through a `.call` — component computed members hovered
`any` — because strictBindCallApply had silently left with the family.
The posture is now spelled in the OTHER direction: gradual rides
TypeScript 7's default strict and subtracts exactly three named
loosenings (strictNullChecks, useUnknownInCatchVariables,
noImplicitThis). A future strict-family member therefore arrives ON,
where any noise it brings leaks into the canaries loudly, instead of
OFF, where it degrades inference silently.

Teeth verified by sabotage: reverting the posture reds gradual.held
with the exact leaked families, on both instruments. Full audit 36/36;
packages corpus stays 13, repo root 29.
The README described the example rather than stating anything the
example could not: a hand-kept Layout table that had already drifted
(it credited index.rip with the migrate/seed that setup.rip owns) and a
roadmap sentence promising a swap to @rip-lang/db. index.rip's header
comment restated the README's opening line and pointed at it. Neither
survives the rule that a doc says what must stay true; the directory,
the imports, and three statements say the rest.

The two mutation handlers go back to v3's plain calls. A bang inside an
arrow makes the arrow async — `(-> placeOrder!)` is `async function(){
return await placeOrder(); }` — so the click and submit handlers had
quietly become async while the sweep around them read as formatting.
Nothing consumes the result and createMutation routes its own failures
through onError, so the await bought nothing it did not also hide.

Bang-calls keep the paren-less spelling AGENTS.md documents.
The closure must be compiled and checked whole — a target's types
cannot resolve otherwise — but reporting it whole made every file in
the closure the caller's problem. Checking examples/cart/api returned
one diagnostic of cart's own and five from packages/server, and exited
non-zero for all six, so a package could not go green while any
dependency it imports was red. One of those five was worse than noise:
the phantom TS2307 on a dynamic `import!` target appears only from a
consumer, so cart's author saw a defect server's own check cannot
reproduce.

Diagnostics now report only for the requested paths. A dependency's
count rides one summary line naming where to look, in the report's own
currency (error and warning, no unused/deprecated fade classes) and
covering the dependency files this closure reached. Its HIDDEN families
still count as before: those name which package.json a `rip.strict`
remedy belongs to, which is as true of a dependency as of a target.

The editor already drew this line from the other side — it publishes
per open document, so a dependency is silent until opened. The two
surfaces now agree about scope as well as verdict.

`rip check examples/cart`: 17 -> 12 with five accounted for; packages
and the repo root are unmoved.
@philiplindberg
philiplindberg marked this pull request as draft August 5, 2026 12:07
Steve's App publication rewrite (#209) lands under the gate. Four
conflicts resolved:

packages/app — apply.rip, workspace.rip, and their tests take main
whole. The type widenings this branch made there were fixes to
`Passport`, a type the rewrite deletes; `ApplyOpts` grew a candidate
parameter and a `reload` verdict that supersede the sync-return
widening. The one fix still owed is unrelated to the conflict: the
watcher Set is plain again, so `watcher event, path` is uncallable.

test/toolchain/audit-contract.test.js — both sides added a summary
field to the clean state (this branch's `gl`, main's `hp.untyped`);
the mock carries both, and each side's contract rows keep their own
fire entries.

examples/cart/README.md — this branch deleted it as drifting prose;
main updated it the same day for the publication lifecycle. Restored:
a file its author is actively tending is not drift.

`rip check packages` reads 38, up from 13 — every new one in the
rewritten App, and none of them merge damage. Twenty are one class,
an implicit return reaching a slot typed `void`, which this makes the
third sighting of.
The section justified holding inference by the rule staying statable,
which is an argument about prose rather than about types. Measured
instead: what inference produces over unannotated Rip is dominated by
confident errors about correct code — a parameter typed from its `= {}`
default so every legitimate member access is missing, an object built
by spread read as closed to its initial keys, a Bun API unknown for
want of @types/bun. All of it lands where the author declined to
annotate, and only an annotation answers any of it. The case the other
side catches is real and is now named as such, along with the fact that
it appears nowhere in this repository.
A component member's type is rendered twice — the class declare and the
same-name companion interface — so a fault in it reaches the editor
twice. The companion built each line by flattening its segments
(segmentsText drops every node and role), and the companion has no
source line of its own, so the unmapped bytes fell to the component's
$self cover. One unresolved type name therefore painted every line of
its component red while the Problems count still read one, and that was
true of every component in the repo, not one spelling.

instanceTypeLines now returns segment lists. Each member line carries
the member as its own cover with the annotation's segments nested
inside, so a byte with a finer span uses it and a byte without one —
the container's `value`, where TypeScript reports a computed cycle —
lands on the member rather than the component. The container spells its
type twice (`value` and `read()`'s return); both are the same
annotation and both now carry its span.

Rendering the type twice also means one claim can arrive on spans that
NEST rather than match, which the existing exact-range collapse could
not see: `: T` against `T`. Same code, severity and message over a
containing range is the same claim, so the narrower span keeps it.

Diagnosed by driving, and the first diagnosis was wrong: the missing
node looked like the `isBehaviorProjected` condition, but attaching it
only added a third diagnostic while the whole-component span survived.
tsc over the face named the four references that actually exist.

The suite missed it because the component diagnostics test asserts
starts and a whole-component span has a correct start; its fixture also
plants the fault in a render body, where no companion row is involved.
The new test plants it on a member and asserts both ends.

`rip check examples/cart`: 12 -> 9.
A directive is charged by a diagnostic STARTING on its governed line, so
retiring rows before that loop can retire the only row that starts
there. Two renderings of one claim need not share a start — the wider
span can begin a line or more above the narrower — and dropping the
wider one first left the directive uncharged, resurrecting the TS2578
the charge exists to drop: an `@ts-expect-error` reported unused while
it is in fact suppressing an error.

The collapse now runs last, on what survives, and its tie-break states
the whole rule rather than a case: the narrowest span keeps the claim,
exact ties go to the first. The previous spelling deferred to index
order only for mutually-contained spans, which the identity collapse
above has already removed — a branch that could not run, reading as
though it governed something.

Both pinned, and the ordering pin was watched to fail with the collapse
moved back ahead of the charging loop.
Rip erases annotations, but the import naming the annotated type was
emitted whole, so JS asked for a binding the module exports only as a
type and the module died at load — `SyntaxError: Export named 'X' not
found` — while `rip check` reported it clean. A green checker over a
program that cannot load is the worst shape a type story has, and it
had already cost the cart example its imports: the port dropped the
names to keep the app running, which is why they read as unresolved.

The test is LOCAL and needs no cross-module resolution. Type syntax is
absent from the s-expression tree, so a name used only in an annotation
appears nowhere in the value tree; a name that also appears among the
recorded type-role spans is one a type genuinely uses. Both halves fail
the safe way — an over-counted use or an unlisted type role only
declines an elision, where the opposite emits an import that cannot
resolve.

Being unreferenced is not enough on its own. An import nobody mentions
at all is dead code, and rewriting it away is not the emitter's
business; `import { useState } from 'react'` in a file that never says
`useState` still emits.

The face keeps every name — the annotations have to resolve there — so
the erased specifier and its separator ride a TS-only region and strip
identity holds byte for byte. When the whole clause erases, the clause
and its `from` ride the region and a bare `import 'mod'` remains: the
module still RUNS, where dropping the statement would have discarded
its side effects in silence.

Pinned by loading the emission, not by reading it: both runtime pins
were watched to fail with the elision disabled, each on the SyntaxError
this commit exists to prevent. Cart's routes go back to the spelling v3
wrote, and `rip check examples/cart` reads 12 -> 8.
A bind into a chain notifies the root container through `touch` — a
nested write changes no container identity — but the face spelled no
`touch`, so every such bind drew TS2339.

The seam has two honest spellings, and a container position takes the
one matching its provenance. A slot holding a container `__state`
minted has `touch` outright, so a consumer calls it unguarded; a slot
that ACCEPTS a container takes whatever arrives on its bind channel,
and the sharing contract admits a caller-supplied `{ value, read }`
the runtime treats as a container but which has no `touch`. Public is
the line, not the member kind: a defaulted prop carries kind 'state'
while `_init` still reads its bind slot first.

A computed has no notify seam at runtime and spells neither.

The editor's cell-hover presenter matches the container shape
verbatim, so it accepts both spellings or leaks the raw container into
every `count := 0` hover.
A derived binding (`UserPublic = User.pick("id", "email")`) builds its
schema by calling the algebra, so the checker types the VALUE from the
intrinsic signatures — but the name meant nothing in type space, and
`form: UserPublic` reads it as one. TS2749 on every such annotation.

The companion is the RESOLVED shape, projected by the folder that
already backs the browser bundle, so a derivation types as what the
runtime builds rather than as a second spelling of the algebra. The
two are checked against each other: rows assign the companion and the
call's result each to the other, so a folder drifting from
`projectableFields` fails at the checker instead of shipping a shape
nothing computed.

The face states no const — the algebra call types the value, and a
fold that disagreed must not become a diagnostic on the user's line.
The declaration road states one: it has no call to infer from, and a
type name whose value never declared cannot be called.

Silence stays the answer wherever the shape is not knowable. Folding
already refuses an unknown base, dynamic keys, and a mixin. A name the
module binds MORE than once joins them: it holds two shapes, one alias
cannot describe both, and two claims on one type name would reject a
program that compiles fine with no companion at all.
@philiplindberg
philiplindberg marked this pull request as ready for review August 5, 2026 22:34
@philiplindberg
philiplindberg merged commit 3dc3719 into main Aug 5, 2026
4 checks passed
@philiplindberg
philiplindberg deleted the gradual-mode branch August 5, 2026 22:35
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