_is_config_json() treats $schema / $ref as config signals, but they are the defining markers of a JSON Schema — so schema/contract files get the full #1224 keyword-tree explosion.
Version: graphify 0.9.27 (Python 3.12, Windows). Code path is byte-identical on main today.
Symptom
A single JSON-Schema contract file (contracts/events-v1.json, 160 KB) contributed 362 nodes whose labels are bare schema keywords — description ×113, type ×83, $ref ×53, required ×20, properties ×20, additionalProperties ×20, enum ×18, oneOf, $comment, format, pattern, items, title, $id, minimum — plus 726 incident edges, every one of them _origin: "ast".
Because the same keyword recurs once per definition, Louvain then produces ~12 communities whose entire membership is schema structure (description ~ $ref ~ type), which pollutes clustering, the god-node analysis and GRAPH_REPORT.md. On a 2,725-node graph that was 13% of all nodes.
This is the failure mode #1224 fixed for data JSON, leaking back through the top-level key probe.
Root cause
graphify/extractors/json_config.py:
_CONFIG_JSON_KEYS = frozenset({
..., "extends", "$ref", "$schema", "compilerOptions",
})
_is_config_json() returns True if any top-level key is in that set, so any JSON Schema — which by definition starts with "$schema": "https://json-schema.org/..." — is classified as a config manifest and AST-walked in full.
The signal is inverted: $schema as a key appears both in configs that point at a schema (biome.json, renovate.json) and in files that are a schema. A root-level $ref is a schema construct only.
Repro
repro.json (12 lines):
{
"$schema": "https://json-schema.org/draft/2020-12/schema",
"$id": "https://example.com/events-v1.json",
"title": "Event catalogue",
"$defs": {
"dataClassification": {
"description": "Handling class for the event payload.",
"type": "string",
"enum": ["Public", "Internal", "CUI"]
}
}
}
from graphify.extractors.json_config import extract_json
from pathlib import Path
r = extract_json(Path('repro.json'))
print(r.get('skipped'))
print([n['label'] for n in r['nodes']])
Actual:
skipped: None
nodes: ['repro.json', '$schema', '$id', 'title', '$defs', 'dataClassification',
'description', 'type', 'enum', 'Public', 'Internal', 'CUI']
edges: repro_json contains repro_schema | repro_json contains repro_id | ...
repro_defs_dataclassification contains repro_dataclassification_description
repro_dataclassification_enum extends ref_public | ... ref_internal | ... ref_cui
Expected: either skipped: "json schema (not a config/manifest)", or — better — dataClassification --contains--> Public/Internal/CUI with no keyword nodes at all.
Suggested fix
Distinguish "config that references a schema" from "file that is a schema", before the key probe:
- Drop
$ref from _CONFIG_JSON_KEYS — a root-level $ref is never a config signal.
- Keep
$schema as a config signal only when the root does not also look like a schema: root has $defs / definitions, or the $schema value points at json-schema.org. Configs such as biome.json and renovate.json carry a vendor schema URL and no $defs, so they keep matching.
Worth considering beyond the classification gate: JSON Schema is genuinely interesting to graph, just not keyword-first. Walking it semantically — definition contains its fields, enum name contains its values, skipping the keyword level entirely — turns the same file into real domain structure (envelope contains event_id, dataClassification contains CUI).
Workaround for anyone hitting this
Contract the keyword nodes rather than deleting them, or the fields underneath go with them. On the graph above, a plain delete orphaned 273 substantive nodes (event_id, tenant_id, payload_material_issued, CUI, …) and split it from 99 to 374 components. Rebuilding a DiGraph from graph.json links (which preserve parent→child order even though the graph is undirected), reconnecting parent→child across each keyword node and then removing it, gave 2,725 → 2,363 nodes, 4,019 → 3,861 edges, 276 → 247 communities, zero newly-orphaned nodes.
.graphifyignore is not a workaround here: it would also drop the file's semantic extraction, which is the valuable part.
_is_config_json()treats$schema/$refas config signals, but they are the defining markers of a JSON Schema — so schema/contract files get the full #1224 keyword-tree explosion.Version: graphify 0.9.27 (Python 3.12, Windows). Code path is byte-identical on
maintoday.Symptom
A single JSON-Schema contract file (
contracts/events-v1.json, 160 KB) contributed 362 nodes whose labels are bare schema keywords —description×113,type×83,$ref×53,required×20,properties×20,additionalProperties×20,enum×18,oneOf,$comment,format,pattern,items,title,$id,minimum— plus 726 incident edges, every one of them_origin: "ast".Because the same keyword recurs once per definition, Louvain then produces ~12 communities whose entire membership is schema structure (
description ~ $ref ~ type), which pollutes clustering, the god-node analysis andGRAPH_REPORT.md. On a 2,725-node graph that was 13% of all nodes.This is the failure mode #1224 fixed for data JSON, leaking back through the top-level key probe.
Root cause
graphify/extractors/json_config.py:_is_config_json()returnsTrueif any top-level key is in that set, so any JSON Schema — which by definition starts with"$schema": "https://json-schema.org/..."— is classified as a config manifest and AST-walked in full.The signal is inverted:
$schemaas a key appears both in configs that point at a schema (biome.json,renovate.json) and in files that are a schema. A root-level$refis a schema construct only.Repro
repro.json(12 lines):{ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://example.com/events-v1.json", "title": "Event catalogue", "$defs": { "dataClassification": { "description": "Handling class for the event payload.", "type": "string", "enum": ["Public", "Internal", "CUI"] } } }Actual:
Expected: either
skipped: "json schema (not a config/manifest)", or — better —dataClassification --contains--> Public/Internal/CUIwith no keyword nodes at all.Suggested fix
Distinguish "config that references a schema" from "file that is a schema", before the key probe:
$reffrom_CONFIG_JSON_KEYS— a root-level$refis never a config signal.$schemaas a config signal only when the root does not also look like a schema: root has$defs/definitions, or the$schemavalue points atjson-schema.org. Configs such asbiome.jsonandrenovate.jsoncarry a vendor schema URL and no$defs, so they keep matching.Worth considering beyond the classification gate: JSON Schema is genuinely interesting to graph, just not keyword-first. Walking it semantically — definition
containsits fields, enum namecontainsits values, skipping the keyword level entirely — turns the same file into real domain structure (envelope contains event_id,dataClassification contains CUI).Workaround for anyone hitting this
Contract the keyword nodes rather than deleting them, or the fields underneath go with them. On the graph above, a plain delete orphaned 273 substantive nodes (
event_id,tenant_id,payload_material_issued,CUI, …) and split it from 99 to 374 components. Rebuilding aDiGraphfromgraph.jsonlinks (which preserve parent→child order even though the graph is undirected), reconnecting parent→child across each keyword node and then removing it, gave 2,725 → 2,363 nodes, 4,019 → 3,861 edges, 276 → 247 communities, zero newly-orphaned nodes..graphifyignoreis not a workaround here: it would also drop the file's semantic extraction, which is the valuable part.