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/README.md +1 -1
- package/bin/rip +162 -10
- package/docs/AGENTS.md +1 -1
- package/docs/RIP-APP.md +109 -17
- package/docs/RIP-LANG.md +4 -5
- package/docs/RIP-TYPES.md +74 -103
- package/docs/demo/README.md +4 -3
- package/docs/dist/rip.js +933 -338
- package/docs/dist/rip.min.js +209 -204
- package/docs/dist/rip.min.js.br +0 -0
- package/docs/example/index.json +7 -7
- package/docs/example/index.json.br +0 -0
- package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
- package/docs/extensions/vscode/print/print-latest.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-0.5.15.vsix +0 -0
- package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
- package/docs/ui/bundle.json +55 -55
- package/docs/ui/bundle.json.br +0 -0
- package/docs/ui/index.html +1 -1
- package/package.json +9 -4
- package/rip-loader.js +59 -2
- package/src/AGENTS.md +5 -5
- package/src/browser.js +52 -11
- package/src/compiler.js +318 -44
- package/src/components.js +178 -39
- package/src/dts.js +62 -47
- package/src/lexer.js +58 -15
- package/src/schema/schema.js +5 -5
- package/src/typecheck.js +1355 -100
- package/src/types.js +85 -5
- /package/docs/demo/{components → routes}/_layout.rip +0 -0
- /package/docs/demo/{components → routes}/about.rip +0 -0
- /package/docs/demo/{components → routes}/card.rip +0 -0
- /package/docs/demo/{components → routes}/counter.rip +0 -0
- /package/docs/demo/{components → routes}/index.rip +0 -0
- /package/docs/demo/{components → routes}/todos.rip +0 -0
package/src/typecheck.js
CHANGED
|
@@ -12,11 +12,11 @@
|
|
|
12
12
|
|
|
13
13
|
import { Compiler, getStdlibCode } from './compiler.js';
|
|
14
14
|
import { STDLIB_TYPE_DECLS } from './stdlib.js';
|
|
15
|
-
import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN } from './dts.js';
|
|
15
|
+
import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN, ripDestructuredNames } from './dts.js';
|
|
16
16
|
import './schema/loader-server.js'; // registers full schema runtime provider
|
|
17
17
|
import { createRequire } from 'module';
|
|
18
18
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
19
|
-
import { resolve, relative, dirname, sep as pathSep } from 'path';
|
|
19
|
+
import { resolve, relative, dirname, basename, sep as pathSep } from 'path';
|
|
20
20
|
import { buildLineMap } from './sourcemaps.js';
|
|
21
21
|
|
|
22
22
|
// ── Typed stash: project entry discovery ───────────────────────────
|
|
@@ -29,12 +29,60 @@ import { buildLineMap } from './sourcemaps.js';
|
|
|
29
29
|
// `import('<rel-to-stash>').__RipStash` into their `app.data` declaration.
|
|
30
30
|
//
|
|
31
31
|
// Discovery: walk up from each file to the nearest dir that contains an
|
|
32
|
-
// `index.rip` AND a `
|
|
33
|
-
//
|
|
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, '
|
|
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 + ')
|
|
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 + '
|
|
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,
|
|
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}:${
|
|
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
|
|
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.
|
|
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.
|
|
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
|
|
973
|
-
//
|
|
974
|
-
//
|
|
975
|
-
//
|
|
976
|
-
|
|
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
|
|
1086
|
-
// the implementation body — injecting an overload
|
|
1087
|
-
|
|
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
|
|
1367
|
-
const
|
|
1368
|
-
const
|
|
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 `{
|
|
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
|
-
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
1587
|
-
|
|
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`).
|
|
1640
|
-
//
|
|
1641
|
-
//
|
|
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
|
|
1644
|
-
for (const m of
|
|
1645
|
-
|
|
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 (
|
|
1648
|
-
|
|
1649
|
-
return
|
|
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
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
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
|
|
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
|
-
|
|
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)
|
|
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
|
|
2462
|
-
//
|
|
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')
|
|
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
|
-
|
|
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) ||
|
|
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(), {
|
|
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(), {
|
|
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` (
|
|
2684
|
-
//
|
|
2685
|
-
//
|
|
2686
|
-
//
|
|
2687
|
-
//
|
|
2688
|
-
//
|
|
2689
|
-
//
|
|
2690
|
-
//
|
|
2691
|
-
//
|
|
2692
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|