sidegraph 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- sidegraph/__init__.py +37 -0
- sidegraph/anchoring.py +246 -0
- sidegraph/bootstrap/__init__.py +49 -0
- sidegraph/bootstrap/apply.py +603 -0
- sidegraph/bootstrap/catalog.py +92 -0
- sidegraph/bootstrap/cli.py +827 -0
- sidegraph/bootstrap/integrations.py +184 -0
- sidegraph/bootstrap/model.py +277 -0
- sidegraph/bootstrap/planner.py +400 -0
- sidegraph/bootstrap/proof.py +103 -0
- sidegraph/bootstrap/review.py +331 -0
- sidegraph/bootstrap/scan.py +289 -0
- sidegraph/capture.py +1794 -0
- sidegraph/cli.py +1902 -0
- sidegraph/config.py +148 -0
- sidegraph/doc_import.py +2099 -0
- sidegraph/doctor.py +1429 -0
- sidegraph/domains.py +902 -0
- sidegraph/engine/__init__.py +7 -0
- sidegraph/engine/reader.py +353 -0
- sidegraph/gitio.py +572 -0
- sidegraph/host/__init__.py +7 -0
- sidegraph/host/hooks.py +770 -0
- sidegraph/importer.py +239 -0
- sidegraph/okf.py +471 -0
- sidegraph/profiles.py +459 -0
- sidegraph/retrieval.py +1657 -0
- sidegraph/schema.py +386 -0
- sidegraph/server.py +2608 -0
- sidegraph/store.py +3363 -0
- sidegraph/sync.py +885 -0
- sidegraph/verify.py +1040 -0
- sidegraph/viz/__init__.py +4 -0
- sidegraph/viz/assets/vis-network.min.js +33 -0
- sidegraph/viz/model.py +248 -0
- sidegraph/viz/render.py +110 -0
- sidegraph/viz/template.html +131 -0
- sidegraph-0.1.0.dist-info/METADATA +392 -0
- sidegraph-0.1.0.dist-info/RECORD +42 -0
- sidegraph-0.1.0.dist-info/WHEEL +4 -0
- sidegraph-0.1.0.dist-info/entry_points.txt +18 -0
- sidegraph-0.1.0.dist-info/licenses/LICENSE +201 -0
sidegraph/sync.py
ADDED
|
@@ -0,0 +1,885 @@
|
|
|
1
|
+
"""Sync / rebinding job (Stage 6): memory that stays anchored as code evolves.
|
|
2
|
+
|
|
3
|
+
After a Graphify rebuild, node ids shift. ``sync`` re-resolves every tracked concrete
|
|
4
|
+
entity with a deterministic ladder — exact (name+file) -> moved (unique name-only AND the
|
|
5
|
+
old path confirmed gone from disk; the descriptor follows the file) -> ambiguous ->
|
|
6
|
+
orphaned — healing or degrading tier-2 leaf bindings (never deleting) and refreshing the
|
|
7
|
+
durable->engine mapping. Gated on
|
|
8
|
+
``graph_version`` vs the store's ``last_synced_graph_version`` meta stamp, so the lazy
|
|
9
|
+
read-path invocation is a cheap no-op in the common case. No LLM, no fuzzy matching
|
|
10
|
+
(deferred). See docs/guides/surviving-refactors.md for the rebinding mechanics.
|
|
11
|
+
|
|
12
|
+
Mind-model layer (see docs/concepts/mind-model.md): the same pass also refreshes each
|
|
13
|
+
accepted ``Domain``'s ``communities`` mapping from BOTH its ``path_prefixes`` (a directory
|
|
14
|
+
sweep) and its ``seed_anchors`` (durable entity anchors resolved via ``reader.resolve`` —
|
|
15
|
+
§2a amendment, design/superpowers/specs/2026-07-08-domain-onboarding-design.md: a curated
|
|
16
|
+
domain anchors to entities, not volatile Leiden community ids, so membership survives a
|
|
17
|
+
fresh clone/rebuild), conservative and abstain-on-ambiguity throughout (same style as
|
|
18
|
+
``_observed_community_for_orphan``), flags domains that recomputed to empty, caps and flags
|
|
19
|
+
a claim that would newly cover more than 20% of all current communities rather than
|
|
20
|
+
writing it (Gate-5 blocker — see
|
|
21
|
+
``_DOMAIN_CLAIM_CAP``/``_recompute_domain_communities``), and precomputes the SessionStart
|
|
22
|
+
TOC cache. ``refresh_domain_communities_now`` resolves a single domain immediately
|
|
23
|
+
(ratify-time, bypassing the ``graph_version`` gate) so a newly-accepted domain's membership
|
|
24
|
+
shows up without waiting for the next graph rebuild.
|
|
25
|
+
|
|
26
|
+
Git-native store wave (see docs/reference/store-format.md): the report also surfaces
|
|
27
|
+
``Store.domain_slug_conflicts()`` — cross-branch domain-slug races — as
|
|
28
|
+
``SyncReport.slug_conflicts``. Sync neither detects nor caches these itself, only calls
|
|
29
|
+
the store's method, which recomputes live on every call (a single indexed query).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import json
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from datetime import UTC, datetime
|
|
37
|
+
from pathlib import Path, PurePosixPath
|
|
38
|
+
|
|
39
|
+
from pydantic import BaseModel
|
|
40
|
+
|
|
41
|
+
from .doctor import scan_code_drift
|
|
42
|
+
from .engine.reader import ANCHORABLE_FILE_TYPES, GraphifyReader, NodeRef
|
|
43
|
+
from .retrieval import DRIFT_CACHE_KEY, TOC_CACHE_KEY, build_toc, drifted_record_ids
|
|
44
|
+
from .schema import (
|
|
45
|
+
AnchorBinding,
|
|
46
|
+
DecisionStatus,
|
|
47
|
+
Descriptor,
|
|
48
|
+
Domain,
|
|
49
|
+
DomainStatus,
|
|
50
|
+
Entity,
|
|
51
|
+
matches_path_prefix,
|
|
52
|
+
)
|
|
53
|
+
from .store import VOLATILE_STALE_KEY, Store
|
|
54
|
+
from .verify import _run_git
|
|
55
|
+
|
|
56
|
+
LAST_SYNCED_KEY = "last_synced_graph_version"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class RebindOutcome(BaseModel):
|
|
60
|
+
entity_id: str
|
|
61
|
+
canonical_name: str
|
|
62
|
+
status: str # unchanged | rebound | moved | ambiguous | orphaned | error
|
|
63
|
+
node_id: str | None = None
|
|
64
|
+
detail: str | None = None
|
|
65
|
+
repointed: int = 0
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _set_leaf_status(entity_id: str, store: Store, status: str) -> None:
|
|
69
|
+
"""Transition all tier-2 (leaf) bindings on an entity. Status change only, never delete."""
|
|
70
|
+
for b in store.bindings_for_entity(entity_id):
|
|
71
|
+
if b.tier == 2 and b.status != status:
|
|
72
|
+
b.status = status
|
|
73
|
+
store.add_binding(b)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _repoint_communities(entity: Entity, new_community: str | None, store: Store) -> int:
|
|
77
|
+
"""Re-point Tier-1 bindings when Leiden renumbered this entity's community.
|
|
78
|
+
|
|
79
|
+
Community ids are snapshot labels, not identities — every rebuild may renumber them
|
|
80
|
+
(dogfood: 149 -> 19). For each decision leaf-bound to this entity: ensure a live Tier-1
|
|
81
|
+
binding to the current community and flip the binding to the old baseline community to
|
|
82
|
+
orphaned (status-only; append-only preserved). Returns the number of decisions re-pointed.
|
|
83
|
+
"""
|
|
84
|
+
old = entity.last_seen_community
|
|
85
|
+
if new_community is None or new_community == old:
|
|
86
|
+
return 0
|
|
87
|
+
old_entity = store.find_abstract_entity(f"community:{old}") if old is not None else None
|
|
88
|
+
new_entity = store.get_or_create_abstract_entity(f"community:{new_community}")
|
|
89
|
+
|
|
90
|
+
repointed = 0
|
|
91
|
+
for b in store.bindings_for_entity(entity.entity_id):
|
|
92
|
+
if b.tier != 2:
|
|
93
|
+
continue
|
|
94
|
+
repointed += 1
|
|
95
|
+
store.add_binding(
|
|
96
|
+
AnchorBinding(
|
|
97
|
+
record_id=b.record_id,
|
|
98
|
+
entity_id=new_entity.entity_id,
|
|
99
|
+
tier=1,
|
|
100
|
+
status="live",
|
|
101
|
+
)
|
|
102
|
+
)
|
|
103
|
+
if old_entity is not None:
|
|
104
|
+
for tb in store.bindings_for_record(b.record_id):
|
|
105
|
+
if (
|
|
106
|
+
tb.tier == 1
|
|
107
|
+
and tb.entity_id == old_entity.entity_id
|
|
108
|
+
and tb.status != "orphaned"
|
|
109
|
+
):
|
|
110
|
+
tb.status = "orphaned"
|
|
111
|
+
store.add_binding(tb)
|
|
112
|
+
return repointed
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _observed_community_for_orphan(desc: Descriptor, reader: GraphifyReader) -> str | None:
|
|
116
|
+
"""The community of an orphaned entity's surviving file, when unambiguous.
|
|
117
|
+
|
|
118
|
+
A symbol rename orphans the entity but usually leaves its file in place — and the same
|
|
119
|
+
rebuild may renumber every community (dogfood: 149 -> 19 -> 256 across three rebuilds).
|
|
120
|
+
The file's cluster is a deterministic, no-guessing locus for re-pointing: we are not
|
|
121
|
+
guessing which node the entity became, only where its code lives now.
|
|
122
|
+
"""
|
|
123
|
+
if desc.file_path is None:
|
|
124
|
+
return None
|
|
125
|
+
communities = {
|
|
126
|
+
n.community for n in reader.nodes_in_file(desc.file_path) if n.community is not None
|
|
127
|
+
}
|
|
128
|
+
return communities.pop() if len(communities) == 1 else None
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def _repoint_off_path(entity: Entity, community: str | None, store: Store) -> int:
|
|
132
|
+
"""Re-point on a non-adopted rung: bindings + baseline only; the node mapping is
|
|
133
|
+
NEVER touched here (never guess the entity)."""
|
|
134
|
+
if community is None:
|
|
135
|
+
return 0
|
|
136
|
+
repointed = _repoint_communities(entity, community, store)
|
|
137
|
+
if entity.last_seen_community != community:
|
|
138
|
+
entity.last_seen_community = community
|
|
139
|
+
store.upsert_entity(entity)
|
|
140
|
+
return repointed
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _adopt(entity: Entity, node_id: str, version: str, store: Store, community: str | None) -> int:
|
|
144
|
+
repointed = _repoint_communities(entity, community, store)
|
|
145
|
+
entity.last_seen_node_id = node_id
|
|
146
|
+
entity.last_seen_graph_version = version
|
|
147
|
+
entity.last_seen_community = community
|
|
148
|
+
store.upsert_entity(entity)
|
|
149
|
+
_set_leaf_status(entity.entity_id, store, "live")
|
|
150
|
+
return repointed
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _resolve_repo_root(reader: GraphifyReader) -> Path | None:
|
|
154
|
+
"""Best-effort git worktree root containing ``reader``'s ``graph.json``, used ONLY by
|
|
155
|
+
the "moved" rung below (see ``rebind_entity``) to check whether an entity's OLD
|
|
156
|
+
``file_path`` is still sitting on disk before trusting a name-only hit as a move. Same
|
|
157
|
+
``git rev-parse --show-toplevel`` call ``verify._find_repo_root``/
|
|
158
|
+
``doctor.scan_code_drift`` already use to answer this same class of question ("is this
|
|
159
|
+
repo-relative path real"), but never raises: a graph outside any git working tree, or
|
|
160
|
+
git being unavailable, degrades to ``None`` (mirrors ``doctor.py``'s own
|
|
161
|
+
``repo_root_failed`` tolerance) -- see the fail-closed consequence of that below.
|
|
162
|
+
|
|
163
|
+
Deliberately keyed off the READER's location, not the store's: entity ``file_path``
|
|
164
|
+
descriptors are relative to the checkout the graph was built from, and the store (a
|
|
165
|
+
directory of small JSON files) is routinely copied elsewhere for safe inspection --
|
|
166
|
+
e.g. to sync a copy against the real, uncopied graph without touching the committed
|
|
167
|
+
store -- which would make a store-rooted lookup report "not a git repo" and silently
|
|
168
|
+
fall back to treating every candidate as unverifiable even when the real checkout is
|
|
169
|
+
right there. Called once per real (non-skipped) sync pass, not per entity -- one
|
|
170
|
+
subprocess call, not O(entities).
|
|
171
|
+
|
|
172
|
+
A ``None`` return means the moved rung CANNOT verify anything for this whole pass --
|
|
173
|
+
every candidate that would otherwise adopt instead fails closed to orphaned (see the
|
|
174
|
+
rung's own comment for why unverifiable is treated as "not gone", not "gone"). This
|
|
175
|
+
matters for non-git corpora: ``engine/reader.py``'s own ``graph_version()`` already
|
|
176
|
+
treats "no ``built_at_commit``" as an ordinary, supported case (falls back to a content
|
|
177
|
+
hash), so a document corpus with no ``.git`` at all is expected to reach this code path
|
|
178
|
+
routinely, not just on a misconfigured git checkout.
|
|
179
|
+
"""
|
|
180
|
+
try:
|
|
181
|
+
result = _run_git(["rev-parse", "--show-toplevel"], cwd=reader.path.parent, timeout=5.0)
|
|
182
|
+
except ValueError:
|
|
183
|
+
return None
|
|
184
|
+
if result.returncode != 0:
|
|
185
|
+
return None
|
|
186
|
+
return Path(result.stdout.strip()).resolve()
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def rebind_entity(
|
|
190
|
+
entity: Entity, store: Store, reader: GraphifyReader, repo_root: Path | None = None
|
|
191
|
+
) -> RebindOutcome:
|
|
192
|
+
"""One entity through the deterministic rebind ladder.
|
|
193
|
+
|
|
194
|
+
``repo_root`` backs the "moved" rung's file-still-on-disk guard (see the comment at
|
|
195
|
+
that rung) -- ``sync()`` resolves it once per pass via ``_resolve_repo_root`` and
|
|
196
|
+
threads it through every call. Direct callers (tests, one-off scripts) that omit it
|
|
197
|
+
get the FAIL-CLOSED default: the moved rung cannot verify the old path is gone, so it
|
|
198
|
+
never adopts, regardless of how unique or same-suffix the name-only hit is -- see the
|
|
199
|
+
rung's own comment for why "can't tell" and "confirmed gone" must not be conflated.
|
|
200
|
+
"""
|
|
201
|
+
version = reader.graph_version()
|
|
202
|
+
# concrete entities always carry a descriptor — see Store.iter_concrete_entities, the
|
|
203
|
+
# only real-world source of entities passed here (tests construct the same invariant by
|
|
204
|
+
# hand). Narrowing once here, rather than at every `entity.descriptor` use below, keeps
|
|
205
|
+
# the ladder's control flow readable.
|
|
206
|
+
assert entity.descriptor is not None
|
|
207
|
+
desc = entity.descriptor
|
|
208
|
+
base = {"entity_id": entity.entity_id, "canonical_name": entity.canonical_name}
|
|
209
|
+
|
|
210
|
+
exact = reader.resolve(desc)
|
|
211
|
+
if exact.status == "resolved":
|
|
212
|
+
# GraphifyReader.resolve() only returns status="resolved" with node_id set (see
|
|
213
|
+
# engine/reader.py) — never both "resolved" and node_id=None.
|
|
214
|
+
assert exact.node_id is not None
|
|
215
|
+
unchanged = exact.node_id == entity.last_seen_node_id
|
|
216
|
+
repointed = _adopt(entity, exact.node_id, version, store, exact.community)
|
|
217
|
+
return RebindOutcome(
|
|
218
|
+
**base,
|
|
219
|
+
status="unchanged" if unchanged else "rebound",
|
|
220
|
+
node_id=exact.node_id,
|
|
221
|
+
repointed=repointed,
|
|
222
|
+
)
|
|
223
|
+
if exact.status == "ambiguous":
|
|
224
|
+
_set_leaf_status(entity.entity_id, store, "degraded")
|
|
225
|
+
repointed = _repoint_off_path(entity, exact.community, store)
|
|
226
|
+
return RebindOutcome(
|
|
227
|
+
**base,
|
|
228
|
+
status="ambiguous",
|
|
229
|
+
detail=f"{len(exact.candidates)} candidates",
|
|
230
|
+
repointed=repointed,
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
# Exact miss: the file may have moved — retry name-only and follow a unique hit,
|
|
234
|
+
# but only when BOTH hold:
|
|
235
|
+
# - same file type (a unique cross-suffix hit, e.g. a vanished code symbol colliding
|
|
236
|
+
# with a doc heading, is a collision, not a move, and is never adopted), and
|
|
237
|
+
# - the OLD path is CONFIRMED gone from disk. An exact miss has two distinct causes,
|
|
238
|
+
# not one: the file genuinely moved (old path gone, the name now lives elsewhere),
|
|
239
|
+
# or the old path was never in the graph's scope at all (e.g. a directory Graphify
|
|
240
|
+
# doesn't index) and the miss is permanent and has nothing to do with a move. Both
|
|
241
|
+
# look IDENTICAL to the graph — it has no nodes at the old path either way — so the
|
|
242
|
+
# graph alone cannot tell them apart; only the filesystem can (bug, observed live on
|
|
243
|
+
# this repo: design/whitepaper/figures/{render.py,README.md} — out of Graphify's
|
|
244
|
+
# scope, never moved — got silently re-anchored onto unrelated same-named files
|
|
245
|
+
# elsewhere in the repo).
|
|
246
|
+
#
|
|
247
|
+
# "Confirmed gone" requires repo_root AND the path's absence there — it is NOT the
|
|
248
|
+
# same as "unknown". When repo_root is unknown (direct rebind_entity callers with no
|
|
249
|
+
# repo_root, or a non-git store — see _resolve_repo_root), this rung FAILS CLOSED:
|
|
250
|
+
# it never adopts, same as a cross-suffix collision. Conflating "can't verify" with
|
|
251
|
+
# "verified gone" is exactly the bug this rung exists to fix, just one level up —
|
|
252
|
+
# the asymmetry is deliberate: an unadopted real move degrades to orphaned, which is
|
|
253
|
+
# visible (doctor/heal-anchors surfaces it) and repairable; a wrong adoption is
|
|
254
|
+
# silent and makes a record describe code it has nothing to do with. Same
|
|
255
|
+
# abstain-over-guess discipline this codebase already applies elsewhere (e.g.
|
|
256
|
+
# doc_import.py's ambiguous-anchor handling, the community-derivation cap above).
|
|
257
|
+
if desc.file_path is not None:
|
|
258
|
+
loose = reader.resolve(Descriptor(name=desc.name))
|
|
259
|
+
if loose.status == "resolved":
|
|
260
|
+
# same resolve() invariant as the exact-match branch above.
|
|
261
|
+
assert loose.node_id is not None
|
|
262
|
+
node = reader.get_node(loose.node_id)
|
|
263
|
+
new_file = node.file_path if node is not None else None
|
|
264
|
+
same_suffix = (
|
|
265
|
+
new_file is not None
|
|
266
|
+
and PurePosixPath(new_file).suffix == PurePosixPath(desc.file_path).suffix
|
|
267
|
+
)
|
|
268
|
+
old_confirmed_gone = repo_root is not None and not (repo_root / desc.file_path).exists()
|
|
269
|
+
if same_suffix and old_confirmed_gone:
|
|
270
|
+
entity.descriptor = Descriptor(name=desc.name, file_path=new_file)
|
|
271
|
+
repointed = _adopt(entity, loose.node_id, version, store, loose.community)
|
|
272
|
+
return RebindOutcome(
|
|
273
|
+
**base,
|
|
274
|
+
status="moved",
|
|
275
|
+
node_id=loose.node_id,
|
|
276
|
+
detail=f"{desc.file_path} -> {new_file}",
|
|
277
|
+
repointed=repointed,
|
|
278
|
+
)
|
|
279
|
+
# fall through to orphan: a cross-suffix hit (collision, not a move), the old
|
|
280
|
+
# path is still on disk (out-of-scope file that never moved), or repo_root is
|
|
281
|
+
# unknown and the old path's fate can't be verified either way (fail closed)
|
|
282
|
+
if loose.status == "ambiguous":
|
|
283
|
+
_set_leaf_status(entity.entity_id, store, "degraded")
|
|
284
|
+
repointed = _repoint_off_path(entity, loose.community, store)
|
|
285
|
+
return RebindOutcome(
|
|
286
|
+
**base,
|
|
287
|
+
status="ambiguous",
|
|
288
|
+
detail=f"{len(loose.candidates)} candidates",
|
|
289
|
+
repointed=repointed,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
observed = _observed_community_for_orphan(desc, reader)
|
|
293
|
+
repointed = _repoint_off_path(entity, observed, store)
|
|
294
|
+
_set_leaf_status(entity.entity_id, store, "orphaned")
|
|
295
|
+
return RebindOutcome(**base, status="orphaned", repointed=repointed)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# Refresh claim cap (Gate-5 blocker): a path-derived candidate community set that would
|
|
299
|
+
# newly claim more than this fraction of ALL current communities is never written — see
|
|
300
|
+
# _recompute_domain_communities. Same 20% figure AND, since Option A (owner-approved
|
|
301
|
+
# 2026-09-14), the same ratio by construction as domains._PREFIX_BREADTH_CAP (the
|
|
302
|
+
# bootstrap-time derivation guard): both count the claimed set with the candidate's own
|
|
303
|
+
# community included, over the same total-community denominator. Applied here on the
|
|
304
|
+
# refresh side as defense in depth: bootstrap's guard keeps an AUTO-DERIVED rule from ever
|
|
305
|
+
# producing a candidate this cap would reject AGAINST THE GRAPH IT WAS DERIVED FROM — that
|
|
306
|
+
# guarantee says nothing about a LATER rebuild (a directory one community owns exclusively
|
|
307
|
+
# today can be shared by five tomorrow, and this cap rejecting the rule then is the design
|
|
308
|
+
# working, not a contradiction of bootstrap's own guard). A human can still hand-author
|
|
309
|
+
# (`sidegraph-domains add --path`) or hand-edit a rule that is overbroad from the start,
|
|
310
|
+
# and this cap must never trust ANY rule's blast radius at face value regardless of who
|
|
311
|
+
# wrote it or when — see the
|
|
312
|
+
# docstring below and docs/guides/naming-your-domains.md.
|
|
313
|
+
_DOMAIN_CLAIM_CAP = 0.2
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _recompute_domain_communities(
|
|
317
|
+
domain: Domain, nodes: list[NodeRef], current_community_ids: set[str], reader: GraphifyReader
|
|
318
|
+
) -> tuple[list[str], int, dict[str, int] | None]:
|
|
319
|
+
"""One domain's recomputed ``communities`` + the count of current anchorable nodes its
|
|
320
|
+
``path_prefixes`` matched, + an overbroad-claim marker (§6, conservative
|
|
321
|
+
abstain-on-ambiguity style mirroring ``_observed_community_for_orphan``).
|
|
322
|
+
|
|
323
|
+
Two INDEPENDENT sources of direct evidence feed the final candidate set (§2a
|
|
324
|
+
amendment — design/superpowers/specs/2026-07-08-domain-onboarding-design.md):
|
|
325
|
+
|
|
326
|
+
- ``path_prefixes``: the communities that >= 1 current anchorable node under one of
|
|
327
|
+
those prefixes sits in (unchanged from before this amendment).
|
|
328
|
+
- ``seed_anchors``: each :class:`Descriptor` resolved via ``reader.resolve(desc)`` —
|
|
329
|
+
only a ``"resolved"`` (unambiguous) hit contributes its ``.community``; an
|
|
330
|
+
``"unresolved"`` or ``"ambiguous"`` anchor is skipped outright (never guess which
|
|
331
|
+
community an ambiguous or vanished entity belongs to — same abstain discipline
|
|
332
|
+
``rebind_entity`` uses for a decision's own leaf anchors).
|
|
333
|
+
|
|
334
|
+
``_DOMAIN_CLAIM_CAP`` (20% of all current communities) applies to the ``path_prefixes``
|
|
335
|
+
contribution ONLY, never to ``seed_anchors`` (fix, found dogfooding on a big C++
|
|
336
|
+
monorepo: a domain with a broad path AND precise seed_anchors — e.g. `path_prefixes=
|
|
337
|
+
["src"]` sweeping 385 of 899 communities alongside a handful of hand-picked anchors —
|
|
338
|
+
used to have its ENTIRE membership zeroed, including the anchors, because the old code
|
|
339
|
+
unioned both contributions first and capped the union. The cap exists to stop a FUZZY
|
|
340
|
+
directory sweep from silently claiming half the graph (Gate-5 blocker: a path rule that
|
|
341
|
+
legitimately-per-the-old-code expanded a domain from its own community to 146 of 304 —
|
|
342
|
+
silent, append-only-permanent corruption of every decision anchored to it since);
|
|
343
|
+
``seed_anchors`` are deliberate, per-entity authoring — as trustworthy as a hand-picked
|
|
344
|
+
list — and must never be discarded just because an accompanying path rule turned out to
|
|
345
|
+
be too broad):
|
|
346
|
+
|
|
347
|
+
- If the ``path_prefixes`` candidate alone would newly claim more than
|
|
348
|
+
``_DOMAIN_CLAIM_CAP`` of all current communities, that contribution is REJECTED and
|
|
349
|
+
the caller is told via the third tuple element (``{"matched": <path candidate
|
|
350
|
+
community count>, "total": <all current communities>}``, surfaced through
|
|
351
|
+
``SyncReport.overbroad_domains`` — "your path rule is too broad", regardless of
|
|
352
|
+
whether anchors happened to rescue membership this pass) — but ``seed_anchors``'
|
|
353
|
+
resolved communities are ALWAYS still included.
|
|
354
|
+
- The final candidate is (the path contribution, if under the cap) UNION (the anchor
|
|
355
|
+
contribution, always). When non-empty, it is deterministic, direct evidence and fully
|
|
356
|
+
REPLACES the recorded set (never a blind union with stale ids) — the anchors/
|
|
357
|
+
stabilizer rules win over memory.
|
|
358
|
+
- When the final candidate is empty (no path_prefixes/seed_anchors, none of them match/
|
|
359
|
+
resolve anything today, or the path was capped and there are no anchors to rescue it),
|
|
360
|
+
fall back to survivors: the subset of the domain's previously recorded community ids
|
|
361
|
+
that still exist somewhere in the current graph — "add nothing speculative", never
|
|
362
|
+
guessing a NEW community without direct evidence. A capped path with no anchors keeps
|
|
363
|
+
the domain's previous mapping exactly as before this fix.
|
|
364
|
+
|
|
365
|
+
This cap is unconditional over the ``path_prefixes`` contribution — it applies
|
|
366
|
+
regardless of how that domain was authored (bootstrap-derived, ``add --path``,
|
|
367
|
+
``propose_domains``, or hand-edited). Bootstrap's own derivation guard
|
|
368
|
+
(``domains._derive_path_prefixes``) already keeps an auto-derived rule from ever being
|
|
369
|
+
this broad against the graph it was derived from — a later rebuild can still grow a
|
|
370
|
+
directory's share past the cap, and this refresh guard capping the rule then is that
|
|
371
|
+
design working, not bootstrap's guarantee failing. A manually-authored rule is
|
|
372
|
+
explicit human intent and is never blocked at write time (see ``domains.py``'s module
|
|
373
|
+
docstring / ``docs/guides/naming-your-domains.md``) — refresh is the one place that
|
|
374
|
+
must still protect against a rule (of ANY provenance) whose blast radius turns out to
|
|
375
|
+
be this large once it meets the live graph, since a human authoring `--path tests` has
|
|
376
|
+
no way to know in advance
|
|
377
|
+
how many communities that will resolve to on a 300-community repo.
|
|
378
|
+
|
|
379
|
+
Single-community floor: a PATH candidate that resolves to exactly ONE community is
|
|
380
|
+
NEVER capped, regardless of ratio — one community cannot possibly "swallow" anything
|
|
381
|
+
else, so the cap has nothing to protect against there. Without this floor there is a
|
|
382
|
+
dead zone on small graphs: with few total communities, `1/total` can legitimately
|
|
383
|
+
exceed 20% for a perfectly ordinary single-community claim (e.g. 1 of 3), and capping
|
|
384
|
+
it would freeze the domain's stale pre-renumber mapping forever with no way to ever
|
|
385
|
+
heal (every future recompute re-derives the same single-community candidate and gets
|
|
386
|
+
capped again). On a tiny graph, a genuine MULTI-community PATH claim can still
|
|
387
|
+
legitimately trip the cap — when it does, the warning means re-scope the domain
|
|
388
|
+
(supersede) or narrow `path_prefixes`, not a signal that the cap itself is wrong for
|
|
389
|
+
that graph size. ``seed_anchors`` have no floor to worry about — they are never capped
|
|
390
|
+
at all, of any size.
|
|
391
|
+
"""
|
|
392
|
+
matched_count = 0
|
|
393
|
+
prefix_candidate: set[str] = set()
|
|
394
|
+
if domain.path_prefixes:
|
|
395
|
+
matched = [
|
|
396
|
+
n
|
|
397
|
+
for n in nodes
|
|
398
|
+
if n.file_type in ANCHORABLE_FILE_TYPES
|
|
399
|
+
and n.file_path is not None
|
|
400
|
+
and any(matches_path_prefix(n.file_path, p) for p in domain.path_prefixes)
|
|
401
|
+
]
|
|
402
|
+
matched_count = len(matched)
|
|
403
|
+
prefix_candidate = {n.community for n in matched if n.community is not None}
|
|
404
|
+
|
|
405
|
+
anchor_candidate: set[str] = set()
|
|
406
|
+
for desc in domain.seed_anchors:
|
|
407
|
+
result = reader.resolve(desc)
|
|
408
|
+
if result.status == "resolved" and result.community is not None:
|
|
409
|
+
anchor_candidate.add(result.community)
|
|
410
|
+
|
|
411
|
+
# Cap the PATH contribution alone (never the anchors): an overbroad sweep is rejected
|
|
412
|
+
# and flagged, but seed_anchors — precise, hand-picked evidence — are never discarded
|
|
413
|
+
# just because an accompanying path rule turned out to be too wide.
|
|
414
|
+
total = len(current_community_ids)
|
|
415
|
+
cap_hit: dict[str, int] | None = None
|
|
416
|
+
if (
|
|
417
|
+
prefix_candidate
|
|
418
|
+
and total > 0
|
|
419
|
+
and len(prefix_candidate) > 1
|
|
420
|
+
and len(prefix_candidate) / total > _DOMAIN_CLAIM_CAP
|
|
421
|
+
):
|
|
422
|
+
cap_hit = {"matched": len(prefix_candidate), "total": total}
|
|
423
|
+
prefix_candidate = set() # rejected; anchor_candidate still counts below
|
|
424
|
+
|
|
425
|
+
candidate = sorted(prefix_candidate | anchor_candidate)
|
|
426
|
+
if candidate:
|
|
427
|
+
return candidate, matched_count, cap_hit
|
|
428
|
+
|
|
429
|
+
if cap_hit is not None:
|
|
430
|
+
# Path was overbroad and no seed_anchors rescued it: keep the previous mapping
|
|
431
|
+
# unchanged (never zero it, never trust an over-claim just because refresh
|
|
432
|
+
# computed it) — same never-guess outcome as before this fix.
|
|
433
|
+
return sorted(domain.communities), matched_count, cap_hit
|
|
434
|
+
|
|
435
|
+
survivors = sorted(cid for cid in domain.communities if cid in current_community_ids)
|
|
436
|
+
return survivors, matched_count, None
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
def _refresh_domains(
|
|
440
|
+
store: Store, reader: GraphifyReader
|
|
441
|
+
) -> tuple[int, list[dict], list[dict], list[dict]]:
|
|
442
|
+
"""Refresh ``communities`` on every ACCEPTED domain; return (refreshed count, empty
|
|
443
|
+
domains, overbroad domains, failures). A domain is flagged empty when its recomputed
|
|
444
|
+
set is empty AND its ``path_prefixes`` matched zero current nodes — a domain whose
|
|
445
|
+
stabilizer still points at live code is never flagged even if none of those nodes
|
|
446
|
+
carry a community label. A domain whose ``path_prefixes`` claim tripped the refresh
|
|
447
|
+
cap (see ``_recompute_domain_communities``/``_DOMAIN_CLAIM_CAP``) is always flagged
|
|
448
|
+
overbroad — "your path rule is too broad" — regardless of outcome: if
|
|
449
|
+
``seed_anchors`` rescued the domain, its (anchor-only) communities are still written
|
|
450
|
+
and counted as refreshed; if nothing rescued it, its previous mapping was kept, not
|
|
451
|
+
zeroed, so it is never also flagged empty. Domains never orphan (they're owned); both
|
|
452
|
+
empty and overbroad are routed to a human via the report, status is never
|
|
453
|
+
auto-changed (never guess). A domain whose refresh raises is isolated (see the loop
|
|
454
|
+
below) and reported in ``failures`` instead of aborting the pass.
|
|
455
|
+
"""
|
|
456
|
+
nodes = reader.list_nodes()
|
|
457
|
+
current_community_ids = {n.community for n in nodes if n.community is not None}
|
|
458
|
+
|
|
459
|
+
refreshed = 0
|
|
460
|
+
empty: list[dict] = []
|
|
461
|
+
overbroad: list[dict] = []
|
|
462
|
+
failures: list[dict] = []
|
|
463
|
+
# `reader.list_nodes()` above stays outside any try on purpose: a reader that
|
|
464
|
+
# constructed has already parsed its graph, and list_nodes is a list copy.
|
|
465
|
+
for domain in store.iter_domains(status=DomainStatus.ACCEPTED):
|
|
466
|
+
# The try encloses the WRITE as well as the recompute. refresh_domain_communities
|
|
467
|
+
# raises ValueError if the domain vanished mid-pass (concurrent supersede — it
|
|
468
|
+
# re-fetches by id) and sqlite3.OperationalError under cross-process lock
|
|
469
|
+
# contention, which the widened gate makes ordinary: a SessionStart hook and the
|
|
470
|
+
# MCP server now both heal after the same pull. Wrapping only the recompute lets
|
|
471
|
+
# those escape, abort the pass, skip every remaining domain, and appear in no
|
|
472
|
+
# report — the exact invisibility this task exists to kill.
|
|
473
|
+
try:
|
|
474
|
+
communities, matched, cap_hit = _recompute_domain_communities(
|
|
475
|
+
domain, nodes, current_community_ids, reader
|
|
476
|
+
)
|
|
477
|
+
if cap_hit is not None:
|
|
478
|
+
overbroad.append({"slug": domain.slug, "title": domain.title, **cap_hit})
|
|
479
|
+
if communities != sorted(domain.communities):
|
|
480
|
+
store.refresh_domain_communities(domain.domain_id, communities)
|
|
481
|
+
refreshed += 1
|
|
482
|
+
if not communities and matched == 0:
|
|
483
|
+
empty.append({"slug": domain.slug, "title": domain.title})
|
|
484
|
+
except Exception as exc: # one bad domain must not cost the others their heal
|
|
485
|
+
failures.append(
|
|
486
|
+
{
|
|
487
|
+
"slug": domain.slug,
|
|
488
|
+
"title": domain.title,
|
|
489
|
+
"error": f"{type(exc).__name__}: {exc}",
|
|
490
|
+
}
|
|
491
|
+
)
|
|
492
|
+
continue
|
|
493
|
+
return refreshed, empty, overbroad, failures
|
|
494
|
+
|
|
495
|
+
|
|
496
|
+
def refresh_domain_communities_now(
|
|
497
|
+
domain: Domain, store: Store, reader: GraphifyReader
|
|
498
|
+
) -> tuple[list[str], dict | None]:
|
|
499
|
+
"""Resolve ONE domain's ``communities`` immediately, bypassing sync's ``graph_version``
|
|
500
|
+
gate (§2a amendment) — called right after a domain is ACCEPTED (see
|
|
501
|
+
``server._ratify_impl``) so ``seed_anchors``/``path_prefixes`` membership shows up in
|
|
502
|
+
``drill_down`` the instant a human picks a set, instead of waiting for the next
|
|
503
|
+
graph-rebuild-triggered ``sync`` pass. Reuses ``_recompute_domain_communities`` — same
|
|
504
|
+
conservative, abstain-on-ambiguity resolution and the same ``_DOMAIN_CLAIM_CAP`` guard
|
|
505
|
+
as the full pass, capping only the ``path_prefixes`` contribution: an overbroad path
|
|
506
|
+
claim is rejected here exactly like it would be during a real sync, never written just
|
|
507
|
+
because this call is immediate — but a domain's ``seed_anchors`` resolve and get
|
|
508
|
+
written immediately regardless of the path cap, same as ``_refresh_domains``. No-op (no
|
|
509
|
+
write) when the recomputed value doesn't change anything, same convention as
|
|
510
|
+
``Store.refresh_domain_communities``. Returns ``(communities, overbroad)`` — the
|
|
511
|
+
(possibly unchanged) communities list, and ``{"slug", "title", "matched", "total"}`` (or
|
|
512
|
+
``None``) when the ``path_prefixes`` claim tripped ``_DOMAIN_CLAIM_CAP``. Callers wanting
|
|
513
|
+
the flag no longer need the full ``sync`` pass's ``SyncReport.overbroad_domains`` — this
|
|
514
|
+
immediate path returns it too, so the caller who just accepted the domain can tell the
|
|
515
|
+
author their path rule was rejected right away.
|
|
516
|
+
"""
|
|
517
|
+
nodes = reader.list_nodes()
|
|
518
|
+
current_community_ids = {n.community for n in nodes if n.community is not None}
|
|
519
|
+
communities, _matched, cap_hit = _recompute_domain_communities(
|
|
520
|
+
domain, nodes, current_community_ids, reader
|
|
521
|
+
)
|
|
522
|
+
overbroad = (
|
|
523
|
+
{"slug": domain.slug, "title": domain.title, **cap_hit} if cap_hit is not None else None
|
|
524
|
+
)
|
|
525
|
+
if communities == sorted(domain.communities):
|
|
526
|
+
return sorted(domain.communities), overbroad
|
|
527
|
+
store.refresh_domain_communities(domain.domain_id, communities)
|
|
528
|
+
return communities, overbroad
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
@dataclass(frozen=True)
|
|
532
|
+
class DomainActivation:
|
|
533
|
+
"""Result of :func:`activate_accepted_domain` (design D2's shared per-domain
|
|
534
|
+
post-accept step). ``resolved`` is the caller's cue for the pre-existing
|
|
535
|
+
never-fail-a-ratify heal flag (``VOLATILE_STALE_KEY``, already set by this function
|
|
536
|
+
when it applies — a caller never needs to set it itself); ``overbroad`` is
|
|
537
|
+
``refresh_domain_communities_now``'s own cap-hit dict (or ``None``); ``error`` is the
|
|
538
|
+
exception text when the refresh raised, else ``None``. Deliberately renders NOTHING —
|
|
539
|
+
the "path rule too broad" sentence is a literal trigger phrase for the
|
|
540
|
+
``sidegraph:heal-anchors`` skill and stays in the two human callers (``server.py``,
|
|
541
|
+
``cli.py``), which build their own message from ``overbroad``.
|
|
542
|
+
"""
|
|
543
|
+
|
|
544
|
+
resolved: bool
|
|
545
|
+
overbroad: dict | None
|
|
546
|
+
error: str | None
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
def activate_accepted_domain(
|
|
550
|
+
domain: Domain, store: Store, reader: GraphifyReader | None
|
|
551
|
+
) -> DomainActivation:
|
|
552
|
+
"""Resolve ONE just-accepted domain's membership immediately (design D2) — the shared
|
|
553
|
+
helper extracted, behavior-preserving, from ``server._ratify_impl``/``cli.ratify_main``
|
|
554
|
+
so MCP, CLI, and the two auto callers (capture's domain propose path,
|
|
555
|
+
``domains.bootstrap_domains``) all resolve membership through the same code. Contract,
|
|
556
|
+
pinned so the extraction is provably pure:
|
|
557
|
+
|
|
558
|
+
- one domain per call, so per-domain failure isolation survives (a caller loops over
|
|
559
|
+
its own accepted-this-batch domain ids);
|
|
560
|
+
- ``reader is None`` -> ``resolved=False, error=None``, and schedules the heal
|
|
561
|
+
(``VOLATILE_STALE_KEY="1"``) without inventing a new human-facing error where there
|
|
562
|
+
was none before (the pre-existing "no graph to resolve against" branch);
|
|
563
|
+
- a raising ``refresh_domain_communities_now`` sets the SAME flag and returns the
|
|
564
|
+
exception text as ``error`` — the pre-existing never-fail-a-ratify rule: membership
|
|
565
|
+
could not be resolved, but the ratify/accept itself must never fail because of it;
|
|
566
|
+
- never rebuilds the ``TOC_CACHE_KEY`` cache — the once-per-batch rebuild stays at the
|
|
567
|
+
caller (``server.py``, ``cli.py``, and both auto callers rebuild once at the end of
|
|
568
|
+
their own batch), so accepting N domains in one pass never pays N ``build_toc`` calls.
|
|
569
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D2
|
|
570
|
+
"""
|
|
571
|
+
if reader is None:
|
|
572
|
+
store.set_meta(VOLATILE_STALE_KEY, "1")
|
|
573
|
+
return DomainActivation(resolved=False, overbroad=None, error=None)
|
|
574
|
+
try:
|
|
575
|
+
_communities, overbroad = refresh_domain_communities_now(domain, store, reader)
|
|
576
|
+
except Exception as e: # never fail a ratify because membership could not be resolved
|
|
577
|
+
store.set_meta(VOLATILE_STALE_KEY, "1")
|
|
578
|
+
return DomainActivation(resolved=False, overbroad=None, error=str(e))
|
|
579
|
+
return DomainActivation(resolved=True, overbroad=overbroad, error=None)
|
|
580
|
+
|
|
581
|
+
|
|
582
|
+
class SyncReport(BaseModel):
|
|
583
|
+
from_version: str | None
|
|
584
|
+
to_version: str
|
|
585
|
+
skipped: bool = False
|
|
586
|
+
outcomes: list[RebindOutcome] = []
|
|
587
|
+
stale_decisions: list[dict] = [] # {"id": ..., "title": ...}
|
|
588
|
+
domains_refreshed: int = 0
|
|
589
|
+
empty_domains: list[dict] = [] # {"slug": ..., "title": ...}
|
|
590
|
+
overbroad_domains: list[dict] = [] # {"slug", "title", "matched", "total"} — see
|
|
591
|
+
# _DOMAIN_CLAIM_CAP; the path_prefixes contribution was dropped (any seed_anchors
|
|
592
|
+
# still applied, so a rescued domain IS written); the path is flagged as noise
|
|
593
|
+
slug_conflicts: list[dict] = [] # {"slug": ..., "domain_ids": [...]} — design §6's
|
|
594
|
+
# cross-branch slug race; see
|
|
595
|
+
# Store.domain_slug_conflicts (computed live on
|
|
596
|
+
# every call, not cached)
|
|
597
|
+
domain_failures: list[dict] = [] # {"slug", "title", "error"} — a domain whose refresh
|
|
598
|
+
# raised. Isolated per domain so one malformed seed cannot cost every other domain its
|
|
599
|
+
# heal, and reported because both lazy-sync callers suppress exceptions: a failure that
|
|
600
|
+
# is not in this list is invisible, and the store silently never heals.
|
|
601
|
+
|
|
602
|
+
def counts(self) -> dict[str, int]:
|
|
603
|
+
out: dict[str, int] = {}
|
|
604
|
+
for o in self.outcomes:
|
|
605
|
+
out[o.status] = out.get(o.status, 0) + 1
|
|
606
|
+
return out
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def sync(store: Store, reader: GraphifyReader, force: bool = False) -> SyncReport:
|
|
610
|
+
"""Full rebind pass, gated on graph_version AND the volatile-reload flag. Stamps
|
|
611
|
+
last_synced, and clears the reload flag, only on completion.
|
|
612
|
+
|
|
613
|
+
Entities that error during rebind are not retried until the next graph-version change
|
|
614
|
+
(or force=True) — the stamp records a completed visit, not universal success. The gate
|
|
615
|
+
below skips the rebind ladder itself when the version already matches, the store's
|
|
616
|
+
volatile state isn't cold (`VOLATILE_STALE_KEY`), and `force` wasn't passed — but the
|
|
617
|
+
gate does NOT skip the TOC cache refresh: a skipped pass still rewrites `toc_cache` when
|
|
618
|
+
at least one accepted domain exists, since that cache can go stale from content-only
|
|
619
|
+
changes the graph never sees (see the gate's own comment below).
|
|
620
|
+
"""
|
|
621
|
+
to_version = reader.graph_version()
|
|
622
|
+
from_version = store.get_meta(LAST_SYNCED_KEY)
|
|
623
|
+
# A canonical reload (pull, merge, branch switch, hand edit — even a bare touch, since
|
|
624
|
+
# the digest hashes mtime_ns) resets every derived table but deliberately PRESERVES the
|
|
625
|
+
# meta table, so graph_version alone cannot see that volatile state went cold. This is
|
|
626
|
+
# the flag git-native design §3 introduced for exactly that event ("the next
|
|
627
|
+
# sidegraph-sync / lazy sync rebuilds volatile state") — set on every reload since, and
|
|
628
|
+
# read here for the first time.
|
|
629
|
+
volatile_stale = store.get_meta(VOLATILE_STALE_KEY) == "1"
|
|
630
|
+
if to_version == from_version and not volatile_stale and not force:
|
|
631
|
+
# TOC cache is volatile state (§5/§6), and `sync` is the store's one volatile-refresh
|
|
632
|
+
# verb (see docs/reference/store-format.md) — a content-only change (e.g. `add_decision`
|
|
633
|
+
# bound to an already-accepted domain, via capture or import) never moves
|
|
634
|
+
# graph_version, so the version gate above must not ALSO gate the cache refresh: doing
|
|
635
|
+
# so left the cache stale until an unrelated domain accept/drop happened to rebuild it
|
|
636
|
+
# (cli.py/server.py's own explicit ratify-time refresh), which real sessions hit (fix-
|
|
637
|
+
# wave B, B6). `build_toc` is store-only (no reader dependency for its fields today) and
|
|
638
|
+
# cheap, so it's safe to recompute on every sync call; gated on "at least one accepted
|
|
639
|
+
# domain exists" purely to avoid a pointless write when there is nothing to refresh.
|
|
640
|
+
if next(store.iter_domains(status=DomainStatus.ACCEPTED), None) is not None:
|
|
641
|
+
store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store, reader)))
|
|
642
|
+
return SyncReport(from_version=from_version, to_version=to_version, skipped=True)
|
|
643
|
+
|
|
644
|
+
outcomes: list[RebindOutcome] = []
|
|
645
|
+
# Resolved once for the whole pass (one subprocess call, not one per entity) and
|
|
646
|
+
# threaded through every rebind_entity call — see _resolve_repo_root / the moved
|
|
647
|
+
# rung's own comment for what this guards against and how it degrades when unknown.
|
|
648
|
+
repo_root = _resolve_repo_root(reader)
|
|
649
|
+
for entity in store.iter_concrete_entities():
|
|
650
|
+
try:
|
|
651
|
+
outcomes.append(rebind_entity(entity, store, reader, repo_root))
|
|
652
|
+
except Exception as e: # one bad entity must not abort the pass
|
|
653
|
+
outcomes.append(
|
|
654
|
+
RebindOutcome(
|
|
655
|
+
entity_id=entity.entity_id,
|
|
656
|
+
canonical_name=entity.canonical_name,
|
|
657
|
+
status="error",
|
|
658
|
+
detail=str(e),
|
|
659
|
+
)
|
|
660
|
+
)
|
|
661
|
+
|
|
662
|
+
domains_refreshed, empty_domains, overbroad_domains, domain_failures = _refresh_domains(
|
|
663
|
+
store, reader
|
|
664
|
+
)
|
|
665
|
+
|
|
666
|
+
stale: list[dict] = []
|
|
667
|
+
for d in store.iter_decisions():
|
|
668
|
+
if d.status in (DecisionStatus.SUPERSEDED, DecisionStatus.REJECTED):
|
|
669
|
+
continue
|
|
670
|
+
# Leaf-based: surviving Tier-1/Tier-0 bindings keep the decision retrievable
|
|
671
|
+
# (the escalation), but do not vouch for the claim — the concrete code is gone.
|
|
672
|
+
leaves = [b for b in store.bindings_for_record(d.id) if b.tier == 2]
|
|
673
|
+
if leaves and all(b.status == "orphaned" for b in leaves):
|
|
674
|
+
stale.append({"id": d.id, "title": d.title})
|
|
675
|
+
|
|
676
|
+
store.set_meta(LAST_SYNCED_KEY, to_version)
|
|
677
|
+
# Cleared HERE, beside the version stamp and BEFORE build_toc: volatile state has been
|
|
678
|
+
# rebuilt by this point (the ladder, _refresh_domains and the stale scan all ran), and
|
|
679
|
+
# the TOC is a render of that state, not part of it. Clearing after build_toc instead
|
|
680
|
+
# let a persistent TOC failure leave the half-state "version stamped but flag set",
|
|
681
|
+
# which the gate below reads as "heal me" on every single retrieval call.
|
|
682
|
+
store.set_meta(VOLATILE_STALE_KEY, "0")
|
|
683
|
+
# TOC precompute (§5/§6): recomputed AFTER the domain-community refresh above and the
|
|
684
|
+
# last_synced stamp, so it reflects this pass's own updates; written on every completed
|
|
685
|
+
# (non-skipped) pass, fresh or forced — a skipped pass returns above and never reaches
|
|
686
|
+
# here, leaving the cache untouched.
|
|
687
|
+
store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store, reader)))
|
|
688
|
+
return SyncReport(
|
|
689
|
+
from_version=from_version,
|
|
690
|
+
to_version=to_version,
|
|
691
|
+
outcomes=outcomes,
|
|
692
|
+
stale_decisions=stale,
|
|
693
|
+
domains_refreshed=domains_refreshed,
|
|
694
|
+
empty_domains=empty_domains,
|
|
695
|
+
overbroad_domains=overbroad_domains,
|
|
696
|
+
slug_conflicts=store.domain_slug_conflicts(),
|
|
697
|
+
domain_failures=domain_failures,
|
|
698
|
+
)
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
# Outcome statuses worth a human's attention -- "unchanged" (nothing happened) and
|
|
702
|
+
# "rebound" (same name+file_path descriptor match as last sync, but the resolved node id
|
|
703
|
+
# changed -- the mapping just heals itself) are noise; everything else is a heads-up. One
|
|
704
|
+
# filter, two consumers: ``report_as_dict``'s "outcomes" field below (shared by both
|
|
705
|
+
# ``sidegraph-sync --json`` and the ``sync_anchors`` MCP tool) and ``sidegraph-sync``'s own
|
|
706
|
+
# prose printer (``cli.sync_main``).
|
|
707
|
+
_SYNC_OUTCOME_NOTEWORTHY = ("moved", "orphaned", "ambiguous", "error")
|
|
708
|
+
|
|
709
|
+
# The subset of ``_SYNC_OUTCOME_NOTEWORTHY`` that makes ``--check``/``report_has_findings``
|
|
710
|
+
# fail (design/superpowers/specs/2026-07-11-ci-live-findings-design.md ruling 1). "moved" is
|
|
711
|
+
# noteworthy (surfaced in "outcomes") but not itself a finding -- the mapping healed itself,
|
|
712
|
+
# nothing needs a human's hand. "orphaned"/"ambiguous" are ALSO informational-only, not a
|
|
713
|
+
# finding: a live GitHub Actions experiment showed a legitimate rename+heal (triage adds a
|
|
714
|
+
# live anchor to the decision) leaves the renamed-away entity's own leaf orphaned for good --
|
|
715
|
+
# an append-only store has no retirement path -- so gating on the outcome kept the healing
|
|
716
|
+
# PR, and the default branch after it merged, red forever. When an orphaned/ambiguous anchor
|
|
717
|
+
# actually costs reachability (it was a decision's ONLY live tier-2 leaf), the decision goes
|
|
718
|
+
# stale and ``stale_decisions`` already fires -- the failure signal is redundant where it
|
|
719
|
+
# matters and harmful (permanently red) where it doesn't. Only "error" (the rebind ladder
|
|
720
|
+
# itself raised on an entity) stays a genuine failing outcome.
|
|
721
|
+
_FINDING_OUTCOME_STATUSES = ("error",)
|
|
722
|
+
|
|
723
|
+
|
|
724
|
+
def report_as_dict(report: SyncReport) -> dict:
|
|
725
|
+
"""The shared report shape both ``sidegraph-sync --json`` (``cli.sync_main``) and the
|
|
726
|
+
``sync_anchors`` MCP tool (``server._sync_anchors_impl``) return -- one dict shape, two
|
|
727
|
+
callers (design/superpowers/specs/2026-07-11-ci-integrity-design.md ruling 1).
|
|
728
|
+
|
|
729
|
+
``synced`` is ``False`` when the pass was skipped outright (``graph_version``
|
|
730
|
+
unchanged, no ``force``) -- every OTHER field is then an EMPTY default (``outcomes:
|
|
731
|
+
[]``, ``counts: ""``, ``repointed: 0``, ``stale_decisions: []``, ``empty_domains:
|
|
732
|
+
[]``, ``overbroad_domains: []``, ``slug_conflicts: []``, ``domains_refreshed: 0``,
|
|
733
|
+
``domain_failures: []``) from a fresh, un-run ``SyncReport(skipped=True)`` -- NOT a
|
|
734
|
+
prior (possibly stale) report --
|
|
735
|
+
so a caller must never read a skipped pass as "everything's clean" on its own;
|
|
736
|
+
``report_has_findings`` below happens to still read a skipped dict as clean (nothing to
|
|
737
|
+
find), which is exactly the "version-skip is exit 0" contract ``--check`` wants.
|
|
738
|
+
|
|
739
|
+
``outcomes`` carries only entities worth a human's attention -- moved/orphaned/
|
|
740
|
+
ambiguous/error -- filtered via ``_SYNC_OUTCOME_NOTEWORTHY``, never the "unchanged"/
|
|
741
|
+
"rebound" majority, same filter ``sidegraph-sync``'s own printer applies. ``counts`` is
|
|
742
|
+
``report.counts()`` rendered as a string (e.g. ``"{'unchanged': 3}"``), ``""`` when
|
|
743
|
+
nothing is tracked yet. ``repointed`` sums EVERY outcome's ``repointed``, not just the
|
|
744
|
+
filtered ones.
|
|
745
|
+
"""
|
|
746
|
+
outcomes = [
|
|
747
|
+
{"status": o.status, "canonical_name": o.canonical_name, "detail": o.detail}
|
|
748
|
+
for o in report.outcomes
|
|
749
|
+
if o.status in _SYNC_OUTCOME_NOTEWORTHY
|
|
750
|
+
]
|
|
751
|
+
counts = report.counts()
|
|
752
|
+
return {
|
|
753
|
+
"synced": not report.skipped,
|
|
754
|
+
"from_version": report.from_version,
|
|
755
|
+
"to_version": report.to_version,
|
|
756
|
+
"counts": str(counts) if counts else "",
|
|
757
|
+
"repointed": sum(o.repointed for o in report.outcomes),
|
|
758
|
+
"outcomes": outcomes,
|
|
759
|
+
"stale_decisions": report.stale_decisions,
|
|
760
|
+
"empty_domains": report.empty_domains,
|
|
761
|
+
"overbroad_domains": report.overbroad_domains,
|
|
762
|
+
"slug_conflicts": report.slug_conflicts,
|
|
763
|
+
"domains_refreshed": report.domains_refreshed,
|
|
764
|
+
"domain_failures": report.domain_failures,
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def report_has_findings(d: dict) -> bool:
|
|
769
|
+
"""True iff a ``report_as_dict`` dict has an attention finding -- design/superpowers/
|
|
770
|
+
specs/2026-07-11-ci-live-findings-design.md ruling 1: an ``error`` outcome, OR a
|
|
771
|
+
non-empty ``stale_decisions``, OR a non-empty ``slug_conflicts``, OR a non-empty
|
|
772
|
+
``domain_failures``. ``orphaned``/``ambiguous`` outcomes and ``empty_domains``/
|
|
773
|
+
``overbroad_domains`` are deliberately excluded -- informational, never fail the check
|
|
774
|
+
on their own. The orphaned/ambiguous exclusion is the live-experiment refinement: a
|
|
775
|
+
legitimate rename+heal leaves the renamed-away entity's leaf orphaned for good (no
|
|
776
|
+
retirement path in an append-only store), and that residue must not red-flag a healing
|
|
777
|
+
PR -- or the default branch after it merges -- forever; when an orphaned/ambiguous
|
|
778
|
+
anchor actually costs reachability (a decision's only live anchor), the decision goes
|
|
779
|
+
stale and ``stale_decisions`` already fires, so the failure signal is covered there
|
|
780
|
+
instead. Drives ``sidegraph-sync --check``'s exit code (2 on True, 0 on False); a
|
|
781
|
+
skipped-pass dict (``synced: False``, everything else empty) always reads False, so a
|
|
782
|
+
version-skip is exit 0, same as a clean report.
|
|
783
|
+
"""
|
|
784
|
+
if any(o["status"] in _FINDING_OUTCOME_STATUSES for o in d["outcomes"]):
|
|
785
|
+
return True
|
|
786
|
+
# domain_failures joins the finding set while orphaned/ambiguous stay out, and the
|
|
787
|
+
# 2026-07-11 exclusion is the reason why: those leave PERMANENT residue in an
|
|
788
|
+
# append-only store (a renamed-away leaf has no retirement path), so gating on them
|
|
789
|
+
# keeps CI red forever on a healthy repo. A domain_failure is the opposite shape — it
|
|
790
|
+
# exists only while a refresh actually raises. But unlike an ordinary per-entity retry,
|
|
791
|
+
# it does NOT reliably disappear once the seed/domain/graph is fixed: a completed pass
|
|
792
|
+
# (this one) clears VOLATILE_STALE_KEY and stamps last_synced regardless of whether this
|
|
793
|
+
# domain's own refresh succeeded, so the NEXT pass is gated on graph_version again and
|
|
794
|
+
# never re-attempts an unfixed domain until the graph version moves or a caller passes
|
|
795
|
+
# `--force`/`force=True`. Same class as an `error` outcome for `--check` purposes; fix
|
|
796
|
+
# the cause, then re-run with `--force` to confirm it cleared.
|
|
797
|
+
return bool(d["stale_decisions"] or d["slug_conflicts"] or d["domain_failures"])
|
|
798
|
+
|
|
799
|
+
|
|
800
|
+
def maybe_sync(store: Store, reader: GraphifyReader | None) -> SyncReport | None:
|
|
801
|
+
"""Lazy read-path trigger: no reader -> None; else sync (self-skips on version match).
|
|
802
|
+
|
|
803
|
+
Callers wrap this best-effort — a sync failure must never break retrieval or startup.
|
|
804
|
+
"""
|
|
805
|
+
if reader is None:
|
|
806
|
+
return None
|
|
807
|
+
return sync(store, reader)
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def refresh_code_drift_cache(
|
|
811
|
+
store: Store, *, repo_root: Path | None = None, deadline: float = 10.0
|
|
812
|
+
) -> int | None:
|
|
813
|
+
"""Rescan code drift and merge the result into the :data:`~sidegraph.retrieval.
|
|
814
|
+
DRIFT_CACHE_KEY` meta cache — the write half of the drift→supersede affordance (D2).
|
|
815
|
+
|
|
816
|
+
Placed in this module because hooks already import it and it writes derived meta
|
|
817
|
+
today (the TOC cache) — by-convention placement; ``sync()`` deliberately does NOT
|
|
818
|
+
call this (the SessionStart hook runs ``maybe_sync`` first, and a second scan there
|
|
819
|
+
would double the git cost for nothing).
|
|
820
|
+
|
|
821
|
+
ALWAYS rescans — no head-stamp no-op: "same HEAD ⇒ no new drift" is false under
|
|
822
|
+
``add_anchors`` (bindings-only write), sync rebinds flipping ``orphaned → live``,
|
|
823
|
+
and backward HEAD movement. Cost is bounded by ``deadline`` (a TOTAL scan budget,
|
|
824
|
+
see ``scan_code_drift``).
|
|
825
|
+
|
|
826
|
+
Merge semantics, per capture commit (spec round-2 N1 — a partial git failure must
|
|
827
|
+
neither erase real markers nor freeze the cache forever):
|
|
828
|
+
|
|
829
|
+
- inactive scan (nothing commit-stamped and anchored) → return ``0``, write nothing;
|
|
830
|
+
- unscanned (repo root resolution failed — no batch ran) → leave any existing cache
|
|
831
|
+
byte-untouched, return ``None``. A failed HEAD resolution alone does NOT discard a
|
|
832
|
+
scan whose batches ran (review M-2: ``head`` is provenance only and gates nothing
|
|
833
|
+
— the cache is written with ``"head": null``);
|
|
834
|
+
- otherwise: a successful batch REPLACES its commit's entry (an empty list is a
|
|
835
|
+
normal write — that is how markers clear once a repair supersede plus its commit
|
|
836
|
+
land); a failed batch RETAINS the previous cache's entry for its commit, if any;
|
|
837
|
+
commits absent from the scan's batch set drop out entirely (their records left
|
|
838
|
+
the live+stamped+anchored join).
|
|
839
|
+
|
|
840
|
+
Returns the live-filtered flattened count (``drifted_record_ids`` — the one filter
|
|
841
|
+
definition, shared with the render side). Never raises: any unexpected failure
|
|
842
|
+
returns ``None`` with the cache untouched (hook-caller contract).
|
|
843
|
+
# see design/superpowers/specs/2026-07-30-drift-supersede-affordance-design.md (D2)
|
|
844
|
+
"""
|
|
845
|
+
try:
|
|
846
|
+
scan = scan_code_drift(store.path, repo_root, deadline=deadline)
|
|
847
|
+
if not scan.active:
|
|
848
|
+
return 0
|
|
849
|
+
if scan.repo_root_failed:
|
|
850
|
+
return None
|
|
851
|
+
|
|
852
|
+
prev_by_commit: dict[str, list[str]] = {}
|
|
853
|
+
raw = store.get_meta(DRIFT_CACHE_KEY)
|
|
854
|
+
if raw is not None:
|
|
855
|
+
try:
|
|
856
|
+
prev = json.loads(raw)
|
|
857
|
+
if isinstance(prev, dict) and isinstance(prev.get("by_commit"), dict):
|
|
858
|
+
prev_by_commit = {
|
|
859
|
+
c: [r for r in ids if isinstance(r, str)]
|
|
860
|
+
for c, ids in prev["by_commit"].items()
|
|
861
|
+
if isinstance(ids, list)
|
|
862
|
+
}
|
|
863
|
+
except json.JSONDecodeError:
|
|
864
|
+
pass
|
|
865
|
+
|
|
866
|
+
by_commit: dict[str, list[str]] = {}
|
|
867
|
+
for batch in scan.batches:
|
|
868
|
+
if batch.failed:
|
|
869
|
+
if batch.commit in prev_by_commit:
|
|
870
|
+
by_commit[batch.commit] = prev_by_commit[batch.commit]
|
|
871
|
+
else:
|
|
872
|
+
by_commit[batch.commit] = sorted({e.record_id for e in batch.entries})
|
|
873
|
+
store.set_meta(
|
|
874
|
+
DRIFT_CACHE_KEY,
|
|
875
|
+
json.dumps(
|
|
876
|
+
{
|
|
877
|
+
"head": scan.head,
|
|
878
|
+
"computed_at": datetime.now(UTC).isoformat(),
|
|
879
|
+
"by_commit": by_commit,
|
|
880
|
+
}
|
|
881
|
+
),
|
|
882
|
+
)
|
|
883
|
+
return len(drifted_record_ids(store))
|
|
884
|
+
except Exception:
|
|
885
|
+
return None
|