rip-lang 3.13.134 → 3.13.136

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/src/typecheck.js CHANGED
@@ -11,6 +11,7 @@
11
11
  // type errors mapped back to Rip source positions.
12
12
 
13
13
  import { Compiler, getStdlibCode } from './compiler.js';
14
+ import { INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL, ARIA_TYPE_DECLS, SIGNAL_INTERFACE, SIGNAL_FN, COMPUTED_INTERFACE, COMPUTED_FN, EFFECT_FN } from './types.js';
14
15
  import { createRequire } from 'module';
15
16
  import { readFileSync, existsSync, readdirSync } from 'fs';
16
17
  import { mapToSourcePos, offsetToLine, getLineText, findNearestWord, lineColToOffset, offsetToLineCol, adjustSwitchDiagnostic, isInjectedOverload } from './sourcemap-utils.js';
@@ -32,7 +33,7 @@ export function hasTypeAnnotations(source) {
32
33
  line = line.replace(/#.*$/, '');
33
34
  // Strip string literals (single and double quoted)
34
35
  line = line.replace(/"(?:[^"\\]|\\.)*"/g, '""').replace(/'(?:[^'\\]|\\.)*'/g, "''");
35
- return /::[ \t=]/.test(line);
36
+ return /::[ \t=]/.test(line) || /(?:^|export\s+)type\s+[A-Z]/.test(line.trimStart());
36
37
  });
37
38
  }
38
39
 
@@ -118,25 +119,29 @@ export function parseComponentDTS(dtsString) {
118
119
  const name = cm[1];
119
120
  const props = [];
120
121
  let hasIntrinsicProps = false;
122
+ let inheritsTag = null;
121
123
  let j = i + 1;
122
124
  while (j < lines.length) {
123
125
  if (/^\}/.test(lines[j])) break;
124
126
  if (/constructor\(props\??/.test(lines[j])) {
125
127
  if (lines[j].includes('__RipProps<')) hasIntrinsicProps = true;
128
+ const tagMatch = lines[j].match(/__RipProps<'(\w+)'>/);
129
+ if (tagMatch) inheritsTag = tagMatch[1];
126
130
  if (!lines[j].includes('{')) { j++; continue; }
127
131
  j++;
128
132
  while (j < lines.length) {
129
133
  if (lines[j].includes('__RipProps<')) hasIntrinsicProps = true;
134
+ if (!inheritsTag) { const tm = lines[j].match(/__RipProps<'(\w+)'>/); if (tm) inheritsTag = tm[1]; }
130
135
  if (/^\s*\}\s*(?:&\s*.+)?\);\s*$/.test(lines[j])) { j++; break; }
131
136
  const pm = lines[j].match(/^\s+(\w+)(\?)?\s*:\s*(.+);$/);
132
- if (pm) props.push({ name: pm[1], type: pm[3].trim(), required: !pm[2] });
137
+ if (pm && !pm[1].startsWith('__bind_')) props.push({ name: pm[1], type: pm[3].trim(), required: !pm[2] });
133
138
  j++;
134
139
  }
135
140
  continue;
136
141
  }
137
142
  j++;
138
143
  }
139
- if (props.length || hasIntrinsicProps) result.set(name, { props, hasIntrinsicProps });
144
+ if (props.length || hasIntrinsicProps) result.set(name, { props, hasIntrinsicProps, inheritsTag });
140
145
  i = Math.max(i + 1, j);
141
146
  }
142
147
  return result;
@@ -188,49 +193,186 @@ export function patchUninitializedTypes(ts, service, compiledEntries) {
188
193
  }
189
194
  }
190
195
  }
191
- if (ts.isExpressionStatement(stmt) && ts.isBinaryExpression(stmt.expression) &&
192
- stmt.expression.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
193
- ts.isIdentifier(stmt.expression.left)) {
194
- const name = stmt.expression.left.text;
195
- const sym = uninitialized.get(name);
196
- if (sym) {
197
- const rhsType = checker.getTypeAtLocation(stmt.expression.right);
198
- sym.flags |= ts.SymbolFlags.Transient;
199
- sym.links = { type: rhsType };
200
- uninitialized.delete(name);
201
- }
202
- }
203
- // Recurse into function bodies
196
+ patchAssignment(stmt, uninitialized);
197
+ // Recurse into function bodies (fresh scope)
204
198
  if (ts.isFunctionDeclaration(stmt) && stmt.body) {
205
199
  patchStatements(stmt.body.statements);
206
200
  }
207
201
  }
208
202
  }
209
203
 
210
- for (const [filePath] of compiledEntries) {
204
+ // Walk a statement (and nested blocks) looking for first assignments
205
+ // to uninitialized variables. Shares the outer scope's map so that
206
+ // assignments inside if/for/while/try/switch are discovered.
207
+ function patchAssignment(stmt, uninitialized) {
208
+ if (!uninitialized.size) return;
209
+ // Unwrap parenthesized expressions: ({a, b} = ...) → {a, b} = ...
210
+ const unwrap = (e) => ts.isParenthesizedExpression(e) ? unwrap(e.expression) : e;
211
+ if (ts.isExpressionStatement(stmt)) {
212
+ const expr = unwrap(stmt.expression);
213
+ if (ts.isBinaryExpression(expr) && expr.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
214
+ if (ts.isIdentifier(expr.left)) {
215
+ const name = expr.left.text;
216
+ const sym = uninitialized.get(name);
217
+ if (sym) {
218
+ const rhsType = checker.getTypeAtLocation(expr.right);
219
+ sym.flags |= ts.SymbolFlags.Transient;
220
+ sym.links = { type: rhsType };
221
+ uninitialized.delete(name);
222
+ }
223
+ } else if (ts.isObjectLiteralExpression(expr.left) || ts.isArrayLiteralExpression(expr.left)) {
224
+ // Destructuring assignment: ({a, b} = {a: 1, b: "hello"})
225
+ // Walk the LHS pattern and patch each identifier from the RHS type.
226
+ const rhsType = checker.getTypeAtLocation(expr.right);
227
+ const patchDestructured = (pattern, contextType) => {
228
+ if (ts.isObjectLiteralExpression(pattern)) {
229
+ for (const prop of pattern.properties) {
230
+ if (ts.isShorthandPropertyAssignment(prop) && ts.isIdentifier(prop.name)) {
231
+ const name = prop.name.text;
232
+ const sym = uninitialized.get(name);
233
+ if (sym) {
234
+ const propSym = contextType.getProperty(name);
235
+ if (propSym) {
236
+ const propType = checker.getTypeOfSymbol(propSym);
237
+ sym.flags |= ts.SymbolFlags.Transient;
238
+ sym.links = { type: propType };
239
+ uninitialized.delete(name);
240
+ }
241
+ }
242
+ } else if (ts.isPropertyAssignment(prop) && ts.isIdentifier(prop.initializer)) {
243
+ const name = prop.initializer.text;
244
+ const sym = uninitialized.get(name);
245
+ if (sym) {
246
+ const key = ts.isIdentifier(prop.name) ? prop.name.text : undefined;
247
+ const propSym = key && contextType.getProperty(key);
248
+ if (propSym) {
249
+ const propType = checker.getTypeOfSymbol(propSym);
250
+ sym.flags |= ts.SymbolFlags.Transient;
251
+ sym.links = { type: propType };
252
+ uninitialized.delete(name);
253
+ }
254
+ }
255
+ }
256
+ }
257
+ } else if (ts.isArrayLiteralExpression(pattern)) {
258
+ const tupleTypes = checker.getTypeArguments(contextType);
259
+ for (let i = 0; i < pattern.elements.length; i++) {
260
+ const el = pattern.elements[i];
261
+ if (ts.isIdentifier(el)) {
262
+ const name = el.text;
263
+ const sym = uninitialized.get(name);
264
+ if (sym && tupleTypes && tupleTypes[i]) {
265
+ sym.flags |= ts.SymbolFlags.Transient;
266
+ sym.links = { type: tupleTypes[i] };
267
+ uninitialized.delete(name);
268
+ }
269
+ }
270
+ }
271
+ }
272
+ };
273
+ patchDestructured(expr.left, rhsType);
274
+ }
275
+ }
276
+ }
277
+ // Recurse into block-containing statements (but not functions — those get their own scope)
278
+ const walkBlock = (node) => { if (node) { if (ts.isBlock(node)) node.statements.forEach(s => patchAssignment(s, uninitialized)); else patchAssignment(node, uninitialized); } };
279
+ if (ts.isIfStatement(stmt)) { walkBlock(stmt.thenStatement); walkBlock(stmt.elseStatement); }
280
+ if (ts.isForStatement(stmt) || ts.isForInStatement(stmt) || ts.isForOfStatement(stmt) || ts.isWhileStatement(stmt) || ts.isDoStatement(stmt)) walkBlock(stmt.statement);
281
+ if (ts.isTryStatement(stmt)) { walkBlock(stmt.tryBlock); if (stmt.catchClause) walkBlock(stmt.catchClause.block); if (stmt.finallyBlock) walkBlock(stmt.finallyBlock); }
282
+ if (ts.isSwitchStatement(stmt)) { for (const clause of stmt.caseBlock.clauses) clause.statements.forEach(s => patchAssignment(s, uninitialized)); }
283
+ if (ts.isBlock(stmt)) stmt.statements.forEach(s => patchAssignment(s, uninitialized));
284
+ }
285
+
286
+ for (const [filePath, entry] of compiledEntries) {
287
+ if (!entry.hasTypes) continue; // @ts-nocheck files skip diagnostics; no need to patch
211
288
  const sf = program.getSourceFile(toVirtual(filePath));
212
289
  if (!sf) continue;
213
- patchStatements(sf.statements);
290
+ try { patchStatements(sf.statements); } catch (e) {
291
+ console.warn(`[rip] patchTypes failed for ${filePath}: ${e.message}`);
292
+ }
214
293
  }
215
294
  }
