fix(javascript): read substitution-free templates as exact strings

Static application analysis read call arguments only from quoted string
literals. A template literal without substitutions denotes the same
exact string, yet `fetch(`https://x`)`, `localStorage.setItem(`k`)`,
`new Worker(`./w.js`)`, and `require(`./dep.js`)` produced no endpoint,
an unnamed storage node, or an unresolved reference expression instead
of the facts their quoted spellings produce. Electron IPC and data-effect
readers already accepted these templates.

Read substitution-free templates in the shared static string and
argument readers so both spellings produce the same graph.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Iwakurakaoru0822 2026-10-06 22:58:43 -07:00
parent e0fd8207a1
commit 77cf757103
2 changed files with 81 additions and 7 deletions

View File

@ -0,0 +1,69 @@
import { writeFile } from "node:fs/promises";
import { join } from "node:path";
import { expect, it } from "vitest";
import { createTestTempDirectory } from "../../tests/fixtures/temporaryDirectory.js";
import { javascriptApplicationAnalysisResultSchema } from "../domain/javascriptApplicationAnalysis.js";
import { analyzeJavaScriptApplication } from "./JavaScriptApplicationService.js";
const graphIdentities = async (source: string) => {
const inputPath = await createTestTempDirectory("rea-js-template-literal-");
await writeFile(join(inputPath, "app.js"), `${source}\n`);
await writeFile(join(inputPath, "dep.js"), "module.exports = 1;\n");
await writeFile(join(inputPath, "worker.js"), "self.onmessage = null;\n");
const result = await analyzeJavaScriptApplication({
input_path: inputPath,
format: "directory",
});
if (!result.ok)
throw new Error(`Expected analysis success: ${result.error.message}`);
const { graph } = javascriptApplicationAnalysisResultSchema.parse(
result.value.normalized_result,
);
return (
graph.nodes
.filter(({ kind }) => kind !== "javascript-asset" && kind !== "artifact")
// Artifact digests differ between spellings; keep the derived keys.
.map(({ kind, identity }) => ({
kind,
strategy: identity.strategy,
...(identity.strategy === "artifact-local-key"
? {
namespace: identity.namespace,
key: identity.key.replace(/^[0-9a-f]{64}:/u, ""),
}
: {}),
}))
.sort((left, right) =>
JSON.stringify(left).localeCompare(JSON.stringify(right)),
)
);
};
// Each quoted literal has the same length as its template spelling, so
// source locations and every derived identity stay comparable.
it.each([
"fetch('https://api.example.com/v1/users');",
"new WebSocket('wss://ws.example.com/socket');",
"const xhr = new XMLHttpRequest(); xhr.open('GET', 'https://xhr.example.com/data');",
"localStorage.setItem('token', 1);",
"sessionStorage.getItem('session-id');",
"indexedDB.open('records');",
"new Worker('./worker.js');",
"const dep = require('./dep.js'); dep();",
"import('./dep.js');",
"navigator.serviceWorker.register('./worker.js');",
])(
"reads a template without substitutions as its exact string: %s",
async (quoted) => {
const template = quoted.replaceAll("'", "`");
const expected = await graphIdentities(quoted);
expect(
expected.some(({ kind }) =>
["endpoint", "storage", "worker", "javascript-module"].includes(kind),
),
).toBe(true);
await expect(graphIdentities(template)).resolves.toEqual(expected);
},
);

View File

@ -406,19 +406,24 @@ const memberPropertyName = (
? `[computed@${String(node.property.start ?? -1)}]`
: semanticStaticPropertyName(node.property, node.computed);
/** Return a string literal value without evaluating an expression. */
/**
* Return a string literal value without evaluating an expression. A template
* literal without substitutions denotes the same exact string.
*/
export const stringValue = (
node: t.Node | null | undefined,
): string | undefined => (t.isStringLiteral(node) ? node.value : undefined);
): string | undefined => {
if (t.isStringLiteral(node)) return node.value;
if (t.isTemplateLiteral(node) && node.expressions.length === 0)
return node.quasis[0]?.value.cooked ?? undefined;
return undefined;
};
/** Return a string or numeric argument literal. */
export const argumentValue = (
node: t.Node | null | undefined,
): string | undefined => {
if (t.isStringLiteral(node) || t.isNumericLiteral(node))
return String(node.value);
return undefined;
};
): string | undefined =>
t.isNumericLiteral(node) ? String(node.value) : stringValue(node);
/** Prefix a static argument value when one is available. */
export const prefixedArgument = (