From 5ea50ac8266b92f8e8be9968177dac014fea3784 Mon Sep 17 00:00:00 2001 From: oekazuma Date: Wed, 22 Jul 2026 20:04:09 +0900 Subject: [PATCH] refactor(core): type unwrapTs and its each-block/index-key callers Introduce TsExpression (estree Expression + the three TS wrapper node types the Svelte script parser emits) so unwrapTs and its direct callers in component-parse.ts and kit-module-parse.ts get real types instead of any. Generic AST walkers stay untyped for now. Co-Authored-By: Claude Sonnet 5 --- packages/action/dist/index.js | 30 +++++----- packages/core/package.json | 3 + packages/core/src/component-parse.ts | 82 ++++++++++++++++++--------- packages/core/src/kit-module-parse.ts | 14 ++++- pnpm-lock.yaml | 4 ++ 5 files changed, 89 insertions(+), 44 deletions(-) diff --git a/packages/action/dist/index.js b/packages/action/dist/index.js index dc12cb44b..7bfdcb816 100644 --- a/packages/action/dist/index.js +++ b/packages/action/dist/index.js @@ -54632,7 +54632,7 @@ function attrTextOf(attr) { } function unwrapTs(expr) { let cur = expr; - while (cur?.type === "TSSatisfiesExpression" || cur?.type === "TSAsExpression" || cur?.type === "TSNonNullExpression") + while (cur.type === "TSSatisfiesExpression" || cur.type === "TSAsExpression" || cur.type === "TSNonNullExpression") cur = cur.expression; return cur; } @@ -54642,7 +54642,7 @@ function isLengthOnlyArrayCall(expr) { if ((e2.type === "CallExpression" || e2.type === "NewExpression") && e2.callee?.type === "Identifier" && e2.callee.name === "Array") { return (e2.arguments?.length ?? 0) === 1; } - if (e2.type === "CallExpression" && e2.callee?.type === "MemberExpression" && !e2.callee.computed && e2.callee.object?.type === "Identifier" && e2.callee.object.name === "Array" && e2.callee.property?.name === "from" && e2.arguments?.[0]?.type === "ObjectExpression") { + if (e2.type === "CallExpression" && e2.callee?.type === "MemberExpression" && !e2.callee.computed && e2.callee.object?.type === "Identifier" && e2.callee.object.name === "Array" && e2.callee.property.type === "Identifier" && e2.callee.property.name === "from" && e2.arguments?.[0]?.type === "ObjectExpression") { return (e2.arguments[0].properties ?? []).some( (p) => p?.type === "Property" && !p.computed && (p.key?.name === "length" || p.key?.value === "length") ); @@ -54650,34 +54650,34 @@ function isLengthOnlyArrayCall(expr) { return false; } function isIdentityFreeEach(node) { - const expr = unwrapTs(node?.expression); - if (expr?.type === "ArrayExpression" && Array.isArray(expr.elements)) { + const expr = unwrapTs(node.expression); + if (expr.type === "ArrayExpression" && Array.isArray(expr.elements)) { return expr.elements.every((el) => el?.type !== "SpreadElement" || isLengthOnlyArrayCall(el.argument)); } return isLengthOnlyArrayCall(expr); } function isIndexExpression(expr, index) { const e2 = unwrapTs(expr); - if (e2?.type === "Identifier") return e2.name === index; - if (e2?.type === "CallExpression") { + if (e2.type === "Identifier") return e2.name === index; + if (e2.type === "CallExpression") { const callee = e2.callee; - if (callee?.type === "Identifier" && (callee.name === "String" || callee.name === "Number") && e2.arguments?.length === 1) { + if (callee.type === "Identifier" && (callee.name === "String" || callee.name === "Number") && e2.arguments.length === 1) { return isIndexExpression(e2.arguments[0], index); } - if (callee?.type === "MemberExpression" && !callee.computed && callee.property?.name === "toString" && (e2.arguments?.length ?? 0) === 0) { + if (callee.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "toString" && e2.arguments.length === 0) { return isIndexExpression(callee.object, index); } return false; } - if (e2?.type === "TemplateLiteral") { - const exprs = e2.expressions ?? []; + if (e2.type === "TemplateLiteral") { + const exprs = e2.expressions; if (exprs.length !== 1) return false; - const hasText = (e2.quasis ?? []).some((q) => (q?.value?.cooked ?? q?.value?.raw ?? "") !== ""); + const hasText = e2.quasis.some((q) => (q.value.cooked ?? q.value.raw) !== ""); if (hasText) return false; return isIndexExpression(exprs[0], index); } - if (e2?.type === "BinaryExpression" && e2.operator === "+") { - const emptyString = (n) => n?.type === "Literal" && n.value === ""; + if (e2.type === "BinaryExpression" && e2.operator === "+") { + const emptyString = (n) => n.type === "Literal" && n.value === ""; if (emptyString(e2.left)) return isIndexExpression(e2.right, index); if (emptyString(e2.right)) return isIndexExpression(e2.left, index); } @@ -55766,14 +55766,14 @@ function isParentCall(arg) { if (e2?.type !== "CallExpression") return false; const callee = e2.callee; if (callee?.type === "Identifier" && callee.name === "parent") return true; - return callee?.type === "MemberExpression" && !callee.computed && callee.property?.name === "parent"; + return callee?.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && callee.property.name === "parent"; } var BODY_METHODS = /* @__PURE__ */ new Set(["json", "text", "blob", "arrayBuffer", "formData", "bytes"]); function isBodyParseCall(arg) { const e2 = unwrapTs(arg); if (e2?.type !== "CallExpression" || e2.arguments?.length) return false; const callee = e2.callee; - return callee?.type === "MemberExpression" && !callee.computed && BODY_METHODS.has(callee.property?.name); + return callee?.type === "MemberExpression" && !callee.computed && callee.property.type === "Identifier" && BODY_METHODS.has(callee.property.name); } function refsTainted(node, tainted) { let hit = false; diff --git a/packages/core/package.json b/packages/core/package.json index 1543e82d0..5cf50e4bb 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -27,6 +27,9 @@ "dependencies": { "svelte": "catalog:" }, + "devDependencies": { + "@types/estree": "catalog:" + }, "exports": { ".": { "types": "./dist/index.d.ts", diff --git a/packages/core/src/component-parse.ts b/packages/core/src/component-parse.ts index a85b8be1b..9f4572c44 100644 --- a/packages/core/src/component-parse.ts +++ b/packages/core/src/component-parse.ts @@ -1,4 +1,6 @@ import { parse } from 'svelte/compiler'; +import type { Expression } from 'estree'; +import type { AST } from 'svelte/compiler'; import type { BrowserGlobalRefFact, ComponentFacts, @@ -18,16 +20,40 @@ import { CHILD_NODE_KEYS, lineOf, findAttr, attrTextOf } from './svelte-ast.js'; /* oxlint-disable @typescript-eslint/no-explicit-any */ type Node = any; +// TypeScript wrapper expressions the Svelte script parser emits for `x satisfies T` / +// `x as T` / `x!` — not part of estree's own type set, so declared here. `unwrapTs` +// is shared with the Kit-module and Vite-config parsers, hence exported alongside them. +export interface TSSatisfiesExpression { + type: 'TSSatisfiesExpression'; + start: number; + end: number; + expression: TsExpression; +} +export interface TSAsExpression { + type: 'TSAsExpression'; + start: number; + end: number; + expression: TsExpression; +} +export interface TSNonNullExpression { + type: 'TSNonNullExpression'; + start: number; + end: number; + expression: TsExpression; +} +/** An estree `Expression`, optionally wrapped in one or more of the three TS wrappers above. */ +export type TsExpression = Expression | TSSatisfiesExpression | TSAsExpression | TSNonNullExpression; + /** Unwrap TS wrapper expressions (`x satisfies T`, `x as T`, `x!`) to the underlying expression. Shared with the Kit-module and Vite-config parsers. */ -export function unwrapTs(expr: Node): Node { +export function unwrapTs(expr: TsExpression): Expression { let cur = expr; - while (cur?.type === 'TSSatisfiesExpression' || cur?.type === 'TSAsExpression' || cur?.type === 'TSNonNullExpression') + while (cur.type === 'TSSatisfiesExpression' || cur.type === 'TSAsExpression' || cur.type === 'TSNonNullExpression') cur = cur.expression; return cur; } /** Whether `expr` is a length-only list constructor: `Array(n)` / `new Array(n)` (single argument = length semantics) or `Array.from({ length: n }, …)`. */ -function isLengthOnlyArrayCall(expr: Node): boolean { +function isLengthOnlyArrayCall(expr: TsExpression): boolean { const e = unwrapTs(expr); if (!e) return false; if ( @@ -43,7 +69,8 @@ function isLengthOnlyArrayCall(expr: Node): boolean { !e.callee.computed && e.callee.object?.type === 'Identifier' && e.callee.object.name === 'Array' && - e.callee.property?.name === 'from' && + e.callee.property.type === 'Identifier' && + e.callee.property.name === 'from' && e.arguments?.[0]?.type === 'ObjectExpression' ) { return (e.arguments[0].properties ?? []).some( @@ -61,10 +88,10 @@ function isLengthOnlyArrayCall(expr: Node): boolean { * length-only list. Such blocks are skipped entirely — neither each-key nor * each-index-key can give useful advice on them. */ -function isIdentityFreeEach(node: Node): boolean { - const expr = unwrapTs(node?.expression); - if (expr?.type === 'ArrayExpression' && Array.isArray(expr.elements)) { - return expr.elements.every((el: Node) => el?.type !== 'SpreadElement' || isLengthOnlyArrayCall(el.argument)); +function isIdentityFreeEach(node: AST.EachBlock): boolean { + const expr = unwrapTs(node.expression); + if (expr.type === 'ArrayExpression' && Array.isArray(expr.elements)) { + return expr.elements.every((el) => el?.type !== 'SpreadElement' || isLengthOnlyArrayCall(el.argument)); } return isLengthOnlyArrayCall(expr); } @@ -77,39 +104,40 @@ function isIdentityFreeEach(node: Node): boolean { * and NOT matched: composite keys may be a deliberate uniqueness workaround for * duplicate items. */ -function isIndexExpression(expr: Node, index: string): boolean { +function isIndexExpression(expr: TsExpression, index: string): boolean { const e = unwrapTs(expr); - if (e?.type === 'Identifier') return e.name === index; - if (e?.type === 'CallExpression') { + if (e.type === 'Identifier') return e.name === index; + if (e.type === 'CallExpression') { const callee = e.callee; if ( - callee?.type === 'Identifier' && + callee.type === 'Identifier' && (callee.name === 'String' || callee.name === 'Number') && - e.arguments?.length === 1 + e.arguments.length === 1 ) { - return isIndexExpression(e.arguments[0], index); + return isIndexExpression(e.arguments[0] as Expression, index); } if ( - callee?.type === 'MemberExpression' && + callee.type === 'MemberExpression' && !callee.computed && - callee.property?.name === 'toString' && - (e.arguments?.length ?? 0) === 0 + callee.property.type === 'Identifier' && + callee.property.name === 'toString' && + e.arguments.length === 0 ) { - return isIndexExpression(callee.object, index); + return isIndexExpression(callee.object as Expression, index); } return false; } - if (e?.type === 'TemplateLiteral') { - const exprs = e.expressions ?? []; + if (e.type === 'TemplateLiteral') { + const exprs = e.expressions; if (exprs.length !== 1) return false; - const hasText = (e.quasis ?? []).some((q: Node) => (q?.value?.cooked ?? q?.value?.raw ?? '') !== ''); + const hasText = e.quasis.some((q) => (q.value.cooked ?? q.value.raw) !== ''); if (hasText) return false; - return isIndexExpression(exprs[0], index); + return isIndexExpression(exprs[0]!, index); } - if (e?.type === 'BinaryExpression' && e.operator === '+') { - const emptyString = (n: Node) => n?.type === 'Literal' && n.value === ''; - if (emptyString(e.left)) return isIndexExpression(e.right, index); - if (emptyString(e.right)) return isIndexExpression(e.left, index); + if (e.type === 'BinaryExpression' && e.operator === '+') { + const emptyString = (n: Expression): boolean => n.type === 'Literal' && n.value === ''; + if (emptyString(e.left as Expression)) return isIndexExpression(e.right, index); + if (emptyString(e.right)) return isIndexExpression(e.left as Expression, index); } return false; } @@ -122,7 +150,7 @@ function isIndexExpression(expr: Node, index: string): boolean { * merely CONTAIN the index add uniqueness and are never matched * (correctness/each-index-key). */ -function isIndexKey(each: Node): boolean { +function isIndexKey(each: AST.EachBlock): boolean { if (typeof each.index !== 'string' || each.key == null) return false; return isIndexExpression(each.key, each.index); } diff --git a/packages/core/src/kit-module-parse.ts b/packages/core/src/kit-module-parse.ts index 61f59d5c1..ab839229e 100644 --- a/packages/core/src/kit-module-parse.ts +++ b/packages/core/src/kit-module-parse.ts @@ -211,7 +211,12 @@ function isParentCall(arg: Node): boolean { if (e?.type !== 'CallExpression') return false; const callee = e.callee; if (callee?.type === 'Identifier' && callee.name === 'parent') return true; - return callee?.type === 'MemberExpression' && !callee.computed && callee.property?.name === 'parent'; + return ( + callee?.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + callee.property.name === 'parent' + ); } const BODY_METHODS = new Set(['json', 'text', 'blob', 'arrayBuffer', 'formData', 'bytes']); @@ -226,7 +231,12 @@ function isBodyParseCall(arg: Node): boolean { const e = unwrapTs(arg); if (e?.type !== 'CallExpression' || e.arguments?.length) return false; const callee = e.callee; - return callee?.type === 'MemberExpression' && !callee.computed && BODY_METHODS.has(callee.property?.name); + return ( + callee?.type === 'MemberExpression' && + !callee.computed && + callee.property.type === 'Identifier' && + BODY_METHODS.has(callee.property.name) + ); } /** diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 2fff04ac7..63993b694 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -226,6 +226,10 @@ importers: svelte: specifier: 'catalog:' version: 5.56.6(@typescript-eslint/types@8.64.0) + devDependencies: + '@types/estree': + specifier: 'catalog:' + version: 1.0.9 packages/mcp: dependencies: