Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
fad682a
Gradual mode gates by declaration scope, not by diagnostic code
philiplindberg Aug 4, 2026
badc34f
Merge remote-tracking branch 'origin/main' into gradual-mode
philiplindberg Aug 4, 2026
3746d2d
Green the extended tier, name what gradual hides, stamp the build
philiplindberg Aug 4, 2026
ce97a4e
The check mirror persists as a regenerable cache, like the editor's
philiplindberg Aug 4, 2026
9c5e962
A top-level `globalThis.NAME ??=` declares the global
philiplindberg Aug 4, 2026
08c1ccc
Three catch handlers returned a crash instead of their error envelope
philiplindberg Aug 4, 2026
4a640d7
bun:* rides the host floor; rip.strict earns its own program
philiplindberg Aug 4, 2026
6459fe2
A dangling import is the importer's defect, not a coverage gap
philiplindberg Aug 4, 2026
3f90070
A nested install resolves like the runtime; an absent one is intent, …
philiplindberg Aug 4, 2026
78fc653
Merge branch 'claude/rip-check-dir-shifting-379213' into gradual-mode
philiplindberg Aug 4, 2026
c75bac5
Emitter scaffolding never opens the gate
philiplindberg Aug 4, 2026
cc890e7
TypeScript 7 defaults strict on; gradual states its loose base
philiplindberg Aug 5, 2026
d436007
The burn-down resurrected an annotation already retired
philiplindberg Aug 5, 2026
98220cd
A trailing generic close ends a cast's line
philiplindberg Aug 5, 2026
3e39d75
App types tell the truth the runtime already spoke
philiplindberg Aug 5, 2026
7d426e2
The corpus declares itself a no-check zone
philiplindberg Aug 5, 2026
8df6dbb
The audit gains gradual's suppression matrix
philiplindberg Aug 5, 2026
b2b962e
The cart example drops its prose and re-syncs two handlers
philiplindberg Aug 5, 2026
9d6ffff
A check answers for the paths it was given
philiplindberg Aug 5, 2026
b160786
Merge origin/main into gradual-mode
philiplindberg Aug 5, 2026
b0ff08e
The inference hold argues from what inference does
philiplindberg Aug 5, 2026
fa41b09
Merge remote-tracking branch 'origin/main' into gradual-mode
philiplindberg Aug 5, 2026
e6b1ea5
A member's type answers for the member, not the component
philiplindberg Aug 5, 2026
946fda0
Collapse nested claims after their directives are charged
philiplindberg Aug 5, 2026
30c0dbc
A name only a type uses is not imported at runtime
philiplindberg Aug 5, 2026
2fa5c3b
A container's notify seam is spelled by how the slot got it
philiplindberg Aug 5, 2026
920b373
A projection's shape is a name, not just a value
philiplindberg Aug 5, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
392 changes: 322 additions & 70 deletions dist/browser/rip.js

Large diffs are not rendered by default.

105 changes: 101 additions & 4 deletions docs/TYPES.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,25 @@ package carries the pinned TypeScript/`tsgo` toolchain it brokers.
A state exports its container, not an unwrapped snapshot:

```ts
{ value: T; read(): T }
{ value: T; read(): T; touch(): void }
```

A computed exports a readonly `value`. `read(): T` is the structural
brand shared with the runtime's container detection, preventing an
ordinary `{value: …}` object from satisfying a binding-container slot.
A computed exports its `value` readonly and carries no `touch` — it has
no notify seam at runtime.

`read(): T` is the structural brand shared with the runtime's container
detection, preventing an ordinary `{value: …}` object from satisfying a
binding-container slot.

`touch` is the writable container's notify seam, which a bind into a
chain calls because a nested write changes no container identity. A slot
that holds a container rip minted — a module reactive, `rest`, a
component's own `:=` member — spells it `touch(): void`, so a consumer
holding that container calls it unguarded. A slot that ACCEPTS a
container from elsewhere — a prop, a bind channel — spells it
`touch?(): void`, because the sharing contract admits a caller-supplied
`{ value, read }`, which the runtime treats as a container but which has
no `touch`.

Typed initializers are checked without changing runtime bytes.

Expand All @@ -172,6 +185,14 @@ Schema descriptors project into:
- callable `this` types;
- enum and union outputs.

A derived binding (`UserPublic = User.pick('id', 'email')`) gets a type
companion under its own name, so it can be annotated and re-exported
like any declared schema. The companion is the resolved shape, projected
by the same folder the browser bundler uses, so a derivation types as
what the runtime builds. The projection is conservative: an unknown
base, dynamic keys, or a `@mixin` base yields no companion rather than a
guessed one, and the binding keeps the type its algebra call infers.

