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/okf.py
ADDED
|
@@ -0,0 +1,471 @@
|
|
|
1
|
+
"""One-way OKF v0.1 bundle projection of the decision store.
|
|
2
|
+
|
|
3
|
+
Maps the owned store onto Open Knowledge Format concepts — a directory of markdown files
|
|
4
|
+
with YAML frontmatter whose cross-links form a graph (spec:
|
|
5
|
+
https://github.com/GoogleCloudPlatform/knowledge-catalog/tree/main/okf). Portable core:
|
|
6
|
+
reads only :class:`~sidegraph.store.Store` — no engine reader, no host specifics.
|
|
7
|
+
``build_bundle`` is pure (store -> in-memory ``{path: content}``, deterministic);
|
|
8
|
+
``write_bundle`` owns the out-directory safety rules. Strictly one-way: the bundle is a
|
|
9
|
+
derived artifact and the store stays the source of truth; nothing here writes to the
|
|
10
|
+
store.
|
|
11
|
+
|
|
12
|
+
# see design/superpowers/specs/2026-07-23-okf-export-design.md
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import shutil
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
from ulid import ULID
|
|
22
|
+
|
|
23
|
+
from .schema import (
|
|
24
|
+
AnchorBinding,
|
|
25
|
+
Decision,
|
|
26
|
+
DecisionStatus,
|
|
27
|
+
Domain,
|
|
28
|
+
DomainStatus,
|
|
29
|
+
Entity,
|
|
30
|
+
EntityKind,
|
|
31
|
+
Fact,
|
|
32
|
+
Provenance,
|
|
33
|
+
slugify,
|
|
34
|
+
)
|
|
35
|
+
from .store import Store
|
|
36
|
+
|
|
37
|
+
#: Spec version this exporter targets, declared in the root ``index.md`` frontmatter.
|
|
38
|
+
OKF_VERSION = "0.1"
|
|
39
|
+
|
|
40
|
+
#: Root-index frontmatter line marking a directory as a previous export. ``write_bundle``
|
|
41
|
+
#: only ever clears a directory carrying it (or an empty one) — see the spec's out-dir
|
|
42
|
+
#: safety rules.
|
|
43
|
+
GENERATOR_LINE = "generator: sidegraph"
|
|
44
|
+
|
|
45
|
+
_SLUG_LIMIT = 60
|
|
46
|
+
_FACT_TITLE_LIMIT = 80
|
|
47
|
+
_DESCRIPTION_LIMIT = 160
|
|
48
|
+
|
|
49
|
+
_DOMAIN_PREFIX = "domain:"
|
|
50
|
+
_COMMUNITY_PREFIX = "community:"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _first_line(text: str, limit: int) -> str:
|
|
54
|
+
stripped = text.strip()
|
|
55
|
+
line = stripped.splitlines()[0] if stripped else ""
|
|
56
|
+
return line if len(line) <= limit else line[: limit - 1] + "…"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _slug(text: str, fallback: str) -> str:
|
|
60
|
+
s = slugify(text)[:_SLUG_LIMIT].rstrip("-")
|
|
61
|
+
return s or fallback
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def _label(text: str) -> str:
|
|
65
|
+
"""Escape a markdown link label (square brackets only — the rest is legal there)."""
|
|
66
|
+
return text.replace("[", "\\[").replace("]", "\\]")
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _yaml_str(value: str) -> str:
|
|
70
|
+
"""A JSON-quoted scalar — valid YAML; handles quotes/colons/newlines exactly."""
|
|
71
|
+
return json.dumps(value, ensure_ascii=False)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _frontmatter(pairs: list[tuple[str, str]]) -> str:
|
|
75
|
+
return "\n".join(["---", *[f"{k}: {v}" for k, v in pairs], "---"])
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def build_bundle(store: Store) -> dict[str, str]:
|
|
79
|
+
"""Project ``store`` into an OKF bundle: bundle-relative posix path -> file content.
|
|
80
|
+
|
|
81
|
+
Pure — reads the store, writes nothing. Deterministic: same store state yields an
|
|
82
|
+
identical dict (all iteration orders pinned, no wall-clock, no randomness).
|
|
83
|
+
"""
|
|
84
|
+
# Proposed (unratified) drafts are never published: the store owner ratifies before a
|
|
85
|
+
# record enters the shared memory, so the bundle carries accepted/superseded/rejected/
|
|
86
|
+
# deprecated only (spec Decisions §1). Only `proposed` is dropped — superseded history
|
|
87
|
+
# is deliberately retained ("tried before, abandoned…" is the product). Everything
|
|
88
|
+
# downstream (paths, bindings, entities, indexes, log) derives from these lists, so
|
|
89
|
+
# filtering here is the single, complete cut. Facts reuse DecisionStatus.
|
|
90
|
+
decisions = sorted(
|
|
91
|
+
(d for d in store.iter_decisions() if d.status is not DecisionStatus.PROPOSED),
|
|
92
|
+
key=lambda d: d.id,
|
|
93
|
+
)
|
|
94
|
+
facts = sorted(
|
|
95
|
+
(f for f in store.iter_facts() if f.status is not DecisionStatus.PROPOSED),
|
|
96
|
+
key=lambda f: f.id,
|
|
97
|
+
)
|
|
98
|
+
domain_rows = [r for r in store.iter_domains() if r.status is not DomainStatus.PROPOSED]
|
|
99
|
+
|
|
100
|
+
# Domains collapse to one concept per slug: greatest domain_id (ULID = creation time)
|
|
101
|
+
# wins and its status is what the page shows (spec decision 3).
|
|
102
|
+
winning: dict[str, Domain] = {}
|
|
103
|
+
for row in domain_rows:
|
|
104
|
+
cur = winning.get(row.slug)
|
|
105
|
+
if cur is None or row.domain_id > cur.domain_id:
|
|
106
|
+
winning[row.slug] = row
|
|
107
|
+
slug_by_row_id = {row.domain_id: row.slug for row in domain_rows}
|
|
108
|
+
|
|
109
|
+
dec_path = {d.id: f"decisions/{_slug(d.title, 'record')}-{d.id.lower()}.md" for d in decisions}
|
|
110
|
+
fact_title = {f.id: _first_line(f.statement, _FACT_TITLE_LIMIT) for f in facts}
|
|
111
|
+
fact_path = {
|
|
112
|
+
f.id: f"facts/{_slug(_first_line(f.statement, 200), 'record')}-{f.id.lower()}.md"
|
|
113
|
+
for f in facts
|
|
114
|
+
}
|
|
115
|
+
domain_path = {slug: f"domains/{slug}.md" for slug in winning}
|
|
116
|
+
record_path = dec_path | fact_path
|
|
117
|
+
record_title = {d.id: d.title for d in decisions} | fact_title
|
|
118
|
+
# A Decision and a Fact sharing one ULID is store corruption (each add-path enforces
|
|
119
|
+
# id uniqueness only within its own type) — the merged maps above would silently
|
|
120
|
+
# resolve such an id to the fact. Treat it as ambiguous: drop the id from the link
|
|
121
|
+
# maps so every reference renders as a plain id, never a wrong link. The two concept
|
|
122
|
+
# files themselves still export (their per-type paths never clash).
|
|
123
|
+
for rid in dec_path.keys() & fact_path.keys():
|
|
124
|
+
del record_path[rid]
|
|
125
|
+
del record_title[rid]
|
|
126
|
+
|
|
127
|
+
# Bindings carry identity only (statuses are volatile and index-only — see the doctor
|
|
128
|
+
# spec); community:* targets are skipped outright (renumberable snapshot labels).
|
|
129
|
+
entity_by_id: dict[str, Entity] = {}
|
|
130
|
+
kept_bindings: dict[str, list[AnchorBinding]] = {}
|
|
131
|
+
records_by_entity: dict[str, list[tuple[str, str]]] = {} # entity_id -> [(rid, relation)]
|
|
132
|
+
records_by_domain_slug: dict[str, list[str]] = {}
|
|
133
|
+
for rid in [*dec_path, *fact_path]:
|
|
134
|
+
kept: list[AnchorBinding] = []
|
|
135
|
+
for b in store.bindings_for_record(rid):
|
|
136
|
+
ent = entity_by_id.get(b.entity_id) or store.get_entity(b.entity_id)
|
|
137
|
+
if ent is None or ent.canonical_name.startswith(_COMMUNITY_PREFIX):
|
|
138
|
+
continue
|
|
139
|
+
entity_by_id[ent.entity_id] = ent
|
|
140
|
+
kept.append(b)
|
|
141
|
+
cname = ent.canonical_name
|
|
142
|
+
if cname.startswith(_DOMAIN_PREFIX) and cname[len(_DOMAIN_PREFIX) :] in domain_path:
|
|
143
|
+
records_by_domain_slug.setdefault(cname[len(_DOMAIN_PREFIX) :], []).append(rid)
|
|
144
|
+
else:
|
|
145
|
+
records_by_entity.setdefault(ent.entity_id, []).append((rid, b.relation))
|
|
146
|
+
kept_bindings[rid] = kept
|
|
147
|
+
|
|
148
|
+
# Entities export only when referenced; domain:* entities with a domain page are
|
|
149
|
+
# represented by that page instead (spec decision 4).
|
|
150
|
+
entities = sorted(
|
|
151
|
+
(
|
|
152
|
+
e
|
|
153
|
+
for e in entity_by_id.values()
|
|
154
|
+
if not (
|
|
155
|
+
e.canonical_name.startswith(_DOMAIN_PREFIX)
|
|
156
|
+
and e.canonical_name[len(_DOMAIN_PREFIX) :] in domain_path
|
|
157
|
+
)
|
|
158
|
+
),
|
|
159
|
+
key=lambda e: (e.canonical_name, e.entity_id),
|
|
160
|
+
)
|
|
161
|
+
ent_path = {
|
|
162
|
+
e.entity_id: f"entities/{_slug(e.canonical_name, 'entity')}-{e.entity_id.lower()}.md"
|
|
163
|
+
for e in entities
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
superseded_by: dict[str, list[str]] = {}
|
|
167
|
+
all_records: list[Decision | Fact] = [*decisions, *facts]
|
|
168
|
+
for rec in all_records:
|
|
169
|
+
if rec.supersedes:
|
|
170
|
+
superseded_by.setdefault(rec.supersedes, []).append(rec.id)
|
|
171
|
+
|
|
172
|
+
def _record_ref(rid: str) -> str:
|
|
173
|
+
# Defensive: a dirty store may hold a dangling id — plain text, never a broken
|
|
174
|
+
# link (spec §Concept mapping). Unreachable through Store's own write guards.
|
|
175
|
+
path = record_path.get(rid)
|
|
176
|
+
if path is None:
|
|
177
|
+
return rid
|
|
178
|
+
return f"[{_label(record_title[rid])}](/{path})"
|
|
179
|
+
|
|
180
|
+
def _record_order(rid: str) -> tuple[int, str]:
|
|
181
|
+
return (0 if rid in dec_path else 1, rid) # decisions first, then facts, by id
|
|
182
|
+
|
|
183
|
+
def _anchor_lines(rid: str) -> list[str]:
|
|
184
|
+
rows: list[tuple[int, str, str]] = []
|
|
185
|
+
for b in kept_bindings[rid]:
|
|
186
|
+
ent = entity_by_id[b.entity_id]
|
|
187
|
+
cname = ent.canonical_name
|
|
188
|
+
if cname.startswith(_DOMAIN_PREFIX) and cname[len(_DOMAIN_PREFIX) :] in domain_path:
|
|
189
|
+
slug = cname[len(_DOMAIN_PREFIX) :]
|
|
190
|
+
label, target = winning[slug].title, f"/{domain_path[slug]}"
|
|
191
|
+
else:
|
|
192
|
+
label = ent.descriptor.name if ent.descriptor else cname
|
|
193
|
+
target = f"/{ent_path[b.entity_id]}"
|
|
194
|
+
line = f"- [{_label(label)}]({target}) — {b.relation}, tier {b.tier}"
|
|
195
|
+
rows.append((b.tier, label, line))
|
|
196
|
+
if not rows:
|
|
197
|
+
return []
|
|
198
|
+
rows.sort()
|
|
199
|
+
return ["# Anchors", *[line for _, _, line in rows]]
|
|
200
|
+
|
|
201
|
+
def _history_lines(rid: str, supersedes: str | None) -> list[str]:
|
|
202
|
+
lines: list[str] = []
|
|
203
|
+
if supersedes:
|
|
204
|
+
lines.append(f"- Supersedes {_record_ref(supersedes)}")
|
|
205
|
+
lines += [f"- Superseded by {_record_ref(s)}" for s in sorted(superseded_by.get(rid, []))]
|
|
206
|
+
return ["# History", *lines] if lines else []
|
|
207
|
+
|
|
208
|
+
def _provenance_lines(p: Provenance) -> list[str]:
|
|
209
|
+
lines = ["# Provenance", f"- source: {p.source}"]
|
|
210
|
+
if p.ref:
|
|
211
|
+
lines.append(f"- ref: {p.ref}")
|
|
212
|
+
if p.author:
|
|
213
|
+
lines.append(f"- author: {p.author}")
|
|
214
|
+
return lines
|
|
215
|
+
|
|
216
|
+
def _render_decision(d: Decision) -> str:
|
|
217
|
+
fm: list[tuple[str, str]] = [
|
|
218
|
+
("type", "decision"),
|
|
219
|
+
("title", _yaml_str(d.title)),
|
|
220
|
+
("description", _yaml_str(_first_line(d.choice, _DESCRIPTION_LIMIT))),
|
|
221
|
+
("timestamp", d.valid_from.isoformat()),
|
|
222
|
+
("id", d.id.lower()),
|
|
223
|
+
("kind", d.kind.value),
|
|
224
|
+
("status", d.status.value),
|
|
225
|
+
("scope", d.scope.value),
|
|
226
|
+
]
|
|
227
|
+
if d.layer:
|
|
228
|
+
fm.append(("layer", d.layer))
|
|
229
|
+
fm.append(("valid_from", d.valid_from.isoformat()))
|
|
230
|
+
if d.valid_to:
|
|
231
|
+
fm.append(("valid_to", d.valid_to.isoformat()))
|
|
232
|
+
if d.supersedes:
|
|
233
|
+
fm.append(("supersedes", d.supersedes.lower()))
|
|
234
|
+
sections: list[str] = []
|
|
235
|
+
for heading, text in (
|
|
236
|
+
("# Context", d.context),
|
|
237
|
+
("# Choice", d.choice),
|
|
238
|
+
("# Rejected", d.rejected or ""),
|
|
239
|
+
("# Consequences", d.consequences or ""),
|
|
240
|
+
):
|
|
241
|
+
if text.strip():
|
|
242
|
+
sections.append(f"{heading}\n{text.strip()}")
|
|
243
|
+
for block in (_anchor_lines(d.id), _history_lines(d.id, d.supersedes)):
|
|
244
|
+
if block:
|
|
245
|
+
sections.append("\n".join(block))
|
|
246
|
+
sections.append("\n".join(_provenance_lines(d.provenance)))
|
|
247
|
+
return _frontmatter(fm) + "\n\n" + "\n\n".join(sections) + "\n"
|
|
248
|
+
|
|
249
|
+
def _render_fact(f: Fact) -> str:
|
|
250
|
+
fm: list[tuple[str, str]] = [
|
|
251
|
+
("type", "fact"),
|
|
252
|
+
("title", _yaml_str(fact_title[f.id])),
|
|
253
|
+
("timestamp", f.valid_from.isoformat()),
|
|
254
|
+
("id", f.id.lower()),
|
|
255
|
+
("status", f.status.value),
|
|
256
|
+
("valid_from", f.valid_from.isoformat()),
|
|
257
|
+
]
|
|
258
|
+
if f.valid_to:
|
|
259
|
+
fm.append(("valid_to", f.valid_to.isoformat()))
|
|
260
|
+
if f.supersedes:
|
|
261
|
+
fm.append(("supersedes", f.supersedes.lower()))
|
|
262
|
+
sections = [f.statement.strip(), f"# Source\n{f.source.strip()}"]
|
|
263
|
+
if f.supports:
|
|
264
|
+
sections.append(
|
|
265
|
+
"\n".join(["# Supports", *[f"- {_record_ref(s)}" for s in sorted(f.supports)]])
|
|
266
|
+
)
|
|
267
|
+
for block in (_anchor_lines(f.id), _history_lines(f.id, f.supersedes)):
|
|
268
|
+
if block:
|
|
269
|
+
sections.append("\n".join(block))
|
|
270
|
+
sections.append("\n".join(_provenance_lines(f.provenance)))
|
|
271
|
+
return _frontmatter(fm) + "\n\n" + "\n\n".join(sections) + "\n"
|
|
272
|
+
|
|
273
|
+
def _render_domain(dm: Domain) -> str:
|
|
274
|
+
fm: list[tuple[str, str]] = [
|
|
275
|
+
("type", "domain"),
|
|
276
|
+
("title", _yaml_str(dm.title)),
|
|
277
|
+
("description", _yaml_str(_first_line(dm.summary, _DESCRIPTION_LIMIT))),
|
|
278
|
+
("timestamp", ULID.from_str(dm.domain_id).datetime.isoformat()),
|
|
279
|
+
("id", dm.domain_id.lower()),
|
|
280
|
+
("slug", dm.slug),
|
|
281
|
+
("status", dm.status.value),
|
|
282
|
+
]
|
|
283
|
+
sections = [dm.summary.strip()]
|
|
284
|
+
parent_slug = slug_by_row_id.get(dm.parent_id) if dm.parent_id else None
|
|
285
|
+
if parent_slug and parent_slug != dm.slug and parent_slug in domain_path:
|
|
286
|
+
parent = winning[parent_slug]
|
|
287
|
+
sections.append(
|
|
288
|
+
f"# Hierarchy\n- Parent: [{_label(parent.title)}](/{domain_path[parent_slug]})"
|
|
289
|
+
)
|
|
290
|
+
bound = sorted(set(records_by_domain_slug.get(dm.slug, [])), key=_record_order)
|
|
291
|
+
if bound:
|
|
292
|
+
sections.append(
|
|
293
|
+
"\n".join(["# Anchored records", *[f"- {_record_ref(r)}" for r in bound]])
|
|
294
|
+
)
|
|
295
|
+
sections.append("\n".join(_provenance_lines(dm.provenance)))
|
|
296
|
+
return _frontmatter(fm) + "\n\n" + "\n\n".join(sections) + "\n"
|
|
297
|
+
|
|
298
|
+
def _render_entity(e: Entity) -> str:
|
|
299
|
+
label = e.descriptor.name if e.descriptor else e.canonical_name
|
|
300
|
+
fm: list[tuple[str, str]] = [
|
|
301
|
+
("type", "code-entity" if e.kind is EntityKind.CONCRETE else "concept"),
|
|
302
|
+
("title", _yaml_str(label)),
|
|
303
|
+
("id", e.entity_id.lower()),
|
|
304
|
+
("name", _yaml_str(e.canonical_name)),
|
|
305
|
+
]
|
|
306
|
+
if e.descriptor and e.descriptor.file_path:
|
|
307
|
+
fm.append(("file_path", _yaml_str(e.descriptor.file_path)))
|
|
308
|
+
refs = sorted(
|
|
309
|
+
set(records_by_entity.get(e.entity_id, [])), key=lambda t: _record_order(t[0])
|
|
310
|
+
)
|
|
311
|
+
lines = ["# Anchored records", *[f"- {_record_ref(rid)} — {rel}" for rid, rel in refs]]
|
|
312
|
+
return _frontmatter(fm) + "\n\n" + "\n".join(lines) + "\n"
|
|
313
|
+
|
|
314
|
+
bundle: dict[str, str] = {}
|
|
315
|
+
for d in decisions:
|
|
316
|
+
bundle[dec_path[d.id]] = _render_decision(d)
|
|
317
|
+
for f in facts:
|
|
318
|
+
bundle[fact_path[f.id]] = _render_fact(f)
|
|
319
|
+
for slug in sorted(winning):
|
|
320
|
+
bundle[domain_path[slug]] = _render_domain(winning[slug])
|
|
321
|
+
for e in entities:
|
|
322
|
+
bundle[ent_path[e.entity_id]] = _render_entity(e)
|
|
323
|
+
|
|
324
|
+
# --- section indexes (reserved files: no frontmatter) ---
|
|
325
|
+
def _section_index(heading: str, entries: list[tuple[str, str, str]]) -> str:
|
|
326
|
+
lines = [heading, *[f"- [{_label(t)}](/{p}) — {desc}" for t, p, desc in entries]]
|
|
327
|
+
return "\n".join(lines) + "\n"
|
|
328
|
+
|
|
329
|
+
if decisions:
|
|
330
|
+
bundle["decisions/index.md"] = _section_index(
|
|
331
|
+
"# Decisions",
|
|
332
|
+
[
|
|
333
|
+
(d.title, dec_path[d.id], _first_line(d.choice, _DESCRIPTION_LIMIT))
|
|
334
|
+
for d in decisions
|
|
335
|
+
],
|
|
336
|
+
)
|
|
337
|
+
if facts:
|
|
338
|
+
bundle["facts/index.md"] = (
|
|
339
|
+
"\n".join(
|
|
340
|
+
["# Facts", *[f"- [{_label(fact_title[f.id])}](/{fact_path[f.id]})" for f in facts]]
|
|
341
|
+
)
|
|
342
|
+
+ "\n"
|
|
343
|
+
)
|
|
344
|
+
if winning:
|
|
345
|
+
bundle["domains/index.md"] = _section_index(
|
|
346
|
+
"# Domains",
|
|
347
|
+
[
|
|
348
|
+
(
|
|
349
|
+
winning[s].title,
|
|
350
|
+
domain_path[s],
|
|
351
|
+
_first_line(winning[s].summary, _DESCRIPTION_LIMIT),
|
|
352
|
+
)
|
|
353
|
+
for s in sorted(winning)
|
|
354
|
+
],
|
|
355
|
+
)
|
|
356
|
+
if entities:
|
|
357
|
+
bundle["entities/index.md"] = _section_index(
|
|
358
|
+
"# Entities",
|
|
359
|
+
[
|
|
360
|
+
(
|
|
361
|
+
e.descriptor.name if e.descriptor else e.canonical_name,
|
|
362
|
+
ent_path[e.entity_id],
|
|
363
|
+
e.descriptor.file_path
|
|
364
|
+
if e.descriptor and e.descriptor.file_path
|
|
365
|
+
else e.canonical_name,
|
|
366
|
+
)
|
|
367
|
+
for e in entities
|
|
368
|
+
],
|
|
369
|
+
)
|
|
370
|
+
|
|
371
|
+
# --- root log.md: chronology of the memory (decisions + facts, newest date first) ---
|
|
372
|
+
by_date: dict[str, list[tuple[str, str]]] = {}
|
|
373
|
+
for d in decisions:
|
|
374
|
+
by_date.setdefault(d.valid_from.date().isoformat(), []).append(
|
|
375
|
+
(d.id, f"- decision ({d.kind.value}): {_record_ref(d.id)}")
|
|
376
|
+
)
|
|
377
|
+
for f in facts:
|
|
378
|
+
by_date.setdefault(f.valid_from.date().isoformat(), []).append(
|
|
379
|
+
(f.id, f"- fact: {_record_ref(f.id)}")
|
|
380
|
+
)
|
|
381
|
+
if by_date:
|
|
382
|
+
log_lines = ["# Log"]
|
|
383
|
+
for date in sorted(by_date, reverse=True):
|
|
384
|
+
log_lines += ["", f"## {date}", *[line for _, line in sorted(by_date[date])]]
|
|
385
|
+
bundle["log.md"] = "\n".join(log_lines) + "\n"
|
|
386
|
+
|
|
387
|
+
# --- root index.md: the ONE reserved file allowed frontmatter (okf_version there) ---
|
|
388
|
+
root = [
|
|
389
|
+
"---",
|
|
390
|
+
f'okf_version: "{OKF_VERSION}"',
|
|
391
|
+
GENERATOR_LINE,
|
|
392
|
+
"---",
|
|
393
|
+
"",
|
|
394
|
+
"# Sidegraph decision memory",
|
|
395
|
+
"",
|
|
396
|
+
"One-way OKF projection of a Sidegraph decision store — append-only decision",
|
|
397
|
+
"memory with full supersession history. The store, not this bundle, is the",
|
|
398
|
+
"source of truth.",
|
|
399
|
+
]
|
|
400
|
+
contents: list[str] = []
|
|
401
|
+
if decisions:
|
|
402
|
+
contents.append(
|
|
403
|
+
f"- [decisions/](/decisions/index.md) — {len(decisions)} decision records "
|
|
404
|
+
"(ADRs, lessons, constraints, gotchas)"
|
|
405
|
+
)
|
|
406
|
+
if facts:
|
|
407
|
+
contents.append(
|
|
408
|
+
f"- [facts/](/facts/index.md) — {len(facts)} facts "
|
|
409
|
+
"(non-derivable knowledge that informed decisions)"
|
|
410
|
+
)
|
|
411
|
+
if winning:
|
|
412
|
+
contents.append(
|
|
413
|
+
f"- [domains/](/domains/index.md) — {len(winning)} domains (named areas of the system)"
|
|
414
|
+
)
|
|
415
|
+
if entities:
|
|
416
|
+
contents.append(
|
|
417
|
+
f"- [entities/](/entities/index.md) — {len(entities)} code entities decisions anchor to"
|
|
418
|
+
)
|
|
419
|
+
if contents:
|
|
420
|
+
root += ["", "## Contents", *contents]
|
|
421
|
+
bundle["index.md"] = "\n".join(root) + "\n"
|
|
422
|
+
return bundle
|
|
423
|
+
|
|
424
|
+
|
|
425
|
+
def _is_previous_export(out_dir: Path) -> bool:
|
|
426
|
+
"""True iff ``out_dir/index.md``'s FIRST frontmatter block carries the literal
|
|
427
|
+
``generator: sidegraph`` line. A deliberate string check, not YAML parsing — the
|
|
428
|
+
exporter has no runtime YAML dependency (spec decision 8)."""
|
|
429
|
+
index = out_dir / "index.md"
|
|
430
|
+
if not index.is_file():
|
|
431
|
+
return False
|
|
432
|
+
try:
|
|
433
|
+
lines = index.read_text(encoding="utf-8").splitlines()
|
|
434
|
+
except (OSError, UnicodeDecodeError):
|
|
435
|
+
return False
|
|
436
|
+
if not lines or lines[0] != "---":
|
|
437
|
+
return False
|
|
438
|
+
for line in lines[1:]:
|
|
439
|
+
if line == "---":
|
|
440
|
+
return False
|
|
441
|
+
if line == GENERATOR_LINE:
|
|
442
|
+
return True
|
|
443
|
+
return False
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def write_bundle(bundle: dict[str, str], out_dir: Path) -> None:
|
|
447
|
+
"""Write ``bundle`` as an exact snapshot of ``out_dir``.
|
|
448
|
+
|
|
449
|
+
Safety rules (spec §CLI contract): create the directory if missing; use it if empty;
|
|
450
|
+
clear and rewrite it ONLY when its root ``index.md`` marks it as a previous sidegraph
|
|
451
|
+
export. Anything else raises ``ValueError`` — clearing a directory the user pointed
|
|
452
|
+
at by mistake would be destructive.
|
|
453
|
+
"""
|
|
454
|
+
if out_dir.exists():
|
|
455
|
+
if not out_dir.is_dir():
|
|
456
|
+
raise ValueError(f"refusing to overwrite {out_dir}: not a directory")
|
|
457
|
+
children = sorted(out_dir.iterdir())
|
|
458
|
+
if children and not _is_previous_export(out_dir):
|
|
459
|
+
raise ValueError(
|
|
460
|
+
f"refusing to overwrite {out_dir}: not an empty directory or a "
|
|
461
|
+
"sidegraph-generated bundle"
|
|
462
|
+
)
|
|
463
|
+
for child in children:
|
|
464
|
+
if child.is_dir() and not child.is_symlink():
|
|
465
|
+
shutil.rmtree(child)
|
|
466
|
+
else:
|
|
467
|
+
child.unlink()
|
|
468
|
+
for rel in sorted(bundle):
|
|
469
|
+
target = out_dir / rel
|
|
470
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
471
|
+
target.write_text(bundle[rel], encoding="utf-8")
|