216
295
 
217
- // TS error codes to skip — Rip resolves modules differently and
218
- // treats async return types transparently.
296
+ // TS error codes to skip — structural artifacts of Rip's compilation model
297
+ // (DTS coexisting with compiled bodies, overload patterns, etc.)
219
298
  export const SKIP_CODES = new Set([
220
- 2300, // Duplicate identifier (DTS declarations coexist with compiled class bodies)
221
- 2307, // Cannot find module
222
299
  2389, // Function implementation name must match overload (DTS + compiled body)
223
300
  2391, // Function implementation is missing (DTS overload sigs separated from implementations)
224
301
  2393, // Duplicate function implementation
225
302
  2394, // Overload signature not compatible with implementation (untyped compiled params)
226
- 2451, // Cannot redeclare block-scoped variable
227
303
  2567, // Enum declarations can only merge with namespace or other enum (DTS + compiled body)
228
304
  2842, // Unused renaming of destructured property (DTS overload has renamed param unused in declaration)
229
305
  1064, // Return type of async function must be Promise
230
- 2582, // Cannot find name 'test' (test runner globals)
231
- 2593, // Cannot find name 'describe' (test runner globals)
232
306
  ]);
233
307
 
308
+ // Codes that need conditional suppression (not blanket).
309
+ // 2300/2451: Suppress only when one endpoint is in the DTS header (structural).
310
+ // Let through when both endpoints are in the compiled body (real shadowing).
311
+ // 2307: Suppress only for @rip-lang/* and .rip imports (Rip resolves these).
312
+ // Let through for genuinely broken npm/JS imports.
313
+ // 2582/2593: Suppress only in test files (test runner globals).
314
+ // Let through in non-test files so typos like `test(...)` are caught.
315
+ export const CONDITIONAL_CODES = new Set([2300, 2451, 2307, 2582, 2593]);
316
+
317
+ // Shared conditional suppression logic — used by both CLI (runCheck) and LSP
318
+ // (publishDiagnostics). Returns true if the diagnostic should be suppressed.
319
+ //
320
+ // Parameters:
321
+ // code — TS diagnostic code (e.g. 2300, 2451, 2307)
322
+ // start — byte offset in the virtual .ts content
323
+ // length — byte length of the diagnostic span
324
+ // tsContent — the full virtual .ts content
325
+ // headerLines — number of header lines in the virtual .ts
326
+ // dts — the .d.ts content (for identifier checks)
327
+ // flatMessage — flattened diagnostic message string (only needed for 2307)
328
+ // filePath — original .rip file path (only needed for 2582/2593)
329
+ export function shouldSuppressConditional(code, start, length, tsContent, headerLines, dts, flatMessage, filePath) {
330
+ if (code === 2300 || code === 2451) {
331
+ // Duplicate identifier: suppress when one endpoint is in the DTS header.
332
+ const diagLine = offsetToLine(tsContent, start);
333
+ if (diagLine < headerLines) return true; // diagnostic is on the header declaration
334
+ // Body-side: check if the identifier also lives in the DTS header
335
+ const ident = length ? tsContent.substring(start, start + length).trim() : '';
336
+ if (ident && dts) {
337
+ const escaped = ident.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
338
+ if (new RegExp('\\b' + escaped + '\\b').test(dts)) return true; // structural — DTS vs body
339
+ }
340
+ return false; // Identifier not in DTS → real shadowing bug
341
+ }
342
+ if (code === 2307) {
343
+ // Cannot find module: suppress only for @rip-lang/* and .rip imports
344
+ const modMatch = flatMessage?.match(/Cannot find module '([^']+)'/);
345
+ if (modMatch) {
346
+ const mod = modMatch[1];
347
+ if (mod.startsWith('@rip-lang/') || mod.endsWith('.rip')) return true;
348
+ }
349
+ return false; // Genuine broken import
350
+ }
351
+ if (code === 2582 || code === 2593) {
352
+ // test/describe globals: suppress only in test files
353
+ if (!filePath) return false;
354
+ const base = filePath.replace(/.*[\/]/, '');
355
+ if (/test|spec/i.test(base)) return true;
356
+ if (/[\/](test|tests|spec|specs|__tests__)[\/]/i.test(filePath)) return true;
357
+ return false;
358
+ }
359
+ return false;
360
+ }
361
+
362
+ // Maximum byte offset to scan for a `# @nocheck` comment at the top of a file.
363
+ // Long shebangs, license headers, or multi-line comments could push the directive
364
+ // further down, but 512 bytes covers typical preambles without scanning entire files.
365
+ export const NOCHECK_SCAN_LIMIT = 512;
366
+
367
+ // Check whether a diagnostic targets a compiler-generated _render() construction
368
+ // variable (_0, _1, …). These typed constants exist solely for prop type-checking
369
+ // and are never read — unused-variable diagnostics on them are spurious.
370
+ export function isRenderConstructionVar(tsContent, start, length) {
371
+ if (!length) return false;
372
+ const span = tsContent.substring(start, start + length);
373
+ return /^_\d+$/.test(span.trim());
374
+ }
375
+
234
376
  // Clean diagnostic messages to hide Rip compiler internals from users.
