mirror of
https://github.com/safishamsi/graphify.git
synced 2026-08-26 16:26:42 +00:00
_parse_js_tree parsed .tsx with language_typescript(), so JSX misparsed and error-recovery floated nested handlers to top level; the symbol-resolution pass then emitted calls edges whose SOURCE was an absolute-stem id for a caller that owns no node — a leak the 0.9.29 backstop (learns only from nodes) can't see. Fix: use language_tsx() for .tsx; never emit a calls use-edge from an unowned source (reattribute to the file node); and teach the backstop stem-form prefixes so any node-less absolute-derived endpoint canonicalizes. No node id or edge endpoint now carries the scan-root slug. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
207 lines
8.6 KiB
Python
207 lines
8.6 KiB
Python
"""Node-id / edge-endpoint canonicalization: no absolute-path (machine/temp
|
|
slug) leaks (#2231, #2243).
|
|
|
|
Every producer that mints ids from ``_make_id(str(absolute_path))`` must end
|
|
up canonicalized by the time ``extract()`` returns: after extraction, no node
|
|
id and no edge endpoint may contain the absolute scan-root slug for any file
|
|
that appears in the batch — on full scans AND on incremental (changed-files
|
|
only) runs.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import os
|
|
from pathlib import Path
|
|
|
|
from graphify.extract import extract, _make_id
|
|
|
|
|
|
def _real(tmp_path: Path) -> Path:
|
|
# macOS pytest tmp dirs live under a /var -> /private/var symlink; resolve
|
|
# so "absolute input path" and "resolved path" agree deterministically.
|
|
return Path(os.path.realpath(tmp_path))
|
|
|
|
|
|
def _slug(root: Path) -> str:
|
|
# The tmp-root directory name is unique per test run; an id or endpoint
|
|
# containing it can only have been minted from the absolute path.
|
|
return _make_id(root.name)
|
|
|
|
|
|
def _assert_no_slug(result: dict, slug: str) -> None:
|
|
leaked_nodes = [n["id"] for n in result["nodes"] if slug in n["id"].lower()]
|
|
assert not leaked_nodes, f"absolute-slug node ids leaked: {leaked_nodes}"
|
|
leaked_edges = [
|
|
(e["source"], e["target"], e["relation"])
|
|
for e in result["edges"]
|
|
if slug in e["source"].lower() or slug in e["target"].lower()
|
|
]
|
|
assert not leaked_edges, f"absolute-slug edge endpoints leaked: {leaked_edges}"
|
|
|
|
|
|
def test_module_level_dispatch_indirect_call_source_is_canonical(tmp_path):
|
|
"""#2231: a module-TOP-LEVEL dispatch table (`HANDLERS = {'a': handle_a}`)
|
|
records the FILE node as the raw_call caller. The emitted indirect_call
|
|
edge's source must be the canonical file node id, not the id minted from
|
|
the absolute input path."""
|
|
root = _real(tmp_path)
|
|
(root / "handlers.py").write_text("def handle_a():\n return 1\n")
|
|
(root / "disp.py").write_text(
|
|
"from handlers import handle_a\n\nHANDLERS = {'a': handle_a}\n"
|
|
)
|
|
|
|
result = extract(
|
|
[root / "disp.py", root / "handlers.py"], cache_root=tmp_path, root=root
|
|
)
|
|
|
|
indirect = [e for e in result["edges"] if e["relation"] == "indirect_call"]
|
|
assert indirect, "module-level dispatch must emit an indirect_call edge"
|
|
node_ids = {n["id"] for n in result["nodes"]}
|
|
for e in indirect:
|
|
assert e["source"] == "disp", (
|
|
f"indirect_call source must be the canonical file node id, got {e['source']}"
|
|
)
|
|
assert e["source"] in node_ids, "indirect_call source must be a real node"
|
|
assert e["target"] == "handlers_handle_a"
|
|
_assert_no_slug(result, _slug(root))
|
|
|
|
|
|
def test_bash_source_incremental_target_canonicalizes(tmp_path):
|
|
"""#2243 (bash): `source ./b.sh` mints the target from the resolved
|
|
absolute path. On an incremental run (only the sourcing script in the
|
|
batch) the target_file stamp must still canonicalize it to the same id the
|
|
full scan produces, with no tmp-root slug."""
|
|
root = _real(tmp_path)
|
|
(root / "a.sh").write_text("#!/bin/bash\nsource ./b.sh\nbash ./c.sh\n")
|
|
(root / "b.sh").write_text("#!/bin/bash\nhello() { echo hi; }\n")
|
|
(root / "c.sh").write_text("#!/bin/bash\necho run\n")
|
|
|
|
full = extract(
|
|
[root / "a.sh", root / "b.sh", root / "c.sh"], cache_root=tmp_path, root=root
|
|
)
|
|
_assert_no_slug(full, _slug(root))
|
|
full_imports = {
|
|
(e["source"], e["target"]) for e in full["edges"]
|
|
if e["relation"] == "imports_from"
|
|
}
|
|
assert ("a", "b") in full_imports
|
|
full_invocations = {
|
|
(e["source"], e["target"]) for e in full["edges"]
|
|
if e["relation"] == "calls" and e.get("context") == "script_invocation"
|
|
}
|
|
assert ("a_sh__entry", "c_sh__entry") in full_invocations
|
|
|
|
# Incremental: only the CHANGED sourcing script is re-extracted; b.sh and
|
|
# c.sh are not in the batch, so their canonical ids must be learned from
|
|
# the target_file stamps.
|
|
incr = extract([root / "a.sh"], cache_root=tmp_path, root=root)
|
|
_assert_no_slug(incr, _slug(root))
|
|
incr_imports = {
|
|
(e["source"], e["target"]) for e in incr["edges"]
|
|
if e["relation"] == "imports_from"
|
|
}
|
|
assert ("a", "b") in incr_imports, (
|
|
f"incremental imports_from target must canonicalize; got {incr_imports}"
|
|
)
|
|
incr_invocations = {
|
|
(e["source"], e["target"]) for e in incr["edges"]
|
|
if e["relation"] == "calls" and e.get("context") == "script_invocation"
|
|
}
|
|
assert ("a_sh__entry", "c_sh__entry") in incr_invocations, (
|
|
"incremental script-invocation __entry endpoint must match the entry "
|
|
f"node id the full scan mints; got {incr_invocations}"
|
|
)
|
|
# The transient stamp must never ship out of extract().
|
|
assert not any("target_file" in e for e in incr["edges"])
|
|
assert not any("target_file" in e for e in full["edges"])
|
|
|
|
|
|
def test_tsx_nested_handler_calls_source_is_canonical(tmp_path):
|
|
"""#2262: a .tsx component with a JSX-returning nested arrow component
|
|
defined BEFORE its handlers used to be parsed with the plain TypeScript
|
|
grammar; tree-sitter's error recovery floated the nested handlers to top
|
|
level, minting `calls` edge SOURCES from the absolute path — ids that own
|
|
no node and that no remap ever learns. Every calls edge source must be a
|
|
real node id and no endpoint may carry the scan-root slug."""
|
|
root = _real(tmp_path)
|
|
(root / "row.tsx").write_text(
|
|
"export const constructRowWithId = (id: string) => {\n"
|
|
" return { id };\n"
|
|
"};\n"
|
|
)
|
|
(root / "panel.tsx").write_text(
|
|
'import { constructRowWithId } from "./row";\n'
|
|
"\n"
|
|
"export const PrepayBalanceContainer = () => {\n"
|
|
" const InvoiceBalanceSubsection = () => {\n"
|
|
' return <section className="invoice">\n'
|
|
" <header>Balance</header>\n"
|
|
' <span data-testid="row">{constructRowWithId("invoice").id}</span>\n'
|
|
" </section>;\n"
|
|
" };\n"
|
|
' const handleApply = () => constructRowWithId("apply");\n'
|
|
" const handleTabClick = (tab: string) => {\n"
|
|
" return constructRowWithId(tab);\n"
|
|
" };\n"
|
|
" return <InvoiceBalanceSubsection />;\n"
|
|
"};\n"
|
|
)
|
|
|
|
result = extract(
|
|
[root / "panel.tsx", root / "row.tsx"], cache_root=tmp_path, root=root
|
|
)
|
|
|
|
# (a) no scan-root slug in any node id or edge endpoint (source AND target).
|
|
_assert_no_slug(result, _slug(root))
|
|
|
|
node_ids = {n["id"] for n in result["nodes"]}
|
|
calls = [
|
|
e for e in result["edges"] if e["relation"] in ("calls", "indirect_call")
|
|
]
|
|
# (b) the call edges into the imported symbol target its canonical id.
|
|
imported_targets = {
|
|
e["target"] for e in calls if e["target"].endswith("constructrowwithid")
|
|
}
|
|
assert imported_targets == {"row_constructrowwithid"}, (
|
|
f"imported-symbol call target must be canonical; got {imported_targets}"
|
|
)
|
|
# (c) every calls edge SOURCE is a real node — a node-less source id can
|
|
# never be canonicalized and leaks the machine slug.
|
|
bad_sources = [
|
|
(e["source"], e["target"]) for e in calls if e["source"] not in node_ids
|
|
]
|
|
assert not bad_sources, f"calls edges with node-less sources: {bad_sources}"
|
|
|
|
|
|
def test_extract_invariant_no_absolute_root_slug_anywhere(tmp_path):
|
|
"""General invariant: extracting a mixed corpus (python module-level
|
|
dispatch + bash source + a normal import) from ABSOLUTE input paths leaves
|
|
no node id and no edge endpoint containing the absolute-root slug."""
|
|
root = _real(tmp_path)
|
|
(root / "handlers.py").write_text("def handle_a():\n return 1\n")
|
|
(root / "disp.py").write_text(
|
|
"from handlers import handle_a\n\nHANDLERS = {'a': handle_a}\n"
|
|
)
|
|
(root / "main.py").write_text("import handlers\n\nhandlers.handle_a()\n")
|
|
(root / "run.sh").write_text("#!/bin/bash\nsource ./lib.sh\n./tool.sh\n")
|
|
(root / "lib.sh").write_text("#!/bin/bash\ngreet() { echo hi; }\n")
|
|
(root / "tool.sh").write_text("#!/bin/bash\necho tool\n")
|
|
|
|
paths = [
|
|
root / "disp.py", root / "handlers.py", root / "main.py",
|
|
root / "run.sh", root / "lib.sh", root / "tool.sh",
|
|
]
|
|
result = extract(paths, cache_root=tmp_path, root=root)
|
|
|
|
slug = _slug(root)
|
|
assert not any(slug in n["id"].lower() for n in result["nodes"]), [
|
|
n["id"] for n in result["nodes"] if slug in n["id"].lower()
|
|
]
|
|
assert not any(
|
|
slug in ep.lower()
|
|
for e in result["edges"]
|
|
for ep in (e["source"], e["target"])
|
|
), [
|
|
(e["source"], e["target"]) for e in result["edges"]
|
|
if slug in e["source"].lower() or slug in e["target"].lower()
|
|
]
|