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