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:
parent
e0fd8207a1
commit
77cf757103
|
|
@ -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);
|
||||
},
|
||||
);
|
||||
|
|
@ -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 = (
|
||||
|
|
|
|||
Loading…
Reference in New Issue