diff --git a/dist/browser/rip.js b/dist/browser/rip.js index 08c707c0..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([ @@ -4279,6 +4319,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; @@ -8837,7 +8879,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 @@ -9068,6 +9127,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 []; @@ -9115,7 +9176,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") @@ -9189,16 +9250,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` }]; @@ -9305,21 +9368,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; } @@ -9413,7 +9482,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) => { @@ -9439,7 +9508,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(["<", ">", "<=", ">=", "==", "!="]); @@ -9542,6 +9614,7 @@ class Emitter { this.script = script; this.browserModule = browserModule; this.importSpans = []; + this.typeOnlyImports = new Set; this.pins = pins; this.pinnables = []; this.mutables = []; @@ -10608,7 +10681,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 = "") { @@ -10731,10 +10804,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 + "}"); @@ -10760,6 +10841,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; @@ -11569,6 +11683,7 @@ class Emitter { } return specs; } + static TYPE_ROLES = new Set(["annotation", "returnType", "typeParams"]); static importedNames(imports) { const names = []; for (const node of imports) { @@ -11682,16 +11797,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) { @@ -11704,7 +11841,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(", "); @@ -11721,6 +11859,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; @@ -14496,12 +14640,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(" "); @@ -18379,6 +18523,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; @@ -18403,6 +18548,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; @@ -18423,8 +18571,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); @@ -18835,14 +18983,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}`)); } @@ -18864,13 +19041,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(" "); @@ -18878,17 +19056,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(")"); } }); @@ -19806,7 +19987,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", @@ -20200,6 +20381,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; @@ -20356,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); @@ -20400,6 +20597,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 {}; @@ -20424,7 +20658,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 @@ -20511,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 " : ""; @@ -20602,6 +20839,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)) { @@ -20640,7 +20891,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); @@ -20706,7 +20957,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) @@ -20730,7 +20981,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) { @@ -20861,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) { @@ -21017,6 +21270,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, @@ -24740,9 +24994,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; })(); @@ -25039,7 +25291,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 39a915f7..a45d4716 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. @@ -172,6 +185,14 @@ Schema descriptors project into: - callable `this` types; - enum and union outputs. +A derived binding (`UserPublic = User.pick('id', 'email')`) gets a type +companion under its own name, so it can be annotated and re-exported +like any declared schema. The companion is the resolved shape, projected +by the same folder the browser bundler uses, so a derivation types as +what the runtime builds. The projection is conservative: an unknown +base, dynamic keys, or a `@mixin` base yields no companion rather than a +guessed one, and the binding keeps the type its algebra call infers. + Runtime delivery and type rendering are separate: using schema syntax delivers the runtime machinery while the TS face and declarations carry the static contract. @@ -191,6 +212,21 @@ It produces: The face and declaration paths consume the same model. +## Declared globals + +A top-level `globalThis.NAME ??= expr` declares the global. The `??=` +spelling says "install unless someone already did" — DSL vocabulary, +like stamp's `sh`/`ok`/`run` — and the face emits the typed declaration +for it: `typeof` the initializer when it is an identifier, `any` +otherwise. Plain `=` and non-top-level installs declare nothing on +purpose: a test overwriting `globalThis.fetch` and an app's guarded, +destroy-cleared lifecycle globals are not vocabulary. + +The declaring package becomes its own program in the mirror (an +automatic project boundary), so the vocabulary stays package-scoped and +reaches importers the way the runtime does — importing the module runs +the installer. A non-importing neighbor keeps its cannot-find. + ## Editor pipeline The VS Code/Cursor extension: @@ -208,6 +244,45 @@ hashes. It is never committed or shipped. Synthetic generated ranges do not receive fabricated Rip positions. Diagnostics without an honest source mapping are dropped. +## Diagnostic publishing + +Every mode checks the same always-on program; modes differ in which +diagnostics publish. + +Gradual — the default — publishes a diagnostic only where type +information reaches its mapped source line: an annotation in the +declaration's header, the compiler's own types (schemas, components), +flow along assignment, or an import of a typed export (annotated `.rip` +exports, relative `.ts` modules, and bare workspace `.rip` packages). +Inference alone never publishes, and an annotation is how you ask for +more. What inference produces over unannotated Rip is dominated by +confident errors about correct code: a parameter is typed from its +`= {}` default, so every legitimate `opts.foo` reads as "does not exist +on type `{}`"; an object built by spread reads as closed to the key set +it was built with; a Bun API is unknown for want of `@types/bun`. Those +land exactly where the author declined to annotate, and no edit but an +annotation answers them. The case the other side would catch — +`answer = 42` later misused as a string — is genuine but was not found +anywhere in this repository. The gate lives in +`packages/vscode/src/scopes.js`, shared verbatim by the editor and +`rip check`, and it fails OPEN: a source the lexer refuses publishes +everything. + +Names and modules that do not resolve, and definition cycles, publish +in every mode — defects no annotation answers. One exception spells the +difference between a typo and stated intent: a bare import DECLARED in +the governing package.json but not installed is held under gradual +(`rip check` counts it with the install remedy), while strict publishes +it; undeclared-and-uninstalled stays a defect everywhere. Gradual rides +TypeScript 7's default `strict` and subtracts only what it deliberately +loosens: `strictNullChecks` (the lever that changes types, not just +which diagnostics publish), `useUnknownInCatchVariables` (an +unannotated `catch` answers `any`), and `noImplicitThis` (`this` in an +unannotated object-literal method is `any`). Everything else — present +and future strict-family members — stays on, so new strictness arrives +as a visible leak, never as silent inference loss. Any strictness the +project's own tsconfig chain sets is yielded to whole. + ## Project configuration The nearest `package.json` is the project boundary. Its `rip` object @@ -227,11 +302,27 @@ use-before-assignment checking for typed forwards. `noCheck` suppresses diagnostics for matching paths while keeping those files in the TypeScript program so imports continue to resolve. +A nested package whose mode FLIPS against its parent package's becomes +its own program in the mirror (the same automatic boundary a +globals-declaring package gets): host floors and null posture are +per-program, so the package's own mode governs them. The flip cuts both +ways — a strict package inside a gradual workspace gets its complaints +(an unresolvable `bun:sqlite` instead of a floored `any`), and a +gradual package inside a strict workspace keeps its loose base instead +of riding strict nulls and refused floors. + Configuration changes refresh open editor documents without a window reload. `rip check [paths...]` applies the same project configuration, materializes the same TypeScript faces and import closure, and translates diagnostics through the same mapping seam without starting an editor. +A check answers for the paths it was given. The closure is compiled and +checked whole — a target's types cannot resolve otherwise — but a +dependency's own diagnostics report through its own check, counted here +in one summary line instead. The editor draws the same line by a +different rule: it publishes per open document, so a dependency stays +silent until you open it. + ## Correctness gates - **Erasure:** typed and untyped twins emit identical JavaScript. @@ -243,6 +334,11 @@ diagnostics through the same mapping seam without starting an editor. and produce their required TS regions. - **Audit:** real Rip fixtures compare compilation, diagnostics, runtime behavior, and editor answers against TypeScript twins. +- **Suppression matrix:** the audit's gradual pair + (`test/audit/corpus/gradual/`) asserts every family gradual holds + publishes nothing in gradual AND still publishes under strict — a + family quiet in both modes is a failure — plus the published set, + pinned per line, with `rip check` and the editor answering alike. - **Mapping:** annotations, diagnostics, hover, and definitions round-trip through exact UTF-16 offsets. @@ -258,6 +354,7 @@ diagnostics through the same mapping seam without starting an editor. | schema type rendering | `src/schema-types.js` | | component type rendering | `src/component-types.js` | | editor broker | `packages/vscode/src/` | +| diagnostic gate | `packages/vscode/src/scopes.js` | | type gates | `test/lang/`, `test/toolchain/`, `test/audit/` | Open type/editor work is tracked in [ROADMAP.md](ROADMAP.md). diff --git a/examples/cart/app/routes/cart.rip b/examples/cart/app/routes/cart.rip index b692e9ea..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' @@ -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..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' @@ -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' 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 c649deae..fe0d0d78 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 an empty set and reloads for ordinary assets", -> 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 } } diff --git a/packages/server/manager.rip b/packages/server/manager.rip index 38dcadd4..d6756ee8 100644 --- a/packages/server/manager.rip +++ b/packages/server/manager.rip @@ -22,7 +22,7 @@ REGISTER_409_MS = positiveEnv 'RIP_REGISTER_409_MS', 30000 parseArgs = (argv) -> command = if ['browse', 'status', 'stop', 'hold', 'release', 'migrate', 'recover'].includes(argv[0]) then argv[0] else 'start' - args: any = { command, entry: null, migrationEntry: null, migrationDir: 'migrations', operationId: null, name: null, hosts: [], control: process.env.JANUS_CONTROL or null, workers: 2, concurrency: 1, watch: null, allowWatch: false, eager: false, untilRestart: false, accessMode: 'pretty', accessPicture: null, accessExplicit: false, startupFlags: [] } + args = { command, entry: null, migrationEntry: null, migrationDir: 'migrations', operationId: null, name: null, hosts: [], control: process.env.JANUS_CONTROL or null, workers: 2, concurrency: 1, watch: null, allowWatch: false, eager: false, untilRestart: false, accessMode: 'pretty', accessPicture: null, accessExplicit: false, startupFlags: [] } i = if command is 'start' then 0 else 1 while i < argv.length value = argv[i] diff --git a/packages/vscode/src/diagnostics.js b/packages/vscode/src/diagnostics.js index 595a9e11..9171b849 100644 --- a/packages/vscode/src/diagnostics.js +++ b/packages/vscode/src/diagnostics.js @@ -13,6 +13,8 @@ import { offsetToPosition, positionToOffset, generatedSpanToSource, SUPPRESSED_TS_CODES, diagnosticTagsFor, } from './translate.js'; +import { ALWAYS_REPORTED_CODES } from './scopes.js'; +import { declaredButUninstalled } from './mirror.js'; // A CompileError → { reason, start, end } in SOURCE offsets: the first // message line with its `path:line:col:` prefix stripped (the excerpt @@ -46,10 +48,27 @@ export function compileErrorInfo(err, textLength) { // related location itself rather than routing it through here. export function mapTsDiagnostic(good, d) { if (!good.strict && SUPPRESSED_TS_CODES.has(d.code)) return null; + // Installation pressure: a bare import the governing manifest declares + // but nobody installed. Gradual holds it (the CLI counts it with the + // install remedy); strict publishes. `good.dir` is the source file's + // own dir — absent (older callers) leaves the defect published. + if (!good.strict && d.code === 2307 && good.dir) { + const spec = /Cannot find module '([^']+)'/.exec(d.message)?.[1]; + if (spec && declaredButUninstalled(spec, good.dir)) return null; + } const s = positionToOffset(good.genLineStarts, good.code.length, d.range.start); const e = positionToOffset(good.genLineStarts, good.code.length, d.range.end); const span = generatedSpanToSource(good.mappings, s, e); if (!span) return null; + // The DECLARATION-SCOPE gate. Judged on the MAPPED source position, so it + // must follow the mapping: the question is which .rip declaration the + // author would see this on, not where it landed in the face. A strict + // project is ungated, and a name/module that does not resolve reports + // either way — see scopes.js. + if (!good.strict && good.checkedLines && !ALWAYS_REPORTED_CODES.has(d.code)) { + const line = offsetToPosition(good.srcLineStarts, span[0]).line; + if (!good.checkedLines[line]) return null; + } // tsgo supplies Unnecessary/Deprecated tags itself over the pull slot; // diagnosticTagsFor is the fallback for any item tsgo leaves untagged // (a batch `tsc` run carries none), so VS Code renders the unused/ @@ -154,6 +173,27 @@ export function ripDirectiveLines(good) { return good._directiveLines; } +// Is `a` within `b`? +const inside = (a, b) => + (a.start.line > b.start.line || (a.start.line === b.start.line && a.start.character >= b.start.character)) && + (a.end.line < b.end.line || (a.end.line === b.end.line && a.end.character <= b.end.character)); + +// One claim can also land on spans that NEST rather than match: a component +// member's type is rendered twice (the class declare and the companion +// interface), and the two renderings mark different extents of the same +// annotation — `: T` against `T`. Same code, severity and message over a +// containing range is the same claim, so the narrowest span keeps it and +// exact ties go to the first. +// +// This runs LAST, after directives have been charged. A directive is +// charged by a diagnostic STARTING on its governed line, so collapsing +// first could retire the only row that starts there and leave the +// directive reading unused. +const collapseNested = (rows) => rows.filter((m, i) => !rows.some((o, j) => j !== i + && o.code === m.code && o.severity === m.severity && o.message === m.message + && inside(o.range, m.range) + && (!inside(m.range, o.range) || j < i))); + export function applyRipDirectives(good, mapped) { // A lowering can manifest one source error at several face positions. // Once mapping collapses them to the same code, severity, range, and @@ -166,7 +206,7 @@ export function applyRipDirectives(good, mapped) { return true; }); const directives = ripDirectiveLines(good); - if (directives.length === 0) return mapped; + if (directives.length === 0) return collapseNested(mapped); const is2578 = (m) => String(m.code) === '2578'; const used = new Set(); const survivors = []; @@ -195,7 +235,7 @@ export function applyRipDirectives(good, mapped) { // directive is genuinely used — an ERROR landed on its governed line (a // mis-governed multi-line face directive whose leaked error we suppressed // over rip positions). Otherwise it survives: unused stays loud. - return survivors.filter((m) => !(is2578(m) && used.has(m.range.start.line))); + return collapseNested(survivors.filter((m) => !(is2578(m) && used.has(m.range.start.line)))); } // A rip.noCheck glob → anchored regex, matched against a project-root- diff --git a/packages/vscode/src/mirror.js b/packages/vscode/src/mirror.js index 91f5814a..8ab7a123 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,36 +74,72 @@ 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. +// 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. +// 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 { strictNullChecks: false, useUnknownInCatchVariables: false, noImplicitThis: 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'); @@ -123,28 +150,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 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) => { @@ -153,16 +185,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) { @@ -178,34 +205,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)) { @@ -215,63 +230,75 @@ 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. -export function projectWrapper({ wrapperDir, sourceTsconfig, chain = new Set(), onUnresolved }) { - const sourceDir = path.dirname(sourceTsconfig); +// 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; + const anchor = sourceTsconfig ?? (rootConfig && fs.existsSync(rootConfig) ? rootConfig : null); const overrides = { noImplicitAny: true, noEmit: true, allowImportingTsExtensions: true, + ...nullPosture(sourceDir, anchor), rootDirs: ['.', posix(path.relative(wrapperDir, sourceDir))], }; + // 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; + } 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 }), }; } 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 - // 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. - const exclude = ['node_modules', ...excludeDirs.map((d) => `${posix(d)}/**`)]; + // 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: 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; const userConfig = !mirrorRootIsFallback && workspaceRoot ? path.join(workspaceRoot, 'tsconfig.json') : null; @@ -299,12 +326,55 @@ 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'`. +// 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 +// __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); @@ -312,29 +382,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', @@ -345,36 +406,37 @@ 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(); const stars = []; + const globals = new Set(); // top-level `globalThis.NAME ??=` — declared vocabulary let hasDefault = false; 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 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 let rest = lines[i].slice('export'.length).trim(); @@ -390,21 +452,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(',')) { @@ -432,10 +488,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]; @@ -454,65 +509,45 @@ 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); 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 -// 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. -export function stubFace({ values = [], types = [], hasDefault = false, defaultName = '_default' } = {}) { +// 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, `any` until the real face materializes. + 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'; } -// 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', @@ -531,8 +566,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 @@ -546,14 +581,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) { @@ -565,12 +597,12 @@ 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({ values: v, types: t, hasDefault: scan.hasDefault, + globals: scan.globals ?? [], defaultName: defaultLocalName(file, new Set([...v, ...t])), })); } @@ -588,8 +620,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) => { @@ -626,10 +658,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; @@ -670,25 +702,139 @@ export function typeImportSpecifiers(text) { return specs; } -// 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` 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; + 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: +// 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; + 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 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; + 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; +} + +// 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'; + +// 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) => { - if (!spec.endsWith('.rip')) return; - if (!spec.startsWith('./') && !spec.startsWith('../')) return; - const abs = path.resolve(fromDir, spec); + // 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; + 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..49418661 --- /dev/null +++ b/packages/vscode/src/scopes.js @@ -0,0 +1,611 @@ +// 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. 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` — +// 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 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 +// 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. +// +// 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 +// 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 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 +// 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: 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 +// 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; + // 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; + // 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); + 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 19ff0b7f..3c9130d9 100644 --- a/packages/vscode/src/server.js +++ b/packages/vscode/src/server.js @@ -72,7 +72,8 @@ import { SUPPRESSED_TS_CODES, } from './translate.js'; import { mapTsDiagnostic, applyRipDirectives, isNoCheckPath, compileErrorInfo } from './diagnostics.js'; -import { generatedMirror as buildGeneratedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf, scanExportNames, stubFacesFromScans } from './mirror.js'; +import { scopeGateOf, typedExportsOf, typedImportsOf } from './scopes.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). @@ -87,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; @@ -97,6 +99,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 +151,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,9 +163,11 @@ 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) +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(); @@ -299,7 +335,8 @@ function writeProjectWrapper(rel, sourceTsconfig) { const wrapperDir = path.join(mirrorRoot, rel); const chain = new Set(); const wrapper = projectWrapper({ - wrapperDir, sourceTsconfig, 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:["*"]`), }); @@ -318,6 +355,47 @@ 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 []; + 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 []; + // 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 []; + 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}: the package becomes its own program (declared globals or rip.strict)`); + return written; +} + function ensureProjectWrapper(fsPath) { if (mirrorRootIsFallback || !workspaceRoot || !mirrorRootReady) return []; const owner = nearestTsconfig(path.dirname(fsPath), workspaceRoot); @@ -608,6 +686,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; } @@ -619,13 +763,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, @@ -635,6 +805,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; @@ -662,10 +837,25 @@ const enumNamesOf = (result) => function mirrorFromDisk(fsPath, source) { faceCache.delete(fsPath); - const result = compile(source, { path: fsPath, runtimeDelivery: 'inline', face: 'ts' }); + if (!mirrorRootIsFallback) linkNestedNodeModules(workspaceRoot, mirrorRoot, fsPath); + const result = rawCompile(fsPath, source, hashText(source)); const mirrorPath = mirrorPathOf('file://' + fsPath); warnOnMirrorCollision(mirrorPath, fsPath); writeMirror(mirrorPath, result.code); + // 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', { + 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, @@ -1051,7 +1241,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; } @@ -1080,10 +1270,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)) { @@ -1097,16 +1294,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 })), + ], }); } } @@ -1204,11 +1405,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(); @@ -1256,9 +1459,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 @@ -1501,6 +1714,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 @@ -1514,6 +1735,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}`); @@ -1551,6 +1773,16 @@ async function refresh(document) { scheduleManifestSave(); } const wrapperFiles = ensureProjectWrapper(fsPath); + // 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) { tsgo.client.notify('workspace/didChangeWatchedFiles', { changes: wrapperFiles.map((p) => ({ uri: 'file://' + p, type: FileChangeType.Changed })), @@ -1814,6 +2046,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) { @@ -1859,6 +2092,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 @@ -1969,7 +2203,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 @@ -2226,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; @@ -2312,15 +2566,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/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]); +}); diff --git a/packages/vscode/test/editor-features.test.js b/packages/vscode/test/editor-features.test.js index c6a56d4b..f3eea32f 100644 --- a/packages/vscode/test/editor-features.test.js +++ b/packages/vscode/test/editor-features.test.js @@ -136,7 +136,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', {}); @@ -415,7 +418,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. @@ -423,24 +465,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..39973e28 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); @@ -270,8 +277,31 @@ 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': 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 +367,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 +376,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 +390,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 +404,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 +651,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 +823,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..2a356430 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); @@ -556,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/check.js b/src/check.js index 89e06c81..31492307 100644 --- a/src/check.js +++ b/src/check.js @@ -21,16 +21,35 @@ 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'; 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 { generatedMirror, projectWrapper, nearestTsconfig, HOST_FLOOR_NAME, mirrorRelForFsPath, ripImportsOf } from '../packages/vscode/src/mirror.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, missingModuleRead, 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 +// 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: @@ -41,44 +60,59 @@ 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 + mismatch means the installed extension is stale -h, --help Show this help 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 +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); }; // ── 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', '--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 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 (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 ─────────────────────────────────────────────── @@ -124,17 +158,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 : ['.']); @@ -145,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 @@ -165,7 +239,35 @@ 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; +// 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(); +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]; while (queue.length) { const fsPath = queue.shift(); @@ -174,8 +276,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; @@ -194,12 +308,13 @@ while (queue.length) { continue; } compiled.set(fsPath, { - source, cfg, + source, cfg, result, good: { source, code: result.code, mappings: result.mappings, echoSpans: result.echoSpans ?? [], srcLineStarts, genLineStarts: lineStartsOf(result.code), strict: cfg.strict === true, + dir: path.dirname(fsPath), }, pinnables: result.pinnables ?? [], }); @@ -208,29 +323,26 @@ 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 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. - 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; - } - 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'; @@ -240,6 +352,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 @@ -256,8 +369,39 @@ if (compiled.size > 0) { wrapperRels.add(path.relative(workspaceRoot, path.dirname(owner))); } } + // 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 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; + 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; } + if (dir === workspaceRoot || path.dirname(dir) === dir) break; + } + } + if (pkgDir === null || pkgDir === workspaceRoot || !pkgDir.startsWith(workspaceRoot + path.sep)) continue; + const rel = path.relative(workspaceRoot, pkgDir); + // 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({ - workspaceRoot, mirrorRootIsFallback, excludeDirs: [...wrapperRels], + 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); @@ -265,6 +409,17 @@ 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)); + fs.writeFileSync(path.join(wrapperDir, HOST_FLOOR_NAME), wrapper.hostFloorDts); + } + for (const rel of autoBoundaryRels) { + 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)); @@ -402,9 +557,45 @@ 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); + // 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)) { + // 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)) || '.'; + 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]; + if (name) missingTypeNames.add(name); + } + // Held by the declaration-scope gate: the author annotated + // nothing here, so nothing is asked of them. + else { hiddenScope++; hiddenScopeDirs.add(proj); } + } if (!m) continue; // The diagnostic carries its own relatedInformation (secondary // "declared here" locations), each mapped from its generated @@ -413,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, @@ -535,7 +740,50 @@ 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 || 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`)); + } + if (hiddenAnnotations > 0) { + 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(', '); + 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\`)`)); + } } // Exit: 1 on type errors; 2 when the run could not cover what was asked — diff --git a/src/compile.js b/src/compile.js index 97dbd493..2995bdba 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/component-types.js b/src/component-types.js index e2fb8ab7..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 @@ -431,15 +459,28 @@ 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' : ''; + // 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, - { 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` }]; return typed; // readonly / plain: the annotation IS `: T` @@ -618,18 +659,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', '', 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; } diff --git a/src/dts.js b/src/dts.js index 1440d313..abe2db3e 100644 --- a/src/dts.js +++ b/src/dts.js @@ -46,7 +46,8 @@ 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, } from './component-types.js'; @@ -84,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 @@ -192,6 +195,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 +256,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 @@ -312,7 +330,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) => { @@ -351,7 +369,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) { @@ -505,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 64182ce8..20b5b4b4 100644 --- a/src/emitter.js +++ b/src/emitter.js @@ -25,12 +25,12 @@ 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, declaresContainer, - propsTypeSegments, propsTypeText, propsParamOptional, instanceTypeLines, containerType, + propsTypeSegments, propsTypeText, propsParamOptional, instanceTypeLines, containerType, MINTED, syntacticLiteralType, selfArgsOf, anyArgsOf, readonlyCastType, } from './component-types.js'; @@ -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, @@ -377,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 @@ -2051,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;')); } @@ -2278,14 +2299,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 + '}'); }); @@ -2376,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; @@ -3617,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) { @@ -3768,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++; }); } @@ -3799,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('{}'); @@ -3812,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; @@ -7206,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 @@ -7218,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(' '); @@ -12252,6 +12374,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 @@ -12276,6 +12400,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; @@ -12306,7 +12435,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 @@ -12317,8 +12446,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. @@ -12328,8 +12457,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 @@ -12850,8 +12982,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). @@ -12861,12 +13035,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 @@ -12898,6 +13086,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', () => { @@ -12905,7 +13096,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(' '); @@ -12916,9 +13107,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 @@ -12926,10 +13121,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(')'); } }); @@ -14191,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'], @@ -14198,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', @@ -14619,6 +14824,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; @@ -14833,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); @@ -14876,6 +15090,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 @@ -14912,7 +15173,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/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/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/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/audit/contract.js b/test/audit/contract.js index 15598abb..4562e9ad 100644 --- a/test/audit/contract.js +++ b/test/audit/contract.js @@ -241,6 +241,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/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/audit/runner.js b/test/audit/runner.js index 44d88568..4a34510b 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 @@ -3396,6 +3403,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 @@ -3640,6 +3648,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 } @@ -4746,7 +4890,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/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); + } + ''' 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/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/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/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": [ + "**" + ] + } +} diff --git a/test/lang/tsface-tsc.test.js b/test/lang/tsface-tsc.test.js index ee16dca1..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 @@ -184,6 +195,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 232835ac..4fc2c4a9 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', () => { @@ -449,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); });'); @@ -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}', ); }); @@ -886,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 @@ -909,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); }'); @@ -960,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). @@ -1035,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); }); @@ -1070,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'); }); }); @@ -1290,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/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 }); + } +}); 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(''); diff --git a/test/toolchain/audit-contract.test.js b/test/toolchain/audit-contract.test.js index 2e7db1f8..a9000cf7 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, untyped: 0 }, tk: { missing: [], badType: [], badReadonly: [], survDrops: [], survUnclassified: 0, unexplained: [], exclusionDrift: [], facesAvailable: true }, }); @@ -144,6 +145,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.typed': (s) => { s.hp.untyped = 1; }, 'hover.silence': (s) => { s.hp.silentLeaks = 1; }, diff --git a/test/toolchain/check.test.js b/test/toolchain/check.test.js index d0ac3808..8ab279f5 100644 --- a/test/toolchain/check.test.js +++ b/test/toolchain/check.test.js @@ -59,11 +59,63 @@ 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 }; } +// 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' }); @@ -81,6 +133,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 { @@ -363,7 +425,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 +433,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 +587,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"]', @@ -550,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'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 = [ + '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 @@ -582,6 +685,305 @@ 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()', + "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')); + 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. 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); + + // 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); + + // 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 + // 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, 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/*'] }), + '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); + + // 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); + + // 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 + // 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); + // 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 +998,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 +1049,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); @@ -1320,27 +1749,61 @@ 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); + 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); - // 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); + 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); - // 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)', () => { + // 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', () => { const dir = workspace({ 'a.rip': 'x: number = 0\nconsole.log x\n' }); try { const editorDir = path.join(dir, '.rip', 'editor'); @@ -1348,29 +1811,91 @@ 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); - // 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) @@ -1413,6 +1938,112 @@ 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); + + // 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 { + 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/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/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 a6b258b1..ae25d4d1 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'], [ @@ -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/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..3334107a 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 }); }); @@ -104,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(''); });