rip-lang 3.15.4 → 3.16.1

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 (112) hide show
  1. package/README.md +6 -4
  2. package/bin/rip +167 -12
  3. package/docs/AGENTS.md +1 -1
  4. package/docs/RIP-APP.md +808 -0
  5. package/docs/RIP-DUCKDB.md +477 -0
  6. package/docs/RIP-INTRO.md +396 -0
  7. package/docs/RIP-LANG.md +59 -5
  8. package/docs/RIP-SCHEMA.md +191 -8
  9. package/docs/RIP-TYPES.md +74 -103
  10. package/docs/demo/README.md +4 -3
  11. package/docs/dist/rip.js +3627 -1470
  12. package/docs/dist/rip.min.js +671 -244
  13. package/docs/dist/rip.min.js.br +0 -0
  14. package/docs/example/index.json +7 -7
  15. package/docs/example/index.json.br +0 -0
  16. package/docs/extensions/duckdb/manifest.json +1 -1
  17. package/docs/extensions/duckdb/v1.5.2/linux_amd64/ripdb.duckdb_extension.gz +0 -0
  18. package/docs/extensions/duckdb/v1.5.2/osx_arm64/ripdb.duckdb_extension.gz +0 -0
  19. package/docs/extensions/vscode/print/index.html +2 -1
  20. package/docs/extensions/vscode/print/print-1.0.13.vsix +0 -0
  21. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  22. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  23. package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
  24. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  25. package/docs/ui/bundle.json +61 -0
  26. package/docs/ui/bundle.json.br +0 -0
  27. package/docs/ui/hljs-rip.js +0 -7
  28. package/docs/ui/index.css +66 -23
  29. package/docs/ui/index.html +6 -6
  30. package/package.json +9 -3
  31. package/rip-loader.js +64 -2
  32. package/src/AGENTS.md +63 -36
  33. package/src/browser.js +96 -14
  34. package/src/compiler.js +960 -143
  35. package/src/components.js +794 -88
  36. package/src/{types-emit.js → dts.js} +181 -71
  37. package/src/grammar/README.md +1 -1
  38. package/src/grammar/grammar.rip +111 -97
  39. package/src/lexer.js +132 -18
  40. package/src/parser.js +203 -205
  41. package/src/repl.js +74 -6
  42. package/src/schema/runtime-orm.js +168 -4
  43. package/src/schema/runtime-validate.js +146 -2
  44. package/src/schema/runtime.generated.js +314 -6
  45. package/src/schema/schema.js +5 -5
  46. package/src/sourcemaps.js +277 -1
  47. package/src/stdlib.js +253 -0
  48. package/src/typecheck.js +2023 -106
  49. package/src/types.js +127 -7
  50. package/docs/ui/accordion.rip +0 -103
  51. package/docs/ui/alert-dialog.rip +0 -53
  52. package/docs/ui/autocomplete.rip +0 -115
  53. package/docs/ui/avatar.rip +0 -37
  54. package/docs/ui/badge.rip +0 -15
  55. package/docs/ui/breadcrumb.rip +0 -47
  56. package/docs/ui/button-group.rip +0 -26
  57. package/docs/ui/button.rip +0 -23
  58. package/docs/ui/card.rip +0 -25
  59. package/docs/ui/carousel.rip +0 -110
  60. package/docs/ui/checkbox-group.rip +0 -61
  61. package/docs/ui/checkbox.rip +0 -33
  62. package/docs/ui/collapsible.rip +0 -50
  63. package/docs/ui/combobox.rip +0 -130
  64. package/docs/ui/context-menu.rip +0 -88
  65. package/docs/ui/date-picker.rip +0 -206
  66. package/docs/ui/dialog.rip +0 -60
  67. package/docs/ui/drawer.rip +0 -58
  68. package/docs/ui/editable-value.rip +0 -82
  69. package/docs/ui/field.rip +0 -53
  70. package/docs/ui/fieldset.rip +0 -22
  71. package/docs/ui/form.rip +0 -39
  72. package/docs/ui/grid.rip +0 -901
  73. package/docs/ui/input-group.rip +0 -28
  74. package/docs/ui/input.rip +0 -36
  75. package/docs/ui/label.rip +0 -16
  76. package/docs/ui/menu.rip +0 -134
  77. package/docs/ui/menubar.rip +0 -151
  78. package/docs/ui/meter.rip +0 -36
  79. package/docs/ui/multi-select.rip +0 -203
  80. package/docs/ui/native-select.rip +0 -33
  81. package/docs/ui/nav-menu.rip +0 -126
  82. package/docs/ui/number-field.rip +0 -162
  83. package/docs/ui/otp-field.rip +0 -89
  84. package/docs/ui/pagination.rip +0 -123
  85. package/docs/ui/popover.rip +0 -93
  86. package/docs/ui/preview-card.rip +0 -75
  87. package/docs/ui/progress.rip +0 -25
  88. package/docs/ui/radio-group.rip +0 -57
  89. package/docs/ui/resizable.rip +0 -123
  90. package/docs/ui/scroll-area.rip +0 -145
  91. package/docs/ui/select.rip +0 -151
  92. package/docs/ui/separator.rip +0 -17
  93. package/docs/ui/skeleton.rip +0 -22
  94. package/docs/ui/slider.rip +0 -165
  95. package/docs/ui/spinner.rip +0 -17
  96. package/docs/ui/table.rip +0 -27
  97. package/docs/ui/tabs.rip +0 -113
  98. package/docs/ui/textarea.rip +0 -48
  99. package/docs/ui/toast.rip +0 -87
  100. package/docs/ui/toggle-group.rip +0 -71
  101. package/docs/ui/toggle.rip +0 -24
  102. package/docs/ui/toolbar.rip +0 -38
  103. package/docs/ui/tooltip.rip +0 -85
  104. package/src/app.rip +0 -1571
  105. package/src/sourcemap-merge.js +0 -287
  106. /package/docs/demo/{components → routes}/_layout.rip +0 -0
  107. /package/docs/demo/{components → routes}/about.rip +0 -0
  108. /package/docs/demo/{components → routes}/card.rip +0 -0
  109. /package/docs/demo/{components → routes}/counter.rip +0 -0
  110. /package/docs/demo/{components → routes}/index.rip +0 -0
  111. /package/docs/demo/{components → routes}/todos.rip +0 -0
  112. /package/src/schema/{dts-emit.js → dts.js} +0 -0
package/src/typecheck.js CHANGED
@@ -11,14 +11,250 @@
11
11
  // type errors mapped back to Rip source positions.
12
12
 
13
13
  import { Compiler, getStdlibCode } from './compiler.js';
14
- import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN } from './types-emit.js';
15
- import { hasSchemas } from './schema/schema.js';
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, 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 } from 'path';
19
+ import { resolve, relative, dirname, basename, sep as pathSep } from 'path';
20
20
  import { buildLineMap } from './sourcemaps.js';
21
21
 
22
+ // ── Typed stash: project entry discovery ───────────────────────────
23
+ //
24
+ // The stash type is inferred, not declared. The user's project has a
25
+ // dedicated `<root>/app/stash.rip` file (in the client bundle) that
26
+ // contains a top-level `stash:: <Type> = ...` declaration. The type
27
+ // checker exposes that variable's type as `__RipStash` on the stash
28
+ // file's virtual module. Components splice
29
+ // `import('<rel-to-stash>').__RipStash` into their `app.data` declaration.
30
+ //
31
+ // Discovery: walk up from each file to the nearest dir that contains an
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.
34
+ const entryFileCache = new Map(); // dir → entryFile|null
35
+ const stashFileCache = new Map(); // root dir → stashFile|null
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
+
86
+ export function findEntryFile(filePath) {
87
+ let dir = dirname(filePath);
88
+ const visited = [];
89
+ while (true) {
90
+ if (entryFileCache.has(dir)) {
91
+ const cached = entryFileCache.get(dir);
92
+ for (const v of visited) entryFileCache.set(v, cached);
93
+ return cached;
94
+ }
95
+ visited.push(dir);
96
+ const hasAnchor = existsSync(resolve(dir, 'package.json'));
97
+ if (hasAnchor) {
98
+ const entry = resolve(dir, 'index.rip');
99
+ const result = existsSync(entry) ? entry : null;
100
+ for (const v of visited) entryFileCache.set(v, result);
101
+ return result;
102
+ }
103
+ const parent = dirname(dir);
104
+ if (parent === dir) {
105
+ for (const v of visited) entryFileCache.set(v, null);
106
+ return null;
107
+ }
108
+ dir = parent;
109
+ }
110
+ }
111
+
112
+ // Build the relative import specifier from a file to its entry. Always uses
113
+ // posix forward slashes (TS module specifiers are not platform-dependent) and
114
+ // is guaranteed to start with './' or '../'.
115
+ function entryImportSpec(filePath, entryFile) {
116
+ let rel = relative(dirname(filePath), entryFile);
117
+ if (pathSep !== '/') rel = rel.split(pathSep).join('/');
118
+ if (!rel.startsWith('.')) rel = './' + rel;
119
+ return rel;
120
+ }
121
+
122
+ // Locate the project's stash file (`<root>/app/stash.rip`). Returns the
123
+ // absolute path or null. Cached per project root.
124
+ export function findStashFile(filePath) {
125
+ const entryFile = findEntryFile(filePath);
126
+ if (!entryFile) return null;
127
+ const root = dirname(entryFile);
128
+ if (stashFileCache.has(root)) return stashFileCache.get(root);
129
+ const stash = resolve(root, 'app', 'stash.rip');
130
+ const result = existsSync(stash) ? stash : null;
131
+ stashFileCache.set(root, result);
132
+ return result;
133
+ }
134
+
135
+ // ── Route tree discovery ───────────────────────────────────────────
136
+ //
137
+ // The project's routes live under `<projectRoot>/app/routes/` — a fixed
138
+ // convention (not configurable) that matches `@rip-lang/server`'s
139
+ // `serve dir: "<root>/app"`, which mounts route files under `app/routes/`.
140
+ // Each `.rip` file there contributes one entry to a
141
+ // generated `__RipRoutes` template-literal union, used for typed
142
+ // `<a href: "...">`, typed `router.push`, and per-route `@params`
143
+ // tightening. Mirrors the runtime rules in `buildRoutes`
144
+ // (packages/app/index.rip): skip `_-prefixed` files, skip files
145
+ // inside `_-prefixed` directories, treat `index.rip` as `/`,
146
+ // `[id]` as a dynamic segment, `[...rest]` as catch-all.
147
+ const routesDirCache = new Map(); // entryDir → absoluteRoutesDir|null
148
+ const routesTreeCache = new Map(); // routesDir → { entries, union }
149
+
150
+ // Invalidate the route-tree cache for the project containing `filePath`,
151
+ // or all projects when no path is given. Called by the LSP whenever a
152
+ // `.rip` file is added/removed/renamed so completions, hover, and
153
+ // diagnostics see the new route shape without a process restart.
154
+ export function invalidateRoutesCache(filePath) {
155
+ if (!filePath) {
156
+ routesDirCache.clear();
157
+ routesTreeCache.clear();
158
+ return;
159
+ }
160
+ // Cheap and correct: drop both caches. Reads are O(routes-dir scan),
161
+ // and the caches refill on the next access. Pin-pointing the exact
162
+ // entryDir would require re-running findEntryFile, which itself
163
+ // caches; clearing wholesale avoids the dependency.
164
+ routesDirCache.clear();
165
+ routesTreeCache.clear();
166
+ }
167
+
168
+ export function findRoutesDir(filePath) {
169
+ const entryFile = findEntryFile(filePath);
170
+ if (!entryFile) return null;
171
+ const root = dirname(entryFile);
172
+ if (routesDirCache.has(root)) return routesDirCache.get(root);
173
+ // Convention: `app/routes/`. Matches `@rip-lang/server`'s `serve dir:
174
+ // "<root>/app"` pattern, which resolves routes under `app/routes/`.
175
+ const dir = resolve(root, 'app/routes');
176
+ const result = existsSync(dir) ? dir : null;
177
+ routesDirCache.set(root, result);
178
+ return result;
179
+ }
180
+
181
+ // Build per-route metadata and the `__RipRoutes` template-literal union.
182
+ // Each entry: { rel, file, pattern (TS expression), dynamic: [{name, catchAll}] }
183
+ export function walkRoutesDir(routesDir) {
184
+ if (!routesDir) return { entries: [], union: 'never' };
185
+ if (routesTreeCache.has(routesDir)) return routesTreeCache.get(routesDir);
186
+ const entries = [];
187
+ function walk(dir, segs) {
188
+ let dirents;
189
+ try { dirents = readdirSync(dir, { withFileTypes: true }); }
190
+ catch { return; }
191
+ for (const e of dirents) {
192
+ // Skip _-prefixed files (_layout.rip etc.) and dirs (shared
193
+ // helpers, not pages). Same rule as runtime buildRoutes.
194
+ if (e.name.startsWith('_')) continue;
195
+ if (e.isDirectory()) walk(resolve(dir, e.name), [...segs, e.name]);
196
+ else if (e.isFile() && e.name.endsWith('.rip')) {
197
+ const base = e.name.slice(0, -'.rip'.length);
198
+ const fileSegs = base === 'index' ? segs : [...segs, base];
199
+ const dynamic = [];
200
+ const displaySegs = [];
201
+ const tsSegs = fileSegs.map(s => {
202
+ let m = s.match(/^\[\.\.\.(\w+)\]$/);
203
+ if (m) { dynamic.push({ name: m[1], catchAll: true }); displaySegs.push('$' + m[1]); return '${string}'; }
204
+ m = s.match(/^\[(\w+)\]$/);
205
+ if (m) { dynamic.push({ name: m[1], catchAll: false }); displaySegs.push('$' + m[1]); return '${string}'; }
206
+ displaySegs.push(s);
207
+ return s;
208
+ });
209
+ const path = '/' + tsSegs.join('/');
210
+ const displayPath = '/' + displaySegs.join('/');
211
+ const pattern = dynamic.length === 0
212
+ ? JSON.stringify(path === '//' ? '/' : path)
213
+ : '`' + (path === '//' ? '/' : path) + '`';
214
+ const display = dynamic.length === 0
215
+ ? null
216
+ : '`' + (displayPath === '//' ? '/' : displayPath) + '`';
217
+ entries.push({
218
+ rel: relative(routesDir, resolve(dir, e.name)),
219
+ file: resolve(dir, e.name),
220
+ pattern,
221
+ display,
222
+ displayPath,
223
+ dynamic,
224
+ });
225
+ }
226
+ }
227
+ }
228
+ walk(routesDir, []);
229
+ // Canonical order: index route ("/") first, then the rest by display
230
+ // path, lexicographically. Filesystem walk order is undefined across
231
+ // platforms — sorting here gives stable union order and a consistent
232
+ // member sequence in completions, hovers, and error messages.
233
+ entries.sort((a, b) => {
234
+ if (a.displayPath === '/') return -1;
235
+ if (b.displayPath === '/') return 1;
236
+ return a.displayPath < b.displayPath ? -1 : a.displayPath > b.displayPath ? 1 : 0;
237
+ });
238
+ // Build union, deduping static patterns (template-literal patterns
239
+ // are inherently distinct by structure). Catch-all routes
240
+ // (`[...rest].rip`) are excluded — they're runtime 404 fallbacks,
241
+ // not navigation targets, and including them as `/${string}` would
242
+ // make the union accept any slash-prefixed string and defeat
243
+ // typo-catching for every other route.
244
+ const seen = new Set();
245
+ const parts = [];
246
+ for (const e of entries) {
247
+ if (e.dynamic.some(d => d.catchAll)) continue;
248
+ if (seen.has(e.pattern)) continue;
249
+ seen.add(e.pattern);
250
+ parts.push(e.pattern);
251
+ }
252
+ const union = parts.length ? parts.join(' | ') : 'never';
253
+ const result = { entries, union };
254
+ routesTreeCache.set(routesDir, result);
255
+ return result;
256
+ }
257
+
22
258
  // ── Shared helpers ─────────────────────────────────────────────────
23
259
 
24
260
  // Detect type annotations (:: followed by space or =) ignoring comments,
