From fad682a04387efcbf5a501d45f6d18175718bab6 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 16:04:01 +0100 Subject: [PATCH 01/23] Gradual mode gates by declaration scope, not by diagnostic code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dist/browser/rip.js | 93 ++- docs/TYPES.md | 23 + packages/vscode/src/diagnostics.js | 10 + packages/vscode/src/mirror.js | 208 +++++- packages/vscode/src/scopes.js | 630 ++++++++++++++++++ packages/vscode/src/server.js | 224 ++++++- packages/vscode/src/translate.js | 39 +- packages/vscode/test/editor-features.test.js | 77 ++- .../editor-gaps-cross-file-resolution.test.js | 40 +- packages/vscode/test/pins.test.js | 14 +- packages/vscode/test/project-model.test.js | 35 +- packages/vscode/test/server.test.js | 4 +- src/check.js | 92 ++- src/dts.js | 17 +- src/emitter.js | 119 +++- src/typetext.js | 34 +- test/lang/tsface.test.js | 75 ++- test/toolchain/check.test.js | 212 +++++- test/toolchain/dts.test.js | 4 +- 19 files changed, 1856 insertions(+), 94 deletions(-) create mode 100644 packages/vscode/src/scopes.js diff --git a/dist/browser/rip.js b/dist/browser/rip.js index a991cab2..b3a93cce 100644 --- a/dist/browser/rip.js +++ b/dist/browser/rip.js @@ -8827,7 +8827,24 @@ var renderParam = (p, isOptional) => { } return renderTarget(p, patternType(p), opt); }; -var renderParams = (params, isOptional) => `(${params.map((p) => renderParam(p, isOptional)).join(", ")})`; +var jsArityOptional = (params) => { + const out = new Set; + for (let i = params.length - 1;i >= 0; i--) { + const p = params[i]; + if (typeof p === "string") { + out.add(i); + continue; + } + if (Array.isArray(p) && (p[0] === "default" || p[0] === "rest")) + continue; + break; + } + return out; +}; +var renderParams = (params, isOptional) => { + const arity = jsArityOptional(params); + return `(${params.map((p, i) => renderParam(p, (q) => isOptional(q) || arity.has(i))).join(", ")})`; +}; var paramTyped = (p) => isTypedWrapper(p) || isNode2(p) && (p[0] === "default" || p[0] === "rest") && isTypedWrapper(p[1]); // src/component-types.js @@ -9403,7 +9420,7 @@ function atParamField(p) { return null; return { name, typed: isNode4(x) && x[0] === "typed-var" && x.length === 3 ? x : null }; } -function ctorAtFields(body) { +function ctorAtFields(bodies) { const out = []; const seen = new Map; const walk = (n, inArrow) => { @@ -9429,7 +9446,10 @@ function ctorAtFields(body) { for (const el of n.slice(1)) walk(el, inArrow || h === "=>"); }; - walk(body, false); + for (const body of bodies) { + if (body !== null && body !== undefined) + walk(body, false); + } return out; } var COMPARISONS = new Set(["<", ">", "<=", ">=", "==", "!="]); @@ -18331,6 +18351,7 @@ ${this.replayPad}}` : " }"); const declared = new Set; let ctorParams = null; let ctorBody = null; + const methodBodies = []; for (const stmt of stmts) { if (!isObject(stmt)) { const field = isStaticKey(stmt) ? null : typeof stmt === "string" ? stmt : Emitter.isTypedWrapper(stmt) && typeof stmt[1] === "string" ? stmt[1] : isNode4(stmt) && stmt[0] === "=" && stmt.length === 3 && typeof stmt[1] === "string" ? stmt[1] : null; @@ -18355,6 +18376,9 @@ ${this.replayPad}}` : " }"); } else if (!isStaticKey(pair[1]) && typeof mName === "string") { declared.add(mName); } + if (isFunc2(pair[2]) && !isStaticKey(pair[1]) && mName !== "constructor") { + methodBodies.push(pair[2][2]); + } if (isFunc2(pair[2]) && pair[2][0] === "=>" && !isStaticKey(pair[1]) && mName !== "constructor") { bound.push(mName); firstBound ??= pair; @@ -18375,8 +18399,8 @@ ${this.replayPad}}` : " }"); `)); } } - if (this.ts && ctorBody !== null) { - for (const at of ctorAtFields(ctorBody)) { + if (this.ts) { + for (const at of ctorAtFields([ctorBody, ...methodBodies])) { if (declared.has(at.name)) continue; declared.add(at.name); @@ -18787,14 +18811,43 @@ ${" ".repeat(ind)}`); })) }; } - emitParams(params, firstParamTypeText = null) { - Emitter.expansionSplit(params).list.forEach((p, i) => { + contextuallyTyped(node) { + const id = this.stores.idOf(node); + const self = id === null ? null : this.stores.node(id); + if (!self || typeof self.sourceStart !== "number") + return false; + this._argSpans ??= this.stores.roles.filter((r) => r.role === "args" && typeof r.sourceStart === "number").map((r) => [r.sourceStart, r.sourceEnd]); + if (this._argSpans.some(([s, e]) => self.sourceStart >= s && self.sourceEnd <= e)) + return true; + this._annotatedValueSpans ??= (() => { + const spans = new Set; + for (const n of this.stores.nodes) { + if (n.semanticKind !== "assign" && n.semanticKind !== "pair") + continue; + if (!this.stores.role(n.nodeId, "annotation")) + continue; + const v = this.stores.role(n.nodeId, "value"); + if (typeof v?.sourceStart === "number") + spans.add(`${v.sourceStart}:${v.sourceEnd}`); + } + return spans; + })(); + return this._annotatedValueSpans.has(`${self.sourceStart}:${self.sourceEnd}`); + } + emitParams(params, firstParamTypeText = null, jsArity = true) { + const list = Emitter.expansionSplit(params).list; + const optional = jsArity ? jsArityOptional(list) : new Set; + if (firstParamTypeText !== null) + optional.delete(0); + list.forEach((p, i) => { if (atParamName(p) !== null || isNode4(p) && p[0] === "default" && atParamName(p[1]) !== null) { throw this.positionedError(isNode4(p) ? p : params, "emitter: an @-parameter promotes only in a constructor (`constructor: (@name) ->`) — bind a plain parameter and assign it here"); } if (i > 0) this.b.emit(", "); this.emitParam(p); + if (optional.has(i) && this.ts) + this.b.tsOnly(() => this.b.emit("?")); if (i === 0 && firstParamTypeText !== null && typeof p === "string" && this.ts) { this.b.tsOnly(() => this.b.emit(`: ${firstParamTypeText}`)); } @@ -18816,13 +18869,14 @@ ${" ".repeat(ind)}`); if (isGen && kind === "=>") { throw this.positionedError(node, srcKind === "->" ? "emitter: a generator arrow cannot sit inside a component body — thin arrows lower to fat arrows there to keep `this` on the instance, and JS has no generator arrows (name the generator a method and call it)" : "emitter: fat arrows cannot contain yield (JS has no generator arrows; use ->)"); } + const inArgs = this.ts && this.contextuallyTyped(node); this.mark(node, "returnType", () => this.mark(node, "$self", () => { if (kind === "->") { if (isAsync) this.b.emit("async "); this.mark(node, "kind", () => this.b.emit(isGen ? "function*" : "function")); this.b.emit("("); - this.mark(node, "params", () => this.emitParams(params)); + this.mark(node, "params", () => this.emitParams(params, null, !inArgs)); this.b.emit(")"); this.tsReturnAnnotation(node, isAsync, isVoid); this.b.emit(" "); @@ -18830,17 +18884,20 @@ ${" ".repeat(ind)}`); } else { if (isAsync) this.b.emit("async "); - const tsParens = this.ts && params.length === 1 && typeof Emitter.paramCore(params[0]) === "string" && (Emitter.isTypedWrapper(params[0]) || this.annotationText(node, "returnType") !== null || isVoid); + const jsArity = this.ts && !inArgs && params.length === 1 && typeof params[0] === "string"; + const tsParens = this.ts && params.length === 1 && typeof Emitter.paramCore(params[0]) === "string" && (Emitter.isTypedWrapper(params[0]) || this.annotationText(node, "returnType") !== null || isVoid || jsArity); this.mark(node, "params", () => { if (params.length === 1 && typeof Emitter.paramCore(params[0]) === "string") { if (tsParens) this.b.tsOnly(() => this.b.emit("(")); this.emitParam(params[0]); + if (jsArity) + this.b.tsOnly(() => this.b.emit("?")); if (tsParens) this.b.tsOnly(() => this.b.emit(")")); } else { this.b.emit("("); - this.emitParams(params); + this.emitParams(params, null, !inArgs); this.b.emit(")"); } }); @@ -20508,6 +20565,20 @@ function emitDeclarations({ sexpr, stores, source }) { } } } + const instanceMethodBodies = []; + for (const stmt of stmts) { + if (!isNode5(stmt) || stmt[0] !== "object") + continue; + for (const pair of stmt.slice(1)) { + if (pair[0] !== ":" && pair[0] !== "void-pair") + continue; + if (isStaticKey(pair[1]) || !isFunc3(pair[2])) + continue; + if (memberName(pair[1]) === "constructor") + continue; + instanceMethodBodies.push(pair[2][2]); + } + } for (const stmt of stmts) { if (isNode5(stmt) && stmt[0] === "object") { for (const pair of stmt.slice(1)) { @@ -20546,7 +20617,7 @@ function emitDeclarations({ sexpr, stores, source }) { const plain = typed !== null ? ["typed-var", n, typed[2]] : n; return dflt !== null ? ["default", plain, dflt[2]] : plain; }); - for (const at of ctorAtFields(value[2])) { + for (const at of ctorAtFields([value[2], ...instanceMethodBodies])) { if (declared.has(at.name)) continue; declared.add(at.name); diff --git a/docs/TYPES.md b/docs/TYPES.md index 39a915f7..6bef1d5f 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -208,6 +208,28 @@ 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 — `answer = 42` misused as a string is +held until someone writes a type — which keeps the rule statable: you +get diagnostics where type information reaches, and an annotation is how +you ask for more. 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. Gradual also supplies +`strictNullChecks: false`, yielding to any strictness the project's own +tsconfig chain sets. + ## Project configuration The nearest `package.json` is the project boundary. Its `rip` object @@ -258,6 +280,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). diff --git a/packages/vscode/src/diagnostics.js b/packages/vscode/src/diagnostics.js index 595a9e11..7047160e 100644 --- a/packages/vscode/src/diagnostics.js +++ b/packages/vscode/src/diagnostics.js @@ -13,6 +13,7 @@ import { offsetToPosition, positionToOffset, generatedSpanToSource, SUPPRESSED_TS_CODES, diagnosticTagsFor, } from './translate.js'; +import { ALWAYS_REPORTED_CODES } from './scopes.js'; // A CompileError → { reason, start, end } in SOURCE offsets: the first // message line with its `path:line:col:` prefix stripped (the excerpt @@ -50,6 +51,15 @@ export function mapTsDiagnostic(good, d) { 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/ diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index 91f5814a..e8bac48e 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -113,6 +113,51 @@ export function chainSetsTypes(configPath, chain, onUnresolved, visited = new Se // program whole-or-not (one tsconfig include), so a nested project's // own package.json cannot govern it per-file the way the diagnostic // gate does. +// Null posture by mode. Gradual rip is stock TypeScript's `strict: false` +// for nullability: `T | undefined` collapses into `T` rather than demanding +// a guard on every read the author never annotated. It is the one mode lever +// that changes TYPES and not just which diagnostics publish — `find()` hovers +// as `T` here and `T | undefined` under `rip.strict` — which is exactly the +// bargain a gradual project makes. Unlike `noImplicitAny`, turning this off +// removes a distinction rather than disabling an inference mechanism, so it +// cannot strand a binding on a worse type (`noImplicitAny: false` drops +// unannotated `[]` onto `never[]`, which is why THAT flag stays on). +const nullPosture = (dir, configPath) => { + if (dir && workspaceIsStrict(dir)) return {}; + // The user's OWN tsconfig wins. rip supplies a default posture; it does + // not overrule a `strict` / `strictNullChecks` the author wrote down — + // an override that silently beat an explicit setting is the stacking + // surprise this whole mode design exists to remove. + if (configPath && chainSetsStrictness(configPath)) return {}; + return { strictNullChecks: false }; +}; + +// Does the config chain SET strictness (`strict` or `strictNullChecks`) +// anywhere? Mirrors chainSetsTypes' conservatism: an unreadable or +// unfollowable link answers TRUE, so an unknown config is never overridden. +export function chainSetsStrictness(configPath, visited = new Set(), depth = 0) { + if (depth > 16 || visited.has(configPath)) return false; + visited.add(configPath); + let text; + try { text = fs.readFileSync(configPath, 'utf8'); } catch { return true; } + const stripped = stripJsonComments(text); + let parsed; + try { parsed = JSON.parse(stripped); } catch { + if (/"(strict|strictNullChecks)"\s*:/.test(stripped)) return true; + return /"extends"\s*:/.test(stripped); + } + const co = parsed?.compilerOptions; + if (co && (co.strict !== undefined || co.strictNullChecks !== undefined)) return true; + if (parsed?.extends === undefined) return false; + const bases = Array.isArray(parsed.extends) ? parsed.extends : [parsed.extends]; + return bases.some((spec) => { + if (typeof spec !== 'string') return true; + const next = resolveExtends(spec, path.dirname(configPath)); + if (!next) return true; + return chainSetsStrictness(next, visited, depth + 1); + }); +} + const workspaceIsStrict = (workspaceRoot) => { for (let dir = workspaceRoot; ; dir = path.dirname(dir)) { const pkgPath = path.join(dir, 'package.json'); @@ -145,6 +190,26 @@ const workspaceIsStrict = (workspaceRoot) => { const HOST_FLOORS = [ { text: 'declare var process: any;', suppliedBy: ['@types/node', '@types/bun'] }, { text: 'declare var Bun: any;', suppliedBy: ['@types/bun'] }, + // `import.meta.dir` and kin are Bun's, and unlike the two above they + // are not a global to shadow but an interface to MERGE — declaring a + // var would not reach `import.meta.x` at all. Merging is additive, so + // when @types/bun is present this entry is absent and the real + // declaration governs alone; the gate below is what guarantees that. + // + // An INDEX SIGNATURE, not an enumeration. Naming the fields means + // asserting the shape of an API rip does not own, and it was wrong on + // arrival — `dir`, `file` and `path` were listed while `import.meta.main` + // was not, so ten call sites in this repo's own packages still failed. + // The honest floor says "this host adds properties I cannot enumerate", + // which is both truer and complete, and it merges with the real + // ImportMeta rather than replacing it (`url` keeps its own type). + // + // It only exists where @types/bun is ABSENT, so nothing it permits + // survives an install — the gate below is what guarantees that. + { + text: 'interface ImportMeta { [key: string]: any }', + suppliedBy: ['@types/bun'], + }, ]; export const HOST_FLOOR_NAME = 'host-floor.d.ts'; const ancestorHas = (fromDir, pkgs) => { @@ -229,14 +294,23 @@ export function nearestTsconfig(dir, anchor) { // emitted per project, from that project's own gate answers: a nested // project's strictness and installed types govern whether ITS files see // it, which a single workspace-root floor could never express. -export function projectWrapper({ wrapperDir, sourceTsconfig, chain = new Set(), onUnresolved }) { +export function projectWrapper({ wrapperDir, sourceTsconfig, workspaceRoot = null, mirrorRoot = null, chain = new Set(), onUnresolved }) { const sourceDir = path.dirname(sourceTsconfig); const overrides = { noImplicitAny: true, noEmit: true, allowImportingTsExtensions: true, + ...nullPosture(sourceDir, sourceTsconfig), rootDirs: ['.', posix(path.relative(wrapperDir, sourceDir))], }; + // The same bare-specifier map the mirror root carries, rebased through + // this wrapper's own reach-up — a nested project's files are governed + // by THIS config, and paths are read from the config that declares + // them, so the root's map never reaches here on its own. + if (workspaceRoot && mirrorRoot) { + const ripPaths = workspaceRipPaths(workspaceRoot, path.relative(wrapperDir, mirrorRoot)); + if (Object.keys(ripPaths).length) overrides.paths = ripPaths; + } chain.clear(); const setsTypes = chainSetsTypes(sourceTsconfig, chain, onUnresolved); if (!setsTypes) overrides.types = ['*']; @@ -253,10 +327,13 @@ export function projectWrapper({ wrapperDir, sourceTsconfig, chain = new Set(), } export function generatedMirror({ workspaceRoot, mirrorRootIsFallback, chain = new Set(), onUnresolved, excludeDirs = [] } = {}) { + const rootConfig = !mirrorRootIsFallback && workspaceRoot + ? path.join(workspaceRoot, 'tsconfig.json') : null; const overrides = { noImplicitAny: true, noEmit: true, allowImportingTsExtensions: true, + ...nullPosture(workspaceRoot, rootConfig && fs.existsSync(rootConfig) ? rootConfig : null), }; if (!mirrorRootIsFallback) overrides.rootDirs = ['.', '../..']; // Workspace AMBIENT declarations (`rip-env.d.ts` and kin) join the @@ -272,6 +349,13 @@ export function generatedMirror({ workspaceRoot, mirrorRootIsFallback, chain = n include.push('../../**/*.d.ts'); exclude.push('../../**/node_modules'); } + // Bare workspace `.rip` specifiers resolve by MAP, not by lookup: + // node_modules holds the runtime's symlink, whose manifest lands on a + // `.rip` file TypeScript will not follow — `paths` points the bare name + // straight at the mirror face the closure compiled. `paths` outranks + // the node_modules walk, so a mapped name never half-resolves. + const ripPaths = mirrorRootIsFallback ? {} : workspaceRipPaths(workspaceRoot); + if (Object.keys(ripPaths).length) overrides.paths = ripPaths; const floorRoot = mirrorRootIsFallback ? null : workspaceRoot; const userConfig = !mirrorRootIsFallback && workspaceRoot ? path.join(workspaceRoot, 'tsconfig.json') : null; @@ -670,6 +754,114 @@ export function typeImportSpecifiers(text) { return specs; } +// The `.rip` file a package.json manifest serves for `subpath` ('.', +// './x'), or null. `exports` wins over `main`; an entry may be a string +// or a conditions object, read in the order bun resolves them at +// runtime (import → default → first string). Only `.rip` targets +// answer — everything else is some other toolchain's module and stays +// on TypeScript's ordinary resolution. Glob subpaths ('./*') are not +// expanded: a miss withholds resolution, which errs toward the +// diagnostic (TS2307) rather than a silent wrong file. +function ripManifestTarget(manifest, subpath) { + const pick = (entry) => { + if (typeof entry === 'string') return entry; + if (entry && typeof entry === 'object') { + for (const key of ['import', 'default', ...Object.keys(entry)]) { + if (typeof entry[key] === 'string') return entry[key]; + } + } + return null; + }; + let target = null; + const exp = manifest?.exports; + if (typeof exp === 'string') { if (subpath === '.') target = exp; } + else if (exp && typeof exp === 'object') { + // A conditions-only exports object ({ import: './x.rip' }) IS the + // '.' entry; a subpath map nests them one level down. + const bySubpath = Object.keys(exp).some((k) => k === '.' || k.startsWith('./')); + target = bySubpath ? pick(exp[subpath]) : (subpath === '.' ? pick(exp) : null); + } else if (subpath === '.' && typeof manifest?.main === 'string') { + target = manifest.main; + } + return target !== null && target.endsWith('.rip') ? target : null; +} + +// The `.rip` file a BARE specifier lands on, resolved the way bun will +// at runtime: walk up from `fromDir` to the first node_modules carrying +// the package, follow its manifest, realpath the target (a workspace +// package is a symlink — the REAL path is the workspace source, which +// is what the mirror is keyed by). Null for anything else: builtins, +// packages that are not installed, targets that are not `.rip`, and +// targets that stay physically inside node_modules (an installed +// third-party `.rip` package would mirror under a tree the generated +// tsconfig excludes — a recorded limit, not an accident). +export function bareRipSpecifierTarget(spec, fromDir) { + if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#')) return null; + if (spec.startsWith('node:') || spec.startsWith('bun:')) return null; + const parts = spec.split('/'); + const pkgName = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + if (!pkgName || (spec.startsWith('@') && parts.length < 2)) return null; + const subpath = '.' + spec.slice(pkgName.length); + for (let dir = fromDir; ; dir = path.dirname(dir)) { + const pkgDir = path.join(dir, 'node_modules', pkgName); + let manifest = null; + try { manifest = JSON.parse(fs.readFileSync(path.join(pkgDir, 'package.json'), 'utf8')); } catch { /* keep walking */ } + if (manifest) { + const target = ripManifestTarget(manifest, subpath); + if (target === null) return null; + let real; + try { real = fs.realpathSync(path.join(pkgDir, target)); } catch { return null; } + return real.includes(`${path.sep}node_modules${path.sep}`) ? null : real; + } + if (path.dirname(dir) === dir) return null; + } +} + +// tsconfig `paths` for every workspace package that serves `.rip`: +// bare-name → mirror face, so tsgo resolves `@rip/util` to the same +// file the closure compiled. Enumerated from the workspace root's +// `workspaces` globs (the `/*` form; a member without a manifest +// or without `.rip` exports simply contributes nothing). Paths are +// RELATIVE TO THE CONFIG that carries them — the mirror root's config +// passes '' for `fromConfigDirToMirrorRoot`; a nested project's wrapper +// passes its own reach-up. +export function workspaceRipPaths(workspaceRoot, fromConfigDirToMirrorRoot = '') { + const paths = {}; + if (!workspaceRoot) return paths; + let ws; + try { ws = JSON.parse(fs.readFileSync(path.join(workspaceRoot, 'package.json'), 'utf8')).workspaces; } catch { return paths; } + const globs = Array.isArray(ws) ? ws : Array.isArray(ws?.packages) ? ws.packages : []; + const memberDirs = []; + for (const glob of globs) { + if (typeof glob !== 'string') continue; + if (glob.endsWith('/*')) { + const parent = path.join(workspaceRoot, glob.slice(0, -2)); + let entries; + try { entries = fs.readdirSync(parent, { withFileTypes: true }); } catch { continue; } + for (const e of entries) if (e.isDirectory()) memberDirs.push(path.join(parent, e.name)); + } else { + memberDirs.push(path.join(workspaceRoot, glob)); + } + } + for (const dir of memberDirs) { + let manifest; + try { manifest = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); } catch { continue; } + if (typeof manifest?.name !== 'string') continue; + const subpaths = ['.']; + if (manifest.exports && typeof manifest.exports === 'object') { + for (const k of Object.keys(manifest.exports)) if (k.startsWith('./')) subpaths.push(k); + } + for (const sub of subpaths) { + const target = ripManifestTarget(manifest, sub); + if (target === null) continue; + const face = path.relative(workspaceRoot, path.resolve(dir, target)) + '.ts'; + const name = sub === '.' ? manifest.name : manifest.name + sub.slice(1); + paths[name] = [posix(path.join(fromConfigDirToMirrorRoot, face))]; + } + } + return paths; +} + // The relative .rip import targets of a compiled file, as absolute paths // — the closure edges. Read from the compiler's OWN stores (never // scanned from generated text — the never-list): import/export nodes @@ -686,9 +878,17 @@ export function ripImportsOf(stores, sourceText, fromDir) { const seen = new Set(); const targets = []; const addSpec = (spec) => { - if (!spec.endsWith('.rip')) return; - if (!spec.startsWith('./') && !spec.startsWith('../')) return; - const abs = path.resolve(fromDir, spec); + // A bare specifier resolves through node_modules — the workspace + // package edge (bareRipSpecifierTarget above); a relative one by the + // filesystem. Same set, two resolutions, absolute paths either way. + let abs; + if (spec.startsWith('./') || spec.startsWith('../')) { + if (!spec.endsWith('.rip')) return; + abs = path.resolve(fromDir, spec); + } else { + abs = bareRipSpecifierTarget(spec, fromDir); + if (abs === null) return; + } if (seen.has(abs)) return; // one edge per module, however many spellings name it seen.add(abs); targets.push(abs); diff --git a/packages/vscode/src/scopes.js b/packages/vscode/src/scopes.js new file mode 100644 index 00000000..01418e88 --- /dev/null +++ b/packages/vscode/src/scopes.js @@ -0,0 +1,630 @@ +// The DECLARATION-SCOPE gate: which lines of a .rip source are type-checked +// under gradual annotations. +// +// Gradual mode's rule is one sentence: you get diagnostics where type +// information reaches. A line is checked when it carries type information +// itself, or when type information reaches it along one of the three ways a +// program actually carries it: +// +// INWARD indentation — an annotated header checks its body, including +// nested closures. Never outward from a body: annotating an +// inner helper leaves its 300-line parent alone. +// OUTWARD an annotation in a DECLARATION'S HEADER is an annotation on +// the declaration, so it types the name and checks the body. A +// constructor's promoted parameter types its class; a +// component's annotated member types its view. +// SIDEWAYS a use site is a SIBLING of its declaration, not a child, so +// indentation alone can never reach it. Bindings carry their +// scope, and type flows along assignment to a fixpoint: `total +// = count + ratio` off two annotated numbers is a number. +// +// This replaces filtering by diagnostic CODE. A missing annotation is born as +// the implicit-any family but dies fifty lines later as TS2339 on the shape it +// produced, so no per-code filter can be complete; the scope is the honest +// dimension. The same reasoning is why SIDEWAYS reaches a fixpoint rather +// than stopping after one hop — a one-hop gate re-creates the same +// born-here-dies-there hole one line further down. +// +// Two things stay outside the gate: +// +// - `rip.strict` — a strict project asked for everything, gate included. +// - ALWAYS_REPORTED — a name or module that does not exist is a bug whether +// or not the author annotated. This is why the gate is a filter and not +// the `# @ts-nocheck` directive: the directive is all-or-nothing per file +// and cannot make this exception. +// +// The question is whether a declaration HAS TYPE INFORMATION, not whether the +// author typed one out. Rip supplies types nobody wrote — an event handler's +// param from HTMLElementEventMap, a schema default, a promoted parameter, a +// typed component factory — and gating those away would silence exactly the +// checking rip gives for free. +// +// The gate is FILE-LOCAL. Type information arriving from another module — a +// typed `.rip` export, an installed `@types` package, a workspace `.d.ts` — +// does not switch on the file that imports it; only what this file says +// about itself does. That is a posture, not an oversight, and it is the open +// question this design puts to its reader. +// +// Errs toward MORE checking wherever it is unsure. A binding it cannot place +// keeps whole-file reach; a construct it does not recognize is not treated as +// a scope. False noise is a nuisance the author can answer with an +// annotation; false silence reads as a clean file and cannot be answered at +// all. +// +// Pure: the caller supplies tokens and the face (the extension resolves the +// compiler at runtime and this module must load in both the in-repo and +// staged-vsix layouts). + +import path from 'node:path'; +import { generatedSpanToSource } from './translate.js'; +import { bareRipSpecifierTarget } from './mirror.js'; + +// A defect no annotation answers. Reported under every mode — gating one of +// these would hide a real bug behind "gradual mode", and there is no +// annotation the author could have written that makes the program right. +// +// The cannot-find family: a name or module that does not exist. 2552 is 2304 +// with a spelling suggestion attached ("Cannot find name 'error'. Did you +// mean 'err'?"): the same defect wearing a different code, and the reason to +// enumerate rather than eyeball. The family is spelled across six codes +// because TypeScript varies the ADVICE, not the defect: a bare miss (2304), a +// spelling suggestion (2552), and two "change your target library" forms +// (2583 for ES built-ins, 2584 for DOM globals) are one diagnostic wearing +// four numbers. Enumerated rather than eyeballed — 2584 was found only by +// driving the real checker over `doc = document.title`, after 2304 and 2552 +// had both looked sufficient. +// +// The definition cycle (2502): a computed that reads itself, directly or +// through others, recurses forever the first time anyone reads it. An +// annotation would break TypeScript's INFERENCE cycle and so silence the +// code — while leaving the runtime cycle exactly where it was. A diagnostic +// an annotation silences without fixing belongs outside the gate. +export const ALWAYS_REPORTED_CODES = new Set([ + 2304, // cannot find name + 2307, // cannot find module + 2502, // referenced directly or indirectly in its own definition + 2503, // cannot find namespace + 2552, // cannot find name — did you mean 'Y'? + 2583, // cannot find name — change your target library (ES built-in) + 2584, // cannot find name — change your target library (DOM global) +]); + +// offset → 0-based line, over one pass of the source. Every pass here works +// in offsets (tokens, node spans, mapped diagnostic positions) and reports in +// lines, so they all cross here. +function lineIndexer(source) { + const starts = [0]; + for (let i = 0; i < source.length; i++) if (source[i] === '\n') starts.push(i + 1); + return (off) => { + let lo = 0, hi = starts.length - 1; + while (lo < hi) { const mid = (lo + hi + 1) >> 1; if (starts[mid] <= off) lo = mid; else hi = mid - 1; } + return lo; + }; +} + +// The 0-based lines carrying a type annotation. `tokens` is the compiler +// lexer's token array; TYPE is the kind every annotation lowers to, which is +// why this reads tokens rather than scanning text — a scan cannot tell the +// annotation `x: T = v` from the object literal `{ x: T }`. +export function annotatedLinesOf(tokens, source) { + const lines = new Set(); + if (!tokens) return lines; + const lineAt = lineIndexer(source); + for (const t of tokens) { + if (t.kind !== 'TYPE') continue; + if (typeof t.start !== 'number') continue; + lines.add(lineAt(t.start)); + } + return lines; +} + +// Per-line gate: 1 where diagnostics are reported, 0 where they are held. +// +// Scope comes from indentation, which in rip IS the block structure: a +// meaningful line opens a scope that runs until the next meaningful line at +// the same or lower indent. A line inherits its enclosing scope's answer, so +// an annotated header checks its whole body without the annotation having to +// repeat. Blank and comment lines inherit the last answer so a diagnostic +// mapped onto one is governed by the code around it. +export function checkedLinesOf(source, annotated) { + const lines = source.split('\n'); + const out = new Uint8Array(lines.length); + // A `# @ts-expect-error` / `# @ts-ignore` is itself a statement about + // types: the author is asserting an error lives on the next line. Both the + // directive and the line it governs are therefore checked, whatever else + // is annotated. Gating them instead would suppress the very diagnostic the + // directive promised to absorb — and then TypeScript's own "unused + // directive" TS2578, which lands on the comment, would be gated too. The + // escape hatch would rot silently, which is the one thing it must not do. + const directive = /^[ \t]*#[ \t]*@ts-(expect-error|ignore)(\s|$)/; + for (let i = 0; i < lines.length; i++) { + if (!directive.test(lines[i])) continue; + annotated.add(i); + if (i + 1 < lines.length) annotated.add(i + 1); + } + const stack = []; + let last = 0; + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const trimmed = line.trim(); + if (!trimmed || trimmed.startsWith('#')) { out[i] = annotated.has(i) ? 1 : last; continue; } + const indent = line.length - line.trimStart().length; + while (stack.length && stack[stack.length - 1].indent >= indent) stack.pop(); + const inherited = stack.length ? stack[stack.length - 1].checked : 0; + const checked = inherited || (annotated.has(i) ? 1 : 0); + out[i] = checked; + last = checked; + stack.push({ indent, checked }); + } + return out; +} + +// The 0-based lines the COMPILER typed. `tsRegions` are the face's TS-only +// spans; mapped back through the face's own mappings they say which .rip +// bytes carry type information the author never wrote. +// +// A region whose mapped span crosses lines is REFUSED, because a +// construct-wide region says nothing about WHICH member carries type +// information. Every component gets the same scaffolding — `declare +// children`, a constructor, a generated interface — all mapped onto the +// whole `export X = component …` span, so a scaffolding region marking a +// construct typed would be an accident of emission, not a statement about +// the construct. +// +// What a construct-wide region cannot say, the tree can: an annotation +// inside a declaration's header types the declaration, and a construct the +// face types wholesale is typed by its KIND — both declarationHeadersOf's +// job below. The author's own multi-line annotations need neither path — +// their TYPE tokens are already counted. +export function typedRegionLinesOf(tsRegions, mappings, source, code = null) { + const lines = new Set(); + if (!tsRegions || !mappings) return lines; + const lineAt = lineIndexer(source); + for (const [s, e] of tsRegions) { + // A lone `?` is the OPPOSITE of type information. The face writes one + // for a parameter the author never annotated (JS arity — see + // `jsArityOptional`), and one for a bare `title?`, which says the + // parameter may be absent and nothing whatever about its type. Reading + // either as an annotation would switch checking on for every function + // with an unannotated parameter, which is most of them. + if (code && code.slice(s, e) === '?') continue; + let span; + try { span = generatedSpanToSource(mappings, s, e); } catch { continue; } + if (!span) continue; + const from = lineAt(span[0]); + const to = lineAt(span[1] > span[0] ? span[1] - 1 : span[0]); + if (from !== to) continue; + lines.add(from); + } + return lines; +} + +// The FUNCTION SCOPES a source declares, as source spans. Read off the parse +// tree rather than re-derived from indentation, because indentation answers a +// different question: it gives the BLOCK structure, and rip's bindings are +// function-scoped, so an `if` body indents without opening a scope. +// +// Only `def` and `func` are taken. Both are unambiguously the scopes rip's +// local bindings live in, and the set is deliberately short: a region named +// here NARROWS a binding's reach, so a wrong entry causes false silence — +// the one failure this design must not have. A construct left out keeps the +// pre-existing whole-file reach, which over-checks and stays safe. +// +// A node's own `name` role is a HOLE in its region: `def filterBy(…)` binds +// `filterBy` in the ENCLOSING scope while its parameters bind inside. Without +// the hole every function name would be invisible at its own call sites, and +// a typed signature would stop checking the calls it exists to check. +export function scopeRegionsOf(stores) { + const regions = []; + if (!stores?.nodes) return regions; + for (const n of stores.nodes) { + if (n.semanticKind !== 'def' && n.semanticKind !== 'func') continue; + if (typeof n.sourceStart !== 'number') continue; + const name = stores.role(n.nodeId, 'name'); + const hole = typeof name?.sourceStart === 'number' ? [name.sourceStart, name.sourceEnd] : null; + regions.push({ start: n.sourceStart, end: n.sourceEnd, hole }); + } + return regions; +} + +// The DECLARATION HEADERS a source spells, each with the span of the name it +// binds. A header is a declaration's span minus every function body nested +// inside it — which is the same subtraction for all four forms and needs no +// per-construct rule: +// +// def f(x: T) header = the signature; the body is subtracted +// class Box header = the fields and the method SIGNATURES, +// including a constructor's promoted parameters +// X = component header = the members; `render`'s body is subtracted +// f = (x: T) -> header = the parameters +// +// The subtraction is what keeps the rule honest in both directions. An +// annotation in the header is a fact about the declaration ITSELF — its +// call signature, its instance shape, its props — so it types the name and +// checks the body. An annotation in the body is a fact about one local, and +// types neither. +// +// A form's name lives where the form is named: `def`/`class` carry a `name` +// role, while a component or a lambda is named by the `assign`/`pair` that +// binds it. Both are read off the tree; neither is guessed from text. +// +// Each declaration reports its KIND, because two of the kinds are typed +// without any TYPE token — see COMPILER_TYPED_KINDS. +export function declarationHeadersOf(stores) { + const decls = []; + if (!stores?.nodes) return decls; + const KINDS = new Set(['def', 'func', 'class', 'component', 'schema']); + // Names conferred from outside: `Gotcha = component …` and `go: (n) ->` + // name a node that carries no name role of its own. Keyed by the named + // node's span, which is what the naming role's own span points at. + const conferred = new Map(); + for (const n of stores.nodes) { + const [valueRole, nameRole] = + n.semanticKind === 'assign' ? ['value', 'target'] : + n.semanticKind === 'pair' ? ['value', 'key'] : []; + if (!valueRole) continue; + const value = stores.role(n.nodeId, valueRole); + const name = stores.role(n.nodeId, nameRole); + if (typeof value?.sourceStart !== 'number' || typeof name?.sourceStart !== 'number') continue; + conferred.set(`${value.sourceStart}:${value.sourceEnd}`, name); + } + // Every function body in the file, subtracted from whichever headers + // contain it. `render` joins them: a component's view is its body in + // exactly the sense that matters here — the part an annotation on the + // component's shape governs rather than the part that spells it. + const bodies = []; + for (const n of stores.nodes) { + if (!['def', 'func', 'render'].includes(n.semanticKind)) continue; + const body = stores.role(n.nodeId, 'body'); + if (typeof body?.sourceStart === 'number') bodies.push([body.sourceStart, body.sourceEnd]); + } + for (const n of stores.nodes) { + if (!KINDS.has(n.semanticKind) || typeof n.sourceStart !== 'number') continue; + const name = stores.role(n.nodeId, 'name') ?? conferred.get(`${n.sourceStart}:${n.sourceEnd}`); + if (typeof name?.sourceStart !== 'number') continue; + const holes = bodies.filter(([s, e]) => s >= n.sourceStart && e <= n.sourceEnd); + decls.push({ start: n.sourceStart, end: n.sourceEnd, holes, name, kind: n.semanticKind }); + } + return decls; +} + +// Declarations the COMPILER types wholesale, so they are typed with no TYPE +// token anywhere in the source. +// +// A schema's fields are typed in the DSL's own vocabulary — `name! string` +// IS the annotation, spelled the way the construct spells it — and the face +// materializes the story as real declarations: a data type per field, a +// typed factory (`as ModelSchema<…>`), a `this` annotation on every +// callable. A component's members get the same treatment from their +// initializers: a `declare x: {…}` per state, a companion interface, a +// `this` annotation on every computed. +// +// The token scan cannot see any of this — a schema body lexes as one opaque +// SCHEMA_BODY token, and a component's member types exist only in the face — +// so the construct's KIND is the signal: the face types every schema and +// every component by construction, and gating their bodies away would +// silence exactly the checking rip gives for free. +const COMPILER_TYPED_KINDS = new Set(['schema', 'component']); + +// True when an offset lies in a declaration's header — inside its span and +// outside every body nested in it. +function inHeader(decl, off) { + if (off < decl.start || off >= decl.end) return false; + return !decl.holes.some(([s, e]) => off >= s && off < e); +} + +// The innermost scope containing an offset, or null for the whole file. A +// hit inside a region's name hole falls THROUGH to the enclosing scope, +// which is what makes a function's name a binding of its parent. +function scopeAt(regions, off) { + let best = null; + for (const r of regions) { + if (off < r.start || off >= r.end) continue; + if (r.hole && off >= r.hole[0] && off < r.hole[1]) continue; + if (!best || r.end - r.start < best.end - best.start) best = r; + } + return best; +} + +// The BINDINGS a typed line is about, each with the span it is visible in. +// An annotation attaches to a binding, not to a row of text, so +// `count: number = 42` on one line and `count = "nope"` on the next are one +// fact and must be gated as one — a purely lexical gate silences the +// violation of the annotation directly above it, which breaks this design's +// own promise. +// +// A binding is a NAME PAIRED WITH A SCOPE, not a name alone. `data: string` +// in one function and an unrelated `data = 5` in the next are two bindings +// that happen to share a spelling; gating them together checks a declaration +// nobody annotated, in a function nobody touched. The scope is read from the +// declaration's own position, so the pairing costs one tree walk and no +// guesswork about which spelling declares what. +// +// The names come off the lines already known to carry type information +// rather than off the token stream, because rip spells annotations several +// ways and only some of them survive as a TYPE token: a bare forward +// declaration (`y: number`, no initializer) lexes as an OBJECT LITERAL and +// carries no TYPE token at all — it is the face's `let y!: number` that makes +// it typed, which the tsRegions pass sees and a token scan never would. +// Taking every identifier on a typed line covers all spellings at once, and +// pulls in the useful neighbours: `def filterBy(query: string)` types both +// the parameter and the function, so call sites are checked too. +// +// Residual: an unannotated INNER binding that shadows a typed outer one +// still reads as typed, because this pass places a binding by where its +// declaration sits and never asks whether an inner scope redeclares the +// name. That errs toward more checking, which is the safe direction. +function typedBindingsOf(tokens, source, typedLines, regions) { + const bindings = new Map(); // name → array of spans; null entry = whole file + if (!tokens) return bindings; + const lineAt = lineIndexer(source); + for (const t of tokens) { + if (t.kind !== 'IDENTIFIER' && t.kind !== 'PROPERTY') continue; + if (typeof t.start !== 'number' || typeof t.value !== 'string') continue; + if (!typedLines.has(lineAt(t.start))) continue; + const scope = scopeAt(regions, t.start); + const spans = bindings.get(t.value); + if (spans === null) continue; // already whole-file + if (!scope) { bindings.set(t.value, null); continue; } + if (spans) spans.push(scope); else bindings.set(t.value, [scope]); + } + return bindings; +} + +// The ASSIGNMENTS a source performs, as `{ name, at, value }` — the name +// bound, the offset it is bound at, and the span of the expression bound to +// it. This is the channel type information travels along: `total = count + +// ratio` off two annotated numbers makes `total` a number as surely as +// writing `total: number` would. +// +// Every reactive form joins the plain one: `:=`, `~=` and `=!` differ in what +// the binding DOES, never in whether a type flows into it. A target that is +// not a plain identifier (`@field`, a destructure, an index) is skipped — +// there is no single name to carry, and skipping only withholds checking. +export function assignmentFlowsOf(stores, source) { + const flows = []; + if (!stores?.nodes) return flows; + const KINDS = new Set(['assign', 'state', 'computed', 'readonly', 'typedvar']); + for (const n of stores.nodes) { + if (!KINDS.has(n.semanticKind)) continue; + const target = stores.role(n.nodeId, 'target'); + const value = stores.role(n.nodeId, 'value'); + if (typeof target?.sourceStart !== 'number' || typeof value?.sourceStart !== 'number') continue; + const name = source.slice(target.sourceStart, target.sourceEnd); + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) continue; + flows.push({ name, at: target.sourceStart, value: [value.sourceStart, value.sourceEnd] }); + } + return flows; +} + +// True when a span reads some binding that is typed where the read sits. +function readsTyped(tokens, bindings, [start, end]) { + for (const t of tokens ?? []) { + if (t.kind !== 'IDENTIFIER' && t.kind !== 'PROPERTY') continue; + if (typeof t.start !== 'number' || t.start < start || t.start >= end) continue; + if (!bindings.has(t.value)) continue; + const spans = bindings.get(t.value); + if (!spans || spans.some((s) => t.start >= s.start && t.start < s.end)) return true; + } + return false; +} + +// Lines mentioning a typed binding, which the lexical pass cannot reach: a +// use site is a sibling of its declaration, not a child of it. An occurrence +// counts only where the binding it names is visible. +function linesMentioning(tokens, source, bindings) { + const lines = new Set(); + if (!tokens || bindings.size === 0) return lines; + const lineAt = lineIndexer(source); + for (const t of tokens) { + if (t.kind !== 'IDENTIFIER' && t.kind !== 'PROPERTY') continue; + if (typeof t.start !== 'number' || !bindings.has(t.value)) continue; + const spans = bindings.get(t.value); + if (spans && !spans.some(s => t.start >= s.start && t.start < s.end)) continue; + lines.add(lineAt(t.start)); + } + return lines; +} + +// The names a module EXPORTS, each with the offset it is declared at. +// Declaration exports (`export answer: number = 42`, `export def go(…)`, +// `export class K`) report the declared name; a plain list (`export { x, y }`, +// with or without `as`) reports each name it lists. +export function exportedNamesOf(stores, source) { + const names = []; + if (!stores?.nodes) return names; + const DECLS = new Set(['assign', 'def', 'class', 'component', 'typedvar']); + for (const node of stores.nodesByKind('export')) { + const spec = stores.role(node.nodeId, 'spec'); + if (typeof spec?.sourceStart !== 'number') continue; + // A declaration export: the declaration begins where the spec does. + const decl = stores.nodes.find((n) => DECLS.has(n.semanticKind) && n.sourceStart === spec.sourceStart); + const named = decl && (stores.role(decl.nodeId, 'name') ?? stores.role(decl.nodeId, 'target')); + if (typeof named?.sourceStart === 'number') { + names.push({ name: source.slice(named.sourceStart, named.sourceEnd), at: named.sourceStart }); + continue; + } + // Otherwise a list. `export default …` binds no name here and drops out + // on the identifier test. + for (const part of source.slice(spec.sourceStart, spec.sourceEnd).split(',')) { + const name = part.trim().split(/\s+as\s+/)[0].trim(); + if (/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name)) names.push({ name, at: spec.sourceStart }); + } + } + return names; +} + +// The bindings an IMPORT introduces: the local name, and the name it is +// bound to in the exporting module. +// +// A namespace import (`* as ns`) is skipped — its uses are member accesses +// through `ns`, not occurrences of a bare local, so there is no name for the +// gate to match. Skipping withholds checking, never grants it. +export function importBindingsOf(stores, source) { + const out = []; + if (!stores?.nodes) return out; + for (const node of stores.nodesByKind('import')) { + const src = stores.role(node.nodeId, 'source'); + if (typeof src?.sourceStart !== 'number') continue; + const module = source.slice(src.sourceStart, src.sourceEnd).replace(/^['"`]|['"`]$/g, ''); + const text = source.slice(node.sourceStart, node.sourceEnd); + const braced = /\{([^}]*)\}/.exec(text); + for (const part of (braced?.[1] ?? '').split(',')) { + const [imported, local] = part.trim().split(/\s+as\s+/).map((s) => s.trim()); + if (!/^[A-Za-z_$][A-Za-z0-9_$]*$/.test(imported ?? '')) continue; + out.push({ local: local || imported, imported, module }); + } + // The default binding: the bare name between `import` and the first + // comma or `from`, with any braced list removed first. + const head = text.replace(/\{[^}]*\}/, '').replace(/^import\s+/, ''); + const def = /^([A-Za-z_$][A-Za-z0-9_$]*)\s*(?:,|from\b)/.exec(head); + if (def) out.push({ local: def[1], imported: 'default', module }); + } + return out; +} + +// The LOCAL names an import bound to a typed export — what a caller hands +// `scopeGateOf` as `typedImports`. +// +// One definition, three callers (the CLI, the editor's disk face, the +// editor's per-keystroke face). They differ only in where a module's typed +// exports come from, which is `typedExportsAt`; letting each resolve imports +// its own way is how the editor and `rip check` drift apart. +// +// Three kinds of specifier carry type information: +// +// Relative `.rip` — the modules whose faces this toolchain builds and can +// therefore ask, matching `ripImportsOf`. Only the ANNOTATED exports +// count, which is `typedExportsAt`'s question. +// +// Relative `.ts`/`.tsx`/`.mts`/`.cts` (and `.d.ts` through the first) — +// TypeScript source IS the annotation language, so every export is typed +// by construction and there is nothing to ask. A `.js` sibling is the +// opposite and stays untyped; an extensionless specifier is not resolved +// (withholding checking, the safe direction). +// +// Bare, landing on a workspace `.rip` (`@rip/util`, resolved the way the +// runtime resolves it — bareRipSpecifierTarget) — the same module as a +// relative `.rip` spelled through its package name, asked the same +// question. Any other bare specifier is some other ecosystem's module +// and carries nothing here. +export function typedImportsOf(stores, source, fromDir, typedExportsAt) { + const local = new Set(); + if (!stores) return local; + for (const b of importBindingsOf(stores, source)) { + let target; + if (b.module.startsWith('./') || b.module.startsWith('../')) { + if (/\.(ts|tsx|mts|cts)$/.test(b.module)) { local.add(b.local); continue; } + if (!b.module.endsWith('.rip')) continue; + target = path.resolve(fromDir, b.module); + } else { + target = bareRipSpecifierTarget(b.module, fromDir); + if (target === null) continue; + } + let exports; + try { exports = typedExportsAt(target); } catch { continue; } + if (exports?.has(b.imported)) local.add(b.local); + } + return local; +} + +// The exports a module supplies TYPE INFORMATION for — what an importer +// needs to gate its own uses, and the one fact it cannot compute for itself. +// +// FILE-LOCAL by construction: it asks only what this source says about its +// own declarations, never what its dependencies say. That is what keeps a +// cycle (`a.rip` ↔ `b.rip`) from recurring through the gate. +export function typedExportsOf(tokens, source, face) { + const names = new Set(); + const typed = typedLinesOf(tokens, source, face); + const bindings = typedBindingsOf(tokens, source, typed, scopeRegionsOf(face?.stores)); + const lineAt = lineIndexer(source); + for (const { name, at } of exportedNamesOf(face?.stores, source)) { + if (typed.has(lineAt(at))) { names.add(name); continue; } + const spans = bindings.get(name); + if (spans === null || (spans && spans.some((s) => at >= s.start && at < s.end))) names.add(name); + } + return names; +} + +// The lines this source itself carries type information on: what the author +// annotated, what the compiler added, and what an annotation in a +// declaration's header says about the declaration it heads. +function typedLinesOf(tokens, source, face) { + const typed = annotatedLinesOf(tokens, source); + for (const line of typedRegionLinesOf(face?.tsRegions, face?.mappings, source, face?.code)) typed.add(line); + const lineAt = lineIndexer(source); + // OUTWARD: an annotation in a declaration's header is an annotation on the + // declaration, so it lands on the line that NAMES it. Only the author's own + // TYPE tokens count here — a directive is a claim about one line and a + // tsRegion is scaffolding, and neither says the declaration is typed. A + // schema or component needs no token at all: the face types it by + // construction (COMPILER_TYPED_KINDS). + const offsets = []; + for (const t of tokens ?? []) if (t.kind === 'TYPE' && typeof t.start === 'number') offsets.push(t.start); + for (const decl of declarationHeadersOf(face?.stores)) { + if (COMPILER_TYPED_KINDS.has(decl.kind) || offsets.some((o) => inHeader(decl, o))) typed.add(lineAt(decl.name.sourceStart)); + } + return typed; +} + +// The whole computation, for a caller holding the lexer and the face. +// +// Three passes widen one set of typed lines, and the lexical scope pass runs +// LAST over the union — so whatever a pass switches on brings its body with +// it, and a declaration reached through its name checks the same way one +// reached through its own annotation does. +export function scopeGateOf(tokens, source, face, typedImports = null) { + const annotated = typedLinesOf(tokens, source, face); + // ACROSS: an import of an ANNOTATED export carries that export's type + // information into this file. `typedImports` is the set of local names the + // caller resolved to one; resolution is the caller's because it needs the + // filesystem and this module stays pure. + // + // Without this the boundary is incoherent: move an annotated function from + // one file to another and checking switches on, move it back and it + // switches off, on a program whose annotations never changed. An import of + // an UNannotated export is not covered — nobody wrote a type in either + // file, so there is nothing to carry. + // FLOW: type information moves along assignment, so a binding initialized + // from typed bindings is typed too — and to a fixpoint, because stopping + // after one hop would gate the violation on the next line, the same + // born-here-dies-there failure that sank filtering by diagnostic code. + // + // Flow follows ASSIGNMENT and never mere adjacency. Seeding takes every + // identifier on a typed line, which is right for a declaration and fatal + // if iterated: `console` sits on typed lines everywhere, and one more + // round would make every `console.log` in the file typed and the gate + // vacuous. An assignment names exactly what receives the type. + // + // Monotone and bounded — each round adds at least one binding or stops. + const regions = scopeRegionsOf(face?.stores); + const bindings = typedBindingsOf(tokens, source, annotated, regions); + // An imported binding is visible file-wide: an import declares at module + // scope, and no inner scope it might be shadowed in was reached by the + // annotation that typed it. + for (const local of typedImports ?? []) bindings.set(local, null); + const flows = assignmentFlowsOf(face?.stores, source); + for (const flow of flows) flow.pending = true; + for (;;) { + let grew = false; + for (const flow of flows) { + if (!flow.pending || !readsTyped(tokens, bindings, flow.value)) continue; + flow.pending = false; + grew = true; + const scope = scopeAt(regions, flow.at); + const spans = bindings.get(flow.name); + if (spans === null) continue; + if (!scope) bindings.set(flow.name, null); + else if (spans) spans.push(scope); + else bindings.set(flow.name, [scope]); + } + if (!grew) break; + } + // SIDEWAYS: a line is typed if it uses one of those bindings, anywhere the + // binding is visible. A use site is a SIBLING of its declaration, which is + // the one direction indentation cannot reach. + for (const line of linesMentioning(tokens, source, bindings)) annotated.add(line); + // INWARD: indentation, last, over everything the passes above found. + return checkedLinesOf(source, annotated); +} diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index 79780fd4..5ecb0c4d 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -72,6 +72,7 @@ import { SUPPRESSED_TS_CODES, } from './translate.js'; import { mapTsDiagnostic, applyRipDirectives, isNoCheckPath, compileErrorInfo } from './diagnostics.js'; +import { scopeGateOf, typedExportsOf, typedImportsOf } from './scopes.js'; import { generatedMirror as buildGeneratedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf, scanExportNames, stubFacesFromScans } from './mirror.js'; // The compiler: in-repo development resolves the repository's src/; @@ -97,6 +98,37 @@ async function loadCompiler() { throw new Error('rip compiler not found (looked for ../../../src/compile.js and ../compiler/src/compile.js)'); } +// The lexer rides the same dual-path resolution as compile. It backs the +// declaration-scope gate (scopes.js), which needs TYPE tokens: a text scan +// cannot tell the annotation `x: T = v` from the object literal `{ x: T }`, +// and reading the wrong one would gate the wrong declarations. Absent (older +// staged compiler): no tokenizer, so `checkedLines` stays undefined and +// mapTsDiagnostic leaves every diagnostic ungated — the pre-gate behaviour, +// never a silent over-suppression. +// Fails OPEN. No tokenizer (older staged compiler) or a source the lexer +// refuses leaves the gate undefined, and an undefined gate publishes +// everything. Failing CLOSED would be an empty annotation set — which reads +// as "no declaration is annotated", silencing the entire file, and a silent +// file is indistinguishable from a clean one. +function scopeGateFor(source, fsPath, face, typedImports) { + if (!tokenize) return undefined; + try { return scopeGateOf(tokenize(source, fsPath).tokens, source, face, typedImports); } + catch { return undefined; } +} + +async function loadTokenizer() { + const candidates = [ + new URL('../../../src/lexer.js', import.meta.url), + new URL('../compiler/src/lexer.js', import.meta.url), + ]; + for (const candidate of candidates) { + if (fs.existsSync(fileURLToPath(candidate))) { + return (await import(candidate.href)).tokenize; + } + } + return null; +} + // readProjectConfig rides the same dual-path resolution as compile // (in-repo src/ or the staged vsix copy). Absent (older staged // compiler): a no-op reader — every project non-strict, no throw. @@ -118,6 +150,7 @@ const documents = new TextDocuments(TextDocument); let compile = null; let readProjectConfig = null; +let tokenize = null; let tsgo = null; let tsgoReady = null; let tsgoLaunches = 0; @@ -129,6 +162,7 @@ let mirrorRoot = null; // where mirrors + the generated tsconfig live let mirrorRootIsFallback = false; // temp-dir mirror root (workspace unwritable/absent) let mirrorRootReady = false; // lazily created on first materialization let clientSupportsWatchers = false; +let clientDefinitionLinks = false; let clientSupportsConfiguration = false; let clientInitialized = false; // the initialize handshake has COMPLETED (onInitialized) let cacheIdentity = null; // compiler build + server build (cache keying) @@ -299,7 +333,7 @@ function writeProjectWrapper(rel, sourceTsconfig) { const wrapperDir = path.join(mirrorRoot, rel); const chain = new Set(); const wrapper = projectWrapper({ - wrapperDir, sourceTsconfig, chain, + wrapperDir, sourceTsconfig, workspaceRoot, mirrorRoot, chain, onUnresolved: (spec) => connection.console.log(`[rip] ${rel}: tsconfig extends "${spec}" not resolvable — not injecting types:["*"]`), }); @@ -594,6 +628,72 @@ function mirrorBytesOf(fsPath) { try { return fs.readFileSync(mirrorPathOf('file://' + fsPath), 'utf8'); } catch { return null; } } +// One PLAIN face compile per (path, bytes), shared by the three sites that +// spell the identical compile — the typed-export reader, the mirror writer, +// and the disk face. On a cold open the gate reads a dependency's typed +// exports and the closure pass then mirrors the same bytes; without this +// memo that is two full compiles of every direct dependency (+57% on the +// average importing file of this repo's packages/, +131 ms on the worst). +// +// Holds full results, so it is BOUNDED — recency-evicted well above the +// widest direct-import fan-out measured here (16). The open buffer's own +// compile never lands here: it rides pins/tolerant/strict options, which +// are a different compile. Every consumer receives the SAME result +// object — nothing mutates compile results today, and a consumer that +// started annotating them would corrupt its siblings. +const rawCompileCache = new Map(); // fsPath → { sourceHash, result }, insertion = recency +const RAW_COMPILE_CAP = 32; +function rawCompile(fsPath, source, sourceHash) { + const hit = rawCompileCache.get(fsPath); + if (hit && hit.sourceHash === sourceHash) return hit.result; + const result = compile(source, { path: fsPath, runtimeDelivery: 'inline', face: 'ts' }); + rawCompileCache.delete(fsPath); + rawCompileCache.set(fsPath, { sourceHash, result }); + if (rawCompileCache.size > RAW_COMPILE_CAP) rawCompileCache.delete(rawCompileCache.keys().next().value); + return result; +} + +// A dependency's ANNOTATED exports, for the declaration-scope gate: an +// import of one carries that export's type information into the importer. +// +// Deliberately NOT served from faceOf. A face carries a gate, a gate asks +// its dependencies for this, and `a.rip` ↔ `b.rip` would recur forever. +// Typed exports are file-local — they ask only what a source says about its +// own declarations — so this path compiles and answers without ever +// building a gate, and the cycle cannot form. +// +// An OPEN buffer answers over disk, matching every other cross-file answer: +// the importer must be checked against the dependency the author is looking +// at, not the one they last saved. +const typedExportCache = new Map(); // fsPath → { sourceHash, names } +function typedExportsFor(fsPath) { + if (!tokenize) return null; + const open = documents.get('file://' + fsPath); + let source; + if (open) source = open.getText(); + else { try { source = fs.readFileSync(fsPath, 'utf8'); } catch { return null; } } + const sourceHash = hashText(source); + const cached = typedExportCache.get(fsPath); + if (cached && cached.sourceHash === sourceHash) return cached.names; + let names; + try { + const result = rawCompile(fsPath, source, sourceHash); + names = typedExportsOf(tokenize(source, fsPath).tokens, source, result); + } catch { + names = new Set(); // a source that will not compile types none of its importers + } + typedExportCache.set(fsPath, { sourceHash, names }); + return names; +} + +// The local names an import bound to an annotated export, for a compile +// whose source sits in `dir`. Fails to null, like the gate itself. +function typedImportsFor(stores, source, dir) { + if (!dir) return null; + try { return typedImportsOf(stores, source, dir, typedExportsFor); } + catch { return null; } +} + function faceOf(fsPath) { let source; try { source = fs.readFileSync(fsPath, 'utf8'); } catch { return null; } @@ -605,13 +705,39 @@ function faceOf(fsPath) { faceCache.delete(fsPath); let result; try { - result = compile(source, { path: fsPath, runtimeDelivery: 'inline', face: 'ts' }); + result = rawCompile(fsPath, source, sourceHash); } catch { return null; // the mirror serves a LAST-GOOD face this source no longer produces } if (mirrorBytesOf(fsPath) !== result.code) { - connection.console.log(`[rip] cross-file mapping refused for ${fsPath}: mirror bytes drifted from the source's face`); - return null; + // A mirror this compile cannot reproduce is either corruption or a + // PINNED face that outlived its session: an open buffer's refresh + // writes its pin-annotated face into the mirror (importers should see + // the richer types while the buffer lives), but pins are per-session + // probe answers, so after a restart the pin-less compile here can + // never match those bytes — and refusing would strand every + // cross-file ask into this file until its next edit. Only CLOSED + // files reach faceOf, and a closed file's canonical face is exactly + // what mirrorFromDisk writes — so re-materialize, tell tsgo, and + // serve. A mirror that STILL disagrees after the rewrite is a write + // failure or a collision, and that refusal stands: a face that does + // not reproduce the mirror describes a different text, and its + // positions would lie. + try { + const { mirrorPath } = mirrorFromDisk(fsPath, source); + if (tsgo) { + tsgo.client.notify('workspace/didChangeWatchedFiles', { + changes: [{ uri: 'file://' + mirrorPath, type: FileChangeType.Changed }], + }); + } + connection.console.log(`[rip] mirror for ${fsPath} re-materialized: its bytes had drifted from the source's face`); + } catch { + return null; // the source no longer compiles; the mirror keeps serving last-good to tsgo + } + if (mirrorBytesOf(fsPath) !== result.code) { + connection.console.log(`[rip] cross-file mapping refused for ${fsPath}: mirror bytes drifted from the source's face`); + return null; + } } const face = { sourceHash, @@ -621,6 +747,11 @@ function faceOf(fsPath) { stores: result.stores, srcLineStarts: lineStartsOf(source), genLineStarts: lineStartsOf(result.code), + // The declaration-scope gate, computed once per face and cached with it + // — the face is keyed by sourceHash, so an edit that changes which + // declarations carry annotations rebuilds this alongside the mappings. + checkedLines: scopeGateFor(source, fsPath, result, + typedImportsFor(result.stores, source, path.dirname(fsPath))), }; faceCache.set(fsPath, face); return face; @@ -648,7 +779,7 @@ const enumNamesOf = (result) => function mirrorFromDisk(fsPath, source) { faceCache.delete(fsPath); - const result = compile(source, { path: fsPath, runtimeDelivery: 'inline', face: 'ts' }); + const result = rawCompile(fsPath, source, hashText(source)); const mirrorPath = mirrorPathOf('file://' + fsPath); warnOnMirrorCollision(mirrorPath, fsPath); writeMirror(mirrorPath, result.code); @@ -1190,11 +1321,13 @@ let semanticTokensLegend = FALLBACK_LEGEND; connection.onInitialize(async (params) => { compile = await loadCompiler(); readProjectConfig = await loadProjectConfigReader(); + tokenize = await loadTokenizer(); workspaceRoot = detectWorkspaceRoot(params); planMirrorRoot(); loadCache(); clientSupportsWatchers = !!params.capabilities?.workspace?.didChangeWatchedFiles?.dynamicRegistration; clientSupportsConfiguration = !!params.capabilities?.workspace?.configuration; + clientDefinitionLinks = !!params.capabilities?.textDocument?.definition?.linkSupport; // Awaited: the advertised trigger characters and semantic-tokens // legend are tsgo's own — a made-up legend would mislabel every token. const session = await launchTsgo(); @@ -1474,6 +1607,14 @@ async function refresh(document) { srcLineStarts, genLineStarts: lineStartsOf(result.code), strict: state.strict === true, // rides the compile it governed + // The declaration-scope gate, over the SAME compile that produced the + // face above — so the lines it calls typed are the lines this face + // actually carries type information for. `dir` is computed just above; + // this reads it back rather than deriving the path a second way. + checkedLines: (() => { + const dir = (() => { try { return path.dirname(fileURLToPath(document.uri)); } catch { return null; } })(); + return scopeGateFor(text, document.uri, result, typedImportsFor(result.stores, text, dir)); + })(), }; // The last-compiled face to disk: program membership for the mirror @@ -1768,6 +1909,7 @@ documents.onDidClose(({ document }) => { connection.onDidChangeWatchedFiles(async ({ changes }) => { if (!compile || !mirrorRoot) return; const forward = []; + const ripChanged = new Set(); // closed .rip files this batch touched on disk let configChanged = false; let refreshAllForConfig = false; for (const change of changes) { @@ -1813,6 +1955,7 @@ connection.onDidChangeWatchedFiles(async ({ changes }) => { } if (!fsPath.endsWith('.rip')) continue; if (documents.get(change.uri)) continue; // open buffers own their mirrors + ripChanged.add(fsPath); const mirrorPath = mirrorPathOf(change.uri); // Closure membership is the BOOKKEEPING, never the disk: every real // face is manifest-tracked (revalidateCache seeds materializedMirrors @@ -1923,7 +2066,27 @@ connection.onDidChangeWatchedFiles(async ({ changes }) => { if (!forward.length) return; await tsgoReady; if (tsgo) tsgo.client.notify('workspace/didChangeWatchedFiles', { changes: forward }); - repullOpenDocuments(); + // A dependency that changed ON DISK can change an open importer's GATE, + // not just its answers: a created file starts carrying typed exports, a + // deleted one stops, an edit can add or remove the annotation an import + // was riding. The importer's checkedLines live in its lastGood, so a + // re-pull through that gate would keep answering from yesterday's + // annotations — an importer of a touched file gets a full refresh. + // Everyone else re-pulls: their gates never read the changed file. + const refreshed = new Set(); + if (ripChanged.size) { + for (const doc of documents.all()) { + let docPath; + try { docPath = fileURLToPath(doc.uri); } catch { continue; } + if (!cacheManifest.entries[docPath]?.imports?.some((p) => ripChanged.has(p))) continue; + refreshed.add(doc.uri); + refresh(doc).catch((err) => connection.console.error(`[rip] importer refresh failed: ${err.stack ?? err}`)); + } + } + for (const uri of states.keys()) { + if (refreshed.has(uri)) continue; + repullDiagnostics(uri).catch((err) => connection.console.error(`[rip] re-pull failed: ${err.stack ?? err}`)); + } }); // ---- feature-request plumbing. Every feature shares the same @@ -2266,15 +2429,58 @@ connection.onHover(async (params) => { return { contents, ...(range ? { range } : {}) }; }); +// The import/export specifier STRING an offset sits in, as a +// current-buffer range — quotes included, matching what TypeScript +// underlines — or null. This is the origin a client underlines for +// go-to-definition: left to the editor's word pattern, a path like +// `@rip-lang/http` underlines one segment at a time (words break at `/`, +// `-`, `.`) where TypeScript underlines the whole string literal. The +// stores carry the exact span, so a definition answered from inside one +// names it — as LocationLink's originSelectionRange, which only a +// linkSupport client is allowed to receive. +function specifierOriginAt(ctx) { + const stores = ctx.good.stores; + if (!stores?.nodesByKind) return null; + for (const kind of ['import', 'export']) { + for (const node of stores.nodesByKind(kind)) { + const src = stores.role(node.nodeId, 'source'); + if (!src || typeof src.sourceStart !== 'number') continue; + if (ctx.offset < src.sourceStart || ctx.offset >= src.sourceEnd) continue; + return goodRangeToCurrent(ctx, { + start: offsetToPosition(ctx.good.srcLineStarts, src.sourceStart), + end: offsetToPosition(ctx.good.srcLineStarts, src.sourceEnd), + }); + } + } + return null; +} + connection.onDefinition(async (params) => { await tsgoReady; const ctx = requestContext(params); - if (!ctx || ctx.genExactPosition === null) return null; + if (!ctx) return null; + // Inside an import specifier the EXACT flavor can refuse honestly: the + // face normalizes quote style, so a double-quoted string's bytes have + // no verbatim twin. The whole specifier names ONE module — the stores + // just said which — so the lenient position cannot land on a wrong + // symbol here, and nowhere else is it accepted. + const origin = specifierOriginAt(ctx); + const position = ctx.genExactPosition ?? (origin ? ctx.genPosition : null); + if (position === null) return null; const result = await tsgoRequest('textDocument/definition', { textDocument: { uri: ctx.state.tsUri }, - position: ctx.genExactPosition, + position, }, 'definition'); - return ripLocations(result); + const locations = ripLocations(result); + if (clientDefinitionLinks && origin && locations.length) { + return locations.map((loc) => ({ + originSelectionRange: origin, + targetUri: loc.uri, + targetRange: loc.range, + targetSelectionRange: loc.range, + })); + } + return locations; }); // Type definition: served exactly like definition (EXACT flavor, diff --git a/packages/vscode/src/translate.js b/packages/vscode/src/translate.js index 5d464dc0..c24fd966 100644 --- a/packages/vscode/src/translate.js +++ b/packages/vscode/src/translate.js @@ -26,7 +26,7 @@ // evolving-`let` inference — an unannotated hoisted `let x; x = 42` // reads as number at use sites, which is exactly what hover and the // real error classes (TS2322/TS2339 etc.) need. -export const SUPPRESSED_TS_CODES = new Set([ +export const IMPLICIT_ANY_CODES = new Set([ 7005, // variable implicitly any 7006, // parameter implicitly any 7008, // member implicitly any @@ -36,6 +36,7 @@ export const SUPPRESSED_TS_CODES = new Set([ 7015, // element implicitly any (index expression) 7016, // imported .js module has no declaration file — implicitly any 7017, // element implicitly any (type has no index signature) + 7018, // object literal's property implicitly any 7019, // rest parameter implicitly any[] 7022, // implicitly any from own-initializer reference 7023, // implicitly any return type from self-reference @@ -45,10 +46,46 @@ export const SUPPRESSED_TS_CODES = new Set([ 7043, // variable implicitly any — inferable suggestion 7044, // parameter implicitly any — inferable suggestion 7045, // member implicitly any — inferable suggestion + 7052, // element implicitly any (no index signature — the `did you mean` phrasing) 7053, // element implicitly any (no index signature) 7057, // `yield` implicitly any (generator lacking return-type annotation) + // Numbered outside the family it belongs to. TS2683's own message is + // "'this' implicitly has type 'any' because it does not have a type + // annotation" — the same diagnostic class, from `noImplicitThis` rather + // than `noImplicitAny`. A receiver the author never annotated, and in + // rip often one they have no spelling to annotate: `@req` inside a + // handler. + 2683, // `this` implicitly any ]); +// The MISSING-@TYPES advisories. TypeScript raises these when a +// well-known types package is absent — `import … from 'fs'` with no +// @types/node — and they are advice, not defects: the binding is already +// `any`, everything downstream type-checks against `any`, and the +// program is fully usable. That is the same situation as 7016 above +// ("imported .js module has no declaration file — implicitly any"), +// which this list has always suppressed, so the two belong together. +// +// TS2307 ("Cannot find module") is deliberately ABSENT. TypeScript +// itself draws the line: 'fs' gets the advisory below, while a typo, a +// missing dependency, and an unresolved workspace `.rip` package all get +// 2307. Suppressing that would hide the errors this project most needs +// to see, including its own module-resolution gaps. +// jQuery's pair (2581/2592) is deliberately absent — nothing reaches for +// it from rip, and carrying it would state an opinion about a library +// this toolchain has no relationship with. Someone who does use it sees +// the advisory, which is the honest outcome for an unlisted case. +export const MISSING_TYPES_CODES = new Set([ + 2580, // needs @types/node + 2582, // needs a test runner's types — bun's `describe`/`it` land here + 2591, // needs @types/node, AND `node` in the tsconfig `types` field + 2593, // needs a test runner's types, same +]); + +// What a GRADUAL file never publishes: both families above. A strict +// file publishes everything. +export const SUPPRESSED_TS_CODES = new Set([...IMPLICIT_ANY_CODES, ...MISSING_TYPES_CODES]); + // TypeScript classifies diagnostics for rendering — reportsUnnecessary // (fade the span) and reportsDeprecated (strike it through) — and tsgo // delivers them as LSP diagnostic tags when the client declares diff --git a/packages/vscode/test/editor-features.test.js b/packages/vscode/test/editor-features.test.js index 18a8a8e4..c546fb5e 100644 --- a/packages/vscode/test/editor-features.test.js +++ b/packages/vscode/test/editor-features.test.js @@ -130,7 +130,10 @@ async function inWorkspace(files, fn) { const init = await client.request('initialize', { processId: process.pid, rootUri: 'file://' + ws, - capabilities: { workspace: { configuration: true } }, + // linkSupport mirrors VS Code: a definition may answer LocationLink, + // and the specifier-origin test below depends on it. Identifier + // definitions still answer plain locations either way. + capabilities: { workspace: { configuration: true }, textDocument: { definition: { linkSupport: true } } }, }); api.capabilities = init.capabilities; client.notify('initialized', {}); @@ -409,7 +412,46 @@ describe.skipIf(!tsgoAvailable)('definition and implementation', () => { }); }, 30000); - test('a mirror corrupted AFTER the face warmed stops answering: results drop, rename refuses (byte-verification on every ask)', async () => { + // Cmd-hover underlines what the definition answer names as its origin. + // Left to the editor's word pattern, a specifier like './util.rip' + // underlines one path segment at a time (words break at '/', '-', '.'); + // TypeScript underlines the whole path. The answer is a LocationLink + // whose originSelectionRange spans the whole string literal, quotes + // included — what TypeScript underlines. + test('definition from inside an import specifier names the whole path as its origin', async () => { + await inWorkspace({ 'util.rip': UTIL }, async (api) => { + await api.open('app.rip', 'import { answer } from "./util.rip"\nconsole.log answer\n'); + const defs = await api.definition('app.rip', 0, 28); // inside 'util' — one word of the path + expect(defs.length).toBeGreaterThanOrEqual(1); + expect(defs[0].targetUri).toBe(api.uriOf('util.rip')); + // Quotes included — TypeScript underlines the whole string literal. + expect(defs[0].originSelectionRange).toEqual({ + start: { line: 0, character: 23 }, end: { line: 0, character: 35 }, + }); + + // Both quote spellings answer, through different mappings: the + // single-quoted specifier is the face's own quote style and maps + // EXACTLY; the double-quoted one above has no verbatim twin (the + // face normalizes quotes) and rides the specifier-scoped lenient + // fallback. Same origin either way. + await api.change('app.rip', "import { answer } from './util.rip'\nconsole.log answer\n"); + const single = await api.definition('app.rip', 0, 28); + expect(single.length).toBeGreaterThanOrEqual(1); + expect(single[0].originSelectionRange).toEqual({ + start: { line: 0, character: 23 }, end: { line: 0, character: 35 }, + }); + }); + }, 30000); + + // The byte verification runs on EVERY ask, cache hits included — and a + // mismatch HEALS rather than stranding: the canonical face of a closed + // file is exactly what a re-materialization writes, so the mirror is + // rewritten and the ask answers. The two real drift sources are a + // crash-partial write and a PINNED face outliving its session (an open + // buffer's refresh writes its pin-annotated face for importers; pins + // are per-session probe answers, so the pin-less recompile can never + // reproduce those bytes). + test('a mirror drifted AFTER the face warmed heals: re-materialized and answering on the next ask', async () => { await inWorkspace(THREE_FILES, async (api) => { await api.open('app.rip', APP_AB); // pulls a, b, util into the program // WARM the mirror face: a cross-file definition into unopened a.rip. @@ -417,24 +459,25 @@ describe.skipIf(!tsgoAvailable)('definition and implementation', () => { expect(defs).toHaveLength(1); expect(defs[0].uri).toBe(api.uriOf('a.rip')); - // Corrupt the MIRROR with the source unchanged — a source-hash - // memo alone would keep serving the stale face; the byte - // verification must run on EVERY ask, cache hits included. + // Drift the MIRROR with the source unchanged — a source-hash memo + // alone would keep serving the stale face. const mirror = path.join(api.ws, '.rip', 'editor', 'a.rip.ts'); - fs.writeFileSync(mirror, fs.readFileSync(mirror, 'utf8') + '\n// drifted\n'); - - // Definition and references DROP the drifted file's landings — - // the face no longer reproduces what tsgo answered from. - expect(await api.definition('app.rip', 2, 5)).toEqual([]); + const good = fs.readFileSync(mirror, 'utf8'); + fs.writeFileSync(mirror, good + '\n// drifted\n'); + + // The next ask detects the drift, re-materializes, and answers — + // and the mirror on disk is the canonical face again. + const healed = await api.definition('app.rip', 2, 5); + expect(healed).toHaveLength(1); + expect(healed[0].uri).toBe(api.uriOf('a.rip')); + expect(fs.readFileSync(mirror, 'utf8')).toBe(good); + expect(api.logs.some((l) => /re-materialized/.test(l))).toBe(true); + + // References and rename serve across the healed file too. await api.open('util.rip', THREE_FILES['util.rip']); const refs = await api.references('util.rip', 0, 10); // answer at its declaration - expect(refs.length).toBeGreaterThan(0); - expect(refs.every((r) => r.uri !== api.uriOf('a.rip'))).toBe(true); - expect(refs.some((r) => r.uri === api.uriOf('b.rip'))).toBe(true); // the intact sibling still serves - - // A rename whose edits would land in the drifted file REFUSES - // WHOLE — never a partial application to the healthy files. - expect(api.rename('util.rip', 0, 10, 'total')).rejects.toThrow(/rename refused.*a\.rip/); + expect(refs.some((r) => r.uri === api.uriOf('a.rip'))).toBe(true); + expect(refs.some((r) => r.uri === api.uriOf('b.rip'))).toBe(true); }); }, 30000); diff --git a/packages/vscode/test/editor-gaps-cross-file-resolution.test.js b/packages/vscode/test/editor-gaps-cross-file-resolution.test.js index bb2886f3..c3cfe8f3 100644 --- a/packages/vscode/test/editor-gaps-cross-file-resolution.test.js +++ b/packages/vscode/test/editor-gaps-cross-file-resolution.test.js @@ -25,15 +25,49 @@ const APP = 'import { answer } from "./util.rip"\nbad = answer.toUpperCase()\n'; describe.skipIf(!tsgoAvailable)('cross-file .rip imports resolve in the editor', () => { // Most favorable case: the dependency is open and the importer re-checked. - // The import resolves (no TS2307) and `answer`'s real type (42) flows so - // the string-method misuse is caught (TS2339). + // The import resolves (no TS2307) and `answer`'s ANNOTATED type flows so + // the string-method misuse is caught (TS2339). The annotation is what + // carries it across — the gate's ACROSS rule; the inferred case is pinned + // below. test('an imported value resolves and its type flows across files', async () => { await session('/proj', async (api) => { - await api.open('/proj/util.rip', 'export answer = 42\n'); + await api.open('/proj/util.rip', 'export answer: number = 42\n'); await api.open('/proj/app.rip', APP); await api.change('/proj/app.rip', APP + '\n'); // re-check with the dependency present expect(api.codes('/proj/app.rip')).not.toContain(2307); // module resolves expect(api.codes('/proj/app.rip')).toContain(2339); // real type flows → bug caught }); }, 30000); + + // The declaration-scope gate is FILE-LOCAL for everything except this: an + // import of an ANNOTATED export carries that export's type information + // across the boundary. Without it the boundary is incoherent — move + // `shout` into app.rip and the diagnostic appears, move it back and it + // vanishes, on a program whose annotations never changed. + // + // Driven through the real server because `rip check` and the editor + // compute this from different places (a whole-closure second pass there, + // a per-dependency cache here) and the two must not answer differently. + const TYPED_UTIL = 'export shout = (s: string): string -> s.toUpperCase()\nexport answer = 42\n'; + const MISUSE = 'import { shout } from "./util.rip"\nconsole.log shout(42)\n'; + const INFERRED = 'import { answer } from "./util.rip"\nconsole.log answer.toUpperCase()\n'; + + test('an ANNOTATED export types its importer; an inferred one does not', async () => { + await session('/proj', async (api) => { + await api.open('/proj/util.rip', TYPED_UTIL); + await api.open('/proj/app.rip', MISUSE); + await api.change('/proj/app.rip', MISUSE + '\n'); + expect(api.codes('/proj/app.rip')).toContain(2345); // the annotation crossed + }); + + // The negative half, and the reason this is not simply "imports are + // typed": `answer` carries no annotation in EITHER file, so nothing + // crosses and the misuse stays held. + await session('/proj2', async (api) => { + await api.open('/proj2/util.rip', TYPED_UTIL); + await api.open('/proj2/app.rip', INFERRED); + await api.change('/proj2/app.rip', INFERRED + '\n'); + expect(api.codes('/proj2/app.rip')).not.toContain(2339); + }); + }, 30000); }); diff --git a/packages/vscode/test/pins.test.js b/packages/vscode/test/pins.test.js index 59aae0ab..440ea887 100644 --- a/packages/vscode/test/pins.test.js +++ b/packages/vscode/test/pins.test.js @@ -4,6 +4,8 @@ // and a wrong-typed write inside the def surfaces as a REAL TS // diagnostic on rip source — the case TS7034 suppression hides today. import { test, expect, describe } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { buildProbe, parseProbeHover } from '../src/pins.js'; @@ -93,15 +95,22 @@ describe.skipIf(!tsgoAvailable)('pin probe over LSP stdio', () => { test('def-referenced hoisted binding gets pinned; wrong-typed write surfaces on rip source', async () => { const { LspClient } = await import('../src/tsgo.js'); const published = []; + // A STRICT project: the wrong-write diagnostic is a pin giving the + // implicit-any family PRECISION — strict is where that family + // publishes, and gradual holds inference-with-certainty by design + // (a pin is exactly materialized inference). The pin's other half, + // truthful hover, is mode-independent and pinned elsewhere. + const ws = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-pin-')); + fs.writeFileSync(path.join(ws, 'package.json'), JSON.stringify({ rip: { strict: true } })); const client = new LspClient('bun', [SERVER, '--stdio'], { onNotification: (method, params) => { if (method === 'textDocument/publishDiagnostics') published.push(params); }, }); try { - await client.request('initialize', { processId: process.pid, rootUri: 'file:///pindemo', capabilities: {} }); + await client.request('initialize', { processId: process.pid, rootUri: 'file://' + ws, capabilities: {} }); client.notify('initialized', {}); - const uri = 'file:///pindemo/app.rip'; + const uri = 'file://' + path.join(ws, 'app.rip'); client.notify('textDocument/didOpen', { textDocument: { uri, languageId: 'rip', version: 1, text: SRC }, }); @@ -119,6 +128,7 @@ describe.skipIf(!tsgoAvailable)('pin probe over LSP stdio', () => { expect(hit.range.start.line).toBe(6); } finally { await client.stop(); + fs.rmSync(ws, { recursive: true, force: true }); } }, 45000); }); diff --git a/packages/vscode/test/project-model.test.js b/packages/vscode/test/project-model.test.js index d67dff16..1c344d6b 100644 --- a/packages/vscode/test/project-model.test.js +++ b/packages/vscode/test/project-model.test.js @@ -203,7 +203,14 @@ const STUB_LINE = /^(export (declare const [A-Za-z_$][\w$]*: any;|type [A-Za-z_$ const isStub = (text) => text.split('\n').filter(Boolean).every((l) => STUB_LINE.test(l)); const faceCount = (ws) => mirrorPaths(ws).filter((p) => !isStub(fs.readFileSync(p, 'utf8'))).length; -const UTIL = 'export answer = 42\n'; +// ANNOTATED: the gate's ACROSS rule is what carries a dependency's type +// into the importer, and it carries annotations, not inference — so the +// misuse observable these tests share requires the export to be typed. +const UTIL = 'export answer: number = 42\n'; + +// The inferred spelling, for the tests whose observable is hover (hover +// answers whatever the face knows, gate or no gate). +const INFERRED_UTIL = 'export answer = 42\n'; const APP = 'import { answer } from "./util.rip"\nbad = answer.toUpperCase()\n'; @@ -238,7 +245,7 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { await inWorkspace({ 'util.rip': UTIL }, async (api) => { // The OPEN buffer contradicts its disk face: string, not number — // so .toUpperCase() is legal and the importer must stay clean. - await api.open('util.rip', 'export answer = "s"\n'); + await api.open('util.rip', 'export answer: string = "s"\n'); await api.open('app.rip', APP); await api.change('app.rip', APP + '\n'); expect(api.codes('app.rip')).not.toContain(2307); @@ -271,7 +278,7 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { }, 30000); test('cross-file readiness: hover crosses the file boundary onto .rip source', async () => { - await inWorkspace({ 'util.rip': UTIL }, async (api) => { + await inWorkspace({ 'util.rip': INFERRED_UTIL }, async (api) => { await api.open('app.rip', APP); await api.change('app.rip', APP + '\n'); // Hover `answer` inside the importer's use site (line 1, `bad = answer…`). @@ -337,8 +344,8 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { // posture: startup COMPILE work follows the closure, not the // workspace — candidacy is what follows the workspace). const files = { - 'chain/a.rip': 'import { b } from "./b.rip"\nexport a = b + 1\n', - 'chain/b.rip': 'export b = 41\n', + 'chain/a.rip': 'import { b } from "./b.rip"\nexport a: number = b + 1\n', + 'chain/b.rip': 'export b: number = 41\n', }; for (let i = 0; i < 200; i++) files[`bulk/mod${i}.rip`] = `export value${i} = ${i}\n`; await inWorkspace(files, async (api) => { @@ -346,7 +353,7 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { await api.open('app.rip', src); await api.change('app.rip', src + '\n'); expect(api.codes('app.rip')).not.toContain(2307); - expect(api.codes('app.rip')).toContain(2339); // the type crossed BOTH hops + expect(api.codes('app.rip')).toContain(2339); // each hop's annotation crossed // The structural pin: exactly the closure — app + a + b — holds a // compiled face. The 200 bystanders are in the tree, and in tsgo's @@ -360,8 +367,8 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { test('persistent cache: a restart recompiles only what changed; a build-identity mismatch purges', async () => { const ws = makeWorkspace({ - 'a.rip': 'import { b } from "./b.rip"\nexport a = b + 1\n', - 'b.rip': 'export b = 41\n', + 'a.rip': 'import { b } from "./b.rip"\nexport a: number = b + 1\n', + 'b.rip': 'export b: number = 41\n', }); try { const APP2 = 'import { a } from "./a.rip"\nbad = a.toUpperCase()\n'; @@ -374,7 +381,7 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { }); // b.rip changes while the server is DOWN. - fs.writeFileSync(path.join(ws, 'b.rip'), 'export b = 43\n'); + fs.writeFileSync(path.join(ws, 'b.rip'), 'export b: number = 43\n'); // Session 2: revalidation recompiles ONLY b (a is fresh by hash; // app never existed on disk — buffer-only, so its entry leaves the @@ -621,8 +628,8 @@ describe.skipIf(!tsgoAvailable)('disk-layer hygiene', () => { test('crash-partial mirrors: corrupted bytes behind the cache are detected and recompiled', async () => { const ws = makeWorkspace({ - 'a.rip': 'import { b } from "./b.rip"\nexport a = b + 1\n', - 'b.rip': 'export b = 41\n', + 'a.rip': 'import { b } from "./b.rip"\nexport a: number = b + 1\n', + 'b.rip': 'export b: number = 41\n', }); try { const APP2 = 'import { a } from "./a.rip"\nbad = a.toUpperCase()\n'; @@ -793,9 +800,9 @@ describe.skipIf(!tsgoAvailable)('workspace ambient .d.ts and prototype augmentat await inWorkspace({ 'rip-env.d.ts': 'interface String { shout(): string }\n' }, async (api) => { await api.open('app.rip', 'String.prototype.shout = -> @toUpperCase() + "!"\nout = "hi".shout()\n'); await api.until('app.rip', (codes) => !codes.includes(2339)); - // The d.ts widened one interface, not the checking: a real typo - // still reports. - await api.change('app.rip', 'bad = "hi".missing()\n'); + // The d.ts widened one interface, not the checking: on a checked + // line (the annotation gates it on), a real typo still reports. + await api.change('app.rip', 'bad: string = "hi".missing()\n'); await api.until('app.rip', (codes) => codes.includes(2339)); }); }, 30000); diff --git a/packages/vscode/test/server.test.js b/packages/vscode/test/server.test.js index 672b6f7c..edb6ac73 100644 --- a/packages/vscode/test/server.test.js +++ b/packages/vscode/test/server.test.js @@ -140,10 +140,12 @@ describe.skipIf(!tsgoAvailable)('server over LSP stdio', () => { // A valid edit after the crash: the restart-once policy brings // TS diagnostics back (the bad call maps onto .rip source again). + // The misuse rides an ANNOTATED binding so the revived pipeline has + // a diagnostic the gradual gate publishes. wait = nextDiagnostics(published); client.notify('textDocument/didChange', { textDocument: { uri, version: 3 }, - contentChanges: [{ text: GOOD + 'bad = count.toUpperCase()\nconsole.log bad\n' }], + contentChanges: [{ text: GOOD + 'n: number = 42\nbad = n.toUpperCase()\nconsole.log bad\n' }], }); const recovered = await wait(); expect(recovered.diagnostics).toHaveLength(1); diff --git a/src/check.js b/src/check.js index 89e06c81..64aa0f37 100644 --- a/src/check.js +++ b/src/check.js @@ -28,9 +28,27 @@ import { identifierRunAt } from './lexer.js'; import { startTsgo } from '../packages/vscode/src/tsgo.js'; import { buildProbe, parseProbeHover } from '../packages/vscode/src/pins.js'; import { mapTsDiagnostic, applyRipDirectives, isNoCheckPath, compileErrorInfo } from '../packages/vscode/src/diagnostics.js'; +import { SUPPRESSED_TS_CODES, IMPLICIT_ANY_CODES, MISSING_TYPES_CODES } from '../packages/vscode/src/translate.js'; +import { scopeGateOf, typedExportsOf, typedImportsOf } from '../packages/vscode/src/scopes.js'; +import { tokenize } from './lexer.js'; import { generatedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf } from '../packages/vscode/src/mirror.js'; import { lineStartsOf, offsetToPosition, positionToOffset, generatedSpanToSource } from '../packages/vscode/src/translate.js'; +// Fails OPEN, like the editor's: a source the lexer refuses leaves the gate +// undefined and every diagnostic publishes. An empty annotation set would +// silence the whole file, and a silent file reads as a clean one. +const scopeGate = (source, fsPath, face, typedImports) => { + try { return scopeGateOf(tokenize(source, fsPath).tokens, source, face, typedImports); } + catch { return undefined; } +}; + +// A module's ANNOTATED exports — file-local, so a lexer refusal costs this +// module's importers their cross-file checking and nothing else. +const moduleTypedExports = (source, fsPath, face) => { + try { return typedExportsOf(tokenize(source, fsPath).tokens, source, face); } + catch { return new Set(); } +}; + const HELP = `rip check — type-check .rip files headlessly (the tsc --noEmit of rip-land) Usage: @@ -124,17 +142,28 @@ function commonAncestor(files) { for (; i < first.length; i++) if (!split.every((parts) => parts[i] === first[i])) break; return first.slice(0, i).join(path.sep) || path.sep; } +// The workspace root anchors the mirror, and a WORKSPACES root outranks +// a nearer plain package.json: checking `packages/http` from a monorepo +// root must land the sibling packages its bare imports resolve to INSIDE +// the mirror, and stopping at the package's own manifest strands them +// outside. The walk records the nearest marker as the fallback and keeps +// climbing for a `workspaces` declaration — bun's own resolution rule. function findWorkspaceRoot(files) { const base = commonAncestor(files); + let nearest = null; for (let cur = base; ; ) { - for (const marker of ['package.json', 'tsconfig.json', '.git']) { - if (fs.existsSync(path.join(cur, marker))) return cur; + const pkg = path.join(cur, 'package.json'); + if (fs.existsSync(pkg)) { + try { if (JSON.parse(fs.readFileSync(pkg, 'utf8')).workspaces) return cur; } catch { /* malformed — a marker, not a root */ } + nearest ??= cur; + } else if (['tsconfig.json', '.git'].some((m) => fs.existsSync(path.join(cur, m)))) { + nearest ??= cur; } const parent = path.dirname(cur); if (parent === cur) break; cur = parent; } - return base; + return nearest ?? base; } const targets = collectTargets(positionals.length ? positionals : ['.']); @@ -165,6 +194,17 @@ const parseDiags = []; // rows for files that failed to compile // clean 0 must mean "checked, and clean", never "couldn't check"), mirroring // the tsgo-unavailable posture below. let incompleteCheck = false; +// Diagnostics dropped by the gradual posture, counted so the summary can +// say so. A run that hides hundreds and reports nothing about it reads as +// "rip's checker is weak" rather than "this project is in gradual mode" — +// the wrong lesson, and an undiscoverable one. +// +// Counted per FAMILY because the remedies differ: one is a mode you can +// turn on, the other is a package you can install. A single total would +// point everyone at the wrong one. +let hiddenAnnotations = 0; +let hiddenMissingTypes = 0; +let hiddenScope = 0; const seen = new Set(); const queue = [...targets]; while (queue.length) { @@ -194,7 +234,7 @@ while (queue.length) { continue; } compiled.set(fsPath, { - source, cfg, + source, cfg, result, good: { source, code: result.code, mappings: result.mappings, echoSpans: result.echoSpans ?? [], @@ -208,6 +248,22 @@ while (queue.length) { } } +// The gate runs in a SECOND pass, once the whole closure is compiled: a +// file's gate depends on which of its imports name an ANNOTATED export, and +// the queue reaches a dependency after the file importing it as often as +// before. Typed exports are file-local, so this pass needs no ordering of +// its own and an import cycle cannot recur through it. +const typedExports = new Map(); +for (const [fsPath, entry] of compiled) { + typedExports.set(fsPath, moduleTypedExports(entry.source, fsPath, entry.result)); +} +for (const [fsPath, entry] of compiled) { + entry.good.checkedLines = scopeGate( + entry.source, fsPath, entry.result, + typedImportsOf(entry.result.stores, entry.source, path.dirname(fsPath), (p) => typedExports.get(p)), + ); +} + // ── materialize the mirror + drive one tsgo session ───────────────── const tsDiags = []; let tsgoUnavailable = false; // tsgo needed but could not start — a run that couldn't type-check @@ -265,6 +321,7 @@ if (compiled.size > 0) { const wrapperDir = path.join(mirrorRoot, rel); const wrapper = projectWrapper({ wrapperDir, sourceTsconfig: path.join(workspaceRoot, rel, 'tsconfig.json'), + workspaceRoot, mirrorRoot, }); fs.mkdirSync(wrapperDir, { recursive: true }); fs.writeFileSync(path.join(wrapperDir, 'tsconfig.json'), JSON.stringify(wrapper.tsconfig, null, 2)); @@ -405,6 +462,19 @@ if (compiled.size > 0) { const mapped = []; for (const d of pulled?.items ?? []) { const m = mapTsDiagnostic(entry.good, d); + // Count only what strict would actually SHOW. The suppression + // check runs before the mapping one, so a bare code test also + // counts diagnostics that would have been dropped anyway for + // having no source span — inflating the number several-fold and + // promising the user diagnostics `rip.strict` would never + // deliver. Re-map with the strict flag to ask the real question. + if (!m && !entry.cfg.strict && mapTsDiagnostic({ ...entry.good, strict: true }, d)) { + if (IMPLICIT_ANY_CODES.has(d.code)) hiddenAnnotations++; + else if (MISSING_TYPES_CODES.has(d.code)) hiddenMissingTypes++; + // Held by the declaration-scope gate: the author annotated + // nothing here, so nothing is asked of them. + else hiddenScope++; + } if (!m) continue; // The diagnostic carries its own relatedInformation (secondary // "declared here" locations), each mapped from its generated @@ -536,6 +606,20 @@ if (asJson) { // Named once, at the end, whatever the run's verdict — a clean run that // hid 2,000 diagnostics is exactly the case where saying nothing // misleads most. + const plural = (n) => (n === 1 ? '' : 's'); + if (hiddenAnnotations > 0 || hiddenMissingTypes > 0 || hiddenScope > 0) console.log(''); + if (hiddenScope > 0) { + console.log(gray(`${hiddenScope} diagnostic${plural(hiddenScope)} hidden ` + + `(unannotated declarations — annotate one to check it, or set \`rip.strict\`)`)); + } + if (hiddenAnnotations > 0) { + console.log(gray(`${hiddenAnnotations} annotation diagnostic${plural(hiddenAnnotations)} hidden ` + + `(gradual mode — set \`rip.strict\` in package.json to see them)`)); + } + if (hiddenMissingTypes > 0) { + console.log(gray(`${hiddenMissingTypes} missing-@types advisor${hiddenMissingTypes === 1 ? 'y' : 'ies'} hidden ` + + `(the imports are \`any\`; install the @types package to type them)`)); + } } // Exit: 1 on type errors; 2 when the run could not cover what was asked — diff --git a/src/dts.js b/src/dts.js index 1440d313..c85b293d 100644 --- a/src/dts.js +++ b/src/dts.js @@ -192,6 +192,21 @@ export function emitDeclarations({ sexpr, stores, source }) { } } } + // Instance method bodies, for the `@field = …` walk below. A field a + // helper establishes is the class's just as surely as one the + // constructor inlines, and the declaration must say so or a consumer + // publishes TS2339 cross-module on a property the runtime really has. + // Statics are excluded — their `this` is the class. + const instanceMethodBodies = []; + for (const stmt of stmts) { + if (!isNode(stmt) || stmt[0] !== 'object') continue; + for (const pair of stmt.slice(1)) { + if (pair[0] !== ':' && pair[0] !== 'void-pair') continue; + if (isStaticKey(pair[1]) || !isFunc(pair[2])) continue; + if (memberName(pair[1]) === 'constructor') continue; + instanceMethodBodies.push(pair[2][2]); + } + } for (const stmt of stmts) { if (isNode(stmt) && stmt[0] === 'object') { for (const pair of stmt.slice(1)) { @@ -238,7 +253,7 @@ export function emitDeclarations({ sexpr, stores, source }) { // author's annotation when there is one; `any` otherwise — // a declaration file cannot repeat the constructor // inference the face relies on. - for (const at of ctorAtFields(value[2])) { + for (const at of ctorAtFields([value[2], ...instanceMethodBodies])) { if (declared.has(at.name)) continue; declared.add(at.name); // Any of the field's assignments can carry the author's diff --git a/src/emitter.js b/src/emitter.js index 019d6747..9633c468 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -25,7 +25,7 @@ import { descriptorSegments, behaviorObjectText, paramNamesOf, splitTopLevelByCo import { buildSchemaTypeStory, isModuleShaped, SchemaTypeError } from './schema-types.js'; import { Parser } from './parser.js'; import { applyInsertionPass, implicitBlocks, implicitObjects, implicitCalls, tagPostfixConditionals, rewriteTypes, identifierRunAt, isIdentifierName } from './lexer.js'; -import { TypeTextError, normalizeTypeText, tidyType, renderTypeDecl, renderParams, optionalReader } from './typetext.js'; +import { TypeTextError, normalizeTypeText, tidyType, renderTypeDecl, renderParams, optionalReader, jsArityOptional } from './typetext.js'; import { TEMPLATE_TAGS, SVG_ONLY_TAGS, DOM_EVENTS, BOOLEAN_ATTRS, knownBareAttribute } from './dom-vocab.js'; import { COMPONENT_HOOKS, COMPONENT_RUNTIME_FIELDS, componentTypeInfo, memberDeclareSegments, isDeclarableMember, @@ -152,7 +152,7 @@ export function atParamField(p) { return { name, typed: isNode(x) && x[0] === 'typed-var' && x.length === 3 ? x : null }; } -// Every `@name = …` a constructor body assigns, in source order, +// Every `@name = …` an instance method body assigns, in source order, // deduped by name (a field written twice declares once). A plain // function is not entered — its `this` is another object; a bound // arrow is, because its `this` is this instance's. `viaArrow` marks @@ -167,7 +167,22 @@ export function atParamField(p) { // field, and a declaration built from the first-seen node alone dropped // whichever annotation arrived later. This walk has no store access, so // it cannot ask which node carries one; the consumers can, and scan. -export function ctorAtFields(body) { +// +// Takes EVERY instance method body, not the constructor's alone. A +// constructor that hands its field setup to a helper (`@_refresh()` filling +// in a dozen cached parts) establishes those fields just as surely as one +// that inlines them, and the class is no less correct for it — this repo's +// own `Time` published 121 diagnostics on that shape. TypeScript reads plain +// `.js` the same way, inferring a class's properties from `this.x =` in any +// method, so this is the rule its own untyped dialect already follows. +// +// The bodies share one `seen` map, so the merging above works ACROSS them: +// a field an arrow assigns in the constructor and a method assigns directly +// is not arrow-only, and the annotation can ride whichever body carries it. +// Pass the constructor first — source order is what orders the result. Takes +// an ARRAY always: a body is itself an array, so no runtime test can tell one +// from a list of them, and guessing silently walked neither. +export function ctorAtFields(bodies) { const out = []; const seen = new Map(); const walk = (n, inArrow) => { @@ -202,7 +217,9 @@ export function ctorAtFields(body) { } for (const el of n.slice(1)) walk(el, inArrow || h === '=>'); }; - walk(body, false); + for (const body of bodies) { + if (body !== null && body !== undefined) walk(body, false); + } return out; } // The comparison family — every COMPARE-level operator chains, @@ -12183,6 +12200,8 @@ class Emitter { const declared = new Set(); let ctorParams = null; let ctorBody = null; + // Instance method bodies, for the `@field = …` pass below. + const methodBodies = []; for (const stmt of stmts) { if (!isObject(stmt)) { const field = isStaticKey(stmt) ? null @@ -12207,6 +12226,11 @@ class Emitter { } else if (!isStaticKey(pair[1]) && typeof mName === 'string') { declared.add(mName); } + // A STATIC method is excluded: its `this` is the class, so what + // it assigns is not an instance property. + if (isFunc(pair[2]) && !isStaticKey(pair[1]) && mName !== 'constructor') { + methodBodies.push(pair[2][2]); + } if (isFunc(pair[2]) && pair[2][0] === '=>' && !isStaticKey(pair[1]) && mName !== 'constructor') { bound.push(mName); firstBound ??= pair; @@ -12237,7 +12261,7 @@ class Emitter { } } - // A constructor BODY's `@field = value` is the same story as a + // An instance method's `@field = value` is the same story as a // promoted parameter, by a different route: it assigns the instance // property and declares nothing, and TypeScript reads a class's // properties from its declarations alone — so every assignment AND @@ -12248,8 +12272,8 @@ class Emitter { // where a declaration would REDEFINE it rather than describe it. // // Control flow is walked THROUGH (a field assigned inside an `if` - // still declares) but a nested function is not: its `this` is not - // this instance's, so an assignment there says nothing about this + // still declares) but a nested plain function is not: its `this` is + // not this instance's, so an assignment there says nothing about this // class. The `declared` set keeps a body-level declaration winning, // here as for promotions — one declaration, or TypeScript reads the // pair as duplicate identifiers. @@ -12259,8 +12283,11 @@ class Emitter { // descend into arrows, so its bare declaration would be an implicit // any — TS7008 under noImplicitAny, minted on generated-only bytes // no source position answers for (the tsScaffoldAny doctrine). - if (this.ts && ctorBody !== null) { - for (const at of ctorAtFields(ctorBody)) { + // + // EVERY instance method feeds this, not the constructor alone — see + // ctorAtFields. The constructor leads so source order holds. + if (this.ts) { + for (const at of ctorAtFields([ctorBody, ...methodBodies])) { if (declared.has(at.name)) continue; declared.add(at.name); // The annotation can ride ANY of the field's assignments — the @@ -12781,8 +12808,50 @@ class Emitter { // TS-only, and only when that param is a bare name — an annotated, // defaulted, rest or pattern param is the author's own shape and is // never overridden. - emitParams(params, firstParamTypeText = null) { - Emitter.expansionSplit(params).list.forEach((p, i) => { + // True when something OTHER than the parameter list already types a + // function's parameters, so JS arity must not touch them: marking a + // contextually-typed `number` optional widens it to `number | undefined` + // and publishes TS18048 on correct code. The audit caught both sources. + // + // ARGUMENT POSITION — `nums.reduce((acc, n) => acc + n, 0)` writes no + // annotation, but `reduce` types `acc` and `n`. Tested by containment + // in an `args` role span, so a callback nested in another callback is + // one too. A `def` inside a callback body is swept along; that only + // WITHHOLDS the marker, which is the safe direction. + // + // AN ANNOTATED BINDING — `doubler: (n: number) => string = (n) => …` + // types `n` from the target's annotation, not from the parameter. + // + // The compiler's own handler-param injection is the third source, handled + // where it is applied (`firstParamTypeText` in emitParams). + contextuallyTyped(node) { + const id = this.stores.idOf(node); + const self = id === null ? null : this.stores.node(id); + if (!self || typeof self.sourceStart !== 'number') return false; + this._argSpans ??= this.stores.roles + .filter((r) => r.role === 'args' && typeof r.sourceStart === 'number') + .map((r) => [r.sourceStart, r.sourceEnd]); + if (this._argSpans.some(([s, e]) => self.sourceStart >= s && self.sourceEnd <= e)) return true; + this._annotatedValueSpans ??= (() => { + const spans = new Set(); + for (const n of this.stores.nodes) { + if (n.semanticKind !== 'assign' && n.semanticKind !== 'pair') continue; + if (!this.stores.role(n.nodeId, 'annotation')) continue; + const v = this.stores.role(n.nodeId, 'value'); + if (typeof v?.sourceStart === 'number') spans.add(`${v.sourceStart}:${v.sourceEnd}`); + } + return spans; + })(); + return this._annotatedValueSpans.has(`${self.sourceStart}:${self.sourceEnd}`); + } + + emitParams(params, firstParamTypeText = null, jsArity = true) { + const list = Emitter.expansionSplit(params).list; + const optional = jsArity ? jsArityOptional(list) : new Set(); + // The injected handler-param annotation types the FIRST param, so JS + // arity leaves it alone — it is no longer unannotated. + if (firstParamTypeText !== null) optional.delete(0); + list.forEach((p, i) => { // A promoted parameter reaching emission was NOT stripped by a // constructor — the shape belongs to constructors alone (there // is no instance for any other function's `@name` to bind). @@ -12792,12 +12861,26 @@ class Emitter { } if (i > 0) this.b.emit(', '); this.emitParam(p); + // JS ARITY (TS-only). A bare parameter the author never annotated is + // optional, because in rip — as in JavaScript — calling with fewer + // arguments is legal and yields `undefined`, and `arguments.length` + // branching on it is idiomatic. Declaring it REQUIRED made the face + // enforce an arity rip never promised: `resolveUrl = (url, env) ->` + // drew TS2554 at every one-argument call it was written to accept. + // + // Nothing is lost under `rip.strict`, which is why this needs no mode: + // an unannotated parameter is already TS7006 there, so the omission is + // reported once, at the parameter, rather than again at every call. + // The moment the author annotates, the parameter is required and the + // arity check is back. + if (optional.has(i) && this.ts) this.b.tsOnly(() => this.b.emit('?')); if (i === 0 && firstParamTypeText !== null && typeof p === 'string' && this.ts) { this.b.tsOnly(() => this.b.emit(`: ${firstParamTypeText}`)); } }); } + // ['->'|'=>', params, block]. `->` emits a function expression, `=>` an // arrow (single simple param drops its parens; a single-expression body // inlines, operand-grouped). Both bodies implicitly return their last @@ -12829,6 +12912,9 @@ class Emitter { ? 'emitter: a generator arrow cannot sit inside a component body — thin arrows lower to fat arrows there to keep `this` on the instance, and JS has no generator arrows (name the generator a method and call it)' : 'emitter: fat arrows cannot contain yield (JS has no generator arrows; use ->)'); } + // A callback's parameters are typed by its callee, so JS arity does + // not apply to them (see inCallArgs). + const inArgs = this.ts && this.contextuallyTyped(node); // The return-type role (side-band) covers the whole emitted // function; mark() is a no-op for untyped rows. this.mark(node, 'returnType', () => this.mark(node, '$self', () => { @@ -12836,7 +12922,7 @@ class Emitter { if (isAsync) this.b.emit('async '); this.mark(node, 'kind', () => this.b.emit(isGen ? 'function*' : 'function')); this.b.emit('('); - this.mark(node, 'params', () => this.emitParams(params)); + this.mark(node, 'params', () => this.emitParams(params, null, !inArgs)); this.b.emit(')'); this.tsReturnAnnotation(node, isAsync, isVoid); this.b.emit(' '); @@ -12847,9 +12933,13 @@ class Emitter { // before a return annotation (`x: T => …` does not parse); // the TS face adds them as TS-only bytes, so stripping // restores the bare-name JS spelling. + // A lone BARE param takes the JS-arity `?` like any other (see + // emitParams), which needs the parens too — `x? => …` does not + // parse. Both are TS-only, so stripping restores `x => …`. + const jsArity = this.ts && !inArgs && params.length === 1 && typeof params[0] === 'string'; const tsParens = this.ts && params.length === 1 && typeof Emitter.paramCore(params[0]) === 'string' && - (Emitter.isTypedWrapper(params[0]) || this.annotationText(node, 'returnType') !== null || isVoid); + (Emitter.isTypedWrapper(params[0]) || this.annotationText(node, 'returnType') !== null || isVoid || jsArity); this.mark(node, 'params', () => { // Only a single PLAIN name drops its parens — patterns, // rests, and defaults keep them (JS requires it). A typed @@ -12857,10 +12947,11 @@ class Emitter { if (params.length === 1 && typeof Emitter.paramCore(params[0]) === 'string') { if (tsParens) this.b.tsOnly(() => this.b.emit('(')); this.emitParam(params[0]); + if (jsArity) this.b.tsOnly(() => this.b.emit('?')); if (tsParens) this.b.tsOnly(() => this.b.emit(')')); } else { this.b.emit('('); - this.emitParams(params); + this.emitParams(params, null, !inArgs); this.b.emit(')'); } }); diff --git a/src/typetext.js b/src/typetext.js index 90869f9d..5ecb2c80 100644 --- a/src/typetext.js +++ b/src/typetext.js @@ -485,7 +485,39 @@ export const renderParam = (p, isOptional) => { return renderTarget(p, patternType(p), opt); }; -export const renderParams = (params, isOptional) => `(${params.map((p) => renderParam(p, isOptional)).join(', ')})`; +// The indices JS ARITY makes optional: the TRAILING run of bare, +// unannotated names. In rip — as in JavaScript — calling with fewer +// arguments is legal and yields `undefined`, and `arguments.length` +// branching on it is idiomatic, so a parameter the author never annotated +// was never promised to be required. +// +// Trailing because TypeScript rejects a required parameter after an +// optional one, so the run has to reach the end. Scanning back, a default +// or a rest is passed OVER — both are already call-site optional, and +// stopping at one would leave `(a, opts = {})` demanding its first +// argument. An ANNOTATED parameter stops the scan: the author said +// something about it, and `x?` is theirs to write. +// +// One definition, both signature emitters — the face reads it through +// `emitParams`, the `.d.ts` through `renderParams` below. It is +// POSITIONAL, so it cannot ride `optionalReader`'s per-param shape, and +// letting each emitter work it out separately is precisely how the `?` +// marker has drifted before, in both directions. +export const jsArityOptional = (params) => { + const out = new Set(); + for (let i = params.length - 1; i >= 0; i--) { + const p = params[i]; + if (typeof p === 'string') { out.add(i); continue; } + if (Array.isArray(p) && (p[0] === 'default' || p[0] === 'rest')) continue; + break; + } + return out; +}; + +export const renderParams = (params, isOptional) => { + const arity = jsArityOptional(params); + return `(${params.map((p, i) => renderParam(p, (q) => isOptional(q) || arity.has(i))).join(', ')})`; +}; export const paramTyped = (p) => isTypedWrapper(p) || diff --git a/test/lang/tsface.test.js b/test/lang/tsface.test.js index 232835ac..75d5764d 100644 --- a/test/lang/tsface.test.js +++ b/test/lang/tsface.test.js @@ -55,6 +55,7 @@ const REGION_SHAPES = [ /^satisfies \S/u, // a schema field default's value enforcement: `v satisfies T` /^<\S/su, // a type ARGUMENT list: an annotated reactive's `__state(v)`, and a generic `def`'s own `` parameters /^!$/u, // a component prop assertion's bare `!` (state fallbacks like `props.label!`) + /^\?$/u, // JS arity: the `?` on a bare unannotated trailing param /^[()]$/u, // arrow-param / cast parens /^as\s+\S/u, // the cast's `as T` spelling /^this: \S/u, // schema callable `this` param @@ -358,7 +359,7 @@ describe('TS-face emission pins', () => { test('return types: def, arrow, parameterless def; async wraps as Promise (TS1064)', () => { expect(ts('def f(a: number): string\n String(a)\n').code) .toBe('function f(a: number): string {\n return String(a);\n}' + MARKER); - expect(ts('k = (x): number => x + 1\n').code).toBe('let k = (x): number => (x + 1);' + MARKER); + expect(ts('k = (x): number => x + 1\n').code).toBe('let k = (x?): number => (x + 1);' + MARKER); expect(ts('def go(a: number): number\n await a\n').code) .toBe('async function go(a: number): Promise {\n return await a;\n}' + MARKER); // A user-spelled Promise passes through unwrapped. @@ -367,13 +368,13 @@ describe('TS-face emission pins', () => { }); test('void definitions annotate `: void` (async: Promise) under the voidMarker', () => { - expect(ts('def save!(x)\n x\n').code).toBe('function save(x): void {\n x;\n return;\n}' + MARKER); + expect(ts('def save!(x)\n x\n').code).toBe('function save(x?): void {\n x;\n return;\n}' + MARKER); // The binding declares in place, so the annotation sits on an // INITIALIZED declaration — the span a semantic token and a hover // are both read at carries the function value. - expect(ts('tick! = (x) =>\n x\n').code).toBe('let tick = (x): void => {\n x;\n return;\n};' + MARKER); + expect(ts('tick! = (x) =>\n x\n').code).toBe('let tick = (x?): void => {\n x;\n return;\n};' + MARKER); expect(ts('def flush!(x)\n await x\n').code) - .toBe('async function flush(x): Promise {\n await x;\n return;\n}' + MARKER); + .toBe('async function flush(x?): Promise {\n await x;\n return;\n}' + MARKER); }); test('structured aliases: one-line, generic, block union, block object, wrapped single', () => { @@ -431,12 +432,72 @@ describe('TS-face emission pins', () => { test('overload signatures print adjacent to their implementation (TS2391)', () => { expect(ts('def f(a: number): string\ndef f(a: string): string\ndef f(a)\n String(a)\n').code) - .toBe('function f(a: number): string;\nfunction f(a: string): string;\nfunction f(a) {\n return String(a);\n}' + MARKER); + .toBe('function f(a: number): string;\nfunction f(a: string): string;\nfunction f(a?) {\n return String(a);\n}' + MARKER); }); test('typed class fields, methods, and void methods', () => { expect(ts('class A\n x: number = 5\n y: string\n m: (v: number): number -> v\n save!: (v) ->\n v\n').code) - .toBe('class A {\n x: number = 5;\n y: string;\n m(v: number): number {\n return v;\n }\n save(v): void {\n v;\n return;\n }\n}' + MARKER); + .toBe('class A {\n x: number = 5;\n y: string;\n m(v: number): number {\n return v;\n }\n save(v?): void {\n v;\n return;\n }\n}' + MARKER); + }); + + test('a field an instance METHOD assigns is declared, wherever the constructor put it', () => { + // A constructor that hands its field setup to a helper establishes + // those fields just as surely as one that inlines them. Scanning the + // constructor alone left them undeclared, and TypeScript reads a + // class's properties from its DECLARATIONS — so the assignment and + // every later read both published TS2339 on a class that runs + // correctly. This repo's own `Time` fills a dozen cached parts in + // `_refresh()` and drew 121 diagnostics for it. + const src = 'class A\n constructor: ->\n @refresh()\n refresh: ->\n @count = 1\n read: -> @count\n'; + const helper = ts(src); + expect(helper.code).toBe( + 'class A {\n count;\n constructor() {\n this.refresh();\n }\n refresh() {\n return (this.count = 1);\n }\n read() {\n return this.count;\n }\n}' + MARKER); + // TS-only, like the promoted parameter's: a declaration in the JS + // twin would REDEFINE the property rather than describe it. + expect(stripFace(helper.code, helper.tsRegions)).toBe(js(src).code); + + // One declaration per field, and the CONSTRUCTOR's annotation wins: + // two would read to TypeScript as duplicate identifiers. + expect(ts('class B\n constructor: ->\n @v: string = "s"\n m: ->\n @v = "t"\n').code) + .toBe('class B {\n v: string;\n constructor() {\n this.v = "s";\n }\n m() {\n return (this.v = "t");\n }\n}' + MARKER); + + // A STATIC method's `this` is the class, so what it assigns is not + // an instance property — `cache` must not declare, `seen` must. + expect(ts('class C\n @make: ->\n @cache = 1\n m: ->\n @seen = 2\n').code) + .toBe('class C {\n seen;\n static make() {\n return (this.cache = 1);\n }\n m() {\n return (this.seen = 2);\n }\n}' + MARKER); + }); + + test('JS arity: a bare trailing parameter is optional — except where something else types it', () => { + // Calling with fewer arguments is legal in rip as in JavaScript, and + // `arguments.length` branching on it is idiomatic. Declaring every + // unannotated parameter REQUIRED made the face enforce an arity rip + // never promised: this repo's own `resolveUrl = (url, env) ->` drew + // TS2554 at each one-argument call it was written to accept. + expect(ts('def g(url, env)\n url\n').code) + .toBe('function g(url?, env?) {\n return url;\n}' + MARKER); + // A default and a rest are passed OVER — already call-site optional, + // and stopping at one would leave `a` demanding its argument. + expect(ts('f = (a, opts = {}) ->\n a\n').code).toContain('function(a?, opts = {})'); + expect(ts('f = (a, ...rest) ->\n a\n').code).toContain('function(a?, ...rest)'); + // An ANNOTATED parameter stops the scan: the author said something, and + // `x?` is theirs to write. It MUST stop, too — TypeScript rejects a + // required parameter following an optional one. + expect(ts('f = (a, b: number) ->\n a\n').code).toContain('function(a, b: number)'); + expect(ts('f = (a: number, b) ->\n a\n').code).toContain('function(a: number, b?)'); + + // NOT where the parameter is already typed from elsewhere: marking a + // contextual `number` optional widens it to `number | undefined` and + // publishes TS18048 on correct code. Argument position… + expect(ts('xs = [1]\nm = xs.map (n) -> n * 2\n').code).toContain('xs.map(function(n) {'); + // …and an annotated binding, which types the parameters from its target. + expect(ts('d: (n: number) => string = (n) => String(n)\n').code) + .toContain('let d: (n: number) => string = n => String(n);'); + + // TS-only throughout: stripping restores the bare JS spelling, including + // the parens a lone `x?` needs and JS mode does not write. + const lone = ts('f = (x) => x\n'); + expect(lone.code).toContain('(x?) => x'); + expect(stripFace(lone.code, lone.tsRegions)).toBe(js('f = (x) => x\n').code); }); test('exported typed declarations annotate the const', () => { @@ -706,7 +767,7 @@ describe('TS directive comments', () => { test('a directive above an overload signature follows it to the printed overload row', () => { pin( 'def o(a: number): string\n# @ts-expect-error\ndef o(a: string): string\ndef o(a)\n String(a)\n', - 'function o(a: number): string;\n// @ts-expect-error\nfunction o(a: string): string;\nfunction o(a) {\n return String(a);\n}', + 'function o(a: number): string;\n// @ts-expect-error\nfunction o(a: string): string;\nfunction o(a?) {\n return String(a);\n}', ); }); diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index d0ac3808..1fa92073 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -59,6 +59,42 @@ function monorepo({ rootStrict = false, nestedStrict = true } = {}) { return dir; } +// A FRESH PROJECT: what a newcomer has after `bun init` plus a .rip +// file — a tsconfig, and @types/bun installed. `withTypes:false` is the +// same project before anything is installed, which is the posture the +// host floor exists for. +// +// The source is deliberately ordinary: the idioms rip encourages, not a +// minimal case. `(opts = {}) ->` is the shape that produced 329 of the +// 1,657 errors in a survey of packages/ (2026-07-31), and `import.meta.dir` +// another 143 — between them a fifth of everything a newcomer would see. +const FRESH = [ + 'greet = (name, opts = {}) ->', + " suffix = opts.suffix ?? ''", + ' name + suffix', + '', + 'here = import.meta.dir', + "console.log greet('world', { suffix: '!' }), here", + '', +].join('\n'); + +function freshProject({ withTypes = true } = {}) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-fresh-')); + fs.writeFileSync(path.join(dir, 'tsconfig.json'), JSON.stringify({ + compilerOptions: { target: 'ESNext', module: 'preserve', moduleDetection: 'force', noEmit: true, skipLibCheck: true }, + }, null, 2)); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fresh', devDependencies: withTypes ? { '@types/bun': 'latest' } : {} }, null, 2)); + if (withTypes) { + const t = path.join(dir, 'node_modules', '@types', 'bun'); + fs.mkdirSync(t, { recursive: true }); + fs.writeFileSync(path.join(t, 'package.json'), JSON.stringify({ name: '@types/bun', version: '1.0.0', types: 'index.d.ts' })); + fs.writeFileSync(path.join(t, 'index.d.ts'), + 'declare var Bun: any;\ndeclare var process: any;\ninterface ImportMeta { dir: string; file: string; path: string }\n'); + } + fs.writeFileSync(path.join(dir, 'app.rip'), FRESH); + return dir; +} + function check(dir, args = []) { const r = spawnSync('bun', [BIN, 'check', ...args], { cwd: dir, encoding: 'utf8', timeout: 60_000 }); return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status }; @@ -363,7 +399,7 @@ describeExtended('rip check: type diagnostics over the real server', () => { 'wrongNested = c.nested', 'wrongDeep = c.deep', ].join('\n') + '\n', - }); + }, { strict: true }); try { const diags = JSON.parse(check(dir, ['--json']).stdout); expect(diags.filter((d) => d.file === 'live.rip').map((d) => d.code)).toEqual([2322]); // liveness @@ -371,6 +407,10 @@ describeExtended('rip check: type diagnostics over the real server', () => { const neg = JSON.parse(check(negDir, ['--json']).stdout); expect(neg.map((d) => [d.code, d.line])).toEqual([ + // Asserted under rip.strict: a negatives fixture asks for every + // diagnostic, and gradual suppresses the implicit-`this` class the + // way it suppresses implicit-any — which would hide the two rows + // this case exists to prove. [2683, 5], // the `->`'s own untyped `this` — not this class's [2683, 7], // and the arrow under it captures THAT one, not the instance [2322, 14], // `plain` inferred `string`; line 13's write to `wide` stays silent @@ -521,8 +561,17 @@ describeExtended('rip check: type diagnostics over the real server', () => { // runs ensures before id/timestamps exist, so the implicit columns // type Partial<> and an unguarded `m.id` is refused (TS18048) instead // of crashing the first create. + // + // Both landmines are NULL-assignability facts, so the project spells + // `strictNullChecks` in its own tsconfig — which also pins the yield: + // gradual supplies `strictNullChecks: false` only to a chain that says + // nothing, and an author's own strictness wins (`nullPosture`, + // mirror.js). Without it the checker cannot draw the distinction these + // contracts ride on, in any mode. test('the schema face follows runtime ordering: date defaults admit strings, required [null] publishes, ensures see Partial implicits', () => { + const audit = JSON.parse(fs.readFileSync(TSCONFIG, 'utf8')); const dir = workspace({ + 'tsconfig.json': JSON.stringify({ ...audit, compilerOptions: { ...audit.compilerOptions, strictNullChecks: true } }), 'ordering.rip': [ 'Ev = schema :shape', ' when! date, ["2024-01-01"]', @@ -582,6 +631,47 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // A bare workspace specifier (`@rip/util`) is how packages import each + // other: a node_modules symlink whose package.json `exports` lands on a + // `.rip` file. bun resolves that at runtime; the mirror must resolve it + // too — the target joins the closure and the generated tsconfig maps + // the bare name onto the mirror face — or every cross-package import in + // the workspace publishes TS2307. The check targets a SUBDIRECTORY on + // purpose: the workspace root is the nearest ancestor declaring + // `workspaces`, not the first package.json above the target, or the + // sibling package sits outside the mirror and nothing resolves. + // The gate's ACROSS rule rides the same resolution: the ANNOTATED + // export carries into the importer, the inferred one stays held. + test('a bare workspace .rip specifier resolves; its annotated exports carry, inferred ones stay held', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-check-ws-')); + try { + fs.copyFileSync(TSCONFIG, path.join(dir, 'tsconfig.json')); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ workspaces: ['packages/*'] })); + fs.mkdirSync(path.join(dir, 'packages', 'app'), { recursive: true }); + fs.mkdirSync(path.join(dir, 'packages', 'util'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'packages', 'util', 'package.json'), + JSON.stringify({ name: '@rip/util', exports: { '.': './util.rip' } })); + fs.writeFileSync(path.join(dir, 'packages', 'util', 'util.rip'), + 'export answer: number = 42\nexport plain = 1\n'); + fs.writeFileSync(path.join(dir, 'packages', 'app', 'app.rip'), [ + "import { answer, plain } from '@rip/util'", + "import * as mod from '@rip/util'", + 'bad = answer.toUpperCase()', + 'meh = plain.toUpperCase()', + 'console.log bad, meh, mod', + ].join('\n') + '\n'); + fs.mkdirSync(path.join(dir, 'node_modules', '@rip'), { recursive: true }); + fs.symlinkSync(path.join('..', '..', 'packages', 'util'), path.join(dir, 'node_modules', '@rip', 'util')); + const diags = JSON.parse(check(dir, ['--json', path.join('packages', 'app')]).stdout); + // Resolution: no cannot-find-module anywhere, on any of the three + // import spellings (named, named-unannotated, namespace). + expect(diags.map((d) => d.code)).not.toContain(2307); + // ACROSS: `answer`'s annotation carries — the misuse reports at its + // line; `plain` carries nothing and its misuse is held. + expect(diags.filter((d) => d.file === path.join('packages', 'app', 'app.rip')).map((d) => [d.code, d.line])).toEqual([[2339, 3]]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // A pattern catch mints its binding (`catch (_err) { ({message} = _err); … }`) // and annotates it, so the lowering's own destructure never publishes — // in EITHER try, statement or value, on EITHER pattern kind. The four @@ -596,7 +686,15 @@ describeExtended('rip check: type diagnostics over the real server', () => { // assignment beside the destructure still publishes — so the annotation // cannot have been spent on the whole clause. Codes bound to their lines, // columns free. Liveness-paired. - test('a pattern catch never publishes from its own lowering, and the identifier spelling keeps unknown', () => { + // The identifier spelling's `unknown` was deliberate once — the author + // can narrow it the ordinary ways, and `catch err: any` is spellable. + // The gradual-annotations posture overrides that: `err.message` is the + // commonest catch body there is, and requiring a narrowing the author + // did not ask for is annotation pressure, which is the one thing this + // mode governs. Under `rip.strict` the `unknown` is back — asserted + // below, so the ruling is pinned in both directions rather than simply + // relaxed. + test('a pattern catch never publishes from its own lowering; an identifier catch follows the mode', () => { const dir = workspace({ 'catchpat.rip': [ 'try', @@ -639,8 +737,27 @@ describeExtended('rip check: type diagnostics over the real server', () => { const diags = JSON.parse(check(dir, ['--json']).stdout); expect(diags.filter((d) => d.file === 'live.rip').map((d) => d.code)).toEqual([2322]); // liveness expect(diags.filter((d) => d.file === 'catchpat.rip')).toEqual([]); + // Gradual: the `e.message` read is gone; the planted TS2322 stays, + // so the file is still being checked rather than skipped. expect(diags.filter((d) => d.file === 'scoped.rip').map((d) => [d.code, d.line])) - .toEqual([[18046, 4], [2322, 9]]); + .toEqual([[2322, 9]]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + + // The other half of the same ruling: a project that asked for strict is + // told about the unnarrowed catch read, exactly as TypeScript would. + test('under rip.strict an identifier catch is `unknown` again', () => { + const dir = workspace({ + 'scoped.rip': [ + 'try', + " JSON.parse('broken')", + 'catch e', + ' console.log e.message', + ].join('\n') + '\n', + }, { strict: true }); + try { + const diags = JSON.parse(check(dir, ['--json']).stdout); + expect(diags.map((d) => [d.code, d.line])).toEqual([[18046, 4]]); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); @@ -1413,6 +1530,95 @@ describeExtended('rip check: type diagnostics over the real server', () => { // The inverse posture, so the assertion above is not passing on a // hardcoded direction: strict at the root, loose in the nested project. // A flat mirror answers the same way in both, which is the whole defect. + // THE ACCEPTANCE GATE for permissive mode: what a newcomer writes on + // day one reports nothing. Permissive is the DEFAULT, so this is the + // first thing anyone experiences; every error here is one they have to + // interpret before they have any way to. + test('a fresh project checks clean under permissive mode', () => { + const dir = freshProject(); + try { + const r = check(dir); + expect(JSON.parse(check(dir, ['--json']).stdout)).toEqual([]); + expect(r.status).toBe(0); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + // The same project BEFORE `bun install` — no @types anywhere. The host + // floor is what carries it, and it deactivates the moment the real + // types arrive (the case above), so the two gates hold both sides of + // that switch. + // The floor stops the moment the real types arrive — asserted, because + // an index signature that survived an install would make every typo on + // `import.meta` legal forever. The read is annotated so the line is + // gated ON: what this pins is the FLOOR yielding (a widened ImportMeta + // would answer `any` and report nothing even on a checked line), not + // where the gate reaches — an ambient global's type does not open the + // lines that merely mention it (see scopes.js). + test('the floor yields to @types/bun rather than widening it', () => { + const dir = freshProject(); // withTypes: the real declaration governs + try { + fs.writeFileSync(path.join(dir, 'app.rip'), 'x: unknown = import.meta.nosuchfield\nconsole.log x\n'); + expect(JSON.parse(check(dir, ['--json']).stdout).map((d) => d.code)).toEqual([2339]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + test('the same project is quiet before anything is installed', () => { + const dir = freshProject({ withTypes: false }); + try { + expect(JSON.parse(check(dir, ['--json']).stdout)).toEqual([]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + // An import TypeScript cannot type is `any` — it says so itself, and + // says it TWICE: TS7016 for a .js module with no declarations, which + // gradual has always suppressed, and TS2580 for a well-known @types + // package that is not installed, which it did not. Same situation, same + // posture. The binding is `any` either way, so nothing downstream + // changes; what changes is whether the advisory is shouted at a project + // that did not ask for it. + test('a missing @types package is advisory in gradual mode, an error under strict', () => { + const files = { 'app.rip': "import { readFileSync } from 'fs'\nconsole.log readFileSync('/x')\n" }; + const gradual = workspace(files); + const strict = workspace(files, { strict: true }); + try { + expect(JSON.parse(check(gradual, ['--json']).stdout)).toEqual([]); + // Strict still says it, so the suppression is a MODE, not a deletion. + expect(JSON.parse(check(strict, ['--json']).stdout).map((d) => d.code)).toEqual([2580]); + } finally { + fs.rmSync(gradual, { recursive: true, force: true }); + fs.rmSync(strict, { recursive: true, force: true }); + } + }, 60_000); + + // `noImplicitThis` rides the strict umbrella, and TS2683's own message + // is "'this' implicitly has type 'any'" — the same class the 7xxx family + // covers, numbered outside it. `@req` in a handler is a receiver the + // author never annotated and has no obvious spelling to annotate, so + // demanding one is annotation pressure by another route. + test("an unannotated `this` is quiet in gradual mode, an error under strict", () => { + const files = { 'app.rip': 'handler = -> @req\nconsole.log handler\n' }; + const gradual = workspace(files); + const strict = workspace(files, { strict: true }); + try { + expect(JSON.parse(check(gradual, ['--json']).stdout)).toEqual([]); + expect(JSON.parse(check(strict, ['--json']).stdout).map((d) => d.code)).toEqual([2683]); + } finally { + fs.rmSync(gradual, { recursive: true, force: true }); + fs.rmSync(strict, { recursive: true, force: true }); + } + }, 60_000); + + // The line that must NOT move: a module nothing can resolve stays an + // error. Typos, missing dependencies, and rip's own unresolved + // workspace packages all live here, and TypeScript's own code is what + // separates them from the advisory above. + test('an unresolvable module is still an error in gradual mode', () => { + const dir = workspace({ 'app.rip': "import { x } from 'totally-not-a-package'\nconsole.log x\n" }); + try { + expect(JSON.parse(check(dir, ['--json']).stdout).map((d) => d.code)).toEqual([2307]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + test('the polarity inverts with the configs — strict root, loose nested', () => { const dir = monorepo({ rootStrict: true, nestedStrict: false }); try { diff --git a/test/toolchain/dts.test.js b/test/toolchain/dts.test.js index a6b258b1..d1c30e39 100644 --- a/test/toolchain/dts.test.js +++ b/test/toolchain/dts.test.js @@ -81,11 +81,11 @@ const ROWS = [ // the void-marker family declares `: void` ['def save!(x: number)\n x', 'declare function save(x: number): void;\nexport {};\n'], ['def tick!\n 1', 'declare function tick(): void;\nexport {};\n'], - ['save! = (x) -> x', 'declare function save(x: any): void;\nexport {};\n'], + ['save! = (x) -> x', 'declare function save(x?: any): void;\nexport {};\n'], ['export save! = (x: number) -> x', 'export declare function save(x: number): void;\n'], // an explicit return type on a void def wins (the rule; the marker // still suppresses the implicit return at runtime) - ['def typed!(x): Number\n bump(x)', 'declare function typed(x: any): Number;\nexport {};\n'], + ['def typed!(x): Number\n bump(x)', 'declare function typed(x?: any): Number;\nexport {};\n'], // bodiless overload signatures ['def f(a: number): string\ndef f(a)\n a', 'declare function f(a: number): string;\nexport {};\n'], [ From 3746d2dd5ac9b9255dc0d6dcb54d6e39d6d1b888 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 19:11:24 +0100 Subject: [PATCH 02/23] Green the extended tier, name what gradual hides, stamp the build MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/vscode/src/scopes.js | 24 +++++++- packages/vscode/src/server.js | 20 ++++-- src/check.js | 78 ++++++++++++++++++++---- test/toolchain/check.test.js | 76 ++++++++++++++++++++++- test/toolchain/config-reactivity.test.js | 8 ++- test/toolchain/hint-positions.test.js | 6 +- test/toolchain/reactive-imports.test.js | 6 +- 7 files changed, 191 insertions(+), 27 deletions(-) diff --git a/packages/vscode/src/scopes.js b/packages/vscode/src/scopes.js index 01418e88..e7544af8 100644 --- a/packages/vscode/src/scopes.js +++ b/packages/vscode/src/scopes.js @@ -66,7 +66,7 @@ import { bareRipSpecifierTarget } from './mirror.js'; // The cannot-find family: a name or module that does not exist. 2552 is 2304 // with a spelling suggestion attached ("Cannot find name 'error'. Did you // mean 'err'?"): the same defect wearing a different code, and the reason to -// enumerate rather than eyeball. The family is spelled across six codes +// enumerate rather than eyeball. The family is spelled across many codes // because TypeScript varies the ADVICE, not the defect: a bare miss (2304), a // spelling suggestion (2552), and two "change your target library" forms // (2583 for ES built-ins, 2584 for DOM globals) are one diagnostic wearing @@ -74,19 +74,35 @@ import { bareRipSpecifierTarget } from './mirror.js'; // driving the real checker over `doc = document.title`, after 2304 and 2552 // had both looked sufficient. // +// The same defect spelled at the MODULE BOUNDARY: importing a member the +// module does not export (2305, and 2724 with a suggestion), a default +// import from a module with no default (2613), and a named import that +// should have been the default (2614). A consumer typo-ing an import name +// wrote a name that does not exist — found by driving a real consumer over +// a workspace package, where 2305 was the one silent member of the family. +// // The definition cycle (2502): a computed that reads itself, directly or // through others, recurses forever the first time anyone reads it. An // annotation would break TypeScript's INFERENCE cycle and so silence the // code — while leaving the runtime cycle exactly where it was. A diagnostic // an annotation silences without fixing belongs outside the gate. +// +// The import write (2632): assigning to an imported binding is a runtime +// TypeError under ESM — the bundler refuses it too — and no annotation in +// either module changes that. export const ALWAYS_REPORTED_CODES = new Set([ 2304, // cannot find name + 2305, // module has no exported member 2307, // cannot find module 2502, // referenced directly or indirectly in its own definition 2503, // cannot find namespace 2552, // cannot find name — did you mean 'Y'? 2583, // cannot find name — change your target library (ES built-in) 2584, // cannot find name — change your target library (DOM global) + 2613, // module has no default export + 2614, // module has no exported member — did you mean a default import? + 2632, // cannot assign to an import + 2724, // module has no exported member — did you mean 'Y'? ]); // offset → 0-based line, over one pass of the source. Every pass here works @@ -433,7 +449,11 @@ function linesMentioning(tokens, source, bindings) { export function exportedNamesOf(stores, source) { const names = []; if (!stores?.nodes) return names; - const DECLS = new Set(['assign', 'def', 'class', 'component', 'typedvar']); + // Every declaring form an export can wrap, the reactive ones included — + // `export count: number := 0` binds `count` exactly as `=` would, and + // leaving `state`/`computed`/`readonly` out silently dropped annotated + // reactive exports from every importer's gate. + const DECLS = new Set(['assign', 'state', 'computed', 'readonly', 'def', 'class', 'component', 'typedvar']); for (const node of stores.nodesByKind('export')) { const spec = stores.role(node.nodeId, 'spec'); if (typeof spec?.sourceStart !== 'number') continue; diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index e24ffedf..d5c21cdd 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -88,8 +88,9 @@ async function loadCompiler() { ]; for (const candidate of candidates) { if (fs.existsSync(fileURLToPath(candidate))) { + compilerDir = path.dirname(fileURLToPath(candidate)); cacheIdentity = cacheIdentityOf( - path.dirname(fileURLToPath(candidate)), + compilerDir, path.dirname(fileURLToPath(import.meta.url)), ); return (await import(candidate.href)).compile; @@ -166,6 +167,7 @@ let clientDefinitionLinks = false; let clientSupportsConfiguration = false; let clientInitialized = false; // the initialize handshake has COMPLETED (onInitialized) let cacheIdentity = null; // compiler build + server build (cache keying) +let compilerDir = null; // where the compiler resolved from (in-repo vs staged) — the ready log names it // rip document uri → per-buffer state. const states = new Map(); @@ -1389,9 +1391,19 @@ connection.onInitialized(async () => { watchers: [{ globPattern: '**/*.rip' }, { globPattern: '**/tsconfig.json' }, { globPattern: '**/package.json' }], }); } - connection.console.log( - `[rip] ready (workspace: ${workspaceRoot ?? 'none'}, mirror root: ${mirrorRoot}${mirrorRootIsFallback ? ' [fallback]' : ''})`, - ); + // The build hash is `rip check --build`'s twin — same content hash over + // the same two trees — so one glance at this block against that output + // says whether the installed extension matches the checkout it serves. + // One aligned line per fact: the single-line form wrapped illegibly the + // moment real paths landed in it. Paths shorten to `~`, and the mirror + // prints workspace-relative when it lives inside the workspace. + const tilde = (p) => (p && p.startsWith(os.homedir() + path.sep) ? '~' + p.slice(os.homedir().length) : p); + const mirrorShown = workspaceRoot && mirrorRoot.startsWith(workspaceRoot + path.sep) + ? path.relative(workspaceRoot, mirrorRoot) : tilde(mirrorRoot); + connection.console.log(`[rip] ready (build ${cacheIdentity ?? 'unknown'})`); + connection.console.log(`[rip] compiler: ${tilde(compilerDir) ?? 'unresolved'}`); + connection.console.log(`[rip] workspace: ${tilde(workspaceRoot) ?? 'none'}`); + connection.console.log(`[rip] mirror: ${mirrorShown}${mirrorRootIsFallback ? ' [fallback]' : ''}`); await revalidateCache(); repullOpenDocuments(); // Auto-import candidacy, and deliberately NOT awaited: it is a diff --git a/src/check.js b/src/check.js index 64aa0f37..af782f01 100644 --- a/src/check.js +++ b/src/check.js @@ -21,7 +21,8 @@ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { pathToFileURL } from 'node:url'; +import { pathToFileURL, fileURLToPath } from 'node:url'; +import { cacheIdentityOf } from '../packages/vscode/src/hash.js'; import { compile } from './compile.js'; import { readProjectConfig } from './config.js'; import { identifierRunAt } from './lexer.js'; @@ -62,6 +63,10 @@ Options: --keep-mirror Keep the generated TS mirror (.rip/check) after the run instead of removing it — for inspecting the exact TypeScript tsgo type-checked + --build Print the build identity (a content hash over the + compiler and editor-server trees) and exit — the + editor logs the same hash in its ready line, so a + mismatch means the installed extension is stale -h, --help Show this help Exit status is 0 when no error-severity diagnostic survives, 1 otherwise. @@ -74,10 +79,24 @@ const fail = (message, code = 2) => { console.error(message); process.exit(code) // ── argument parsing ──────────────────────────────────────────────── const argv = process.argv.slice(2); if (argv.includes('-h') || argv.includes('--help')) { console.log(HELP); process.exit(0); } +// The build identity, printed and exited on before anything else: the same +// content hash over the same two trees (compiler + editor server) the +// editor computes for its cache key and logs in its ready line. When the +// two hashes differ, the installed extension is running different code +// than this CLI — the skew behind "the editor and rip check disagree". +if (argv.includes('--build')) { + const compilerDir = path.dirname(fileURLToPath(import.meta.url)); + const serverDir = path.join(compilerDir, '..', 'packages', 'vscode', 'src'); + const tilde = (p) => (p.startsWith(os.homedir() + path.sep) ? '~' + p.slice(os.homedir().length) : p); + console.log(`rip check build ${cacheIdentityOf(compilerDir, serverDir)}`); + console.log(` compiler ${tilde(compilerDir)}`); + console.log(` server ${tilde(serverDir)}`); + process.exit(0); +} const asJson = argv.includes('--json'); const showFrames = !argv.includes('--no-frame') && !asJson; const keepMirror = argv.includes('--keep-mirror'); -const KNOWN = new Set(['--json', '--no-frame', '--keep-mirror']); +const KNOWN = new Set(['--json', '--no-frame', '--keep-mirror', '--build']); const positionals = argv.filter((a) => !a.startsWith('-')); const unknownFlags = argv.filter((a) => a.startsWith('-') && !KNOWN.has(a)); if (unknownFlags.length) fail(`rip check: unknown option${unknownFlags.length === 1 ? '' : 's'}: ${unknownFlags.join(', ')}\n\nRun 'rip check --help' for usage.`); @@ -205,6 +224,16 @@ let incompleteCheck = false; let hiddenAnnotations = 0; let hiddenMissingTypes = 0; let hiddenScope = 0; +// The NAMES the missing-types advisories are about (`describe`, `require` +// …) — TypeScript's own message carries each one, and a summary that says +// "install the @types package" without a noun sends the user hunting +// through their imports for which declaration is absent. +const missingTypeNames = new Set(); +// The PROJECTS the hidden diagnostics belong to (config-dir, cwd-relative), +// per family — named in the summary so the `rip.strict` remedy points at +// the right package.json. The home project ('.') stays unnamed. +const hiddenScopeDirs = new Set(); +const hiddenAnnotationDirs = new Set(); const seen = new Set(); const queue = [...targets]; while (queue.length) { @@ -469,11 +498,21 @@ if (compiled.size > 0) { // promising the user diagnostics `rip.strict` would never // deliver. Re-map with the strict flag to ask the real question. if (!m && !entry.cfg.strict && mapTsDiagnostic({ ...entry.good, strict: true }, d)) { - if (IMPLICIT_ANY_CODES.has(d.code)) hiddenAnnotations++; - else if (MISSING_TYPES_CODES.has(d.code)) hiddenMissingTypes++; + // Which PROJECT the hidden diagnostic belongs to — config is + // per file, so a strict consumer's check still hides its + // gradual dependencies' diagnostics, and a summary that says + // "set `rip.strict`" right after the user did exactly that + // reads as broken unless it names whose package.json is meant. + const proj = path.relative(process.cwd(), entry.cfg._configDir ?? path.dirname(fsPath)) || '.'; + if (IMPLICIT_ANY_CODES.has(d.code)) { hiddenAnnotations++; hiddenAnnotationDirs.add(proj); } + else if (MISSING_TYPES_CODES.has(d.code)) { + hiddenMissingTypes++; + const name = /Cannot find name '([^']+)'/.exec(d.message)?.[1]; + if (name) missingTypeNames.add(name); + } // Held by the declaration-scope gate: the author annotated // nothing here, so nothing is asked of them. - else hiddenScope++; + else { hiddenScope++; hiddenScopeDirs.add(proj); } } if (!m) continue; // The diagnostic carries its own relatedInformation (secondary @@ -605,20 +644,35 @@ if (asJson) { } // Named once, at the end, whatever the run's verdict — a clean run that // hid 2,000 diagnostics is exactly the case where saying nothing - // misleads most. + // misleads most. Three lines because the remedies differ — annotate a + // declaration, flip the mode, install declarations — and the strict + // remedy is SPELLED IDENTICALLY on both lines that offer it: a summary + // wording one lever two ways reads as two levers. const plural = (n) => (n === 1 ? '' : 's'); + // The projects a family's hidden diagnostics live in, minus the home + // project — "set `rip.strict`" must point at the right package.json when + // the hiding happens in a dependency the target does not govern. + const inProjects = (dirs) => { + const named = [...dirs].filter((d) => d !== '.').sort(); + if (!named.length) return ''; + return ` (${named.slice(0, 3).join(', ')}${named.length > 3 ? ` and ${named.length - 3} more` : ''})`; + }; if (hiddenAnnotations > 0 || hiddenMissingTypes > 0 || hiddenScope > 0) console.log(''); if (hiddenScope > 0) { - console.log(gray(`${hiddenScope} diagnostic${plural(hiddenScope)} hidden ` - + `(unannotated declarations — annotate one to check it, or set \`rip.strict\`)`)); + console.log(gray(`${hiddenScope} diagnostic${plural(hiddenScope)} hidden in unannotated code${inProjects(hiddenScopeDirs)} ` + + `— annotate a declaration to check its scope, or set \`rip.strict\` in package.json`)); } if (hiddenAnnotations > 0) { - console.log(gray(`${hiddenAnnotations} annotation diagnostic${plural(hiddenAnnotations)} hidden ` - + `(gradual mode — set \`rip.strict\` in package.json to see them)`)); + console.log(gray(`${hiddenAnnotations} annotation diagnostic${plural(hiddenAnnotations)} hidden${inProjects(hiddenAnnotationDirs)} ` + + `— set \`rip.strict\` in package.json to see where annotations are missing`)); } if (hiddenMissingTypes > 0) { - console.log(gray(`${hiddenMissingTypes} missing-@types advisor${hiddenMissingTypes === 1 ? 'y' : 'ies'} hidden ` - + `(the imports are \`any\`; install the @types package to type them)`)); + const names = [...missingTypeNames].sort(); + const shown = names.slice(0, 4).map((n) => `\`${n}\``).join(', '); + const more = names.length > 4 ? ` and ${names.length - 4} more` : ''; + const about = names.length ? ` — no declarations for ${shown}${more}` : ''; + console.log(gray(`${hiddenMissingTypes} missing-types advisor${hiddenMissingTypes === 1 ? 'y' : 'ies'} hidden` + + `${about} (try \`bun add -d @types/bun\`)`)); } } diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 1fa92073..104411f0 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -117,6 +117,16 @@ describe('rip check: usage surface (no server)', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); + // The build hash makes CLI-vs-editor skew diagnosable at a glance: the + // editor logs the same identity in its ready line, computed over the + // same two trees (compiler + server) by content, so an installed + // extension and a worktree CLI agree exactly when their code does. + test('--build prints the build identity and exits 0', () => { + const r = spawnSync('bun', [BIN, 'check', '--build'], { encoding: 'utf8' }); + expect(r.status).toBe(0); + expect(r.stdout).toMatch(/^rip check build [0-9a-f]+\n compiler .+\n server .+\n$/); + }); + test('an unknown flag exits 2', () => { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-check-flag-')); try { @@ -658,7 +668,8 @@ describeExtended('rip check: type diagnostics over the real server', () => { "import * as mod from '@rip/util'", 'bad = answer.toUpperCase()', 'meh = plain.toUpperCase()', - 'console.log bad, meh, mod', + "import { nosuch } from '@rip/util'", // a member that does not exist + 'console.log bad, meh, mod, nosuch', ].join('\n') + '\n'); fs.mkdirSync(path.join(dir, 'node_modules', '@rip'), { recursive: true }); fs.symlinkSync(path.join('..', '..', 'packages', 'util'), path.join(dir, 'node_modules', '@rip', 'util')); @@ -667,8 +678,67 @@ describeExtended('rip check: type diagnostics over the real server', () => { // import spellings (named, named-unannotated, namespace). expect(diags.map((d) => d.code)).not.toContain(2307); // ACROSS: `answer`'s annotation carries — the misuse reports at its - // line; `plain` carries nothing and its misuse is held. - expect(diags.filter((d) => d.file === path.join('packages', 'app', 'app.rip')).map((d) => [d.code, d.line])).toEqual([[2339, 3]]); + // line; `plain` carries nothing and its misuse is held. Importing a + // member the module does not export is a NAME that does not exist — + // the cannot-find family spelled at the module boundary — and + // publishes whatever is annotated. + expect(diags.filter((d) => d.file === path.join('packages', 'app', 'app.rip')).map((d) => [d.code, d.line])).toEqual([[2339, 3], [2305, 5]]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + + // The hidden-diagnostics summary is the mode's ledger: three lines, + // one per family, because the remedies differ — annotate a + // declaration, flip `rip.strict`, install declarations. The lines + // spell the strict remedy IDENTICALLY (a summary that words the same + // lever two ways reads as two levers), and the missing-types advisory + // NAMES the declarations it is about — "install the @types package" + // with no noun sends the user hunting through their own imports. + test('the hidden-diagnostics summary: consistent remedies, and the missing declarations are named', () => { + const dir = workspace({ + 'app.rip': [ + 'n = 42', + 'bad = n.toUpperCase()', // real error, held → scope family + 'def shout(msg)', // implicitly-any parameter → annotation family + ' msg', + "describe 'adds', ->", // known-typings globals, no types installed — + ' console.log bad, shout', + "fsMod = require('fs')", // …each advisory names ITS missing declaration + 'console.log fsMod', + ].join('\n') + '\n', + }); + try { + const out = check(dir).stdout; + expect(out).toMatch(/\d+ diagnostics? hidden in unannotated code — annotate a declaration to check its scope, or set `rip\.strict` in package\.json/); + expect(out).toMatch(/\d+ annotation diagnostics? hidden — set `rip\.strict` in package\.json to see where annotations are missing/); + expect(out).toMatch(/\d+ missing-types advisor(y|ies) hidden — no declarations for `describe`, `require` \(try `bun add -d @types\/bun`\)/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + + // Config is per FILE (nearest package.json), so a strict consumer's + // check still hides its gradual DEPENDENCIES' diagnostics — and a + // summary that says "set `rip.strict` in package.json" after the user + // just did exactly that reads as broken. The lines name the projects + // the hidden diagnostics belong to, so the remedy points at the right + // package.json; the home project ('.') alone stays unnamed. + test('hidden-diagnostics summary names the gradual projects when the target itself is strict', () => { + const dir = workspace({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), // anchor the mirror at the monorepo root + 'packages/app/package.json': JSON.stringify({ rip: { strict: true } }), + 'packages/app/app.rip': "import { x } from '../util/util.rip'\nconsole.log x\n", + 'packages/util/package.json': JSON.stringify({}), + 'packages/util/util.rip': [ + 'y = 42', + 'bad = y.toUpperCase()', // held → scope family, charged to packages/util + 'def shout(msg)', // implicitly-any parameter → annotation family + ' msg', + 'export x = 1', + 'console.log bad, shout', + ].join('\n') + '\n', + }); + try { + const out = check(dir, [path.join('packages', 'app')]).stdout; + expect(out).toMatch(/\d+ diagnostics? hidden in unannotated code \(packages\/util\) — annotate a declaration/); + expect(out).toMatch(/\d+ annotation diagnostics? hidden \(packages\/util\) — set `rip\.strict`/); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); diff --git a/test/toolchain/config-reactivity.test.js b/test/toolchain/config-reactivity.test.js index 40e10b7b..7d5d41ac 100644 --- a/test/toolchain/config-reactivity.test.js +++ b/test/toolchain/config-reactivity.test.js @@ -149,7 +149,11 @@ describeExtended('the config surface is reactive', () => { // workspace package.json, which is on the watch list — // node_modules events themselves never arrive (VS Code's watcher // excludes them), and nothing in this pipeline self-watches the disk. - const FLOOR_PROBE = 'console.log(Bun.nonsense)\n'; + // ANNOTATED probe: an ambient global's type never opens the lines that + // merely mention it (the gate's stated limit), so the read rides an + // annotated binding — what these tests pin is the FLOOR's answer, and + // that answer must be asserted on a line the gradual gate checks. + const FLOOR_PROBE = 'probe: unknown = Bun.nonsense\nconsole.log(probe)\n'; const UNRESOLVED_BUN = [2304, 2580, 2867, 2868]; // "cannot find name" family, per checker context test('installing @types retracts the floor live — the manifest edit re-governs the program', async () => { @@ -184,7 +188,7 @@ describeExtended('the config surface is reactive', () => { // `process` at its installed truth, `Bun` still an honest `any` — a // single all-or-nothing floor would strand `Bun` unresolvable here. const s = await openSession({ - 'app.rip': 'console.log(Bun.nonsense)\nconsole.log(process.madeUp)\n', + 'app.rip': 'a: unknown = Bun.nonsense\nb: unknown = process.madeUp\nconsole.log(a, b)\n', 'package.json': pkg(null), 'node_modules/@types/node/package.json': '{ "name": "@types/node", "version": "0.0.0", "types": "index.d.ts" }\n', 'node_modules/@types/node/index.d.ts': 'declare var process: { pid: number };\n', diff --git a/test/toolchain/hint-positions.test.js b/test/toolchain/hint-positions.test.js index 5399a818..d5988546 100644 --- a/test/toolchain/hint-positions.test.js +++ b/test/toolchain/hint-positions.test.js @@ -29,11 +29,11 @@ const SRC = [ '', // line 4 'instant = new Ticket!', // line 5 bang at col 20 'numbered = new Ticket!(7)', // line 6 bang at col 21 - 'syncFn = -> 42', // line 7 + 'syncFn: () => number = -> 42', // line 7 annotated: types line 8's read 'got = syncFn!', // line 8 bang at col 12 'fetchReal = -> Promise.resolve(7)', // line 9 'good = fetchReal!', // line 10 a real await — silent - 'plain = await 5', // line 11 author-spelled, cols 8-13 + 'plain: number = await 5', // line 11 author-spelled, cols 16-21 'console.log(instant.serial, numbered.serial, got, good, plain)', // line 12 '', ].join('\n'); @@ -60,7 +60,7 @@ describeExtended('hint positions — the await hint lands on the operator the au expect(spanOf(6), 'new Ticket!(7) — the bang').toEqual([21, 6, 22]); expect(spanOf(8), 'syncFn! — the bang').toEqual([12, 8, 13]); // The author-spelled control: the keyword itself, never bang-hunted. - expect(spanOf(11), 'await 5 — the keyword').toEqual([8, 11, 13]); + expect(spanOf(11), 'await 5 — the keyword').toEqual([16, 11, 21]); // The discriminating silence: awaiting a real Promise draws nothing. expect(hints.some((d) => d.range.start.line === 10), 'fetchReal! stays silent').toBe(false); } finally { diff --git a/test/toolchain/reactive-imports.test.js b/test/toolchain/reactive-imports.test.js index 94929df8..1a1a9cf7 100644 --- a/test/toolchain/reactive-imports.test.js +++ b/test/toolchain/reactive-imports.test.js @@ -32,7 +32,11 @@ beforeAll(() => { dir = mkdtempSync(join(tmpdir(), 'rip-reactive-import-')); writeFileSync(join(dir, 'package.json'), '{"name":"reactive-import-fixture"}'); writeFileSync(join(dir, 'tsconfig.json'), readFileSync(TSCONFIG, 'utf8')); - writeFileSync(join(dir, 'store.rip'), 'export count := 0\nexport bump = -> count += 1\n'); + // ANNOTATED: the checker assertions below ride the gate's ACROSS rule — + // an importer's cell misuse publishes because the export carries a type. + // The cell CONTRACT is annotation-blind (erasure), so the runtime and + // hover tests read the same either way. + writeFileSync(join(dir, 'store.rip'), 'export count: number := 0\nexport bump = -> count += 1\n'); }); afterAll(() => { rmSync(dir, { recursive: true, force: true }); }); From ce97a4eafa132077222bd24f91695f906635317e Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 19:45:56 +0100 Subject: [PATCH 03/23] The check mirror persists as a regenerable cache, like the editor's .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. --- src/check.js | 49 ++++++++++++++++-------------------- test/toolchain/check.test.js | 34 +++++++++++++------------ 2 files changed, 39 insertions(+), 44 deletions(-) diff --git a/src/check.js b/src/check.js index af782f01..b0e5d0e4 100644 --- a/src/check.js +++ b/src/check.js @@ -60,9 +60,6 @@ Options: --json Emit diagnostics as a JSON array instead of the human-readable text report --no-frame Suppress the source code-frame under each error - --keep-mirror Keep the generated TS mirror (.rip/check) after the - run instead of removing it — for inspecting the - exact TypeScript tsgo type-checked --build Print the build identity (a content hash over the compiler and editor-server trees) and exit — the editor logs the same hash in its ready line, so a @@ -72,7 +69,9 @@ Options: Exit status is 0 when no error-severity diagnostic survives, 1 otherwise. Directories are walked for *.rip (node_modules and dot-directories are skipped). Config — package.json#rip (strict / noCheck) and the project -tsconfig — governs exactly as it does in the editor.`; +tsconfig — governs exactly as it does in the editor. The generated TS +mirror stays at /.rip/check after the run — the exact TypeScript +tsgo type-checked, rebuilt fresh each run.`; const fail = (message, code = 2) => { console.error(message); process.exit(code); }; @@ -95,27 +94,24 @@ if (argv.includes('--build')) { } const asJson = argv.includes('--json'); const showFrames = !argv.includes('--no-frame') && !asJson; -const keepMirror = argv.includes('--keep-mirror'); -const KNOWN = new Set(['--json', '--no-frame', '--keep-mirror', '--build']); +const KNOWN = new Set(['--json', '--no-frame', '--build']); const positionals = argv.filter((a) => !a.startsWith('-')); const unknownFlags = argv.filter((a) => a.startsWith('-') && !KNOWN.has(a)); if (unknownFlags.length) fail(`rip check: unknown option${unknownFlags.length === 1 ? '' : 's'}: ${unknownFlags.join(', ')}\n\nRun 'rip check --help' for usage.`); -// The generated TS mirror is scratch, not a build product: it is removed -// when the process exits by ANY path (normal, error, or process.exit) — -// rmSync in an exit handler runs synchronously, so this reclaims the -// /.rip/check tree AND the temp fallback without leaving either -// behind between runs. `--keep-mirror` retains it for inspecting the -// exact TypeScript tsgo checked. -let mirrorToClean = null; -let dotRipDir = null; // /.rip, pruned if the mirror left it empty +// The generated TS mirror at /.rip/check is a persistent, +// regenerable cache — the peer of the editor's .rip/editor, self- +// gitignored, left in place between runs so the exact TypeScript tsgo +// checked stays inspectable. Freshness never depends on cleanup: every +// run rebuilds the tree from scratch before tsgo sees it. (Create-then- +// delete also made the .rip dir flicker in and out of editor file trees +// on every run.) Only the temp fallback root — used when the workspace +// isn't writable — is ours to remove, on ANY exit path: rmSync in an +// exit handler runs synchronously. +let fallbackToClean = null; process.on('exit', () => { - if (keepMirror || mirrorToClean === null) return; - try { fs.rmSync(mirrorToClean, { recursive: true, force: true }); } catch { /* best effort */ } - // Prune the .rip parent too, but only when now empty — the editor's - // .rip/editor may share it, and rmdirSync refuses a non-empty dir, so - // this removes .rip only when `rip check` was what created it. - if (dotRipDir !== null) { try { fs.rmdirSync(dotRipDir); } catch { /* not empty / shared */ } } + if (fallbackToClean === null) return; + try { fs.rmSync(fallbackToClean, { recursive: true, force: true }); } catch { /* best effort */ } }); // ── target collection ─────────────────────────────────────────────── @@ -298,11 +294,10 @@ const tsDiags = []; let tsgoUnavailable = false; // tsgo needed but could not start — a run that couldn't type-check if (compiled.size > 0) { // A dedicated mirror at /.rip/check (peer of the editor's - // .rip/editor), rebuilt from scratch and removed on exit (see the - // exit handler above) — scratch, not a build product. The start-of-run - // wipe also guards against a stale mirror a killed/`--keep-mirror` run - // left behind, so a since-deleted source's face never lingers in the - // `**/*.ts` program. + // .rip/editor), rebuilt from scratch each run and left in place after + // (see the lifecycle comment on the exit handler above). The start-of- + // run wipe is what carries correctness: a since-deleted source's face + // from an earlier run never lingers in the `**/*.ts` program. let mirrorRoot = path.join(workspaceRoot, '.rip', 'check'); let mirrorRootIsFallback = false; try { @@ -312,10 +307,8 @@ if (compiled.size > 0) { } catch { mirrorRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-check-')); mirrorRootIsFallback = true; + fallbackToClean = mirrorRoot; } - mirrorToClean = mirrorRoot; - if (!mirrorRootIsFallback) dotRipDir = path.join(workspaceRoot, '.rip'); - if (keepMirror) console.error(`rip check: keeping TS mirror at ${mirrorRoot}`); for (const [fsPath, entry] of compiled) { const rel = mirrorRelForFsPath(fsPath, mirrorRootIsFallback ? null : workspaceRoot); const mirrorPath = path.join(mirrorRoot, rel) + '.ts'; diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 104411f0..19ed7498 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -1507,27 +1507,30 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); - // The generated TS mirror is scratch, removed on exit by default so a - // repeatedly-run check never litters .rip/check — retained only under - // --keep-mirror, for inspecting the exact TypeScript tsgo checked. - test('the TS mirror is removed after a run, kept only with --keep-mirror', () => { + // The generated TS mirror is a persistent, regenerable cache (the peer + // of the editor's .rip/editor): it stays at .rip/check after the run so + // the exact TypeScript tsgo checked is inspectable, is self-gitignored, + // and freshness comes from the start-of-run wipe — a stale face from an + // earlier run never survives into the next program. + test('the TS mirror persists after a run and is rebuilt fresh each run', () => { const dir = workspace({ 'a.rip': 'x: number = 0\nconsole.log x\n' }); try { - const dotRip = path.join(dir, '.rip'); - const mirror = path.join(dotRip, 'check'); + const mirror = path.join(dir, '.rip', 'check'); check(dir); - // The whole .rip parent goes when the check created it (nothing - // else lives there) — not just .rip/check. - expect(fs.existsSync(dotRip)).toBe(false); - const r = check(dir, ['--keep-mirror']); - expect(fs.existsSync(path.join(mirror, 'a.rip.ts'))).toBe(true); // the face is retained - expect(r.stderr).toContain('keeping TS mirror'); + expect(fs.existsSync(path.join(mirror, 'a.rip.ts'))).toBe(true); // the face is retained + expect(fs.readFileSync(path.join(mirror, '.gitignore'), 'utf8')).toBe('*\n'); // and git never sees it + // A face whose source no longer exists is wiped by the next run, + // not trusted from the cache. + fs.writeFileSync(path.join(mirror, 'deleted.rip.ts'), 'const ghost: number = 0;\n'); + check(dir); + expect(fs.existsSync(path.join(mirror, 'a.rip.ts'))).toBe(true); + expect(fs.existsSync(path.join(mirror, 'deleted.rip.ts'))).toBe(false); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); - // The .rip parent is pruned only when empty: a coexisting editor mirror - // (.rip/editor) must survive a batch check. - test('a coexisting .rip/editor is preserved (only the empty parent is pruned)', () => { + // A coexisting editor mirror (.rip/editor) must survive a batch check: + // the two mirrors share the .rip parent but own disjoint subtrees. + test('a coexisting .rip/editor is preserved', () => { const dir = workspace({ 'a.rip': 'x: number = 0\nconsole.log x\n' }); try { const editorDir = path.join(dir, '.rip', 'editor'); @@ -1535,7 +1538,6 @@ describeExtended('rip check: type diagnostics over the real server', () => { fs.writeFileSync(path.join(editorDir, 'marker'), 'keep me\n'); check(dir); expect(fs.existsSync(path.join(editorDir, 'marker'))).toBe(true); // editor mirror untouched - expect(fs.existsSync(path.join(dir, '.rip', 'check'))).toBe(false); // batch mirror cleaned } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); From 9c5e962a963f314507261607f94bb7ed60adef2c Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 19:52:33 +0100 Subject: [PATCH 04/23] A top-level `globalThis.NAME ??=` declares the global MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dist/browser/rip.js | 40 ++++++++++++- docs/TYPES.md | 15 +++++ packages/vscode/src/mirror.js | 47 +++++++++++---- packages/vscode/src/server.js | 69 ++++++++++++++++++++-- packages/vscode/test/project-model.test.js | 23 ++++++++ src/check.js | 35 ++++++++++- src/compile.js | 1 + src/emitter.js | 49 ++++++++++++++- test/lang/tsface.test.js | 29 +++++++++ test/toolchain/check.test.js | 42 +++++++++++++ 10 files changed, 329 insertions(+), 21 deletions(-) diff --git a/dist/browser/rip.js b/dist/browser/rip.js index 0326a8fe..dd9d4a62 100644 --- a/dist/browser/rip.js +++ b/dist/browser/rip.js @@ -20448,6 +20448,43 @@ return { ${unit.names.join(", ")} }; emitter.rframes.pop(); emitter.scopes.pop(); } + const globalDecls = []; + if (face === "ts" && isNode4(parseResult.sexpr) && parseResult.sexpr[0] === "program") { + const IDENT2 = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + for (const stmt of parseResult.sexpr.slice(1)) { + if (!isNode4(stmt) || stmt[0] !== "??=" || stmt.length !== 3) + continue; + const target = stmt[1]; + if (!isNode4(target) || target[0] !== "." || target[1] !== "globalThis") + continue; + if (typeof target[2] !== "string" || !IDENT2.test(target[2])) + continue; + const RESERVED = new Set(["null", "undefined", "true", "false", "this"]); + const v = stmt[2]; + globalDecls.push({ + name: target[2], + anchor: typeof v === "string" && IDENT2.test(v) && !RESERVED.has(v) ? v : null + }); + } + if (globalDecls.length) { + builder.tsOnly(() => { + for (const g of globalDecls) { + if (g.anchor !== null) + builder.emit(` +type __ripGlobal_${g.name} = typeof ${g.anchor};`); + } + builder.emit(` +declare global {`); + for (const g of globalDecls) { + builder.emit(` + var ${g.name}: ${g.anchor === null ? "any" : `__ripGlobal_${g.name}`};`); + } + builder.emit(` +} +`); + }); + } + } if (face === "ts" && !isModuleShaped(parseResult.sexpr, (s) => emitter.isModuleImport(s))) { builder.tsOnly(() => builder.emit(` export {}; @@ -20472,7 +20509,7 @@ export {}; valueGen: [valueRow.generatedStart, valueRow.generatedEnd] }); } - return { code: builder.code, mappings: builder.rows, vocabulary: emitter.vocabulary, silences: emitter.silences, memberDecls: emitter.memberDecls, enums: emitter.enums, importedRefs: emitter.importedRefs, stores, runtimes, bindings, bindingNames, replResultName: emitter.replResultName, replImportResolver: emitter.replImportResolver, tsRegions: builder.tsRegions, echoSpans: builder.echoSpans, pinnables, mutables: emitter.mutables, classDecls: emitter.classDecls, loopVars: emitter.loopVars, attrNames: emitter.attrNames, imports: emitter.importSpans }; + return { code: builder.code, mappings: builder.rows, vocabulary: emitter.vocabulary, silences: emitter.silences, memberDecls: emitter.memberDecls, enums: emitter.enums, importedRefs: emitter.importedRefs, stores, runtimes, bindings, bindingNames, replResultName: emitter.replResultName, replImportResolver: emitter.replImportResolver, tsRegions: builder.tsRegions, echoSpans: builder.echoSpans, globalDecls: globalDecls.map((g) => g.name), pinnables, mutables: emitter.mutables, classDecls: emitter.classDecls, loopVars: emitter.loopVars, attrNames: emitter.attrNames, imports: emitter.importSpans }; } // src/sourcemap.js @@ -21079,6 +21116,7 @@ function compile(source, { path = "", runtimeDelivery = "inline", fac replImportResolver: emitted.replImportResolver, tsRegions: emitted.tsRegions, echoSpans: emitted.echoSpans ?? [], + globalDecls: emitted.globalDecls ?? [], pinnables: emitted.pinnables, mutables: emitted.mutables, enums: emitted.enums, diff --git a/docs/TYPES.md b/docs/TYPES.md index 6bef1d5f..a3c5928c 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -191,6 +191,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: diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index e8bac48e..46ce2384 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -294,13 +294,20 @@ export function nearestTsconfig(dir, anchor) { // emitted per project, from that project's own gate answers: a nested // project's strictness and installed types govern whether ITS files see // it, which a single workspace-root floor could never express. -export function projectWrapper({ wrapperDir, sourceTsconfig, workspaceRoot = null, mirrorRoot = null, chain = new Set(), onUnresolved }) { - const sourceDir = path.dirname(sourceTsconfig); +// `sourceTsconfig: null` (with `sourceDir` given) is the AUTO-BOUNDARY +// form: a package that declares globals becomes its own program so its +// vocabulary stays package-scoped, without owning a tsconfig — the +// wrapper anchors on the workspace root's config instead (or the same +// defaults the mirror root uses when there is none). +export function projectWrapper({ wrapperDir, sourceTsconfig, sourceDir: sourceDirIn = null, workspaceRoot = null, mirrorRoot = null, chain = new Set(), onUnresolved }) { + const sourceDir = sourceDirIn ?? path.dirname(sourceTsconfig); + const rootConfig = workspaceRoot ? path.join(workspaceRoot, 'tsconfig.json') : null; + const anchor = sourceTsconfig ?? (rootConfig && fs.existsSync(rootConfig) ? rootConfig : null); const overrides = { noImplicitAny: true, noEmit: true, allowImportingTsExtensions: true, - ...nullPosture(sourceDir, sourceTsconfig), + ...nullPosture(sourceDir, anchor), rootDirs: ['.', posix(path.relative(wrapperDir, sourceDir))], }; // The same bare-specifier map the mirror root carries, rebased through @@ -312,16 +319,18 @@ export function projectWrapper({ wrapperDir, sourceTsconfig, workspaceRoot = nul if (Object.keys(ripPaths).length) overrides.paths = ripPaths; } chain.clear(); - const setsTypes = chainSetsTypes(sourceTsconfig, chain, onUnresolved); + const setsTypes = anchor !== null && chainSetsTypes(anchor, chain, onUnresolved); if (!setsTypes) overrides.types = ['*']; const reachUp = posix(path.relative(wrapperDir, sourceDir)); + const tsconfig = { + compilerOptions: overrides, + include: ['**/*.ts', `${reachUp}/**/*.d.ts`], + exclude: ['node_modules', `${reachUp}/**/node_modules`], + }; + if (anchor !== null) tsconfig.extends = posix(path.relative(wrapperDir, anchor)); + else Object.assign(overrides, { target: 'esnext', module: 'esnext', lib: ['esnext', 'dom'] }); return { - tsconfig: { - extends: posix(path.relative(wrapperDir, sourceTsconfig)), - compilerOptions: overrides, - include: ['**/*.ts', `${reachUp}/**/*.d.ts`], - exclude: ['node_modules', `${reachUp}/**/node_modules`], - }, + tsconfig, hostFloorDts: hostFloorDts(sourceDir, { userSetsTypes: setsTypes }), }; } @@ -448,6 +457,7 @@ export function scanExportNames(source) { const values = new Set(); const types = new Set(); const stars = []; + const globals = new Set(); // top-level `globalThis.NAME ??=` — declared vocabulary let hasDefault = false; const lines = source.split('\n'); let inBlockString = false; @@ -459,6 +469,11 @@ export function scanExportNames(source) { const wasInBlockString = inBlockString; if (fences % 2) inBlockString = !inBlockString; if (wasInBlockString) continue; + // A top-level `globalThis.NAME ??=` DECLARES the global (the compiler + // emits the typed declaration in the real face); the stub carries an + // `any`-typed twin so the name resolves before anything compiles. + const g = /^globalThis\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\?\?=/.exec(lines[i]); + if (g) { globals.add(g[1]); continue; } if (!/^export\b/.test(lines[i])) continue; // exports are top-level: column 0 let rest = lines[i].slice('export'.length).trim(); @@ -549,7 +564,7 @@ export function scanExportNames(source) { types.add(name); if (schema[1] === 'model') { types.add(name + 'Data'); types.add(name + 'Create'); } } - return { values: [...values], types: [...types], stars, hasDefault }; + return { values: [...values], types: [...types], stars, hasDefault, globals: [...globals] }; } // The stub text for one file's exported names. Every name is `any`: the @@ -560,11 +575,18 @@ export function scanExportNames(source) { // `defaultName` is the local the default export binds, and it is the name // the editor OFFERS the default under — see defaultLocalName below, where // every constraint on it is recorded. -export function stubFace({ values = [], types = [], hasDefault = false, defaultName = '_default' } = {}) { +export function stubFace({ values = [], types = [], hasDefault = false, defaultName = '_default', globals = [] } = {}) { const lines = []; for (const name of types) lines.push(`export type ${name} = any;`); for (const name of values) lines.push(`export declare const ${name}: any;`); if (hasDefault) lines.push(`declare const ${defaultName}: any;`, `export default ${defaultName};`); + // Declared vocabulary rides the stub as `any` — resolution before the + // real face's typed declaration materializes over it. + if (globals.length) { + lines.push('declare global {'); + for (const name of globals) lines.push(` var ${name}: any;`); + lines.push('}'); + } lines.push('export {};'); // a module even when it exports nothing return lines.join('\n') + '\n'; } @@ -655,6 +677,7 @@ export function stubFacesFromScans(scans) { const t = [...types.get(file)]; faces.set(file, stubFace({ values: v, types: t, hasDefault: scan.hasDefault, + globals: scan.globals ?? [], defaultName: defaultLocalName(file, new Set([...v, ...t])), })); } diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index d5c21cdd..00f3a0de 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -335,7 +335,8 @@ function writeProjectWrapper(rel, sourceTsconfig) { const wrapperDir = path.join(mirrorRoot, rel); const chain = new Set(); const wrapper = projectWrapper({ - wrapperDir, sourceTsconfig, workspaceRoot, mirrorRoot, chain, + wrapperDir, sourceTsconfig, sourceDir: sourceTsconfig === null ? path.join(workspaceRoot, rel) : null, + workspaceRoot, mirrorRoot, chain, onUnresolved: (spec) => connection.console.log(`[rip] ${rel}: tsconfig extends "${spec}" not resolvable — not injecting types:["*"]`), }); @@ -354,6 +355,40 @@ function writeProjectWrapper(rel, sourceTsconfig) { // yet. Returns the mirror paths written — a new wrapper also rewrites the // ROOT config (its exclusions grew), so callers forward all of them to // tsgo or the new project's files stay in the root's program. +// The AUTO BOUNDARY: a package that DECLARES globals (`globalThis.NAME +// ??=` at top level) becomes its own program without owning a tsconfig, +// so its vocabulary stays package-scoped — reaching importers the way the +// runtime does, and leaving a non-importing neighbor its cannot-find. A +// nested tsconfig outranks it (that wrapper already partitions), and the +// workspace root has no narrower scope to give. Idempotent; returns every +// mirror path the ensure wrote — the wrapper files plus the regenerated +// root config, whose exclusions grew — for the caller to forward to tsgo. +function ensureAutoBoundary(fsPath) { + if (mirrorRootIsFallback || !workspaceRoot || !mirrorRootReady) return []; + const owner = nearestTsconfig(path.dirname(fsPath), workspaceRoot); + if (owner !== null && path.dirname(owner) !== workspaceRoot) return []; + let dir = path.dirname(fsPath); + let pkgDir = null; + for (;;) { + if (fs.existsSync(path.join(dir, 'package.json'))) { pkgDir = dir; break; } + if (dir === workspaceRoot || path.dirname(dir) === dir) break; + dir = path.dirname(dir); + } + if (pkgDir === null || pkgDir === workspaceRoot) return []; + const rel = path.relative(workspaceRoot, pkgDir); + if (rel === '' || path.isAbsolute(rel) || rel === '..' || rel.startsWith('..' + path.sep)) return []; + if (wrapperDirs.has(rel)) return []; + let written; + try { written = writeProjectWrapper(rel, null); } catch (err) { + connection.console.error(`[rip] auto boundary for ${rel} failed: ${err.message}`); + return []; + } + writeGeneratedTsconfig(); + written.push(path.join(mirrorRoot, 'tsconfig.json'), path.join(mirrorRoot, HOST_FLOOR_NAME)); + connection.console.log(`[rip] ${rel}: declares globals — the package becomes its own program`); + return written; +} + function ensureProjectWrapper(fsPath) { if (mirrorRootIsFallback || !workspaceRoot || !mirrorRootReady) return []; const owner = nearestTsconfig(path.dirname(fsPath), workspaceRoot); @@ -799,6 +834,16 @@ function mirrorFromDisk(fsPath, source) { const mirrorPath = mirrorPathOf('file://' + fsPath); warnOnMirrorCollision(mirrorPath, fsPath); writeMirror(mirrorPath, result.code); + // A dependency that DECLARES globals gets its boundary the moment its + // face materializes — the closure pass may be the first to see it. + if (result.globalDecls?.length) { + const bw = ensureAutoBoundary(fsPath); + if (bw.length && tsgo) { + tsgo.client.notify('workspace/didChangeWatchedFiles', { + changes: bw.map((p) => ({ uri: 'file://' + p, type: FileChangeType.Changed })), + }); + } + } const imports = ripImportsOf(result.stores, source, path.dirname(fsPath)); cacheManifest.entries[fsPath] = { sourceHash: hashText(source), codeHash: hashText(result.code), imports, @@ -1184,7 +1229,7 @@ function stubTextFor(file) { } const scan = scans.get(file); if (!scan) return null; - if (!scan.values.length && !scan.types.length && !scan.stars.length && !scan.hasDefault) return null; + if (!scan.values.length && !scan.types.length && !scan.stars.length && !scan.hasDefault && !scan.globals?.length) return null; return stubFacesFromScans(scans).get(file) ?? null; } @@ -1213,10 +1258,17 @@ async function populateAutoImportStubs(candidates = null) { // It also matters on the re-stub path — a closed buffer that exports // nothing must not acquire a mirror it never had. for (const [file, scan] of [...scans]) { - if (!scan.values.length && !scan.types.length && !scan.stars.length && !scan.hasDefault) scans.delete(file); + if (!scan.values.length && !scan.types.length && !scan.stars.length && !scan.hasDefault && !scan.globals?.length) scans.delete(file); } if (!scans.size) return; ensureMirrorRoot(); // deferred to here: a workspace with no .rip stays untouched + // Declared vocabulary discovered at SCAN time: the boundary must exist + // before tsgo first assigns these files to a program, or a cold-open + // handler lands in the root program where its globals miss (or leak). + const configWritten = new Set(); + for (const [file, scan] of scans) { + if (scan.globals?.length) for (const w of ensureAutoBoundary(file)) configWritten.add(w); + } const written = []; let done = 0; for (const [file, text] of stubFacesFromScans(scans)) { @@ -1230,16 +1282,20 @@ async function populateAutoImportStubs(candidates = null) { } catch { /* candidacy only — never fatal */ } if (++done % 10 === 0) await new Promise((resolve) => setImmediate(resolve)); } - if (!written.length) return; + if (!written.length && !configWritten.size) return; connection.console.log( `[rip] auto-import stubs: ${written.length} declaration-only mirror(s) in ${Math.round(performance.now() - t0)} ms`, ); // tsgo does not notice a bare mid-session disk write. The Created batch - // is what puts these files in its program — driven, and decisive. + // is what puts these files in its program — driven, and decisive. The + // auto-boundary's config writes ride along as Changed. await tsgoReady; if (tsgo) { tsgo.client.notify('workspace/didChangeWatchedFiles', { - changes: written.map((p) => ({ uri: 'file://' + p, type: FileChangeType.Created })), + changes: [ + ...written.map((p) => ({ uri: 'file://' + p, type: FileChangeType.Created })), + ...[...configWritten].map((p) => ({ uri: 'file://' + p, type: FileChangeType.Changed })), + ], }); } } @@ -1704,6 +1760,7 @@ async function refresh(document) { scheduleManifestSave(); } const wrapperFiles = ensureProjectWrapper(fsPath); + if (result.globalDecls?.length) wrapperFiles.push(...ensureAutoBoundary(fsPath)); if (wrapperFiles.length && tsgo) { tsgo.client.notify('workspace/didChangeWatchedFiles', { changes: wrapperFiles.map((p) => ({ uri: 'file://' + p, type: FileChangeType.Changed })), diff --git a/packages/vscode/test/project-model.test.js b/packages/vscode/test/project-model.test.js index 1c344d6b..39973e28 100644 --- a/packages/vscode/test/project-model.test.js +++ b/packages/vscode/test/project-model.test.js @@ -277,6 +277,29 @@ describe.skipIf(!tsgoAvailable)('the workspace project model', () => { }); }, 30000); + // A package declaring vocabulary (`globalThis.NAME ??=` at top level) + // resolves it package-wide in the editor — through the auto boundary + // and, before anything compiles, through the stub's any-typed twin. + // The neighbor outside the package keeps its cannot-find: the boundary + // is the typo protection. + test('declared globals resolve package-wide in the editor; the neighbor keeps its cannot-find', async () => { + await inWorkspace({ + 'vocab/package.json': JSON.stringify({ name: '@t/vocab' }), + 'vocab/vocab.rip': 'sh = (cmd: string): string -> cmd\nglobalThis.sh ??= sh\nexport ping = 1\n', + 'vocab/handler.rip': 'out = sh("ls")\nbad = shh("ls")\nconsole.log out, bad\n', + }, async (api) => { + // Cold open: vocab.rip is UNOPENED — a stub carries the declaration. + await api.open('vocab/handler.rip', 'out = sh("ls")\nbad = shh("ls")\nconsole.log out, bad\n'); + await api.until('vocab/handler.rip', (codes) => { + const cannotFinds = codes.filter((c) => [2304, 2552].includes(c)); + return cannotFinds.length === 1; // exactly the typo — `sh` resolved + }); + // Outside the package: the vocabulary does not reach. + await api.open('other.rip', 'oops = sh("ls")\nconsole.log oops\n'); + await api.until('other.rip', (codes) => codes.some((c) => [2304, 2552].includes(c))); + }); + }, 30000); + test('cross-file readiness: hover crosses the file boundary onto .rip source', async () => { await inWorkspace({ 'util.rip': INFERRED_UTIL }, async (api) => { await api.open('app.rip', APP); diff --git a/src/check.js b/src/check.js index af782f01..da9b95f5 100644 --- a/src/check.js +++ b/src/check.js @@ -341,8 +341,31 @@ if (compiled.size > 0) { wrapperRels.add(path.relative(workspaceRoot, path.dirname(owner))); } } + // A package that DECLARES globals (`globalThis.NAME ??=` at top level) + // becomes its own program — the AUTO BOUNDARY. Its vocabulary stays + // package-scoped and reaches importers the way the runtime does (an + // import runs the installer); a non-importing neighbor keeps its + // cannot-find. Anchored at the nearest package.json; a declarer already + // inside a tsconfig-wrapped project needs nothing more, and a declarer + // at the workspace root has no narrower scope to give it. + const globalDeclRels = new Set(); + if (!mirrorRootIsFallback) { + for (const [fsPath, entry] of compiled) { + if (!entry.result.globalDecls?.length) continue; + let dir = path.dirname(fsPath); + let pkgDir = null; + for (;;) { + if (fs.existsSync(path.join(dir, 'package.json'))) { pkgDir = dir; break; } + if (dir === workspaceRoot || path.dirname(dir) === dir) break; + dir = path.dirname(dir); + } + if (pkgDir === null || pkgDir === workspaceRoot) continue; + const rel = path.relative(workspaceRoot, pkgDir); + if (![...wrapperRels].some((w) => rel === w || rel.startsWith(w + path.sep))) globalDeclRels.add(rel); + } + } const mirror = generatedMirror({ - workspaceRoot, mirrorRootIsFallback, excludeDirs: [...wrapperRels], + workspaceRoot, mirrorRootIsFallback, excludeDirs: [...wrapperRels, ...globalDeclRels], }); fs.writeFileSync(path.join(mirrorRoot, 'tsconfig.json'), JSON.stringify(mirror.tsconfig, null, 2)); fs.writeFileSync(path.join(mirrorRoot, HOST_FLOOR_NAME), mirror.hostFloorDts); @@ -356,6 +379,16 @@ if (compiled.size > 0) { fs.writeFileSync(path.join(wrapperDir, 'tsconfig.json'), JSON.stringify(wrapper.tsconfig, null, 2)); fs.writeFileSync(path.join(wrapperDir, HOST_FLOOR_NAME), wrapper.hostFloorDts); } + for (const rel of globalDeclRels) { + const wrapperDir = path.join(mirrorRoot, rel); + const wrapper = projectWrapper({ + wrapperDir, sourceTsconfig: null, sourceDir: path.join(workspaceRoot, rel), + workspaceRoot, mirrorRoot, + }); + fs.mkdirSync(wrapperDir, { recursive: true }); + fs.writeFileSync(path.join(wrapperDir, 'tsconfig.json'), JSON.stringify(wrapper.tsconfig, null, 2)); + fs.writeFileSync(path.join(wrapperDir, HOST_FLOOR_NAME), wrapper.hostFloorDts); + } let session = null; try { diff --git a/src/compile.js b/src/compile.js index 95a61adf..8b1a80ed 100644 --- a/src/compile.js +++ b/src/compile.js @@ -260,6 +260,7 @@ export function compile(source, { path = '', runtimeDelivery = 'inlin // diagnostic mapper drops a non-exact-mapped diagnostic born here — // the real copy publishes the same claim at its own position. echoSpans: emitted.echoSpans ?? [], + globalDecls: emitted.globalDecls ?? [], pinnables: emitted.pinnables, // Generated spans of `:=` state names — writable in rip, `const` in the // face. The editor clears TypeScript's `readonly` token modifier on these. diff --git a/src/emitter.js b/src/emitter.js index c1163d39..f0a50728 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -14958,6 +14958,53 @@ export function emit(parseResult, { source = '', runtimeDelivery = 'none', face emitter.rframes.pop(); emitter.scopes.pop(); } + // A top-level `globalThis.NAME ??= expr` DECLARES the global. The + // spelling is the boundary: `??=` says "install unless someone already + // did", which is a declaration wearing runtime clothes — DSL vocabulary + // like stamp's `sh`/`ok`/`run`, whose handlers import nothing by + // design. Plain `=` stays meaningless here on purpose (a test + // overwriting `globalThis.fetch` must not redeclare the host's fetch), + // and a non-top-level install is lifecycle state, not vocabulary (an + // app's guarded `__ripApp`, deleted on destroy, must not be declared + // ever-present). An identifier initializer declares `typeof` it — the + // module binding is lexically visible inside `declare global` — and + // any other initializer declares `any`. The block is TS-only, so the + // strip gate holds byte identity; `declare global` requires module + // shape, which the marker below guarantees for bare files. + const globalDecls = []; + if (face === 'ts' && isNode(parseResult.sexpr) && parseResult.sexpr[0] === 'program') { + const IDENT = /^[A-Za-z_$][A-Za-z0-9_$]*$/; + for (const stmt of parseResult.sexpr.slice(1)) { + if (!isNode(stmt) || stmt[0] !== '??=' || stmt.length !== 3) continue; + const target = stmt[1]; + if (!isNode(target) || target[0] !== '.' || target[1] !== 'globalThis') continue; + if (typeof target[2] !== 'string' || !IDENT.test(target[2])) continue; + // Keyword literals are identifier-shaped in the sexpr (`??= null` + // seeds ui's focus tracker) but are not typeof-able values. + const RESERVED = new Set(['null', 'undefined', 'true', 'false', 'this']); + const v = stmt[2]; + globalDecls.push({ + name: target[2], + anchor: typeof v === 'string' && IDENT.test(v) && !RESERVED.has(v) ? v : null, + }); + } + if (globalDecls.length) { + // The type rides a MODULE-LEVEL alias: inside `declare global` the + // bare name resolves to the global being declared (TS2502, + // driven), so `typeof sh` must be taken where `sh` still means the + // module binding and carried in by alias. + builder.tsOnly(() => { + for (const g of globalDecls) { + if (g.anchor !== null) builder.emit(`\ntype __ripGlobal_${g.name} = typeof ${g.anchor};`); + } + builder.emit('\ndeclare global {'); + for (const g of globalDecls) { + builder.emit(`\n var ${g.name}: ${g.anchor === null ? 'any' : `__ripGlobal_${g.name}`};`); + } + builder.emit('\n}\n'); + }); + } + } // The module marker: the loader runs every .rip file as a Bun // ES module, so a face whose own syntax carries no import/export // must not present as a global SCRIPT — script-ness misrepresents @@ -14994,7 +15041,7 @@ export function emit(parseResult, { source = '', runtimeDelivery = 'none', face // was written (reactiveDecl) rather than reconstructed by scanning rows: the // emitter knows the offset as it emits, so no lookup, and no ambiguity about // which row is the name's. - return { code: builder.code, mappings: builder.rows, vocabulary: emitter.vocabulary, silences: emitter.silences, memberDecls: emitter.memberDecls, enums: emitter.enums, importedRefs: emitter.importedRefs, stores, runtimes, bindings, bindingNames, replResultName: emitter.replResultName, replImportResolver: emitter.replImportResolver, tsRegions: builder.tsRegions, echoSpans: builder.echoSpans, pinnables, mutables: emitter.mutables, classDecls: emitter.classDecls, loopVars: emitter.loopVars, attrNames: emitter.attrNames, imports: emitter.importSpans }; + return { code: builder.code, mappings: builder.rows, vocabulary: emitter.vocabulary, silences: emitter.silences, memberDecls: emitter.memberDecls, enums: emitter.enums, importedRefs: emitter.importedRefs, stores, runtimes, bindings, bindingNames, replResultName: emitter.replResultName, replImportResolver: emitter.replImportResolver, tsRegions: builder.tsRegions, echoSpans: builder.echoSpans, globalDecls: globalDecls.map((g) => g.name), pinnables, mutables: emitter.mutables, classDecls: emitter.classDecls, loopVars: emitter.loopVars, attrNames: emitter.attrNames, imports: emitter.importSpans }; } // The strip transform: delete the recorded TS-only regions from a diff --git a/test/lang/tsface.test.js b/test/lang/tsface.test.js index 75d5764d..57a9286a 100644 --- a/test/lang/tsface.test.js +++ b/test/lang/tsface.test.js @@ -1351,3 +1351,32 @@ describe('soak prototype access', () => { .toThrow(/soak form cannot carry the annotation/); }); }); + +// A top-level `globalThis.NAME ??= expr` DECLARES the global (the +// spelling says "install unless someone already did" — DSL vocabulary, +// stamp's sh/ok/run). The type rides a module-level alias because inside +// `declare global` the bare name resolves to the global being declared +// (TS2502, driven against real tsc). Plain `=` and non-top-level `??=` +// deliberately declare nothing: overwrites (fetch mocks) and lifecycle +// installs (an app's guarded __ripApp, deleted on destroy) are not +// vocabulary. +describe('globalThis ??= declares the global', () => { + test('top-level ??= emits the declare-global block, typed through the alias', () => { + const src = 'sh = (cmd: string): string -> cmd\nglobalThis.sh ??= sh\nexport ping = 1\n'; + const r = ts(src); + expect(r.code).toContain('type __ripGlobal_sh = typeof sh;'); + expect(r.code).toContain('declare global {\n var sh: __ripGlobal_sh;\n}'); + expect(r.globalDecls).toEqual(['sh']); + expect(stripFace(r.code, r.tsRegions)).toBe(js(src).code); + }); + + test('non-identifier initializers declare `any`; `=` and nested `??=` declare nothing', () => { + const src = 'globalThis.seed ??= null\nglobalThis.late = 1\nf = -> globalThis.inner ??= 2\nconsole.log f\n'; + const r = ts(src); + expect(r.code).toContain('var seed: any;'); + expect(r.code).not.toContain('var late'); + expect(r.code).not.toContain('var inner'); + expect(r.globalDecls).toEqual(['seed']); + expect(stripFace(r.code, r.tsRegions)).toBe(js(src).code); + }); +}); diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 104411f0..8fa0b2f8 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -714,6 +714,48 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // A top-level `globalThis.NAME ??= expr` DECLARES the global: the + // spelling says "install unless someone already did", which is a + // declaration in runtime clothes — stamp's sh/ok/run vocabulary is the + // resident pattern ("handlers import nothing"). The declaration is + // scoped to the declaring PACKAGE (its own program in the mirror), and + // reaches importers the way the runtime does — importing the module + // runs the installer. A non-importing neighbor keeps its TS2304, which + // is the typo protection ambient-everywhere would have spent. + test('a top-level `globalThis.NAME ??=` declares the global, scoped to its package', () => { + const dir = workspace({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), + 'packages/vocab/package.json': JSON.stringify({ name: '@t/vocab' }), + 'packages/vocab/vocab.rip': [ + 'sh = (cmd: string): string -> cmd', + 'globalThis.sh ??= sh', + 'export ping = 1', + ].join('\n') + '\n', + 'packages/vocab/handler.rip': [ + 'out = sh("ls")', // resolves: same package, declared vocabulary + 'bad = shh("ls")', // typo protection survives: cannot-find still fires + 'console.log out, bad', + ].join('\n') + '\n', + 'packages/other/package.json': JSON.stringify({ name: '@t/other' }), + 'packages/other/other.rip': [ + 'oops = sh("ls")', // different package, no import: the global does NOT reach + 'console.log oops', + ].join('\n') + '\n', + }); + try { + const diags = JSON.parse(check(dir, ['--json']).stdout); + const names = diags.map((d) => [d.file, /'([^']+)'/.exec(d.message)?.[1]]); + // vocab's own package: `sh` resolves everywhere; only the typo reports. + expect(names.filter(([f]) => f.includes('vocab'))).toEqual([ + [path.join('packages', 'vocab', 'handler.rip'), 'shh'], + ]); + // the non-importing neighbor: `sh` is not its vocabulary. + expect(names.filter(([f]) => f.includes('other'))).toEqual([ + [path.join('packages', 'other', 'other.rip'), 'sh'], + ]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // Config is per FILE (nearest package.json), so a strict consumer's // check still hides its gradual DEPENDENCIES' diagnostics — and a // summary that says "set `rip.strict` in package.json" after the user From 08c1cccb523ea054150dfc5a2be0a389f4d4ff93 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 20:02:28 +0100 Subject: [PATCH 05/23] Three catch handlers returned a crash instead of their error envelope MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/db/db.rip | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/db/db.rip b/packages/db/db.rip index 85dd92ae..0d09b7a8 100755 --- a/packages/db/db.rip +++ b/packages/db/db.rip @@ -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 @@ -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 @@ -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 } } From 4a640d796026a91bf9048222541e29f58cbc6ccf Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 20:18:21 +0100 Subject: [PATCH 06/23] bun:* rides the host floor; rip.strict earns its own program MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- docs/TYPES.md | 6 + packages/vscode/src/mirror.js | 517 ++++++++++++---------------------- packages/vscode/src/server.js | 8 +- src/check.js | 38 +-- test/toolchain/check.test.js | 39 +++ 5 files changed, 256 insertions(+), 352 deletions(-) diff --git a/docs/TYPES.md b/docs/TYPES.md index a3c5928c..0c225f0b 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -264,6 +264,12 @@ 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 that sets `rip.strict` 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 +strictness governs them — an unresolvable `bun:sqlite` is a complaint +there, not a floored `any`. + 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 diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index 46ce2384..b2c19ef9 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -1,21 +1,16 @@ // The mirror layout and its generated tsconfig — shared by the editor -// server (which materializes the mirror demand-driven, per keystroke) -// and the batch `rip check` (which materializes a target's closure once, -// then runs `tsc --noEmit` over it). Both need the SAME tsconfig and the -// SAME mirror-file naming, or the batch checker would resolve imports / -// @types / strictness differently from the editor. +// server and the batch `rip check`, which must resolve imports, @types, +// and strictness identically. import fs from 'node:fs'; import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath } from 'node:url'; -// The lexer rides the same dual-path resolution as the server's -// compiler load: `../../../src/` in-repo, `../compiler/src/` in the -// staged .vsix (scripts/package.js). A static relative import knows -// only the repo layout — installed, it reaches OUTSIDE the extension -// directory and the server dies at import time, which is a dead editor -// with no diagnostics at all. +// Dual-path like the server's compiler load: `../../../src/` in-repo, +// `../compiler/src/` in the staged .vsix. A static relative import knows +// only the repo layout — installed, it reaches outside the extension and +// the server dies at import time. const { identifierRunAt } = await (async () => { const candidates = [ new URL('../../../src/lexer.js', import.meta.url), // in-repo @@ -30,10 +25,9 @@ const { identifierRunAt } = await (async () => { const stripJsonComments = (text) => text.replace(/\/\*[\s\S]*?\*\//g, '').replace(/\/\/[^\n]*/g, ''); -// Resolve one `extends` specifier the way TS does, bounded: relative and -// absolute paths get the exact / +.json / +/tsconfig.json attempts; bare -// package specifiers resolve node-style from the extending config's -// directory. Null when unresolvable. +// Resolve one `extends` specifier the way TS does: exact / +.json / +// +/tsconfig.json for paths, node-style for bare packages. Null when +// unresolvable. export function resolveExtends(spec, fromDir) { const attempts = []; if (spec.startsWith('.') || path.isAbsolute(spec)) { @@ -51,13 +45,10 @@ export function resolveExtends(spec, fromDir) { return null; } -// Does the user's config — ANYWHERE in its resolved `extends` chain — -// set compilerOptions.types? Injecting types:["*"] over a chain that -// narrows types would clobber the user's narrowing, so unresolvable or -// unreadable links answer TRUE (conservative: never clobber what we -// cannot see). Visited files are recorded into `chain` so a watcher can -// re-govern when a chain member changes; `onUnresolved(spec)` is an -// optional log hook. Bounded depth, cycles guarded. +// Does the config — anywhere in its resolved `extends` chain — set +// compilerOptions.types? Unresolvable or unreadable links answer TRUE: +// never clobber a narrowing we cannot see. Visited files land in `chain` +// for the watcher's re-govern; `onUnresolved(spec)` is a log hook. export function chainSetsTypes(configPath, chain, onUnresolved, visited = new Set(), depth = 0) { if (depth > 16 || visited.has(configPath)) return false; // cycle/degenerate visited.add(configPath); @@ -83,51 +74,29 @@ export function chainSetsTypes(configPath, chain, onUnresolved, visited = new Se }); } -// The zero-config host floor: the runtime-host globals (`process`, -// `Bun`) declared by EXISTENCE only, as `any` — the permissive default -// for a workspace that supplies no host types. A workspace with no -// node_modules has nothing for tsgo's typeRoots walk-up to find, so the -// names are unresolvable on every face; a floor (a one-line d.ts -// shipped with the toolchain) resolves its name without claiming any -// shape that could go stale or be wrong. GATED per name: a workspace -// that installs the name's real declaration package (anywhere up its -// ancestor chain — the same walk tsgo's default typeRoots performs) -// never sees that floor; a second declaration beside the real one -// would be a TS2403 conflict, and precision is the user's opt-in. -// The probes walk the DISK, not module resolution: Bun's -// `createRequire().resolve` falls back to the machine-global install -// cache (`~/.bun/install/cache`), which tsgo's typeRoots walk never -// consults — a resolve-based probe would false-positive on any machine -// that ever installed the package. -// -// A strict project refuses every floor: `rip.strict` means MISSING -// annotations get complained about (src/config.js), and a floor is -// exactly a missing-annotation forgiveness — host globals as `any`. A -// strict project gets the unresolved-name diagnostic (pointing at -// @types/bun) until it declares real host types. The probe mirrors -// readProjectConfig's boundary rule — walk UP to the FIRST package.json -// and stop; unreadable answers false, like readProjectConfig's own -// defaults — but stays local: mirror.js must remain layout-agnostic -// (repo checkout vs staged vsix), so it cannot import the compiler. -// The strictness read is the WORKSPACE root's: a floor joins the -// program whole-or-not (one tsconfig include), so a nested project's -// own package.json cannot govern it per-file the way the diagnostic -// gate does. -// Null posture by mode. Gradual rip is stock TypeScript's `strict: false` -// for nullability: `T | undefined` collapses into `T` rather than demanding -// a guard on every read the author never annotated. It is the one mode lever -// that changes TYPES and not just which diagnostics publish — `find()` hovers -// as `T` here and `T | undefined` under `rip.strict` — which is exactly the -// bargain a gradual project makes. Unlike `noImplicitAny`, turning this off -// removes a distinction rather than disabling an inference mechanism, so it -// cannot strand a binding on a worse type (`noImplicitAny: false` drops -// unannotated `[]` onto `never[]`, which is why THAT flag stays on). +// The zero-config host floor: host globals (`process`, `Bun`) declared +// by EXISTENCE as `any` — otherwise unresolvable on every face in a +// workspace with no installed host types. Gated per name by a DISK probe +// of ancestor node_modules, never module resolution (Bun's resolve falls +// back to the machine-global install cache, which tsgo's typeRoots walk +// never consults — a resolve probe false-positives). A floored name that +// gets its real package installed would be a TS2403 double declaration, +// so the floor exists only where the package is absent. A strict project +// refuses every floor — a floor is exactly a missing-annotation +// forgiveness — and keeps the unresolved-name diagnostic until real host +// types are declared. The strictness read is the WORKSPACE ROOT's (a +// floor joins the program whole-or-not), walked to the first +// package.json locally: mirror.js stays layout-agnostic and cannot +// import the compiler's readProjectConfig. +// Null posture by mode: gradual supplies `strictNullChecks: false` — the +// one lever that changes TYPES, not just which diagnostics publish +// (`find()` hovers `T` here, `T | undefined` under `rip.strict`). +// `noImplicitAny` stays ON everywhere: turning it off disables +// evolving-array inference and strands unannotated `[]` on `never[]`. const nullPosture = (dir, configPath) => { if (dir && workspaceIsStrict(dir)) return {}; - // The user's OWN tsconfig wins. rip supplies a default posture; it does - // not overrule a `strict` / `strictNullChecks` the author wrote down — - // an override that silently beat an explicit setting is the stacking - // surprise this whole mode design exists to remove. + // The user's OWN tsconfig wins: rip's default posture never overrules + // a strictness the author wrote down. if (configPath && chainSetsStrictness(configPath)) return {}; return { strictNullChecks: false }; }; @@ -168,48 +137,33 @@ const workspaceIsStrict = (workspaceRoot) => { if (path.dirname(dir) === dir) return false; } }; -// One floor PER NAME, because the names' real-type suppliers differ: -// @types/node declares `process` but not `Bun`. A workspace with only -// @types/node keeps `process` at its installed truth and still floors -// `Bun` (rip runs on Bun — the name is real in every rip program); a -// workspace with @types/bun supplies both and floors nothing. The disk -// probe governs only the no-`types`-field world, where the mirror's -// `types:["*"]` makes @types presence and program inclusion the same -// fact — which is also why bare `bun-types` is NOT probed: on disk it -// is inert (outside the @types root) until a `types` entry names it, -// and a chain that sets `types` refuses floors wholesale before any -// probe runs (`userSetsTypes` below). -// -// The declarations are COMPILER-TOOLCHAIN DATA, not shipped .d.ts -// assets: the text lives here and is emitted into the mirror root as a -// generated file beside the generated tsconfig (the mirror's existing -// `**/*.ts` include picks it up). A shipped asset would be one more -// packaging surface (vsix staging, a future CLI `files` list) whose -// omission fails SILENTLY — an existence-gated floor that simply never -// materializes; text in this module ships wherever the toolchain does. +// One floor PER NAME — the suppliers differ (@types/node declares +// `process` but not `Bun`), so a partial install keeps each name at its +// installed truth. Bare `bun-types` is not probed: outside the @types +// root it is inert until a `types` field names it, and a chain that sets +// `types` refuses floors wholesale before any probe runs. The texts live +// HERE, not as shipped .d.ts assets: a packaging list can omit an asset +// silently; text in this module ships wherever the toolchain does. const HOST_FLOORS = [ { text: 'declare var process: any;', suppliedBy: ['@types/node', '@types/bun'] }, { text: 'declare var Bun: any;', suppliedBy: ['@types/bun'] }, - // `import.meta.dir` and kin are Bun's, and unlike the two above they - // are not a global to shadow but an interface to MERGE — declaring a - // var would not reach `import.meta.x` at all. Merging is additive, so - // when @types/bun is present this entry is absent and the real - // declaration governs alone; the gate below is what guarantees that. - // - // An INDEX SIGNATURE, not an enumeration. Naming the fields means - // asserting the shape of an API rip does not own, and it was wrong on - // arrival — `dir`, `file` and `path` were listed while `import.meta.main` - // was not, so ten call sites in this repo's own packages still failed. - // The honest floor says "this host adds properties I cannot enumerate", - // which is both truer and complete, and it merges with the real - // ImportMeta rather than replacing it (`url` keeps its own type). - // - // It only exists where @types/bun is ABSENT, so nothing it permits - // survives an install — the gate below is what guarantees that. + // `import.meta.dir` and kin are an interface to MERGE, not a global to + // shadow — a var would not reach `import.meta.x` at all. An INDEX + // SIGNATURE, not an enumeration: naming fields asserts an API rip does + // not own, and the enumeration was wrong on arrival (`main` missing, + // ten call sites red). Merging is additive: `url` keeps its real type. { text: 'interface ImportMeta { [key: string]: any }', suppliedBy: ['@types/bun'], }, + // Bun's builtin modules (`bun:sqlite`, `bun:ffi`, …): a bodyless + // wildcard SHORTHAND types every matching import `any`, and an exact + // declaration from installed @types/bun outranks a wildcard by TS's + // own pattern rules. + { + text: 'declare module "bun:*";', + suppliedBy: ['@types/bun'], + }, ]; export const HOST_FLOOR_NAME = 'host-floor.d.ts'; const ancestorHas = (fromDir, pkgs) => { @@ -218,16 +172,11 @@ const ancestorHas = (fromDir, pkgs) => { if (path.dirname(dir) === dir) return false; } }; -// `userSetsTypes`: the resolved tsconfig chain sets -// compilerOptions.types — the user's COMPLETE ambient manifest, the -// same signal that already stops the `types:["*"]` injection. Floors -// defer to it wholesale: an enumerated list means "these ambients and -// no others", whether or not the named packages are even installed — -// a floor beside an explicit manifest would clobber the narrowing -// exactly like `["*"]` would. The generated file is written even when -// every floor is refused (with the reason in its body) — an -// always-present file with varying content, so the flip is a plain -// Changed event and no caller carries a create/delete lifecycle. +// `userSetsTypes`: the chain sets compilerOptions.types — the user's +// COMPLETE ambient manifest, the same signal that stops the types:["*"] +// injection — and floors defer to it wholesale. The file is written even +// when every floor is refused (reason in the body): an always-present +// file makes the flip a plain Changed event, no create/delete lifecycle. export function hostFloorDts(workspaceRoot, { userSetsTypes = false } = {}) { const head = '// Generated by rip — the zero-config host floor. Do not edit.\n'; if (userSetsTypes) { @@ -243,34 +192,22 @@ export function hostFloorDts(workspaceRoot, { userSetsTypes = false } = {}) { return head + active.map(({ text }) => text + '\n').join(''); } -// The generated mirror-root files, built together because they share -// one probe of the user's config chain: the tsconfig, and the host -// floor's content (written as HOST_FLOOR_NAME beside it — the -// tsconfig's own `**/*.ts` include picks it up). Tsconfig overrides -// applied over the user's config (or the defaults): noImplicitAny -// stays ON (it activates the evolving-`let` inference; the -// implicit-any family is suppressed per-code in translate.js), noEmit -// (the project never emits; also what legalizes -// allowImportingTsExtensions), and rootDirs merging the mirror tree -// with the real workspace (a .rip file importing a real .ts sibling -// resolves). The mirror root MUST sit two levels below the workspace so -// the `../../` reach-ups (extends, ambient d.ts, node_modules) resolve. -// A tsconfig path is written into a generated config, so it is always -// POSIX-separated — a Windows `..\\pkg\\tsconfig.json` is not a legal -// `extends` spec. +// The generated mirror-root files, built together because they share one +// probe of the user's config chain. Overrides: noImplicitAny stays ON (it +// powers evolving-`let` inference; the implicit-any family is suppressed +// per-code in translate.js), noEmit also legalizes +// allowImportingTsExtensions, and rootDirs merges the mirror with the +// real workspace so a `.rip` file importing a real `.ts` sibling +// resolves. The mirror root MUST sit two levels below the workspace for +// the `../../` reach-ups to resolve; written tsconfig paths are POSIX +// (`..\\pkg` is not a legal `extends` spec). const posix = (p) => p.split(path.sep).join('/'); -// The nearest `tsconfig.json` at or above `dir`, stopping AT `anchor` -// (inclusive). Null when the walk reaches the anchor without finding -// one, which is the ordinary case: most directories are governed by the -// workspace root's config and need no wrapper of their own. -// -// The anchor BOUNDS the walk, it is not merely a stop the walk may pass -// through: a dir OUTSIDE the anchor answers null immediately. Without -// that, an out-of-workspace document climbed to the filesystem root, -// adopted whatever tsconfig.json it met on the way, and the resulting -// '..'-shaped rel carried the wrapper write out of the mirror — into -// directories the extension does not own. +// The nearest tsconfig.json at or above `dir`, BOUNDED by `anchor`: a +// dir outside the anchor answers null immediately — without that, an +// out-of-workspace document adopted whatever config it met on the climb +// and the '..'-shaped rel carried the wrapper write out of the mirror. +// Null is the ordinary answer (the workspace root's config governs). export function nearestTsconfig(dir, anchor) { if (dir !== anchor && !dir.startsWith(anchor + path.sep)) return null; for (let d = dir; ; d = path.dirname(d)) { @@ -280,25 +217,15 @@ export function nearestTsconfig(dir, anchor) { } } -// The generated WRAPPER for one nested project: the same overrides the -// mirror root gets, but reaching up to that project's own tsconfig -// instead of the workspace root's, with every reach-up computed rather -// than spelled. tsgo's LSP does per-file nearest-tsconfig discovery (the -// tsserver configured-project model), so placing one of these at each -// mirrored project dir partitions the faces by project inside a SINGLE -// mirror tree and a SINGLE session — no multiplexing, and the pin pass -// is untouched. -// -// The wrapper states its own include/exclude, so the source tsconfig's -// FILE SET is not inherited — only its compilerOptions. And the floor is -// emitted per project, from that project's own gate answers: a nested -// project's strictness and installed types govern whether ITS files see -// it, which a single workspace-root floor could never express. -// `sourceTsconfig: null` (with `sourceDir` given) is the AUTO-BOUNDARY -// form: a package that declares globals becomes its own program so its -// vocabulary stays package-scoped, without owning a tsconfig — the -// wrapper anchors on the workspace root's config instead (or the same -// defaults the mirror root uses when there is none). +// The generated WRAPPER for one nested project: tsgo's LSP assigns each +// file its nearest config, so a wrapper per mirrored project dir +// partitions the faces inside ONE tree and ONE session. It states its +// own include/exclude — only the source config's compilerOptions are +// inherited — and emits the floor per project, from that project's own +// strictness and installs. `sourceTsconfig: null` (with `sourceDir`) is +// the AUTO-BOUNDARY form for a globals-declaring package that owns no +// tsconfig: the wrapper anchors on the workspace root's config, or the +// mirror-root defaults when there is none. export function projectWrapper({ wrapperDir, sourceTsconfig, sourceDir: sourceDirIn = null, workspaceRoot = null, mirrorRoot = null, chain = new Set(), onUnresolved }) { const sourceDir = sourceDirIn ?? path.dirname(sourceTsconfig); const rootConfig = workspaceRoot ? path.join(workspaceRoot, 'tsconfig.json') : null; @@ -310,10 +237,8 @@ export function projectWrapper({ wrapperDir, sourceTsconfig, sourceDir: sourceDi ...nullPosture(sourceDir, anchor), rootDirs: ['.', posix(path.relative(wrapperDir, sourceDir))], }; - // The same bare-specifier map the mirror root carries, rebased through - // this wrapper's own reach-up — a nested project's files are governed - // by THIS config, and paths are read from the config that declares - // them, so the root's map never reaches here on its own. + // The mirror root's bare-specifier map, rebased through this wrapper's + // reach-up: paths are read from the config that declares them. if (workspaceRoot && mirrorRoot) { const ripPaths = workspaceRipPaths(workspaceRoot, path.relative(wrapperDir, mirrorRoot)); if (Object.keys(ripPaths).length) overrides.paths = ripPaths; @@ -345,24 +270,20 @@ export function generatedMirror({ workspaceRoot, mirrorRootIsFallback, chain = n ...nullPosture(workspaceRoot, rootConfig && fs.existsSync(rootConfig) ? rootConfig : null), }; if (!mirrorRootIsFallback) overrides.rootDirs = ['.', '../..']; - // Workspace AMBIENT declarations (`rip-env.d.ts` and kin) join the - // program. An explicit `exclude` REPLACES the built-in defaults, so - // `node_modules` is restated alongside the `../../` reach-up. + // Workspace ambient d.ts join the program; an explicit `exclude` + // REPLACES the defaults, so node_modules is restated. const include = ['**/*.ts']; - // A nested project's mirrored subtree belongs to ITS wrapper. Without - // the exclusion both configs claim the same faces, and which one - // answers is tsgo's discovery order rather than the file's own - // nearest config. + // A wrapped subtree belongs to ITS config; without the exclusion two + // configs claim the same faces in tsgo's discovery order. const exclude = ['node_modules', ...excludeDirs.map((d) => `${posix(d)}/**`)]; if (!mirrorRootIsFallback) { include.push('../../**/*.d.ts'); exclude.push('../../**/node_modules'); } - // Bare workspace `.rip` specifiers resolve by MAP, not by lookup: - // node_modules holds the runtime's symlink, whose manifest lands on a - // `.rip` file TypeScript will not follow — `paths` points the bare name - // straight at the mirror face the closure compiled. `paths` outranks - // the node_modules walk, so a mapped name never half-resolves. + // Bare workspace `.rip` specifiers resolve by MAP: their manifests land + // on `.rip` files TypeScript will not follow, so `paths` points each + // bare name at the mirror face the closure compiled. `paths` outranks + // the node_modules walk. const ripPaths = mirrorRootIsFallback ? {} : workspaceRipPaths(workspaceRoot); if (Object.keys(ripPaths).length) overrides.paths = ripPaths; const floorRoot = mirrorRootIsFallback ? null : workspaceRoot; @@ -392,12 +313,11 @@ export function generatedMirror({ workspaceRoot, mirrorRootIsFallback, chain = n }; } -// A workspace file's mirror path is RELATIVE to the mirror root: workspace -// files keep their relative structure (so relative imports between mirrors -// resolve exactly as between sources — and a `foo.rip.ts` name lets a bare -// `./foo.rip` import resolve by TS's extension-append); files outside the -// workspace mirror under __external__ so distinct buffers never collide. -// The caller forms the on-disk path: `join(mirrorRoot, rel) + '.ts'`. +// Workspace files keep their relative structure (imports between mirrors +// resolve as between sources; `foo.rip.ts` lets `./foo.rip` resolve by +// TS's extension-append); files outside the workspace mirror under +// __external__ so distinct buffers never collide. The caller forms the +// on-disk path: `join(mirrorRoot, rel) + '.ts'`. export function mirrorRelForFsPath(fsPath, workspaceRoot) { if (workspaceRoot && fsPath.startsWith(workspaceRoot + path.sep)) { return path.relative(workspaceRoot, fsPath); @@ -405,29 +325,20 @@ export function mirrorRelForFsPath(fsPath, workspaceRoot) { return path.join('__external__', fsPath.replace(/^[/\\]/, '').replace(/:/g, '')); } -// ---- the auto-import stub face: what a workspace `.rip` NOTHING has -// opened or imported contributes to the program, so its exported names -// are auto-import candidates from cold. -// -// Built from a SOURCE scan, not a compile, and that is the whole reason -// the eager pass is affordable: compiling every workspace face is ~92% -// of population time (1456 ms over 277 files, measured), against ~8 ms -// to read the export lines. A stub and a full face produce the same -// completion item and the same import edit, so the compile buys nothing -// candidacy needs. -// -// Scanning SOURCE for a fact the compiler already knows is otherwise the -// never-list's territory (ripImportsOf reads the stores, not text) — the -// exemption is precise and holds only here: this runs where there is no -// compile to read stores from, its output is thrown away the instant a -// real edge materializes the true face over it, and a name this scan -// misses costs a missing completion candidate, never a wrong answer. It -// must NOT be reused anywhere a mapping, a diagnostic, or a closure edge +// ---- the auto-import stub face: what an unopened, unimported workspace +// `.rip` contributes, so its exports are auto-import candidates from +// cold. A SOURCE scan, not a compile — compiling every face is ~92% of +// population time (1456 ms over 277 files, measured) and buys nothing +// candidacy needs. Text-scanning is otherwise the never-list's +// territory; the exemption holds only here: no compile exists to read +// stores from, the output dies when a real face materializes over it, +// and a missed name costs a completion candidate, never a wrong answer. +// It must NOT be reused anywhere a mapping, diagnostic, or closure edge // depends on it. const IDENT = String.raw`[A-Za-z_$][A-Za-z0-9_$]*`; -// Words that open a declaration form and are therefore never the -// exported NAME in rip's bare `export name = …` production. +// Words that open a declaration form — never the exported NAME in +// rip's bare `export name = …` production. const EXPORT_KEYWORDS = new Set([ 'default', 'from', 'as', 'declare', 'async', 'abstract', 'type', 'interface', 'class', 'enum', 'function', 'def', 'const', 'let', 'var', 'namespace', 'module', @@ -438,21 +349,17 @@ const DECLARATION = new RegExp( ); const STAR = new RegExp(String.raw`^\*\s*(?:as\s+(${IDENT})\s+)?from\s*['"]([^'"]+)['"]`); const CLAUSE = new RegExp(String.raw`^(?:(type)\s+)?(${IDENT}|default)(?:\s+as\s+(${IDENT}|default))?$`); -// A binding inside a destructuring pattern: an identifier followed by a -// separator, a closer, end of text, or `=` — the last because a binding -// may carry a DEFAULT (`{ a = 1, b }`) and is still the name being bound. -// A renamed key (`{ a: renamed }`) is deliberately not matched at `a`, -// whose `:` is in none of these: `renamed` is the binding. +// A pattern binding: an identifier before a separator, closer, end, or +// `=` (a default's name is still bound). A renamed key's `a:` matches +// none of these — `renamed` is the binding. const PATTERN_NAME = new RegExp(String.raw`(${IDENT})\s*(?:[,}\]=]|$)`, 'g'); const FROM_SPEC = /^from\s*['"]([^'"]+)['"]/; // The exported names of one `.rip` SOURCE, split by declaration space. -// `stars` carries the specifiers of `export * from …`, which name no -// names of their own — the caller resolves those against its own scan of -// the target (buildStubFaces does). `hasDefault` is tracked separately -// because a default export has no name to carry: a consumer spells it -// `import theme from …` or `import { default as theme } from …`, and a -// stub that omitted it answered TS1192/TS2305 on every such consumer. +// `stars` carries `export * from …` specifiers (the caller resolves them +// against its other scans); `hasDefault` is separate because a default +// has no name — a stub omitting it answered TS1192/TS2305 on every +// `import theme from …` consumer. export function scanExportNames(source) { const values = new Set(); const types = new Set(); @@ -462,16 +369,15 @@ export function scanExportNames(source) { const lines = source.split('\n'); let inBlockString = false; for (let i = 0; i < lines.length; i++) { - // A block string's CONTENT can start a line with `export` — rip's own - // suites embed whole modules that way, and reading one as an export - // put two names in the candidate set that the face never exported. + // A block string's content can start a line with `export` (rip's own + // suites embed whole modules) — never read those as exports. const fences = (lines[i].match(/"""|'''/g) ?? []).length; const wasInBlockString = inBlockString; if (fences % 2) inBlockString = !inBlockString; if (wasInBlockString) continue; - // A top-level `globalThis.NAME ??=` DECLARES the global (the compiler - // emits the typed declaration in the real face); the stub carries an - // `any`-typed twin so the name resolves before anything compiles. + // A top-level `globalThis.NAME ??=` declares the global (the face + // emits the typed declaration); the stub carries an `any` twin so the + // name resolves before anything compiles. const g = /^globalThis\.([A-Za-z_$][A-Za-z0-9_$]*)\s*\?\?=/.exec(lines[i]); if (g) { globals.add(g[1]); continue; } if (!/^export\b/.test(lines[i])) continue; // exports are top-level: column 0 @@ -489,21 +395,15 @@ export function scanExportNames(source) { if (/^type\s*\{/.test(rest)) { typeOnly = true; rest = rest.slice('type'.length).trim(); } if (rest.startsWith('{')) { - // A brace list may span lines (`export {\n a,\n b,\n}`), so read - // forward to the closing brace rather than judging one line. Joined - // with a COMMA: rip lets a multi-line list separate its entries by - // newline alone, and a space-join fuses those into one unparseable - // clause (an empty clause from a trailing comma is skipped below). + // A brace list may span lines; joined with a COMMA because rip lets + // entries separate by newline alone — a space-join fuses them into + // one unparseable clause. while (!rest.includes('}') && i + 1 < lines.length) rest += ',' + lines[++i].trim(); const close = rest.indexOf('}'); if (close < 0) continue; // unterminated: nothing trustworthy to emit - // A brace list is the whole statement or a re-export — and a - // re-export's target contributes nothing beyond the names written - // here (`export { a } from './x.rip'` states `a` outright). Anything - // ELSE after the brace is a shape this scan does not understand, so - // the clauses are not read: names guessed out of it would be offered - // as candidates the real face never exports. Checked BEFORE the - // clause loop, so it gates the names rather than trailing them. + // Anything after the brace other than a `from` spec is a shape this + // scan does not understand — gate the names BEFORE reading clauses, + // or it offers candidates the real face never exports. const tail = rest.slice(close + 1).trim(); if (tail && !FROM_SPEC.test(tail)) continue; for (const raw of rest.slice(1, close).split(',')) { @@ -531,10 +431,9 @@ export function scanExportNames(source) { const pattern = /^(?:declare\s+)?(?:const|let|var)\s*([[{])/.exec(rest); if (pattern) { const body = rest.slice(rest.indexOf(pattern[1])); - // The assignment `=` is the one at DEPTH ZERO. A binding's DEFAULT - // spells `=` too (`{ a = 1, b }`), and cutting at the first one - // drops every name after it — the pattern's own tail, not the - // initializer. + // Cut at the DEPTH-ZERO `=`: a binding's default spells `=` too + // (`{ a = 1, b }`), and cutting at the first drops the pattern's + // own tail. let depth = 0, end = -1; for (let j = 0; j < body.length && end < 0; j++) { const ch = body[j]; @@ -553,12 +452,9 @@ export function scanExportNames(source) { if (!bare || EXPORT_KEYWORDS.has(bare[1])) continue; const name = bare[1]; values.add(name); - // A schema binding is a TYPE as well as a value, and a `:model` also - // ships the two companion types its lowering derives from the name. - // This is the one place the scan encodes a lowering rule rather than - // reading what the source says, and the cost of it going stale is - // bounded to candidacy: an offered name the real face does not export - // fails loudly at the import the moment that face materializes. + // A schema binding is a TYPE too, and `:model` ships two derived + // companions — the one lowering rule the scan encodes; staleness is + // bounded to candidacy and fails loudly at the import. const schema = /^(?:[^=]*)=\s*schema\s+:(\w+)/.exec(rest); if (!schema) continue; types.add(name); @@ -567,21 +463,16 @@ export function scanExportNames(source) { return { values: [...values], types: [...types], stars, hasDefault, globals: [...globals] }; } -// The stub text for one file's exported names. Every name is `any`: the -// stub exists to make the NAME reachable, and a shape it guessed would be -// a shape that could be wrong. A type alias and a variable occupy -// different declaration spaces, so a class or enum can legally be both. -// -// `defaultName` is the local the default export binds, and it is the name -// the editor OFFERS the default under — see defaultLocalName below, where -// every constraint on it is recorded. +// The stub text for one file's exported names — every name `any`: the +// stub makes NAMES reachable, and a guessed shape could be wrong. A type +// alias and a variable occupy different declaration spaces, so a class +// or enum is legally both. `defaultName`: see defaultLocalName. export function stubFace({ values = [], types = [], hasDefault = false, defaultName = '_default', globals = [] } = {}) { const lines = []; for (const name of types) lines.push(`export type ${name} = any;`); for (const name of values) lines.push(`export declare const ${name}: any;`); if (hasDefault) lines.push(`declare const ${defaultName}: any;`, `export default ${defaultName};`); - // Declared vocabulary rides the stub as `any` — resolution before the - // real face's typed declaration materializes over it. + // Declared vocabulary, `any` until the real face materializes. if (globals.length) { lines.push('declare global {'); for (const name of globals) lines.push(` var ${name}: any;`); @@ -591,34 +482,15 @@ export function stubFace({ values = [], types = [], hasDefault = false, defaultN return lines.join('\n') + '\n'; } -// Stub faces from already-scanned sources (fsPath → scanExportNames -// result), as fsPath → text. `export * from './x.rip'` is resolved -// against the OTHER scans here — the one export form whose names are not -// written where it appears. Split from the scan so a caller populating a -// large workspace can yield between reads; `buildStubFaces` below is the -// whole-thing convenience. -// The local a default export binds, and therefore the name the editor -// offers it under. Derived from the SOURCE file, because that is the name -// the author would write: a mirror is `theme.rip.ts`, and TypeScript's own -// derivation from THAT yields `themeRip`. A named declaration beats the -// file derivation, which is the whole reason this exists. -// -// Four fallbacks to `_default`, each driven, none theoretical: -// · not an identifier at all (`10-modules.rip` cannot start with a digit) -// · a LEADING DOUBLE underscore — escaped in TypeScript's symbol tables -// (`__x` → `___x`), which leaves the default with no candidate at all -// · already exported by this module, so binding it twice would not compile -// · a reserved word, which is a SYNTAX error in a const declaration -// `_default` itself always works; it just answers with TypeScript's -// file-derived spelling instead of the author's. -// -// Names that CANNOT be a `const` binding. Verified by compiling -// `declare const X: any; export default X;` for each: every one draws a -// syntax error (TS1389 and friends), while the contextual keywords — -// `type`, `as`, `of`, `from`, `any`, `never` — bind fine and are -// deliberately absent, also driven. `let` is here because a lexical -// declaration cannot bind it, and `await` because a module body is always -// async context. +// The local a default export binds — the name the editor OFFERS it +// under, derived from the SOURCE filename (the mirror's `theme.rip.ts` +// would derive as `themeRip`). Four fallbacks to `_default`, each +// driven: not an identifier; a leading double underscore (TS escapes +// `__x` to `___x`, leaving no candidate); already exported here; a +// reserved word. Below: the names a `const` cannot bind, verified by +// compiling each — contextual keywords (`type`, `as`, `of`, …) bind +// fine and are deliberately absent; `await` is reserved because a +// module body is always async context. const NOT_BINDABLE = new Set([ 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', 'else', 'enum', 'export', 'extends', 'false', @@ -637,8 +509,8 @@ function defaultLocalName(file, taken) { } export function stubFacesFromScans(scans) { - // Star edges, resolved once. A target outside `scans` contributes - // nothing — there is no source here to read names from. + // Star edges, resolved once; a target outside `scans` contributes + // nothing. const starsOf = new Map(); for (const [file, scan] of scans) { starsOf.set(file, scan.stars @@ -652,14 +524,11 @@ export function stubFacesFromScans(scans) { values.set(file, new Set(scan.values)); types.set(file, new Set(scan.types)); } - // Grown to a FIXPOINT rather than resolved by recursion. `export *` is - // transitive and a cycle of them is legal (mutually re-exporting barrel - // files), so a recursive walk has to break the cycle somewhere and - // whichever file it breaks on gets a short answer — which a memo then - // stores as if it were complete, making the result depend on traversal - // order. Iterating to closure has no break to place: every file ends up - // with the union its stars reach, cycles included. It terminates because - // the sets only grow and the name space is finite. + // Grown to a FIXPOINT, not recursed: `export *` cycles are legal + // (mutual barrels), and a recursive walk breaks the cycle somewhere, + // memoizing that file's short answer as complete — order-dependent + // results. Iteration terminates: the sets only grow, the space is + // finite. for (let changed = true; changed; ) { changed = false; for (const [file, targets] of starsOf) { @@ -671,8 +540,7 @@ export function stubFacesFromScans(scans) { } const faces = new Map(); for (const [file, scan] of scans) { - // `export * from` carries names but never a DEFAULT — the star form - // is defined to skip it — so hasDefault stays this file's own. + // `export * from` never carries a default; hasDefault stays local. const v = [...values.get(file)]; const t = [...types.get(file)]; faces.set(file, stubFace({ @@ -695,8 +563,8 @@ export function buildStubFaces(files, read) { return stubFacesFromScans(scans); } -// The roles whose recorded span is TYPE TEXT — where an import type's -// specifier lives, since it belongs to no import node. +// Roles whose recorded span is TYPE TEXT — where an import type's +// specifier lives (it belongs to no import node). const TYPE_TEXT_ROLES = new Set(['annotation', 'returnType', 'typeParams', 'declaration']); const skipTypeTrivia = (text, from) => { @@ -733,10 +601,10 @@ const quotedTypeText = (text, start) => { return null; }; -// Static module specifiers in authored type text. Literal and comment -// contents are data, and a property named `import` is not an import type. -// Identifier recognition comes from the lexer vocabulary so a larger -// Unicode name ending in `import` cannot be split into a false keyword. +// Static module specifiers in authored type text. Literals and comments +// are data; a property named `import` is not an import type; identifier +// recognition uses the lexer vocabulary so a Unicode name ending in +// `import` cannot split into a false keyword. export function typeImportSpecifiers(text) { const specs = []; let i = 0; @@ -777,14 +645,11 @@ export function typeImportSpecifiers(text) { return specs; } -// The `.rip` file a package.json manifest serves for `subpath` ('.', -// './x'), or null. `exports` wins over `main`; an entry may be a string -// or a conditions object, read in the order bun resolves them at -// runtime (import → default → first string). Only `.rip` targets -// answer — everything else is some other toolchain's module and stays -// on TypeScript's ordinary resolution. Glob subpaths ('./*') are not -// expanded: a miss withholds resolution, which errs toward the -// diagnostic (TS2307) rather than a silent wrong file. +// The `.rip` file a manifest serves for `subpath` ('.', './x'), or +// null. `exports` beats `main`; a conditions object reads in bun's +// runtime order (import → default → first string). Only `.rip` targets +// answer; glob subpaths are not expanded — a miss errs toward the +// TS2307, never a silent wrong file. function ripManifestTarget(manifest, subpath) { const pick = (entry) => { if (typeof entry === 'string') return entry; @@ -809,15 +674,12 @@ function ripManifestTarget(manifest, subpath) { return target !== null && target.endsWith('.rip') ? target : null; } -// The `.rip` file a BARE specifier lands on, resolved the way bun will -// at runtime: walk up from `fromDir` to the first node_modules carrying -// the package, follow its manifest, realpath the target (a workspace -// package is a symlink — the REAL path is the workspace source, which -// is what the mirror is keyed by). Null for anything else: builtins, -// packages that are not installed, targets that are not `.rip`, and -// targets that stay physically inside node_modules (an installed -// third-party `.rip` package would mirror under a tree the generated -// tsconfig excludes — a recorded limit, not an accident). +// The `.rip` file a BARE specifier lands on, resolved the way bun will: +// node_modules walk-up, manifest, then realpath (a workspace package is +// a symlink; the mirror is keyed by the real source path). Null for +// builtins, uninstalled packages, non-`.rip` targets, and targets +// physically inside node_modules — those would mirror under an excluded +// tree (a recorded limit). export function bareRipSpecifierTarget(spec, fromDir) { if (!spec || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#')) return null; if (spec.startsWith('node:') || spec.startsWith('bun:')) return null; @@ -840,14 +702,11 @@ export function bareRipSpecifierTarget(spec, fromDir) { } } -// tsconfig `paths` for every workspace package that serves `.rip`: -// bare-name → mirror face, so tsgo resolves `@rip/util` to the same -// file the closure compiled. Enumerated from the workspace root's -// `workspaces` globs (the `/*` form; a member without a manifest -// or without `.rip` exports simply contributes nothing). Paths are -// RELATIVE TO THE CONFIG that carries them — the mirror root's config -// passes '' for `fromConfigDirToMirrorRoot`; a nested project's wrapper -// passes its own reach-up. +// tsconfig `paths` for every workspace package serving `.rip`: +// bare-name → mirror face, enumerated from the root's `workspaces` +// globs (`/*` form; members without `.rip` exports contribute +// nothing). Paths are relative to the CONFIG carrying them — the mirror +// root passes '', a wrapper passes its reach-up. export function workspaceRipPaths(workspaceRoot, fromConfigDirToMirrorRoot = '') { const paths = {}; if (!workspaceRoot) return paths; @@ -885,25 +744,19 @@ export function workspaceRipPaths(workspaceRoot, fromConfigDirToMirrorRoot = '') return paths; } -// The relative .rip import targets of a compiled file, as absolute paths -// — the closure edges. Read from the compiler's OWN stores (never -// scanned from generated text — the never-list): import/export nodes -// carry a `source` role whose exact source span is the specifier string; -// dynimport nodes carry an `args` span, followed only when it is a single -// static string literal (a computed specifier is a recorded closure miss); -// and an IMPORT TYPE (`c: import('./lib.rip').Crate`) names its module -// inside recorded type text, which is read the same way — a role's own -// span over SOURCE, never a scan of anything generated. Program -// membership is the whole fix there: with the sibling in the program the -// untouched `.rip` specifier resolves by the mirror's filename, so -// nothing rewrites a specifier and there is no second resolution rule. +// The `.rip` import targets of a compiled file — the closure edges. +// Read from the compiler's OWN stores, never generated text (the +// never-list): import/export `source` roles, dynimport args only when a +// single static literal (computed specifiers are a recorded miss), and +// import types inside recorded type text. Program membership is the +// whole fix: with the sibling in the program, the untouched specifier +// resolves by the mirror's filename — no rewriting, no second rule. export function ripImportsOf(stores, sourceText, fromDir) { const seen = new Set(); const targets = []; const addSpec = (spec) => { - // A bare specifier resolves through node_modules — the workspace - // package edge (bareRipSpecifierTarget above); a relative one by the - // filesystem. Same set, two resolutions, absolute paths either way. + // Bare specifiers resolve through node_modules, relative ones by the + // filesystem — absolute paths either way. let abs; if (spec.startsWith('./') || spec.startsWith('../')) { if (!spec.endsWith('.rip')) return; diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index 00f3a0de..8b2b2860 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -385,7 +385,7 @@ function ensureAutoBoundary(fsPath) { } writeGeneratedTsconfig(); written.push(path.join(mirrorRoot, 'tsconfig.json'), path.join(mirrorRoot, HOST_FLOOR_NAME)); - connection.console.log(`[rip] ${rel}: declares globals — the package becomes its own program`); + connection.console.log(`[rip] ${rel}: the package becomes its own program (declared globals or rip.strict)`); return written; } @@ -1760,7 +1760,11 @@ async function refresh(document) { scheduleManifestSave(); } const wrapperFiles = ensureProjectWrapper(fsPath); - if (result.globalDecls?.length) wrapperFiles.push(...ensureAutoBoundary(fsPath)); + // Globals-declaring or nested-strict: either way the package needs + // its own program (floors and null posture are per-program). + if (result.globalDecls?.length || (state.strict === true && state.configDir && state.configDir !== workspaceRoot)) { + wrapperFiles.push(...ensureAutoBoundary(fsPath)); + } if (wrapperFiles.length && tsgo) { tsgo.client.notify('workspace/didChangeWatchedFiles', { changes: wrapperFiles.map((p) => ({ uri: 'file://' + p, type: FileChangeType.Changed })), diff --git a/src/check.js b/src/check.js index da9b95f5..17472c69 100644 --- a/src/check.js +++ b/src/check.js @@ -341,31 +341,33 @@ if (compiled.size > 0) { wrapperRels.add(path.relative(workspaceRoot, path.dirname(owner))); } } - // A package that DECLARES globals (`globalThis.NAME ??=` at top level) - // becomes its own program — the AUTO BOUNDARY. Its vocabulary stays - // package-scoped and reaches importers the way the runtime does (an - // import runs the installer); a non-importing neighbor keeps its - // cannot-find. Anchored at the nearest package.json; a declarer already - // inside a tsconfig-wrapped project needs nothing more, and a declarer - // at the workspace root has no narrower scope to give it. - const globalDeclRels = new Set(); + // The AUTO BOUNDARY: a package becomes its own program when it DECLARES + // globals (`globalThis.NAME ??=` — the vocabulary stays package-scoped, + // reaching importers the way the runtime does) or when it sets + // `rip.strict` (floors and null posture are per-PROGRAM, so a strict + // package inside the root program kept getting the gradual floor's + // `any`s — driven by `bun:sqlite` staying unsquiggled in a strict + // package). A package already inside a tsconfig-wrapped project needs + // nothing more; the workspace root has no narrower scope to give. + const autoBoundaryRels = new Set(); if (!mirrorRootIsFallback) { for (const [fsPath, entry] of compiled) { - if (!entry.result.globalDecls?.length) continue; - let dir = path.dirname(fsPath); let pkgDir = null; - for (;;) { - if (fs.existsSync(path.join(dir, 'package.json'))) { pkgDir = dir; break; } - if (dir === workspaceRoot || path.dirname(dir) === dir) break; - dir = path.dirname(dir); + if (entry.cfg.strict === true && entry.cfg._configDir && entry.cfg._configDir !== workspaceRoot) { + pkgDir = entry.cfg._configDir; + } else if (entry.result.globalDecls?.length) { + for (let dir = path.dirname(fsPath); ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'package.json'))) { pkgDir = dir; break; } + if (dir === workspaceRoot || path.dirname(dir) === dir) break; + } } - if (pkgDir === null || pkgDir === workspaceRoot) continue; + if (pkgDir === null || pkgDir === workspaceRoot || !pkgDir.startsWith(workspaceRoot + path.sep)) continue; const rel = path.relative(workspaceRoot, pkgDir); - if (![...wrapperRels].some((w) => rel === w || rel.startsWith(w + path.sep))) globalDeclRels.add(rel); + if (![...wrapperRels].some((w) => rel === w || rel.startsWith(w + path.sep))) autoBoundaryRels.add(rel); } } const mirror = generatedMirror({ - workspaceRoot, mirrorRootIsFallback, excludeDirs: [...wrapperRels, ...globalDeclRels], + workspaceRoot, mirrorRootIsFallback, excludeDirs: [...wrapperRels, ...autoBoundaryRels], }); fs.writeFileSync(path.join(mirrorRoot, 'tsconfig.json'), JSON.stringify(mirror.tsconfig, null, 2)); fs.writeFileSync(path.join(mirrorRoot, HOST_FLOOR_NAME), mirror.hostFloorDts); @@ -379,7 +381,7 @@ if (compiled.size > 0) { fs.writeFileSync(path.join(wrapperDir, 'tsconfig.json'), JSON.stringify(wrapper.tsconfig, null, 2)); fs.writeFileSync(path.join(wrapperDir, HOST_FLOOR_NAME), wrapper.hostFloorDts); } - for (const rel of globalDeclRels) { + for (const rel of autoBoundaryRels) { const wrapperDir = path.join(mirrorRoot, rel); const wrapper = projectWrapper({ wrapperDir, sourceTsconfig: null, sourceDir: path.join(workspaceRoot, rel), diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 8fa0b2f8..0c67b50a 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -756,6 +756,28 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // A nested package that sets `rip.strict` becomes its own program, the + // same auto boundary a globals-declaring package gets — floors are + // per-PROGRAM, so without it the root program's floor kept answering + // `any` for a package that asked for complaints. Driven: Philip flipped + // packages/ai strict and `bun:sqlite` stayed unsquiggled. + test('a nested rip.strict package refuses the floors: its own program, its own posture', () => { + const dir = workspace({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), + 'packages/lib/package.json': JSON.stringify({ name: '@t/lib', rip: { strict: true } }), + 'packages/lib/lib.rip': "import { Database } from 'bun:sqlite'\nconsole.log Database\n", + 'packages/loose/package.json': JSON.stringify({ name: '@t/loose' }), + 'packages/loose/loose.rip': "import { Database } from 'bun:sqlite'\nconsole.log Database\n", + }); + try { + const diags = JSON.parse(check(dir, ['--json']).stdout); + // The strict package: floor refused, the defect publishes. + expect(diags.filter((d) => d.file.includes('lib')).map((d) => d.code)).toContain(2307); + // The gradual sibling: floored `any`, still quiet. + expect(diags.filter((d) => d.file.includes('loose'))).toEqual([]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // Config is per FILE (nearest package.json), so a strict consumer's // check still hides its gradual DEPENDENCIES' diagnostics — and a // summary that says "set `rip.strict` in package.json" after the user @@ -1674,6 +1696,23 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); + // Bun's builtin MODULES ride the floor too: `import { Database } from + // 'bun:sqlite'` is ordinary Bun code, and without installed host types + // the import is a cannot-find DEFECT on a module that demonstrably + // exists at runtime. One wildcard shorthand (`declare module "bun:*"`) + // floors them as `any`, with the floor's own gates: installed types + // outrank it, and strict refuses it — the defect returns until the + // project declares real host types. + test('bun:* builtin modules ride the host floor: `any` under gradual, the defect under strict', () => { + const dir = freshProject({ withTypes: false }); + try { + fs.writeFileSync(path.join(dir, 'app.rip'), "import { Database } from 'bun:sqlite'\ndb = new Database(':memory:')\nconsole.log db\n"); + expect(JSON.parse(check(dir, ['--json']).stdout)).toEqual([]); + fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ name: 'fresh', rip: { strict: true } })); + expect(JSON.parse(check(dir, ['--json']).stdout).map((d) => d.code)).toContain(2307); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + test('the same project is quiet before anything is installed', () => { const dir = freshProject({ withTypes: false }); try { From 6459fe2a8ba125d9043f2746c1d01adf0550c097 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 20:31:05 +0100 Subject: [PATCH 07/23] A dangling import is the importer's defect, not a coverage gap MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/vscode/src/mirror.js | 13 ++++ src/check.js | 77 ++++++++++++------ test/toolchain/check.test.js | 142 ++++++++++++++++++++++++++++++---- 3 files changed, 191 insertions(+), 41 deletions(-) diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index e8bac48e..27d803bf 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -874,6 +874,19 @@ export function workspaceRipPaths(workspaceRoot, fromConfigDirToMirrorRoot = '') // membership is the whole fix there: with the sibling in the program the // untouched `.rip` specifier resolves by the mirror's filename, so // nothing rewrites a specifier and there is no second resolution rule. +// A closure-edge read failure that means the module DOES NOT EXIST as +// specified — nothing at the path (ENOENT, including a dangling +// symlink), a path segment that is a file (ENOTDIR), a symlink cycle +// (ELOOP), an unresolvable name (ENAMETOOLONG) — as opposed to a module +// that exists but cannot be read (EACCES, EIO). ripImportsOf hands out +// relative edges without an existence check, so its consumers meet both +// classes and route them differently: a missing module is the +// IMPORTER's defect (its absent face earns tsgo's TS2307 on the +// importing line), while an unreadable one is a coverage gap a skip +// would mislabel as "cannot find module". +export const missingModuleRead = (err) => + err?.code === 'ENOENT' || err?.code === 'ENOTDIR' || err?.code === 'ELOOP' || err?.code === 'ENAMETOOLONG'; + export function ripImportsOf(stores, sourceText, fromDir) { const seen = new Set(); const targets = []; diff --git a/src/check.js b/src/check.js index b0e5d0e4..15d42b9a 100644 --- a/src/check.js +++ b/src/check.js @@ -32,7 +32,7 @@ import { mapTsDiagnostic, applyRipDirectives, isNoCheckPath, compileErrorInfo } import { SUPPRESSED_TS_CODES, IMPLICIT_ANY_CODES, MISSING_TYPES_CODES } from '../packages/vscode/src/translate.js'; import { scopeGateOf, typedExportsOf, typedImportsOf } from '../packages/vscode/src/scopes.js'; import { tokenize } from './lexer.js'; -import { generatedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf } from '../packages/vscode/src/mirror.js'; +import { generatedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf, missingModuleRead } from '../packages/vscode/src/mirror.js'; import { lineStartsOf, offsetToPosition, positionToOffset, generatedSpanToSource } from '../packages/vscode/src/translate.js'; // Fails OPEN, like the editor's: a source the lexer refuses leaves the gate @@ -71,7 +71,9 @@ Directories are walked for *.rip (node_modules and dot-directories are skipped). Config — package.json#rip (strict / noCheck) and the project tsconfig — governs exactly as it does in the editor. The generated TS mirror stays at /.rip/check after the run — the exact TypeScript -tsgo type-checked, rebuilt fresh each run.`; +the LAST run type-checked (only the files that run covered), wiped and +rebuilt at the start of every run; .build inside it names the compiler +build that wrote it.`; const fail = (message, code = 2) => { console.error(message); process.exit(code); }; @@ -103,11 +105,10 @@ if (unknownFlags.length) fail(`rip check: unknown option${unknownFlags.length == // regenerable cache — the peer of the editor's .rip/editor, self- // gitignored, left in place between runs so the exact TypeScript tsgo // checked stays inspectable. Freshness never depends on cleanup: every -// run rebuilds the tree from scratch before tsgo sees it. (Create-then- -// delete also made the .rip dir flicker in and out of editor file trees -// on every run.) Only the temp fallback root — used when the workspace -// isn't writable — is ours to remove, on ANY exit path: rmSync in an -// exit handler runs synchronously. +// run wipes and rebuilds the tree before tsgo sees it. Only the temp +// fallback root — used when the workspace isn't writable — is ours to +// remove, on ANY exit path: rmSync in an exit handler runs +// synchronously. let fallbackToClean = null; process.on('exit', () => { if (fallbackToClean === null) return; @@ -189,6 +190,35 @@ if (targets.length === 0) { } const workspaceRoot = findWorkspaceRoot(targets); +// ── mirror root ───────────────────────────────────────────────────── +// A dedicated mirror at /.rip/check (peer of the editor's +// .rip/editor), wiped and rebuilt EVERY run — unconditionally, before +// anything compiles, so a run whose targets all fail to parse still +// clears the previous run's faces and the tree always holds exactly +// what the LAST run checked. `.build` stamps it with the compiler build +// that wrote it. The wipe is what carries correctness: a since-deleted +// source's face from an earlier run never lingers in the `**/*.ts` +// program. +let mirrorRoot = path.join(workspaceRoot, '.rip', 'check'); +let mirrorRootIsFallback = false; +try { + fs.rmSync(mirrorRoot, { recursive: true, force: true }); + fs.mkdirSync(mirrorRoot, { recursive: true }); + fs.writeFileSync(path.join(mirrorRoot, '.gitignore'), '*\n'); + const compilerDir = path.dirname(fileURLToPath(import.meta.url)); + fs.writeFileSync(path.join(mirrorRoot, '.build'), + cacheIdentityOf(compilerDir, path.join(compilerDir, '..', 'packages', 'vscode', 'src')) + '\n'); +} catch (err) { + // Degraded, never silent: the fallback re-roots tsgo outside the + // workspace, so per-project wrappers stop applying and @types + // resolution changes — the user must know their diagnostics come + // from a different posture than the editor's. + mirrorRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-check-')); + mirrorRootIsFallback = true; + fallbackToClean = mirrorRoot; + console.error(`rip check: workspace mirror root unavailable (${err.code ?? err.message}) — using a temp fallback (tsconfig/@types fidelity degrades)`); +} + // ── closure compile (pins-less) ───────────────────────────────────── // BFS the target set + its transitive .rip imports. Each source is // compiled to its TS face once (with its own rip.strict); a parse @@ -231,6 +261,7 @@ const missingTypeNames = new Set(); const hiddenScopeDirs = new Set(); const hiddenAnnotationDirs = new Set(); const seen = new Set(); +const explicitTargets = new Set(targets); const queue = [...targets]; while (queue.length) { const fsPath = queue.shift(); @@ -239,8 +270,20 @@ while (queue.length) { let source; try { source = fs.readFileSync(fsPath, 'utf8'); } catch (err) { - // Readable at collect time (statSync), not now: a permission flip, a - // broken symlink, a race. Never silently drop it — mark the run short. + // A QUEUED import whose module does not exist as specified + // (missingModuleRead: ENOENT, ENOTDIR, ELOOP…) is not a coverage + // gap: a dangling specifier is the IMPORTER's defect, and its + // missing face already earns the importer tsgo's TS2307 on the .rip + // line (or silence under @ts-nocheck, whose writ covers the file's + // imports). Everything else stays loud and marks the run short: an + // explicit target is part of what was ASKED to be checked (named on + // the command line or found by the walk), and an import that EXISTS + // but cannot be read (EACCES) must not skip into a "cannot find + // module" that misstates the problem. The editor's closure walk is + // broader here — it parks EVERY unreadable import for a later + // Created event — because an open-buffer server retries where a + // batch gate answers once, loudly. + if (!explicitTargets.has(fsPath) && missingModuleRead(err)) continue; incompleteCheck = true; console.error(`rip check: cannot read ${path.relative(process.cwd(), fsPath)} (${err.code ?? err.message}) — skipped; the run is incomplete`); continue; @@ -293,22 +336,6 @@ for (const [fsPath, entry] of compiled) { const tsDiags = []; let tsgoUnavailable = false; // tsgo needed but could not start — a run that couldn't type-check if (compiled.size > 0) { - // A dedicated mirror at /.rip/check (peer of the editor's - // .rip/editor), rebuilt from scratch each run and left in place after - // (see the lifecycle comment on the exit handler above). The start-of- - // run wipe is what carries correctness: a since-deleted source's face - // from an earlier run never lingers in the `**/*.ts` program. - let mirrorRoot = path.join(workspaceRoot, '.rip', 'check'); - let mirrorRootIsFallback = false; - try { - fs.rmSync(mirrorRoot, { recursive: true, force: true }); - fs.mkdirSync(mirrorRoot, { recursive: true }); - fs.writeFileSync(path.join(mirrorRoot, '.gitignore'), '*\n'); - } catch { - mirrorRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-check-')); - mirrorRootIsFallback = true; - fallbackToClean = mirrorRoot; - } for (const [fsPath, entry] of compiled) { const rel = mirrorRelForFsPath(fsPath, mirrorRootIsFallback ? null : workspaceRoot); const mirrorPath = path.join(mirrorRoot, rel) + '.ts'; diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 19ed7498..11e178fa 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -100,6 +100,22 @@ function check(dir, args = []) { return { stdout: r.stdout ?? '', stderr: r.stderr ?? '', status: r.status }; } +// Mode-000 `file` for the duration of fn(). Returns false WITHOUT running +// fn on root/owner-override filesystems where the file stays readable +// anyway — callers bail, the scenario cannot exist there. The restore +// lives here so no failure path leaves an unreadable file for the +// caller's cleanup rmSync to trip on. +function withUnreadable(file, fn) { + fs.chmodSync(file, 0o000); + try { + try { fs.readFileSync(file, 'utf8'); return false; } catch { /* unreadable, as intended */ } + fn(); + return true; + } finally { + try { fs.chmodSync(file, 0o644); } catch { /* gone with the fixture */ } + } +} + describe('rip check: usage surface (no server)', () => { test('--help prints usage and exits 0', () => { const r = spawnSync('bun', [BIN, 'check', '--help'], { encoding: 'utf8' }); @@ -1519,15 +1535,46 @@ describeExtended('rip check: type diagnostics over the real server', () => { check(dir); expect(fs.existsSync(path.join(mirror, 'a.rip.ts'))).toBe(true); // the face is retained expect(fs.readFileSync(path.join(mirror, '.gitignore'), 'utf8')).toBe('*\n'); // and git never sees it + expect(fs.readFileSync(path.join(mirror, '.build'), 'utf8').trim()).not.toBe(''); // stamped with the build that wrote it // A face whose source no longer exists is wiped by the next run, // not trusted from the cache. fs.writeFileSync(path.join(mirror, 'deleted.rip.ts'), 'const ghost: number = 0;\n'); check(dir); expect(fs.existsSync(path.join(mirror, 'a.rip.ts'))).toBe(true); expect(fs.existsSync(path.join(mirror, 'deleted.rip.ts'))).toBe(false); + // The wipe is unconditional: a run whose only target fails to PARSE + // still clears the previous run's faces, so the tree never shows a + // face for source that no longer compiles. + fs.writeFileSync(path.join(dir, 'a.rip'), 'x = (\n'); + const r = check(dir); + expect(r.status).toBe(1); // the parse error still reports + expect(fs.existsSync(path.join(mirror, 'a.rip.ts'))).toBe(false); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); + // An unwritable workspace still checks — rerouted to a temp mirror, + // LOUDLY (fidelity degrades: per-project wrappers and @types resolution + // change), and the temp root is removed by the exit handler. + test('an unwritable workspace falls back to a temp mirror, loudly, and cleans it up', () => { + const dir = workspace({ 'a.rip': 'x: number = 0\nconsole.log x\n' }); + try { + fs.chmodSync(dir, 0o555); + let writable = false; + try { fs.mkdirSync(path.join(dir, '.probe')); writable = true; fs.rmdirSync(path.join(dir, '.probe')); } catch { /* expected EACCES */ } + if (writable) return; // root / owner-override filesystem can't exercise this path + const before = new Set(fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('rip-check-'))); + const r = check(dir); + expect(r.stderr).toContain('temp fallback'); // degraded, never silent + expect(r.status).toBe(0); // the clean file still checks clean + expect(fs.existsSync(path.join(dir, '.rip'))).toBe(false); // nothing forced into the workspace + const leaked = fs.readdirSync(os.tmpdir()).filter((n) => n.startsWith('rip-check-') && !before.has(n)); + expect(leaked).toEqual([]); // the exit handler reclaimed the temp root + } finally { + try { fs.chmodSync(dir, 0o755); } catch { /* restore for cleanup */ } + fs.rmSync(dir, { recursive: true, force: true }); + } + }, 60_000); + // A coexisting editor mirror (.rip/editor) must survive a batch check: // the two mirrors share the .rip parent but own disjoint subtrees. test('a coexisting .rip/editor is preserved', () => { @@ -1541,25 +1588,88 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); - // Coverage short of what was asked never exits 0: a file readable at - // collect time but not at read time is skipped loudly (exit 2, a stderr - // note), and a clean sibling does NOT rescue the exit code into a false 0. + // A dangling import is the IMPORTER's defect, not a coverage gap: the + // absent module never marks the run incomplete — tsgo's TS2307 on the + // importing line is the report, matching the editor's closure walk. + test('a dangling .rip import earns TS2307 on the importer, not an incomplete run', () => { + const dir = workspace({ 'a.rip': "p: import('./gone.rip').T = 5\nconsole.log p\n" }); + try { + const r = check(dir); + expect(r.status).toBe(1); + expect(r.stdout).toContain('TS2307'); + expect(r.stdout).toContain('a.rip:1'); + expect(r.stderr).not.toContain('incomplete'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + // @ts-nocheck's writ covers the file's imports too: a nocheck'd importer + // with a dangling import checks clean and SILENT — the corpus's own + // errors fixtures dangle an import on purpose, and a default `rip check` + // over a repo containing them must not be permanently "incomplete". + test('a dangling import under @ts-nocheck stays silent (exit 0)', () => { + const dir = workspace({ 'a.rip': "# @ts-nocheck\np: import('./gone.rip').T = 5\nconsole.log p\n" }); + try { + const r = check(dir); + expect(r.status).toBe(0); + expect(r.stdout).toContain('No type errors'); + expect(r.stderr).not.toContain('incomplete'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + // ENOENT is not the only "module does not exist as specified" errno: a + // specifier whose path walks THROUGH a file (./lib.rip/T.rip, ENOTDIR) + // is the same importer-side defect and gets the same report — TS2307 on + // the importer, never a permanently incomplete run. + test('a specifier through a file (ENOTDIR) is a TS2307, not an incomplete run', () => { + const dir = workspace({ + 'a.rip': "p: import('./lib.rip/T.rip').T = 5\nconsole.log p\n", + 'lib.rip': 'export x = 1\n', + }); + try { + const r = check(dir); + expect(r.status).toBe(1); + expect(r.stdout).toContain('TS2307'); + expect(r.stderr).not.toContain('incomplete'); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + // An import that EXISTS but cannot be read is a real coverage gap: the + // run stays loud (the incomplete note beside whatever tsgo says about + // the missing face), never a bare "cannot find module" that misstates + // the problem. The exit is 1, not 2 — an error-severity diagnostic + // outranks the incomplete posture in the exit-code ladder. + test('an unreadable (existing) import still marks the run incomplete', () => { + const dir = workspace({ + 'a.rip': "p: import('./locked.rip').T = 5\nconsole.log p\n", + 'locked.rip': 'export helper = 42\n', + }); + try { + const exercised = withUnreadable(path.join(dir, 'locked.rip'), () => { + // a.rip is the explicit target; locked.rip is reached only as its import. + const r = check(dir, [path.join(dir, 'a.rip')]); + expect(r.stderr).toContain('locked.rip (EACCES)'); + expect(r.stderr).toContain('the run is incomplete'); + expect(r.status).toBe(1); // tsgo's TS2307 on the missing face outranks exit 2 + }); + if (!exercised) return; // root / owner-override filesystem can't exercise this path + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 60_000); + + // Coverage short of what was asked never exits 0: an explicit target — + // named on the command line or swept up by the directory walk — that + // cannot be read is skipped loudly (exit 2, a stderr note), and a clean + // sibling does NOT rescue the exit code into a false 0. test('an unreadable file leaves the run incomplete (exit 2, no false clean)', () => { const dir = workspace({ 'ok.rip': 'x: number = 1\nconsole.log x\n', 'locked.rip': 'y: number = 2\nconsole.log y\n' }); - const locked = path.join(dir, 'locked.rip'); try { - fs.chmodSync(locked, 0o000); - let readable = false; - try { fs.readFileSync(locked, 'utf8'); readable = true; } catch { /* expected EACCES */ } - if (readable) return; // root / owner-override filesystem can't exercise this path - const r = check(dir); - expect(r.status).toBe(2); // incomplete coverage → never 0 - expect(r.stderr).toContain('the run is incomplete'); - expect(r.stdout).not.toContain('No type errors'); // ok.rip is clean, but the run isn't - } finally { - try { fs.chmodSync(locked, 0o644); } catch { /* already restored */ } - fs.rmSync(dir, { recursive: true, force: true }); - } + const exercised = withUnreadable(path.join(dir, 'locked.rip'), () => { + const r = check(dir); + expect(r.status).toBe(2); // incomplete coverage → never 0 + expect(r.stderr).toContain('the run is incomplete'); + expect(r.stdout).not.toContain('No type errors'); // ok.rip is clean, but the run isn't + }); + if (!exercised) return; // root / owner-override filesystem can't exercise this path + } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 60_000); // tsgo emits relatedInformation locations as canonical (percent-encoded) From 3f900700f38bfbb147fa194c7779b9f4915feb79 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 21:13:14 +0100 Subject: [PATCH 08/23] A nested install resolves like the runtime; an absent one is intent, not a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/TYPES.md | 10 ++++-- packages/vscode/src/diagnostics.js | 9 +++++ packages/vscode/src/mirror.js | 48 +++++++++++++++++++++++-- packages/vscode/src/server.js | 4 ++- src/check.js | 19 ++++++++-- test/toolchain/check.test.js | 58 ++++++++++++++++++++++++++++++ 6 files changed, 139 insertions(+), 9 deletions(-) diff --git a/docs/TYPES.md b/docs/TYPES.md index 0c225f0b..889746ac 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -241,9 +241,13 @@ 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. Gradual also supplies -`strictNullChecks: false`, yielding to any strictness the project's own -tsconfig chain sets. +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 also +supplies `strictNullChecks: false`, yielding to any strictness the +project's own tsconfig chain sets. ## Project configuration diff --git a/packages/vscode/src/diagnostics.js b/packages/vscode/src/diagnostics.js index 7047160e..a4cb47f7 100644 --- a/packages/vscode/src/diagnostics.js +++ b/packages/vscode/src/diagnostics.js @@ -14,6 +14,7 @@ import { 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 @@ -47,6 +48,14 @@ 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); diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index b2c19ef9..51e6f5d8 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -250,7 +250,7 @@ export function projectWrapper({ wrapperDir, sourceTsconfig, sourceDir: sourceDi const tsconfig = { compilerOptions: overrides, include: ['**/*.ts', `${reachUp}/**/*.d.ts`], - exclude: ['node_modules', `${reachUp}/**/node_modules`], + exclude: ['**/node_modules', `${reachUp}/**/node_modules`], }; if (anchor !== null) tsconfig.extends = posix(path.relative(wrapperDir, anchor)); else Object.assign(overrides, { target: 'esnext', module: 'esnext', lib: ['esnext', 'dom'] }); @@ -275,7 +275,7 @@ export function generatedMirror({ workspaceRoot, mirrorRootIsFallback, chain = n const include = ['**/*.ts']; // A wrapped subtree belongs to ITS config; without the exclusion two // configs claim the same faces in tsgo's discovery order. - const exclude = ['node_modules', ...excludeDirs.map((d) => `${posix(d)}/**`)]; + const exclude = ['**/node_modules', ...excludeDirs.map((d) => `${posix(d)}/**`)]; if (!mirrorRootIsFallback) { include.push('../../**/*.d.ts'); exclude.push('../../**/node_modules'); @@ -313,6 +313,50 @@ export function generatedMirror({ workspaceRoot, mirrorRootIsFallback, chain = n }; } +// A bare specifier DECLARED in a governing package.json but not +// installed anywhere on the ancestor chain — the manifest's stated +// intent, not a typo. Returns the declaring dir (for the install +// remedy), or null. Gradual holds these 2307s; strict publishes them. +export function declaredButUninstalled(spec, fromDir) { + if (!spec || !fromDir || spec.startsWith('.') || spec.startsWith('/') || spec.startsWith('#')) return null; + if (spec.startsWith('node:') || spec.startsWith('bun:')) return null; + const parts = spec.split('/'); + const pkgName = spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0]; + if (!pkgName || (spec.startsWith('@') && parts.length < 2)) return null; + let declaringDir = null; + for (let dir = fromDir; ; dir = path.dirname(dir)) { + if (fs.existsSync(path.join(dir, 'node_modules', pkgName))) return null; // installed: resolution's problem, not ours + if (declaringDir === null && fs.existsSync(path.join(dir, 'package.json'))) { + try { + const m = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8')); + for (const field of ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies']) { + if (m?.[field]?.[pkgName] !== undefined) { declaringDir = dir; break; } + } + } catch { /* unreadable manifest declares nothing */ } + } + if (path.dirname(dir) === dir) return declaringDir; + } +} + +// A face's module walk lives in the MIRROR tree, so a nested source +// install (a quarantined bench dir's own node_modules) was invisible to +// it — bun resolves those at runtime. Every source dir on the file's +// ancestor chain that carries node_modules gets a symlink twin in the +// mirror; the workspace root needs none (the walk exits the mirror into +// it). Best-effort: a failed link leaves the 2307 standing. +export function linkNestedNodeModules(workspaceRoot, mirrorRoot, fsPath) { + if (!workspaceRoot || !mirrorRoot || !fsPath.startsWith(workspaceRoot + path.sep)) return; + for (let dir = path.dirname(fsPath); dir !== workspaceRoot && dir.startsWith(workspaceRoot + path.sep); dir = path.dirname(dir)) { + const srcNm = path.join(dir, 'node_modules'); + if (!fs.existsSync(srcNm)) continue; + const at = path.join(mirrorRoot, path.relative(workspaceRoot, dir), 'node_modules'); + try { + fs.mkdirSync(path.dirname(at), { recursive: true }); + if (!fs.lstatSync(at, { throwIfNoEntry: false })) fs.symlinkSync(srcNm, at); + } catch { /* best-effort */ } + } +} + // Workspace files keep their relative structure (imports between mirrors // resolve as between sources; `foo.rip.ts` lets `./foo.rip` resolve by // TS's extension-append); files outside the workspace mirror under diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index 8b2b2860..f834f7a4 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -73,7 +73,7 @@ import { } from './translate.js'; import { mapTsDiagnostic, applyRipDirectives, isNoCheckPath, compileErrorInfo } from './diagnostics.js'; import { scopeGateOf, typedExportsOf, typedImportsOf } from './scopes.js'; -import { generatedMirror as buildGeneratedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf, scanExportNames, stubFacesFromScans } from './mirror.js'; +import { generatedMirror as buildGeneratedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf, scanExportNames, stubFacesFromScans, linkNestedNodeModules } from './mirror.js'; // The compiler: in-repo development resolves the repository's src/; // the staged .vsix carries a copy at compiler/src/ (scripts/package.js). @@ -830,6 +830,7 @@ const enumNamesOf = (result) => function mirrorFromDisk(fsPath, source) { faceCache.delete(fsPath); + if (!mirrorRootIsFallback) linkNestedNodeModules(workspaceRoot, mirrorRoot, fsPath); const result = rawCompile(fsPath, source, hashText(source)); const mirrorPath = mirrorPathOf('file://' + fsPath); warnOnMirrorCollision(mirrorPath, fsPath); @@ -1723,6 +1724,7 @@ async function refresh(document) { if (good.parseDiagnostics.length === 0) { try { warnOnMirrorCollision(state.mirrorPath, document.uri); + if (!mirrorRootIsFallback) { try { linkNestedNodeModules(workspaceRoot, mirrorRoot, fileURLToPath(document.uri)); } catch { /* non-file uri */ } } writeMirror(state.mirrorPath, result.code); } catch (err) { connection.console.error(`[rip] mirror write failed: ${err.message}`); diff --git a/src/check.js b/src/check.js index 17472c69..b6d8b4c4 100644 --- a/src/check.js +++ b/src/check.js @@ -32,7 +32,7 @@ import { mapTsDiagnostic, applyRipDirectives, isNoCheckPath, compileErrorInfo } import { SUPPRESSED_TS_CODES, IMPLICIT_ANY_CODES, MISSING_TYPES_CODES } from '../packages/vscode/src/translate.js'; import { scopeGateOf, typedExportsOf, typedImportsOf } from '../packages/vscode/src/scopes.js'; import { tokenize } from './lexer.js'; -import { generatedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf } from '../packages/vscode/src/mirror.js'; +import { generatedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf, linkNestedNodeModules, declaredButUninstalled } from '../packages/vscode/src/mirror.js'; import { lineStartsOf, offsetToPosition, positionToOffset, generatedSpanToSource } from '../packages/vscode/src/translate.js'; // Fails OPEN, like the editor's: a source the lexer refuses leaves the gate @@ -234,6 +234,8 @@ const missingTypeNames = new Set(); // the right package.json. The home project ('.') stays unnamed. const hiddenScopeDirs = new Set(); const hiddenAnnotationDirs = new Set(); +let hiddenUninstalled = 0; +const hiddenUninstalledDirs = new Set(); // where `bun install` answers const seen = new Set(); const queue = [...targets]; while (queue.length) { @@ -269,6 +271,7 @@ while (queue.length) { echoSpans: result.echoSpans ?? [], srcLineStarts, genLineStarts: lineStartsOf(result.code), strict: cfg.strict === true, + dir: path.dirname(fsPath), }, pinnables: result.pinnables ?? [], }); @@ -325,6 +328,7 @@ if (compiled.size > 0) { // them whenever the path carries a space or non-ASCII char. entry.mirrorUri = pathToFileURL(mirrorPath).href; fs.mkdirSync(path.dirname(mirrorPath), { recursive: true }); + if (!mirrorRootIsFallback) linkNestedNodeModules(workspaceRoot, mirrorRoot, fsPath); fs.writeFileSync(mirrorPath, entry.good.code); } // Per-project wrappers: one generated tsconfig at each mirrored dir whose @@ -539,7 +543,10 @@ if (compiled.size > 0) { // "set `rip.strict`" right after the user did exactly that // reads as broken unless it names whose package.json is meant. const proj = path.relative(process.cwd(), entry.cfg._configDir ?? path.dirname(fsPath)) || '.'; - if (IMPLICIT_ANY_CODES.has(d.code)) { hiddenAnnotations++; hiddenAnnotationDirs.add(proj); } + const uninstalledAt = d.code === 2307 + ? declaredButUninstalled(/Cannot find module '([^']+)'/.exec(d.message)?.[1], path.dirname(fsPath)) : null; + if (uninstalledAt) { hiddenUninstalled++; hiddenUninstalledDirs.add(path.relative(process.cwd(), uninstalledAt) || '.'); } + else if (IMPLICIT_ANY_CODES.has(d.code)) { hiddenAnnotations++; hiddenAnnotationDirs.add(proj); } else if (MISSING_TYPES_CODES.has(d.code)) { hiddenMissingTypes++; const name = /Cannot find name '([^']+)'/.exec(d.message)?.[1]; @@ -692,7 +699,7 @@ if (asJson) { if (!named.length) return ''; return ` (${named.slice(0, 3).join(', ')}${named.length > 3 ? ` and ${named.length - 3} more` : ''})`; }; - if (hiddenAnnotations > 0 || hiddenMissingTypes > 0 || hiddenScope > 0) console.log(''); + if (hiddenAnnotations > 0 || hiddenMissingTypes > 0 || hiddenScope > 0 || hiddenUninstalled > 0) console.log(''); if (hiddenScope > 0) { console.log(gray(`${hiddenScope} diagnostic${plural(hiddenScope)} hidden in unannotated code${inProjects(hiddenScopeDirs)} ` + `— annotate a declaration to check its scope, or set \`rip.strict\` in package.json`)); @@ -701,6 +708,12 @@ if (asJson) { console.log(gray(`${hiddenAnnotations} annotation diagnostic${plural(hiddenAnnotations)} hidden${inProjects(hiddenAnnotationDirs)} ` + `— set \`rip.strict\` in package.json to see where annotations are missing`)); } + if (hiddenUninstalled > 0) { + const dirs = [...hiddenUninstalledDirs].sort(); + const shown = dirs.slice(0, 3).join(', ') + (dirs.length > 3 ? ` and ${dirs.length - 3} more` : ''); + console.log(gray(`${hiddenUninstalled} uninstalled-dependency import${plural(hiddenUninstalled)} hidden ` + + `— run \`bun install\` in ${shown}`)); + } if (hiddenMissingTypes > 0) { const names = [...missingTypeNames].sort(); const shown = names.slice(0, 4).map((n) => `\`${n}\``).join(', '); diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 0c67b50a..81791b05 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -756,6 +756,64 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // A NESTED node_modules resolves through the mirror the way bun + // resolves it at runtime: the face's ancestor walk lives in the mirror + // tree, so a source-tree install (a quarantined bench dir with its own + // manifest) was invisible — bun ran the imports that tsgo called + // cannot-finds. The mirror plants a node_modules symlink at each dir + // whose source twin has one. + test('a nested source-tree node_modules resolves through the mirror', () => { + const dir = workspace({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), + 'packages/csvish/package.json': JSON.stringify({ name: '@t/csvish' }), + 'packages/csvish/bench/package.json': JSON.stringify({ name: 'bench', dependencies: { fakelib: '1.0.0' } }), + 'packages/csvish/bench/node_modules/fakelib/package.json': JSON.stringify({ name: 'fakelib', version: '1.0.0', types: 'index.d.ts', main: 'index.js' }), + 'packages/csvish/bench/node_modules/fakelib/index.d.ts': 'export declare function parse(s: string): string[];\n', + 'packages/csvish/bench/node_modules/fakelib/index.js': 'export const parse = (s) => s.split(",");\n', + 'packages/csvish/bench/compare.rip': [ + "import { parse } from 'fakelib'", // resolves through the nested install + "import { nope } from 'nolib'", // liveness: a genuinely-missing module still 2307s + "rows: string[] = parse('a,b')", + 'console.log rows, nope', + ].join('\n') + '\n', + }); + try { + const diags = JSON.parse(check(dir, ['--json']).stdout); + const cannotFinds = diags.filter((d) => d.code === 2307).map((d) => /'([^']+)'/.exec(d.message)?.[1]); + expect(cannotFinds).toEqual(['nolib']); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + + // Installation pressure is annotation pressure's cousin: a bare import + // DECLARED in the governing package.json but not installed is the + // manifest's stated intent, not a typo — gradual holds its 2307 and + // the summary names the install dir; strict publishes it (complaints + // mode, like the floors). Undeclared-and-uninstalled stays a published + // defect everywhere. Keeps `rip check` green on a fresh clone whose + // optional dirs (a quarantined bench) were never installed. + test('a declared-but-uninstalled import is held in gradual with the install remedy; strict and undeclared publish', () => { + const dir = workspace({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), + 'packages/q/package.json': JSON.stringify({ name: '@t/q' }), + 'packages/q/bench/package.json': JSON.stringify({ name: 'q-bench', dependencies: { fakelib: '1.0.0' } }), + 'packages/q/bench/compare.rip': [ + "import { parse } from 'fakelib'", // declared, not installed → held, advised + "import { nope } from 'nolib'", // undeclared → the defect publishes + 'console.log parse, nope', + ].join('\n') + '\n', + 'packages/r/package.json': JSON.stringify({ name: '@t/r', rip: { strict: true }, dependencies: { fakelib: '1.0.0' } }), + 'packages/r/app.rip': "import { parse } from 'fakelib'\nconsole.log parse\n", + }); + try { + const diags = JSON.parse(check(dir, ['--json']).stdout); + const modOf = (d) => /'([^']+)'/.exec(d.message)?.[1]; + expect(diags.filter((d) => d.file.includes('q')).map(modOf)).toEqual(['nolib']); + expect(diags.filter((d) => d.file.includes(path.join('r', 'app'))).map(modOf)).toEqual(['fakelib']); + const text = check(dir).stdout; + expect(text).toMatch(/1 uninstalled-dependency import hidden — run `bun install` in packages\/q\/bench/); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // A nested package that sets `rip.strict` becomes its own program, the // same auto boundary a globals-declaring package gets — floors are // per-PROGRAM, so without it the root program's floor kept answering From c75bac5e1b87b84d0af14c8f774b24191242dd95 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Tue, 4 Aug 2026 21:44:05 +0100 Subject: [PATCH 09/23] Emitter scaffolding never opens the gate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/vscode/src/scopes.js | 75 +++++++++-------------------------- src/check.js | 7 ++-- test/toolchain/check.test.js | 24 +++++++++-- 3 files changed, 41 insertions(+), 65 deletions(-) diff --git a/packages/vscode/src/scopes.js b/packages/vscode/src/scopes.js index e7544af8..49418661 100644 --- a/packages/vscode/src/scopes.js +++ b/packages/vscode/src/scopes.js @@ -34,10 +34,11 @@ // and cannot make this exception. // // The question is whether a declaration HAS TYPE INFORMATION, not whether the -// author typed one out. Rip supplies types nobody wrote — an event handler's -// param from HTMLElementEventMap, a schema default, a promoted parameter, a -// typed component factory — and gating those away would silence exactly the -// checking rip gives for free. +// author typed one out. The constructs rip types wholesale — schemas and +// components (COMPILER_TYPED_KINDS) — are checked without a single TYPE +// token, because gating them away would silence exactly the checking rip +// gives for free. Nothing else the emitter writes counts: lowering +// scaffolding and pin annotations are output, not intent. // // The gate is FILE-LOCAL. Type information arriving from another module — a // typed `.rip` export, an installed `@types` package, a workspace `.d.ts` — @@ -70,16 +71,13 @@ import { bareRipSpecifierTarget } from './mirror.js'; // because TypeScript varies the ADVICE, not the defect: a bare miss (2304), a // spelling suggestion (2552), and two "change your target library" forms // (2583 for ES built-ins, 2584 for DOM globals) are one diagnostic wearing -// four numbers. Enumerated rather than eyeballed — 2584 was found only by -// driving the real checker over `doc = document.title`, after 2304 and 2552 -// had both looked sufficient. +// four numbers. Enumerated rather than eyeballed. // // The same defect spelled at the MODULE BOUNDARY: importing a member the // module does not export (2305, and 2724 with a suggestion), a default // import from a module with no default (2613), and a named import that // should have been the default (2614). A consumer typo-ing an import name -// wrote a name that does not exist — found by driving a real consumer over -// a workspace package, where 2305 was the one silent member of the family. +// wrote a name that does not exist. // // The definition cycle (2502): a computed that reads itself, directly or // through others, recurses forever the first time anyone reads it. An @@ -175,45 +173,12 @@ export function checkedLinesOf(source, annotated) { return out; } -// The 0-based lines the COMPILER typed. `tsRegions` are the face's TS-only -// spans; mapped back through the face's own mappings they say which .rip -// bytes carry type information the author never wrote. -// -// A region whose mapped span crosses lines is REFUSED, because a -// construct-wide region says nothing about WHICH member carries type -// information. Every component gets the same scaffolding — `declare -// children`, a constructor, a generated interface — all mapped onto the -// whole `export X = component …` span, so a scaffolding region marking a -// construct typed would be an accident of emission, not a statement about -// the construct. -// -// What a construct-wide region cannot say, the tree can: an annotation -// inside a declaration's header types the declaration, and a construct the -// face types wholesale is typed by its KIND — both declarationHeadersOf's -// job below. The author's own multi-line annotations need neither path — -// their TYPE tokens are already counted. -export function typedRegionLinesOf(tsRegions, mappings, source, code = null) { - const lines = new Set(); - if (!tsRegions || !mappings) return lines; - const lineAt = lineIndexer(source); - for (const [s, e] of tsRegions) { - // A lone `?` is the OPPOSITE of type information. The face writes one - // for a parameter the author never annotated (JS arity — see - // `jsArityOptional`), and one for a bare `title?`, which says the - // parameter may be absent and nothing whatever about its type. Reading - // either as an annotation would switch checking on for every function - // with an unannotated parameter, which is most of them. - if (code && code.slice(s, e) === '?') continue; - let span; - try { span = generatedSpanToSource(mappings, s, e); } catch { continue; } - if (!span) continue; - const from = lineAt(span[0]); - const to = lineAt(span[1] > span[0] ? span[1] - 1 : span[0]); - if (from !== to) continue; - lines.add(from); - } - return lines; -} +// The face's TS-only regions are never type information: every region is +// emitter output — lowering scaffolding (`: void` on a bang-def, arity +// `?`s) and pin annotations — and reading one as an annotation opens +// whole unannotated files and re-admits held inference through pins. +// Author annotations are counted from their TYPE tokens; wholesale-typed +// constructs by KIND (COMPILER_TYPED_KINDS). // The FUNCTION SCOPES a source declares, as source spans. Read off the parse // tree rather than re-derived from indentation, because indentation answers a @@ -357,14 +322,11 @@ function scopeAt(regions, off) { // guesswork about which spelling declares what. // // The names come off the lines already known to carry type information -// rather than off the token stream, because rip spells annotations several -// ways and only some of them survive as a TYPE token: a bare forward -// declaration (`y: number`, no initializer) lexes as an OBJECT LITERAL and -// carries no TYPE token at all — it is the face's `let y!: number` that makes -// it typed, which the tsRegions pass sees and a token scan never would. -// Taking every identifier on a typed line covers all spellings at once, and -// pulls in the useful neighbours: `def filterBy(query: string)` types both -// the parameter and the function, so call sites are checked too. +// rather than off the token stream: taking every identifier on a typed +// line covers all annotation spellings at once (a bare forward's +// `y: number` lexes as a TYPE token like the rest), and pulls in +// the useful neighbours: `def filterBy(query: string)` types both the +// parameter and the function, so call sites are checked too. // // Residual: an unannotated INNER binding that shadows a typed outer one // still reads as typed, because this pass places a binding by where its @@ -572,7 +534,6 @@ export function typedExportsOf(tokens, source, face) { // declaration's header says about the declaration it heads. function typedLinesOf(tokens, source, face) { const typed = annotatedLinesOf(tokens, source); - for (const line of typedRegionLinesOf(face?.tsRegions, face?.mappings, source, face?.code)) typed.add(line); const lineAt = lineIndexer(source); // OUTWARD: an annotation in a declaration's header is an annotation on the // declaration, so it lands on the line that NAMES it. Only the author's own diff --git a/src/check.js b/src/check.js index 0cca349e..de99f86a 100644 --- a/src/check.js +++ b/src/check.js @@ -368,10 +368,9 @@ if (compiled.size > 0) { // The AUTO BOUNDARY: a package becomes its own program when it DECLARES // globals (`globalThis.NAME ??=` — the vocabulary stays package-scoped, // reaching importers the way the runtime does) or when it sets - // `rip.strict` (floors and null posture are per-PROGRAM, so a strict - // package inside the root program kept getting the gradual floor's - // `any`s — driven by `bun:sqlite` staying unsquiggled in a strict - // package). A package already inside a tsconfig-wrapped project needs + // `rip.strict` (floors and null posture are per-PROGRAM: a strict + // package inside the root program would get the gradual floor's + // `any`s). A package already inside a tsconfig-wrapped project needs // nothing more; the workspace root has no narrower scope to give. const autoBoundaryRels = new Set(); if (!mirrorRootIsFallback) { diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 03dbaf8b..194d89bc 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -772,6 +772,23 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // Emitter scaffolding (a bang-def's `: void`, arity `?`s, pin + // annotations) never opens the gate: an unannotated file is a silent + // file, whatever the face emits for its lowerings. + test('an unannotated file with bang-defs stays silent — scaffolding never opens the gate', () => { + const dir = workspace({ + 'tool.rip': [ + 'write! = (s) -> s', + 'n = 42', + 'bad = n.toUpperCase()', // inference-only misuse: held + 'console.log write, bad', + ].join('\n') + '\n', + }); + try { + expect(JSON.parse(check(dir, ['--json']).stdout)).toEqual([]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // A NESTED node_modules resolves through the mirror the way bun // resolves it at runtime: the face's ancestor walk lives in the mirror // tree, so a source-tree install (a quarantined bench dir with its own @@ -831,10 +848,9 @@ describeExtended('rip check: type diagnostics over the real server', () => { }, 90_000); // A nested package that sets `rip.strict` becomes its own program, the - // same auto boundary a globals-declaring package gets — floors are - // per-PROGRAM, so without it the root program's floor kept answering - // `any` for a package that asked for complaints. Driven: Philip flipped - // packages/ai strict and `bun:sqlite` stayed unsquiggled. + // same auto boundary a globals-declaring package gets: floors are + // per-PROGRAM, and without the boundary the root program's floor keeps + // answering `any` for a package that asked for complaints. test('a nested rip.strict package refuses the floors: its own program, its own posture', () => { const dir = workspace({ 'package.json': JSON.stringify({ workspaces: ['packages/*'] }), From cc890e753b3ef9acee9098258018d4f885712085 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 08:03:12 +0100 Subject: [PATCH 10/23] TypeScript 7 defaults strict on; gradual states its loose base MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/TYPES.md | 6 ++++-- packages/vscode/src/mirror.js | 14 ++++++++------ test/toolchain/check.test.js | 28 ++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 8 deletions(-) diff --git a/docs/TYPES.md b/docs/TYPES.md index 889746ac..aa4b24bd 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -246,8 +246,10 @@ 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 also -supplies `strictNullChecks: false`, yielding to any strictness the -project's own tsconfig chain sets. +supplies `strict: false` (TypeScript 7 defaults it on), restoring the +loose base — null checks off, catch bindings `any` — while +`noImplicitAny` stays on; any strictness the project's own tsconfig +chain sets is yielded to whole. ## Project configuration diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index dea0cdbb..9bd3045d 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -88,17 +88,19 @@ export function chainSetsTypes(configPath, chain, onUnresolved, visited = new Se // floor joins the program whole-or-not), walked to the first // package.json locally: mirror.js stays layout-agnostic and cannot // import the compiler's readProjectConfig. -// Null posture by mode: gradual supplies `strictNullChecks: false` — the -// one lever that changes TYPES, not just which diagnostics publish -// (`find()` hovers `T` here, `T | undefined` under `rip.strict`). -// `noImplicitAny` stays ON everywhere: turning it off disables -// evolving-array inference and strands unannotated `[]` on `never[]`. +// Base posture by mode: TypeScript 7 defaults `strict` ON, so gradual's +// loose base must be stated: `strict: false` turns the family off whole +// (null checks change TYPES, not just which diagnostics publish — +// `find()` hovers `T` here, `T | undefined` under `rip.strict` — and +// catch bindings are `any`, not `unknown`). The generated config keeps +// `noImplicitAny` ON everywhere: turning it off disables evolving-array +// inference and strands unannotated `[]` on `never[]`. const nullPosture = (dir, configPath) => { if (dir && workspaceIsStrict(dir)) return {}; // The user's OWN tsconfig wins: rip's default posture never overrules // a strictness the author wrote down. if (configPath && chainSetsStrictness(configPath)) return {}; - return { strictNullChecks: false }; + return { strict: false }; }; // Does the config chain SET strictness (`strict` or `strictNullChecks`) diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 194d89bc..79d8112c 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -625,6 +625,34 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // TypeScript 7 defaults `strict` ON, which drags catch bindings to + // `unknown` through `useUnknownInCatchVariables` — narrowing ceremony + // on every member read of an unannotated `catch err`. Gradual restores + // the loose base (`strict: false` — nullPosture, mirror.js); a chain + // that states its own strictness is yielded to whole and keeps the + // unknown. + test('gradual catch bindings are not unknown; an author strictness chain keeps them', () => { + const catcher = [ + 'export label: (job: () => void) => void = (job) ->', + ' try', + ' job()', + ' catch err', + ' console.log(err.message)', + ].join('\n') + '\n'; + const gradual = workspace({ 'catcher.rip': catcher }); + const audit = JSON.parse(fs.readFileSync(TSCONFIG, 'utf8')); + const strict = workspace({ 'catcher.rip': catcher }); + fs.writeFileSync(path.join(strict, 'tsconfig.json'), + JSON.stringify({ ...audit, compilerOptions: { ...audit.compilerOptions, strictNullChecks: true } })); + try { + expect(JSON.parse(check(gradual, ['--json']).stdout)).toEqual([]); + expect(JSON.parse(check(strict, ['--json']).stdout).map((d) => d.code)).toEqual([18046]); + } finally { + fs.rmSync(gradual, { recursive: true, force: true }); + fs.rmSync(strict, { recursive: true, force: true }); + } + }, 90_000); + // The mixin face promises exactly what the runtime serves, in both // directions. The projection algebra works on a mixin — __schemaDerive // refuses only :union/:enum, and a mixin derivation is an instantiable From d4360074cb38c29321811eda91ccb77c69806960 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 08:03:22 +0100 Subject: [PATCH 11/23] The burn-down resurrected an annotation already retired MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/server/manager.rip | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/server/manager.rip b/packages/server/manager.rip index fd180b5c..e26d726b 100644 --- a/packages/server/manager.rip +++ b/packages/server/manager.rip @@ -21,7 +21,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] From 98220cd86895ac4c33db2b5bd92f02a1bd9d99b9 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 08:25:17 +0100 Subject: [PATCH 12/23] A trailing generic close ends a cast's line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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` 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. --- src/lexer.js | 8 ++++++++ test/battery/cast.rip | 36 ++++++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+) diff --git a/src/lexer.js b/src/lexer.js index f5c3dc2b..8412f2b4 100644 --- a/src/lexer.js +++ b/src/lexer.js @@ -1055,6 +1055,14 @@ const syncTypeGenericMemo = (tokens, memo) => { // angle level (`interface P` / `… extends Q` — the // trailing close must end the line so the body INDENT forms). memo.answers.set(memo.level, true); + } else if (t.kind === 'IDENTIFIER' && t.value === 'as' && + tokens[j - 1] && tokens[j - 1].kind !== '.' && tokens[j - 1].kind !== '?.' && + CAST_LHS_ENDERS.has(tokens[j - 1].kind)) { + // A postfix-cast head (`expr as Map`): the trailing close + // ends the line the same way a return-type or declaration head + // does — otherwise the next line continues this one and its + // statement silently nests into the wrong block. + memo.answers.set(memo.level, true); } } memo.upTo = tokens.length; diff --git a/test/battery/cast.rip b/test/battery/cast.rip index b25fdce6..f4c11d43 100644 --- a/test/battery/cast.rip +++ b/test/battery/cast.rip @@ -122,3 +122,39 @@ code "a clause keyword ends the cast's type run", ''' return {live: false}; } })(); ''' + +# ============================================================================== +# A trailing generic close ends the cast's line +# ============================================================================== + +# The `>` closing `as D` is a generic close, not a comparison waiting on a +# right operand: the next line starts its own statement in the OUTER block — +# continuation here would nest it into the wrong block silently. +code "block-final generic cast keeps the dedent", """ + def f(x) + if x + return g(x) as D + h(x) + """, ''' + function f(x) { + if (x) { + return g(x); + } + return h(x); + } + ''' + +code "block-final generic cast before a sibling if", """ + def f(x) + if x + return g(x) as D + if x + h(x) + """, ''' + function f(x) { + if (x) { + return g(x); + } + return (x ? h(x) : undefined); + } + ''' From 3e39d759a73065bc0c360387d45b9b480b86e761 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 08:25:35 +0100 Subject: [PATCH 13/23] App types tell the truth the runtime already spoke MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/app/apply.rip | 4 ++-- packages/app/components.rip | 2 +- packages/app/source.rip | 8 ++++---- packages/app/stash.rip | 6 +++--- packages/app/test/apply.rip | 3 +++ packages/app/test/workspace.rip | 11 +++++++++++ packages/app/workspace.rip | 6 +++--- 7 files changed, 27 insertions(+), 13 deletions(-) diff --git a/packages/app/apply.rip b/packages/app/apply.rip index 64a7e33e..96154156 100644 --- a/packages/app/apply.rip +++ b/packages/app/apply.rip @@ -19,8 +19,8 @@ export type Apply = export type ApplyOpts = # Renderer seam still speaks narrow/escape/noop; absorb maps to update/ignore. - renderer: { remountDirty: (paths: string[]) => Promise<'narrow' | 'noop' | 'escape'> } - escape: (paths: string[]) => Promise + renderer: { remountDirty: (paths: string[]) => 'narrow' | 'noop' | 'escape' | Promise<'narrow' | 'noop' | 'escape'> } + escape: (paths: string[]) => void report?: (...args: any[]) => void export def createApply(opts: ApplyOpts): Apply diff --git a/packages/app/components.rip b/packages/app/components.rip index 9b83dabd..47fed57a 100644 --- a/packages/app/components.rip +++ b/packages/app/components.rip @@ -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) diff --git a/packages/app/source.rip b/packages/app/source.rip index e8f01445..428f2eca 100644 --- a/packages/app/source.rip +++ b/packages/app/source.rip @@ -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 = -> @@ -260,11 +260,11 @@ export def source(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 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 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 else makeSourceCell(opts.fetch, staleTime) as SourceDeclaration diff --git a/packages/app/stash.rip b/packages/app/stash.rip index ef33e89f..73aaf0fb 100644 --- a/packages/app/stash.rip +++ b/packages/app/stash.rip @@ -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). @@ -449,7 +449,7 @@ makeProxy = (target) -> PROXIES.set target, proxy proxy -export def createStash>(data: D = {}): Stash +export def createStash>(data: D = {} as D): Stash 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 @@ -457,4 +457,4 @@ export def createStash>(data: D = {}): Stash makeProxy data export def unwrapStash>(stash: Stash): D - if stash? and stash[RAW] then stash[RAW] else stash + (stash as any)?[RAW] or stash diff --git a/packages/app/test/apply.rip b/packages/app/test/apply.rip index 5099ea05..bea7b274 100644 --- a/packages/app/test/apply.rip +++ b/packages/app/test/apply.rip @@ -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 empty sets and non-App file types", -> diff --git a/packages/app/test/workspace.rip b/packages/app/test/workspace.rip index 7d9d6bba..6264a57f 100644 --- a/packages/app/test/workspace.rip +++ b/packages/app/test/workspace.rip @@ -105,6 +105,7 @@ test "rejects non-string paths and content", -> throws (-> ws.write('', 'source')), 'non-empty string' throws (-> ws.write(null, 'source')), 'non-empty string' throws (-> ws.write('app.rip', null)), 'must be a string' + # @ts-expect-error throws (-> ws.read(42)), 'non-empty string' test "exists and size", -> @@ -128,6 +129,7 @@ test "list is one level; listAll is recursive", -> eq ws.listAll('routes'), ['routes/hi.rip', 'routes/admin/users.rip'] eq ws.listAll().length, 3 throws (-> ws.list('routes//admin')), 'invalid component directory' + # @ts-expect-error throws (-> ws.list(42)), 'directory must be a string' test "load is bulk write and fires no events", -> @@ -139,6 +141,7 @@ test "load is bulk write and fires no events", -> eq ws.read('fresh.rip'), 'born' eq events, [] throws (-> ws.load(null)), 'source object' + # @ts-expect-error throws (-> ws.load(['app.rip'])), 'source object' test "load clears compiled for rewritten paths", -> @@ -198,6 +201,7 @@ test "setCompiled validates its module and requires a passport", -> ws = createWorkspace() ws.write 'app.rip', 'src' throws (-> ws.setCompiled('app.rip', null)), 'must be an object' + # @ts-expect-error throws (-> ws.setCompiled('app.rip', [1])), 'must be an object' throws (-> ws.setCompiled('ghost.rip', {})), "unknown component path 'ghost.rip'" eq ws.getCompiled('ghost.rip'), undefined @@ -245,16 +249,21 @@ test "populate is birth: a second populate rejects", -> test "populate rejects malformed input", -> ws = createWorkspace() throws (-> ws.populate(null)), 'expects an array' + # @ts-expect-error throws (-> ws.populate({})), 'expects an array' throws (-> ws.populate([null])), 'expects passport records' throws (-> ws.populate([{ id: '', path: 'a.rip', hash: srcHash('s'), source: 's' }])), 'id must be a non-empty string' throws (-> ws.populate([{ id: 'c1', path: 'bad', hash: srcHash('s'), source: 's' }])), 'invalid component path' + # @ts-expect-error throws (-> ws.populate([{ id: 'c1', path: 'a.rip', source: 's' }])), 'six Base64URL-folded' + # @ts-expect-error throws (-> ws.populate([{ id: 'c1', path: 'a.rip', hash: 1, source: 's' }])), 'six Base64URL-folded' throws (-> ws.populate([{ id: 'c1', path: 'a.rip', hash: 'abc-12', source: 's' }])), 'six Base64URL-folded' throws (-> ws.populate([{ id: 'c1', path: 'a.rip', hash: 'short', source: 's' }])), 'six Base64URL-folded' + # @ts-expect-error throws (-> ws.populate([{ id: 'c1', path: 'a.rip', etag: 'Q8-tolerated?', source: 's' }])), 'six Base64URL-folded' throws (-> ws.populate([{ id: 'c1', path: 'a.rip', hash: srcHash('s'), source: null }])), 'source must be a string' + # @ts-expect-error throws (-> ws.populate([{ id: 'c1', path: 'a.rip', hash: srcHash('s'), source: 's', compiled: [] }])), 'must be an object' test "populate rejects duplicate ids and duplicate paths", -> @@ -336,6 +345,7 @@ test "an unknown id without a full record rejects", -> test "set validates its passport", -> { ws } = seeded() throws (-> ws.set(null)), 'set expects a passport record' + # @ts-expect-error throws (-> ws.set([])), 'set expects a passport record' throws (-> ws.set({ hash: 'dddddd' })), 'id must be a non-empty string' throws (-> ws.set({ id: 'c1' })), 'six Base64URL-folded' @@ -475,6 +485,7 @@ test "passport of an unknown key is undefined; non-strings reject", -> eq ws.passport('ghost.rip'), undefined eq ws.passport('c9'), undefined throws (-> ws.passport('')), 'passport lookup expects' + # @ts-expect-error throws (-> ws.passport(42)), 'passport lookup expects' test "ids and paths enumerate the bag", -> diff --git a/packages/app/workspace.rip b/packages/app/workspace.rip index 8db203ee..41e95732 100644 --- a/packages/app/workspace.rip +++ b/packages/app/workspace.rip @@ -18,8 +18,8 @@ export type Passport = id: string path: string hash: string - source: string - compiled: Record | undefined + source?: string + compiled?: Record export type Workspace = read: (path: string) => string | undefined @@ -91,7 +91,7 @@ hashOf = (text) -> rash TextEncoder.new().encode(text) export def createWorkspace(): Workspace passports = Map.new() pathToId = Map.new() - watchers = Set.new() + watchers: Set<(event: WorkspaceEvent, path: string) => void> = Set.new() locked = false guard = (operation) -> From 7d426e249af552f425b6d95e145f6697ca14231e Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 08:40:11 +0100 Subject: [PATCH 14/23] The corpus declares itself a no-check zone MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- test/corpus/package.json | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 test/corpus/package.json diff --git a/test/corpus/package.json b/test/corpus/package.json new file mode 100644 index 00000000..4552bd45 --- /dev/null +++ b/test/corpus/package.json @@ -0,0 +1,7 @@ +{ + "rip": { + "noCheck": [ + "**" + ] + } +} From 8df6dbb42c5c209a499e2e3e2a9a3aa4014b99da Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 10:20:21 +0100 Subject: [PATCH 15/23] The audit gains gradual's suppression matrix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/TYPES.md | 32 +++-- packages/vscode/src/mirror.js | 27 +++-- packages/vscode/src/server.js | 32 +++-- src/check.js | 21 ++-- test/audit/contract.js | 15 +++ test/audit/corpus/gradual/held.rip | 53 +++++++++ test/audit/corpus/gradual/package.json | 8 ++ test/audit/corpus/gradual/published.rip | 26 ++++ test/audit/gradual-pins.json | 118 ++++++++++++++++++ test/audit/runner.js | 152 +++++++++++++++++++++++- test/toolchain/audit-contract.test.js | 3 + test/toolchain/check.test.js | 34 +++++- 12 files changed, 481 insertions(+), 40 deletions(-) create mode 100644 test/audit/corpus/gradual/held.rip create mode 100644 test/audit/corpus/gradual/package.json create mode 100644 test/audit/corpus/gradual/published.rip create mode 100644 test/audit/gradual-pins.json diff --git a/docs/TYPES.md b/docs/TYPES.md index aa4b24bd..8703c24c 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -245,11 +245,15 @@ 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 also -supplies `strict: false` (TypeScript 7 defaults it on), restoring the -loose base — null checks off, catch bindings `any` — while -`noImplicitAny` stays on; any strictness the project's own tsconfig -chain sets is yielded to whole. +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 @@ -270,11 +274,14 @@ 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 that sets `rip.strict` 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 -strictness governs them — an unresolvable `bun:sqlite` is a complaint -there, not a floored `any`. +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, @@ -292,6 +299,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. diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index 9bd3045d..8ab7a123 100644 --- a/packages/vscode/src/mirror.js +++ b/packages/vscode/src/mirror.js @@ -88,19 +88,30 @@ export function chainSetsTypes(configPath, chain, onUnresolved, visited = new Se // floor joins the program whole-or-not), walked to the first // package.json locally: mirror.js stays layout-agnostic and cannot // import the compiler's readProjectConfig. -// Base posture by mode: TypeScript 7 defaults `strict` ON, so gradual's -// loose base must be stated: `strict: false` turns the family off whole -// (null checks change TYPES, not just which diagnostics publish — -// `find()` hovers `T` here, `T | undefined` under `rip.strict` — and -// catch bindings are `any`, not `unknown`). The generated config keeps -// `noImplicitAny` ON everywhere: turning it off disables evolving-array -// inference and strands unannotated `[]` on `never[]`. +// Base posture by mode: TypeScript 7 defaults `strict` ON, and gradual +// RIDES that default, subtracting only what it deliberately loosens. The +// direction is the constraint: a strict-family member this list does not +// name arrives ON, so any noise it brings LEAKS where the canaries look +// (the gradual pair's held fixture, the corpus count) — never OFF, where +// it would degrade inference silently (`strictBindCallApply` off, for +// one, collapses every type the face routes through a `.call` to +// `any`). The three loosenings: +// `strictNullChecks` — the one lever that changes TYPES, not just +// which diagnostics publish (`find()` hovers `T` here, +// `T | undefined` under `rip.strict`); +// `useUnknownInCatchVariables` — an unannotated `catch err` answers +// `any`, never narrowing ceremony; +// `noImplicitThis` — `this` in an unannotated object-literal method +// is `any`, not the literal's inferred type. +// `noImplicitAny` stays ON everywhere (set in the generated config): +// turning it off disables evolving-array inference and strands +// unannotated `[]` on `never[]`. const nullPosture = (dir, configPath) => { if (dir && workspaceIsStrict(dir)) return {}; // The user's OWN tsconfig wins: rip's default posture never overrules // a strictness the author wrote down. if (configPath && chainSetsStrictness(configPath)) return {}; - return { strict: false }; + return { strictNullChecks: false, useUnknownInCatchVariables: false, noImplicitThis: false }; }; // Does the config chain SET strictness (`strict` or `strictNullChecks`) diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index f834f7a4..336d926b 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -365,8 +365,6 @@ function writeProjectWrapper(rel, sourceTsconfig) { // root config, whose exclusions grew — for the caller to forward to tsgo. function ensureAutoBoundary(fsPath) { if (mirrorRootIsFallback || !workspaceRoot || !mirrorRootReady) return []; - const owner = nearestTsconfig(path.dirname(fsPath), workspaceRoot); - if (owner !== null && path.dirname(owner) !== workspaceRoot) return []; let dir = path.dirname(fsPath); let pkgDir = null; for (;;) { @@ -375,6 +373,15 @@ function ensureAutoBoundary(fsPath) { dir = path.dirname(dir); } if (pkgDir === null || pkgDir === workspaceRoot) return []; + // A tsconfig AT or BELOW the package already partitions it (that wrapper + // reads its posture from the package's own directory). One ABOVE it does + // not — the wrapper's posture is the wrapper's — so a flipped package + // below a wrapped project still earns its boundary. + const owner = nearestTsconfig(path.dirname(fsPath), workspaceRoot); + if (owner !== null) { + const ownerDir = path.dirname(owner); + if (ownerDir === pkgDir || ownerDir.startsWith(pkgDir + path.sep)) return []; + } const rel = path.relative(workspaceRoot, pkgDir); if (rel === '' || path.isAbsolute(rel) || rel === '..' || rel.startsWith('..' + path.sep)) return []; if (wrapperDirs.has(rel)) return []; @@ -835,9 +842,13 @@ function mirrorFromDisk(fsPath, source) { const mirrorPath = mirrorPathOf('file://' + fsPath); warnOnMirrorCollision(mirrorPath, fsPath); writeMirror(mirrorPath, result.code); - // A dependency that DECLARES globals gets its boundary the moment its - // face materializes — the closure pass may be the first to see it. - if (result.globalDecls?.length) { + // A dependency that DECLARES globals or lives in a mode-flipped package + // gets its boundary the moment its face materializes — the closure pass + // may be the first to see it. + const depCfg = readProjectConfig ? readProjectConfig(path.dirname(fsPath)) : null; + const depFlipped = depCfg?._configDir && depCfg._configDir !== workspaceRoot + && (depCfg.strict === true) !== (readProjectConfig(path.dirname(depCfg._configDir)).strict === true); + if (result.globalDecls?.length || depFlipped) { const bw = ensureAutoBoundary(fsPath); if (bw.length && tsgo) { tsgo.client.notify('workspace/didChangeWatchedFiles', { @@ -1762,9 +1773,14 @@ async function refresh(document) { scheduleManifestSave(); } const wrapperFiles = ensureProjectWrapper(fsPath); - // Globals-declaring or nested-strict: either way the package needs - // its own program (floors and null posture are per-program). - if (result.globalDecls?.length || (state.strict === true && state.configDir && state.configDir !== workspaceRoot)) { + // Globals-declaring or mode-flipped against the parent package: + // either way the package needs its own program (floors and null + // posture are per-program, and a flip cuts both ways — a strict + // package inside a gradual program would ride the floor's `any`s, a + // gradual one inside a strict program would ride strict nulls). + const flipped = state.configDir && state.configDir !== workspaceRoot + && (state.strict === true) !== (readProjectConfig(path.dirname(state.configDir)).strict === true); + if (result.globalDecls?.length || flipped) { wrapperFiles.push(...ensureAutoBoundary(fsPath)); } if (wrapperFiles.length && tsgo) { diff --git a/src/check.js b/src/check.js index de99f86a..1585c81e 100644 --- a/src/check.js +++ b/src/check.js @@ -367,17 +367,20 @@ if (compiled.size > 0) { } // The AUTO BOUNDARY: a package becomes its own program when it DECLARES // globals (`globalThis.NAME ??=` — the vocabulary stays package-scoped, - // reaching importers the way the runtime does) or when it sets - // `rip.strict` (floors and null posture are per-PROGRAM: a strict - // package inside the root program would get the gradual floor's - // `any`s). A package already inside a tsconfig-wrapped project needs + // reaching importers the way the runtime does) or when its MODE FLIPS + // against its parent package's (floors and null posture are per-PROGRAM: + // a strict package inside a gradual program would get the floor's `any`s, + // and a gradual package inside a strict program would get strict nulls + // and refused floors). A package whose own tsconfig wraps it needs // nothing more; the workspace root has no narrower scope to give. const autoBoundaryRels = new Set(); if (!mirrorRootIsFallback) { for (const [fsPath, entry] of compiled) { let pkgDir = null; - if (entry.cfg.strict === true && entry.cfg._configDir && entry.cfg._configDir !== workspaceRoot) { - pkgDir = entry.cfg._configDir; + const cfgDir = entry.cfg._configDir; + if (cfgDir && cfgDir !== workspaceRoot + && (entry.cfg.strict === true) !== (readProjectConfig(path.dirname(cfgDir)).strict === true)) { + pkgDir = cfgDir; } else if (entry.result.globalDecls?.length) { for (let dir = path.dirname(fsPath); ; dir = path.dirname(dir)) { if (fs.existsSync(path.join(dir, 'package.json'))) { pkgDir = dir; break; } @@ -386,7 +389,11 @@ if (compiled.size > 0) { } if (pkgDir === null || pkgDir === workspaceRoot || !pkgDir.startsWith(workspaceRoot + path.sep)) continue; const rel = path.relative(workspaceRoot, pkgDir); - if (![...wrapperRels].some((w) => rel === w || rel.startsWith(w + path.sep))) autoBoundaryRels.add(rel); + // A tsconfig AT the package dir already partitions it (that wrapper + // reads its posture from the package). One ABOVE it does not — the + // wrapper's posture is the wrapper's, so a flipped package below a + // wrapped project still needs its own boundary. + if (![...wrapperRels].some((w) => rel === w)) autoBoundaryRels.add(rel); } } const mirror = generatedMirror({ diff --git a/test/audit/contract.js b/test/audit/contract.js index 0bb85aa5..e4e70450 100644 --- a/test/audit/contract.js +++ b/test/audit/contract.js @@ -197,6 +197,21 @@ export const CONTRACT = [ property: 'a wrong-element diagnostic lands on the offending element, not the whole list', red: (s) => s.el.problems.some((p) => p.kind === 'position' && /^11-types\./.test(p.file)), }, + { + // The gradual pair's held side is TWO invariants in one predicate on + // purpose: "publishes nothing in gradual" (leak) and "still publishes + // under strict" (vacuous) are one property — a hold is only a hold + // while the diagnostic it holds exists. A toolchain default flip + // reds this row the day it lands. + name: 'gradual.held', lane: 'errors', + property: 'every family gradual holds publishes nothing in gradual and still publishes under strict', + red: (s) => s.gl.problems.some((p) => p.file === 'held.rip' || p.kind === 'vacuous' || p.kind === 'leak'), + }, + { + name: 'gradual.published', lane: 'errors', + property: 'gradual publishes exactly the pinned reach-and-defect set, and rip check answers like the editor', + red: (s) => s.gl.problems.some((p) => p.file === 'published.rip' || p.kind === 'parity'), + }, { name: 'diagnostics.positions.arity', lane: 'errors', property: "a paren-injected call's arity error lands on the excess argument", diff --git a/test/audit/corpus/gradual/held.rip b/test/audit/corpus/gradual/held.rip new file mode 100644 index 00000000..51f1b750 --- /dev/null +++ b/test/audit/corpus/gradual/held.rip @@ -0,0 +1,53 @@ +# Every family gradual HOLDS, one section each. This file publishes NOTHING +# in gradual mode — no directives, no exemptions — so it is a live canary: +# `rip check` over the tree goes red the day any hold regresses. Every +# section still publishes under strict (gradual-pins.json pins that side), +# so a family that goes quiet in BOTH modes is a red row, never a pass. + +# hold, declared-but-uninstalled: declared by this directory's package.json, +# never installed (strict: TS2307) +import { pad } from '@rip-audit/absent' + +# floor, host builtins: bun:* rides the any floor (strict: TS2307) +import { Database } from 'bun:sqlite' + +console.log(pad, Database) + +# code suppression, implicit-any: an unannotated parameter (strict: TS7006) +def double(value) + value * 2 +console.log(double(21)) + +# code suppression, missing-types: a known-typings global with no +# declarations installed (strict: TS2580) +console.log(Buffer.from('abc')) + +# gate, inference-only: a misuse no annotation reaches (strict: TS2339) +answer = 42 +console.log(answer.length) + +# gate, scaffolding: a bang-def's face `: void` is emitter output, not an +# annotation — it opens nothing (strict: TS7006 on the parameter; the body +# types through `any` even there) +def ping!(payload) + console.log(payload.missingMember) + +# posture, null family: null into an annotated string — the line IS reached +# in gradual; strictNullChecks holds it (strict: TS2322) +greeting: string = null +console.log(greeting) + +# posture, catch bindings: a member read on an unannotated catch inside a +# reached scope; useUnknownInCatchVariables holds it (strict: TS18046) +export label: (job: () => void) => void = (job) -> + try + job() + catch err + console.log(err.message) + +# posture, object-literal this: a method reading `@` members; the loose base +# types `this` as `any` (strict: TS2339 on the member and TS7023 on the +# circular return) +counter = + bump: -> (@count or 0) + 1 +console.log(counter.bump()) diff --git a/test/audit/corpus/gradual/package.json b/test/audit/corpus/gradual/package.json new file mode 100644 index 00000000..fa94ebc1 --- /dev/null +++ b/test/audit/corpus/gradual/package.json @@ -0,0 +1,8 @@ +{ + "rip": { + "strict": false + }, + "dependencies": { + "@rip-audit/absent": "1.0.0" + } +} diff --git a/test/audit/corpus/gradual/published.rip b/test/audit/corpus/gradual/published.rip new file mode 100644 index 00000000..594ac331 --- /dev/null +++ b/test/audit/corpus/gradual/published.rip @@ -0,0 +1,26 @@ +# published.rip — what gradual PUBLISHES, each diagnostic pinned in +# gradual-pins.json (the lane strips the pragma before measuring). +# @ts-nocheck + +# always-reported, unresolvable module: nothing declares or resolves it (TS2307) +import { missing } from './does-not-exist.rip' + +# always-reported, free name (TS2304) +console.log(mysteryValue) + +# always-reported, misspelling with a near name (TS2552) +oops = fetchh('https://example.com') +console.log(oops) + +# reach, annotation: a wrong-typed annotated binding (TS2322) +port: number = 'eighty' + +# reach, flow: the annotated type moves along assignment (TS2339) +alias = port +console.log(alias.length) + +# reach, compiler-typed: a model scope filtering an undeclared column (TS2353) +Coupon = schema :model + code! string + @scope :active, -> @where(active: true) +console.log(Coupon) diff --git a/test/audit/gradual-pins.json b/test/audit/gradual-pins.json new file mode 100644 index 00000000..4f625524 --- /dev/null +++ b/test/audit/gradual-pins.json @@ -0,0 +1,118 @@ +{ + "held.rip": [ + { + "line": 9, + "character": 20, + "code": 2307, + "token": "'@rip-audit/absent'", + "why": "declared-but-uninstalled: gradual holds it with the install remedy; strict publishes the unresolved module" + }, + { + "line": 12, + "character": 25, + "code": 2307, + "token": "'bun:sqlite'", + "why": "the host floor: gradual supplies `declare module \"bun:*\"`; strict refuses every floor" + }, + { + "line": 17, + "character": 11, + "code": 7006, + "token": "value", + "why": "the implicit-any family: code-suppressed in gradual, published in strict" + }, + { + "line": 23, + "character": 12, + "code": 2580, + "token": "Buffer", + "why": "the missing-types family: gradual counts it as an advisory, strict publishes the missing declarations" + }, + { + "line": 27, + "character": 19, + "code": 2339, + "token": "length", + "why": "inference-only reach: no annotation reaches the line, so the gate holds it; strict checks everything" + }, + { + "line": 32, + "character": 10, + "code": 7006, + "token": "payload", + "why": "scaffolding: the bang-def's face `: void` is emitter output and opens nothing in gradual; strict still asks for the parameter's type" + }, + { + "line": 37, + "character": 0, + "code": 2322, + "token": "greeting", + "why": "the null family: the line is reached in gradual (it is annotated) and held by posture, not by the gate" + }, + { + "line": 46, + "character": 16, + "code": 18046, + "token": "err", + "why": "catch bindings: reached scope, held by posture (useUnknownInCatchVariables) — the family TypeScript 7 turned on by default" + }, + { + "line": 52, + "character": 2, + "code": 7023, + "token": "bump", + "why": "object-literal `this` under strict makes the member read circular; gradual's loose base types `this` as `any`" + }, + { + "line": 52, + "character": 13, + "code": 2339, + "token": "count", + "why": "the same method's member read against the literal type strict gives `this`" + } + ], + "published.rip": [ + { + "line": 6, + "character": 24, + "code": 2307, + "token": "'./does-not-exist.rip'", + "why": "always-reported: an unresolvable module no one declared is a defect in every mode" + }, + { + "line": 9, + "character": 12, + "code": 2304, + "token": "mysteryValue", + "why": "always-reported: a free name is a defect in every mode" + }, + { + "line": 12, + "character": 7, + "code": 2552, + "token": "fetchh", + "why": "always-reported: the did-you-mean misspelling family publishes in gradual" + }, + { + "line": 16, + "character": 0, + "code": 2322, + "token": "port", + "why": "reach by annotation: the author wrote a type, so the violation publishes" + }, + { + "line": 20, + "character": 18, + "code": 2339, + "token": "length", + "why": "reach by flow: the annotated type moves along assignment to the alias" + }, + { + "line": 23, + "character": 16, + "code": 2353, + "token": ":model", + "why": "reach by construction: models are compiler-typed, and the scope filters a column the schema never declared — anchored on the schema head" + } + ] +} diff --git a/test/audit/runner.js b/test/audit/runner.js index 8fd57520..c1d28527 100644 --- a/test/audit/runner.js +++ b/test/audit/runner.js @@ -309,6 +309,7 @@ const SURVIVAL_EXCUSED = (() => { // hover-pins.json: reviewed measurements, gated on RULINGS.md, asserting the // interim where a ledger row holds the target. const ERROR_PINS = path.join(HERE, 'error-pins.json'); +const GRADUAL_PINS = path.join(HERE, 'gradual-pins.json'); // ONE terminal width for every wrap in this file. Four sites used to read it // independently, with three different fallbacks (120, 120, 200, 200) and only // two honouring COLUMNS — so one run could wrap its totals at 200 and its kind @@ -1007,7 +1008,7 @@ let corpusConfigCache = null; const corpusConfig = () => (corpusConfigCache ??= readProjectConfig(CORPUS)); class EditorServer { - constructor() { + constructor({ packageJson = null } = {}) { this.diags = new Map(); this.dir = mkTemp(path.join(os.tmpdir(), 'rip-audit-')); this.open = null; @@ -1021,8 +1022,14 @@ class EditorServer { // // Only the `rip` block travels. A tsconfig would change what the // faces resolve against, which every pin was measured under. + // + // `packageJson` overrides the workspace config whole — the gradual + // pair measures under corpus/gradual's own config (mode AND declared + // dependencies), and such a workspace stays bare: no fixture copies, + // no mode assertion against the corpus. + this.corpusMode = packageJson === null; fs.writeFileSync(path.join(this.dir, 'package.json'), - JSON.stringify({ rip: { strict: corpusConfig().strict } }, null, 2)); + JSON.stringify(packageJson ?? { rip: { strict: corpusConfig().strict } }, null, 2)); } // ── THE INVARIANT THAT MAKES CONCURRENCY SAFE ──────────────────────────── @@ -1054,7 +1061,7 @@ class EditorServer { } release(uri) { if (this.open === uri) this.open = null; } async start() { - for (const d of [FIX, CLM]) if (fs.existsSync(d)) for (const f of fs.readdirSync(d)) if (f.endsWith('.rip')) fs.copyFileSync(path.join(d, f), path.join(this.dir, f)); + if (this.corpusMode) for (const d of [FIX, CLM]) if (fs.existsSync(d)) for (const f of fs.readdirSync(d)) if (f.endsWith('.rip')) fs.copyFileSync(path.join(d, f), path.join(this.dir, f)); // No errors/ copy: the Diagnostics Audit opens its fixtures with in-memory // text under `errors/…` URIs (distinct from every flat fixture by path // alone), and the server compiles the didOpen text — it never reads an @@ -3391,6 +3398,7 @@ if (RUN_MAIN) { // rank in the rip source's same line, fixes the expected column. A twin that // stops aligning therefore fails loudly instead of drifting. let el = null; +let gl = null; if (RUN_ERRORS) { // Each regex mirrors ITS tool's honoring rule — not a tidier one — so // whatever would silence a measurement is exactly what gets stripped and @@ -3635,6 +3643,142 @@ if (RUN_ERRORS) { ...stalePinKeys.map((k) => ({ kind: 'stale-pin', note: `${k}: error-pins.json entry with no fixture`, file: k })), ], }; + + // ── the GRADUAL PAIR (corpus/gradual): the suppression matrix. Every + // fixture above measures under the corpus's strict config; this pair + // measures the OTHER mode, under corpus/gradual's own package.json + // (gradual, with a declared-but-never-installed dependency), through the + // same editor server the lane drives. + // + // held.rip every family gradual HOLDS, one section each. Gradual + // must publish NOTHING — the file carries no directives, + // so it doubles as an in-tree canary under `rip check`. + // The SAME text measured under the corpus's strict + // config must publish every pinned family + // (gradual-pins.json): a family quiet in BOTH modes + // stopped producing its diagnostic at all — `vacuous`, + // never a pass. A toolchain default flip lands here: + // a new strict-family member leaking into gradual is a + // `leak` row the day the toolchain pin moves. + // published.rip what gradual DOES publish — reach by annotation, by + // flow, by compiler-typed construction, and the + // always-reported defects — pinned per line, under the + // errors/ pragma discipline. + // + // `rip check` then runs over a stripped copy of the pair as the second + // instrument: the CLI and the editor share the gate (scopes.js), and + // this is the seam that proves they keep answering alike. + { + const GRAD = path.join(CORPUS, 'gradual'); + auditBanner('GRADUAL PAIR', 'held publishes nothing in gradual, everything pinned under strict · published matches its pins · CLI parity'); + const pins = fs.existsSync(GRADUAL_PINS) ? JSON.parse(fs.readFileSync(GRADUAL_PINS, 'utf8')) : {}; + const heldSrc = fs.readFileSync(path.join(GRAD, 'held.rip'), 'utf8'); + const pubRaw = fs.readFileSync(path.join(GRAD, 'published.rip'), 'utf8'); + const heldLines = heldSrc.split('\n'); + const pubLines = pubRaw.split('\n'); + const problems = []; + + // held.rip stays BARE everywhere — any directive would consume the very + // leak the canary exists to publish; published.rip leads with the pragma + // (the errors/ discipline) and carries nothing besides. + heldLines.forEach((l, i) => { + if (/^[ \t]*#[ \t]*@ts-(expect-error|ignore|nocheck)(?=\s|$)/.test(l)) problems.push({ kind: 'shape', file: 'held.rip', note: `line ${i + 1} carries a suppression directive — the canary must be bare` }); + }); + if (!pragmaLeads(pubLines, RIP_NOCHECK, /^\s*#/)) problems.push({ kind: 'shape', file: 'published.rip', note: '`# @ts-nocheck` missing or below the first statement — authoring surfaces would squiggle instrument content' }); + pubLines.forEach((l, i) => { + const m = l.match(/^[ \t]*#[ \t]*@ts-(expect-error|ignore)(?=\s|$)/); + if (m) problems.push({ kind: 'shape', file: 'published.rip', note: `line ${i + 1} carries @ts-${m[1]} — the pair's fixtures must be unsuppressed beyond the leading pragma` }); + }); + const pubStripped = pubLines.map((l) => (RIP_NOCHECK.test(l) ? '#' : l)).join('\n'); + + // One matcher for both sides, the errors-lane discipline: token is the + // pin's checksum, exact column claims before same-line fallback, and + // whatever remains unclaimed is a stray. + const matchPins = (fixture, side, ds, pinRows, srcLines) => { + const out = []; + const expected = []; + for (const p of pinRows) { + if (typeof p.token !== 'string' || p.token === '') { out.push({ kind: 'shape', file: fixture, note: `pin TS${p.code} at ${p.line}:${p.character}: no \`token\` — a pin states the source text it sits on` }); continue; } + if ((srcLines[p.line - 1] ?? '').slice(p.character, p.character + p.token.length) !== p.token) { + out.push({ kind: 'shape', file: fixture, note: `pin \`${p.token}\` not at ${p.line}:${p.character} — the fixture moved under the pin (re-measure and re-pin)` }); + continue; + } + expected.push({ line: p.line - 1, character: p.character, code: p.code, token: p.token }); + } + const unmatched = ds.filter((d) => (d.severity ?? 1) <= 2); + for (const e of expected) { + let i = unmatched.findIndex((d) => d.code === e.code && d.range.start.line === e.line && d.range.start.character === e.character); + if (i < 0) i = unmatched.findIndex((d) => d.code === e.code && d.range.start.line === e.line); + if (i < 0) { + out.push({ kind: side === 'strict' ? 'vacuous' : 'missing', file: fixture, note: `expected TS${e.code} at ${e.line + 1}:${e.character} (\`${e.token}\`) — never published${side === 'strict' ? ' even under strict: the construct stopped producing its diagnostic, so the gradual hold above it proves nothing' : ''}` }); + continue; + } + const [d] = unmatched.splice(i, 1); + if (d.range.start.character !== e.character) out.push({ kind: 'position', file: fixture, note: `TS${e.code} at line ${e.line + 1}: expected column ${e.character} (\`${e.token}\`), published ${d.range.start.character}` }); + } + for (const d of unmatched) out.push({ kind: 'stray', file: fixture, note: `unexpected TS${d.code} at ${d.range.start.line + 1}:${d.range.start.character} — ${String(d.message).split('\n')[0]}` }); + return { problems: out, asserted: expected.length }; + }; + + // The gradual workspace: corpus/gradual's package.json AT THE ROOT, so + // the opened documents resolve gradual mode and the declared-but-absent + // dependency from it. The strict side reuses a lane server — its + // workspace already carries the corpus's strict config — and both open + // the same text under a `gradual/` URI no flat fixture collides with. + const gserver = new EditorServer({ packageJson: JSON.parse(fs.readFileSync(path.join(GRAD, 'package.json'), 'utf8')) }); + await gserver.start(); + let heldGradual, pubGradual, heldStrict; + try { + heldGradual = await gserver.verdict('gradual/held.rip', heldSrc); + pubGradual = await gserver.verdict('gradual/published.rip', pubStripped); + heldStrict = await pool[0].verdict('gradual/held.rip', heldSrc); + } finally { await gserver.stop(); } + + for (const d of heldGradual.filter((d) => (d.severity ?? 1) <= 2)) { + problems.push({ kind: 'leak', file: 'held.rip', note: `gradual published TS${d.code} at ${d.range.start.line + 1}:${d.range.start.character} — ${String(d.message).split('\n')[0]}` }); + } + const strictSide = matchPins('held.rip', 'strict', heldStrict, pins['held.rip'] ?? [], heldLines); + const pubSide = matchPins('published.rip', 'gradual', pubGradual, pins['published.rip'] ?? [], pubLines); + problems.push(...strictSide.problems, ...pubSide.problems); + + // The CLI instrument over the same pair: held silent, published equal + // to the pins. rip check exits non-zero when it reports, so the JSON + // rides stdout either way. + const pdir = mkTemp(path.join(os.tmpdir(), 'rip-audit-gradual-check-')); + fs.copyFileSync(path.join(GRAD, 'package.json'), path.join(pdir, 'package.json')); + fs.writeFileSync(path.join(pdir, 'held.rip'), heldSrc); + fs.writeFileSync(path.join(pdir, 'published.rip'), pubStripped); + let cliRows = null; + try { cliRows = JSON.parse((await execFileP('bun', [RIP, 'check', '--json', pdir], { encoding: 'utf8', timeout: 120000 })).stdout); } + catch (err) { + try { cliRows = JSON.parse((err.stdout || '').toString()); } + catch { problems.push({ kind: 'parity', file: 'gradual', note: `rip check over the pair produced no JSON: ${String(err.message).split('\n')[0]}` }); } + } + if (cliRows) { + for (const r of cliRows.filter((r) => path.basename(r.file) === 'held.rip')) { + problems.push({ kind: 'parity', file: 'held.rip', note: `rip check reports TS${r.code} at ${r.line}:${r.column} where the editor holds — the instruments disagree` }); + } + const want = (pins['published.rip'] ?? []).map((p) => `${p.line}:${p.character}:TS${p.code}`).sort(); + const got = cliRows.filter((r) => path.basename(r.file) === 'published.rip').map((r) => `${r.line}:${r.column - 1}:TS${r.code}`).sort(); + if (want.join(' ') !== got.join(' ')) { + problems.push({ kind: 'parity', file: 'published.rip', note: `rip check disagrees with the pins — pinned [${want.join(', ')}] vs CLI [${got.join(', ')}]` }); + } + } + + const buckets = [ + ['gradual/held.rip · gradual verdict', `${heldGradual.length === 0 ? 'nothing published' : `${heldGradual.length} published`}`, (p) => p.kind === 'leak' || (p.kind === 'shape' && p.file === 'held.rip')], + ['gradual/held.rip · strict pairing', `${strictSide.asserted} famil${strictSide.asserted === 1 ? 'y' : 'ies'} asserted`, (p) => (p.kind === 'vacuous' || ((p.kind === 'stray' || p.kind === 'position') && p.file === 'held.rip'))], + ['gradual/published.rip', `${pubSide.asserted} diagnostic${pubSide.asserted === 1 ? '' : 's'} asserted`, (p) => p.file === 'published.rip' && p.kind !== 'parity'], + ['gradual/ · rip check parity', 'CLI and editor share the gate', (p) => p.kind === 'parity'], + ]; + for (const [label, detail, match] of buckets) { + const mine = problems.filter(match); + console.log(` ${mine.length === 0 ? green('✓') : red('✗')} ${pad(label, ERR_NAME_W + 18)} ${dim(detail)}` + + (mine.length === 0 ? '' : dim(' · ') + red(`${mine.length} violation${mine.length === 1 ? '' : 's'}`))); + for (const p of mine) out(` ${red('·')} ${yellow(p.kind)} ${dim(p.note)}`); + } + gl = { held: strictSide.asserted, published: pubSide.asserted, problems }; + } } const PROBES = new Map(); // file → { decls, hovers, tokens, tmap } @@ -4722,7 +4866,7 @@ if (tk) { // detail-of-detail and left a gap under a section that has no middle tier. const reason = (text) => { for (const l of wrapText(text, TERM_W - 6, 0)) console.log(` ${dim(l)}`); }; const { verdicts, failures, drift } = judge({ - states: { gr, mp, el, hp, tk, fails }, + states: { gr, mp, el, gl, hp, tk, fails }, ran: (lane) => AUDITS.find((a) => a.key === lane).ran, }); // STRUCTURAL refusal, not a verdict: a predicate read a field no summary diff --git a/test/toolchain/audit-contract.test.js b/test/toolchain/audit-contract.test.js index 519c680e..22a9a4b5 100644 --- a/test/toolchain/audit-contract.test.js +++ b/test/toolchain/audit-contract.test.js @@ -31,6 +31,7 @@ const cleanStates = () => ({ mp: { missing: 0, drifted: 0, census: 0, decompositionDrift: 0, badExclusions: 0 }, fails: 0, el: { problems: [] }, + gl: { held: 1, published: 1, problems: [] }, hp: { gap: 0, snapChanged: 0, violations: [], silentLeaks: 0, ruledDiverging: 0, stalePinKeys: [], ruledPopulation: 1 }, tk: { missing: [], badType: [], badReadonly: [], survDrops: [], survUnclassified: 0, unexplained: [], exclusionDrift: [], facesAvailable: true }, }); @@ -140,6 +141,8 @@ describe('the audit contract judges in both directions', () => { 'diagnostics.positions': (s) => { s.el.problems = [{ kind: 'position', file: '09-classes.errors.rip' }]; }, 'diagnostics.positions.element': (s) => { s.el.problems = [{ kind: 'position', file: '11-types.errors.rip' }]; }, 'diagnostics.positions.arity': (s) => { s.el.problems = [{ kind: 'position', file: '02-operations.errors.rip' }]; }, + 'gradual.held': (s) => { s.gl.problems = [{ kind: 'leak', file: 'held.rip' }]; }, + 'gradual.published': (s) => { s.gl.problems = [{ kind: 'missing', file: 'published.rip' }]; }, 'hover.parity': (s) => { s.hp.gap = 1; }, 'hover.silence': (s) => { s.hp.silentLeaks = 1; }, 'hover.ruled': (s) => { s.hp.ruledDiverging = 1; }, diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index 79d8112c..d53b7418 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -627,9 +627,9 @@ describeExtended('rip check: type diagnostics over the real server', () => { // TypeScript 7 defaults `strict` ON, which drags catch bindings to // `unknown` through `useUnknownInCatchVariables` — narrowing ceremony - // on every member read of an unannotated `catch err`. Gradual restores - // the loose base (`strict: false` — nullPosture, mirror.js); a chain - // that states its own strictness is yielded to whole and keeps the + // on every member read of an unannotated `catch err`. Gradual's + // posture subtracts that flag (nullPosture, mirror.js); a chain that + // states its own strictness is yielded to whole and keeps the // unknown. test('gradual catch bindings are not unknown; an author strictness chain keeps them', () => { const catcher = [ @@ -896,6 +896,34 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // The INVERSE flip: a gradual package nested in a STRICT workspace earns + // the same boundary — posture and floors are per-program, so without it + // the package rides strict nulls, unknown catches, and refused floors it + // never asked for. The tsconfig ABOVE the package must not swallow the + // boundary either: a wrapper's posture is the wrapper's, not the + // package's (the audit-tree shape, where corpus/gradual sits under + // test/audit's tsconfig and strict package.json). + test('a gradual package nested in a strict workspace keeps its loose posture and floors', () => { + const dir = workspace({ + 'package.json': JSON.stringify({ workspaces: ['packages/*'], rip: { strict: true } }), + 'mid/tsconfig.json': JSON.stringify({ compilerOptions: { noEmit: true } }), + 'mid/loose/package.json': JSON.stringify({ name: '@t/loose' }), + 'mid/loose/loose.rip': [ + "import { Database } from 'bun:sqlite'", + 'greeting: string = null', + 'console.log(Database, greeting)', + ].join('\n') + '\n', + 'strict.rip': 'flag: string = null\nconsole.log(flag)\n', + }); + try { + const diags = JSON.parse(check(dir, ['--json']).stdout); + // The flipped package: floored bun:*, loose nulls — silent. + expect(diags.filter((d) => d.file.includes('loose'))).toEqual([]); + // The strict root still means strict. + expect(diags.filter((d) => d.file === 'strict.rip').map((d) => d.code)).toEqual([2322]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // Config is per FILE (nearest package.json), so a strict consumer's // check still hides its gradual DEPENDENCIES' diagnostics — and a // summary that says "set `rip.strict` in package.json" after the user From b2b962ea3d70585016e9b96f2defe61ab9d98ef4 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 12:30:32 +0100 Subject: [PATCH 16/23] The cart example drops its prose and re-syncs two handlers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/cart/README.md | 39 ---------------------------- examples/cart/app/routes/cart.rip | 2 +- examples/cart/app/routes/profile.rip | 2 +- examples/cart/index.rip | 2 -- 4 files changed, 2 insertions(+), 43 deletions(-) delete mode 100644 examples/cart/README.md diff --git a/examples/cart/README.md b/examples/cart/README.md deleted file mode 100644 index e1669ded..00000000 --- a/examples/cart/README.md +++ /dev/null @@ -1,39 +0,0 @@ -# Cart - -A multi-route shop that proves the full app rails: `index.rip` + `app/` + -`api/` + SQLite persistence. Products, cart, profile, and orders — the -canonical full-shape exemplar beside Pulse’s thin Workspace door demo -([docs/WORKSPACE.md](../../docs/WORKSPACE.md)). - -It is a runnable example — not a CI suite. - -## Layout - -| Path | Role | -| --- | --- | -| `index.rip` | API entry: migrate/seed, `/styles.css`, bare `start!` handoff | -| `setup.rip` | One-shot `migrate` + `seed` | -| `api/` | SQLite adapter, models, seed, `/api/*` handlers (not a public URL tree) | -| `app/index.html` | SPA shell (Pico + styles + `bootApp`) | -| `app/stash.rip` | `stash` — cart + `source` cells for user/products/orders | -| `app/routes/` | File routes + `_layout.rip` | - -Client modules import `UserPublic` / etc. from `../api/models.rip`; the -bundler overlays shippable projections at that path (`:model` stays server-side). - -Persistence starts on **bun:sqlite**. A follow-up swaps `api/db.rip` to -`@rip-lang/db` (DuckDB over duckdb-harbor). - -## Run - -With a Janus control endpoint running: - -```bash -cd examples/cart -rip server index.rip --name cart -``` - -Open the registered site. Seeded products render; add to cart, place an -order, and edit the profile. Data lives in `api/cart.sqlite` (gitignored). -Watching mode opens the Workspace door; edit a client module under `app/` -to see a live update. diff --git a/examples/cart/app/routes/cart.rip b/examples/cart/app/routes/cart.rip index b692e9ea..4813a107 100644 --- a/examples/cart/app/routes/cart.rip +++ b/examples/cart/app/routes/cart.rip @@ -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 diff --git a/examples/cart/app/routes/profile.rip b/examples/cart/app/routes/profile.rip index 1b926ad1..ebbccd5a 100644 --- a/examples/cart/app/routes/profile.rip +++ b/examples/cart/app/routes/profile.rip @@ -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' diff --git a/examples/cart/index.rip b/examples/cart/index.rip index 12cd6ad0..4239cc26 100644 --- a/examples/cart/index.rip +++ b/examples/cart/index.rip @@ -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' From 9d6ffff748bcefe01ba99c799984f54980773a58 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 12:52:47 +0100 Subject: [PATCH 17/23] A check answers for the paths it was given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/TYPES.md | 7 +++++++ src/check.js | 38 +++++++++++++++++++++++++++++++++++- test/toolchain/check.test.js | 32 ++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 1 deletion(-) diff --git a/docs/TYPES.md b/docs/TYPES.md index 8703c24c..4f9a238d 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -288,6 +288,13 @@ 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. diff --git a/src/check.js b/src/check.js index 1585c81e..31492307 100644 --- a/src/check.js +++ b/src/check.js @@ -262,6 +262,10 @@ const hiddenScopeDirs = new Set(); const hiddenAnnotationDirs = new Set(); let hiddenUninstalled = 0; const hiddenUninstalledDirs = new Set(); // where `bun install` answers +// A DEPENDENCY's diagnostics — a closure file the run was not asked +// about. Counted, never reported: see the pull loop. +let dependencyDiags = 0; +const dependencyDirs = new Set(); const seen = new Set(); const explicitTargets = new Set(targets); const queue = [...targets]; @@ -553,6 +557,16 @@ if (compiled.size > 0) { console.error(`rip check: could not pull diagnostics for ${path.relative(process.cwd(), fsPath)} (${err.message}) — the run is incomplete`); continue; } + // A DEPENDENCY answers for itself: a file the run was not asked + // about is still compiled and checked — a target's types cannot + // resolve otherwise — but its diagnostics report through its own + // check, not this one. Reporting them makes a package's exit + // code hostage to code its author does not own, and shows a + // consumer defects the dependency's own check cannot reproduce. + // Its HIDDEN families still count below: those name which + // package.json a `rip.strict` remedy belongs to, which is as + // true of a dependency as of a target. + const isTarget = explicitTargets.has(fsPath); const mapped = []; for (const d of pulled?.items ?? []) { const m = mapTsDiagnostic(entry.good, d); @@ -590,6 +604,20 @@ if (compiled.size > 0) { mapped.push(m); } for (const m of applyRipDirectives(entry.good, mapped)) { + if (!isTarget) { + // Counted under the REPORT's own rule — error/warning only, + // no unused/deprecated fade classes — so the number is in the + // same currency as the count above it. It covers the + // dependency files THIS closure reached, which is fewer than + // checking that directory outright. + if ((m.severity ?? 1) > 2) continue; + dependencyDiags++; + // The file's own directory, not its project: the line answers + // "where do I go to see these", and a directory is what `rip + // check` takes back. + dependencyDirs.add(path.relative(process.cwd(), path.dirname(fsPath)) || '.'); + continue; + } tsDiags.push({ file: fsPath, severity: m.severity, code: m.code, message: m.message, line: m.range.start.line, character: m.range.start.character, @@ -725,7 +753,15 @@ if (asJson) { if (!named.length) return ''; return ` (${named.slice(0, 3).join(', ')}${named.length > 3 ? ` and ${named.length - 3} more` : ''})`; }; - if (hiddenAnnotations > 0 || hiddenMissingTypes > 0 || hiddenScope > 0 || hiddenUninstalled > 0) console.log(''); + if (hiddenAnnotations > 0 || hiddenMissingTypes > 0 || hiddenScope > 0 || hiddenUninstalled > 0 || dependencyDiags > 0) console.log(''); + if (dependencyDiags > 0) { + // Every directory names itself here — unlike the `rip.strict` + // families, whose home project is the one the reader is already in. + const dirs = [...dependencyDirs].sort(); + const shown = dirs.slice(0, 3).join(', ') + (dirs.length > 3 ? ` and ${dirs.length - 3} more` : ''); + console.log(gray(`${dependencyDiags} diagnostic${plural(dependencyDiags)} in dependencies (${shown}) ` + + `— check them there`)); + } if (hiddenScope > 0) { console.log(gray(`${hiddenScope} diagnostic${plural(hiddenScope)} hidden in unannotated code${inProjects(hiddenScopeDirs)} ` + `— annotate a declaration to check its scope, or set \`rip.strict\` in package.json`)); diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index d53b7418..8ab279f5 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -924,6 +924,38 @@ describeExtended('rip check: type diagnostics over the real server', () => { } finally { fs.rmSync(dir, { recursive: true, force: true }); } }, 90_000); + // A check answers for the paths it was ASKED about. The closure is + // compiled and checked whole — types cannot resolve otherwise — but a + // dependency's own diagnostics are its author's, not the caller's: + // reporting them makes a package's exit code hostage to code its + // author does not own, and surfaces defects the dependency's own + // check cannot reproduce. The dependency is not silently dropped — + // one summary line names where to look. + test('a check reports its targets, not its dependencies', () => { + const dir = workspace({ + 'app/app.rip': [ + "import { helper } from '../lib/lib.rip'", + 'label: string = helper', + 'console.log(label)', + ].join('\n') + '\n', + 'lib/lib.rip': [ + 'export helper: string = "x"', + 'broken: number = "not a number"', + 'console.log(broken)', + ].join('\n') + '\n', + }); + try { + // Asked about app/ — the dependency's TS2322 is not the answer. + const scoped = JSON.parse(check(dir, ['--json', 'app']).stdout); + expect(scoped).toEqual([]); + // But it is accounted for, not hidden. + expect(check(dir, ['app']).stdout).toMatch(/1 diagnostic in dependencies \(lib\)/); + // Asked about the whole tree — the same diagnostic IS the answer. + const whole = JSON.parse(check(dir, ['--json']).stdout); + expect(whole.map((d) => [d.file, d.code])).toEqual([['lib/lib.rip', 2322]]); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } + }, 90_000); + // Config is per FILE (nearest package.json), so a strict consumer's // check still hides its gradual DEPENDENCIES' diagnostics — and a // summary that says "set `rip.strict` in package.json" after the user From b0ff08e5fdaa42da623835f1699e4b784bbe0ec0 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 14:16:01 +0100 Subject: [PATCH 18/23] The inference hold argues from what inference does MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- docs/TYPES.md | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/TYPES.md b/docs/TYPES.md index 4f9a238d..dd702215 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -233,12 +233,19 @@ 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 — `answer = 42` misused as a string is -held until someone writes a type — which keeps the rule statable: you -get diagnostics where type information reaches, and an annotation is how -you ask for more. 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. +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 From e6b1ea5ade7be8560f45abcaa593f27feed78d3d Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 17:59:17 +0100 Subject: [PATCH 19/23] A member's type answers for the member, not the component MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/vscode/src/diagnostics.js | 13 ++++++++ packages/vscode/test/server.test.js | 40 +++++++++++++++++++++++++ src/component-types.js | 46 ++++++++++++++++++++++------- src/dts.js | 3 +- src/emitter.js | 19 ++++++++---- 5 files changed, 104 insertions(+), 17 deletions(-) diff --git a/packages/vscode/src/diagnostics.js b/packages/vscode/src/diagnostics.js index a4cb47f7..c74e969c 100644 --- a/packages/vscode/src/diagnostics.js +++ b/packages/vscode/src/diagnostics.js @@ -184,6 +184,19 @@ export function applyRipDirectives(good, mapped) { seen.add(key); return true; }); + // The same claim can also land on spans that NEST rather than match: a + // 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`. Identical code, severity and message + // over a containing range is the same claim, so the narrower span keeps + // it; only exact duplicates were collapsed above. + const inside = (a, b) => // is `a` within `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)); + mapped = mapped.filter((m, i) => !mapped.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))); // identical spans: keep the first const directives = ripDirectiveLines(good); if (directives.length === 0) return mapped; const is2578 = (m) => String(m.code) === '2578'; diff --git a/packages/vscode/test/server.test.js b/packages/vscode/test/server.test.js index edb6ac73..2a356430 100644 --- a/packages/vscode/test/server.test.js +++ b/packages/vscode/test/server.test.js @@ -558,6 +558,46 @@ describe.skipIf(!tsgoAvailable)('server over LSP stdio', () => { } }, 30000); + // A member's type is rendered TWICE — on the class declare and again on + // the same-name companion interface — so a fault in it reaches the editor + // twice. Both must land on the member the author wrote. The companion has + // no source line of its own, so an unmapped byte there falls to the + // component's `$self` cover and paints every line of the component red + // while the Problems count still reads one. The END of the range is what + // carries that, which is why it is asserted here: the test above pins + // starts, and a whole-component span has a correct start. + test('a fault in a member type anchors on the member — no diagnostic spans the component', async () => { + const published = []; + const client = await startServer((p) => published.push(p)); + try { + const wait = nextDiagnostics(published); + const fixture = [ + 'export Widget = component', // 0 + ' bad: Nope := {}', // 1 ← the plant: an unresolved type + ' render', // 2 + " div 'one'", // 3 + " div 'two'", // 4 + '', + ].join('\n'); + client.notify('textDocument/didOpen', { + textDocument: { uri, languageId: 'rip', version: 1, text: fixture }, + }); + const { diagnostics } = await wait(); + expect(diagnostics.length).toBeGreaterThan(0); + for (const d of diagnostics) { + expect(d.code).toBe(2304); + // Every publication sits on the member's own line and covers the + // annotation — never the component. The exact extent differs (the + // container spells the type twice, once with its colon), so the + // assertion is containment, not one span. + expect([d.range.start.line, d.range.end.line]).toEqual([1, 1]); + expect(fixture.split('\n')[1].slice(d.range.start.character, d.range.end.character)).toContain('Nope'); + } + } finally { + await client.stop(); + } + }, 30000); + test('prop completions and prop-key hover at a child-component call site', async () => { const published = []; const client = await startServer((p) => published.push(p)); diff --git a/src/component-types.js b/src/component-types.js index e2fb8ab7..f9ae63e3 100644 --- a/src/component-types.js +++ b/src/component-types.js @@ -431,15 +431,22 @@ const memberTypeSegments = (m, lead) => { : [{ text: ': any' }]; const vt = t ?? 'any'; if (m.kind === 'accept') return [{ text: `${lead}any` }]; + // The container renders the member's type TWICE — once on `value`, once + // as `read()`'s return. Both spellings are the same annotation, so both + // carry its span: an unmarked one falls to whatever cover encloses the + // line, which in the companion interface is the whole component. + const readBack = (pre, post) => (t !== null + ? [{ text: pre }, { text: vt, node: m.node, role: 'annotation' }, { text: post }] + : [{ text: `${pre}${vt}${post}` }]); if (containerish(m)) { const und = t !== null && m.optional && m.kind === 'prop' ? ' | undefined' : ''; return [ { text: `${lead}{ value` }, ...typed, - { text: `${und}; read(): ${vt}${und} }` }, + ...readBack(`${und}; read(): `, `${und} }`), ]; } if (m.kind === 'computed' || m.kind === 'gate') { - return [{ text: `${lead}{ readonly value` }, ...typed, { text: `; read(): ${vt} }` }]; + return [{ text: `${lead}{ readonly value` }, ...typed, ...readBack('; read(): ', ' }')]; } if (t === null) return [{ text: `${lead}any` }]; return typed; // readonly / plain: the annotation IS `: T` @@ -618,18 +625,37 @@ export function instanceTypeLines(info, selfType) { const declared = info.roleText(m.func, 'returnType'); const base = declared ?? (m.isVoid ? 'void' : 'any'); const ret = awaitsIn(m.func[2]) && !/^Promise\s*` : base; - lines.push({ text: `${m.name}${renderParams(m.func[1], info.isOptionalParam)}: ${ret};` }); + lines.push({ segs: [{ text: `${m.name}${renderParams(m.func[1], info.isOptionalParam)}: ${ret};` }] }); continue; } + // SEGMENTS, not one blob: the member's type is rendered here a second + // time (the class declare is the first), so a fault in it publishes + // twice, and the companion has no source line of its own to fall back + // on — an unmapped byte lands on the component's `$self` cover and + // paints every line of the component. The type segments already carry + // their annotation spans; passing them through is what puts the second + // publication on the member the author wrote. lines.push({ - text: `${m.kind === 'readonly' ? 'readonly ' : ''}${m.name}${segmentsText(memberTypeSegments(m, ': '))};`, - ...(isBehaviorProjected(m) ? { node: m.nameNode, role: m.nameRole } : {}), + // The line's own cover is the MEMBER, so any byte without a finer + // span of its own — the container's `value`, which is where TS + // reports a computed cycle — lands on the member the author wrote + // instead of on the component. Segments carrying a span (the + // annotation) nest inside and win where they apply. + node: m.nameNode, role: m.nameRole, + segs: [ + { text: m.kind === 'readonly' ? 'readonly ' : '' }, + { text: m.name }, + ...memberTypeSegments(m, ': '), + { text: ';' }, + ], }); } - if (!hasChildren) lines.push({ text: 'children?: any;' }); - if (info.extendsTag !== null) lines.push({ text: `rest: ${containerType('Record')};` }); - lines.push({ text: `mount(target?: any): ${selfType};` }); - lines.push({ text: 'unmount(options?: { removeDOM?: boolean }): void;' }); - lines.push({ text: 'emit(name: string, detail?: any): void;' }); + // Scaffolding the author never wrote: no source span exists for these, so + // they carry no mark and stay under the component's cover. + if (!hasChildren) lines.push({ segs: [{ text: 'children?: any;' }] }); + if (info.extendsTag !== null) lines.push({ segs: [{ text: `rest: ${containerType('Record')};` }] }); + lines.push({ segs: [{ text: `mount(target?: any): ${selfType};` }] }); + lines.push({ segs: [{ text: 'unmount(options?: { removeDOM?: boolean }): void;' }] }); + lines.push({ segs: [{ text: 'emit(name: string, detail?: any): void;' }] }); return lines; } diff --git a/src/dts.js b/src/dts.js index c85b293d..e79bc0b9 100644 --- a/src/dts.js +++ b/src/dts.js @@ -47,6 +47,7 @@ import { buildSchemaTypeStory, SchemaTypeError } from './schema-types.js'; import { protoMemberTarget, PROTO_GENERIC_PARAMS, moduleSourceText, resolveEnumMembers, isModuleImportNode, ctorAtFields } from './emitter.js'; import { componentTypeInfo, propsTypeText, propsParamOptional, instanceTypeLines, containerType, + segmentsText, selfArgsOf, anyArgsOf, } from './component-types.js'; @@ -366,7 +367,7 @@ export function emitDeclarations({ sexpr, stores, source }) { const typeParams = typeParamsOf(stmt); const self = `${name}${selfArgsOf(typeParams)}`; lines.push(`${exp}interface ${name}${typeParams} {`); - for (const l of rendered(() => instanceTypeLines(info, self))) lines.push(` ${l.text}`); + for (const l of rendered(() => instanceTypeLines(info, self))) lines.push(` ${segmentsText(l.segs)}`); lines.push('}'); lines.push(`${exp}declare let ${name}: {`); if (gated) { diff --git a/src/emitter.js b/src/emitter.js index 8547d6f5..2358d2c9 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -2295,14 +2295,21 @@ class Emitter { this.b.emit('\n' + pad); this.mark(compNode, '$self', () => { this.b.emit(`${exported ? 'export ' : ''}interface ${name}${typeParams ?? ''} {`); - // Lines carrying a node (behavior-projected computeds) mark - // their own row nested inside the $self cover, so a diagnostic - // born in the projection anchors at the member the author - // wrote rather than across the whole component. + // Every segment carrying a node marks its own row nested inside + // the $self cover, so a diagnostic born in this second rendering + // of a member's type anchors at the member the author wrote + // rather than across the whole component. Scaffolding segments + // have no source span and stay under the cover. for (const l of instanceTypeLines(info, `${name}${selfArgs}`)) { this.b.emit('\n' + pad + ' '); - if (l.node !== undefined) this.mark(l.node, l.role, () => this.b.emit(l.text)); - else this.b.emit(l.text); + const segs = () => { + for (const s of l.segs) { + if (s.node !== undefined) this.mark(s.node, s.role, () => this.b.emit(s.text)); + else this.b.emit(s.text); + } + }; + if (l.node !== undefined) this.mark(l.node, l.role, segs); + else segs(); } this.b.emit('\n' + pad + '}'); }); From 946fda08f8fb1aff91df245789ab1b5f967b37a0 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 19:04:04 +0100 Subject: [PATCH 20/23] Collapse nested claims after their directives are charged MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- packages/vscode/src/diagnostics.js | 38 +++++++++++-------- .../test/diagnostics-suppression.test.js | 38 +++++++++++++++++++ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/packages/vscode/src/diagnostics.js b/packages/vscode/src/diagnostics.js index c74e969c..9171b849 100644 --- a/packages/vscode/src/diagnostics.js +++ b/packages/vscode/src/diagnostics.js @@ -173,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 @@ -184,21 +205,8 @@ export function applyRipDirectives(good, mapped) { seen.add(key); return true; }); - // The same claim can also land on spans that NEST rather than match: a - // 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`. Identical code, severity and message - // over a containing range is the same claim, so the narrower span keeps - // it; only exact duplicates were collapsed above. - const inside = (a, b) => // is `a` within `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)); - mapped = mapped.filter((m, i) => !mapped.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))); // identical spans: keep the first 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 = []; @@ -227,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- diff --git a/packages/vscode/test/diagnostics-suppression.test.js b/packages/vscode/test/diagnostics-suppression.test.js index aea2e79b..3b6a2c3b 100644 --- a/packages/vscode/test/diagnostics-suppression.test.js +++ b/packages/vscode/test/diagnostics-suppression.test.js @@ -62,3 +62,41 @@ test('identical mapped manifestations collapse before directive handling', () => ]; expect(applyRipDirectives({ source: '' }, mapped)).toEqual([mapped[0], mapped[2]]); }); + +// Two renderings of one claim can mark different EXTENTS of the same span +// (`: T` against `T`), which the identity collapse above cannot see. The +// narrowest keeps it. +test('a claim on nested spans collapses to the narrowest', () => { + const at = (sc, ec) => ({ start: { line: 3, character: sc }, end: { line: 3, character: ec } }); + const mapped = [ + { code: 2304, severity: 1, message: "Cannot find name 'Nope'.", range: at(5, 11) }, + { code: 2304, severity: 1, message: "Cannot find name 'Nope'.", range: at(7, 11) }, + { code: 2304, severity: 1, message: "Cannot find name 'Other'.", range: at(5, 11) }, + ]; + expect(applyRipDirectives({ source: '' }, mapped)).toEqual([mapped[1], mapped[2]]); +}); + +// A directive is charged by a diagnostic STARTING on its governed line, so +// the nested collapse has to run AFTER charging: collapsing first can retire +// the only row that starts there and leave the directive reading unused, +// resurrecting the TS2578 the charge exists to drop. +test('the wider span still charges its directive before the collapse', () => { + const source = 'x = 1\n# @ts-expect-error\nouter\ninner\n'; + const wide = { + code: 2304, severity: 1, message: "Cannot find name 'Nope'.", + range: { start: { line: 2, character: 0 }, end: { line: 3, character: 9 } }, + }; + const narrow = { + code: 2304, severity: 1, message: "Cannot find name 'Nope'.", + range: { start: { line: 3, character: 2 }, end: { line: 3, character: 6 } }, + }; + // TS2578 lands on the directive comment itself (line 1). + const unused = { + code: 2578, severity: 1, message: "Unused '@ts-expect-error' directive.", + range: { start: { line: 1, character: 0 }, end: { line: 1, character: 19 } }, + }; + const out = applyRipDirectives({ source }, [wide, narrow, unused]); + // The directive absorbed the wide one and is therefore USED, so its + // TS2578 drops; the narrow one is not on a governed line and survives. + expect(out).toEqual([narrow]); +}); From 30c0dbc54b147294b7c30f0fb7dea09c5d9d8a7e Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 19:21:01 +0100 Subject: [PATCH 21/23] A name only a type uses is not imported at runtime MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- examples/cart/app/routes/cart.rip | 2 +- examples/cart/app/routes/profile.rip | 2 +- src/emitter.js | 115 +++++++++++++++++++++++++-- test/battery/modules.rip | 39 +++++++++ test/lang/type-only-imports.test.js | 74 +++++++++++++++++ 5 files changed, 222 insertions(+), 10 deletions(-) create mode 100644 test/lang/type-only-imports.test.js diff --git a/examples/cart/app/routes/cart.rip b/examples/cart/app/routes/cart.rip index 4813a107..b3c7a523 100644 --- a/examples/cart/app/routes/cart.rip +++ b/examples/cart/app/routes/cart.rip @@ -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' diff --git a/examples/cart/app/routes/profile.rip b/examples/cart/app/routes/profile.rip index ebbccd5a..3c1618a8 100644 --- a/examples/cart/app/routes/profile.rip +++ b/examples/cart/app/routes/profile.rip @@ -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' diff --git a/src/emitter.js b/src/emitter.js index 2358d2c9..60bb4548 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -394,6 +394,10 @@ class Emitter { // rediscovered by scanning output): the browser module loader // splices resolved specifiers by these exact offsets. this.importSpans = []; + // Imported bindings the running program never references — the face + // keeps them (they are real types there), the JS emission does not + // (see collectTypeOnlyImports). + this.typeOnlyImports = new Set(); // Tier 3 pins (TS face only): Map of `${name}@${valueHash}` → type // text, supplied by the editor's probe pass. Names the scan reports // as pinnable (still hoisted + nested occurrence) collect in @@ -2400,6 +2404,54 @@ class Emitter { // child-ctor object's LAYOUT (one pair per line), and the strip // contract — face minus regions === JS bytes — holds only if both // modes make that layout decision from the same map. + // The imported bindings that are TYPE-ONLY: named in an import and + // then never referenced by the running program. Type syntax is absent + // from the s-expression tree — an annotation is erased into the side + // tables — so a name used only in one appears NOWHERE in the value + // tree, and that absence is the whole test. No cross-module + // resolution is needed, and none would help: whether the exporting + // module spells the name as a type is its business, while whether + // THIS file needs it at runtime is answerable here. + // + // A name must be USED BY A TYPE to be elided, not merely unused: an + // import nobody references at all is dead code, which is the author's + // business and not the emitter's — removing it would rewrite a program + // nobody asked to have rewritten. + // + // Both halves are deliberately conservative, and they fail the same + // safe way. Every string in the value tree counts as a use, including + // ones that are not references (a property key sharing the name); and + // only the roles below are read for type text, so a type position this + // list does not name goes unseen. Either way the name is KEPT, which + // costs an elision. The opposite — emitting an import of a name the + // module does not export — is a module that fails to load. + collectTypeOnlyImports(sexpr, source) { + const used = new Set(); + const bound = []; + const walk = (x) => { + if (typeof x === 'string') { used.add(x); return; } + if (!isNode(x)) return; + // A module import's own specifiers are the declaration, not a use, + // so they are recorded and not descended into. A DYNAMIC import is + // an ordinary expression and walks normally. + if (isModuleImportNode(this.stores, x)) { + bound.push(...Emitter.importedNames([x])); + return; + } + for (const c of x) walk(c); + }; + walk(sexpr); + if (bound.length === 0 || !source) return; + const inTypes = new Set(); + for (const n of this.stores.nodes ?? []) { + for (const r of this.stores.rolesOf(n.nodeId)) { + if (!Emitter.TYPE_ROLES.has(r.role) || typeof r.sourceStart !== 'number') continue; + for (const m of source.slice(r.sourceStart, r.sourceEnd).matchAll(/[A-Za-z_$][\w$]*/g)) inTypes.add(m[0]); + } + } + for (const name of bound) if (inTypes.has(name) && !used.has(name)) this.typeOnlyImports.add(name); + } + collectTsDirectives(sexpr, trivia, source) { this.tsDirectiveMap = new Map(); this.tsNocheck = null; @@ -3641,6 +3693,10 @@ class Emitter { return specs; } + // The roles whose recorded span is TYPE text. Additive by design — + // see collectTypeOnlyImports for why an omission is safe. + static TYPE_ROLES = new Set(['annotation', 'returnType', 'typeParams']); + static importedNames(imports) { const names = []; for (const node of imports) { @@ -3792,14 +3848,43 @@ class Emitter { // stops being verbatim at the first newline and every name past it loses its // position. Each name is a source read and takes its own row; the alias is a // binding site and takes one too. + // The LOCAL name a named specifier binds — what the rest of the file + // would have to reference for the import to be needed at runtime. + static specifierLocal(s) { return isNode(s) ? s[1] : s; } + emitSpecifiers(list) { - list.forEach((s, i) => { - if (i > 0) this.b.emit(', '); - if (isNode(s)) { - this.emitPrimitive(s[0]); - this.b.emit(' as '); - this.emitPrimitive(s[1]); - } else this.emitPrimitive(s); + // A type-only name stays in the FACE — it is a real type there, and + // dropping it would strand every annotation that uses it — but never + // reaches the JS, where importing a name the module does not export + // is a module that fails to load. The region carries the separator + // too, or stripping would leave a dangling comma. + const kept = list.filter((s) => !this.typeOnlyImports.has(Emitter.specifierLocal(s))); + let emitted = 0; + list.forEach((s) => { + const erased = this.typeOnlyImports.has(Emitter.specifierLocal(s)); + const one = () => { + if (isNode(s)) { + this.emitPrimitive(s[0]); + this.b.emit(' as '); + this.emitPrimitive(s[1]); + } else this.emitPrimitive(s); + }; + if (erased) { + if (!this.ts) return; // the shipping emission: gone + // The face keeps it, inside a region so stripping reproduces the + // JS byte for byte. The separator rides along — lead with it when + // something survives ahead, otherwise trail it — or a strip would + // leave a dangling comma. + this.b.tsOnly(() => { + if (emitted > 0) this.b.emit(', '); + one(); + if (emitted === 0 && kept.length > 0) this.b.emit(', '); + }); + return; + } + if (emitted > 0) this.b.emit(', '); + one(); + emitted++; }); } @@ -3823,7 +3908,16 @@ class Emitter { // waiting to be filled, and an editor offering to add a name puts it // there instead of on a real import. The two are distinct nodes now, // so the emission can be what was written. - if (specs.length > 0) { + // Every binding erased leaves the clause with nothing to say, and + // the JS becomes a bare `import 'mod'` — which still RUNS the + // module. Dropping the statement outright would silently discard + // its side effects, so the clause and its `from` ride the region + // and the specifier stays. + const allErased = specs.length > 0 && specs.every((spec) => spec !== '{}' + && (typeof spec === 'string' || spec[0] === '*' + ? this.typeOnlyImports.has(typeof spec === 'string' ? spec : spec[1]) + : spec.every((s) => this.typeOnlyImports.has(Emitter.specifierLocal(s))))); + const clause = () => { specs.forEach((spec, i) => { if (i > 0) this.b.emit(', '); if (spec === '{}') this.b.emit('{}'); @@ -3836,6 +3930,10 @@ class Emitter { } }); this.b.emit(' from '); + }; + if (specs.length > 0) { + if (!allErased) clause(); + else if (this.ts) this.b.tsOnly(clause); } { const specStart = this.b.offset; @@ -14717,6 +14815,7 @@ export function emit(parseResult, { source = '', runtimeDelivery = 'none', face // ahead of any runtime injection — since TypeScript honors it only // before all code. emitter.collectTsDirectives(parseResult.sexpr, parseResult.trivia ?? [], source); + emitter.collectTypeOnlyImports(parseResult.sexpr, source); if (emitter.tsNocheck !== null) { const programId = stores.idOf(parseResult.sexpr); const t = emitter.tsNocheck; diff --git a/test/battery/modules.rip b/test/battery/modules.rip index 6095ad29..76aa8d0f 100644 --- a/test/battery/modules.rip +++ b/test/battery/modules.rip @@ -492,3 +492,42 @@ code "an exported class rebinds", ''' }; Marker = 5; ''' + +# ============================================================================== +# Type-only imports +# ============================================================================== + +# A name used only in an annotation is not a value: the running program never +# references it, and emitting the import would name an export the module may +# not have. The face keeps it — the annotation still has to resolve there. +code "an imported name used only in a type is elided", ''' + import { api, Shape } from './lib.rip' + errors: Shape = {} + console.log(api, errors) +''', ''' + import { api } from "./lib.rip"; + let errors = {}; + console.log(api, errors); +''' + +# Every name erased leaves the module still IMPORTED — a bare specifier runs +# it. Dropping the statement would discard its side effects silently. +code "an all-type clause keeps the module's side effect", ''' + import { Shape } from './lib.rip' + errors: Shape = {} + console.log(errors) +''', ''' + import "./lib.rip"; + let errors = {}; + console.log(errors); +''' + +# An import nobody references AT ALL is dead code, not a type — the emitter +# does not rewrite it away. +code "an unreferenced value import survives", ''' + import { useState } from 'react' + console.log(1) +''', ''' + import { useState } from "react"; + console.log(1); +''' diff --git a/test/lang/type-only-imports.test.js b/test/lang/type-only-imports.test.js new file mode 100644 index 00000000..4a86fc7e --- /dev/null +++ b/test/lang/type-only-imports.test.js @@ -0,0 +1,74 @@ +// Type-only import elision, judged by RUNNING the emission. +// +// The defect this guards against was invisible to every compile-only +// check: the face type-checked, `rip check` reported no errors, the JS +// looked reasonable — and the module died at load with `SyntaxError: +// Export named 'X' not found`, because the import named a binding the +// module exports only as a type. Nothing short of loading the emitted +// module can see that, so this test writes both files and imports one. +import { test, expect } from 'bun:test'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { compile } from '../../src/compile.js'; + +// Compile each .rip to .js beside it and return the entry's URL. +const build = (files, entry) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'rip-typeimport-')); + for (const [name, text] of Object.entries(files)) { + const src = path.join(dir, name); + fs.writeFileSync(src, text); + fs.writeFileSync(src.replace(/\.rip$/, '.js'), + compile(text, { path: src }).code.replace(/(from\s*|import\s*)(['"])(\.[^'"]*)\.rip\2/g, '$1$2$3.js$2')); + } + return { dir, url: 'file://' + path.join(dir, entry.replace(/\.rip$/, '.js')) }; +}; + +const LIB = [ + 'export type Shape = { form?: string }', + 'export val = 41', + "console.log('lib ran')", +].join('\n') + '\n'; + +test('a module importing a type-only name loads', async () => { + const { dir, url } = build({ + 'lib.rip': LIB, + 'use.rip': [ + "import { val, Shape } from './lib.rip'", + 'errors: Shape = {}', + 'export answer = val + 1', + 'export seen = errors', + ].join('\n') + '\n', + }, 'use.rip'); + try { + // The import is the assertion: a surviving `Shape` specifier throws + // SyntaxError here, before any of this runs. + const mod = await import(url); + expect(mod.answer).toBe(42); + expect(mod.seen).toEqual({}); + } finally { fs.rmSync(dir, { recursive: true, force: true }); } +}); + +test("a module whose whole clause is types still RUNS the module it imported", async () => { + const { dir, url } = build({ + 'lib.rip': LIB, + 'use.rip': [ + "import { Shape } from './lib.rip'", + 'errors: Shape = {}', + 'export seen = errors', + ].join('\n') + '\n', + }, 'use.rip'); + const logged = []; + const realLog = console.log; + console.log = (...a) => logged.push(a.join(' ')); + try { + const mod = await import(url); + expect(mod.seen).toEqual({}); + // The side effect is the point: eliding every name must not take the + // statement with it, or the module never runs. + expect(logged).toContain('lib ran'); + } finally { + console.log = realLog; + fs.rmSync(dir, { recursive: true, force: true }); + } +}); From 2fa5c3b9bbb2e085098fc61914a8d8e8a0e02e52 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 20:53:14 +0100 Subject: [PATCH 22/23] A container's notify seam is spelled by how the slot got it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dist/browser/rip.js | 147 ++++++++++++++++++------ docs/TYPES.md | 21 +++- packages/vscode/src/server.js | 2 +- src/component-types.js | 42 ++++++- src/dts.js | 4 +- src/emitter.js | 19 ++- test/audit/hover-pins.json | 2 +- test/battery/types.rip | 6 +- test/lang/tsface-tsc.test.js | 6 + test/lang/tsface.test.js | 28 ++--- test/toolchain/dts-tsc.test.js | 2 +- test/toolchain/dts.test.js | 20 ++-- test/toolchain/reactive-imports.test.js | 27 ++++- test/ui/reactive.test.js | 4 +- 14 files changed, 250 insertions(+), 80 deletions(-) diff --git a/dist/browser/rip.js b/dist/browser/rip.js index 0160c914..2c837e36 100644 --- a/dist/browser/rip.js +++ b/dist/browser/rip.js @@ -4279,6 +4279,8 @@ var syncTypeGenericMemo = (tokens, memo) => { memo.answers.set(memo.level, typeAliasEq(tokens, j)); } else if (t.kind === "RESERVED" && t.value === "interface" && atStatementBoundary(tokens, j - 1)) { memo.answers.set(memo.level, true); + } else if (t.kind === "IDENTIFIER" && t.value === "as" && tokens[j - 1] && tokens[j - 1].kind !== "." && tokens[j - 1].kind !== "?." && CAST_LHS_ENDERS.has(tokens[j - 1].kind)) { + memo.answers.set(memo.level, true); } } memo.upTo = tokens.length; @@ -9085,6 +9087,8 @@ function componentTypeInfo(stores, source, node, behavior = null) { } var segmentsText = (segs) => segs.map((s) => s.text).join(""); var containerish = (m) => m.kind === "state" || m.kind === "prop"; +var MINTED = "; touch(): void"; +var TAKEN = "; touch?(): void"; var typeParamNames = (typeParams) => { if (!typeParams) return []; @@ -9132,7 +9136,7 @@ var selfArgsOf = (typeParams) => { const names = typeParamNames(typeParams); return names.length === 0 ? "" : `<${names.join(", ")}>`; }; -var containerType = (t, ro = "") => `{ ${ro}value: ${t}; read(): ${t} }`; +var containerType = (t, ro = "", notify = TAKEN) => `{ ${ro}value: ${t}; read(): ${t}${ro === "" ? notify : ""} }`; var syntacticLiteralType = (v) => { if (typeof v === "string") { if (v === "true" || v === "false") @@ -9206,16 +9210,18 @@ var memberTypeSegments = (m, lead) => { const vt = t ?? "any"; if (m.kind === "accept") return [{ text: `${lead}any` }]; + const readBack = (pre, post) => t !== null ? [{ text: pre }, { text: vt, node: m.node, role: "annotation" }, { text: post }] : [{ text: `${pre}${vt}${post}` }]; if (containerish(m)) { const und = t !== null && m.optional && m.kind === "prop" ? " | undefined" : ""; + const notify = m.isPublic ? TAKEN : MINTED; return [ { text: `${lead}{ value` }, ...typed, - { text: `${und}; read(): ${vt}${und} }` } + ...readBack(`${und}; read(): `, `${und}${notify} }`) ]; } if (m.kind === "computed" || m.kind === "gate") { - return [{ text: `${lead}{ readonly value` }, ...typed, { text: `; read(): ${vt} }` }]; + return [{ text: `${lead}{ readonly value` }, ...typed, ...readBack("; read(): ", " }")]; } if (t === null) return [{ text: `${lead}any` }]; @@ -9322,21 +9328,27 @@ function instanceTypeLines(info, selfType) { const declared = info.roleText(m.func, "returnType"); const base = declared ?? (m.isVoid ? "void" : "any"); const ret = awaitsIn(m.func[2]) && !/^Promise\s*` : base; - lines.push({ text: `${m.name}${renderParams(m.func[1], info.isOptionalParam)}: ${ret};` }); + lines.push({ segs: [{ text: `${m.name}${renderParams(m.func[1], info.isOptionalParam)}: ${ret};` }] }); continue; } lines.push({ - text: `${m.kind === "readonly" ? "readonly " : ""}${m.name}${segmentsText(memberTypeSegments(m, ": "))};`, - ...isBehaviorProjected(m) ? { node: m.nameNode, role: m.nameRole } : {} + node: m.nameNode, + role: m.nameRole, + segs: [ + { text: m.kind === "readonly" ? "readonly " : "" }, + { text: m.name }, + ...memberTypeSegments(m, ": "), + { text: ";" } + ] }); } if (!hasChildren) - lines.push({ text: "children?: any;" }); + lines.push({ segs: [{ text: "children?: any;" }] }); if (info.extendsTag !== null) - lines.push({ text: `rest: ${containerType("Record")};` }); - lines.push({ text: `mount(target?: any): ${selfType};` }); - lines.push({ text: "unmount(options?: { removeDOM?: boolean }): void;" }); - lines.push({ text: "emit(name: string, detail?: any): void;" }); + lines.push({ segs: [{ text: `rest: ${containerType("Record", "", MINTED)};` }] }); + lines.push({ segs: [{ text: `mount(target?: any): ${selfType};` }] }); + lines.push({ segs: [{ text: "unmount(options?: { removeDOM?: boolean }): void;" }] }); + lines.push({ segs: [{ text: "emit(name: string, detail?: any): void;" }] }); return lines; } @@ -9562,6 +9574,7 @@ class Emitter { this.script = script; this.browserModule = browserModule; this.importSpans = []; + this.typeOnlyImports = new Set; this.pins = pins; this.pinnables = []; this.mutables = []; @@ -10628,7 +10641,7 @@ class Emitter { if (!hasChildren) line(() => this.b.emit("declare children: any;")); if (info.extendsTag !== null) - line(() => this.b.emit(`declare rest: ${containerType("Record")};`)); + line(() => this.b.emit(`declare rest: ${containerType("Record", "", MINTED)};`)); line(() => this.b.emit("[key: `_${string}`]: any;")); } tsScaffoldAny(suffix = "") { @@ -10751,10 +10764,18 @@ class Emitter { for (const l of instanceTypeLines(info, `${name}${selfArgs}`)) { this.b.emit(` ` + pad + " "); + const segs = () => { + for (const s of l.segs) { + if (s.node !== undefined) + this.mark(s.node, s.role, () => this.b.emit(s.text)); + else + this.b.emit(s.text); + } + }; if (l.node !== undefined) - this.mark(l.node, l.role, () => this.b.emit(l.text)); + this.mark(l.node, l.role, segs); else - this.b.emit(l.text); + segs(); } this.b.emit(` ` + pad + "}"); @@ -10780,6 +10801,39 @@ class Emitter { })); } static TS_DIRECTIVE = /^#[ \t]*@ts-(expect-error|ignore|nocheck)(?=\s|$)/; + collectTypeOnlyImports(sexpr, source) { + const used = new Set; + const bound = []; + const walk = (x) => { + if (typeof x === "string") { + used.add(x); + return; + } + if (!isNode4(x)) + return; + if (isModuleImportNode(this.stores, x)) { + bound.push(...Emitter.importedNames([x])); + return; + } + for (const c of x) + walk(c); + }; + walk(sexpr); + if (bound.length === 0 || !source) + return; + const inTypes = new Set; + for (const n of this.stores.nodes ?? []) { + for (const r of this.stores.rolesOf(n.nodeId)) { + if (!Emitter.TYPE_ROLES.has(r.role) || typeof r.sourceStart !== "number") + continue; + for (const m of source.slice(r.sourceStart, r.sourceEnd).matchAll(/[A-Za-z_$][\w$]*/g)) + inTypes.add(m[0]); + } + } + for (const name of bound) + if (inTypes.has(name) && !used.has(name)) + this.typeOnlyImports.add(name); + } collectTsDirectives(sexpr, trivia, source) { this.tsDirectiveMap = new Map; this.tsNocheck = null; @@ -11589,6 +11643,7 @@ class Emitter { } return specs; } + static TYPE_ROLES = new Set(["annotation", "returnType", "typeParams"]); static importedNames(imports) { const names = []; for (const node of imports) { @@ -11702,16 +11757,38 @@ class Emitter { moduleSource(s) { return moduleSourceText(s); } + static specifierLocal(s) { + return isNode4(s) ? s[1] : s; + } emitSpecifiers(list) { - list.forEach((s, i) => { - if (i > 0) + const kept = list.filter((s) => !this.typeOnlyImports.has(Emitter.specifierLocal(s))); + let emitted = 0; + list.forEach((s) => { + const erased = this.typeOnlyImports.has(Emitter.specifierLocal(s)); + const one = () => { + if (isNode4(s)) { + this.emitPrimitive(s[0]); + this.b.emit(" as "); + this.emitPrimitive(s[1]); + } else + this.emitPrimitive(s); + }; + if (erased) { + if (!this.ts) + return; + this.b.tsOnly(() => { + if (emitted > 0) + this.b.emit(", "); + one(); + if (emitted === 0 && kept.length > 0) + this.b.emit(", "); + }); + return; + } + if (emitted > 0) this.b.emit(", "); - if (isNode4(s)) { - this.emitPrimitive(s[0]); - this.b.emit(" as "); - this.emitPrimitive(s[1]); - } else - this.emitPrimitive(s); + one(); + emitted++; }); } importStatement(node) { @@ -11724,7 +11801,8 @@ class Emitter { const specs = node.slice(1, -1); this.mark(node, "$self", () => { this.b.emit("import "); - if (specs.length > 0) { + const allErased = specs.length > 0 && specs.every((spec) => spec !== "{}" && (typeof spec === "string" || spec[0] === "*" ? this.typeOnlyImports.has(typeof spec === "string" ? spec : spec[1]) : spec.every((s) => this.typeOnlyImports.has(Emitter.specifierLocal(s))))); + const clause = () => { specs.forEach((spec, i) => { if (i > 0) this.b.emit(", "); @@ -11741,6 +11819,12 @@ class Emitter { } }); this.b.emit(" from "); + }; + if (specs.length > 0) { + if (!allErased) + clause(); + else if (this.ts) + this.b.tsOnly(clause); } { const specStart = this.b.offset; @@ -14516,12 +14600,12 @@ ${pad ?? ""}`); this.mutables.push([nameStart, this.b.offset]); if (this.ts && this.annotationText(node) !== null) { const ro = head === "computed" ? "readonly " : ""; - this.tsAnnotate(node, "annotation", containerType(this.annotationText(node), ro)); + this.tsAnnotate(node, "annotation", containerType(this.annotationText(node), ro, MINTED)); } else if (this.ts) { const t = syntacticLiteralType(value); if (t !== null) { const ro = head === "computed" ? "readonly " : ""; - this.b.tsOnly(() => this.b.emit(`: ${containerType(t, ro)}`)); + this.b.tsOnly(() => this.b.emit(`: ${containerType(t, ro, MINTED)}`)); } } this.b.emit(" "); @@ -19863,7 +19947,7 @@ var RUNTIME_TABLE = [ url: new URL("./runtime/reactive.js", import.meta.url), triggers: (sexpr, preds) => containsReactive(sexpr, preds.isTrigger), types: { - __state: "(value: T | { value: T; read(): T }) => { value: T; read(): T }", + __state: "(value: T | { value: T; read(): T }) => { value: T; read(): T; touch(): void }", __computed: "(fn: () => T) => { readonly value: T; read(): T }", __effect: "(fn: () => void | (() => void)) => () => void", __batch: "(fn: () => T) => T", @@ -20257,6 +20341,7 @@ function emit(parseResult, { source = "", runtimeDelivery = "none", face = "js", throw new Error(`emitter: unknown runtimeDelivery '${runtimeDelivery}' — expected 'none', 'import', or 'inline'`); } emitter.collectTsDirectives(parseResult.sexpr, parseResult.trivia ?? [], source); + emitter.collectTypeOnlyImports(parseResult.sexpr, source); if (emitter.tsNocheck !== null) { const programId = stores.idOf(parseResult.sexpr); const t = emitter.tsNocheck; @@ -20814,7 +20899,7 @@ function emitDeclarations({ sexpr, stores, source }) { if (annotation === null) return; const ro = node[0] === "computed" ? "readonly " : ""; - lines.push(`${exported ? "export " : ""}declare const ${node[1]}: ${containerType(annotation, ro)};`); + lines.push(`${exported ? "export " : ""}declare const ${node[1]}: ${containerType(annotation, ro, MINTED)};`); }; const isReactiveDecl = (stmt) => { if (!isNode5(stmt) || stmt[0] !== "state" && stmt[0] !== "computed" || stmt.length !== 3) @@ -20838,7 +20923,7 @@ function emitDeclarations({ sexpr, stores, source }) { const self = `${name}${selfArgsOf(typeParams)}`; lines.push(`${exp}interface ${name}${typeParams} {`); for (const l of rendered(() => instanceTypeLines(info, self))) - lines.push(` ${l.text}`); + lines.push(` ${segmentsText(l.segs)}`); lines.push("}"); lines.push(`${exp}declare let ${name}: {`); if (gated) { @@ -24849,9 +24934,7 @@ resetSources = function(value, seen) { if (!Object.hasOwn(raw, key)) continue; let nested = raw[key]; - if (key !== SIGNALS) { - result.push(resetSources(nested, seen)); - } + result.push(resetSources(nested, seen)); } return result; })(); @@ -25148,7 +25231,7 @@ function createStash(data = {}) { return makeProxy(data); } function unwrapStash(stash) { - return stash != null && stash[RAW] ? stash[RAW] : stash; + return stash?.[RAW] || stash; } // packages/app/mutation.rip function createMutation(fn, opts = {}) { diff --git a/docs/TYPES.md b/docs/TYPES.md index dd702215..2919c1ab 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -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. diff --git a/packages/vscode/src/server.js b/packages/vscode/src/server.js index 843ca724..3c9130d9 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -2480,7 +2480,7 @@ function presentReactiveCellHover(contents, atMemberDecl = false) { // and anything narrower silently leaves the container standing on every // generic component. The greedy run cannot swallow the type, which is // anchored behind `: { … value: `. - const m = /^(?:(const|let) ([A-Za-z_$][\w$]*)|\(property\) ((?:.+\.)?[A-Za-z_$][\w$]*)): \{ (readonly )?value: (.+); read\(\): (.+?);? \}$/.exec(flat); + const m = /^(?:(const|let) ([A-Za-z_$][\w$]*)|\(property\) ((?:.+\.)?[A-Za-z_$][\w$]*)): \{ (readonly )?value: (.+); read\(\): (.+?)(?:; touch\??\(\): void)?;? \}$/.exec(flat); if (!m) return null; const [, , plain, qualified, ro, t, readT] = m; const member = qualified !== undefined; diff --git a/src/component-types.js b/src/component-types.js index f9ae63e3..ad342f14 100644 --- a/src/component-types.js +++ b/src/component-types.js @@ -241,9 +241,36 @@ export function componentTypeInfo(stores, source, node, behavior = null) { export const segmentsText = (segs) => segs.map((s) => s.text).join(''); // Is a reactive-container member — its runtime slot is a `__state` -// container ({ value: T; read(): T }). +// container ({ value: T; read(): T; touch(): void }). const containerish = (m) => m.kind === 'state' || m.kind === 'prop'; +// A WRITABLE container's notify seam: a nested write (`form.first <=> …`) +// changes no container identity, so the bind notifies the root through +// `touch`. The two spellings answer two different questions, and a +// container position must pick the one that matches how it got its +// container: +// +// MINTED — the slot holds a container `__state` made (a module +// reactive, `rest`, a component's own `:=` member). It has `touch`, +// so the type says so outright and a consumer holding the container +// writes `count.touch()` with no guard. Spelling this optional was +// measured: under `rip.strict` the guardless call draws TS2722 on a +// notify that cannot be absent. +// +// TAKEN — the slot ACCEPTS a container from somewhere else (a prop, a +// bind channel, and the prop's own instance type, since that is the +// accepted container). The sharing contract admits a caller-supplied +// `{ value, read }`, which the runtime treats as a container (the +// `read` predicate) but which has no `touch`, so its nested writes +// notify nothing. Optional is the honest spelling and is why the +// lowering emits `.touch?.()` rather than `.touch()`. +// +// Read-only containers (`~=`) have no `touch` at runtime and spell +// neither. TAKEN is the default: claiming a `touch` that is not there +// rejects containers the runtime accepts, which is the louder failure. +export const MINTED = '; touch(): void'; +export const TAKEN = '; touch?(): void'; + // The container type carries the STRUCTURAL BRAND `read(): T` — the // runtime's own container-detection predicate (`typeof x.read === // 'function'`, src/runtime/reactive.js), spelled into the type. A @@ -310,7 +337,8 @@ export const selfArgsOf = (typeParams) => { return names.length === 0 ? '' : `<${names.join(', ')}>`; }; -export const containerType = (t, ro = '') => `{ ${ro}value: ${t}; read(): ${t} }`; +export const containerType = (t, ro = '', notify = TAKEN) => + `{ ${ro}value: ${t}; read(): ${t}${ro === '' ? notify : ''} }`; // The member's INSTANCE type as segments (`declare name: …` bodies, // interface member lines). The annotated piece marks as `: T` — the @@ -440,9 +468,15 @@ const memberTypeSegments = (m, lead) => { : [{ text: `${pre}${vt}${post}` }]); if (containerish(m)) { const und = t !== null && m.optional && m.kind === 'prop' ? ' | undefined' : ''; + // PUBLIC is the line, not the kind: a member the caller can reach + // takes whatever container arrives on its bind channel, and a + // defaulted prop (`@step: number = 1`) carries kind 'state' while + // `_init` still reads `props.__bind_step__` first. A private member + // is minted here and nowhere else. + const notify = m.isPublic ? TAKEN : MINTED; return [ { text: `${lead}{ value` }, ...typed, - ...readBack(`${und}; read(): `, `${und} }`), + ...readBack(`${und}; read(): `, `${und}${notify} }`), ]; } if (m.kind === 'computed' || m.kind === 'gate') { @@ -653,7 +687,7 @@ export function instanceTypeLines(info, selfType) { // Scaffolding the author never wrote: no source span exists for these, so // they carry no mark and stay under the component's cover. if (!hasChildren) lines.push({ segs: [{ text: 'children?: any;' }] }); - if (info.extendsTag !== null) lines.push({ segs: [{ text: `rest: ${containerType('Record')};` }] }); + if (info.extendsTag !== null) lines.push({ segs: [{ text: `rest: ${containerType('Record', '', MINTED)};` }] }); lines.push({ segs: [{ text: `mount(target?: any): ${selfType};` }] }); lines.push({ segs: [{ text: 'unmount(options?: { removeDOM?: boolean }): void;' }] }); lines.push({ segs: [{ text: 'emit(name: string, detail?: any): void;' }] }); diff --git a/src/dts.js b/src/dts.js index e79bc0b9..dd1cbb23 100644 --- a/src/dts.js +++ b/src/dts.js @@ -46,7 +46,7 @@ import { import { buildSchemaTypeStory, SchemaTypeError } from './schema-types.js'; import { protoMemberTarget, PROTO_GENERIC_PARAMS, moduleSourceText, resolveEnumMembers, isModuleImportNode, ctorAtFields } from './emitter.js'; import { - componentTypeInfo, propsTypeText, propsParamOptional, instanceTypeLines, containerType, + componentTypeInfo, propsTypeText, propsParamOptional, instanceTypeLines, containerType, MINTED, segmentsText, selfArgsOf, anyArgsOf, @@ -328,7 +328,7 @@ export function emitDeclarations({ sexpr, stores, source }) { const annotation = roleType(node, 'annotation'); if (annotation === null) return; const ro = node[0] === 'computed' ? 'readonly ' : ''; - lines.push(`${exported ? 'export ' : ''}declare const ${node[1]}: ${containerType(annotation, ro)};`); + lines.push(`${exported ? 'export ' : ''}declare const ${node[1]}: ${containerType(annotation, ro, MINTED)};`); }; const isReactiveDecl = (stmt) => { diff --git a/src/emitter.js b/src/emitter.js index 60bb4548..60f71e82 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -30,7 +30,7 @@ import { TEMPLATE_TAGS, SVG_ONLY_TAGS, DOM_EVENTS, BOOLEAN_ATTRS, knownBareAttri import { COMPONENT_HOOKS, COMPONENT_RUNTIME_FIELDS, componentTypeInfo, memberDeclareSegments, isDeclarableMember, declaresContainer, - propsTypeSegments, propsTypeText, propsParamOptional, instanceTypeLines, containerType, + propsTypeSegments, propsTypeText, propsParamOptional, instanceTypeLines, containerType, MINTED, syntacticLiteralType, selfArgsOf, anyArgsOf, readonlyCastType, } from './component-types.js'; @@ -2072,7 +2072,7 @@ class Emitter { line(() => this.emitSegments(memberDeclareSegments(m))); } if (!hasChildren) line(() => this.b.emit('declare children: any;')); - if (info.extendsTag !== null) line(() => this.b.emit(`declare rest: ${containerType('Record')};`)); + if (info.extendsTag !== null) line(() => this.b.emit(`declare rest: ${containerType('Record', '', MINTED)};`)); line(() => this.b.emit('[key: `_${string}`]: any;')); } @@ -7328,7 +7328,7 @@ class Emitter { // satisfy a container position the runtime would double-wrap. if (this.ts && this.annotationText(node) !== null) { const ro = head === 'computed' ? 'readonly ' : ''; - this.tsAnnotate(node, 'annotation', containerType(this.annotationText(node), ro)); + this.tsAnnotate(node, 'annotation', containerType(this.annotationText(node), ro, MINTED)); } else if (this.ts) { // Unannotated reactive with a syntactically-evident value type //: the container annotates from the @@ -7340,7 +7340,7 @@ class Emitter { const t = syntacticLiteralType(value); if (t !== null) { const ro = head === 'computed' ? 'readonly ' : ''; - this.b.tsOnly(() => this.b.emit(`: ${containerType(t, ro)}`)); + this.b.tsOnly(() => this.b.emit(`: ${containerType(t, ro, MINTED)}`)); } } this.b.emit(' '); @@ -14387,6 +14387,15 @@ const RUNTIME_TABLE = [ // the pass-through, and the alternative was measured — splitting // it into overloads makes TypeScript report TS2769 against the // last one, which loses the array/tuple spellings' element anchor. + // The RETURN states `touch()`, the minted spelling, because an + // annotated `:=` declares a minted container and this call is its + // initializer — an optional return would not be assignable to it. + // The pass-through arm is where that reaches past what the runtime + // guarantees: a hand-built `{ value, read }` handed to `__state` + // comes back with a `touch` the type promises and the object lacks. + // That arm's own reason for existing is the prop seam, where the + // result lands in a member typed with the TAKEN spelling and the + // promise is narrowed straight back off. key: 'reactive', names: ['__state', '__computed', '__effect', '__batch', '__readonly', '__setErrorHandler', '__handleError', '__catchErrors', 'getEffectSignal'], @@ -14394,7 +14403,7 @@ const RUNTIME_TABLE = [ url: new URL('./runtime/reactive.js', import.meta.url), triggers: (sexpr, preds) => containsReactive(sexpr, preds.isTrigger), types: { - __state: '(value: T | { value: T; read(): T }) => { value: T; read(): T }', + __state: '(value: T | { value: T; read(): T }) => { value: T; read(): T; touch(): void }', __computed: '(fn: () => T) => { readonly value: T; read(): T }', __effect: '(fn: () => void | (() => void)) => () => void', __batch: '(fn: () => T) => T', diff --git a/test/audit/hover-pins.json b/test/audit/hover-pins.json index a001dc78..512199c8 100644 --- a/test/audit/hover-pins.json +++ b/test/audit/hover-pins.json @@ -195,7 +195,7 @@ "line": 72, "character": 8, "token": "label", - "expect": "(property) label?: string | { value: string; read(): string; } | undefined", + "expect": "(property) label?: string | { value: string; read(): string; touch?(): void; } | undefined", "rule": "attr/prop name (component)", "why": "the props surface's real declared type — truthful interim; the container arm rides the bind slot by design" }, diff --git a/test/battery/types.rip b/test/battery/types.rip index 26435aed..aecd8a25 100644 --- a/test/battery/types.rip +++ b/test/battery/types.rip @@ -433,7 +433,7 @@ type "typed constant dts", "MAX: number =! 100", ''' # Reactive state type "reactive state dts", "count: number := 0", ''' - declare const count: { value: number; read(): number }; + declare const count: { value: number; read(): number; touch(): void }; export {}; ''' @@ -443,7 +443,7 @@ type "computed dts", ''' count: number := 0 doubled: number ~= count * 2 ''', ''' - declare const count: { value: number; read(): number }; + declare const count: { value: number; read(): number; touch(): void }; declare const doubled: { readonly value: number; read(): number }; export {}; ''' @@ -494,7 +494,7 @@ type "this-typed interface dts", ''' # Export reactive type "export reactive dts", "export count: number := 0", ''' - export declare const count: { value: number; read(): number }; + export declare const count: { value: number; read(): number; touch(): void }; ''' # Arrow function diff --git a/test/lang/tsface-tsc.test.js b/test/lang/tsface-tsc.test.js index ee16dca1..daafc5fe 100644 --- a/test/lang/tsface-tsc.test.js +++ b/test/lang/tsface-tsc.test.js @@ -184,6 +184,12 @@ const CLEAN_ROWS = [ // the face carries NO duplicate `children` entries (was TS2300 ×4 // + TS2717 ×2 on this five-line legal component) 'Child = component\n @children: string\n render\n div "x"\nHost = component\n msg := "hi"\n render\n section\n Child children: msg\nconsole.log Child, Host', + // A bind into a CHAIN notifies the root container with + // `.touch?.()` — a nested write changes no container identity. The + // member is annotated so the container type is real and the call + // meets a checked receiver; an inferred `any` would type-check + // whatever the face spelled and prove nothing. + 'Form = component\n data: { first: string } := { first: "" }\n render\n input type: "text", value <=> data.first\nconsole.log Form', ]; // The clean rows check as ONE tsc program: one file per row, diff --git a/test/lang/tsface.test.js b/test/lang/tsface.test.js index 57a9286a..4fc2c4a9 100644 --- a/test/lang/tsface.test.js +++ b/test/lang/tsface.test.js @@ -510,7 +510,7 @@ describe('TS-face emission pins', () => { // explicit TYPE ARGUMENT — which checks the initializer and // simultaneously makes the call's return type the annotated // container, so a wrong value publishes once rather than twice. - expect(code).toContain('const count: { value: number; read(): number } = __state(0);'); + expect(code).toContain('const count: { value: number; read(): number; touch(): void } = __state(0);'); expect(code).toContain('const total: { readonly value: number; read(): number } = __computed(() => (count.value * 2));'); expect(code).toContain('const ro: string = "s";'); expect(code).toContain('const h: Function = __effect(() => { console.log(count.value); });'); @@ -947,12 +947,12 @@ describe('the component face (M12-E): TS-only member declares, the props ctor, t test('every member kind declares: state/prop containers, computed readonly, readonly/plain/accept raw', () => { const code = ts(FIXTURE).code; - expect(code).toContain('declare count: { value: number; read(): number };'); // unannotated state: literal initializers infer syntactically - expect(code).toContain('declare label: { value: any; read(): any };'); // bare prop - expect(code).toContain('declare opt: { value: any; read(): any };'); // optional bare prop - expect(code).toContain('declare max: { value: number | undefined; read(): number | undefined };'); // @max?: number — the value may be absent - expect(code).toContain('declare title: { value: string; read(): string };'); // required typed prop - expect(code).toContain('declare step: { value: number; read(): number };'); // typed defaulted prop + expect(code).toContain('declare count: { value: number; read(): number; touch(): void };'); // unannotated state: literal initializers infer syntactically + expect(code).toContain('declare label: { value: any; read(): any; touch?(): void };'); // bare prop + expect(code).toContain('declare opt: { value: any; read(): any; touch?(): void };'); // optional bare prop + expect(code).toContain('declare max: { value: number | undefined; read(): number | undefined; touch?(): void };'); // @max?: number — the value may be absent + expect(code).toContain('declare title: { value: string; read(): string; touch?(): void };'); // required typed prop + expect(code).toContain('declare step: { value: number; read(): number; touch?(): void };'); // typed defaulted prop expect(code).toContain('declare total: { readonly value: number; read(): number };'); // computed expect(code).toContain('declare readonly limit: number;'); // =! members declare readonly // readonly: the raw value expect(code).toContain('declare note: string;'); // plain field: literal initializer infers @@ -970,13 +970,13 @@ describe('the component face (M12-E): TS-only member declares, the props ctor, t test('the props ctor: optional entries with container unions and bind slots; the REQUIRED prop is an arm', () => { const code = ts(FIXTURE).code; - expect(code).toContain('max?: number | { value: number; read(): number }'); - expect(code).toContain('__bind_max__?: { value: number; read(): number }'); + expect(code).toContain('max?: number | { value: number; read(): number; touch?(): void }'); + expect(code).toContain('__bind_max__?: { value: number; read(): number; touch?(): void }'); expect(code).toContain('label?: any'); expect(code).toContain('children?: any'); // @title: string (annotated, no marker, no default) is REQUIRED — // passable as the plain slot or the `<=>` container slot. - expect(code).toContain('& ({ title: string | { value: string; read(): string } } | { __bind_title__: { value: string; read(): string } })'); + expect(code).toContain('& ({ title: string | { value: string; read(): string; touch?(): void } } | { __bind_title__: { value: string; read(): string; touch?(): void } })'); // A required prop makes the ctor's props param required. expect(code).toContain('constructor(props: {'); expect(code).toContain(') { super(props); }'); @@ -1021,7 +1021,7 @@ describe('the component face (M12-E): TS-only member declares, the props ctor, t test('extends: the tag attribute surface + string index in the props type, the rest declare', () => { const code = ts('Deck = component extends section\n name := "n"\n render\n section.deck\n = name\n').code; - expect(code).toContain('declare rest: { value: Record; read(): Record };'); + expect(code).toContain('declare rest: { value: Record; read(): Record; touch(): void };'); // Intrinsic attrs type through the tag's DOM interface with an // extends-Record guard; camelCased DOM twins get // their own entries (tabindex/tabIndex). @@ -1096,10 +1096,10 @@ describe('the component face (M12-E): TS-only member declares, the props ctor, t // The declared prop's entry (+ its bind slot and required arm) // carries the name; the projection-channel fallback suppresses — // a duplicate key is TS2300 on every artifact. - expect(ctorLine).toContain('children?: string | { value: string; read(): string }'); + expect(ctorLine).toContain('children?: string | { value: string; read(): string; touch?(): void }'); expect(ctorLine).not.toContain('children?: any'); expect(faced.code.split('\n').filter((l) => l.includes('declare children')).length).toBe(1); - expect(faced.code).toContain('declare children: { value: string; read(): string };'); + expect(faced.code).toContain('declare children: { value: string; read(): string; touch?(): void };'); expect(stripFace(faced.code, faced.tsRegions)).toBe(js(src).code); }); @@ -1131,7 +1131,7 @@ describe('the component face (M12-E): TS-only member declares, the props ctor, t const src = 'Outer = component\n inner = component\n n := 1\n render\n div "x"\n'; const code = ts(src).code; expect(code).toContain('declare inner: any;'); - expect(code).toContain('declare n: { value: number; read(): number };'); + expect(code).toContain('declare n: { value: number; read(): number; touch(): void };'); expect(code).not.toContain('interface inner'); }); }); diff --git a/test/toolchain/dts-tsc.test.js b/test/toolchain/dts-tsc.test.js index 428b43fe..65231179 100644 --- a/test/toolchain/dts-tsc.test.js +++ b/test/toolchain/dts-tsc.test.js @@ -366,7 +366,7 @@ describeTscExtended('component declarations: consumer programs check against the // while a REAL container (another module's exported reactive, // whose .d.ts carries the same brand) still passes. const reactiveDts = compile('export label: string := "x"').declarations; - expect(reactiveDts).toContain('export declare const label: { value: string; read(): string };'); + expect(reactiveDts).toContain('export declare const label: { value: string; read(): string; touch(): void };'); const bad = [ "import { Counter } from './counter';", "new Counter({ title: { value: 'x' } });", diff --git a/test/toolchain/dts.test.js b/test/toolchain/dts.test.js index d1c30e39..ae25d4d1 100644 --- a/test/toolchain/dts.test.js +++ b/test/toolchain/dts.test.js @@ -343,9 +343,9 @@ describe('component declarations: the class shape, the props surface, the extend '', ].join('\n')).declarations; expect(d).toContain('interface Counter {'); - expect(d).toContain(' count: { value: number; read(): number };'); - expect(d).toContain(' title: { value: string; read(): string };'); - expect(d).toContain(' max: { value: number | undefined; read(): number | undefined };'); + expect(d).toContain(' count: { value: number; read(): number; touch(): void };'); + expect(d).toContain(' title: { value: string; read(): string; touch?(): void };'); + expect(d).toContain(' max: { value: number | undefined; read(): number | undefined; touch?(): void };'); expect(d).toContain(' total: { readonly value: number; read(): number };'); expect(d).toContain(' readonly limit: number;'); // =! members surface readonly expect(d).toContain(' theme: any;'); @@ -356,8 +356,8 @@ describe('component declarations: the class shape, the props surface, the extend expect(d).toContain(' emit(name: string, detail?: any): void;'); expect(d).toContain('declare let Counter: {'); // The required prop's union arm and the bind slot. - expect(d).toContain('& ({ title: string | { value: string; read(): string } } | { __bind_title__: { value: string; read(): string } })'); - expect(d).toContain('__bind_max__?: { value: number; read(): number }'); + expect(d).toContain('& ({ title: string | { value: string; read(): string; touch?(): void } } | { __bind_title__: { value: string; read(): string; touch?(): void } })'); + expect(d).toContain('__bind_max__?: { value: number; read(): number; touch?(): void }'); // A REQUIRED prop suppresses the static mount mirror (the // runtime's static mount constructs with NO props — offering it // would be tsc-clean with a required container holding @@ -386,14 +386,14 @@ describe('component declarations: the class shape, the props surface, the extend // (a plain `{ value: 5 }` would DOUBLE-WRAP). The tsc rejection // cells live in dts-tsc/tsface-tsc. const d = compile('Chip = component\n @size: number := 1\n').declarations; - expect(d).toContain('size: { value: number; read(): number };'); - expect(d).toContain('size?: number | { value: number; read(): number }'); - expect(d).toContain('__bind_size__?: { value: number; read(): number }'); + expect(d).toContain('size: { value: number; read(): number; touch?(): void };'); + expect(d).toContain('size?: number | { value: number; read(): number; touch?(): void }'); + expect(d).toContain('__bind_size__?: { value: number; read(): number; touch?(): void }'); }); test('extends: the attribute surface for the extended tag, the index signature, the rest view', () => { const d = compile('Btn = component extends button\n @label := "go"\n render\n button\n = @label\n').declarations; - expect(d).toContain('rest: { value: Record; read(): Record };'); + expect(d).toContain('rest: { value: Record; read(): Record; touch(): void };'); expect(d).toContain(`disabled?: HTMLElementTagNameMap["button"] extends Record<'disabled', infer T> ? T : any`); // per-tag, DOM-typed expect(d).toContain(`formaction?: HTMLElementTagNameMap["button"] extends Record<'formAction', infer T> ? T : any`); // camelCased DOM twin expect(d).toContain(`id?: HTMLElementTagNameMap["button"] extends Record<'id', infer T> ? T : any`); // global attr, DOM-typed @@ -412,7 +412,7 @@ describe('component declarations: the class shape, the props surface, the extend test('a declared @children prop owns the key in the .d.ts too', () => { const d = compile('export Child = component\n @children: string\n render\n div "x"\n').declarations; - expect(d).toContain('children: { value: string; read(): string };'); + expect(d).toContain('children: { value: string; read(): string; touch?(): void };'); expect(d).not.toContain('children?: any'); // Exactly three manifestations: the interface member, the ctor's // optional entry, the required arm's plain slot (`__bind_children__` diff --git a/test/toolchain/reactive-imports.test.js b/test/toolchain/reactive-imports.test.js index 1a1a9cf7..3334107a 100644 --- a/test/toolchain/reactive-imports.test.js +++ b/test/toolchain/reactive-imports.test.js @@ -108,7 +108,32 @@ describeExtended('a reactive import — what the checker says', () => { ].join('\n')); const out = check('t.rip').stdout; expect(out).toContain('TS2322'); - expect(out).toContain('{ value: number; read(): number; }'); + expect(out).toContain('{ value: number; read(): number; touch(): void; }'); + }); + + // `touch()` is the notify seam a consumer reaches for after mutating + // through `.value` — and on an EXPORTED `:=` the container is one + // `__state` minted, so the call can never meet an absent method. The + // gate is the STRICT posture and nothing else: spelling `touch` + // optional draws TS2722 ("possibly 'undefined'") on this line, and + // strictNullChecks is off in the gradual posture, so a default-mode run + // is quiet whichever way the face spells it and witnesses nothing. + test('a consumer notifies through `touch()` unguarded — under rip.strict too', () => { + const strictDir = mkdtempSync(join(tmpdir(), 'rip-reactive-strict-')); + try { + writeFileSync(join(strictDir, 'package.json'), '{"name":"strict-fixture","rip":{"strict":true}}'); + writeFileSync(join(strictDir, 'tsconfig.json'), readFileSync(TSCONFIG, 'utf8')); + writeFileSync(join(strictDir, 'store.rip'), 'export count: number := 0\n'); + writeFileSync(join(strictDir, 'notify.rip'), [ + "import { count } from './store.rip'", + 'count.touch()', + 'console.log count.value', + '', + ].join('\n')); + const r = spawnSync('bun', [BIN, 'check', 'notify.rip'], { cwd: strictDir, encoding: 'utf8' }); + expect(r.stdout).toContain('No type errors'); + expect(r.status).toBe(0); + } finally { rmSync(strictDir, { recursive: true, force: true }); } }); test('`.value` checks clean — the contract type-checks, not merely runs', () => { diff --git a/test/ui/reactive.test.js b/test/ui/reactive.test.js index 811f9b46..e553e7bf 100644 --- a/test/ui/reactive.test.js +++ b/test/ui/reactive.test.js @@ -402,11 +402,11 @@ describe('typed reactive declarations: erased twins with recorded spans', () => test('declarations surface the CONTAINER type (the cross-module contract); computed is readonly', () => { expect(fullCompile('export count: number := 0').declarations) - .toBe('export declare const count: { value: number; read(): number };\n'); + .toBe('export declare const count: { value: number; read(): number; touch(): void };\n'); expect(fullCompile('export total: number ~= count * 2').declarations) .toBe('export declare const total: { readonly value: number; read(): number };\n'); expect(fullCompile('label: string := "tag"').declarations) - .toBe('declare const label: { value: string; read(): string };\nexport {};\n'); + .toBe('declare const label: { value: string; read(): string; touch(): void };\nexport {};\n'); // Untyped declarations declare nothing (M8's contract). expect(fullCompile('count := 0').declarations).toBe(''); }); From 920b373833af6cd586ef68b6e7e090e05ff99f93 Mon Sep 17 00:00:00 2001 From: Philip Lindberg Date: Wed, 5 Aug 2026 23:14:29 +0100 Subject: [PATCH 23/23] A projection's shape is a name, not just a value MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- dist/browser/rip.js | 112 ++++++++++++++++++------ docs/TYPES.md | 8 ++ src/dts.js | 4 + src/emitter.js | 24 ++++-- src/schema-types.js | 141 ++++++++++++++++++++++++------- src/schema.js | 36 ++++++-- test/battery/schema.rip | 16 ++-- test/lang/tsface-tsc.test.js | 11 +++ test/schema/schema-types.test.js | 47 +++++++++-- 9 files changed, 316 insertions(+), 83 deletions(-) diff --git a/dist/browser/rip.js b/dist/browser/rip.js index 2c837e36..3b83a589 100644 --- a/dist/browser/rip.js +++ b/dist/browser/rip.js @@ -228,6 +228,20 @@ function collectSchemaDecls(programSexpr) { } return out; } +function collectAssignedNames(programSexpr) { + const counts = new Map; + if (!isNode(programSexpr) || programSexpr[0] !== "program") + return counts; + for (const stmt of programSexpr.slice(1)) { + const assign = isNode(stmt) && stmt[0] === "export" && stmt.length === 2 ? stmt[1] : stmt; + if (!isNode(assign) || assign[0] !== "=" || assign.length !== 3) + continue; + if (typeof assign[1] !== "string") + continue; + counts.set(assign[1], (counts.get(assign[1]) ?? 0) + 1); + } + return counts; +} function collectUserTypeNames(programSexpr) { const names = new Map; if (!isNode(programSexpr) || programSexpr[0] !== "program") @@ -499,7 +513,12 @@ function buildSchemaTypeStory(programSexpr) { const decls = collectSchemaDecls(programSexpr); if (decls.length === 0) return null; - const known = new Set(decls.map((d) => d.name)); + const derived = derivedSchemaDescriptors(programSexpr); + const assignedNames = collectAssignedNames(programSexpr); + const known = new Set([ + ...decls.map((d) => d.name), + ...derived.filter((d) => assignedNames.get(d.name) === 1).map((d) => d.name) + ]); const byName = new Map(decls.map((d) => [d.name, d])); const userTypes = collectUserTypeNames(programSexpr); const withModel = decls.some((d) => d.descriptor.kind === "model"); @@ -511,23 +530,25 @@ function buildSchemaTypeStory(programSexpr) { } } const owners = new Map; + const claim = (t, whose, start, at = null) => { + if (SCHEMA_INTRINSIC_NAMES.has(t)) { + throw new SchemaTypeError(`${whose} emits the type name '${t}', which is reserved by the schema ` + `intrinsic declarations (${[...SCHEMA_INTRINSIC_NAMES].join(", ")}) — rename the schema`, start, at); + } + const prior = owners.get(t); + if (prior !== undefined) { + throw new SchemaTypeError(`${whose} emits the type name '${t}', which ${prior} already emits — ` + `every schema-emitted type name binds once per module; rename one`, start, at); + } + owners.set(t, whose); + const user = userTypes.get(t); + if (user !== undefined) { + throw new SchemaTypeError(`${whose} emits the type name '${t}', which collides with ${user.what} — ` + `the schema's types and the user declaration would merge or duplicate; rename one`, null, user.node); + } + }; const stories = []; for (const d of decls) { const story = schemaTypeStory(d, byName, known); - for (const t of story.typeNames) { - if (SCHEMA_INTRINSIC_NAMES.has(t)) { - throw new SchemaTypeError(`schema '${d.name}' emits the type name '${t}', which is reserved by the schema ` + `intrinsic declarations (${[...SCHEMA_INTRINSIC_NAMES].join(", ")}) — rename the schema`, d.descriptor.start ?? null); - } - const prior = owners.get(t); - if (prior !== undefined) { - throw new SchemaTypeError(`schema '${d.name}' emits the type name '${t}', which ${prior} already emits — ` + `every schema-emitted type name binds once per module; rename one`, d.descriptor.start ?? null); - } - owners.set(t, `schema '${d.name}'`); - const user = userTypes.get(t); - if (user !== undefined) { - throw new SchemaTypeError(`schema '${d.name}' emits the type name '${t}', which collides with ${user.what} — ` + `the schema's types and the user declaration would merge or duplicate; rename one`, null, user.node); - } - } + for (const t of story.typeNames) + claim(t, `schema '${d.name}'`, d.descriptor.start ?? null); const defaultTypes = new Map; d.descriptor.entries.forEach((e, i) => { if (e.tag !== "field" || e.constraints?.default === undefined) @@ -537,8 +558,18 @@ function buildSchemaTypeStory(programSexpr) { }); stories.push({ decl: d, ...story, defaultTypes }); } + const derivations = []; + for (const d of derived) { + if (assignedNames.get(d.name) > 1) + continue; + const story = schemaTypeStory({ name: d.name, descriptor: d.descriptor }, byName, known); + for (const t of story.typeNames) + claim(t, `the derived schema '${d.name}'`, null, d.node); + derivations.push({ decl: d, aliasLines: story.aliasLines, constType: story.constType }); + } return { stories, + derivations, intrinsicLines: schemaIntrinsicLines(withModel, withMixin), withModel }; @@ -2352,7 +2383,7 @@ function foldParseStrLit(node) { return null; return inner; } -function foldDerivedSchemas(sexpr) { +function walkDerivedSchemas(sexpr, onFolded) { if (!Array.isArray(sexpr)) return; const head = foldStr(sexpr[0]); @@ -2361,9 +2392,8 @@ function foldDerivedSchemas(sexpr) { for (const stmt of stmts) { if (!Array.isArray(stmt)) continue; - let assign = stmt; - if (foldStr(stmt[0]) === "export" && Array.isArray(stmt[1])) - assign = stmt[1]; + const exported = foldStr(stmt[0]) === "export" && Array.isArray(stmt[1]); + const assign = exported ? stmt[1] : stmt; if (foldStr(assign[0]) !== "=" || !Array.isArray(assign[2])) continue; const name = foldStr(assign[1]); @@ -2382,10 +2412,20 @@ function foldDerivedSchemas(sexpr) { const folded = foldProjectionDescriptor(baseDesc, chain.ops, byName); if (!folded) continue; - assign[2] = ["schema", folded]; byName.set(name, folded); + onFolded({ name, descriptor: folded, node: assign, exported }); } } +function foldDerivedSchemas(sexpr) { + walkDerivedSchemas(sexpr, ({ descriptor, node }) => { + node[2] = ["schema", descriptor]; + }); +} +function derivedSchemaDescriptors(sexpr) { + const out = []; + walkDerivedSchemas(sexpr, (d) => out.push(d)); + return out; +} // src/dom-vocab.js var HTML_TAGS = new Set([ @@ -20498,16 +20538,31 @@ return { ${unit.names.join(", ")} }; }); } emitter.schemaStories = new Map; - story.stories.forEach((s, i) => { - emitter.schemaStories.set(s.decl.node, s); - const exp = s.decl.exported ? "export " : ""; - const nodeId = stores.idOf(s.decl.node); - const tail = i === story.stories.length - 1 ? ` + const blocks = [ + ...story.stories.map((s) => ({ + node: s.decl.node, + exported: s.decl.exported, + story: s, + lines: s.faceAliasLines ?? s.aliasLines + })), + ...story.derivations.map((d) => ({ + node: d.decl.node, + exported: d.decl.exported, + story: null, + lines: d.aliasLines + })) + ]; + blocks.forEach((b, i) => { + if (b.story !== null) + emitter.schemaStories.set(b.node, b.story); + const exp = b.exported ? "export " : ""; + const nodeId = stores.idOf(b.node); + const tail = i === blocks.length - 1 ? ` ` : ` `; builder.tsOnly(() => { - const lines = () => builder.emit((s.faceAliasLines ?? s.aliasLines).map((l) => `${exp}${l}`).join(` + const lines = () => builder.emit(b.lines.map((l) => `${exp}${l}`).join(` `)); if (nodeId !== null) builder.mark(nodeId, "$self", lines); @@ -20690,10 +20745,13 @@ function emitDeclarations({ sexpr, stores, source }) { throw err; } const schemaByNode = new Map; + const schemaDerivedByNode = new Map; if (schemaStory) { lines.push(...schemaStory.intrinsicLines); for (const s of schemaStory.stories) schemaByNode.set(s.decl.node, s); + for (const d of schemaStory.derivations) + schemaDerivedByNode.set(d.decl.node, d); } const schemaDecl = (story, exported) => { const exp = exported ? "export " : ""; @@ -21054,6 +21112,8 @@ function emitDeclarations({ sexpr, stores, source }) { defDecl(stmt, exported); else if (head === "=" && stmt.length === 3 && schemaByNode.has(stmt[2])) { schemaDecl(schemaByNode.get(stmt[2]), exported); + } else if (head === "=" && stmt.length === 3 && schemaDerivedByNode.has(stmt)) { + schemaDecl(schemaDerivedByNode.get(stmt), exported); } else if (head === "=" && stmt.length === 3 && typeof stmt[1] === "string" && isComponentDecl(stmt[2])) { componentDecl(stmt[2], stmt[1], exported, stmt); } else if (head === "=" && stmt.length === 3 && protoMemberTarget(stmt) !== null) { diff --git a/docs/TYPES.md b/docs/TYPES.md index 2919c1ab..a45d4716 100644 --- a/docs/TYPES.md +++ b/docs/TYPES.md @@ -185,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. diff --git a/src/dts.js b/src/dts.js index dd1cbb23..abe2db3e 100644 --- a/src/dts.js +++ b/src/dts.js @@ -85,9 +85,11 @@ export function emitDeclarations({ sexpr, stores, source }) { throw err; } const schemaByNode = new Map(); + const schemaDerivedByNode = new Map(); if (schemaStory) { lines.push(...schemaStory.intrinsicLines); for (const s of schemaStory.stories) schemaByNode.set(s.decl.node, s); + for (const d of schemaStory.derivations) schemaDerivedByNode.set(d.decl.node, d); } // One schema declaration's lines: exported schemas export their @@ -521,6 +523,8 @@ export function emitDeclarations({ sexpr, stores, source }) { else if (isDefHead(head) && stmt.length === 4) defDecl(stmt, exported); else if (head === '=' && stmt.length === 3 && schemaByNode.has(stmt[2])) { schemaDecl(schemaByNode.get(stmt[2]), exported); + } else if (head === '=' && stmt.length === 3 && schemaDerivedByNode.has(stmt)) { + schemaDecl(schemaDerivedByNode.get(stmt), exported); } else if (head === '=' && stmt.length === 3 && typeof stmt[1] === 'string' && isComponentDecl(stmt[2])) { componentDecl(stmt[2], stmt[1], exported, stmt); } else if (head === '=' && stmt.length === 3 && protoMemberTarget(stmt) !== null) { diff --git a/src/emitter.js b/src/emitter.js index 60f71e82..20b5b4b4 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -15039,15 +15039,23 @@ export function emit(parseResult, { source = '', runtimeDelivery = 'none', face }); } emitter.schemaStories = new Map(); - story.stories.forEach((s, i) => { - emitter.schemaStories.set(s.decl.node, s); - const exp = s.decl.exported ? 'export ' : ''; - const nodeId = stores.idOf(s.decl.node); - // The last block carries the blank separator before the - // program's own first line (still inside its region). - const tail = i === story.stories.length - 1 ? '\n\n' : '\n'; + // A derived binding contributes an alias and nothing else, so it + // joins the same block on the same terms — marked under its own + // assignment, and the LAST block of either kind carries the + // separator before the program's first line. + const blocks = [ + ...story.stories.map((s) => ({ node: s.decl.node, exported: s.decl.exported, story: s, + lines: s.faceAliasLines ?? s.aliasLines })), + ...story.derivations.map((d) => ({ node: d.decl.node, exported: d.decl.exported, story: null, + lines: d.aliasLines })), + ]; + blocks.forEach((b, i) => { + if (b.story !== null) emitter.schemaStories.set(b.node, b.story); + const exp = b.exported ? 'export ' : ''; + const nodeId = stores.idOf(b.node); + const tail = i === blocks.length - 1 ? '\n\n' : '\n'; builder.tsOnly(() => { - const lines = () => builder.emit((s.faceAliasLines ?? s.aliasLines).map((l) => `${exp}${l}`).join('\n')); + const lines = () => builder.emit(b.lines.map((l) => `${exp}${l}`).join('\n')); if (nodeId !== null) builder.mark(nodeId, '$self', lines); else lines(); builder.emit(tail); diff --git a/src/schema-types.js b/src/schema-types.js index 325db395..7b704fdb 100644 --- a/src/schema-types.js +++ b/src/schema-types.js @@ -51,6 +51,8 @@ // one is known; dts wraps them as DtsError and the TS face as a // positioned emitter diagnostic. +import { derivedSchemaDescriptors } from './schema.js'; + export class SchemaTypeError extends Error { constructor(message, start = null, node = null) { super(message); @@ -238,6 +240,11 @@ export const schemaIntrinsicLines = (withModel, withMixin = false) => [ // ── collection ─────────────────────────────────────────────────────── +// The projection folder lives with the runtime's own algebra +// (src/schema.js), so the shape a derivation TYPES is the shape the +// runtime BUILDS, computed once. schema.js imports `behaviorName` from +// here; both sides of that cycle are function declarations, reached +// only at call time. const isNode = (x) => Array.isArray(x); const isSchemaNode = (x) => isNode(x) && x[0] === 'schema' && x.length === 2 && @@ -262,6 +269,24 @@ export function collectSchemaDecls(programSexpr) { return out; } +// How many times the module's top level BINDS each name with `=`. +// A companion may only be stated for a name bound exactly once: a +// rebound name holds a different value on different lines, and one +// alias cannot describe both. Counted over every `=` statement rather +// than only the foldable ones, so a name folded once and reassigned +// something unprojectable is caught too. +export function collectAssignedNames(programSexpr) { + const counts = new Map(); + if (!isNode(programSexpr) || programSexpr[0] !== 'program') return counts; + for (const stmt of programSexpr.slice(1)) { + const assign = isNode(stmt) && stmt[0] === 'export' && stmt.length === 2 ? stmt[1] : stmt; + if (!isNode(assign) || assign[0] !== '=' || assign.length !== 3) continue; + if (typeof assign[1] !== 'string') continue; + counts.set(assign[1], (counts.get(assign[1]) ?? 0) + 1); + } + return counts; +} + // User-declared TYPE-SPACE names at module level — everything that // occupies a TS type name in the emitted artifacts: type/interface // declarations, class declarations, and enum declarations (the enum @@ -639,7 +664,26 @@ export function schemaTypeStory(decl, byName, known) { export function buildSchemaTypeStory(programSexpr) { const decls = collectSchemaDecls(programSexpr); if (decls.length === 0) return null; - const known = new Set(decls.map((d) => d.name)); + // 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 and needs nothing from here — but the + // name has no type-space meaning, and `form: UserPublic` reads it as + // one (TS2749, "refers to a value, but is being used as a type"). + // The shape comes from the same folder the browser bundler uses, so a + // derivation's TYPE is what the runtime's own algebra BUILDS rather + // than a second spelling of it, and it is a resolved shape rather + // than a `Pick<…>` the reader has to apply. Folding is conservative + // and bails to nothing on an unknown base, dynamic keys, or a mixin — + // each of those keeps today's behavior, never a wrong companion. + const derived = derivedSchemaDescriptors(programSexpr); + const assignedNames = collectAssignedNames(programSexpr); + // Only names that actually get an alias may be `known`: a field + // rendering a name no alias binds would ship an unresolved + // identifier, which this module's whole rendering contract forbids. + const known = new Set([ + ...decls.map((d) => d.name), + ...derived.filter((d) => assignedNames.get(d.name) === 1).map((d) => d.name), + ]); const byName = new Map(decls.map((d) => [d.name, d])); const userTypes = collectUserTypeNames(programSexpr); const withModel = decls.some((d) => d.descriptor.kind === 'model'); @@ -667,37 +711,45 @@ export function buildSchemaTypeStory(programSexpr) { } const owners = new Map(); // emitted type name → owning description + // One name's claim on the module's type-space namespace. Derived + // bindings claim through here too — their alias occupies the same + // namespace and collides on the same three fronts. + // `at` positions the rejection where no descriptor offset exists: a + // folded descriptor is built fresh and carries none, so a derived + // name's collision would otherwise report against the file instead of + // the line the reader has to change. + const claim = (t, whose, start, at = null) => { + if (SCHEMA_INTRINSIC_NAMES.has(t)) { + throw new SchemaTypeError( + `${whose} emits the type name '${t}', which is reserved by the schema ` + + `intrinsic declarations (${[...SCHEMA_INTRINSIC_NAMES].join(', ')}) — rename the schema`, + start, at); + } + const prior = owners.get(t); + if (prior !== undefined) { + throw new SchemaTypeError( + `${whose} emits the type name '${t}', which ${prior} already emits — ` + + `every schema-emitted type name binds once per module; rename one`, + start, at); + } + owners.set(t, whose); + const user = userTypes.get(t); + if (user !== undefined) { + // Positioned on the USER declaration — the offender the user + // can rename — like the intrinsic-collision path above. The + // reserved family is `${name}Data` / `${name}Create` / + // `${name}Ensure` / `${name}Query` beside the schema's own + // name: a deliberate per-schema namespace reservation. + throw new SchemaTypeError( + `${whose} emits the type name '${t}', which collides with ${user.what} — ` + + `the schema's types and the user declaration would merge or duplicate; rename one`, + null, user.node); + } + }; const stories = []; for (const d of decls) { const story = schemaTypeStory(d, byName, known); - for (const t of story.typeNames) { - if (SCHEMA_INTRINSIC_NAMES.has(t)) { - throw new SchemaTypeError( - `schema '${d.name}' emits the type name '${t}', which is reserved by the schema ` + - `intrinsic declarations (${[...SCHEMA_INTRINSIC_NAMES].join(', ')}) — rename the schema`, - d.descriptor.start ?? null); - } - const prior = owners.get(t); - if (prior !== undefined) { - throw new SchemaTypeError( - `schema '${d.name}' emits the type name '${t}', which ${prior} already emits — ` + - `every schema-emitted type name binds once per module; rename one`, - d.descriptor.start ?? null); - } - owners.set(t, `schema '${d.name}'`); - const user = userTypes.get(t); - if (user !== undefined) { - // Positioned on the USER declaration — the offender the user - // can rename — like the intrinsic-collision path above. The - // reserved family is `${name}Data` / `${name}Create` / - // `${name}Ensure` / `${name}Query` beside the schema's own - // name: a deliberate per-schema namespace reservation. - throw new SchemaTypeError( - `schema '${d.name}' emits the type name '${t}', which collides with ${user.what} — ` + - `the schema's types and the user declaration would merge or duplicate; rename one`, - null, user.node); - } - } + for (const t of story.typeNames) claim(t, `schema '${d.name}'`, d.descriptor.start ?? null); // A field's `[default]` is a bare JS value in the runtime // descriptor, related to the field's declared type by nothing the // checker can see — so the face states the relation: entry index → @@ -719,8 +771,39 @@ export function buildSchemaTypeStory(programSexpr) { }); stories.push({ decl: d, ...story, defaultTypes }); } + + // The companion alias, and ONLY that: the binding keeps the type the + // algebra gives its value, so nothing here re-declares the const and + // a fold that disagreed with the intrinsic signatures could not turn + // into a diagnostic on the user's own line. The two agree by + // construction — `pick` returns `Pick` over the same field set + // the folder projects — and the CLEAN_ROWS in + // test/lang/tsface-tsc.test.js assign each to the other, so a folder + // that drifted fails at the checker rather than shipping a wrong + // shape. + // + // A name the module binds more than once gets NO companion. It has no + // single shape to state — whichever were emitted would describe one + // assignment and misdescribe the other — and a rebound name is legal + // code, so the alternative is worse than silence: two claims on one + // type name would meet the collision check below and REJECT a program + // that compiles fine without any companion at all. + const derivations = []; + for (const d of derived) { + if (assignedNames.get(d.name) > 1) continue; + const story = schemaTypeStory({ name: d.name, descriptor: d.descriptor }, byName, known); + for (const t of story.typeNames) claim(t, `the derived schema '${d.name}'`, null, d.node); + // `constType` is the DECLARATION road's alone. A face has the + // algebra call to infer from and must not re-state it; a .d.ts has + // no call at all, so the value would otherwise vanish while its + // type name shipped — a consumer could annotate `u: UserView` and + // still not call `UserView.parse`. + derivations.push({ decl: d, aliasLines: story.aliasLines, constType: story.constType }); + } + return { stories, + derivations, intrinsicLines: schemaIntrinsicLines(withModel, withMixin), withModel, }; diff --git a/src/schema.js b/src/schema.js index 8532c90f..2b9f4339 100644 --- a/src/schema.js +++ b/src/schema.js @@ -1955,12 +1955,13 @@ function foldParseStrLit(node) { return inner; } -// Walk the program's top-level statements, fold every foldable -// derived-schema assignment in place, and thread folded results into the -// same-file schema map so a later `.extend`/chain can reference an -// already-folded projection. Mutates `sexpr`. Called by compile() only -// when options.foldProjections is set. -export function foldDerivedSchemas(sexpr) { +// Walk the program's top-level statements and evaluate every foldable +// derived-schema assignment, threading each result into the same-file +// schema map so a later `.extend`/chain can reference an already-folded +// projection. `onFolded` receives every one it proves; the walk itself +// never writes to the tree, so a caller that only wants to READ the +// projected shapes leaves the program alone. +function walkDerivedSchemas(sexpr, onFolded) { if (!Array.isArray(sexpr)) return; const head = foldStr(sexpr[0]); const stmts = (head === 'program' || head === 'block') ? sexpr.slice(1) : [sexpr]; @@ -1968,8 +1969,8 @@ export function foldDerivedSchemas(sexpr) { for (const stmt of stmts) { if (!Array.isArray(stmt)) continue; // Unwrap `export ` to reach the assignment node. - let assign = stmt; - if (foldStr(stmt[0]) === 'export' && Array.isArray(stmt[1])) assign = stmt[1]; + const exported = foldStr(stmt[0]) === 'export' && Array.isArray(stmt[1]); + const assign = exported ? stmt[1] : stmt; if (foldStr(assign[0]) !== '=' || !Array.isArray(assign[2])) continue; const name = foldStr(assign[1]); if (typeof name !== 'string') continue; @@ -1988,7 +1989,24 @@ export function foldDerivedSchemas(sexpr) { const folded = foldProjectionDescriptor(baseDesc, chain.ops, byName); if (!folded) continue; // not statically foldable — leave the runtime call - assign[2] = ['schema', folded]; byName.set(name, folded); + onFolded({ name, descriptor: folded, node: assign, exported }); } } + +// Rewrite every foldable derived-schema assignment to a fresh, +// self-contained schema literal. Mutates `sexpr`. Called by compile() +// only when options.foldProjections is set. +export function foldDerivedSchemas(sexpr) { + walkDerivedSchemas(sexpr, ({ descriptor, node }) => { node[2] = ['schema', descriptor]; }); +} + +// The same projections, READ rather than written: the shape each +// derived binding yields, for a consumer that wants the projected type +// while the JS keeps the runtime call (which carries the +// `_sourceModel` back-pointer folding drops). Declaration order. +export function derivedSchemaDescriptors(sexpr) { + const out = []; + walkDerivedSchemas(sexpr, (d) => out.push(d)); + return out; +} diff --git a/test/battery/schema.rip b/test/battery/schema.rip index bb4cf79e..b6855f14 100644 --- a/test/battery/schema.rip +++ b/test/battery/schema.rip @@ -2531,11 +2531,13 @@ type "cross-file relation target degrades to unknown", ''' export {}; ''' -# A derived schema (`Name = Base.pick(...)`) has no `schema` body, so nothing -# declares a bare `Name` type — yet projections are exactly where one is needed -# (annotate `u: UserView`, re-export under a clean name). Emit a bare type that -# reads the algebra's result back off the value's own `parse`, reusing the -# Schema interface inference instead of re-deriving Pick/Omit here. +# A derived schema (`Name = Base.pick(...)`) has no `schema` body, and +# projections are exactly where a bare `Name` type is needed — to annotate +# `u: UserView`, or to re-export under a clean name. The companion is the +# RESOLVED shape, projected by the same folder the browser bundler runs, so +# it reads as a shape rather than as a `Pick<…>` the reader has to apply. +# The declaration road states the const too: it has no algebra call to infer +# from, and a shipped type name whose value never declared is unusable. type "derived schema emits a bare projection type", ''' User = schema :model @@ -2606,6 +2608,8 @@ type "derived schema emits a bare projection type", ''' type UserCreate = { firstName: string; lastName: string }; type User = UserData & { save(): Promise; destroy(opts?: { hard?: boolean }): Promise; ok(): boolean; errors(): SchemaIssue[]; markDirty(name: string): User; savedChanges: Map; toJSON(): UserData }; declare const User: ModelSchema; + type UserView = { id: number; firstName: string }; + declare const UserView: Schema; export {}; ''' @@ -2681,6 +2685,8 @@ type "chained + exported derived schema emits one bare type each", ''' type UserCreate = { firstName: string; lastName: string }; type User = UserData & { save(): Promise; destroy(opts?: { hard?: boolean }): Promise; ok(): boolean; errors(): SchemaIssue[]; markDirty(name: string): User; savedChanges: Map; toJSON(): UserData }; declare const User: ModelSchema; + export type UserView = { id: number; firstName: string }; + export declare const UserView: Schema; export {}; ''' diff --git a/test/lang/tsface-tsc.test.js b/test/lang/tsface-tsc.test.js index daafc5fe..ff3007ee 100644 --- a/test/lang/tsface-tsc.test.js +++ b/test/lang/tsface-tsc.test.js @@ -169,6 +169,17 @@ const CLEAN_ROWS = [ // the algebra generics: a derived schema types through TS's own // Pick/Omit (the face needs no emission for it) 'Base = schema :shape\n a! string\n b? integer\nView = Base.omit("b")\nw = View.parse({})\ns: string = w.a\ns = "z"', + // A derivation's COMPANION and the type its algebra call yields are + // two independent computations of one shape — the folder projects + // the field set, the intrinsic `pick`/`omit` signatures apply TS's + // own Pick/Omit. Assigning each to the other is what makes them one + // claim: were the folder to drift from `projectableFields`, a + // companion naming a field the algebra does not (or missing one it + // does) fails here rather than shipping a wrong shape. The model + // arm carries the implicit columns — id and @timestamps — which is + // where the two field sets are computed most differently. + 'Base = schema :shape\n a! string\n b? integer\nView = Base.omit("b")\nv: View = View.parse({})\nechoed = View.parse({})\nechoed = v\ns: string = v.a\ns = "z"', + 'User = schema :model\n name! string\n @timestamps\nView = User.pick("id", "name", "createdAt")\nv: View = View.parse({})\nechoed = View.parse({})\nechoed = v\ni: number = v.id\nn: string = v.name\nn = "z"\ni = 2', // the component member model — every member kind's declare, // methods/hooks, the ctor and _init props annotations, the // companion interface used as an annotation type diff --git a/test/schema/schema-types.test.js b/test/schema/schema-types.test.js index 9c1e29db..8ac2a05d 100644 --- a/test/schema/schema-types.test.js +++ b/test/schema/schema-types.test.js @@ -175,13 +175,48 @@ describe('schema declarations: the per-kind shapes', () => { expect(dts('f = (a) -> a + 1')).toBe(''); }); - test('boundaries: derived-schema bindings and function-local schemas declare nothing', () => { - // `View = Base.omit(...)` — the face infers it through the - // algebra generics; a shipped declaration would need argument - // re-derivation (recorded future work) + test('a derived binding declares its projected shape AND its const', () => { + // The const is the declaration road's own obligation: a .d.ts has + // no algebra call to infer the value from, so a type name shipped + // without it could be annotated but never called. const d = dts('Base = schema :shape\n a! string\n b? integer\nView = Base.omit("b")'); - expect(d).toContain('type Base = '); - expect(d).not.toContain('View'); + expect(d).toContain('type Base = { a: string; b?: number };'); + expect(d).toContain('type View = { a: string };'); + expect(d).toContain('declare const View: Schema;'); + }); + + test('a REBOUND name gets no companion, and the module still compiles', () => { + // A companion is a claim about one shape, and a name bound twice + // holds two. Silence is the only honest answer — and the only safe + // one: two claims on one type name meet the collision check and + // would reject a program that compiles fine with no companion at + // all. Each of these is legal code, so each must still compile. + const twice = 'A = schema :shape\n a! string\nB = schema :shape\n b! integer\n' + + 'View = A.pick("a")\nView = B.pick("b")\nconsole.log View\n'; + expect(dts(twice)).not.toContain('type View'); + expect(() => dts(twice)).not.toThrow(); + // Counted over EVERY assignment, not only the foldable ones: the + // second write here cannot be projected, and a companion stating + // the first would misdescribe what the binding ends up holding. + const mixed = 'A = schema :shape\n a! string\nk = "a"\n' + + 'View = A.pick("a")\nView = A.pick(k)\nconsole.log View\n'; + expect(dts(mixed)).not.toContain('type View'); + // The control: bound once, companion emitted. + expect(dts('A = schema :shape\n a! string\nView = A.pick("a")')) + .toContain('type View = { a: string };'); + }); + + test('boundaries: an unprovable projection and function-local schemas declare nothing', () => { + // Folding is what makes the projected shape knowable, and it + // refuses to guess: dynamic keys and a base that is not a same-file + // schema each leave the binding with the type its algebra call + // infers and no companion. Emitting one here would state a shape + // nothing computed. + const dynamic = dts('Base = schema :shape\n a! string\n b? integer\nk = "b"\nView = Base.omit(k)'); + expect(dynamic).toContain('type Base = '); + expect(dynamic).not.toContain('View'); + const foreign = dts('Base = schema :shape\n a! string\nView = Imported.omit("b")'); + expect(foreign).not.toContain('View'); // function-local schemas are not module-boundary surface const f = dts('mk = ->\n S = schema :shape\n a! string\n S'); expect(f).toBe('');