@stll/oxlint-plugin 0.2.1 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1 +1 @@
1
- {"version":3,"file":"no-unsafe-inner-html.mjs","names":[],"sources":["../src/no-unsafe-inner-html.ts"],"sourcesContent":["import { eslintCompatPlugin } from \"@oxlint/plugins\";\n// Forbid injecting un-proven HTML into the DOM.\n//\n// Raw HTML may only reach the DOM from a value that is provably sanitized or\n// escaped. This precise guard can replace a blanket `react/no-danger` rule:\n// approved sanitizer calls remain usable while unescaped stored or user input\n// cannot flow into `__html` or `el.innerHTML` unnoticed.\n//\n// Two sinks share one allowlist:\n// • JSX `__html` property of a `dangerouslySetInnerHTML` object literal\n// (report on the value expression).\n// • `AssignmentExpression` whose LHS is a non-computed `.innerHTML`\n// MemberExpression (report on the RHS).\n//\n// A value is allowed when it is:\n// • a string Literal or TemplateLiteral whose interpolations are all\n// independently proven safe (static markup, no raw injection), OR\n// • a CallExpression whose callee is an HTML/DOM sanitizer or escaper\n// (`escapeHtml`, `escapeXml`, `sanitizeHtml`, `sanitizeDom`,\n// `DOMPurify.sanitize`), including a `.value` / `.data` / `.html`-style\n// member read OFF such a call (some sanitizers return `{ value }`), OR\n// • carries an explicit `// safe-html:` escape-hatch comment on the line\n// directly above the sink (loc adjacency, like suppression-hygiene.ts).\n//\n// A value is NOT auto-allowed just because the identifier is named\n// `html` / `content` / `headline` / `body` — those are the sinks, not\n// proof of safety. Prove safety at the source or annotate with a reason.\n//\n// Flagged:\n// <div dangerouslySetInnerHTML={{ __html: userInput }} />\n// <div dangerouslySetInnerHTML={{ __html: hit.headline }} /> // unless annotated\n// el.innerHTML = html; // unless annotated\n// el.innerHTML = data.body;\n//\n// Allowed:\n// <div dangerouslySetInnerHTML={{ __html: sanitizeHtml(x) }} />\n// <div dangerouslySetInnerHTML={{ __html: `<b>${escapeXml(n)}</b>` }} />\n// <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(x) }} />\n// el.innerHTML = \"\";\n// el.innerHTML = \"&nbsp;\";\n// // safe-html: escaped by escapeAndHighlight()\n// <div dangerouslySetInnerHTML={{ __html: hit.headline }} />\n\nimport {\n getCalleeName,\n getPropertyName,\n isIdentifier,\n unwrapExpression,\n} from \"./utils.ts\";\n\n// Callee names that prove the value was run through an HTML/DOM sanitizer or\n// escaper. Keep this narrow: `escapeRegex()` and `sanitizeFilename()` are not\n// HTML-safe.\nconst HTML_SANITIZER_LEAF_RE =\n /^(?:escape(?:html|xml)|sanitize(?:html|dom)|purify(?:html|dom)|dompurify)$/iu;\n\nconst DOM_PURIFY_RE = /^dompurify$/iu;\n\n// Members that some sanitizers expose on their result object\n// (`sanitizeHtml(x).value`, `sanitizeHtml(x).html`). Reading one of these OFF a\n// sanitizer call is still proven-safe.\nconst SANITIZER_RESULT_MEMBERS = new Set([\"value\", \"data\", \"html\"]);\n\nconst ESCAPE_HATCH_RE = /^\\s*safe-html:/u;\n\nconst isComment = (value) =>\n typeof value === \"object\" &&\n value !== null &&\n typeof value.value === \"string\" &&\n typeof value.loc === \"object\" &&\n value.loc !== null;\n\nconst isJsxIdentifier = (node, name) =>\n typeof node === \"object\" &&\n node !== null &&\n node.type === \"JSXIdentifier\" &&\n node.name === name;\n\nconst isHtmlSanitizerCalleeName = (dotted) => {\n const parts = dotted.split(\".\");\n const leaf = parts.at(-1);\n if (leaf === undefined) {\n return false;\n }\n if (HTML_SANITIZER_LEAF_RE.test(leaf)) {\n return true;\n }\n const objectName = parts.at(-2);\n return leaf === \"sanitize\" && DOM_PURIFY_RE.test(objectName ?? \"\");\n};\n\nconst isSanitizerCall = (node) => {\n if (node.type !== \"CallExpression\") {\n return false;\n }\n const dotted = getCalleeName(node.callee);\n return dotted !== null && isHtmlSanitizerCalleeName(dotted);\n};\n\n// A value is proven safe by its own shape (independent of any comment):\n// static string, or the result of a sanitizer call (directly, or via a\n// `.value`/`.data`/`.html` accessor off the call).\nconst isProvenSafeValue = (node) => {\n if (!node || typeof node.type !== \"string\") {\n return false;\n }\n if (node.type === \"Literal\" && typeof node.value === \"string\") {\n return true;\n }\n if (node.type === \"TemplateLiteral\") {\n return (\n Array.isArray(node.expressions) &&\n node.expressions.every(isProvenSafeValue)\n );\n }\n if (isSanitizerCall(node)) {\n return true;\n }\n // `sanitize(x).value`, `purify(x).html` — a result accessor off a call.\n if (\n node.type === \"MemberExpression\" &&\n node.computed === false &&\n isProvenSafeMemberLeaf(node)\n ) {\n return true;\n }\n return false;\n};\n\nconst isProvenSafeMemberLeaf = (node) => {\n const propertyName = getPropertyName(node.property);\n if (propertyName === null || !SANITIZER_RESULT_MEMBERS.has(propertyName)) {\n return false;\n }\n return isSanitizerCall(node.object);\n};\n\nconst isDangerouslySetInnerHtmlValue = (property) => {\n if (getPropertyName(property.key) !== \"__html\") {\n return false;\n }\n const objectExpression = property.parent;\n if (objectExpression?.type !== \"ObjectExpression\") {\n return false;\n }\n\n let current = objectExpression.parent;\n while (\n current?.type === \"TSAsExpression\" ||\n current?.type === \"TSSatisfiesExpression\"\n ) {\n current = current.parent;\n }\n\n if (current?.type !== \"JSXExpressionContainer\") {\n return false;\n }\n const attribute = current.parent;\n return (\n attribute?.type === \"JSXAttribute\" &&\n isJsxIdentifier(attribute.name, \"dangerouslySetInnerHTML\")\n );\n};\n\nexport default eslintCompatPlugin({\n meta: { name: \"no-unsafe-inner-html\" },\n rules: {\n \"no-unsafe-inner-html\": {\n meta: {\n type: \"problem\",\n messages: {\n unsafeInnerHtml:\n \"Raw HTML injected into the DOM must come from a provably \" +\n \"sanitized/escaped value: a static string, an HTML/DOM \" +\n \"sanitize/escape call (or its .value/.data/.html result). \" +\n \"Wrap the value in a sanitizer, or add a `// safe-html: <reason \" +\n \"naming the sanitizer/source>` comment on the line directly above \" +\n \"if it is escaped at its source.\",\n unsafeInnerHtmlSpread:\n \"Do not spread an object into dangerouslySetInnerHTML. Keep \" +\n \"`__html` inline so this rule can prove the HTML value is \" +\n \"sanitized or escaped.\",\n },\n },\n createOnce(context) {\n const escapeHatchLines = new Set();\n\n const recordEscapeHatches = (node) => {\n const comments =\n node && Array.isArray(node.comments)\n ? node.comments.filter(isComment)\n : [];\n for (const comment of comments) {\n if (ESCAPE_HATCH_RE.test(comment.value)) {\n escapeHatchLines.add(comment.loc.end.line);\n }\n }\n };\n\n // The escape-hatch comment sits on the line directly above the\n // reported node's first line. Sink expressions inside JSX object\n // literals span multiple lines, so anchor on the node's start line.\n const hasEscapeHatchAbove = (node) =>\n escapeHatchLines.has(node.loc.start.line - 1);\n\n const reportIfUnsafe = (node) => {\n if (isProvenSafeValue(node)) {\n return;\n }\n if (hasEscapeHatchAbove(node)) {\n return;\n }\n context.report({ node, messageId: \"unsafeInnerHtml\" });\n };\n\n const reportPayloadSpreads = (objectNode) => {\n for (const property of objectNode.properties) {\n if (property?.type !== \"SpreadElement\") {\n continue;\n }\n context.report({\n node: property,\n messageId: \"unsafeInnerHtmlSpread\",\n });\n }\n };\n\n return {\n before() {\n escapeHatchLines.clear();\n },\n Program(node) {\n recordEscapeHatches(node);\n },\n\n // Reject hoisted payloads such as\n // `dangerouslySetInnerHTML={payload}`. Keeping the `__html` object\n // inline lets this rule inspect the actual HTML expression.\n JSXAttribute(node) {\n if (!isJsxIdentifier(node.name, \"dangerouslySetInnerHTML\")) {\n return;\n }\n const value = node.value;\n if (value?.type !== \"JSXExpressionContainer\") {\n return;\n }\n if (!value.expression) {\n return;\n }\n const expression = unwrapExpression(value.expression);\n if (expression?.type === \"ObjectExpression\") {\n reportPayloadSpreads(expression);\n return;\n }\n reportIfUnsafe(value.expression);\n },\n\n // Sink 1: `dangerouslySetInnerHTML={{ __html: <expr> }}`.\n Property(node) {\n if (!isDangerouslySetInnerHtmlValue(node)) {\n return;\n }\n reportIfUnsafe(node.value);\n },\n\n // Sink 2: `<el>.innerHTML = <expr>` (non-computed member LHS).\n AssignmentExpression(node) {\n const target = node.left;\n if (\n target.type !== \"MemberExpression\" ||\n target.computed !== false ||\n !isIdentifier(target.property, \"innerHTML\")\n ) {\n return;\n }\n reportIfUnsafe(node.right);\n },\n };\n },\n },\n },\n});\n"],"mappings":";;;AAqDA,MAAM,yBACJ;AAEF,MAAM,gBAAgB;AAKtB,MAAM,2CAA2B,IAAI,IAAI;CAAC;CAAS;CAAQ;AAAM,CAAC;AAElE,MAAM,kBAAkB;AAExB,MAAM,aAAa,UACjB,OAAO,UAAU,YACjB,UAAU,QACV,OAAO,MAAM,UAAU,YACvB,OAAO,MAAM,QAAQ,YACrB,MAAM,QAAQ;AAEhB,MAAM,mBAAmB,MAAM,SAC7B,OAAO,SAAS,YAChB,SAAS,QACT,KAAK,SAAS,mBACd,KAAK,SAAS;AAEhB,MAAM,6BAA6B,WAAW;CAC5C,MAAM,QAAQ,OAAO,MAAM,GAAG;CAC9B,MAAM,OAAO,MAAM,GAAG,EAAE;CACxB,IAAI,SAAS,KAAA,GACX,OAAO;CAET,IAAI,uBAAuB,KAAK,IAAI,GAClC,OAAO;CAET,MAAM,aAAa,MAAM,GAAG,EAAE;CAC9B,OAAO,SAAS,cAAc,cAAc,KAAK,cAAc,EAAE;AACnE;AAEA,MAAM,mBAAmB,SAAS;CAChC,IAAI,KAAK,SAAS,kBAChB,OAAO;CAET,MAAM,SAAS,cAAc,KAAK,MAAM;CACxC,OAAO,WAAW,QAAQ,0BAA0B,MAAM;AAC5D;AAKA,MAAM,qBAAqB,SAAS;CAClC,IAAI,CAAC,QAAQ,OAAO,KAAK,SAAS,UAChC,OAAO;CAET,IAAI,KAAK,SAAS,aAAa,OAAO,KAAK,UAAU,UACnD,OAAO;CAET,IAAI,KAAK,SAAS,mBAChB,OACE,MAAM,QAAQ,KAAK,WAAW,KAC9B,KAAK,YAAY,MAAM,iBAAiB;CAG5C,IAAI,gBAAgB,IAAI,GACtB,OAAO;CAGT,IACE,KAAK,SAAS,sBACd,KAAK,aAAa,SAClB,uBAAuB,IAAI,GAE3B,OAAO;CAET,OAAO;AACT;AAEA,MAAM,0BAA0B,SAAS;CACvC,MAAM,eAAe,gBAAgB,KAAK,QAAQ;CAClD,IAAI,iBAAiB,QAAQ,CAAC,yBAAyB,IAAI,YAAY,GACrE,OAAO;CAET,OAAO,gBAAgB,KAAK,MAAM;AACpC;AAEA,MAAM,kCAAkC,aAAa;CACnD,IAAI,gBAAgB,SAAS,GAAG,MAAM,UACpC,OAAO;CAET,MAAM,mBAAmB,SAAS;CAClC,IAAI,kBAAkB,SAAS,oBAC7B,OAAO;CAGT,IAAI,UAAU,iBAAiB;CAC/B,OACE,SAAS,SAAS,oBAClB,SAAS,SAAS,yBAElB,UAAU,QAAQ;CAGpB,IAAI,SAAS,SAAS,0BACpB,OAAO;CAET,MAAM,YAAY,QAAQ;CAC1B,OACE,WAAW,SAAS,kBACpB,gBAAgB,UAAU,MAAM,yBAAyB;AAE7D;AAEA,IAAA,+BAAe,mBAAmB;CAChC,MAAM,EAAE,MAAM,uBAAuB;CACrC,OAAO,EACL,wBAAwB;EACtB,MAAM;GACJ,MAAM;GACN,UAAU;IACR,iBACE;IAMF,uBACE;GAGJ;EACF;EACA,WAAW,SAAS;GAClB,MAAM,mCAAmB,IAAI,IAAI;GAEjC,MAAM,uBAAuB,SAAS;IACpC,MAAM,WACJ,QAAQ,MAAM,QAAQ,KAAK,QAAQ,IAC/B,KAAK,SAAS,OAAO,SAAS,IAC9B,CAAC;IACP,KAAK,MAAM,WAAW,UACpB,IAAI,gBAAgB,KAAK,QAAQ,KAAK,GACpC,iBAAiB,IAAI,QAAQ,IAAI,IAAI,IAAI;GAG/C;GAKA,MAAM,uBAAuB,SAC3B,iBAAiB,IAAI,KAAK,IAAI,MAAM,OAAO,CAAC;GAE9C,MAAM,kBAAkB,SAAS;IAC/B,IAAI,kBAAkB,IAAI,GACxB;IAEF,IAAI,oBAAoB,IAAI,GAC1B;IAEF,QAAQ,OAAO;KAAE;KAAM,WAAW;IAAkB,CAAC;GACvD;GAEA,MAAM,wBAAwB,eAAe;IAC3C,KAAK,MAAM,YAAY,WAAW,YAAY;KAC5C,IAAI,UAAU,SAAS,iBACrB;KAEF,QAAQ,OAAO;MACb,MAAM;MACN,WAAW;KACb,CAAC;IACH;GACF;GAEA,OAAO;IACL,SAAS;KACP,iBAAiB,MAAM;IACzB;IACA,QAAQ,MAAM;KACZ,oBAAoB,IAAI;IAC1B;IAKA,aAAa,MAAM;KACjB,IAAI,CAAC,gBAAgB,KAAK,MAAM,yBAAyB,GACvD;KAEF,MAAM,QAAQ,KAAK;KACnB,IAAI,OAAO,SAAS,0BAClB;KAEF,IAAI,CAAC,MAAM,YACT;KAEF,MAAM,aAAa,iBAAiB,MAAM,UAAU;KACpD,IAAI,YAAY,SAAS,oBAAoB;MAC3C,qBAAqB,UAAU;MAC/B;KACF;KACA,eAAe,MAAM,UAAU;IACjC;IAGA,SAAS,MAAM;KACb,IAAI,CAAC,+BAA+B,IAAI,GACtC;KAEF,eAAe,KAAK,KAAK;IAC3B;IAGA,qBAAqB,MAAM;KACzB,MAAM,SAAS,KAAK;KACpB,IACE,OAAO,SAAS,sBAChB,OAAO,aAAa,SACpB,CAAC,aAAa,OAAO,UAAU,WAAW,GAE1C;KAEF,eAAe,KAAK,KAAK;IAC3B;GACF;EACF;CACF,EACF;AACF,CAAC"}
1
+ {"version":3,"file":"no-unsafe-inner-html.mjs","names":["exhaustive"],"sources":["../src/no-unsafe-inner-html.ts"],"sourcesContent":["// Forbid injecting un-proven HTML into the DOM.\n//\n// Raw HTML may only reach the DOM from a value that is provably sanitized or\n// escaped. This rule covers common DOM and JSX sinks while allowing explicit\n// sanitizer calls and narrowly scoped provenance comments.\n//\n// Sinks, all sharing one allowlist:\n// • `__html` of a `dangerouslySetInnerHTML` object, whether the object is a\n// JSX attribute value or a property of any object literal (props passed\n// to `createElement` / `jsx()`, or spread into JSX).\n// • assignment to `innerHTML`, `outerHTML` or `srcdoc`, dot or static\n// bracket notation, and the same keys in an `Object.assign` source.\n// • the JSX `srcDoc` attribute.\n// • `insertAdjacentHTML`, `setHTMLUnsafe`, `createContextualFragment`,\n// `setAttribute(\"srcdoc\", …)`, and `write` / `writeln` on a document.\n//\n// In the default `allowlist` policy, a value is allowed when it is:\n// • a static string Literal or TemplateLiteral whose interpolations are all\n// independently static, OR\n// • passed through the shared HTML sanitizer allowlist (`escapeHtml`,\n// `escapeXml`, `sanitizeHtml`, `sanitizeDom`, `DOMPurify.sanitize`, and\n// related established names), including `.value` / `.data` / `.html`\n// result accessors; OR\n// • annotated by a `// safe-html: <provenance>` comment with non-empty text\n// on the line directly above the sink. One comment covers one sink: a\n// second sink starting on the same line needs its own annotation.\n// The `provenance` policy omits the sanitizer-name allowlist and requires the\n// comment for dynamic sanitizer results as well.\n//\n// The sanitizer allowlist preserves the shared plugin contract. For dynamic\n// values outside that list, function names are not proof: a local identity\n// function can be named `sanitizeHtml`. Add explicit provenance at the sink\n// until the codebase has a branded SafeHtml boundary.\n\nimport type { Scope, Variable } from \"@oxlint/plugins\";\nimport { eslintCompatPlugin } from \"@oxlint/plugins\";\n\nimport type { AstNode, LiteralArgumentResolution } from \"./utils.ts\";\nimport {\n getPropertyName,\n invokedCallee,\n isAstNode,\n isIdentifier,\n isStringLiteral,\n memberPropertyName,\n resolveLiteralArraySpreads,\n staticStringValue,\n unwrapExpression,\n} from \"./utils.ts\";\n\ntype DomElementProducer = {\n name: string;\n kind: \"property\" | \"method\" | \"collection\";\n receiver: \"document\" | \"element\" | \"either\";\n};\n\n// DOM element provenance and the generated equivalence corpus share this table.\nexport const DOM_ELEMENT_PRODUCERS = [\n { name: \"body\", kind: \"property\", receiver: \"document\" },\n { name: \"head\", kind: \"property\", receiver: \"document\" },\n { name: \"documentElement\", kind: \"property\", receiver: \"document\" },\n { name: \"activeElement\", kind: \"property\", receiver: \"document\" },\n { name: \"scrollingElement\", kind: \"property\", receiver: \"document\" },\n { name: \"fullscreenElement\", kind: \"property\", receiver: \"document\" },\n { name: \"createElement\", kind: \"method\", receiver: \"document\" },\n { name: \"createElementNS\", kind: \"method\", receiver: \"document\" },\n { name: \"getElementById\", kind: \"method\", receiver: \"document\" },\n { name: \"querySelector\", kind: \"method\", receiver: \"either\" },\n { name: \"closest\", kind: \"method\", receiver: \"element\" },\n { name: \"elementFromPoint\", kind: \"method\", receiver: \"document\" },\n { name: \"getElementsByClassName\", kind: \"collection\", receiver: \"either\" },\n { name: \"getElementsByTagName\", kind: \"collection\", receiver: \"either\" },\n { name: \"getElementsByTagNameNS\", kind: \"collection\", receiver: \"either\" },\n { name: \"getElementsByName\", kind: \"collection\", receiver: \"document\" },\n { name: \"querySelectorAll\", kind: \"collection\", receiver: \"either\" },\n { name: \"parentElement\", kind: \"property\", receiver: \"element\" },\n { name: \"firstElementChild\", kind: \"property\", receiver: \"element\" },\n { name: \"lastElementChild\", kind: \"property\", receiver: \"element\" },\n { name: \"nextElementSibling\", kind: \"property\", receiver: \"element\" },\n { name: \"previousElementSibling\", kind: \"property\", receiver: \"element\" },\n { name: \"offsetParent\", kind: \"property\", receiver: \"element\" },\n] as const satisfies readonly DomElementProducer[];\n\n// The provenance text after the marker must be non-empty.\nconst WAIVER_RE = /^\\s*safe-html:\\s*\\S/u;\n\nexport const REACT_HTML_PROPS = [\n { name: \"dangerouslySetInnerHTML\", kind: \"payload\", htmlKey: \"__html\" },\n { name: \"srcDoc\", kind: \"value\" },\n { name: \"srcdoc\", kind: \"value\" },\n] as const;\ntype ReactHtmlProp = (typeof REACT_HTML_PROPS)[number];\nconst REACT_ELEMENT_FACTORIES: ReadonlySet<string> = new Set([\n \"createElement\",\n \"React.createElement\",\n \"jsx\",\n \"jsxs\",\n \"jsxDEV\",\n]);\nconst HTML_PROPERTIES: ReadonlySet<string> = new Set([\n \"innerHTML\",\n \"outerHTML\",\n \"srcdoc\",\n]);\n// Methods whose first argument is parsed as HTML.\nconst HTML_FIRST_ARGUMENT_METHODS: ReadonlySet<string> = new Set([\n \"createContextualFragment\",\n \"setHTMLUnsafe\",\n]);\nconst DOCUMENT_WRITE_METHODS: ReadonlySet<string> = new Set([\n \"write\",\n \"writeln\",\n]);\nconst DOCUMENT_MEMBERS: ReadonlySet<string> = new Set([\n \"contentDocument\",\n \"document\",\n \"ownerDocument\",\n]);\n\nconst HTML_SANITIZER_LEAF_RE =\n /^(?:escape(?:html|xml)|sanitize(?:html|dom)|purify(?:html|dom)|dompurify)$/iu;\nconst SANITIZER_RESULT_MEMBERS = new Set([\"value\", \"data\", \"html\"]);\nconst SANITIZER_POLICY = {\n allowlist: \"allowlist\",\n provenance: \"provenance\",\n} as const;\nconst SANITIZER_POLICY_VALUES = Object.values(SANITIZER_POLICY);\ntype SanitizerPolicy = (typeof SANITIZER_POLICY)[keyof typeof SANITIZER_POLICY];\n\nconst sanitizerPolicyFrom = (option: unknown): SanitizerPolicy => {\n if (option === undefined) {\n return SANITIZER_POLICY.allowlist;\n }\n if (typeof option !== \"object\" || option === null || Array.isArray(option)) {\n throw new Error(\"no-unsafe-inner-html options must be an object\");\n }\n const policy = Reflect.get(option, \"sanitizerPolicy\");\n switch (policy) {\n case undefined:\n case SANITIZER_POLICY.allowlist:\n return SANITIZER_POLICY.allowlist;\n case SANITIZER_POLICY.provenance:\n return SANITIZER_POLICY.provenance;\n default:\n throw new Error(`unknown sanitizer policy: ${String(policy)}`);\n }\n};\n\n// Use the same static member-name resolution for sinks and safe-value proofs.\nconst staticCalleeName = (node: unknown): string | null => {\n const expression = unwrapExpression(node);\n if (isIdentifier(expression)) return expression.name;\n if (expression?.type !== \"MemberExpression\") return null;\n const receiver = staticCalleeName(expression.object);\n const property = memberPropertyName(expression);\n return receiver === null || property === null\n ? null\n : `${receiver}.${property}`;\n};\n\nconst isSanitizerCall = (node: unknown): boolean => {\n if (!isAstNode(node) || node.type !== \"CallExpression\") {\n return false;\n }\n const dotted = staticCalleeName(node.callee);\n if (dotted === null) {\n return false;\n }\n const parts = dotted.split(\".\");\n const leaf = parts.at(-1);\n if (leaf === undefined) {\n return false;\n }\n if (HTML_SANITIZER_LEAF_RE.test(leaf)) {\n return true;\n }\n return leaf === \"sanitize\" && /^dompurify$/iu.test(parts.at(-2) ?? \"\");\n};\n\nconst isSanitizerResult = (node: unknown): boolean => {\n const expression = unwrapExpression(node);\n if (expression?.type !== \"MemberExpression\") {\n return false;\n }\n const property = memberPropertyName(expression);\n return (\n property !== null &&\n SANITIZER_RESULT_MEMBERS.has(property) &&\n isSanitizerCall(expression.object)\n );\n};\n\ntype Located = { loc: { start: { line: number } } };\n\nconst hasLocation = (node: unknown): node is Located =>\n typeof node === \"object\" &&\n node !== null &&\n \"loc\" in node &&\n typeof node.loc === \"object\" &&\n node.loc !== null &&\n \"start\" in node.loc &&\n typeof node.loc.start === \"object\" &&\n node.loc.start !== null &&\n \"line\" in node.loc.start &&\n typeof node.loc.start.line === \"number\";\n\n// A node without location data never matches a waiver line.\nconst startLine = (node: unknown): number =>\n hasLocation(node) ? node.loc.start.line : Number.NaN;\n\ntype WaiverComment = {\n value: string;\n loc: { end: { line: number } };\n};\n\nconst isWaiverComment = (value: unknown): value is WaiverComment =>\n typeof value === \"object\" &&\n value !== null &&\n \"value\" in value &&\n typeof value.value === \"string\" &&\n WAIVER_RE.test(value.value) &&\n \"loc\" in value &&\n typeof value.loc === \"object\" &&\n value.loc !== null;\n\nconst jsxAttributeName = (node: unknown): string | null =>\n isAstNode(node) && node.type === \"JSXIdentifier\" && typeof node.name === \"string\"\n ? node.name\n : null;\n\n// A value is proven safe by its own static shape, independent of comments.\nconst isProvenSafeValue = (\n node: unknown,\n sanitizerPolicy: SanitizerPolicy,\n): boolean => {\n const expression = unwrapExpression(node);\n if (expression === null) {\n return false;\n }\n if (isStringLiteral(expression)) {\n return true;\n }\n if (\n sanitizerPolicy === SANITIZER_POLICY.allowlist &&\n (isSanitizerCall(expression) || isSanitizerResult(expression))\n ) {\n return true;\n }\n return (\n expression.type === \"TemplateLiteral\" &&\n Array.isArray(expression.expressions) &&\n expression.expressions.every((entry) =>\n isProvenSafeValue(entry, sanitizerPolicy),\n )\n );\n};\n\n// The static key of an object-literal Property: `a`, `\"a\"`, `[\"a\"]`.\nconst objectPropertyKey = (property: unknown): string | null => {\n if (!isAstNode(property)) {\n return null;\n }\n if (property.computed === true) {\n return staticStringValue(property.key);\n }\n return getPropertyName(property.key);\n};\n\nconst isOpaqueProperty = (property: AstNode): boolean =>\n property.type === \"SpreadElement\" ||\n (property.type === \"Property\" &&\n property.computed === true &&\n objectPropertyKey(property) === null);\n\nconst objectProperties = (node: unknown): AstNode[] => {\n const expression = unwrapExpression(node);\n if (\n expression?.type !== \"ObjectExpression\" ||\n !Array.isArray(expression.properties)\n ) {\n return [];\n }\n return expression.properties.filter(isAstNode);\n};\n\ntype AppliedArguments = LiteralArgumentResolution;\n\nconst appliedArgumentsFor = (args: unknown[]): AppliedArguments => {\n const applied = unwrapExpression(args.at(1));\n if (\n applied === null ||\n (applied.type === \"Literal\" && applied.value === null) ||\n isIdentifier(applied, \"undefined\")\n ) {\n return { status: \"known\", args: [] };\n }\n if (applied.type !== \"ArrayExpression\" || !Array.isArray(applied.elements)) {\n return { status: \"opaque\", args: [], spread: args.at(1), tail: [] };\n }\n return resolveLiteralArraySpreads(applied.elements);\n};\n\n// The arguments the invoked function receives: `f.call(thisArg, ...args)`\n// shifts them by one; `f.apply(thisArg, [...])` forwards a literal array.\nconst invokedArguments = (call: unknown): AppliedArguments => {\n if (!isAstNode(call)) {\n return { status: \"known\", args: [] };\n }\n const rawArguments = Array.isArray(call.arguments) ? call.arguments : [];\n const resolved = resolveLiteralArraySpreads(rawArguments);\n const callee = unwrapExpression(call.callee);\n if (callee?.type !== \"MemberExpression\") return resolved;\n const method = memberPropertyName(callee);\n if (method === \"call\") {\n return { ...resolved, args: resolved.args.slice(1) };\n }\n if (method !== \"apply\") return resolved;\n if (resolved.status === \"opaque\") {\n if (resolved.args.length >= 2) return appliedArgumentsFor(resolved.args);\n return { ...resolved, args: [], tail: [] };\n }\n return appliedArgumentsFor(resolved.args);\n};\n\n// `document.write`, `window.document.write`, `frame.contentDocument.write`,\n// `node.ownerDocument.write`.\nconst isDocumentReceiver = (node: unknown): boolean => {\n const receiver = unwrapExpression(node);\n if (isIdentifier(receiver, \"document\")) {\n return true;\n }\n if (receiver?.type !== \"MemberExpression\") {\n return false;\n }\n const name = memberPropertyName(receiver);\n return name !== null && DOCUMENT_MEMBERS.has(name);\n};\n\nexport default eslintCompatPlugin({\n meta: { name: \"no-unsafe-inner-html\" },\n rules: {\n \"no-unsafe-inner-html\": {\n meta: {\n type: \"problem\",\n schema: [\n {\n type: \"object\",\n properties: {\n sanitizerPolicy: {\n type: \"string\",\n enum: SANITIZER_POLICY_VALUES,\n },\n },\n additionalProperties: false,\n },\n ],\n messages: {\n unsafeInnerHtml:\n \"Dynamic HTML injected into the DOM must be static, proven by \" +\n \"the configured sanitizer policy, or have an adjacent \" +\n \"`// safe-html: <provenance>` comment naming the trusted source. \" +\n \"Function names outside the allowlist are not proof because a \" +\n \"local identity function can shadow them.\",\n unsafeInnerHtmlSpread:\n \"Do not spread an object into dangerouslySetInnerHTML. Keep \" +\n \"`__html` inline so this rule can prove the HTML value is \" +\n \"sanitized or escaped.\",\n },\n },\n createOnce(context) {\n type DomTargetOptions = {\n node: unknown;\n scope: Scope;\n seen?: Set<Variable>;\n kind?: \"element\" | \"collection\";\n };\n const isKnownDomTarget = ({\n node,\n scope,\n seen = new Set<Variable>(),\n kind = \"element\",\n }: DomTargetOptions): boolean => {\n const target = unwrapExpression(node);\n if (isIdentifier(target)) {\n let current: Scope | null = scope;\n while (current !== null) {\n const variable = current.set.get(target.name);\n if (variable === undefined) {\n current = current.upper;\n continue;\n }\n if (variable.defs.length === 0) {\n return kind === \"element\" && isDocumentReceiver(target);\n }\n if (seen.has(variable)) return false;\n seen.add(variable);\n const definition = variable.defs.at(0);\n if (\n variable.defs.length !== 1 ||\n definition?.type !== \"Variable\" ||\n definition.node.type !== \"VariableDeclarator\" ||\n definition.node.id.type !== \"Identifier\" ||\n definition.node.init === null ||\n variable.references.some(\n (reference) => reference.isWrite() && !reference.init,\n )\n ) {\n return false;\n }\n return isKnownDomTarget({\n node: definition.node.init,\n scope: context.sourceCode.getScope(definition.node),\n seen,\n kind,\n });\n }\n }\n const hasProducerReceiver = (\n receiver: unknown,\n producer: DomElementProducer,\n ): boolean => {\n if (\n producer.receiver !== \"element\" &&\n isDocumentReceiver(receiver)\n ) {\n return true;\n }\n return (\n producer.receiver !== \"document\" &&\n isKnownDomTarget({ node: receiver, scope, seen })\n );\n };\n const isElementCollection = (collection: unknown): boolean =>\n isKnownDomTarget({ node: collection, scope, seen, kind: \"collection\" });\n if (kind === \"element\" && target?.type === \"MemberExpression\") {\n const property = memberPropertyName(target);\n const producer = DOM_ELEMENT_PRODUCERS.find(\n (entry) => entry.kind === \"property\" && entry.name === property,\n );\n if (producer !== undefined) {\n return hasProducerReceiver(target.object, producer);\n }\n const index = unwrapExpression(target.property);\n if (\n target.computed === true &&\n index?.type === \"Literal\" &&\n typeof index.value === \"number\" &&\n Number.isInteger(index.value) &&\n index.value >= 0\n ) {\n return isElementCollection(target.object);\n }\n }\n if (target?.type === \"CallExpression\") {\n const callee = invokedCallee(target);\n if (callee?.type !== \"MemberExpression\") return false;\n const method = memberPropertyName(callee);\n if (\n kind === \"element\" &&\n method === \"item\" &&\n isElementCollection(callee.object)\n ) {\n return true;\n }\n const producer = DOM_ELEMENT_PRODUCERS.find(\n (entry) =>\n entry.kind === (kind === \"collection\" ? \"collection\" : \"method\") &&\n entry.name === method,\n );\n return (\n producer !== undefined &&\n hasProducerReceiver(callee.object, producer)\n );\n }\n return kind === \"element\" && isDocumentReceiver(target);\n };\n\n let sanitizerPolicy: SanitizerPolicy = SANITIZER_POLICY.allowlist;\n // Associate waivers after visiting the file, so source position decides\n // the first covered sink even when AST visitors inspect values out of order.\n const waiverLines = new Set<number>();\n type SinkObservation = {\n node: AstNode;\n kind: \"safe\" | \"unsafe\" | \"opaque-payload\";\n };\n const sinks: SinkObservation[] = [];\n const observeSink = (\n node: unknown,\n kind: SinkObservation[\"kind\"],\n ): void => {\n if (isAstNode(node)) sinks.push({ node, kind });\n };\n\n const consumeWaiver = (node: unknown): boolean => {\n const line = startLine(node) - 1;\n if (!waiverLines.has(line)) {\n return false;\n }\n waiverLines.delete(line);\n return true;\n };\n\n const reportIfUnsafe = (node: unknown): void => {\n observeSink(\n node,\n isProvenSafeValue(node, sanitizerPolicy) ? \"safe\" : \"unsafe\",\n );\n };\n\n // The value of a `dangerouslySetInnerHTML` prop: an inline object has\n // its `__html` checked and its spreads rejected; anything else is a\n // hoisted payload whose HTML this rule cannot see.\n const checkDangerousPayload = (value: unknown, htmlKey: string): void => {\n const expression = unwrapExpression(value);\n if (expression?.type !== \"ObjectExpression\") {\n reportIfUnsafe(value);\n return;\n }\n for (const property of objectProperties(expression)) {\n if (isOpaqueProperty(property)) {\n observeSink(property, \"opaque-payload\");\n continue;\n }\n if (\n property.type === \"Property\" &&\n objectPropertyKey(property) === htmlKey\n ) {\n reportIfUnsafe(property.value);\n }\n }\n };\n\n const checkReactHtmlProp = (prop: ReactHtmlProp, value: unknown): void => {\n switch (prop.kind) {\n case \"payload\":\n checkDangerousPayload(value, prop.htmlKey);\n return;\n case \"value\":\n reportIfUnsafe(value);\n return;\n default: {\n const exhaustive: never = prop;\n throw new Error(`Unknown React HTML prop: ${String(exhaustive)}`);\n }\n }\n };\n\n const checkHtmlPropertySources = (\n sources: unknown[],\n domTarget: boolean,\n ): void => {\n for (const source of sources) {\n const expression = unwrapExpression(source);\n if (expression?.type !== \"ObjectExpression\") {\n if (domTarget) {\n reportIfUnsafe(source);\n }\n continue;\n }\n for (const property of objectProperties(source)) {\n if (isOpaqueProperty(property)) {\n if (domTarget) {\n reportIfUnsafe(property);\n }\n continue;\n }\n const key =\n property.type === \"Property\"\n ? objectPropertyKey(property)\n : null;\n if (key !== null && HTML_PROPERTIES.has(key)) {\n reportIfUnsafe(property.value);\n }\n }\n }\n };\n\n return {\n before() {\n sanitizerPolicy = sanitizerPolicyFrom(context.options.at(0));\n waiverLines.clear();\n sinks.length = 0;\n },\n \"Program:exit\"() {\n sinks.sort((left, right) => left.node.range[0] - right.node.range[0]);\n for (const { node, kind } of sinks) {\n const waived = consumeWaiver(node);\n if (kind === \"safe\" || (kind === \"unsafe\" && waived)) continue;\n context.report({\n node,\n messageId:\n kind === \"opaque-payload\"\n ? \"unsafeInnerHtmlSpread\"\n : \"unsafeInnerHtml\",\n });\n }\n sinks.length = 0;\n },\n Program(node) {\n const comments: unknown[] = Array.isArray(node.comments)\n ? node.comments\n : [];\n for (const comment of comments) {\n if (isWaiverComment(comment)) {\n waiverLines.add(comment.loc.end.line);\n }\n }\n },\n\n JSXAttribute(node) {\n const name = jsxAttributeName(node.name);\n const prop = REACT_HTML_PROPS.find((entry) => entry.name === name);\n if (prop === undefined) return;\n const value = unwrapExpression(node.value);\n checkReactHtmlProp(\n prop,\n value?.type === \"JSXExpressionContainer\" ? value.expression : value,\n );\n },\n\n // Payload objects remain checked when hoisted or spread into JSX.\n // Direct value props require a recognized factory-call context below.\n Property(node) {\n const key = objectPropertyKey(node);\n const prop = REACT_HTML_PROPS.find((entry) => entry.name === key);\n if (prop?.kind !== \"payload\") return;\n const container = node.parent;\n if (isAstNode(container) && container.type === \"ObjectExpression\") {\n checkReactHtmlProp(prop, node.value);\n }\n },\n\n AssignmentExpression(node) {\n const target = unwrapExpression(node.left);\n if (target?.type !== \"MemberExpression\") {\n return;\n }\n const name = memberPropertyName(target);\n if (name !== null && HTML_PROPERTIES.has(name)) {\n reportIfUnsafe(node.right);\n }\n },\n\n CallExpression(node) {\n const call: unknown = node;\n const callee = isAstNode(call) ? invokedCallee(call) : null;\n const invoked = invokedArguments(node);\n const factory = staticCalleeName(callee);\n if (factory !== null && REACT_ELEMENT_FACTORIES.has(factory)) {\n for (const property of objectProperties(invoked.args.at(1))) {\n if (property.type !== \"Property\") continue;\n const key = objectPropertyKey(property);\n const prop = REACT_HTML_PROPS.find((entry) => entry.name === key);\n // Payload props are already checked by the Property visitor.\n if (prop?.kind === \"value\") {\n checkReactHtmlProp(prop, property.value);\n }\n }\n return;\n }\n if (callee?.type !== \"MemberExpression\") {\n return;\n }\n const method = memberPropertyName(callee);\n if (method === null) {\n return;\n }\n if (HTML_FIRST_ARGUMENT_METHODS.has(method)) {\n if (invoked.status === \"opaque\") {\n reportIfUnsafe(node);\n return;\n }\n reportIfUnsafe(invoked.args.at(0));\n return;\n }\n if (method === \"insertAdjacentHTML\") {\n if (invoked.status === \"opaque\") {\n reportIfUnsafe(node);\n return;\n }\n reportIfUnsafe(invoked.args.at(1));\n return;\n }\n if (method === \"setAttribute\") {\n if (invoked.status === \"opaque\") {\n reportIfUnsafe(node);\n return;\n }\n const attribute = staticStringValue(invoked.args.at(0));\n if (attribute?.toLowerCase() === \"srcdoc\") {\n reportIfUnsafe(invoked.args.at(1));\n }\n return;\n }\n if (\n DOCUMENT_WRITE_METHODS.has(method) &&\n isDocumentReceiver(callee.object)\n ) {\n if (invoked.status === \"opaque\") {\n reportIfUnsafe(node);\n return;\n }\n observeSink(\n node,\n invoked.args.every((argument) =>\n isProvenSafeValue(argument, sanitizerPolicy),\n )\n ? \"safe\"\n : \"unsafe\",\n );\n return;\n }\n if (isIdentifier(callee.object, \"Object\") && method === \"assign\") {\n const domTarget = isKnownDomTarget({\n node: invoked.args.at(0),\n scope: context.sourceCode.getScope(node),\n });\n checkHtmlPropertySources(\n [\n ...invoked.args.slice(1),\n ...(invoked.status === \"opaque\" ? invoked.tail : []),\n ],\n domTarget,\n );\n if (invoked.status === \"opaque\" && domTarget) {\n reportIfUnsafe(invoked.spread);\n }\n }\n },\n };\n },\n },\n },\n});\n"],"mappings":"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"}
@@ -19,7 +19,7 @@ var __copyProps = (to, from, except, desc) => {
19
19
  }