235
377
  // Strips Signal<T>/Computed<T> wrappers, __bind_X__ property names, and
236
378
  // removes __bind_*__ entries from inline type displays.
@@ -253,7 +395,12 @@ export function cleanDiagnosticMessage(msg) {
253
395
  // Rewrite verbose __ripEl tag union mismatch into a clean JSX-like message
254
396
  msg = msg.replace(
255
397
  /Argument of type '"([\w-]+)"' is not assignable to parameter of type '(?:__RipTag|[^']*\bkeyof HTMLElementTagNameMap\b[^']*)'\./,
256
- "'$1' is not a known HTML or SVG element."
398
+ "'$1' is not a known element."
399
+ );
400
+ // Rewrite verbose __RipTag constraint error (e.g. component extends unknown element)
401
+ msg = msg.replace(
402
+ /Type '"([\w-]+)"' does not satisfy the constraint '(?:__RipTag|[^']*\bkeyof HTMLElementTagNameMap\b[^']*)'\./,
403
+ "'$1' is not a known element."
257
404
  );
258
405
  // Deduplicate consecutive identical lines (unwrapping can collapse nested messages)
259
406
  msg = msg.split('\n').filter((line, i, arr) => i === 0 || line.trim() !== arr[i - 1].trim()).join('\n');
@@ -325,15 +472,17 @@ function replaceFnParams(line, newParams) {
325
472
  // Compile a .rip file for type-checking. Prepends DTS declarations to
326
473
  // compiled JS, detects type annotations, and builds bidirectional
327
474
  // source maps. Returns everything both the CLI and LSP need.
328
- export function compileForCheck(filePath, source, compiler) {
475
+ // When opts.strict is true, all non-nocheck files are type-checked.
476
+ export function compileForCheck(filePath, source, compiler, opts = {}) {
329
477
  const result = compiler.compile(source, { sourceMap: true, types: 'emit', skipPreamble: true, stubComponents: true });
330
478
  let code = result.code || '';
331
479
  const dts = result.dts ? result.dts.trimEnd() + '\n' : '';
332
480
 
333
481
  // Determine if this file should be type-checked.
334
482
  // A `# @nocheck` comment near the top of the file opts out entirely.
335
- const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, 256));
336
- const hasOwnTypes = !nocheck && hasTypeAnnotations(source);
483
+ // In strict mode, all non-nocheck files are type-checked.
484
+ const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
485
+ const hasOwnTypes = !nocheck && (hasTypeAnnotations(source) || !!opts.strict);
337
486
  let importsTyped = false;
338
487
  if (!hasOwnTypes && !nocheck) {
339
488
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -414,7 +563,12 @@ export function compileForCheck(filePath, source, compiler) {
414
563
  // First pass: copy typed params AND return types from signatures to
415
564
  // implementations. Typed params give TS type info inside function bodies;
416
565
  // return types let TS verify the body matches the declared return.
417
- for (const inj of injections) {
566
+ // When multiple sigs target the same codeLine (overloads), only apply
567
+ // from the last one — that's the implementation signature whose types
568
+ // should annotate the function body.
569
+ const lastByLine = new Map();
570
+ for (const inj of injections) lastByLine.set(inj.codeLine, inj);
571
+ for (const inj of lastByLine.values()) {
418
572
  const sig = inj.sig.replace(/^declare /, '');
419
573
  const sigParams = extractFnParams(sig);
420
574
  if (sigParams !== null) {
@@ -512,6 +666,9 @@ export function compileForCheck(filePath, source, compiler) {
512
666
  if (cl[k].match(/^(?:export\s+)?(?:class|const)\s+\w+/) && k > j + 1) break;
513
667
  const fm = cl[k].match(/^\s+(?:declare\s+)?(\w+):\s+.+;$/);
514
668
  if (fm) existingFields.add(fm[1]);
669
+ // Also match field assignments (e.g. `name = __computed(...)` in component stubs)
670
+ const am = cl[k].match(/^\s+(\w+)\s*=\s+/);
671
+ if (am) existingFields.add(am[1]);
515
672
  if (cl[k].match(/^\s+_init\s*\(/)) break;
516
673
  }
517
674
  const missingFields = info.fields.filter(f => !existingFields.has(f.name));
@@ -692,14 +849,14 @@ export function compileForCheck(filePath, source, compiler) {
692
849
  if (needSignal || needComputed || needEffect) {
693
850
  const decls = [];
694
851
  if (needSignal) {
695
- if (!/\binterface Signal\b/.test(headerDts)) decls.push('interface Signal<T> { value: T; read(): T; lock(): Signal<T>; free(): Signal<T>; kill(): T; }');
696
- decls.push('declare function __state<T>(value: T | Signal<T>): Signal<T>;');
852
+ if (!/\binterface Signal\b/.test(headerDts)) decls.push(SIGNAL_INTERFACE);
853
+ decls.push(SIGNAL_FN);
697
854
  }
698
855
  if (needComputed) {
699
- if (!/\binterface Computed\b/.test(headerDts)) decls.push('interface Computed<T> { readonly value: T; read(): T; lock(): Computed<T>; free(): Computed<T>; kill(): T; }');
700
- decls.push('declare function __computed<T>(fn: () => T): Computed<T>;');
856
+ if (!/\binterface Computed\b/.test(headerDts)) decls.push(COMPUTED_INTERFACE);
857
+ decls.push(COMPUTED_FN);
701
858
  }
702
- if (needEffect) decls.push('declare function __effect(fn: () => void | (() => void)): () => void;');
859
+ if (needEffect) decls.push(EFFECT_FN);
703
860
  headerDts = decls.join('\n') + '\n' + headerDts;
704
861
  }
705
862
  }
@@ -748,42 +905,68 @@ export function compileForCheck(filePath, source, compiler) {
748
905
  // Inject intrinsic element type declarations for render block type-checking.
749
906
  // Uses TypeScript's built-in DOM types (HTMLElementTagNameMap, etc.) as the
750
907
  // source of truth for tag names, attribute types, and event handler types.
908
+ // Skip type aliases if the DTS already includes them (types.js prepends for component files).
751
909
  if (hasTypes && (/\b__ripEl\b/.test(code) || /\b__RipProps\b/.test(headerDts))) {
752
- const intrinsicDecls = [
753
- 'type __RipElementMap = HTMLElementTagNameMap & Omit<SVGElementTagNameMap, keyof HTMLElementTagNameMap>;',
754
- 'type __RipTag = keyof __RipElementMap;',
755
- "type __RipBrowserElement = Omit<HTMLElement, 'querySelector' | 'querySelectorAll' | 'closest' | 'setAttribute' | 'hidden'> & { hidden: boolean | 'until-found'; setAttribute(qualifiedName: string, value: any): void; querySelector(selectors: string): __RipBrowserElement | null; querySelectorAll(selectors: string): NodeListOf<__RipBrowserElement>; closest(selectors: string): __RipBrowserElement | null; };",
756
- "type __RipDomEl<K extends __RipTag> = Omit<__RipElementMap[K], 'querySelector' | 'querySelectorAll' | 'closest' | 'setAttribute' | 'hidden'> & __RipBrowserElement;",
757
- "type __RipAttrKeys<T> = { [K in keyof T]-?: K extends 'style' ? never : T[K] extends (...args: any[]) => any ? never : K }[keyof T] & string;",
758
- 'type __RipEvents = { [K in keyof HTMLElementEventMap as `@${K}`]?: ((event: HTMLElementEventMap[K]) => void) | null };',
759
- 'type __RipClassValue = string | boolean | null | undefined | Record<string, boolean> | __RipClassValue[];',
760
- 'type __RipProps<K extends __RipTag> = { [P in __RipAttrKeys<__RipElementMap[K]>]?: __RipElementMap[K][P] } & __RipEvents & { ref?: string; class?: __RipClassValue | __RipClassValue[]; style?: string; [k: `data-${string}`]: any; [k: `aria-${string}`]: any };',
761
- 'declare function __ripEl<K extends __RipTag>(tag: K, props?: __RipProps<K>): void;',
762
- ];
763
- headerDts = intrinsicDecls.join('\n') + '\n' + headerDts;
910
+ const alreadyHasTypes = /\btype __RipElementMap\b/.test(headerDts);
911
+ const parts = alreadyHasTypes ? [INTRINSIC_FN_DECL] : [...INTRINSIC_TYPE_DECLS, INTRINSIC_FN_DECL];
912
+ headerDts = parts.join('\n') + '\n' + headerDts;
764
913
  }
765
914
 
766
915
  if (hasTypes && /\bARIA\./.test(code) && !/\bdeclare const ARIA\b/.test(headerDts)) {
767
- const ariaDecls = [
768
- 'type __RipAriaNavHandlers = { next?: () => void; prev?: () => void; first?: () => void; last?: () => void; select?: () => void; dismiss?: () => void; tab?: () => void; char?: () => void; };',
769
- "declare const ARIA: {",
770
- " bindPopover(open: boolean, popover: () => Element | null | undefined, setOpen: (isOpen: boolean) => void, source?: (() => Element | null | undefined) | null): void;",
771
- " bindDialog(open: boolean, dialog: () => Element | null | undefined, setOpen: (isOpen: boolean) => void, dismissable?: boolean): void;",
772
- " popupDismiss(open: boolean, popup: () => Element | null | undefined, close: () => void, els?: Array<() => Element | null | undefined>, repos?: (() => void) | null): void;",
773
- " popupGuard(delay?: number): any;",
774
- " listNav(event: KeyboardEvent, handlers: __RipAriaNavHandlers): void;",
775
- " rovingNav(event: KeyboardEvent, handlers: __RipAriaNavHandlers, orientation?: 'vertical' | 'horizontal' | 'both'): void;",
776
- " positionBelow(trigger: Element | null | undefined, popup: Element | null | undefined, gap?: number, setVisible?: boolean): void;",
777
- " position(trigger: Element | null | undefined, floating: Element | null | undefined, opts?: any): void;",
778
- " trapFocus(panel: Element | null | undefined): void;",
779
- " wireAria(panel: Element, id: string): void;",
780
- " lockScroll(instance: any): void;",
781
- " unlockScroll(instance: any): void;",
782
- " hasAnchor: boolean;",
783
- " [key: string]: any;",
784
- "};",
785
- ];
786
- headerDts = ariaDecls.join('\n') + '\n' + headerDts;
916
+ headerDts = ARIA_TYPE_DECLS.join('\n') + '\n' + headerDts;
917
+ }
918
+
919
+ // Inline hoisted `let` declarations at their first assignment in the shadow
920
+ // TS file. Rip always hoists `let` to the top of scope for correct JS
921
+ // scoping. For TypeScript inference, we move the declaration inline so TS
922
+ // can infer types from initializers (e.g., `let x; x = 1` → `let x = 1`).
923
+ // Only rewrites same-scope, straight-line assignments — stops at control
924
+ // flow to avoid changing binding visibility in the shadow file.
925
+ if (hasTypes && code) {
926
+ const cl = code.split('\n');
927
+ for (let i = 0; i < cl.length; i++) {
928
+ const m = cl[i].match(/^(\s*)let\s+([A-Za-z_$][\w$]*(?:\s*,\s*[A-Za-z_$][\w$]*)*)\s*;\s*$/);
929
+ if (!m) continue;
930
+ // Only process hoist-position lets (first non-blank line after `{` or start of file)
931
+ let prev = null;
932
+ for (let k = i - 1; k >= 0; k--) { if (cl[k].trim() !== '') { prev = cl[k]; break; } }
933
+ if (prev !== null && !/\{\s*$/.test(prev)) continue;
934
+
935
+ const indent = m[1];
936
+ const vars = m[2].split(/\s*,\s*/);
937
+ const inlined = new Set();
938
+ const bailed = new Set();
939
+ const reEsc = s => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
940
+
941
+ for (let j = i + 1; j < cl.length; j++) {
942
+ const line = cl[j];
943
+ if (line.trim() === '') continue;
944
+ // End of scope
945
+ if (new RegExp('^' + reEsc(indent) + '}').test(line)) break;
946
+ // Skip deeper-indented lines
947
+ if (line.startsWith(indent + ' ')) continue;
948
+ // Stop at structural statements
949
+ if (new RegExp('^' + reEsc(indent) + '(?:if\\b|for\\b|while\\b|switch\\b|try\\b|catch\\b|finally\\b|function\\b|class\\b|do\\b|\\{|\\})').test(line)) break;
950
+
951
+ for (const v of vars) {
952
+ if (inlined.has(v) || bailed.has(v)) continue;
953
+ const ve = reEsc(v);
954
+ const assignRe = new RegExp('^' + reEsc(indent) + ve + '\\s*=\\s*(.*);\\s*$');
955
+ const assign = line.match(assignRe);
956
+ if (assign) {
957
+ cl[j] = `${indent}let ${v} = ${assign[1]};`;
958
+ inlined.add(v);
959
+ continue;
960
+ }
961
+ if (new RegExp('\\b' + ve + '\\b').test(line)) bailed.add(v);
962
+ }
963
+ if (vars.every(v => inlined.has(v) || bailed.has(v))) break;
964
+ }
965
+
966
+ const remaining = vars.filter(v => !inlined.has(v));
967
+ cl[i] = remaining.length ? `${indent}let ${remaining.join(', ')};` : '';
968
+ }
969
+ code = cl.filter(l => l !== '').join('\n');
787
970
  }
788
971
 
789
972
  let tsContent = (hasTypes ? headerDts + '\n' : '') + code;
@@ -805,6 +988,20 @@ export function compileForCheck(filePath, source, compiler) {
805
988
  for (let i = 0; i < dtsLines.length; i++) {
806
989
  const line = dtsLines[i];
807
990
 
991
+ // Map import lines by module path (from "..." or from '...')
992
+ const importMatch = line.match(/^import\s+.+\s+from\s+['"]([^'"]+)['"]/);
993
+ if (importMatch) {
994
+ const modulePath = importMatch[1];
995
+ for (let s = 0; s < srcLines.length; s++) {
996
+ if (srcLines[s].includes(modulePath) && /^import\s/.test(srcLines[s].trimStart())) {
997
+ genToSrc.set(i, s);
998
+ srcToGen.set(s, i);
999
+ break;
1000
+ }
1001
+ }
1002
+ continue;
1003
+ }
1004
+
808
1005
  const m = line.match(/^(?:export\s+)?(?:declare\s+)?(?:let|var|type|interface|enum|class)\s+(\w+)/);
809
1006
  if (!m) continue;
810
1007
  const name = m[1];
@@ -935,13 +1132,73 @@ export function mapToSource(entry, offset) {
935
1132
  return tsLine - entry.headerLines;
936
1133
  }
937
1134
 
1135
+ // ── Project config ─────────────────────────────────────────────────
1136
+
1137
+ // Read project config: rip.json in the given directory, or "rip" key in
1138
+ // the nearest ancestor package.json. Returns { strict, exclude }.
1139
+ export function readProjectConfig(dir) {
1140
+ const config = {};
1141
+ try {
1142
+ let d = resolve(dir);
1143
+ while (true) {
1144
+ const ripJsonPath = resolve(d, 'rip.json');
1145
+ if (existsSync(ripJsonPath)) {
1146
+ Object.assign(config, JSON.parse(readFileSync(ripJsonPath, 'utf8')));
1147
+ config._configDir = d;
1148
+ break;
1149
+ }
1150
+ const pkgPath = resolve(d, 'package.json');
1151
+ if (existsSync(pkgPath)) {
1152
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1153
+ if (pkg.rip && typeof pkg.rip === 'object') { Object.assign(config, pkg.rip); config._configDir = d; break; }
1154
+ }
1155
+ const parent = dirname(d);
1156
+ if (parent === d) break;
1157
+ d = parent;
1158
+ }
1159
+ } catch {}
1160
+ return config;
1161
+ }
1162
+
938
1163
  // ── CLI batch type-checker ─────────────────────────────────────────
939
1164
 
940
- function findRipFiles(dir, files = []) {
1165
+ // Convert a simple glob pattern to a RegExp for matching relative paths.
1166
+ // Supports: ** (any path segments), * (any within segment), ? (single char).
1167
+ export function globToRegex(pattern) {
1168
+ let re = '';
1169
+ let i = 0;
1170
+ while (i < pattern.length) {
1171
+ const c = pattern[i];
1172
+ if (c === '*' && pattern[i + 1] === '*') {
1173
+ re += '.*';
1174
+ i += 2;
1175
+ if (pattern[i] === '/') i++; // skip trailing slash after **
1176
+ } else if (c === '*') {
1177
+ re += '[^/]*';
1178
+ i++;
1179
+ } else if (c === '?') {
1180
+ re += '[^/]';
1181
+ i++;
1182
+ } else if (c === '.') {
1183
+ re += '\\.';
1184
+ i++;
1185
+ } else {
1186
+ re += c;
1187
+ i++;
1188
+ }
1189
+ }
1190
+ return new RegExp('^' + re + '$');
1191
+ }
1192
+
1193
+ function findRipFiles(dir, files = [], excludePatterns = [], rootDir = dir) {
941
1194
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
942
1195
  if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
943
1196
  const full = resolve(dir, entry.name);
944
- if (entry.isDirectory()) findRipFiles(full, files);
1197
+ if (excludePatterns.length > 0) {
1198
+ const rel = relative(rootDir, full);
1199
+ if (excludePatterns.some(p => p.test(rel))) continue;
1200
+ }
1201
+ if (entry.isDirectory()) findRipFiles(full, files, excludePatterns, rootDir);
945
1202
  else if (entry.name.endsWith('.rip')) files.push(full);
946
1203
  }
947
1204
  return files;
@@ -973,23 +1230,31 @@ export async function runCheck(targetDir, opts = {}) {
973
1230
  return 1;
974
1231
  }
975
1232
 
976
- const allFiles = findRipFiles(rootPath);
1233
+ const ripConfig = readProjectConfig(rootPath);
1234
+
1235
+ // Merge: CLI flags override config file
1236
+ const strict = opts.strict || ripConfig.strict === true;
1237
+ const excludeGlobs = Array.isArray(ripConfig.exclude) ? ripConfig.exclude : [];
1238
+ const excludePatterns = excludeGlobs.map(globToRegex);
1239
+
1240
+ const allFiles = findRipFiles(rootPath, [], excludePatterns);
977
1241
  if (allFiles.length === 0) {
978
1242
  console.error(red(`No .rip files found in ${targetDir}`));
979
1243
  return 1;
980
1244
  }
981
1245
 
982
1246
  // Pre-scan: only compile files that have type annotations or are imported by typed files.
983
- // This avoids compiling hundreds of untyped files that would just get @ts-nocheck.
1247
+ // In strict mode, all non-nocheck files are type-checked.
984
1248
  const typedFiles = new Set();
985
1249
  const sourcesByPath = new Map();
986
1250
  for (const fp of allFiles) {
987
1251
  const source = readFileSync(fp, 'utf8');
988
1252
  sourcesByPath.set(fp, source);
989
- const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, 256));
990
- if (!nocheck && hasTypeAnnotations(source)) typedFiles.add(fp);
1253
+ const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
1254
+ if (!nocheck && (hasTypeAnnotations(source) || strict)) typedFiles.add(fp);
991
1255
  }
992
- // Include imports of typed files
1256
+
1257
+ // Include imports of typed files (files imported BY typed files)
993
1258
  for (const fp of typedFiles) {
994
1259
  const source = sourcesByPath.get(fp);
995
1260
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -1003,6 +1268,17 @@ export async function runCheck(targetDir, opts = {}) {
1003
1268
  }
1004
1269
  }
1005
1270
  }
1271
+ // Include files that import FROM typed files (consumers of typed modules)
1272
+ for (const [fp, source] of sourcesByPath) {
1273
+ if (typedFiles.has(fp)) continue;
1274
+ const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
1275
+ if (nocheck) continue;
1276
+ const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
1277
+ for (const m of ripImports) {
1278
+ const imported = resolve(dirname(fp), m[1]);
1279
+ if (typedFiles.has(imported)) { typedFiles.add(fp); break; }
1280
+ }
1281
+ }
1006
1282
 
1007
1283
  // Compile only typed files (and their imports)
1008
1284
  const compiled = new Map();
@@ -1011,7 +1287,7 @@ export async function runCheck(targetDir, opts = {}) {
1011
1287
  for (const fp of typedFiles) {
1012
1288
  try {
1013
1289
  const source = sourcesByPath.get(fp);
1014
- compiled.set(fp, compileForCheck(fp, source, new Compiler()));
1290
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { strict }));
1015
1291
  } catch (e) {
1016
1292
  compileErrors++;
1017
1293
  const rel = relative(rootPath, fp);
@@ -1120,12 +1396,15 @@ export async function runCheck(targetDir, opts = {}) {
1120
1396
  if (d.start === undefined) continue;
1121
1397
  if (SKIP_CODES.has(d.code)) continue;
1122
1398
 
1123
- // Skip 6133 on compiler-generated _render() construction variables (_0, _1, …)
1124
- if ((d.code === 6133 || d.code === 6196) && d.length > 0) {
1125
- const span = entry.tsContent.substring(d.start, d.start + d.length);
1126
- if (/^_\d+$/.test(span.trim())) continue;
1399
+ // Conditional suppression narrowed instead of blanket
1400
+ if (CONDITIONAL_CODES.has(d.code)) {
1401
+ const flatMsg = d.code === 2307 ? ts.flattenDiagnosticMessageText(d.messageText, '\n') : null;
1402
+ if (shouldSuppressConditional(d.code, d.start, d.length, entry.tsContent, entry.headerLines, entry.dts, flatMsg, fp)) continue;
1127
1403
  }
1128
1404
 
1405
+ // Skip 6133 on compiler-generated _render() construction variables (_0, _1, …)
1406
+ if ((d.code === 6133 || d.code === 6196) && isRenderConstructionVar(entry.tsContent, d.start, d.length)) continue;
1407
+
1129
1408
  // Skip diagnostics on injected overload signatures — the real function
1130
1409
  // definition already carries the same diagnostic.
1131
1410
  if (isInjectedOverload(entry, d.start)) continue;
@@ -1133,6 +1412,10 @@ export async function runCheck(targetDir, opts = {}) {
1133
1412
  const pos = mapToSourcePos(entry, d.start);
1134
1413
  if (!pos) continue;
1135
1414
 
1415
+ // Drop diagnostics that map beyond the source file (e.g. from component
1416
+ // stubs where the compiled line has no real source counterpart).
1417
+ if (pos.line >= srcLines.length) continue;
1418
+
1136
1419
  // Remap IIFE-switch diagnostics to the enclosing function declaration
1137
1420
  const adj = adjustSwitchDiagnostic(entry.source, pos, d.code);
1138
1421
  if (adj) { pos.line = adj.line; pos.col = adj.col; }
@@ -1285,7 +1568,9 @@ export async function runCheck(targetDir, opts = {}) {
1285
1568
  console.log('');
1286
1569
  const lineNum = String(e.line);
1287
1570
  console.log(`${lineNum} ${e.srcLine}`);
1288
- console.log(`${' '.repeat(lineNum.length)} ${' '.repeat(e.col - 1)}${red('~'.repeat(e.len))}`);
1571
+ const pad = Math.max(0, e.col - 1);
1572
+ const underline = Math.max(1, e.len);
1573
+ console.log(`${' '.repeat(lineNum.length)} ${' '.repeat(pad)}${red('~'.repeat(underline))}`);
1289
1574
  }
1290
1575
 
1291
1576
  if (e.related) {