Skip to content

fix(json_config): link a package.json dependency's manifest entry to its ref node (#4569) - #4572

Open
Deepjyoti-Sarmah wants to merge 2 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4569-dep-key-ref-link
Open

Deepjyoti-Sarmah wants to merge 2 commits into
Graphify-Labs:v8from
Deepjyoti-Sarmah:fix/4569-dep-key-ref-link

Conversation

@Deepjyoti-Sarmah

Copy link
Copy Markdown
Contributor

What does this PR do?

Fixes #4569. Each package.json dependency produced two nodes labelled react with no edge between them:

  • the manifest entry <stem>_dependencies_react (type code, carries the version-range line), reached through dependencies --contains-->;
  • the ref node ref_react (type concept), reached through package.json --imports-->. Code imports (import React from "react") resolve here.

5711e9d (#1764 follow-up) moved the imports edge from key_nid to file_nid so it would stop reading as react --imports--> react. That edge was the only link between the two nodes. On v8 the manifest entry is 4 hops from the code that imports the package, the two land in different communities, explain react exits 3 ("Ambiguous: 'react' matches 2 nodes in package.json"), and affected react prints "No unique node match".

The manifest entry now has a declares edge to the ref node and is labelled name@range:

 elif parent_key in _DEP_KEYS and val_text.strip():
     dep_nid = _make_id("ref", key)
     add_node(dep_nid, key, line, file_type="concept")
     add_edge(file_nid, dep_nid, "imports", line, context="import")
+    add_edge(key_nid, dep_nid, "declares", line)
+    version = one line of val_text, capped at 40 chars ("...")
+    relabel key_nid to f"{key}@{version}"   # only if its source_location is this line

The ref node keeps the bare name that imports resolve to, so the two nodes no longer share a label: no react -> react edge, and react resolves to one node. A blank range (" ") now behaves like "": bare label, no ref, no edge. _AST_CACHE_SCHEMA goes from 17 to 18.

The relabel checks source_location because a key node id carries only the immediate parent key. In a v2 lockfile, dependencies.semver under two node_modules/* entries mints one node; add_node keeps the first, so relabelling every pair would put the later range on the first line (4 of 44 relabelled nodes on a 340-package lockfile).

Before (the issue's package.json):

dependencies --contains--> react [code]
package.json --contains--> dependencies
package.json --contains--> name
package.json --imports--> react [concept]

After:

dependencies --contains--> react@^19.0.0 [code]
package.json --contains--> dependencies
package.json --contains--> name
package.json --imports--> react [concept]
react@^19.0.0 --declares--> react [concept]

I chose this after writing tests for the wanted behaviour and running four candidates against them:

A: declares edge A2: references edge B: no key node C: edge + name@range (this PR)
entry connected to importing code yes yes yes yes
no edge between same-label nodes (#1764) no no yes yes
explain / affected resolve react no no yes yes
unimported devDependency not flagged isolated yes yes no yes
existing tests pass yes yes no (test_extract_json_nested_contains) yes

A2 also makes affected list the manifest line as a consumer, since references is in DEFAULT_AFFECTED_RELATIONS. declares is not, and nothing in graphify whitelists relation names.

Type of change

  • Bug fix
  • New feature
  • Documentation
  • Tests or CI
  • Refactor
  • Security fix

Verification & Invariants

A package.json dependency's manifest entry is connected to the code that imports the package, and no edge joins two nodes with the same label.

  • Read the CONTRIBUTING.md guide.
  • Reproduced the issue and identified the invariant.
  • Made the smallest fix necessary.
  • Added a regression test (if bug fix) or isolated boundary test.
  • Kept the PR description synchronized with the final implementation.
  • Documented any limitations / unsupported cases explicitly.

Merge Danger

Door: two-way. One edge and one relabel in the _DEP_KEYS branch, plus a cache schema bump. No id changes. Reverting restores v8 output after one re-extract.

Blast Radius: moderate. Every manifest entry under a dependency block changes label (react becomes react@^19.0.0), and anything keyed on that label sees the new text. The ref node keeps react, so searching for the package still finds it. Bumping _AST_CACHE_SCHEMA invalidates the whole AST cache, so the first forced run after upgrading re-extracts every code file once.

Limitations

  • A v2 package-lock.json already takes this path on v8 (its top-level dependencies key makes it config JSON). Its nested entries get the new label and edge too: 99 declares edges on a 340-package lockfile. v3 lockfiles are skipped as data JSON. I did not change _is_config_json.
  • npm reads "" and " " as *. graphify has never linked "", unchanged here; " " used to mint ref_react and an imports edge and now matches "".
  • A small community named after its hub can now carry a range, e.g. left-pad@workspace:*.
  • Obsidian note names drop the caret (react@19.0.0.md); the existing numeric-suffix dedup handles collisions.
  • With two package.json files, each manifest's ref id is file-qualified (a_package_json_ref_react) while code imports a separate ref_react, so in a monorepo the manifest and the code still don't connect. Separate bug, recorded as a strict xfail.
  • The issue says the @types/* key nodes show as isolated. On v8 they are already exempt (Weakly-connected / "Knowledge Gaps" signal doesn't filter AST-origin declarations without a () suffix — false positives dominate #4205); the flagged node is the ref node, which this PR now connects.

How was this tested?

New tests in tests/test_extract.py, next to the existing json_config tests. The build-level tests run extract + build_from_json on a package.json and a .tsx that imports react. The manifest entry is found by structure (contains from the dependency block), not by label, so a relabel can't pass them on its own.

Test v8 This PR
entry within 2 hops of the importing code fail: 4 hops pass
every dependency block (×4) fail pass
stale cache entry not served (fails with schema 17) fail pass
one node labelled with the bare name fail: 2 pass
explain / affected resolve the package name fail: ambiguous pass
unimported devDependency not reported isolated fail pass
label carries a one-line capped range (×8) fail pass
blank range: bare name, no ref, no edge fail pass
key shared by two sibling blocks labelled with the range on its own line fail pass
no same-label imports, no self-loop pass pass
no edge of any relation between same-label nodes pass pass
utils dependency stays a namespaced ref_ pass pass
declaring line recorded pass pass
two manifests keep their own lines pass pass
non-dependency keys unchanged pass pass
ref node keeps the bare name pass pass
empty range unchanged pass pass
non-string values unchanged pass pass
two-manifest corpus reaches its entry xfail xfail (strict)

The ones that pass on v8 guard #1764, J-4 and existing output.

pytest tests/test_extract.py -q (new tests, on v8 86c57be8)   -> 19 failed, 9 passed, 1 xfailed (new tests only)
pytest tests/test_extract.py -q (this branch)                 -> 371 passed, 1 xfailed
pytest -q (full suite, --basetemp=/var/tmp)                   -> 8420 passed, 14 skipped, 1 xfailed
ruff check graphify/extractors/json_config.py graphify/cache.py tests/test_extract.py -> All checks passed

Graphify-specific checklist

  • I updated generated skill artifacts (uv run python -m tools.skillgen --bless) when changing their source fragments. (N/A: no skill fragments touched.)
  • I confirmed that AST/structural extraction remains deterministic (no ambient state dependencies like ENV variables).
  • I reviewed changes for security implications (no unsafe interpolation into shell/Python).
  • I confirmed no API keys or local-only graph data are included.
  • (If applicable) I disclosed AI authorship in my commit messages.

…its ref node (Graphify-Labs#4569)

Each dependency produced two nodes with the same label: the manifest
entry (reached through `dependencies --contains-->`) and the `ref_*`
node that code imports resolve to. The fix for Graphify-Labs#1764 moved the `imports`
edge to the manifest, which removed the only link between them, so the
entry and the code that uses the package fell into different
communities, and `explain` and `affected` found two nodes for the name.

The entry now has a `declares` edge to its ref node, and its label is
`name@range` (the range is one line, capped at 40 characters), so the
two nodes no longer share a label and nothing reads as `react -> react`.
The ref node keeps the bare name that imports resolve to. An empty or
blank range links nothing and keeps the bare name. Values that are not
strings are extracted as before. A key shared by two sibling blocks is
relabelled only for the pair on its own line.

The AST cache schema goes from 17 to 18 so cached package.json files
are extracted again.
@github-actions

Copy link
Copy Markdown

Thanks for the pull request, @Deepjyoti-Sarmah. A maintainer will review it soon.

Want to talk it through while it is in review? Come join us on our Discord server. For longer-form discussion there is also GitHub Discussions.

A couple of things that speed up review: make sure the test suite passes on Python 3.10 and 3.13, and that the change keeps extraction deterministic.

@github-actions github-actions Bot added the contributor Pull request from a returning community contributor label Oct 19, 2026

@graphify-labs graphify-labs Bot left a comment •

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Graphify reviewed this change.

No issues found by static checks — no coupling regressions or blocking issues in the code graph. That is not the same as safe to merge: see what was not checked.

Not checked: tests were not run; formal verification proved 0 of 1 changed function(s) (1 sampled, not proven).

Formal verification. PR-changed functions: 1/1 verified (0 proven, 1 may-equivalent, 0 distinguished) · 0 not verified.


Graphify review — findings

Fixes disconnected package.json dependency entries (#4569): each manifest entry now links to its ref node with a declares edge, so the entry and the code that imports the package land in the same community. The entry is relabelled name@range (range collapsed to one line, capped at 40 chars) to avoid a same-label react -> react edge and ambiguous explain/affected lookups, while the ref node keeps the bare name that import "react" resolves to; a blank range links nothing and the entry keeps its bare name. Bumps _AST_CACHE_SCHEMA to 18 so stale cached extractions are not served, and adds tests covering all four dependency blocks, local-module name clashes, and a two-manifest corpus case marked strict xfail whose node lookup passes a path where a label is expected.

No blocking issues surfaced. 4 lower-confidence candidates did not survive cross-model review.

Analysis details — impact, health, verification

Impact & health

Graphify review

Impact — 3021 functions depend on the 684 functions this change touches.

Health — this change adds coupling hotspots:

  • worse: extract() — 976 callers, 59 callees
  • worse: save_cached() — 49 callers, 9 callees
  • worse: extract_json() — 33 callers, 7 callees
  • new: test_a_dev_dependency_nothing_imports_is_not_reported_isolated() — 0 callers, 6 callees

Verification — 3021 functions in the blast radius were not formally verified this run (proofs are advisory here).

Gate & verification

graphify gate

PASS — objectively clean (no health regressions, tests not run — proofs not run this pass (advisory)). Grounded, not self-assessed.

Advisory (not blocking):

  • verification_scope: 1798 function(s) in the blast radius were not formally verified this run

Test selection

Test selection

40 of 432 test file(s) selected (9%) via static blast radius.

  • tests/test_cache.py — impact
  • tests/test_cache_stale_import_target.py — impact
  • tests/test_charmap_encoding.py — impact
  • tests/test_chunking.py — impact
  • tests/test_csharp_partial_classes.py — impact
  • tests/test_definition_file_portability.py — impact
  • tests/test_detect.py — impact
  • tests/test_extract.py — impact, changed-test
  • tests/test_extract_cache_location.py — impact
  • tests/test_extract_cli.py — impact
  • tests/test_grammar_cache_key.py — impact
  • tests/test_ignore_file_encoding.py — impact
  • tests/test_incremental_mtime_collision.py — impact
  • tests/test_java_member_calls.py — impact
  • tests/test_js_member_collisions.py — impact
  • tests/test_json_schema_config.py — impact
  • tests/test_language_override_lifecycle.py — impact
  • tests/test_language_overrides.py — impact
  • tests/test_llm_backends.py — impact
  • tests/test_manifest_ingest.py — impact
  • tests/test_objc_field_table_remap.py — impact
  • tests/test_out_dir_evidence.py — impact
  • tests/test_partial_cache.py — impact
  • tests/test_pipeline.py — impact
  • tests/test_python_inherited_method_calls.py — impact
  • tests/test_python_property_reads.py — impact
  • tests/test_python_pytest_fixture_receivers.py — impact
  • tests/test_rust_self_member_calls.py — impact
  • tests/test_rust_use_trees.py — impact
  • tests/test_semantic_cache_basename_recovery.py — impact
  • tests/test_semantic_cache_out_root.py — impact
  • tests/test_skill_persisted_scan_options.py — impact
  • tests/test_skill_semantic_all_cached.py — impact
  • tests/test_stale_prune.py — impact
  • tests/test_stat_index_husk.py — impact
  • tests/test_stat_index_multi_root.py — impact
  • tests/test_stat_index_portability.py — impact
  • tests/test_watch_manifest_location.py — impact
  • tests/test_word_count_cache.py — impact
  • tests/test_zero_node_no_cache.py — impact

Selection is safe under the controlled-regression assumption; always-run tests + a periodic full run are the backstops. Advisory — it never changes the check verdict.

Formal verification

No difference found (not proven): No behavior difference found in extract\_json (not a proof).

The verifier ran both versions of extract\_json on many inputs and saw identical behavior every time. Strong evidence the change is safe, but evidence, not a proof.

Guarantee: Empirical: differential testing (both versions run on many generated inputs). A divergence on an untested input remains possible, so this is 'no counterexample found', not 'proven equivalent'.

Note: An input the sampler did not try could still differ.

· 1 grounded finding(s) anchored inline below; 3 more finding(s) on lines outside this diff (see the check run).

Comment thread tests/test_extract.py
assert resolve_seed(G, "react") is not None # what `graphify affected react` calls


def test_a_dev_dependency_nothing_imports_is_not_reported_isolated(tmp_path):

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Health regression — test_a_dev_dependency_nothing_imports_is_not_reported_isolated()

fans out to 6 callees (efferent coupling).

Grounded coupling-delta finding (deterministic), not an LLM guess.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contributor Pull request from a returning community contributor

Projects

None yet

Development

Successfully merging this pull request may close these issues.

package.json dependencies produce two unlinked nodes with the same label

1 participant