rip-lang 3.16.0 → 3.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +3 -4
  2. package/bin/rip +201 -18
  3. package/bin/rip-schema +175 -0
  4. package/docs/AGENTS.md +1 -1
  5. package/docs/RIP-APP.md +200 -19
  6. package/docs/RIP-DUCKDB.md +64 -1
  7. package/docs/RIP-INTRO.md +4 -4
  8. package/docs/RIP-LANG.md +36 -38
  9. package/docs/RIP-SCHEMA.md +1204 -364
  10. package/docs/RIP-TYPES.md +74 -103
  11. package/docs/demo/README.md +4 -3
  12. package/docs/dist/rip.js +4195 -966
  13. package/docs/dist/rip.min.js +1161 -284
  14. package/docs/dist/rip.min.js.br +0 -0
  15. package/docs/example/index.json +7 -7
  16. package/docs/example/index.json.br +0 -0
  17. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  18. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  19. package/docs/extensions/vscode/rip/index.html +2 -1
  20. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  21. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  22. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  23. package/docs/index.html +1 -1
  24. package/docs/ui/bundle.json +55 -55
  25. package/docs/ui/bundle.json.br +0 -0
  26. package/docs/ui/hljs-rip.js +1 -1
  27. package/docs/ui/index.html +1 -1
  28. package/package.json +15 -7
  29. package/rip-loader.js +59 -2
  30. package/src/AGENTS.md +43 -12
  31. package/src/browser.js +52 -11
  32. package/src/compiler.js +538 -80
  33. package/src/components.js +488 -48
  34. package/src/dts.js +80 -50
  35. package/src/grammar/README.md +29 -170
  36. package/src/grammar/grammar.rip +17 -12
  37. package/src/grammar/solar.rip +4 -17
  38. package/src/lexer.js +82 -32
  39. package/src/parser.js +229 -229
  40. package/src/schema/dts.js +328 -54
  41. package/src/schema/loader-server.js +2 -1
  42. package/src/schema/runtime-browser-stubs.js +20 -9
  43. package/src/schema/runtime-ddl.js +161 -44
  44. package/src/schema/runtime-migrate.js +681 -0
  45. package/src/schema/runtime-orm.js +698 -54
  46. package/src/schema/runtime-validate.js +808 -24
  47. package/src/schema/runtime.generated.js +2395 -135
  48. package/src/schema/schema.js +1054 -94
  49. package/src/typecheck.js +1610 -127
  50. package/src/types.js +90 -6
  51. package/src/grammar/lunar.rip +0 -2412
  52. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  53. /package/docs/demo/{components → routes}/about.rip +0 -0
  54. /package/docs/demo/{components → routes}/card.rip +0 -0
  55. /package/docs/demo/{components → routes}/counter.rip +0 -0
  56. /package/docs/demo/{components → routes}/index.rip +0 -0
  57. /package/docs/demo/{components → routes}/todos.rip +0 -0
package/src/typecheck.js CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  import { Compiler, getStdlibCode } from './compiler.js';
14
14
  import { STDLIB_TYPE_DECLS } from './stdlib.js';
15
- import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN } from './dts.js';
15
+ import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN, ripDestructuredNames } from './dts.js';
16
16
  import './schema/loader-server.js'; // registers full schema runtime provider
17
17
  import { createRequire } from 'module';
18
18
  import { readFileSync, existsSync, readdirSync } from 'fs';
19
- import { resolve, relative, dirname, sep as pathSep } from 'path';
19
+ import { resolve, relative, dirname, basename, sep as pathSep } from 'path';
20
20
  import { buildLineMap } from './sourcemaps.js';
21
21
 
22
22
  // ── Typed stash: project entry discovery ───────────────────────────
@@ -29,12 +29,60 @@ import { buildLineMap } from './sourcemaps.js';
29
29
  // `import('<rel-to-stash>').__RipStash` into their `app.data` declaration.
30
30
  //
31
31
  // Discovery: walk up from each file to the nearest dir that contains an
32
- // `index.rip` AND a `rip.json` or `package.json` (the project anchor),
33
- // then look for `<root>/app/stash.rip`. Cached per-directory for the
34
- // process lifetime.
32
+ // `index.rip` AND a `package.json` (the project anchor), then look for
33
+ // `<root>/app/stash.rip`. Cached per-directory for the process lifetime.
35
34
  const entryFileCache = new Map(); // dir → entryFile|null
36
35
  const stashFileCache = new Map(); // root dir → stashFile|null
37
36
 