Runtime delivery and type rendering are separate: using schema syntax
delivers the runtime machinery while the TS face and declarations carry
the static contract.
Expand All @@ -191,6 +212,21 @@ It produces:

The face and declaration paths consume the same model.

## Declared globals

A top-level `globalThis.NAME ??= expr` declares the global. The `??=`
spelling says "install unless someone already did" — DSL vocabulary,
like stamp's `sh`/`ok`/`run` — and the face emits the typed declaration
for it: `typeof` the initializer when it is an identifier, `any`
otherwise. Plain `=` and non-top-level installs declare nothing on
purpose: a test overwriting `globalThis.fetch` and an app's guarded,
destroy-cleared lifecycle globals are not vocabulary.

The declaring package becomes its own program in the mirror (an
automatic project boundary), so the vocabulary stays package-scoped and
reaches importers the way the runtime does — importing the module runs
the installer. A non-importing neighbor keeps its cannot-find.

## Editor pipeline

The VS Code/Cursor extension:
Expand All @@ -208,6 +244,45 @@ hashes. It is never committed or shipped.
Synthetic generated ranges do not receive fabricated Rip positions.
Diagnostics without an honest source mapping are dropped.

## Diagnostic publishing

Every mode checks the same always-on program; modes differ in which
diagnostics publish.

Gradual — the default — publishes a diagnostic only where type
information reaches its mapped source 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 (annotated `.rip`
exports, relative `.ts` modules, and bare workspace `.rip` packages).
Inference alone never publishes, and an annotation is how you ask for
more. What inference produces over unannotated Rip is dominated by
confident errors about correct code: a parameter is typed from its
`= {}` default, so every legitimate `opts.foo` reads as "does not exist
on type `{}`"; an object built by spread reads as closed to the key set
it was built with; a Bun API is unknown for want of `@types/bun`. Those
land exactly where the author declined to annotate, and no edit but an
annotation answers them. The case the other side would catch —
`answer = 42` later misused as a string — is genuine but was not found
anywhere in this repository. 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.

Names and modules that do not resolve, and definition cycles, publish
in every mode — defects no annotation answers. One exception spells the
difference between a typo and stated intent: a bare import DECLARED in
the governing package.json but not installed is held under gradual
(`rip check` counts it with the install remedy), while strict publishes
it; undeclared-and-uninstalled stays a defect everywhere. Gradual rides
TypeScript 7's default `strict` and subtracts only what it deliberately
loosens: `strictNullChecks` (the lever that changes types, not just
which diagnostics publish), `useUnknownInCatchVariables` (an
unannotated `catch` answers `any`), and `noImplicitThis` (`this` in an
unannotated object-literal method is `any`). Everything else — present
and future strict-family members — stays on, so new strictness arrives
as a visible leak, never as silent inference loss. Any strictness the
project's own tsconfig chain sets is yielded to whole.

## Project configuration

The nearest `package.json` is the project boundary. Its `rip` object
Expand All @@ -227,11 +302,27 @@ use-before-assignment checking for typed forwards. `noCheck` suppresses
diagnostics for matching paths while keeping those files in the
TypeScript program so imports continue to resolve.

A nested package whose mode FLIPS against its parent package's becomes
its own program in the mirror (the same automatic boundary a
globals-declaring package gets): host floors and null posture are
per-program, so the package's own mode governs them. The flip cuts both
ways — a strict package inside a gradual workspace gets its complaints
(an unresolvable `bun:sqlite` instead of a floored `any`), and a
gradual package inside a strict workspace keeps its loose base instead
of riding strict nulls and refused floors.

Configuration changes refresh open editor documents without a window
reload. `rip check [paths...]` applies the same project configuration,
materializes the same TypeScript faces and import closure, and translates
diagnostics through the same mapping seam without starting an editor.

A check answers for the paths it was given. The closure is compiled and
checked whole — a target's types cannot resolve otherwise — but a
dependency's own diagnostics report through its own check, counted here
in one summary line instead. The editor draws the same line by a
different rule: it publishes per open document, so a dependency stays
silent until you open it.

## Correctness gates

- **Erasure:** typed and untyped twins emit identical JavaScript.
Expand All @@ -243,6 +334,11 @@ diagnostics through the same mapping seam without starting an editor.
and produce their required TS regions.
- **Audit:** real Rip fixtures compare compilation, diagnostics,
runtime behavior, and editor answers against TypeScript twins.
- **Suppression matrix:** the audit's gradual pair
(`test/audit/corpus/gradual/`) asserts every family gradual holds
publishes nothing in gradual AND still publishes under strict — a
family quiet in both modes is a failure — plus the published set,
pinned per line, with `rip check` and the editor answering alike.
- **Mapping:** annotations, diagnostics, hover, and definitions
round-trip through exact UTF-16 offsets.

