rip-lang 3.16.0 → 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.
package/src/typecheck.js CHANGED
@@ -12,11 +12,11 @@
12
12
 
13
13
  import { Compiler, getStdlibCode } from './compiler.js';
14
14
  import { STDLIB_TYPE_DECLS } from './stdlib.js';
15
- import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN } from './dts.js';
15
+ import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN, ripDestructuredNames } from './dts.js';
16
16
  import './schema/loader-server.js'; // registers full schema runtime provider
17
17
  import { createRequire } from 'module';
18
18
  import { readFileSync, existsSync, readdirSync } from 'fs';
19
- import { resolve, relative, dirname, sep as pathSep } from 'path';
19
+ import { resolve, relative, dirname, basename, sep as pathSep } from 'path';
20
20
  import { buildLineMap } from './sourcemaps.js';
21
21
 
22
22
  // ── Typed stash: project entry discovery ───────────────────────────
@@ -29,12 +29,60 @@ import { buildLineMap } from './sourcemaps.js';
29
29
  // `import('<rel-to-stash>').__RipStash` into their `app.data` declaration.
30
30
  //
31
31
  // Discovery: walk up from each file to the nearest dir that contains an
32
- // `index.rip` AND a `rip.json` or `package.json` (the project anchor),
33
- // then look for `<root>/app/stash.rip`. Cached per-directory for the
34
- // process lifetime.
32
+ // `index.rip` AND a `package.json` (the project anchor), then look for
33
+ // `<root>/app/stash.rip`. Cached per-directory for the process lifetime.
35
34
  const entryFileCache = new Map(); // dir → entryFile|null
36
35
  const stashFileCache = new Map(); // root dir → stashFile|null
37
36
 
