diff --git a/js/eslint-runner.js b/js/eslint-runner.js index c9c6de1f..ce2e25d4 100644 --- a/js/eslint-runner.js +++ b/js/eslint-runner.js @@ -3,17 +3,8 @@ const { nodeView, _nodeViewRaw, NONE, effectiveTypeName, T, getChainExprIfOutermost } = require("./estree-adapter"); const { RuleMetadataIndex, DEFAULT_STRATEGY } = require("./rule-metadata"); -// Rules verified to work with the native type facade (js/ts-type-facade.js) at -// its current surface. ONLY these get a real `parserServices.program`; every -// other type-aware rule keeps `program: null` and skips gracefully — an -// incomplete facade would otherwise turn a clean skip into a crash for rules -// calling checker methods we haven't implemented. Grow this set as the facade -// gains surface and each rule is verified (tests/ts_facade_runner.js). -// Only rules whose report fires on a DEFINITE `any` are allowlisted: the native -// checker is incomplete (some refs resolve to Unknown), so a rule that fires on -// "type is NOT X" would false-positive on those. "fire on any" rules are robust -// because Unknown != Any → no spurious report. (Rules needing symbols/signatures/ -// property surface — argument/return/for-in-array — stay out until that's built.) +// Rules verified to work with the native type facade. Exported for the +// facade audit harness (ts_facade_runner.js); not used for runtime gating. const _TYPE_FACADE_RULES = new Set([ "@typescript-eslint/no-unsafe-member-access", "@typescript-eslint/no-unsafe-assignment", @@ -155,47 +146,18 @@ const _TYPE_FACADE_RULES = new Set([ "@typescript-eslint/require-array-sort-compare", // 8/16, 50% (PARTIAL: 0 FP; checker user-shadowed-Array guard fixed 2 FP) "@typescript-eslint/no-unnecessary-type-constraint", // 18/18, 100% (CLEAN: TSTypeParameterDeclaration via getAncestorsFor; arrow/method type_params materialized in Zig resolved_parents) "@typescript-eslint/no-unnecessary-qualifier", // 5/9, 56% (PARTIAL: 0 FP; ESTree scope-manager namespace resolution; FN on dotted-ns/enum-member/import-alias) + // ── Batch: remaining @typescript-eslint corpus rules (not yet audited) ── + "@typescript-eslint/no-for-in-array", + "@typescript-eslint/no-unnecessary-type-parameters", + "@typescript-eslint/no-unsafe-unary-minus", + "@typescript-eslint/no-unused-private-class-members", + "@typescript-eslint/non-nullable-type-assertion-style", + "@typescript-eslint/only-throw-error", + "@typescript-eslint/prefer-for-of", + "@typescript-eslint/prefer-promise-reject-errors", + "@typescript-eslint/prefer-readonly-parameter-types", + "@typescript-eslint/prefer-return-this-type", ]); -// Rule id whose create() is currently executing. The `program` getter on the -// light parserServices consults this: only an allowlisted rule reading -// `parserServices.program` (the original @typescript-eslint getParserServices -// reads it in create()) gets the native facade — everyone else gets null and -// skips, exactly as before. Gating here (not via a getParserServices -// monkey-patch) is required because that package blocks the deep import the -// patch needs AND rules capture the function by value, so the patch can't -// intercept. Set around each rule's create() by the runner. -// -// NOTE: this gates program reads during create() only. A rule that reads -// `parserServices.program` from a VISITOR (during the walk) sees null — some -// sonarjs rules do exactly that, and handing them the facade crashes them on -// methods we don't implement. So allowlist only rules whose visitors get types -// via `services.getTypeAtLocation` (ungated) rather than `services.program`. -// Visitor-time program access would need per-handler active-rule tracking. -let _activeRuleId = null; - -// Allowlisted type-facade rules may read `parserServices.program` inside their -// VISITORS (not just create()) — e.g. `checker.getTypeChecker()`, -// `isBuiltinSymbolLike(services.program, …)`. `_activeRuleId` is set during -// create() but reset before the walk, so visitor-time program access would see -// null. Wrap such a rule's handlers to restore `_activeRuleId` for the duration -// of each visitor call. Only allowlisted rules (rare) are wrapped, so the hot -// dispatch path and every other rule are untouched (their program stays null). -function _wrapVisitorsForFacade(visitors, ruleId) { - if (!visitors || !_TYPE_FACADE_RULES.has(ruleId)) return visitors; - for (const k in visitors) { - const h = visitors[k]; - if (typeof h === "function") { - visitors[k] = function _facadeRuleHandler(...a) { - const prev = _activeRuleId; - _activeRuleId = ruleId; - try { return h.apply(this, a); } - finally { _activeRuleId = prev; } - }; - } - } - return visitors; -} - // Monkey-patch @typescript-eslint/utils' getParserServices so rules that gate // on parserServices.esTreeNodeToTSNodeMap (TS-aware rules with the // allowWithoutFullTypeInformation=true flag) can run against ez's light @@ -295,17 +257,30 @@ function typeFacadeMod() { return _typeFacadeMod; } +// True when a case's parserOptions requested full type information (a TS +// project, projectService, or a pre-built program). Type-aware rules only run +// when this is present; without it ESLint's isRequiredParserServices bails, so +// the oracle produces no diagnostics for plain-JS cases. We gate the light +// facade's `program` on this to match. +function _parserOptionsHaveTypeInfo(po) { + if (!po || typeof po !== "object") return false; + return !!(po.projectService || po.project || po.program || + po.EXPERIMENTAL_useProjectService); +} + function _makeLightParserServices(sourceCode) { const synth = tsSynth(); const map = synth ? synth.buildEsTreeNodeToTSNodeMap(sourceCode.text) : null; - // The native facade is surfaced ONLY via the `program` getter below, gated by - // `_activeRuleId` so non-allowlisted rules (es-x / sonarjs etc.) see `program` - // as null — exactly the prior behavior — and never take a crashing type-aware - // path on our incomplete facade. let _facade; // undefined = not opened, null = unavailable, object = open function openFacade() { if (_facade !== undefined) return _facade; + // Type-aware rules see `program` only when the case requested type info + // (projectService / project / program). When the runner explicitly marks + // it absent, keep `program` null so type-aware rules (sonarjs, etc.) bail + // exactly like the oracle. `undefined` (non-differential entry points) + // preserves the facade's default-on behavior. + if (sourceCode._typeInfoRequested === false) { _facade = null; return null; } const mod = typeFacadeMod(); if (!mod || !mod.isAvailable()) { _facade = null; return null; } const lang = (sourceCode._ast && sourceCode._ast._lang != null) ? sourceCode._ast._lang : 1 /* ts */; @@ -323,28 +298,40 @@ function _makeLightParserServices(sourceCode) { return _facade; } + const _e2tMap = map || new WeakMap(); + // Synth TS nodes all carry ._estree pointing back to the originating ESTree + // node. Rules that reverse-lookup a synth TS node (e.g. no-misused-promises' + // isStaticMember check after returnsThenable) get the ESTree node via this + // fallback rather than undefined → crash. + const _t2eMap = new Proxy(new WeakMap(), { + get(target, prop, recv) { + if (prop === "get") return (k) => { + const v = target.get(k); + return v !== undefined ? v : (k && k._estree); + }; + return Reflect.get(target, prop, recv); + }, + }); + // Present NO TS node maps for a plain-JS file that did not request type + // information — exactly like the oracle's espree services. This makes + // type-aware plugins' getParserServices return null and bail (instead of + // seeing maps-present + program-null and throwing, e.g. eslint-plugin-n's + // no-sync). TypeScript files keep the maps even without a project (matching + // @typescript-eslint/parser, which always supplies the syntactic maps but + // a null program) so rules like naming-convention still get TS-shaped nodes. + // `undefined` _typeInfoRequested (non-differential entry points) keeps maps. + const _suppressTSMaps = () => { + if (sourceCode._typeInfoRequested !== false) return false; + const lang = sourceCode._ast && sourceCode._ast._lang; + return lang === 0 /* js */ || lang === 2 /* jsx */; + }; const base = { __ez_light__: true, - esTreeNodeToTSNodeMap: map || new WeakMap(), - // Synth TS nodes all carry ._estree pointing back to the originating ESTree - // node. Rules that reverse-lookup a synth TS node (e.g. no-misused-promises' - // isStaticMember check after returnsThenable) get the ESTree node via this - // fallback rather than undefined → crash. - tsNodeToESTreeNodeMap: new Proxy(new WeakMap(), { - get(target, prop, recv) { - if (prop === "get") return (k) => { - const v = target.get(k); - return v !== undefined ? v : (k && k._estree); - }; - return Reflect.get(target, prop, recv); - }, - }), + get esTreeNodeToTSNodeMap() { return _suppressTSMaps() ? null : _e2tMap; }, + get tsNodeToESTreeNodeMap() { return _suppressTSMaps() ? null : _t2eMap; }, emitDecoratorMetadata: false, experimentalDecorators: false, // Services-level type access used by rules / getConstrainedTypeAtLocation. - // Only an allowlisted rule ever holds this services object (a non-allowlisted - // rule's getParserServices throws on the null program below), so this can - // open the facade unconditionally. getTypeAtLocation(node) { const f = openFacade(); const t = f ? f.getTypeAtLocation(node) : undefined; @@ -425,16 +412,8 @@ function _makeLightParserServices(sourceCode) { // Close hook used by the runner after each file's walk. __ez_closeFacade() { if (_facade) { try { _facade.close(); } catch {} } _facade = undefined; }, }; - // `program` is null for everyone EXCEPT an allowlisted rule whose create() is - // currently running. That rule's original getParserServices sees a non-null - // program (the facade) and returns these services; every other rule sees null - // and skips — identical to the prior behavior, so no new crashes. Object.defineProperty(base, "program", { - get() { - if (!_TYPE_FACADE_RULES.has(_activeRuleId)) return null; - const f = openFacade(); - return f ? f.program : null; - }, + get() { const f = openFacade(); return f ? f.program : null; }, enumerable: true, configurable: true, }); return base; @@ -1660,6 +1639,7 @@ class SourceCode { // baked; only the lighter var-scope-name Map is still rebuilt here.) this._declSymIndex = null; this._varScopeNameIndex = null; + this._allNameScopeIndex = null; // _declVarsCache: per-lintSource memo for getDeclaredVariables(node) results. // Cleared every reset so eslintUsed mutations on cached Variables don't // leak across runs (Variables themselves are cached separately via _varCache). @@ -2880,6 +2860,7 @@ class SourceCode { const _shared = this._sharedCaches; if (_shared && _shared.varScopeNameIndex) { this._varScopeNameIndex = _shared.varScopeNameIndex; + this._allNameScopeIndex = _shared.allNameScopeIndex; return; } const ast = this._ast; @@ -2887,7 +2868,10 @@ class SourceCode { // needed — `_declSymsForNode` reads the typed-array slice directly. // We still build `_varScopeNameIndex` (small, var-only, used by the // duplicate-var-merge fallback in `_computeDeclaredVariables`). + // We also build `_allNameScopeIndex` (scopeId:name → symIds for ALL + // symbols) to support merging duplicate imports / type-alias + import. const varScopeNameIndex = new Map(); + const allNameScopeIndex = new Map(); if (ast._symDeclNodes && ast._symFlags && ast._symScopeIds) { const symFlags = ast._symFlags; const symScopeIds = ast._symScopeIds; @@ -2896,21 +2880,27 @@ class SourceCode { // Match `_ensureDeclSymIndex` legacy path: var-only symbols are // those with the var bit set and neither let nor const. const is_var_only = (flags & 0x01) !== 0 && (flags & 0x02) === 0 && (flags & 0x04) === 0; - if (!is_var_only) continue; const scopeId = symScopeIds[i]; const name = ast._symName(i); const key = scopeId + ':' + name; - let arr2 = varScopeNameIndex.get(key); - if (!arr2) { arr2 = []; varScopeNameIndex.set(key, arr2); } - arr2.push(i); + if (is_var_only) { + let arr2 = varScopeNameIndex.get(key); + if (!arr2) { arr2 = []; varScopeNameIndex.set(key, arr2); } + arr2.push(i); + } + let arr3 = allNameScopeIndex.get(key); + if (!arr3) { arr3 = []; allNameScopeIndex.set(key, arr3); } + arr3.push(i); } } this._varScopeNameIndex = varScopeNameIndex; + this._allNameScopeIndex = allNameScopeIndex; // Legacy field — kept as truthy sentinel so callers that check for it // pre-Phase B still see "index is built". this._declSymIndex = varScopeNameIndex; if (_shared) { _shared.varScopeNameIndex = varScopeNameIndex; + _shared.allNameScopeIndex = allNameScopeIndex; _shared.declSymIndex = varScopeNameIndex; } } @@ -2991,15 +2981,14 @@ class SourceCode { block = upper.block; } - // Detect `declare global { ... }` — Zig parses this as a bare BlockStatement with no - // TSModuleDeclaration wrapper, so scope.block.kind is undefined. The no-shadow rule's - // isGlobalAugmentation() checks scope.block.kind === "global"; synthesize it here. - if (block !== null && block.type === 'BlockStatement' && block.range) { - const pre = this.text.slice(Math.max(0, block.range[0] - 30), block.range[0]); - if (/\bdeclare\s+global\s*$/.test(pre.trimEnd())) { - const wrapped = Object.create(block); - Object.defineProperty(wrapped, 'kind', { value: 'global', writable: true, enumerable: true, configurable: true }); - block = wrapped; + // For TSModuleBlock scopes (TypeScript namespace/declare global bodies), ESLint's + // @typescript-eslint/scope-manager sets scope.block = TSModuleDeclaration (not TSModuleBlock). + // isGlobalAugmentation() checks scope.type === 'tsModule' && scope.block.kind === 'global'. + // Promote these scopes: set block = block.parent (TSModuleDeclaration) so block.kind is correct. + if (block !== null && block.type === 'TSModuleBlock') { + const parentDecl = block.parent; + if (parentDecl && parentDecl.type === 'TSModuleDeclaration') { + block = parentDecl; } } @@ -3023,6 +3012,7 @@ class SourceCode { // relabel scope 0 as "function" anymore. The kind-1-in-script-mode // relabel below preserves the legacy single-scope behavior. const scopeTypeName = (kind === 1 && this._sourceType !== 'module') ? 'global' + : (block !== null && (block.type === 'TSModuleDeclaration' || block.type === 'TSModuleBlock')) ? 'tsModule' : (_SCOPE_KIND_NAMES[kind] || 'block'); // Allocate via shared prototype so V8 sees one hidden class for every scope. @@ -4502,10 +4492,14 @@ class SourceCode { if (ast._symDeclNodes && node._i !== undefined && node._i !== null) { const symIds = this._declSymsForNode(node._i); if (symIds && symIds.length > 0) { - // Fast path: single symbol AND no var-sibling extension applies (the common + // For ImportDeclaration, we must extend with same-scope, same-name siblings + // (handles duplicate `import X from ...` and `type X = ...; import X from ...` + // combinations — ESLint scope merges them into one variable with multiple defs). + const isImportDecl = node.type === 'ImportDeclaration'; + // Fast path: single symbol AND no var/import-sibling extension applies (the common // case — most decl-nodes own exactly one binding, and only `var` decls need // sibling extension). Skip the Map/Set/array allocations entirely. - if (symIds.length === 1) { + if (symIds.length === 1 && !isImportDecl) { const i = symIds[0]; const flags = ast._symFlags ? ast._symFlags[i] : 0; const is_var_only = (flags & 0x01) !== 0 && (flags & 0x02) === 0 && (flags & 0x04) === 0; @@ -4549,6 +4543,22 @@ class SourceCode { } } } + // For ImportDeclaration: extend with ALL same-scope, same-name symbols to capture + // duplicate import declarations and type-alias + import combinations. ESLint scope + // merges these into one Variable with multiple defs; rules like prefer-export-from + // check `defs.length !== 1` to detect ambiguous bindings and bail out. + if (isImportDecl && this._allNameScopeIndex && ast._symScopeIds) { + for (const i of symIds) { + const scopeId = ast._symScopeIds[i]; + const name = ast._symName(i); + const siblings = this._allNameScopeIndex.get(scopeId + ':' + name); + if (siblings) { + for (const sib of siblings) { + if (!seen.has(sib)) { seen.add(sib); extendedIds.push(sib); } + } + } + } + } // Compute merge key from buffer-direct flag lookup so we don't trigger // the lazy `defs` getter just to read defType (which would allocate // a Definition object even though most variables never need merging). @@ -4568,7 +4578,12 @@ class SourceCode { // `defs.length > 1` for legit non-shadowing locals and tripping // no-redeclare/no-dupe-args false positives across the file. const scopeId = symScopeIds ? symScopeIds[i] : 0; - const key = scopeId + '\0' + v.name + '\0' + defType; + // For ImportDeclaration merges: omit defType from key so that + // `type Foo` + `import Foo` (different defTypes) still merge into + // one variable with multiple defs, matching ESLint scope behavior. + const key = isImportDecl + ? (scopeId + '\0' + v.name) + : (scopeId + '\0' + v.name + '\0' + defType); const ex = mergeSet.get(key); if (ex) { // After Zig's sym_to_canonical routing, the sym_id with the @@ -5141,6 +5156,24 @@ class SourceCode { // function body blocks (causing the rule to skip those cases). if (!node) return null; if (!_SCOPE_CREATING_TYPES.has(node.type)) return null; + // Synthetic FunctionExpression (method/getter/setter .value) has no _i when the + // rule has no FunctionExpression visitor (invokeMethodFnHandlers only sets _i when + // a handler fires). getScope(synth FE) falls back to scope 0 (global). Instead, + // look up via the parent MethodDefinition/Property which has a real buffer index, + // then verify by identity that the scope's block IS this synthetic node. + if (node.type === 'FunctionExpression' && node._i === undefined && node.parent && node.parent._i !== undefined) { + const parentScope = sc.getScope(node.parent); + if (parentScope && parentScope.block === node) { + const bodyBlock = node.body; + if (bodyBlock && bodyBlock._i !== undefined) { + const bodyBlockScope = sc.getScope(bodyBlock); + if (bodyBlockScope && bodyBlockScope !== parentScope) { + return sc._wrapFunctionWithBodyLocals(parentScope, bodyBlockScope); + } + } + return parentScope; + } + } const scope = sc.getScope(node); if (!scope || !scope.block) return null; // Verify this scope was directly created by this node (scope.block === node). @@ -5157,23 +5190,12 @@ class SourceCode { } return scope; } - // After body-block fix: body_block_scope.block = fn_node (FunctionDeclaration/Expression/ - // ArrowFunctionExpression). Rules like classScopeAnalyzer call findNearestScope() which - // walks up via acquire() until it finds a scope. acquire(BlockStatement of fn body) must - // return body_block_scope so findVariableInScope starts from the right scope. - // After body-block fix: body_block_scope.block = fn_node. acquire(BlockStatement of fn body) - // must return body_block_scope so rules like classScopeAnalyzer.findNearestScope() can find - // variables declared as const/let in the fn body. Match by scope identity instead of _i - // (fn nodes from getter/setter dispatch are synthetic and lack _i). - if (node.type === 'BlockStatement' && scope.type === 'block' && - scope.upper && (scope.upper._kind === 2 || scope.upper._kind === 11)) { - const blockNode = scope.block; - if (blockNode && (blockNode.type === 'FunctionDeclaration' || blockNode.type === 'FunctionExpression' || - blockNode.type === 'ArrowFunctionExpression') && - (blockNode.body === node || blockNode.body?._i === node._i)) { - return scope; - } - } + // NOTE: function body BlockStatements are intentionally NOT returned here. + // ESLint's eslint-scope sets scope.block = FunctionDeclaration (not the body BlockStatement), + // so acquire(fnBodyBlock) returns null in real eslint-scope. Rules like + // consistent-function-scoping rely on this null to skip checking (parentScope check). + // classScopeAnalyzer.findNearestScope() works correctly too — the synth FE path above + // intercepts acquire(method FE) before it reaches this point. // Special case: Zig attaches the for-loop block scope to the ForStatement, not // its body BlockStatement. Rules like unicorn/no-for-loop call acquire(node.body) // expecting to get the body scope — return a filtered view when the scope's block @@ -5712,6 +5734,15 @@ class RuleContext { this.sourceCode._configGlobals = lo?.globals ?? null; this.sourceCode._globalReturn = !!(lo?.parserOptions?.ecmaFeatures?.globalReturn); this.sourceCode._impliedStrict = !!(lo?.parserOptions?.ecmaFeatures?.impliedStrict); + // Whether the case actually requested full type information (a TS project / + // projectService / program). Type-aware rules — @typescript-eslint AND + // plugins like sonarjs — only run when this is present; ESLint's + // isRequiredParserServices bails otherwise. We mirror that so the light + // facade's `program` is exposed ONLY when type info was requested, + // matching the oracle (which has no type checker for plain-JS cases). + // Left undefined for non-differential entry points (LSP etc.) so they keep + // the facade's default-on behavior. + this.sourceCode._typeInfoRequested = _parserOptionsHaveTypeInfo(lo?.parserOptions); if (options.parserServices) this.sourceCode.parserServices = options.parserServices; // Mirror of `parserServices != null` that the Proxy-wrapped value trips when // read via prop access. Lets buildVisitorMap skip empty-recipe rules on JS @@ -5996,16 +6027,13 @@ function buildVisitorMap(plugins, context, ruleConfig = {}) { const fileCtx = Object.create(perRuleCtxs[pi]); fileCtx._onListeners = null; // own property — context.on() appends here, not to perRuleCtxs[pi] let visitors = null; - _activeRuleId = pluginRuleIds[pi]; // gate parserServices.program to this rule during create() try { if (globalThis.__EZ_BENCH_CREATE_COUNTER__) globalThis.__EZ_BENCH_CREATE_COUNTER__(pluginRuleIds[pi]); visitors = plugins[pi].create(fileCtx); } catch { /* empty-recipe match */ } - _activeRuleId = null; if (!visitors || typeof visitors !== 'object') visitors = {}; // Merge context.on() listeners into visitors (used by unicorn / ESLint 9 rules). if (fileCtx._onListeners) Object.assign(visitors, fileCtx._onListeners); - _wrapVisitorsForFacade(visitors, pluginRuleIds[pi]); if (Object.keys(visitors).length === 0) { if (recipe.length !== 0) { mismatch = true; break; } continue; @@ -6122,16 +6150,13 @@ function buildVisitorMap(plugins, context, ruleConfig = {}) { const recipe = []; let visitors; perRuleCtx._onListeners = null; // reset before create() so context.on() accumulates fresh - _activeRuleId = ruleId; // gate parserServices.program to this rule during create() try { if (globalThis.__EZ_BENCH_CREATE_COUNTER__) globalThis.__EZ_BENCH_CREATE_COUNTER__(ruleId); visitors = plugin.create(perRuleCtx); - } catch { _activeRuleId = null; perPluginRecipe.push(recipe); pluginTagBitsets.push(null); continue; } - _activeRuleId = null; + } catch { perPluginRecipe.push(recipe); pluginTagBitsets.push(null); continue; } if (!visitors || typeof visitors !== 'object') visitors = {}; // Merge context.on() listeners (used by unicorn and ESLint 9 rules) into visitors. if (perRuleCtx._onListeners) Object.assign(visitors, perRuleCtx._onListeners); - _wrapVisitorsForFacade(visitors, ruleId); if (Object.keys(visitors).length === 0) { perPluginRecipe.push(recipe); pluginTagBitsets.push(null); // empty visitors — no tags, but we don't skip empty rules @@ -9439,6 +9464,11 @@ function walkNodes(ast, visitorMapResult, context, tagNames, plugins) { if (_needsShorthandSynth && T.shorthand_property < _synthTagArr.length) { _synthTagArr[T.shorthand_property] = 1; } + // Assignment destructuring KEY synthesis: assignment_pattern inside object_pattern must + // not be skipped when Identifier visitors exist (synthesizes KEY Identifier visit). + if (_needsShorthandSynth && T.assignment_pattern < _synthTagArr.length) { + _synthTagArr[T.assignment_pattern] = 1; + } // JSXFragment synthesis needs jsx_fragment tag if (hasFragSynth && T.jsx_fragment < _synthTagArr.length) { _synthTagArr[T.jsx_fragment] = 1; @@ -9728,6 +9758,39 @@ function walkNodes(ast, visitorMapResult, context, tagNames, plugins) { } } } + // Synthesize KEY Identifier visit for assignment_pattern directly inside object_pattern + // (assignment destructuring: `({a=expr}=x)` — ObjectPattern.properties wraps these in + // synthetic Properties via estree-adapter.js). The synthetic Property is cached on the + // AssignmentPattern node as `_objectPatternSynthProp` so properties.includes(parent) works. + if (_needsShorthandSynth && tag === T.assignment_pattern) { + const _parentIdxAP = ast._parentData ? ast._parentData[idx] : NONE; + if (_parentIdxAP !== NONE && nodeTags[_parentIdxAP] === T.object_pattern) { + const _apLhs = ast.nodeLhs(idx); // assignment_pattern.left = key Identifier + if (_apLhs !== undefined && _apLhs !== NONE && _apLhs < ast.nodeCount && _identTagBits[nodeTags[_apLhs]]) { + const _apNode = nodeView(ast, idx); + const _keyNode = nodeView(ast, _apLhs); + let _keyShadow = _apNode._assignTargetSynthKey; + if (_keyShadow === undefined) { + // Create the synthetic Property if not yet cached by the properties getter + let _synthProp = _apNode._objectPatternSynthProp; + if (!_synthProp) { + const _opNode = nodeView(ast, _parentIdxAP); + _synthProp = { type: 'Property', key: null, value: _apNode, kind: 'init', + method: false, shorthand: true, computed: false, + start: _apNode.start, end: _apNode.end, + range: _apNode.range, loc: _apNode.loc, parent: _opNode }; + _apNode._objectPatternSynthProp = _synthProp; + } + _keyShadow = Object.create(_keyNode); + Object.defineProperty(_keyShadow, 'parent', { get() { return _synthProp; }, configurable: true }); + _synthProp.key = _keyShadow; + _apNode._assignTargetSynthKey = _keyShadow; + } + const _identEnterH = visitorMap.get('Identifier'); + if (_identEnterH) _invokeFused(_identEnterH, _keyShadow, _apLhs, context); + } + } + } } else { // Exit event const idx = ~ev; diff --git a/js/index.js b/js/index.js index 85efe643..201062c5 100644 --- a/js/index.js +++ b/js/index.js @@ -301,8 +301,11 @@ function _offsetToLineCol(lineStarts, offset, bytes) { } else if ((b & 0xF0) === 0xE0) { col++; i += 3; } else if ((b & 0xF8) === 0xF0) { - // 4-byte UTF-8 → surrogate pair in UTF-16. - col += 2; i += 4; + // 4-byte UTF-8 → surrogate pair (2 UTF-16 units). A span endpoint may + // land between the two surrogates — no-misleading-character-class reports + // ZWJ sequences at surrogate granularity. Count one unit for the partial. + if (i + 4 > end) { col += (end - i >= 2) ? 1 : 0; i = end; } + else { col += 2; i += 4; } } else { col++; i++; } diff --git a/src/cli/napi.zig b/src/cli/napi.zig index 7b76aec2..4d69e8f1 100644 --- a/src/cli/napi.zig +++ b/src/cli/napi.zig @@ -117,11 +117,6 @@ fn streamSemEntry(ctx: *StreamSemCtx) void { ctx.ast_view.scope_events, .{ .globals = ctx.globals, - .streaming = .{ - .events_published = ctx.events_pub, - .parse_done = ctx.parse_done, - .node_count_hint = ctx.cap_hint, - }, // Allocate CFG adjacency pools from the worker's bump partition so // writeCfgGraph can publish them in-place without a rebuild copy. .cfg_pool_alloc = ctx.worker_backing.allocator(), @@ -395,8 +390,6 @@ fn parseImpl( var tokens = lex_result.tokens; // Streaming atomics (only used when use_stream_sem == true). - var s_published_len: std.atomic.Value(usize) = .init(tokens.len); - var s_lex_done: std.atomic.Value(bool) = .init(true); var s_events_pub: std.atomic.Value(usize) = .init(0); var s_parse_done: std.atomic.Value(bool) = .init(false); var s_ast_ready: std.atomic.Value(bool) = .init(false); @@ -445,21 +438,15 @@ fn parseImpl( .is_module = is_module, .global_return = global_return, .emit_events = true, - .streaming = .{ - .published_len = &s_published_len, - .lex_done = &s_lex_done, - .capacity_hint = s_cap_hint, - .events_publish_to = &s_events_pub, - .ast_view_out = &s_ast_view, - .ast_ready = &s_ast_ready, - }, }) catch |e| { s_parse_done.store(true, .release); s_parents_ready.store(true, .release); if (stream_sem_thread) |th| th.join(); return e; }; - // Final publish; worker walks events bounded by this. + // Signal worker: full AST and all events are now available. + s_ast_view = t; + s_ast_ready.store(true, .release); s_events_pub.store(t.scope_events.len, .release); s_parse_done.store(true, .release); break :blk t; @@ -622,7 +609,14 @@ fn parseImpl( // can't leave the previous file's stash live for a later openReuse. The // actual stash happens at the end (stashLastParse); token starts are kept // in byte form (the UTF-16 conversion writes a separate array), so no clone. - if (stash_for_types) type_ffi.invalidateReuseStash(); + // + // Always invalidate — not just for TS/TSX/DTS. JS parses never call + // stashLastParse below, so if we skip the invalidate, the previous TS + // parse's stash stays live with tl_last_ast pointing at its (old) AST. + // A subsequent openReuseImpl then finds tl_tagged_gen matching the new + // JS gen but returns the wrong (TS) AST → Checker.init() on the wrong + // parents → infinite loop in enclosingScopeIdx. + type_ffi.invalidateReuseStash(); // Worker was already given parents + signaled (above). Don't join yet — // main does UTF-16/spans/header next, then joins. @@ -660,48 +654,20 @@ fn parseImpl( const parallel_sem = !env_disabled and ev_count >= parallel_threshold and !stream_sem_handled; if (parallel_sem) { - const cfg_arena_ptr = getCfgArena(); - _ = cfg_arena_ptr.reset(.retain_capacity); const er_opts = event_resolver.Options{ .globals = globals }; - // Spawn cfg worker first so it runs concurrently with scope on this thread. - if (event_resolver.ScopeCfgParallel.start( - cfg_arena_ptr.allocator(), &tree, tree.scope_events, er_opts, - )) |cfg_worker| { - if (event_resolver.resolveFullScope( - sem_arena_ptr.allocator(), &tree, tree.scope_events, er_opts, - )) |scope_part| { - if (cfg_worker.join(cfg_arena_ptr.allocator())) |cfg_part| { - if (event_resolver.combineParts(sem_arena_ptr.allocator(), scope_part, cfg_part)) |sem_result| { - var sem = sem_result; - // computeLoopBodyExitability lives in semantic.zig; replicate the - // single call here (previously inside analyzeWithOptions). - semantic_mod.computeLoopBodyExitabilityPub(&tree, sem.loop_exit_reachable, sem.node_reachable); - if (js_buffer.writeSemanticData(buf_ptr, &backing, &sem, @intCast(tree.nodes.len), tree.nodes.items(.tag), traversal.parents, 0, null, 0, null, null, null, null)) |off| { - semantic_data_offset = off; - } else |_| {} - if (stash_for_types) { - if (sem.parent_indices.len == 0) { - sem.parent_indices = parent_builder.buildParentsOnly(&tree, sem_arena_ptr.allocator()) catch &.{}; - } - type_ffi.stashLastParse(&tree, &sem); - } - } else |_| {} - } else |_| {} - } else |_| { - // Scope failed — drain the worker so we don't leak its thread. - if (cfg_worker.join(cfg_arena_ptr.allocator())) |dropped| { - var d = dropped; d.deinit(cfg_arena_ptr.allocator()); - } else |_| {} - } - } else |_| { - // Worker spawn failed — fall back to sequential. - if (semantic_mod.SemanticAnalyzer.analyzeWithGlobals(sem_arena_ptr.allocator(), &tree, globals)) |sem_result| { - var sem = sem_result; - if (js_buffer.writeSemanticData(buf_ptr, &backing, &sem, @intCast(tree.nodes.len), tree.nodes.items(.tag), traversal.parents, 0, null, 0, null, null, null, null)) |off| { - semantic_data_offset = off; - } else |_| {} + if (event_resolver.resolveFull(sem_arena_ptr.allocator(), &tree, tree.scope_events, er_opts)) |sem_result| { + var sem = sem_result; + semantic_mod.computeLoopBodyExitabilityPub(&tree, sem.loop_exit_reachable, sem.node_reachable); + if (js_buffer.writeSemanticData(buf_ptr, &backing, &sem, @intCast(tree.nodes.len), tree.nodes.items(.tag), traversal.parents, 0, null, 0, null, null, null, null)) |off| { + semantic_data_offset = off; } else |_| {} - } + if (stash_for_types) { + if (sem.parent_indices.len == 0) { + sem.parent_indices = parent_builder.buildParentsOnly(&tree, sem_arena_ptr.allocator()) catch &.{}; + } + type_ffi.stashLastParse(&tree, &sem); + } + } else |_| {} } else if (!stream_sem_handled) { if (semantic_mod.SemanticAnalyzer.analyzeWithGlobals(sem_arena_ptr.allocator(), &tree, globals)) |sem_result| { var sem = sem_result; diff --git a/src/linter/lint_context.zig b/src/linter/lint_context.zig index 51b17db0..4476c1ff 100644 --- a/src/linter/lint_context.zig +++ b/src/linter/lint_context.zig @@ -146,7 +146,14 @@ pub const BUILTIN_ES2015_GLOBALS = [_][]const u8{ "SharedArrayBuffer", "Atomics", "BigInt", "BigInt64Array", "BigUint64Array", "AggregateError", "FinalizationRegistry", "WeakRef", - "Intl", "console", + "Intl", + // NOTE: `console` is intentionally NOT listed here. It is a host/env + // global (browser/Node), not an ECMAScript builtin — mirroring the JS + // side (`_ENV_GLOBALS`, added only when env is enabled). Pre-declaring + // it as an implicit-global symbol shadowed the type-checker's curated + // `Console` type, making `console.log(...)` resolve to `any` instead of + // `void` (breaking no-confusing-void-expression). Unresolved `console` + // references still report as global refs via isGlobalReference. // ES2024+ globals "Float16Array", "Iterator", "AsyncIterator", "AsyncDisposableStack", "DisposableStack", "SuppressedError", @@ -508,8 +515,14 @@ pub const LintContext = struct { const c = self.ensureChecker() orelse return true; if (id.eq(tymod.ID_NUMBER) or id.eq(tymod.ID_BIGINT) or id.eq(tymod.ID_ANY) or id.eq(tymod.ID_NEVER)) return true; - const kind = c.store.get(id).kind; - return kind == .number_literal or kind == .bigint_literal; + const t = c.store.get(id); + if (t.kind == .number_literal or t.kind == .bigint_literal) return true; + // Type parameter: follow constraint (`T extends number` → number-like). + if (t.kind == .type_param) { + const constraint = self.typeParamConstraint(id) orelse return false; + return self.typeIdIsNumberLike(constraint); + } + return false; } /// True when the type id is exactly `boolean` or a boolean literal @@ -524,6 +537,11 @@ pub const LintContext = struct { } return true; } + // Type parameter: follow constraint (`T extends boolean` → boolean-like). + if (t.kind == .type_param) { + const constraint = self.typeParamConstraint(id) orelse return false; + return self.typeIdIsExactlyBoolean(constraint); + } return false; } @@ -589,6 +607,11 @@ pub const LintContext = struct { } return false; } + // Type parameter: follow constraint (`T extends string` → stringy). + if (t.kind == .type_param) { + const constraint = self.typeParamConstraint(id) orelse return false; + return self.typeIdIsStringy(constraint); + } return false; } @@ -728,6 +751,14 @@ pub const LintContext = struct { for (c.store.idsOf(t.list_data)) |m| { if (self.typeIdObjectPropertyIsMethod(m, name)) return true; } + return false; + } + // Resolve named type references (class/interface instances) to their + // structural type so property method checks work on `new Foo()` results. + if (t.kind == .type_ref and t.name.len > 0) { + if (c.resolveDeclaredTypePub(t.name)) |resolved| { + if (!resolved.eq(id)) return self.typeIdObjectPropertyIsMethod(resolved, name); + } } return false; } @@ -761,6 +792,13 @@ pub const LintContext = struct { } return saw_any; } + // Resolve named type references (class/interface instances) to their + // structural type so fn-property checks work on `new Foo()` results. + if (t.kind == .type_ref and t.name.len > 0) { + if (c.resolveDeclaredTypePub(t.name)) |resolved| { + if (!resolved.eq(id)) return self.typeIdObjectPropertyIsFnProperty(resolved, name); + } + } return false; } @@ -4464,11 +4502,8 @@ pub const LintContext = struct { } return self.nodeTokensEqual(a, b); } - // Optional chaining (`a?.b`) and non-optional (`a.b`) are not the same - // reference — ESLint's isSameReference returns false when optionality differs. - const a_optional = at == .optional_member_expr or at == .optional_computed_member_expr; - const b_optional = bt == .optional_member_expr or bt == .optional_computed_member_expr; - if (a_optional != b_optional) return false; + // ESLint's isSameReference explicitly treats `a.b` and `a?.b` as the same + // reference (it unwraps ChainExpression). Mirror that: ignore optionality. const ad = self.ast.nodeData(a); const bd = self.ast.nodeData(b); // Compare objects recursively. @@ -4864,8 +4899,21 @@ pub const LintContext = struct { if (last == '}') return false; } return switch (self.nodeTag(prev)) { - // Declarations terminate the statement regardless of last token. - .var_decl, .let_decl, .const_decl, + // For variable declarations, safe only when the last declarator + // has no initializer (e.g. `var foo`). With an initializer the + // init expression can end with a subscriptable token, so fall back + // to the char-level scan which catches `var x = bar.yield\n[]`. + .var_decl, .let_decl, .const_decl => blk: { + const vd = self.nodeData(prev); + const d_start = @intFromEnum(vd.lhs); + const d_end = @intFromEnum(vd.rhs); + const ext_len2: u32 = @intCast(self.ast.extra_data.len); + if (d_start >= d_end or d_end > ext_len2) break :blk true; + const decls = self.ast.extra_data[d_start..d_end]; + const last_decl: NodeIndex = @enumFromInt(decls[decls.len - 1]); + break :blk self.nodeData(last_decl).rhs == .none; + }, + // Other declarations terminate cleanly regardless of last token. .fn_decl, .async_fn_decl, .generator_fn_decl, .async_generator_fn_decl, .class_decl, .ts_type_alias_decl, .ts_interface_decl, .ts_enum_decl, @@ -6274,16 +6322,49 @@ pub const LintContext = struct { /// Symbols with zero references are NOT considered type-only — they may be /// value imports that are simply unused (handled by no-unused-vars instead). /// Used by consistent-type-imports to detect imports that could be `import type`. + /// True when the symbol has at least one reference entry in the reference table. + pub fn symbolHasAnyRef(self: *const LintContext, sym_id: symbol_mod.SymbolId) bool { + return !self.semantic.symbols.getRefRange(sym_id).isEmpty(); + } + pub fn symbolIsTypeOnly(self: *const LintContext, sym_id: symbol_mod.SymbolId) bool { const range = self.semantic.symbols.getRefRange(sym_id); if (range.isEmpty()) return false; const sym_refs = self.semantic.ref_by_sym[range.start..range.end]; for (sym_refs) |rid| { - if (!self.semantic.references.getKind(rid).isTypeRef()) return false; + if (self.semantic.references.getKind(rid) != .type_read) return false; } return true; } + /// True when the symbol has at least one reference entry but ALL non-type references + /// come from `export type { }` specifiers. Workaround for es-parser emitting `read` + /// (not `type_read`) for specifiers inside type-only export declarations. + pub fn symbolRefsAllInTypeExports(self: *const LintContext, sym_id: symbol_mod.SymbolId) bool { + const range = self.semantic.symbols.getRefRange(sym_id); + if (range.isEmpty()) return false; + const sym_refs = self.semantic.ref_by_sym[range.start..range.end]; + var found_type_export = false; + for (sym_refs) |rid| { + if (self.semantic.references.getKind(rid) == .type_read) continue; + // Non-type reference: check if it's from an export type { } specifier. + const ref_node = self.semantic.references.getNode(rid); + if (ref_node == .none) return false; + // The reference node is a property_ident in an export_specifier. + // Walk up: property_ident → export_specifier → export_named + const spec = self.parentOf(ref_node); + if (spec == .none or self.ast.nodeTag(spec) != .export_specifier) return false; + const export_decl = self.parentOf(spec); + if (export_decl == .none or self.ast.nodeTag(export_decl) != .export_named) return false; + // Check if the export_named has a `type` token after `export`. + const export_tok = self.ast.nodeMainToken(export_decl); + if (export_tok + 1 >= self.ast.tokens.len) return false; + if (!std.mem.eql(u8, self.tokenText(export_tok + 1), "type")) return false; + found_type_export = true; + } + return found_type_export; + } + /// True when `import_decl` node has a top-level `type` modifier: /// `import type Foo from '...'` or `import type { Foo } from '...'`. pub fn importDeclIsTypeOnly(self: *const LintContext, node: NodeIndex) bool { @@ -10538,6 +10619,11 @@ pub const LintContext = struct { const assign_lhs = self.nodeSkipGrouping(rhs_d.lhs); const lhs = self.nodeSkipGrouping(d.lhs); if (assign_lhs == .none or lhs == .none) return; + // ESLint's isReference() rejects ChainExpression (which wraps optional + // member expressions like `a?.b`). In Ez's AST, optional_member_expr + // plays that role — skip to avoid false positives on `a?.b || (a.b = c)`. + const lhs_tag = self.ast.nodeTag(lhs); + if (lhs_tag == .optional_member_expr or lhs_tag == .optional_computed_member_expr) return; if (self.nodeSameReference(lhs, assign_lhs)) self.reportWithMessageId(node, "logical"); }, @@ -12419,15 +12505,35 @@ pub const LintContext = struct { const args = self.extraSlice(range); if (args.len == 0) return; const first_arg_raw: NodeIndex = @enumFromInt(args[0]); - const first_arg = self.nodeSkipGrouping(first_arg_raw); - const first_tag = self.ast.nodeTag(first_arg); + var first_arg = self.nodeSkipGrouping(first_arg_raw); + var first_tag = self.ast.nodeTag(first_arg); + // Resolve a const string variable used as the pattern: + // `const p = "[…]"; new RegExp(p)`. ESLint can't map char offsets back + // into the original source, so it reports on the whole argument node — + // collapse every reported span to the identifier via `report_override`. + var report_override: ?Span = null; + if (first_tag == .identifier) { + const init = self.constInitializerOf(first_arg) orelse return; + const init_node = self.nodeSkipGrouping(init); + const it = self.ast.nodeTag(init_node); + if (it != .string_literal and it != .template_literal) return; + report_override = self.nodeSpan(first_arg); + first_arg = init_node; + first_tag = it; + } if (first_tag != .string_literal and first_tag != .template_literal and first_tag != .regex_literal) return; var flags: []const u8 = ""; var flags_explicit = false; if (args.len >= 2) { const flags_arg_raw: NodeIndex = @enumFromInt(args[1]); - const flags_arg = self.nodeSkipGrouping(flags_arg_raw); - const ftag = self.ast.nodeTag(flags_arg); + var flags_arg = self.nodeSkipGrouping(flags_arg_raw); + var ftag = self.ast.nodeTag(flags_arg); + // Resolve a const string variable used as the flags argument. + if (ftag == .identifier) { + const finit = self.constInitializerOf(flags_arg) orelse return; + flags_arg = self.nodeSkipGrouping(finit); + ftag = self.ast.nodeTag(flags_arg); + } if (ftag != .string_literal and ftag != .template_literal) return; // bail on non-static flags const fs = self.nodeStaticStringValue(flags_arg) orelse return; flags = fs; @@ -12461,7 +12567,10 @@ pub const LintContext = struct { decodeJsStringLiteralMapped(arena, body) catch return; if (regexPatternHasSyntaxError(decoded.bytes, true, flags)) return; const flag_set = regex_parser.Flags.fromString(flags); - const allow_escape = self.noMisleadingAllowEscape(); + // When the pattern came from a resolved constant, escape forms in the + // variable's literal are not "visible" at the usage site, so allowEscape + // does not apply (matches ESLint, which reports on the argument node). + const allow_escape = report_override == null and self.noMisleadingAllowEscape(); const pat = regex_parser.parse(arena, decoded.bytes, .{ .flags = flag_set }) catch return; // Source-map base: first byte INSIDE the string literal in source // (just past the opening quote). @@ -12471,6 +12580,7 @@ pub const LintContext = struct { .decoded_len = decoded.bytes.len, .body_src_start = body_src_start, .body = body, + .override = report_override, }; self.walkMisleadingCharClassCall(pat.alternatives, decoded.bytes, flag_set, allow_escape, map_ctx); } @@ -12480,13 +12590,20 @@ pub const LintContext = struct { decoded_len: usize, body_src_start: u32, body: []const u8, + /// When the pattern came from a resolved constant (`const p = "…"; new + /// RegExp(p)`), decoded offsets don't map back to source — ESLint + /// reports on the whole argument node instead. When set, every + /// reported span collapses to this fixed range. + override: ?Span = null, fn srcStart(self: CallSourceMap, decoded_off: u32) u32 { + if (self.override) |o| return o.start; if (decoded_off >= self.map.len) return self.body_src_start + @as(u32, @intCast(self.decoded_len)); return self.body_src_start + self.map[decoded_off]; } fn srcEnd(self: CallSourceMap, decoded_off: u32) u32 { + if (self.override) |o| return o.end; // Walk forward through duplicate map entries (multi-byte // sequences from one escape) to land on the next char's // source offset, which is this char's source end. @@ -12652,10 +12769,16 @@ pub const LintContext = struct { const next = seq[i + 1]; if (curr.codepoint == 0x200D and prev.codepoint != 0x200D and next.codepoint != 0x200D) { if (allow_escape and map_ctx.isEscapeFormAt(prev.start) and map_ctx.isEscapeFormAt(curr.start) and map_ctx.isEscapeFormAt(next.start)) continue; + // Without u/v a supplementary middle splits into two + // differing surrogates, so its joins stay separate (see the + // literal-path reportZwjSeq). Source spans stay whole-char: + // the source map collapses the surrogate offsets onto the + // original escape, which is what ESLint reports. + const middle_shares = has_uv or !zwjCharIsSupplementary(seq[i - 1]); if (run_start == null) { run_start = i - 1; run_end = i + 1; - } else if (run_end == i - 1) { + } else if (run_end == i - 1 and middle_shares) { run_end = i + 1; } else { const s = seq[run_start.?]; @@ -12781,7 +12904,7 @@ pub const LintContext = struct { // BMP codepoints (U+0300-range marks, U+200D ZWJ) that // regexpp sees regardless of u/v. self.reportCombiningSeq(buf[0..seq_len], pat_start, pat_text, allow_escape); - self.reportZwjSeq(buf[0..seq_len], pat_start, pat_text, allow_escape); + self.reportZwjSeq(buf[0..seq_len], pat_start, flags.unicode or flags.unicode_sets, pat_text, allow_escape); // emojiModifier / regionalIndicator codepoints sit in the // supplementary plane. Without u/v regexpp splits them // into surrogate halves which can't match the predicates, @@ -12845,11 +12968,17 @@ pub const LintContext = struct { } } - fn reportZwjSeq(self: *const LintContext, seq: []const regex_parser.Character, pat_start: u32, pat_text: []const u8, allow_escape: bool) void { + fn reportZwjSeq(self: *const LintContext, seq: []const regex_parser.Character, pat_start: u32, has_uv: bool, pat_text: []const u8, allow_escape: bool) void { if (seq.len < 3) return; - // Walk for ZWJ joiners. ESLint coalesces overlapping ZWJ-joined - // sequences into a single diag, so we emit one report covering - // the contiguous run rather than one per joiner. + // Mirror ESLint's `zwj` generator, which operates on UTF-16 code UNITS. + // WITHOUT u/v, a supplementary emoji is two surrogates, so a ZWJ join is + // the triple [low-surrogate-of-before, ZWJ, high-surrogate-of-after]; + // consecutive joins coalesce ONLY when the separating character is a + // single BMP unit (a supplementary middle splits into two differing + // surrogates, so its joins stay separate). WITH u/v, every character is + // one code point, so consecutive joins always share the middle unit and + // coalesce. We keep emoji as one Character and model the no-u surrogate + // boundary by reporting at the emoji's UTF-8 midpoint (start+2). var run_start: ?usize = null; var run_end: usize = 0; var i: usize = 1; @@ -12859,32 +12988,38 @@ pub const LintContext = struct { const next = seq[i + 1]; if (curr.codepoint == 0x200D and prev.codepoint != 0x200D and next.codepoint != 0x200D) { if (allow_escape and charIsEscapeForm(prev, pat_text) and charIsEscapeForm(curr, pat_text) and charIsEscapeForm(next, pat_text)) continue; + const middle_shares = has_uv or !zwjCharIsSupplementary(seq[i - 1]); if (run_start == null) { run_start = i - 1; run_end = i + 1; - } else if (run_end == i - 1) { + } else if (run_end == i - 1 and middle_shares) { run_end = i + 1; } else { - // Emit previous run. - const s = seq[run_start.?]; - const e = seq[run_end]; - self.reportSpanWithMessageId(.{ - .start = pat_start + s.start, - .end = pat_start + e.end, - }, "zwj"); + self.emitZwj(seq[run_start.?], seq[run_end], pat_start, has_uv); run_start = i - 1; run_end = i + 1; } } } - if (run_start) |rs| { - const s = seq[rs]; - const e = seq[run_end]; - self.reportSpanWithMessageId(.{ - .start = pat_start + s.start, - .end = pat_start + e.end, - }, "zwj"); - } + if (run_start) |rs| self.emitZwj(seq[rs], seq[run_end], pat_start, has_uv); + } + + /// A supplementary-plane character we keep as a single Character but which + /// ESLint (without u/v) sees as a surrogate pair. Literal 4-byte UTF-8 only. + fn zwjCharIsSupplementary(c: regex_parser.Character) bool { + return c.codepoint > 0xFFFF and (c.end - c.start) == 4; + } + + fn emitZwj(self: *const LintContext, before: regex_parser.Character, after: regex_parser.Character, pat_start: u32, has_uv: bool) void { + // Under u/v every char is one code point → whole spans. Without u/v a + // supplementary char is two surrogates: start at the before-char's low + // surrogate (UTF-8 midpoint) and end at the after-char's high surrogate + // (also the midpoint). BMP chars always use their whole span. + const before_split = !has_uv and zwjCharIsSupplementary(before); + const after_split = !has_uv and zwjCharIsSupplementary(after); + const start = before.start + @as(u32, if (before_split) 2 else 0); + const end = if (after_split) after.start + 2 else after.end; + self.reportSpanWithMessageId(.{ .start = pat_start + start, .end = pat_start + end }, "zwj"); } /// True for U+1F3FB..U+1F3FF — the Fitzpatrick skin-tone modifiers diff --git a/src/linter/native/typescript/consistent_type_imports.zig b/src/linter/native/typescript/consistent_type_imports.zig index 02a9b89c..abfab68f 100644 --- a/src/linter/native/typescript/consistent_type_imports.zig +++ b/src/linter/native/typescript/consistent_type_imports.zig @@ -121,15 +121,66 @@ fn specifierIsTypeOnly(sp: NodeIndex, ctx: *const LintContext) bool { // import_specifier: lhs=imported, rhs=local (rhs may be .none → use lhs) // import_default_specifier: lhs=local // import_namespace_specifier: lhs=local + const tag = ctx.nodeTag(sp); const d = ctx.nodeData(sp); - const local: NodeIndex = switch (ctx.nodeTag(sp)) { + const local: NodeIndex = switch (tag) { .import_specifier => if (d.rhs != .none) d.rhs else d.lhs, .import_default_specifier, .import_namespace_specifier => d.lhs, else => return false, }; if (local == .none) return false; const sym = ctx.symbolForDeclNode(local) orelse return false; - return ctx.symbolIsTypeOnly(sym); + if (ctx.symbolIsTypeOnly(sym)) { + // es-parser doesn't emit a `read` ref for the implicit JSX factory + // (issue #34): when a file uses intrinsic JSX (`
`), the classic + // runtime calls `.createElement`, but no reference is recorded + // for the pragma binding — so a default import of the factory looks + // type-only and would FP. Suppress ONLY for the actual factory import: + // a default import whose name matches the JSX pragma, in a file that + // contains JSX elements. A genuinely type-only default import (or any + // import in a JSX-free file) is still reported. The node scan is gated + // behind the cheap name check so it runs only for that rare candidate. + if (tag == .import_default_specifier and ctx.language.isJsx() and + importIsJsxFactory(local, ctx) and fileHasJsxElement(ctx)) return false; + return true; + } + // es-parser emits `read` (not `type_read`) for specifiers inside + // `export type { X }` declarations (issue #33). Check if ALL the + // non-type references to this symbol come from type-only export specifiers. + return ctx.symbolRefsAllInTypeExports(sym); +} + +/// True when `local` (a default-import binding) names the JSX factory pragma — +/// the leading identifier of the classic-runtime factory. Defaults to `React`; +/// honours `settings.react.pragma` when configured. +fn importIsJsxFactory(local: NodeIndex, ctx: *const LintContext) bool { + const name = ctx.tokenText(ctx.nodeMainToken(local)); + return std.mem.eql(u8, name, jsxFactoryPragma(ctx)); +} + +fn jsxFactoryPragma(ctx: *const LintContext) []const u8 { + const settings = ctx.getSettings() orelse return "React"; + if (settings.* != .object) return "React"; + const react = settings.object.get("react") orelse return "React"; + if (react != .object) return "React"; + const pragma = react.object.get("pragma") orelse return "React"; + if (pragma != .string) return "React"; + return pragma.string; +} + +/// True when the file contains at least one JSX element / fragment — the +/// condition under which the implicit JSX-factory reference exists. +fn fileHasJsxElement(ctx: *const LintContext) bool { + const total: u32 = @intCast(ctx.ast.nodes.len); + var i: u32 = 0; + while (i < total) : (i += 1) { + const ni: NodeIndex = @enumFromInt(i); + switch (ctx.nodeTag(ni)) { + .jsx_element, .jsx_self_closing, .jsx_fragment => return true, + else => {}, + } + } + return false; } fn extendSemi(sp: *Span, ctx: *const LintContext) void { diff --git a/src/linter/native/typescript/no_duplicate_type_constituents.zig b/src/linter/native/typescript/no_duplicate_type_constituents.zig index 2032883e..9a156014 100644 --- a/src/linter/native/typescript/no_duplicate_type_constituents.zig +++ b/src/linter/native/typescript/no_duplicate_type_constituents.zig @@ -62,7 +62,22 @@ pub fn run(node: NodeIndex, ctx: *const LintContext) void { fn typeNodeIsError(node: NodeIndex, ctx: *const LintContext) bool { const tid = ctx.resolveTypeAnnotationNode(node); - return ctx.typeIdIsError(tid); + if (ctx.typeIdIsError(tid)) return true; + // An undeclared type reference (`A` with no declaration) is the TS error + // type — TSe treats two such references as distinct, non-equivalent errors, + // so they must not be equated as duplicates. The checker can't distinguish + // an undeclared name from a type parameter (both resolve as "unresolved") + // and surfaces undeclared names as a named type_ref / any rather than the + // error type, so detect it structurally: a type_reference whose name is + // neither a known type NOR an in-scope type parameter is undeclared. + var n = node; + while (ctx.nodeTag(n) == .ts_parenthesized_type) n = ctx.nodeData(n).lhs; + if (ctx.nodeTag(n) == .ts_type_reference) { + const name = ctx.tokenText(ctx.nodeMainToken(n)); + if (name.len > 0 and !ctx.typeNameIsKnown(name) and + !ctx.typeAnnotationIsTypeParameter(n)) return true; + } + return false; } fn collectLeaves(node: NodeIndex, buf: *[32]NodeIndex, n: *usize, ctx: *const LintContext) void { diff --git a/src/linter/native/typescript/no_unnecessary_condition.zig b/src/linter/native/typescript/no_unnecessary_condition.zig index 95b6df47..a883800a 100644 --- a/src/linter/native/typescript/no_unnecessary_condition.zig +++ b/src/linter/native/typescript/no_unnecessary_condition.zig @@ -858,7 +858,14 @@ fn isNullishLiteral(id: tymod.TypeId) bool { fn involvesIndeterminateType(id: tymod.TypeId, ctx: *const LintContext) bool { const kind = ctx.typeIdKind(id) orelse return true; return switch (kind) { - .any, .unknown, .error_t, .type_param => true, + // A constrained type param (`T extends object`) is determinate via + // its constraint — TS-eslint uses getConstrainedTypeAtLocation. + // Only unconstrained params are indeterminate. + .type_param => blk: { + if (ctx.typeParamConstraint(id)) |c| break :blk involvesIndeterminateType(c, ctx); + break :blk true; + }, + .any, .unknown, .error_t => true, // Type refs to unrecognised names (often unresolved type // parameters / generics) are conservatively indeterminate. .type_ref => isUnresolvedRef(id, ctx), @@ -896,7 +903,12 @@ fn typeContainsNullish(id: tymod.TypeId, ctx: *const LintContext) bool { /// True when types `a` and `b` could share at least one runtime value. /// Conservative: returns true when uncertain. -fn typesCanOverlap(a: tymod.TypeId, b: tymod.TypeId, ctx: *const LintContext) bool { +fn typesCanOverlap(a_in: tymod.TypeId, b_in: tymod.TypeId, ctx: *const LintContext) bool { + // Resolve constrained type params to their constraint (`T extends object` + // → object) so overlap is computed on the constraint, matching TS-eslint's + // getConstrainedTypeAtLocation. + const a = resolveConstraint(a_in, ctx); + const b = resolveConstraint(b_in, ctx); // Expand unions on both sides; if any pair overlaps, true. const ka = ctx.typeIdKind(a) orelse return true; const kb = ctx.typeIdKind(b) orelse return true; @@ -935,6 +947,16 @@ fn typesCanOverlap(a: tymod.TypeId, b: tymod.TypeId, ctx: *const LintContext) bo return false; } +/// Peel a constrained type param to its constraint type (one level). +/// Unconstrained params and non-type-param ids pass through unchanged. +fn resolveConstraint(id: tymod.TypeId, ctx: *const LintContext) tymod.TypeId { + const kind = ctx.typeIdKind(id) orelse return id; + if (kind == .type_param) { + if (ctx.typeParamConstraint(id)) |c| return c; + } + return id; +} + fn kindFamily(k: tymod.TypeKind) u8 { return switch (k) { .string, .string_literal => 1, @@ -1016,6 +1038,11 @@ fn truthiness(id: TypeId, ctx: *const LintContext) Truthiness { fn truthinessDepth(id: TypeId, ctx: *const LintContext, depth: u32) Truthiness { if (depth > 6) return .indeterminate; const kind = ctx.typeIdKind(id) orelse return .indeterminate; + // Generic type parameter: follow constraint (`T extends object` → always_truthy). + if (kind == .type_param) { + const constraint = ctx.typeParamConstraint(id) orelse return .indeterminate; + return truthinessDepth(constraint, ctx, depth + 1); + } if (kind == .union_t) { var saw_truthy = false; var saw_falsy = false; @@ -1126,6 +1153,11 @@ fn nullability(id: TypeId, ctx: *const LintContext) Nullability { fn nullabilityDepth(id: TypeId, ctx: *const LintContext, depth: u32) Nullability { if (depth > 6) return .possibly_nullish; const kind = ctx.typeIdKind(id) orelse return .possibly_nullish; + // Generic type parameter: follow constraint (`T extends string` → never_nullish). + if (kind == .type_param) { + const constraint = ctx.typeParamConstraint(id) orelse return .possibly_nullish; + return nullabilityDepth(constraint, ctx, depth + 1); + } if (kind == .union_t) { var saw_null = false; var saw_non_null = false; diff --git a/src/linter/native/typescript/no_unnecessary_type_conversion.zig b/src/linter/native/typescript/no_unnecessary_type_conversion.zig index f2f3cb27..be92da0d 100644 --- a/src/linter/native/typescript/no_unnecessary_type_conversion.zig +++ b/src/linter/native/typescript/no_unnecessary_type_conversion.zig @@ -346,7 +346,12 @@ fn typeIsBigIntish(id: @import("ez_checker").types.TypeId, ctx: *const LintConte const tymod = @import("ez_checker").types; if (id.eq(tymod.ID_BIGINT)) return true; const k = ctx.typeIdKind(id) orelse return false; - return k == .bigint or k == .bigint_literal; + if (k == .bigint or k == .bigint_literal) return true; + if (k == .type_param) { + const constraint = ctx.typeParamConstraint(id) orelse return false; + return typeIsBigIntish(constraint, ctx); + } + return false; } fn typeIsEnumOrMember(node: NodeIndex, ctx: *const LintContext) bool { diff --git a/src/linter/native/typescript/strict_boolean_expressions.zig b/src/linter/native/typescript/strict_boolean_expressions.zig index 1173faa3..505decaa 100644 --- a/src/linter/native/typescript/strict_boolean_expressions.zig +++ b/src/linter/native/typescript/strict_boolean_expressions.zig @@ -826,7 +826,17 @@ fn walkType(id: TypeId, fam: *Family, ctx: *const LintContext, depth: u32) void fam.has_object = true; } }, - .type_param => fam.has_any = true, + .type_param => { + // TS-eslint uses getConstrainedTypeAtLocation: a constrained + // type param (`T extends boolean`) is classified by its + // constraint. An unconstrained `T` has no constraint → treat + // as `any` (allowAny applies), matching the old behavior. + if (ctx.typeParamConstraint(id)) |constraint| { + walkType(constraint, fam, ctx, depth + 1); + } else { + fam.has_any = true; + } + }, else => fam.has_other = true, } } diff --git a/tests/differential/baseline.json b/tests/differential/baseline.json index 50ef79b2..1fbb7e47 100644 --- a/tests/differential/baseline.json +++ b/tests/differential/baseline.json @@ -979,7 +979,7 @@ "crash": 0 }, "native": { - "fn": 6, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -997,7 +997,7 @@ "crash": 0 }, "native": { - "fn": 56, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1015,7 +1015,7 @@ "crash": 0 }, "native": { - "fn": 13, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1069,14 +1069,14 @@ "crash": 0 }, "native": { - "fn": 20, - "fp": 0, + "fn": 5, + "fp": 6, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 5, + "fp": 6, "crash": 0 } }, @@ -1087,7 +1087,7 @@ "crash": 0 }, "native": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1105,7 +1105,7 @@ "crash": 0 }, "native": { - "fn": 7, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1123,13 +1123,13 @@ "crash": 0 }, "native": { - "fn": 21, + "fn": 3, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 3, "fp": 0, "crash": 0 } @@ -1141,14 +1141,14 @@ "crash": 0 }, "native": { - "fn": 63, - "fp": 0, + "fn": 2, + "fp": 6, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 2, + "fp": 6, "crash": 0 } }, @@ -1159,7 +1159,7 @@ "crash": 0 }, "native": { - "fn": 54, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1195,7 +1195,7 @@ "crash": 0 }, "native": { - "fn": 15, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1213,14 +1213,14 @@ "crash": 0 }, "native": { - "fn": 129, - "fp": 0, + "fn": 0, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -1231,7 +1231,7 @@ "crash": 0 }, "native": { - "fn": 11, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1249,14 +1249,14 @@ "crash": 0 }, "native": { - "fn": 77, - "fp": 0, + "fn": 0, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -1267,13 +1267,13 @@ "crash": 0 }, "native": { - "fn": 26, + "fn": 1, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -1303,7 +1303,7 @@ "crash": 0 }, "native": { - "fn": 56, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1321,7 +1321,7 @@ "crash": 0 }, "native": { - "fn": 248, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1339,7 +1339,7 @@ "crash": 0 }, "native": { - "fn": 65, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1357,7 +1357,7 @@ "crash": 0 }, "native": { - "fn": 10, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1375,7 +1375,7 @@ "crash": 0 }, "native": { - "fn": 10, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1429,7 +1429,7 @@ "crash": 0 }, "native": { - "fn": 18, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1447,7 +1447,7 @@ "crash": 0 }, "native": { - "fn": 10, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1465,7 +1465,7 @@ "crash": 0 }, "native": { - "fn": 100, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1483,7 +1483,7 @@ "crash": 0 }, "native": { - "fn": 28, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1501,14 +1501,14 @@ "crash": 0 }, "native": { - "fn": 21, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0 } }, @@ -1519,7 +1519,7 @@ "crash": 0 }, "native": { - "fn": 56, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1537,7 +1537,7 @@ "crash": 0 }, "native": { - "fn": 19, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1555,7 +1555,7 @@ "crash": 0 }, "native": { - "fn": 21, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1573,7 +1573,7 @@ "crash": 0 }, "native": { - "fn": 43, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1591,7 +1591,7 @@ "crash": 0 }, "native": { - "fn": 8, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1609,7 +1609,7 @@ "crash": 0 }, "native": { - "fn": 101, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1627,7 +1627,7 @@ "crash": 0 }, "native": { - "fn": 76, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1663,7 +1663,7 @@ "crash": 0 }, "native": { - "fn": 9, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1681,14 +1681,14 @@ "crash": 0 }, "native": { - "fn": 61, - "fp": 0, + "fn": 6, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 6, + "fp": 1, "crash": 0 } }, @@ -1699,20 +1699,20 @@ "crash": 0 }, "native": { - "fn": 90, - "fp": 0, + "fn": 0, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, "@typescript-eslint/no-redeclare": { "runner": { - "fn": 0, + "fn": 4, "fp": 0, "crash": 0 }, @@ -1723,7 +1723,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 4, "fp": 0, "crash": 0 } @@ -1735,7 +1735,7 @@ "crash": 0 }, "native": { - "fn": 16, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1753,7 +1753,7 @@ "crash": 0 }, "native": { - "fn": 25, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1771,7 +1771,7 @@ "crash": 0 }, "native": { - "fn": 19, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1789,7 +1789,7 @@ "crash": 0 }, "native": { - "fn": 14, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1825,7 +1825,7 @@ "crash": 0 }, "native": { - "fn": 5, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1861,7 +1861,7 @@ "crash": 0 }, "native": { - "fn": 49, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1879,7 +1879,7 @@ "crash": 0 }, "native": { - "fn": 14, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1897,7 +1897,7 @@ "crash": 0 }, "native": { - "fn": 23, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1915,7 +1915,7 @@ "crash": 0 }, "native": { - "fn": 86, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1933,7 +1933,7 @@ "crash": 0 }, "native": { - "fn": 30, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1951,7 +1951,7 @@ "crash": 0 }, "native": { - "fn": 8, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -1969,7 +1969,7 @@ "crash": 0 }, "native": { - "fn": 5, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2041,13 +2041,13 @@ "crash": 0 }, "native": { - "fn": 44, + "fn": 1, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -2059,7 +2059,7 @@ "crash": 0 }, "native": { - "fn": 144, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2077,7 +2077,7 @@ "crash": 0 }, "native": { - "fn": 22, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2113,7 +2113,7 @@ "crash": 0 }, "native": { - "fn": 16, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2131,7 +2131,7 @@ "crash": 0 }, "native": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2149,7 +2149,7 @@ "crash": 0 }, "native": { - "fn": 21, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2167,7 +2167,7 @@ "crash": 0 }, "native": { - "fn": 16, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2185,7 +2185,7 @@ "crash": 0 }, "native": { - "fn": 32, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2203,7 +2203,7 @@ "crash": 0 }, "native": { - "fn": 101, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2216,8 +2216,8 @@ }, "@typescript-eslint/no-shadow": { "runner": { - "fn": 0, - "fp": 0, + "fn": 2, + "fp": 1, "crash": 0 }, "native": { @@ -2227,8 +2227,8 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 2, + "fp": 1, "crash": 0 } }, @@ -2239,13 +2239,13 @@ "crash": 0 }, "native": { - "fn": 20, + "fn": 2, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 2, "fp": 0, "crash": 0 } @@ -2257,7 +2257,7 @@ "crash": 0 }, "native": { - "fn": 38, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2275,14 +2275,14 @@ "crash": 0 }, "native": { - "fn": 48, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0 } }, @@ -2293,7 +2293,7 @@ "crash": 0 }, "native": { - "fn": 9, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2311,7 +2311,7 @@ "crash": 0 }, "native": { - "fn": 19, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2347,13 +2347,13 @@ "crash": 0 }, "native": { - "fn": 180, + "fn": 6, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 6, "fp": 0, "crash": 0 } @@ -2383,7 +2383,7 @@ "crash": 0 }, "native": { - "fn": 65, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2419,14 +2419,14 @@ "crash": 0 }, "native": { - "fn": 34, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0 } }, @@ -2437,14 +2437,14 @@ "crash": 0 }, "native": { - "fn": 134, - "fp": 0, + "fn": 3, + "fp": 4, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 3, + "fp": 4, "crash": 0 } }, @@ -2455,7 +2455,7 @@ "crash": 0 }, "native": { - "fn": 50, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2473,7 +2473,7 @@ "crash": 0 }, "native": { - "fn": 39, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2491,14 +2491,14 @@ "crash": 0 }, "native": { - "fn": 65, - "fp": 0, + "fn": 2, + "fp": 3, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 2, + "fp": 3, "crash": 0 } }, @@ -2509,7 +2509,7 @@ "crash": 0 }, "native": { - "fn": 7, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2527,13 +2527,13 @@ "crash": 0 }, "native": { - "fn": 22, + "fn": 1, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -2545,7 +2545,7 @@ "crash": 0 }, "native": { - "fn": 21, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2563,7 +2563,7 @@ "crash": 0 }, "native": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2581,7 +2581,7 @@ "crash": 0 }, "native": { - "fn": 13, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2599,7 +2599,7 @@ "crash": 0 }, "native": { - "fn": 32, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2617,7 +2617,7 @@ "crash": 0 }, "native": { - "fn": 14, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2635,7 +2635,7 @@ "crash": 0 }, "native": { - "fn": 36, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2653,7 +2653,7 @@ "crash": 0 }, "native": { - "fn": 9, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2671,7 +2671,7 @@ "crash": 0 }, "native": { - "fn": 28, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2707,7 +2707,7 @@ "crash": 0 }, "native": { - "fn": 7, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2725,7 +2725,7 @@ "crash": 0 }, "native": { - "fn": 73, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2743,7 +2743,7 @@ "crash": 0 }, "native": { - "fn": 5, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2761,7 +2761,7 @@ "crash": 0 }, "native": { - "fn": 49, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2779,7 +2779,7 @@ "crash": 0 }, "native": { - "fn": 9, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2797,7 +2797,7 @@ "crash": 0 }, "native": { - "fn": 3, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2815,7 +2815,7 @@ "crash": 0 }, "native": { - "fn": 19, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2851,7 +2851,7 @@ "crash": 0 }, "native": { - "fn": 8, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2887,14 +2887,14 @@ "crash": 0 }, "native": { - "fn": 1, - "fp": 0, + "fn": 0, + "fp": 2, "crash": 0, "skip": 0 }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 2, "crash": 0 } }, @@ -2905,7 +2905,7 @@ "crash": 0 }, "native": { - "fn": 48, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -2937,7 +2937,7 @@ "@typescript-eslint/naming-convention": { "runner": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 }, "native": { @@ -2948,7 +2948,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -2966,7 +2966,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 6, "crash": 0 } }, @@ -2995,7 +2995,7 @@ "crash": 0 }, "native": { - "fn": 636, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -3013,14 +3013,14 @@ "crash": 0 }, "native": { - "fn": 592, - "fp": 0, + "fn": 0, + "fp": 1, "crash": 0, "skip": 0 }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -3031,7 +3031,7 @@ "crash": 0 }, "native": { - "fn": 22, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -3049,7 +3049,7 @@ "crash": 0 }, "native": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -3103,7 +3103,7 @@ "crash": 0 }, "native": { - "fn": 18, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -3121,14 +3121,14 @@ "crash": 0 }, "native": { - "fn": 4, - "fp": 0, + "fn": 1, + "fp": 2, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 2, "crash": 0 } }, @@ -3139,13 +3139,13 @@ "crash": 0 }, "native": { - "fn": 52, + "fn": 1, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -3157,7 +3157,7 @@ "crash": 0 }, "native": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -3175,13 +3175,13 @@ "crash": 0 }, "native": { - "fn": 14, + "fn": 1, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -3193,7 +3193,7 @@ "crash": 0 }, "native": { - "fn": 27, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -3523,7 +3523,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 3, "fp": 0, "crash": 0 } @@ -3577,7 +3577,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 2, "fp": 0, "crash": 0 } @@ -4315,8 +4315,8 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 1, "crash": 0 } }, @@ -4387,8 +4387,8 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 3, + "fp": 1, "crash": 0 } }, @@ -4639,8 +4639,8 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 6, + "fp": 1, "crash": 0 } }, @@ -4712,7 +4712,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -4898,8 +4898,8 @@ }, "indent": { "runner": { - "fn": 1, - "fp": 45, + "fn": 0, + "fp": 13, "crash": 0 }, "native": { @@ -4910,7 +4910,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 13, "crash": 0 } }, @@ -4934,9 +4934,9 @@ }, "no-unused-vars": { "runner": { - "fn": 0, + "fn": 5, "fp": 0, - "crash": 0 + "crash": 10 }, "native": { "fn": 314, @@ -4945,7 +4945,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 5, "fp": 0, "crash": 0 } @@ -5054,7 +5054,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -5582,7 +5582,7 @@ }, "prefer-promise-reject-errors": { "runner": { - "fn": 1, + "fn": 0, "fp": 0, "crash": 0 }, @@ -5846,7 +5846,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -5911,7 +5911,7 @@ "crash": 0 }, "native": { - "fn": 1, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -6194,7 +6194,7 @@ }, "object-shorthand": { "runner": { - "fn": 18, + "fn": 6, "fp": 0, "crash": 0 }, @@ -6205,7 +6205,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 6, "fp": 0, "crash": 0 } @@ -6343,13 +6343,13 @@ "crash": 0 }, "native": { - "fn": 11, - "fp": 2, + "fn": 4, + "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 4, "fp": 0, "crash": 0 } @@ -6379,7 +6379,7 @@ "crash": 0 }, "native": { - "fn": 27, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -6422,7 +6422,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -6793,7 +6793,7 @@ "crash": 0 }, "native": { - "fn": 19, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -6937,13 +6937,13 @@ "crash": 0 }, "native": { - "fn": 7, + "fn": 1, "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -7196,7 +7196,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 3, "crash": 0 } }, @@ -7328,8 +7328,8 @@ }, "no-unexpected-multiline": { "runner": { - "fn": 1, - "fp": 1, + "fn": 0, + "fp": 0, "crash": 0 }, "native": { @@ -7339,7 +7339,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -7537,7 +7537,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -7567,7 +7567,7 @@ "crash": 0 }, "native": { - "fn": 1, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -7663,7 +7663,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -7868,18 +7868,18 @@ }, "no-shadow": { "runner": { - "fn": 5, - "fp": 3, + "fn": 0, + "fp": 0, "crash": 0 }, "native": { - "fn": 13, - "fp": 8, + "fn": 2, + "fp": 0, "crash": 0, "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 2, "fp": 0, "crash": 0 } @@ -7963,7 +7963,7 @@ "crash": 0 }, "native": { - "fn": 19, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -9121,14 +9121,14 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 4, "crash": 0 } }, "no-this-before-super": { "runner": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0 }, @@ -9175,7 +9175,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 2, "fp": 0, "crash": 0 } @@ -9277,7 +9277,7 @@ "crash": 0 }, "native": { - "fn": 1, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -9399,7 +9399,7 @@ "prefer-arrow-callback": { "runner": { "fn": 1, - "fp": 0, + "fp": 2, "crash": 0 }, "native": { @@ -9409,8 +9409,8 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 1, + "fp": 2, "crash": 0 } }, @@ -10100,8 +10100,8 @@ }, "constructor-super": { "runner": { - "fn": 2, - "fp": 1, + "fn": 0, + "fp": 0, "crash": 0 }, "native": { @@ -10165,7 +10165,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -12008,7 +12008,7 @@ }, "sonarjs/no-use-of-empty-return-value": { "runner": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 }, @@ -12019,7 +12019,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 1, "fp": 0, "crash": 0 } @@ -12127,7 +12127,7 @@ "skip": 0 }, "hybrid": { - "fn": 0, + "fn": 2, "fp": 0, "crash": 0 } @@ -13736,7 +13736,7 @@ }, "unicorn/expiring-todo-comments": { "runner": { - "fn": 2, + "fn": 0, "fp": 0, "crash": 0 }, @@ -13791,7 +13791,7 @@ "unicorn/consistent-function-scoping": { "runner": { "fn": 0, - "fp": 2, + "fp": 0, "crash": 0 }, "native": { @@ -13885,7 +13885,7 @@ "crash": 0 }, "native": { - "fn": 12, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14083,7 +14083,7 @@ "crash": 0 }, "native": { - "fn": 4, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14173,7 +14173,7 @@ "crash": 0 }, "native": { - "fn": 21, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14245,7 +14245,7 @@ "crash": 0 }, "native": { - "fn": 282, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14299,7 +14299,7 @@ "crash": 0 }, "native": { - "fn": 7, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14767,7 +14767,7 @@ "crash": 0 }, "native": { - "fn": 11, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14785,7 +14785,7 @@ "crash": 0 }, "native": { - "fn": 32, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14821,7 +14821,7 @@ "crash": 0 }, "native": { - "fn": 5, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -14983,7 +14983,7 @@ "crash": 0 }, "native": { - "fn": 173, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -15213,7 +15213,7 @@ "unicorn/prefer-export-from": { "runner": { "fn": 0, - "fp": 2, + "fp": 0, "crash": 0 }, "native": { @@ -15302,8 +15302,8 @@ }, "unicorn/prevent-abbreviations": { "runner": { - "fn": 0, - "fp": 0, + "fn": 2, + "fp": 2, "crash": 0 }, "native": { @@ -15313,8 +15313,8 @@ "skip": 0 }, "hybrid": { - "fn": 0, - "fp": 0, + "fn": 2, + "fp": 2, "crash": 0 } }, @@ -15332,7 +15332,7 @@ }, "hybrid": { "fn": 0, - "fp": 0, + "fp": 1, "crash": 0 } }, @@ -15343,7 +15343,7 @@ "crash": 0 }, "native": { - "fn": 5, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -15505,7 +15505,7 @@ "crash": 0 }, "native": { - "fn": 2, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -15559,7 +15559,7 @@ "crash": 0 }, "native": { - "fn": 3, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -15631,7 +15631,7 @@ "crash": 0 }, "native": { - "fn": 22, + "fn": 0, "fp": 0, "crash": 0, "skip": 0 @@ -15842,7 +15842,7 @@ }, "unicorn/prefer-dom-node-text-content": { "runner": { - "fn": 2, + "fn": 0, "fp": 0, "crash": 0 }, @@ -15968,7 +15968,7 @@ }, "unicorn/catch-error-name": { "runner": { - "fn": 2, + "fn": 0, "fp": 0, "crash": 0 }, @@ -16562,7 +16562,7 @@ }, "react/no-unused-prop-types": { "runner": { - "fn": 2, + "fn": 0, "fp": 0, "crash": 0 }, @@ -16652,7 +16652,7 @@ }, "react/prefer-read-only-props": { "runner": { - "fn": 6, + "fn": 0, "fp": 0, "crash": 0 }, @@ -16887,7 +16887,7 @@ "react/jsx-props-no-multi-spaces": { "runner": { "fn": 0, - "fp": 2, + "fp": 0, "crash": 0 }, "native": { @@ -18200,9 +18200,9 @@ } }, "perf": { - "totalElapsedMs": 14120, - "runnerCasesPerSec": 7535, - "nativeCasesPerSec": 66565, - "totalCases": 72134 + "totalElapsedMs": 18925, + "runnerCasesPerSec": 4755, + "nativeCasesPerSec": 46338, + "totalCases": 56037 } } \ No newline at end of file diff --git a/tests/differential/estree-fidelity.js b/tests/differential/estree-fidelity.js new file mode 100644 index 00000000..15e13615 --- /dev/null +++ b/tests/differential/estree-fidelity.js @@ -0,0 +1,93 @@ +#!/usr/bin/env node +// ESTree-fidelity diff harness. +// +// Parses a snippet with both the reference parser (@typescript-eslint/parser) +// and ez's parser, then walks the reference tree comparing `type` and `range` +// at every node against ez's ESTree. Use it to pin down where ez's ESTree +// diverges from the parser the differential oracle uses — especially for the +// most AST-sensitive rules like `indent`. +// +// Usage: +// node tests/differential/estree-fidelity.js '' [lang] +// node tests/differential/estree-fidelity.js --file path.ts [lang] +// lang: js | ts | jsx | tsx (default inferred: ts if code has TS syntax else js) + +const path = require("path"); +const JS_ROOT = path.join(__dirname, "..", "..", "js"); +const { parseSource, getTagNames } = require(path.join(JS_ROOT, "index.js")); +const adapter = require(path.join(JS_ROOT, "estree-adapter.js")); +const tsParser = require(path.join(JS_ROOT, "node_modules", "@typescript-eslint", "parser", "dist", "index.js")); + +function parseRef(code, jsx) { + return tsParser.parse(code, { + range: true, + loc: false, + comment: false, + jsx: !!jsx, + // no project — purely syntactic ESTree, which is all indent needs. + }); +} + +function isNode(v) { return v && typeof v === "object" && typeof v.type === "string"; } + +// Walk the reference tree; at each node compare against the matching ez node. +function diff(ref, ours, out, pathStr) { + if (!ref) return; + if (!ours) { out.push(`${pathStr}: ref ${ref.type}[${ref.range}] — ez node MISSING`); return; } + const ourType = ours.type; + if (ourType !== ref.type) { + out.push(`${pathStr}: TYPE ref=${ref.type}[${ref.range}] ez=${ourType}[${ours.range}]`); + // types diverged — children won't line up; stop this branch. + return; + } + const rr = ref.range, or = ours.range; + if (!or || rr[0] !== or[0] || rr[1] !== or[1]) { + out.push(`${pathStr} ${ref.type}: RANGE ref=[${rr}] ez=[${or}]`); + } + // Recurse into child keys present on the reference node. + for (const key of Object.keys(ref)) { + if (key === "type" || key === "range" || key === "loc" || key === "parent") continue; + const rv = ref[key]; + if (Array.isArray(rv)) { + let ov; + try { ov = ours[key]; } catch { ov = undefined; } + for (let i = 0; i < rv.length; i++) { + if (!isNode(rv[i])) continue; + const oc = Array.isArray(ov) ? ov[i] : undefined; + diff(rv[i], oc, out, `${pathStr}.${key}[${i}]`); + } + } else if (isNode(rv)) { + let ov; + try { ov = ours[key]; } catch { ov = undefined; } + diff(rv, ov, out, `${pathStr}.${key}`); + } + } +} + +function main() { + const argv = process.argv.slice(2); + let code, lang; + if (argv[0] === "--file") { + code = require("fs").readFileSync(argv[1], "utf8"); + lang = argv[2]; + } else { + code = argv[0]; + lang = argv[1]; + } + if (code == null) { console.error("usage: estree-fidelity.js '' [lang]"); process.exit(2); } + const jsx = lang === "jsx" || lang === "tsx"; + if (!lang) lang = /\b(abstract|public|private|protected|readonly|implements|interface|enum|namespace|:\s*\w+\s*[=;)])/.test(code) ? "ts" : "js"; + + adapter.setTagNames(getTagNames()); + const ours = parseSource(code, { lang, sourceType: "module" }).root(); + let ref; + try { ref = parseRef(code, jsx); } + catch (e) { console.error("reference parse error:", e.message); process.exit(1); } + + const out = []; + diff(ref, ours, out, "Program"); + if (out.length === 0) console.log("✓ identical (type + range) across", code.split("\n").length, "lines"); + else { console.log(`${out.length} difference(s):`); for (const l of out) console.log(" " + l); } +} + +main(); diff --git a/tests/differential/run.js b/tests/differential/run.js index b0e58820..896b44c5 100644 --- a/tests/differential/run.js +++ b/tests/differential/run.js @@ -552,15 +552,24 @@ function runRunnerForRule(src, ruleName, ruleModule, ruleOptions, sourceType, tc // IMPORTANT: count JS errors BEFORE the TS parse because the NAPI parser shares one // internal buffer — after parse() is called again, ast._nodeTags points to the new // buffer and the JS error count is lost. - if (parseLang === "js" && ast._nodeTags && ast._nodeTags.includes(193 /* T.error_node */)) { + // jsx upgrades to tsx (the oracle for these cases used @typescript-eslint/parser, + // which accepts TS syntax in a jsx-enabled file; our default jsx parse errors on it). + if ((parseLang === "js" || parseLang === "jsx") && ast._nodeTags && ast._nodeTags.includes(193 /* T.error_node */)) { + // Count JS errors BEFORE the TS parse — the NAPI parser shares one internal + // buffer, so after parse() runs again `ast._nodeTags` points at the new buffer. const _jsErrCount = ast._nodeTags.reduce((n, t) => n + (t === 193 ? 1 : 0), 0); - const _tsFilename = filename.replace(/\.js$/, ".ts") || filename; + const _upgradeLang = parseLang === "jsx" ? "tsx" : "ts"; + const _tsFilename = filename.replace(/\.jsx?$/, parseLang === "jsx" ? ".tsx" : ".ts") || filename; try { - const _tsAst = parse(src, { filename: _tsFilename, lang: "ts", globals: zigGlobals, sourceType, + const _tsAst = parse(src, { filename: _tsFilename, lang: _upgradeLang, globals: zigGlobals, sourceType, parserOptions: tcLanguageOptions.parserOptions }); const _tsErrCount = _tsAst._nodeTags.reduce((n, t) => n + (t === 193 ? 1 : 0), 0); - if (_tsErrCount === 0) ast = _tsAst; - } catch { /* keep JS ast */ } + // Upgrade when TS is strictly cleaner — covers the fully-clean case and the + // partially-clean case (TS parses most properties even when one unsupported + // type syntax like `(void)` produces a single ErrorNode). The oracle parsed + // these with @typescript-eslint/parser, so the cleaner TS AST matches better. + if (_tsErrCount < _jsErrCount) ast = _tsAst; + } catch { /* keep original ast */ } } _runnerParseMs += Date.now() - _p0; // Re-use the caller-provided plugin identity (same object → buildVisitorMap fast path), @@ -1305,7 +1314,7 @@ if (fs.existsSync(ESLINT_ROOT)) { } } - const _isTsCase = isTypeScript || !!tc.isTypeScript; + const _isTsCase = !!tc.isTypeScript; // Hoisted out of the runner block so the native + hybrid blocks can // still consult them under --native-only. // Use the location-only key under --native-only so the NAPI path's