37
+ // ── Robust import extraction ───────────────────────────────────────
38
+ //
39
+ // Walk a file's `import ...` / dynamic `import(...)` specifiers via Bun's
40
+ // real parser instead of regex-scanning. This is immune to false matches
41
+ // from comments, string literals, regex bodies, and any other place a
42
+ // `from "@rip-lang/..."`-shaped sequence might appear in source.
43
+ //
44
+ // `scanText` is the compiled TS-virtual content (entry.tsContent) when
45
+ // available; we fall back to the raw .rip source for entries that
46
+ // haven't been compiled yet (the only such call site reads a freshly-
47
+ // read package file).
48
+ const _ripImportTranspiler = new Bun.Transpiler({ loader: 'ts' });
49
+ function scanRipPkgImports(scanText) {
50
+ if (!scanText) return [];
51
+ let imports;
52
+ try { imports = _ripImportTranspiler.scanImports(scanText); }
53
+ catch { return []; }
54
+ const out = [];
55
+ for (const imp of imports) {
56
+ const p = imp.path;
57
+ if (typeof p === 'string' && p.startsWith('@rip-lang/')) out.push(p);
58
+ }
59
+ return out;
60
+ }
61
+ // Type-position `import('@rip-lang/...')` import-types are erased by
62
+ // `scanImports` (they live in type space), so the parser-based scan above
63
+ // never sees them. The DTS pipeline injects exactly these — e.g.
64
+ // `declare router: import('@rip-lang/app').Router` and the `NavOpts` alias
65
+ // — so the referenced package must still be pulled into the TS
66
+ // program or its types silently resolve to `any` (e.g. `push`'s `opts`
67
+ // going unchecked). Used ONLY for package seeding, never the
68
+ // undeclared-import check: these are synthesized references, not source
69
+ // dependencies the user must declare in package.json.
70
+ const _ripImportTypeRe = /\bimport\(\s*(["'])(@rip-lang\/[^"']+)\1\s*\)/g;
71
+ function scanRipPkgImportTypes(scanText) {
72
+ if (!scanText) return [];
73
+ const out = [];
74
+ _ripImportTypeRe.lastIndex = 0;
75
+ let m;
76
+ while ((m = _ripImportTypeRe.exec(scanText))) out.push(m[2]);
77
+ return out;
78
+ }
79
+ // Extract the bare package name (`@rip-lang/foo`) from a specifier
80
+ // that may include a subpath (`@rip-lang/foo/sub/path`).
81
+ function ripPkgRoot(spec) {
82
+ const i = spec.indexOf('/', '@rip-lang/'.length);
83
+ return i === -1 ? spec : spec.slice(0, i);
84
+ }
85
+
38
86
  export function findEntryFile(filePath) {
39
87
  let dir = dirname(filePath);
40
88
  const visited = [];
@@ -45,7 +93,7 @@ export function findEntryFile(filePath) {
45
93
  return cached;
46
94
  }
47
95
  visited.push(dir);
48
- const hasAnchor = existsSync(resolve(dir, 'rip.json')) || existsSync(resolve(dir, 'package.json'));
96
+ const hasAnchor = existsSync(resolve(dir, 'package.json'));
49
97
  if (hasAnchor) {
50
98
  const entry = resolve(dir, 'index.rip');
51
99
  const result = existsSync(entry) ? entry : null;
@@ -84,6 +132,236 @@ export function findStashFile(filePath) {
84
132
  return result;
85
133
  }
86
134
 
135
+ // ── Static gate-path validation ────────────────────────────────────
136
+ //
137
+ // `x <~ @app.data.path` requires the path to resolve to a source key —
138
+ // the renderer enforces this with a deterministic mount-time error, and
139
+ // `rip check` mirrors it at compile time by reading the stash module's
140
+ // s-expressions: collect module-level bindings, find the `stash` object
141
+ // literal, and classify each gate path's keys. The analysis is
142
+ // CONSERVATIVE: only paths that provably land on a plain key (or on no
143
+ // key) error; anything whose source-ness isn't statically visible —
144
+ // imported cells, factory calls, spreads — stays silent and the mount
145
+ // check backstops it. False positives are the failure mode to avoid;
146
+ // false negatives just fall back to today's runtime error.
147
+
148
+ function sexprName(v) {
149
+ return (typeof v === 'string' || v instanceof String) ? v.valueOf() : null;
150
+ }
151
+
152
+ // Walk a stash module's program s-expression into { stashObj, bindings }:
153
+ // the `stash` object literal plus every module-level `name = value`
154
+ // binding (so `orders: ordersSource` can chase `ordersSource = source …`).
155
+ export function collectStashAnalysis(sexpr) {
156
+ if (!Array.isArray(sexpr) || sexpr[0] !== 'program') return null;
157
+ const bindings = new Map();
158
+ for (let stmt of sexpr.slice(1)) {
159
+ if (!Array.isArray(stmt)) continue;
160
+ if (stmt[0] === 'export' && Array.isArray(stmt[1])) stmt = stmt[1];
161
+ if (stmt[0] === '=' || stmt[0] === 'readonly') {
162
+ const name = sexprName(stmt[1]);
163
+ if (name) bindings.set(name, stmt[2]);
164
+ }
165
+ }
166
+ const stashObj = bindings.get('stash');
167
+ if (!Array.isArray(stashObj) || sexprName(stashObj[0]) !== 'object') return null;
168
+ return { stashObj, bindings };
169
+ }
170
+
171
+ // 'source' | 'plain' | 'unknown' | { kind: 'object', node } — what a stash
172
+ // value provably is. Identifier references chase module-level bindings.
173
+ function classifyStashValue(node, bindings, depth = 0) {
174
+ if (depth > 8) return 'unknown';
175
+ const name = sexprName(node);
176
+ if (name != null) {
177
+ if (/^["'\d]/.test(name) || name === 'true' || name === 'false' ||
178
+ name === 'null' || name === 'undefined') return 'plain';
179
+ if (bindings.has(name)) return classifyStashValue(bindings.get(name), bindings, depth + 1);
180
+ return 'unknown'; // imported or otherwise invisible
181
+ }
182
+ if (!Array.isArray(node)) return 'unknown';
183
+ const head = sexprName(node[0]);
184
+ if (head === 'source') return 'source'; // application of the source() declarator
185
+ if (head === 'object') return { kind: 'object', node };
186
+ if (head === 'array') return 'plain';
187
+ return 'unknown';
188
+ }
189
+
190
+ // Shared by `rip check` and the LSP: turn a file's hoisted gates into
191
+ // diagnostic records against the stash analysis. Lines/cols are 1-based
192
+ // (the LSP converts). Only provably-wrong paths produce records — see
193
+ // validateGatePath below for the conservative verdict rules.
194
+ export function collectGateDiagnostics(gates, source, analysis) {
195
+ const out = [];
196
+ if (!gates || !gates.length || !analysis) return out;
197
+ const srcLines = source.split('\n');
198
+ for (const g of gates) {
199
+ const verdict = validateGatePath(g.path, analysis);
200
+ if (verdict !== 'plain' && verdict !== 'missing') continue;
201
+ const lineText = srcLines[g.line - 1] ?? '';
202
+ const idx = lineText.indexOf('@app.data');
203
+ const detail = verdict === 'missing'
204
+ ? `'${g.path}' is not a key of the stash`
205
+ : `'${g.path}' is a plain key`;
206
+ out.push({
207
+ line: g.line,
208
+ col: (idx >= 0 ? idx : 0) + 1,
209
+ len: idx >= 0 ? '@app.data.'.length + String(g.path).length : Math.max(lineText.trim().length, 1),
210
+ message: `'${g.path} <~' does not resolve to a source (${detail}) — declare it with source() in app/stash.rip`,
211
+ srcLine: lineText,
212
+ });
213
+ }
214
+ return out;
215
+ }
216
+
217
+ // Verdict for one gate path against the stash literal:
218
+ // 'source' (ok), 'unknown' (silent), 'plain' / 'missing' (error).
219
+ // Walks segments through plain object literals; stops at the nearest
220
+ // source on the path (subpath gates bind under the loaded value).
221
+ export function validateGatePath(path, analysis) {
222
+ let node = analysis.stashObj;
223
+ for (const seg of String(path).split('.')) {
224
+ const entries = new Map();
225
+ let hasOpaqueEntries = false;
226
+ for (const e of node.slice(1)) {
227
+ const key = Array.isArray(e) && sexprName(e[0]) === ':' ? sexprName(e[1]) : null;
228
+ if (key != null) entries.set(key, e[2]);
229
+ else hasOpaqueEntries = true; // spread / computed key — can't enumerate
230
+ }
231
+ if (!entries.has(seg)) return hasOpaqueEntries ? 'unknown' : 'missing';
232
+ const c = classifyStashValue(entries.get(seg), analysis.bindings);
233
+ if (c === 'source' || c === 'unknown' || c === 'plain') return c;
234
+ node = c.node; // plain object literal — walk deeper
235
+ }
236
+ return 'plain'; // path exhausted inside plain literals, no source found
237
+ }
238
+
239
+ // ── Route tree discovery ───────────────────────────────────────────
240
+ //
241
+ // The project's routes live under `<projectRoot>/app/routes/` — a fixed
242
+ // convention (not configurable) that matches `@rip-lang/server`'s
243
+ // `serve dir: "<root>/app"`, which mounts route files under `app/routes/`.
244
+ // Each `.rip` file there contributes one entry to a
245
+ // generated `__RipRoutes` template-literal union, used for typed
246
+ // `<a href: "...">`, typed `router.push`, and per-route `@params`
247
+ // tightening. Mirrors the runtime rules in `buildRoutes`
248
+ // (packages/app/index.rip): skip `_-prefixed` files, skip files
249
+ // inside `_-prefixed` directories, treat `index.rip` as `/`,
250
+ // `[id]` as a dynamic segment, `[...rest]` as catch-all.
251
+ const routesDirCache = new Map(); // entryDir → absoluteRoutesDir|null
252
+ const routesTreeCache = new Map(); // routesDir → { entries, union }
253
+
254
+ // Invalidate the route-tree cache for the project containing `filePath`,
255
+ // or all projects when no path is given. Called by the LSP whenever a
256
+ // `.rip` file is added/removed/renamed so completions, hover, and
257
+ // diagnostics see the new route shape without a process restart.
258
+ export function invalidateRoutesCache(filePath) {
259
+ if (!filePath) {
260
+ routesDirCache.clear();
261
+ routesTreeCache.clear();
262
+ return;
263
+ }
264
+ // Cheap and correct: drop both caches. Reads are O(routes-dir scan),
265
+ // and the caches refill on the next access. Pin-pointing the exact
266
+ // entryDir would require re-running findEntryFile, which itself
267
+ // caches; clearing wholesale avoids the dependency.
268
+ routesDirCache.clear();
269
+ routesTreeCache.clear();
270
+ }
271
+
272
+ export function findRoutesDir(filePath) {
273
+ const entryFile = findEntryFile(filePath);
274
+ if (!entryFile) return null;
275
+ const root = dirname(entryFile);
276
+ if (routesDirCache.has(root)) return routesDirCache.get(root);
277
+ // Convention: `app/routes/`. Matches `@rip-lang/server`'s `serve dir:
278
+ // "<root>/app"` pattern, which resolves routes under `app/routes/`.
279
+ const dir = resolve(root, 'app/routes');
280
+ const result = existsSync(dir) ? dir : null;
281
+ routesDirCache.set(root, result);
282
+ return result;
283
+ }
284
+
285
+ // Build per-route metadata and the `__RipRoutes` template-literal union.
286
+ // Each entry: { rel, file, pattern (TS expression), dynamic: [{name, catchAll}] }
287
+ export function walkRoutesDir(routesDir) {
288
+ if (!routesDir) return { entries: [], union: 'never' };
289
+ if (routesTreeCache.has(routesDir)) return routesTreeCache.get(routesDir);
290
+ const entries = [];
291
+ function walk(dir, segs) {
292
+ let dirents;
293
+ try { dirents = readdirSync(dir, { withFileTypes: true }); }
294
+ catch { return; }
295
+ for (const e of dirents) {
296
+ // Skip _-prefixed files (_layout.rip etc.) and dirs (shared
297
+ // helpers, not pages). Same rule as runtime buildRoutes.
298
+ if (e.name.startsWith('_')) continue;
299
+ // Pathless route groups: a `(name)` directory organizes routes and
300
+ // shares a _layout.rip without contributing a URL segment. Recurse
301
+ // but don't add it to the path. Mirrors runtime fileToPattern.
302
+ if (e.isDirectory()) walk(resolve(dir, e.name), /^\(.+\)$/.test(e.name) ? segs : [...segs, e.name]);
303
+ else if (e.isFile() && e.name.endsWith('.rip')) {
304
+ const base = e.name.slice(0, -'.rip'.length);
305
+ const fileSegs = base === 'index' ? segs : [...segs, base];
306
+ const dynamic = [];
307
+ const displaySegs = [];
308
+ const tsSegs = fileSegs.map(s => {
309
+ let m = s.match(/^\[\.\.\.(\w+)\]$/);
310
+ if (m) { dynamic.push({ name: m[1], catchAll: true }); displaySegs.push('$' + m[1]); return '${string}'; }
311
+ m = s.match(/^\[(\w+)\]$/);
312
+ if (m) { dynamic.push({ name: m[1], catchAll: false }); displaySegs.push('$' + m[1]); return '${string}'; }
313
+ displaySegs.push(s);
314
+ return s;
315
+ });
316
+ const path = '/' + tsSegs.join('/');
317
+ const displayPath = '/' + displaySegs.join('/');
318
+ const pattern = dynamic.length === 0
319
+ ? JSON.stringify(path === '//' ? '/' : path)
320
+ : '`' + (path === '//' ? '/' : path) + '`';
321
+ const display = dynamic.length === 0
322
+ ? null
323
+ : '`' + (displayPath === '//' ? '/' : displayPath) + '`';
324
+ entries.push({
325
+ rel: relative(routesDir, resolve(dir, e.name)),
326
+ file: resolve(dir, e.name),
327
+ pattern,
328
+ display,
329
+ displayPath,
330
+ dynamic,
331
+ });
332
+ }
333
+ }
334
+ }
335
+ walk(routesDir, []);
336
+ // Canonical order: index route ("/") first, then the rest by display
337
+ // path, lexicographically. Filesystem walk order is undefined across
338
+ // platforms — sorting here gives stable union order and a consistent
339
+ // member sequence in completions, hovers, and error messages.
340
+ entries.sort((a, b) => {
341
+ if (a.displayPath === '/') return -1;
342
+ if (b.displayPath === '/') return 1;
343
+ return a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0;
344
+ });
345
+ // Build union, deduping static patterns (template-literal patterns
346
+ // are inherently distinct by structure). Catch-all routes
347
+ // (`[...rest].rip`) are excluded — they're runtime 404 fallbacks,
348
+ // not navigation targets, and including them as `/${string}` would
349
+ // make the union accept any slash-prefixed string and defeat
350
+ // typo-catching for every other route.
351
+ const seen = new Set();
352
+ const parts = [];
353
+ for (const e of entries) {
354
+ if (e.dynamic.some(d => d.catchAll)) continue;
355
+ if (seen.has(e.pattern)) continue;
356
+ seen.add(e.pattern);
357
+ parts.push(e.pattern);
358
+ }
359
+ const union = parts.length ? parts.join(' | ') : 'never';
360
+ const result = { entries, union };
361
+ routesTreeCache.set(routesDir, result);
362
+ return result;
363
+ }
364
+
87
365
  // ── Shared helpers ─────────────────────────────────────────────────
88
366
 
89
367
  // Detect type annotations (:: followed by space or =) ignoring comments,
@@ -219,12 +497,12 @@ export function checkComponentDefs(compProps, srcLines, startLine = 0) {
219
497
  const errors = [];
220
498
  for (const prop of compProps) {
221
499
  for (let s = startLine; s < srcLines.length; s++) {
222
- const m = new RegExp('(@' + prop.name + ')\\s*(::|([:!]?=))').exec(srcLines[s]);
500
+ const m = new RegExp('(@' + prop.name + ')\\??\\s*(::|([:!]?=))').exec(srcLines[s]);
223
501
  if (!m) continue;
224
502
  if (m[1 + 1] !== '::') {
225
503
  errors.push({ line: s, col: m.index, len: m[1].length, propName: prop.name, message: `Prop '${prop.name}' has no type annotation` });
226
504
  } else {
227
- const dm = srcLines[s].match(new RegExp('@' + prop.name + '\\s*::\\s*(.+?)\\s*:=\\s*(.+)'));
505
+ const dm = srcLines[s].match(new RegExp('@' + prop.name + '\\??\\s*::\\s*(.+?)\\s*:=\\s*(.+)'));
228
506
  if (dm) {
229
507
  const defVal = dm[2].replace(/#.*$/, '').trim();
230
508
  const err = validatePropDefault(dm[1].trim(), defVal);
@@ -419,19 +697,30 @@ export const SKIP_CODES = new Set([
419
697
  1064, // Return type of async function must be Promise
420
698
  ]);
421
699
 
422
- // Dedup diagnostics by (start line/col, end line/col, code, message).
700
+ // Dedup diagnostics by (start line/col, code).
423
701
  // The same TS error can fire twice when the dts header and compiled body
424
702
  // both contain the offending construct (e.g. an `import { X }` line that
425
- // maps to the same source position from both copies).
703
+ // maps to the same source position from both copies), or when a diagnostic
704
+ // hits both an injected function overload signature and its implementation.
705
+ //
706
+ // The key intentionally excludes end position and message: the duplicates we
707
+ // want to collapse routinely differ in span length (overload sig vs impl
708
+ // token widths) and occasionally in message text (TS referencing different
709
+ // candidate signatures). Same start position + same code is the right
710
+ // invariant — distinct logical errors at the exact same (line, col, code)
711
+ // would be vanishingly rare and folding them is preferable to leaking
712
+ // structural duplicates.
426
713
  //
427
714
  // `getRange(d)` must return `{ startLine, startCol, endLine, endCol }`.
428
- // Returns a new array; does not mutate the input.
715
+ // Returns a new array; does not mutate the input. Preserves input object
716
+ // identity so callers can use Set membership to find which entries were
717
+ // dropped.
429
718
  export function dedupDiagnostics(diags, getRange) {
430
719
  const seen = new Set();
431
720
  const out = [];
432
721
  for (const d of diags) {
433
722
  const r = getRange(d);
434
- const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}:${d.code}:${d.message}`;
723
+ const key = `${r.startLine}:${r.startCol}:${d.code}`;
435
724
  if (seen.has(key)) continue;
436
725
  seen.add(key);
437
726
  out.push(d);
@@ -605,6 +894,116 @@ export function cleanDiagnosticMessage(msg) {
605
894
  return msg;
606
895
  }
607
896
 
897
+ // Classify a route-related diagnostic so the message rewrite and the
898
+ // squiggle-snap use one consistent detection. Returns:
899
+ // 'el' — anchor href mismatch (static __ripEl or dynamic __ripRoute)
900
+ // 'route' — programmatic router.push/replace mismatch
901
+ // null — unrelated diagnostic
902
+ //
903
+ // Detection is position-aware: a single source line can host both an anchor
904
+ // and an inline event handler (e.g. `a @click: () -> @router.push(...)`),
905
+ // so substring-checking the whole TS line is ambiguous. Instead we look at
906
+ // the call site immediately preceding the diagnostic's TS offset.
907
+ function classifyRouteDiagnostic(entry, start) {
908
+ if (!entry?.tsContent || start == null) return null;
909
+ const before = entry.tsContent.slice(Math.max(0, start - 64), start);
910
+ if (/(?:__ripEl|__ripRoute)\([^()]*$/.test(before)) return 'el';
911
+ if (/\.(?:push|replace)\([^()]*$/.test(before)) return 'route';
912
+ return null;
913
+ }
914
+
915
+ // Unify route diagnostics with the static __ripEl form so users see one
916
+ // consistent message shape regardless of which call site (anchor href,
917
+ // router.push, etc.) produced the error. Rewrites TS2345 "Argument of
918
+ // type 'X' is not assignable to parameter of type 'Y'." into TS2322
919
+ // "Type 'X' is not assignable to type 'Y | undefined'." Then prettifies
920
+ // `${string}` placeholders in route patterns to their source-form
921
+ // `$paramName` (from `[id].rip` → `$id`). Used by both the CLI
922
+ // (runCheck) and the LSP diagnostic publisher.
923
+ export function unifyRouteDiagnostic(code, message, entry, start, filePath) {
924
+ const kind = classifyRouteDiagnostic(entry, start);
925
+ const routesDir = filePath ? findRoutesDir(filePath) : null;
926
+ const tree = routesDir ? walkRoutesDir(routesDir) : null;
927
+
928
+ if ((kind === 'route' || kind === 'el') && code === 2345) {
929
+ const m = message.match(/^Argument of type '([^']*(?:''[^']*)*)' is not assignable to parameter of type '([^']*(?:''[^']*)*)'\.$/);
930
+ if (m) {
931
+ code = 2322;
932
+ message = `Type '${m[1]}' is not assignable to type '${m[2]} | undefined'.`;
933
+ }
934
+ }
935
+
936
+ // Prettify ${string} placeholders in known route patterns.
937
+ if (tree && message.includes('${string}')) {
938
+ message = prettifyRoutePatterns(message, tree);
939
+ }
940
+ // Canonicalize route-union member order (TS normalizes unions, so the
941
+ // order shifts between error contexts — pin to walkRoutesDir order).
942
+ if (tree) message = canonicalizeRouteUnion(message, tree);
943
+ return { code, message };
944
+ }
945
+
946
+ // Rewrite `${string}` placeholders in route patterns to their source-form
947
+ // `$paramName` (from `[id].rip` → `$id`). Used by diagnostics and hover.
948
+ export function prettifyRoutePatterns(text, tree) {
949
+ if (!tree || !text || !text.includes('${string}')) return text;
950
+ for (const e of tree.entries) {
951
+ if (!e.display) continue;
952
+ if (e.pattern !== e.display) text = text.split(e.pattern).join(e.display);
953
+ }
954
+ return text;
955
+ }
956
+
957
+ // Reorder route-union members to match walkRoutesDir order. TS normalizes
958
+ // unions internally, so the same set can render in different orders across
959
+ // hover and error contexts. Scans for runs of unioned string/template/
960
+ // undefined members, and if the run exactly covers the known route set
961
+ // (plus optional `undefined`), rewrites it in canonical order. Leaves
962
+ // unrelated unions untouched.
963
+ export function canonicalizeRouteUnion(text, tree) {
964
+ if (!tree || !text || !text.includes(' | ')) return text;
965
+ const canonical = [];
966
+ const seen = new Set();
967
+ for (const e of tree.entries) {
968
+ if (e.dynamic.some(d => d.catchAll)) continue;
969
+ const member = e.display || e.pattern;
970
+ if (seen.has(member)) continue;
971
+ seen.add(member);
972
+ canonical.push(member);
973
+ }
974
+ if (canonical.length === 0) return text;
975
+ const canonicalSet = new Set(canonical);
976
+ const memberRe = /(?:"[^"]*"|`[^`]*`|undefined)/.source;
977
+ const unionRe = new RegExp(`${memberRe}(?:\\s*\\|\\s*${memberRe})+`, 'g');
978
+ return text.replace(unionRe, run => {
979
+ const parts = run.split(/\s*\|\s*/);
980
+ const hasUndefined = parts.includes('undefined');
981
+ const nonUndef = parts.filter(p => p !== 'undefined');
982
+ if (nonUndef.length !== canonical.length) return run;
983
+ if (!nonUndef.every(p => canonicalSet.has(p))) return run;
984
+ const ordered = hasUndefined ? [...canonical, 'undefined'] : canonical;
985
+ return ordered.join(' | ');
986
+ });
987
+ }
988
+
989
+ // Locate the best span for a route diagnostic in the source line. TS
990
+ // reports the span on the generated `__ripEl`/`__ripRoute` call, which
991
+ // source-maps back to imprecise positions. We snap to the meaningful
992
+ // token in the source:
993
+ // - 'el' → the `href` attribute name
994
+ // - 'route' → the method name `push`/`replace`
995
+ export function locateRouteDiagnosticSpan(entry, start, srcLine) {
996
+ const kind = classifyRouteDiagnostic(entry, start);
997
+ if (kind === 'el') {
998
+ const m = srcLine.match(/\bhref\b/);
999
+ if (m) return { col: m.index, len: 4 };
1000
+ } else if (kind === 'route') {
1001
+ const m = srcLine.match(/\.(push|replace)\b/);
1002
+ if (m) return { col: m.index + 1, len: m[1].length };
1003
+ }
1004
+ return null;
1005
+ }
1006
+
608
1007
  // Base TypeScript compiler settings for type-checking. Callers can
609
1008
  // pass overrides (e.g. { strict: true } when a project opts in).
610
1009
  //
@@ -612,8 +1011,8 @@ export function cleanDiagnosticMessage(msg) {
612
1011
  // ("optional, design scaffolding, not safety rails") and matches the
613
1012
  // gradual-typing default of comparable systems (Sorbet's `# typed: false`,
614
1013
  // mypy's permissive default, Hack's `partial`, TypeScript's own pre-strict
615
- // default). Projects opt UP to strict via rip.json's `strict: true`, which
616
- // implies noImplicitAny, strictNullChecks, and the rest of TS's strict
1014
+ // default). Projects opt UP to strict via package.json's `rip.strict: true`,
1015
+ // which implies noImplicitAny, strictNullChecks, and the rest of TS's strict
617
1016
  // family. Do NOT pin those flags to `false` here — that would shadow the
618
1017
  // strict-family inference when an opt-in caller passes `{ strict: true }`.
619
1018
  export function createTypeCheckSettings(ts, overrides = {}) {
@@ -629,6 +1028,45 @@ export function createTypeCheckSettings(ts, overrides = {}) {
629
1028
  };
630
1029
  }
631
1030
 
1031
+ // Collect ambient type packages (e.g. `@types/bun`, `@types/node`) by
1032
+ // walking up from rootPath gathering every `node_modules/@types` dir.
1033
+ // TS's default typeRoots only checks `<cwd>/node_modules/@types`, so a
1034
+ // sub-package check would miss workspace-root ambients. Accepts symlinks
1035
+ // because bun's nested-package layout symlinks `@types/bun` to
1036
+ // `.bun/@types+bun@.../node_modules/@types/bun`.
1037
+ //
1038
+ // rip targets the Bun runtime, so `Bun`, `process`, `Buffer`, etc. are
1039
+ // always present — rip ships `@types/bun` as a dependency and includes
1040
+ // its own `node_modules/@types` last, so every project type-checks those
1041
+ // globals without installing anything. A workspace that installs its own
1042
+ // `@types/bun`/`@types/node` still wins: its dir is walked first and the
1043
+ // name dedup below keeps that copy.
1044
+ export function collectAmbientTypes(rootPath) {
1045
+ const typeRoots = [];
1046
+ const types = [];
1047
+ const scan = (cand) => {
1048
+ if (typeRoots.includes(cand) || !existsSync(cand)) return;
1049
+ typeRoots.push(cand);
1050
+ try {
1051
+ for (const entry of readdirSync(cand, { withFileTypes: true })) {
1052
+ if ((entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.') && !types.includes(entry.name)) {
1053
+ types.push(entry.name);
1054
+ }
1055
+ }
1056
+ } catch {}
1057
+ };
1058
+ let dir = rootPath;
1059
+ while (true) {
1060
+ scan(resolve(dir, 'node_modules/@types'));
1061
+ const parent = dirname(dir);
1062
+ if (parent === dir) break;
1063
+ dir = parent;
1064
+ }
1065
+ // rip's own bundled ambients (this file lives at <rip-lang>/src/typecheck.js).
1066
+ scan(resolve(import.meta.dirname, '../node_modules/@types'));
1067
+ return { typeRoots, types };
1068
+ }
1069
+
632
1070
  // ── Param helpers ──────────────────────────────────────────────────
633
1071
 
634
1072
  // Extract the text between the first balanced ( ) — handles nested parens
@@ -824,21 +1262,59 @@ function injectTypeParams(line, typeParams) {
824
1262
  // Compile a .rip file for type-checking. Prepends DTS declarations to
825
1263
  // compiled JS, detects type annotations, and builds bidirectional
826
1264
  // source maps. Returns everything both the CLI and LSP need.
827
- // When opts.strict is true, all non-nocheck files are type-checked.
1265
+ // When opts.checkAll is true, all non-nocheck files are type-checked.
828
1266
  export function compileForCheck(filePath, source, compiler, opts = {}) {
829
- const result = compiler.compile(source, { sourceMap: true, types: 'emit', skipPreamble: true, stubComponents: true });
1267
+ const result = compiler.compile(source, { sourceMap: true, types: 'emit', skipPreamble: true, stubComponents: true, inlineTypes: true });
830
1268
  let code = result.code || '';
831
- const dts = result.dts ? result.dts.trimEnd() + '\n' : '';
1269
+ let dts = result.dts ? result.dts.trimEnd() + '\n' : '';
1270
+
1271
+ // ── Schema shadow reconciliation ──────────────────────────────────────
1272
+ // The compiled body keeps its runtime `const Name = __schema({...})`
1273
+ // bindings verbatim, so source maps stay exact. But in a `.ts` shadow that
1274
+ // body references an undeclared `__schema` (skipPreamble drops the runtime
1275
+ // import) and would also collide with the dts `declare const Name`. Rewrite
1276
+ // each schema `const` declaration into a `__schema` overload keyed on the
1277
+ // schema's `name` literal, so the body's `__schema({name:"Name",...})` call
1278
+ // resolves to the precise Schema/ModelSchema type with no duplicate binding.
1279
+ // The `type` aliases (NameValue/NameData/NameInstance) are kept untouched —
1280
+ // importers and the body both reference them.
1281
+ let usesSchemas = false;
1282
+ if (dts) {
1283
+ const overloads = [];
1284
+ const kept = [];
1285
+ for (const line of dts.split('\n')) {
1286
+ const m = line.match(/^(?:export )?declare const (\w+): (.+);\s*$/);
1287
+ if (m && /^(?:Schema<|ModelSchema<|\{ parse\()/.test(m[2])) {
1288
+ usesSchemas = true;
1289
+ overloads.push(`declare function __schema(d: { name: "${m[1]}"; [k: string]: any }): ${m[2]};`);
1290
+ } else {
1291
+ // Anonymous-schema overloads are emitted directly by the dts pass
1292
+ // (keyed on the descriptor's `__anon` marker); keep them, but they
1293
+ // still mark the file as schema-using so the `(d: any) => any`
1294
+ // fallback and registry declares are appended.
1295
+ if (/^declare function __schema\(/.test(line)) usesSchemas = true;
1296
+ kept.push(line);
1297
+ }
1298
+ }
1299
+ if (usesSchemas) {
1300
+ overloads.push('declare function __schema(d: any): any;');
1301
+ overloads.push('declare const SchemaError: any;');
1302
+ overloads.push('declare const __SchemaRegistry: any;');
1303
+ overloads.push('declare const __schemaSetAdapter: any;');
1304
+ dts = kept.join('\n').trimEnd() + '\n' + overloads.join('\n') + '\n';
1305
+ }
1306
+ }
832
1307
 
833
1308
  // Determine if this file should be type-checked.
834
1309
  // A `# @nocheck` comment near the top of the file opts out entirely.
835
1310
  // In strict mode, all non-nocheck files are type-checked.
836
1311
  const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
837
- // Must match the CLI predicate in runCheck. Don't add `hasSchemas(source)`:
838
- // that probe is a raw-source regex that fires on `schema :input` inside
839
- // heredoc string literals (e.g. test files), flooding the LSP with TS2304
840
- // false positives. Schema files still get their DTS via the schema pass.
841
- const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.strict);
1312
+ // A file that declares schemas has an exportable type surface (its
1313
+ // `NameValue`/`NameInstance` aliases), so it must be checked even without
1314
+ // explicit `::`/`type` annotations otherwise importers can't resolve those
1315
+ // names. `usesSchemas` is derived from the compiled dts, not a raw-source
1316
+ // regex, so it never false-fires on `schema` inside heredoc literals.
1317
+ const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.checkAll || usesSchemas);
842
1318
  let importsTyped = false;
843
1319
  if (!hasOwnTypes && !nocheck) {
844
1320
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -969,11 +1445,20 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
969
1445
  // intended source. Strip the DTS line so it doesn't bleed into
970
1446
  // the wrong scope; the locals fall back to per-binding inference.
971
1447
  if (multipleLocals) { localTypedLetIdxs.add(dtsIdx); continue; }
972
- // No local site found: this is genuinely a module-scope typed
973
- // declaration (no function-local to hoist into). Leave the DTS
974
- // line in placeit's the `let name: T;` declaration the body's
975
- // top-level `name = value` needs to type-check.
976
- if (localLine < 0) continue;
1448
+ // No untyped local site found. Before assuming this is a
1449
+ // module-scope decl, check for an *already-typed* function-local
1450
+ // `let X: T`the compiler emits the type inline on the body's
1451
+ // hoisted `let` for typed locals, which makes `localPat`'s
1452
+ // `(?!\s*:)` look-ahead skip the line. In that case the DTS
1453
+ // header decl is redundant and would otherwise show up as a
1454
+ // bogus "declared but never read" hint at the top of the file.
1455
+ if (localLine < 0) {
1456
+ const typedLocalPat = new RegExp(`^\\s+let\\s+[^;]*\\b${name}\\b\\s*:`);
1457
+ for (let j = 0; j < cl.length; j++) {
1458
+ if (typedLocalPat.test(cl[j])) { localTypedLetIdxs.add(dtsIdx); break; }
1459
+ }
1460
+ continue;
1461
+ }
977
1462
  // Single unambiguous match — perform the hoist.
978
1463
  cl[localLine] = cl[localLine].replace(
979
1464
  new RegExp(`(\\blet\\s[^;]*?\\b${name}\\b)(?!\\s*:)`),
@@ -1032,6 +1517,47 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1032
1517
  return depth === 0 && sig.slice(i).includes(':');
1033
1518
  }
1034
1519
 
1520
+ // Check if any parameter in a DTS signature lacks a type annotation.
1521
+ // Used to suppress overload-sig injection: if a param is untyped, TS
1522
+ // will fire TS7006 on both the injected sig and the impl (same source
1523
+ // position, same code) — let it fire on the impl only.
1524
+ //
1525
+ // Each top-level param part is "typed" iff it contains a top-level `:`
1526
+ // outside of any nested (), [], {}, or <> groups. Destructured-rename
1527
+ // colons inside `{a: aliased}` don't count because they're nested.
1528
+ // Empty param lists are trivially "all typed".
1529
+ function hasUntypedParam(sig) {
1530
+ const params = extractFnParams(sig);
1531
+ if (params === null || params.trim() === '') return false;
1532
+ const parts = splitTopLevelParams(params);
1533
+ for (const part of parts) {
1534
+ if (!part) continue;
1535
+ // Skip TS `this: T` pseudo-param if it appears.
1536
+ if (/^this\s*:/.test(part)) continue;
1537
+ let depth = 0, angle = 0, hasColon = false;
1538
+ for (let i = 0; i < part.length; i++) {
1539
+ const c = part[i];
1540
+ if (c === '"' || c === "'" || c === '`') {
1541
+ // skip string literal
1542
+ const q = c; i++;
1543
+ while (i < part.length && part[i] !== q) {
1544
+ if (part[i] === '\\') i++;
1545
+ i++;
1546
+ }
1547
+ continue;
1548
+ }
1549
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
1550
+ if (c === ')' || c === ']' || c === '}') { depth--; continue; }
1551
+ if (c === '=' && part[i + 1] === '>') { i++; continue; }
1552
+ if (c === '<' && i > 0 && /[A-Za-z_$0-9>\]]/.test(part[i - 1])) { angle++; continue; }
1553
+ if (c === '>' && angle > 0) { angle--; continue; }
1554
+ if (c === ':' && depth === 0 && angle === 0) { hasColon = true; break; }
1555
+ }
1556
+ if (!hasColon) return true;
1557
+ }
1558
+ return false;
1559
+ }
1560
+
1035
1561
  // Extract the return type from a DTS signature (e.g. ": number" from
1036
1562
  // "function add(a: number, b: number): number;").
1037
1563
  function extractReturnType(sig) {
@@ -1081,10 +1607,13 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1081
1607
  }
1082
1608
  }
1083
1609
 
1084
- // Only inject overload signatures for functions with explicit return types.
1085
- // Functions without a return type annotation let TS infer the return from
1086
- // the implementation body — injecting an overload would force it to `any`.
1087
- const overloads = injections.filter(inj => hasExplicitReturn(inj.sig));
1610
+ // Only inject overload signatures for functions with explicit return types
1611
+ // AND fully-typed params. Functions without a return type annotation let
1612
+ // TS infer the return from the implementation body — injecting an overload
1613
+ // would force it to `any`. Functions with any untyped param would fire
1614
+ // TS7006 twice (once on the injected sig, once on the impl) at the same
1615
+ // source position — skip the injection so the user sees a single error.
1616
+ const overloads = injections.filter(inj => hasExplicitReturn(inj.sig) && !hasUntypedParam(inj.sig));
1088
1617
 
1089
1618
  // Adjust reverseMap: each overload injection shifts subsequent code lines down by 1.
1090
1619
  // Compare against the original genLine (not genLine + offset) because bottom-up
@@ -1162,7 +1691,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1162
1691
  const existingFields = new Set();
1163
1692
  for (let k = j + 1; k < cl.length; k++) {
1164
1693
  if (cl[k].match(/^(?:export\s+)?(?:class|const)\s+\w+/) && k > j + 1) break;
1165
- const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;$/);
1694
+ const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;(?:\s*\/\/.*)?$/);
1166
1695
  if (fm) existingFields.add(fm[1]);
1167
1696
  // Also match field assignments (e.g. `name = __computed(...)` in component stubs)
1168
1697
  const am = cl[k].match(/^\s+(\w+)\s*=\s+/);
@@ -1336,7 +1865,16 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1336
1865
 
1337
1866
  for (let i = 0; i < dl.length; i++) {
1338
1867
  const m = dl[i].match(/^(?:export\s+)?declare\s+const\s+(\w+):\s+(.+);$/);
1339
- if (m) constTypes.set(m[1], { type: m[2], idx: i });
1868
+ if (m) { constTypes.set(m[1], { type: m[2], idx: i }); continue; }
1869
+ // Also merge `(export )?let X: T;` forward-decls from the DTS header
1870
+ // into matching body `(export )?const X = expr` declarations. dts.js
1871
+ // emits the `let` form for typed module-scope value bindings declared
1872
+ // via `name:: T = expr`. Without this merge, TS sees two separate
1873
+ // declarations (header `let` + body `const`) and loses the typed
1874
+ // identity on property access — e.g. `getStore()` returns `unknown`
1875
+ // instead of the declared `AsyncLocalStorage<T>`'s element type.
1876
+ const lm = dl[i].match(/^(?:export\s+)?let\s+(\w+):\s+(.+);$/);
1877
+ if (lm) constTypes.set(lm[1], { type: lm[2], idx: i });
1340
1878
  }
1341
1879
 
1342
1880
  if (constTypes.size > 0) {
@@ -1363,9 +1901,10 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1363
1901
  // but files that import from typed modules may have untyped reactive vars whose
1364
1902
  // compiled code still references __state/__computed/__effect.
1365
1903
  if (hasTypes) {
1366
- const needSignal = /\b__state\(/.test(code) && !/\bdeclare function __state\b/.test(headerDts);
1367
- const needComputed = /\b__computed\(/.test(code) && !/\bdeclare function __computed\b/.test(headerDts);
1368
- const needEffect = /\b__effect\(/.test(code) && !/\bdeclare function __effect\b/.test(headerDts);
1904
+ const bound = ripDestructuredNames(source);
1905
+ const needSignal = /\b__state\(/.test(code) && !/\bdeclare function __state\b/.test(headerDts) && !bound.has('__state');
1906
+ const needComputed = /\b__computed\(/.test(code) && !/\bdeclare function __computed\b/.test(headerDts) && !bound.has('__computed');
1907
+ const needEffect = /\b__effect\(/.test(code) && !/\bdeclare function __effect\b/.test(headerDts) && !bound.has('__effect');
1369
1908
  if (needSignal || needComputed || needEffect) {
1370
1909
  const decls = [];
1371
1910
  if (needSignal) {
@@ -1379,6 +1918,13 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1379
1918
  if (needEffect) decls.push(EFFECT_FN);
1380
1919
  headerDts = decls.join('\n') + '\n' + headerDts;
1381
1920
  }
1921
+ // Gated bindings (`x <~ @app.data.x`) stub as
1922
+ // `__computed(() => __ripGate(this.app.data.x))`. __ripGate is the
1923
+ // generic narrow — soundness is supplied by the runtime gate, which
1924
+ // loads the source before the component is constructed.
1925
+ if (/\b__ripGate\(/.test(code) && !/\bdeclare function __ripGate\b/.test(headerDts)) {
1926
+ headerDts = 'declare function __ripGate<T>(v: T | null | undefined): T;\n' + headerDts;
1927
+ }
1382
1928
  }
1383
1929
 
1384
1930
  // Inject declarations for Rip's stdlib globals (abort, assert, p, sleep, etc.)
@@ -1492,22 +2038,37 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1492
2038
  for (let i = 0; i < cl.length; i++) {
1493
2039
  const m = cl[i].match(/^(\s*)let\s+([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*;\s*$/);
1494
2040
  if (!m) continue;
1495
- // Only process hoist-position lets (first non-blank line after `{` or start of file)
2041
+ // Only process hoist-position lets (first non-blank line after `{`, start
2042
+ // of file, or the module's import block — the compiler emits the
2043
+ // module-scope hoist right after the imports).
1496
2044
  let prev = null;
1497
2045
  for (let k = i - 1; k >= 0; k--) { if (cl[k].trim() !== '') { prev = cl[k]; break; } }
1498
- if (prev !== null && !/\{\s*$/.test(prev)) continue;
2046
+ if (prev !== null && !/\{\s*$/.test(prev) && !/^\s*import\b/.test(prev)) continue;
1499
2047
 
1500
2048
  const baseIndent = m[1];
1501
2049
  const vars = m[2].split(/\s*,\s*/);
1502
2050
  const inlined = new Set();
1503
2051
  const bailed = new Set();
1504
- const scopeEndRe = new RegExp('^' + reEsc(baseIndent) + '}');
2052
+ // Scope-end detection by indent: the enclosing block ends at the first
2053
+ // non-empty line whose indent is strictly less than baseIndent. This is
2054
+ // correct for compiler-generated JS where indentation reliably reflects
2055
+ // nesting. Regex-matching `}` at baseIndent was wrong because inner
2056
+ // block-closing braces (e.g. ` }` ending a nested `if`) sit at exactly
2057
+ // baseIndent and would terminate the scan prematurely. For top-level
2058
+ // hoists (baseIndent === ''), the scope is the whole file — never end.
2059
+ const baseIndentLen = baseIndent.length;
2060
+ const isScopeEnd = (line) => {
2061
+ if (baseIndentLen === 0) return false;
2062
+ if (line.trim() === '') return false;
2063
+ const li = line.match(/^(\s*)/)[1].length;
2064
+ return li < baseIndentLen;
2065
+ };
1505
2066
 
1506
2067
  // Phase 1: straight-line scan at base indent
1507
2068
  for (let j = i + 1; j < cl.length; j++) {
1508
2069
  const line = cl[j];
1509
2070
  if (line.trim() === '') continue;
1510
- if (scopeEndRe.test(line)) break;
2071
+ if (isScopeEnd(line)) break;
1511
2072
  // Skip deeper-indented lines
1512
2073
  if (line.startsWith(baseIndent + ' ')) continue;
1513
2074
  // Stop at structural statements (if/for/while/switch/try/do/function/class)
@@ -1539,7 +2100,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1539
2100
  for (let j = i + 1; j < cl.length; j++) {
1540
2101
  const line = cl[j];
1541
2102
  if (line.trim() === '') continue;
1542
- if (scopeEndRe.test(line)) break;
2103
+ if (isScopeEnd(line)) break;
1543
2104
  if (!vRe.test(line)) continue;
1544
2105
  if (firstRefLine < 0) firstRefLine = j;
1545
2106
  if (!foundAssign) {
@@ -1560,7 +2121,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1560
2121
  for (let j = foundAssign.line + 1; j < cl.length; j++) {
1561
2122
  const line = cl[j];
1562
2123
  if (line.trim() === '') continue;
1563
- if (scopeEndRe.test(line)) { blockEndLine = j; break; }
2124
+ if (isScopeEnd(line)) { blockEndLine = j; break; }
1564
2125
  const li = line.match(/^(\s*)/)[1];
1565
2126
  if (li.length < foundAssign.indent.length) { blockEndLine = j; break; }
1566
2127
  }
@@ -1571,7 +2132,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1571
2132
  for (let j = blockEndLine + 1; j < cl.length; j++) {
1572
2133
  const line = cl[j];
1573
2134
  if (line.trim() === '') continue;
1574
- if (scopeEndRe.test(line)) break;
2135
+ if (isScopeEnd(line)) break;
1575
2136
  if (vRe.test(line)) { hasRefAfterBlock = true; break; }
1576
2137
  }
1577
2138
  }
@@ -1583,8 +2144,23 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1583
2144
  }
1584
2145
 
1585
2146
  const remaining = vars.filter(v => !inlined.has(v));
1586
- if (remaining.length) cl[i] = `${baseIndent}let ${remaining.join(', ')};`;
1587
- else cl[i] = '';
2147
+ // Module-scope only: drop any var that still has a DTS-header decl
2148
+ // (`let name: T;`) from this untyped hoist. These are chiefly module-level
2149
+ // function bindings, whose assignment spans multiple lines
2150
+ // (`proxy = function(c) {` … `};`) and so never folds into a single
2151
+ // `let name: T = value;` line. The header decl is the single,
2152
+ // correctly-typed declaration and the body's later `name = …` assigns it;
2153
+ // leaving name in this hoist too would duplicate the module-scope binding,
2154
+ // and TS resolves to the untyped copy — dropping contextual typing for the
2155
+ // function's params and `this` (the redeclare is auto-suppressed, so the
2156
+ // symptom surfaces elsewhere). We deliberately keep the type on the header
2157
+ // decl rather than re-emitting it onto this synthetic hoist line: the
2158
+ // compiler source-maps the header's type name back to the real `name:: T`
2159
+ // annotation, so diagnostics/quick-fixes land on it — re-emitting here
2160
+ // would map them to the hoist's position instead. Function-body hoists are
2161
+ // left alone: their locals get types from the compiler's inline hoist.
2162
+ const kept = baseIndent === '' ? remaining.filter(v => !letTypes.has(v)) : remaining;
2163
+ cl[i] = kept.length ? `${baseIndent}let ${kept.join(', ')};` : '';
1588
2164
  }
1589
2165
  code = cl.join('\n');
1590
2166
 
@@ -1608,19 +2184,28 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1608
2184
  const isStash = stashFile && stashFile === filePath;
1609
2185
 
1610
2186
  if (isStash) {
1611
- // The DTS header hoists `export let stash: <Type>;` so the type is
1612
- // visible everywhere. The body emits either `let stash; ... stash = {...}`
1613
- // (no export) or `export const stash = {...}` (with export). Both
1614
- // conflict with the typed hoist TS sees a redeclaration and the
1615
- // un-annotated body wins, collapsing the inferred type to `{ items:
1616
- // never[], ... }`. Rewrite both forms into a bare assignment to the
1617
- // already-declared `stash`, preserving the contextual type.
1618
- const letRe = /^(\s*let\s+)([^;=]+);/m;
1619
- code = code.replace(letRe, (full, prefix, names) => {
1620
- const remaining = names.split(',').map(s => s.trim()).filter(n => n !== 'stash');
1621
- return remaining.length ? `${prefix}${remaining.join(', ')};` : '';
1622
- });
1623
- code = code.replace(/^(\s*)export\s+const\s+stash\s*=/m, '$1stash =');
2187
+ // For an ANNOTATED stash (`stash:: T = ...`) the DTS header hoists
2188
+ // `export let stash: <Type>;` so the type is visible everywhere. The
2189
+ // body emits either `let stash; ... stash = {...}` (no export) or
2190
+ // `export const stash = {...}` (with export). Both conflict with the
2191
+ // typed hoist TS sees a redeclaration and the un-annotated body
2192
+ // wins, collapsing the inferred type to `{ items: never[], ... }`.
2193
+ // Rewrite both forms into a bare assignment to the already-declared
2194
+ // `stash`, preserving the contextual type.
2195
+ //
2196
+ // An UNANNOTATED stash (`export stash = ...`, the inference path —
2197
+ // source() keys carry their own types) hoists nothing, so the
2198
+ // body declaration must stay: removing it left `typeof stash` with no
2199
+ // `stash` at all.
2200
+ const stashHoisted = /(^|\n)\s*export\s+(let|const|var)\s+stash\s*:/.test(headerDts || '');
2201
+ if (stashHoisted) {
2202
+ const letRe = /^(\s*let\s+)([^;=]+);/m;
2203
+ code = code.replace(letRe, (full, prefix, names) => {
2204
+ const remaining = names.split(',').map(s => s.trim()).filter(n => n !== 'stash');
2205
+ return remaining.length ? `${prefix}${remaining.join(', ')};` : '';
2206
+ });
2207
+ code = code.replace(/^(\s*)export\s+const\s+stash\s*=/m, '$1stash =');
2208
+ }
1624
2209
  code += `\nexport type __RipStash = typeof stash;\n`;
1625
2210
  }
1626
2211
 
@@ -1628,25 +2213,153 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1628
2213
  let typedApp = null;
1629
2214
  if (stashFile && !isStash) {
1630
2215
  const spec = entryImportSpec(filePath, stashFile);
1631
- typedApp = `declare app: { data: import('${spec}').__RipStash; components: any; routes: any; params: any; query: any; router: any }`;
2216
+ // data intersects the stash shape with the reserved stash methods
2217
+ // (inc/dec/…, peek/reset, and the source handle) so they carry
2218
+ // signatures and completion in typed projects.
2219
+ typedApp = `declare app: { data: import('${spec}').__RipStash & import('@rip-lang/app').StashMethods<import('${spec}').__RipStash>; components: any; routes: any; params: any; query: any; router: any }`;
1632
2220
  }
1633
2221
  if (typedApp) code = code.replace(/declare app: any/g, typedApp);
1634
2222
  }
1635
2223
 
2224
+ // `declare router: any` in the component stub is rewritten to the Router
2225
+ // type exported by @rip-lang/app. Always available — the package ships its
2226
+ // own DTS. Gated on a typed project (same `findEntryFile` check the stash
2227
+ // splice uses) to avoid touching untyped sources.
2228
+ if (code.includes('declare router: any') && findEntryFile(filePath)) {
2229
+ code = code.replace(
2230
+ /declare router: any/g,
2231
+ `declare router: import('@rip-lang/app').Router`,
2232
+ );
2233
+ }
2234
+
2235
+ // ── Typed routes ─────────────────────────────────────────────────
2236
+ //
2237
+ // Three splices, all keyed off the project's `<routesDir>/` tree:
2238
+ // 1. Entry file — append `export type __RipRoutes = ...;` so every
2239
+ // file in the project can reach it via
2240
+ // `import('<entry>').__RipRoutes`.
2241
+ // 2. Per-route file (anything under <routesDir>/) — tighten the
2242
+ // `params: any` slot in the typed `declare app:` line so
2243
+ // `routes/users/[id].rip` sees `params: { id: string }`.
2244
+ // 3. Any typed file that uses `<a>` elements — override the
2245
+ // INTRINSIC `__ripEl` declaration so anchor `href` is typed via
2246
+ // a `const H extends string` conditional: if H is a literal
2247
+ // starting with `/`, it must satisfy `__RipRoutes`; otherwise
2248
+ // (external URLs `https:`/`mailto:`, fragments `#x`, dynamic
2249
+ // `string`) it falls through to plain H. Also narrow
2250
+ // `router.push` to `__RipRoutes` for typo-catching.
2251
+ const entryFile = findEntryFile(filePath);
2252
+ const routesDir = findRoutesDir(filePath);
2253
+ const isEntry = entryFile && entryFile === filePath;
2254
+ const isRoute = routesDir && filePath.startsWith(routesDir + pathSep);
2255
+
2256
+ if (isEntry && routesDir) {
2257
+ const { union } = walkRoutesDir(routesDir);
2258
+ code += `\nexport type __RipRoutes = ${union};\n`;
2259
+ }
2260
+
2261
+ // Per-route @params tightening: the component stub declares
2262
+ // `declare params: Record<string, string>`. For route files whose
2263
+ // filename carries dynamic segments (`[id].rip`, `[...rest].rip`),
2264
+ // replace that with a precise shape so typos like `@params.bogu`
2265
+ // are caught and `@params.id` narrows to `string` (the literal).
2266
+ if (isRoute && entryFile) {
2267
+ const { entries } = walkRoutesDir(routesDir);
2268
+ const me = entries.find(e => e.file === filePath);
2269
+ if (me && me.dynamic.length > 0) {
2270
+ const paramFields = me.dynamic
2271
+ .map(d => `${d.name}: string`)
2272
+ .join('; ');
2273
+ code = code.replace(
2274
+ /declare params: Record<string, string>/g,
2275
+ `declare params: { ${paramFields} }`,
2276
+ );
2277
+ }
2278
+ }
2279
+
2280
+ // Anchor href + typed router.push/replace overrides. Spliced into the
2281
+ // DTS header (where `__RipProps` is defined) and the `declare router`
2282
+ // line (already rewritten above). Gated on the project having a
2283
+ // routes dir at all — without routes there's no `__RipRoutes` to
2284
+ // intersect with, so the default `string` href is what users get.
2285
+ if (routesDir && entryFile && findStashFile(filePath)) {
2286
+ // Reach __RipRoutes via the entry file's virtual module.
2287
+ const entrySpec = entryImportSpec(filePath, entryFile);
2288
+ const anchorRouteType = `import('${entrySpec}').__RipRoutes`;
2289
+ // Inline the routes union for diagnostics on __ripRoute (dynamic
2290
+ // interpolated hrefs). The static __ripEl path resolves the alias
2291
+ // already; for the helper-call path we inline so error messages
2292
+ // read "Argument of type '`/x/${number}`' is not assignable to
2293
+ // parameter of type '<actual route union>'" instead of '__RipRoutes'.
2294
+ const { union: inlineRoutesUnion } = walkRoutesDir(routesDir);
2295
+
2296
+ // Declare a clean local alias for NavOpts so hover shows `NavOpts`
2297
+ // instead of `import("@rip-lang/app").NavOpts`. We *don't* alias
2298
+ // __RipRoutes — inlining the union directly into the push signature
2299
+ // makes hover and errors both show the actual list of routes,
2300
+ // avoiding the leak of an implementation-detail name.
2301
+ if (headerDts) {
2302
+ headerDts = `type NavOpts = import('@rip-lang/app').NavOpts;\n` + headerDts;
2303
+ }
2304
+
2305
+ // (a) Constrain <a href>: replace the INTRINSIC __ripEl declaration
2306
+ // with a const-H-generic version whose href slot conditionally
2307
+ // narrows to __RipRoutes for slash-prefixed literals. External
2308
+ // URLs (https:, mailto:, #frag) and dynamic `string` values fall
2309
+ // through to H. Error reads:
2310
+ // Type '"/foo"' is not assignable to type '__RipRoutes | undefined'.
2311
+ if (headerDts) {
2312
+ const newFnDecl = `declare function __ripEl<K extends __RipTag, const H extends string = string>(tag: K, props?: __RipProps<K> & (K extends 'a' ? { href?: H extends \`/\${string}\` ? ${inlineRoutesUnion} : H } : {})): void;`;
2313
+ headerDts = headerDts.replace(
2314
+ /declare function __ripEl<K extends __RipTag>\(tag: K, props\?: __RipProps<K>\): void;/,
2315
+ newFnDecl,
2316
+ );
2317
+ // Strengthen __ripRoute: compiler wraps interpolated /-prefixed
2318
+ // anchor href values in __ripRoute(...) so TS checks the dynamic
2319
+ // template against __RipRoutes. Without this strengthening the
2320
+ // baseline passthrough lets every string through.
2321
+ headerDts = headerDts.replace(
2322
+ /declare function __ripRoute<const T extends string>\(s: T\): T;/,
2323
+ `declare function __ripRoute<const T extends ${inlineRoutesUnion}>(s: T): T;`,
2324
+ );
2325
+ }
2326
+
2327
+ // (b) Route-check router.push / router.replace the same way as <a href>:
2328
+ // slash-prefixed string LITERALS must be a known route (typos caught, clean
2329
+ // route-list errors); dynamic strings and external URLs fall through. Build
2330
+ // query/hash URLs as `string` values rather than slash-prefixed literals.
2331
+ // Omit + re-add instead of intersection: intersecting overloaded methods
2332
+ // unions the parameter type (contravariance), which loses the narrowing.
2333
+ if (code.includes(`declare router: import('@rip-lang/app').Router`)) {
2334
+ const typedRouter = `declare router: Omit<import('@rip-lang/app').Router, 'push' | 'replace'> & { push<const P extends string>(url: P extends \`/\${string}\` ? ${inlineRoutesUnion} : P, opts?: NavOpts): void; replace<const U extends string>(url: U extends \`/\${string}\` ? ${inlineRoutesUnion} : U, opts?: NavOpts): void; }`;
2335
+ code = code.replace(
2336
+ /declare router: import\('@rip-lang\/app'\)\.Router(?![ &])/g,
2337
+ typedRouter,
2338
+ );
2339
+ }
2340
+ }
2341
+
1636
2342
  // Dedupe imports: when the DTS header and the body import from the same
1637
2343
  // module specifier, TypeScript reports TS2300 (Duplicate identifier) for
1638
2344
  // every shared binding, which cascades and corrupts type resolution
1639
- // elsewhere (e.g. `typeof <stateIdent>` collapses to `any`). The DTS-side
1640
- // import is sufficient for type-checking; blank out matching body imports
1641
- // (preserve line count so source maps stay aligned).
2345
+ // elsewhere (e.g. `typeof <stateIdent>` collapses to `any`).
2346
+ //
2347
+ // We prefer to drop the *DTS-header* duplicate and keep the body's import:
2348
+ // the body import carries per-specifier source-map mappings (needed for
2349
+ // hover and go-to-definition on each imported name), while the DTS header
2350
+ // import has none. Dropping the body import would leave the source-map
2351
+ // entries pointing at a blanked line, breaking hover for type-only
2352
+ // imports like `import { RetryConfig } from '@rip-lang/http'`.
2353
+ //
2354
+ // Preserve line count on both sides so source maps stay aligned.
1642
2355
  if (hasTypes && headerDts && code) {
1643
- const dtsSpecs = new Set();
1644
- for (const m of headerDts.matchAll(/^\s*import\s+[^;]*?from\s+['"]([^'"]+)['"]\s*;?\s*$/gm)) {
1645
- dtsSpecs.add(m[1]);
2356
+ const bodySpecs = new Set();
2357
+ for (const m of code.matchAll(/^\s*import\s+[^;]*?from\s+['"]([^'"]+)['"]\s*;?\s*$/gm)) {
2358
+ bodySpecs.add(m[1]);
1646
2359
  }
1647
- if (dtsSpecs.size > 0) {
1648
- code = code.replace(/^(\s*)import\s+[^;]*?from\s+(['"])([^'"]+)\2\s*;?\s*$/gm, (full, _ws, _q, spec) => {
1649
- return dtsSpecs.has(spec) ? '' : full;
2360
+ if (bodySpecs.size > 0) {
2361
+ headerDts = headerDts.replace(/^(\s*)import\s+[^;]*?from\s+(['"])([^'"]+)\2\s*;?\s*$/gm, (full, _ws, _q, spec) => {
2362
+ return bodySpecs.has(spec) ? '' : full;
1650
2363
  });
1651
2364
  }
1652
2365
  }
@@ -1732,10 +2445,16 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1732
2445
  const genGap = genB - genA;
1733
2446
  if (srcGap > 1 && genGap > 1 && srcGap <= genGap + 2) {
1734
2447
  for (let d = 1; d < srcGap; d++) {
1735
- if (!srcToGen.has(srcA + d) && genA + d < genB) {
1736
- srcToGen.set(srcA + d, genA + d);
1737
- if (!genToSrc.has(genA + d)) {
1738
- genToSrc.set(genA + d, srcA + d);
2448
+ const gen = genA + d;
2449
+ if (!srcToGen.has(srcA + d) && gen < genB) {
2450
+ // Don't interpolate INTO the DTS header. Header lines must only
2451
+ // carry explicit DTS-back-mappings (or none); fabricating a mapping
2452
+ // here causes go-to-def to land on whatever source line happens to
2453
+ // fall in the gap (typically a doc comment).
2454
+ if (gen < headerLines) continue;
2455
+ srcToGen.set(srcA + d, gen);
2456
+ if (!genToSrc.has(gen)) {
2457
+ genToSrc.set(gen, srcA + d);
1739
2458
  }
1740
2459
  }
1741
2460
  }
@@ -1813,7 +2532,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1813
2532
  }
1814
2533
  }
1815
2534
 
1816
- return { tsContent, headerLines, hasTypes, srcToGen, genToSrc, srcColToGen, source, dts };
2535
+ return { tsContent, headerLines, hasTypes, srcToGen, genToSrc, srcColToGen, source, dts, sexpr: result.sexpr, gates: result.gates || [] };
1817
2536
  }
1818
2537
 
1819
2538
  // ── Source-position mapping helpers ────────────────────────────────
@@ -1890,13 +2609,27 @@ export function findNearestWord(text, word, approx) {
1890
2609
 
1891
2610
  // Check whether an offset falls on an injected function overload signature line
1892
2611
  // (generated by compileForCheck, not from user code). These are body lines that
1893
- // match `function NAME(...): TYPE;` and have no genToSrc entry.
2612
+ // match `[export ][async ]function NAME(...): TYPE;` and are immediately followed
2613
+ // by the matching implementation `[export ][async ]function NAME(...) ... {`.
2614
+ //
2615
+ // Note: we can't rely on `!genToSrc.has(line)` as a discriminator — the gap-fill
2616
+ // interpolation in buildLineMap will fabricate a mapping for the injected line.
1894
2617
  export function isInjectedOverload(entry, offset) {
1895
2618
  const tsLine = offsetToLine(entry.tsContent, offset);
1896
2619
  if (tsLine < entry.headerLines) return false;
1897
- if (entry.genToSrc.get(tsLine) !== undefined) return false;
1898
2620
  const lineText = getLineText(entry.tsContent, tsLine);
1899
- return /^(?:async\s+)?function\s+\w+\s*\(/.test(lineText) && lineText.trimEnd().endsWith(';');
2621
+ const m = lineText.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/);
2622
+ if (!m) return false;
2623
+ if (!lineText.trimEnd().endsWith(';')) return false;
2624
+ // Confirm the next non-empty line is the implementation of the same function.
2625
+ const allLines = entry.tsContent.split('\n');
2626
+ for (let i = tsLine + 1; i < allLines.length; i++) {
2627
+ const next = allLines[i];
2628
+ if (next.trim() === '') continue;
2629
+ const nm = next.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/);
2630
+ return !!nm && nm[1] === m[1] && !next.trimEnd().endsWith(';');
2631
+ }
2632
+ return false;
1900
2633
  }
1901
2634
 
1902
2635
  export function offsetToLine(text, offset) {
@@ -2162,6 +2895,39 @@ export function mapToSourcePos(entry, offset) {
2162
2895
  }
2163
2896
  }
2164
2897
  const srcText = entry.source ? getLineText(entry.source, srcLine) : '';
2898
+ // Synthetic anchor: `__ripRoute(...)` wraps an anchor href value for
2899
+ // dynamic route type-checking. The TS diagnostic span starts at the
2900
+ // call argument (a template literal), which has no clean source token
2901
+ // to land on — landing instead on the source `href:` keyword keeps
2902
+ // dynamic anchor diagnostics visually consistent with the static
2903
+ // `__ripEl` `href` case (TS2820 lands on the property identifier).
2904
+ // Map both the start and end offsets that fall anywhere inside a
2905
+ // `__ripRoute(...)` call to the bounds of the `href` keyword so the
2906
+ // squiggle length matches the static case (4 chars) instead of
2907
+ // spanning the whole compiled call expression.
2908
+ if (srcText) {
2909
+ const callStart = genText.lastIndexOf('__ripRoute(', genCol);
2910
+ if (callStart >= 0) {
2911
+ // Find matching `)` after the call
2912
+ let depth = 0, callEnd = -1;
2913
+ for (let i = callStart + '__ripRoute('.length - 1; i < genText.length; i++) {
2914
+ const ch = genText[i];
2915
+ if (ch === '(') depth++;
2916
+ else if (ch === ')') { depth--; if (depth === 0) { callEnd = i; break; } }
2917
+ }
2918
+ if (callEnd >= 0 && genCol <= callEnd + 1) {
2919
+ const m = srcText.match(/\bhref\b/);
2920
+ if (m) {
2921
+ // Heuristic for end offset: anchor at end of `href` (start + 4)
2922
+ // when the gen offset is inside the call body (past the opening
2923
+ // paren). For the start offset (at the opening paren or first
2924
+ // arg char), anchor at the start of `href`.
2925
+ const atOrBeforeArg = genCol <= callStart + '__ripRoute('.length;
2926
+ return { line: srcLine, col: atOrBeforeArg ? m.index : m.index + 4 };
2927
+ }
2928
+ }
2929
+ }
2930
+ }
2165
2931
  // Text-match: find the word at genCol in the gen line, then locate it in the source line
2166
2932
  if (srcText) {
2167
2933
  let wordAt = genText.slice(genCol).match(/^\w+/);
@@ -2270,6 +3036,122 @@ export function mapToSourcePos(entry, offset) {
2270
3036
  return { line: srcLine, col: srcCol };
2271
3037
  }
2272
3038
 
3039
+ // Count top-level commas in `s` (depth 0 w.r.t. ()/[]/{}). Used to map a
3040
+ // cursor onto the Nth argument / Nth property when retargeting completion
3041
+ // offsets into object-literal call arguments.
3042
+ function countTopLevelCommas(s) {
3043
+ let depth = 0, n = 0;
3044
+ for (let i = 0; i < s.length; i++) {
3045
+ const ch = s[i];
3046
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
3047
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
3048
+ else if (ch === ',' && depth === 0) n++;
3049
+ }
3050
+ return n;
3051
+ }
3052
+
3053
+ // Completion-offset fixup for inline object-literal call arguments, e.g.
3054
+ // `@router.push('/', { ▮ })`. At a non-identifier cursor (empty slot, after
3055
+ // a comma) the word-anchored `srcToOffset` has nothing to grab and lands on
3056
+ // the call name, so TS returns the receiver's *members* (push, replace, …)
3057
+ // instead of the object's contextually-typed *properties* (noScroll, …).
3058
+ //
3059
+ // Given the gen offset `srcToOffset` produced (which sits at/near the call
3060
+ // name on the gen line), this walks the gen call to the same argument index
3061
+ // and property slot the source cursor occupies and returns the corrected
3062
+ // absolute offset. Returns the original offset unchanged when the cursor
3063
+ // isn't in this situation, so callers can apply it unconditionally.
3064
+ export function retargetObjectArgOffset(entry, srcLine, srcCol, genOffset) {
3065
+ if (!entry || genOffset == null || !srcLine) return genOffset;
3066
+ const before = srcLine.slice(0, srcCol);
3067
+ // On an identifier? Word-anchoring already mapped it precisely — leave it.
3068
+ if (/\w$/.test(before) || /^\w/.test(srcLine.slice(srcCol))) return genOffset;
3069
+
3070
+ // Walk back to the enclosing object-literal `{`, tracking bracket depth.
3071
+ let depth = 0, objOpen = -1;
3072
+ for (let i = srcCol - 1; i >= 0; i--) {
3073
+ const ch = before[i];
3074
+ if (ch === '}' || ch === ')' || ch === ']') depth++;
3075
+ else if (ch === '{') { if (depth === 0) { objOpen = i; break; } depth--; }
3076
+ else if (ch === '(' || ch === '[') { if (depth === 0) return genOffset; depth--; }
3077
+ }
3078
+ if (objOpen < 0) return genOffset;
3079
+ // The object must be a call argument: a `name(` must precede it with only
3080
+ // argument text (no nested braces) between the `(` and this `{`.
3081
+ const head = before.slice(0, objOpen);
3082
+ const callM = head.match(/\.\s*\w+\s*\(([^(){}]*)$/);
3083
+ if (!callM) return genOffset;
3084
+ const argIndex = countTopLevelCommas(callM[1]); // object is this call-arg
3085
+ const propSlot = countTopLevelCommas(before.slice(objOpen + 1)); // commas before cursor in object
3086
+
3087
+ // Gen side: locate the call paren near the mapped offset, then walk to the
3088
+ // same argument and the same property slot inside its object literal.
3089
+ const tsText = entry.tsContent;
3090
+ const lc = offsetToLineCol(tsText, genOffset);
3091
+ const genLine = tsText.split('\n')[lc.line];
3092
+ if (genLine == null) return genOffset;
3093
+ const callRe = /\.\s*\w+\s*\(/g;
3094
+ callRe.lastIndex = Math.max(0, lc.col - 4);
3095
+ const gm = callRe.exec(genLine);
3096
+ if (!gm) return genOffset;
3097
+ let i = gm.index + gm[0].length; // just inside the call's `(`
3098
+ let d = 1, args = 0;
3099
+ // Advance to the start of argument `argIndex`.
3100
+ while (i < genLine.length && args < argIndex) {
3101
+ const ch = genLine[i];
3102
+ if (ch === '(' || ch === '[' || ch === '{') d++;
3103
+ else if (ch === ')' || ch === ']' || ch === '}') { d--; if (d === 0) return genOffset; }
3104
+ else if (ch === ',' && d === 1) args++;
3105
+ i++;
3106
+ }
3107
+ while (i < genLine.length && /\s/.test(genLine[i])) i++;
3108
+ if (genLine[i] !== '{') return genOffset; // arg isn't an object literal
3109
+ i++; // step inside the object
3110
+ // Skip `propSlot` top-level properties within the object.
3111
+ let pd = 1, props = 0;
3112
+ while (i < genLine.length && props < propSlot) {
3113
+ const ch = genLine[i];
3114
+ if (ch === '(' || ch === '[' || ch === '{') pd++;
3115
+ else if (ch === ')' || ch === ']' || ch === '}') { pd--; if (pd === 0) break; }
3116
+ else if (ch === ',' && pd === 1) props++;
3117
+ i++;
3118
+ }
3119
+ while (i < genLine.length && /\s/.test(genLine[i])) i++;
3120
+ return genOffset - lc.col + i;
3121
+ }
3122
+
3123
+ // Completion-offset fixup for object-literal property *values* whose owning
3124
+ // object spans several source lines that collapse to a single generated line.
3125
+ // An implicit-object call block —
3126
+ // use serve
3127
+ // preload: '▮'
3128
+ // — compiles to `use(serve({ …, preload: '' }))`, so srcToOffset maps the
3129
+ // property's source line onto that one statement gen line but lands near the
3130
+ // statement start, not inside the value string. Given the source line, the
3131
+ // cursor column, and the gen offset srcToOffset produced, this finds the same
3132
+ // `key: <quote>` on the gen line and returns the absolute gen offset at the
3133
+ // matching spot inside the gen string — so TS can supply the contextually-typed
3134
+ // string-literal completions. Returns genOffset unchanged outside this context.
3135
+ export function retargetObjectValueOffset(entry, srcLine, srcCol, genOffset) {
3136
+ if (!entry || genOffset == null || !srcLine) return genOffset;
3137
+ const m = srcLine.match(/^(\s*)([\w$]+)(\s*:\s*)(["'])/);
3138
+ if (!m) return genOffset;
3139
+ const key = m[2];
3140
+ const strStart = m[0].length; // src index just inside the opening quote
3141
+ if (srcCol < strStart) return genOffset; // cursor isn't inside the value string
3142
+ const inStr = srcCol - strStart; // characters into the string content
3143
+
3144
+ const tsText = entry.tsContent;
3145
+ const lc = offsetToLineCol(tsText, genOffset);
3146
+ const genLine = tsText.split('\n')[lc.line];
3147
+ if (genLine == null) return genOffset;
3148
+ const re = new RegExp('\\b' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*:\\s*["\']');
3149
+ const gm = re.exec(genLine);
3150
+ if (!gm) return genOffset;
3151
+ const genStrInside = gm.index + gm[0].length; // just inside the gen opening quote
3152
+ return (genOffset - lc.col) + genStrInside + inStr;
3153
+ }
3154
+
2273
3155
  // Map a Rip source (line, col) to a TypeScript virtual file byte offset.
2274
3156
  // This is the forward direction: source → generated (used for hover, definition, etc.)
2275
3157
  //
@@ -2285,6 +3167,17 @@ export function srcToOffset(entry, line, col) {
2285
3167
  if (entry.srcColToGen) {
2286
3168
  const colEntries = entry.srcColToGen.get(line);
2287
3169
  if (colEntries && colEntries.length > 0) {
3170
+ // Exact-column match wins regardless of genLine. Sub-mapping anchors
3171
+ // for identifiers in arrow bodies, spreads, etc. legitimately land on
3172
+ // a different genLine than the statement's primary anchor; preferring
3173
+ // them when they line up exactly with the queried column avoids the
3174
+ // line-anchor filter (below) from discarding a precise mapping.
3175
+ const exact = colEntries.find(e => e.srcCol === col);
3176
+ if (exact) {
3177
+ genLine = exact.genLine;
3178
+ genColHint = exact.genCol;
3179
+ bestSrcCol = exact.srcCol;
3180
+ } else {
2288
3181
  // When srcToGen anchors this source line to a specific genLine, only
2289
3182
  // consider colEntries on that same genLine. Stray entries on other
2290
3183
  // genLines (caused by upstream sub-mapping contamination across
@@ -2307,6 +3200,7 @@ export function srcToOffset(entry, line, col) {
2307
3200
  genLine = best.genLine;
2308
3201
  genColHint = best.genCol;
2309
3202
  bestSrcCol = best.srcCol;
3203
+ }
2310
3204
  }
2311
3205
  }
2312
3206
 
@@ -2367,7 +3261,17 @@ export function srcToOffset(entry, line, col) {
2367
3261
  const dist = Math.abs(m.index - expectedGenCol) + (inStr ? STRING_PENALTY : 0);
2368
3262
  if (dist < bestDist) { bestDist = dist; bestCol = m.index; }
2369
3263
  }
2370
- if (bestCol >= 0) return lineColToOffset(entry.tsContent, targetLine, bestCol);
3264
+ if (bestCol >= 0) {
3265
+ // If every match for `word` in the target line is inside a string
3266
+ // literal (so hover would return nothing) but we have a precise
3267
+ // mapping hint for this source position, prefer the hint. This is
3268
+ // what makes hover on `:foo` (which compiles to `Symbol.for("foo")`)
3269
+ // resolve to the `Symbol` identifier instead of the dead string.
3270
+ if (useHint && bestDist >= STRING_PENALTY) {
3271
+ return lineColToOffset(entry.tsContent, targetLine, genColHint);
3272
+ }
3273
+ return lineColToOffset(entry.tsContent, targetLine, bestCol);
3274
+ }
2371
3275
 
2372
3276
  // Fall back to original genLine if overload didn't match
2373
3277
  if (targetLine !== genLine) {
@@ -2458,23 +3362,32 @@ export function mapToSource(entry, offset) {
2458
3362
 
2459
3363
  // ── Project config ─────────────────────────────────────────────────
2460
3364
 
2461
- // Read project config: rip.json in the given directory, or "rip" key in
2462
- // the nearest ancestor package.json. Returns { strict, exclude }.
3365
+ // Read project config from the "rip" key in the nearest ancestor
3366
+ // package.json. Returns { strict, checkAll, exclude, ... } merged from
3367
+ // `package.json#rip`, plus `_configDir` marking where it was found.
3368
+ //
3369
+ // `strict` — TS strictness family (noImplicitAny, strictNullChecks, …)
3370
+ // `checkAll` — coverage policy: check every non-@nocheck file, not just
3371
+ // annotated ones. Independent of `strict`.
2463
3372
  export function readProjectConfig(dir) {
2464
3373
  const config = {};
2465
3374
  try {
2466
3375
  let d = resolve(dir);
2467
3376
  while (true) {
2468
- const ripJsonPath = resolve(d, 'rip.json');
2469
- if (existsSync(ripJsonPath)) {
2470
- Object.assign(config, JSON.parse(readFileSync(ripJsonPath, 'utf8')));
2471
- config._configDir = d;
2472
- break;
2473
- }
2474
3377
  const pkgPath = resolve(d, 'package.json');
2475
3378
  if (existsSync(pkgPath)) {
3379
+ // The first package.json walking up is the project boundary — matching
3380
+ // the LSP's findProjectRoot and TypeScript's nearest-config resolution.
3381
+ // Apply its `rip` config if present, otherwise fall back to defaults;
3382
+ // either way stop here. We deliberately do NOT walk past it into
3383
+ // ancestors: a parent repo's config must not silently leak across a
3384
+ // project boundary (e.g. a standalone app nested inside a larger git
3385
+ // repo inheriting that repo's `strict`). Inheritance, if ever wanted,
3386
+ // should be opt-in and explicit rather than positional.
2476
3387
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
2477
- if (pkg.rip && typeof pkg.rip === 'object') { Object.assign(config, pkg.rip); config._configDir = d; break; }
3388
+ if (pkg.rip && typeof pkg.rip === 'object') Object.assign(config, pkg.rip);
3389
+ config._configDir = d;
3390
+ break;
2478
3391
  }
2479
3392
  const parent = dirname(d);
2480
3393
  if (parent === d) break;
@@ -2532,6 +3445,7 @@ function findRipFiles(dir, files = [], excludePatterns = [], rootDir = dir) {
2532
3445
 
2533
3446
  const isColor = process.stdout.isTTY !== false;
2534
3447
  const red = (s) => isColor ? `\x1b[31m${s}\x1b[0m` : s;
3448
+ const green = (s) => isColor ? `\x1b[32m${s}\x1b[0m` : s;
2535
3449
  const yellow = (s) => isColor ? `\x1b[33m${s}\x1b[0m` : s;
2536
3450
  const cyan = (s) => isColor ? `\x1b[36m${s}\x1b[0m` : s;
2537
3451
  const dim = (s) => isColor ? `\x1b[2m${s}\x1b[0m` : s;
@@ -2540,15 +3454,16 @@ const bold = (s) => isColor ? `\x1b[1m${s}\x1b[0m` : s;
2540
3454
  export async function runCheck(targetDir, opts = {}) {
2541
3455
  const rootPath = resolve(targetDir);
2542
3456
 
3457
+ // Use rip's own catalog-pinned TypeScript (a dependency), never the
3458
+ // consumer's — so `rip check` and the editor type-check with the exact
3459
+ // same version. import('typescript') resolves from this file's location
3460
+ // (rip-lang's node_modules) regardless of the consumer's setup.
2543
3461
  let ts;
2544
3462
  try {
2545
- const req = createRequire(resolve(rootPath, 'package.json'));
2546
- ts = req('typescript');
3463
+ ts = await import('typescript').then(m => m.default || m);
2547
3464
  } catch {
2548
- try { ts = await import('typescript').then(m => m.default || m); } catch {
2549
- console.error('TypeScript is required for type checking. Install with: bun add -d typescript');
2550
- return 1;
2551
- }
3465
+ console.error('TypeScript could not be loaded. Reinstall rip-lang (it ships TypeScript as a dependency).');
3466
+ return 1;
2552
3467
  }
2553
3468
 
2554
3469
  if (!existsSync(rootPath)) {
@@ -2557,9 +3472,8 @@ export async function runCheck(targetDir, opts = {}) {
2557
3472
  }
2558
3473
 
2559
3474
  const ripConfig = readProjectConfig(rootPath);
2560
-
2561
- // Merge: CLI flags override config file
2562
- const strict = opts.strict || ripConfig.strict === true;
3475
+ const strict = ripConfig.strict === true;
3476
+ const checkAll = ripConfig.checkAll === true;
2563
3477
  const excludeGlobs = Array.isArray(ripConfig.exclude) ? ripConfig.exclude : [];
2564
3478
  const excludePatterns = excludeGlobs.map(globToRegex);
2565
3479
 
@@ -2577,7 +3491,7 @@ export async function runCheck(targetDir, opts = {}) {
2577
3491
  const source = readFileSync(fp, 'utf8');
2578
3492
  sourcesByPath.set(fp, source);
2579
3493
  const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
2580
- if (!nocheck && (hasTypeAnnotations(source) || strict)) typedFiles.add(fp);
3494
+ if (!nocheck && (hasTypeAnnotations(source) || checkAll)) typedFiles.add(fp);
2581
3495
  }
2582
3496
 
2583
3497
  // Include imports of typed files (files imported BY typed files)
@@ -2613,7 +3527,7 @@ export async function runCheck(targetDir, opts = {}) {
2613
3527
  for (const fp of typedFiles) {
2614
3528
  try {
2615
3529
  const source = sourcesByPath.get(fp);
2616
- compiled.set(fp, compileForCheck(fp, source, new Compiler(), { strict }));
3530
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { checkAll }));
2617
3531
  } catch (e) {
2618
3532
  compileErrors++;
2619
3533
  const rel = relative(rootPath, fp);
@@ -2633,7 +3547,7 @@ export async function runCheck(targetDir, opts = {}) {
2633
3547
  if (compiled.has(stashFile) || !existsSync(stashFile)) continue;
2634
3548
  try {
2635
3549
  const src = sourcesByPath.get(stashFile) ?? readFileSync(stashFile, 'utf8');
2636
- const compiledStash = compileForCheck(stashFile, src, new Compiler(), { strict });
3550
+ const compiledStash = compileForCheck(stashFile, src, new Compiler(), { checkAll });
2637
3551
  compiledStash._typeOnly = true; // skip diagnostics — only here for cross-module types
2638
3552
  compiled.set(stashFile, compiledStash);
2639
3553
  } catch (e) {
@@ -2641,6 +3555,30 @@ export async function runCheck(targetDir, opts = {}) {
2641
3555
  }
2642
3556
  }
2643
3557
 
3558
+ // Always compile the project's entry file when routes exist, so its
3559
+ // `__RipRoutes` export is resolvable from typed route/layout files. The
3560
+ // entry file (server bin) is typically untyped — it just calls `start()` —
3561
+ // so it wouldn't otherwise be pulled into the typed set, and
3562
+ // `import('<entry>').__RipRoutes` would silently resolve to `any`,
3563
+ // disabling the route-typo check. Diagnostics from the entry are
3564
+ // suppressed via `_typeOnly` — only here as a cross-module type carrier.
3565
+ const seenEntry = new Set();
3566
+ for (const fp of typedFiles) {
3567
+ const entryFile = findEntryFile(fp);
3568
+ if (!entryFile || seenEntry.has(entryFile)) continue;
3569
+ seenEntry.add(entryFile);
3570
+ if (!findRoutesDir(fp)) continue;
3571
+ if (compiled.has(entryFile) || !existsSync(entryFile)) continue;
3572
+ try {
3573
+ const src = sourcesByPath.get(entryFile) ?? readFileSync(entryFile, 'utf8');
3574
+ const compiledEntry = compileForCheck(entryFile, src, new Compiler(), { checkAll });
3575
+ compiledEntry._typeOnly = true;
3576
+ compiled.set(entryFile, compiledEntry);
3577
+ } catch (e) {
3578
+ console.warn(`[rip] entry compile failed for ${entryFile}: ${e.message}`);
3579
+ }
3580
+ }
3581
+
2644
3582
  // Also compile any .rip files imported from typed files that aren't yet compiled
2645
3583
  for (const [fp, entry] of [...compiled.entries()]) {
2646
3584
  if (!entry.hasTypes) continue;
@@ -2658,7 +3596,105 @@ export async function runCheck(targetDir, opts = {}) {
2658
3596
  }
2659
3597
  }
2660
3598
 
2661
- // Check for unresolved relative imports in all files (not just typed ones)
3599
+ // ── @rip-lang/* package resolution ─────────────────────────────────
3600
+ //
3601
+ // When a typed file imports from `@rip-lang/foo` (or `@rip-lang/foo/sub`),
3602
+ // resolve the specifier via Node module resolution rooted at the project,
3603
+ // and if it lands on a `.rip` entry, compile that entry with
3604
+ // compileForCheck so its exported annotations become visible to TS as
3605
+ // a virtual `.rip.ts` module. Diagnostics from the package itself are
3606
+ // suppressed (`_typeOnly = true`) — package internals are checked when
3607
+ // the package is checked, not when its consumers are.
3608
+ const pkgRequire = createRequire(resolve(rootPath, 'package.json'));
3609
+ const pkgSpecCache = new Map(); // spec → resolved abs path | null
3610
+ function resolvePkgSpec(spec) {
3611
+ if (pkgSpecCache.has(spec)) return pkgSpecCache.get(spec);
3612
+ let resolved = null;
3613
+ try {
3614
+ const r = pkgRequire.resolve(spec);
3615
+ if (typeof r === 'string' && r.endsWith('.rip') && existsSync(r)) resolved = r;
3616
+ } catch {}
3617
+ pkgSpecCache.set(spec, resolved);
3618
+ return resolved;
3619
+ }
3620
+
3621
+ // ── Undeclared-import diagnostic (`rip check` surface) ──
3622
+ // Same check as the loader and bundler — surfaced earlier when typing is on.
3623
+ // The project's own `package.json#name` is treated as a self-import (covers
3624
+ // in-package fixtures/tests like `packages/server/bench/index.rip`).
3625
+ let undeclaredCount = 0;
3626
+ try {
3627
+ const projPkgPath = resolve(rootPath, 'package.json');
3628
+ if (existsSync(projPkgPath)) {
3629
+ const projPkg = JSON.parse(readFileSync(projPkgPath, 'utf8'));
3630
+ const declared = new Set([
3631
+ ...Object.keys(projPkg.dependencies || {}),
3632
+ ...Object.keys(projPkg.devDependencies || {}),
3633
+ ...Object.keys(projPkg.peerDependencies || {}),
3634
+ ...Object.keys(projPkg.optionalDependencies || {}),
3635
+ ]);
3636
+ const selfName = projPkg.name || null;
3637
+ const reported = new Set();
3638
+ for (const [fp, entry] of compiled) {
3639
+ if (entry._typeOnly) continue;
3640
+ for (const spec of scanRipPkgImports(entry.tsContent || entry.source)) {
3641
+ const pkgKey = ripPkgRoot(spec);
3642
+ if (pkgKey === selfName) continue;
3643
+ if (declared.has(pkgKey)) continue;
3644
+ const key = `${fp}::${pkgKey}`;
3645
+ if (reported.has(key)) continue;
3646
+ reported.add(key);
3647
+ const rel = relative(rootPath, fp);
3648
+ console.error(`${red('error')} ${cyan(rel)}: import of '${pkgKey}' is not declared in package.json. Run \`bun add ${pkgKey}\` (or use \`workspace:*\` inside this monorepo).`);
3649
+ undeclaredCount++;
3650
+ }
3651
+ }
3652
+ }
3653
+ } catch (e) {
3654
+ console.warn(`[rip] undeclared-import check failed: ${e.message}`);
3655
+ }
3656
+ const pendingPkgFiles = new Set();
3657
+ for (const [, entry] of compiled) {
3658
+ const text = entry.tsContent || entry.source;
3659
+ for (const spec of [...scanRipPkgImports(text), ...scanRipPkgImportTypes(text)]) {
3660
+ const r = resolvePkgSpec(spec);
3661
+ if (r && !compiled.has(r)) pendingPkgFiles.add(r);
3662
+ }
3663
+ }
3664
+ // Iterate transitively: package files may themselves import other
3665
+ // @rip-lang/* entries. Bounded by the number of unique resolved paths.
3666
+ while (pendingPkgFiles.size) {
3667
+ const next = pendingPkgFiles.values().next().value;
3668
+ pendingPkgFiles.delete(next);
3669
+ if (compiled.has(next)) continue;
3670
+ try {
3671
+ const pkgSrc = readFileSync(next, 'utf8');
3672
+ const compiledPkg = compileForCheck(next, pkgSrc, new Compiler());
3673
+ compiledPkg._typeOnly = true;
3674
+ compiled.set(next, compiledPkg);
3675
+ const pkgText = compiledPkg.tsContent || pkgSrc;
3676
+ for (const spec of [...scanRipPkgImports(pkgText), ...scanRipPkgImportTypes(pkgText)]) {
3677
+ const r = resolvePkgSpec(spec);
3678
+ if (r && !compiled.has(r)) pendingPkgFiles.add(r);
3679
+ }
3680
+ } catch (e) {
3681
+ console.warn(`[rip] @rip-lang package compile failed for ${next}: ${e.message}`);
3682
+ }
3683
+ }
3684
+
3685
+ // Check for unresolved relative imports in all files (not just typed ones),
3686
+ // and validate render gates (<~) against the stash's source-key set.
3687
+ const stashAnalyses = new Map();
3688
+ const stashAnalysisFor = (fp) => {
3689
+ const stashFile = findStashFile(fp);
3690
+ if (!stashFile) return null;
3691
+ if (!stashAnalyses.has(stashFile)) {
3692
+ const entry = compiled.get(stashFile);
3693
+ stashAnalyses.set(stashFile, entry?.sexpr ? collectStashAnalysis(entry.sexpr) : null);
3694
+ }
3695
+ return stashAnalyses.get(stashFile);
3696
+ };
3697
+
2662
3698
  const fileResults = [];
2663
3699
  let totalErrors = 0, totalWarnings = 0;
2664
3700
  for (const [fp, source] of sourcesByPath) {
@@ -2675,21 +3711,42 @@ export async function runCheck(targetDir, opts = {}) {
2675
3711
  totalErrors++;
2676
3712
  }
2677
3713
  }
3714
+
3715
+ // A gate whose path provably lands on a plain key (or no key)
3716
+ // in app/stash.rip is the compile-time form of the renderer's
3717
+ // deterministic mount error. Conservative — see validateGatePath.
3718
+ const gates = compiled.get(fp)?.gates;
3719
+ if (gates && gates.length) {
3720
+ for (const d of collectGateDiagnostics(gates, source, stashAnalysisFor(fp))) {
3721
+ errors.push({ line: d.line, col: d.col, len: d.len, message: d.message, severity: 'error', code: 'rip', srcLine: d.srcLine, related: [] });
3722
+ totalErrors++;
3723
+ }
3724
+ }
3725
+
2678
3726
  if (errors.length > 0) fileResults.push({ file: fp, errors });
2679
3727
  }
2680
3728
 
2681
3729
  // Create TypeScript language service
2682
3730
  //
2683
- // Project-scope `strict` (rip.json / package.json `rip.strict: true`, or
2684
- // CLI --strict) opts the project UP to TypeScript's `strict` family,
2685
- // which implies noImplicitAny, strictNullChecks, strictFunctionTypes,
2686
- // and friends. With strict on: `T` excludes null/undefined, untyped
2687
- // params error, etc. Without strict: lenient gradual-typing defaults —
2688
- // annotations are accepted as documentation but not enforced as
2689
- // contracts. The default is lenient to match Rip's "scaffolding, not
2690
- // safety rails" philosophy; projects opt up when they want the
2691
- // contract enforced.
2692
- const settings = createTypeCheckSettings(ts, strict ? { strict: true } : {});
3731
+ // Project-scope `strict` (package.json `rip.strict: true`)
3732
+ // opts the project UP to TypeScript's `strict` family, which implies
3733
+ // noImplicitAny, strictNullChecks, strictFunctionTypes, and friends.
3734
+ // With strict on: `T` excludes null/undefined, untyped params error,
3735
+ // etc. Without strict: lenient gradual-typing defaults — annotations
3736
+ // are accepted as documentation but not enforced as contracts. The
3737
+ // default is lenient to match Rip's "scaffolding, not safety rails"
3738
+ // philosophy; projects opt up when they want the contract enforced.
3739
+ // Collect `node_modules/@types` directories walking up from rootPath so
3740
+ // ambient type packages (e.g. `@types/bun`) installed at the workspace
3741
+ // root are picked up even when `rip check` runs in a sub-package. TS's
3742
+ // default `typeRoots` only looks at `<cwd>/node_modules/@types`.
3743
+ const { typeRoots, types: ambientTypes } = collectAmbientTypes(rootPath);
3744
+
3745
+ const settings = createTypeCheckSettings(ts, {
3746
+ ...(strict ? { strict: true } : {}),
3747
+ ...(typeRoots.length ? { typeRoots } : {}),
3748
+ ...(ambientTypes.length ? { types: ambientTypes } : {}),
3749
+ });
2693
3750
 
2694
3751
  const host = {
2695
3752
  getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
@@ -2708,6 +3765,14 @@ export async function runCheck(targetDir, opts = {}) {
2708
3765
  getDirectories: (...a) => ts.sys.getDirectories(...a),
2709
3766
  directoryExists: (...a) => ts.sys.directoryExists(...a),
2710
3767
 
3768
+ resolveTypeReferenceDirectives(typeDirectiveNames, containingFile, redirectedReference, options) {
3769
+ return typeDirectiveNames.map((name) => {
3770
+ const n = typeof name === 'string' ? name : name.name;
3771
+ const r = ts.resolveTypeReferenceDirective(n, containingFile, options || settings, ts.sys, redirectedReference);
3772
+ return r.resolvedTypeReferenceDirective;
3773
+ });
3774
+ },
3775
+
2711
3776
  resolveModuleNames(names, containingFile) {
2712
3777
  return names.map((name) => {
2713
3778
  if (name.endsWith('.rip')) {
@@ -2716,6 +3781,12 @@ export async function runCheck(targetDir, opts = {}) {
2716
3781
  return { resolvedFileName: toVirtual(resolved), extension: '.ts', isExternalLibraryImport: false };
2717
3782
  }
2718
3783
  }
3784
+ if (name.startsWith('@rip-lang/')) {
3785
+ const r = resolvePkgSpec(name);
3786
+ if (r && compiled.has(r)) {
3787
+ return { resolvedFileName: toVirtual(r), extension: '.ts', isExternalLibraryImport: false };
3788
+ }
3789
+ }
2719
3790
  const r = ts.resolveModuleName(name, containingFile, settings, {
2720
3791
  fileExists: host.fileExists,
2721
3792
  readFile: host.readFile,
@@ -2783,12 +3854,18 @@ export async function runCheck(targetDir, opts = {}) {
2783
3854
  if (adj) { pos.line = adj.line; pos.col = adj.col; }
2784
3855
 
2785
3856
  const endPos = adj ? { line: adj.line, col: adj.col + adj.len } : (d.length ? mapToSourcePos(entry, d.start + d.length) : null);
2786
- const len = endPos && endPos.line === pos.line ? endPos.col - pos.col : 1;
3857
+ let len = endPos && endPos.line === pos.line ? endPos.col - pos.col : 1;
2787
3858
 
2788
3859
  const message = cleanDiagnosticMessage(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
2789
3860
  const severity = d.category === 1 ? 'error' : d.category === 0 ? 'warning' : 'info';
2790
3861
  const srcLine = srcLines[pos.line] || '';
2791
3862
 
3863
+ const { code: finalCode, message: finalMessage } = unifyRouteDiagnostic(d.code, message, entry, d.start, fp);
3864
+
3865
+ // Snap route diagnostics to the meaningful token (`href` / `push`).
3866
+ const routeSpan = locateRouteDiagnosticSpan(entry, d.start, srcLine);
3867
+ if (routeSpan) { pos.col = routeSpan.col; len = routeSpan.len; }
3868
+
2792
3869
  // Collect related information
2793
3870
  const related = [];
2794
3871
  if (d.relatedInformation) {
@@ -2835,7 +3912,7 @@ export async function runCheck(targetDir, opts = {}) {
2835
3912
  }
2836
3913
  }
2837
3914
 
2838
- errors.push({ line: pos.line + 1, col: pos.col + 1, len: Math.max(1, len), message, severity, code: d.code, srcLine, related });
3915
+ errors.push({ line: pos.line + 1, col: pos.col + 1, len: Math.max(1, len), message: finalMessage, severity, code: finalCode, srcLine, related });
2839
3916
  if (severity === 'error') totalErrors++;
2840
3917
  else if (severity === 'warning') totalWarnings++;
2841
3918
  }
@@ -2942,6 +4019,12 @@ export async function runCheck(targetDir, opts = {}) {
2942
4019
  // round-trip: srcToOffset must resolve, and getQuickInfoAtPosition must
2943
4020
  // return hover info for it. Failures indicate source map gaps that make
2944
4021
  // hover/definition/completion silently break in the editor.
4022
+ //
4023
+ // Opt-in via `rip check --sourcemap`. This is a compiler-development
4024
+ // diagnostic — gaps usually mean the audit's skip list is incomplete or
4025
+ // that codegen lost a binding, both compiler-side concerns rather than
4026
+ // anything a package author can fix. Not run as part of `--audit` (which
4027
+ // is the package-author-facing public-API check).
2945
4028
 
2946
4029
  const AUDIT_SKIP = new Set([
2947
4030
  'if', 'else', 'then', 'unless', 'switch', 'when', 'for', 'while', 'until',
@@ -3009,7 +4092,7 @@ export async function runCheck(targetDir, opts = {}) {
3009
4092
  let auditGaps = 0;
3010
4093
  const auditResults = [];
3011
4094
 
3012
- for (const [fp, entry] of compiled) {
4095
+ if (opts.sourceMapAudit) for (const [fp, entry] of compiled) {
3013
4096
  if (!entry.hasTypes) continue;
3014
4097
  if (entry._typeOnly) continue;
3015
4098
  const srcLines = entry.source.split('\n');
@@ -3106,19 +4189,6 @@ export async function runCheck(targetDir, opts = {}) {
3106
4189
  }
3107
4190
  }
3108
4191
 
3109
- // Print audit results
3110
- if (auditResults.length > 0) {
3111
- console.log(bold('\n── Source Map Audit ──\n'));
3112
- for (const { file, gaps } of auditResults) {
3113
- const rel = relative(rootPath, file);
3114
- for (const g of gaps) {
3115
- const loc = `${cyan(rel)}${dim(':')}${yellow(String(g.line))}${dim(':')}${yellow(String(g.col))}`;
3116
- console.log(`${loc} ${dim('-')} ${yellow('warning')} ${dim('audit:')} ${g.issue} for '${g.word}'`);
3117
- }
3118
- }
3119
- console.log(`\n${yellow(String(auditGaps))} source map gap${auditGaps === 1 ? '' : 's'} found\n`);
3120
- }
3121
-
3122
4192
  // Print results — tsc format with Rip source positions
3123
4193
  for (const { file, errors } of fileResults) {
3124
4194
  const rel = relative(rootPath, file);
@@ -3158,7 +4228,8 @@ export async function runCheck(targetDir, opts = {}) {
3158
4228
  // Summary — tsc format
3159
4229
  const totalFound = totalErrors + totalWarnings;
3160
4230
  if (totalFound === 0) {
3161
- return compileErrors > 0 ? 1 : 0;
4231
+ printSourceMapAudit();
4232
+ return compileErrors > 0 || undeclaredCount > 0 ? 1 : 0;
3162
4233
  }
3163
4234
 
3164
4235
  const s = totalFound === 1 ? '' : 's';
@@ -3180,5 +4251,417 @@ export async function runCheck(targetDir, opts = {}) {
3180
4251
  console.log('');
3181
4252
  }
3182
4253
 
3183
- return totalErrors > 0 ? 1 : 0;
4254
+ printSourceMapAudit();
4255
+ return totalErrors > 0 || undeclaredCount > 0 ? 1 : 0;
4256
+
4257
+ function printSourceMapAudit() {
4258
+ if (!opts.sourceMapAudit || auditResults.length === 0) return;
4259
+ console.log(bold('── Source Map Audit ──\n'));
4260
+ for (const { file, gaps } of auditResults) {
4261
+ const rel = relative(rootPath, file);
4262
+ for (const g of gaps) {
4263
+ const loc = `${cyan(rel)}${dim(':')}${yellow(String(g.line))}${dim(':')}${yellow(String(g.col))}`;
4264
+ console.log(`${loc} ${dim('-')} ${yellow('warning')} ${dim('audit:')} ${g.issue} for '${g.word}'`);
4265
+ }
4266
+ }
4267
+ console.log(`\n${yellow(String(auditGaps))} source map gap${auditGaps === 1 ? '' : 's'} found\n`);
4268
+ }
4269
+ }
4270
+
4271
+ // ── Public-surface `any` audit ─────────────────────────────────────
4272
+ //
4273
+ // `rip check --audit [pkgDir]` walks a package's public exports and
4274
+ // flags any export whose type contains `any` (in parameters, return
4275
+ // types, or own properties of types declared in the package).
4276
+ // External types (e.g. lib.es5, @types/*) are treated as opaque —
4277
+ // we don't dive into `Promise<Response>` looking for `any` inside
4278
+ // `Response`. The package can only control its own surface; this
4279
+ // audit measures exactly that.
4280
+ //
4281
+ // Exit code: 0 if every export is `any`-free, 1 otherwise.
4282
+
4283
+ // Resolve a package's public entries from its package.json. Handles
4284
+ // `main`, `module`, string `exports`, and the subpath/conditional
4285
+ // `exports` map. Returns [{ subpath, file }] with absolute paths.
4286
+ function collectPackageEntries(pkg, pkgDir) {
4287
+ const entries = new Map(); // subpath → abs file
4288
+ const add = (subpath, p) => {
4289
+ if (typeof p !== 'string') return;
4290
+ if (entries.has(subpath)) return;
4291
+ entries.set(subpath, resolve(pkgDir, p));
4292
+ };
4293
+ const walkConditional = (subpath, v) => {
4294
+ if (typeof v === 'string') { add(subpath, v); return; }
4295
+ if (!v || typeof v !== 'object') return;
4296
+ // Prefer `import` then `default`, fall back to any string value.
4297
+ if (typeof v.import === 'string') { add(subpath, v.import); return; }
4298
+ if (typeof v.default === 'string') { add(subpath, v.default); return; }
4299
+ for (const k of Object.keys(v)) walkConditional(subpath, v[k]);
4300
+ };
4301
+
4302
+ if (typeof pkg.exports === 'string') {
4303
+ add('.', pkg.exports);
4304
+ } else if (pkg.exports && typeof pkg.exports === 'object') {
4305
+ const keys = Object.keys(pkg.exports);
4306
+ const isSubpathMap = keys.some(k => k.startsWith('.'));
4307
+ if (isSubpathMap) {
4308
+ for (const sp of keys) walkConditional(sp, pkg.exports[sp]);
4309
+ } else {
4310
+ walkConditional('.', pkg.exports);
4311
+ }
4312
+ }
4313
+ if (!entries.has('.') && typeof pkg.module === 'string') add('.', pkg.module);
4314
+ if (!entries.has('.') && typeof pkg.main === 'string') add('.', pkg.main);
4315
+ return [...entries.entries()].map(([subpath, file]) => ({ subpath, file }));
4316
+ }
4317
+
4318
+ // True if `type`'s declarations all live outside the package's
4319
+ // compiled set (i.e. it's a lib/`@types`/external type). Anonymous
4320
+ // types (no symbol or no declarations) are treated as local — they
4321
+ // are inline shapes from the package's own annotations.
4322
+ function isExternalType(type, compiled) {
4323
+ const sym = type.aliasSymbol || type.symbol;
4324
+ if (!sym || !sym.declarations || sym.declarations.length === 0) return false;
4325
+ for (const d of sym.declarations) {
4326
+ const sf = d.getSourceFile?.();
4327
+ if (!sf) continue;
4328
+ if (compiled.has(fromVirtual(sf.fileName))) return false;
4329
+ }
4330
+ return true;
4331
+ }
4332
+
4333
+ // Walk a TS type looking for `any`. Returns null if no leak, or a
4334
+ // breadcrumb string describing where the `any` lives.
4335
+ //
4336
+ // Scoping rule: EXTERNAL types are fully opaque. We don't walk into
4337
+ // their unions, type arguments, signatures, or properties. The
4338
+ // package is only accountable for shapes it directly wrote. If a
4339
+ // package's export references another local exported type, that
4340
+ // referenced type gets audited on its own export — not transitively
4341
+ // through every place it's referenced.
4342
+ //
4343
+ // Why so strict: lib.dom types like `BodyInit` resolve to unions
4344
+ // including `ReadableStream<R = any>`. Walking into the expansion
4345
+ // blames the package for `any` it never wrote. Same for `Promise<T>`,
4346
+ // `Array<T>`, `Record<K, V>`, etc. — diving into them surfaces
4347
+ // internals the package doesn't control.
4348
+ //
4349
+ // Trade-off: `Promise<any>` written literally in package source is
4350
+ // also opaque under this rule, so we miss it. Acceptable: such a
4351
+ // pattern is rare and easy to spot in review.
4352
+ function findAnyLeaks(type, ts, checker, compiled, seen = new WeakSet(), depth = 0, exportedSymbols = null, rootSymbol = null) {
4353
+ if (!type || depth > 12) return null;
4354
+ if (type.flags & ts.TypeFlags.Any) return '';
4355
+ if (isExternalType(type, compiled)) return null;
4356
+ // On recursion (depth > 0), stop at any other exported symbol.
4357
+ // Each export is audited on its own line — don't double-count
4358
+ // leaks through cross-references.
4359
+ if (depth > 0 && exportedSymbols) {
4360
+ const sym = type.aliasSymbol || type.symbol;
4361
+ if (sym && sym !== rootSymbol && exportedSymbols.has(sym)) return null;
4362
+ }
4363
+ if (seen.has(type)) return null;
4364
+ seen.add(type);
4365
+
4366
+ const named = (type.aliasSymbol || type.symbol)?.getName?.();
4367
+ const label = named && named !== '__type' && named !== '__object' ? named : null;
4368
+
4369
+ if (type.isUnion?.() || type.isIntersection?.()) {
4370
+ for (let i = 0; i < type.types.length; i++) {
4371
+ const p = findAnyLeaks(type.types[i], ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4372
+ if (p !== null) return joinPath(label, `|${i}`, p);
4373
+ }
4374
+ }
4375
+
4376
+ const args = checker.getTypeArguments?.(type) || type.typeArguments || [];
4377
+ for (let i = 0; i < args.length; i++) {
4378
+ const p = findAnyLeaks(args[i], ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4379
+ if (p !== null) return joinPath(label, `<${i}>`, p);
4380
+ }
4381
+
4382
+ const walkParams = (sig) => {
4383
+ for (const p of sig.parameters) {
4384
+ const decl = p.valueDeclaration || p.declarations?.[0];
4385
+ if (!decl) continue;
4386
+ const pt = checker.getTypeOfSymbolAtLocation(p, decl);
4387
+ const sub = findAnyLeaks(pt, ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4388
+ if (sub !== null) return joinPath(null, `(${p.getName()})`, sub);
4389
+ }
4390
+ return null;
4391
+ };
4392
+
4393
+ for (const sig of type.getCallSignatures?.() || []) {
4394
+ const ps = walkParams(sig);
4395
+ if (ps !== null) return joinPath(label, '', ps);
4396
+ const rs = findAnyLeaks(sig.getReturnType(), ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4397
+ if (rs !== null) return joinPath(label, '=>', rs);
4398
+ }
4399
+ for (const sig of type.getConstructSignatures?.() || []) {
4400
+ const ps = walkParams(sig);
4401
+ if (ps !== null) return joinPath(label, 'new', ps);
4402
+ const rs = findAnyLeaks(sig.getReturnType(), ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4403
+ if (rs !== null) return joinPath(label, 'new=>', rs);
4404
+ }
4405
+
4406
+ if (type.getProperties) {
4407
+ for (const p of type.getProperties()) {
4408
+ const decl = p.valueDeclaration || p.declarations?.[0];
4409
+ if (!decl) continue;
4410
+ const sf = decl.getSourceFile?.();
4411
+ if (!sf) continue;
4412
+ if (!compiled.has(fromVirtual(sf.fileName))) continue;
4413
+ const pt = checker.getTypeOfSymbolAtLocation(p, decl);
4414
+ const sub = findAnyLeaks(pt, ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4415
+ if (sub !== null) return joinPath(label, `.${p.getName()}`, sub);
4416
+ }
4417
+ }
4418
+
4419
+ return null;
4420
+ }
4421
+
4422
+ function joinPath(label, step, rest) {
4423
+ const head = label ? `${label}${step}` : step;
4424
+ if (!rest) return head || '<any>';
4425
+ if (rest.startsWith('|') || rest.startsWith('<') || rest.startsWith('(') || rest.startsWith('.') || rest.startsWith('=>') || rest.startsWith('new')) {
4426
+ return head + rest;
4427
+ }
4428
+ return head ? `${head}.${rest}` : rest;
4429
+ }
4430
+
4431
+ export async function runAudit(targetDir) {
4432
+ const rootPath = resolve(targetDir);
4433
+
4434
+ // Use rip's own catalog-pinned TypeScript (a dependency), never the
4435
+ // consumer's — see runCheck above.
4436
+ let ts;
4437
+ try {
4438
+ ts = await import('typescript').then(m => m.default || m);
4439
+ } catch {
4440
+ console.error('TypeScript could not be loaded. Reinstall rip-lang (it ships TypeScript as a dependency).');
4441
+ return 1;
4442
+ }
4443
+
4444
+ const pkgJsonPath = resolve(rootPath, 'package.json');
4445
+ if (!existsSync(pkgJsonPath)) {
4446
+ console.error(red(`No package.json found at ${rootPath}`));
4447
+ return 1;
4448
+ }
4449
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
4450
+ const pkgName = pkg.name || basename(rootPath);
4451
+
4452
+ const allEntries = collectPackageEntries(pkg, rootPath);
4453
+ const ripEntries = allEntries.filter(e => e.file.endsWith('.rip') && existsSync(e.file));
4454
+ if (ripEntries.length === 0) {
4455
+ console.error(red(`No .rip entry points found in ${pkgName}`));
4456
+ if (allEntries.length > 0) {
4457
+ console.error(dim(` package.json declares entries, but none point to a .rip file:`));
4458
+ for (const e of allEntries) console.error(dim(` ${e.subpath} → ${relative(rootPath, e.file)}`));
4459
+ }
4460
+ return 1;
4461
+ }
4462
+
4463
+ // Compile each entry plus its transitive `.rip` imports so the
4464
+ // language service can resolve cross-module types. Only entries
4465
+ // themselves are audited; imported files exist purely so types
4466
+ // referenced from the entry can be expanded.
4467
+ const compiled = new Map();
4468
+ const queue = ripEntries.map(e => e.file);
4469
+ while (queue.length) {
4470
+ const fp = queue.shift();
4471
+ if (compiled.has(fp)) continue;
4472
+ try {
4473
+ const source = readFileSync(fp, 'utf8');
4474
+ // Compile with strict: true so every export's annotations are
4475
+ // emitted into the DTS — without strict, partially-annotated
4476
+ // exports could fall back to inferred types that look cleaner
4477
+ // than they really are.
4478
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { checkAll: true }));
4479
+ for (const m of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) {
4480
+ const spec = m[1];
4481
+ if (spec.endsWith('.rip')) {
4482
+ const r = resolve(dirname(fp), spec);
4483
+ if (existsSync(r) && !compiled.has(r)) queue.push(r);
4484
+ }
4485
+ }
4486
+ } catch (e) {
4487
+ console.error(`${red('error')} ${cyan(relative(rootPath, fp))}: compile error — ${e.message}`);
4488
+ return 1;
4489
+ }
4490
+ }
4491
+
4492
+ // Resolve @rip-lang/* package imports (siblings in the workspace)
4493
+ // so cross-package re-exports type correctly.
4494
+ const pkgRequire = createRequire(resolve(rootPath, 'package.json'));
4495
+ const pkgSpecCache = new Map();
4496
+ function resolvePkgSpec(spec) {
4497
+ if (pkgSpecCache.has(spec)) return pkgSpecCache.get(spec);
4498
+ let resolved = null;
4499
+ try {
4500
+ const r = pkgRequire.resolve(spec);
4501
+ if (typeof r === 'string' && r.endsWith('.rip') && existsSync(r)) resolved = r;
4502
+ } catch {}
4503
+ pkgSpecCache.set(spec, resolved);
4504
+ return resolved;
4505
+ }
4506
+ const pkgQueue = new Set();
4507
+ for (const [, entry] of compiled) {
4508
+ for (const spec of scanRipPkgImports(entry.tsContent || entry.source)) {
4509
+ const r = resolvePkgSpec(spec);
4510
+ if (r && !compiled.has(r)) pkgQueue.add(r);
4511
+ }
4512
+ }
4513
+ while (pkgQueue.size) {
4514
+ const next = pkgQueue.values().next().value;
4515
+ pkgQueue.delete(next);
4516
+ if (compiled.has(next)) continue;
4517
+ try {
4518
+ const src = readFileSync(next, 'utf8');
4519
+ const compiledPkg = compileForCheck(next, src, new Compiler());
4520
+ compiled.set(next, compiledPkg);
4521
+ for (const spec of scanRipPkgImports(compiledPkg.tsContent || src)) {
4522
+ const r = resolvePkgSpec(spec);
4523
+ if (r && !compiled.has(r)) pkgQueue.add(r);
4524
+ }
4525
+ } catch (e) {
4526
+ console.warn(`[rip] @rip-lang package compile failed for ${next}: ${e.message}`);
4527
+ }
4528
+ }
4529
+
4530
+ const { typeRoots, types: ambientTypes } = collectAmbientTypes(rootPath);
4531
+ const settings = createTypeCheckSettings(ts, {
4532
+ strict: true,
4533
+ ...(typeRoots.length ? { typeRoots } : {}),
4534
+ ...(ambientTypes.length ? { types: ambientTypes } : {}),
4535
+ });
4536
+
4537
+ const host = {
4538
+ getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
4539
+ getScriptVersion: () => '1',
4540
+ getScriptSnapshot(f) {
4541
+ const c = compiled.get(fromVirtual(f));
4542
+ if (c) return ts.ScriptSnapshot.fromString(c.tsContent);
4543
+ try { return ts.ScriptSnapshot.fromString(readFileSync(f, 'utf8')); } catch { return undefined; }
4544
+ },
4545
+ getCompilationSettings: () => settings,
4546
+ getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
4547
+ getCurrentDirectory: () => rootPath,
4548
+ fileExists(f) { return compiled.has(fromVirtual(f)) || ts.sys.fileExists(f); },
4549
+ readFile(f) { return compiled.get(fromVirtual(f))?.tsContent || ts.sys.readFile(f); },
4550
+ readDirectory: (...a) => ts.sys.readDirectory(...a),
4551
+ getDirectories: (...a) => ts.sys.getDirectories(...a),
4552
+ directoryExists: (...a) => ts.sys.directoryExists(...a),
4553
+ resolveTypeReferenceDirectives(names, containingFile, redirectedReference, options) {
4554
+ return names.map(n => {
4555
+ const name = typeof n === 'string' ? n : n.name;
4556
+ const r = ts.resolveTypeReferenceDirective(name, containingFile, options || settings, ts.sys, redirectedReference);
4557
+ return r.resolvedTypeReferenceDirective;
4558
+ });
4559
+ },
4560
+ resolveModuleNames(names, containingFile) {
4561
+ return names.map(name => {
4562
+ if (name.endsWith('.rip')) {
4563
+ const r = resolve(dirname(fromVirtual(containingFile)), name);
4564
+ if (compiled.has(r)) return { resolvedFileName: toVirtual(r), extension: '.ts', isExternalLibraryImport: false };
4565
+ }
4566
+ if (name.startsWith('@rip-lang/')) {
4567
+ const r = resolvePkgSpec(name);
4568
+ if (r && compiled.has(r)) return { resolvedFileName: toVirtual(r), extension: '.ts', isExternalLibraryImport: false };
4569
+ }
4570
+ const r = ts.resolveModuleName(name, containingFile, settings, {
4571
+ fileExists: host.fileExists,
4572
+ readFile: host.readFile,
4573
+ directoryExists: host.directoryExists,
4574
+ getCurrentDirectory: host.getCurrentDirectory,
4575
+ getDirectories: host.getDirectories,
4576
+ });
4577
+ return r.resolvedModule;
4578
+ });
4579
+ },
4580
+ };
4581
+
4582
+ const service = ts.createLanguageService(host, ts.createDocumentRegistry());
4583
+ const program = service.getProgram();
4584
+ const checker = program.getTypeChecker();
4585
+ const fmtFlags = ts.TypeFormatFlags.NoTruncation
4586
+ | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope
4587
+ | ts.TypeFormatFlags.WriteArrayAsGenericType;
4588
+
4589
+ let totalExports = 0, totalLeaks = 0;
4590
+
4591
+ // First pass: collect ALL exported symbols across all entries.
4592
+ // The walker treats these as opaque on recursion — each export is
4593
+ // audited only on its own direct shape. If export A references
4594
+ // export B and B leaks, the leak surfaces on B's audit line, not
4595
+ // by polluting every type that mentions B.
4596
+ const exportedSymbols = new Set();
4597
+ const entryData = [];
4598
+ for (const entry of ripEntries) {
4599
+ const vf = toVirtual(entry.file);
4600
+ const sourceFile = program.getSourceFile(vf);
4601
+ if (!sourceFile) { entryData.push(null); continue; }
4602
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
4603
+ if (!moduleSymbol) { entryData.push({ sourceFile, exports: null }); continue; }
4604
+ const exps = checker.getExportsOfModule(moduleSymbol);
4605
+ for (const s of exps) exportedSymbols.add(s);
4606
+ entryData.push({ sourceFile, exports: exps });
4607
+ }
4608
+
4609
+ for (let idx = 0; idx < ripEntries.length; idx++) {
4610
+ const entry = ripEntries[idx];
4611
+ const data = entryData[idx];
4612
+ const rel = relative(rootPath, entry.file);
4613
+
4614
+ if (!data) {
4615
+ console.log(` ${red('error')} could not load ${cyan(rel)}`);
4616
+ continue;
4617
+ }
4618
+ const { sourceFile, exports } = data;
4619
+ if (!exports) {
4620
+ console.log(` ${dim('(no exports)')}`);
4621
+ continue;
4622
+ }
4623
+
4624
+ const header = entry.subpath === '.' ? rel : `${rel} ${dim('('+entry.subpath+')')}`;
4625
+ console.log(`\n ${cyan(header)}`);
4626
+
4627
+ // Compute max name width for column alignment.
4628
+ const names = exports.map(s => s.getName());
4629
+ const colW = Math.min(28, names.reduce((m, n) => Math.max(m, n.length), 0));
4630
+
4631
+ for (const sym of exports) {
4632
+ totalExports++;
4633
+ let t;
4634
+ if (sym.flags & ts.SymbolFlags.Value) {
4635
+ t = checker.getTypeOfSymbolAtLocation(sym, sourceFile);
4636
+ } else {
4637
+ t = checker.getDeclaredTypeOfSymbol(sym);
4638
+ }
4639
+ // Pass `sym` as the rootSymbol so recursion into OTHER exported
4640
+ // symbols stops, but recursion into the export's own self-named
4641
+ // type (e.g. typeof Class → the class itself) doesn't immediately
4642
+ // bail out.
4643
+ const leakPath = findAnyLeaks(t, ts, checker, compiled, new WeakSet(), 0, exportedSymbols, sym);
4644
+ const leaks = leakPath !== null;
4645
+ const typeStr = checker.typeToString(t, sourceFile, fmtFlags);
4646
+ const name = sym.getName();
4647
+ const mark = leaks ? red('✗') : green('✓');
4648
+ console.log(` ${mark} ${name.padEnd(colW)} ${dim(typeStr)}`);
4649
+ if (leaks) {
4650
+ totalLeaks++;
4651
+ console.log(` ${dim('└─ any at: ')}${yellow(leakPath || '<root>')}`);
4652
+ }
4653
+ }
4654
+ }
4655
+
4656
+ const typed = totalExports - totalLeaks;
4657
+ const pct = totalExports > 0 ? (100 * typed / totalExports).toFixed(1) : '100.0';
4658
+
4659
+ console.log('');
4660
+ if (totalLeaks === 0) {
4661
+ console.log(`${green('✓')} ${bold(pkgName)}: ${typed}/${totalExports} exports fully typed (${pct}%).`);
4662
+ } else {
4663
+ console.log(`${red('✗')} ${bold(pkgName)}: ${typed}/${totalExports} exports fully typed (${pct}%). ${red(String(totalLeaks))} export${totalLeaks === 1 ? '' : 's'} leak \`any\`.`);
4664
+ }
4665
+
4666
+ return totalLeaks > 0 ? 1 : 0;
3184
4667
  }