37
+ // ── Robust import extraction ───────────────────────────────────────
38
+ //
39
+ // Walk a file's `import ...` / dynamic `import(...)` specifiers via Bun's
40
+ // real parser instead of regex-scanning. This is immune to false matches
41
+ // from comments, string literals, regex bodies, and any other place a
42
+ // `from "@rip-lang/..."`-shaped sequence might appear in source.
43
+ //
44
+ // `scanText` is the compiled TS-virtual content (entry.tsContent) when
45
+ // available; we fall back to the raw .rip source for entries that
46
+ // haven't been compiled yet (the only such call site reads a freshly-
47
+ // read package file).
48
+ const _ripImportTranspiler = new Bun.Transpiler({ loader: 'ts' });
49
+ function scanRipPkgImports(scanText) {
50
+ if (!scanText) return [];
51
+ let imports;
52
+ try { imports = _ripImportTranspiler.scanImports(scanText); }
53
+ catch { return []; }
54
+ const out = [];
55
+ for (const imp of imports) {
56
+ const p = imp.path;
57
+ if (typeof p === 'string' && p.startsWith('@rip-lang/')) out.push(p);
58
+ }
59
+ return out;
60
+ }
61
+ // Type-position `import('@rip-lang/...')` import-types are erased by
62
+ // `scanImports` (they live in type space), so the parser-based scan above
63
+ // never sees them. The DTS pipeline injects exactly these — e.g.
64
+ // `declare router: import('@rip-lang/app').Router` and the `NavOpts` alias
65
+ // — so the referenced package must still be pulled into the TS
66
+ // program or its types silently resolve to `any` (e.g. `push`'s `opts`
67
+ // going unchecked). Used ONLY for package seeding, never the
68
+ // undeclared-import check: these are synthesized references, not source
69
+ // dependencies the user must declare in package.json.
70
+ const _ripImportTypeRe = /\bimport\(\s*(["'])(@rip-lang\/[^"']+)\1\s*\)/g;
71
+ function scanRipPkgImportTypes(scanText) {
72
+ if (!scanText) return [];
73
+ const out = [];
74
+ _ripImportTypeRe.lastIndex = 0;
75
+ let m;
76
+ while ((m = _ripImportTypeRe.exec(scanText))) out.push(m[2]);
77
+ return out;
78
+ }
79
+ // Extract the bare package name (`@rip-lang/foo`) from a specifier
80
+ // that may include a subpath (`@rip-lang/foo/sub/path`).
81
+ function ripPkgRoot(spec) {
82
+ const i = spec.indexOf('/', '@rip-lang/'.length);
83
+ return i === -1 ? spec : spec.slice(0, i);
84
+ }
85
+
38
86
  export function findEntryFile(filePath) {
39
87
  let dir = dirname(filePath);
40
88
  const visited = [];
@@ -45,7 +93,7 @@ export function findEntryFile(filePath) {
45
93
  return cached;
46
94
  }
47
95
  visited.push(dir);
48
- const hasAnchor = existsSync(resolve(dir, 'rip.json')) || existsSync(resolve(dir, 'package.json'));
96
+ const hasAnchor = existsSync(resolve(dir, 'package.json'));
49
97
  if (hasAnchor) {
50
98
  const entry = resolve(dir, 'index.rip');
51
99
  const result = existsSync(entry) ? entry : null;
@@ -84,6 +132,129 @@ export function findStashFile(filePath) {
84
132
  return result;
85
133
  }
86
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
+
87
258
  // ── Shared helpers ─────────────────────────────────────────────────
88
259
 
89
260
  // Detect type annotations (:: followed by space or =) ignoring comments,
@@ -219,12 +390,12 @@ export function checkComponentDefs(compProps, srcLines, startLine = 0) {
219
390
  const errors = [];
220
391
  for (const prop of compProps) {
221
392
  for (let s = startLine; s < srcLines.length; s++) {
222
- const m = new RegExp('(@' + prop.name + ')\\s*(::|([:!]?=))').exec(srcLines[s]);
393
+ const m = new RegExp('(@' + prop.name + ')\\??\\s*(::|([:!]?=))').exec(srcLines[s]);
223
394
  if (!m) continue;
224
395
  if (m[1 + 1] !== '::') {
225
396
  errors.push({ line: s, col: m.index, len: m[1].length, propName: prop.name, message: `Prop '${prop.name}' has no type annotation` });
226
397
  } else {
227
- 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*(.+)'));
228
399
  if (dm) {
229
400
  const defVal = dm[2].replace(/#.*$/, '').trim();
230
401
  const err = validatePropDefault(dm[1].trim(), defVal);
@@ -419,19 +590,30 @@ export const SKIP_CODES = new Set([
419
590
  1064, // Return type of async function must be Promise
420
591
  ]);
421
592
 
422
- // Dedup diagnostics by (start line/col, end line/col, code, message).
593
+ // Dedup diagnostics by (start line/col, code).
423
594
  // The same TS error can fire twice when the dts header and compiled body
424
595
  // both contain the offending construct (e.g. an `import { X }` line that
425
- // maps to the same source position from both copies).
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.
426
606
  //
427
607
  // `getRange(d)` must return `{ startLine, startCol, endLine, endCol }`.
428
- // Returns a new array; does not mutate the input.
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.
429
611
  export function dedupDiagnostics(diags, getRange) {
430
612
  const seen = new Set();
431
613
  const out = [];
432
614
  for (const d of diags) {
433
615
  const r = getRange(d);
434
- const key = `${r.startLine}:${r.startCol}:${r.endLine}:${r.endCol}:${d.code}:${d.message}`;
616
+ const key = `${r.startLine}:${r.startCol}:${d.code}`;
435
617
  if (seen.has(key)) continue;
436
618
  seen.add(key);
437
619
  out.push(d);
@@ -605,6 +787,116 @@ export function cleanDiagnosticMessage(msg) {
605
787
  return msg;
606
788
  }
607
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
+
608
900
  // Base TypeScript compiler settings for type-checking. Callers can
609
901
  // pass overrides (e.g. { strict: true } when a project opts in).
610
902
  //
@@ -612,8 +904,8 @@ export function cleanDiagnosticMessage(msg) {
612
904
  // ("optional, design scaffolding, not safety rails") and matches the
613
905
  // gradual-typing default of comparable systems (Sorbet's `# typed: false`,
614
906
  // mypy's permissive default, Hack's `partial`, TypeScript's own pre-strict
615
- // default). Projects opt UP to strict via rip.json's `strict: true`, which
616
- // implies noImplicitAny, strictNullChecks, and the rest of TS's strict
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
617
909
  // family. Do NOT pin those flags to `false` here — that would shadow the
618
910
  // strict-family inference when an opt-in caller passes `{ strict: true }`.
619
911
  export function createTypeCheckSettings(ts, overrides = {}) {
@@ -629,6 +921,35 @@ export function createTypeCheckSettings(ts, overrides = {}) {
629
921
  };
630
922
  }
631
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
+
632
953
  // ── Param helpers ──────────────────────────────────────────────────
633
954
 
634
955
  // Extract the text between the first balanced ( ) — handles nested parens
@@ -824,9 +1145,9 @@ function injectTypeParams(line, typeParams) {
824
1145
  // Compile a .rip file for type-checking. Prepends DTS declarations to
825
1146
  // compiled JS, detects type annotations, and builds bidirectional
826
1147
  // source maps. Returns everything both the CLI and LSP need.
827
- // 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.
828
1149
  export function compileForCheck(filePath, source, compiler, opts = {}) {
829
- 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 });
830
1151
  let code = result.code || '';
831
1152
  const dts = result.dts ? result.dts.trimEnd() + '\n' : '';
832
1153
 
@@ -838,7 +1159,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
838
1159
  // that probe is a raw-source regex that fires on `schema :input` inside
839
1160
  // heredoc string literals (e.g. test files), flooding the LSP with TS2304
840
1161
  // false positives. Schema files still get their DTS via the schema pass.
841
- const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.strict);
1162
+ const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.checkAll);
842
1163
  let importsTyped = false;
843
1164
  if (!hasOwnTypes && !nocheck) {
844
1165
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -969,11 +1290,20 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
969
1290
  // intended source. Strip the DTS line so it doesn't bleed into
970
1291
  // the wrong scope; the locals fall back to per-binding inference.
971
1292
  if (multipleLocals) { localTypedLetIdxs.add(dtsIdx); continue; }
972
- // No local site found: this is genuinely a module-scope typed
973
- // declaration (no function-local to hoist into). Leave the DTS
974
- // line in placeit's the `let name: T;` declaration the body's
975
- // top-level `name = value` needs to type-check.
976
- if (localLine < 0) continue;
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
+ }
977
1307
  // Single unambiguous match — perform the hoist.
978
1308
  cl[localLine] = cl[localLine].replace(
979
1309
  new RegExp(`(\\blet\\s[^;]*?\\b${name}\\b)(?!\\s*:)`),
@@ -1032,6 +1362,47 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1032
1362
  return depth === 0 && sig.slice(i).includes(':');
1033
1363
  }
1034
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
+
1035
1406
  // Extract the return type from a DTS signature (e.g. ": number" from
1036
1407
  // "function add(a: number, b: number): number;").
1037
1408
  function extractReturnType(sig) {
@@ -1081,10 +1452,13 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1081
1452
  }
1082
1453
  }
1083
1454
 
1084
- // Only inject overload signatures for functions with explicit return types.
1085
- // Functions without a return type annotation let TS infer the return from
1086
- // the implementation body — injecting an overload would force it to `any`.
1087
- const overloads = injections.filter(inj => hasExplicitReturn(inj.sig));
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));
1088
1462
 
1089
1463
  // Adjust reverseMap: each overload injection shifts subsequent code lines down by 1.
1090
1464
  // Compare against the original genLine (not genLine + offset) because bottom-up
@@ -1162,7 +1536,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1162
1536
  const existingFields = new Set();
1163
1537
  for (let k = j + 1; k < cl.length; k++) {
1164
1538
  if (cl[k].match(/^(?:export\s+)?(?:class|const)\s+\w+/) && k > j + 1) break;
1165
- const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;$/);
1539
+ const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;(?:\s*\/\/.*)?$/);
1166
1540
  if (fm) existingFields.add(fm[1]);
1167
1541
  // Also match field assignments (e.g. `name = __computed(...)` in component stubs)
1168
1542
  const am = cl[k].match(/^\s+(\w+)\s*=\s+/);
@@ -1336,7 +1710,16 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1336
1710
 
1337
1711
  for (let i = 0; i < dl.length; i++) {
1338
1712
  const m = dl[i].match(/^(?:export\s+)?declare\s+const\s+(\w+):\s+(.+);$/);
1339
- if (m) constTypes.set(m[1], { type: m[2], idx: i });
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 });
1340
1723
  }
1341
1724
 
1342
1725
  if (constTypes.size > 0) {
@@ -1363,9 +1746,10 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1363
1746
  // but files that import from typed modules may have untyped reactive vars whose
1364
1747
  // compiled code still references __state/__computed/__effect.
1365
1748
  if (hasTypes) {
1366
- const needSignal = /\b__state\(/.test(code) && !/\bdeclare function __state\b/.test(headerDts);
1367
- const needComputed = /\b__computed\(/.test(code) && !/\bdeclare function __computed\b/.test(headerDts);
1368
- const needEffect = /\b__effect\(/.test(code) && !/\bdeclare function __effect\b/.test(headerDts);
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');
1369
1753
  if (needSignal || needComputed || needEffect) {
1370
1754
  const decls = [];
1371
1755
  if (needSignal) {
@@ -1492,22 +1876,37 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1492
1876
  for (let i = 0; i < cl.length; i++) {
1493
1877
  const m = cl[i].match(/^(\s*)let\s+([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*;\s*$/);
1494
1878
  if (!m) continue;
1495
- // 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).
1496
1882
  let prev = null;
1497
1883
  for (let k = i - 1; k >= 0; k--) { if (cl[k].trim() !== '') { prev = cl[k]; break; } }
1498
- if (prev !== null && !/\{\s*$/.test(prev)) continue;
1884
+ if (prev !== null && !/\{\s*$/.test(prev) && !/^\s*import\b/.test(prev)) continue;
1499
1885
 
1500
1886
  const baseIndent = m[1];
1501
1887
  const vars = m[2].split(/\s*,\s*/);
1502
1888
  const inlined = new Set();
1503
1889
  const bailed = new Set();
1504
- 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
+ };
1505
1904
 
1506
1905
  // Phase 1: straight-line scan at base indent
1507
1906
  for (let j = i + 1; j < cl.length; j++) {
1508
1907
  const line = cl[j];
1509
1908
  if (line.trim() === '') continue;
1510
- if (scopeEndRe.test(line)) break;
1909
+ if (isScopeEnd(line)) break;
1511
1910
  // Skip deeper-indented lines
1512
1911
  if (line.startsWith(baseIndent + ' ')) continue;
1513
1912
  // Stop at structural statements (if/for/while/switch/try/do/function/class)
@@ -1539,7 +1938,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1539
1938
  for (let j = i + 1; j < cl.length; j++) {
1540
1939
  const line = cl[j];
1541
1940
  if (line.trim() === '') continue;
1542
- if (scopeEndRe.test(line)) break;
1941
+ if (isScopeEnd(line)) break;
1543
1942
  if (!vRe.test(line)) continue;
1544
1943
  if (firstRefLine < 0) firstRefLine = j;
1545
1944
  if (!foundAssign) {
@@ -1560,7 +1959,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1560
1959
  for (let j = foundAssign.line + 1; j < cl.length; j++) {
1561
1960
  const line = cl[j];
1562
1961
  if (line.trim() === '') continue;
1563
- if (scopeEndRe.test(line)) { blockEndLine = j; break; }
1962
+ if (isScopeEnd(line)) { blockEndLine = j; break; }
1564
1963
  const li = line.match(/^(\s*)/)[1];
1565
1964
  if (li.length < foundAssign.indent.length) { blockEndLine = j; break; }
1566
1965
  }
@@ -1571,7 +1970,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1571
1970
  for (let j = blockEndLine + 1; j < cl.length; j++) {
1572
1971
  const line = cl[j];
1573
1972
  if (line.trim() === '') continue;
1574
- if (scopeEndRe.test(line)) break;
1973
+ if (isScopeEnd(line)) break;
1575
1974
  if (vRe.test(line)) { hasRefAfterBlock = true; break; }
1576
1975
  }
1577
1976
  }
@@ -1583,8 +1982,23 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1583
1982
  }
1584
1983
 
1585
1984
  const remaining = vars.filter(v => !inlined.has(v));
1586
- if (remaining.length) cl[i] = `${baseIndent}let ${remaining.join(', ')};`;
1587
- 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(', ')};` : '';
1588
2002
  }
1589
2003
  code = cl.join('\n');
1590
2004
 
@@ -1633,20 +2047,147 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1633
2047
  if (typedApp) code = code.replace(/declare app: any/g, typedApp);
1634
2048
  }
1635
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
+
1636
2170
  // Dedupe imports: when the DTS header and the body import from the same
1637
2171
  // module specifier, TypeScript reports TS2300 (Duplicate identifier) for
1638
2172
  // every shared binding, which cascades and corrupts type resolution
1639
- // elsewhere (e.g. `typeof <stateIdent>` collapses to `any`). The DTS-side
1640
- // import is sufficient for type-checking; blank out matching body imports
1641
- // (preserve line count so source maps stay aligned).
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.
1642
2183
  if (hasTypes && headerDts && code) {
1643
- const dtsSpecs = new Set();
1644
- for (const m of headerDts.matchAll(/^\s*import\s+[^;]*?from\s+['"]([^'"]+)['"]\s*;?\s*$/gm)) {
1645
- dtsSpecs.add(m[1]);
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]);
1646
2187
  }
1647
- if (dtsSpecs.size > 0) {
1648
- code = code.replace(/^(\s*)import\s+[^;]*?from\s+(['"])([^'"]+)\2\s*;?\s*$/gm, (full, _ws, _q, spec) => {
1649
- return dtsSpecs.has(spec) ? '' : full;
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;
1650
2191
  });
1651
2192
  }
1652
2193
  }
@@ -1732,10 +2273,16 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1732
2273
  const genGap = genB - genA;
1733
2274
  if (srcGap > 1 && genGap > 1 && srcGap <= genGap + 2) {
1734
2275
  for (let d = 1; d < srcGap; d++) {
1735
- if (!srcToGen.has(srcA + d) && genA + d < genB) {
1736
- srcToGen.set(srcA + d, genA + d);
1737
- if (!genToSrc.has(genA + d)) {
1738
- genToSrc.set(genA + d, srcA + d);
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);
1739
2286
  }
1740
2287
  }
1741
2288
  }
@@ -1890,13 +2437,27 @@ export function findNearestWord(text, word, approx) {
1890
2437
 
1891
2438
  // Check whether an offset falls on an injected function overload signature line
1892
2439
  // (generated by compileForCheck, not from user code). These are body lines that
1893
- // 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.
1894
2445
  export function isInjectedOverload(entry, offset) {
1895
2446
  const tsLine = offsetToLine(entry.tsContent, offset);
1896
2447
  if (tsLine < entry.headerLines) return false;
1897
- if (entry.genToSrc.get(tsLine) !== undefined) return false;
1898
2448
  const lineText = getLineText(entry.tsContent, tsLine);
1899
- 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;
1900
2461
  }
1901
2462
 
1902
2463
  export function offsetToLine(text, offset) {
@@ -2162,6 +2723,39 @@ export function mapToSourcePos(entry, offset) {
2162
2723
  }
2163
2724
  }
2164
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
+ }
2165
2759
  // Text-match: find the word at genCol in the gen line, then locate it in the source line
2166
2760
  if (srcText) {
2167
2761
  let wordAt = genText.slice(genCol).match(/^\w+/);
@@ -2270,6 +2864,90 @@ export function mapToSourcePos(entry, offset) {
2270
2864
  return { line: srcLine, col: srcCol };
2271
2865
  }
2272
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
+
2273
2951
  // Map a Rip source (line, col) to a TypeScript virtual file byte offset.
2274
2952
  // This is the forward direction: source → generated (used for hover, definition, etc.)
2275
2953
  //
@@ -2285,6 +2963,17 @@ export function srcToOffset(entry, line, col) {
2285
2963
  if (entry.srcColToGen) {
2286
2964
  const colEntries = entry.srcColToGen.get(line);
2287
2965
  if (colEntries && colEntries.length > 0) {
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 {
2288
2977
  // When srcToGen anchors this source line to a specific genLine, only
2289
2978
  // consider colEntries on that same genLine. Stray entries on other
2290
2979
  // genLines (caused by upstream sub-mapping contamination across
@@ -2307,6 +2996,7 @@ export function srcToOffset(entry, line, col) {
2307
2996
  genLine = best.genLine;
2308
2997
  genColHint = best.genCol;
2309
2998
  bestSrcCol = best.srcCol;
2999
+ }
2310
3000
  }
2311
3001
  }
2312
3002
 
@@ -2367,7 +3057,17 @@ export function srcToOffset(entry, line, col) {
2367
3057
  const dist = Math.abs(m.index - expectedGenCol) + (inStr ? STRING_PENALTY : 0);
2368
3058
  if (dist < bestDist) { bestDist = dist; bestCol = m.index; }
2369
3059
  }
2370
- 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
+ }
2371
3071
 
2372
3072
  // Fall back to original genLine if overload didn't match
2373
3073
  if (targetLine !== genLine) {
@@ -2458,23 +3158,32 @@ export function mapToSource(entry, offset) {
2458
3158
 
2459
3159
  // ── Project config ─────────────────────────────────────────────────
2460
3160
 
2461
- // Read project config: rip.json in the given directory, or "rip" key in
2462
- // 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`.
2463
3168
  export function readProjectConfig(dir) {
2464
3169
  const config = {};
2465
3170
  try {
2466
3171
  let d = resolve(dir);
2467
3172
  while (true) {
2468
- const ripJsonPath = resolve(d, 'rip.json');
2469
- if (existsSync(ripJsonPath)) {
2470
- Object.assign(config, JSON.parse(readFileSync(ripJsonPath, 'utf8')));
2471
- config._configDir = d;
2472
- break;
2473
- }
2474
3173
  const pkgPath = resolve(d, 'package.json');
2475
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.
2476
3183
  const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
2477
- 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;
2478
3187
  }
2479
3188
  const parent = dirname(d);
2480
3189
  if (parent === d) break;
@@ -2532,6 +3241,7 @@ function findRipFiles(dir, files = [], excludePatterns = [], rootDir = dir) {
2532
3241
 
2533
3242
  const isColor = process.stdout.isTTY !== false;
2534
3243
  const red = (s) => isColor ? `\x1b[31m${s}\x1b[0m` : s;
3244
+ const green = (s) => isColor ? `\x1b[32m${s}\x1b[0m` : s;
2535
3245
  const yellow = (s) => isColor ? `\x1b[33m${s}\x1b[0m` : s;
2536
3246
  const cyan = (s) => isColor ? `\x1b[36m${s}\x1b[0m` : s;
2537
3247
  const dim = (s) => isColor ? `\x1b[2m${s}\x1b[0m` : s;
@@ -2557,9 +3267,8 @@ export async function runCheck(targetDir, opts = {}) {
2557
3267
  }
2558
3268
 
2559
3269
  const ripConfig = readProjectConfig(rootPath);
2560
-
2561
- // Merge: CLI flags override config file
2562
- const strict = opts.strict || ripConfig.strict === true;
3270
+ const strict = ripConfig.strict === true;
3271
+ const checkAll = ripConfig.checkAll === true;
2563
3272
  const excludeGlobs = Array.isArray(ripConfig.exclude) ? ripConfig.exclude : [];
2564
3273
  const excludePatterns = excludeGlobs.map(globToRegex);
2565
3274
 
@@ -2577,7 +3286,7 @@ export async function runCheck(targetDir, opts = {}) {
2577
3286
  const source = readFileSync(fp, 'utf8');
2578
3287
  sourcesByPath.set(fp, source);
2579
3288
  const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
2580
- if (!nocheck && (hasTypeAnnotations(source) || strict)) typedFiles.add(fp);
3289
+ if (!nocheck && (hasTypeAnnotations(source) || checkAll)) typedFiles.add(fp);
2581
3290
  }
2582
3291
 
2583
3292
  // Include imports of typed files (files imported BY typed files)
@@ -2613,7 +3322,7 @@ export async function runCheck(targetDir, opts = {}) {
2613
3322
  for (const fp of typedFiles) {
2614
3323
  try {
2615
3324
  const source = sourcesByPath.get(fp);
2616
- compiled.set(fp, compileForCheck(fp, source, new Compiler(), { strict }));
3325
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { checkAll }));
2617
3326
  } catch (e) {
2618
3327
  compileErrors++;
2619
3328
  const rel = relative(rootPath, fp);
@@ -2633,7 +3342,7 @@ export async function runCheck(targetDir, opts = {}) {
2633
3342
  if (compiled.has(stashFile) || !existsSync(stashFile)) continue;
2634
3343
  try {
2635
3344
  const src = sourcesByPath.get(stashFile) ?? readFileSync(stashFile, 'utf8');
2636
- const compiledStash = compileForCheck(stashFile, src, new Compiler(), { strict });
3345
+ const compiledStash = compileForCheck(stashFile, src, new Compiler(), { checkAll });
2637
3346
  compiledStash._typeOnly = true; // skip diagnostics — only here for cross-module types
2638
3347
  compiled.set(stashFile, compiledStash);
2639
3348
  } catch (e) {
@@ -2641,6 +3350,30 @@ export async function runCheck(targetDir, opts = {}) {
2641
3350
  }
2642
3351
  }
2643
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
+
2644
3377
  // Also compile any .rip files imported from typed files that aren't yet compiled
2645
3378
  for (const [fp, entry] of [...compiled.entries()]) {
2646
3379
  if (!entry.hasTypes) continue;
@@ -2658,6 +3391,92 @@ export async function runCheck(targetDir, opts = {}) {
2658
3391
  }
2659
3392
  }
2660
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
+
2661
3480
  // Check for unresolved relative imports in all files (not just typed ones)
2662
3481
  const fileResults = [];
2663
3482
  let totalErrors = 0, totalWarnings = 0;
@@ -2680,16 +3499,25 @@ export async function runCheck(targetDir, opts = {}) {
2680
3499
 
2681
3500
  // Create TypeScript language service
2682
3501
  //
2683
- // Project-scope `strict` (rip.json / package.json `rip.strict: true`, or
2684
- // CLI --strict) opts the project UP to TypeScript's `strict` family,
2685
- // which implies noImplicitAny, strictNullChecks, strictFunctionTypes,
2686
- // and friends. With strict on: `T` excludes null/undefined, untyped
2687
- // params error, etc. Without strict: lenient gradual-typing defaults —
2688
- // annotations are accepted as documentation but not enforced as
2689
- // contracts. The default is lenient to match Rip's "scaffolding, not
2690
- // safety rails" philosophy; projects opt up when they want the
2691
- // contract enforced.
2692
- const settings = createTypeCheckSettings(ts, strict ? { strict: true } : {});
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
+ });
2693
3521
 
2694
3522
  const host = {
2695
3523
  getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
@@ -2708,6 +3536,14 @@ export async function runCheck(targetDir, opts = {}) {
2708
3536
  getDirectories: (...a) => ts.sys.getDirectories(...a),
2709
3537
  directoryExists: (...a) => ts.sys.directoryExists(...a),
2710
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
+
2711
3547
  resolveModuleNames(names, containingFile) {
2712
3548
  return names.map((name) => {
2713
3549
  if (name.endsWith('.rip')) {
@@ -2716,6 +3552,12 @@ export async function runCheck(targetDir, opts = {}) {
2716
3552
  return { resolvedFileName: toVirtual(resolved), extension: '.ts', isExternalLibraryImport: false };
2717
3553
  }
2718
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
+ }
2719
3561
  const r = ts.resolveModuleName(name, containingFile, settings, {
2720
3562
  fileExists: host.fileExists,
2721
3563
  readFile: host.readFile,
@@ -2783,12 +3625,18 @@ export async function runCheck(targetDir, opts = {}) {
2783
3625
  if (adj) { pos.line = adj.line; pos.col = adj.col; }
2784
3626
 
2785
3627
  const endPos = adj ? { line: adj.line, col: adj.col + adj.len } : (d.length ? mapToSourcePos(entry, d.start + d.length) : null);
2786
- const len = endPos && endPos.line === pos.line ? endPos.col - pos.col : 1;
3628
+ let len = endPos && endPos.line === pos.line ? endPos.col - pos.col : 1;
2787
3629
 
2788
3630
  const message = cleanDiagnosticMessage(ts.flattenDiagnosticMessageText(d.messageText, '\n'));
2789
3631
  const severity = d.category === 1 ? 'error' : d.category === 0 ? 'warning' : 'info';
2790
3632
  const srcLine = srcLines[pos.line] || '';
2791
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
+
2792
3640
  // Collect related information
2793
3641
  const related = [];
2794
3642
  if (d.relatedInformation) {
@@ -2835,7 +3683,7 @@ export async function runCheck(targetDir, opts = {}) {
2835
3683
  }
2836
3684
  }
2837
3685
 
2838
- 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 });
2839
3687
  if (severity === 'error') totalErrors++;
2840
3688
  else if (severity === 'warning') totalWarnings++;
2841
3689
  }
@@ -2942,6 +3790,12 @@ export async function runCheck(targetDir, opts = {}) {
2942
3790
  // round-trip: srcToOffset must resolve, and getQuickInfoAtPosition must
2943
3791
  // return hover info for it. Failures indicate source map gaps that make
2944
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).
2945
3799
 
2946
3800
  const AUDIT_SKIP = new Set([
2947
3801
  'if', 'else', 'then', 'unless', 'switch', 'when', 'for', 'while', 'until',
@@ -3009,7 +3863,7 @@ export async function runCheck(targetDir, opts = {}) {
3009
3863
  let auditGaps = 0;
3010
3864
  const auditResults = [];
3011
3865
 
3012
- for (const [fp, entry] of compiled) {
3866
+ if (opts.sourceMapAudit) for (const [fp, entry] of compiled) {
3013
3867
  if (!entry.hasTypes) continue;
3014
3868
  if (entry._typeOnly) continue;
3015
3869
  const srcLines = entry.source.split('\n');
@@ -3106,19 +3960,6 @@ export async function runCheck(targetDir, opts = {}) {
3106
3960
  }
3107
3961
  }
3108
3962
 
3109
- // Print audit results
3110
- if (auditResults.length > 0) {
3111
- console.log(bold('\n── Source Map Audit ──\n'));
3112
- for (const { file, gaps } of auditResults) {
3113
- const rel = relative(rootPath, file);
3114
- for (const g of gaps) {
3115
- const loc = `${cyan(rel)}${dim(':')}${yellow(String(g.line))}${dim(':')}${yellow(String(g.col))}`;
3116
- console.log(`${loc} ${dim('-')} ${yellow('warning')} ${dim('audit:')} ${g.issue} for '${g.word}'`);
3117
- }
3118
- }
3119
- console.log(`\n${yellow(String(auditGaps))} source map gap${auditGaps === 1 ? '' : 's'} found\n`);
3120
- }
3121
-
3122
3963
  // Print results — tsc format with Rip source positions
3123
3964
  for (const { file, errors } of fileResults) {
3124
3965
  const rel = relative(rootPath, file);
@@ -3158,7 +3999,8 @@ export async function runCheck(targetDir, opts = {}) {
3158
3999
  // Summary — tsc format
3159
4000
  const totalFound = totalErrors + totalWarnings;
3160
4001
  if (totalFound === 0) {
3161
- return compileErrors > 0 ? 1 : 0;
4002
+ printSourceMapAudit();
4003
+ return compileErrors > 0 || undeclaredCount > 0 ? 1 : 0;
3162
4004
  }
3163
4005
 
3164
4006
  const s = totalFound === 1 ? '' : 's';
@@ -3180,5 +4022,418 @@ export async function runCheck(targetDir, opts = {}) {
3180
4022
  console.log('');
3181
4023
  }
3182
4024
 
3183
- 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;
3184
4439
  }