Pre-flight checks
What happened?
graphify update . writes a graph.html that renders nothing — an empty page, no graph, no legend — whenever the node labels contain an unclosed <!-- followed, later in the embedded JSON, by <script.
Expected: the page opens and draws the graph, whatever text the labels happen to carry.
Actual: the document is empty and the browser console reports SyntaxError: Unexpected token '<' in graph.html. An HTML parser finds only one inline <script> element instead of two: everything after the data block has been swallowed as script text.
Cause. The graph is embedded as JSON inside a <script> element, and the escaping only neutralises </:
# graphify/exporters/html.py, ~line 617
def _js_safe(obj) -> str:
return json.dumps(obj).replace("</", "<\\/")
Inside a script element the HTML tokenizer does not care about quotes — it reacts to three sequences: <!--, <script and </script. An unclosed <!-- moves it to script data escaped, a later <script moves it to script data double escaped, and in that state the real </script> no longer closes the element (HTML spec, script data double escaped state). The rest of the document becomes part of the script, which is then a syntax error, so nothing runs and nothing renders.
A closed <!-- … --> inside a single label is harmless: the --> returns the tokenizer to the normal state. It takes an unclosed opener — which is exactly what label truncation produces — followed by a <script in a later label.
How I hit it. Under 0.8.19 this happened on an ordinary repository with no crafted input: fenced ```vue blocks in the markdown became nodes labelled code:vue (<!-- components/… the panel t) (truncated, so the comment is never closed) and code:vue (<script setup>). 0.9.76 no longer extracts fenced blocks as nodes, so that particular path is gone — but the escaping is unchanged, and a heading or a symbol name reaches the same place. The minimal repro below fails on 0.9.76 today.
Suggested fix: escape the < itself, not the </ pair. JavaScript reads \u003c as <, so labels still display <script setup> while the HTML tokenizer never sees the sequence:
def _js_safe(obj) -> str:
return json.dumps(obj).replace("<", "\\u003c")
The same one-line helper is duplicated in graphify/tree_html.py and graphify/callflow_html.py and has the same gap.
Steps to reproduce
1. mkdir repro && cd repro
2. Create guide.md with exactly this content (the <!-- is deliberately never closed,
and the <script heading must come after it):
# Guide
## An unclosed <!-- opener in a heading
Prose.
## Then a <script setup> heading
More prose.
3. Create hello.js so the corpus has a code file:
export function hello() {
return "hi";
}
4. graphify update .
5. Open graphify-out/graph.html in a browser: the page is blank.
The console shows SyntaxError: Unexpected token '<'.
Checking it without a browser (any HTML parser does):
node -e "const {JSDOM}=require('jsdom');const d=new JSDOM(require('fs').readFileSync('graphify-out/graph.html','utf8'));console.log([...d.window.document.querySelectorAll('script')].length)"
prints 2 on a healthy page (vis-network + the data block) and 1 here, because the
closing </script> was not recognised.
Swapping the two headings so that <script setup> comes first, or closing the comment
as <!-- comment -->, produces a working page: the order and the unclosed opener are
what matter.
Error output or graph output
Browser console on opening graphify-out/graph.html:
Uncaught SyntaxError: Unexpected token '<' (graph.html)
Parsing the same file with a standards HTML parser (jsdom):
inline <script> elements found: 1 (expected 2)
block 1: SyntaxError: Unexpected token '<'
body elements: 17 (18 on a page that is not broken)
The embedded data, as written into graph.html:
{"id": "guide_md_h2_1", "label": "An unclosed <!-- opener in a heading", ...}
{"id": "guide_md_h2_2", "label": "Then a <script setup> heading", ...}
Both sequences reach the document verbatim; only "</" would have been escaped.
CLI output is normal and reports no problem:
[graphify watch] Rebuilt: 12 nodes, 11 edges, 3 communities
[graphify watch] graph.json, graph.html and GRAPH_REPORT.md updated in graphify-out
Graphify version
0.9.76
Operating System
Windows
Python Version
3.1
Installation Method
uv tool install (recommended)
Additional Environment Details
- graphify 0.9.76, installed with
uv tool install graphifyy (uv 0.11.16); the tool's interpreter is Python 3.14.5.
- Windows 11, PowerShell and Git Bash alike.
- No provider environment variables set: no
OPENAI_API_KEY, GEMINI_API_KEY, GOOGLE_API_KEY or ANTHROPIC_API_KEY. The run is AST-only, no LLM involved, and the embedded labels come straight from the markdown headings.
- A clean checkout reproduces it: the repro above is an empty directory with two files and nothing else — no config, no
.graphifyignore, no cache.
- First seen on 0.8.19 in a real repository, where fenced
vue blocks in the docs produced the two labels by themselves; still reproducible on 0.9.76 with the minimal case.
Additional context
The escaping has been tightened once before, in a523509 ("fix(tree): escape title, header, and JSON blob in emit_html"), which mirrored _js_safe() into tree_html.py. That change covered </ only, so all three copies share the same gap today:
graphify/exporters/html.py (~line 617) — the one this report is about;
graphify/tree_html.py;
graphify/callflow_html.py.
It does not look exploitable: </script is already neutralised, so hostile content cannot close the element and inject markup. The effect is a graph page that silently will not render — which, with the CLI reporting success, is hard to attribute without reading the HTML.
A regression test would be cheap: generate a graph whose labels contain <!-- and <script, then assert that neither sequence appears inside the <script> block of the output (or, stronger, that an HTML parser finds both inline scripts).
I can open a PR with the one-line change in all three files plus that test, if it is welcome.
Pre-flight checks
What happened?
graphify update .writes agraph.htmlthat renders nothing — an empty page, no graph, no legend — whenever the node labels contain an unclosed<!--followed, later in the embedded JSON, by<script.Expected: the page opens and draws the graph, whatever text the labels happen to carry.
Actual: the document is empty and the browser console reports
SyntaxError: Unexpected token '<'ingraph.html. An HTML parser finds only one inline<script>element instead of two: everything after the data block has been swallowed as script text.Cause. The graph is embedded as JSON inside a
<script>element, and the escaping only neutralises</:Inside a script element the HTML tokenizer does not care about quotes — it reacts to three sequences:
<!--,<scriptand</script. An unclosed<!--moves it to script data escaped, a later<scriptmoves it to script data double escaped, and in that state the real</script>no longer closes the element (HTML spec, script data double escaped state). The rest of the document becomes part of the script, which is then a syntax error, so nothing runs and nothing renders.A closed
<!-- … -->inside a single label is harmless: the-->returns the tokenizer to the normal state. It takes an unclosed opener — which is exactly what label truncation produces — followed by a<scriptin a later label.How I hit it. Under 0.8.19 this happened on an ordinary repository with no crafted input: fenced
```vueblocks in the markdown became nodes labelledcode:vue (<!-- components/… the panel t)(truncated, so the comment is never closed) andcode:vue (<script setup>). 0.9.76 no longer extracts fenced blocks as nodes, so that particular path is gone — but the escaping is unchanged, and a heading or a symbol name reaches the same place. The minimal repro below fails on 0.9.76 today.Suggested fix: escape the
<itself, not the</pair. JavaScript reads\u003cas<, so labels still display<script setup>while the HTML tokenizer never sees the sequence:The same one-line helper is duplicated in
graphify/tree_html.pyandgraphify/callflow_html.pyand has the same gap.Steps to reproduce
Error output or graph output
Graphify version
0.9.76
Operating System
Windows
Python Version
3.1
Installation Method
uv tool install (recommended)
Additional Environment Details
uv tool install graphifyy(uv 0.11.16); the tool's interpreter is Python 3.14.5.OPENAI_API_KEY,GEMINI_API_KEY,GOOGLE_API_KEYorANTHROPIC_API_KEY. The run is AST-only, no LLM involved, and the embedded labels come straight from the markdown headings..graphifyignore, no cache.vueblocks in the docs produced the two labels by themselves; still reproducible on 0.9.76 with the minimal case.Additional context
The escaping has been tightened once before, in
a523509("fix(tree): escape title, header, and JSON blob in emit_html"), which mirrored_js_safe()intotree_html.py. That change covered</only, so all three copies share the same gap today:graphify/exporters/html.py(~line 617) — the one this report is about;graphify/tree_html.py;graphify/callflow_html.py.It does not look exploitable:
</scriptis already neutralised, so hostile content cannot close the element and inject markup. The effect is a graph page that silently will not render — which, with the CLI reporting success, is hard to attribute without reading the HTML.A regression test would be cheap: generate a graph whose labels contain
<!--and<script, then assert that neither sequence appears inside the<script>block of the output (or, stronger, that an HTML parser finds both inline scripts).I can open a PR with the one-line change in all three files plus that test, if it is welcome.