Skip to content

Commit cb96bda

Browse files
safishamsiclaude
andcommitted
fix: preserve semantic layer, stamp hyperedges, PHP namespaces, ignore diagnostic (#1925 #1920 #1923 #1922)
- #1925: a missing manifest.json no longer degrades `extract --code-only` into a full scan that discards the committed semantic layer. An existing graph.json is a sufficient incremental baseline (detect_incremental treats an absent manifest as "all new / none deleted"), so out-of-scope doc/paper/ image nodes are preserved while genuinely deleted sources still evict. - #1920: _stamped_manifest_files now counts hyperedge output, so a doc whose only chunk output is a hyperedge is stamped instead of re-extracted forever. - #1923: new namespace/use-aware PHP resolver (mirrors the Java resolver, runs before the unique-name rewire) so App\Models\Page and an imported Filament\Pages\Page stay distinct — no more false inherits/imports edge. - #1922: detect() records ignored files/dirs in a new `ignored` diagnostic field (the nested-ignore scoping bug itself shipped in 0.9.16 / #1873). Regression tests added for each; full suite 3325 passed, 3 skipped. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 19c496d commit cb96bda

8 files changed

Lines changed: 645 additions & 8 deletions

File tree

CHANGELOG.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,10 @@ Full release notes with details on each version: [GitHub Releases](https://githu
44

55
## 0.9.17 (unreleased)
66

7+
- Fix: a missing `manifest.json` no longer degrades `graphify extract --code-only` into a full scan that discards the committed semantic layer (#1925). On a fresh clone (or when the manifest is deliberately untracked because its mtimes churn), the incremental gate required both `manifest.json` and `graph.json`; with only the graph present it fell to a full scan, and under `--code-only` that dropped every doc/paper/image node — silently replacing a curated graph with an AST-only skeleton. An existing `graph.json` is now a sufficient incremental baseline: `detect_incremental` already treats an absent manifest as "everything new / nothing deleted", so `build_merge` + `_stale_graph_sources` preserve files that are merely out of this run's scope while still evicting genuinely deleted sources.
8+
- Fix: hyperedge-only documents are now stamped in the manifest instead of being re-extracted on every run (#1920). `_stamped_manifest_files` (#1897) decided a semantic doc "produced output" by inspecting only `nodes` and `edges`, never `hyperedges`, so a chunk whose only output for a doc was a hyperedge (3+ nodes sharing a concept) left that doc unstamped and perpetually re-queued. Stamping now counts hyperedge output too, mirroring the per-`source_file` keying the semantic cache already uses.
9+
- Fix: the PHP extractor now disambiguates same-named classes across namespaces (#1923). A bare class reference (`extends Page`) collapsed onto the only internal class named `Page`, so `App\Models\Page` and an imported `Filament\Pages\Page` fused into one node, manufacturing a false `inherits`/`imports` edge and a bogus cross-community bridge. A new namespace/`use`-aware resolution pass (mirroring the Java resolver, running before the unique-name rewire) re-points supertype/import references to the real definition, or parks provably-external ones on a fully-qualified stub the bare-name rewire cannot collapse. Plain, non-namespaced PHP is unchanged.
10+
- Fix: `detect()` now records files and directories dropped by a `.gitignore`/`.graphifyignore` rule in a new `ignored` diagnostic field (#1922). The nested-ignore scoping bug itself was fixed in 0.9.16 (#1873); this closes the remaining gap where an ignored path left no trace in any diagnostic, so an over-broad rule looked like a clean scan. Entries are per-directory where a subtree is pruned, keeping the list bounded.
711
- Fix: `_semantic_id_remap` is now idempotent, so incremental rebuilds stop churning (#1917). When a file's canonical stem contained its own legacy stem as a prefix (parent dir name equals the file stem, e.g. `.claude/CLAUDE.md`, `docs/docs.md`), an already-migrated semantic node id re-matched the legacy branch and gained another stem segment on every build (`claude_x` -> `claude_claude_x` -> ...). Because `_origin` is persisted, every `graphify update` re-fed nodes through the remap, so the ids grew unboundedly and the `same_topology`/`same_graph`/`no_change` short-circuits never fired — rewriting `graph.json` and re-running clustering on every zero-delta update. The remap now skips an id that already carries its canonical stem (mirroring the `graph_has_legacy_ids` check), while a genuine one-time legacy migration still applies. (An already-corrupted graph needs one `graphify extract --force` to reset the grown ids.)
812
- Perf: `graphify query` now scores the graph once per query instead of T+1 times for a T-term query (#1889 / #1918, thanks @Sirhan1). The per-term-guarantee (#1445) previously re-scored the whole graph once per token; `_score_query` now computes the combined ranking and each token's singleton winner in a single traversal, feeding `_pick_seeds` via `best_seed_by_term`. Behavior is preserved (verified byte-identical against the old per-term scoring across a differential fuzz); ~1.3-1.4x faster and independent of query length.
913

graphify/cli.py

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -65,11 +65,18 @@ def _stamped_manifest_files(
6565
empty so detect_incremental re-queues them (#933).
6666
6767
Both sides of the membership test are resolved against the scan ``root``
68-
before comparing (#1897): node/edge ``source_file`` values are
68+
before comparing (#1897): node/edge/hyperedge ``source_file`` values are
6969
root-relative on a fresh extraction while ``files_by_type`` entries are
7070
absolute (from detect()), so a raw string comparison never matched and
7171
every freshly-extracted semantic doc was dropped from the manifest.
7272
Mirrors the #1890 path normalization in graphify.llm.
73+
74+
Hyperedges are counted as output (#1920): a chunk whose only result for a
75+
document is a hyperedge (3+ nodes sharing a concept) is valid output that
76+
the semantic cache persists per-``source_file`` — omitting it here left the
77+
doc unstamped, so detect_incremental re-queued it on every run. The stamping
78+
condition mirrors the cache-write keying (a hyperedge carries its own
79+
``source_file``); do not derive it from member nodes.
7380
"""
7481
root = Path(root)
7582

@@ -83,7 +90,7 @@ def _resolve(value: str) -> Path:
8390
return p
8491

8592
sem_extracted: set[Path] = set()
86-
for coll in ("nodes", "edges"):
93+
for coll in ("nodes", "edges", "hyperedges"):
8794
for item in sem_result.get(coll, []):
8895
sf = item.get("source_file", "")
8996
if sf:
@@ -2281,12 +2288,26 @@ def _parse_float(name: str, raw: str) -> float:
22812288
)
22822289
manifest_path = graphify_out / "manifest.json"
22832290
existing_graph_path = graphify_out / "graph.json"
2284-
incremental_mode = manifest_path.exists() and existing_graph_path.exists() if has_path else False
2291+
# #1925: a missing manifest.json must not degrade to a full scan that
2292+
# discards the existing graph's semantic layer. An existing graph.json
2293+
# is a sufficient incremental baseline: detect_incremental treats an
2294+
# absent manifest as "everything is new" (re-extract all, nothing
2295+
# deleted), and build_merge + _stale_graph_sources reconcile replaced
2296+
# and genuinely-deleted sources against the current corpus, so doc/
2297+
# paper/image nodes survive a --code-only rebuild instead of being
2298+
# dropped with the rest of the committed graph.
2299+
incremental_mode = existing_graph_path.exists() if has_path else False
22852300
# --force: full scan, not the manifest-gated incremental diff — a warm
22862301
# unchanged tree would otherwise dispatch zero files (#1894).
22872302
incremental_mode = incremental_mode and not force
22882303
if force:
22892304
print("[graphify extract] --force: full re-scan, semantic cache reads skipped")
2305+
elif incremental_mode and not manifest_path.exists():
2306+
print(
2307+
"[graphify extract] manifest.json missing; using existing "
2308+
"graph.json as the incremental baseline (all files re-checked; "
2309+
"nodes for files outside this run's scope are preserved)"
2310+
)
22902311

22912312
if not has_path:
22922313
code_files = []

graphify/detect.py

Lines changed: 16 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1151,6 +1151,11 @@ def _wc(path: Path) -> int:
11511151

11521152
skipped_sensitive: list[str] = []
11531153
unclassified: list[str] = []
1154+
# Files/dirs dropped by a .gitignore/.graphifyignore rule. Recorded so an
1155+
# over-broad ignore (or a legitimately-ignored subtree) is visible instead
1156+
# of silently vanishing from the graph (#1922). Directory-level entries keep
1157+
# this bounded — a pruned `data/` is one entry, not one per contained file.
1158+
ignored: list[str] = []
11541159
ignore_patterns = _load_graphifyignore(root)
11551160
ignore_cache: dict[Path, bool] = {} # shared across all _is_ignored calls in this scan
11561161
# CLI --exclude patterns are anchored at the scan root and appended last
@@ -1222,11 +1227,15 @@ def _on_walk_error(err: OSError) -> None:
12221227
# any `!` rule existed — e.g. a single `!docs/**` made the walk descend
12231228
# bin/, obj/, wwwroot/, generated/, … : a pathological slowdown on large
12241229
# repos for no correctness gain.
1225-
dirnames[:] = [
1226-
d for d in dirnames
1227-
if not _is_noise_dir(d, dp)
1228-
and not _is_ignored(dp / d, root, ignore_patterns, _cache=ignore_cache)
1229-
]
1230+
kept_dirs: list[str] = []
1231+
for d in dirnames:
1232+
if _is_noise_dir(d, dp):
1233+
continue
1234+
if _is_ignored(dp / d, root, ignore_patterns, _cache=ignore_cache):
1235+
ignored.append(str(dp / d) + os.sep)
1236+
continue
1237+
kept_dirs.append(d)
1238+
dirnames[:] = kept_dirs
12301239
if follow_symlinks:
12311240
safe_dirs: list[str] = []
12321241
for d in dirnames:
@@ -1256,6 +1265,7 @@ def _on_walk_error(err: OSError) -> None:
12561265
if str(p).startswith(str(converted_dir)):
12571266
continue
12581267
if not in_memory and _is_ignored(p, root, ignore_patterns, _cache=ignore_cache):
1268+
ignored.append(str(p))
12591269
continue
12601270
if not _resolves_under_root(p, root):
12611271
skipped_sensitive.append(str(p) + " [symlink target outside scan root]")
@@ -1338,6 +1348,7 @@ def _on_walk_error(err: OSError) -> None:
13381348
"skipped_sensitive": skipped_sensitive,
13391349
"unclassified": sorted(unclassified),
13401350
"walk_errors": walk_errors,
1351+
"ignored": sorted(ignored),
13411352
"graphifyignore_patterns": len(ignore_patterns),
13421353
"scan_root": str(root.resolve()),
13431354
}

graphify/extract.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@
113113
_resolve_cross_file_java_imports,
114114
_resolve_export_target,
115115
_resolve_java_type_references,
116+
_resolve_php_type_references,
116117
_resolve_js_import_path,
117118
_resolve_js_import_target,
118119
_resolve_js_module_path,
@@ -4581,6 +4582,22 @@ def extract(
45814582
_merge_swift_extensions(per_file, all_nodes, all_edges)
45824583
_disambiguate_colliding_node_ids(all_nodes, all_edges, all_raw_calls, root)
45834584
_canonicalize_csharp_namespace_nodes(all_nodes, all_edges)
4585+
# PHP namespace/use disambiguation must run BEFORE the unique-stub rewire:
4586+
# the false merge (#1923) happens inside the rewire when a bare-name stub
4587+
# matches a unique internal class from a different namespace.
4588+
_php_exts = {".php", ".phtml", ".php3", ".php4", ".php5", ".php7", ".phps"}
4589+
_php_sel = [
4590+
(r, p) for r, p in zip(per_file, paths)
4591+
if p.suffix.lower() in _php_exts and not p.name.lower().endswith(".blade.php")
4592+
]
4593+
if _php_sel:
4594+
try:
4595+
_resolve_php_type_references(
4596+
[r for r, _ in _php_sel], [p for _, p in _php_sel], all_nodes, all_edges
4597+
)
4598+
except Exception as exc:
4599+
import logging
4600+
logging.getLogger(__name__).warning("PHP type-reference resolution failed, skipping: %s", exc)
45844601
_rewire_unique_stub_nodes(all_nodes, all_edges)
45854602

45864603
# Add cross-file class-level edges (Python only - uses Python parser internally)

0 commit comments

Comments
 (0)