@@ -154,12 +390,12 @@ export function checkComponentDefs(compProps, srcLines, startLine = 0) {
154
390
  const errors = [];
155
391
  for (const prop of compProps) {
156
392
  for (let s = startLine; s < srcLines.length; s++) {
157
- const m = new RegExp('(@' + prop.name + ')\\s*(::|([:!]?=))').exec(srcLines[s]);
393
+ const m = new RegExp('(@' + prop.name + ')\\??\\s*(::|([:!]?=))').exec(srcLines[s]);
158
394
  if (!m) continue;
159
395
  if (m[1 + 1] !== '::') {
160
396
  errors.push({ line: s, col: m.index, len: m[1].length, propName: prop.name, message: `Prop '${prop.name}' has no type annotation` });
161
397
  } else {
162
- const dm = srcLines[s].match(new RegExp('@' + prop.name + '\\s*::\\s*(.+?)\\s*:=\\s*(.+)'));
398
+ const dm = srcLines[s].match(new RegExp('@' + prop.name + '\\??\\s*::\\s*(.+?)\\s*:=\\s*(.+)'));
163
399
  if (dm) {
164
400
  const defVal = dm[2].replace(/#.*$/, '').trim();
165
401
  const err = validatePropDefault(dm[1].trim(), defVal);
@@ -354,6 +590,37 @@ export const SKIP_CODES = new Set([
354
590
  1064, // Return type of async function must be Promise
355
591
  ]);
356
592
 
593
+ // Dedup diagnostics by (start line/col, code).
594
+ // The same TS error can fire twice when the dts header and compiled body
595
+ // both contain the offending construct (e.g. an `import { X }` line that
596
+ // maps to the same source position from both copies), or when a diagnostic
597
+ // hits both an injected function overload signature and its implementation.
598
+ //
599
+ // The key intentionally excludes end position and message: the duplicates we
600
+ // want to collapse routinely differ in span length (overload sig vs impl
601
+ // token widths) and occasionally in message text (TS referencing different
602
+ // candidate signatures). Same start position + same code is the right
603
+ // invariant — distinct logical errors at the exact same (line, col, code)
604
+ // would be vanishingly rare and folding them is preferable to leaking
605
+ // structural duplicates.
606
+ //
607
+ // `getRange(d)` must return `{ startLine, startCol, endLine, endCol }`.
608
+ // Returns a new array; does not mutate the input. Preserves input object
609
+ // identity so callers can use Set membership to find which entries were
610
+ // dropped.
611
+ export function dedupDiagnostics(diags, getRange) {
612
+ const seen = new Set();
613
+ const out = [];
614
+ for (const d of diags) {
615
+ const r = getRange(d);
616
+ const key = `${r.startLine}:${r.startCol}:${d.code}`;
617
+ if (seen.has(key)) continue;
618
+ seen.add(key);
619
+ out.push(d);
620
+ }
621
+ return out;
622
+ }
623
+
357
624
  // Codes that need conditional suppression (not blanket).
358
625
  // 2300/2451: Suppress only when one endpoint is in the DTS header (structural).
359
626
  // Let through when both endpoints are in the compiled body (real shadowing).
@@ -375,12 +642,60 @@ export const CONDITIONAL_CODES = new Set([2300, 2451, 2307, 2582, 2593]);
375
642
  // dts — the .d.ts content (for identifier checks)
376
643
  // flatMessage — flattened diagnostic message string (only needed for 2307)
377
644
  // filePath — original .rip file path (only needed for 2582/2593)
378
- export function shouldSuppressConditional(code, start, length, tsContent, headerLines, dts, flatMessage, filePath) {
645
+ export function shouldSuppressConditional(code, start, length, tsContent, headerLines, dts, flatMessage, filePath, relatedInformation) {
379
646
  if (code === 2300 || code === 2451) {
380
647
  // Duplicate identifier: suppress when one endpoint is in the DTS header.
381
648
  const diagLine = offsetToLine(tsContent, start);
382
649
  if (diagLine < headerLines) return true; // diagnostic is on the header declaration
383
- // Body-side: check if the identifier also lives in the DTS header
650
+
651
+ // Body-side: if TS attached relatedInformation pointing at the other
652
+ // declaration, trust it. When the *other* endpoint is also in the body
653
+ // (same file), this is a real shadowing collision and must surface.
654
+ if (Array.isArray(relatedInformation) && relatedInformation.length) {
655
+ for (const r of relatedInformation) {
656
+ if (r.start === undefined) continue;
657
+ // Only consider related info in the same virtual file.
658
+ if (r.file && r.file.text !== tsContent) continue;
659
+ const rLine = offsetToLine(tsContent, r.start);
660
+ if (rLine >= headerLines) return false; // body ↔ body collision — real bug
661
+ }
662
+ return true; // every related endpoint sits in the DTS header → structural
663
+ }
664
+
665
+ // Skip the dts-heuristic for import specifiers — TS doesn't double-emit
666
+ // imports the way it does `def`/`class`, so a body-side 2300 on an import
667
+ // name is real iff the same name is imported by 2+ body import statements.
668
+ // (When only one body import has the name, the duplicate is the dts copy
669
+ // that the typecheck virtual file injects, which is structural noise.)
670
+ const lineStart = tsContent.lastIndexOf('\n', start - 1) + 1;
671
+ const lineSoFar = tsContent.substring(lineStart, start);
672
+ if (/^\s*import\b/.test(lineSoFar)) {
673
+ const ident = length ? tsContent.substring(start, start + length).trim() : '';
674
+ if (ident) {
675
+ // Walk only body lines (past the dts header) and tally imports of `ident`.
676
+ const bodyStart = (() => {
677
+ let pos = 0, line = 0;
678
+ while (line < headerLines && pos < tsContent.length) {
679
+ const nl = tsContent.indexOf('\n', pos);
680
+ if (nl < 0) return tsContent.length;
681
+ pos = nl + 1; line++;
682
+ }
683
+ return pos;
684
+ })();
685
+ const body = tsContent.slice(bodyStart);
686
+ const importRe = /^[ \t]*import\s+(?:[A-Za-z_$][\w$]*\s*,\s*)?\{([^}]*)\}\s+from\b/gm;
687
+ let im, hits = 0;
688
+ const wordRe = new RegExp('\\b' + ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
689
+ while ((im = importRe.exec(body))) {
690
+ if (wordRe.test(im[1])) hits++;
691
+ if (hits > 1) break;
692
+ }
693
+ return hits < 2; // suppress only when there's no real body↔body collision
694
+ }
695
+ return false;
696
+ }
697
+
698
+ // Fallback when no relatedInformation: use the dts identifier heuristic.
384
699
  const ident = length ? tsContent.substring(start, start + length).trim() : '';
385
700
  if (ident && dts) {
386
701
  const escaped = ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
@@ -472,21 +787,169 @@ export function cleanDiagnosticMessage(msg) {
472
787
  return msg;
473
788
  }
474
789
 
790
+ // Classify a route-related diagnostic so the message rewrite and the
791
+ // squiggle-snap use one consistent detection. Returns:
792
+ // 'el' — anchor href mismatch (static __ripEl or dynamic __ripRoute)
793
+ // 'route' — programmatic router.push/replace mismatch
794
+ // null — unrelated diagnostic
795
+ //
796
+ // Detection is position-aware: a single source line can host both an anchor
797
+ // and an inline event handler (e.g. `a @click: () -> @router.push(...)`),
798
+ // so substring-checking the whole TS line is ambiguous. Instead we look at
799
+ // the call site immediately preceding the diagnostic's TS offset.
800
+ function classifyRouteDiagnostic(entry, start) {
801
+ if (!entry?.tsContent || start == null) return null;
802
+ const before = entry.tsContent.slice(Math.max(0, start - 64), start);
803
+ if (/(?:__ripEl|__ripRoute)\([^()]*$/.test(before)) return 'el';
804
+ if (/\.(?:push|replace)\([^()]*$/.test(before)) return 'route';
805
+ return null;
806
+ }
807
+
808
+ // Unify route diagnostics with the static __ripEl form so users see one
809
+ // consistent message shape regardless of which call site (anchor href,
810
+ // router.push, etc.) produced the error. Rewrites TS2345 "Argument of
811
+ // type 'X' is not assignable to parameter of type 'Y'." into TS2322
812
+ // "Type 'X' is not assignable to type 'Y | undefined'." Then prettifies
813
+ // `${string}` placeholders in route patterns to their source-form
814
+ // `$paramName` (from `[id].rip` → `$id`). Used by both the CLI
815
+ // (runCheck) and the LSP diagnostic publisher.
816
+ export function unifyRouteDiagnostic(code, message, entry, start, filePath) {
817
+ const kind = classifyRouteDiagnostic(entry, start);
818
+ const routesDir = filePath ? findRoutesDir(filePath) : null;
819
+ const tree = routesDir ? walkRoutesDir(routesDir) : null;
820
+
821
+ if ((kind === 'route' || kind === 'el') && code === 2345) {
822
+ const m = message.match(/^Argument of type '([^']*(?:''[^']*)*)' is not assignable to parameter of type '([^']*(?:''[^']*)*)'\.$/);
823
+ if (m) {
824
+ code = 2322;
825
+ message = `Type '${m[1]}' is not assignable to type '${m[2]} | undefined'.`;
826
+ }
827
+ }
828
+
829
+ // Prettify ${string} placeholders in known route patterns.
830
+ if (tree && message.includes('${string}')) {
831
+ message = prettifyRoutePatterns(message, tree);
832
+ }
833
+ // Canonicalize route-union member order (TS normalizes unions, so the
834
+ // order shifts between error contexts — pin to walkRoutesDir order).
835
+ if (tree) message = canonicalizeRouteUnion(message, tree);
836
+ return { code, message };
837
+ }
838
+
839
+ // Rewrite `${string}` placeholders in route patterns to their source-form
840
+ // `$paramName` (from `[id].rip` → `$id`). Used by diagnostics and hover.
841
+ export function prettifyRoutePatterns(text, tree) {
842
+ if (!tree || !text || !text.includes('${string}')) return text;
843
+ for (const e of tree.entries) {
844
+ if (!e.display) continue;
845
+ if (e.pattern !== e.display) text = text.split(e.pattern).join(e.display);
846
+ }
847
+ return text;
848
+ }
849
+
850
+ // Reorder route-union members to match walkRoutesDir order. TS normalizes
851
+ // unions internally, so the same set can render in different orders across
852
+ // hover and error contexts. Scans for runs of unioned string/template/
853
+ // undefined members, and if the run exactly covers the known route set
854
+ // (plus optional `undefined`), rewrites it in canonical order. Leaves
855
+ // unrelated unions untouched.
856
+ export function canonicalizeRouteUnion(text, tree) {
857
+ if (!tree || !text || !text.includes(' | ')) return text;
858
+ const canonical = [];
859
+ const seen = new Set();
860
+ for (const e of tree.entries) {
861
+ if (e.dynamic.some(d => d.catchAll)) continue;
862
+ const member = e.display || e.pattern;
863
+ if (seen.has(member)) continue;
864
+ seen.add(member);
865
+ canonical.push(member);
866
+ }
867
+ if (canonical.length === 0) return text;
868
+ const canonicalSet = new Set(canonical);
869
+ const memberRe = /(?:"[^"]*"|`[^`]*`|undefined)/.source;
870
+ const unionRe = new RegExp(`${memberRe}(?:\\s*\\|\\s*${memberRe})+`, 'g');
871
+ return text.replace(unionRe, run => {
872
+ const parts = run.split(/\s*\|\s*/);
873
+ const hasUndefined = parts.includes('undefined');
874
+ const nonUndef = parts.filter(p => p !== 'undefined');
875
+ if (nonUndef.length !== canonical.length) return run;
876
+ if (!nonUndef.every(p => canonicalSet.has(p))) return run;
877
+ const ordered = hasUndefined ? [...canonical, 'undefined'] : canonical;
878
+ return ordered.join(' | ');
879
+ });
880
+ }
881
+
882
+ // Locate the best span for a route diagnostic in the source line. TS
883
+ // reports the span on the generated `__ripEl`/`__ripRoute` call, which
884
+ // source-maps back to imprecise positions. We snap to the meaningful
885
+ // token in the source:
886
+ // - 'el' → the `href` attribute name
887
+ // - 'route' → the method name `push`/`replace`
888
+ export function locateRouteDiagnosticSpan(entry, start, srcLine) {
889
+ const kind = classifyRouteDiagnostic(entry, start);
890
+ if (kind === 'el') {
891
+ const m = srcLine.match(/\bhref\b/);
892
+ if (m) return { col: m.index, len: 4 };
893
+ } else if (kind === 'route') {
894
+ const m = srcLine.match(/\.(push|replace)\b/);
895
+ if (m) return { col: m.index + 1, len: m[1].length };
896
+ }
897
+ return null;
898
+ }
899
+
475
900
  // Base TypeScript compiler settings for type-checking. Callers can
476
- // pass overrides (e.g. { noImplicitAny: true } for the CLI).
901
+ // pass overrides (e.g. { strict: true } when a project opts in).
902
+ //
903
+ // Default `strict: false` aligns with Rip's stated philosophy
904
+ // ("optional, design scaffolding, not safety rails") and matches the
905
+ // gradual-typing default of comparable systems (Sorbet's `# typed: false`,
906
+ // mypy's permissive default, Hack's `partial`, TypeScript's own pre-strict
907
+ // default). Projects opt UP to strict via package.json's `rip.strict: true`,
908
+ // which implies noImplicitAny, strictNullChecks, and the rest of TS's strict
909
+ // family. Do NOT pin those flags to `false` here — that would shadow the
910
+ // strict-family inference when an opt-in caller passes `{ strict: true }`.
477
911
  export function createTypeCheckSettings(ts, overrides = {}) {
478
912
  return {
479
913
  target: ts.ScriptTarget.ESNext,
480
914
  module: ts.ModuleKind.ESNext,
481
915
  moduleResolution: ts.ModuleResolutionKind.Bundler,
482
916
  allowJs: true,
483
- strict: true,
917
+ strict: false,
484
918
  noEmit: true,
485
919
  skipLibCheck: true,
486
920
  ...overrides,
487
921
  };
488
922
  }
489
923
 
924
+ // Collect ambient type packages (e.g. `@types/bun`, `@types/node`) by
925
+ // walking up from rootPath gathering every `node_modules/@types` dir.
926
+ // TS's default typeRoots only checks `<cwd>/node_modules/@types`, so a
927
+ // sub-package check would miss workspace-root ambients. Accepts symlinks
928
+ // because bun's nested-package layout symlinks `@types/bun` to
929
+ // `.bun/@types+bun@.../node_modules/@types/bun`.
930
+ export function collectAmbientTypes(rootPath) {
931
+ const typeRoots = [];
932
+ const types = [];
933
+ let dir = rootPath;
934
+ while (true) {
935
+ const cand = resolve(dir, 'node_modules/@types');
936
+ if (existsSync(cand)) {
937
+ typeRoots.push(cand);
938
+ try {
939
+ for (const entry of readdirSync(cand, { withFileTypes: true })) {
940
+ if ((entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.') && !types.includes(entry.name)) {
941
+ types.push(entry.name);
942
+ }
943
+ }
944
+ } catch {}
945
+ }
946
+ const parent = dirname(dir);
947
+ if (parent === dir) break;
948
+ dir = parent;
949
+ }
950
+ return { typeRoots, types };
951
+ }
952
+
490
953
  // ── Param helpers ──────────────────────────────────────────────────
491
954
 
492
955
  // Extract the text between the first balanced ( ) — handles nested parens
@@ -516,14 +979,175 @@ function replaceFnParams(line, newParams) {
516
979
  return depth === 0 ? line.slice(0, idx + 1) + newParams + line.slice(i - 1) : line;
517
980
  }
518
981
 
982
+ // Depth-aware split of a parameter list on top-level commas. Respects
983
+ // nested parens, brackets, braces, and angle brackets so callback types
984
+ // like `(fn: (x: number) => void)` and generic types like `Map<K, V>`
985
+ // don't get split mid-argument.
986
+ //
987
+ // Angle brackets are tracked separately because `>` is ambiguous: it
988
+ // appears in `=>` (function-type arrow), `>=`/`>>`/`>=`, and as a
989
+ // generic-close. We only treat `<` as opening a generic when it
990
+ // follows an identifier-ending character (or another `<` for nested
991
+ // `Map<K, Set<V>>`); arrow `=>` is recognized and skipped before any
992
+ // angle handling. String/template/regex literals are skipped wholesale
993
+ // so commas inside them don't terminate a part.
994
+ function splitTopLevelParams(paramsStr) {
995
+ const parts = [];
996
+ let depth = 0; // counts (), [], {}
997
+ let angle = 0; // counts <> when used as generics
998
+ let start = 0;
999
+ const isWordEnd = (i) => i > 0 && /[A-Za-z_$0-9>\]]/.test(paramsStr[i - 1]);
1000
+ const skipString = (i, quote) => {
1001
+ let k = i + 1;
1002
+ while (k < paramsStr.length && paramsStr[k] !== quote) {
1003
+ if (paramsStr[k] === '\\') k += 2; else k++;
1004
+ }
1005
+ return k;
1006
+ };
1007
+ for (let i = 0; i < paramsStr.length; i++) {
1008
+ const c = paramsStr[i];
1009
+ // Skip strings to avoid commas / brackets inside string defaults.
1010
+ if (c === '"' || c === "'" || c === '`') {
1011
+ i = skipString(i, c);
1012
+ continue;
1013
+ }
1014
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
1015
+ if (c === ')' || c === ']' || c === '}') { depth--; continue; }
1016
+ // Arrow `=>` — ignore the `>` so it doesn't decrement angle/depth.
1017
+ if (c === '=' && paramsStr[i + 1] === '>') { i++; continue; }
1018
+ if (c === '<' && isWordEnd(i)) { angle++; continue; }
1019
+ if (c === '>' && angle > 0) { angle--; continue; }
1020
+ if (c === ',' && depth === 0 && angle === 0) {
1021
+ parts.push(paramsStr.slice(start, i).trim());
1022
+ start = i + 1;
1023
+ }
1024
+ }
1025
+ const last = paramsStr.slice(start).trim();
1026
+ if (last.length > 0) parts.push(last);
1027
+ return parts;
1028
+ }
1029
+
1030
+ // Find the top-level `= default` separator in a single parameter slot,
1031
+ // skipping `=` inside destructured patterns (`{a = 1, b = 2}`) and inside
1032
+ // type expressions. Returns the index of the `=` or -1 if none.
1033
+ //
1034
+ // A param shape is one of:
1035
+ // simple — `name`, `name: T`, `name?: T`
1036
+ // rest — `...rest`, `...rest: T[]`
1037
+ // destructured — `{a, b}`, `{a, b}: {a: T, b: U}`, `[x, y]: [T, U]`
1038
+ //
1039
+ // The default arrives at the OUTERMOST level only, after the optional
1040
+ // type annotation. Inside the destructured pattern's braces/brackets,
1041
+ // `=` denotes per-property defaults (a JS pattern feature) and is not
1042
+ // the slot's outer default — those belong to the destructured shape and
1043
+ // don't survive the merge. Only the top-level `=` matters.
1044
+ //
1045
+ // Skips strings/templates and uses the same identifier-aware angle
1046
+ // tracking as `splitTopLevelParams` so default expressions containing
1047
+ // `=>`, `<`, `>`, comparisons, or generic types don't confuse depth.
1048
+ function findOuterDefault(paramPart) {
1049
+ let depth = 0;
1050
+ let angle = 0;
1051
+ const isWordEnd = (i) => i > 0 && /[A-Za-z_$0-9>\]]/.test(paramPart[i - 1]);
1052
+ const skipString = (i, quote) => {
1053
+ let k = i + 1;
1054
+ while (k < paramPart.length && paramPart[k] !== quote) {
1055
+ if (paramPart[k] === '\\') k += 2; else k++;
1056
+ }
1057
+ return k;
1058
+ };
1059
+ for (let i = 0; i < paramPart.length; i++) {
1060
+ const c = paramPart[i];
1061
+ if (c === '"' || c === "'" || c === '`') { i = skipString(i, c); continue; }
1062
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
1063
+ if (c === ')' || c === ']' || c === '}') { depth--; continue; }
1064
+ if (c === '<' && isWordEnd(i)) { angle++; continue; }
1065
+ if (c === '>' && angle > 0) { angle--; continue; }
1066
+ if (c === '=' && depth === 0 && angle === 0) {
1067
+ // `==`, `===`, `!=`, `!==`, `>=`, `<=`, `=>` are operators, not
1068
+ // the default `=` separator.
1069
+ const prev = paramPart[i - 1];
1070
+ const next = paramPart[i + 1];
1071
+ if (next === '=' || next === '>') { i++; continue; }
1072
+ if (prev === '=' || prev === '!' || prev === '<' || prev === '>') continue;
1073
+ return i;
1074
+ }
1075
+ }
1076
+ return -1;
1077
+ }
1078
+
1079
+ // Merge a DTS-emitted sig's params into the impl line's params, preserving
1080
+ // the impl's default values. The DTS emits `name?: T` for parameters with
1081
+ // JS defaults (because callers may omit them — that's the overload view),
1082
+ // but inside the body the default ensures `name` is always defined, so the
1083
+ // body should see `name: T = default`, not `name?: T`. This merge keeps the
1084
+ // caller-facing overload `name?: T` and produces a body-facing impl where
1085
+ // each defaulted slot becomes `name: T = default`. Non-defaulted impl
1086
+ // params keep whatever the sig provided (`name?: T` for `?:: T` params,
1087
+ // `name: T` for required typed params, bare `name` for untyped).
1088
+ //
1089
+ // Returns the merged param string, or `sigParams` unchanged if the impl
1090
+ // has no top-level defaults to preserve.
1091
+ function mergeSigWithImplDefaults(sigParams, implParams) {
1092
+ if (!implParams) return sigParams;
1093
+ const sigList = splitTopLevelParams(sigParams);
1094
+ const implList = splitTopLevelParams(implParams);
1095
+ if (sigList.length !== implList.length) return sigParams; // shape mismatch — bail
1096
+ let anyDefault = false;
1097
+ for (const p of implList) { if (findOuterDefault(p) >= 0) { anyDefault = true; break; } }
1098
+ if (!anyDefault) return sigParams;
1099
+ const merged = [];
1100
+ for (let i = 0; i < sigList.length; i++) {
1101
+ const sigPart = sigList[i];
1102
+ const implPart = implList[i];
1103
+ const eqIdx = findOuterDefault(implPart);
1104
+ if (eqIdx < 0) {
1105
+ // No top-level default in impl — use the sig form unchanged.
1106
+ merged.push(sigPart);
1107
+ continue;
1108
+ }
1109
+ const defaultExpr = implPart.slice(eqIdx + 1).trim();
1110
+ // Drop `?` from the sig param's name binding and append `= default`.
1111
+ // Pattern: NAME, NAME?: T, NAME: T, ...REST: T[], {a, b}: {...}, [x, y]: [...]
1112
+ const colonMatch = sigPart.match(/^(\s*(?:\.\.\.|))([A-Za-z_$][\w$]*|\{[\s\S]*?\}|\[[\s\S]*?\])(\?)?(\s*:\s*[\s\S]+)?$/);
1113
+ if (!colonMatch) {
1114
+ // Couldn't parse — keep sig form, append default conservatively.
1115
+ merged.push(`${sigPart} = ${defaultExpr}`);
1116
+ continue;
1117
+ }
1118
+ const prefix = colonMatch[1] || '';
1119
+ const name = colonMatch[2];
1120
+ const tail = colonMatch[4] || '';
1121
+ merged.push(`${prefix}${name}${tail} = ${defaultExpr}`);
1122
+ }
1123
+ return merged.join(', ');
1124
+ }
1125
+
1126
+ // Extract the type parameter list (e.g. "<K extends string>") between the
1127
+ // function name and the first `(`. Returns "" when none is present.
1128
+ function extractTypeParams(sig) {
1129
+ const m = sig.match(/^(?:export\s+)?(?:async\s+)?function\s+\w+\s*(<[^(]*>)\s*\(/);
1130
+ return m ? m[1] : '';
1131
+ }
1132
+
1133
+ // Inject `typeParams` between the function name and its `(` on an
1134
+ // implementation line. No-op when `typeParams` is empty or the line
1135
+ // already has type parameters.
1136
+ function injectTypeParams(line, typeParams) {
1137
+ if (!typeParams) return line;
1138
+ const m = line.match(/^(\s*(?:export\s+)?(?:async\s+)?function\s+\w+)(\s*)([(<])/);
1139
+ if (!m || m[3] === '<') return line;
1140
+ return m[1] + typeParams + line.slice(m[0].length - 1);
1141
+ }
1142
+
519
1143
  // ── Shared compilation pipeline ────────────────────────────────────
520
1144
 
521
1145
  // Compile a .rip file for type-checking. Prepends DTS declarations to
522
1146
  // compiled JS, detects type annotations, and builds bidirectional
523
1147
  // source maps. Returns everything both the CLI and LSP need.
524
- // When opts.strict is true, all non-nocheck files are type-checked.
1148
+ // When opts.checkAll is true, all non-nocheck files are type-checked.
525
1149
  export function compileForCheck(filePath, source, compiler, opts = {}) {
526
- const result = compiler.compile(source, { sourceMap: true, types: 'emit', skipPreamble: true, stubComponents: true });
1150
+ const result = compiler.compile(source, { sourceMap: true, types: 'emit', skipPreamble: true, stubComponents: true, inlineTypes: true });
527
1151
  let code = result.code || '';
528
1152
  const dts = result.dts ? result.dts.trimEnd() + '\n' : '';
529
1153
 
@@ -531,7 +1155,11 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
531
1155
  // A `# @nocheck` comment near the top of the file opts out entirely.
532
1156
  // In strict mode, all non-nocheck files are type-checked.
533
1157
  const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
534
- const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || hasSchemas(source) || !!opts.strict);
1158
+ // Must match the CLI predicate in runCheck. Don't add `hasSchemas(source)`:
1159
+ // that probe is a raw-source regex that fires on `schema :input` inside
1160
+ // heredoc string literals (e.g. test files), flooding the LSP with TS2304
1161
+ // false positives. Schema files still get their DTS via the schema pass.
1162
+ const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.checkAll);
535
1163
  let importsTyped = false;
536
1164
  if (!hasOwnTypes && !nocheck) {
537
1165
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -559,6 +1187,134 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
559
1187
  if (hasTypes && dts && code) {
560
1188
  const dl = dts.split('\n');
561
1189
  const cl = code.split('\n');
1190
+
1191
+ // Hoist locally-scoped typed declarations into the function body's
1192
+ // hoisted `let` line. dts.js emits `let name: T;` at the DTS header
1193
+ // for any typed assignment, including locals declared inside function
1194
+ // bodies (`def f() ... res:: Response | null = null`). Those land at
1195
+ // module scope where TypeScript treats them as separate bindings —
1196
+ // the function-local `let res;` (emitted untyped by the compiler's
1197
+ // function-top hoist) shadows them, and TS infers `res` purely from
1198
+ // the first assignment. Pulling the type back into the local `let`
1199
+ // makes the typed declaration cover the actual binding the body uses.
1200
+ // Typed-local hoist — pull `let X: T;` declarations out of the DTS
1201
+ // header and merge them into the body's matching function-local
1202
+ // `let X` line.
1203
+ //
1204
+ // The hoist is name-based: it doesn't carry source-scope identity
1205
+ // through the DTS → body merge, so we can only act when the name
1206
+ // is unambiguous on BOTH sides:
1207
+ //
1208
+ // 1. DTS-side: exactly one `let X: T;` candidate. Multiple
1209
+ // typed declarations of the same name (one per function
1210
+ // scope, e.g. `def a() … x:: number; def b() … x:: string`)
1211
+ // are ambiguous — we don't know which body site each one
1212
+ // came from.
1213
+ // 2. Body-side: exactly one indented `let X` site. Multiple
1214
+ // same-named locals across functions would all receive the
1215
+ // same type otherwise, which is wrong even when DTS itself
1216
+ // is unambiguous.
1217
+ // 3. No module-scope binding for the name. A top-level typed
1218
+ // declaration (`x:: string = "..."` at module scope) would
1219
+ // otherwise be hoisted into an unrelated function-local
1220
+ // `let x;` — different bindings, same name.
1221
+ //
1222
+ // When any check fails, we leave the local untyped and let
1223
+ // TypeScript infer per-binding from the first assignment.
1224
+ const dtsCandidatesByName = new Map(); // name -> [{dtsIdx, typeSuffix}]
1225
+ for (let i = 0; i < dl.length; i++) {
1226
+ const m = dl[i].match(/^let\s+(\w+)\s*:\s*(.+)\s*;\s*$/);
1227
+ if (!m) continue;
1228
+ const name = m[1];
1229
+ const typeBody = m[2];
1230
+ // Disqualify lines that are really initialized declarations
1231
+ // (`let x: T = init;`). Walk the string honoring brackets,
1232
+ // generics, and `=>` so we don't mistake an arrow's `=>` or a
1233
+ // comparison's `>=` for an assignment `=`.
1234
+ let isAssignment = false;
1235
+ {
1236
+ let d = 0, ang = 0;
1237
+ for (let p = 0; p < typeBody.length; p++) {
1238
+ const c = typeBody[p];
1239
+ if (c === '(' || c === '[' || c === '{') d++;
1240
+ else if (c === ')' || c === ']' || c === '}') d--;
1241
+ else if (c === '<') ang++;
1242
+ else if (c === '>') ang = Math.max(0, ang - 1);
1243
+ else if (c === '=' && d === 0 && ang === 0 &&
1244
+ typeBody[p + 1] !== '>' && typeBody[p - 1] !== '=' &&
1245
+ typeBody[p - 1] !== '!' && typeBody[p - 1] !== '<' &&
1246
+ typeBody[p - 1] !== '>') {
1247
+ isAssignment = true;
1248
+ break;
1249
+ }
1250
+ }
1251
+ }
1252
+ if (isAssignment) continue;
1253
+ const typeSuffix = ': ' + typeBody.trim();
1254
+ if (!dtsCandidatesByName.has(name)) dtsCandidatesByName.set(name, []);
1255
+ dtsCandidatesByName.get(name).push({ dtsIdx: i, typeSuffix });
1256
+ }
1257
+
1258
+ const localTypedLetIdxs = new Set();
1259
+ for (const [name, candidates] of dtsCandidatesByName) {
1260
+ const { dtsIdx, typeSuffix } = candidates[0];
1261
+ // DTS-side collision: same name typed in two function scopes.
1262
+ // We can't tell which body site each DTS line came from, so
1263
+ // strip both — the locals fall back to per-binding inference.
1264
+ if (candidates.length !== 1) {
1265
+ for (const c of candidates) localTypedLetIdxs.add(c.dtsIdx);
1266
+ continue;
1267
+ }
1268
+ const localPat = new RegExp(`^\\s+let\\s+(?:[^;=]*?\\b)?${name}\\b(?!\\s*:)([^;=]*?)?;`);
1269
+ // Module-scope binding probe: a non-indented `let`/`const`/`var`/
1270
+ // export of the same name, or a bare `name = ...` at the start
1271
+ // of a line. If any exist, this DTS declaration belongs to that
1272
+ // module-scope binding, not to a function-local — leave the DTS
1273
+ // declaration alone (it IS the typed declaration for that
1274
+ // top-level binding).
1275
+ const moduleScopePat = new RegExp(
1276
+ `^(?:export\\s+(?:default\\s+)?)?(?:const|let|var)\\s+(?:[^;=]*?\\b)?${name}\\b|^${name}\\s*=`,
1277
+ );
1278
+ let hasModuleScope = false;
1279
+ let localLine = -1;
1280
+ let multipleLocals = false;
1281
+ for (let j = 0; j < cl.length; j++) {
1282
+ if (moduleScopePat.test(cl[j])) { hasModuleScope = true; break; }
1283
+ if (!localPat.test(cl[j])) continue;
1284
+ if (localLine >= 0) { multipleLocals = true; break; }
1285
+ localLine = j;
1286
+ }
1287
+ if (hasModuleScope) continue;
1288
+ // Body-side collision: multiple function-local `let X` sites
1289
+ // for one DTS declaration. We don't know which one was the
1290
+ // intended source. Strip the DTS line so it doesn't bleed into
1291
+ // the wrong scope; the locals fall back to per-binding inference.
1292
+ if (multipleLocals) { localTypedLetIdxs.add(dtsIdx); continue; }
1293
+ // No untyped local site found. Before assuming this is a
1294
+ // module-scope decl, check for an *already-typed* function-local
1295
+ // `let X: T` — the compiler emits the type inline on the body's
1296
+ // hoisted `let` for typed locals, which makes `localPat`'s
1297
+ // `(?!\s*:)` look-ahead skip the line. In that case the DTS
1298
+ // header decl is redundant and would otherwise show up as a
1299
+ // bogus "declared but never read" hint at the top of the file.
1300
+ if (localLine < 0) {
1301
+ const typedLocalPat = new RegExp(`^\\s+let\\s+[^;]*\\b${name}\\b\\s*:`);
1302
+ for (let j = 0; j < cl.length; j++) {
1303
+ if (typedLocalPat.test(cl[j])) { localTypedLetIdxs.add(dtsIdx); break; }
1304
+ }
1305
+ continue;
1306
+ }
1307
+ // Single unambiguous match — perform the hoist.
1308
+ cl[localLine] = cl[localLine].replace(
1309
+ new RegExp(`(\\blet\\s[^;]*?\\b${name}\\b)(?!\\s*:)`),
1310
+ `$1${typeSuffix}`,
1311
+ );
1312
+ localTypedLetIdxs.add(dtsIdx);
1313
+ }
1314
+ if (localTypedLetIdxs.size > 0) {
1315
+ for (const i of localTypedLetIdxs) dl[i] = '';
1316
+ }
1317
+
562
1318
  const fnSigs = [];
563
1319
  for (let i = 0; i < dl.length; i++) {
564
1320
  const m = dl[i].match(/^(?:export\s+)?(?:declare\s+)?function\s+(\w+)/);
@@ -568,9 +1324,22 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
568
1324
  const injections = [];
569
1325
  const moved = new Set();
570
1326
  for (const fn of fnSigs) {
571
- const pat = new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s+${fn.name}\\s*[(<]`);
1327
+ // Match the impl in either of two shapes:
1328
+ // 1. top-level: `function NAME(`, `function NAME<`,
1329
+ // `async function NAME(`, `export function NAME(`
1330
+ // 2. bare-name arrow assignment: `NAME = function(`,
1331
+ // `NAME = async function(`, `NAME = function*(`
1332
+ // Pattern (2) is how Rip emits arrow assignments at module
1333
+ // scope: `name = (...) -> ...` becomes `name = function(...) {…}`.
1334
+ // We deliberately do NOT match `obj.NAME = function(`: the DTS
1335
+ // emits `declare function NAME(...)` only for module-scope,
1336
+ // bare-name arrow assignments. A property-style match would
1337
+ // pick up an unrelated `obj.NAME = ...` line that happens to
1338
+ // share the function name and apply the wrong signature there.
1339
+ const topLevelPat = new RegExp(`^(?:export\\s+)?(?:async\\s+)?function\\s+${fn.name}\\s*[(<]`);
1340
+ const arrowAssignPat = new RegExp(`^\\s*${fn.name}\\s*=\\s*(?:async\\s+)?function\\s*\\*?\\s*\\(`);
572
1341
  for (let j = 0; j < cl.length; j++) {
573
- if (pat.test(cl[j])) {
1342
+ if (topLevelPat.test(cl[j]) || arrowAssignPat.test(cl[j])) {
574
1343
  injections.push({ codeLine: j, sig: fn.sig });
575
1344
  moved.add(fn.idx);
576
1345
  break;
@@ -593,6 +1362,47 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
593
1362
  return depth === 0 && sig.slice(i).includes(':');
594
1363
  }
595
1364
 
1365
+ // Check if any parameter in a DTS signature lacks a type annotation.
1366
+ // Used to suppress overload-sig injection: if a param is untyped, TS
1367
+ // will fire TS7006 on both the injected sig and the impl (same source
1368
+ // position, same code) — let it fire on the impl only.
1369
+ //
1370
+ // Each top-level param part is "typed" iff it contains a top-level `:`
1371
+ // outside of any nested (), [], {}, or <> groups. Destructured-rename
1372
+ // colons inside `{a: aliased}` don't count because they're nested.
1373
+ // Empty param lists are trivially "all typed".
1374
+ function hasUntypedParam(sig) {
1375
+ const params = extractFnParams(sig);
1376
+ if (params === null || params.trim() === '') return false;
1377
+ const parts = splitTopLevelParams(params);
1378
+ for (const part of parts) {
1379
+ if (!part) continue;
1380
+ // Skip TS `this: T` pseudo-param if it appears.
1381
+ if (/^this\s*:/.test(part)) continue;
1382
+ let depth = 0, angle = 0, hasColon = false;
1383
+ for (let i = 0; i < part.length; i++) {
1384
+ const c = part[i];
1385
+ if (c === '"' || c === "'" || c === '`') {
1386
+ // skip string literal
1387
+ const q = c; i++;
1388
+ while (i < part.length && part[i] !== q) {
1389
+ if (part[i] === '\\') i++;
1390
+ i++;
1391
+ }
1392
+ continue;
1393
+ }
1394
+ if (c === '(' || c === '[' || c === '{') { depth++; continue; }
1395
+ if (c === ')' || c === ']' || c === '}') { depth--; continue; }
1396
+ if (c === '=' && part[i + 1] === '>') { i++; continue; }
1397
+ if (c === '<' && i > 0 && /[A-Za-z_$0-9>\]]/.test(part[i - 1])) { angle++; continue; }
1398
+ if (c === '>' && angle > 0) { angle--; continue; }
1399
+ if (c === ':' && depth === 0 && angle === 0) { hasColon = true; break; }
1400
+ }
1401
+ if (!hasColon) return true;
1402
+ }
1403
+ return false;
1404
+ }
1405
+
596
1406
  // Extract the return type from a DTS signature (e.g. ": number" from
597
1407
  // "function add(a: number, b: number): number;").
598
1408
  function extractReturnType(sig) {
@@ -621,7 +1431,17 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
621
1431
  const sig = inj.sig.replace(/^declare /, '');
622
1432
  const sigParams = extractFnParams(sig);
623
1433
  if (sigParams !== null) {
624
- cl[inj.codeLine] = replaceFnParams(cl[inj.codeLine], sigParams);
1434
+ // Merge in the impl's default values BEFORE replacing — keeps
1435
+ // `name?: T` on the overload (caller view) but writes
1436
+ // `name: T = default` into the impl signature (body view) so
1437
+ // TypeScript sees the body's `opts` as `T`, not `T | undefined`.
1438
+ const implParams = extractFnParams(cl[inj.codeLine]);
1439
+ const merged = mergeSigWithImplDefaults(sigParams, implParams);
1440
+ cl[inj.codeLine] = replaceFnParams(cl[inj.codeLine], merged);
1441
+ }
1442
+ const typeParams = extractTypeParams(sig);
1443
+ if (typeParams) {
1444
+ cl[inj.codeLine] = injectTypeParams(cl[inj.codeLine], typeParams);
625
1445
  }
626
1446
  const retType = extractReturnType(sig);
627
1447
  if (retType) {
@@ -632,10 +1452,13 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
632
1452
  }
633
1453
  }
634
1454
 
635
- // Only inject overload signatures for functions with explicit return types.
636
- // Functions without a return type annotation let TS infer the return from
637
- // the implementation body — injecting an overload would force it to `any`.
638
- const overloads = injections.filter(inj => hasExplicitReturn(inj.sig));
1455
+ // Only inject overload signatures for functions with explicit return types
1456
+ // AND fully-typed params. Functions without a return type annotation let
1457
+ // TS infer the return from the implementation body — injecting an overload
1458
+ // would force it to `any`. Functions with any untyped param would fire
1459
+ // TS7006 twice (once on the injected sig, once on the impl) at the same
1460
+ // source position — skip the injection so the user sees a single error.
1461
+ const overloads = injections.filter(inj => hasExplicitReturn(inj.sig) && !hasUntypedParam(inj.sig));
639
1462
 
640
1463
  // Adjust reverseMap: each overload injection shifts subsequent code lines down by 1.
641
1464
  // Compare against the original genLine (not genLine + offset) because bottom-up
@@ -713,7 +1536,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
713
1536
  const existingFields = new Set();
714
1537
  for (let k = j + 1; k < cl.length; k++) {
715
1538
  if (cl[k].match(/^(?:export\s+)?(?:class|const)\s+\w+/) && k > j + 1) break;
716
- const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;$/);
1539
+ const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;(?:\s*\/\/.*)?$/);
717
1540
  if (fm) existingFields.add(fm[1]);
718
1541
  // Also match field assignments (e.g. `name = __computed(...)` in component stubs)
719
1542
  const am = cl[k].match(/^\s+(\w+)\s*=\s+/);
@@ -887,7 +1710,16 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
887
1710
 
888
1711
  for (let i = 0; i < dl.length; i++) {
889
1712
  const m = dl[i].match(/^(?:export\s+)?declare\s+const\s+(\w+):\s+(.+);$/);
890
- if (m) constTypes.set(m[1], { type: m[2], idx: i });
1713
+ if (m) { constTypes.set(m[1], { type: m[2], idx: i }); continue; }
1714
+ // Also merge `(export )?let X: T;` forward-decls from the DTS header
1715
+ // into matching body `(export )?const X = expr` declarations. dts.js
1716
+ // emits the `let` form for typed module-scope value bindings declared
1717
+ // via `name:: T = expr`. Without this merge, TS sees two separate
1718
+ // declarations (header `let` + body `const`) and loses the typed
1719
+ // identity on property access — e.g. `getStore()` returns `unknown`
1720
+ // instead of the declared `AsyncLocalStorage<T>`'s element type.
1721
+ const lm = dl[i].match(/^(?:export\s+)?let\s+(\w+):\s+(.+);$/);
1722
+ if (lm) constTypes.set(lm[1], { type: lm[2], idx: i });
891
1723
  }
892
1724
 
893
1725
  if (constTypes.size > 0) {
@@ -914,9 +1746,10 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
914
1746
  // but files that import from typed modules may have untyped reactive vars whose
915
1747
  // compiled code still references __state/__computed/__effect.
916
1748
  if (hasTypes) {
917
- const needSignal = /\b__state\(/.test(code) && !/\bdeclare function __state\b/.test(headerDts);
918
- const needComputed = /\b__computed\(/.test(code) && !/\bdeclare function __computed\b/.test(headerDts);
919
- const needEffect = /\b__effect\(/.test(code) && !/\bdeclare function __effect\b/.test(headerDts);
1749
+ const bound = ripDestructuredNames(source);
1750
+ const needSignal = /\b__state\(/.test(code) && !/\bdeclare function __state\b/.test(headerDts) && !bound.has('__state');
1751
+ const needComputed = /\b__computed\(/.test(code) && !/\bdeclare function __computed\b/.test(headerDts) && !bound.has('__computed');
1752
+ const needEffect = /\b__effect\(/.test(code) && !/\bdeclare function __effect\b/.test(headerDts) && !bound.has('__effect');
920
1753
  if (needSignal || needComputed || needEffect) {
921
1754
  const decls = [];
922
1755
  if (needSignal) {
@@ -941,24 +1774,14 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
941
1774
  // automatically. Precise type overrides are provided where the generic
942
1775
  // fallback (...args: any[]) => any would lose useful type information.
943
1776
  if (hasTypes) {
944
- const preciseTypes = {
945
- abort: 'declare function abort(msg?: string): never;',
946
- assert: 'declare function assert(v: any, msg?: string): asserts v;',
947
- exit: 'declare function exit(code?: number): never;',
948
- kind: 'declare function kind(v: any): string;',
949
- noop: 'declare function noop(): void;',
950
- p: 'declare function p(...args: any[]): void;',
951
- pp: 'declare function pp(v: any): any;',
952
- raise: 'declare function raise(a: any, b?: any): never;',
953
- rand: 'declare function rand(a?: number, b?: number): number;',
954
- sleep: 'declare function sleep(ms: number): Promise<void>;',
955
- todo: 'declare function todo(msg?: string): never;',
956
- warn: 'declare function warn(...args: any[]): void;',
957
- zip: 'declare function zip(...arrays: any[][]): any[][];',
958
- };
1777
+ // Helper names auto-derived from getStdlibCode() so adding a stdlib
1778
+ // helper in stdlib.js automatically picks up a type declaration here.
1779
+ // The precise signatures live alongside the runtime bodies in
1780
+ // stdlib.js as STDLIB_TYPE_DECLS; helpers without an entry fall back
1781
+ // to a generic `(...args: any[]) => any` declaration.
959
1782
  const names = [...getStdlibCode().matchAll(/globalThis\.(\w+)\s*\?\?=/g)].map(m => m[1]);
960
1783
  const stdlibDecls = names.map(name =>
961
- preciseTypes[name] || `declare function ${name}(...args: any[]): any;`
1784
+ STDLIB_TYPE_DECLS[name] || `declare function ${name}(...args: any[]): any;`
962
1785
  );
963
1786
  headerDts = stdlibDecls.join('\n') + '\n' + headerDts;
964
1787
 
@@ -1006,16 +1829,37 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1006
1829
  const cl = code.split('\n');
1007
1830
  const reEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1008
1831
 
1009
- // Build DTS header type map for merging
1832
+ // Build DTS header type map for merging. The map is name-based,
1833
+ // and the inline-let pass below uses it to inject types into
1834
+ // function-top hoist `let X;` declarations.
1835
+ //
1836
+ // DTS-side collision: when the same name appears in multiple
1837
+ // `let X: T;` lines with different types (one per independent
1838
+ // function scope), `letTypes.set` would otherwise let the last
1839
+ // one win and silently apply the wrong type. Detect the conflict
1840
+ // and remove the name from the map so the inline-let pass leaves
1841
+ // those locals untyped (TS infers per-binding from the first
1842
+ // assignment, which is the correct fallback). The typed-local
1843
+ // hoist above also handles body-side ambiguity for the cases
1844
+ // where the inline-let pass doesn't fire.
1010
1845
  const letTypes = new Map();
1846
+ const ambiguousLetNames = new Set();
1011
1847
  const movedDts = new Set();
1012
1848
  let dl;
1013
1849
  if (headerDts) {
1014
1850
  dl = headerDts.split('\n');
1015
1851
  for (let i = 0; i < dl.length; i++) {
1016
1852
  const m = dl[i].match(/^(?:export\s+)?(?:declare\s+)?let\s+(\w+):\s+(.+);$/);
1017
- if (m) letTypes.set(m[1], { type: m[2], idx: i });
1853
+ if (!m) continue;
1854
+ const [, name, type] = m;
1855
+ const prev = letTypes.get(name);
1856
+ if (prev) {
1857
+ if (prev.type !== type) ambiguousLetNames.add(name);
1858
+ continue;
1859
+ }
1860
+ letTypes.set(name, { type, idx: i });
1018
1861
  }
1862
+ for (const name of ambiguousLetNames) letTypes.delete(name);
1019
1863
  }
1020
1864
 
1021
1865
  // Helper: inline a variable at the given line
@@ -1032,22 +1876,37 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1032
1876
  for (let i = 0; i < cl.length; i++) {
1033
1877
  const m = cl[i].match(/^(\s*)let\s+([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*;\s*$/);
1034
1878
  if (!m) continue;
1035
- // Only process hoist-position lets (first non-blank line after `{` or start of file)
1879
+ // Only process hoist-position lets (first non-blank line after `{`, start
1880
+ // of file, or the module's import block — the compiler emits the
1881
+ // module-scope hoist right after the imports).
1036
1882
  let prev = null;
1037
1883
  for (let k = i - 1; k >= 0; k--) { if (cl[k].trim() !== '') { prev = cl[k]; break; } }
1038
- if (prev !== null && !/\{\s*$/.test(prev)) continue;
1884
+ if (prev !== null && !/\{\s*$/.test(prev) && !/^\s*import\b/.test(prev)) continue;
1039
1885
 
1040
1886
  const baseIndent = m[1];
1041
1887
  const vars = m[2].split(/\s*,\s*/);
1042
1888
  const inlined = new Set();
1043
1889
  const bailed = new Set();
1044
- const scopeEndRe = new RegExp('^' + reEsc(baseIndent) + '}');
1890
+ // Scope-end detection by indent: the enclosing block ends at the first
1891
+ // non-empty line whose indent is strictly less than baseIndent. This is
1892
+ // correct for compiler-generated JS where indentation reliably reflects
1893
+ // nesting. Regex-matching `}` at baseIndent was wrong because inner
1894
+ // block-closing braces (e.g. ` }` ending a nested `if`) sit at exactly
1895
+ // baseIndent and would terminate the scan prematurely. For top-level
1896
+ // hoists (baseIndent === ''), the scope is the whole file — never end.
1897
+ const baseIndentLen = baseIndent.length;
1898
+ const isScopeEnd = (line) => {
1899
+ if (baseIndentLen === 0) return false;
1900
+ if (line.trim() === '') return false;
1901
+ const li = line.match(/^(\s*)/)[1].length;
1902
+ return li < baseIndentLen;
1903
+ };
1045
1904
 
1046
1905
  // Phase 1: straight-line scan at base indent
1047
1906
  for (let j = i + 1; j < cl.length; j++) {
1048
1907
  const line = cl[j];
1049
1908
  if (line.trim() === '') continue;
1050
- if (scopeEndRe.test(line)) break;
1909
+ if (isScopeEnd(line)) break;
1051
1910
  // Skip deeper-indented lines
1052
1911
  if (line.startsWith(baseIndent + ' ')) continue;
1053
1912
  // Stop at structural statements (if/for/while/switch/try/do/function/class)
@@ -1079,7 +1938,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1079
1938
  for (let j = i + 1; j < cl.length; j++) {
1080
1939
  const line = cl[j];
1081
1940
  if (line.trim() === '') continue;
1082
- if (scopeEndRe.test(line)) break;
1941
+ if (isScopeEnd(line)) break;
1083
1942
  if (!vRe.test(line)) continue;
1084
1943
  if (firstRefLine < 0) firstRefLine = j;
1085
1944
  if (!foundAssign) {
@@ -1100,7 +1959,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1100
1959
  for (let j = foundAssign.line + 1; j < cl.length; j++) {
1101
1960
  const line = cl[j];
1102
1961
  if (line.trim() === '') continue;
1103
- if (scopeEndRe.test(line)) { blockEndLine = j; break; }
1962
+ if (isScopeEnd(line)) { blockEndLine = j; break; }
1104
1963
  const li = line.match(/^(\s*)/)[1];
1105
1964
  if (li.length < foundAssign.indent.length) { blockEndLine = j; break; }
1106
1965
  }
@@ -1111,7 +1970,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1111
1970
  for (let j = blockEndLine + 1; j < cl.length; j++) {
1112
1971
  const line = cl[j];
1113
1972
  if (line.trim() === '') continue;
1114
- if (scopeEndRe.test(line)) break;
1973
+ if (isScopeEnd(line)) break;
1115
1974
  if (vRe.test(line)) { hasRefAfterBlock = true; break; }
1116
1975
  }
1117
1976
  }
@@ -1123,8 +1982,23 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1123
1982
  }
1124
1983
 
1125
1984
  const remaining = vars.filter(v => !inlined.has(v));
1126
- if (remaining.length) cl[i] = `${baseIndent}let ${remaining.join(', ')};`;
1127
- else cl[i] = '';
1985
+ // Module-scope only: drop any var that still has a DTS-header decl
1986
+ // (`let name: T;`) from this untyped hoist. These are chiefly module-level
1987
+ // function bindings, whose assignment spans multiple lines
1988
+ // (`proxy = function(c) {` … `};`) and so never folds into a single
1989
+ // `let name: T = value;` line. The header decl is the single,
1990
+ // correctly-typed declaration and the body's later `name = …` assigns it;
1991
+ // leaving name in this hoist too would duplicate the module-scope binding,
1992
+ // and TS resolves to the untyped copy — dropping contextual typing for the
1993
+ // function's params and `this` (the redeclare is auto-suppressed, so the
1994
+ // symptom surfaces elsewhere). We deliberately keep the type on the header
1995
+ // decl rather than re-emitting it onto this synthetic hoist line: the
1996
+ // compiler source-maps the header's type name back to the real `name:: T`
1997
+ // annotation, so diagnostics/quick-fixes land on it — re-emitting here
1998
+ // would map them to the hoist's position instead. Function-body hoists are
1999
+ // left alone: their locals get types from the compiler's inline hoist.
2000
+ const kept = baseIndent === '' ? remaining.filter(v => !letTypes.has(v)) : remaining;
2001
+ cl[i] = kept.length ? `${baseIndent}let ${kept.join(', ')};` : '';
1128
2002
  }
1129
2003
  code = cl.join('\n');
1130
2004
 
@@ -1134,6 +2008,190 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1134
2008
  }
1135
2009
  }
1136
2010
 
2011
+ // Typed stash: the project's `<root>/app/stash.rip` declares
2012
+ // `stash:: <Type> = ...`. We expose its inferred type as `__RipStash`
2013
+ // on the stash file's virtual module, then rewrite the per-component
2014
+ // `declare app: any` stub to point at it. Components get `app.data`
2015
+ // typed without writing anything ceremonial — just put the stash in
2016
+ // `app/stash.rip`.
2017
+ //
2018
+ // Two splices, both same-line so source maps are unaffected:
2019
+ // 1. Stash file — append `export type __RipStash = typeof stash;`
2020
+ // 2. Component files — replace `declare app: any` with the typed shape
2021
+ const stashFile = findStashFile(filePath);
2022
+ const isStash = stashFile && stashFile === filePath;
2023
+
2024
+ if (isStash) {
2025
+ // The DTS header hoists `export let stash: <Type>;` so the type is
2026
+ // visible everywhere. The body emits either `let stash; ... stash = {...}`
2027
+ // (no export) or `export const stash = {...}` (with export). Both
2028
+ // conflict with the typed hoist — TS sees a redeclaration and the
2029
+ // un-annotated body wins, collapsing the inferred type to `{ items:
2030
+ // never[], ... }`. Rewrite both forms into a bare assignment to the
2031
+ // already-declared `stash`, preserving the contextual type.
2032
+ const letRe = /^(\s*let\s+)([^;=]+);/m;
2033
+ code = code.replace(letRe, (full, prefix, names) => {
2034
+ const remaining = names.split(',').map(s => s.trim()).filter(n => n !== 'stash');
2035
+ return remaining.length ? `${prefix}${remaining.join(', ')};` : '';
2036
+ });
2037
+ code = code.replace(/^(\s*)export\s+const\s+stash\s*=/m, '$1stash =');
2038
+ code += `\nexport type __RipStash = typeof stash;\n`;
2039
+ }
2040
+
2041
+ if (code.includes('declare app: any')) {
2042
+ let typedApp = null;
2043
+ if (stashFile && !isStash) {
2044
+ const spec = entryImportSpec(filePath, stashFile);
2045
+ typedApp = `declare app: { data: import('${spec}').__RipStash; components: any; routes: any; params: any; query: any; router: any }`;
2046
+ }
2047
+ if (typedApp) code = code.replace(/declare app: any/g, typedApp);
2048
+ }
2049
+
2050
+ // `declare router: any` in the component stub is rewritten to the Router
2051
+ // type exported by @rip-lang/app. Always available — the package ships its
2052
+ // own DTS. Gated on a typed project (same `findEntryFile` check the stash
2053
+ // splice uses) to avoid touching untyped sources.
2054
+ if (code.includes('declare router: any') && findEntryFile(filePath)) {
2055
+ code = code.replace(
2056
+ /declare router: any/g,
2057
+ `declare router: import('@rip-lang/app').Router`,
2058
+ );
2059
+ }
2060
+
2061
+ // ── Typed routes ─────────────────────────────────────────────────
2062
+ //
2063
+ // Three splices, all keyed off the project's `<routesDir>/` tree:
2064
+ // 1. Entry file — append `export type __RipRoutes = ...;` so every
2065
+ // file in the project can reach it via
2066
+ // `import('<entry>').__RipRoutes`.
2067
+ // 2. Per-route file (anything under <routesDir>/) — tighten the
2068
+ // `params: any` slot in the typed `declare app:` line so
2069
+ // `routes/users/[id].rip` sees `params: { id: string }`.
2070
+ // 3. Any typed file that uses `<a>` elements — override the
2071
+ // INTRINSIC `__ripEl` declaration so anchor `href` is typed via
2072
+ // a `const H extends string` conditional: if H is a literal
2073
+ // starting with `/`, it must satisfy `__RipRoutes`; otherwise
2074
+ // (external URLs `https:`/`mailto:`, fragments `#x`, dynamic
2075
+ // `string`) it falls through to plain H. Also narrow
2076
+ // `router.push` to `__RipRoutes` for typo-catching.
2077
+ const entryFile = findEntryFile(filePath);
2078
+ const routesDir = findRoutesDir(filePath);
2079
+ const isEntry = entryFile && entryFile === filePath;
2080
+ const isRoute = routesDir && filePath.startsWith(routesDir + pathSep);
2081
+
2082
+ if (isEntry && routesDir) {
2083
+ const { union } = walkRoutesDir(routesDir);
2084
+ code += `\nexport type __RipRoutes = ${union};\n`;
2085
+ }
2086
+
2087
+ // Per-route @params tightening: the component stub declares
2088
+ // `declare params: Record<string, string>`. For route files whose
2089
+ // filename carries dynamic segments (`[id].rip`, `[...rest].rip`),
2090
+ // replace that with a precise shape so typos like `@params.bogu`
2091
+ // are caught and `@params.id` narrows to `string` (the literal).
2092
+ if (isRoute && entryFile) {
2093
+ const { entries } = walkRoutesDir(routesDir);
2094
+ const me = entries.find(e => e.file === filePath);
2095
+ if (me && me.dynamic.length > 0) {
2096
+ const paramFields = me.dynamic
2097
+ .map(d => `${d.name}: string`)
2098
+ .join('; ');
2099
+ code = code.replace(
2100
+ /declare params: Record<string, string>/g,
2101
+ `declare params: { ${paramFields} }`,
2102
+ );
2103
+ }
2104
+ }
2105
+
2106
+ // Anchor href + typed router.push/replace overrides. Spliced into the
2107
+ // DTS header (where `__RipProps` is defined) and the `declare router`
2108
+ // line (already rewritten above). Gated on the project having a
2109
+ // routes dir at all — without routes there's no `__RipRoutes` to
2110
+ // intersect with, so the default `string` href is what users get.
2111
+ if (routesDir && entryFile && findStashFile(filePath)) {
2112
+ // Reach __RipRoutes via the entry file's virtual module.
2113
+ const entrySpec = entryImportSpec(filePath, entryFile);
2114
+ const anchorRouteType = `import('${entrySpec}').__RipRoutes`;
2115
+ // Inline the routes union for diagnostics on __ripRoute (dynamic
2116
+ // interpolated hrefs). The static __ripEl path resolves the alias
2117
+ // already; for the helper-call path we inline so error messages
2118
+ // read "Argument of type '`/x/${number}`' is not assignable to
2119
+ // parameter of type '<actual route union>'" instead of '__RipRoutes'.
2120
+ const { union: inlineRoutesUnion } = walkRoutesDir(routesDir);
2121
+
2122
+ // Declare a clean local alias for NavOpts so hover shows `NavOpts`
2123
+ // instead of `import("@rip-lang/app").NavOpts`. We *don't* alias
2124
+ // __RipRoutes — inlining the union directly into the push signature
2125
+ // makes hover and errors both show the actual list of routes,
2126
+ // avoiding the leak of an implementation-detail name.
2127
+ if (headerDts) {
2128
+ headerDts = `type NavOpts = import('@rip-lang/app').NavOpts;\n` + headerDts;
2129
+ }
2130
+
2131
+ // (a) Constrain <a href>: replace the INTRINSIC __ripEl declaration
2132
+ // with a const-H-generic version whose href slot conditionally
2133
+ // narrows to __RipRoutes for slash-prefixed literals. External
2134
+ // URLs (https:, mailto:, #frag) and dynamic `string` values fall
2135
+ // through to H. Error reads:
2136
+ // Type '"/foo"' is not assignable to type '__RipRoutes | undefined'.
2137
+ if (headerDts) {
2138
+ 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;`;
2139
+ headerDts = headerDts.replace(
2140
+ /declare function __ripEl<K extends __RipTag>\(tag: K, props\?: __RipProps<K>\): void;/,
2141
+ newFnDecl,
2142
+ );
2143
+ // Strengthen __ripRoute: compiler wraps interpolated /-prefixed
2144
+ // anchor href values in __ripRoute(...) so TS checks the dynamic
2145
+ // template against __RipRoutes. Without this strengthening the
2146
+ // baseline passthrough lets every string through.
2147
+ headerDts = headerDts.replace(
2148
+ /declare function __ripRoute<const T extends string>\(s: T\): T;/,
2149
+ `declare function __ripRoute<const T extends ${inlineRoutesUnion}>(s: T): T;`,
2150
+ );
2151
+ }
2152
+
2153
+ // (b) Narrow router.push argument to __RipRoutes for typo-catching
2154
+ // on programmatic navigation. Leave `router.replace` accepting plain
2155
+ // `string` (the base Router signature) — it's commonly used to
2156
+ // mutate the current URL with query strings, where the result is
2157
+ // built dynamically and can't satisfy a literal-route union. Use
2158
+ // Omit + re-add instead of intersection because intersecting
2159
+ // overloaded methods makes the parameter type a union
2160
+ // (contravariance), which loses the narrowing.
2161
+ if (code.includes(`declare router: import('@rip-lang/app').Router`)) {
2162
+ const typedRouter = `declare router: Omit<import('@rip-lang/app').Router, 'push'> & { push(url: ${inlineRoutesUnion}, opts?: NavOpts): void; }`;
2163
+ code = code.replace(
2164
+ /declare router: import\('@rip-lang\/app'\)\.Router(?![ &])/g,
2165
+ typedRouter,
2166
+ );
2167
+ }
2168
+ }
2169
+
2170
+ // Dedupe imports: when the DTS header and the body import from the same
2171
+ // module specifier, TypeScript reports TS2300 (Duplicate identifier) for
2172
+ // every shared binding, which cascades and corrupts type resolution
2173
+ // elsewhere (e.g. `typeof <stateIdent>` collapses to `any`).
2174
+ //
2175
+ // We prefer to drop the *DTS-header* duplicate and keep the body's import:
2176
+ // the body import carries per-specifier source-map mappings (needed for
2177
+ // hover and go-to-definition on each imported name), while the DTS header
2178
+ // import has none. Dropping the body import would leave the source-map
2179
+ // entries pointing at a blanked line, breaking hover for type-only
2180
+ // imports like `import { RetryConfig } from '@rip-lang/http'`.
2181
+ //
2182
+ // Preserve line count on both sides so source maps stay aligned.
2183
+ if (hasTypes && headerDts && code) {
2184
+ const bodySpecs = new Set();
2185
+ for (const m of code.matchAll(/^\s*import\s+[^;]*?from\s+['"]([^'"]+)['"]\s*;?\s*$/gm)) {
2186
+ bodySpecs.add(m[1]);
2187
+ }
2188
+ if (bodySpecs.size > 0) {
2189
+ headerDts = headerDts.replace(/^(\s*)import\s+[^;]*?from\s+(['"])([^'"]+)\2\s*;?\s*$/gm, (full, _ws, _q, spec) => {
2190
+ return bodySpecs.has(spec) ? '' : full;
2191
+ });
2192
+ }
2193
+ }
2194
+
1137
2195
  let tsContent = (hasTypes ? headerDts + '\n' : '') + code;
1138
2196
  const headerLines = hasTypes ? countLines(headerDts + '\n') : 1;
1139
2197
 
@@ -1215,10 +2273,16 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1215
2273
  const genGap = genB - genA;
1216
2274
  if (srcGap > 1 && genGap > 1 && srcGap <= genGap + 2) {
1217
2275
  for (let d = 1; d < srcGap; d++) {
1218
- if (!srcToGen.has(srcA + d) && genA + d < genB) {
1219
- srcToGen.set(srcA + d, genA + d);
1220
- if (!genToSrc.has(genA + d)) {
1221
- genToSrc.set(genA + d, srcA + d);
2276
+ const gen = genA + d;
2277
+ if (!srcToGen.has(srcA + d) && gen < genB) {
2278
+ // Don't interpolate INTO the DTS header. Header lines must only
2279
+ // carry explicit DTS-back-mappings (or none); fabricating a mapping
2280
+ // here causes go-to-def to land on whatever source line happens to
2281
+ // fall in the gap (typically a doc comment).
2282
+ if (gen < headerLines) continue;
2283
+ srcToGen.set(srcA + d, gen);
2284
+ if (!genToSrc.has(gen)) {
2285
+ genToSrc.set(gen, srcA + d);
1222
2286
  }
1223
2287
  }
1224
2288
  }
@@ -1373,13 +2437,27 @@ export function findNearestWord(text, word, approx) {
1373
2437
 
1374
2438
  // Check whether an offset falls on an injected function overload signature line
1375
2439
  // (generated by compileForCheck, not from user code). These are body lines that
1376
- // match `function NAME(...): TYPE;` and have no genToSrc entry.
2440
+ // match `[export ][async ]function NAME(...): TYPE;` and are immediately followed
2441
+ // by the matching implementation `[export ][async ]function NAME(...) ... {`.
2442
+ //
2443
+ // Note: we can't rely on `!genToSrc.has(line)` as a discriminator — the gap-fill
2444
+ // interpolation in buildLineMap will fabricate a mapping for the injected line.
1377
2445
  export function isInjectedOverload(entry, offset) {
1378
2446
  const tsLine = offsetToLine(entry.tsContent, offset);
1379
2447
  if (tsLine < entry.headerLines) return false;
1380
- if (entry.genToSrc.get(tsLine) !== undefined) return false;
1381
2448
  const lineText = getLineText(entry.tsContent, tsLine);
1382
- return /^(?:async\s+)?function\s+\w+\s*\(/.test(lineText) && lineText.trimEnd().endsWith(';');
2449
+ const m = lineText.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/);
2450
+ if (!m) return false;
2451
+ if (!lineText.trimEnd().endsWith(';')) return false;
2452
+ // Confirm the next non-empty line is the implementation of the same function.
2453
+ const allLines = entry.tsContent.split('\n');
2454
+ for (let i = tsLine + 1; i < allLines.length; i++) {
2455
+ const next = allLines[i];
2456
+ if (next.trim() === '') continue;
2457
+ const nm = next.match(/^(?:export\s+)?(?:async\s+)?function\s+(\w+)\s*\(/);
2458
+ return !!nm && nm[1] === m[1] && !next.trimEnd().endsWith(';');
2459
+ }
2460
+ return false;
1383
2461
  }
1384
2462
 
1385
2463
  export function offsetToLine(text, offset) {
@@ -1399,6 +2477,54 @@ export function lineColToOffset(text, line, col) {
1399
2477
  return text.length;
1400
2478
  }
1401
2479
 
2480
+ // Detect whether `col` on a single generated line falls inside a string
2481
+ // literal or a `//` line comment. Used to penalize identifier matches
2482
+ // that land inside non-code regions during source-map lookup.
2483
+ function isInsideStringOrComment(text, col) {
2484
+ let inStr = false, q = '';
2485
+ for (let i = 0; i < col && i < text.length; i++) {
2486
+ const ch = text[i];
2487
+ if (inStr) {
2488
+ if (ch === '\\') { i++; continue; }
2489
+ if (ch === '`' && q === '`') { inStr = false; continue; }
2490
+ if (ch === q) inStr = false;
2491
+ } else if (ch === '/' && text[i + 1] === '/') {
2492
+ return true; // rest of line is a comment
2493
+ } else if (ch === '"' || ch === "'" || ch === '`') {
2494
+ inStr = true; q = ch;
2495
+ }
2496
+ }
2497
+ return inStr;
2498
+ }
2499
+
2500
+ // Detect whether `col` on a Rip source line falls inside a `#` comment.
2501
+ // Walks the line tracking string state (', ", """, ''') so a `#` inside a
2502
+ // string literal isn't mistaken for the start of a comment. Used by the
2503
+ // diagnostic remapper to reject false-positive identifier matches inside
2504
+ // comments.
2505
+ function isInsideRipComment(text, col) {
2506
+ let inStr = false, q = '', triple = false;
2507
+ for (let i = 0; i < col && i < text.length; i++) {
2508
+ const ch = text[i];
2509
+ if (inStr) {
2510
+ if (ch === '\\') { i++; continue; }
2511
+ if (triple && ch === q && text[i + 1] === q && text[i + 2] === q) {
2512
+ inStr = false; triple = false; i += 2; continue;
2513
+ }
2514
+ if (!triple && ch === q) inStr = false;
2515
+ } else if (ch === '#') {
2516
+ return true;
2517
+ } else if (ch === '"' || ch === "'") {
2518
+ if (text[i + 1] === ch && text[i + 2] === ch) {
2519
+ inStr = true; q = ch; triple = true; i += 2;
2520
+ } else {
2521
+ inStr = true; q = ch;
2522
+ }
2523
+ }
2524
+ }
2525
+ return false;
2526
+ }
2527
+
1402
2528
  export function offsetToLineCol(text, offset) {
1403
2529
  let line = 0, ls = 0;
1404
2530
  for (let i = 0; i < offset && i < text.length; i++) {
@@ -1597,6 +2723,39 @@ export function mapToSourcePos(entry, offset) {
1597
2723
  }
1598
2724
  }
1599
2725
  const srcText = entry.source ? getLineText(entry.source, srcLine) : '';
2726
+ // Synthetic anchor: `__ripRoute(...)` wraps an anchor href value for
2727
+ // dynamic route type-checking. The TS diagnostic span starts at the
2728
+ // call argument (a template literal), which has no clean source token
2729
+ // to land on — landing instead on the source `href:` keyword keeps
2730
+ // dynamic anchor diagnostics visually consistent with the static
2731
+ // `__ripEl` `href` case (TS2820 lands on the property identifier).
2732
+ // Map both the start and end offsets that fall anywhere inside a
2733
+ // `__ripRoute(...)` call to the bounds of the `href` keyword so the
2734
+ // squiggle length matches the static case (4 chars) instead of
2735
+ // spanning the whole compiled call expression.
2736
+ if (srcText) {
2737
+ const callStart = genText.lastIndexOf('__ripRoute(', genCol);
2738
+ if (callStart >= 0) {
2739
+ // Find matching `)` after the call
2740
+ let depth = 0, callEnd = -1;
2741
+ for (let i = callStart + '__ripRoute('.length - 1; i < genText.length; i++) {
2742
+ const ch = genText[i];
2743
+ if (ch === '(') depth++;
2744
+ else if (ch === ')') { depth--; if (depth === 0) { callEnd = i; break; } }
2745
+ }
2746
+ if (callEnd >= 0 && genCol <= callEnd + 1) {
2747
+ const m = srcText.match(/\bhref\b/);
2748
+ if (m) {
2749
+ // Heuristic for end offset: anchor at end of `href` (start + 4)
2750
+ // when the gen offset is inside the call body (past the opening
2751
+ // paren). For the start offset (at the opening paren or first
2752
+ // arg char), anchor at the start of `href`.
2753
+ const atOrBeforeArg = genCol <= callStart + '__ripRoute('.length;
2754
+ return { line: srcLine, col: atOrBeforeArg ? m.index : m.index + 4 };
2755
+ }
2756
+ }
2757
+ }
2758
+ }
1600
2759
  // Text-match: find the word at genCol in the gen line, then locate it in the source line
1601
2760
  if (srcText) {
1602
2761
  let wordAt = genText.slice(genCol).match(/^\w+/);
@@ -1606,6 +2765,18 @@ export function mapToSourcePos(entry, offset) {
1606
2765
  }
1607
2766
  if (wordAt) {
1608
2767
  let word = wordAt[0];
2768
+ // Compiler-injected `this.foo` / `ctx.foo` (component bodies, server
2769
+ // handlers): the diagnostic offset typically lands on `this`/`ctx`,
2770
+ // which doesn't exist in the Rip source. Peek past it and use the
2771
+ // member name instead so the squiggle anchors on the user-visible
2772
+ // identifier rather than falling through to a fuzzy fallback.
2773
+ if (word === 'this' || word === 'ctx') {
2774
+ const memberMatch = genText.slice(genCol).match(/^(?:this|ctx)\.([A-Za-z_$][\w$]*)\b/);
2775
+ if (memberMatch) {
2776
+ const idx = findNearestWord(srcText, memberMatch[1], approx);
2777
+ if (idx >= 0) return { line: srcLine, col: idx };
2778
+ }
2779
+ }
1609
2780
  let idx = findNearestWord(srcText, word, approx);
1610
2781
  // __bind_xxx__ → xxx: two-way binding props use mangled names in gen
1611
2782
  if (idx < 0 && word.startsWith('__bind_') && word.endsWith('__')) {
@@ -1652,13 +2823,28 @@ export function mapToSourcePos(entry, offset) {
1652
2823
  if (wordFallback) {
1653
2824
  let word = wordFallback[0];
1654
2825
  if (word.startsWith('__bind_') && word.endsWith('__')) word = word.slice(7, -2);
1655
- const srcLines = entry.source.split('\n');
1656
- const re = new RegExp('\\b' + word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
1657
- for (let delta = 0; delta <= 10; delta++) {
1658
- for (const d of delta === 0 ? [srcLine] : [srcLine + delta, srcLine - delta]) {
1659
- if (d >= 0 && d < srcLines.length) {
1660
- const m = re.exec(srcLines[d]);
1661
- if (m) return { line: d, col: m.index };
2826
+ // Skip identifiers that exist only in generated TypeScript, never in
2827
+ // the Rip source: `this` (component bodies), `ctx` (server handlers),
2828
+ // and any `__`-prefixed runtime helper (`__state`, `__effect`,
2829
+ // `__ripEl`, ...). Searching for them across source lines reliably
2830
+ // finds false positives most commonly the word `this` inside a `#`
2831
+ // comment. `value` is intentionally NOT skipped (it's a valid user
2832
+ // identifier; the `.value` signal-accessor case is handled by the
2833
+ // member-extraction fix in the primary word-match path above).
2834
+ // `__bind_xxx__` was already stripped to its user name above.
2835
+ if (word !== 'this' && word !== 'ctx' && !word.startsWith('__')) {
2836
+ const srcLines = entry.source.split('\n');
2837
+ const re = new RegExp('\\b' + word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\b');
2838
+ for (let delta = 0; delta <= 10; delta++) {
2839
+ for (const d of delta === 0 ? [srcLine] : [srcLine + delta, srcLine - delta]) {
2840
+ if (d >= 0 && d < srcLines.length) {
2841
+ const m = re.exec(srcLines[d]);
2842
+ // Reject matches that fall inside a Rip `#` comment — they're
2843
+ // never the source of a type error.
2844
+ if (m && !isInsideRipComment(srcLines[d], m.index)) {
2845
+ return { line: d, col: m.index };
2846
+ }
2847
+ }
1662
2848
  }
1663
2849
  }
1664
2850
  }
@@ -1678,6 +2864,90 @@ export function mapToSourcePos(entry, offset) {
1678
2864
  return { line: srcLine, col: srcCol };
1679
2865
  }
1680
2866
 
2867
+ // Count top-level commas in `s` (depth 0 w.r.t. ()/[]/{}). Used to map a
2868
+ // cursor onto the Nth argument / Nth property when retargeting completion
2869
+ // offsets into object-literal call arguments.
2870
+ function countTopLevelCommas(s) {
2871
+ let depth = 0, n = 0;
2872
+ for (let i = 0; i < s.length; i++) {
2873
+ const ch = s[i];
2874
+ if (ch === '(' || ch === '[' || ch === '{') depth++;
2875
+ else if (ch === ')' || ch === ']' || ch === '}') depth--;
2876
+ else if (ch === ',' && depth === 0) n++;
2877
+ }
2878
+ return n;
2879
+ }
2880
+
2881
+ // Completion-offset fixup for inline object-literal call arguments, e.g.
2882
+ // `@router.push('/', { ▮ })`. At a non-identifier cursor (empty slot, after
2883
+ // a comma) the word-anchored `srcToOffset` has nothing to grab and lands on
2884
+ // the call name, so TS returns the receiver's *members* (push, replace, …)
2885
+ // instead of the object's contextually-typed *properties* (noScroll, …).
2886
+ //
2887
+ // Given the gen offset `srcToOffset` produced (which sits at/near the call
2888
+ // name on the gen line), this walks the gen call to the same argument index
2889
+ // and property slot the source cursor occupies and returns the corrected
2890
+ // absolute offset. Returns the original offset unchanged when the cursor
2891
+ // isn't in this situation, so callers can apply it unconditionally.
2892
+ export function retargetObjectArgOffset(entry, srcLine, srcCol, genOffset) {
2893
+ if (!entry || genOffset == null || !srcLine) return genOffset;
2894
+ const before = srcLine.slice(0, srcCol);
2895
+ // On an identifier? Word-anchoring already mapped it precisely — leave it.
2896
+ if (/\w$/.test(before) || /^\w/.test(srcLine.slice(srcCol))) return genOffset;
2897
+
2898
+ // Walk back to the enclosing object-literal `{`, tracking bracket depth.
2899
+ let depth = 0, objOpen = -1;
2900
+ for (let i = srcCol - 1; i >= 0; i--) {
2901
+ const ch = before[i];
2902
+ if (ch === '}' || ch === ')' || ch === ']') depth++;
2903
+ else if (ch === '{') { if (depth === 0) { objOpen = i; break; } depth--; }
2904
+ else if (ch === '(' || ch === '[') { if (depth === 0) return genOffset; depth--; }
2905
+ }
2906
+ if (objOpen < 0) return genOffset;
2907
+ // The object must be a call argument: a `name(` must precede it with only
2908
+ // argument text (no nested braces) between the `(` and this `{`.
2909
+ const head = before.slice(0, objOpen);
2910
+ const callM = head.match(/\.\s*\w+\s*\(([^(){}]*)$/);
2911
+ if (!callM) return genOffset;
2912
+ const argIndex = countTopLevelCommas(callM[1]); // object is this call-arg
2913
+ const propSlot = countTopLevelCommas(before.slice(objOpen + 1)); // commas before cursor in object
2914
+
2915
+ // Gen side: locate the call paren near the mapped offset, then walk to the
2916
+ // same argument and the same property slot inside its object literal.
2917
+ const tsText = entry.tsContent;
2918
+ const lc = offsetToLineCol(tsText, genOffset);
2919
+ const genLine = tsText.split('\n')[lc.line];
2920
+ if (genLine == null) return genOffset;
2921
+ const callRe = /\.\s*\w+\s*\(/g;
2922
+ callRe.lastIndex = Math.max(0, lc.col - 4);
2923
+ const gm = callRe.exec(genLine);
2924
+ if (!gm) return genOffset;
2925
+ let i = gm.index + gm[0].length; // just inside the call's `(`
2926
+ let d = 1, args = 0;
2927
+ // Advance to the start of argument `argIndex`.
2928
+ while (i < genLine.length && args < argIndex) {
2929
+ const ch = genLine[i];
2930
+ if (ch === '(' || ch === '[' || ch === '{') d++;
2931
+ else if (ch === ')' || ch === ']' || ch === '}') { d--; if (d === 0) return genOffset; }
2932
+ else if (ch === ',' && d === 1) args++;
2933
+ i++;
2934
+ }
2935
+ while (i < genLine.length && /\s/.test(genLine[i])) i++;
2936
+ if (genLine[i] !== '{') return genOffset; // arg isn't an object literal
2937
+ i++; // step inside the object
2938
+ // Skip `propSlot` top-level properties within the object.
2939
+ let pd = 1, props = 0;
2940
+ while (i < genLine.length && props < propSlot) {
2941
+ const ch = genLine[i];
2942
+ if (ch === '(' || ch === '[' || ch === '{') pd++;
2943
+ else if (ch === ')' || ch === ']' || ch === '}') { pd--; if (pd === 0) break; }
2944
+ else if (ch === ',' && pd === 1) props++;
2945
+ i++;
2946
+ }
2947
+ while (i < genLine.length && /\s/.test(genLine[i])) i++;
2948
+ return genOffset - lc.col + i;
2949
+ }
2950
+
1681
2951
  // Map a Rip source (line, col) to a TypeScript virtual file byte offset.
1682
2952
  // This is the forward direction: source → generated (used for hover, definition, etc.)
1683
2953
  //
@@ -1693,18 +2963,40 @@ export function srcToOffset(entry, line, col) {
1693
2963
  if (entry.srcColToGen) {
1694
2964
  const colEntries = entry.srcColToGen.get(line);
1695
2965
  if (colEntries && colEntries.length > 0) {
1696
- let best = colEntries[0];
1697
- for (const e of colEntries) {
2966
+ // Exact-column match wins regardless of genLine. Sub-mapping anchors
2967
+ // for identifiers in arrow bodies, spreads, etc. legitimately land on
2968
+ // a different genLine than the statement's primary anchor; preferring
2969
+ // them when they line up exactly with the queried column avoids the
2970
+ // line-anchor filter (below) from discarding a precise mapping.
2971
+ const exact = colEntries.find(e => e.srcCol === col);
2972
+ if (exact) {
2973
+ genLine = exact.genLine;
2974
+ genColHint = exact.genCol;
2975
+ bestSrcCol = exact.srcCol;
2976
+ } else {
2977
+ // When srcToGen anchors this source line to a specific genLine, only
2978
+ // consider colEntries on that same genLine. Stray entries on other
2979
+ // genLines (caused by upstream sub-mapping contamination across
2980
+ // adjacent statements) can otherwise yank the lookup into an unrelated
2981
+ // gen-line context.
2982
+ const anchoredGen = entry.srcToGen.get(line);
2983
+ const filtered = anchoredGen != null
2984
+ ? colEntries.filter(e => e.genLine === anchoredGen)
2985
+ : colEntries;
2986
+ const pool = filtered.length > 0 ? filtered : colEntries;
2987
+ let best = pool[0];
2988
+ for (const e of pool) {
1698
2989
  if (e.srcCol <= col && (best.srcCol > col || e.srcCol > best.srcCol)) best = e;
1699
2990
  }
1700
2991
  if (best.srcCol > col) {
1701
- for (const e of colEntries) {
2992
+ for (const e of pool) {
1702
2993
  if (Math.abs(e.srcCol - col) < Math.abs(best.srcCol - col)) best = e;
1703
2994
  }
1704
2995
  }
1705
2996
  genLine = best.genLine;
1706
2997
  genColHint = best.genCol;
1707
2998
  bestSrcCol = best.srcCol;
2999
+ }
1708
3000
  }
1709
3001
  }
1710
3002
 
@@ -1755,18 +3047,35 @@ export function srcToOffset(entry, line, col) {
1755
3047
  // closer to the raw source column.
1756
3048
  const expectedGenCol = useHint ? genColHint
1757
3049
  : genColHint >= 0 ? genColHint + (col - bestSrcCol) : col;
3050
+ // Penalize matches that fall inside string literals or line comments
3051
+ // so an identifier appearing both as a value reference and as quoted
3052
+ // text (e.g. `console.log "clicks:", clicks`) doesn't resolve into
3053
+ // the string portion — TS returns no hover for offsets inside strings.
3054
+ const STRING_PENALTY = 1e6;
1758
3055
  while ((m = re.exec(targetText)) !== null) {
1759
- const dist = Math.abs(m.index - expectedGenCol);
3056
+ const inStr = isInsideStringOrComment(targetText, m.index);
3057
+ const dist = Math.abs(m.index - expectedGenCol) + (inStr ? STRING_PENALTY : 0);
1760
3058
  if (dist < bestDist) { bestDist = dist; bestCol = m.index; }
1761
3059
  }
1762
- if (bestCol >= 0) return lineColToOffset(entry.tsContent, targetLine, bestCol);
3060
+ if (bestCol >= 0) {
3061
+ // If every match for `word` in the target line is inside a string
3062
+ // literal (so hover would return nothing) but we have a precise
3063
+ // mapping hint for this source position, prefer the hint. This is
3064
+ // what makes hover on `:foo` (which compiles to `Symbol.for("foo")`)
3065
+ // resolve to the `Symbol` identifier instead of the dead string.
3066
+ if (useHint && bestDist >= STRING_PENALTY) {
3067
+ return lineColToOffset(entry.tsContent, targetLine, genColHint);
3068
+ }
3069
+ return lineColToOffset(entry.tsContent, targetLine, bestCol);
3070
+ }
1763
3071
 
1764
3072
  // Fall back to original genLine if overload didn't match
1765
3073
  if (targetLine !== genLine) {
1766
3074
  const re1b = new RegExp('\\b' + escaped + '\\b', 'g');
1767
3075
  let m1b, bestCol1b = -1, bestDist1b = Infinity;
1768
3076
  while ((m1b = re1b.exec(genText)) !== null) {
1769
- const dist = Math.abs(m1b.index - expectedGenCol);
3077
+ const inStr = isInsideStringOrComment(genText, m1b.index);
3078
+ const dist = Math.abs(m1b.index - expectedGenCol) + (inStr ? STRING_PENALTY : 0);
1770
3079
  if (dist < bestDist1b) { bestDist1b = dist; bestCol1b = m1b.index; }
1771
3080
  }
1772
3081
  if (bestCol1b >= 0) return lineColToOffset(entry.tsContent, genLine, bestCol1b);
@@ -1780,7 +3089,8 @@ export function srcToOffset(entry, line, col) {
1780
3089
  const re2 = new RegExp('\\b' + escaped + '\\b', 'g');
1781
3090
  let m2, best2 = -1, bestDist2 = Infinity;
1782
3091
  while ((m2 = re2.exec(tryText)) !== null) {
1783
- const dist2 = Math.abs(m2.index - col);
3092
+ const inStr = isInsideStringOrComment(tryText, m2.index);
3093
+ const dist2 = Math.abs(m2.index - col) + (inStr ? STRING_PENALTY : 0);
1784
3094
  if (dist2 < bestDist2) { bestDist2 = dist2; best2 = m2.index; }
1785
3095
  }
1786
3096
  if (best2 >= 0) return lineColToOffset(entry.tsContent, tryLine, best2);
@@ -1848,23 +3158,32 @@ export function mapToSource(entry, offset) {
1848
3158
 
1849
3159
  // ── Project config ─────────────────────────────────────────────────
1850
3160
 
1851
- // Read project config: rip.json in the given directory, or "rip" key in
1852
- // the nearest ancestor package.json. Returns { strict, exclude }.
3161
+ // Read project config from the "rip" key in the nearest ancestor
3162
+ // package.json. Returns { strict, checkAll, exclude, ... } merged from
3163
+ // `package.json#rip`, plus `_configDir` marking where it was found.
3164
+ //
3165
+ // `strict` — TS strictness family (noImplicitAny, strictNullChecks, …)
3166
+ // `checkAll` — coverage policy: check every non-@nocheck file, not just
3167
+ // annotated ones. Independent of `strict`.
1853
3168
  export function readProjectConfig(dir) {
1854
3169
  const config = {};
1855
3170
  try {
1856
3171
  let d = resolve(dir);
1857
3172
  while (true) {
1858
- const ripJsonPath = resolve(d, 'rip.json');
1859
- if (existsSync(ripJsonPath)) {
1860
- Object.assign(config, JSON.parse(readFileSync(ripJsonPath, 'utf8')));
1861
- config._configDir = d;
1862
- break;
1863
- }
1864
3173
  const pkgPath = resolve(d, 'package.json');
1865
3174
  if (existsSync(pkgPath)) {
3175
+ // The first package.json walking up is the project boundary — matching
3176
+ // the LSP's findProjectRoot and TypeScript's nearest-config resolution.
3177
+ // Apply its `rip` config if present, otherwise fall back to defaults;
3178
+ // either way stop here. We deliberately do NOT walk past it into
3179
+ // ancestors: a parent repo's config must not silently leak across a
3180
+ // project boundary (e.g. a standalone app nested inside a larger git
3181
+ // repo inheriting that repo's `strict`). Inheritance, if ever wanted,
3182
+ // should be opt-in and explicit rather than positional.
1866
3183
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1867
- if (pkg.rip && typeof pkg.rip === 'object') { Object.assign(config, pkg.rip); config._configDir = d; break; }
3184
+ if (pkg.rip && typeof pkg.rip === 'object') Object.assign(config, pkg.rip);
3185
+ config._configDir = d;
3186
+ break;
1868
3187
  }
1869
3188
  const parent = dirname(d);
1870
3189
  if (parent === d) break;
@@ -1922,6 +3241,7 @@ function findRipFiles(dir, files = [], excludePatterns = [], rootDir = dir) {
1922
3241
 
1923
3242
  const isColor = process.stdout.isTTY !== false;
1924
3243
  const red = (s) => isColor ? `\x1b[31m${s}\x1b[0m` : s;
3244
+ const green = (s) => isColor ? `\x1b[32m${s}\x1b[0m` : s;
1925
3245
  const yellow = (s) => isColor ? `\x1b[33m${s}\x1b[0m` : s;
1926
3246
  const cyan = (s) => isColor ? `\x1b[36m${s}\x1b[0m` : s;
1927
3247
  const dim = (s) => isColor ? `\x1b[2m${s}\x1b[0m` : s;
@@ -1947,9 +3267,8 @@ export async function runCheck(targetDir, opts = {}) {
1947
3267
  }
1948
3268
 
1949
3269
  const ripConfig = readProjectConfig(rootPath);
1950
-
1951
- // Merge: CLI flags override config file
1952
- const strict = opts.strict || ripConfig.strict === true;
3270
+ const strict = ripConfig.strict === true;
3271
+ const checkAll = ripConfig.checkAll === true;
1953
3272
  const excludeGlobs = Array.isArray(ripConfig.exclude) ? ripConfig.exclude : [];
1954
3273
  const excludePatterns = excludeGlobs.map(globToRegex);
1955
3274
 
@@ -1967,7 +3286,7 @@ export async function runCheck(targetDir, opts = {}) {
1967
3286
  const source = readFileSync(fp, 'utf8');
1968
3287
  sourcesByPath.set(fp, source);
1969
3288
  const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
1970
- if (!nocheck && (hasTypeAnnotations(source) || strict)) typedFiles.add(fp);
3289
+ if (!nocheck && (hasTypeAnnotations(source) || checkAll)) typedFiles.add(fp);
1971
3290
  }
1972
3291
 
1973
3292
  // Include imports of typed files (files imported BY typed files)
@@ -2003,7 +3322,7 @@ export async function runCheck(targetDir, opts = {}) {
2003
3322
  for (const fp of typedFiles) {
2004
3323
  try {
2005
3324
  const source = sourcesByPath.get(fp);
2006
- compiled.set(fp, compileForCheck(fp, source, new Compiler(), { strict }));
3325
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { checkAll }));
2007
3326
  } catch (e) {
2008
3327
  compileErrors++;
2009
3328
  const rel = relative(rootPath, fp);
@@ -2011,6 +3330,50 @@ export async function runCheck(targetDir, opts = {}) {
2011
3330
  }
2012
3331
  }
2013
3332
 
3333
+ // Always compile the project's stash file (even when excluded), so its
3334
+ // `__RipStash` export is resolvable from typed components that consume it.
3335
+ // Diagnostics from the stash file are still suppressed via exclude when
3336
+ // emitting results — this only ensures cross-module type info is available.
3337
+ const seenStash = new Set();
3338
+ for (const fp of typedFiles) {
3339
+ const stashFile = findStashFile(fp);
3340
+ if (!stashFile || seenStash.has(stashFile)) continue;
3341
+ seenStash.add(stashFile);
3342
+ if (compiled.has(stashFile) || !existsSync(stashFile)) continue;
3343
+ try {
3344
+ const src = sourcesByPath.get(stashFile) ?? readFileSync(stashFile, 'utf8');
3345
+ const compiledStash = compileForCheck(stashFile, src, new Compiler(), { checkAll });
3346
+ compiledStash._typeOnly = true; // skip diagnostics — only here for cross-module types
3347
+ compiled.set(stashFile, compiledStash);
3348
+ } catch (e) {
3349
+ console.warn(`[rip] stash compile failed for ${stashFile}: ${e.message}`);
3350
+ }
3351
+ }
3352
+
3353
+ // Always compile the project's entry file when routes exist, so its
3354
+ // `__RipRoutes` export is resolvable from typed route/layout files. The
3355
+ // entry file (server bin) is typically untyped — it just calls `start()` —
3356
+ // so it wouldn't otherwise be pulled into the typed set, and
3357
+ // `import('<entry>').__RipRoutes` would silently resolve to `any`,
3358
+ // disabling the route-typo check. Diagnostics from the entry are
3359
+ // suppressed via `_typeOnly` — only here as a cross-module type carrier.
3360
+ const seenEntry = new Set();
3361
+ for (const fp of typedFiles) {
3362
+ const entryFile = findEntryFile(fp);
3363
+ if (!entryFile || seenEntry.has(entryFile)) continue;
3364
+ seenEntry.add(entryFile);
3365
+ if (!findRoutesDir(fp)) continue;
3366
+ if (compiled.has(entryFile) || !existsSync(entryFile)) continue;
3367
+ try {
3368
+ const src = sourcesByPath.get(entryFile) ?? readFileSync(entryFile, 'utf8');
3369
+ const compiledEntry = compileForCheck(entryFile, src, new Compiler(), { checkAll });
3370
+ compiledEntry._typeOnly = true;
3371
+ compiled.set(entryFile, compiledEntry);
3372
+ } catch (e) {
3373
+ console.warn(`[rip] entry compile failed for ${entryFile}: ${e.message}`);
3374
+ }
3375
+ }
3376
+
2014
3377
  // Also compile any .rip files imported from typed files that aren't yet compiled
2015
3378
  for (const [fp, entry] of [...compiled.entries()]) {
2016
3379
  if (!entry.hasTypes) continue;
@@ -2028,6 +3391,92 @@ export async function runCheck(targetDir, opts = {}) {
2028
3391
  }
2029
3392
  }
2030
3393
 
3394
+ // ── @rip-lang/* package resolution ─────────────────────────────────
3395
+ //
3396
+ // When a typed file imports from `@rip-lang/foo` (or `@rip-lang/foo/sub`),
3397
+ // resolve the specifier via Node module resolution rooted at the project,
3398
+ // and if it lands on a `.rip` entry, compile that entry with
3399
+ // compileForCheck so its exported annotations become visible to TS as
3400
+ // a virtual `.rip.ts` module. Diagnostics from the package itself are
3401
+ // suppressed (`_typeOnly = true`) — package internals are checked when
3402
+ // the package is checked, not when its consumers are.
3403
+ const pkgRequire = createRequire(resolve(rootPath, 'package.json'));
3404
+ const pkgSpecCache = new Map(); // spec → resolved abs path | null
3405
+ function resolvePkgSpec(spec) {
3406
+ if (pkgSpecCache.has(spec)) return pkgSpecCache.get(spec);
3407
+ let resolved = null;
3408
+ try {
3409
+ const r = pkgRequire.resolve(spec);
3410
+ if (typeof r === 'string' && r.endsWith('.rip') && existsSync(r)) resolved = r;
3411
+ } catch {}
3412
+ pkgSpecCache.set(spec, resolved);
3413
+ return resolved;
3414
+ }
3415
+
3416
+ // ── Undeclared-import diagnostic (`rip check` surface) ──
3417
+ // Same check as the loader and bundler — surfaced earlier when typing is on.
3418
+ // The project's own `package.json#name` is treated as a self-import (covers
3419
+ // in-package fixtures/tests like `packages/server/bench/index.rip`).
3420
+ let undeclaredCount = 0;
3421
+ try {
3422
+ const projPkgPath = resolve(rootPath, 'package.json');
3423
+ if (existsSync(projPkgPath)) {
3424
+ const projPkg = JSON.parse(readFileSync(projPkgPath, 'utf8'));
3425
+ const declared = new Set([
3426
+ ...Object.keys(projPkg.dependencies || {}),
3427
+ ...Object.keys(projPkg.devDependencies || {}),
3428
+ ...Object.keys(projPkg.peerDependencies || {}),
3429
+ ...Object.keys(projPkg.optionalDependencies || {}),
3430
+ ]);
3431
+ const selfName = projPkg.name || null;
3432
+ const reported = new Set();
3433
+ for (const [fp, entry] of compiled) {
3434
+ if (entry._typeOnly) continue;
3435
+ for (const spec of scanRipPkgImports(entry.tsContent || entry.source)) {
3436
+ const pkgKey = ripPkgRoot(spec);
3437
+ if (pkgKey === selfName) continue;
3438
+ if (declared.has(pkgKey)) continue;
3439
+ const key = `${fp}::${pkgKey}`;
3440
+ if (reported.has(key)) continue;
3441
+ reported.add(key);
3442
+ const rel = relative(rootPath, fp);
3443
+ 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).`);
3444
+ undeclaredCount++;
3445
+ }
3446
+ }
3447
+ }
3448
+ } catch (e) {
3449
+ console.warn(`[rip] undeclared-import check failed: ${e.message}`);
3450
+ }
3451
+ const pendingPkgFiles = new Set();
3452
+ for (const [, entry] of compiled) {
3453
+ const text = entry.tsContent || entry.source;
3454
+ for (const spec of [...scanRipPkgImports(text), ...scanRipPkgImportTypes(text)]) {
3455
+ const r = resolvePkgSpec(spec);
3456
+ if (r && !compiled.has(r)) pendingPkgFiles.add(r);
3457
+ }
3458
+ }
3459
+ // Iterate transitively: package files may themselves import other
3460
+ // @rip-lang/* entries. Bounded by the number of unique resolved paths.
3461
+ while (pendingPkgFiles.size) {
3462
+ const next = pendingPkgFiles.values().next().value;
3463
+ pendingPkgFiles.delete(next);
3464
+ if (compiled.has(next)) continue;
3465
+ try {
3466
+ const pkgSrc = readFileSync(next, 'utf8');
3467
+ const compiledPkg = compileForCheck(next, pkgSrc, new Compiler());
3468
+ compiledPkg._typeOnly = true;
3469
+ compiled.set(next, compiledPkg);
3470
+ const pkgText = compiledPkg.tsContent || pkgSrc;
3471
+ for (const spec of [...scanRipPkgImports(pkgText), ...scanRipPkgImportTypes(pkgText)]) {
3472
+ const r = resolvePkgSpec(spec);
3473
+ if (r && !compiled.has(r)) pendingPkgFiles.add(r);
3474
+ }
3475
+ } catch (e) {
3476
+ console.warn(`[rip] @rip-lang package compile failed for ${next}: ${e.message}`);
3477
+ }
3478
+ }
3479
+
2031
3480
  // Check for unresolved relative imports in all files (not just typed ones)
2032
3481
  const fileResults = [];
2033
3482
  let totalErrors = 0, totalWarnings = 0;
@@ -2049,7 +3498,26 @@ export async function runCheck(targetDir, opts = {}) {
2049
3498
  }
2050
3499
 
2051
3500
  // Create TypeScript language service
2052
- const settings = createTypeCheckSettings(ts);
3501
+ //
3502
+ // Project-scope `strict` (package.json `rip.strict: true`)
3503
+ // opts the project UP to TypeScript's `strict` family, which implies
3504
+ // noImplicitAny, strictNullChecks, strictFunctionTypes, and friends.
3505
+ // With strict on: `T` excludes null/undefined, untyped params error,
3506
+ // etc. Without strict: lenient gradual-typing defaults — annotations
3507
+ // are accepted as documentation but not enforced as contracts. The
3508
+ // default is lenient to match Rip's "scaffolding, not safety rails"
3509
+ // philosophy; projects opt up when they want the contract enforced.
3510
+ // Collect `node_modules/@types` directories walking up from rootPath so
3511
+ // ambient type packages (e.g. `@types/bun`) installed at the workspace
3512
+ // root are picked up even when `rip check` runs in a sub-package. TS's
3513
+ // default `typeRoots` only looks at `<cwd>/node_modules/@types`.
3514
+ const { typeRoots, types: ambientTypes } = collectAmbientTypes(rootPath);
3515
+
3516
+ const settings = createTypeCheckSettings(ts, {
3517
+ ...(strict ? { strict: true } : {}),
3518
+ ...(typeRoots.length ? { typeRoots } : {}),
3519
+ ...(ambientTypes.length ? { types: ambientTypes } : {}),
3520
+ });
2053
3521
 
2054
3522
  const host = {
2055
3523
  getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
@@ -2068,6 +3536,14 @@ export async function runCheck(targetDir, opts = {}) {
2068
3536
  getDirectories: (...a) => ts.sys.getDirectories(...a),
2069
3537
  directoryExists: (...a) => ts.sys.directoryExists(...a),
2070
3538
 
3539
+ resolveTypeReferenceDirectives(typeDirectiveNames, containingFile, redirectedReference, options) {
3540
+ return typeDirectiveNames.map((name) => {
3541
+ const n = typeof name === 'string' ? name : name.name;
3542
+ const r = ts.resolveTypeReferenceDirective(n, containingFile, options || settings, ts.sys, redirectedReference);
3543
+ return r.resolvedTypeReferenceDirective;
3544
+ });
3545
+ },
3546
+
2071
3547
  resolveModuleNames(names, containingFile) {
2072
3548
  return names.map((name) => {
2073
3549
  if (name.endsWith('.rip')) {
@@ -2076,6 +3552,12 @@ export async function runCheck(targetDir, opts = {}) {
2076
3552
  return { resolvedFileName: toVirtual(resolved), extension: '.ts', isExternalLibraryImport: false };
2077
3553
  }
2078
3554
  }
3555
+ if (name.startsWith('@rip-lang/')) {
3556
+ const r = resolvePkgSpec(name);
3557
+ if (r && compiled.has(r)) {
3558
+ return { resolvedFileName: toVirtual(r), extension: '.ts', isExternalLibraryImport: false };
3559
+ }
3560
+ }
2079
3561
  const r = ts.resolveModuleName(name, containingFile, settings, {
2080
3562
  fileExists: host.fileExists,
2081
3563
  readFile: host.readFile,
@@ -2097,6 +3579,7 @@ export async function runCheck(targetDir, opts = {}) {
2097
3579
 
2098
3580
  for (const [fp, entry] of compiled) {
2099
3581
  if (!entry.hasTypes) continue;
3582
+ if (entry._typeOnly) continue;
2100
3583
 
2101
3584
  const vf = toVirtual(fp);
2102
3585
  let diags;
@@ -2120,7 +3603,7 @@ export async function runCheck(targetDir, opts = {}) {
2120
3603
  // Conditional suppression — narrowed instead of blanket
2121
3604
  if (CONDITIONAL_CODES.has(d.code)) {
2122
3605
  const flatMsg = d.code === 2307 ? ts.flattenDiagnosticMessageText(d.messageText, '\n') : null;
2123
- if (shouldSuppressConditional(d.code, d.start, d.length, entry.tsContent, entry.headerLines, entry.dts, flatMsg, fp)) continue;
3606
+ if (shouldSuppressConditional(d.code, d.start, d.length, entry.tsContent, entry.headerLines, entry.dts, flatMsg, fp, d.relatedInformation)) continue;
2124
3607
  }
2125
3608
 
2126
3609
  // Skip 6133 on compiler-generated _render() construction variables (_0, _1, …)
@@ -2142,12 +3625,18 @@ export async function runCheck(targetDir, opts = {}) {
2142
3625
  if (adj) { pos.line = adj.line; pos.col = adj.col; }
2143
3626
 
2144
3627
  const endPos = adj ? { line: adj.line, col: adj.col + adj.len } : (d.length ? mapToSourcePos(entry, d.start + d.length) : null);
2145
- const len = endPos && endPos.line === pos.line ? endPos.col - pos.col : 1;
3628
+ let len = endPos && endPos.line === pos.line ? endPos.col - pos.col : 1;
2146
3629
 
2147
3630
  const message = cleanDiagnosticMessage(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
2148
3631
  const severity = d.category === 1 ? 'error' : d.category === 0 ? 'warning' : 'info';
2149
3632
  const srcLine = srcLines[pos.line] || '';
2150
3633
 
3634
+ const { code: finalCode, message: finalMessage } = unifyRouteDiagnostic(d.code, message, entry, d.start, fp);
3635
+
3636
+ // Snap route diagnostics to the meaningful token (`href` / `push`).
3637
+ const routeSpan = locateRouteDiagnosticSpan(entry, d.start, srcLine);
3638
+ if (routeSpan) { pos.col = routeSpan.col; len = routeSpan.len; }
3639
+
2151
3640
  // Collect related information
2152
3641
  const related = [];
2153
3642
  if (d.relatedInformation) {
@@ -2194,11 +3683,30 @@ export async function runCheck(targetDir, opts = {}) {
2194
3683
  }
2195
3684
  }
2196
3685
 
2197
- errors.push({ line: pos.line + 1, col: pos.col + 1, len: Math.max(1, len), message, severity, code: d.code, srcLine, related });
3686
+ errors.push({ line: pos.line + 1, col: pos.col + 1, len: Math.max(1, len), message: finalMessage, severity, code: finalCode, srcLine, related });
2198
3687
  if (severity === 'error') totalErrors++;
2199
3688
  else if (severity === 'warning') totalWarnings++;
2200
3689
  }
2201
3690
 
3691
+ // Dedup: same diagnostic can map twice when the dts header and compiled
3692
+ // body both contain the offending construct (e.g. an `import { X }` line).
3693
+ {
3694
+ const deduped = dedupDiagnostics(errors, e => ({
3695
+ startLine: e.line, startCol: e.col,
3696
+ endLine: e.line, endCol: e.col + e.len,
3697
+ }));
3698
+ if (deduped.length < errors.length) {
3699
+ const kept = new Set(deduped);
3700
+ for (const e of errors) {
3701
+ if (kept.has(e)) continue;
3702
+ if (e.severity === 'error') totalErrors--;
3703
+ else if (e.severity === 'warning') totalWarnings--;
3704
+ }
3705
+ errors.length = 0;
3706
+ errors.push(...deduped);
3707
+ }
3708
+ }
3709
+
2202
3710
  // Untyped component prop checking — flag props without :: annotation
2203
3711
  if (entry.dts) {
2204
3712
  for (const [compName, compInfo] of parseComponentDTS(entry.dts)) {
@@ -2233,6 +3741,7 @@ export async function runCheck(targetDir, opts = {}) {
2233
3741
  if (globalDefs.size > 0) {
2234
3742
  for (const [fp, entry] of compiled) {
2235
3743
  if (!entry.hasTypes) continue;
3744
+ if (entry._typeOnly) continue;
2236
3745
  const srcLines = entry.source.split('\n');
2237
3746
  const errors = fileResults.find(r => r.file === fp)?.errors || [];
2238
3747
  const hadEntry = errors.length > 0;
@@ -2281,6 +3790,12 @@ export async function runCheck(targetDir, opts = {}) {
2281
3790
  // round-trip: srcToOffset must resolve, and getQuickInfoAtPosition must
2282
3791
  // return hover info for it. Failures indicate source map gaps that make
2283
3792
  // hover/definition/completion silently break in the editor.
3793
+ //
3794
+ // Opt-in via `rip check --sourcemap`. This is a compiler-development
3795
+ // diagnostic — gaps usually mean the audit's skip list is incomplete or
3796
+ // that codegen lost a binding, both compiler-side concerns rather than
3797
+ // anything a package author can fix. Not run as part of `--audit` (which
3798
+ // is the package-author-facing public-API check).
2284
3799
 
2285
3800
  const AUDIT_SKIP = new Set([
2286
3801
  'if', 'else', 'then', 'unless', 'switch', 'when', 'for', 'while', 'until',
@@ -2348,8 +3863,9 @@ export async function runCheck(targetDir, opts = {}) {
2348
3863
  let auditGaps = 0;
2349
3864
  const auditResults = [];
2350
3865
 
2351
- for (const [fp, entry] of compiled) {
3866
+ if (opts.sourceMapAudit) for (const [fp, entry] of compiled) {
2352
3867
  if (!entry.hasTypes) continue;
3868
+ if (entry._typeOnly) continue;
2353
3869
  const srcLines = entry.source.split('\n');
2354
3870
  const vf = toVirtual(fp);
2355
3871
  const gaps = [];
@@ -2444,19 +3960,6 @@ export async function runCheck(targetDir, opts = {}) {
2444
3960
  }
2445
3961
  }
2446
3962
 
2447
- // Print audit results
2448
- if (auditResults.length > 0) {
2449
- console.log(bold('\n── Source Map Audit ──\n'));
2450
- for (const { file, gaps } of auditResults) {
2451
- const rel = relative(rootPath, file);
2452
- for (const g of gaps) {
2453
- const loc = `${cyan(rel)}${dim(':')}${yellow(String(g.line))}${dim(':')}${yellow(String(g.col))}`;
2454
- console.log(`${loc} ${dim('-')} ${yellow('warning')} ${dim('audit:')} ${g.issue} for '${g.word}'`);
2455
- }
2456
- }
2457
- console.log(`\n${yellow(String(auditGaps))} source map gap${auditGaps === 1 ? '' : 's'} found\n`);
2458
- }
2459
-
2460
3963
  // Print results — tsc format with Rip source positions
2461
3964
  for (const { file, errors } of fileResults) {
2462
3965
  const rel = relative(rootPath, file);
@@ -2496,7 +3999,8 @@ export async function runCheck(targetDir, opts = {}) {
2496
3999
  // Summary — tsc format
2497
4000
  const totalFound = totalErrors + totalWarnings;
2498
4001
  if (totalFound === 0) {
2499
- return compileErrors > 0 ? 1 : 0;
4002
+ printSourceMapAudit();
4003
+ return compileErrors > 0 || undeclaredCount > 0 ? 1 : 0;
2500
4004
  }
2501
4005
 
2502
4006
  const s = totalFound === 1 ? '' : 's';
@@ -2518,5 +4022,418 @@ export async function runCheck(targetDir, opts = {}) {
2518
4022
  console.log('');
2519
4023
  }
2520
4024
 
2521
- return totalErrors > 0 ? 1 : 0;
4025
+ printSourceMapAudit();
4026
+ return totalErrors > 0 || undeclaredCount > 0 ? 1 : 0;
4027
+
4028
+ function printSourceMapAudit() {
4029
+ if (!opts.sourceMapAudit || auditResults.length === 0) return;
4030
+ console.log(bold('── Source Map Audit ──\n'));
4031
+ for (const { file, gaps } of auditResults) {
4032
+ const rel = relative(rootPath, file);
4033
+ for (const g of gaps) {
4034
+ const loc = `${cyan(rel)}${dim(':')}${yellow(String(g.line))}${dim(':')}${yellow(String(g.col))}`;
4035
+ console.log(`${loc} ${dim('-')} ${yellow('warning')} ${dim('audit:')} ${g.issue} for '${g.word}'`);
4036
+ }
4037
+ }
4038
+ console.log(`\n${yellow(String(auditGaps))} source map gap${auditGaps === 1 ? '' : 's'} found\n`);
4039
+ }
4040
+ }
4041
+
4042
+ // ── Public-surface `any` audit ─────────────────────────────────────
4043
+ //
4044
+ // `rip check --audit [pkgDir]` walks a package's public exports and
4045
+ // flags any export whose type contains `any` (in parameters, return
4046
+ // types, or own properties of types declared in the package).
4047
+ // External types (e.g. lib.es5, @types/*) are treated as opaque —
4048
+ // we don't dive into `Promise<Response>` looking for `any` inside
4049
+ // `Response`. The package can only control its own surface; this
4050
+ // audit measures exactly that.
4051
+ //
4052
+ // Exit code: 0 if every export is `any`-free, 1 otherwise.
4053
+
4054
+ // Resolve a package's public entries from its package.json. Handles
4055
+ // `main`, `module`, string `exports`, and the subpath/conditional
4056
+ // `exports` map. Returns [{ subpath, file }] with absolute paths.
4057
+ function collectPackageEntries(pkg, pkgDir) {
4058
+ const entries = new Map(); // subpath → abs file
4059
+ const add = (subpath, p) => {
4060
+ if (typeof p !== 'string') return;
4061
+ if (entries.has(subpath)) return;
4062
+ entries.set(subpath, resolve(pkgDir, p));
4063
+ };
4064
+ const walkConditional = (subpath, v) => {
4065
+ if (typeof v === 'string') { add(subpath, v); return; }
4066
+ if (!v || typeof v !== 'object') return;
4067
+ // Prefer `import` then `default`, fall back to any string value.
4068
+ if (typeof v.import === 'string') { add(subpath, v.import); return; }
4069
+ if (typeof v.default === 'string') { add(subpath, v.default); return; }
4070
+ for (const k of Object.keys(v)) walkConditional(subpath, v[k]);
4071
+ };
4072
+
4073
+ if (typeof pkg.exports === 'string') {
4074
+ add('.', pkg.exports);
4075
+ } else if (pkg.exports && typeof pkg.exports === 'object') {
4076
+ const keys = Object.keys(pkg.exports);
4077
+ const isSubpathMap = keys.some(k => k.startsWith('.'));
4078
+ if (isSubpathMap) {
4079
+ for (const sp of keys) walkConditional(sp, pkg.exports[sp]);
4080
+ } else {
4081
+ walkConditional('.', pkg.exports);
4082
+ }
4083
+ }
4084
+ if (!entries.has('.') && typeof pkg.module === 'string') add('.', pkg.module);
4085
+ if (!entries.has('.') && typeof pkg.main === 'string') add('.', pkg.main);
4086
+ return [...entries.entries()].map(([subpath, file]) => ({ subpath, file }));
4087
+ }
4088
+
4089
+ // True if `type`'s declarations all live outside the package's
4090
+ // compiled set (i.e. it's a lib/`@types`/external type). Anonymous
4091
+ // types (no symbol or no declarations) are treated as local — they
4092
+ // are inline shapes from the package's own annotations.
4093
+ function isExternalType(type, compiled) {
4094
+ const sym = type.aliasSymbol || type.symbol;
4095
+ if (!sym || !sym.declarations || sym.declarations.length === 0) return false;
4096
+ for (const d of sym.declarations) {
4097
+ const sf = d.getSourceFile?.();
4098
+ if (!sf) continue;
4099
+ if (compiled.has(fromVirtual(sf.fileName))) return false;
4100
+ }
4101
+ return true;
4102
+ }
4103
+
4104
+ // Walk a TS type looking for `any`. Returns null if no leak, or a
4105
+ // breadcrumb string describing where the `any` lives.
4106
+ //
4107
+ // Scoping rule: EXTERNAL types are fully opaque. We don't walk into
4108
+ // their unions, type arguments, signatures, or properties. The
4109
+ // package is only accountable for shapes it directly wrote. If a
4110
+ // package's export references another local exported type, that
4111
+ // referenced type gets audited on its own export — not transitively
4112
+ // through every place it's referenced.
4113
+ //
4114
+ // Why so strict: lib.dom types like `BodyInit` resolve to unions
4115
+ // including `ReadableStream<R = any>`. Walking into the expansion
4116
+ // blames the package for `any` it never wrote. Same for `Promise<T>`,
4117
+ // `Array<T>`, `Record<K, V>`, etc. — diving into them surfaces
4118
+ // internals the package doesn't control.
4119
+ //
4120
+ // Trade-off: `Promise<any>` written literally in package source is
4121
+ // also opaque under this rule, so we miss it. Acceptable: such a
4122
+ // pattern is rare and easy to spot in review.
4123
+ function findAnyLeaks(type, ts, checker, compiled, seen = new WeakSet(), depth = 0, exportedSymbols = null, rootSymbol = null) {
4124
+ if (!type || depth > 12) return null;
4125
+ if (type.flags & ts.TypeFlags.Any) return '';
4126
+ if (isExternalType(type, compiled)) return null;
4127
+ // On recursion (depth > 0), stop at any other exported symbol.
4128
+ // Each export is audited on its own line — don't double-count
4129
+ // leaks through cross-references.
4130
+ if (depth > 0 && exportedSymbols) {
4131
+ const sym = type.aliasSymbol || type.symbol;
4132
+ if (sym && sym !== rootSymbol && exportedSymbols.has(sym)) return null;
4133
+ }
4134
+ if (seen.has(type)) return null;
4135
+ seen.add(type);
4136
+
4137
+ const named = (type.aliasSymbol || type.symbol)?.getName?.();
4138
+ const label = named && named !== '__type' && named !== '__object' ? named : null;
4139
+
4140
+ if (type.isUnion?.() || type.isIntersection?.()) {
4141
+ for (let i = 0; i < type.types.length; i++) {
4142
+ const p = findAnyLeaks(type.types[i], ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4143
+ if (p !== null) return joinPath(label, `|${i}`, p);
4144
+ }
4145
+ }
4146
+
4147
+ const args = checker.getTypeArguments?.(type) || type.typeArguments || [];
4148
+ for (let i = 0; i < args.length; i++) {
4149
+ const p = findAnyLeaks(args[i], ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4150
+ if (p !== null) return joinPath(label, `<${i}>`, p);
4151
+ }
4152
+
4153
+ const walkParams = (sig) => {
4154
+ for (const p of sig.parameters) {
4155
+ const decl = p.valueDeclaration || p.declarations?.[0];
4156
+ if (!decl) continue;
4157
+ const pt = checker.getTypeOfSymbolAtLocation(p, decl);
4158
+ const sub = findAnyLeaks(pt, ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4159
+ if (sub !== null) return joinPath(null, `(${p.getName()})`, sub);
4160
+ }
4161
+ return null;
4162
+ };
4163
+
4164
+ for (const sig of type.getCallSignatures?.() || []) {
4165
+ const ps = walkParams(sig);
4166
+ if (ps !== null) return joinPath(label, '', ps);
4167
+ const rs = findAnyLeaks(sig.getReturnType(), ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4168
+ if (rs !== null) return joinPath(label, '=>', rs);
4169
+ }
4170
+ for (const sig of type.getConstructSignatures?.() || []) {
4171
+ const ps = walkParams(sig);
4172
+ if (ps !== null) return joinPath(label, 'new', ps);
4173
+ const rs = findAnyLeaks(sig.getReturnType(), ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4174
+ if (rs !== null) return joinPath(label, 'new=>', rs);
4175
+ }
4176
+
4177
+ if (type.getProperties) {
4178
+ for (const p of type.getProperties()) {
4179
+ const decl = p.valueDeclaration || p.declarations?.[0];
4180
+ if (!decl) continue;
4181
+ const sf = decl.getSourceFile?.();
4182
+ if (!sf) continue;
4183
+ if (!compiled.has(fromVirtual(sf.fileName))) continue;
4184
+ const pt = checker.getTypeOfSymbolAtLocation(p, decl);
4185
+ const sub = findAnyLeaks(pt, ts, checker, compiled, seen, depth + 1, exportedSymbols, rootSymbol);
4186
+ if (sub !== null) return joinPath(label, `.${p.getName()}`, sub);
4187
+ }
4188
+ }
4189
+
4190
+ return null;
4191
+ }
4192
+
4193
+ function joinPath(label, step, rest) {
4194
+ const head = label ? `${label}${step}` : step;
4195
+ if (!rest) return head || '<any>';
4196
+ if (rest.startsWith('|') || rest.startsWith('<') || rest.startsWith('(') || rest.startsWith('.') || rest.startsWith('=>') || rest.startsWith('new')) {
4197
+ return head + rest;
4198
+ }
4199
+ return head ? `${head}.${rest}` : rest;
4200
+ }
4201
+
4202
+ export async function runAudit(targetDir) {
4203
+ const rootPath = resolve(targetDir);
4204
+
4205
+ let ts;
4206
+ try {
4207
+ const req = createRequire(resolve(rootPath, 'package.json'));
4208
+ ts = req('typescript');
4209
+ } catch {
4210
+ try { ts = await import('typescript').then(m => m.default || m); } catch {
4211
+ console.error('TypeScript is required for --audit. Install with: bun add -d typescript');
4212
+ return 1;
4213
+ }
4214
+ }
4215
+
4216
+ const pkgJsonPath = resolve(rootPath, 'package.json');
4217
+ if (!existsSync(pkgJsonPath)) {
4218
+ console.error(red(`No package.json found at ${rootPath}`));
4219
+ return 1;
4220
+ }
4221
+ const pkg = JSON.parse(readFileSync(pkgJsonPath, 'utf8'));
4222
+ const pkgName = pkg.name || basename(rootPath);
4223
+
4224
+ const allEntries = collectPackageEntries(pkg, rootPath);
4225
+ const ripEntries = allEntries.filter(e => e.file.endsWith('.rip') && existsSync(e.file));
4226
+ if (ripEntries.length === 0) {
4227
+ console.error(red(`No .rip entry points found in ${pkgName}`));
4228
+ if (allEntries.length > 0) {
4229
+ console.error(dim(` package.json declares entries, but none point to a .rip file:`));
4230
+ for (const e of allEntries) console.error(dim(` ${e.subpath} → ${relative(rootPath, e.file)}`));
4231
+ }
4232
+ return 1;
4233
+ }
4234
+
4235
+ // Compile each entry plus its transitive `.rip` imports so the
4236
+ // language service can resolve cross-module types. Only entries
4237
+ // themselves are audited; imported files exist purely so types
4238
+ // referenced from the entry can be expanded.
4239
+ const compiled = new Map();
4240
+ const queue = ripEntries.map(e => e.file);
4241
+ while (queue.length) {
4242
+ const fp = queue.shift();
4243
+ if (compiled.has(fp)) continue;
4244
+ try {
4245
+ const source = readFileSync(fp, 'utf8');
4246
+ // Compile with strict: true so every export's annotations are
4247
+ // emitted into the DTS — without strict, partially-annotated
4248
+ // exports could fall back to inferred types that look cleaner
4249
+ // than they really are.
4250
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { checkAll: true }));
4251
+ for (const m of source.matchAll(/from\s+['"]([^'"]+)['"]/g)) {
4252
+ const spec = m[1];
4253
+ if (spec.endsWith('.rip')) {
4254
+ const r = resolve(dirname(fp), spec);
4255
+ if (existsSync(r) && !compiled.has(r)) queue.push(r);
4256
+ }
4257
+ }
4258
+ } catch (e) {
4259
+ console.error(`${red('error')} ${cyan(relative(rootPath, fp))}: compile error — ${e.message}`);
4260
+ return 1;
4261
+ }
4262
+ }
4263
+
4264
+ // Resolve @rip-lang/* package imports (siblings in the workspace)
4265
+ // so cross-package re-exports type correctly.
4266
+ const pkgRequire = createRequire(resolve(rootPath, 'package.json'));
4267
+ const pkgSpecCache = new Map();
4268
+ function resolvePkgSpec(spec) {
4269
+ if (pkgSpecCache.has(spec)) return pkgSpecCache.get(spec);
4270
+ let resolved = null;
4271
+ try {
4272
+ const r = pkgRequire.resolve(spec);
4273
+ if (typeof r === 'string' && r.endsWith('.rip') && existsSync(r)) resolved = r;
4274
+ } catch {}
4275
+ pkgSpecCache.set(spec, resolved);
4276
+ return resolved;
4277
+ }
4278
+ const pkgQueue = new Set();
4279
+ for (const [, entry] of compiled) {
4280
+ for (const spec of scanRipPkgImports(entry.tsContent || entry.source)) {
4281
+ const r = resolvePkgSpec(spec);
4282
+ if (r && !compiled.has(r)) pkgQueue.add(r);
4283
+ }
4284
+ }
4285
+ while (pkgQueue.size) {
4286
+ const next = pkgQueue.values().next().value;
4287
+ pkgQueue.delete(next);
4288
+ if (compiled.has(next)) continue;
4289
+ try {
4290
+ const src = readFileSync(next, 'utf8');
4291
+ const compiledPkg = compileForCheck(next, src, new Compiler());
4292
+ compiled.set(next, compiledPkg);
4293
+ for (const spec of scanRipPkgImports(compiledPkg.tsContent || src)) {
4294
+ const r = resolvePkgSpec(spec);
4295
+ if (r && !compiled.has(r)) pkgQueue.add(r);
4296
+ }
4297
+ } catch (e) {
4298
+ console.warn(`[rip] @rip-lang package compile failed for ${next}: ${e.message}`);
4299
+ }
4300
+ }
4301
+
4302
+ const { typeRoots, types: ambientTypes } = collectAmbientTypes(rootPath);
4303
+ const settings = createTypeCheckSettings(ts, {
4304
+ strict: true,
4305
+ ...(typeRoots.length ? { typeRoots } : {}),
4306
+ ...(ambientTypes.length ? { types: ambientTypes } : {}),
4307
+ });
4308
+
4309
+ const host = {
4310
+ getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
4311
+ getScriptVersion: () => '1',
4312
+ getScriptSnapshot(f) {
4313
+ const c = compiled.get(fromVirtual(f));
4314
+ if (c) return ts.ScriptSnapshot.fromString(c.tsContent);
4315
+ try { return ts.ScriptSnapshot.fromString(readFileSync(f, 'utf8')); } catch { return undefined; }
4316
+ },
4317
+ getCompilationSettings: () => settings,
4318
+ getDefaultLibFileName: (o) => ts.getDefaultLibFilePath(o),
4319
+ getCurrentDirectory: () => rootPath,
4320
+ fileExists(f) { return compiled.has(fromVirtual(f)) || ts.sys.fileExists(f); },
4321
+ readFile(f) { return compiled.get(fromVirtual(f))?.tsContent || ts.sys.readFile(f); },
4322
+ readDirectory: (...a) => ts.sys.readDirectory(...a),
4323
+ getDirectories: (...a) => ts.sys.getDirectories(...a),
4324
+ directoryExists: (...a) => ts.sys.directoryExists(...a),
4325
+ resolveTypeReferenceDirectives(names, containingFile, redirectedReference, options) {
4326
+ return names.map(n => {
4327
+ const name = typeof n === 'string' ? n : n.name;
4328
+ const r = ts.resolveTypeReferenceDirective(name, containingFile, options || settings, ts.sys, redirectedReference);
4329
+ return r.resolvedTypeReferenceDirective;
4330
+ });
4331
+ },
4332
+ resolveModuleNames(names, containingFile) {
4333
+ return names.map(name => {
4334
+ if (name.endsWith('.rip')) {
4335
+ const r = resolve(dirname(fromVirtual(containingFile)), name);
4336
+ if (compiled.has(r)) return { resolvedFileName: toVirtual(r), extension: '.ts', isExternalLibraryImport: false };
4337
+ }
4338
+ if (name.startsWith('@rip-lang/')) {
4339
+ const r = resolvePkgSpec(name);
4340
+ if (r && compiled.has(r)) return { resolvedFileName: toVirtual(r), extension: '.ts', isExternalLibraryImport: false };
4341
+ }
4342
+ const r = ts.resolveModuleName(name, containingFile, settings, {
4343
+ fileExists: host.fileExists,
4344
+ readFile: host.readFile,
4345
+ directoryExists: host.directoryExists,
4346
+ getCurrentDirectory: host.getCurrentDirectory,
4347
+ getDirectories: host.getDirectories,
4348
+ });
4349
+ return r.resolvedModule;
4350
+ });
4351
+ },
4352
+ };
4353
+
4354
+ const service = ts.createLanguageService(host, ts.createDocumentRegistry());
4355
+ const program = service.getProgram();
4356
+ const checker = program.getTypeChecker();
4357
+ const fmtFlags = ts.TypeFormatFlags.NoTruncation
4358
+ | ts.TypeFormatFlags.UseAliasDefinedOutsideCurrentScope
4359
+ | ts.TypeFormatFlags.WriteArrayAsGenericType;
4360
+
4361
+ let totalExports = 0, totalLeaks = 0;
4362
+
4363
+ // First pass: collect ALL exported symbols across all entries.
4364
+ // The walker treats these as opaque on recursion — each export is
4365
+ // audited only on its own direct shape. If export A references
4366
+ // export B and B leaks, the leak surfaces on B's audit line, not
4367
+ // by polluting every type that mentions B.
4368
+ const exportedSymbols = new Set();
4369
+ const entryData = [];
4370
+ for (const entry of ripEntries) {
4371
+ const vf = toVirtual(entry.file);
4372
+ const sourceFile = program.getSourceFile(vf);
4373
+ if (!sourceFile) { entryData.push(null); continue; }
4374
+ const moduleSymbol = checker.getSymbolAtLocation(sourceFile);
4375
+ if (!moduleSymbol) { entryData.push({ sourceFile, exports: null }); continue; }
4376
+ const exps = checker.getExportsOfModule(moduleSymbol);
4377
+ for (const s of exps) exportedSymbols.add(s);
4378
+ entryData.push({ sourceFile, exports: exps });
4379
+ }
4380
+
4381
+ for (let idx = 0; idx < ripEntries.length; idx++) {
4382
+ const entry = ripEntries[idx];
4383
+ const data = entryData[idx];
4384
+ const rel = relative(rootPath, entry.file);
4385
+
4386
+ if (!data) {
4387
+ console.log(` ${red('error')} could not load ${cyan(rel)}`);
4388
+ continue;
4389
+ }
4390
+ const { sourceFile, exports } = data;
4391
+ if (!exports) {
4392
+ console.log(` ${dim('(no exports)')}`);
4393
+ continue;
4394
+ }
4395
+
4396
+ const header = entry.subpath === '.' ? rel : `${rel} ${dim('('+entry.subpath+')')}`;
4397
+ console.log(`\n ${cyan(header)}`);
4398
+
4399
+ // Compute max name width for column alignment.
4400
+ const names = exports.map(s => s.getName());
4401
+ const colW = Math.min(28, names.reduce((m, n) => Math.max(m, n.length), 0));
4402
+
4403
+ for (const sym of exports) {
4404
+ totalExports++;
4405
+ let t;
4406
+ if (sym.flags & ts.SymbolFlags.Value) {
4407
+ t = checker.getTypeOfSymbolAtLocation(sym, sourceFile);
4408
+ } else {
4409
+ t = checker.getDeclaredTypeOfSymbol(sym);
4410
+ }
4411
+ // Pass `sym` as the rootSymbol so recursion into OTHER exported
4412
+ // symbols stops, but recursion into the export's own self-named
4413
+ // type (e.g. typeof Class → the class itself) doesn't immediately
4414
+ // bail out.
4415
+ const leakPath = findAnyLeaks(t, ts, checker, compiled, new WeakSet(), 0, exportedSymbols, sym);
4416
+ const leaks = leakPath !== null;
4417
+ const typeStr = checker.typeToString(t, sourceFile, fmtFlags);
4418
+ const name = sym.getName();
4419
+ const mark = leaks ? red('✗') : green('✓');
4420
+ console.log(` ${mark} ${name.padEnd(colW)} ${dim(typeStr)}`);
4421
+ if (leaks) {
4422
+ totalLeaks++;
4423
+ console.log(` ${dim('└─ any at: ')}${yellow(leakPath || '<root>')}`);
4424
+ }
4425
+ }
4426
+ }
4427
+
4428
+ const typed = totalExports - totalLeaks;
4429
+ const pct = totalExports > 0 ? (100 * typed / totalExports).toFixed(1) : '100.0';
4430
+
4431
+ console.log('');
4432
+ if (totalLeaks === 0) {
4433
+ console.log(`${green('✓')} ${bold(pkgName)}: ${typed}/${totalExports} exports fully typed (${pct}%).`);
4434
+ } else {
4435
+ console.log(`${red('✗')} ${bold(pkgName)}: ${typed}/${totalExports} exports fully typed (${pct}%). ${red(String(totalLeaks))} export${totalLeaks === 1 ? '' : 's'} leak \`any\`.`);
4436
+ }
4437
+
4438
+ return totalLeaks > 0 ? 1 : 0;
2522
4439
  }