Problem
The CSS parser (tools/languages/css.py) returns every selector, whether class, id or tag, in "functions". The writer then stores each one as a Function node. A selector repeated in a stylesheet becomes one Function per occurrence. The HTML parser (tools/languages/html.py) stores class="…" values as Class nodes and id="…" values as Variable nodes.
Selectors and attribute values are not callable, and they don't declare classes or variables. Nothing ever CALLS them, so every stylesheet selector becomes a find_dead_code row. They also show up in find_code as functions and classes.
Repro (upstream/main 1b595a6, no code changes; real Kùzu 0.11.3 and LadybugDB 0.19.1, each in its own process)
A 3-file repo:
/* style.css */
:root { --brand: #336699; }
body { margin: 0; }
.navbar { display: flex; }
.navbar a:hover, .navbar .active { color: var(--brand); }
#main > p { line-height: 1.5; }
@media (min-width: 640px) { .navbar { gap: 1rem; } }
<!-- index.html -->
<!doctype html>
<html><head><link rel="stylesheet" href="style.css"></head>
<body><nav class="navbar"><a class="active" href="#">Home</a></nav>
<div id="main"><p class="lead">Hello</p></div>
<script src="app.js"></script></body></html>
// app.js
export function render() { return document.querySelector('.navbar'); }
render();
Indexed with GraphBuilder.build_graph_from_path_async, then queried with MATCH (f:File)-[:CONTAINS]->(n:<Label>) and the find_dead_code / find_code handlers:
Function nodes (10): app.js render; style.css body, .navbar(3), .active(4), .navbar(4), .navbar(4), a(4), #main(5), p(5), .navbar(6)
Class nodes (3): index.html active, navbar, lead
Variable nodes (2): index.html main; style.css --brand
find_dead_code rows: 9 -> every one is a style.css selector (body, a, p, .navbar x4, .active, #main)
find_code '.navbar': functions_by_name = 4 x ('.navbar', style.css)
find_code 'navbar': classes_by_name = ('navbar', index.html)
find_code 'main': variables_by_name = ('main', index.html)
The node and dead-code sets are identical on Kùzu and LadybugDB; only the order of the two tied rows on line 4 differs. Of the 10 Function nodes, 9 are selectors. .navbar gets 4 Function nodes, two of them on line 4 because it appears twice in one selector list. The only real function, render, is the one that isn't dead code. Every reported dead-code row is a false positive.
Impact
Measured on real repositories:
- A mid-size web application: 727 selector
Function nodes. Every one is a find_dead_code row and find_code noise.
- A large Odoo 19 codebase (core + addons): 6,812 selector
Function nodes and 486 HTML-derived Class/Variable nodes.
Every selector Function is a false find_dead_code row and an extra find_code hit. find_code for .x returns one "function" per occurrence of the selector, and a bare x also gets one content match per occurrence. Tools that list Class nodes per file report HTML attribute values as classes.
Proposed fix
Treat selectors as facts about the file, not as graph symbols:
css.py emits no functions. It collects class and id selector names into two File list properties, for example css_classes and css_ids. Each list is deduplicated, sorted, and null when empty, so a rewritten file drops a stale list. CSS identifier escapes are unescaped (.sm\:flex becomes sm:flex). Tag selectors (body, a, p) are dropped. CSS custom properties (--brand) stay Variable nodes, because they are declarations with values that var() and scripts read.
html.py writes class / id attribute values to the same two File lists instead of creating Class/Variable nodes.
- The embedded DDL gains
css_classes STRING[] and css_ids STRING[] on File, with an ALTER for existing databases. Neo4j needs no index for them.
find_code treats a file as an exact match when the query names one of its classes or ids (.x for a class, #x for an id, or bare x for either), so a lookup like "where is .navbar styled" still works without selector nodes.
- Existing Neo4j graphs keep the old selector nodes until a re-index. An upgrade note with a one-off cleanup query would cover that.
This is a data-model change: selector names stop being nodes. That's why this is an issue rather than a PR. A downstream fork implements exactly this model, with tests and Kùzu/LadybugDB bundle round-trip parity. Its implementation depends on that fork's own schema and search infrastructure, though, so it can't be submitted to upstream as-is.
Related
- fix(css): extract quoted and unquoted url imports #1806 (open,
fix(css): url imports) and fix(html): index unquoted attributes and self-closing tags #1807 (open, fix(html): unquoted attributes / self-closing tags) edit the same two parsers. Their new audit tests lock in the current model (('has', 'functions', '.one'), ('has', 'classes', 'one'), ('has', 'variables', 'main')), so either change will need to adjust the other's assertions.
- CSS parser: url() imports are omitted #1801 (CSS
url() imports omitted) and HTML parser: unquoted attributes and self-closing tags are omitted #1800 (HTML unquoted attributes / self-closing tags omitted) are the issues behind those PRs. They are related only: neither addresses selectors stored as Function nodes.