Expand All @@ -258,6 +354,7 @@ diagnostics through the same mapping seam without starting an editor.
| schema type rendering | `src/schema-types.js` |
| component type rendering | `src/component-types.js` |
| editor broker | `packages/vscode/src/` |
| diagnostic gate | `packages/vscode/src/scopes.js` |
| type gates | `test/lang/`, `test/toolchain/`, `test/audit/` |

Open type/editor work is tracked in [ROADMAP.md](ROADMAP.md).
4 changes: 2 additions & 2 deletions examples/cart/app/routes/cart.rip
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { api, parseApiError } from '../api-client.rip'
import { api, ApiErrors, parseApiError } from '../api-client.rip'
import { Button, ButtonLink } from '../components/button.rip'
import { title, formatPrice } from '../config.rip'
import { createMutation } from '@rip-lang/app'
Expand Down Expand Up @@ -73,7 +73,7 @@ export Cart = component
strong "#{formatPrice(cart.totalPrice())}"
td
div.cart-actions
Button loading: placeOrder.pending, @click: (-> placeOrder!), 'Place Order'
Button loading: placeOrder.pending, @click: (-> placeOrder()), 'Place Order'
Button variant: 'secondary', outline, link: true, @click: (-> cart.clear()), 'Clear Cart'
if errors.form
p.error errors.form
4 changes: 2 additions & 2 deletions examples/cart/app/routes/profile.rip
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { UserPublic as User } from '../../api/models.rip'
import { api, parseApiError } from '../api-client.rip'
import { api, ApiErrors, parseApiError } from '../api-client.rip'
import { Button } from '../components/button.rip'
import { title } from '../config.rip'
import { createMutation } from '@rip-lang/app'
Expand All @@ -21,7 +21,7 @@ export Profile = component

render
h1 'Profiler'
form @submit: ((e) -> e.preventDefault(); updateUser!(form))
form @submit: ((e) -> e.preventDefault(); updateUser(form))
div.grid
label
'First name'
Expand Down
2 changes: 0 additions & 2 deletions examples/cart/index.rip
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
# Cart — a multi-route shop proving the full app rails: index.rip +
# app/ + api/ + SQLite. See README.md for the run script.
import { get, start } from '@rip-lang/server'
import { setup } from './setup.rip'
import './api/index.rip'
Expand Down
2 changes: 1 addition & 1 deletion packages/app/components.rip
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ validDirectory = (dir) ->
export def createComponents(): ComponentsStore
files = Map.new()
compiled = Map.new()
watchers = Set.new()
watchers: Set<(event: ComponentEvent, path: string) => void> = Set.new()

notify = (event, path) ->
for watcher in Array.from(watchers)
Expand Down
8 changes: 4 additions & 4 deletions packages/app/source.rip
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,7 @@ makeSourceFamily = (fetchFn, staleTime) ->
prune normalized
cell

