fix(visualize): use function replacers so vendored assets containing $& survive intact

String.prototype.replace with a string replacement argument interprets
`$&`, `$'`, `` $` ``, and `$$` as special tokens (match, post-match,
pre-match, literal-$). The vendored bundles contain the classic
regex-escape idiom `"\\$&"` (`cytoscape.min.js` and `dagre.min.js` each
have one), which the asset-injection pipeline was corrupting into e.g.
`"\\{{CYTOSCAPE}}"` in the generated HTML. The visible symptom was a
broken Cytoscape search-box escape path whenever a user typed a regex
metacharacter into the live-search input.

Fix: convert the six asset/data replacements in buildInteractiveGraphHtml
to function-replacer form (`() => assets.cytoscape`), which uses the
returned string literally with no `$`-token interpretation. Same class
of bug would have affected embedded JSON had a file path or symbol name
contained `$&` — now neutralised for the whole chain.

Regression test added: asserts the `\\$&` idiom survives verbatim in the
rendered HTML and that neither `\\{{CYTOSCAPE}}` nor `\\{{DAGRE}}`
appears. A single `expect(html).toContain('"\\\\$&"')` catches the bug.

Quality gates (all green):
- Biome lint: clean
- TypeScript (tsc --noEmit): clean
- Unit tests: 686/686 (was 685; +1 regression test)
- CodeRabbit: No findings
- Snyk: 0 issues

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Giancarlo Erra
2026-04-23 16:48:58 +01:00
parent 50d8853ea6
commit 081606f9e2
2 changed files with 24 additions and 6 deletions
+10 -6
View File
@@ -281,16 +281,20 @@ export async function buildInteractiveGraphHtml(opts: InteractiveHtmlOptions): P
? `${files.length} files · ${fileEdges.length} edges · ${sym.symbolCount ?? 0} symbols · ${sym.edgeCount ?? 0} calls`
: `${files.length} files · ${fileEdges.length} edges`;
// Use function replacers for asset/data injection: string replacements
// would interpret `$&`, `$'`, `` $` ``, and `$$` in the replacement, which
// corrupts vendored bundles (cytoscape.min.js and dagre.min.js both contain
// a regex-escape idiom of the form `\$&`).
const html = assets.template
.replace(/\{\{TITLE\}\}/g, escapeHtml(`${opts.projectName} — SocratiCode graph`))
.replace(/\{\{PROJECT_NAME\}\}/g, escapeHtml(opts.projectName))
.replace(/\{\{STATS\}\}/g, escapeHtml(statsLine))
.replace("{{STYLES}}", assets.styles)
.replace("{{CYTOSCAPE}}", assets.cytoscape)
.replace("{{DAGRE}}", assets.dagre)
.replace("{{CYTOSCAPE_DAGRE}}", assets.cytoscapeDagre)
.replace("{{DATA_JSON}}", toEmbeddedJson(data))
.replace("{{APP}}", assets.app);
.replace("{{STYLES}}", () => assets.styles)
.replace("{{CYTOSCAPE}}", () => assets.cytoscape)
.replace("{{DAGRE}}", () => assets.dagre)
.replace("{{CYTOSCAPE_DAGRE}}", () => assets.cytoscapeDagre)
.replace("{{DATA_JSON}}", () => toEmbeddedJson(data))
.replace("{{APP}}", () => assets.app);
logger.info("Built interactive graph HTML", {
project: opts.projectName,
+14
View File
@@ -105,6 +105,20 @@ describe("graph-visualize-html", () => {
expect(html).toContain('"symbolMode":"capped"');
});
it("inlines vendored Cytoscape/Dagre bundles byte-for-byte (no $-token interpretation)", async () => {
// Regression for a `String.prototype.replace(string, string)` pitfall: a `$&`
// inside the replacement is interpreted as the match, corrupting any asset
// (or embedded data) that contains `$&`, `$'`, `` $` ``, or `$$`.
const { html } = await buildInteractiveGraphHtml({
projectPath: "/proj", projectName: "bytes", projectId: "p1", graph: SAMPLE_GRAPH,
});
// If the bug returns, `\$&` becomes `\{{CYTOSCAPE}}` / `\{{DAGRE}}` in the output.
expect(html.includes("\\{{CYTOSCAPE}}")).toBe(false);
expect(html.includes("\\{{DAGRE}}")).toBe(false);
// And the original token must survive verbatim from both bundles.
expect(html.includes("\\$&")).toBe(true);
});
it("marks edges that belong to a cycle as cyclic:true", async () => {
const cyclic: CodeGraph = {
nodes: [