rip-lang 3.17.3 → 3.17.5

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.
@@ -904,6 +904,13 @@ class __SchemaDef {
904
904
  // a non-enumerable accessor so order.user_id and order.userId read
905
905
  // the same slot — useful when DB column names leak into user code
906
906
  // via raw SQL helpers.
907
+ //
908
+ // Temporal values arrive already decoded by the adapter: @rip-lang/db
909
+ // decodes date/datetime columns to real `Date` at the wire boundary
910
+ // (naive TIMESTAMP is read as UTC), so hydrate stores them verbatim —
911
+ // do NOT add a decode here. That keeps a single decode seam and honors
912
+ // the `date`/`datetime` -> `Date` .d.ts contract. (`_coerceDates` below
913
+ // is parse-only, for JSON/HTTP input — it never runs on this path.)
907
914
  const data = {};
908
915
  for (let i = 0; i < columns.length; i++) {
909
916
  data[__schemaCamel(columns[i].name)] = row[i];
@@ -992,6 +992,13 @@ class __SchemaDef {
992
992
  // a non-enumerable accessor so order.user_id and order.userId read
993
993
  // the same slot — useful when DB column names leak into user code
994
994
  // via raw SQL helpers.
995
+ //
996
+ // Temporal values arrive already decoded by the adapter: @rip-lang/db
997
+ // decodes date/datetime columns to real \`Date\` at the wire boundary
998
+ // (naive TIMESTAMP is read as UTC), so hydrate stores them verbatim —
999
+ // do NOT add a decode here. That keeps a single decode seam and honors
1000
+ // the \`date\`/\`datetime\` -> \`Date\` .d.ts contract. (\`_coerceDates\` below
1001
+ // is parse-only, for JSON/HTTP input — it never runs on this path.)
995
1002
  const data = {};
996
1003
  for (let i = 0; i < columns.length; i++) {
997
1004
  data[__schemaCamel(columns[i].name)] = row[i];
package/src/typecheck.js CHANGED
@@ -13,7 +13,7 @@
13
13
  import { Compiler, getStdlibCode } from './compiler.js';
14
14
  import { Lexer } from './lexer.js';
15
15
  import { STDLIB_TYPE_DECLS } from './stdlib.js';
16
- 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
+ import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN, ripDestructuredNames } from './dts.js';
17
17
  import './schema/loader-server.js'; // registers full schema runtime provider
18
18
  import { createRequire } from 'module';
19
19
  import { readFileSync, existsSync, readdirSync } from 'fs';
@@ -407,25 +407,28 @@ export function validatePropDefault(type, defVal) {
407
407
  // A string literal in either quote style: "a" or 'a'.
408
408
  const isStrLit = s => /^"[^"]*"$/.test(s) || /^'[^']*'$/.test(s);
409
409
  const strInner = s => s.slice(1, -1);
410
+ // The literal kind of a default, or null when it isn't a recognizable literal
411
+ // (an identifier, call, or other expression). Only literal defaults can be
412
+ // judged here; a non-literal carries no statically known type, so it is left
413
+ // to the type checker and never flagged.
414
+ const litKind = s =>
415
+ isStrLit(s) ? 'string' :
416
+ /^-?\d+(\.\d+)?$/.test(s) ? 'number' :
417
+ (s === 'true' || s === 'false') ? 'boolean' : null;
410
418
  const parts = type.split('|').map(s => s.trim());
411
- // String literal union: "a" | "b" | "c" (either quote style)
419
+ // String literal union: "a" | "b" | "c" (either quote style). Only a literal
420
+ // default naming no member is wrong; a non-literal default is left alone.
412
421
  if (parts.every(p => isStrLit(p))) {
413
422
  if (isStrLit(defVal) && !parts.some(p => strInner(p) === strInner(defVal))) {
414
423
  return `Type '${defVal}' is not assignable to type '${type}'`;
415
424
  }
416
425
  return null;
417
426
  }
418
- // Single type checks
419
- if (parts.length === 1) {
420
- const t = parts[0];
421
- if (t === 'boolean' && defVal !== 'true' && defVal !== 'false') {
422
- return `Type '${defVal}' is not assignable to type 'boolean'`;
423
- }
424
- if (t === 'number' && !/^-?\d+(\.\d+)?$/.test(defVal)) {
425
- return `Type '${defVal}' is not assignable to type 'number'`;
426
- }
427
- if (t === 'string' && !isStrLit(defVal)) {
428
- return `Type '${defVal}' is not assignable to type 'string'`;
427
+ // Single primitive type: flag only a literal default of a different kind.
428
+ if (parts.length === 1 && (parts[0] === 'boolean' || parts[0] === 'number' || parts[0] === 'string')) {
429
+ const kind = litKind(defVal);
430
+ if (kind && kind !== parts[0]) {
431
+ return `Type '${defVal}' is not assignable to type '${parts[0]}'`;
429
432
  }
430
433
  }
431
434
  return null;
@@ -536,25 +539,28 @@ export function parseComponentDTS(dtsString) {
536
539
  return result;
537
540
  }
538
541
 
539
- // Check component prop definitions for untyped props and invalid defaults.
542
+ // Check component prop definitions for invalid defaults.
540
543
  // Returns array of { line (0-based), col, len, message } error objects.
544
+ //
545
+ // Type annotations use a single ':' (e.g. `@count: number := 0`); `:=`/`=` is
546
+ // an untyped initializer whose type is inferred from the default, and `::` is
547
+ // prototype access — never a type. A typed prop with a `:= default` has its
548
+ // default validated against the annotation; untyped props carry no annotation
549
+ // to check against and are left alone.
541
550
  export function checkComponentDefs(compProps, srcLines, startLine = 0) {
542
551
  const errors = [];
543
552
  for (const prop of compProps) {
553
+ // `@name` + optional `?`, then a single `:` that is NOT part of `:=`.
554
+ const re = new RegExp('(@' + prop.name + ')\\b\\??\\s*:(?!=)\\s*(.+)$');
544
555
  for (let s = startLine; s < srcLines.length; s++) {
545
- const m = new RegExp('(@' + prop.name + ')\\??\\s*(::|([:!]?=))').exec(srcLines[s]);
556
+ const m = re.exec(srcLines[s]);
546
557
  if (!m) continue;
547
- if (m[1 + 1] !== '::') {
548
- errors.push({ line: s, col: m.index, len: m[1].length, propName: prop.name, message: `Prop '${prop.name}' has no type annotation` });
549
- } else {
550
- const dm = srcLines[s].match(new RegExp('@' + prop.name + '\\??\\s*::\\s*(.+?)\\s*:=\\s*(.+)'));
551
- if (dm) {
552
- const defVal = dm[2].replace(/#.*$/, '').trim();
553
- const err = validatePropDefault(dm[1].trim(), defVal);
554
- if (err) {
555
- errors.push({ line: s, col: m.index, len: m[1].length, propName: prop.name, message: err });
556
- }
557
- }
558
+ // Validate a `:= default` against the annotation, if one is present.
559
+ const dm = m[2].match(/^(.+?)\s*:=\s*(.+)$/);
560
+ if (dm) {
561
+ const defVal = stripDefaultComment(dm[2]).trim();
562
+ const err = validatePropDefault(dm[1].trim(), defVal);
563
+ if (err) errors.push({ line: s, col: m.index, len: m[1].length, message: err });
558
564
  }
559
565
  break;
560
566
  }
@@ -562,6 +568,19 @@ export function checkComponentDefs(compProps, srcLines, startLine = 0) {
562
568
  return errors;
563
569
  }
564
570
 
571
+ // Strip a trailing `# comment` from a default-value expression, ignoring a `#`
572
+ // inside a quoted string literal (e.g. a CSS color `'#fff'`).
573
+ function stripDefaultComment(s) {
574
+ let quote = null;
575
+ for (let i = 0; i < s.length; i++) {
576
+ const c = s[i];
577
+ if (quote) { if (c === quote) quote = null; }
578
+ else if (c === '"' || c === "'") quote = c;
579
+ else if (c === '#') return s.slice(0, i);
580
+ }
581
+ return s;
582
+ }
583
+
565
584
  // Patch uninitialized, untyped variables with inferred types from their
566
585
  // first assignment. This makes `let total; total = count + ratio;` behave
567
586
  // like `let total: number;` — so a later `total = "string"` is caught.
@@ -2078,10 +2097,6 @@ export function compileForCheck(filePath, source, compiler, opts = {}) {
2078
2097
  headerDts = parts.join('\n') + '\n' + headerDts;
2079
2098
  }
2080
2099
 
2081
- if (hasTypes && /\bARIA\./.test(code) && !/\bdeclare const ARIA\b/.test(headerDts)) {
2082
- headerDts = ARIA_TYPE_DECLS.join('\n') + '\n' + headerDts;
2083
- }
2084
-
2085
2100
  // Inline hoisted `let` declarations at their first assignment in the shadow
2086
2101
  // TS file, then merge DTS header types into the inlined declarations.
2087
2102
  //
@@ -3523,6 +3538,113 @@ export function readProjectConfig(dir) {
3523
3538
  return config;
3524
3539
  }
3525
3540
 
3541
+ // Resolve a project's ambient `.d.ts` includes — both explicit and automatic —
3542
+ // to a deduped list of absolute paths, added as explicit TS program roots
3543
+ // (appended to the language-service host's getScriptFileNames). An explicit
3544
+ // root-file `.d.ts` contributes its global `declare`s to every other file in the
3545
+ // program regardless of the `types` compiler option (like tsconfig `files`), so
3546
+ // this is how a project pulls in a shared ambient contract without a per-file
3547
+ // `/// <reference>`. Two mechanisms feed it:
3548
+ //
3549
+ // ① explicit — `package.json#rip.types: ["path/to/x.d.ts", ...]`. The escape
3550
+ // hatch for ad-hoc / non-dependency `.d.ts`; paths resolve relative to the
3551
+ // project root.
3552
+ // ② automatic — every declared `@rip-lang/*` dependency that ships ambient
3553
+ // types (its own `package.json#rip.ambient: ["x.d.ts", ...]`) has those
3554
+ // files auto-included, resolved relative to the dependency's installed
3555
+ // location. So depending on `@rip-lang/app` makes its global `ARIA` contract
3556
+ // "just there" with zero config — "you used the framework, so its types are
3557
+ // present."
3558
+ //
3559
+ // Deduped by absolute path (an explicit `rip.types` pointing at a file a
3560
+ // dependency already provides isn't included twice). Missing/unresolvable
3561
+ // entries are warned and skipped, never fatal.
3562
+ export function resolveTypeIncludes(ripConfig, rootPath) {
3563
+ const out = [];
3564
+ const seen = new Set();
3565
+ const add = (abs) => { if (abs && !seen.has(abs)) { seen.add(abs); out.push(abs); } };
3566
+
3567
+ // ① explicit rip.types
3568
+ const list = Array.isArray(ripConfig?.types) ? ripConfig.types : [];
3569
+ for (const entry of list) {
3570
+ if (typeof entry !== 'string' || !entry) continue;
3571
+ const abs = resolve(rootPath, entry);
3572
+ if (existsSync(abs)) add(abs);
3573
+ else console.warn(`[rip] rip.types include not found, skipping: ${entry}`);
3574
+ }
3575
+
3576
+ // ② auto-include ambient .d.ts from declared @rip-lang/* dependencies
3577
+ for (const abs of collectDependencyAmbients(rootPath)) add(abs);
3578
+
3579
+ return out;
3580
+ }
3581
+
3582
+ // Walk the project's declared `@rip-lang/*` dependencies (deps + devDeps +
3583
+ // peerDeps + optionalDeps) and collect the ambient `.d.ts` files each one
3584
+ // advertises via its own `package.json#rip.ambient`. Each file is resolved
3585
+ // relative to the dependency's resolved package directory (handles workspace
3586
+ // symlinks and real node_modules alike). Returns absolute paths of files that
3587
+ // exist; everything else is skipped — a declared-but-missing ambient file warns,
3588
+ // while an unresolvable dependency is silent (its absence surfaces through the
3589
+ // undeclared-import / module-resolution paths, not here).
3590
+ export function collectDependencyAmbients(rootPath) {
3591
+ const out = [];
3592
+ let projPkg;
3593
+ try {
3594
+ const projPkgPath = resolve(rootPath, 'package.json');
3595
+ if (!existsSync(projPkgPath)) return out;
3596
+ projPkg = JSON.parse(readFileSync(projPkgPath, 'utf8'));
3597
+ } catch (e) {
3598
+ console.warn(`[rip] ambient dep scan: cannot read project package.json: ${e.message}`);
3599
+ return out;
3600
+ }
3601
+ const deps = [...new Set([
3602
+ ...Object.keys(projPkg.dependencies || {}),
3603
+ ...Object.keys(projPkg.devDependencies || {}),
3604
+ ...Object.keys(projPkg.peerDependencies || {}),
3605
+ ...Object.keys(projPkg.optionalDependencies || {}),
3606
+ ])].filter(name => name.startsWith('@rip-lang/'));
3607
+ if (deps.length === 0) return out;
3608
+
3609
+ const req = createRequire(resolve(rootPath, 'package.json'));
3610
+ for (const dep of deps) {
3611
+ const depPkgJson = resolveDepPackageJson(dep, rootPath, req);
3612
+ if (!depPkgJson) continue; // not installed/resolvable — not ours to report
3613
+ let depPkg;
3614
+ try { depPkg = JSON.parse(readFileSync(depPkgJson, 'utf8')); } catch { continue; }
3615
+ const ambient = depPkg?.rip?.ambient;
3616
+ if (!Array.isArray(ambient)) continue;
3617
+ const depDir = dirname(depPkgJson);
3618
+ for (const rel of ambient) {
3619
+ if (typeof rel !== 'string' || !rel) continue;
3620
+ const abs = resolve(depDir, rel);
3621
+ if (existsSync(abs)) out.push(abs);
3622
+ else console.warn(`[rip] ${dep} declares ambient '${rel}' but it was not found, skipping`);
3623
+ }
3624
+ }
3625
+ return out;
3626
+ }
3627
+
3628
+ // Locate a dependency's package.json. Prefer Node/Bun module resolution
3629
+ // (`require.resolve('<dep>/package.json')`); fall back to walking node_modules up
3630
+ // from the project root — robust against `exports` maps that don't list
3631
+ // `./package.json`, and against either runtime (CLI/Bun or the LSP/Node).
3632
+ function resolveDepPackageJson(dep, rootPath, req) {
3633
+ try {
3634
+ const r = req.resolve(`${dep}/package.json`);
3635
+ if (typeof r === 'string' && existsSync(r)) return r;
3636
+ } catch {}
3637
+ let dir = resolve(rootPath);
3638
+ while (true) {
3639
+ const cand = resolve(dir, 'node_modules', dep, 'package.json');
3640
+ if (existsSync(cand)) return cand;
3641
+ const parent = dirname(dir);
3642
+ if (parent === dir) break;
3643
+ dir = parent;
3644
+ }
3645
+ return null;
3646
+ }
3647
+
3526
3648
  // ── CLI batch type-checker ─────────────────────────────────────────
3527
3649
 
3528
3650
  // Convert a simple glob pattern to a RegExp for matching relative paths.
@@ -3602,6 +3724,7 @@ export async function runCheck(targetDir, opts = {}) {
3602
3724
  const checkAll = ripConfig.checkAll === true;
3603
3725
  const excludeGlobs = Array.isArray(ripConfig.exclude) ? ripConfig.exclude : [];
3604
3726
  const excludePatterns = excludeGlobs.map(globToRegex);
3727
+ const typeIncludes = resolveTypeIncludes(ripConfig, rootPath);
3605
3728
 
3606
3729
  const allFiles = findRipFiles(rootPath, [], excludePatterns);
3607
3730
  if (allFiles.length === 0) {
@@ -3875,7 +3998,7 @@ export async function runCheck(targetDir, opts = {}) {
3875
3998
  });
3876
3999
 
3877
4000
  const host = {
3878
- getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
4001
+ getScriptFileNames: () => [...[...compiled.keys()].map(toVirtual), ...typeIncludes],
3879
4002
  getScriptVersion: () => '1',
3880
4003
  getScriptSnapshot(f) {
3881
4004
  const c = compiled.get(fromVirtual(f));
@@ -4071,11 +4194,11 @@ export async function runCheck(targetDir, opts = {}) {
4071
4194
  }
4072
4195
  }
4073
4196
 
4074
- // Untyped component prop checking — flag props without :: annotation
4197
+ // Component prop checking — validate prop default values against their types
4075
4198
  if (entry.dts) {
4076
- for (const [compName, compInfo] of parseComponentDTS(entry.dts)) {
4199
+ for (const [, compInfo] of parseComponentDTS(entry.dts)) {
4077
4200
  for (const e of checkComponentDefs(compInfo.props, srcLines)) {
4078
- errors.push({ line: e.line + 1, col: e.col + 1, len: e.len, message: e.message.includes('type annotation') ? `Prop '${e.propName}' on component ${compName} has no type annotation` : e.message, severity: 'error', code: 'rip', srcLine: srcLines[e.line], related: [] });
4201
+ errors.push({ line: e.line + 1, col: e.col + 1, len: e.len, message: e.message, severity: 'error', code: 'rip', srcLine: srcLines[e.line], related: [] });
4079
4202
  totalErrors++;
4080
4203
  }
4081
4204
  }
@@ -4663,6 +4786,7 @@ export async function runAudit(targetDir) {
4663
4786
  }
4664
4787
 
4665
4788
  const { typeRoots, types: ambientTypes } = collectAmbientTypes(rootPath);
4789
+ const typeIncludes = resolveTypeIncludes(pkg.rip, rootPath);
4666
4790
  const settings = createTypeCheckSettings(ts, {
4667
4791
  strict: true,
4668
4792
  ...(typeRoots.length ? { typeRoots } : {}),
@@ -4670,7 +4794,7 @@ export async function runAudit(targetDir) {
4670
4794
  });
4671
4795
 
4672
4796
  const host = {
4673
- getScriptFileNames: () => [...compiled.keys()].map(toVirtual),
4797
+ getScriptFileNames: () => [...[...compiled.keys()].map(toVirtual), ...typeIncludes],
4674
4798
  getScriptVersion: () => '1',
4675
4799
  getScriptSnapshot(f) {
4676
4800
  const c = compiled.get(fromVirtual(f));
package/src/types.js CHANGED
@@ -14,6 +14,44 @@
14
14
 
15
15
  import { RipError } from './error.js';
16
16
 
17
+ // ============================================================================
18
+ // `expr as Type` cast — recognition sets
19
+ // ============================================================================
20
+ //
21
+ // The cast is a contextual postfix operator: a bare `as` (already an
22
+ // IDENTIFIER here — the lexer emits AS / FORAS / FORASAWAIT for the
23
+ // import/export and for-loop cases, so an `as` that survives as an
24
+ // IDENTIFIER is never one of those) glued to a value on its left and a type
25
+ // on its right. We recognize it purely from token shape: the previous token
26
+ // must be able to END an expression, and the next must be able to START a
27
+ // type. Detection runs in rewriteTypes(), BEFORE addImplicitBracesAndParens(),
28
+ // so `x as Foo` is still three bare identifiers (not yet `x(as(Foo))`).
29
+
30
+ // Tokens whose token can be the tail of the cast's left-hand expression.
31
+ // CAST is included so chains (`x as A as B`) work: the first cast collapses
32
+ // to `<value> CAST`, and the second `as` sees CAST as its left edge.
33
+ const CAST_LHS_ENDERS = new Set([
34
+ 'IDENTIFIER', 'PROPERTY', 'NUMBER', 'STRING', 'STRING_END',
35
+ 'REGEX', 'REGEX_END', 'BOOL', 'NULL', 'UNDEFINED', 'INFINITY', 'NAN', 'JS',
36
+ ')', 'CALL_END', 'PARAM_END', ']', 'INDEX_END', '}',
37
+ 'THIS', 'SUPER', '?', 'PRESENCE', 'CAST',
38
+ ]);
39
+
40
+ // Tokens that can begin a type expression on the cast's right-hand side.
41
+ const CAST_TYPE_STARTERS = new Set([
42
+ 'IDENTIFIER', '(', 'CALL_START', 'PARAM_START', '{', '[', 'INDEX_START',
43
+ 'STRING', 'STRING_START', 'NUMBER', 'BOOL', 'NULL', 'UNDEFINED', '-', 'UNARY',
44
+ ]);
45
+
46
+ // Does the `as` token at index i look like a cast (vs. a stray identifier)?
47
+ function isCastAs(tokens, i) {
48
+ let prev = tokens[i - 1];
49
+ if (!prev || prev[0] === '.' || prev[0] === '?.') return false;
50
+ if (!CAST_LHS_ENDERS.has(prev[0])) return false;
51
+ let next = tokens[i + 1];
52
+ return !!next && CAST_TYPE_STARTERS.has(next[0]);
53
+ }
54
+
17
55
  // ============================================================================
18
56
  // installTypeSupport — adds rewriteTypes() to Lexer.prototype
19
57
  // ============================================================================
@@ -60,6 +98,45 @@ export function installTypeSupport(Lexer) {
60
98
  this.scanTokens((token, i, tokens) => {
61
99
  let tag = token[0];
62
100
 
101
+ // ── `expr as Type` cast — postfix type assertion (route A) ──────────
102
+ // A bare `as` glued to a value on its left and a type on its right. The
103
+ // Type RHS is collected as a string (the same machinery as `::`
104
+ // annotations) and the whole `as Type` run COLLAPSES into a single
105
+ // in-stream CAST marker token carrying that string as its value. The
106
+ // grammar reduces `Expression CAST` to `['cast', expr, typeStr]` — so
107
+ // the parser sees a structural postfix node (like `?`/`!`) but never a
108
+ // type. The compiler erases it at runtime and re-materializes it as a
109
+ // real TS `(expr as Type)` only in the shadow-TS check path. Because the
110
+ // marker is a real token, the reduction fires AFTER the full postfix
111
+ // expression is built, so call/index/paren results narrow too.
112
+ // Chaining (`x as A as B`) emits one CAST per `as` (`x CAST CAST`); the
113
+ // grammar's left-associativity nests them: `['cast',['cast',x,'A'],'B']`.
114
+ if (tag === 'IDENTIFIER' && token[1] === 'as' && !token.data?.bang &&
115
+ isCastAs(tokens, i)) {
116
+ let typeTokens = collectTypeExpression(tokens, i + 1, { castContext: true });
117
+ if (typeTokens.length > 0) {
118
+ let typeStr = buildTypeString(typeTokens);
119
+ for (let tt of typeTokens) {
120
+ if (tt[0] === 'IDENTIFIER') typeRefNames.add(tt[1]);
121
+ }
122
+ let castTok = gen('CAST', typeStr, token);
123
+ tokens.splice(i, 1 + typeTokens.consumed, castTok);
124
+ // A trailing `>` (or other "unfinished" tail) makes the lexer
125
+ // suppress the newline after the type, so once the `as Type` run is
126
+ // collapsed the statement can collide with the next line. If what now
127
+ // follows the marker sits on a later row and isn't already a
128
+ // separator, restore the TERMINATOR the lexer ate.
129
+ let follow = tokens[i + 1];
130
+ if (follow && follow[0] !== 'TERMINATOR' && follow[0] !== 'INDENT' &&
131
+ follow[0] !== 'CAST' &&
132
+ follow.loc?.r != null && castTok.loc?.r != null &&
133
+ follow.loc.r > castTok.loc.r) {
134
+ tokens.splice(i + 1, 0, gen('TERMINATOR', '\n', follow));
135
+ }
136
+ return 1; // advance past the CAST marker (a chained `as` follows it)
137
+ }
138
+ }
139
+
63
140
  // ── Generic type parameters: DEF name<T>(...) or Name<T> = component ──
64
141
  // (Generic params on type aliases are handled by the `type` keyword handler below)
65
142
  if (tag === 'IDENTIFIER') {
@@ -595,6 +672,38 @@ function reclassifyColonTypes(tokens) {
595
672
  return atStatementStart(tokens[i - 2]);
596
673
  };
597
674
 
675
+ // Is this top-level `=` the assignment of a `type Name [<generics>] =`
676
+ // declaration? Its RHS is a whole type expression, so we arm `inType` there —
677
+ // otherwise a braced type literal RHS (`type X = { f: (e: Event) => void }`,
678
+ // incl. intersections `… & { … }`) reaches the field colon at bracket-depth
679
+ // ≥ 1, where none of the `:`-reclassify branches (all `stack.length === 0`)
680
+ // fire, so `inType` is never armed and the function-type's `(e: Event)` param
681
+ // is wrongly reclassified — putting a synthetic `::` inside a structural type
682
+ // literal. Mirrors `isDefParamStart`'s backward generic skip. (The indented
683
+ // `type T =` ⏎ INDENT form is unaffected: it disarms `inType` at the INDENT
684
+ // and is collected per-field by `collectStructuralType`.)
685
+ const isTypeDeclEq = (i) => {
686
+ let j = i - 1;
687
+ const g0 = tokens[j]?.[0], v0 = tokens[j]?.[1];
688
+ if ((g0 === 'COMPARE' && v0 === '>') || (g0 === 'SHIFT' && v0 === '>>')) {
689
+ let depth = g0 === 'SHIFT' ? 2 : 1;
690
+ j--;
691
+ while (j >= 0 && depth > 0) {
692
+ const g = tokens[j][0], v = tokens[j][1];
693
+ if (g === 'COMPARE' && v === '>') depth++;
694
+ else if (g === 'SHIFT' && v === '>>') depth += 2;
695
+ else if (g === 'COMPARE' && v === '<') depth--;
696
+ else if (g === 'SHIFT' && v === '<<') depth -= 2;
697
+ j--;
698
+ }
699
+ }
700
+ // tokens[j] is the type name, preceded by the `type` contextual keyword at
701
+ // a statement boundary (the same shape the `type Name =` collector matches).
702
+ if (tokens[j]?.[0] !== 'IDENTIFIER') return false;
703
+ if (!(tokens[j - 1]?.[0] === 'IDENTIFIER' && tokens[j - 1]?.[1] === 'type')) return false;
704
+ return atStatementStart(tokens[j - 2]);
705
+ };
706
+
598
707
  // A type expression (opened by any `::` / TYPE_ANNOTATION, or by a `:` we
599
708
  // reclassify) must never have its interior touched: a function type like
600
709
  // `{ cb: (r: R) => void }` carries single `:` colons that are TS member/param
@@ -660,6 +769,16 @@ function reclassifyColonTypes(tokens) {
660
769
  // Commit per-line key:value state at statement boundaries (depth 0).
661
770
  if (tag === 'TERMINATOR' && stack.length === 0) { prevSiblingKV = curLineKV; curLineKV = false; }
662
771
 
772
+ // Type-alias RHS: `type Name [<generics>] = <type>`. The whole RHS is a
773
+ // type expression — arm `inType` so its interior colons (a braced literal's
774
+ // field `:`, a function-type param's `:`) are left as TS separators rather
775
+ // than reclassified. Disarms at the RHS terminator / on exiting its depth,
776
+ // like every other `enterType`. A value-position `=` (`x = { a: 1 }`) is not
777
+ // a type decl, so `isTypeDeclEq` rejects it and object literals stay intact.
778
+ if (tag === '=' && stack.length === 0 && !inType && isTypeDeclEq(i)) {
779
+ enterType(); continue;
780
+ }
781
+
663
782
  if (tag === ':') {
664
783
  const prev = tokens[i - 1];
665
784
  const prevTag = prev?.[0];
@@ -829,6 +948,25 @@ function collectTypeExpression(tokens, j, opts = {}) {
829
948
  break;
830
949
  }
831
950
 
951
+ // Cast context: a cast's type lives on one logical line, but the lexer
952
+ // suppresses the newline after a trailing `>` (a COMPARE token is
953
+ // "unfinished"), so no TERMINATOR separates `x as Map<K, V>` from the next
954
+ // statement. Stop at a depth-0 row change so the collector can't run past
955
+ // end-of-line into the following code (the `Map<K, V> nextStmt` footgun).
956
+ if (opts.castContext && depth === 0 && j > startJ) {
957
+ let prevRow = tokens[j - 1]?.loc?.r;
958
+ let curRow = t.loc?.r;
959
+ if (prevRow != null && curRow != null && curRow > prevRow) break;
960
+ }
961
+
962
+ // Cast context: a chained cast (`x as A as B`) — the type RHS is just `A`;
963
+ // the second `as` starts a new cast on the result. Stop here so each `as`
964
+ // becomes its own CAST marker and the grammar nests them left-to-right.
965
+ // (`as` never appears inside a real type expression, so this is safe.)
966
+ if (opts.castContext && depth === 0 && tTag === 'IDENTIFIER' && t[1] === 'as') {
967
+ break;
968
+ }
969
+
832
970
  // Delimiters that end the type at depth 0
833
971
  if (depth === 0) {
834
972
  // Arrow-return context: a depth-0 `=>` is the arrow OPERATOR, so it ends
@@ -855,6 +993,21 @@ function collectTypeExpression(tokens, j, opts = {}) {
855
993
  tTag === '->' || tTag === ',') {
856
994
  break;
857
995
  }
996
+ // Cast context (`expr as Type`): the type RHS lives inside a larger
997
+ // expression, so it must also end at any depth-0 binary/relational/
998
+ // ternary operator. `|` and `&` are NOT stops — they're union /
999
+ // intersection type operators (TS reads everything after `as` as a
1000
+ // type, so `x as A & B` is `x as (A & B)`). COMPARE's `<`/`>` never
1001
+ // reach here (handled as generic brackets above), leaving only
1002
+ // `== != <= >=`, which do stop.
1003
+ if (opts.castContext && (
1004
+ tTag === '+' || tTag === '-' || tTag === 'MATH' || tTag === '**' ||
1005
+ tTag === 'SHIFT' || tTag === 'COMPARE' || tTag === '&&' ||
1006
+ tTag === '||' || tTag === '??' || tTag === '^' ||
1007
+ tTag === 'RELATION' || tTag === 'TERNARY' || tTag === '?' ||
1008
+ tTag === 'PRESENCE' || tTag === ':')) {
1009
+ break;
1010
+ }
858
1011
  }
859
1012
 
860
1013
  // Inside a bracketed type expression, INDENT/OUTDENT/TERMINATOR are