diff --git a/graphify/extract.py b/graphify/extract.py index 3030c47f..b51c4633 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -1696,10 +1696,24 @@ def _js_extra_walk(node, source: bytes, file_nid: str, stem: str, str_path: str, # CJS require imports — emit edges, do not block other lexical_declaration handling require_found = _require_imports_js(node, source, file_nid, stem, edges, str_path) + # Scope guard (#1077): only emit nodes for module-level declarations. + # Without this, `const x = ...` inside an arrow callback (e.g. inside + # `describe(() => { const set = new Set(...) })`) emits a bare-named + # node, and the same name collides across unrelated files producing + # phantom god-nodes. Bodies of arrow functions are walked separately + # via function_bodies, so we never need to emit nodes for locals here. + parent = node.parent + is_module_level = parent is not None and ( + parent.type == "program" + or (parent.type == "export_statement" + and parent.parent is not None + and parent.parent.type == "program") + ) + # Arrow function declarations and module-level const literals (lexical_declaration only) arrow_found = False const_found = False - if node.type == "lexical_declaration": + if node.type == "lexical_declaration" and is_module_level: for child in node.children: if child.type == "variable_declarator": value = child.child_by_field_name("value") @@ -7819,14 +7833,17 @@ def extract_markdown(path: Path) -> dict: Produces nodes for: - The file itself - Each heading (# / ## / ### etc.) - - Each fenced code block (``` ... ```) Produces edges for: - file --contains--> heading - parent heading --contains--> child heading (nesting by level) - - heading --contains--> code block - heading --references--> other node (when backtick `Name` matches a known pattern) + Fenced code blocks (``` ... ```) are skipped during parsing so their + contents don't get treated as headings, but no node is emitted for + them — they were always orphans (only a single contains edge to the + parent doc) and inflated the disconnected-component count (#1077). + No tree-sitter dependency — pure line-by-line parsing. """ try: @@ -7858,44 +7875,19 @@ def extract_markdown(path: Path) -> dict: # Track heading stack for nesting: [(level, nid), ...] heading_stack: list[tuple[int, str]] = [] in_code_block = False - code_block_lang: str | None = None - code_block_start: int = 0 - code_block_lines: list[str] = [] - code_block_count = 0 lines = source.splitlines() for line_num_0, line_text in enumerate(lines): line_num = line_num_0 + 1 - # Toggle fenced code blocks + # Skip over fenced code blocks so their contents are not parsed as + # headings, but do not emit nodes/edges for them (#1077). stripped = line_text.strip() if stripped.startswith("```"): - if not in_code_block: - in_code_block = True - code_block_lang = stripped[3:].strip().split()[0] if len(stripped) > 3 else None - code_block_start = line_num - code_block_lines = [] - continue - else: - # End of code block — create a node - in_code_block = False - code_block_count += 1 - snippet = "\n".join(code_block_lines[:3]) # first 3 lines as preview - label = f"code:{code_block_lang}" if code_block_lang else f"code:block{code_block_count}" - if snippet: - # Use first meaningful line as label hint - first_line = code_block_lines[0].strip()[:60] if code_block_lines else "" - if first_line: - label = f"{label} ({first_line})" - cb_nid = _make_id(stem, f"codeblock_{code_block_count}") - add_node(cb_nid, label, code_block_start) - # Attach to nearest heading or file - parent = heading_stack[-1][1] if heading_stack else file_nid - add_edge(parent, cb_nid, "contains", code_block_start) - continue + in_code_block = not in_code_block + continue if in_code_block: - code_block_lines.append(line_text) continue # Detect headings: # Heading, ## Heading, etc. diff --git a/tests/test_languages.py b/tests/test_languages.py index 747ed8c0..5cb8502d 100644 --- a/tests/test_languages.py +++ b/tests/test_languages.py @@ -1080,6 +1080,36 @@ def test_ts_static_template_literal_resolved(): f"Static template literal import not resolved: {targets}" +def test_js_local_const_does_not_emit_phantom_node(tmp_path): + """Local const/let/var inside an arrow callback must NOT emit a node (#1077). + + Previously `_js_extra_walk` recursed into arrow_function bodies and + emitted a node for every `const x = ...` inside e.g. `describe(() => {})`, + so bare names like `set`, `sorted` collided across unrelated test files. + """ + src = ( + "describe('suite', () => {\n" + " const inner = new Set([1, 2, 3]);\n" + " let other = [1, 2];\n" + "});\n" + "\n" + "const moduleConst = new Set([4, 5]);\n" + "export const exportedConst = { a: 1 };\n" + ) + f = tmp_path / "scope_guard.js" + f.write_text(src) + r = extract_js(f) + labels = _labels(r) + + # Locals inside the arrow callback must not produce nodes. + assert "inner" not in labels, f"phantom node for arrow-body local 'inner': {labels}" + assert "other" not in labels, f"phantom node for arrow-body local 'other': {labels}" + + # Module-level consts should still produce nodes. + assert "moduleConst" in labels, f"module-level const 'moduleConst' missing: {labels}" + assert "exportedConst" in labels, f"exported const 'exportedConst' missing: {labels}" + + # ── Markdown ───────────────────────────────────────────────────────────────── from graphify.extract import extract_markdown @@ -1102,19 +1132,25 @@ def test_markdown_finds_nested_heading(): labels = _labels(r) assert any("Database Migration" in l for l in labels) -def test_markdown_finds_code_blocks(): +def test_markdown_skips_fenced_code_blocks(): + """Fenced code blocks should NOT emit nodes (#1077). + + They were always orphans (single contains edge to parent doc) and + inflated the disconnected-component count. We still skip over their + *contents* when parsing so the inside of a fence is not misread as a + heading. + """ r = extract_markdown(FIXTURES / "deploy_guide.md") labels = _labels(r) - assert any("code:bash" in l for l in labels) - assert any("code:sql" in l for l in labels) - assert any("code:python" in l for l in labels) + assert not any(l.startswith("code:") for l in labels), \ + f"Expected no code:* nodes after #1077 fix, got: {[l for l in labels if l.startswith('code:')]}" def test_markdown_contains_edges(): - """Headings and code blocks should be connected via 'contains' edges.""" + """Headings should be connected via 'contains' edges (file->h, h->h).""" r = extract_markdown(FIXTURES / "deploy_guide.md") assert "contains" in _relations(r) contains_edges = [e for e in r["edges"] if e["relation"] == "contains"] - assert len(contains_edges) >= 5 # file->h1, h1->h2s, h2->h3, h2->codeblocks + assert len(contains_edges) >= 4 # file->h1, h1->h2s, h2->h3 def test_markdown_no_dangling_edges(): r = extract_markdown(FIXTURES / "deploy_guide.md")