Skip to content

json_config: $schema/$ref in _CONFIG_JSON_KEYS makes every JSON-Schema file explode into keyword nodes (#1224 regression path) #2255

Description

@harshslr

_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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions