mirror of
https://github.com/safishamsi/graphify.git
synced 2026-09-22 21:45:58 +00:00
fix JS/TS phantom god-nodes from arrow-fn locals and drop markdown code-block orphan nodes (#1077)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
5642c1b533
commit
925eb81ec1
+24
-32
@@ -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.
|
||||
|
||||
+42
-6
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user