diff --git a/graphify/detect.py b/graphify/detect.py index d5811ad5..fabded3c 100644 --- a/graphify/detect.py +++ b/graphify/detect.py @@ -24,7 +24,7 @@ class FileType(str, Enum): _MANIFEST_PATH = "graphify-out/manifest.json" -CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk'} +CODE_EXTENSIONS = {'.py', '.ts', '.js', '.jsx', '.tsx', '.mjs', '.ejs', '.go', '.rs', '.java', '.groovy', '.gradle', '.cpp', '.cc', '.cxx', '.c', '.h', '.hpp', '.rb', '.swift', '.kt', '.kts', '.cs', '.scala', '.php', '.lua', '.luau', '.toc', '.zig', '.ps1', '.ex', '.exs', '.m', '.mm', '.jl', '.vue', '.svelte', '.astro', '.dart', '.v', '.sv', '.sql', '.r', '.f', '.F', '.f90', '.F90', '.f95', '.F95', '.f03', '.F03', '.f08', '.F08', '.pas', '.pp', '.dpr', '.dpk', '.lpr', '.inc', '.dfm', '.lfm', '.lpk', '.sh', '.bash', '.json'} DOC_EXTENSIONS = {'.md', '.mdx', '.qmd', '.txt', '.rst', '.html', '.yaml', '.yml'} PAPER_EXTENSIONS = {'.pdf'} IMAGE_EXTENSIONS = {'.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'} diff --git a/graphify/extract.py b/graphify/extract.py index 61e21714..e5854d49 100644 --- a/graphify/extract.py +++ b/graphify/extract.py @@ -5696,9 +5696,14 @@ def extract_bash(path: Path) -> dict: line = node.start_point[0] + 1 if raw.startswith((".", "/")): resolved = (path.parent / raw).resolve() - tgt_nid = _make_id(str(resolved)) - add_edge(file_nid, tgt_nid, "imports_from", line, - context="import") + # Only emit the edge if the target actually exists on + # disk — prevents graph pollution from crafted paths + # like `source ../../etc/passwd` that traverse outside + # the project tree (B-1). + if resolved.exists(): + tgt_nid = _make_id(str(resolved)) + add_edge(file_nid, tgt_nid, "imports_from", line, + context="import") else: tgt_nid = _make_id(raw) if tgt_nid: @@ -5744,11 +5749,15 @@ def extract_json(path: Path) -> dict: return {"nodes": [], "edges": [], "error": "tree-sitter-json not installed"} try: - if path.stat().st_size > _JSON_MAX_BYTES: + # Bounded read instead of stat()+read() to eliminate TOCTOU (J-1): + # read one byte beyond the limit so we can detect oversized files even + # if the file grows between stat and read. + with path.open("rb") as _f: + source = _f.read(_JSON_MAX_BYTES + 1) + if len(source) > _JSON_MAX_BYTES: return {"nodes": [], "edges": [], "error": "json file too large to index"} language = Language(tsjson.language()) parser = Parser(language) - source = path.read_bytes() tree = parser.parse(source) root = tree.root_node except Exception as e: @@ -5805,11 +5814,13 @@ def extract_json(path: Path) -> dict: def walk_object(obj_node, parent_nid: str, parent_key: str | None, depth: int, pair_count: list) -> None: - if depth > 6 or pair_count[0] > 500: + if depth > 6: return for child in obj_node.children: if child.type != "pair": continue + if pair_count[0] >= 500: # check per-pair so the cap is honoured exactly (J-3) + return pair_count[0] += 1 key = _key_text(child) if not key: @@ -5829,13 +5840,15 @@ def extract_json(path: Path) -> dict: walk_object(val, key_nid, key, depth + 1, pair_count) elif val.type == "array": - # For "extends" arrays (tsconfig, eslint): each string element + # For "extends" arrays (tsconfig, eslint): each string element. + # Prefix with "ref_" so external refs don't collide with real + # code/file node IDs that share the same collapsed _make_id (J-4). for item in val.children: if item.type == "string": content = item.child_by_field_name("string_content") ref = _read_text(content, source) if content else _read_text(item, source).strip('"\'') if ref: - ref_nid = _make_id(ref) + ref_nid = _make_id("ref", ref) if ref_nid: add_edge(key_nid, ref_nid, "extends", line, context="import") @@ -5844,12 +5857,14 @@ def extract_json(path: Path) -> dict: val_text = _read_text(content, source) if content else _read_text(val, source).strip('"\'') if key == "extends" and val_text: - ref_nid = _make_id(val_text) + # Namespace external refs to avoid ID collision with file nodes (J-4) + ref_nid = _make_id("ref", val_text) if ref_nid: add_edge(file_nid, ref_nid, "extends", line, context="import") elif key == "$ref" and val_text: - ref_nid = _make_id(val_text) + # Namespace $ref values to prevent edge hijacking into code nodes (J-4) + ref_nid = _make_id("ref", val_text) if ref_nid: add_edge(parent_nid, ref_nid, "references", line) diff --git a/tests/test_detect.py b/tests/test_detect.py index 740d1e49..1263f66d 100644 --- a/tests/test_detect.py +++ b/tests/test_detect.py @@ -230,7 +230,11 @@ def test_detect_incremental_propagates_follow_symlinks(tmp_path, monkeypatch): (real_dir / "note.md").write_text("# real note\n\nsome content") (tmp_path / "linked_corpus").symlink_to(real_dir) - manifest_path = str(tmp_path / "manifest.json") + # Store manifest inside graphify-out/ so it is pruned by _SKIP_DIRS + # and doesn't get re-detected as a code file now that .json is indexed. + manifest_dir = tmp_path / "graphify-out" + manifest_dir.mkdir() + manifest_path = str(manifest_dir / "manifest.json") # Without following symlinks, the symlinked dir contents are invisible. no_link = detect_incremental(tmp_path, manifest_path, follow_symlinks=False) diff --git a/tests/test_watch.py b/tests/test_watch.py index 72a7f88a..e3d3f665 100644 --- a/tests/test_watch.py +++ b/tests/test_watch.py @@ -47,7 +47,9 @@ def test_watched_extensions_includes_images(): assert ".jpg" in _WATCHED_EXTENSIONS def test_watched_extensions_excludes_noise(): - assert ".json" not in _WATCHED_EXTENSIONS + # .json is now indexed (bash/JSON extractors added in #866) + assert ".json" in _WATCHED_EXTENSIONS + assert ".sh" in _WATCHED_EXTENSIONS assert ".pyc" not in _WATCHED_EXTENSIONS assert ".log" not in _WATCHED_EXTENSIONS