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/components.js CHANGED
@@ -18,6 +18,12 @@ const BIND_PREFIX = '__bind_';
18
18
  const BIND_SUFFIX = '__';
19
19
 
20
20
  const LIFECYCLE_HOOKS = new Set(['beforeMount', 'mounted', 'beforeUnmount', 'unmounted', 'onError']);
21
+
22
+ // Canonical payload type for the onError lifecycle hook — the single source
23
+ // of truth for both the optional declaration emitted when a component does
24
+ // NOT define the hook and the shadow param type of a user-defined hook
25
+ // whose param is unannotated.
26
+ const ON_ERROR_PARAM_TYPE = '{ status?: number; message?: string; error?: Error; path?: string }';
21
27
  const BOOLEAN_ATTRS = new Set([
22
28
  'disabled', 'hidden', 'readonly', 'required', 'checked', 'selected',
23
29
  'autofocus', 'autoplay', 'controls', 'loop', 'muted', 'multiple',
@@ -90,6 +96,73 @@ function getMemberOptional(target) {
90
96
  return false;
91
97
  }
92
98
 
99
+ /**
100
+ * Flatten a pure `.` chain s-expression into its segment names.
101
+ * `(. (. this app) data)` → ['this', 'app', 'data']. Returns null when the
102
+ * node isn't a plain identifier chain (computed access, calls, etc.).
103
+ */
104
+ function chainSegments(node) {
105
+ const segs = [];
106
+ while (Array.isArray(node) && node[0] === '.') {
107
+ const prop = node[2];
108
+ if (!(typeof prop === 'string' || prop instanceof String)) return null;
109
+ segs.unshift(prop.valueOf());
110
+ node = node[1];
111
+ }
112
+ if (typeof node === 'string' || node instanceof String) segs.unshift(node.valueOf());
113
+ else return null;
114
+ return segs;
115
+ }
116
+
117
+ /**
118
+ * Parse a render gate's RHS (`user <~ @app.data.user`) into its hoistable
119
+ * parts. The RHS must be a literal `@app.data.…` chain, optionally applied
120
+ * to exactly one key argument (keyed source). Returns
121
+ * { path, keyExpr? } or { error }.
122
+ */
123
+ function parseGatePath(rhs) {
124
+ let keyExpr;
125
+ let chain = rhs;
126
+ // Application form: [calleeChain, keyArg]
127
+ if (Array.isArray(rhs) && Array.isArray(rhs[0]) && rhs[0][0] === '.') {
128
+ if (rhs.length !== 2) return { error: 'a keyed gate takes exactly one key argument' };
129
+ chain = rhs[0];
130
+ keyExpr = rhs[1];
131
+ }
132
+ const segs = chainSegments(chain);
133
+ if (!segs) return { error: "'<~' requires a literal @app.data.… path on the right-hand side" };
134
+ if (segs[0] === 'this') segs.shift();
135
+ if (segs[0] !== 'app' || segs[1] !== 'data' || segs.length < 3) {
136
+ return { error: "'<~' requires a literal @app.data.… path on the right-hand side" };
137
+ }
138
+ return { path: segs.slice(2).join('.'), keyExpr };
139
+ }
140
+
141
+ /**
142
+ * Parse a keyed gate's key expression. The key must be available
143
+ * to the renderer before construction, so it is restricted to a literal or
144
+ * a params/query access (`params.id`, `@query.tab`). Returns
145
+ * { code, stubCode } (runtime and type-stub spellings) or { error }.
146
+ */
147
+ function parseGateKey(expr) {
148
+ const KEY_ERROR = { error: 'a keyed gate key may only reference params or query (e.g. params.id)' };
149
+ const lit = (typeof expr === 'string' || expr instanceof String) ? expr.valueOf() : null;
150
+ if (lit != null) {
151
+ if (/^-?[\d.]/.test(lit) || lit.startsWith('"') || lit.startsWith("'")) {
152
+ return { code: lit, stubCode: lit };
153
+ }
154
+ return KEY_ERROR; // a bare identifier (incl. `params`/`query` alone) isn't a key
155
+ }
156
+ const segs = chainSegments(expr);
157
+ if (!segs) return KEY_ERROR;
158
+ if (segs[0] === 'this') segs.shift();
159
+ if ((segs[0] !== 'params' && segs[0] !== 'query') || segs.length < 2) return KEY_ERROR;
160
+ const code = segs.join('.');
161
+ // `params` and `query` are both Record<string, string> on the component, so
162
+ // the shadow reads the key the same way the runtime does — no cast needed.
163
+ return { code, stubCode: `this.${code}` };
164
+ }
165
+
93
166
  // ============================================================================
94
167
  // Prototype Installation
95
168
  // ============================================================================
@@ -730,6 +803,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
730
803
  // Categorize statements
731
804
  const stateVars = [];
732
805
  const derivedVars = [];
806
+ const gateVars = [];
733
807
  const readonlyVars = [];
734
808
  const methods = [];
735
809
  const lifecycleHooks = [];
@@ -786,6 +860,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
786
860
  memberNames.add(varName);
787
861
  reactiveMembers.add(varName);
788
862
  }
863
+ } else if (op === 'gate') {
864
+ // Render-ready binding: hoist the literal stash path into the
865
+ // component's static gate-set; bind the member as a reactive read.
866
+ const varName = getMemberName(stmt[1]);
867
+ if (varName) {
868
+ const parsed = parseGatePath(stmt[2]);
869
+ if (parsed.error) this.error(parsed.error, stmt);
870
+ let key = null;
871
+ if (parsed.keyExpr !== undefined) {
872
+ key = parseGateKey(parsed.keyExpr);
873
+ if (key.error) this.error(key.error, stmt);
874
+ }
875
+ gateVars.push({ name: varName, path: parsed.path, key, type: getMemberType(stmt[1]), srcLine: stmt.loc?.r });
876
+ memberNames.add(varName);
877
+ reactiveMembers.add(varName);
878
+ }
789
879
  } else if (op === 'readonly') {
790
880
  const varName = getMemberName(stmt[1]);
791
881
  if (varName) {
@@ -803,7 +893,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
803
893
  methods.push({ name: varName, func: val });
804
894
  memberNames.add(varName);
805
895
  } else {
806
- stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), srcLine: stmt.loc?.r });
896
+ stateVars.push({ name: varName, value: val, isPublic: isPublicProp(stmt[1]), type: getMemberType(stmt[1]), optional: getMemberOptional(stmt[1]), srcLine: stmt.loc?.r });
807
897
  memberNames.add(varName);
808
898
  reactiveMembers.add(varName);
809
899
  }
@@ -828,6 +918,17 @@ export function installComponentSupport(CodeEmitter, Lexer) {
828
918
  }
829
919
  }
830
920
 
921
+ // Record gate declarations on the generator so the compile result can
922
+ // expose them (compiler.js threads `_gateDecls` through as `gates`).
923
+ // `rip check` validates each path against app/stash.rip's source-key
924
+ // set — the compile-time mirror of the renderer's mount check.
925
+ if (gateVars.length > 0) {
926
+ if (!this._gateDecls) this._gateDecls = [];
927
+ for (const { path, key, srcLine } of gateVars) {
928
+ this._gateDecls.push({ path, keyed: !!key, line: (srcLine ?? 0) + 1 });
929
+ }
930
+ }
931
+
831
932
  // Auto-event map: onClick → 'click', onKeydown → 'keydown', etc.
832
933
  const autoEventHandlers = new Map();
833
934
  for (const { name } of methods) {
@@ -883,14 +984,14 @@ export function installComponentSupport(CodeEmitter, Lexer) {
883
984
  // method bodies past where interpolation expects them — breaking
884
985
  // @ts-expect-error injection. Keeping a single header line preserves
885
986
  // the relative offsets.
886
- sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query: URLSearchParams; declare children: any;');
987
+ sl.push(' declare _root: Element | null; declare app: any; declare router: any; declare params: Record<string, string>; declare query: Record<string, string>; declare children: any;');
887
988
  const userHookNames = new Set(lifecycleHooks.map(h => h.name));
888
989
  const hookDecls = [];
889
990
  if (!userHookNames.has('beforeMount')) hookDecls.push('beforeMount?(): void;');
890
991
  if (!userHookNames.has('mounted')) hookDecls.push('mounted?(): void;');
891
992
  if (!userHookNames.has('beforeUnmount')) hookDecls.push('beforeUnmount?(): void;');
892
993
  if (!userHookNames.has('unmounted')) hookDecls.push('unmounted?(): void;');
893
- if (!userHookNames.has('onError')) hookDecls.push('onError?(err: { status?: number; message?: string; error?: Error; path?: string }): void;');
994
+ if (!userHookNames.has('onError')) hookDecls.push(`onError?(err: ${ON_ERROR_PARAM_TYPE}): void;`);
894
995
  if (hookDecls.length) sl.push(' ' + hookDecls.join(' '));
895
996
  sl.push(' emit(_name: string, _detail?: any): void {}');
896
997
 
@@ -927,16 +1028,55 @@ export function installComponentSupport(CodeEmitter, Lexer) {
927
1028
  return null;
928
1029
  };
929
1030
 
1031
+ // A value references `this` synchronously if it reads `this`/`@…` or a
1032
+ // reactive member (which emits as `this.x.value`) OUTSIDE any nested
1033
+ // function. Refs inside a `->`/`=>` are deferred to call time, so they
1034
+ // don't run during field initialization. Used below to decide whether a
1035
+ // value is safe to emit as a field initializer (the injected `app`,
1036
+ // `router`, … are `declare`-only, so a synchronous `this.app` read in a
1037
+ // field initializer would trip TS2729 "used before initialization").
1038
+ const refsThisSync = (node) => {
1039
+ if (!Array.isArray(node)) {
1040
+ if (typeof node === 'string' || node instanceof String) {
1041
+ const s = node.valueOf();
1042
+ return s === 'this' || reactiveMembers.has(s);
1043
+ }
1044
+ return false;
1045
+ }
1046
+ const head = node[0]?.valueOf?.() ?? node[0];
1047
+ if (head === '->' || head === '=>') return false; // nested fn — deferred
1048
+ return node.some(refsThisSync);
1049
+ };
1050
+
930
1051
  // Property declarations (declare avoids definite-assignment errors)
931
- for (const { name, type, value, optional, srcLine } of stateVars) {
1052
+ const deferredStateInits = [];
1053
+ for (const { name, type, value, isPublic, optional, srcLine } of stateVars) {
932
1054
  const ts = expandType(type) || inferLiteralType(value);
933
1055
  // Optional prop with no default: field can be undefined at runtime
934
1056
  // (we don't synthesize a `?? null` fallback below), so the Signal's
935
1057
  // payload type must include undefined.
936
- const optNoDefault = optional && value === undefined;
1058
+ // `?` widens the Signal payload to `| undefined` when the member has
1059
+ // no initializer (`@label?:: T` — caller may omit) OR an explicit
1060
+ // `:= undefined` (`x?:: T := undefined` — starts empty). A `?` with a
1061
+ // real default stays `T`: the default fills the gap, so the field is
1062
+ // never undefined.
1063
+ const noValue = value === undefined || (value?.valueOf?.() ?? value) === 'undefined';
1064
+ const optNoDefault = optional && noValue;
937
1065
  const wrapped = ts ? (optNoDefault ? `${ts} | undefined` : ts) : null;
938
1066
  const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
939
- sl.push((wrapped ? ` declare ${name}: Signal<${wrapped}>;` : ` declare ${name}: Signal<any>;`) + marker);
1067
+ // No declared or literal-inferred type, but a private initializer is
1068
+ // present and reads nothing from `this` synchronously: emit a real
1069
+ // field initializer (deferred below) so TS infers the Signal payload
1070
+ // from the value — e.g. `x = createMutation(...)` → Signal<Mutation<R>>
1071
+ // — instead of falling back to Signal<any>. Mirrors how computed/gate
1072
+ // members already infer from their `__computed(...)` initializers.
1073
+ // Values that touch `this` synchronously keep the declare form (they
1074
+ // can't be field initializers — see refsThisSync above).
1075
+ if (!wrapped && !isPublic && !noValue && !refsThisSync(value)) {
1076
+ deferredStateInits.push({ name, value, marker });
1077
+ } else {
1078
+ sl.push((wrapped ? ` declare ${name}: Signal<${wrapped}>;` : ` declare ${name}: Signal<any>;`) + marker);
1079
+ }
940
1080
  }
941
1081
  if (inheritsTag) {
942
1082
  sl.push(` declare rest: Signal<__RipProps<'${inheritsTag}'>>;`);
@@ -945,6 +1085,19 @@ export function installComponentSupport(CodeEmitter, Lexer) {
945
1085
  const ts = expandType(type) || inferLiteralType(value);
946
1086
  sl.push(ts ? ` declare ${name}: ${ts};` : ` declare ${name}: any;`);
947
1087
  }
1088
+ // Gated bindings (<~): non-null by construction — the renderer loads
1089
+ // the source before the component exists. __ripGate is the generic
1090
+ // narrow `(v: T | null | undefined) => T`; soundness comes from the
1091
+ // runtime gate, not from the type system proving the load ran.
1092
+ for (const { name, path, key, type, srcLine } of gateVars) {
1093
+ const ts = expandType(type);
1094
+ const typeAnnot = ts ? `: Computed<${ts}>` : '';
1095
+ const access = key
1096
+ ? `this.app.data.${path}(${key.stubCode})`
1097
+ : `this.app.data.${path}`;
1098
+ const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1099
+ sl.push(` ${name}${typeAnnot} = __computed(() => __ripGate(${access}));` + marker);
1100
+ }
948
1101
  for (const { name, expr, type } of derivedVars) {
949
1102
  const ts = expandType(type);
950
1103
  const typeAnnot = ts ? `: Computed<${ts}>` : '';
@@ -958,6 +1111,13 @@ export function installComponentSupport(CodeEmitter, Lexer) {
958
1111
  }
959
1112
  }
960
1113
 
1114
+ // Inferred-type private state vars: field initializers emitted after the
1115
+ // computed members above, so any member they read is already declared.
1116
+ for (const { name, value, marker } of deferredStateInits) {
1117
+ const val = this.emitInComponent(value, 'value');
1118
+ sl.push(` ${name} = __state(${val});` + marker);
1119
+ }
1120
+
961
1121
  // _init body — readonly, state, computed assignments (skip accepted/offered)
962
1122
  sl.push(' _init(props) {');
963
1123
  for (const { name, value, isPublic, srcLine } of readonlyVars) {
@@ -966,6 +1126,7 @@ export function installComponentSupport(CodeEmitter, Lexer) {
966
1126
  sl.push((isPublic ? ` this.${name} = props.${name} ?? ${val};` : ` this.${name} = ${val};`) + marker);
967
1127
  }
968
1128
  for (const { name, value, isPublic, required, type, srcLine } of stateVars) {
1129
+ if (deferredStateInits.some(d => d.name === name)) continue;
969
1130
  const marker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
970
1131
  if (isPublic && (required || value === undefined)) {
971
1132
  sl.push(` this.${name} = __state(props.__bind_${name}__ ?? props.${name});` + marker);
@@ -1072,7 +1233,22 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1072
1233
  for (const { name, value } of lifecycleHooks) {
1073
1234
  if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
1074
1235
  const [, params, hookBody] = value;
1075
- const paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
1236
+ let paramStr = Array.isArray(params) ? params.map(p => this.formatParam(p)).join(', ') : '';
1237
+ // onError's payload shape is known to the framework, but its typed
1238
+ // optional declaration above is only emitted when the user does
1239
+ // NOT define the hook (a real method would collide with it) — so a
1240
+ // user-defined `onError: (err) ->` has no signature to inherit
1241
+ // from, and an unannotated param would be an implicit any in
1242
+ // strict projects. Type it from the same canonical constant the
1243
+ // declaration uses. Detection is structural (the parse tree's
1244
+ // type metadata, like getMemberType), and an explicit user
1245
+ // annotation always wins.
1246
+ if (name === 'onError' && Array.isArray(params) && params.length === 1) {
1247
+ const p = params[0];
1248
+ if ((typeof p === 'string' || p instanceof String) && !meta(p, 'type')) {
1249
+ paramStr = `${p.valueOf()}: ${ON_ERROR_PARAM_TYPE}`;
1250
+ }
1251
+ }
1076
1252
  const transformed = this.reactiveMembers ? this.transformComponentMembers(hookBody) : hookBody;
1077
1253
  const isAsync = this.containsAwait(hookBody);
1078
1254
  let bodyCode = this.emitFunctionBody(transformed, params || []);
@@ -1087,6 +1263,18 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1087
1263
  const constructions = [];
1088
1264
  let constructionIdx = 0;
1089
1265
  const sourceLines = this.options.source?.split('\n');
1266
+ // Route-check any `href:` prop passed to a component the same way
1267
+ // intrinsic `<a href>` is checked. A component that forwards href to
1268
+ // an anchor (e.g. `component extends a`) exposes it via `@rest` typed
1269
+ // as plain `string`, so without this `ButtonLink href: "/typo"` would
1270
+ // escape route validation entirely. We can't see the component's tag
1271
+ // cross-module, so we key on the prop name — `href` is a URL wherever
1272
+ // it appears. Wrap slash-prefixed string and template literals in
1273
+ // `__ripRoute(...)` (strengthened to the route union when the project
1274
+ // has routes); external URLs and variables (no leading-slash literal)
1275
+ // pass through untouched.
1276
+ const wrapCompHref = (val) =>
1277
+ typeof val === 'string' && /^["'`]\//.test(val) ? `__ripRoute(${val})` : val;
1090
1278
  const extractProps = (args) => {
1091
1279
  const props = [];
1092
1280
  const sideExprs = [];
@@ -1130,7 +1318,8 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1130
1318
  props.push({ code: `${key}: ${member}`, srcLine });
1131
1319
  } else {
1132
1320
  const val = this.emitInComponent(value, 'value');
1133
- props.push({ code: `${key}: ${val}`, srcLine });
1321
+ const finalVal = key === 'href' ? wrapCompHref(val) : val;
1322
+ props.push({ code: `${key}: ${finalVal}`, srcLine });
1134
1323
  }
1135
1324
  }
1136
1325
  }
@@ -1173,9 +1362,25 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1173
1362
  if (Array.isArray(key) && key[0] === '.' && key[1] === 'this') {
1174
1363
  let memberName = typeof key[2] === 'string' ? key[2] : key[2]?.valueOf?.();
1175
1364
  if (!memberName) continue;
1176
- const eventKey = '@' + memberName.split('.')[0];
1177
- const val = this.emitInComponent(value, 'value');
1178
- props.push({ code: `'${eventKey}': ${val}`, srcLine });
1365
+ const eventName = memberName.split('.')[0];
1366
+ let val = this.emitInComponent(value, 'value');
1367
+ // Inline arrow handler: explicitly annotate its untyped first
1368
+ // param with the event type. Letting TS contextually type it
1369
+ // through __ripEl's generic props is fragile — certain prop
1370
+ // combinations silently drop the param to `any`. This gives
1371
+ // inline handlers the same reliable typing the `@event: @method`
1372
+ // path already gets, matching __RipEvents' shape exactly.
1373
+ if (Array.isArray(value) && (value[0] === '->' || value[0] === '=>')) {
1374
+ const p0 = value[1]?.[0];
1375
+ const pName = typeof p0 === 'string' ? p0 : p0?.valueOf?.();
1376
+ if (pName && !(p0 && p0.type) && /^[A-Za-z_$][\w$]*$/.test(pName)) {
1377
+ const T = `RipEvent<HTMLElementEventMap['${eventName}'], __RipElementMap['${tagName}']>`;
1378
+ val = val[0] === '('
1379
+ ? val.replace(new RegExp('^\\(\\s*' + pName + '\\b'), `(${pName}: ${T}`)
1380
+ : val.replace(new RegExp('^' + pName + '\\b'), `(${pName}: ${T})`);
1381
+ }
1382
+ }
1383
+ props.push({ code: `'@${eventName}': ${val}`, srcLine });
1179
1384
  } else if (typeof key === 'string') {
1180
1385
  if (key === 'key') {
1181
1386
  // key: is not an HTML attribute, but emit its value
@@ -1269,14 +1474,38 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1269
1474
  const condCode = this.emitInComponent(condition, 'value');
1270
1475
  const srcLine = node.loc?.r;
1271
1476
  const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1272
- constructions.push(` ${condCode};${srcMarker}`);
1477
+ // Emit a real `if (...) { } else { }` (mirroring the for-loop
1478
+ // case below) instead of a bare condition statement with the
1479
+ // branches flattened as unguarded siblings. The structure is
1480
+ // what lets TS narrow the condition inside the then-branch —
1481
+ // `if order then order.total` type-checks against `T | null`
1482
+ // without `?.` laundering. Reads are plain property chains
1483
+ // (`this.order.value`), which TS keeps narrowed across the
1484
+ // intervening `__ripEl(...)` stub calls.
1485
+ const guard = head === 'unless' ? `!(${condCode})` : condCode;
1486
+ constructions.push(` if (${guard}) {${srcMarker}`);
1487
+ if (node[2] != null) walkRender(node[2]);
1488
+ const elses = node.slice(3).filter(n => n != null);
1489
+ if (elses.length) {
1490
+ constructions.push(` } else {`);
1491
+ for (const e of elses) walkRender(e);
1492
+ }
1493
+ constructions.push(` }`);
1494
+ return; // branches fully handled — don't re-walk children below
1273
1495
  }
1274
1496
  } else if (head === '?:') {
1275
- // Emit the full ternary so all branches are type-checked
1497
+ // Emit the full ternary so all branches are type-checked, then
1498
+ // return — like the `__text__`/`str` cases. The emitted expression
1499
+ // already covers every branch, so re-walking children is redundant
1500
+ // and actively harmful: a nested member access (e.g. `opt.label`
1501
+ // in `typeof opt is "string" ? opt : opt.label`) would hit the
1502
+ // bare member-access case below and be re-emitted *unguarded*,
1503
+ // stripping the `typeof` narrowing and producing a false error.
1276
1504
  const ternCode = this.emitInComponent(node, 'value');
1277
1505
  const srcLine = node.loc?.r;
1278
1506
  const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1279
1507
  constructions.push(` ${ternCode};${srcMarker}`);
1508
+ return;
1280
1509
  } else if (head === 'switch') {
1281
1510
  const discriminant = node[1];
1282
1511
  if (discriminant != null) {
@@ -1345,6 +1574,26 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1345
1574
  constructions.push(` ${exprCode};${srcMarker}`);
1346
1575
  } catch {}
1347
1576
  return;
1577
+ } else if (head === '.' || head === '?.') {
1578
+ // Bare member-access expression as element content
1579
+ // (e.g. `div config.site.theme.logoMark`). Walk to the chain's
1580
+ // base identifier: emit it for type-checking only when the base
1581
+ // is a value reference, NOT an HTML tag-shorthand like `div.image`
1582
+ // (whose base IS a tag). Without this, static text-content
1583
+ // expressions never reach the stub and escape type-checking —
1584
+ // only interpolated "#{...}" content was being checked.
1585
+ let base = node;
1586
+ while (Array.isArray(base) && (base[0] === '.' || base[0] === '?.')) base = base[1];
1587
+ const baseName = typeof base === 'string' ? base : base?.valueOf?.();
1588
+ if (typeof baseName === 'string' && !this.isHtmlTag(baseName) && !CodeEmitter.GENERATORS[baseName]) {
1589
+ try {
1590
+ const exprCode = this.emitInComponent(node, 'value');
1591
+ const srcLine = node.loc?.r;
1592
+ const srcMarker = srcLine != null ? ` // @rip-src:${srcLine}` : '';
1593
+ constructions.push(` ${exprCode};${srcMarker}`);
1594
+ } catch {}
1595
+ }
1596
+ return;
1348
1597
  }
1349
1598
 
1350
1599
  // Emit a bare lowercase identifier as either a property access
@@ -1526,9 +1775,28 @@ export function installComponentSupport(CodeEmitter, Lexer) {
1526
1775
 
1527
1776
  lines.push('class extends __Component {');
1528
1777
 
1778
+ // Static gate-set — readable by the renderer without
1779
+ // constructing the component. Strings are plain stash paths; keyed
1780
+ // gates carry a key fn the renderer evaluates with the matched
1781
+ // params/query before construction.
1782
+ if (gateVars.length > 0) {
1783
+ const items = gateVars.map(({ path, key }) => key
1784
+ ? `{ path: '${path}', key: (params, query) => ${key.code} }`
1785
+ : `'${path}'`);
1786
+ lines.push(` static __gates = [${items.join(', ')}];`);
1787
+ }
1788
+
1529
1789
  // --- Init (called by __Component constructor) ---
1530
1790
  lines.push(' _init(props) {');
1531
1791
 
1792
+ // Gated bindings (<~) — first, so any readonly/state initializer can
1793
+ // consume the loaded value synchronously (`form := { ...user }`).
1794
+ for (const { name, path, key } of gateVars) {
1795
+ lines.push(key
1796
+ ? ` this.${name} = __gateBind(this, '${path}', (params, query) => ${key.code});`
1797
+ : ` this.${name} = __gateBind(this, '${path}');`);
1798
+ }
1799
+
1532
1800
  // Constants (readonly)
1533
1801
  for (const { name, value, isPublic } of readonlyVars) {
1534
1802
  const val = this.emitInComponent(value, 'value');
@@ -3526,6 +3794,28 @@ function __transition(el, name, dir, done) {
3526
3794
  });
3527
3795
  }
3528
3796
 
3797
+ // Render-ready binding (\`x <~ @app.data.x\`): a reactive read of a
3798
+ // stash source key that retains the last non-null value for the life of
3799
+ // this instance. A null write or reset() invalidates the CELL (ungated
3800
+ // readers see it immediately) without yanking mounted gates — the
3801
+ // live-binding invariant. Last-good lives in this closure, so it dies
3802
+ // with the instance.
3803
+ function __gateBind(self, path, keyFn) {
3804
+ // The component runtime ships both inlined after the reactive runtime
3805
+ // (where __computed is a local) and as a standalone bundle chunk (where
3806
+ // it is not) — resolve through globalThis.__rip in the latter case.
3807
+ const __c = typeof __computed !== 'undefined' ? __computed : globalThis.__rip.__computed;
3808
+ let last;
3809
+ return __c(() => {
3810
+ const data = self.app && self.app.data;
3811
+ if (!data) return last;
3812
+ const v = keyFn
3813
+ ? data[path](keyFn(self.params || {}, self.query || {}))
3814
+ : data[path]; // dotted paths route through the stash's path-key get
3815
+ return v != null ? (last = v) : last;
3816
+ });
3817
+ }
3818
+
3529
3819
  function __handleComponentError(error, component) {
3530
3820
  let current = component;
3531
3821
  // Defensive cycle guard: if the parent chain is corrupted (e.g. by
@@ -3547,6 +3837,17 @@ class __Component {
3547
3837
  constructor(props = {}) {
3548
3838
  Object.assign(this, props);
3549
3839
  if (!this.app && globalThis.__ripApp) this.app = globalThis.__ripApp;
3840
+ // A component with render gates (<~) is honored only via route
3841
+ // matching — the renderer sets __ripGateMount around each route/layout
3842
+ // construction. Constructed as an embedded child (a parent scope is
3843
+ // active but the renderer flag doesn't match), its gates were never
3844
+ // loaded, so the T-typed bindings would be silent nulls. Fail loud and
3845
+ // deterministically instead. Direct construction outside any component
3846
+ // scope (tests: seed the stash, then construct) stays allowed.
3847
+ const __g = this.constructor.__gates;
3848
+ if (__g && __g.length && globalThis.__ripGateMount !== this.constructor && __currentComponent) {
3849
+ throw new Error('[Rip] component declares render gates (<~) but was constructed as an embedded child; gates are honored only via route matching — lift the gate to the route/layout and pass the value as a prop');
3850
+ }
3550
3851
  const prev = __pushComponent(this);
3551
3852
  try {
3552
3853
  this._init(props);
@@ -3646,7 +3947,7 @@ class __Component {
3646
3947
 
3647
3948
  // Register on globalThis for runtime deduplication
3648
3949
  if (typeof globalThis !== 'undefined') {
3649
- globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __Component };
3950
+ globalThis.__ripComponent = { __pushComponent, __popComponent, __getCurrentComponent, setContext, getContext, hasContext, __clsx, __lis, __reconcile, __transition, __handleComponentError, __gateBind, __Component };
3650
3951
  }
3651
3952
 
3652
3953
  `;
package/src/dts.js CHANGED
@@ -83,7 +83,7 @@ export function ripDestructuredNames(source) {
83
83
  // emitTypes — generate .d.ts from annotated tokens + s-expression tree
84
84
  // ============================================================================
85
85
 
86
- export function emitTypes(tokens, sexpr = null, source = '') {
86
+ export function emitTypes(tokens, sexpr = null, source = '', schemaBehavior = null, schemaAnon = null) {
87
87
  let lines = [];
88
88
  let indentLevel = 0;
89
89
  let indentStr = ' ';
@@ -797,6 +797,12 @@ export function emitTypes(tokens, sexpr = null, source = '') {
797
797
  lines.push(`${indent()}${exp}${declare}const ${varName}: Computed<${type}>;`);
798
798
  } else if (next[0] === 'EFFECT') {
799
799
  lines.push(`${indent()}${exp}${declare}const ${varName}: () => void;`);
800
+ } else if (next[0] === 'GATE') {
801
+ // Render-ready gate (<~) — only valid inside component
802
+ // bodies, where the component stub (not this hoist) types the
803
+ // binding. Explicit no-op so an annotated gate never falls
804
+ // through to the `=` arrow-scan below.
805
+ continue;
800
806
  } else if (next[0] === '=') {
801
807
  // Check if RHS is an arrow function with return type
802
808
  let arrowIdx = i + 2;
@@ -821,7 +827,16 @@ export function emitTypes(tokens, sexpr = null, source = '') {
821
827
  } else if (inClass) {
822
828
  lines.push(`${indent()}${varName}: ${type};`);
823
829
  classFields.add(varName);
824
- } else {
830
+ } else if (bodyDepth === 0) {
831
+ // Module-scope typed binding: typecheck.js's inline-let pass
832
+ // merges this header line into the body's hoist by NAME. That
833
+ // name-based merge is only sound at module scope — for a
834
+ // function-local (`bodyDepth > 0`) the same name can exist
835
+ // untyped in sibling functions, and the merge would stamp this
836
+ // annotation onto every one of them. Function-locals don't
837
+ // need the header line anyway: the compiler's typed-local
838
+ // hoist (collectTypedLocals) annotates the owning function's
839
+ // own `let` in the body, scope-correctly.
825
840
  lines.push(`${indent()}${exp}let ${varName}: ${type};`);
826
841
  }
827
842
  } else if (inClass) {
@@ -852,7 +867,7 @@ export function emitTypes(tokens, sexpr = null, source = '') {
852
867
  // Schema declarations — strip any prior auto-emitted `declare let Foo`
853
868
  // for the same bindings (they are re-emitted as typed Schema<T>).
854
869
  let schemaLines = [];
855
- hasSchemaDecls = emitSchemaTypes(sexpr, schemaLines);
870
+ hasSchemaDecls = emitSchemaTypes(sexpr, schemaLines, schemaBehavior, schemaAnon);
856
871
  if (hasSchemaDecls) {
857
872
  let bindings = new Set();
858
873
  for (let line of schemaLines) {