210 lines
9.1 KiB
Python
210 lines
9.1 KiB
Python
"""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}
|