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/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