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 `' [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