family = (key) -> cellFor(key).read()
family: any = (key) -> cellFor(key).read()
family[SOURCE_FAMILY] = true
family.cellFor = cellFor
family.reset = ->
Expand All @@ -260,11 +260,11 @@ export def source<T extends NonNullSourceValue, A extends SourceArgs>(opts: Sour
if opts.kind is 'singleton'
if opts.fetch.length > 1
throw TypeError.new "Rip App: singleton source fetch accepts at most one AbortSignal parameter"
return makeSourceCell opts.fetch, staleTime
return makeSourceCell(opts.fetch, staleTime) as SourceDeclaration<T, A>
if opts.fetch.length < 1 or opts.fetch.length > 2
throw TypeError.new "Rip App: keyed source fetch requires a key parameter and accepts one optional AbortSignal parameter"
return makeSourceFamily opts.fetch, staleTime
return makeSourceFamily(opts.fetch, staleTime) as SourceDeclaration<T, A>

if opts.fetch.length > 1
throw TypeError.new 'Rip App: inferred source fetch accepts no parameters for a singleton or one key parameter for a keyed family'
if opts.fetch.length is 1 then makeSourceFamily(opts.fetch, staleTime) else makeSourceCell(opts.fetch, staleTime)
if opts.fetch.length is 1 then makeSourceFamily(opts.fetch, staleTime) as SourceDeclaration<T, A> else makeSourceCell(opts.fetch, staleTime) as SourceDeclaration<T, A>
6 changes: 3 additions & 3 deletions packages/app/stash.rip
Original file line number Diff line number Diff line change
Expand Up @@ -248,7 +248,7 @@ resetSources = (value, seen) ->
raw = if value[RAW] then value[RAW] else value
return if seen.has raw
seen.add raw
resetSources nested, seen for own key, nested of raw when key isnt SIGNALS
resetSources nested, seen for own key, nested of raw

# The declared baseline: plain values snapshotted deeply, source cells
# skipped (they reset to unloaded, not to a value).
Expand Down Expand Up @@ -449,12 +449,12 @@ makeProxy = (target) ->
PROXIES.set target, proxy
proxy

export def createStash<D extends Record<string, any>>(data: D = {}): Stash<D>
export def createStash<D extends Record<string, any>>(data: D = {} as D): Stash<D>
unless data? and typeof data is 'object' and not Array.isArray(data)
throw TypeError.new 'Rip App: createStash expects a plain object'
unless Object.getPrototypeOf(data) is Object.prototype or Object.getPrototypeOf(data) is null
throw TypeError.new 'Rip App: createStash expects a plain object'
makeProxy data

export def unwrapStash<D extends Record<string, any>>(stash: Stash<D>): D
if stash? and stash[RAW] then stash[RAW] else stash
(stash as any)?[RAW] or stash
3 changes: 3 additions & 0 deletions packages/app/test/apply.rip
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,11 @@ import { test, eq, throws } from '@rip-lang/testing'
import { createApply } from '../apply.rip'

test "createApply validates its options", ->
# @ts-expect-error
throws (-> createApply()), 'expects an options object'
# @ts-expect-error
throws (-> createApply {}), 'requires renderer.remountDirty'
# @ts-expect-error
throws (-> createApply { renderer: remountDirty: -> 'narrow' }), 'requires an escape remount'

test! "absorb ignores an empty set and reloads for ordinary assets", ->
Expand Down
6 changes: 3 additions & 3 deletions packages/db/db.rip
Original file line number Diff line number Diff line change
Expand Up @@ -1036,7 +1036,7 @@ export def createMcpServer(deps)
output.warning = warning if warning
output
catch err
{ success: false, error, errorType: err.constructor?.name or 'Error' }
{ success: false, error: err.message or String(err), errorType: err.constructor?.name or 'Error' }

listTables = ->
try
Expand All @@ -1052,7 +1052,7 @@ export def createMcpServer(deps)
tables = rowsToObjects result.columns, result.data
{ success: true, tables, tableCount: (tables.filter (t) -> t.type is 'table').length, viewCount: (tables.filter (t) -> t.type is 'view').length }
catch err
{ success: false, error, errorType: err.constructor?.name or 'Error' }
{ success: false, error: err.message or String(err), errorType: err.constructor?.name or 'Error' }

listColumns = (params) ->
table = params?.table
Expand Down Expand Up @@ -1082,7 +1082,7 @@ export def createMcpServer(deps)
columns = rowsToObjects result.columns, result.data
{ success: true, table, columns, columnCount: columns.length }
catch err
{ success: false, error, errorType: err.constructor?.name or 'Error' }
{ success: false, error: err.message or String(err), errorType: err.constructor?.name or 'Error' }

respond = (id, result) -> JSON.stringify { jsonrpc: '2.0', id, result }
respondError = (id, code, message) -> JSON.stringify { jsonrpc: '2.0', id, error: { code, message } }
Expand Down
2 changes: 1 addition & 1 deletion packages/server/manager.rip
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@ REGISTER_409_MS = positiveEnv 'RIP_REGISTER_409_MS', 30000

parseArgs = (argv) ->
command = if ['browse', 'status', 'stop', 'hold', 'release', 'migrate', 'recover'].includes(argv[0]) then argv[0] else 'start'
args: any = { command, entry: null, migrationEntry: null, migrationDir: 'migrations', operationId: null, name: null, hosts: [], control: process.env.JANUS_CONTROL or null, workers: 2, concurrency: 1, watch: null, allowWatch: false, eager: false, untilRestart: false, accessMode: 'pretty', accessPicture: null, accessExplicit: false, startupFlags: [] }
args = { command, entry: null, migrationEntry: null, migrationDir: 'migrations', operationId: null, name: null, hosts: [], control: process.env.JANUS_CONTROL or null, workers: 2, concurrency: 1, watch: null, allowWatch: false, eager: false, untilRestart: false, accessMode: 'pretty', accessPicture: null, accessExplicit: false, startupFlags: [] }
i = if command is 'start' then 0 else 1
while i < argv.length
value = argv[i]
Expand Down
44 changes: 42 additions & 2 deletions packages/vscode/src/diagnostics.js
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import {
offsetToPosition, positionToOffset, generatedSpanToSource,
SUPPRESSED_TS_CODES, diagnosticTagsFor,
} from './translate.js';
import { ALWAYS_REPORTED_CODES } from './scopes.js';
import { declaredButUninstalled } from './mirror.js';

// A CompileError → { reason, start, end } in SOURCE offsets: the first
// message line with its `path:line:col:` prefix stripped (the excerpt
Expand Down Expand Up @@ -46,10 +48,27 @@ export function compileErrorInfo(err, textLength) {
// related location itself rather than routing it through here.
export function mapTsDiagnostic(good, d) {
if (!good.strict && SUPPRESSED_TS_CODES.has(d.code)) return null;
// Installation pressure: a bare import the governing manifest declares
// but nobody installed. Gradual holds it (the CLI counts it with the
// install remedy); strict publishes. `good.dir` is the source file's
// own dir — absent (older callers) leaves the defect published.
if (!good.strict && d.code === 2307 && good.dir) {
const spec = /Cannot find module '([^']+)'/.exec(d.message)?.[1];
if (spec && declaredButUninstalled(spec, good.dir)) return null;
}
const s = positionToOffset(good.genLineStarts, good.code.length, d.range.start);
const e = positionToOffset(good.genLineStarts, good.code.length, d.range.end);
const span = generatedSpanToSource(good.mappings, s, e);
if (!span) return null;
// The DECLARATION-SCOPE gate. Judged on the MAPPED source position, so it
// must follow the mapping: the question is which .rip declaration the
// author would see this on, not where it landed in the face. A strict
// project is ungated, and a name/module that does not resolve reports
// either way — see scopes.js.
if (!good.strict && good.checkedLines && !ALWAYS_REPORTED_CODES.has(d.code)) {
const line = offsetToPosition(good.srcLineStarts, span[0]).line;
if (!good.checkedLines[line]) return null;
}
// tsgo supplies Unnecessary/Deprecated tags itself over the pull slot;
// diagnosticTagsFor is the fallback for any item tsgo leaves untagged
// (a batch `tsc` run carries none), so VS Code renders the unused/
Expand Down Expand Up @@ -154,6 +173,27 @@ export function ripDirectiveLines(good) {
return good._directiveLines;
}

// Is `a` within `b`?
const inside = (a, b) =>
(a.start.line > b.start.line || (a.start.line === b.start.line && a.start.character >= b.start.character)) &&
(a.end.line < b.end.line || (a.end.line === b.end.line && a.end.character <= b.end.character));

// One claim can also land on spans that NEST rather than match: a component
// member's type is rendered twice (the class declare and the companion
// interface), and the two renderings mark different extents of the same
// annotation — `: T` against `T`. Same code, severity and message over a
// containing range is the same claim, so the narrowest span keeps it and
// exact ties go to the first.
//
// This runs LAST, after directives have been charged. A directive is
// charged by a diagnostic STARTING on its governed line, so collapsing
// first could retire the only row that starts there and leave the
// directive reading unused.
const collapseNested = (rows) => rows.filter((m, i) => !rows.some((o, j) => j !== i
&& o.code === m.code && o.severity === m.severity && o.message === m.message
&& inside(o.range, m.range)
&& (!inside(m.range, o.range) || j < i)));

export function applyRipDirectives(good, mapped) {
// A lowering can manifest one source error at several face positions.
// Once mapping collapses them to the same code, severity, range, and
Expand All @@ -166,7 +206,7 @@ export function applyRipDirectives(good, mapped) {
return true;
});
const directives = ripDirectiveLines(good);
if (directives.length === 0) return mapped;
if (directives.length === 0) return collapseNested(mapped);
const is2578 = (m) => String(m.code) === '2578';
const used = new Set();
const survivors = [];
Expand Down Expand Up @@ -195,7 +235,7 @@ export function applyRipDirectives(good, mapped) {
// directive is genuinely used — an ERROR landed on its governed line (a
// mis-governed multi-line face directive whose leaked error we suppressed
// over rip positions). Otherwise it survives: unused stays loud.
return survivors.filter((m) => !(is2578(m) && used.has(m.range.start.line)));
return collapseNested(survivors.filter((m) => !(is2578(m) && used.has(m.range.start.line))));
}

// A rip.noCheck glob → anchored regex, matched against a project-root-
Expand Down
Loading