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