20
20
  return to;
21
21
  };
22
- var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
22
+ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule || !__hasOwnProp.call(mod, "default") ? __defProp(target, "default", {
23
23
  value: mod,
24
24
  enumerable: true
25
25
  }) : target, mod));
@@ -41,7 +41,8 @@ const isSuspenseCallee = (callee) => {
41
41
  };
42
42
  const suspenseFactoryName = (node) => {
43
43
  if (!isSuspenseCallee(node.callee)) return null;
44
- const arg = require_utils.unwrapExpression(node.arguments?.[0]);
44
+ const { args } = require_utils.resolveLiteralArraySpreads(node.arguments ?? []);
45
+ const arg = require_utils.unwrapExpression(args.at(0));
45
46
  if (arg?.type === "CallExpression" && require_utils.isIdentifier(arg.callee)) return arg.callee.name;
46
47
  if (require_utils.isIdentifier(arg)) return arg.name;
47
48
  return null;
@@ -74,7 +75,7 @@ const CHILD_SUSPENSE_RE = /useSuspenseQuery\(\s*([A-Za-z_$][\w$]*)\s*([(),])/g;
74
75
  const suspenseFactoriesInSource = (sourceText) => {
75
76
  const factories = /* @__PURE__ */ new Set();
76
77
  for (const match of sourceText.matchAll(CHILD_SUSPENSE_RE)) {
77
- const factory = match[1];
78
+ const factory = match.at(1);
78
79
  if (factory !== void 0) factories.add(factory);
79
80
  }
80
81
  return factories;
@@ -110,8 +111,9 @@ var require_loader_prefetch_default = (0, _oxlint_plugins.eslintCompatPlugin)({
110
111
  if (isCreateFileRouteCall(callee)) isRouteFile = true;
111
112
  if (isCreateFileRouteOptionsCall(callee)) {
112
113
  isRouteFile = true;
113
- const optionsArg = node.arguments?.[0];
114
- if (routeOptions === null && optionsArg?.type === "ObjectExpression") routeOptions = optionsArg;
114
+ const { args } = require_utils.resolveLiteralArraySpreads(node.arguments);
115
+ const optionsArg = args.at(0);
116
+ if (routeOptions === null && require_utils.isAstNode(optionsArg) && optionsArg.type === "ObjectExpression") routeOptions = { properties: optionsArg.properties };
115
117
  }
116
118
  const factory = suspenseFactoryName(node);
117
119
  if (factory !== null) suspenseCalls.push({
@@ -147,8 +149,7 @@ var require_loader_prefetch_default = (0, _oxlint_plugins.eslintCompatPlugin)({
147
149
  });
148
150
  }
149
151
  if (colocatedImports.length === 0) return;
150
- const filename = context.filename ?? context.getFilename?.();
151
- if (typeof filename !== "string") return;
152
+ const filename = require_utils.filenameForContext(context);
152
153
  for (const { node, source } of colocatedImports) {
153
154
  const basePath = resolveColocatedImportBase(filename, source);
154
155
  if (basePath === null) continue;
@@ -1 +1 @@
1
- {"version":3,"file":"require-loader-prefetch.cjs","names":["isIdentifier","unwrapExpression","getPropertyName","path","isStringLiteral"],"sources":["../src/require-loader-prefetch.ts"],"sourcesContent":["// Require route loaders to prefetch the queries their component suspends on.\n//\n// A route component that calls `useSuspenseQuery(someOptions(...))` without the\n// route loader prefetching that same query forces a render-fetch waterfall: on\n// a cold visit the router resolves the route, the component mounts, suspends,\n// and only THEN starts the fetch — an extra sequential network round on every\n// cold navigation. TanStack Router loaders (`loader:` in `createFileRoute`\n// options) start the fetch during navigation, in parallel with code-split\n// chunk loading, via the helpers in `apps/web/src/lib/react-query.ts`\n// (`ensureRouteQueryData` for critical/blocking data, `prefetchRouteQuery` for\n// non-blocking warmup). With the loader priming the cache, `useSuspenseQuery`\n// in the component consumes a warm cache instead of opening a new round.\n//\n// Single-file AST analysis, scoped to route files (`createFileRoute(...)`):\n// - Flags `useSuspenseQuery(factory(...))` / `useSuspenseQuery(factory)` when\n// the route has no `loader`.\n// - Flags the same when a `loader` exists but never references the factory\n// identifier (any reference inside the loader body counts — typically\n// inside `ensureRouteQueryData` / `prefetchRouteQuery`).\n//\n// Cross-file, one-hop check for colocated route components: the single-file\n// analysis above is blind to a `useSuspenseQuery(factory(...))` call living in\n// a sibling file imported by the route (the codebase convention for colocated,\n// non-route-generating files is a dash-prefixed directory next to the route,\n// e.g. `-components/`, `-hooks/`, `-queries/`, imported via the `@/routes/...`\n// alias — TanStack Router excludes `-`-prefixed paths from route generation).\n// A real instance of this shipped on `/settings/account/connections` before\n// being caught by the e2e depth guard: the page component had no\n// `useSuspenseQuery` of its own, it all lived in a child card component.\n//\n// To close that gap without a real module resolver: once a file is confirmed\n// to be a route file with an inline options object (the same precondition the\n// single-file check already requires), each `@/routes/.../-<dir>/...` import\n// it declares is resolved to an absolute path by reusing the *linted file's\n// own* path up to its `/routes/` segment as the alias root (mirrors the\n// `\"@/*\": [\"./src/*\"]` tsconfig path). The resolved file is read once — bounded\n// to that one hop, its own imports are never followed — and scanned with a\n// regex for the same two `useSuspenseQuery(...)` shapes the AST check\n// recognizes. Any factory it finds is checked against the route's `loader`\n// exactly like an in-file suspense call would be.\n//\n// Safe (not flagged):\n// - `useSuspenseQuery` whose argument is a member expression or other shape\n// the rule can't statically attribute to a factory identifier (in-file or\n// in a colocated import).\n// - `useSuspenseQuery` in a non-route file (no `createFileRoute`) — including\n// when that file is itself a route's colocated component and is linted\n// directly rather than through the importing route.\n// - A route whose loader references the factory identifier.\n// - A colocated import that isn't under a dash-prefixed directory, or that\n// can't be read as a sibling `.tsx`/`.ts` file (no fs read is attempted\n// unless the file is already a confirmed route file with such an import).\n//\n// Known limitation: the child-file scan is a raw-text regex, not a second AST\n// parse, so it does not strip comments or string literals. A comment or\n// string in the colocated file that happens to spell out\n// `useSuspenseQuery(identifier(` / `useSuspenseQuery(identifier)` verbatim\n// would false-positive. Judged an acceptable, rare edge case for the\n// bounded/cheap scan this rule relies on; do not write that literal pattern\n// in prose comments inside `-components`/`-hooks`/`-queries` files.\n\nimport { eslintCompatPlugin, type Ranged } from \"@oxlint/plugins\";\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\n\nimport {\n getPropertyName,\n isIdentifier,\n isStringLiteral,\n unwrapExpression,\n} from \"./utils.ts\";\n\nconst CREATE_FILE_ROUTE = \"createFileRoute\";\nconst SUSPENSE_HOOK = \"useSuspenseQuery\";\nconst LOADER_KEY = \"loader\";\n\nconst isCreateFileRouteCall = (callee) =>\n isIdentifier(callee, CREATE_FILE_ROUTE);\n\n// `createFileRoute(\"...\")({ ... })` — the outer call whose callee is itself the\n// `createFileRoute(\"...\")` call. The first argument is the route options object.\nconst isCreateFileRouteOptionsCall = (callee) =>\n callee?.type === \"CallExpression\" && isCreateFileRouteCall(callee.callee);\n\nconst isSuspenseCallee = (callee) => {\n if (isIdentifier(callee, SUSPENSE_HOOK)) {\n return true;\n }\n return (\n callee?.type === \"MemberExpression\" &&\n callee.computed === false &&\n isIdentifier(callee.property, SUSPENSE_HOOK)\n );\n};\n\n// Resolve the factory identifier a `useSuspenseQuery` call depends on:\n// useSuspenseQuery(factory(...)) -> \"factory\"\n// useSuspenseQuery(factory) -> \"factory\"\n// Returns null for any other argument shape (member expressions, object\n// literals, etc.) so the rule never guesses.\nconst suspenseFactoryName = (node) => {\n if (!isSuspenseCallee(node.callee)) {\n return null;\n }\n const arg = unwrapExpression(node.arguments?.[0]);\n if (arg?.type === \"CallExpression\" && isIdentifier(arg.callee)) {\n return arg.callee.name;\n }\n if (isIdentifier(arg)) {\n return arg.name;\n }\n return null;\n};\n\nconst isInsideLoader = (node) => {\n let current = node.parent;\n while (current) {\n if (\n current.type === \"Property\" &&\n getPropertyName(current.key) === LOADER_KEY\n ) {\n return true;\n }\n current = current.parent;\n }\n return false;\n};\n\nconst ROUTES_SEGMENT = \"/routes/\";\nconst COLOCATED_DIR_RE = /\\/-[^/]+\\//;\n\n// A colocated, non-route-generating import: under `@/routes/...` and through\n// a dash-prefixed directory (`-components/`, `-hooks/`, `-queries/`, ...).\nconst isColocatedRouteImport = (source) =>\n source.startsWith(\"@/routes/\") && COLOCATED_DIR_RE.test(source);\n\n// Resolve a `@/routes/...` specifier to an absolute path by reusing the\n// currently-linted file's own path up to its `/routes/` segment as the alias\n// root — mirrors the `\"@/*\": [\"./src/*\"]` tsconfig path without a real module\n// resolver. Returns null if the linted file isn't itself under a `routes`\n// directory (shouldn't happen for a confirmed route file, but fails closed).\nconst resolveColocatedImportBase = (filename, source) => {\n // Windows gives `context.filename` with backslashes, which would make\n // `lastIndexOf(ROUTES_SEGMENT)` miss; normalize to forward slashes first\n // (valid path input on Windows, and `join` below normalizes the output).\n const normalizedFilename = filename.replaceAll(\"\\\\\", \"/\");\n const routesIndex = normalizedFilename.lastIndexOf(ROUTES_SEGMENT);\n if (routesIndex === -1) {\n return null;\n }\n const aliasRoot = normalizedFilename.slice(0, routesIndex);\n return path.join(aliasRoot, source.slice(\"@/\".length));\n};\n\n// Bounded, one-hop read: try the two extensions a colocated TS/TSX import can\n// resolve to. No further imports of the child file are followed.\nconst readColocatedFile = (basePath) => {\n for (const extension of [\".tsx\", \".ts\"]) {\n try {\n return readFileSync(`${basePath}${extension}`, \"utf-8\");\n } catch {\n // Try the next extension; if neither exists this is a shape the\n // simplified alias resolution can't handle (e.g. a directory index) —\n // acceptable since this is a best-effort bounded check, not a resolver.\n }\n }\n return null;\n};\n\n// Regex counterpart of `suspenseFactoryName`, scanning raw source text for\n// the same two argument shapes: `useSuspenseQuery(factory(...))` and\n// `useSuspenseQuery(factory)`. The character right after the identifier\n// disambiguates them from a member-expression argument the AST check would\n// also decline to attribute (e.g. `useSuspenseQuery(factory.options())`).\nconst CHILD_SUSPENSE_RE = /useSuspenseQuery\\(\\s*([A-Za-z_$][\\w$]*)\\s*([(),])/g;\n\nconst suspenseFactoriesInSource = (sourceText: string): Set<string> => {\n const factories = new Set<string>();\n for (const match of sourceText.matchAll(CHILD_SUSPENSE_RE)) {\n const factory = match[1];\n if (factory !== undefined) {\n factories.add(factory);\n }\n }\n return factories;\n};\n\nexport default eslintCompatPlugin({\n meta: { name: \"require-loader-prefetch\" },\n rules: {\n \"require-loader-prefetch\": {\n meta: {\n type: \"problem\",\n messages: {\n missingLoaderPrefetch:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route defines no `loader`, so this query fetches only after the \" +\n \"component mounts and suspends — an extra sequential network round \" +\n \"on cold navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery consumes the \" +\n \"warm cache.\",\n factoryNotPrefetched:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route `loader` never references {{factory}}, so this query still \" +\n \"fetches on mount — an extra sequential network round on cold \" +\n \"navigation. Prefetch {{factory}}(...) in the `loader` via \" +\n \"ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query; \" +\n \"useSuspenseQuery in the component then consumes the warm cache.\",\n childMissingLoaderPrefetch:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route defines no \" +\n \"`loader`, so this query fetches only after the child component \" +\n \"mounts and suspends — an extra sequential network round on cold \" +\n \"navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery in the child \" +\n \"component consumes the warm cache.\",\n childFactoryNotPrefetched:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route `loader` never \" +\n \"references {{factory}}, so this query still fetches on mount — \" +\n \"an extra sequential network round on cold navigation. Prefetch \" +\n \"{{factory}}(...) in the `loader` via ensureRouteQueryData \" +\n \"(blocking, critical data) or prefetchRouteQuery (non-blocking) \" +\n \"from @/lib/react-query; useSuspenseQuery in the child component \" +\n \"then consumes the warm cache.\",\n },\n },\n createOnce(context) {\n let isRouteFile = false;\n let routeOptions: { properties: unknown } | null = null;\n const suspenseCalls: { factory: string; node: Ranged }[] = [];\n const referencedInLoader = new Set();\n const colocatedImports: { node: Ranged; source: string }[] = [];\n\n return {\n before() {\n isRouteFile = false;\n routeOptions = null;\n suspenseCalls.length = 0;\n referencedInLoader.clear();\n colocatedImports.length = 0;\n },\n CallExpression(node) {\n const callee = node.callee;\n\n if (isCreateFileRouteCall(callee)) {\n isRouteFile = true;\n }\n\n if (isCreateFileRouteOptionsCall(callee)) {\n isRouteFile = true;\n const optionsArg = node.arguments?.[0];\n if (\n routeOptions === null &&\n optionsArg?.type === \"ObjectExpression\"\n ) {\n routeOptions = optionsArg;\n }\n }\n\n const factory = suspenseFactoryName(node);\n if (factory !== null) {\n suspenseCalls.push({ node, factory });\n }\n },\n\n Identifier(node) {\n if (isInsideLoader(node)) {\n referencedInLoader.add(node.name);\n }\n },\n\n ImportDeclaration(node) {\n if (\n isStringLiteral(node.source) &&\n isColocatedRouteImport(node.source.value)\n ) {\n colocatedImports.push({ node, source: node.source.value });\n }\n },\n\n \"Program:exit\"() {\n // Only route files with an inline options object are analyzable;\n // a non-inline options argument can't be statically inspected.\n if (!isRouteFile || routeOptions === null) {\n return;\n }\n\n const properties = Array.isArray(routeOptions.properties)\n ? routeOptions.properties\n : [];\n const hasLoader = properties.some(\n (prop) =>\n prop.type === \"Property\" &&\n getPropertyName(prop.key) === LOADER_KEY,\n );\n\n for (const { node, factory } of suspenseCalls) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"missingLoaderPrefetch\",\n data: { factory },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"factoryNotPrefetched\",\n data: { factory },\n });\n }\n }\n\n if (colocatedImports.length === 0) {\n return;\n }\n\n const filename = context.filename ?? context.getFilename?.();\n if (typeof filename !== \"string\") {\n return;\n }\n\n for (const { node, source } of colocatedImports) {\n const basePath = resolveColocatedImportBase(filename, source);\n if (basePath === null) {\n continue;\n }\n\n const childSource = readColocatedFile(basePath);\n if (childSource === null) {\n continue;\n }\n\n for (const factory of suspenseFactoriesInSource(childSource)) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"childMissingLoaderPrefetch\",\n data: { factory, importPath: source },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"childFactoryNotPrefetched\",\n data: { factory, importPath: source },\n });\n }\n }\n }\n },\n };\n },\n },\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"require-loader-prefetch.cjs","names":["isIdentifier","resolveLiteralArraySpreads","unwrapExpression","getPropertyName","path","readFileSync","eslintCompatPlugin","isAstNode","isStringLiteral","filenameForContext"],"sources":["../src/require-loader-prefetch.ts"],"sourcesContent":["// Require route loaders to prefetch the queries their component suspends on.\n//\n// A route component that calls `useSuspenseQuery(someOptions(...))` without the\n// route loader prefetching that same query forces a render-fetch waterfall: on\n// a cold visit the router resolves the route, the component mounts, suspends,\n// and only THEN starts the fetch — an extra sequential network round on every\n// cold navigation. TanStack Router loaders (`loader:` in `createFileRoute`\n// options) start the fetch during navigation, in parallel with code-split\n// chunk loading, via the helpers in `apps/web/src/lib/react-query.ts`\n// (`ensureRouteQueryData` for critical/blocking data, `prefetchRouteQuery` for\n// non-blocking warmup). With the loader priming the cache, `useSuspenseQuery`\n// in the component consumes a warm cache instead of opening a new round.\n//\n// Single-file AST analysis, scoped to route files (`createFileRoute(...)`):\n// - Flags `useSuspenseQuery(factory(...))` / `useSuspenseQuery(factory)` when\n// the route has no `loader`.\n// - Flags the same when a `loader` exists but never references the factory\n// identifier (any reference inside the loader body counts — typically\n// inside `ensureRouteQueryData` / `prefetchRouteQuery`).\n//\n// Cross-file, one-hop check for colocated route components: the single-file\n// analysis above is blind to a `useSuspenseQuery(factory(...))` call in an\n// imported sibling file. Dash-prefixed directories such as `-components/`,\n// `-hooks/`, and `-queries/` are treated as colocated non-route files.\n//\n// Without a module resolver, confirmed route files with inline options resolve\n// each `@/routes/.../-<dir>/...` import from the linted file's `/routes/`\n// segment. The child file is read once and scanned with a regex for the same\n// two `useSuspenseQuery(...)` shapes the AST check recognizes. Child imports\n// are not followed, and each factory is checked against the route's `loader`.\n//\n// Safe (not flagged):\n// - `useSuspenseQuery` whose argument is a member expression or other shape\n// the rule can't statically attribute to a factory identifier (in-file or\n// in a colocated import).\n// - `useSuspenseQuery` in a non-route file (no `createFileRoute`) — including\n// when that file is itself a route's colocated component and is linted\n// directly rather than through the importing route.\n// - A route whose loader references the factory identifier.\n// - A colocated import that isn't under a dash-prefixed directory, or that\n// can't be read as a sibling `.tsx`/`.ts` file (no fs read is attempted\n// unless the file is already a confirmed route file with such an import).\n//\n// Known limitation: the child-file scan is a raw-text regex, not a second AST\n// parse, so it does not strip comments or string literals. A comment or\n// string in the colocated file that happens to spell out\n// `useSuspenseQuery(identifier(` / `useSuspenseQuery(identifier)` verbatim\n// would false-positive. Judged an acceptable, rare edge case for the\n// bounded/cheap scan this rule relies on; do not write that literal pattern\n// in prose comments inside `-components`/`-hooks`/`-queries` files.\n\nimport { eslintCompatPlugin, type Ranged } from \"@oxlint/plugins\";\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\n\nimport {\n filenameForContext,\n getPropertyName,\n isAstNode,\n isIdentifier,\n isStringLiteral,\n resolveLiteralArraySpreads,\n unwrapExpression,\n} from \"./utils.ts\";\n\nconst CREATE_FILE_ROUTE = \"createFileRoute\";\nconst SUSPENSE_HOOK = \"useSuspenseQuery\";\nconst LOADER_KEY = \"loader\";\n\nconst isCreateFileRouteCall = (callee) =>\n isIdentifier(callee, CREATE_FILE_ROUTE);\n\n// `createFileRoute(\"...\")({ ... })` — the outer call whose callee is itself the\n// `createFileRoute(\"...\")` call. The first argument is the route options object.\nconst isCreateFileRouteOptionsCall = (callee) =>\n callee?.type === \"CallExpression\" && isCreateFileRouteCall(callee.callee);\n\nconst isSuspenseCallee = (callee) => {\n if (isIdentifier(callee, SUSPENSE_HOOK)) {\n return true;\n }\n return (\n callee?.type === \"MemberExpression\" &&\n callee.computed === false &&\n isIdentifier(callee.property, SUSPENSE_HOOK)\n );\n};\n\n// Resolve the factory identifier a `useSuspenseQuery` call depends on:\n// useSuspenseQuery(factory(...)) -> \"factory\"\n// useSuspenseQuery(factory) -> \"factory\"\n// Returns null for any other argument shape (member expressions, object\n// literals, etc.) so the rule never guesses.\nconst suspenseFactoryName = (node) => {\n if (!isSuspenseCallee(node.callee)) {\n return null;\n }\n const { args } = resolveLiteralArraySpreads(node.arguments ?? []);\n const arg = unwrapExpression(args.at(0));\n if (arg?.type === \"CallExpression\" && isIdentifier(arg.callee)) {\n return arg.callee.name;\n }\n if (isIdentifier(arg)) {\n return arg.name;\n }\n return null;\n};\n\nconst isInsideLoader = (node) => {\n let current = node.parent;\n while (current) {\n if (\n current.type === \"Property\" &&\n getPropertyName(current.key) === LOADER_KEY\n ) {\n return true;\n }\n current = current.parent;\n }\n return false;\n};\n\nconst ROUTES_SEGMENT = \"/routes/\";\nconst COLOCATED_DIR_RE = /\\/-[^/]+\\//;\n\n// A colocated, non-route-generating import: under `@/routes/...` and through\n// a dash-prefixed directory (`-components/`, `-hooks/`, `-queries/`, ...).\nconst isColocatedRouteImport = (source) =>\n source.startsWith(\"@/routes/\") && COLOCATED_DIR_RE.test(source);\n\n// Resolve a `@/routes/...` specifier to an absolute path by reusing the\n// currently-linted file's own path up to its `/routes/` segment as the alias\n// root — mirrors the `\"@/*\": [\"./src/*\"]` tsconfig path without a real module\n// resolver. Returns null if the linted file isn't itself under a `routes`\n// directory (shouldn't happen for a confirmed route file, but fails closed).\nconst resolveColocatedImportBase = (filename, source) => {\n // Windows gives `context.filename` with backslashes, which would make\n // `lastIndexOf(ROUTES_SEGMENT)` miss; normalize to forward slashes first\n // (valid path input on Windows, and `join` below normalizes the output).\n const normalizedFilename = filename.replaceAll(\"\\\\\", \"/\");\n const routesIndex = normalizedFilename.lastIndexOf(ROUTES_SEGMENT);\n if (routesIndex === -1) {\n return null;\n }\n const aliasRoot = normalizedFilename.slice(0, routesIndex);\n return path.join(aliasRoot, source.slice(\"@/\".length));\n};\n\n// Bounded, one-hop read: try the two extensions a colocated TS/TSX import can\n// resolve to. No further imports of the child file are followed.\nconst readColocatedFile = (basePath) => {\n for (const extension of [\".tsx\", \".ts\"]) {\n try {\n return readFileSync(`${basePath}${extension}`, \"utf-8\");\n } catch {\n // Try the next extension; if neither exists this is a shape the\n // simplified alias resolution can't handle (e.g. a directory index) —\n // acceptable since this is a best-effort bounded check, not a resolver.\n }\n }\n return null;\n};\n\n// Regex counterpart of `suspenseFactoryName`, scanning raw source text for\n// the same two argument shapes: `useSuspenseQuery(factory(...))` and\n// `useSuspenseQuery(factory)`. The character right after the identifier\n// disambiguates them from a member-expression argument the AST check would\n// also decline to attribute (e.g. `useSuspenseQuery(factory.options())`).\nconst CHILD_SUSPENSE_RE = /useSuspenseQuery\\(\\s*([A-Za-z_$][\\w$]*)\\s*([(),])/g;\n\nconst suspenseFactoriesInSource = (sourceText: string): Set<string> => {\n const factories = new Set<string>();\n for (const match of sourceText.matchAll(CHILD_SUSPENSE_RE)) {\n const factory = match.at(1);\n if (factory !== undefined) {\n factories.add(factory);\n }\n }\n return factories;\n};\n\nexport default eslintCompatPlugin({\n meta: { name: \"require-loader-prefetch\" },\n rules: {\n \"require-loader-prefetch\": {\n meta: {\n type: \"problem\",\n messages: {\n missingLoaderPrefetch:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route defines no `loader`, so this query fetches only after the \" +\n \"component mounts and suspends — an extra sequential network round \" +\n \"on cold navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery consumes the \" +\n \"warm cache.\",\n factoryNotPrefetched:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route `loader` never references {{factory}}, so this query still \" +\n \"fetches on mount — an extra sequential network round on cold \" +\n \"navigation. Prefetch {{factory}}(...) in the `loader` via \" +\n \"ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query; \" +\n \"useSuspenseQuery in the component then consumes the warm cache.\",\n childMissingLoaderPrefetch:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route defines no \" +\n \"`loader`, so this query fetches only after the child component \" +\n \"mounts and suspends — an extra sequential network round on cold \" +\n \"navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery in the child \" +\n \"component consumes the warm cache.\",\n childFactoryNotPrefetched:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route `loader` never \" +\n \"references {{factory}}, so this query still fetches on mount — \" +\n \"an extra sequential network round on cold navigation. Prefetch \" +\n \"{{factory}}(...) in the `loader` via ensureRouteQueryData \" +\n \"(blocking, critical data) or prefetchRouteQuery (non-blocking) \" +\n \"from @/lib/react-query; useSuspenseQuery in the child component \" +\n \"then consumes the warm cache.\",\n },\n },\n createOnce(context) {\n let isRouteFile = false;\n let routeOptions: { properties: unknown } | null = null;\n const suspenseCalls: { factory: string; node: Ranged }[] = [];\n const referencedInLoader = new Set();\n const colocatedImports: { node: Ranged; source: string }[] = [];\n\n return {\n before() {\n isRouteFile = false;\n routeOptions = null;\n suspenseCalls.length = 0;\n referencedInLoader.clear();\n colocatedImports.length = 0;\n },\n CallExpression(node) {\n const callee = node.callee;\n\n if (isCreateFileRouteCall(callee)) {\n isRouteFile = true;\n }\n\n if (isCreateFileRouteOptionsCall(callee)) {\n isRouteFile = true;\n const { args } = resolveLiteralArraySpreads(node.arguments);\n const optionsArg = args.at(0);\n if (\n routeOptions === null &&\n isAstNode(optionsArg) &&\n optionsArg.type === \"ObjectExpression\"\n ) {\n routeOptions = { properties: optionsArg.properties };\n }\n }\n\n const factory = suspenseFactoryName(node);\n if (factory !== null) {\n suspenseCalls.push({ node, factory });\n }\n },\n\n Identifier(node) {\n if (isInsideLoader(node)) {\n referencedInLoader.add(node.name);\n }\n },\n\n ImportDeclaration(node) {\n if (\n isStringLiteral(node.source) &&\n isColocatedRouteImport(node.source.value)\n ) {\n colocatedImports.push({ node, source: node.source.value });\n }\n },\n\n \"Program:exit\"() {\n // Only route files with an inline options object are analyzable;\n // a non-inline options argument can't be statically inspected.\n if (!isRouteFile || routeOptions === null) {\n return;\n }\n\n const properties = Array.isArray(routeOptions.properties)\n ? routeOptions.properties\n : [];\n const hasLoader = properties.some(\n (prop) =>\n prop.type === \"Property\" &&\n getPropertyName(prop.key) === LOADER_KEY,\n );\n\n for (const { node, factory } of suspenseCalls) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"missingLoaderPrefetch\",\n data: { factory },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"factoryNotPrefetched\",\n data: { factory },\n });\n }\n }\n\n if (colocatedImports.length === 0) {\n return;\n }\n\n const filename = filenameForContext(context);\n\n for (const { node, source } of colocatedImports) {\n const basePath = resolveColocatedImportBase(filename, source);\n if (basePath === null) {\n continue;\n }\n\n const childSource = readColocatedFile(basePath);\n if (childSource === null) {\n continue;\n }\n\n for (const factory of suspenseFactoriesInSource(childSource)) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"childMissingLoaderPrefetch\",\n data: { factory, importPath: source },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"childFactoryNotPrefetched\",\n data: { factory, importPath: source },\n });\n }\n }\n }\n },\n };\n },\n },\n },\n});\n"],"mappings":"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"}
@@ -1,4 +1,4 @@
1
- import { a as isStringLiteral, i as isIdentifier, o as unwrapExpression, r as getPropertyName } from "./utils.mjs";
1
+ import { a as isAstNode, d as unwrapExpression, l as resolveLiteralArraySpreads, o as isIdentifier, r as getPropertyName, s as isStringLiteral, t as filenameForContext } from "./utils.mjs";
2
2
  import { eslintCompatPlugin } from "@oxlint/plugins";
3
3
  import { readFileSync } from "node:fs";
4
4
  import path from "node:path";
@@ -14,7 +14,8 @@ const isSuspenseCallee = (callee) => {
14
14
  };
15
15
  const suspenseFactoryName = (node) => {
16
16
  if (!isSuspenseCallee(node.callee)) return null;
17
- const arg = unwrapExpression(node.arguments?.[0]);
17
+ const { args } = resolveLiteralArraySpreads(node.arguments ?? []);
18
+ const arg = unwrapExpression(args.at(0));
18
19
  if (arg?.type === "CallExpression" && isIdentifier(arg.callee)) return arg.callee.name;
19
20
  if (isIdentifier(arg)) return arg.name;
20
21
  return null;
@@ -47,7 +48,7 @@ const CHILD_SUSPENSE_RE = /useSuspenseQuery\(\s*([A-Za-z_$][\w$]*)\s*([(),])/g;
47
48
  const suspenseFactoriesInSource = (sourceText) => {
48
49
  const factories = /* @__PURE__ */ new Set();
49
50
  for (const match of sourceText.matchAll(CHILD_SUSPENSE_RE)) {
50
- const factory = match[1];
51
+ const factory = match.at(1);
51
52
  if (factory !== void 0) factories.add(factory);
52
53
  }
53
54
  return factories;
@@ -83,8 +84,9 @@ var require_loader_prefetch_default = eslintCompatPlugin({
83
84
  if (isCreateFileRouteCall(callee)) isRouteFile = true;
84
85
  if (isCreateFileRouteOptionsCall(callee)) {
85
86
  isRouteFile = true;
86
- const optionsArg = node.arguments?.[0];
87
- if (routeOptions === null && optionsArg?.type === "ObjectExpression") routeOptions = optionsArg;
87
+ const { args } = resolveLiteralArraySpreads(node.arguments);
88
+ const optionsArg = args.at(0);
89
+ if (routeOptions === null && isAstNode(optionsArg) && optionsArg.type === "ObjectExpression") routeOptions = { properties: optionsArg.properties };
88
90
  }
89
91
  const factory = suspenseFactoryName(node);
90
92
  if (factory !== null) suspenseCalls.push({
@@ -120,8 +122,7 @@ var require_loader_prefetch_default = eslintCompatPlugin({
120
122
  });
121
123
  }
122
124
  if (colocatedImports.length === 0) return;
123
- const filename = context.filename ?? context.getFilename?.();
124
- if (typeof filename !== "string") return;
125
+ const filename = filenameForContext(context);
125
126
  for (const { node, source } of colocatedImports) {
126
127
  const basePath = resolveColocatedImportBase(filename, source);
127
128
  if (basePath === null) continue;
@@ -1 +1 @@
1
- {"version":3,"file":"require-loader-prefetch.mjs","names":[],"sources":["../src/require-loader-prefetch.ts"],"sourcesContent":["// Require route loaders to prefetch the queries their component suspends on.\n//\n// A route component that calls `useSuspenseQuery(someOptions(...))` without the\n// route loader prefetching that same query forces a render-fetch waterfall: on\n// a cold visit the router resolves the route, the component mounts, suspends,\n// and only THEN starts the fetch — an extra sequential network round on every\n// cold navigation. TanStack Router loaders (`loader:` in `createFileRoute`\n// options) start the fetch during navigation, in parallel with code-split\n// chunk loading, via the helpers in `apps/web/src/lib/react-query.ts`\n// (`ensureRouteQueryData` for critical/blocking data, `prefetchRouteQuery` for\n// non-blocking warmup). With the loader priming the cache, `useSuspenseQuery`\n// in the component consumes a warm cache instead of opening a new round.\n//\n// Single-file AST analysis, scoped to route files (`createFileRoute(...)`):\n// - Flags `useSuspenseQuery(factory(...))` / `useSuspenseQuery(factory)` when\n// the route has no `loader`.\n// - Flags the same when a `loader` exists but never references the factory\n// identifier (any reference inside the loader body counts — typically\n// inside `ensureRouteQueryData` / `prefetchRouteQuery`).\n//\n// Cross-file, one-hop check for colocated route components: the single-file\n// analysis above is blind to a `useSuspenseQuery(factory(...))` call living in\n// a sibling file imported by the route (the codebase convention for colocated,\n// non-route-generating files is a dash-prefixed directory next to the route,\n// e.g. `-components/`, `-hooks/`, `-queries/`, imported via the `@/routes/...`\n// alias — TanStack Router excludes `-`-prefixed paths from route generation).\n// A real instance of this shipped on `/settings/account/connections` before\n// being caught by the e2e depth guard: the page component had no\n// `useSuspenseQuery` of its own, it all lived in a child card component.\n//\n// To close that gap without a real module resolver: once a file is confirmed\n// to be a route file with an inline options object (the same precondition the\n// single-file check already requires), each `@/routes/.../-<dir>/...` import\n// it declares is resolved to an absolute path by reusing the *linted file's\n// own* path up to its `/routes/` segment as the alias root (mirrors the\n// `\"@/*\": [\"./src/*\"]` tsconfig path). The resolved file is read once — bounded\n// to that one hop, its own imports are never followed — and scanned with a\n// regex for the same two `useSuspenseQuery(...)` shapes the AST check\n// recognizes. Any factory it finds is checked against the route's `loader`\n// exactly like an in-file suspense call would be.\n//\n// Safe (not flagged):\n// - `useSuspenseQuery` whose argument is a member expression or other shape\n// the rule can't statically attribute to a factory identifier (in-file or\n// in a colocated import).\n// - `useSuspenseQuery` in a non-route file (no `createFileRoute`) — including\n// when that file is itself a route's colocated component and is linted\n// directly rather than through the importing route.\n// - A route whose loader references the factory identifier.\n// - A colocated import that isn't under a dash-prefixed directory, or that\n// can't be read as a sibling `.tsx`/`.ts` file (no fs read is attempted\n// unless the file is already a confirmed route file with such an import).\n//\n// Known limitation: the child-file scan is a raw-text regex, not a second AST\n// parse, so it does not strip comments or string literals. A comment or\n// string in the colocated file that happens to spell out\n// `useSuspenseQuery(identifier(` / `useSuspenseQuery(identifier)` verbatim\n// would false-positive. Judged an acceptable, rare edge case for the\n// bounded/cheap scan this rule relies on; do not write that literal pattern\n// in prose comments inside `-components`/`-hooks`/`-queries` files.\n\nimport { eslintCompatPlugin, type Ranged } from \"@oxlint/plugins\";\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\n\nimport {\n getPropertyName,\n isIdentifier,\n isStringLiteral,\n unwrapExpression,\n} from \"./utils.ts\";\n\nconst CREATE_FILE_ROUTE = \"createFileRoute\";\nconst SUSPENSE_HOOK = \"useSuspenseQuery\";\nconst LOADER_KEY = \"loader\";\n\nconst isCreateFileRouteCall = (callee) =>\n isIdentifier(callee, CREATE_FILE_ROUTE);\n\n// `createFileRoute(\"...\")({ ... })` — the outer call whose callee is itself the\n// `createFileRoute(\"...\")` call. The first argument is the route options object.\nconst isCreateFileRouteOptionsCall = (callee) =>\n callee?.type === \"CallExpression\" && isCreateFileRouteCall(callee.callee);\n\nconst isSuspenseCallee = (callee) => {\n if (isIdentifier(callee, SUSPENSE_HOOK)) {\n return true;\n }\n return (\n callee?.type === \"MemberExpression\" &&\n callee.computed === false &&\n isIdentifier(callee.property, SUSPENSE_HOOK)\n );\n};\n\n// Resolve the factory identifier a `useSuspenseQuery` call depends on:\n// useSuspenseQuery(factory(...)) -> \"factory\"\n// useSuspenseQuery(factory) -> \"factory\"\n// Returns null for any other argument shape (member expressions, object\n// literals, etc.) so the rule never guesses.\nconst suspenseFactoryName = (node) => {\n if (!isSuspenseCallee(node.callee)) {\n return null;\n }\n const arg = unwrapExpression(node.arguments?.[0]);\n if (arg?.type === \"CallExpression\" && isIdentifier(arg.callee)) {\n return arg.callee.name;\n }\n if (isIdentifier(arg)) {\n return arg.name;\n }\n return null;\n};\n\nconst isInsideLoader = (node) => {\n let current = node.parent;\n while (current) {\n if (\n current.type === \"Property\" &&\n getPropertyName(current.key) === LOADER_KEY\n ) {\n return true;\n }\n current = current.parent;\n }\n return false;\n};\n\nconst ROUTES_SEGMENT = \"/routes/\";\nconst COLOCATED_DIR_RE = /\\/-[^/]+\\//;\n\n// A colocated, non-route-generating import: under `@/routes/...` and through\n// a dash-prefixed directory (`-components/`, `-hooks/`, `-queries/`, ...).\nconst isColocatedRouteImport = (source) =>\n source.startsWith(\"@/routes/\") && COLOCATED_DIR_RE.test(source);\n\n// Resolve a `@/routes/...` specifier to an absolute path by reusing the\n// currently-linted file's own path up to its `/routes/` segment as the alias\n// root — mirrors the `\"@/*\": [\"./src/*\"]` tsconfig path without a real module\n// resolver. Returns null if the linted file isn't itself under a `routes`\n// directory (shouldn't happen for a confirmed route file, but fails closed).\nconst resolveColocatedImportBase = (filename, source) => {\n // Windows gives `context.filename` with backslashes, which would make\n // `lastIndexOf(ROUTES_SEGMENT)` miss; normalize to forward slashes first\n // (valid path input on Windows, and `join` below normalizes the output).\n const normalizedFilename = filename.replaceAll(\"\\\\\", \"/\");\n const routesIndex = normalizedFilename.lastIndexOf(ROUTES_SEGMENT);\n if (routesIndex === -1) {\n return null;\n }\n const aliasRoot = normalizedFilename.slice(0, routesIndex);\n return path.join(aliasRoot, source.slice(\"@/\".length));\n};\n\n// Bounded, one-hop read: try the two extensions a colocated TS/TSX import can\n// resolve to. No further imports of the child file are followed.\nconst readColocatedFile = (basePath) => {\n for (const extension of [\".tsx\", \".ts\"]) {\n try {\n return readFileSync(`${basePath}${extension}`, \"utf-8\");\n } catch {\n // Try the next extension; if neither exists this is a shape the\n // simplified alias resolution can't handle (e.g. a directory index) —\n // acceptable since this is a best-effort bounded check, not a resolver.\n }\n }\n return null;\n};\n\n// Regex counterpart of `suspenseFactoryName`, scanning raw source text for\n// the same two argument shapes: `useSuspenseQuery(factory(...))` and\n// `useSuspenseQuery(factory)`. The character right after the identifier\n// disambiguates them from a member-expression argument the AST check would\n// also decline to attribute (e.g. `useSuspenseQuery(factory.options())`).\nconst CHILD_SUSPENSE_RE = /useSuspenseQuery\\(\\s*([A-Za-z_$][\\w$]*)\\s*([(),])/g;\n\nconst suspenseFactoriesInSource = (sourceText: string): Set<string> => {\n const factories = new Set<string>();\n for (const match of sourceText.matchAll(CHILD_SUSPENSE_RE)) {\n const factory = match[1];\n if (factory !== undefined) {\n factories.add(factory);\n }\n }\n return factories;\n};\n\nexport default eslintCompatPlugin({\n meta: { name: \"require-loader-prefetch\" },\n rules: {\n \"require-loader-prefetch\": {\n meta: {\n type: \"problem\",\n messages: {\n missingLoaderPrefetch:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route defines no `loader`, so this query fetches only after the \" +\n \"component mounts and suspends — an extra sequential network round \" +\n \"on cold navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery consumes the \" +\n \"warm cache.\",\n factoryNotPrefetched:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route `loader` never references {{factory}}, so this query still \" +\n \"fetches on mount — an extra sequential network round on cold \" +\n \"navigation. Prefetch {{factory}}(...) in the `loader` via \" +\n \"ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query; \" +\n \"useSuspenseQuery in the component then consumes the warm cache.\",\n childMissingLoaderPrefetch:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route defines no \" +\n \"`loader`, so this query fetches only after the child component \" +\n \"mounts and suspends — an extra sequential network round on cold \" +\n \"navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery in the child \" +\n \"component consumes the warm cache.\",\n childFactoryNotPrefetched:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route `loader` never \" +\n \"references {{factory}}, so this query still fetches on mount — \" +\n \"an extra sequential network round on cold navigation. Prefetch \" +\n \"{{factory}}(...) in the `loader` via ensureRouteQueryData \" +\n \"(blocking, critical data) or prefetchRouteQuery (non-blocking) \" +\n \"from @/lib/react-query; useSuspenseQuery in the child component \" +\n \"then consumes the warm cache.\",\n },\n },\n createOnce(context) {\n let isRouteFile = false;\n let routeOptions: { properties: unknown } | null = null;\n const suspenseCalls: { factory: string; node: Ranged }[] = [];\n const referencedInLoader = new Set();\n const colocatedImports: { node: Ranged; source: string }[] = [];\n\n return {\n before() {\n isRouteFile = false;\n routeOptions = null;\n suspenseCalls.length = 0;\n referencedInLoader.clear();\n colocatedImports.length = 0;\n },\n CallExpression(node) {\n const callee = node.callee;\n\n if (isCreateFileRouteCall(callee)) {\n isRouteFile = true;\n }\n\n if (isCreateFileRouteOptionsCall(callee)) {\n isRouteFile = true;\n const optionsArg = node.arguments?.[0];\n if (\n routeOptions === null &&\n optionsArg?.type === \"ObjectExpression\"\n ) {\n routeOptions = optionsArg;\n }\n }\n\n const factory = suspenseFactoryName(node);\n if (factory !== null) {\n suspenseCalls.push({ node, factory });\n }\n },\n\n Identifier(node) {\n if (isInsideLoader(node)) {\n referencedInLoader.add(node.name);\n }\n },\n\n ImportDeclaration(node) {\n if (\n isStringLiteral(node.source) &&\n isColocatedRouteImport(node.source.value)\n ) {\n colocatedImports.push({ node, source: node.source.value });\n }\n },\n\n \"Program:exit\"() {\n // Only route files with an inline options object are analyzable;\n // a non-inline options argument can't be statically inspected.\n if (!isRouteFile || routeOptions === null) {\n return;\n }\n\n const properties = Array.isArray(routeOptions.properties)\n ? routeOptions.properties\n : [];\n const hasLoader = properties.some(\n (prop) =>\n prop.type === \"Property\" &&\n getPropertyName(prop.key) === LOADER_KEY,\n );\n\n for (const { node, factory } of suspenseCalls) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"missingLoaderPrefetch\",\n data: { factory },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"factoryNotPrefetched\",\n data: { factory },\n });\n }\n }\n\n if (colocatedImports.length === 0) {\n return;\n }\n\n const filename = context.filename ?? context.getFilename?.();\n if (typeof filename !== \"string\") {\n return;\n }\n\n for (const { node, source } of colocatedImports) {\n const basePath = resolveColocatedImportBase(filename, source);\n if (basePath === null) {\n continue;\n }\n\n const childSource = readColocatedFile(basePath);\n if (childSource === null) {\n continue;\n }\n\n for (const factory of suspenseFactoriesInSource(childSource)) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"childMissingLoaderPrefetch\",\n data: { factory, importPath: source },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"childFactoryNotPrefetched\",\n data: { factory, importPath: source },\n });\n }\n }\n }\n },\n };\n },\n },\n },\n});\n"],"mappings":"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"}
1
+ {"version":3,"file":"require-loader-prefetch.mjs","names":[],"sources":["../src/require-loader-prefetch.ts"],"sourcesContent":["// Require route loaders to prefetch the queries their component suspends on.\n//\n// A route component that calls `useSuspenseQuery(someOptions(...))` without the\n// route loader prefetching that same query forces a render-fetch waterfall: on\n// a cold visit the router resolves the route, the component mounts, suspends,\n// and only THEN starts the fetch — an extra sequential network round on every\n// cold navigation. TanStack Router loaders (`loader:` in `createFileRoute`\n// options) start the fetch during navigation, in parallel with code-split\n// chunk loading, via the helpers in `apps/web/src/lib/react-query.ts`\n// (`ensureRouteQueryData` for critical/blocking data, `prefetchRouteQuery` for\n// non-blocking warmup). With the loader priming the cache, `useSuspenseQuery`\n// in the component consumes a warm cache instead of opening a new round.\n//\n// Single-file AST analysis, scoped to route files (`createFileRoute(...)`):\n// - Flags `useSuspenseQuery(factory(...))` / `useSuspenseQuery(factory)` when\n// the route has no `loader`.\n// - Flags the same when a `loader` exists but never references the factory\n// identifier (any reference inside the loader body counts — typically\n// inside `ensureRouteQueryData` / `prefetchRouteQuery`).\n//\n// Cross-file, one-hop check for colocated route components: the single-file\n// analysis above is blind to a `useSuspenseQuery(factory(...))` call in an\n// imported sibling file. Dash-prefixed directories such as `-components/`,\n// `-hooks/`, and `-queries/` are treated as colocated non-route files.\n//\n// Without a module resolver, confirmed route files with inline options resolve\n// each `@/routes/.../-<dir>/...` import from the linted file's `/routes/`\n// segment. The child file is read once and scanned with a regex for the same\n// two `useSuspenseQuery(...)` shapes the AST check recognizes. Child imports\n// are not followed, and each factory is checked against the route's `loader`.\n//\n// Safe (not flagged):\n// - `useSuspenseQuery` whose argument is a member expression or other shape\n// the rule can't statically attribute to a factory identifier (in-file or\n// in a colocated import).\n// - `useSuspenseQuery` in a non-route file (no `createFileRoute`) — including\n// when that file is itself a route's colocated component and is linted\n// directly rather than through the importing route.\n// - A route whose loader references the factory identifier.\n// - A colocated import that isn't under a dash-prefixed directory, or that\n// can't be read as a sibling `.tsx`/`.ts` file (no fs read is attempted\n// unless the file is already a confirmed route file with such an import).\n//\n// Known limitation: the child-file scan is a raw-text regex, not a second AST\n// parse, so it does not strip comments or string literals. A comment or\n// string in the colocated file that happens to spell out\n// `useSuspenseQuery(identifier(` / `useSuspenseQuery(identifier)` verbatim\n// would false-positive. Judged an acceptable, rare edge case for the\n// bounded/cheap scan this rule relies on; do not write that literal pattern\n// in prose comments inside `-components`/`-hooks`/`-queries` files.\n\nimport { eslintCompatPlugin, type Ranged } from \"@oxlint/plugins\";\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\n\nimport {\n filenameForContext,\n getPropertyName,\n isAstNode,\n isIdentifier,\n isStringLiteral,\n resolveLiteralArraySpreads,\n unwrapExpression,\n} from \"./utils.ts\";\n\nconst CREATE_FILE_ROUTE = \"createFileRoute\";\nconst SUSPENSE_HOOK = \"useSuspenseQuery\";\nconst LOADER_KEY = \"loader\";\n\nconst isCreateFileRouteCall = (callee) =>\n isIdentifier(callee, CREATE_FILE_ROUTE);\n\n// `createFileRoute(\"...\")({ ... })` — the outer call whose callee is itself the\n// `createFileRoute(\"...\")` call. The first argument is the route options object.\nconst isCreateFileRouteOptionsCall = (callee) =>\n callee?.type === \"CallExpression\" && isCreateFileRouteCall(callee.callee);\n\nconst isSuspenseCallee = (callee) => {\n if (isIdentifier(callee, SUSPENSE_HOOK)) {\n return true;\n }\n return (\n callee?.type === \"MemberExpression\" &&\n callee.computed === false &&\n isIdentifier(callee.property, SUSPENSE_HOOK)\n );\n};\n\n// Resolve the factory identifier a `useSuspenseQuery` call depends on:\n// useSuspenseQuery(factory(...)) -> \"factory\"\n// useSuspenseQuery(factory) -> \"factory\"\n// Returns null for any other argument shape (member expressions, object\n// literals, etc.) so the rule never guesses.\nconst suspenseFactoryName = (node) => {\n if (!isSuspenseCallee(node.callee)) {\n return null;\n }\n const { args } = resolveLiteralArraySpreads(node.arguments ?? []);\n const arg = unwrapExpression(args.at(0));\n if (arg?.type === \"CallExpression\" && isIdentifier(arg.callee)) {\n return arg.callee.name;\n }\n if (isIdentifier(arg)) {\n return arg.name;\n }\n return null;\n};\n\nconst isInsideLoader = (node) => {\n let current = node.parent;\n while (current) {\n if (\n current.type === \"Property\" &&\n getPropertyName(current.key) === LOADER_KEY\n ) {\n return true;\n }\n current = current.parent;\n }\n return false;\n};\n\nconst ROUTES_SEGMENT = \"/routes/\";\nconst COLOCATED_DIR_RE = /\\/-[^/]+\\//;\n\n// A colocated, non-route-generating import: under `@/routes/...` and through\n// a dash-prefixed directory (`-components/`, `-hooks/`, `-queries/`, ...).\nconst isColocatedRouteImport = (source) =>\n source.startsWith(\"@/routes/\") && COLOCATED_DIR_RE.test(source);\n\n// Resolve a `@/routes/...` specifier to an absolute path by reusing the\n// currently-linted file's own path up to its `/routes/` segment as the alias\n// root — mirrors the `\"@/*\": [\"./src/*\"]` tsconfig path without a real module\n// resolver. Returns null if the linted file isn't itself under a `routes`\n// directory (shouldn't happen for a confirmed route file, but fails closed).\nconst resolveColocatedImportBase = (filename, source) => {\n // Windows gives `context.filename` with backslashes, which would make\n // `lastIndexOf(ROUTES_SEGMENT)` miss; normalize to forward slashes first\n // (valid path input on Windows, and `join` below normalizes the output).\n const normalizedFilename = filename.replaceAll(\"\\\\\", \"/\");\n const routesIndex = normalizedFilename.lastIndexOf(ROUTES_SEGMENT);\n if (routesIndex === -1) {\n return null;\n }\n const aliasRoot = normalizedFilename.slice(0, routesIndex);\n return path.join(aliasRoot, source.slice(\"@/\".length));\n};\n\n// Bounded, one-hop read: try the two extensions a colocated TS/TSX import can\n// resolve to. No further imports of the child file are followed.\nconst readColocatedFile = (basePath) => {\n for (const extension of [\".tsx\", \".ts\"]) {\n try {\n return readFileSync(`${basePath}${extension}`, \"utf-8\");\n } catch {\n // Try the next extension; if neither exists this is a shape the\n // simplified alias resolution can't handle (e.g. a directory index) —\n // acceptable since this is a best-effort bounded check, not a resolver.\n }\n }\n return null;\n};\n\n// Regex counterpart of `suspenseFactoryName`, scanning raw source text for\n// the same two argument shapes: `useSuspenseQuery(factory(...))` and\n// `useSuspenseQuery(factory)`. The character right after the identifier\n// disambiguates them from a member-expression argument the AST check would\n// also decline to attribute (e.g. `useSuspenseQuery(factory.options())`).\nconst CHILD_SUSPENSE_RE = /useSuspenseQuery\\(\\s*([A-Za-z_$][\\w$]*)\\s*([(),])/g;\n\nconst suspenseFactoriesInSource = (sourceText: string): Set<string> => {\n const factories = new Set<string>();\n for (const match of sourceText.matchAll(CHILD_SUSPENSE_RE)) {\n const factory = match.at(1);\n if (factory !== undefined) {\n factories.add(factory);\n }\n }\n return factories;\n};\n\nexport default eslintCompatPlugin({\n meta: { name: \"require-loader-prefetch\" },\n rules: {\n \"require-loader-prefetch\": {\n meta: {\n type: \"problem\",\n messages: {\n missingLoaderPrefetch:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route defines no `loader`, so this query fetches only after the \" +\n \"component mounts and suspends — an extra sequential network round \" +\n \"on cold navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery consumes the \" +\n \"warm cache.\",\n factoryNotPrefetched:\n \"Route component calls useSuspenseQuery({{factory}}(...)) but the \" +\n \"route `loader` never references {{factory}}, so this query still \" +\n \"fetches on mount — an extra sequential network round on cold \" +\n \"navigation. Prefetch {{factory}}(...) in the `loader` via \" +\n \"ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query; \" +\n \"useSuspenseQuery in the component then consumes the warm cache.\",\n childMissingLoaderPrefetch:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route defines no \" +\n \"`loader`, so this query fetches only after the child component \" +\n \"mounts and suspends — an extra sequential network round on cold \" +\n \"navigation. Prefetch the same query options in the route \" +\n \"`loader` via ensureRouteQueryData (blocking, critical data) or \" +\n \"prefetchRouteQuery (non-blocking) from @/lib/react-query, so the \" +\n \"fetch starts during navigation and useSuspenseQuery in the child \" +\n \"component consumes the warm cache.\",\n childFactoryNotPrefetched:\n \"Route imports {{importPath}}, whose component calls \" +\n \"useSuspenseQuery({{factory}}(...)), but the route `loader` never \" +\n \"references {{factory}}, so this query still fetches on mount — \" +\n \"an extra sequential network round on cold navigation. Prefetch \" +\n \"{{factory}}(...) in the `loader` via ensureRouteQueryData \" +\n \"(blocking, critical data) or prefetchRouteQuery (non-blocking) \" +\n \"from @/lib/react-query; useSuspenseQuery in the child component \" +\n \"then consumes the warm cache.\",\n },\n },\n createOnce(context) {\n let isRouteFile = false;\n let routeOptions: { properties: unknown } | null = null;\n const suspenseCalls: { factory: string; node: Ranged }[] = [];\n const referencedInLoader = new Set();\n const colocatedImports: { node: Ranged; source: string }[] = [];\n\n return {\n before() {\n isRouteFile = false;\n routeOptions = null;\n suspenseCalls.length = 0;\n referencedInLoader.clear();\n colocatedImports.length = 0;\n },\n CallExpression(node) {\n const callee = node.callee;\n\n if (isCreateFileRouteCall(callee)) {\n isRouteFile = true;\n }\n\n if (isCreateFileRouteOptionsCall(callee)) {\n isRouteFile = true;\n const { args } = resolveLiteralArraySpreads(node.arguments);\n const optionsArg = args.at(0);\n if (\n routeOptions === null &&\n isAstNode(optionsArg) &&\n optionsArg.type === \"ObjectExpression\"\n ) {\n routeOptions = { properties: optionsArg.properties };\n }\n }\n\n const factory = suspenseFactoryName(node);\n if (factory !== null) {\n suspenseCalls.push({ node, factory });\n }\n },\n\n Identifier(node) {\n if (isInsideLoader(node)) {\n referencedInLoader.add(node.name);\n }\n },\n\n ImportDeclaration(node) {\n if (\n isStringLiteral(node.source) &&\n isColocatedRouteImport(node.source.value)\n ) {\n colocatedImports.push({ node, source: node.source.value });\n }\n },\n\n \"Program:exit\"() {\n // Only route files with an inline options object are analyzable;\n // a non-inline options argument can't be statically inspected.\n if (!isRouteFile || routeOptions === null) {\n return;\n }\n\n const properties = Array.isArray(routeOptions.properties)\n ? routeOptions.properties\n : [];\n const hasLoader = properties.some(\n (prop) =>\n prop.type === \"Property\" &&\n getPropertyName(prop.key) === LOADER_KEY,\n );\n\n for (const { node, factory } of suspenseCalls) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"missingLoaderPrefetch\",\n data: { factory },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"factoryNotPrefetched\",\n data: { factory },\n });\n }\n }\n\n if (colocatedImports.length === 0) {\n return;\n }\n\n const filename = filenameForContext(context);\n\n for (const { node, source } of colocatedImports) {\n const basePath = resolveColocatedImportBase(filename, source);\n if (basePath === null) {\n continue;\n }\n\n const childSource = readColocatedFile(basePath);\n if (childSource === null) {\n continue;\n }\n\n for (const factory of suspenseFactoriesInSource(childSource)) {\n if (!hasLoader) {\n context.report({\n node,\n messageId: \"childMissingLoaderPrefetch\",\n data: { factory, importPath: source },\n });\n continue;\n }\n\n if (!referencedInLoader.has(factory)) {\n context.report({\n node,\n messageId: \"childFactoryNotPrefetched\",\n data: { factory, importPath: source },\n });\n }\n }\n }\n },\n };\n },\n },\n },\n});\n"],"mappings":"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"}
package/dist/utils.cjs CHANGED
@@ -1,4 +1,5 @@
1
1
  //#region src/utils.ts
2
+ const filenameForContext = (context) => (context.filename ?? context.getFilename?.() ?? "").replaceAll("\\", "/");
2
3
  const isAstNode = (node) => typeof node === "object" && node !== null && "type" in node && typeof node.type === "string" && "range" in node;
3
4
  const isIdentifier = (node, name) => {
4
5
  if (!isAstNode(node) || node.type !== "Identifier") return false;
@@ -11,20 +12,64 @@ const getPropertyName = (node) => {
11
12
  if (isStringLiteral(node)) return node.value;
12
13
  return null;
13
14
  };
14
- const getCalleeName = (callee) => {
15
- if (isIdentifier(callee)) return callee.name;
16
- if (!isAstNode(callee) || callee.type !== "MemberExpression") return null;
17
- if (callee.computed !== false) return null;
18
- const objectName = getCalleeName(callee.object);
19
- const propertyName = getPropertyName(callee.property);
20
- if (propertyName === null) return null;
21
- return objectName === null ? propertyName : `${objectName}.${propertyName}`;
15
+ const staticStringValue = (node) => {
16
+ const expression = unwrapExpression(node);
17
+ if (expression === null) return null;
18
+ node = expression;
19
+ if (isStringLiteral(node)) return node.value;
20
+ if (!isAstNode(node) || node.type !== "TemplateLiteral" || !Array.isArray(node.expressions) || node.expressions.length !== 0 || !Array.isArray(node.quasis)) return null;
21
+ const quasi = node.quasis.at(0);
22
+ if (!isAstNode(quasi)) return null;
23
+ const value = quasi.value;
24
+ const cooked = typeof value === "object" && value !== null ? Reflect.get(value, "cooked") : void 0;
25
+ return typeof cooked === "string" ? cooked : null;
26
+ };
27
+ const memberPropertyName = (node) => {
28
+ if (!isAstNode(node) || node.type !== "MemberExpression") return null;
29
+ if (node.computed !== true) return getPropertyName(node.property);
30
+ return staticStringValue(node.property);
31
+ };
32
+ const invokedCallee = (node) => {
33
+ if (!isAstNode(node) || node.type !== "CallExpression") return null;
34
+ const callee = unwrapExpression(node.callee);
35
+ if (!isAstNode(callee)) return null;
36
+ if (callee.type === "SequenceExpression" && Array.isArray(callee.expressions)) return unwrapExpression(callee.expressions.at(-1));
37
+ if (callee.type === "MemberExpression" && (memberPropertyName(callee) === "call" || memberPropertyName(callee) === "apply")) return unwrapExpression(callee.object);
38
+ return callee;
22
39
  };
23
40
  const unwrapExpression = (node) => {
24
41
  if (!isAstNode(node)) return null;
25
42
  if (node.type === "TSAsExpression" || node.type === "TSSatisfiesExpression" || node.type === "ChainExpression") return unwrapExpression(node.expression);
26
43
  return node;
27
44
  };
45
+ const resolveLiteralArraySpreads = (args) => {
46
+ const expanded = [];
47
+ const append = (argument) => {
48
+ if (!isAstNode(argument) || argument.type !== "SpreadElement") {
49
+ expanded.push(argument);
50
+ return;
51
+ }
52
+ const expression = unwrapExpression(argument.argument);
53
+ if (expression?.type !== "ArrayExpression" || !Array.isArray(expression.elements)) {
54
+ expanded.push(argument);
55
+ return;
56
+ }
57
+ for (const element of expression.elements) append(element);
58
+ };
59
+ for (const argument of args) append(argument);
60
+ const barrierIndex = expanded.findIndex((argument) => isAstNode(argument) && argument.type === "SpreadElement");
61
+ if (barrierIndex === -1) return {
62
+ status: "known",
63
+ args: expanded
64
+ };
65
+ const spread = expanded.at(barrierIndex);
66
+ return {
67
+ status: "opaque",
68
+ args: expanded.slice(0, barrierIndex),
69
+ spread,
70
+ tail: expanded.slice(barrierIndex + 1)
71
+ };
72
+ };
28
73
  const getImportedName = (specifier) => {
29
74
  if (!isAstNode(specifier) || specifier.type !== "ImportSpecifier") return null;
30
75
  const imported = specifier.imported;
@@ -33,10 +78,10 @@ const getImportedName = (specifier) => {
33
78
  return null;
34
79
  };
35
80
  //#endregion
36
- Object.defineProperty(exports, "getCalleeName", {
81
+ Object.defineProperty(exports, "filenameForContext", {
37
82
  enumerable: true,
38
83
  get: function() {
39
- return getCalleeName;
84
+ return filenameForContext;
40
85
  }
41
86
  });
42
87
  Object.defineProperty(exports, "getImportedName", {
@@ -51,6 +96,18 @@ Object.defineProperty(exports, "getPropertyName", {
51
96
  return getPropertyName;
52
97
  }
53
98
  });
99
+ Object.defineProperty(exports, "invokedCallee", {
100
+ enumerable: true,
101
+ get: function() {
102
+ return invokedCallee;
103
+ }
104
+ });
105
+ Object.defineProperty(exports, "isAstNode", {
106
+ enumerable: true,
107
+ get: function() {
108
+ return isAstNode;
109
+ }
110
+ });
54
111
  Object.defineProperty(exports, "isIdentifier", {
55
112
  enumerable: true,
56
113
  get: function() {
@@ -63,6 +120,24 @@ Object.defineProperty(exports, "isStringLiteral", {
63
120
  return isStringLiteral;
64
121
  }
65
122
  });
123
+ Object.defineProperty(exports, "memberPropertyName", {
124
+ enumerable: true,
125
+ get: function() {
126
+ return memberPropertyName;
127
+ }
128
+ });
129
+ Object.defineProperty(exports, "resolveLiteralArraySpreads", {
130
+ enumerable: true,
131
+ get: function() {
132
+ return resolveLiteralArraySpreads;
133
+ }
134
+ });
135
+ Object.defineProperty(exports, "staticStringValue", {
136
+ enumerable: true,
137
+ get: function() {
138
+ return staticStringValue;
139
+ }
140
+ });
66
141
  Object.defineProperty(exports, "unwrapExpression", {
67
142
  enumerable: true,
68
143
  get: function() {
@@ -1 +1 @@
1
- {"version":3,"file":"utils.cjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["// Shared AST helpers for the rules in this folder.\n//\n// Oxlint plugin AST nodes are passed in untyped. Each helper narrows from\n// `unknown` so rule files can call them without per-call type ceremony or\n// shared type-import boilerplate.\n\nimport type { Ranged } from \"@oxlint/plugins\";\n\nexport type AstNode = Ranged & { type: string } & Record<string, unknown>;\n\ntype FilenameContext = {\n filename?: string;\n getFilename?: () => string;\n};\n\nexport const filenameForContext = (context: FilenameContext): string =>\n (context.filename ?? context.getFilename?.() ?? \"\").replaceAll(\"\\\\\", \"/\");\n\nexport const isAstNode = (node: unknown): node is AstNode =>\n typeof node === \"object\" &&\n node !== null &&\n \"type\" in node &&\n typeof (node as { type: unknown }).type === \"string\" &&\n \"range\" in node;\n\nexport const isIdentifier = (\n node: unknown,\n name?: string,\n): node is AstNode & { type: \"Identifier\"; name: string } => {\n if (!isAstNode(node) || node.type !== \"Identifier\") {\n return false;\n }\n if (typeof node.name !== \"string\") {\n return false;\n }\n return name === undefined || node.name === name;\n};\n\nexport const isStringLiteral = (\n node: unknown,\n): node is AstNode & { type: \"Literal\"; value: string } =>\n isAstNode(node) && node.type === \"Literal\" && typeof node.value === \"string\";\n\n// Resolve the static name of a Property or MemberExpression key:\n// Identifier.name or string-Literal.value. Returns null for computed keys\n// driven by a non-literal expression.\nexport const getPropertyName = (node: unknown): string | null => {\n if (isIdentifier(node)) {\n return node.name;\n }\n if (isStringLiteral(node)) {\n return node.value;\n }\n return null;\n};\n\n// Match `<object>.<property>` member access where both halves are\n// Identifiers and the access is not computed.\nexport const isMemberAccess = (\n node: unknown,\n object: string,\n property: string,\n): boolean =>\n isAstNode(node) &&\n node.type === \"MemberExpression\" &&\n node.computed === false &&\n isIdentifier(node.object, object) &&\n isIdentifier(node.property, property);\n\n// Match `CallExpression` whose callee is an Identifier with the given name.\nexport const isCallTo = (node: unknown, name: string): boolean =>\n isAstNode(node) &&\n node.type === \"CallExpression\" &&\n isIdentifier(node.callee, name);\n\n// Resolve the dot-notation name of a callee: an Identifier, or a\n// non-computed MemberExpression chain (e.g. `t.String`, `Schema.is`,\n// `process.stderr.write`). Returns null when the chain is computed\n// or the property name itself can't be resolved.\n//\n// If the chain is rooted at a non-Identifier expression (e.g. `foo().bar`),\n// returns the bare property name (\"bar\") rather than null. Callers that\n// match the result against a fixed allowlist must consider whether they\n// need to distinguish `foo().createSafeHandler` from `createSafeHandler`.\nexport const getCalleeName = (callee: unknown): string | null => {\n if (isIdentifier(callee)) {\n return callee.name;\n }\n if (!isAstNode(callee) || callee.type !== \"MemberExpression\") {\n return null;\n }\n if (callee.computed !== false) {\n return null;\n }\n const objectName = getCalleeName(callee.object);\n const propertyName = getPropertyName(callee.property);\n if (propertyName === null) {\n return null;\n }\n return objectName === null ? propertyName : `${objectName}.${propertyName}`;\n};\n\n// Peel TS-only wrapping nodes so a shape check sees the underlying\n// expression. Returns the original node when no wrapping is present.\nexport const unwrapExpression = (node: unknown): AstNode | null => {\n if (!isAstNode(node)) {\n return null;\n }\n if (\n node.type === \"TSAsExpression\" ||\n node.type === \"TSSatisfiesExpression\" ||\n node.type === \"ChainExpression\"\n ) {\n return unwrapExpression(node.expression);\n }\n return node;\n};\n\n// Resolve an ImportSpecifier's imported binding name (Identifier.name or\n// string-Literal.value). Returns null when the specifier shape is unexpected.\nexport const getImportedName = (specifier: unknown): string | null => {\n if (!isAstNode(specifier) || specifier.type !== \"ImportSpecifier\") {\n return null;\n }\n const imported = specifier.imported;\n if (isIdentifier(imported)) {\n return imported.name;\n }\n if (isStringLiteral(imported)) {\n return imported.value;\n }\n return null;\n};\n\n// Resolve an ImportSpecifier's local binding name. This differs from\n// getImportedName for aliased imports such as `import { source as local }`.\nexport const getImportLocalName = (specifier: unknown): string | null => {\n if (!isAstNode(specifier) || specifier.type !== \"ImportSpecifier\") {\n return null;\n }\n return isIdentifier(specifier.local) ? specifier.local.name : null;\n};\n"],"mappings":";AAkBA,MAAa,aAAa,SACxB,OAAO,SAAS,YAChB,SAAS,QACT,UAAU,QACV,OAAQ,KAA2B,SAAS,YAC5C,WAAW;AAEb,MAAa,gBACX,MACA,SAC2D;CAC3D,IAAI,CAAC,UAAU,IAAI,KAAK,KAAK,SAAS,cACpC,OAAO;CAET,IAAI,OAAO,KAAK,SAAS,UACvB,OAAO;CAET,OAAO,SAAS,KAAA,KAAa,KAAK,SAAS;AAC7C;AAEA,MAAa,mBACX,SAEA,UAAU,IAAI,KAAK,KAAK,SAAS,aAAa,OAAO,KAAK,UAAU;AAKtE,MAAa,mBAAmB,SAAiC;CAC/D,IAAI,aAAa,IAAI,GACnB,OAAO,KAAK;CAEd,IAAI,gBAAgB,IAAI,GACtB,OAAO,KAAK;CAEd,OAAO;AACT;AA8BA,MAAa,iBAAiB,WAAmC;CAC/D,IAAI,aAAa,MAAM,GACrB,OAAO,OAAO;CAEhB,IAAI,CAAC,UAAU,MAAM,KAAK,OAAO,SAAS,oBACxC,OAAO;CAET,IAAI,OAAO,aAAa,OACtB,OAAO;CAET,MAAM,aAAa,cAAc,OAAO,MAAM;CAC9C,MAAM,eAAe,gBAAgB,OAAO,QAAQ;CACpD,IAAI,iBAAiB,MACnB,OAAO;CAET,OAAO,eAAe,OAAO,eAAe,GAAG,WAAW,GAAG;AAC/D;AAIA,MAAa,oBAAoB,SAAkC;CACjE,IAAI,CAAC,UAAU,IAAI,GACjB,OAAO;CAET,IACE,KAAK,SAAS,oBACd,KAAK,SAAS,2BACd,KAAK,SAAS,mBAEd,OAAO,iBAAiB,KAAK,UAAU;CAEzC,OAAO;AACT;AAIA,MAAa,mBAAmB,cAAsC;CACpE,IAAI,CAAC,UAAU,SAAS,KAAK,UAAU,SAAS,mBAC9C,OAAO;CAET,MAAM,WAAW,UAAU;CAC3B,IAAI,aAAa,QAAQ,GACvB,OAAO,SAAS;CAElB,IAAI,gBAAgB,QAAQ,GAC1B,OAAO,SAAS;CAElB,OAAO;AACT"}
1
+ {"version":3,"file":"utils.cjs","names":[],"sources":["../src/utils.ts"],"sourcesContent":["// Shared AST helpers for the rules in this folder.\n//\n// Oxlint plugin AST nodes are passed in untyped. Each helper narrows from\n// `unknown` so rule files can call them without per-call type ceremony or\n// shared type-import boilerplate.\n\nimport type { Ranged } from \"@oxlint/plugins\";\n\nexport type AstNode = Ranged & { type: string } & Record<string, unknown>;\n\ntype FilenameContext = {\n filename?: string;\n getFilename?: () => string;\n};\n\nexport const filenameForContext = (context: FilenameContext): string =>\n (context.filename ?? context.getFilename?.() ?? \"\").replaceAll(\"\\\\\", \"/\");\n\nexport const isAstNode = (node: unknown): node is AstNode =>\n typeof node === \"object\" &&\n node !== null &&\n \"type\" in node &&\n typeof (node as { type: unknown }).type === \"string\" &&\n \"range\" in node;\n\nexport const isIdentifier = (\n node: unknown,\n name?: string,\n): node is AstNode & { type: \"Identifier\"; name: string } => {\n if (!isAstNode(node) || node.type !== \"Identifier\") {\n return false;\n }\n if (typeof node.name !== \"string\") {\n return false;\n }\n return name === undefined || node.name === name;\n};\n\nexport const isStringLiteral = (\n node: unknown,\n): node is AstNode & { type: \"Literal\"; value: string } =>\n isAstNode(node) && node.type === \"Literal\" && typeof node.value === \"string\";\n\n// Resolve the static name of a Property or MemberExpression key:\n// Identifier.name or string-Literal.value. Returns null for computed keys\n// driven by a non-literal expression.\nexport const getPropertyName = (node: unknown): string | null => {\n if (isIdentifier(node)) {\n return node.name;\n }\n if (isStringLiteral(node)) {\n return node.value;\n }\n return null;\n};\n\nexport const staticStringValue = (node: unknown): string | null => {\n const expression = unwrapExpression(node);\n if (expression === null) return null;\n node = expression;\n if (isStringLiteral(node)) return node.value;\n if (\n !isAstNode(node) ||\n node.type !== \"TemplateLiteral\" ||\n !Array.isArray(node.expressions) ||\n node.expressions.length !== 0 ||\n !Array.isArray(node.quasis)\n )\n return null;\n const quasi = node.quasis.at(0);\n if (!isAstNode(quasi)) return null;\n const value = quasi.value;\n const cooked =\n typeof value === \"object\" && value !== null\n ? Reflect.get(value, \"cooked\")\n : undefined;\n return typeof cooked === \"string\" ? cooked : null;\n};\n\nexport const memberPropertyName = (node: unknown): string | null => {\n if (!isAstNode(node) || node.type !== \"MemberExpression\") {\n return null;\n }\n if (node.computed !== true) {\n return getPropertyName(node.property);\n }\n return staticStringValue(node.property);\n};\n\nexport const invokedCallee = (node: unknown): AstNode | null => {\n if (!isAstNode(node) || node.type !== \"CallExpression\") {\n return null;\n }\n const callee = unwrapExpression(node.callee);\n if (!isAstNode(callee)) {\n return null;\n }\n if (\n callee.type === \"SequenceExpression\" &&\n Array.isArray(callee.expressions)\n ) {\n return unwrapExpression(callee.expressions.at(-1));\n }\n if (\n callee.type === \"MemberExpression\" &&\n (memberPropertyName(callee) === \"call\" ||\n memberPropertyName(callee) === \"apply\")\n ) {\n return unwrapExpression(callee.object);\n }\n return callee;\n};\n\n// Match `<object>.<property>` member access where both halves are\n// Identifiers and the access is not computed.\nexport const isMemberAccess = (\n node: unknown,\n object: string,\n property: string,\n): boolean =>\n isAstNode(node) &&\n node.type === \"MemberExpression\" &&\n node.computed === false &&\n isIdentifier(node.object, object) &&\n isIdentifier(node.property, property);\n\n// Match `CallExpression` whose callee is an Identifier with the given name.\nexport const isCallTo = (node: unknown, name: string): boolean =>\n isAstNode(node) &&\n node.type === \"CallExpression\" &&\n isIdentifier(node.callee, name);\n\n// Resolve the dot-notation name of a callee: an Identifier, or a\n// non-computed MemberExpression chain (e.g. `t.String`, `Schema.is`,\n// `process.stderr.write`). Returns null when the chain is computed\n// or the property name itself can't be resolved.\n//\n// If the chain is rooted at a non-Identifier expression (e.g. `foo().bar`),\n// returns the bare property name (\"bar\") rather than null. Callers that\n// match the result against a fixed allowlist must consider whether they\n// need to distinguish `foo().createSafeHandler` from `createSafeHandler`.\nexport const getCalleeName = (callee: unknown): string | null => {\n if (isIdentifier(callee)) {\n return callee.name;\n }\n if (!isAstNode(callee) || callee.type !== \"MemberExpression\") {\n return null;\n }\n if (callee.computed !== false) {\n return null;\n }\n const objectName = getCalleeName(callee.object);\n const propertyName = getPropertyName(callee.property);\n if (propertyName === null) {\n return null;\n }\n return objectName === null ? propertyName : `${objectName}.${propertyName}`;\n};\n\n// Peel TS-only wrapping nodes so a shape check sees the underlying\n// expression. Returns the original node when no wrapping is present.\nexport const unwrapExpression = (node: unknown): AstNode | null => {\n if (!isAstNode(node)) {\n return null;\n }\n if (\n node.type === \"TSAsExpression\" ||\n node.type === \"TSSatisfiesExpression\" ||\n node.type === \"ChainExpression\"\n ) {\n return unwrapExpression(node.expression);\n }\n return node;\n};\n\nexport type LiteralArgumentResolution =\n | { status: \"known\"; args: unknown[] }\n | { status: \"opaque\"; args: unknown[]; spread: unknown; tail: unknown[] };\n\n// Expand literal array spreads and retain unresolved spread nodes in sequence.\n// Positional readers use only `args`, the prefix before the first barrier;\n// source inspectors can still inspect later entries in `tail`.\nexport const resolveLiteralArraySpreads = (\n args: unknown[],\n): LiteralArgumentResolution => {\n const expanded: unknown[] = [];\n const append = (argument: unknown): void => {\n if (!isAstNode(argument) || argument.type !== \"SpreadElement\") {\n expanded.push(argument);\n return;\n }\n const expression = unwrapExpression(argument.argument);\n if (\n expression?.type !== \"ArrayExpression\" ||\n !Array.isArray(expression.elements)\n ) {\n expanded.push(argument);\n return;\n }\n for (const element of expression.elements) append(element);\n };\n for (const argument of args) append(argument);\n const barrierIndex = expanded.findIndex(\n (argument) => isAstNode(argument) && argument.type === \"SpreadElement\",\n );\n if (barrierIndex === -1) return { status: \"known\", args: expanded };\n const spread = expanded.at(barrierIndex);\n return {\n status: \"opaque\",\n args: expanded.slice(0, barrierIndex),\n spread,\n tail: expanded.slice(barrierIndex + 1),\n };\n};\n\n// Resolve an ImportSpecifier's imported binding name (Identifier.name or\n// string-Literal.value). Returns null when the specifier shape is unexpected.\nexport const getImportedName = (specifier: unknown): string | null => {\n if (!isAstNode(specifier) || specifier.type !== \"ImportSpecifier\") {\n return null;\n }\n const imported = specifier.imported;\n if (isIdentifier(imported)) {\n return imported.name;\n }\n if (isStringLiteral(imported)) {\n return imported.value;\n }\n return null;\n};\n\n// Resolve an ImportSpecifier's local binding name. This differs from\n// getImportedName for aliased imports such as `import { source as local }`.\nexport const getImportLocalName = (specifier: unknown): string | null => {\n if (!isAstNode(specifier) || specifier.type !== \"ImportSpecifier\") {\n return null;\n }\n return isIdentifier(specifier.local) ? specifier.local.name : null;\n};\n"],"mappings":"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"}