#4634 suppressed the module-scope node for a require binding so it no
longer shadows the required class. But its guard used
_find_require_call(value), which matches ANY identifier(...) call, so it
also dropped real `const x = factory()` nodes (e.g. express(), NextAuth(),
wrapper()) and regressed #2604/#2552 (10 test failures). Add
_js_value_is_require, which name-checks the callee is literally `require`
the same way _require_imports_js already does, and gate the binding on it.
Bump _AST_CACHE_SCHEMA 20 -> 21: the require-binding change alters
extraction output (drops the shadow node, redirects the calls edge).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
#4561 changes extract_markdown's output (vault-relative wikilinks now resolve to
the real page), and markdown extraction is AST-cached, so a .md file cached under
schema 19 would serve stale unresolved links on re-extract until the file itself
changed. Bump the schema so the first build after upgrading re-resolves markdown
links. (The other batch-3 fixes -- cli, serve, PDF-warning, shebang detection --
do not change cached per-file AST output.)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This batch's Dart typedef (#4582), C++ Metal/CUDA qualifier (#4554, #4550) and
extern-"C" header-routing (#4526) fixes all change extraction output, so a repo
cached under schema 18 would be served stale output on re-extract. The Dart PR's
own 17->18 bump collided with the already-landed 0.9.93 schema-18 note and was
dropped; the C++ and extern-"C" PRs did not bump at all. One global bump to 19
covers the whole batch.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Kotlin typealias (#4553), C# delegate (#4552), and Swift macro (#4551) fixes
in this batch change extraction output (new node kinds), so a repo cached under
schema 17 would be served stale, stub-laden output on re-extract. The Rust fix
(#4567) already bumped _AST_CACHE_SCHEMA to 18; the schema is global, so that one
bump invalidates stale entries for the whole batch. This only extends the
schema-18 comment to document the three extractor changes it also covers.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
A `use` whose first path segment is a `mod` declared in the same scope,
such as `mod item; pub use item::Invoice;`, named no node for the stub
rewire. When nothing else lent the rewire a node, the import edge
dangled, build minted an external stub `invoice`, and `affected` and
`explain` tied on it, the same symptom as #4439 for paths rooted at
`crate`, `self` or `super`.
The extractor now also names a `use` whose first segment is a `mod`
declared beside it. The scope is the use's own parent, so an inline
`mod tests { .. }` does not see the file's top-level mods, and a path
that starts with `::` is never named, because it always means a crate.
`use serde::Serialize` therefore still never lands on a same-named
type in the corpus.
The AST cache schema goes from 17 to 18 so cached Rust files are
extracted again instead of keeping the dangling edge.
A Rust `use` targets the bare, case-folded name it imports. That reached the
declaration only when a type annotation in the corpus had left a sourceless
node under the same id for the stub rewire to remap. Otherwise the edge
dangled, and since 0.9.63 build mints the target as an external stub that
ties with the real declaration in `affected` and `explain`.
The `use` edge now carries the name as written, and the rewire is lent a
sourceless node for each imported name that has none. The lent nodes are not
kept, so an import with no unique declaration dangles exactly as before.
Standard-library paths are left out.
An incremental build re-extracts only the changed files, so the rewire also
takes the unchanged ones as candidates; without that it resolved less than a
full build.
Bumps the AST cache schema: cached edges lack the name.
(cherry picked from commit 672a2b69b1)
Go generic receiver types are normalized before building the method's
parent id (#4438), so methods attach to the type node; this changes AST
extraction output and warm caches must re-extract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Java inherited-supertype and chained/new/cast receiver typing (#4305,
#4307), C/C++ cross-directory quoted-include provenance (#4392), and
JS external imports anchored to the package rather than the path leaf
(#3092) all change AST extraction output, so warm caches must
re-extract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Python property-accessor decorators and `Self` (#4385, #4386), Swift
`Self` (#4387), Go generic type params (#4388), the Python forward
type-hint file-node fix (#4343), PowerShell dot-source paths (#4369),
and the C# verbatim-interpolated-string recovery (#3612) all change AST
extraction output, so warm caches must re-extract.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The partial merge grouped halves by (namespace, label) and skipped nested
types, because nested ids omit the enclosing type. A nested partial type split
across files stayed split, and `Box` / `Box<T>` partials were folded into one
node although they are distinct types (#4249).
- The C# extractor records each partial type's path inside its namespace,
enclosing types first, each with its arity (`Outer`1.Inner`), as
metadata.type_path; the merge keys on (assembly, namespace, type_path).
- The cross-file call pass binds a C# unqualified call to the one candidate
that is a member of the caller's own type, so a call into another half of a
partial type is not lost to a same-named method elsewhere (#4252 does this
in-file only). Ties fall through unchanged.
- After C# type resolution, a merged partial type drops a placeholder base
that sits next to a real base of the same name; one half resolving a sibling
nested base and the other not left two bases, and base-chain lookups failed.
- _AST_CACHE_SCHEMA 8 -> 9, so cached C# results without type_path are
re-extracted.
Fixes#4363.
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
(cherry picked from commit 92d9197787)
An ambiguous extension (`.inc`, `.h`, TS-family, ...) is hard-mapped to one
global default; when that is wrong the file does not error — it parses as the
wrong language and yields a few incidental nodes, so the graph looks populated
while the real symbols are missing. A project can now declare what an extension
means via `language.<ext>=<language|.ext>` lines in `.graphifyrc` (which already
holds `viz_node_limit`). The declaration flows through file classification,
extractor selection, grammar/resolution, imports, watch, and the AST cache
(salt + manifest language field + incremental invalidation), is thread-local
per scan root, and is forwarded to the worker pool. Default detection is
byte-for-byte unchanged when no declaration is present.
Landed from PR #3789 (squashed), which carried @abhay-codes07's original #3075
forward with reviewed follow-ups. Authorship credited to both contributors.
Co-Authored-By: Andre Brait <andrebrait@gmail.com>
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Give each binding of a use tree the import edge that v8 produces for
its equivalent plain use declaration. Preserve aliases, path comments,
separator whitespace, nested prefixes and self/glob binding behavior.
Keep the old group edge as call evidence until the call pass finishes;
exclude per-binding edges from that pass, then remove private markers.
Plain imports and all call edges retain their current-v8 behavior.
AST cache schema 6 -> 7 rejects results written before expansion.
Replace the earlier broad resolver in #4135 with this syntax-only fix.
The resolver is preserved on local branch wip/rust-use-resolver.
On current v8, all 30 new tests and the full 6642-test suite pass.
Fresh ripgrep builds have 864 import edges instead of 247, with every
call unchanged. Existing name-based import rewiring can still produce
wrong matches, just as it does for equivalent plain declarations.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Codex <noreply@openai.com>
(cherry picked from commit 2669ee4f11)
The in-file pass walks only bases declared in the same file, so `Flask.__init__` ->
`App.__init__` (flask/app.py -> flask/sansio/app.py) and every other inherited call
across modules had no calls edge.
_resolve_python_member_calls now walks the caller's class up a single-inheritance
chain and binds the first class that owns the method, as the MRO does. It stops with
no edge at multiple inheritance, a base the inherits edges cannot show (marked
`python_opaque_bases` by the extractor: `mod.Base`, `Generic[T]`), a base outside
the corpus or a self-loop, a caller base its file neither defines nor imports, and
`super(Cls, obj)` with arguments. On a changed-files rebuild an unchanged base is
still a name stub, so the walk rewires copies of the inherits edges the way the full
build does; the opaque-base marker is forwarded with the other context metadata.
AST cache schema 6 -> 7.
flask: 66.7% -> 71.6% of jedi's call pairs found; 16 new edges on httpx/click/flask,
16 confirmed by jedi, 0 lost. Changed-files rebuilds match full builds.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01B8gwEpjajiWHVW3c9smuKf
(cherry picked from commit b241c3bc79)
Resolves a call on a local or parameter whose class is known from an annotation
(c: Client), an Optional/union/string annotation, or a constructor/with binding,
to the owning class's method - fail-closed to a single in-file-or-imported class
that owns the method. Complements the self.<attr> arm from #4176.
Rebased onto the landed #4176: reuses its _emit_call(inferred=True) INFERRED/0.85
path instead of the PR's own confidence= parameter.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
An AST cache entry is keyed by its own file's content, so after
pkg/slice.py is renamed or deleted, the unchanged importer pkg/user.py
is a hit and replays its import edges resolved while slice.py existed:
a target id minted from the absolute path and a target_file stamp to
the old file. extract() maps a stamped target to a portable id only
when it exists, so the edge kept the absolute-path id and the #2873
stub was minted with it: graph.json held the checkout folder and OS
username, and a warm update differed from a cold build (pkg_slice).
save_cached now records the target_file stamps that name an existing
file when an AST entry is written (made portable like the entry's
ids). load_cached returns a miss when any recorded file is gone, so
the importer is extracted again exactly as on a cold build. Stamps
that did not exist at write time are not recorded and never cause a
miss; entries without the list load as before.
A no-change warm rebuild of graphify's own repo has the same 16 cache
misses as before, and its cold build is unchanged.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
(cherry picked from commit 48578d04f1)
_ensure_stat_index bound to the first root/cache_root a process ever
saw and returned immediately for every later call, for the life of
the process. A read for a later root silently came from the first
root's file, and at exit the accumulated in memory index, now holding
entries for every root ever touched, was written back into the first
root's file alone, poisoning it with paths from unrelated projects.
Detect a switch to a different cache root, flush the outgoing root's
own pending entries to its own file before switching, then load the
new root fresh, so each root's file only ever holds that root's own
entries. Also warn on the switch, so a library caller crossing project
roots in one process sees it rather than losing the fastpath silently.
Capture the resolved stat index path at bind time rather than
deriving it fresh from the live out directory at flush time, since a
caller following the documented pattern of setting the out directory
then calling has already pointed it at the new root by the time the
switch is noticed, and deriving it fresh then would flush the outgoing
root's entries into the new root's file instead of its own.
Fixes#3989.
(cherry picked from commit 6175248175)
#3644 persists HCL block attributes into node metadata, which is serialized to
graph.json verbatim and surfaced to the model over MCP (query/get_node) with no
sanitize_metadata pass. A hardcoded credential in a .tf file (password,
db_password, aws_secret_access_key, client_secret, a connection string, ...)
would leak. Redact the VALUE of any attribute whose key names a secret while
keeping the key visible, so instance_type/ami queries still work and a user can
still see THAT a secret is set. Recurses into map values so a nested secret in a
tags/connection map is redacted too.
Also notes Terraform block attributes in the _AST_CACHE_SCHEMA 4 comment (they
are newly-cached facts covered by the same bump #3653 introduced).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A recovered group's write KEY pointed at the right file, but every
node, edge, and hyperedge inside it still carried the original
unresolvable source_file string. load_cached rejects an entry on read
if any item's source_file does not match the path it was loaded
under, so the previous commit alone wrote a group that reported as
saved but could never actually be read back, silently reproducing the
exact loss this fix exists for.
This corrects every item's source_file to the recovered path's
portable form before the group is written, only when recovery
actually redirected the key, so an unrecovered group stays byte
identical to before.
Includes the regression test proving the full round trip: a group
recovered by basename now survives a subsequent load_cached call.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 4b91dcd462)
Fixes issue #2973.
group_skipped and the save_semantic_cache write loop now call
_recover_group_path instead of resolving fpath directly, so a group
whose reported source_file never resolves to a real file gets one more
chance: an unambiguous basename match against the already dispatched
allowlist. An ambiguous or genuinely bogus basename still falls
through to the previous skip behavior unchanged, and unscoped callers
(no allowed_source_files) are byte identical to before since recovery
only runs when an allowlist is available.
This stops an incremental run from silently redoing already processed
chunks every time the adaptive retry split path reports a source_file
that a weak or local backend lost track of.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 73f5a66370)
save_semantic_cache groups nodes and edges by source_file, then skips
any group whose path does not resolve to a real file. A weak or local
backend's adaptive retry split path sometimes reports a source_file
for a group that never resolves at all, silently dropping that
group's nodes and edges from the cache on every incremental run.
This adds a helper that recovers such a group by an unambiguous
basename match against the known good, already dispatched allowlist,
with no behavior change yet since nothing calls it. The write loop is
switched over in the next commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 4528666ba6)
The AST extraction cache writes its own temp file and replace, a
separate code path from the shared atomic writer, and only caught
PermissionError. This was the exact write named in the report: worker
failed for a file with WinError 17 while replacing a cache entry
under the output directory's ast cache. Also covers the stat index
write, which had no Windows fallback at all.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_017qfdzgbA5KedGEjD1AayNh
(cherry picked from commit 7638d47565)
#2990 stamped definition_file with the still-absolute implementation path, so graph.json
shipped the build host's absolute path beside a repo-relative source_file (and the AST cache
replayed it on every warm rebuild). Normalize definition_file through the same
_norm_source_file path as source_file in build_from_json and the cache round-trip, so it is
relative/portable and byte-stable across machines.
The Objective-C field->type table is keyed by class node id, but the id-carrier rewrites
(prefix strip, symbol canonicalisation, cache portability round-trip) never remapped those
keys, so after graphify update the keys went stale and every self.field / obj.field method
call missed resolution. Remap the table keys alongside the existing id remaps (in-process
and in the cache round-trip); a dropped key can only cost a missed edge, never a phantom.
The atexit stat-index flush did an unconditional mkdir(parents=True) on the cache path, so
if the corpus/output tree was deleted during the run the flush recreated the whole
graphify-out/cache/ chain as a husk. Skip the write when the index root no longer exists
(clearing the dirty flag so nothing retries); a live run still writes, and a cold start is
unaffected because the root exists before graphify-out/ does.
The #1757 guard scoped the semantic-cache WRITE to dispatched files, but the unfiltered
fresh result still fed build_merge, whose replace-set logic swapped a non-dispatched
file's entire prior contribution for a stray misattributed fragment (and logged the
'skipped out-of-scope source_file' warning). Apply the same allowlist to the result dict
before it reaches the merge, via a shared _semantic_source_matcher so the write guard and
the graph filter cannot drift.
A semantic result with no nodes and no hyperedges (only edges, or nothing) was cached and
stamped into the manifest, so an empty/degenerate LLM reply for a file froze that file:
detect_incremental saw it unchanged and never re-dispatched it. Reject zero-node results
from the cache read and write, drop edges from the manifest stamp tuple, and heal an
existing manifest by re-queueing files that were already stamped with a zero-node result.
file_hash salted the content digest with the resolved (symlink-collapsed) path, so a
symlink alias and its target hashed identically and one displaced the other from the
graph on a warm cache. Salt with the walked (lexical abspath) path relative to root
instead, giving aliases distinct keys; when the scan root itself is a symlink, derive
the leaf-relative salt from the lexical ancestor matching the resolved root. The
detect-layer containment guard (which resolves to block symlink escape) is untouched.
Extractors mint node ids from the path STRING they are handed
(_make_id(str(path)), _file_node_id(path)), so an AST cache entry written
under root A embeds A's slug in every id and edge endpoint. save_cached
relativized only source_file, never the ids. Because extract()'s
id-remap / final-canonicalization passes key their rewrites off the
CURRENT run's paths, an A-derived id matches no key on a warm hit under
root B (a clone, a moved checkout, a second mount) and the stale
machine slug survives into graph.json. Distinct from #2231/#2243, which
fix producers on a cold run, and from #2199 (stat-index portability).
Entries are now stored root-anchored and re-anchored on read, the same
store-portable/re-anchor-on-load contract source_file (#777) and the
stat index (#2199) already use: _relativize_ids_in replaces the root's
contribution with a $graphify-root$ marker on write, _absolutize_ids_in
restores what the current run's extractor would mint on read. That is
the pre-remap form every downstream pass in extract() expects, so a
replay reproduces a cold run exactly and no other pass changed.
The anchor is derived per entry rather than assumed equal to the scan
root (normalize_id distributes over path joins), so a symlinked root or
relative inputs decompose exactly; only absolute root spellings may
anchor, or a relative root ("src") would rewrite an already-canonical
src_utils_foo into an absolute-derived id on the semantic path. The
walk covers the whole payload rather than a bucket list, since the id
form is self-identifying: that also reaches raw_calls[].caller_nid,
swift_extensions[].nid, edges[].target_file, bash_sources[].source_file
and *_type_table.path, the last three being resolution inputs that
would otherwise still point at root A. save_cached's deepcopy is now
unconditional; the old truthiness gate skipped it for a payload whose
only content lives outside nodes/edges, which would have let the
transform mutate the caller's dict and break cold-run remapping.
Pre-fix entries carry no marker and their content hash never changes,
so they cannot self-heal; they are swept when the release bumps the
version, since AST entries live under cache/ast/v{version}/.
Tests: extract a python/C/bash/markdown corpus under root A, copy the
tree and graphify-out to root B, extract under B on the warm cache, and
assert the run is genuinely warm (zero extractor calls), that no node id
or edge endpoint carries A's slug, that the on-disk entries hold neither
A's slug nor an absolute path, that a cold run still yields canonical
ids, and that warm and cold match exactly. The fixture avoids JS/TS on
purpose: those suffixes bypass the cache, which would make the warm
assertions vacuous.
stat-index.json keyed entries by absolute path and never pruned, so a
moved/cloned corpus got 0% cache hits and the index grew unbounded. Keys
are now stored root-relative and re-anchored on load (mirroring the
manifest.json portability fix), and dead-file entries are pruned on flush.
save_semantic_cache also normalizes source_file to root-relative before
persisting so an absolute/backslash fragment can't poison later updates.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
With `graphify extract --out <dir>`, the semantic cache write and read
sides disagreed on both location and key anchoring, breaking the cache
round-trip in two ways:
- Checkpoints (#1990): `_checkpoint_chunk` called `save_semantic_cache`
with only `root=target`, so per-chunk recovery checkpoints were written
under `<corpus>/graphify-out/` while the reader consulted
`<out>/graphify-out/` — creating an unwanted graphify-out/ inside the
analyzed source tree and making every interrupted run re-extract (and
re-bill) completed chunks.
- Final save (#1991): cli.py passed `root=out_root`, so corpus-relative
`source_file` paths resolved against the --out directory, failed
`p.is_file()`, and every result group was silently skipped — the cache
the reader would consult was never populated at all, with no warning.
Fix, following the split the AST cache already uses (#1774):
- `save_semantic_cache` and `check_semantic_cache` gain a `cache_root`
parameter mirroring `load_cached`/`save_cached`: `root` stays the
source-key anchor (content-hash keys, source_file resolution and
relativization), `cache_root` selects where cache files live. Omitting
it keeps `root` for both, so existing callers are unchanged.
- `extract_corpus_parallel` plumbs `cache_root` into `_checkpoint_chunk`.
- cli.py extract passes `root=target, cache_root=out_root` at the cache
read, the checkpoint path, and the final save, and re-anchors the
prune sweep's live hashes to `target` (keys anchored to out_root would
mismatch every entry and sweep the fresh cache as orphaned).
- `save_semantic_cache` now warns loudly when every result group is
dropped because its source_file does not resolve to a real file — the
silent-0-writes failure mode #1991 asked to surface.
Regression tests cover: checkpoint written under cache_root (not the
corpus, no corpus graphify-out/ created), recovery read finds the
checkpoint via the same root/cache_root split, the final-save call shape
writes entries where the reader looks, the all-groups-dropped warning,
and backward compatibility when cache_root is omitted.
Fixes#1990Fixes#1991
The digest salts content with the path relative to root (portability, #1774),
but the stat-index memo was keyed by absolute path only — so the same file
hashed under two roots (which happens within one `--out` run) served whichever
digest was computed first, making file_hash order-dependent and poisoning the
persisted stat-index across runs. Store one digest per salt under a "hashes"
map; legacy un-salted "hash" entries are never trusted (recompute once). Digest
computation is byte-identical, so existing cache entries still hit.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The _partial item-marker approach couldn't fire on the most common truncation
shape: a mid-JSON cut parses to zero items, so a sliced document whose second
slice truncated empty was still stamped complete (the PR's own headline case).
- The adaptive-retry give-up sites now record the chunk's own source files in a
result-level _partial_files list, independent of parsed items; it propagates
through _merge_two / the recursion merges / _merge_into so it reaches both the
per-chunk checkpoint and the run-level manifest stamp.
- _partial_source_files unions _partial_files with the item markers.
- save_semantic_cache seeds an empty group for a named partial file with no
items so its entry is stamped partial, and carries a partial prev entry's flag
forward so a later clean slice merging over it can't re-promote it to complete.
- the CLI final save now passes partial_source_files (computed before the save)
so an empty-parse file isn't written back as a complete entry.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
A chunk whose LLM response is truncated (`finish_reason="length"`) and can't be
recovered by splitting, or that hits the adaptive-retry depth cap, returns a
partial node set. Today that set is checkpointed and written to the content-hash
semantic cache + manifest-stamped as complete, so the incomplete nodes are
served forever until the file content changes or `--force`.
Truncated give-up results are now tagged with an internal `_partial` marker.
`save_semantic_cache` stamps the affected file's entry `partial: True` (detected
from the marker or an explicit `partial_source_files` arg), and `load_cached`
treats a partial entry as a cache MISS, so the file re-dispatches and retries.
The file is also left unstamped in the manifest (like a failed chunk, #933) so
detect_incremental re-queues it on the next incremental run — not only on a full
/ `--force` / content-change run. A file sliced across chunks accumulates via a
partial-aware `merge_existing` peek (`load_cached(allow_partial=True)`) so a
truncated slice is never dropped or silently promoted to complete. Self-heals: a
later complete extraction overwrites the same key with a non-partial entry. The
marker is stripped after the final save so it never leaks into graph.json.
The semantic cache keyed entries on sha256(file content + path) alone, with
no component for the extraction prompt that produced them. After an upgrade
that changed the prompt, every unchanged file was a cache hit and replayed
the older prompt's extraction: the run exited 0, cost.json looked cheap, and
the graph silently carried two prompt generations side by side. The reporter
saw 506 of 512 docs replay an older vintage on a rebuild they expected to be
cold; deleting the whole cache was the only workaround.
output, and invalidating them on every release would re-bill extraction for
unchanged files. Fingerprinting the prompt itself keeps both properties:
entries survive releases that don't touch the prompt, and invalidate only
when it actually changed.
Semantic entries now live under cache/semantic/p{fingerprint}/, mirroring the
AST cache's v{version}/ layout. Both extraction paths pass their prompt: the
Python/CLI path from llm.py's _EXTRACTION_SYSTEM (shared by every backend),
and the skill path via a new prompt_file argument in Step B0/B3 naming the
references/extraction-spec.md the subagents were handed. The fingerprint
normalizes line endings so a CRLF checkout isn't mistaken for a new prompt.
Pre-existing entries predate fingerprinting and have unknowable vintage, so
they are still served rather than re-billing a whole corpus on upgrade — but
check_semantic_cache now warns with the count, turning "no signal at all"
into a visible one. merge_existing refuses to fuse such an entry into a
current-vintage write, which would mix two prompts inside one entry and then
attest the result to a prompt that produced half of it.
Old-fingerprint entries are pruned by liveness only, never swept wholesale
the way stale AST versions are: two hosts with different prompts (verbose vs
compact extraction-spec) can share one graphify-out/, and a wholesale sweep
would have each run delete the other's entries and re-bill on every
alternation. prune/clear/cached_files glob recursively so fingerprinted
entries can't become unprunable orphans (the #1527 failure mode).
The two monolith skills (aider, devin) inline their prompt instead of
shipping a spec sidecar and stay on the unfingerprinted path for now.