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/domains.py
ADDED
|
@@ -0,0 +1,902 @@
|
|
|
1
|
+
"""Bootstrap ``Domain`` proposals from graph communities (portable core, path 1 of the
|
|
2
|
+
three authoring paths — see docs/concepts/mind-model.md#domain-lifecycle).
|
|
3
|
+
|
|
4
|
+
Reads communities via the engine seam (``GraphifyReader.communities()`` +
|
|
5
|
+
``community_labels()``) and writes proposals through ``store.add_domain`` — same
|
|
6
|
+
"deterministic pipeline, status=proposed" shape as ``importer.import_rationales`` and
|
|
7
|
+
``capture.propose_domains``, but scanning the graph's own community structure instead of
|
|
8
|
+
rationale nodes or agent drafts. One ``Domain`` draft per *significant* community (member
|
|
9
|
+
count, counting only anchorable nodes, at or above ``min_members``): slug/title/summary
|
|
10
|
+
prefer the engine's community label when the corpus has one, falling back to a
|
|
11
|
+
deterministic god-node-derived name/digest otherwise. Everything lands ``status=proposed``
|
|
12
|
+
— bootstrap is not exempt from the single ratification gate (§4).
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
|
|
19
|
+
from pydantic import BaseModel, Field
|
|
20
|
+
|
|
21
|
+
from .capture import (
|
|
22
|
+
AutoEligibility,
|
|
23
|
+
RatifyPolicy,
|
|
24
|
+
_auto_ratify,
|
|
25
|
+
_domain_anchored,
|
|
26
|
+
_lint_domain_path_prefixes,
|
|
27
|
+
auto_ratify_eligible,
|
|
28
|
+
redact,
|
|
29
|
+
)
|
|
30
|
+
from .engine.reader import ANCHORABLE_FILE_TYPES, Community, GraphifyReader
|
|
31
|
+
from .retrieval import TOC_CACHE_KEY, build_toc
|
|
32
|
+
from .schema import Descriptor, Domain, DomainStatus, Provenance, matches_path_prefix, slugify
|
|
33
|
+
from .store import Store
|
|
34
|
+
from .sync import activate_accepted_domain
|
|
35
|
+
|
|
36
|
+
# Below this fraction of anchorable members sharing a single top-level directory,
|
|
37
|
+
# path_prefixes is left empty rather than derived from a weak majority (§4.1: "≥80% of
|
|
38
|
+
# members share a top-level dir").
|
|
39
|
+
_PATH_PREFIX_THRESHOLD = 0.8
|
|
40
|
+
|
|
41
|
+
# How many member names surface in the deterministic digest fallback summary
|
|
42
|
+
# ("N entities incl. a, b, c — around <top file>").
|
|
43
|
+
_DIGEST_SAMPLE_SIZE = 3
|
|
44
|
+
|
|
45
|
+
# Well-known shared/infra top-level directory names: NEVER derived as a path_prefixes
|
|
46
|
+
# stabilizer, even when a community's members overwhelmingly share one of them. Gate-5
|
|
47
|
+
# blocker: a community that clusters with its own tests/ (>=80% of its anchorable members
|
|
48
|
+
# live there) is the NORMAL case for a god-node community with per-module tests, not
|
|
49
|
+
# evidence "tests/" is that community's home — deriving it as a stabilizer let sync's
|
|
50
|
+
# REPLACE refresh (see sync._recompute_domain_communities) legitimately, and silently,
|
|
51
|
+
# expand the domain to swallow every OTHER community that also ships tests/ (true of any
|
|
52
|
+
# community in the corpus). See docs/guides/naming-your-domains.md.
|
|
53
|
+
_SHARED_DIR_NAMES = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"test",
|
|
56
|
+
"tests",
|
|
57
|
+
"src",
|
|
58
|
+
"lib",
|
|
59
|
+
"docs",
|
|
60
|
+
"doc",
|
|
61
|
+
"examples",
|
|
62
|
+
"scripts",
|
|
63
|
+
"tools",
|
|
64
|
+
"utils",
|
|
65
|
+
}
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
# A path-derived candidate is also skipped when the communities sharing its winning
|
|
69
|
+
# directory — the candidate's own community INCLUDED — already add up to more than this
|
|
70
|
+
# fraction of ALL communities in the graph. Both the numerator (the claimed set, computed
|
|
71
|
+
# by _prefix_community_breadth) and the denominator (the total community count) count the
|
|
72
|
+
# candidate's own community; this is deliberately the SAME ratio
|
|
73
|
+
# sync._recompute_domain_communities computes for its refresh-time _DOMAIN_CLAIM_CAP
|
|
74
|
+
# (Option A, owner-approved 2026-09-14: the two guards used to disagree — derive excluded
|
|
75
|
+
# the candidate's own community from its "other communities" count while refresh included
|
|
76
|
+
# it, so bootstrap could derive a stabilizer the very next refresh rejected as overbroad.
|
|
77
|
+
# Tightening derive to match refresh's numerator, over the SAME graph snapshot, is the
|
|
78
|
+
# goal — a rule this guard lets through should never trip the refresh cap AT THE MOMENT
|
|
79
|
+
# IT IS DERIVED. That depended on _majority_top_dir and _prefix_community_breadth
|
|
80
|
+
# decomposing file_path the SAME way, which round 1 alone did not achieve — see
|
|
81
|
+
# _prefix_community_breadth's docstring, NEW Major A, fix round 2. This says nothing
|
|
82
|
+
# about a LATER graph rebuild either way — a directory one community owns exclusively
|
|
83
|
+
# today can be shared by five tomorrow, and refresh capping the rule then is the design
|
|
84
|
+
# working as intended, not a regression of this guarantee). Generalizes the
|
|
85
|
+
# _SHARED_DIR_NAMES guard past its fixed name list to any cross-cutting directory pattern
|
|
86
|
+
# (vendored deps, generated code, ...).
|
|
87
|
+
_PREFIX_BREADTH_CAP = 0.2
|
|
88
|
+
|
|
89
|
+
# Scale-aware default `limit` (finding B, scale-robustness hardening wave): on a monorepo-
|
|
90
|
+
# scale corpus (Airflow: 5,517 communities, 2,578 significant) an unbounded
|
|
91
|
+
# `list_domain_candidates` call is a ~276K-token dump — ~140x a normal budget. 100 is the
|
|
92
|
+
# measured sweet spot (~10K tokens). This is a PUBLIC-SURFACE default applied by the
|
|
93
|
+
# `list_domain_candidates` MCP tool and the `sidegraph-domains bootstrap` CLI, one shared
|
|
94
|
+
# constant so the two stay consistent by construction; `collect_domain_candidates`'s own
|
|
95
|
+
# `limit` parameter default stays `None` (unlimited) -- callers that want everything
|
|
96
|
+
# (tests included) never have to fight a collector-level default.
|
|
97
|
+
DEFAULT_CANDIDATE_LIMIT = 100
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
class BootstrapReport(BaseModel):
|
|
101
|
+
"""Counts (+ dry-run-only listing) for one ``bootstrap_domains`` run.
|
|
102
|
+
|
|
103
|
+
``proposed``/``skipped_existing`` reflect what WOULD happen whether or not
|
|
104
|
+
``dry_run`` actually wrote anything (mirrors ``ImportReport``); ``below_threshold``
|
|
105
|
+
and ``filtered`` explain why a community was never even considered a candidate.
|
|
106
|
+
``dry_run`` carries the listing and is populated only on a dry run.
|
|
107
|
+
|
|
108
|
+
``total_before_limit`` (finding B/C) mirrors ``CollectStats.total_before_limit`` — the
|
|
109
|
+
full significant-community count BEFORE ``limit`` truncated it, computed either way
|
|
110
|
+
(dry run or not). The CLI uses it to note when ``--limit`` (default or explicit) cut
|
|
111
|
+
candidates the run never even considered, in BOTH the ``--dry-run`` listing and the
|
|
112
|
+
real, pre-write threshold check (BUG C: that check runs off a separate, read-only
|
|
113
|
+
``collect_domain_candidates`` call made BEFORE ``bootstrap_domains`` writes anything —
|
|
114
|
+
see ``cli._domains_bootstrap``).
|
|
115
|
+
"""
|
|
116
|
+
|
|
117
|
+
proposed: int = 0
|
|
118
|
+
skipped_existing: int = 0
|
|
119
|
+
below_threshold: int = 0
|
|
120
|
+
filtered: int = 0
|
|
121
|
+
total_before_limit: int = 0
|
|
122
|
+
# Auto-ratification policy (design D2/D6) -- additive/defaulted, same contract as
|
|
123
|
+
# importer.ImportReport's/doc_import.DocImportReport's own pair: incremented/appended
|
|
124
|
+
# by the post-write auto block below, always empty/zero under `manual`/`auto-low-risk`
|
|
125
|
+
# (domains are `auto-all`-only, D3) or when a proposed domain was ineligible. A domain
|
|
126
|
+
# accepted before its activation step fails increments this AND appends the
|
|
127
|
+
# activation failure -- the counter reports canonical acceptance, not a clean run.
|
|
128
|
+
auto_ratified: int = 0
|
|
129
|
+
auto_ratify_failures: list[str] = Field(default_factory=list) # ["<domain id>: <reason>"]
|
|
130
|
+
dry_run: list[dict] = Field(default_factory=list) # [{"community_id", "slug", ...}, ...]
|
|
131
|
+
# design D7.4 (staleness-machinery wave, E8 gate checklist): deterministic lint
|
|
132
|
+
# warnings, one entry per candidate that has >= 1 (see capture._lint_domain_path_
|
|
133
|
+
# prefixes) -- {"slug", "warnings": [...]}. Populated on BOTH dry-run and real runs;
|
|
134
|
+
# advisory only, never blocks the write -- under `auto-all` any warning keeps the
|
|
135
|
+
# draft proposed.
|
|
136
|
+
warnings: list[dict] = Field(default_factory=list)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
class DomainCandidate(BaseModel):
|
|
140
|
+
"""One significant community's fully-resolved bootstrap proposal, as computed by
|
|
141
|
+
``collect_domain_candidates`` — the single source of truth ``bootstrap_domains``
|
|
142
|
+
(which writes it) and the read-only ``list_domain_candidates`` MCP tool (which only
|
|
143
|
+
displays it) both build on, so the tool always shows exactly what the CLI would
|
|
144
|
+
propose (see domain-onboarding design §1)."""
|
|
145
|
+
|
|
146
|
+
community_id: str
|
|
147
|
+
suggested_slug: str
|
|
148
|
+
suggested_title: str
|
|
149
|
+
summary: str
|
|
150
|
+
path_prefixes: list[str] = Field(default_factory=list)
|
|
151
|
+
member_count: int
|
|
152
|
+
top_members: list[str] = Field(default_factory=list) # <= _DIGEST_SAMPLE_SIZE names
|
|
153
|
+
top_file: str | None = None
|
|
154
|
+
has_label: bool = False # a validated engine label survived _label_names_other_community
|
|
155
|
+
# The community's god-node resolved to a durable name+file_path Descriptor (§2a
|
|
156
|
+
# amendment) -- what `list_domain_candidates` surfaces so the name-domains skill can
|
|
157
|
+
# build a Domain.seed_anchors entry from a candidate, rather than the volatile
|
|
158
|
+
# community_id alone (which does not survive a fresh clone/rebuild). None only when the
|
|
159
|
+
# community has no god node, or the god node itself has no name (defensive).
|
|
160
|
+
anchor: Descriptor | None = None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
class CollectStats(BaseModel):
|
|
164
|
+
"""Selection counts for one ``collect_domain_candidates`` run — the shared basis for
|
|
165
|
+
``BootstrapReport``'s fields and ``list_domain_candidates``'s ``skipped``/
|
|
166
|
+
``already_claimed`` output. ``already_claimed`` folds together the two "this
|
|
167
|
+
community isn't a NEW candidate" reasons the collector's Pass 1 can hit — the
|
|
168
|
+
community itself already claimed by a non-superseded domain, and a computed slug
|
|
169
|
+
colliding with one already in the store — matching ``BootstrapReport.skipped_existing``
|
|
170
|
+
exactly (see ``collect_domain_candidates``'s own docstring, "Idempotent:" paragraph,
|
|
171
|
+
for why both count as "existing").
|
|
172
|
+
|
|
173
|
+
``total_before_limit`` (finding B) is the significant (threshold/path-filter-surviving)
|
|
174
|
+
community count BEFORE ``limit`` truncates it — cheap to compute (a bare ``len()`` right
|
|
175
|
+
after the filter loop, no per-candidate label/summary/path_prefixes work, which stays
|
|
176
|
+
bounded to at most ``limit`` items). This is the basis for ``list_domain_candidates``'s
|
|
177
|
+
``truncated``/``total_significant`` signaling and ``sidegraph-domains bootstrap``'s
|
|
178
|
+
before-write over-threshold nag: comparing it against the requested ``limit`` answers
|
|
179
|
+
"were there more candidates than what got shown/written?" independent of the SEPARATE
|
|
180
|
+
already-claimed skip, which only ever narrows the (possibly already-limited) survivor
|
|
181
|
+
set further and says nothing about how many candidates existed in total."""
|
|
182
|
+
|
|
183
|
+
total: int = 0
|
|
184
|
+
total_before_limit: int = 0
|
|
185
|
+
already_claimed: int = 0
|
|
186
|
+
below_threshold: int = 0
|
|
187
|
+
filtered: int = 0
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _anchorable_member_ids(community_members: list[str], reader: GraphifyReader) -> list[str]:
|
|
191
|
+
out = []
|
|
192
|
+
for node_id in community_members:
|
|
193
|
+
node = reader.get_node(node_id)
|
|
194
|
+
if node is not None and node.file_type in ANCHORABLE_FILE_TYPES:
|
|
195
|
+
out.append(node_id)
|
|
196
|
+
return out
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _matches_any_prefix(node_ids: list[str], prefixes: list[str], reader: GraphifyReader) -> bool:
|
|
200
|
+
for node_id in node_ids:
|
|
201
|
+
node = reader.get_node(node_id)
|
|
202
|
+
if (
|
|
203
|
+
node is not None
|
|
204
|
+
and node.file_path is not None
|
|
205
|
+
and any(matches_path_prefix(node.file_path, p) for p in prefixes)
|
|
206
|
+
):
|
|
207
|
+
return True
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _top_file(
|
|
212
|
+
anchorable_ids: list[str],
|
|
213
|
+
god_node_id: str | None,
|
|
214
|
+
reader: GraphifyReader,
|
|
215
|
+
) -> str | None:
|
|
216
|
+
"""The god node's file, when it has one; else the most common file_path among
|
|
217
|
+
members (a deterministic stand-in for "where this community lives" when no single
|
|
218
|
+
node dominates by degree)."""
|
|
219
|
+
if god_node_id is not None:
|
|
220
|
+
god = reader.get_node(god_node_id)
|
|
221
|
+
if god is not None and god.file_path:
|
|
222
|
+
return god.file_path
|
|
223
|
+
counts: dict[str, int] = {}
|
|
224
|
+
for node_id in anchorable_ids:
|
|
225
|
+
node = reader.get_node(node_id)
|
|
226
|
+
if node is not None and node.file_path:
|
|
227
|
+
counts[node.file_path] = counts.get(node.file_path, 0) + 1
|
|
228
|
+
if not counts:
|
|
229
|
+
return None
|
|
230
|
+
return max(counts.items(), key=lambda kv: kv[1])[0]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _god_node_descriptor(god_node_id: str | None, reader: GraphifyReader) -> Descriptor | None:
|
|
234
|
+
"""The community's god-node resolved to a durable ``Descriptor`` (name + file_path) —
|
|
235
|
+
what a ``Domain.seed_anchors`` entry needs to durably anchor to this community across
|
|
236
|
+
clones/rebuilds (§2a amendment). ``None`` when the community has no god node, or the
|
|
237
|
+
god node has no name (defensive — mirrors the blank-name guard ``collect_domain_
|
|
238
|
+
candidates`` already applies to the god-node title fallback)."""
|
|
239
|
+
if god_node_id is None:
|
|
240
|
+
return None
|
|
241
|
+
node = reader.get_node(god_node_id)
|
|
242
|
+
if node is None or not node.name.strip():
|
|
243
|
+
return None
|
|
244
|
+
return Descriptor(name=node.name, file_path=node.file_path)
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def _top_member_names(
|
|
248
|
+
anchorable_ids: list[str],
|
|
249
|
+
reader: GraphifyReader,
|
|
250
|
+
limit: int = _DIGEST_SAMPLE_SIZE,
|
|
251
|
+
) -> list[str]:
|
|
252
|
+
"""First ``limit`` named anchorable members, in member-list order — the same sample
|
|
253
|
+
``_member_digest``'s "incl. a, b, c" prose draws from, also surfaced verbatim as a
|
|
254
|
+
``DomainCandidate``'s ``top_members`` for the ``list_domain_candidates`` tool."""
|
|
255
|
+
names: list[str] = []
|
|
256
|
+
for node_id in anchorable_ids:
|
|
257
|
+
node = reader.get_node(node_id)
|
|
258
|
+
if node is not None and node.name:
|
|
259
|
+
names.append(node.name)
|
|
260
|
+
if len(names) >= limit:
|
|
261
|
+
break
|
|
262
|
+
return names
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def _member_digest(
|
|
266
|
+
anchorable_ids: list[str],
|
|
267
|
+
god_node_id: str | None,
|
|
268
|
+
reader: GraphifyReader,
|
|
269
|
+
) -> str:
|
|
270
|
+
"""Deterministic fallback summary when the engine has no label for this community:
|
|
271
|
+
"N entities incl. a, b, c — around <top file>"."""
|
|
272
|
+
names = _top_member_names(anchorable_ids, reader)
|
|
273
|
+
incl = ", ".join(names) if names else "unnamed members"
|
|
274
|
+
n = len(anchorable_ids)
|
|
275
|
+
top_file = _top_file(anchorable_ids, god_node_id, reader)
|
|
276
|
+
if top_file:
|
|
277
|
+
return f"{n} entities incl. {incl} — around {top_file}"
|
|
278
|
+
return f"{n} entities incl. {incl}"
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _prefix_community_breadth(reader: GraphifyReader) -> tuple[dict[str, set[str]], int]:
|
|
282
|
+
"""Precompute, once per bootstrap run: for every top-level directory that appears in
|
|
283
|
+
an anchorable node's file_path ANYWHERE in the graph, the set of community ids that
|
|
284
|
+
have >= 1 such node, plus the total number of distinct communities in the graph. This
|
|
285
|
+
is the denominator/lookup ``_derive_path_prefixes``'s breadth guard needs (see
|
|
286
|
+
``_PREFIX_BREADTH_CAP``) — computed once rather than per-candidate-community, since
|
|
287
|
+
it depends on the whole graph, not any one community's members.
|
|
288
|
+
|
|
289
|
+
The index key is ``file_path.split("/", 1)[0]`` — the text up to (not including) the
|
|
290
|
+
first ``"/"``, or the whole string when there is none (fix round 1, Major 1: a
|
|
291
|
+
``PurePosixPath`` + "skip anything under 2 parts" version used here before missed a
|
|
292
|
+
``file_path`` that IS the bare directory name, e.g. ``"trader"``, or that name plus a
|
|
293
|
+
trailing slash, e.g. ``"trader/"`` — both match ``schema.matches_path_prefix`` at
|
|
294
|
+
refresh time but were invisible to derive's breadth count, so a community sharing a
|
|
295
|
+
directory only through such a node could trip the refresh cap without derive ever
|
|
296
|
+
seeing it coming). No length filter: a bare ``"trader"`` node contributes to
|
|
297
|
+
``dir_to_communities["trader"]`` exactly as ``"trader/x.py"`` does, same as
|
|
298
|
+
``matches_path_prefix("trader", "trader")`` is ``True``.
|
|
299
|
+
|
|
300
|
+
``_majority_top_dir`` decomposes each candidate's OWN members' paths with
|
|
301
|
+
``str.partition("/")`` for the identical head value (fix round 2, NEW Major A: it was
|
|
302
|
+
still on ``PurePosixPath`` when this function's key moved above, so the two disagreed
|
|
303
|
+
on an absolute ``file_path`` — ``PurePosixPath("/a/x.py").parts[0]`` is ``"/"``, this
|
|
304
|
+
function's key for the same path is ``""`` — and derive could pick a majority top
|
|
305
|
+
directory this dict had no entry for at all, so the single-community floor waved it
|
|
306
|
+
through unchecked). ``_majority_top_dir`` additionally REJECTS an empty head (an
|
|
307
|
+
absolute path can never become a majority top directory — see its own docstring), so
|
|
308
|
+
every ``top_dir`` this module can actually derive is now a key this dict's lookup
|
|
309
|
+
treats the identical way ``matches_path_prefix`` would.
|
|
310
|
+
"""
|
|
311
|
+
dir_to_communities: dict[str, set[str]] = {}
|
|
312
|
+
all_communities: set[str] = set()
|
|
313
|
+
for node in reader.list_nodes():
|
|
314
|
+
if node.community is not None:
|
|
315
|
+
all_communities.add(node.community)
|
|
316
|
+
if node.file_type not in ANCHORABLE_FILE_TYPES or not node.file_path:
|
|
317
|
+
continue
|
|
318
|
+
if node.community is None:
|
|
319
|
+
continue
|
|
320
|
+
top_dir = node.file_path.split("/", 1)[0]
|
|
321
|
+
dir_to_communities.setdefault(top_dir, set()).add(node.community)
|
|
322
|
+
return dir_to_communities, len(all_communities)
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _majority_top_dir(anchorable_ids: list[str], reader: GraphifyReader) -> str | None:
|
|
326
|
+
"""The single top-level directory shared by >= ``_PATH_PREFIX_THRESHOLD`` of the
|
|
327
|
+
members that HAVE a file_path (and a directory component); else ``None`` — a weak
|
|
328
|
+
majority is worse than no signal at all. The bare majority calc, with neither of
|
|
329
|
+
``_derive_path_prefixes``'s two stabilizer-only vetoes applied — reused by
|
|
330
|
+
``community_group_path`` for ``list_domain_candidates``'s presentational grouping
|
|
331
|
+
(a structure HINT, never a membership rule, so those vetoes don't apply there).
|
|
332
|
+
|
|
333
|
+
Decomposes each ``file_path`` with ``str.partition("/")`` — ``head, sep, _rest`` —
|
|
334
|
+
the SAME single decomposition ``_prefix_community_breadth`` uses, and equivalent to
|
|
335
|
+
``schema.matches_path_prefix`` for every head this function can return (fix round 2,
|
|
336
|
+
NEW Major A: this function was left on ``PurePosixPath`` when round 1 moved breadth's
|
|
337
|
+
own key to ``split("/", 1)[0]``, so the two disagreed on an absolute path —
|
|
338
|
+
``PurePosixPath("/a/x.py").parts[0]`` is ``"/"``, the partition head is ``""`` — and
|
|
339
|
+
derive could pick a majority directory breadth had no record of at all). NOT the same
|
|
340
|
+
decomposition ``matches_path_prefix`` itself performs — that function never
|
|
341
|
+
partitions anything, it compares with ``==``/``startswith`` — merely one that always
|
|
342
|
+
agrees with it for a single-segment prefix (measured exhaustively over 9,331
|
|
343
|
+
synthetic paths and every real corpus in the workspace, fix round 2 review).
|
|
344
|
+
|
|
345
|
+
A member "has a directory component" (counts toward the vote) only when there IS a
|
|
346
|
+
separator AND the head is non-empty: no separator (``"x.py"``) is skipped exactly as
|
|
347
|
+
before the round-2 fix; an EMPTY head (``"/a/x.py"``, ``"//a/x.py"``) is skipped too
|
|
348
|
+
— an absolute path can never become a majority top directory, since after
|
|
349
|
+
``matches_path_prefix`` strips a trailing slash from an empty-string prefix, it would
|
|
350
|
+
match every absolute path AND every empty ``file_path`` in the graph. This "no
|
|
351
|
+
separator" skip is DELIBERATELY narrower than ``_prefix_community_breadth``'s own
|
|
352
|
+
filter (fix round 3, NEW Minor C: a bare ``"trader"`` node still indexes under
|
|
353
|
+
``dir_breadth["trader"]`` there, unchanged — see that function's docstring) because
|
|
354
|
+
the two functions answer different questions over the SAME decomposed head: this one
|
|
355
|
+
asks which directory a community's OWN members mostly live in — a member with no
|
|
356
|
+
separator has no directory to vote for, full stop — while breadth asks which
|
|
357
|
+
candidate top directories a refresh with prefix D would go on to MATCH — and
|
|
358
|
+
``matches_path_prefix(fp, fp)`` is ``True`` for a bare name matching itself exactly,
|
|
359
|
+
so a bare-name node must still be indexed under its own name for that question. A
|
|
360
|
+
majority function that let bare names vote would propose file names like
|
|
361
|
+
``"pyproject.toml"`` as directory stabilizers on a real corpus, instead of a
|
|
362
|
+
community's actual home directory (or no stabilizer, correctly, when there is
|
|
363
|
+
none) — mutant F15 (fix round 3 review) is this exact veto removed, and
|
|
364
|
+
``test_bootstrap_majority_ignores_a_root_level_file_like_a_missing_vote`` below pins
|
|
365
|
+
it."""
|
|
366
|
+
dirs: dict[str, int] = {}
|
|
367
|
+
total = 0
|
|
368
|
+
for node_id in anchorable_ids:
|
|
369
|
+
node = reader.get_node(node_id)
|
|
370
|
+
if node is None or not node.file_path:
|
|
371
|
+
continue
|
|
372
|
+
head, sep, _rest = node.file_path.partition("/")
|
|
373
|
+
if not sep or not head:
|
|
374
|
+
continue # no directory component to group by (bare name, or an absolute path)
|
|
375
|
+
total += 1
|
|
376
|
+
dirs[head] = dirs.get(head, 0) + 1
|
|
377
|
+
if total == 0:
|
|
378
|
+
return None
|
|
379
|
+
top_dir, count = max(dirs.items(), key=lambda kv: kv[1])
|
|
380
|
+
if count / total < _PATH_PREFIX_THRESHOLD:
|
|
381
|
+
return None
|
|
382
|
+
return top_dir
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _derive_path_prefixes(
|
|
386
|
+
anchorable_ids: list[str],
|
|
387
|
+
community_id: str,
|
|
388
|
+
reader: GraphifyReader,
|
|
389
|
+
dir_breadth: dict[str, set[str]],
|
|
390
|
+
total_communities: int,
|
|
391
|
+
) -> list[str]:
|
|
392
|
+
"""``[_majority_top_dir(...)]`` when there is one; else ``[]``.
|
|
393
|
+
|
|
394
|
+
Past the majority check, two more guards (Gate-5 finding, see the module-level
|
|
395
|
+
``_SHARED_DIR_NAMES``/``_PREFIX_BREADTH_CAP`` docstrings) can still veto the winning
|
|
396
|
+
directory back to ``[]``: (a) it's a well-known shared/infra directory name, or (b) the
|
|
397
|
+
communities sharing it — its own community INCLUDED, via ``dir_breadth`` from
|
|
398
|
+
``_prefix_community_breadth`` — already add up to more than 20% of ALL communities in
|
|
399
|
+
the graph today (the same claimed-set-over-total ratio
|
|
400
|
+
``sync._recompute_domain_communities`` caps at refresh time, over the SAME graph
|
|
401
|
+
snapshot; the single-community floor below is the identical floor that guard has, for
|
|
402
|
+
the identical reason). This depends on ``top_dir`` itself and ``dir_breadth`` agreeing
|
|
403
|
+
on what a directory name IS (fix round 2, NEW Major A: ``_majority_top_dir`` used to
|
|
404
|
+
decompose ``file_path`` differently than ``_prefix_community_breadth`` did, so a
|
|
405
|
+
``top_dir`` this function picked could be a key ``dir_breadth`` had never populated at
|
|
406
|
+
all — the single-community floor then waved an absolute-path directory like ``"/"``
|
|
407
|
+
straight through; see both functions' own docstrings). Either veto is evidence the
|
|
408
|
+
directory is a cross-cutting pattern, not this community's own home, and deriving it
|
|
409
|
+
as a stabilizer would let sync's REPLACE refresh silently swallow every other
|
|
410
|
+
community that happens to share it too — today. A later graph rebuild can still grow
|
|
411
|
+
a directory's share past the cap; refresh capping the rule then is this guard's own
|
|
412
|
+
defense-in-depth working as designed, not a hole in what this function promises.
|
|
413
|
+
"""
|
|
414
|
+
top_dir = _majority_top_dir(anchorable_ids, reader)
|
|
415
|
+
if top_dir is None:
|
|
416
|
+
return []
|
|
417
|
+
if top_dir in _SHARED_DIR_NAMES:
|
|
418
|
+
return []
|
|
419
|
+
if total_communities > 0:
|
|
420
|
+
claimed = dir_breadth.get(top_dir, set()) | {community_id}
|
|
421
|
+
if len(claimed) > 1 and len(claimed) / total_communities > _PREFIX_BREADTH_CAP:
|
|
422
|
+
return []
|
|
423
|
+
return [top_dir]
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def community_group_path(
|
|
427
|
+
community_id: str,
|
|
428
|
+
reader: GraphifyReader,
|
|
429
|
+
communities_by_id: dict[str, Community] | None = None,
|
|
430
|
+
) -> str | None:
|
|
431
|
+
"""The presentational top-level-directory grouping hint for ``community_id`` — the
|
|
432
|
+
SAME majority-dir calc ``_derive_path_prefixes`` uses for its stabilizer rule, minus
|
|
433
|
+
both of its vetoes (a structure HINT only, per domain-onboarding design §1's "hybrid"
|
|
434
|
+
grouping — never a membership rule, so the vetoes that protect ``path_prefixes`` from
|
|
435
|
+
becoming an over-broad sync stabilizer don't apply here). Used by
|
|
436
|
+
``list_domain_candidates`` to group a ``DomainCandidate`` that didn't itself derive a
|
|
437
|
+
``path_prefixes`` stabilizer. ``communities_by_id``, when given, skips re-walking
|
|
438
|
+
``reader.communities()`` per candidate — the caller builds it once."""
|
|
439
|
+
by_id = communities_by_id
|
|
440
|
+
if by_id is None:
|
|
441
|
+
by_id = {c.community_id: c for c in reader.communities()}
|
|
442
|
+
community = by_id.get(community_id)
|
|
443
|
+
if community is None:
|
|
444
|
+
return None
|
|
445
|
+
anchorable = _anchorable_member_ids(community.members, reader)
|
|
446
|
+
return _majority_top_dir(anchorable, reader)
|
|
447
|
+
|
|
448
|
+
|
|
449
|
+
def _dedupe_identical_prefix_sets(
|
|
450
|
+
pending: list[dict],
|
|
451
|
+
reader: GraphifyReader,
|
|
452
|
+
dir_breadth: dict[str, set[str]],
|
|
453
|
+
total_communities: int,
|
|
454
|
+
) -> dict[str, list[str]]:
|
|
455
|
+
"""Derive ``path_prefixes`` for every ``pending`` bootstrap candidate this run WILL
|
|
456
|
+
propose, then dedupe: when >= 2 of them derive the IDENTICAL non-empty prefix set
|
|
457
|
+
(live finding — two communities each independently >= 80% under the same top-level
|
|
458
|
+
directory, e.g. both under 'trader/'), only the candidate with the MOST anchorable
|
|
459
|
+
members keeps it; the rest fall back to ``[]`` (title/summary/slug untouched, still
|
|
460
|
+
proposed). Ties on member count break on the LOWER community id
|
|
461
|
+
(``_community_sort_key`` — same numeric-aware ordering ``bootstrap_domains`` already
|
|
462
|
+
uses for candidate ordering, so the winner is deterministic across runs of the same
|
|
463
|
+
graph).
|
|
464
|
+
|
|
465
|
+
Without this, two domains racing for the same directory stabilizer within one run
|
|
466
|
+
would both carry a ``path_prefixes`` rule that matches the SAME code — a later sync
|
|
467
|
+
REPLACE refresh (``sync._recompute_domain_communities``) would then let both domains'
|
|
468
|
+
``communities`` legitimately point at (some of) the same nodes, the identical
|
|
469
|
+
"silently-overlapping memory" shape the Gate-5 breadth guard exists to prevent, just
|
|
470
|
+
triggered by two rules agreeing with each other instead of one rule alone reaching too
|
|
471
|
+
broad.
|
|
472
|
+
"""
|
|
473
|
+
derived: dict[str, list[str]] = {
|
|
474
|
+
p["community_id"]: _derive_path_prefixes(
|
|
475
|
+
p["anchorable"], p["community_id"], reader, dir_breadth, total_communities
|
|
476
|
+
)
|
|
477
|
+
for p in pending
|
|
478
|
+
}
|
|
479
|
+
member_counts = {p["community_id"]: len(p["anchorable"]) for p in pending}
|
|
480
|
+
|
|
481
|
+
by_prefix_set: dict[tuple[str, ...], list[str]] = {}
|
|
482
|
+
for community_id, prefixes in derived.items():
|
|
483
|
+
if prefixes:
|
|
484
|
+
by_prefix_set.setdefault(tuple(prefixes), []).append(community_id)
|
|
485
|
+
|
|
486
|
+
for community_ids in by_prefix_set.values():
|
|
487
|
+
if len(community_ids) < 2:
|
|
488
|
+
continue
|
|
489
|
+
winner = min(
|
|
490
|
+
community_ids,
|
|
491
|
+
key=lambda cid: (-member_counts[cid], _community_sort_key(cid)),
|
|
492
|
+
)
|
|
493
|
+
for cid in community_ids:
|
|
494
|
+
if cid != winner:
|
|
495
|
+
derived[cid] = []
|
|
496
|
+
|
|
497
|
+
return derived
|
|
498
|
+
|
|
499
|
+
|
|
500
|
+
def _dedupe_slug(base: str, used: set[str]) -> str:
|
|
501
|
+
"""Disambiguate two communities that would otherwise mint the same slug within THIS
|
|
502
|
+
run (e.g. two communities both god-node-named ``utils.py``) with -2/-3 suffixes.
|
|
503
|
+
Collisions against slugs already living in the store are handled separately, as a
|
|
504
|
+
skip (see the idempotency check in ``bootstrap_domains``) — never suffixed."""
|
|
505
|
+
if base not in used:
|
|
506
|
+
used.add(base)
|
|
507
|
+
return base
|
|
508
|
+
i = 2
|
|
509
|
+
candidate = f"{base}-{i}"
|
|
510
|
+
while candidate in used:
|
|
511
|
+
i += 1
|
|
512
|
+
candidate = f"{base}-{i}"
|
|
513
|
+
used.add(candidate)
|
|
514
|
+
return candidate
|
|
515
|
+
|
|
516
|
+
|
|
517
|
+
def _community_sort_key(community_id: str) -> tuple[int, object]:
|
|
518
|
+
"""Numeric communities sort numerically; anything else falls back to lexicographic —
|
|
519
|
+
keeps bootstrap output (and --limit's meaning) deterministic across runs of the same
|
|
520
|
+
graph.json regardless of dict/set iteration order."""
|
|
521
|
+
try:
|
|
522
|
+
return (0, int(community_id))
|
|
523
|
+
except ValueError:
|
|
524
|
+
return (1, community_id)
|
|
525
|
+
|
|
526
|
+
|
|
527
|
+
def _claimed_communities(store: Store) -> set[str]:
|
|
528
|
+
"""Community ids already claimed by a non-superseded domain (proposed, accepted, OR
|
|
529
|
+
dropped — matches ``find_domain_by_slug``'s "non-superseded" rule exactly, see
|
|
530
|
+
capture.py's ``_propose_domain_one``)."""
|
|
531
|
+
claimed: set[str] = set()
|
|
532
|
+
for domain in store.iter_domains():
|
|
533
|
+
if domain.status == DomainStatus.SUPERSEDED:
|
|
534
|
+
continue
|
|
535
|
+
claimed.update(domain.communities)
|
|
536
|
+
return claimed
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def _label_names_other_community(label: str, community_id: str, reader: GraphifyReader) -> bool:
|
|
540
|
+
"""True when ``label`` is demonstrably the engine's name for an entity that
|
|
541
|
+
``reader``'s CURRENT graph places in a community other than ``community_id``.
|
|
542
|
+
|
|
543
|
+
Guards against a real-corpus finding (independent evaluation, 2026-07-08): a
|
|
544
|
+
community's label in ``.graphify_labels.json`` does not always correspond to the
|
|
545
|
+
SAME community partition as the graph.json sitting next to it — verified even when
|
|
546
|
+
both come from the exact same ``graph_version`` (so not stale/drifted engine data,
|
|
547
|
+
just the label pass and the community field disagreeing on numbering). Trusting such
|
|
548
|
+
a label verbatim would title/slug a domain after an entity that isn't actually in the
|
|
549
|
+
community the domain stores, e.g. a domain named "OrderBook" whose stored community
|
|
550
|
+
is all TA-strategy files because the label sidecar's community numbering slipped.
|
|
551
|
+
|
|
552
|
+
Uses ``GraphifyReader.resolve`` — the same name -> node(s) -> community resolution
|
|
553
|
+
``capture``'s anchor binding already relies on — rather than re-deriving anything
|
|
554
|
+
here:
|
|
555
|
+
|
|
556
|
+
- ``resolved`` (one match): rejected when that node's community differs from
|
|
557
|
+
``community_id``.
|
|
558
|
+
- ``ambiguous`` (several same-named nodes): each candidate's OWN community is looked
|
|
559
|
+
up (``ResolveResult.candidates`` only carries node ids); rejected ONLY when NONE of
|
|
560
|
+
them live in ``community_id`` — i.e. every candidate is provably elsewhere. A label
|
|
561
|
+
with at least one candidate in ``community_id`` is plausibly correct and kept, even
|
|
562
|
+
though other, same-named entities exist elsewhere too (measured on the real corpus:
|
|
563
|
+
32/259 proposals had a label with zero candidates in its own community — all
|
|
564
|
+
provably wrong; the remaining ambiguous labels all had a candidate that matched,
|
|
565
|
+
and rejecting those too would have thrown away otherwise-correct titles).
|
|
566
|
+
- ``unresolved`` (no node anywhere has this name — free-form descriptive prose, e.g.
|
|
567
|
+
"Alpha Domain", a legitimate, tested label shape, see
|
|
568
|
+
``test_bootstrap_labeled_community_uses_label_for_title_and_summary``): no evidence
|
|
569
|
+
against it, kept.
|
|
570
|
+
"""
|
|
571
|
+
result = reader.resolve(Descriptor(name=label))
|
|
572
|
+
if result.status == "unresolved":
|
|
573
|
+
return False
|
|
574
|
+
if result.status == "resolved":
|
|
575
|
+
return result.community is not None and result.community != community_id
|
|
576
|
+
candidate_communities = {
|
|
577
|
+
node.community
|
|
578
|
+
for node in (reader.get_node(node_id) for node_id in result.candidates)
|
|
579
|
+
if node is not None and node.community is not None
|
|
580
|
+
}
|
|
581
|
+
return bool(candidate_communities) and community_id not in candidate_communities
|
|
582
|
+
|
|
583
|
+
|
|
584
|
+
def collect_domain_candidates(
|
|
585
|
+
store: Store,
|
|
586
|
+
reader: GraphifyReader,
|
|
587
|
+
*,
|
|
588
|
+
min_members: int = 5,
|
|
589
|
+
paths: list[str] | None = None,
|
|
590
|
+
limit: int | None = None,
|
|
591
|
+
) -> tuple[list[DomainCandidate], CollectStats]:
|
|
592
|
+
"""The deterministic candidate-selection body shared by ``bootstrap_domains`` (which
|
|
593
|
+
then writes each survivor as a ``Domain``) and the read-only ``list_domain_candidates``
|
|
594
|
+
MCP tool (which only displays them) — one source of truth, so the tool always shows
|
|
595
|
+
exactly what the CLI would propose (domain-onboarding design §1).
|
|
596
|
+
|
|
597
|
+
A community is *significant* when its anchorable member count (code/document/
|
|
598
|
+
concept/rationale nodes only — never-guess extends to file types, same filter as
|
|
599
|
+
``GraphifyReader.ANCHORABLE_FILE_TYPES``) is ``>= min_members``. ``paths``, when
|
|
600
|
+
given, further restricts candidates to communities with at least one anchorable
|
|
601
|
+
member whose ``file_path`` starts with one of the given prefixes (mirrors
|
|
602
|
+
``import_rationales``'s per-node ``--path`` filter, applied existentially at the
|
|
603
|
+
community level since a community has no single file_path of its own). ``limit``
|
|
604
|
+
caps the number of *significant* communities considered, applied after the
|
|
605
|
+
threshold/path filters in a deterministic (community-id-sorted) order — same
|
|
606
|
+
ordering rule ``import_rationales`` uses for its own ``kept[:limit]`` slice.
|
|
607
|
+
``None`` (this function's own default) means unlimited; the public surfaces built on
|
|
608
|
+
top of this collector (the ``list_domain_candidates`` MCP tool, ``sidegraph-domains
|
|
609
|
+
bootstrap``) apply their own scale-aware default (``DEFAULT_CANDIDATE_LIMIT``, finding
|
|
610
|
+
B) instead of inheriting this one, so a caller reaching for the collector directly (a
|
|
611
|
+
test, a script) never has to fight an implicit cap. The returned ``CollectStats.
|
|
612
|
+
total_before_limit`` names the full significant count regardless of what ``limit``
|
|
613
|
+
trimmed it to — repeatedly bootstrapping under the SAME low limit only ever proposes
|
|
614
|
+
the same low community-id-sorted window (already-claimed communities in that window
|
|
615
|
+
get skipped on a rerun, but communities past the window are never reached); widen
|
|
616
|
+
``limit`` (or narrow ``min_members``/``paths``) to get past it.
|
|
617
|
+
|
|
618
|
+
Per significant community: slug/title come from the engine's community label
|
|
619
|
+
(``GraphifyReader.community_labels()``) when present, else the community's god-node
|
|
620
|
+
name; summary is the label itself when present, else a deterministic member digest
|
|
621
|
+
("N entities incl. a, b, c — around <top file>"). A label that ``resolve()`` shows
|
|
622
|
+
names an entity actually living in a DIFFERENT community is rejected first and never
|
|
623
|
+
reaches title/summary — see ``_label_names_other_community`` — since the community
|
|
624
|
+
field a label's key was written against isn't guaranteed to match this graph.json's
|
|
625
|
+
(real-corpus finding: a domain's own namesake god node ended up outside the community
|
|
626
|
+
the domain stored). ``path_prefixes`` is derived only
|
|
627
|
+
when >= 80% of members share a single top-level directory AND that directory is
|
|
628
|
+
neither a well-known shared/infra name (``tests/``, ``src/``, ...) nor already claimed
|
|
629
|
+
(own community included) by communities adding up to > 20% of ALL communities in the
|
|
630
|
+
graph (Gate-5 guard — see ``_derive_path_prefixes``); either trips leaves
|
|
631
|
+
``path_prefixes`` empty rather than a stabilizer that would let a later sync silently
|
|
632
|
+
swallow unrelated communities.
|
|
633
|
+
Redaction runs on
|
|
634
|
+
title/summary/slug regardless of origin (§4.1: "bootstrap summaries derive from
|
|
635
|
+
engine data, redact anyway" — the graph can embed doc/rationale text a human typed);
|
|
636
|
+
the slug is derived from the already-redacted title, never the raw label/god-node
|
|
637
|
+
name, so secret material can't leak into the repo-committed slug or its paired
|
|
638
|
+
`domain:<slug>` entity name.
|
|
639
|
+
|
|
640
|
+
Each candidate also carries ``anchor`` — the community's god-node resolved to a durable
|
|
641
|
+
name+file_path ``Descriptor`` (§2a amendment, unredacted — a code/doc symbol name, not
|
|
642
|
+
free-form prose). ``list_domain_candidates`` surfaces this so the ``name-domains``
|
|
643
|
+
skill can build a ``Domain.seed_anchors`` entry directly from a candidate instead of
|
|
644
|
+
the volatile ``community_id`` alone, which does not survive a fresh clone or rebuild.
|
|
645
|
+
|
|
646
|
+
Idempotent: a community already claimed by a non-superseded domain (``communities``
|
|
647
|
+
contains its id) is skipped outright, and a computed slug colliding with an existing
|
|
648
|
+
non-superseded domain is also skipped (``store.find_domain_by_slug``) — never
|
|
649
|
+
reproposed, never suffixed against the store (suffixing only disambiguates
|
|
650
|
+
collisions WITHIN this run). Both skips fold into ``CollectStats.already_claimed``.
|
|
651
|
+
|
|
652
|
+
Within-run prefix dedup (live finding, see ``_dedupe_identical_prefix_sets``): when two
|
|
653
|
+
or more candidates surviving THIS run independently derive the IDENTICAL non-empty
|
|
654
|
+
``path_prefixes`` set, only the one with the most anchorable members keeps it — the
|
|
655
|
+
rest still survive, just with ``path_prefixes=[]`` (title/summary/slug untouched). Two
|
|
656
|
+
domains carrying a stabilizer that matches the same code would let a later sync
|
|
657
|
+
REPLACE refresh point both at (some of) the same communities, the same
|
|
658
|
+
silently-overlapping-memory shape the breadth guard above exists to prevent.
|
|
659
|
+
"""
|
|
660
|
+
labels = reader.community_labels()
|
|
661
|
+
claimed = _claimed_communities(store)
|
|
662
|
+
dir_breadth, total_communities = _prefix_community_breadth(reader)
|
|
663
|
+
|
|
664
|
+
stats = CollectStats()
|
|
665
|
+
# (community_id, anchorable_ids, god_node)
|
|
666
|
+
candidates: list[tuple[str, list[str], str | None]] = []
|
|
667
|
+
for community in reader.communities():
|
|
668
|
+
anchorable = _anchorable_member_ids(community.members, reader)
|
|
669
|
+
if len(anchorable) < min_members:
|
|
670
|
+
stats.below_threshold += 1
|
|
671
|
+
continue
|
|
672
|
+
if paths and not _matches_any_prefix(anchorable, paths, reader):
|
|
673
|
+
stats.filtered += 1
|
|
674
|
+
continue
|
|
675
|
+
candidates.append((community.community_id, anchorable, community.god_node))
|
|
676
|
+
|
|
677
|
+
candidates.sort(key=lambda c: _community_sort_key(c[0]))
|
|
678
|
+
# Free byproduct of the loop above (no per-candidate label/summary/path_prefixes work
|
|
679
|
+
# yet) -- the true significant-community count BEFORE `limit` truncates it (finding B).
|
|
680
|
+
stats.total_before_limit = len(candidates)
|
|
681
|
+
if limit is not None:
|
|
682
|
+
candidates = candidates[:limit]
|
|
683
|
+
|
|
684
|
+
# Pass 1: resolve which candidates actually survive (the "claimed" skip and within-run
|
|
685
|
+
# slug dedup are inherently order-dependent — `used_slugs` and
|
|
686
|
+
# `store.find_domain_by_slug` both need sequential state), building each survivor's
|
|
687
|
+
# slug/title/summary WITHOUT yet deriving path_prefixes.
|
|
688
|
+
used_slugs: set[str] = set()
|
|
689
|
+
pending: list[dict] = []
|
|
690
|
+
for community_id, anchorable, god_node_id in candidates:
|
|
691
|
+
if community_id in claimed:
|
|
692
|
+
stats.already_claimed += 1
|
|
693
|
+
continue
|
|
694
|
+
|
|
695
|
+
label = labels.get(community_id)
|
|
696
|
+
if label is not None and _label_names_other_community(label, community_id, reader):
|
|
697
|
+
label = None
|
|
698
|
+
# Belt-and-braces: community_labels() already drops blank-after-strip values, but
|
|
699
|
+
# a god node's name comes straight from graph.json with no such guard — a blank
|
|
700
|
+
# name must fall through to the next fallback rather than reach Domain.title,
|
|
701
|
+
# which raises (mid-run, after any earlier proposals in the batch already
|
|
702
|
+
# committed) on blank text.
|
|
703
|
+
god_name = None
|
|
704
|
+
if god_node_id is not None:
|
|
705
|
+
god_node = reader.get_node(god_node_id)
|
|
706
|
+
if god_node is not None and god_node.name.strip():
|
|
707
|
+
god_name = god_node.name
|
|
708
|
+
|
|
709
|
+
# Redact FIRST: title/slug both derive from this one clean value, never the raw
|
|
710
|
+
# label/god-node name — the engine can embed doc/rationale text a human typed, and
|
|
711
|
+
# slugify() alone doesn't scrub secrets, only unsafe slug characters (probe:
|
|
712
|
+
# "Auth (api_key=sk-live-...)" leaked the raw key into the repo-committed slug,
|
|
713
|
+
# and thus the `domain:<slug>` entity name, when slugify ran on unredacted text).
|
|
714
|
+
base_name = label or god_name or f"community-{community_id}"
|
|
715
|
+
title, _ = redact(base_name)
|
|
716
|
+
base_slug = slugify(title) or f"community-{community_id}"
|
|
717
|
+
slug = _dedupe_slug(base_slug, used_slugs)
|
|
718
|
+
|
|
719
|
+
existing = store.find_domain_by_slug(slug)
|
|
720
|
+
if existing is not None:
|
|
721
|
+
stats.already_claimed += 1
|
|
722
|
+
continue
|
|
723
|
+
|
|
724
|
+
raw_summary = label if label else _member_digest(anchorable, god_node_id, reader)
|
|
725
|
+
summary, _ = redact(raw_summary)
|
|
726
|
+
|
|
727
|
+
pending.append(
|
|
728
|
+
{
|
|
729
|
+
"community_id": community_id,
|
|
730
|
+
"anchorable": anchorable,
|
|
731
|
+
"god_node_id": god_node_id,
|
|
732
|
+
"slug": slug,
|
|
733
|
+
"title": title,
|
|
734
|
+
"summary": summary,
|
|
735
|
+
"has_label": label is not None,
|
|
736
|
+
}
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# Pass 2: derive path_prefixes for every survivor, then dedupe an identical non-empty
|
|
740
|
+
# set derived by >= 2 of them down to the single largest-membership candidate (live
|
|
741
|
+
# finding — see `_dedupe_identical_prefix_sets`).
|
|
742
|
+
derived_prefixes = _dedupe_identical_prefix_sets(
|
|
743
|
+
pending, reader, dir_breadth, total_communities
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
# Pass 3: assemble the final DomainCandidate per survivor, same order as `pending`.
|
|
747
|
+
out: list[DomainCandidate] = []
|
|
748
|
+
for p in pending:
|
|
749
|
+
out.append(
|
|
750
|
+
DomainCandidate(
|
|
751
|
+
community_id=p["community_id"],
|
|
752
|
+
suggested_slug=p["slug"],
|
|
753
|
+
suggested_title=p["title"],
|
|
754
|
+
summary=p["summary"],
|
|
755
|
+
path_prefixes=derived_prefixes[p["community_id"]],
|
|
756
|
+
member_count=len(p["anchorable"]),
|
|
757
|
+
top_members=_top_member_names(p["anchorable"], reader),
|
|
758
|
+
top_file=_top_file(p["anchorable"], p["god_node_id"], reader),
|
|
759
|
+
has_label=p["has_label"],
|
|
760
|
+
anchor=_god_node_descriptor(p["god_node_id"], reader),
|
|
761
|
+
)
|
|
762
|
+
)
|
|
763
|
+
|
|
764
|
+
stats.total = len(out)
|
|
765
|
+
return out, stats
|
|
766
|
+
|
|
767
|
+
|
|
768
|
+
def bootstrap_domains(
|
|
769
|
+
store: Store,
|
|
770
|
+
reader: GraphifyReader,
|
|
771
|
+
*,
|
|
772
|
+
min_members: int = 5,
|
|
773
|
+
paths: list[str] | None = None,
|
|
774
|
+
dry_run: bool = False,
|
|
775
|
+
limit: int | None = None,
|
|
776
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
777
|
+
) -> BootstrapReport:
|
|
778
|
+
"""Propose one ``Domain`` draft per significant community (§4.1, bootstrap path).
|
|
779
|
+
|
|
780
|
+
Selection is entirely delegated to ``collect_domain_candidates`` (see its docstring
|
|
781
|
+
for the full threshold/label/path_prefixes/redaction/idempotency rules); this
|
|
782
|
+
function's own job is only to turn each survivor into a ``Domain`` write (or, on
|
|
783
|
+
``dry_run=True``, a listing entry) — the writing half of the bootstrap pipeline.
|
|
784
|
+
|
|
785
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``): the resolved
|
|
786
|
+
``SIDEGRAPH_RATIFY_POLICY`` value (design D1), sampled once by the CLI shell
|
|
787
|
+
immediately before this call and passed down unchanged. Under ``AUTO_ALL`` only (D3:
|
|
788
|
+
domains are never eligible under ``auto-low-risk``), each proposed domain's own
|
|
789
|
+
post-write block stamps it ``auto:auto-all`` via the shared ``_auto_ratify`` helper
|
|
790
|
+
when ``_domain_anchored`` and the rest of the D3 gate hold, then immediately resolves
|
|
791
|
+
its membership through the same ``sync.activate_accepted_domain`` helper the human
|
|
792
|
+
MCP/CLI ratify paths use — a bootstrap domain is not exempt from the one
|
|
793
|
+
``ratify_domains`` gate, it can just clear it without a human tap. The once-per-batch
|
|
794
|
+
TOC rebuild happens after the whole loop, only when at least one domain was actually
|
|
795
|
+
auto-ratified.
|
|
796
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1/D2/D3
|
|
797
|
+
"""
|
|
798
|
+
graph_version = reader.graph_version()
|
|
799
|
+
candidates, stats = collect_domain_candidates(
|
|
800
|
+
store, reader, min_members=min_members, paths=paths, limit=limit
|
|
801
|
+
)
|
|
802
|
+
|
|
803
|
+
report = BootstrapReport(
|
|
804
|
+
below_threshold=stats.below_threshold,
|
|
805
|
+
filtered=stats.filtered,
|
|
806
|
+
skipped_existing=stats.already_claimed,
|
|
807
|
+
total_before_limit=stats.total_before_limit,
|
|
808
|
+
)
|
|
809
|
+
|
|
810
|
+
for c in candidates:
|
|
811
|
+
# D7.4: computed regardless of dry_run -- a real run's proposals get the same
|
|
812
|
+
# lint a dry run's listing would have shown (the CLI prints report.warnings after
|
|
813
|
+
# either path; see cli._domains_bootstrap).
|
|
814
|
+
prefix_warnings = _lint_domain_path_prefixes(c.path_prefixes, reader, store)
|
|
815
|
+
if prefix_warnings:
|
|
816
|
+
report.warnings.append({"slug": c.suggested_slug, "warnings": prefix_warnings})
|
|
817
|
+
|
|
818
|
+
if dry_run:
|
|
819
|
+
report.proposed += 1
|
|
820
|
+
report.dry_run.append(
|
|
821
|
+
{
|
|
822
|
+
"community_id": c.community_id,
|
|
823
|
+
"slug": c.suggested_slug,
|
|
824
|
+
"title": c.suggested_title,
|
|
825
|
+
"summary": c.summary,
|
|
826
|
+
"path_prefixes": c.path_prefixes,
|
|
827
|
+
"warnings": prefix_warnings,
|
|
828
|
+
}
|
|
829
|
+
)
|
|
830
|
+
continue
|
|
831
|
+
|
|
832
|
+
domain = Domain(
|
|
833
|
+
slug=c.suggested_slug,
|
|
834
|
+
title=c.suggested_title,
|
|
835
|
+
summary=c.summary,
|
|
836
|
+
communities=[c.community_id],
|
|
837
|
+
path_prefixes=c.path_prefixes,
|
|
838
|
+
provenance=Provenance(
|
|
839
|
+
source="bootstrap",
|
|
840
|
+
author="sidegraph-domains",
|
|
841
|
+
graph_version=graph_version,
|
|
842
|
+
),
|
|
843
|
+
)
|
|
844
|
+
store.add_domain(domain)
|
|
845
|
+
report.proposed += 1
|
|
846
|
+
|
|
847
|
+
# Auto-ratify (design D2/D3), auto-all only -- reuses `prefix_warnings`, already
|
|
848
|
+
# computed above for the dry-run listing too, rather than a second lint call.
|
|
849
|
+
if not dry_run and ratify_policy == RatifyPolicy.AUTO_ALL:
|
|
850
|
+
domain_anchored = _domain_anchored(
|
|
851
|
+
reader, prefix_warnings, domain.seed_anchors, domain.path_prefixes
|
|
852
|
+
)
|
|
853
|
+
signal = AutoEligibility(
|
|
854
|
+
kind="domain",
|
|
855
|
+
live_tier12=0,
|
|
856
|
+
ambiguous_or_orphan_only=True,
|
|
857
|
+
pipeline_clean=True,
|
|
858
|
+
has_provenance=True,
|
|
859
|
+
domain_anchored=domain_anchored,
|
|
860
|
+
has_supersedes=False,
|
|
861
|
+
)
|
|
862
|
+
if auto_ratify_eligible(signal, ratify_policy):
|
|
863
|
+
outcome = _auto_ratify(store, domain.domain_id, "domain", ratify_policy)
|
|
864
|
+
auto_ratify_error = outcome.error
|
|
865
|
+
if outcome.ratified_by is not None:
|
|
866
|
+
report.auto_ratified += 1
|
|
867
|
+
# Ruling R (design D2/D6 checkpoint-2 fix): the domain transition
|
|
868
|
+
# already committed by this point, so an activation failure must
|
|
869
|
+
# report and continue, never abort the batch -- mirrors
|
|
870
|
+
# capture.py's own domain auto-block; sync.activate_accepted_domain
|
|
871
|
+
# itself stays unchanged (its human MCP/CLI callers carry the
|
|
872
|
+
# identical unprotected stale-marker write).
|
|
873
|
+
try:
|
|
874
|
+
activation = activate_accepted_domain(domain, store, reader)
|
|
875
|
+
except Exception as e:
|
|
876
|
+
auto_ratify_error = f"activation: {e}"
|
|
877
|
+
else:
|
|
878
|
+
# `resolved` and `overbroad` are independent fields on
|
|
879
|
+
# `sync.DomainActivation` (checked separately, not elif'd) --
|
|
880
|
+
# rev 12 erratum: a claim-cap rejection used to report a clean
|
|
881
|
+
# success here, while the human MCP/CLI wrappers (server.py,
|
|
882
|
+
# cli.py) rendered their own "path rule too broad" sentence for
|
|
883
|
+
# the identical outcome -- mirrors capture.py's own domain
|
|
884
|
+
# auto-block (same sentence, same literal
|
|
885
|
+
# `sidegraph:heal-anchors` trigger phrase).
|
|
886
|
+
if not activation.resolved:
|
|
887
|
+
auto_ratify_error = f"activation: {activation.error}"
|
|
888
|
+
if activation.overbroad is not None:
|
|
889
|
+
prefixes = ", ".join(repr(p) for p in domain.path_prefixes)
|
|
890
|
+
auto_ratify_error = (
|
|
891
|
+
f"activation: path rule too broad: {prefixes} match "
|
|
892
|
+
f"{activation.overbroad['matched']}/"
|
|
893
|
+
f"{activation.overbroad['total']} communities — not "
|
|
894
|
+
"applied; seed_anchors, if any, still applied"
|
|
895
|
+
)
|
|
896
|
+
if auto_ratify_error is not None:
|
|
897
|
+
report.auto_ratify_failures.append(f"{domain.domain_id}: {auto_ratify_error}")
|
|
898
|
+
|
|
899
|
+
if report.auto_ratified > 0:
|
|
900
|
+
store.set_meta(TOC_CACHE_KEY, json.dumps(build_toc(store)))
|
|
901
|
+
|
|
902
|
+
return report
|