fix(watch): widen semantic-doc gate to the doc-shaped file_type subset (#1954)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Alpha Nury
2026-07-17 10:54:59 +01:00
committed by safishamsi
co-authored by Claude Fable 5
parent 5a480a83a7
commit 5163a6243b
3 changed files with 125 additions and 5 deletions
+1
View File
@@ -9,6 +9,7 @@ Full release notes with details on each version: [GitHub Releases](https://githu
- Fix: truncated LLM chunks are no longer promoted to the semantic cache as complete (#1950, thanks @TPAteeq). A chunk that hit the output-token limit and couldn't be split further was cached and manifest-stamped as authoritative, so its incomplete node set replayed forever. Such chunks are now marked partial (treated as a cache miss and re-dispatched), including the common case where the truncation parses to *nothing*: the give-up sites record the chunk's own files independently of parsed items, so a sliced document whose later slice truncated empty is no longer stamped complete, and a clean slice can't re-promote a partial entry.
- Fix: untrusted subagent chunk JSON is validated before `merge-chunks` merges it (#1953, thanks @TPAteeq). A malformed chunk used to crash the whole merge (aborting good chunks) or silently pass an adversarial node id (path-escape) into the graph. Each chunk is now validated (reusing the #825 fragment validator, size-capped, id-charset checked) and a bad one is skipped with a warning rather than aborting; non-numeric token counts can no longer TypeError the merge either.
- Fix: code-typed semantic nodes with no evidence in the source are flagged (#1949, thanks @TPAteeq). The LLM can surface a `file_type:"code"` node from a document whose symbol name never actually appears in that source (an inferred or hallucinated symbol); it's now flagged `verification: "unverified"` (a dedicated node field, reported by `graphify diagnose`) rather than presented as a read fact. The check verifies against the node's label and id, only touches nodes the model itself presented as solid, and never drops a node.
- Fix: `watch`/`update` no longer re-scans and duplicates concept/rationale-only semantic docs on every rebuild (#1954, thanks @jw3b-dev). The #1915 semantic-doc gate in `_rebuild_code` recognized a doc as having a semantic (LLM) layer only via a `file_type=="document"` node, but the extraction spec's preferred shape for a doc full of named concepts is to represent it with ONLY `concept`/`rationale` nodes and no separate `document` node — such a doc never entered `semantic_doc_identities`, stayed in the AST quick-scan set, and got duplicate heading nodes minted on top of its real semantic nodes on every `graphify update`/`watch`/hook rebuild (the #1915 symptom returning for this doc shape). The gate now recognizes the full doc-shaped subset of the canonical six-value `file_type` enum (`document`, `concept`, `rationale`, `paper` — the same set `build.py` already treats as doc-representation types), while `code`/`image` nodes and AST-origin-marked nodes are still excluded, so the pre-#1865 legacy-graph safeguard the gate relies on is unchanged.
- Fix: the semantic cache no longer replays extractions from an older prompt after an upgrade (#1939, thanks @HunterMcGrew and @SinghAman21). Entries were keyed on `sha256(file content + path)` alone, with no component for the extraction prompt that produced them, so a release that changed the prompt left every unchanged file a cache hit: the run exited 0, `cost.json` looked cheap, and the graph silently carried two prompt generations side by side. Semantic entries are now namespaced by a fingerprint of the extraction prompt (`cache/semantic/p{fingerprint}/`, mirroring the AST cache's `v{version}/` layout), keeping both properties #1252 wanted — entries survive releases that don't touch the prompt, and invalidate only when it actually changed. The fingerprint normalizes line endings so a CRLF checkout doesn't look like a prompt change. Both extraction paths pass their prompt: the Python/CLI path (`llm.py`'s `_EXTRACTION_SYSTEM`, all backends) automatically, and the skill path via a new `prompt_file` argument in Step B0/B3 pointing at the `references/extraction-spec.md` the subagents were handed. Pre-existing entries predate fingerprinting and have unknowable vintage: they are still served rather than re-billing a whole corpus, but `check_semantic_cache` now warns with the count, so the "no signal at all" the report describes becomes a visible one; `--force` (or `GRAPHIFY_FORCE=1`) re-extracts them. Old-fingerprint entries are pruned by liveness only, never swept wholesale the way stale AST versions are — two hosts with different prompts can share one `graphify-out/`, and a wholesale sweep would have each run delete the other's entries. (The two monolith skills, aider and devin, inline their prompt instead of shipping a spec sidecar and stay on the unfingerprinted path for now.)
- Fix: PostgreSQL foreign-key `references` edges are no longer dropped when a routine in the same schema is unparseable (#1854, thanks @sekmur). `pg_introspect` builds one synthetic DDL document and parsed it with the function stubs emitted before the FK `ALTER TABLE`s, so a C-language (or otherwise unparseable) routine's stub parsed as a tree-sitter ERROR node that swallowed the trailing FK statements into the error region, losing every FK edge after it. The FK DDL is now emitted before the function stubs, so table-to-table `references` edges are produced first and can't be eaten by a later unparseable routine.
+16 -5
View File
@@ -912,15 +912,26 @@ def _rebuild_code(
watch_root=watch_root,
normalize_source=_nsf,
)
# Semantic doc nodes lack the AST origin marker. Gate on
# file_type=="document" as well so a pre-#1865 graph whose
# AST nodes lack the ``_origin`` marker isn't misread as
# semantic-backed via some other marker-less node kind.
# Semantic doc nodes lack the AST origin marker. Gate on the
# doc-shaped subset of the six-value file_type enum
# (document/concept/rationale/paper, matching build.py's
# canonical set minus code/image) rather than "document"
# alone: per the extraction spec, a doc full of named
# concepts may be represented with ONLY concept/rationale
# nodes and no separate "document" node — that's still
# evidence of a semantic layer, not a marker-less AST node
# (#1954). The narrower pre-#1954 check under-recognized
# exactly that doc shape, letting it be re-quick-scanned
# every rebuild. A pre-#1865 graph whose AST nodes lack the
# ``_origin`` marker still isn't misread as semantic-backed,
# since "code" stays outside this set.
semantic_doc_identities: set[str] = set()
for node in prior.get("nodes", []):
if node.get("_origin") == "ast":
continue
if node.get("file_type") != "document":
if node.get("file_type") not in (
"document", "concept", "rationale", "paper"
):
continue
identity = prior_paths.identity(node.get("source_file"))
if identity:
+108
View File
@@ -1702,6 +1702,48 @@ def _seed_semantic_doc_graph(corpus):
return graph_path
_CONCEPT_ONLY_GUIDE_IDS = {"auth_flow", "session_model"}
def _seed_semantic_doc_graph_concept_only(corpus):
"""Like ``_seed_semantic_doc_graph``, but guide.md's semantic layer is
ONLY concept/rationale nodes (no ``file_type=="document"`` node) — the
extraction spec's preferred shape for a doc full of named concepts (#1954).
"""
from graphify.watch import _rebuild_code
corpus.mkdir()
(corpus / "app.py").write_text(
"def handle_login():\n return 1\n", encoding="utf-8"
)
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
(corpus / "guide.md").write_text(
"# Overview\n\nIntro.\n\n## Setup\n\nSteps.\n\n## Usage\n\nMore.\n",
encoding="utf-8",
)
graph_path = corpus / "graphify-out" / "graph.json"
data = json.loads(graph_path.read_text(encoding="utf-8"))
code_node_id = next(
n["id"] for n in data["nodes"] if n.get("source_file") == "app.py"
)
data["nodes"].extend([
{"id": "auth_flow", "label": "Auth Flow", "file_type": "concept",
"source_file": "guide.md"},
{"id": "session_model", "label": "Session Model", "file_type": "rationale",
"source_file": "guide.md"},
])
data["links"].extend([
{"source": "auth_flow", "target": "session_model", "relation": "explains",
"confidence": "INFERRED", "source_file": "guide.md"},
{"source": "auth_flow", "target": code_node_id,
"relation": "implemented_by", "confidence": "INFERRED",
"source_file": "guide.md"},
])
graph_path.write_text(json.dumps(data), encoding="utf-8")
return graph_path
def test_rebuild_code_semantic_doc_not_double_represented_on_full_rebuild(tmp_path):
"""#1915: a full _rebuild_code must not AST-quick-scan a doc whose semantic
(LLM) nodes already represent it. Before the fix the quick-scan minted
@@ -1726,6 +1768,31 @@ def test_rebuild_code_semantic_doc_not_double_represented_on_full_rebuild(tmp_pa
)
def test_rebuild_code_concept_only_semantic_doc_not_double_represented_on_full_rebuild(
tmp_path,
):
"""#1954: a doc represented ONLY by concept/rationale nodes (no
file_type=="document" node) must also be recognized as semantic-backed
and skipped by the AST quick-scan — not just docs with a "document" node."""
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
graph_path = _seed_semantic_doc_graph_concept_only(corpus)
before = json.loads(graph_path.read_text(encoding="utf-8"))
assert _rebuild_code(corpus, no_cluster=True, acquire_lock=False) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))
after_ids = {n["id"] for n in after["nodes"]}
assert _CONCEPT_ONLY_GUIDE_IDS <= after_ids, "semantic doc nodes must be preserved"
assert not (_AST_GUIDE_IDS & after_ids), (
"AST heading nodes minted for a concept-only semantic-backed doc (#1954)"
)
assert len(after["nodes"]) == len(before["nodes"]), (
f"node count inflated {len(before['nodes'])} -> {len(after['nodes'])} (#1954)"
)
@pytest.mark.parametrize(
"changed",
[[Path("guide.md")], [Path("guide.md"), Path("app.py")]],
@@ -1767,6 +1834,47 @@ def test_rebuild_code_incremental_preserves_semantic_doc_nodes_and_edges(
)
@pytest.mark.parametrize(
"changed",
[[Path("guide.md")], [Path("guide.md"), Path("app.py")]],
ids=["doc-only", "doc-plus-code"],
)
def test_rebuild_code_incremental_preserves_concept_only_semantic_doc_nodes_and_edges(
tmp_path, changed
):
"""#1954: incremental analogue — a concept/rationale-only semantic doc
must not lose its nodes/edges nor get AST-quick-scanned on an incremental
rebuild, mirroring the #1915 doc-node case above."""
from graphify.watch import _rebuild_code
corpus = tmp_path / "corpus"
graph_path = _seed_semantic_doc_graph_concept_only(corpus)
assert _rebuild_code(
corpus, changed_paths=changed, no_cluster=True, acquire_lock=False
) is True
after = json.loads(graph_path.read_text(encoding="utf-8"))
after_ids = {n["id"] for n in after["nodes"]}
assert _CONCEPT_ONLY_GUIDE_IDS <= after_ids, (
"concept-only semantic doc nodes wiped by an incremental rebuild"
)
relations = {
(e.get("source"), e.get("target"), e.get("relation"))
for e in after["links"]
}
assert ("auth_flow", "session_model", "explains") in relations, (
"concept-only semantic doc edge dropped by an incremental rebuild"
)
assert any(
src == "auth_flow" and rel == "implemented_by"
for src, _tgt, rel in relations
), "doc-to-code semantic edge dropped by an incremental rebuild"
assert not (_AST_GUIDE_IDS & after_ids), (
"incremental rebuild AST-quick-scanned a concept-only semantic-backed doc (#1954)"
)
def test_rebuild_code_quick_scans_doc_without_semantic_nodes(tmp_path):
"""#09b33b7 guard: a doc with NO semantic layer still gets the AST
quick-scan so no-LLM corpora keep their heading structure — #1915's