"""Json_config extractor. Moved verbatim from graphify/extract.py.""" from __future__ import annotations from pathlib import Path from graphify.extractors.base import _file_stem, _make_id, _read_text _CONFIG_JSON_NAMES = frozenset({ "package.json", "tsconfig.json", "jsconfig.json", "composer.json", "deno.json", "deno.jsonc", "bower.json", "manifest.json", "app.json", "now.json", "vercel.json", "angular.json", "nest-cli.json", "biome.json", "biome.jsonc", "renovate.json", ".babelrc", ".babelrc.json", ".eslintrc.json", ".prettierrc.json", ".prettierrc", "babel.config.json", }) _CONFIG_JSON_KEYS = frozenset({ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "bundleDependencies", "bundledDependencies", "extends", "$ref", "$schema", "compilerOptions", }) def _is_config_json(path: Path, obj_node, source: bytes) -> bool: """True if a .json file is a recognized config/manifest worth AST-extracting. Matches by filename first (cheap), then falls back to a top-level key probe so arbitrarily-named config files (e.g. ``api.tsconfig.json``, ``foo.eslintrc.json``) are still picked up. Returns False for data JSON so it is skipped by the structural pass (#1224).""" name = path.name.casefold() if name in _CONFIG_JSON_NAMES: return True # Common compound config names: *.eslintrc.json, *.prettierrc.json, etc. if name.endswith((".eslintrc.json", ".prettierrc.json", ".babelrc.json", "tsconfig.json", "jsconfig.json")): return True # Top-level key probe: scan the root object's immediate keys (no deep walk). for top_key in obj_node.children: if top_key.type != "pair": continue key_node = top_key.child_by_field_name("key") if key_node is None: continue kc = key_node.child_by_field_name("string_content") text = _read_text(kc, source) if kc else _read_text(key_node, source).strip('"\'') if text in _CONFIG_JSON_KEYS: return True return False def extract_json(path: Path) -> dict: """Extract structure and dependency edges from a *config/manifest* .json file. Data-shaped JSON (eval fixtures, datasets, GeoJSON, API response dumps) is deliberately skipped — AST-walking it produced hundreds of orphan key-nodes and duplicate communities that swamped real structure (#1224). Recognition is by filename (package.json, tsconfig.json, …) or a top-level key probe (dependencies / extends / $ref / $schema / compilerOptions).""" _JSON_MAX_BYTES = 1_048_576 # 1 MiB — skip large fixture dumps / GeoJSON blobs try: import tree_sitter_json as tsjson from tree_sitter import Language, Parser except ImportError: return {"nodes": [], "edges": [], "error": "tree-sitter-json not installed"} try: # 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) tree = parser.parse(source) root = tree.root_node except Exception as e: return {"nodes": [], "edges": [], "error": str(e)} stem = _file_stem(path) str_path = str(path) nodes: list[dict] = [] edges: list[dict] = [] seen_ids: set[str] = set() # Keys whose string values become imports (package.json dep blocks) _DEP_KEYS = frozenset({ "dependencies", "devDependencies", "peerDependencies", "optionalDependencies", "bundleDependencies", "bundledDependencies", }) def add_node(nid: str, label: str, line: int, file_type: str = "code") -> None: if nid and nid not in seen_ids: seen_ids.add(nid) nodes.append({"id": nid, "label": label, "file_type": file_type, "source_file": str_path, "source_location": f"L{line}"}) def add_edge(src: str, tgt: str, relation: str, line: int, context: str | None = None) -> None: if not src or not tgt or src == tgt: return edge = {"source": src, "target": tgt, "relation": relation, "confidence": "EXTRACTED", "source_file": str_path, "source_location": f"L{line}", "weight": 1.0} if context: edge["context"] = context edges.append(edge) file_nid = _make_id(str(path)) add_node(file_nid, path.name, 1) def _key_text(pair_node) -> str | None: """Extract the string content of a pair's key.""" key_node = pair_node.child_by_field_name("key") if key_node is None: return None if key_node.type == "string": content = key_node.child_by_field_name("string_content") if content: return _read_text(content, source) # fallback: strip surrounding quotes raw = _read_text(key_node, source) return raw.strip('"\'') return _read_text(key_node, source) def _val_node(pair_node): return pair_node.child_by_field_name("value") def walk_object(obj_node, parent_nid: str, parent_key: str | None, depth: int, pair_count: list) -> None: 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: continue key_nid = _make_id(stem, *(([parent_key] if parent_key else []) + [key])) if not key_nid: continue line = child.start_point[0] + 1 add_node(key_nid, key, line) add_edge(parent_nid, key_nid, "contains", line) val = _val_node(child) if val is None: continue if val.type == "object": walk_object(val, key_nid, key, depth + 1, pair_count) elif val.type == "array": # 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) if ref_nid: add_node(ref_nid, ref, line, file_type="concept") add_edge(key_nid, ref_nid, "extends", line, context="import") elif val.type == "string": content = val.child_by_field_name("string_content") val_text = _read_text(content, source) if content else _read_text(val, source).strip('"\'') if key == "extends" and 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_node(ref_nid, val_text, line, file_type="concept") add_edge(file_nid, ref_nid, "extends", line, context="import") elif key == "$ref" and 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) elif parent_key in _DEP_KEYS and val_text: dep_nid = _make_id(key) if dep_nid: add_node(dep_nid, key, line, file_type="concept") add_edge(key_nid, dep_nid, "imports", line, context="import") # Entry: find root document → object doc = root if doc.type == "document" and doc.child_count > 0: doc = doc.children[0] if doc.type == "object": # Only AST-extract recognized config/manifest JSON. Data JSON (fixtures, # datasets, GeoJSON, API dumps) is skipped so it doesn't explode into # orphan key-nodes (#1224); it's left to the LLM semantic pass. if not _is_config_json(path, doc, source): return {"nodes": [], "edges": [], "skipped": "data json (not a config/manifest)"} walk_object(doc, file_nid, None, 0, [0]) else: # Top-level array or scalar => data JSON, never a config/manifest. return {"nodes": [], "edges": [], "skipped": "data json (non-object root)"} return {"nodes": nodes, "edges": edges}