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