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/importer.py
ADDED
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
"""Bootstrap decisions from Graphify rationale nodes (portable core).
|
|
2
|
+
|
|
3
|
+
Reads rationale nodes via the engine seam (``GraphifyReader.rationale_nodes()``) and writes
|
|
4
|
+
them through the existing deterministic pipeline (redact -> validate -> anchor -> write) —
|
|
5
|
+
same shape as ``capture.py``'s propose path, but for bulk, non-interactive import. See
|
|
6
|
+
``docs/reference/cli.md#sidegraph-import``.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from datetime import UTC, datetime
|
|
12
|
+
|
|
13
|
+
from pydantic import BaseModel, Field
|
|
14
|
+
|
|
15
|
+
from .anchoring import resolve_and_bind
|
|
16
|
+
from .capture import (
|
|
17
|
+
AutoEligibility,
|
|
18
|
+
RatifyPolicy,
|
|
19
|
+
_anchor_signal,
|
|
20
|
+
_auto_ratify,
|
|
21
|
+
auto_ratify_eligible,
|
|
22
|
+
redact,
|
|
23
|
+
)
|
|
24
|
+
from .engine.reader import GraphifyReader, RationaleNode
|
|
25
|
+
from .schema import Decision, DecisionKind, DecisionStatus, Descriptor, Provenance, canonicalize
|
|
26
|
+
from .store import Store
|
|
27
|
+
|
|
28
|
+
# Cap per design decision 3: a well-connected rationale node must not fan out into an
|
|
29
|
+
# unbounded number of bindings on a bulk import.
|
|
30
|
+
_MAX_ANCHORS = 3
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ImportReport(BaseModel):
|
|
34
|
+
"""Counts (+ dry-run-only listing) for one ``import_rationales`` run.
|
|
35
|
+
|
|
36
|
+
``imported``/``skipped_existing``/``skipped_unanchorable``/``filtered`` reflect what
|
|
37
|
+
WOULD happen whether or not ``dry_run`` actually wrote anything; ``dry_run`` carries the
|
|
38
|
+
(file_path, title) listing and is populated only when the run was a dry run.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
imported: int = 0
|
|
42
|
+
skipped_existing: int = 0
|
|
43
|
+
skipped_unanchorable: int = 0
|
|
44
|
+
filtered: int = 0
|
|
45
|
+
# Auto-ratification policy (design D2/D6) -- additive/defaulted, same contract as
|
|
46
|
+
# capture.py's ProposeResult pair: incremented/appended by the post-write auto block
|
|
47
|
+
# below, always empty/zero under `manual` or when a written record was ineligible.
|
|
48
|
+
auto_ratified: int = 0
|
|
49
|
+
auto_ratify_failures: list[str] = Field(default_factory=list) # ["<decision id>: <reason>"]
|
|
50
|
+
dry_run: list[dict] = Field(default_factory=list) # [{"file_path", "title", "node_id"}, ...]
|
|
51
|
+
|
|
52
|
+
def by_file(self) -> dict[str, int]:
|
|
53
|
+
"""Per-file breakdown of the dry-run listing, for the CLI's ``--dry-run`` printer."""
|
|
54
|
+
out: dict[str, int] = {}
|
|
55
|
+
for item in self.dry_run:
|
|
56
|
+
fp = item["file_path"] or "<unknown>"
|
|
57
|
+
out[fp] = out.get(fp, 0) + 1
|
|
58
|
+
return out
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _make_title(text: str) -> str:
|
|
62
|
+
"""First line of the rationale text, capped at 120 chars (design decision: the rationale
|
|
63
|
+
text IS the recorded reasoning — the title is a lead-in, not a re-summarization).
|
|
64
|
+
|
|
65
|
+
``text`` must already be redacted: redacting the full text first and truncating the
|
|
66
|
+
already-redacted result avoids leaving a partial secret fragment when the 120-char cut
|
|
67
|
+
falls inside what would otherwise be a secret token (redact-then-truncate, never the
|
|
68
|
+
other way — a truncated fragment of a secret usually no longer matches the secret
|
|
69
|
+
pattern, so a later redact pass over the already-cut title would miss it)."""
|
|
70
|
+
lines = text.splitlines()
|
|
71
|
+
first = lines[0] if lines else text
|
|
72
|
+
return first[:120]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _resolved(reader: GraphifyReader, ref: Descriptor) -> bool:
|
|
76
|
+
return reader.resolve(ref).status == "resolved"
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _select_anchors(node: RationaleNode, reader: GraphifyReader) -> list[Descriptor]:
|
|
80
|
+
"""Up to 3 of the node's targets, each independently confirmed ``resolved`` — never
|
|
81
|
+
guess: ambiguous/unresolved targets are skipped outright, not orphan-bound (unlike the
|
|
82
|
+
interactive capture path, a bulk import must not flood the entity table with junk
|
|
83
|
+
orphans for every AST edge that doesn't line up). Falls back to the source file's own
|
|
84
|
+
node (``Descriptor(name=file_path, file_path=file_path)`` — the file-level anchor
|
|
85
|
+
pattern already used on doc corpora) only when zero targets resolved. An empty return
|
|
86
|
+
means "skip this rationale entirely"; the caller counts it as unanchorable.
|
|
87
|
+
"""
|
|
88
|
+
anchors: list[Descriptor] = []
|
|
89
|
+
for target in node.targets:
|
|
90
|
+
if len(anchors) >= _MAX_ANCHORS:
|
|
91
|
+
break
|
|
92
|
+
ref = Descriptor(name=target.name, file_path=target.file_path)
|
|
93
|
+
if _resolved(reader, ref):
|
|
94
|
+
anchors.append(ref)
|
|
95
|
+
if anchors:
|
|
96
|
+
return anchors
|
|
97
|
+
if node.file_path is not None:
|
|
98
|
+
file_ref = Descriptor(name=node.file_path, file_path=node.file_path)
|
|
99
|
+
if _resolved(reader, file_ref):
|
|
100
|
+
anchors.append(file_ref)
|
|
101
|
+
return anchors
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def import_rationales(
|
|
105
|
+
store: Store,
|
|
106
|
+
reader: GraphifyReader,
|
|
107
|
+
*,
|
|
108
|
+
kind: str = "adr",
|
|
109
|
+
propose: bool = False,
|
|
110
|
+
dry_run: bool = False,
|
|
111
|
+
limit: int | None = None,
|
|
112
|
+
path_prefixes: list[str] | None = None,
|
|
113
|
+
ratify_policy: RatifyPolicy = RatifyPolicy.MANUAL,
|
|
114
|
+
) -> ImportReport:
|
|
115
|
+
"""One :class:`Decision` per rationale node (see design doc section 3).
|
|
116
|
+
|
|
117
|
+
``title`` = the rationale text's first line (<=120 chars, derived from the FULL text
|
|
118
|
+
AFTER redaction — see ``_make_title``); ``context`` = "imported from <file_path>
|
|
119
|
+
(<node_id>)", or "imported from <node_id>" when the rationale has no ``file_path``;
|
|
120
|
+
``choice`` = the rationale text verbatim — it IS the recorded reasoning. Redaction
|
|
121
|
+
(``capture.redact``) runs on the full rationale text first, before title/context are
|
|
122
|
+
derived from it.
|
|
123
|
+
|
|
124
|
+
``provenance.ref`` is stamped with the rationale's ``file_path`` (falling back to its
|
|
125
|
+
``node_id`` when the rationale has no file_path) — this is also the idempotency key's
|
|
126
|
+
second component (see below).
|
|
127
|
+
|
|
128
|
+
``kind`` defaults to ``adr`` (rationale = recorded reasoning; this also keeps bulk
|
|
129
|
+
imports out of retrieval's ``_MISTAKE_KINDS`` bucket) — override per run. ``propose=True``
|
|
130
|
+
writes ``proposed`` (ratify gate) instead of the default ``accepted``. ``dry_run=True``
|
|
131
|
+
runs the full selection (path filter -> limit -> idempotency -> anchor resolution) but
|
|
132
|
+
writes nothing to the store; counts still reflect what WOULD happen, and
|
|
133
|
+
``ImportReport.dry_run`` carries the listing. Idempotent: a rerun skips any rationale
|
|
134
|
+
whose canonicalized title, ``provenance.ref``, AND ``provenance.source == "import"`` all
|
|
135
|
+
match an existing non-superseded decision (``store.find_decision_by_title``) — title
|
|
136
|
+
alone over-dedups, so identical first lines in different files are distinct memories and
|
|
137
|
+
both import (S2 review; see design doc's "idempotency" bullet).
|
|
138
|
+
|
|
139
|
+
``ratify_policy`` (default ``RatifyPolicy.MANUAL``): the resolved
|
|
140
|
+
``SIDEGRAPH_RATIFY_POLICY`` value (design D1), sampled once by the CLI shell
|
|
141
|
+
immediately before this call and passed down unchanged. When a written record is
|
|
142
|
+
``propose=True`` (not a dry run) and passes the same D3 gate ``capture.propose`` uses,
|
|
143
|
+
its own post-write block stamps it ``auto:<policy>`` via the shared ``_auto_ratify``
|
|
144
|
+
helper — never a second copy of that predicate or stamp construction. Every importer
|
|
145
|
+
write already carries ≥1 live Tier-2 binding (only resolved anchors are ever bound),
|
|
146
|
+
so eligibility here turns on kind/policy/supersedes, same as everywhere else.
|
|
147
|
+
# see design/superpowers/specs/2026-09-11-auto-ratification-policy-design.md D1/D2/D3
|
|
148
|
+
"""
|
|
149
|
+
decision_kind = DecisionKind(kind)
|
|
150
|
+
status = DecisionStatus.PROPOSED if propose else DecisionStatus.ACCEPTED
|
|
151
|
+
graph_version = reader.graph_version()
|
|
152
|
+
|
|
153
|
+
all_nodes = reader.rationale_nodes()
|
|
154
|
+
if path_prefixes:
|
|
155
|
+
kept = [
|
|
156
|
+
n
|
|
157
|
+
for n in all_nodes
|
|
158
|
+
if n.file_path is not None and any(n.file_path.startswith(p) for p in path_prefixes)
|
|
159
|
+
]
|
|
160
|
+
else:
|
|
161
|
+
kept = all_nodes
|
|
162
|
+
filtered = len(all_nodes) - len(kept)
|
|
163
|
+
process = kept[:limit] if limit is not None else kept
|
|
164
|
+
|
|
165
|
+
report = ImportReport(filtered=filtered)
|
|
166
|
+
|
|
167
|
+
for node in process:
|
|
168
|
+
ref = node.file_path if node.file_path is not None else node.node_id
|
|
169
|
+
|
|
170
|
+
# Redact the FULL text first; title/context are then derived from the already-
|
|
171
|
+
# redacted result (never the reverse — see _make_title).
|
|
172
|
+
choice, _ = redact(node.text)
|
|
173
|
+
title = _make_title(choice)
|
|
174
|
+
if node.file_path is not None:
|
|
175
|
+
context = f"imported from {node.file_path} ({node.node_id})"
|
|
176
|
+
else:
|
|
177
|
+
context = f"imported from {node.node_id}"
|
|
178
|
+
context, _ = redact(context)
|
|
179
|
+
|
|
180
|
+
canonical_title = canonicalize(title)
|
|
181
|
+
if store.find_decision_by_title(canonical_title, "import", ref) is not None:
|
|
182
|
+
report.skipped_existing += 1
|
|
183
|
+
continue
|
|
184
|
+
|
|
185
|
+
anchors = _select_anchors(node, reader)
|
|
186
|
+
if not anchors:
|
|
187
|
+
report.skipped_unanchorable += 1
|
|
188
|
+
continue
|
|
189
|
+
|
|
190
|
+
if dry_run:
|
|
191
|
+
report.imported += 1
|
|
192
|
+
report.dry_run.append(
|
|
193
|
+
{"file_path": node.file_path, "title": title, "node_id": node.node_id}
|
|
194
|
+
)
|
|
195
|
+
continue
|
|
196
|
+
|
|
197
|
+
decision = Decision(
|
|
198
|
+
title=title,
|
|
199
|
+
kind=decision_kind,
|
|
200
|
+
status=status,
|
|
201
|
+
context=context,
|
|
202
|
+
choice=choice,
|
|
203
|
+
valid_from=datetime.now(UTC),
|
|
204
|
+
provenance=Provenance(
|
|
205
|
+
source="import",
|
|
206
|
+
author="sidegraph-import",
|
|
207
|
+
ref=ref,
|
|
208
|
+
graph_version=graph_version,
|
|
209
|
+
),
|
|
210
|
+
)
|
|
211
|
+
store.add_decision(decision)
|
|
212
|
+
for anchor in anchors:
|
|
213
|
+
resolve_and_bind(decision.id, anchor, reader, store)
|
|
214
|
+
report.imported += 1
|
|
215
|
+
|
|
216
|
+
# Auto-ratify (design D2/D3), AFTER the binding loop above — _anchor_signal reads
|
|
217
|
+
# live bindings, which don't exist yet before it runs. `propose` is checked
|
|
218
|
+
# explicitly (not left to auto_ratify_eligible alone) so a decision that already
|
|
219
|
+
# landed accepted through the pre-existing `propose=False` default is never
|
|
220
|
+
# handed to a ratify transition at all.
|
|
221
|
+
if propose and not dry_run and ratify_policy != RatifyPolicy.MANUAL:
|
|
222
|
+
live_tier12, ambiguous_or_orphan_only = _anchor_signal(store, decision.id)
|
|
223
|
+
signal = AutoEligibility(
|
|
224
|
+
kind=decision_kind.value,
|
|
225
|
+
live_tier12=live_tier12,
|
|
226
|
+
ambiguous_or_orphan_only=ambiguous_or_orphan_only,
|
|
227
|
+
pipeline_clean=True,
|
|
228
|
+
has_provenance=True,
|
|
229
|
+
domain_anchored=False,
|
|
230
|
+
has_supersedes=decision.supersedes is not None,
|
|
231
|
+
)
|
|
232
|
+
if auto_ratify_eligible(signal, ratify_policy):
|
|
233
|
+
outcome = _auto_ratify(store, decision.id, signal.kind, ratify_policy)
|
|
234
|
+
if outcome.ratified_by is not None:
|
|
235
|
+
report.auto_ratified += 1
|
|
236
|
+
if outcome.error is not None:
|
|
237
|
+
report.auto_ratify_failures.append(f"{decision.id}: {outcome.error}")
|
|
238
|
+
|
|
239
|
+
return report
|