graphify path decided hop direction from the persisted source/target order,
so a link stored in flipped order (pre-#563 graphs, raw dumps, merge-driver
output) printed backwards; explain had the same defect, and the query/merge
load shims clobbered in-file _src/_tgt markers. All now honor _src/_tgt (the
build-side direction truth), falling back to arc order for markerless files.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
SQL references to a table defined in another file (e.g. prisma m2 -> m1) were
minted as node-less absolute-stem ids under the referencing file, leaking the
scan path and losing the FK. They now mint a sourceless bare-name stub (Go
#1402 pattern) that _rewire_unique_stub_nodes collapses onto the real table.
Also normalize identifiers (strip quotes, lowercase, bare-name alias) on both
table_nids writes and reference lookups, so a quoted definition ("public".
"users") resolves an unquoted reference (public.users) in-file instead of
stubbing — which also fixes pg_introspect's reconstructed-DDL views/FKs.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The C# receiver-type table was per-FILE and poisoned a name on any
conflicting/untypable rebind anywhere in the file, so `var item = items[i]`
in one method silently deleted the true calls edge in another method where
the same name was a typed parameter (~2.3% of calls lost, per the reporter).
Ported C# to a per-method table mirroring the Java resolver (per-class field
scope + method params/locals, method-local poisoning only) and retired the
file-wide table. The namespace resolver, ambiguity bail, and inherits-chain
guards are unchanged, so the no-wrong-edge bar holds.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
`_find_node` ranks matches but never reports that a tie was broken, so callers
taking `matches[0]` present one arbitrary file as the answer. Two workspaces
that each define `MetricsPort` put both nodes in the same `exact` tier,
separated only by `G.nodes()` iteration order — reorder the graph and the same
query answers with a different file, equally confidently.
`affected` already declines this via `resolve_seed()` returning None, and
`path` warns when the top two scores are within 10%. Only `explain` (and the
MCP `get_neighbors` tool, which shares the matcher) was silent.
Split `_find_node` into `_find_node_tiers` (logic unchanged, tiers exposed) plus
a flattening wrapper, and add `find_node_ambiguity()`, which returns rivals only
when the winning tier spans multiple source files. Matches within one file (a
file node plus its members) stay ordinary precedence and resolve as before.
`_disambiguate_file_node_labels` (#2032) already relabels colliding *file*
nodes; this covers the symbol case it does not reach.
Review feedback on #2296: the previous revision added Go's predeclared
names to _LANGUAGE_BUILTIN_GLOBALS, which ~11 languages consult through
engine.py and the cross-file pass, and whose check wraps the in-file
EXTRACTED branch as well as raw_calls. Two confirmed regressions:
* Rust normalizes 'Widget::new(3)' to the bare token 'new', so every
in-file 'Type::new()' edge disappeared. Rust keeps its own
_RUST_TRAIT_METHOD_BLOCKLIST, deliberately on the cross-file branch
only — this change follows that language-local pattern.
* Go 'h.append(v)' is a selector_expression call to a real method and
was dropped with it. On the 3x-ui corpus this cost a genuine
'systemMetrics.append(...)' -> '(*metricHistory).append' edge.
The filter now lives in extractors/go.py as _GO_PREDECLARED_FUNCS and
fires only when the callee node is a bare identifier, so selector calls
('h.append(v)', 'pkg.Delete(x)') and every other language are untouched.
Go raw_calls now carry language="go" and the shared pass gates on it,
mirroring the bash gate, as a backstop for Go raw_calls minted
elsewhere. The set is the Go spec's predeclared list in full: being
Go-local and bare-identifier-only makes 'len'/'max'/'min'/'print' safe
to include, and a spec boundary beats a hand-picked subset.
Remeasured on the same 466-file Go corpus:
upstream v8 16904 edges, 334 inbound to 'append'
previous rev 16571 edges, 1 inbound (332 phantom gone, but the
genuine selector call too)
this rev 16572 edges, 2 inbound (332 phantom gone, genuine
call restored)
Tests kept, plus three regressions: builtin 'append' must not bind
in-file (the branch a cross-file-only gate would miss), the Go selector
call must survive, and the in-file Rust 'Type::new()' edge must survive.
The last two fail on the previous revision.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
graphify/extractors/go.py already consults _LANGUAGE_BUILTIN_GLOBALS
when resolving a callee, but the table covered JS/TS, Python and Swift
(#726, #2147) and never Go. Because the Go resolver looks the callee up
by bare name, an unexported method sharing a builtin's name absorbs
every builtin call in the repository.
Measured on a real 8.9k-node Go+TS codebase: a
'func (h *metricHistory) append(...)' method collected 330 phantom
inbound calls edges from the project's ordinary append(slice, x) calls,
which in turn invented twelve database-layer -> service-layer edges —
a layering violation absent from the source. After the fix, phantom
cross-file edges to builtin-named nodes drop from 325 to zero; the 8
that remain are all same-file (struct -> method, and real calls to that
method), and the user's method node is untouched.
Builtin types are deliberately not listed. Go conversions are
call-shaped too, but they produced no phantom edges on that corpus and
listing them would suppress genuine constructor-like calls.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
`_ANTIGRAVITY_WORKFLOW` is shared by the global and project-scoped installs,
which write SKILL.md to different locations (~/.gemini/config/skills vs
.agents/skills). The template named the global path unconditionally, so
`install --project --platform antigravity` (and `antigravity install
--project`) emitted a workflow pointing at a file that scope never writes -
a dangling reference on any machine without a prior global install.
Refer to the skill by name rather than by location, matching what every
block in always_on/ already does. Antigravity resolves it from the
`name: graphify` frontmatter in either scope, so no path interpolation is
needed and neither scope can dangle.
The existing tests only asserted the workflow file exists, which is why this
shipped; add coverage that the generated workflow names no skill path, in
both project and global scope.
mcp 2.0 removed the low-level decorator API the server was built on and
changed several models. _build_server now defines the four handlers as
plain coroutines and binds them at the end in a version-aware way: the
@server.list_tools()/... decorators on 1.x, the on_list_tools=/...
constructor callbacks (with explicit ListToolsResult/CallToolResult/
ListResourcesResult/ReadResourceResult wrapping) on 2.x. Model diffs
handled: Tool.inputSchema is input_schema on 2.x (constructor aliases
work on both, attribute access does not); Resource.uri is a plain str
on 2.x and rejects AnyUrl instances, so resource URIs are passed as
strings (1.x coerces them). The 2.x server also reports graphifyy's own
version in serverInfo. Extras relaxed from mcp>=1,<2 to mcp>=1,<3.
Verified: full stdio handshake (initialize / tools/list / tools/call /
resources/list / resources/read) and the tests in test_serve.py +
test_serve_http.py (145 passed) under BOTH mcp 1.29.0 and mcp 2.0.0.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mcp 2.0 removed the low-level decorator API (Server.list_tools/call_tool/
list_resources/read_resource) that serve.py is built on, so an install
that resolved the unpinned extra to 2.0.0 crashed at server startup
(first at `from mcp.types import AnyUrl`, then at @server.list_tools()).
Pin mcp>=1,<2 in the `mcp` and `all` extras; import AnyUrl with a
pydantic fallback so that piece is already 2.x-ready. Verified with a
full stdio handshake (initialize / tools/list / tools/call) against the
installed graphify-mcp with mcp 1.29.0. Porting to the 2.x API is a
separate work item.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
botocore max_attempts counts the initial call, so GRAPHIFY_MAX_RETRIES must
map to _resolve_max_retries() + 1 (a value of 6 -> 7 total attempts; 0 ->
1, i.e. no retry). Also stub botocore.config in the #2288 reasoning-model
test, which broke once #2283 added the botocore.config import.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_parse_js_tree parsed .tsx with language_typescript(), so JSX misparsed and
error-recovery floated nested handlers to top level; the symbol-resolution
pass then emitted calls edges whose SOURCE was an absolute-stem id for a
caller that owns no node — a leak the 0.9.29 backstop (learns only from
nodes) can't see. Fix: use language_tsx() for .tsx; never emit a calls
use-edge from an unowned source (reattribute to the file node); and teach
the backstop stem-form prefixes so any node-less absolute-derived endpoint
canonicalizes. No node id or edge endpoint now carries the scan-root slug.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The two bedrock-runtime clients (primary extraction in _call_bedrock and
the secondary dispatch path in _call_llm) were built with no botocore
config, so Converse used botocore's 60s default read timeout and ignored
GRAPHIFY_API_TIMEOUT / --api-timeout entirely. A long opus-class
generation then died with "Read timeout on endpoint URL" no matter how
high the timeout was set.
Both client constructions now pass a botocore.config.Config wiring
read_timeout to _resolve_api_timeout() (default 600s), a 10s
connect_timeout, and retries from _resolve_max_retries() in adaptive
mode. This mirrors the fixes that closed the same gap for the claude-cli
subprocess (#1112/#1111) and the secondary LLM dispatch path (#1442) --
bedrock was the last cloud backend still ignoring the knob.
Also updates the README env-var row, which listed the timeout as
applying to HTTP/claude-cli/Anthropic only, and the _fake_boto3 test
fixture to register botocore.config and capture the client config so the
new coverage can assert the timeout is wired.
Converse returns output.message.content as a list of blocks and does not
promise a text block is first. Reasoning-capable models emit a
reasoningContent block ahead of the answer, and toolUse or future block
types can precede it too, but both bedrock call sites indexed position 0:
content", [{}])[0].get("text", "{}")
For those models the default was returned on every call, so _parse_llm_json
saw an empty object, _response_is_hollow reported a hollow result,
finish_reason was rewritten to "length", and the adaptive retry bisected the
chunk. Splitting could not converge because the position assumption fails
identically at every chunk size, and raising GRAPHIFY_MAX_OUTPUT_TOKENS did
nothing because output length was never the constraint. stopReason on those
responses was end_turn, i.e. the model had answered correctly.
Selection now keys on the block's shape rather than its position, at both
_call_bedrock and the bedrock branch of _call_llm. A response whose first
block is already text -- every non-reasoning model today -- is unaffected.
On a 48-document corpus the hollow warnings and the bisection to the
recursion cap disappear, the 17 files previously reported as producing no
nodes are extracted, and output tokens drop from 217,538 to 53,274 as the
wasted retries stop.
Fixes#2287
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.
mcp 2.0.0 dropped mcp.types.AnyUrl and the Server decorator-registration
API graphify/serve.py relies on, so an unpinned resolve broke graphify-mcp
on every fresh graphifyy[mcp]/[all] install. Cap mcp>=1,<2 (resolves 1.29.0)
and starlette>=1.3.1,<2 in both extras; re-lock. Bump to 0.9.30. Porting to
the mcp 2.x API is a tracked follow-up.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-ups on the cherry-picked #2242/#2232: an all-dots label ('...') no
longer produces an empty 'dot-' Obsidian stem (falls back to 'unnamed'),
and the .env.example carve-out gets the regression test it shipped without
(templates graphable, real .env still sensitive, secrets/.env.example still dropped).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
_reconcile_existing_graph loaded graph.json inside a swallowing try, so a
graph that was merely unreadable (over the size cap or unparseable) was
silently replaced by the code-only extraction, in both the clustered and
--no-cluster hook paths (force made it worse). It now loads through the
fail-closed build._load_existing_graph and _rebuild_code refuses the write
(prints and returns False) on a load failure, matching the CLI path; the
--no-cluster write is now atomic with a protected-graph backup.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Absolute/machine-slug ids still leaked into edge endpoints from producers
the target_file-stamp loop didn't reach. Three fixes: apply id_remap to
raw_calls caller_nid so module-top-level indirect_call sources canonicalize
(#2231); a general backstop in the final relativization pass that learns
_make_id(abs source_file) -> canonical id for every node and rewrites all
node ids and edge endpoints (in-root -> _file_node_id, out-of-root -> ext_),
suffix-aware for __entry; and target_file stamps on bash source/entry edges
so they ride the same canonicalization. No node id or edge endpoint now
carries the scan-root slug for any file in the batch. Builds on #2250.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Follow-up to #1899. That fix taught the relativization pass to catch a NODE
whose id was minted from an absolute out-of-root path and give it a portable
"ext_"-namespaced id, by matching the node's own id against
_make_id(str(its source_file)). But several cross-file resolvers (Python
relative imports, C/C++/ObjC quoted #include) only ever emit an EDGE for an
import target, no node -- so when that target lives outside the scan root,
the belt-and-braces pass has nothing to learn the old->new id from, and the
edge keeps the raw _make_id(str(absolute_path)) slug forever. The scan path,
including the OS username, ends up in links[].source/target, and differs
between machines/checkouts even though the node id sets are identical.
_import_c also never stamped the transient `target_file` hint (#1814/#2169)
its Python/JS siblings already use for exactly this kind of cross-file
target canonicalization, so it could not benefit from that machinery either.
Fix, in the two places this root cause actually lives:
- _import_c now stamps target_file on a resolved #include, mirroring
_import_python/_import_js.
- The id_remap pass that already walks target_file-stamped edges to
canonicalize in-root-but-unscanned targets now also handles the
out-of-root branch it previously skipped ("leave its ids alone"): an
existing out-of-root target gets the same portable ext_-namespaced id an
out-of-root NODE already gets, so an edge with no node of its own is
covered too. A target that does not exist on disk still stays dangling,
unchanged from before.
_portable_out_of_root_sf moved next to id_remap so both the new edge-target
branch and the existing node-level pass share one implementation.
Four tests in tests/test_extract.py: the out-of-root #include gets a
portable id instead of the raw slug, and its transient target_file hint
never leaks into the returned edge; the same corpus built from two
differently-nested checkout paths produces a byte-identical target (the
reported non-determinism, made explicit); an in-root, same-batch include
still resolves to the real node's id (negative/regression guard); and the
equivalent out-of-root Python relative import is fixed too, since the gap
was in the shared remap path, not language-specific.
Known limitation: this covers every current target_file-stamping resolver
(Python relative imports, C/C++/ObjC #include, JS/TS/Svelte/Astro/Vue
rescued imports). A resolver that mints a path-derived edge target WITHOUT
stamping target_file at all -- none do today -- would still leak; the fix
closes the gap in the shared mechanism, not a per-language allowlist.
Stage 2's .env regex treated .env.example / .sample / .template / .dist
like live secret files and dropped them from the graph. Carve out those
suffixes for .env / .envrc basenames only — real .env.local etc. stay blocked.
Fixes#2184
safe_name left stems like .env intact, so the vault wrote .env.md which
Obsidian treats as a hidden file — invisible in the explorer and as
unresolved wikilinks. Prefix with dot- (shared _obsidian_safe_stem for
vault + canvas). True label stays in the note body.
Fixes#2205
self_type (`self: Logging with Database =>`, `this: T =>`) was never
dispatched on anywhere in the Scala extractor, so a trait/class's
structural precondition on its enclosing type produced zero edges, in
any context. The type node sits at a fixed position among self_type's
unnamed-field children (binder identifier first, type second when
present), and _scala_collect_type_refs already handles every shape
that position can take (type_identifier, compound_type for `with`,
refinement bodies) -- reused unchanged, one new dispatch branch.
Also add the new `requires` relation to DEFAULT_AFFECTED_RELATIONS,
mirroring how `indirect_call` was wired into blast-radius traversal
when it was introduced, so `graphify affected` follows it like the
existing inherits/mixes_in/embeds structural relations.
Covers: single type, `with`-compound, structural refinement (base
type only, matching how refinement bodies are already unscanned
elsewhere), the binder-only `self =>` shape (no requires edge),
coexistence with an unrelated `extends`, and a plain class without a
self-type (no spurious edge).
walk_calls flattens an inline/untracked arrow or function-expression argument
(one not separately tracked in function_bodies) onto the enclosing named
function's caller_nid, so its calls resolve as if made directly by that
function (#1630). But the closure's own parameters and locals were never
folded into the shadow set used to guard argument-based indirect_call
resolution, so a call argument inside the closure that happened to share a
name with an unrelated callable elsewhere in the corpus produced a fabricated
indirect_call edge, confidence 0.8 — even though the identifier was, in fact,
a local binding one lexical scope down:
rows.map((r) => c.get(r)) // `r` is the arrow's own param, not a
// reference to some other same-named function
Single-letter names make this common, since they collide with same-named
symbols anywhere else in the repo (loop vars, test helpers).
Fix: thread an extra_locals set through walk_calls's recursion. Entering an
untracked closure folds that closure's own bindings (computed the same way as
a tracked function's, via _js_local_bound_names) into extra_locals for its
subtree only; deeper untracked closures compound the same way on their own
recursion. All six call sites that build the caller's shadow set now union in
extra_locals, so the fix applies uniformly to the argument, collection, and
assignment/return capture paths already sharing that guard, not just the
argument one that surfaced it. Tracked closures (const-assigned arrows,
methods) are unaffected — they already get their own caller_nid and their own
correctly-scoped shadow set.
Scope: this fixes the shadow-set gap for closures. A `for (const x of xs)`
loop variable not wrapped in a variable_declarator is a separate, pre-existing
gap in the same shadow computation, already addressed by #1985 — not
duplicated here.
_extract_python_rationale / _extract_js_rationale sliced the raw
docstring/comment text to 80 characters before collapsing whitespace,
so the cut could land mid-word, leave a run of literal spaces where a
newline + indentation used to be, and, when the cut landed on a ".",
produce an Obsidian export filename ending in "..md".
Both _add_rationale sites now share _shorten_rationale_label, which
normalizes whitespace first via textwrap.shorten (word-boundary safe,
adds a placeholder only when it actually truncates) and falls back to
a plain character truncation when shorten collapses to a bare
placeholder -- which it does when the first word alone is already
>= 80 chars (e.g. a comment opening with one long URL), a case that
would otherwise regress to a content-free label.
Adds the regression test PR #2224 shipped without: an NFD-keyed manifest
(portable and legacy-absolute) must match an NFC scan so --update is a
no-op instead of re-extracting everything.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
claude_uninstall/gemini_uninstall/codebuddy_uninstall accepted project_dir
but, with the default project=False, still deleted the user-global skill
tree, so a library/test caller passing a project_dir nuked ~/.claude et al
(the API trap behind #2168). They now take remove_user_skill and treat a
passed project_dir as authoritative; uninstall_all opts in explicitly to
preserve 'graphify uninstall' behavior. Also fixes a live CLI bug where
'uninstall --project' deleted the global codebuddy skill.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
#2212: benchmark, the merge-driver graph load, and callflow_html crashed
or silently failed on a --no-cluster graph.json (edges stored under
'edges', not 'links'). A shared load_node_link_graph helper normalizes
links/edges before node_link_graph and is used at all three sites.
#2210: _stale_graph_sources compared graph source_file spellings to the
scan with a raw string test (no NFC), and pruned any non-match with no
liveness check, so alive files (macOS NFD paths, legacy basenames) were
pruned as 'deleted'. It now compares NFC-on-both-sides and is fail-closed:
a corpus-missing source whose file still exists is pruned only when the
exclusion is provable, else kept with a warning. Prune message corrected
to 'deleted or excluded'.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
The #2169 incremental canonicalization only rewrites edge targets that
carry a target_file stamp. Python relative imports and markdown reference
links emitted absolute-path-derived target ids without one, so on an
incremental/subset extraction they dangled on an absolute id instead of
resolving to the canonical root-relative node (dropping md->md references
and leaving a dangling imports_from on --no-cluster). Both now stamp the
resolved target (existence-gated); the stamp is popped before graph.json
ships. Also register the unresolved target form in the remap loop so a
symlinked root (macOS /tmp) can't cause an id-form mismatch.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Crossing MAX_NODES_FOR_VIZ left the project with no graph.html at all:
_rebuild_code unlinked the existing file and wrote nothing in its place. The
delete on its own is defensible — a kept graph.html would describe an older,
smaller graph — but the file is gone before the user reads the message, and
the next incremental rebuild silently removes it again, so a repo that grows
past the threshold just loses its visualization with no way to keep one.
The export path already solved this (#1019): over the cap it re-renders the
community-aggregation view rather than going without. Do the same here, so
the artifact is current AND present instead of current OR present.
GRAPHIFY_VIZ_NODE_LIMIT=0 still means "no HTML viz" (CI runners) rather than
"aggregate", and if the aggregated render also fails the old skip-and-remove
behaviour stands.
Community labels are saved keyed by community id, but re-clustering
reassigns those ids: after a rebuild that adds nodes, cid 30 can cover a
completely different community and its saved name is then simply wrong.
cluster-only already guards this — it validates each reused label against
the `.graphify_labels.json.sig` membership fingerprints and re-hubs any
community that changed (the case community_member_sigs() was written for).
_rebuild_code() skipped that check entirely: it reused every label whose
cid was present and hub-filled only the *missing* ones, so stale names
survived — and then wrote them back to labels.json, laundering them as
current. It also never refreshed the .sig sidecar, so the signatures kept
describing an older clustering and drifted out of step with the labels
they sit beside, leaving the cluster-only guard nothing accurate to check.
Adding ~3.5k nodes to a real graph re-clustered 463 -> 515 communities and
mislabeled 162 of them this way: a `domain.audit` namespace reading
"ACH / bank payments", a `domain.auth` namespace reading
"document-sensitivity.up.sql". Node and edge data stayed correct, so
nothing failed loudly — only the names lied.
Apply the same signature check in the incremental path, write the sidecar
in step with the labels, and print the same "run `graphify label`" notice
cluster-only emits so a drifted community set is visible rather than
silent.
Stamped keys can already be NFC while Path(root).resolve() is NFD, so
os.path.relpath treated in-root files as ../ and kept them absolute.
Normalize both operands inside _to_relative_for_storage (and the join in
_to_absolute_from_storage).
macOS yields NFD paths from os.walk/getcwd while skill path literals are
often NFC. Raw string compare treated every file as deleted+new and forced
a full re-extract. Canonicalize at the manifest boundary (same idea as #1226).
Fixes#2221
function_types only recognised func/init/deinit/subscript, so computed properties (var body: some View { ... }) and willSet/didSet observers produced no node and their bodies were never walked — erasing the whole SwiftUI view layer. Emit a function-like member node for them and defer the body to the call-walk via function_bodies; stored properties are unchanged. Adds tests.