rip-lang 3.16.1 → 3.16.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.md +2 -3
  2. package/bin/rip +39 -8
  3. package/bin/rip-schema +175 -0
  4. package/docs/RIP-APP.md +91 -2
  5. package/docs/RIP-DUCKDB.md +64 -1
  6. package/docs/RIP-INTRO.md +4 -4
  7. package/docs/RIP-LANG.md +32 -33
  8. package/docs/RIP-SCHEMA.md +1204 -364
  9. package/docs/dist/rip.js +3245 -611
  10. package/docs/dist/rip.min.js +1161 -289
  11. package/docs/dist/rip.min.js.br +0 -0
  12. package/docs/extensions/vscode/print/print-1.0.14.vsix +0 -0
  13. package/docs/extensions/vscode/print/print-latest.vsix +0 -0
  14. package/docs/extensions/vscode/rip/index.html +2 -1
  15. package/docs/extensions/vscode/rip/rip-latest.vsix +0 -0
  16. package/docs/extensions/vscode/rip/vscode-rip-0.6.0.vsix +0 -0
  17. package/docs/index.html +1 -1
  18. package/docs/ui/hljs-rip.js +1 -1
  19. package/package.json +7 -4
  20. package/src/AGENTS.md +39 -8
  21. package/src/compiler.js +220 -36
  22. package/src/components.js +315 -14
  23. package/src/dts.js +18 -3
  24. package/src/grammar/README.md +29 -170
  25. package/src/grammar/grammar.rip +17 -12
  26. package/src/grammar/solar.rip +4 -17
  27. package/src/lexer.js +24 -17
  28. package/src/parser.js +229 -229
  29. package/src/schema/dts.js +328 -54
  30. package/src/schema/loader-server.js +2 -1
  31. package/src/schema/runtime-browser-stubs.js +20 -9
  32. package/src/schema/runtime-ddl.js +161 -44
  33. package/src/schema/runtime-migrate.js +681 -0
  34. package/src/schema/runtime-orm.js +698 -54
  35. package/src/schema/runtime-validate.js +808 -24
  36. package/src/schema/runtime.generated.js +2395 -135
  37. package/src/schema/schema.js +1049 -89
  38. package/src/typecheck.js +283 -55
  39. package/src/types.js +5 -1
  40. package/src/grammar/lunar.rip +0 -2412
package/src/typecheck.js CHANGED
@@ -132,6 +132,110 @@ export function findStashFile(filePath) {
132
132
  return result;
133
133
  }
134
134
 
