rip-lang 3.13.133 → 3.13.135

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,15 @@ 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;
787
917
  }
788
918
 
789
919
  let tsContent = (hasTypes ? headerDts + '\n' : '') + code;
@@ -805,6 +935,20 @@ export function compileForCheck(filePath, source, compiler) {
805
935
  for (let i = 0; i < dtsLines.length; i++) {
806
936
  const line = dtsLines[i];
807
937
 
938
+ // Map import lines by module path (from "..." or from '...')
939
+ const importMatch = line.match(/^import\s+.+\s+from\s+['"]([^'"]+)['"]/);
940
+ if (importMatch) {
941
+ const modulePath = importMatch[1];
942
+ for (let s = 0; s < srcLines.length; s++) {
943
+ if (srcLines[s].includes(modulePath) && /^import\s/.test(srcLines[s].trimStart())) {
944
+ genToSrc.set(i, s);
945
+ srcToGen.set(s, i);
946
+ break;
947
+ }
948
+ }
949
+ continue;
950
+ }
951
+
808
952
  const m = line.match(/^(?:export\s+)?(?:declare\s+)?(?:let|var|type|interface|enum|class)\s+(\w+)/);
809
953
  if (!m) continue;
810
954
  const name = m[1];
@@ -935,13 +1079,73 @@ export function mapToSource(entry, offset) {
935
1079
  return tsLine - entry.headerLines;
936
1080
  }
937
1081
 
1082
+ // ── Project config ─────────────────────────────────────────────────
1083
+
1084
+ // Read project config: rip.json in the given directory, or "rip" key in
1085
+ // the nearest ancestor package.json. Returns { strict, exclude }.
1086
+ export function readProjectConfig(dir) {
1087
+ const config = {};
1088
+ try {
1089
+ let d = resolve(dir);
1090
+ while (true) {
1091
+ const ripJsonPath = resolve(d, 'rip.json');
1092
+ if (existsSync(ripJsonPath)) {
1093
+ Object.assign(config, JSON.parse(readFileSync(ripJsonPath, 'utf8')));
1094
+ config._configDir = d;
1095
+ break;
1096
+ }
1097
+ const pkgPath = resolve(d, 'package.json');
1098
+ if (existsSync(pkgPath)) {
1099
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
1100
+ if (pkg.rip && typeof pkg.rip === 'object') { Object.assign(config, pkg.rip); config._configDir = d; break; }
1101
+ }
1102
+ const parent = dirname(d);
1103
+ if (parent === d) break;
1104
+ d = parent;
1105
+ }
1106
+ } catch {}
1107
+ return config;
1108
+ }
1109
+
938
1110
  // ── CLI batch type-checker ─────────────────────────────────────────
939
1111
 
940
- function findRipFiles(dir, files = []) {
1112
+ // Convert a simple glob pattern to a RegExp for matching relative paths.
1113
+ // Supports: ** (any path segments), * (any within segment), ? (single char).
1114
+ export function globToRegex(pattern) {
1115
+ let re = '';
1116
+ let i = 0;
1117
+ while (i < pattern.length) {
1118
+ const c = pattern[i];
1119
+ if (c === '*' && pattern[i + 1] === '*') {
1120
+ re += '.*';
1121
+ i += 2;
1122
+ if (pattern[i] === '/') i++; // skip trailing slash after **
1123
+ } else if (c === '*') {
1124
+ re += '[^/]*';
1125
+ i++;
1126
+ } else if (c === '?') {
1127
+ re += '[^/]';
1128
+ i++;
1129
+ } else if (c === '.') {
1130
+ re += '\\.';
1131
+ i++;
1132
+ } else {
1133
+ re += c;
1134
+ i++;
1135
+ }
1136
+ }
1137
+ return new RegExp('^' + re + '$');
1138
+ }
1139
+
1140
+ function findRipFiles(dir, files = [], excludePatterns = [], rootDir = dir) {
941
1141
  for (const entry of readdirSync(dir, { withFileTypes: true })) {
942
1142
  if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
943
1143
  const full = resolve(dir, entry.name);
944
- if (entry.isDirectory()) findRipFiles(full, files);
1144
+ if (excludePatterns.length > 0) {
1145
+ const rel = relative(rootDir, full);
1146
+ if (excludePatterns.some(p => p.test(rel))) continue;
1147
+ }
1148
+ if (entry.isDirectory()) findRipFiles(full, files, excludePatterns, rootDir);
945
1149
  else if (entry.name.endsWith('.rip')) files.push(full);
946
1150
  }
947
1151
  return files;
@@ -973,23 +1177,31 @@ export async function runCheck(targetDir, opts = {}) {
973
1177
  return 1;
974
1178
  }
975
1179
 
976
- const allFiles = findRipFiles(rootPath);
1180
+ const ripConfig = readProjectConfig(rootPath);
1181
+
1182
+ // Merge: CLI flags override config file
1183
+ const strict = opts.strict || ripConfig.strict === true;
1184
+ const excludeGlobs = Array.isArray(ripConfig.exclude) ? ripConfig.exclude : [];
1185
+ const excludePatterns = excludeGlobs.map(globToRegex);
1186
+
1187
+ const allFiles = findRipFiles(rootPath, [], excludePatterns);
977
1188
  if (allFiles.length === 0) {
978
1189
  console.error(red(`No .rip files found in ${targetDir}`));
979
1190
  return 1;
980
1191
  }
981
1192
 
982
1193
  // 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.
1194
+ // In strict mode, all non-nocheck files are type-checked.
984
1195
  const typedFiles = new Set();
985
1196
  const sourcesByPath = new Map();
986
1197
  for (const fp of allFiles) {
987
1198
  const source = readFileSync(fp, 'utf8');
988
1199
  sourcesByPath.set(fp, source);
989
- const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, 256));
990
- if (!nocheck && hasTypeAnnotations(source)) typedFiles.add(fp);
1200
+ const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
1201
+ if (!nocheck && (hasTypeAnnotations(source) || strict)) typedFiles.add(fp);
991
1202
  }
992
- // Include imports of typed files
1203
+
1204
+ // Include imports of typed files (files imported BY typed files)
993
1205
  for (const fp of typedFiles) {
994
1206
  const source = sourcesByPath.get(fp);
995
1207
  const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
@@ -1003,6 +1215,17 @@ export async function runCheck(targetDir, opts = {}) {
1003
1215
  }
1004
1216
  }
1005
1217
  }
1218
+ // Include files that import FROM typed files (consumers of typed modules)
1219
+ for (const [fp, source] of sourcesByPath) {
1220
+ if (typedFiles.has(fp)) continue;
1221
+ const nocheck = /^#\s*@nocheck\b/m.test(source.slice(0, NOCHECK_SCAN_LIMIT));
1222
+ if (nocheck) continue;
1223
+ const ripImports = [...source.matchAll(/from\s+['"]([^'"]*\.rip)['"]/g)];
1224
+ for (const m of ripImports) {
1225
+ const imported = resolve(dirname(fp), m[1]);
1226
+ if (typedFiles.has(imported)) { typedFiles.add(fp); break; }
1227
+ }
1228
+ }
1006
1229
 
1007
1230
  // Compile only typed files (and their imports)
1008
1231
  const compiled = new Map();
@@ -1011,7 +1234,7 @@ export async function runCheck(targetDir, opts = {}) {
1011
1234
  for (const fp of typedFiles) {
1012
1235
  try {
1013
1236
  const source = sourcesByPath.get(fp);
1014
- compiled.set(fp, compileForCheck(fp, source, new Compiler()));
1237
+ compiled.set(fp, compileForCheck(fp, source, new Compiler(), { strict }));
1015
1238
  } catch (e) {
1016
1239
  compileErrors++;
1017
1240
  const rel = relative(rootPath, fp);
@@ -1120,12 +1343,15 @@ export async function runCheck(targetDir, opts = {}) {
1120
1343
  if (d.start === undefined) continue;
1121
1344
  if (SKIP_CODES.has(d.code)) continue;
1122
1345
 
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;
1346
+ // Conditional suppression narrowed instead of blanket
1347
+ if (CONDITIONAL_CODES.has(d.code)) {
1348
+ const flatMsg = d.code === 2307 ? ts.flattenDiagnosticMessageText(d.messageText, '\n') : null;
1349
+ if (shouldSuppressConditional(d.code, d.start, d.length, entry.tsContent, entry.headerLines, entry.dts, flatMsg, fp)) continue;
1127
1350
  }
1128
1351
 
1352
+ // Skip 6133 on compiler-generated _render() construction variables (_0, _1, …)
1353
+ if ((d.code === 6133 || d.code === 6196) && isRenderConstructionVar(entry.tsContent, d.start, d.length)) continue;
1354
+
1129
1355
  // Skip diagnostics on injected overload signatures — the real function
1130
1356
  // definition already carries the same diagnostic.
1131
1357
  if (isInjectedOverload(entry, d.start)) continue;
@@ -1133,6 +1359,10 @@ export async function runCheck(targetDir, opts = {}) {
1133
1359
  const pos = mapToSourcePos(entry, d.start);
1134
1360
  if (!pos) continue;
1135
1361
 
1362
+ // Drop diagnostics that map beyond the source file (e.g. from component
1363
+ // stubs where the compiled line has no real source counterpart).
1364
+ if (pos.line >= srcLines.length) continue;
1365
+
1136
1366
  // Remap IIFE-switch diagnostics to the enclosing function declaration
1137
1367
  const adj = adjustSwitchDiagnostic(entry.source, pos, d.code);
1138
1368
  if (adj) { pos.line = adj.line; pos.col = adj.col; }
@@ -1285,7 +1515,9 @@ export async function runCheck(targetDir, opts = {}) {
1285
1515
  console.log('');
1286
1516
  const lineNum = String(e.line);
1287
1517
  console.log(`${lineNum} ${e.srcLine}`);
1288
- console.log(`${' '.repeat(lineNum.length)} ${' '.repeat(e.col - 1)}${red('~'.repeat(e.len))}`);
1518
+ const pad = Math.max(0, e.col - 1);
1519
+ const underline = Math.max(1, e.len);
1520
+ console.log(`${' '.repeat(lineNum.length)} ${' '.repeat(pad)}${red('~'.repeat(underline))}`);
1289
1521
  }
1290
1522
 
1291
1523
  if (e.related) {