135
+ // ── Static gate-path validation ────────────────────────────────────
136
+ //
137
+ // `x <~ @app.data.path` requires the path to resolve to a source key —
138
+ // the renderer enforces this with a deterministic mount-time error, and
139
+ // `rip check` mirrors it at compile time by reading the stash module's
140
+ // s-expressions: collect module-level bindings, find the `stash` object
141
+ // literal, and classify each gate path's keys. The analysis is
142
+ // CONSERVATIVE: only paths that provably land on a plain key (or on no
143
+ // key) error; anything whose source-ness isn't statically visible —
144
+ // imported cells, factory calls, spreads — stays silent and the mount
145
+ // check backstops it. False positives are the failure mode to avoid;
146
+ // false negatives just fall back to today's runtime error.
147
+
148
+ function sexprName(v) {
149
+ return (typeof v === 'string' || v instanceof String) ? v.valueOf() : null;
150
+ }
151
+
152
+ // Walk a stash module's program s-expression into { stashObj, bindings }:
153
+ // the `stash` object literal plus every module-level `name = value`
154
+ // binding (so `orders: ordersSource` can chase `ordersSource = source …`).
155
+ export function collectStashAnalysis(sexpr) {
156
+ if (!Array.isArray(sexpr) || sexpr[0] !== 'program') return null;
157
+ const bindings = new Map();
158
+ for (let stmt of sexpr.slice(1)) {
159
+ if (!Array.isArray(stmt)) continue;
160
+ if (stmt[0] === 'export' && Array.isArray(stmt[1])) stmt = stmt[1];
161
+ if (stmt[0] === '=' || stmt[0] === 'readonly') {
162
+ const name = sexprName(stmt[1]);
163
+ if (name) bindings.set(name, stmt[2]);
164
+ }
165
+ }
166
+ const stashObj = bindings.get('stash');
167
+ if (!Array.isArray(stashObj) || sexprName(stashObj[0]) !== 'object') return null;
168
+ return { stashObj, bindings };
169
+ }
170
+
171
+ // 'source' | 'plain' | 'unknown' | { kind: 'object', node } — what a stash
172
+ // value provably is. Identifier references chase module-level bindings.
173
+ function classifyStashValue(node, bindings, depth = 0) {
174
+ if (depth > 8) return 'unknown';
175
+ const name = sexprName(node);
176
+ if (name != null) {
177
+ if (/^["'\d]/.test(name) || name === 'true' || name === 'false' ||
178
+ name === 'null' || name === 'undefined') return 'plain';
179
+ if (bindings.has(name)) return classifyStashValue(bindings.get(name), bindings, depth + 1);
180
+ return 'unknown'; // imported or otherwise invisible
181
+ }
182
+ if (!Array.isArray(node)) return 'unknown';
183
+ const head = sexprName(node[0]);
184
+ if (head === 'source') return 'source'; // application of the source() declarator
185
+ if (head === 'object') return { kind: 'object', node };
186
+ if (head === 'array') return 'plain';
187
+ return 'unknown';
188
+ }
189
+
190
+ // Shared by `rip check` and the LSP: turn a file's hoisted gates into
191
+ // diagnostic records against the stash analysis. Lines/cols are 1-based
192
+ // (the LSP converts). Only provably-wrong paths produce records — see
193
+ // validateGatePath below for the conservative verdict rules.
194
+ export function collectGateDiagnostics(gates, source, analysis) {
195
+ const out = [];
196
+ if (!gates || !gates.length || !analysis) return out;
197
+ const srcLines = source.split('\n');
198
+ for (const g of gates) {
199
+ const verdict = validateGatePath(g.path, analysis);
200
+ if (verdict !== 'plain' && verdict !== 'missing') continue;
201
+ const lineText = srcLines[g.line - 1] ?? '';
202
+ const idx = lineText.indexOf('@app.data');
203
+ const detail = verdict === 'missing'
204
+ ? `'${g.path}' is not a key of the stash`
205
+ : `'${g.path}' is a plain key`;
206
+ out.push({
207
+ line: g.line,
208
+ col: (idx >= 0 ? idx : 0) + 1,
209
+ len: idx >= 0 ? '@app.data.'.length + String(g.path).length : Math.max(lineText.trim().length, 1),
210
+ message: `'${g.path} <~' does not resolve to a source (${detail}) — declare it with source() in app/stash.rip`,
211
+ srcLine: lineText,
212
+ });
213
+ }
214
+ return out;
215
+ }
216
+
217
+ // Verdict for one gate path against the stash literal:
218
+ // 'source' (ok), 'unknown' (silent), 'plain' / 'missing' (error).
219
+ // Walks segments through plain object literals; stops at the nearest
220
+ // source on the path (subpath gates bind under the loaded value).
221
+ export function validateGatePath(path, analysis) {
222
+ let node = analysis.stashObj;
223
+ for (const seg of String(path).split('.')) {
224
+ const entries = new Map();
225
+ let hasOpaqueEntries = false;
226
+ for (const e of node.slice(1)) {
227
+ const key = Array.isArray(e) && sexprName(e[0]) === ':' ? sexprName(e[1]) : null;
228
+ if (key != null) entries.set(key, e[2]);
229
+ else hasOpaqueEntries = true; // spread / computed key — can't enumerate
230
+ }
231
+ if (!entries.has(seg)) return hasOpaqueEntries ? 'unknown' : 'missing';
232
+ const c = classifyStashValue(entries.get(seg), analysis.bindings);
233
+ if (c === 'source' || c === 'unknown' || c === 'plain') return c;
234
+ node = c.node; // plain object literal — walk deeper
235
+ }
236
+ return 'plain'; // path exhausted inside plain literals, no source found
237
+ }
238
+
135
239
  // ── Route tree discovery ───────────────────────────────────────────
136
240
  //
137
241
  // The project's routes live under `<projectRoot>/app/routes/` — a fixed
@@ -192,7 +296,10 @@ export function walkRoutesDir(routesDir) {
192
296
  // Skip _-prefixed files (_layout.rip etc.) and dirs (shared
193
297
  // helpers, not pages). Same rule as runtime buildRoutes.
194
298
  if (e.name.startsWith('_')) continue;
195
- if (e.isDirectory()) walk(resolve(dir, e.name), [...segs, e.name]);
299
+ // Pathless route groups: a `(name)` directory organizes routes and
300
+ // shares a _layout.rip without contributing a URL segment. Recurse
301
+ // but don't add it to the path. Mirrors runtime fileToPattern.
302
+ if (e.isDirectory()) walk(resolve(dir, e.name), /^\(.+\)$/.test(e.name) ? segs : [...segs, e.name]);
196
303
  else if (e.isFile() && e.name.endsWith('.rip')) {
197
304
  const base = e.name.slice(0, -'.rip'.length);
198
305
  const fileSegs = base === 'index' ? segs : [...segs, base];
@@ -927,26 +1034,36 @@ export function createTypeCheckSettings(ts, overrides = {}) {
927
1034
  // sub-package check would miss workspace-root ambients. Accepts symlinks
928
1035
  // because bun's nested-package layout symlinks `@types/bun` to
929
1036
  // `.bun/@types+bun@.../node_modules/@types/bun`.
1037
+ //
1038
+ // rip targets the Bun runtime, so `Bun`, `process`, `Buffer`, etc. are
1039
+ // always present — rip ships `@types/bun` as a dependency and includes
1040
+ // its own `node_modules/@types` last, so every project type-checks those
1041
+ // globals without installing anything. A workspace that installs its own
1042
+ // `@types/bun`/`@types/node` still wins: its dir is walked first and the
1043
+ // name dedup below keeps that copy.
930
1044
  export function collectAmbientTypes(rootPath) {
931
1045
  const typeRoots = [];
932
1046
  const types = [];
1047
+ const scan = (cand) => {
1048
+ if (typeRoots.includes(cand) || !existsSync(cand)) return;
1049
+ typeRoots.push(cand);
1050
+ try {
1051
+ for (const entry of readdirSync(cand, { withFileTypes: true })) {
1052
+ if ((entry.isDirectory() || entry.isSymbolicLink()) && !entry.name.startsWith('.') && !types.includes(entry.name)) {
1053
+ types.push(entry.name);
1054
+ }
1055
+ }
1056
+ } catch {}
1057
+ };
933
1058
  let dir = rootPath;
934
1059
  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
- }
1060
+ scan(resolve(dir, 'node_modules/@types'));
946
1061
  const parent = dirname(dir);
947
1062
  if (parent === dir) break;
948
1063
  dir = parent;
949
1064
  }
1065
+ // rip's own bundled ambients (this file lives at <rip-lang>/src/typecheck.js).
1066
+ scan(resolve(import.meta.dirname, '../node_modules/@types'));
950
1067
  return { typeRoots, types };
951
1068
  }
952
1069
 
@@ -1149,17 +1266,55 @@ function injectTypeParams(line, typeParams) {
1149
1266
  export function compileForCheck(filePath, source, compiler, opts = {}) {
1150
1267
  const result = compiler.compile(source, { sourceMap: true, types: 'emit', skipPreamble: true, stubComponents: true, inlineTypes: true });
1151
1268
  let code = result.code || '';
1152
- const dts = result.dts ? result.dts.trimEnd() + '\n' : '';
1269
+ let dts = result.dts ? result.dts.trimEnd() + '\n' : '';
1270
+
1271
+ // ── Schema shadow reconciliation ──────────────────────────────────────
1272
+ // The compiled body keeps its runtime `const Name = __schema({...})`
1273
+ // bindings verbatim, so source maps stay exact. But in a `.ts` shadow that
1274
+ // body references an undeclared `__schema` (skipPreamble drops the runtime
1275
+ // import) and would also collide with the dts `declare const Name`. Rewrite
1276
+ // each schema `const` declaration into a `__schema` overload keyed on the
1277
+ // schema's `name` literal, so the body's `__schema({name:"Name",...})` call
1278
+ // resolves to the precise Schema/ModelSchema type with no duplicate binding.
1279
+ // The `type` aliases (NameValue/NameData/NameInstance) are kept untouched —
1280
+ // importers and the body both reference them.
1281
+ let usesSchemas = false;
1282
+ if (dts) {
1283
+ const overloads = [];
1284
+ const kept = [];
1285
+ for (const line of dts.split('\n')) {
1286
+ const m = line.match(/^(?:export )?declare const (\w+): (.+);\s*$/);
1287
+ if (m && /^(?:Schema<|ModelSchema<|\{ parse\()/.test(m[2])) {
1288
+ usesSchemas = true;
1289
+ overloads.push(`declare function __schema(d: { name: "${m[1]}"; [k: string]: any }): ${m[2]};`);
1290
+ } else {
1291
+ // Anonymous-schema overloads are emitted directly by the dts pass
1292
+ // (keyed on the descriptor's `__anon` marker); keep them, but they
1293
+ // still mark the file as schema-using so the `(d: any) => any`
1294
+ // fallback and registry declares are appended.
1295
+ if (/^declare function __schema\(/.test(line)) usesSchemas = true;
1296
+ kept.push(line);
1297
+ }
1298
+ }
1299
+ if (usesSchemas) {
1300
+ overloads.push('declare function __schema(d: any): any;');
1301
+ overloads.push('declare const SchemaError: any;');
1302
+ overloads.push('declare const __SchemaRegistry: any;');
1303
+ overloads.push('declare const __schemaSetAdapter: any;');
1304
+ dts = kept.join('\n').trimEnd() + '\n' + overloads.join('\n') + '\n';
1305
+ }
1306
+ }
1153
1307
 
1154
1308
  // Determine if this file should be type-checked.
1155
1309
  // A `# @nocheck` comment near the top of the file opts out entirely.
1156
1310
  // In strict mode, all non-nocheck files are type-checked.
1157
1311
  const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
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);
1312
+ // A file that declares schemas has an exportable type surface (its
1313
+ // `NameValue`/`NameInstance` aliases), so it must be checked even without
1314
+ // explicit `::`/`type` annotations otherwise importers can't resolve those
1315
+ // names. `usesSchemas` is derived from the compiled dts, not a raw-source
1316
+ // regex, so it never false-fires on `schema` inside heredoc literals.
1317
+ const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.checkAll || usesSchemas);
1163
1318
  let importsTyped = false;
1164
1319
  if (!hasOwnTypes && !nocheck) {
1165
1320
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -1763,6 +1918,13 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
1763
1918
  if (needEffect) decls.push(EFFECT_FN);
1764
1919
  headerDts = decls.join('\n') + '\n' + headerDts;
1765
1920
  }
1921
+ // Gated bindings (`x <~ @app.data.x`) stub as
1922
+ // `__computed(() => __ripGate(this.app.data.x))`. __ripGate is the
1923
+ // generic narrow — soundness is supplied by the runtime gate, which
1924
+ // loads the source before the component is constructed.
1925
+ if (/\b__ripGate\(/.test(code) && !/\bdeclare function __ripGate\b/.test(headerDts)) {
1926
+ headerDts = 'declare function __ripGate<T>(v: T | null | undefined): T;\n' + headerDts;
1927
+ }
1766
1928
  }
1767
1929
 
1768
1930
  // Inject declarations for Rip's stdlib globals (abort, assert, p, sleep, etc.)
@@ -2022,19 +2184,28 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
2022
2184
  const isStash = stashFile && stashFile === filePath;
2023
2185
 
2024
2186
  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 =');
2187
+ // For an ANNOTATED stash (`stash:: T = ...`) the DTS header hoists
2188
+ // `export let stash: <Type>;` so the type is visible everywhere. The
2189
+ // body emits either `let stash; ... stash = {...}` (no export) or
2190
+ // `export const stash = {...}` (with export). Both conflict with the
2191
+ // typed hoist TS sees a redeclaration and the un-annotated body
2192
+ // wins, collapsing the inferred type to `{ items: never[], ... }`.
2193
+ // Rewrite both forms into a bare assignment to the already-declared
2194
+ // `stash`, preserving the contextual type.
2195
+ //
2196
+ // An UNANNOTATED stash (`export stash = ...`, the inference path —
2197
+ // source() keys carry their own types) hoists nothing, so the
2198
+ // body declaration must stay: removing it left `typeof stash` with no
2199
+ // `stash` at all.
2200
+ const stashHoisted = /(^|\n)\s*export\s+(let|const|var)\s+stash\s*:/.test(headerDts || '');
2201
+ if (stashHoisted) {
2202
+ const letRe = /^(\s*let\s+)([^;=]+);/m;
2203
+ code = code.replace(letRe, (full, prefix, names) => {
2204
+ const remaining = names.split(',').map(s => s.trim()).filter(n => n !== 'stash');
2205
+ return remaining.length ? `${prefix}${remaining.join(', ')};` : '';
2206
+ });
2207
+ code = code.replace(/^(\s*)export\s+const\s+stash\s*=/m, '$1stash =');
2208
+ }
2038
2209
  code += `\nexport type __RipStash = typeof stash;\n`;
2039
2210
  }
2040
2211
 
@@ -2042,7 +2213,10 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
2042
2213
  let typedApp = null;
2043
2214
  if (stashFile && !isStash) {
2044
2215
  const spec = entryImportSpec(filePath, stashFile);
2045
- typedApp = `declare app: { data: import('${spec}').__RipStash; components: any; routes: any; params: any; query: any; router: any }`;
2216
+ // data intersects the stash shape with the reserved stash methods
2217
+ // (inc/dec/…, peek/reset, and the source handle) so they carry
2218
+ // signatures and completion in typed projects.
2219
+ typedApp = `declare app: { data: import('${spec}').__RipStash & import('@rip-lang/app').StashMethods<import('${spec}').__RipStash>; components: any; routes: any; params: any; query: any; router: any }`;
2046
2220
  }
2047
2221
  if (typedApp) code = code.replace(/declare app: any/g, typedApp);
2048
2222
  }
@@ -2150,16 +2324,14 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
2150
2324
  );
2151
2325
  }
2152
2326
 
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.
2327
+ // (b) Route-check router.push / router.replace the same way as <a href>:
2328
+ // slash-prefixed string LITERALS must be a known route (typos caught, clean
2329
+ // route-list errors); dynamic strings and external URLs fall through. Build
2330
+ // query/hash URLs as `string` values rather than slash-prefixed literals.
2331
+ // Omit + re-add instead of intersection: intersecting overloaded methods
2332
+ // unions the parameter type (contravariance), which loses the narrowing.
2161
2333
  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; }`;
2334
+ const typedRouter = `declare router: Omit<import('@rip-lang/app').Router, 'push' | 'replace'> & { push<const P extends string>(url: P extends \`/\${string}\` ? ${inlineRoutesUnion} : P, opts?: NavOpts): void; replace<const U extends string>(url: U extends \`/\${string}\` ? ${inlineRoutesUnion} : U, opts?: NavOpts): void; }`;
2163
2335
  code = code.replace(
2164
2336
  /declare router: import\('@rip-lang\/app'\)\.Router(?![ &])/g,
2165
2337
  typedRouter,
@@ -2360,7 +2532,7 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
2360
2532
  }
2361
2533
  }
2362
2534
 
2363
- return { tsContent, headerLines, hasTypes, srcToGen, genToSrc, srcColToGen, source, dts };
2535
+ return { tsContent, headerLines, hasTypes, srcToGen, genToSrc, srcColToGen, source, dts, sexpr: result.sexpr, gates: result.gates || [] };
2364
2536
  }
2365
2537
 
2366
2538
  // ── Source-position mapping helpers ────────────────────────────────
@@ -2948,6 +3120,38 @@ export function retargetObjectArgOffset(entry, srcLine, srcCol, genOffset) {
2948
3120
  return genOffset - lc.col + i;
2949
3121
  }
2950
3122
 
3123
+ // Completion-offset fixup for object-literal property *values* whose owning
3124
+ // object spans several source lines that collapse to a single generated line.
3125
+ // An implicit-object call block —
3126
+ // use serve
3127
+ // preload: '▮'
3128
+ // — compiles to `use(serve({ …, preload: '' }))`, so srcToOffset maps the
3129
+ // property's source line onto that one statement gen line but lands near the
3130
+ // statement start, not inside the value string. Given the source line, the
3131
+ // cursor column, and the gen offset srcToOffset produced, this finds the same
3132
+ // `key: <quote>` on the gen line and returns the absolute gen offset at the
3133
+ // matching spot inside the gen string — so TS can supply the contextually-typed
3134
+ // string-literal completions. Returns genOffset unchanged outside this context.
3135
+ export function retargetObjectValueOffset(entry, srcLine, srcCol, genOffset) {
3136
+ if (!entry || genOffset == null || !srcLine) return genOffset;
3137
+ const m = srcLine.match(/^(\s*)([\w$]+)(\s*:\s*)(["'])/);
3138
+ if (!m) return genOffset;
3139
+ const key = m[2];
3140
+ const strStart = m[0].length; // src index just inside the opening quote
3141
+ if (srcCol < strStart) return genOffset; // cursor isn't inside the value string
3142
+ const inStr = srcCol - strStart; // characters into the string content
3143
+
3144
+ const tsText = entry.tsContent;
3145
+ const lc = offsetToLineCol(tsText, genOffset);
3146
+ const genLine = tsText.split('\n')[lc.line];
3147
+ if (genLine == null) return genOffset;
3148
+ const re = new RegExp('\\b' + key.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + '\\s*:\\s*["\']');
3149
+ const gm = re.exec(genLine);
3150
+ if (!gm) return genOffset;
3151
+ const genStrInside = gm.index + gm[0].length; // just inside the gen opening quote
3152
+ return (genOffset - lc.col) + genStrInside + inStr;
3153
+ }
3154
+
2951
3155
  // Map a Rip source (line, col) to a TypeScript virtual file byte offset.
2952
3156
  // This is the forward direction: source → generated (used for hover, definition, etc.)
2953
3157
  //
@@ -3250,15 +3454,16 @@ const bold = (s) => isColor ? `\x1b[1m${s}\x1b[0m` : s;
3250
3454
  export async function runCheck(targetDir, opts = {}) {
3251
3455
  const rootPath = resolve(targetDir);
3252
3456
 
3457
+ // Use rip's own catalog-pinned TypeScript (a dependency), never the
3458
+ // consumer's — so `rip check` and the editor type-check with the exact
3459
+ // same version. import('typescript') resolves from this file's location
3460
+ // (rip-lang's node_modules) regardless of the consumer's setup.
3253
3461
  let ts;
3254
3462
  try {
3255
- const req = createRequire(resolve(rootPath, 'package.json'));
3256
- ts = req('typescript');
3463
+ ts = await import('typescript').then(m => m.default || m);
3257
3464
  } catch {
3258
- try { ts = await import('typescript').then(m => m.default || m); } catch {
3259
- console.error('TypeScript is required for type checking. Install with: bun add -d typescript');
3260
- return 1;
3261
- }
3465
+ console.error('TypeScript could not be loaded. Reinstall rip-lang (it ships TypeScript as a dependency).');
3466
+ return 1;
3262
3467
  }
3263
3468
 
3264
3469
  if (!existsSync(rootPath)) {
@@ -3477,7 +3682,19 @@ export async function runCheck(targetDir, opts = {}) {
3477
3682
  }
3478
3683
  }
3479
3684
 
3480
- // Check for unresolved relative imports in all files (not just typed ones)
3685
+ // Check for unresolved relative imports in all files (not just typed ones),
3686
+ // and validate render gates (<~) against the stash's source-key set.
3687
+ const stashAnalyses = new Map();
3688
+ const stashAnalysisFor = (fp) => {
3689
+ const stashFile = findStashFile(fp);
3690
+ if (!stashFile) return null;
3691
+ if (!stashAnalyses.has(stashFile)) {
3692
+ const entry = compiled.get(stashFile);
3693
+ stashAnalyses.set(stashFile, entry?.sexpr ? collectStashAnalysis(entry.sexpr) : null);
3694
+ }
3695
+ return stashAnalyses.get(stashFile);
3696
+ };
3697
+
3481
3698
  const fileResults = [];
3482
3699
  let totalErrors = 0, totalWarnings = 0;
3483
3700
  for (const [fp, source] of sourcesByPath) {
@@ -3494,6 +3711,18 @@ export async function runCheck(targetDir, opts = {}) {
3494
3711
  totalErrors++;
3495
3712
  }
3496
3713
  }
3714
+
3715
+ // A gate whose path provably lands on a plain key (or no key)
3716
+ // in app/stash.rip is the compile-time form of the renderer's
3717
+ // deterministic mount error. Conservative — see validateGatePath.
3718
+ const gates = compiled.get(fp)?.gates;
3719
+ if (gates && gates.length) {
3720
+ for (const d of collectGateDiagnostics(gates, source, stashAnalysisFor(fp))) {
3721
+ errors.push({ line: d.line, col: d.col, len: d.len, message: d.message, severity: 'error', code: 'rip', srcLine: d.srcLine, related: [] });
3722
+ totalErrors++;
3723
+ }
3724
+ }
3725
+
3497
3726
  if (errors.length > 0) fileResults.push({ file: fp, errors });
3498
3727
  }
3499
3728
 
@@ -4202,15 +4431,14 @@ function joinPath(label, step, rest) {
4202
4431
  export async function runAudit(targetDir) {
4203
4432
  const rootPath = resolve(targetDir);
4204
4433
 
4434
+ // Use rip's own catalog-pinned TypeScript (a dependency), never the
4435
+ // consumer's — see runCheck above.
4205
4436
  let ts;
4206
4437
  try {
4207
- const req = createRequire(resolve(rootPath, 'package.json'));
4208
- ts = req('typescript');
4438
+ ts = await import('typescript').then(m => m.default || m);
4209
4439
  } 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
- }
4440
+ console.error('TypeScript could not be loaded. Reinstall rip-lang (it ships TypeScript as a dependency).');
4441
+ return 1;
4214
4442
  }
4215
4443
 
4216
4444
  const pkgJsonPath = resolve(rootPath, 'package.json');
package/src/types.js CHANGED
@@ -378,7 +378,7 @@ function collectTypeExpression(tokens, j) {
378
378
  }
379
379
  if (tTag === '=' || tTag === 'REACTIVE_ASSIGN' ||
380
380
  tTag === 'COMPUTED_ASSIGN' || tTag === 'READONLY_ASSIGN' ||
381
- tTag === 'EFFECT' || tTag === 'TERMINATOR' ||
381
+ tTag === 'EFFECT' || tTag === 'GATE' || tTag === 'TERMINATOR' ||
382
382
  tTag === 'INDENT' || tTag === 'OUTDENT' ||
383
383
  tTag === '->' || tTag === ',') {
384
384
  break;
@@ -470,6 +470,10 @@ function buildTypeString(typeTokens) {
470
470
  if (t.data?.optional && next && (next[0] === 'TYPE_ANNOTATION' || next[0] === ':')) {
471
471
  return `${t[1]}?`;
472
472
  }
473
+ // Embedded-JS tokens are backtick literals with the delimiters stripped
474
+ // by the lexer. In type position they are TS template-literal types
475
+ // (e.g. `${number} min`) — re-wrap them so the emitted type is valid.
476
+ if (t[0] === 'JS') return '`' + t[1] + '`';
473
477
  return t[1];
474
478
  });
475
479
  let typeStr = parts.join(' ').replace(/\s+/g, ' ').trim();