superdev-cli 0.1.0

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.
Files changed (182) hide show
  1. package/.claude-plugin/marketplace.json +33 -0
  2. package/.claude-plugin/plugin.json +21 -0
  3. package/.codex-plugin/plugin.json +27 -0
  4. package/CODE_OF_CONDUCT.md +109 -0
  5. package/CONTRIBUTING.md +205 -0
  6. package/LICENSE +202 -0
  7. package/NOTICE +11 -0
  8. package/README.md +1051 -0
  9. package/SECURITY.md +135 -0
  10. package/THIRD-PARTY-NOTICES.md +253 -0
  11. package/hooks/hooks.json +61 -0
  12. package/hooks/run.mjs +88 -0
  13. package/package.json +65 -0
  14. package/references/confidentiality.md +48 -0
  15. package/references/evidence-and-risk.md +73 -0
  16. package/references/operating-model.md +105 -0
  17. package/references/platform-capabilities.md +51 -0
  18. package/references/project-record.md +91 -0
  19. package/references/provider-contracts.md +102 -0
  20. package/scripts/doctor/doctor.mjs +539 -0
  21. package/scripts/privacy/scan-history.mjs +163 -0
  22. package/scripts/privacy/scan.mjs +368 -0
  23. package/scripts/validate/README.md +94 -0
  24. package/scripts/validate/common.mjs +149 -0
  25. package/scripts/validate/data-model.mjs +225 -0
  26. package/scripts/validate/dependencies.mjs +69 -0
  27. package/scripts/validate/docs-templates.mjs +99 -0
  28. package/scripts/validate/footprint.mjs +78 -0
  29. package/scripts/validate/imports.mjs +61 -0
  30. package/scripts/validate/manifests.mjs +173 -0
  31. package/scripts/validate/markdown.mjs +123 -0
  32. package/scripts/validate/migrations.mjs +187 -0
  33. package/scripts/validate/no-tests.mjs +136 -0
  34. package/scripts/validate/privacy.mjs +115 -0
  35. package/scripts/validate/record-links.mjs +127 -0
  36. package/scripts/validate/skill-commands.mjs +190 -0
  37. package/scripts/validate/skills.mjs +112 -0
  38. package/scripts/validate/specification.mjs +88 -0
  39. package/scripts/validate/style.mjs +78 -0
  40. package/scripts/validate/validate-all.mjs +175 -0
  41. package/skills/debug/SKILL.md +68 -0
  42. package/skills/decision/SKILL.md +99 -0
  43. package/skills/docs/SKILL.md +86 -0
  44. package/skills/docs/assets/fragments/api/events.md +14 -0
  45. package/skills/docs/assets/fragments/api/graphql.md +14 -0
  46. package/skills/docs/assets/fragments/api/local-only.md +13 -0
  47. package/skills/docs/assets/fragments/api/rest.md +13 -0
  48. package/skills/docs/assets/fragments/api/rpc.md +13 -0
  49. package/skills/docs/assets/fragments/async/event-bus.md +13 -0
  50. package/skills/docs/assets/fragments/async/none.md +11 -0
  51. package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
  52. package/skills/docs/assets/fragments/async/queue.md +13 -0
  53. package/skills/docs/assets/fragments/async/scheduler.md +13 -0
  54. package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
  55. package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
  56. package/skills/docs/assets/fragments/data/document.md +13 -0
  57. package/skills/docs/assets/fragments/data/external-saas.md +13 -0
  58. package/skills/docs/assets/fragments/data/key-value.md +13 -0
  59. package/skills/docs/assets/fragments/data/none.md +12 -0
  60. package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
  61. package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
  62. package/skills/docs/assets/fragments/env/conventional.md +13 -0
  63. package/skills/docs/assets/fragments/env/envx.md +13 -0
  64. package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
  65. package/skills/docs/assets/fragments/env/none.md +11 -0
  66. package/skills/docs/assets/fragments/ui/api-only.md +13 -0
  67. package/skills/docs/assets/fragments/ui/cli.md +14 -0
  68. package/skills/docs/assets/fragments/ui/desktop.md +14 -0
  69. package/skills/docs/assets/fragments/ui/mobile.md +14 -0
  70. package/skills/docs/assets/fragments/ui/web.md +14 -0
  71. package/skills/docs/assets/templates/adr.md +62 -0
  72. package/skills/docs/assets/templates/api.md +30 -0
  73. package/skills/docs/assets/templates/architecture.md +52 -0
  74. package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
  75. package/skills/docs/assets/templates/compliance.md +29 -0
  76. package/skills/docs/assets/templates/data-schema.md +43 -0
  77. package/skills/docs/assets/templates/feature.md +43 -0
  78. package/skills/docs/assets/templates/foundations.md +55 -0
  79. package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
  80. package/skills/docs/assets/templates/module-inventory.md +19 -0
  81. package/skills/docs/assets/templates/module.md +50 -0
  82. package/skills/docs/assets/templates/nfr.md +22 -0
  83. package/skills/docs/assets/templates/observability.md +28 -0
  84. package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
  85. package/skills/docs/assets/templates/project-summary.md +44 -0
  86. package/skills/docs/assets/templates/roles-permissions.md +26 -0
  87. package/skills/docs/assets/templates/test-plan.md +23 -0
  88. package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
  89. package/skills/docs/references/adr-authoring.md +24 -0
  90. package/skills/docs/references/apis-and-data.md +23 -0
  91. package/skills/docs/references/capability-fragments.md +25 -0
  92. package/skills/docs/references/change-tracking.md +62 -0
  93. package/skills/docs/references/diagrams.md +31 -0
  94. package/skills/docs/references/discovery.md +59 -0
  95. package/skills/docs/references/edge-cases.md +45 -0
  96. package/skills/docs/references/foundations-modules.md +18 -0
  97. package/skills/docs/references/ingestion.md +52 -0
  98. package/skills/docs/references/initialize-adopt.md +25 -0
  99. package/skills/docs/references/module-decomposition.md +38 -0
  100. package/skills/docs/references/profiles.md +60 -0
  101. package/skills/docs/references/quality-attributes.md +27 -0
  102. package/skills/docs/references/reverse-engineer.md +21 -0
  103. package/skills/docs/references/spec-depths.md +31 -0
  104. package/skills/docs/references/summarize.md +18 -0
  105. package/skills/docs/references/surfaces-and-actions.md +24 -0
  106. package/skills/docs/references/validation.md +47 -0
  107. package/skills/docs/references/workflows-and-jobs.md +25 -0
  108. package/skills/docs/scripts/ingest.mjs +984 -0
  109. package/skills/docs/scripts/profile-detect.mjs +299 -0
  110. package/skills/docs/scripts/screen.mjs +96 -0
  111. package/skills/docs/scripts/template-lint.mjs +143 -0
  112. package/skills/docs/scripts/validate-docs.mjs +363 -0
  113. package/skills/doctor/SKILL.md +167 -0
  114. package/skills/feature/SKILL.md +132 -0
  115. package/skills/init/SKILL.md +137 -0
  116. package/skills/project/SKILL.md +261 -0
  117. package/skills/project/references/commands.md +213 -0
  118. package/skills/resume/SKILL.md +79 -0
  119. package/skills/review/SKILL.md +91 -0
  120. package/skills/status/SKILL.md +85 -0
  121. package/skills/task/SKILL.md +183 -0
  122. package/src/cli/product-map.mjs +468 -0
  123. package/src/cli/render.mjs +544 -0
  124. package/src/cli.mjs +2774 -0
  125. package/src/cloud/crypto.mjs +118 -0
  126. package/src/cloud/merge.mjs +186 -0
  127. package/src/cloud/policy.mjs +115 -0
  128. package/src/cloud/sync.mjs +512 -0
  129. package/src/cloud/transport.mjs +116 -0
  130. package/src/db/connect.mjs +189 -0
  131. package/src/db/maintenance.mjs +419 -0
  132. package/src/db/migrate.mjs +188 -0
  133. package/src/db/migrations/001_initial.sql +1024 -0
  134. package/src/db/migrations/002_docs_coverage.sql +126 -0
  135. package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
  136. package/src/db/migrations/004_task_categories.sql +59 -0
  137. package/src/db/migrations/005_executable_evidence.sql +32 -0
  138. package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
  139. package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
  140. package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
  141. package/src/db/migrations/009_retired_documents.sql +54 -0
  142. package/src/db/migrations/010_test_plan_evidence.sql +20 -0
  143. package/src/db/migrations/011_criterion_waiver.sql +13 -0
  144. package/src/db/migrations/012_synchronization.sql +56 -0
  145. package/src/db/store.mjs +327 -0
  146. package/src/decisions/record.mjs +167 -0
  147. package/src/docs/proposals.mjs +871 -0
  148. package/src/docs/render.mjs +1424 -0
  149. package/src/docs/templates.mjs +1281 -0
  150. package/src/features/acceptance.mjs +315 -0
  151. package/src/features/specify.mjs +248 -0
  152. package/src/init/discovery.mjs +901 -0
  153. package/src/init/index.mjs +784 -0
  154. package/src/init/questions.mjs +562 -0
  155. package/src/memory/benchmark.mjs +171 -0
  156. package/src/memory/capture.mjs +135 -0
  157. package/src/memory/consolidate.mjs +255 -0
  158. package/src/memory/index.mjs +810 -0
  159. package/src/model/ids.mjs +185 -0
  160. package/src/model/screening.mjs +150 -0
  161. package/src/model/toolkit.mjs +258 -0
  162. package/src/model/vocabulary.mjs +190 -0
  163. package/src/product/assumptions.mjs +120 -0
  164. package/src/product/changes.mjs +180 -0
  165. package/src/product/test-plans.mjs +183 -0
  166. package/src/progress/index.mjs +1364 -0
  167. package/src/runtime/harness.mjs +264 -0
  168. package/src/runtime/hooks.mjs +1021 -0
  169. package/src/runtime/identity.mjs +305 -0
  170. package/src/runtime/resume.mjs +343 -0
  171. package/src/runtime/session.mjs +679 -0
  172. package/src/runtime/version.mjs +315 -0
  173. package/src/service/assets/control-center.html +206 -0
  174. package/src/service/assets/control-center.manifest.json +7 -0
  175. package/src/service/manage.mjs +542 -0
  176. package/src/service/mutations.mjs +860 -0
  177. package/src/service/read-model.mjs +1557 -0
  178. package/src/service/server.mjs +783 -0
  179. package/src/tasks/categories.mjs +154 -0
  180. package/src/tasks/derive.mjs +977 -0
  181. package/src/tasks/lifecycle.mjs +830 -0
  182. package/src/verify/index.mjs +236 -0
@@ -0,0 +1,1424 @@
1
+ // Database to Markdown. The database is the source of truth; every file under
2
+ // talks/ is a projection of it.
3
+ //
4
+ // Three rules shape this module:
5
+ //
6
+ // 1. Deterministic. Stable ordering everywhere and no clock reading inside a
7
+ // body, so re-rendering unchanged data produces an identical hash and git
8
+ // stays quiet.
9
+ // 2. A document exists only when its capability is applicable. An absent file
10
+ // is not a defect when nothing in the database calls for it.
11
+ // 3. A manual edit is never silently overwritten. An authored projection whose
12
+ // body on disk is not exactly what we last generated raises a proposal. A
13
+ // derived view (changelog, reports) is always rewritten, because a report
14
+ // that can be trapped behind a proposal stops being a report.
15
+ //
16
+ // File writes happen outside the write transaction, always, because the engine
17
+ // holds a process-wide lock for the transaction's whole life.
18
+
19
+ import { createHash } from "node:crypto";
20
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
21
+ import { dirname, join, posix } from "node:path";
22
+ import { query, mutate, create, recordActivity, currentProject, json } from "../db/store.mjs";
23
+ import { assertStorable } from "../model/screening.mjs";
24
+ import { slugify } from "../model/ids.mjs";
25
+ import { EDGE_CASE_CATEGORIES, MODULE_STEPS } from "../model/vocabulary.mjs";
26
+ import * as T from "./templates.mjs";
27
+
28
+ export const MARKER = "superdev:generated";
29
+
30
+ const MARKER_RE = /^<!--\s*superdev:generated\s+source=(\S+)\s+revision=(\d+)\s+hash=([0-9a-f]{64})\s*-->[ \t]*\r?\n?/;
31
+
32
+ const ROOT_DIR = "talks";
33
+
34
+ /** LF endings, no trailing whitespace on any line, exactly one terminal newline. */
35
+ export function normalizeBody(body) {
36
+ const lines = String(body ?? "").replace(/\r\n?/g, "\n").split("\n");
37
+ return `${lines.map((l) => l.replace(/[ \t]+$/, "")).join("\n").replace(/\n+$/, "")}\n`;
38
+ }
39
+
40
+ export const bodyHash = (body) => createHash("sha256").update(normalizeBody(body)).digest("hex");
41
+
42
+ /** Full file text: the marker line, then the normalized body. */
43
+ export function withMarker({ sourceId, revision, body }) {
44
+ const normalized = normalizeBody(body);
45
+ return `<!-- ${MARKER} source=${sourceId} revision=${revision} hash=${bodyHash(normalized)} -->\n${normalized}`;
46
+ }
47
+
48
+ export function parseMarker(text) {
49
+ const raw = String(text ?? "");
50
+ const m = MARKER_RE.exec(raw);
51
+ if (!m) return null;
52
+ return {
53
+ sourceId: m[1],
54
+ revision: Number(m[2]),
55
+ hash: m[3],
56
+ body: normalizeBody(raw.slice(m[0].length)),
57
+ };
58
+ }
59
+
60
+ // ------------------------------------------------------------------ snapshot
61
+
62
+ // One read of everything the renderers need, cached against the connection that
63
+ // produced it. The cache dies with the connection, which is what keeps the
64
+ // "never cache a connection" rule intact: this caches rows, not a handle.
65
+ const cache = new WeakMap();
66
+
67
+ const rows = (db, sql, ...params) => db.all(sql, ...params);
68
+
69
+ async function snapshot(db, projectId) {
70
+ const hit = cache.get(db);
71
+ if (hit && hit.projectId === projectId) return hit;
72
+
73
+ const s = { projectId };
74
+ s.project = await db.get("SELECT * FROM projects WHERE id = ?", projectId);
75
+ // The revision a document is stamped with has to be the same one freshness
76
+ // compares it against, or the two can never agree. Freshness deliberately
77
+ // ignores documentation events, because a generate run records its own event
78
+ // after rendering and comparing against the raw maximum would report every
79
+ // document as stale the instant it was produced. Stamping with the raw
80
+ // maximum here recreated exactly that: documents were written at a revision
81
+ // that freshness would never consider current, so five sat permanently behind
82
+ // while the documentation check confirmed all 295 matched the database.
83
+ s.revision = (await db.value(
84
+ `SELECT max(sequence) FROM activity_events
85
+ WHERE project_id = ? AND event_type NOT IN
86
+ ('documentation_generated','documentation_proposal_raised',
87
+ 'documentation_proposal_resolved','documentation_possibly_stale')`,
88
+ projectId,
89
+ )) ?? 0;
90
+
91
+ s.goals = await rows(db, "SELECT * FROM goals WHERE project_id = ? ORDER BY sequence, id", projectId);
92
+ s.goalCriteria = await rows(db, "SELECT * FROM goal_success_criteria ORDER BY goal_id, sequence, id");
93
+ s.milestones = await rows(db, "SELECT * FROM milestones WHERE project_id = ? ORDER BY sequence, id", projectId);
94
+ s.scopeItems = await rows(db, "SELECT * FROM project_scope_items WHERE project_id = ? ORDER BY direction, sequence, id", projectId);
95
+ s.glossary = await rows(db, "SELECT * FROM glossary_terms WHERE project_id = ? ORDER BY term", projectId);
96
+ s.pieces = await rows(db, "SELECT * FROM runtime_pieces WHERE project_id = ? ORDER BY sequence, name", projectId);
97
+ s.pieceEdges = await rows(db, "SELECT * FROM runtime_piece_edges ORDER BY from_piece_id, to_piece_id");
98
+ s.capabilities = await rows(db, "SELECT * FROM capability_areas WHERE project_id = ? ORDER BY catalog, sequence, area", projectId);
99
+
100
+ s.modules = await rows(db, "SELECT * FROM modules WHERE project_id = ? ORDER BY sequence, slug", projectId);
101
+ s.completeness = await rows(db, "SELECT * FROM module_completeness ORDER BY module_id, step");
102
+ s.features = await rows(db, "SELECT * FROM features WHERE project_id = ? ORDER BY module_id, slug", projectId);
103
+ s.flows = await rows(db, "SELECT * FROM feature_flows ORDER BY feature_id, sequence");
104
+ s.acceptance = await rows(db, "SELECT * FROM feature_acceptance_criteria ORDER BY feature_id, sequence, id");
105
+ s.edgeCases = await rows(db, "SELECT * FROM feature_edge_cases ORDER BY feature_id, category");
106
+ s.featureGoals = await rows(db, "SELECT * FROM feature_goals ORDER BY feature_id, goal_id");
107
+
108
+ s.workflows = await rows(db, "SELECT * FROM workflows ORDER BY feature_id, name, id");
109
+ s.wfActors = await rows(db, "SELECT * FROM workflow_actors ORDER BY workflow_id, sequence, actor");
110
+ s.wfSteps = await rows(db, "SELECT * FROM workflow_steps ORDER BY workflow_id, sequence");
111
+ s.wfBranches = await rows(db, "SELECT * FROM workflow_branches ORDER BY workflow_id, from_step_id, condition");
112
+ s.machines = await rows(db, "SELECT * FROM state_machines ORDER BY entity_name, id");
113
+ s.states = await rows(db, "SELECT * FROM states ORDER BY state_machine_id, sequence, name");
114
+ s.transitions = await rows(db, "SELECT * FROM state_transitions ORDER BY state_machine_id, from_state_id, event");
115
+
116
+ s.surfaces = await rows(db, "SELECT * FROM surfaces ORDER BY module_id, name, id");
117
+ s.surfaceStates = await rows(db, "SELECT * FROM surface_states ORDER BY surface_id, state_type");
118
+ s.actions = await rows(db, "SELECT * FROM ui_actions ORDER BY surface_id, name, id");
119
+
120
+ s.apis = await rows(db, "SELECT * FROM api_operations ORDER BY module_id, name, id");
121
+ s.entities = await rows(db, "SELECT * FROM data_entities ORDER BY module_id, name, id");
122
+ s.fields = await rows(db, "SELECT * FROM data_fields ORDER BY entity_id, sequence, name");
123
+ s.relationships = await rows(db, "SELECT * FROM data_relationships ORDER BY from_entity_id, name");
124
+ s.migrations = await rows(db, "SELECT * FROM schema_migrations WHERE project_id = ? ORDER BY sequence, id", projectId);
125
+ s.migrationEntities = await rows(db, "SELECT * FROM schema_migration_entities ORDER BY migration_id, entity_id");
126
+
127
+ s.integrations = await rows(db, "SELECT * FROM integrations ORDER BY module_id, name, id");
128
+ s.jobs = await rows(db, "SELECT * FROM jobs ORDER BY module_id, name, id");
129
+ s.webhooks = await rows(db, "SELECT * FROM webhooks ORDER BY module_id, name, id");
130
+
131
+ s.roles = await rows(db, "SELECT * FROM roles WHERE project_id = ? ORDER BY sequence, name", projectId);
132
+ s.permissions = await rows(db, "SELECT * FROM permissions ORDER BY role_id, scope_type, capability");
133
+ s.nfrs = await rows(db, "SELECT * FROM non_functional_requirements WHERE project_id = ? ORDER BY category, id", projectId);
134
+
135
+ s.decisions = await rows(db, "SELECT * FROM decisions WHERE project_id = ? ORDER BY id", projectId);
136
+ s.decisionLinks = await rows(db, "SELECT * FROM decision_links ORDER BY decision_id, relationship, target_id");
137
+ s.decisionTransitions = await rows(db, "SELECT * FROM decision_transitions ORDER BY decision_id, sequence");
138
+
139
+ s.questions = await rows(db, "SELECT * FROM questions WHERE project_id = ? ORDER BY status, id", projectId);
140
+ s.discovery = await rows(db, "SELECT * FROM discovery_items WHERE project_id = ? ORDER BY kind, id", projectId);
141
+ s.documents = await rows(db, "SELECT * FROM documents WHERE project_id = ? ORDER BY path", projectId);
142
+ s.evidence = await rows(db, "SELECT * FROM verification_evidence WHERE project_id = ? ORDER BY id", projectId);
143
+ s.tasks = await rows(db, "SELECT id, feature_id, status FROM tasks WHERE project_id = ? ORDER BY id", projectId);
144
+ s.events = await rows(
145
+ db,
146
+ `SELECT sequence, event_type, summary, actor_label, created_at FROM activity_events
147
+ WHERE project_id = ? AND event_type NOT LIKE 'documentation_%'
148
+ ORDER BY sequence DESC LIMIT 200`,
149
+ projectId,
150
+ );
151
+
152
+ index(s);
153
+ cache.set(db, s);
154
+ return s;
155
+ }
156
+
157
+ /** Lookup maps. Built once so no renderer ever scans a whole table. */
158
+ function index(s) {
159
+ s.byId = new Map();
160
+ for (const key of ["modules", "features", "workflows", "machines", "surfaces", "apis",
161
+ "entities", "integrations", "jobs", "webhooks", "roles", "decisions", "milestones", "goals"]) {
162
+ for (const row of s[key]) s.byId.set(row.id, row);
163
+ }
164
+ s.moduleOf = (row) => {
165
+ if (row?.module_id) return s.byId.get(row.module_id) ?? null;
166
+ const feature = row?.feature_id ? s.byId.get(row.feature_id) : null;
167
+ return feature ? s.byId.get(feature.module_id) ?? null : null;
168
+ };
169
+ s.featureModule = (feature) => (feature ? s.byId.get(feature.module_id) ?? null : null);
170
+ s.workflowFeature = (w) => s.byId.get(w.feature_id) ?? null;
171
+ }
172
+
173
+ const by = (list, key) => {
174
+ const map = new Map();
175
+ for (const row of list) {
176
+ const k = row[key];
177
+ if (!map.has(k)) map.set(k, []);
178
+ map.get(k).push(row);
179
+ }
180
+ return map;
181
+ };
182
+
183
+ const pick = (map, key) => map.get(key) ?? [];
184
+
185
+ // ---------------------------------------------------------------------- plan
186
+
187
+ const AUTHORED = "authored_projection";
188
+ const DERIVED = "derived_view";
189
+
190
+ /** A slug that is unique inside one folder, allocated in the order given. */
191
+ function folderSlug(used, name, fallback) {
192
+ const base = slugify(name, fallback);
193
+ let candidate = base;
194
+ for (let n = 2; used.has(candidate); n++) candidate = `${base}-${n}`;
195
+ used.add(candidate);
196
+ return candidate;
197
+ }
198
+
199
+ /**
200
+ * Every document this project should have, and nothing more. Paths follow brief
201
+ * section 9.1. A project-level artifact appears only when the database holds the
202
+ * records that fill it; a per-record artifact appears because the record exists.
203
+ */
204
+ const REPORT_KINDS = new Set(["project_summary", "status", "drift"]);
205
+
206
+ export async function documentPlan(db, projectId, { includeReports = false } = {}) {
207
+ const s = await snapshot(db, projectId);
208
+ if (!s.project) return [];
209
+ if (s.plan) return includeReports ? s.plan : s.plan.filter((entry) => !REPORT_KINDS.has(entry.kind));
210
+
211
+ const plan = [];
212
+ const add = (kind, scopeType, scopeId, path, template, mode = AUTHORED, label = null) => {
213
+ plan.push({ kind, projectId, scopeType, scopeId, sourceId: scopeId ?? projectId, path, template, regenerationMode: mode, label });
214
+ };
215
+
216
+ const F = `${ROOT_DIR}/foundations`;
217
+ add("foundations_product", "project", null, `${F}/product.md`, "foundations.md", AUTHORED, "Product");
218
+ if (s.roles.length) add("foundations_users", "project", null, `${F}/users-and-roles.md`, "foundations.md", AUTHORED, "Users and roles");
219
+ if (s.permissions.length) add("roles_permissions", "project", null, `${F}/roles-and-permissions.md`, "roles-permissions.md", AUTHORED, "Roles and permissions");
220
+ if (s.scopeItems.length) add("foundations_scope", "project", null, `${F}/scope.md`, "foundations.md", AUTHORED, "Scope");
221
+ if (s.modules.length || s.pieces.length) add("architecture", "project", null, `${F}/architecture.md`, "architecture.md", AUTHORED, "Architecture");
222
+ if (s.capabilities.some((c) => c.catalog === "stack_slot")) add("foundations_stack", "project", null, `${F}/stack.md`, "foundations.md", AUTHORED, "Stack");
223
+ if (s.glossary.length) add("foundations_glossary", "project", null, `${F}/glossary.md`, "foundations.md", AUTHORED, "Glossary");
224
+ if (s.nfrs.length) add("nfrs", "project", null, `${F}/nfrs.md`, "nfr.md", AUTHORED, "Non-functional requirements");
225
+ // Compliance is written only where regulated data or a declared regime exists.
226
+ // Generating it otherwise would invent a regime, which the template forbids.
227
+ const sensitive = s.fields.filter((f) => f.sensitivity_class !== "none")
228
+ .concat(s.entities.filter((e) => e.sensitivity_class !== "none"));
229
+ if (sensitive.length || complianceDecisions(s).length) {
230
+ add("compliance", "project", null, `${F}/compliance.md`, "compliance.md", AUTHORED, "Compliance");
231
+ }
232
+
233
+ if (s.modules.length) add("module_inventory", "project", null, `${ROOT_DIR}/modules/module-inventory.md`, "module-inventory.md", AUTHORED, "Module inventory");
234
+
235
+ const featuresByModule = by(s.features, "module_id");
236
+ for (const m of s.modules) {
237
+ const root = `${ROOT_DIR}/modules/${m.slug}`;
238
+ add("module", "module", m.id, `${root}/module.md`, "module.md", AUTHORED, m.name);
239
+
240
+ for (const f of pick(featuresByModule, m.id)) {
241
+ add("feature", "feature", f.id, `${root}/features/${f.slug}.md`, "feature.md", AUTHORED, f.name);
242
+ }
243
+
244
+ const moduleFeatureIds = new Set(pick(featuresByModule, m.id).map((f) => f.id));
245
+ const used = { workflows: new Set(), surfaces: new Set(), apis: new Set(), data: new Set(), integrations: new Set(), jobs: new Set() };
246
+
247
+ const workflows = s.workflows.filter((w) => moduleFeatureIds.has(w.feature_id));
248
+ for (const w of workflows) {
249
+ add("workflow", "workflow", w.id, `${root}/workflows/${folderSlug(used.workflows, w.name, "workflow")}.md`, "workflow-state-machine.md", AUTHORED, w.name);
250
+ }
251
+ // A state machine that no workflow document already covers still needs a home.
252
+ const coveredFeatures = new Set(workflows.map((w) => w.feature_id));
253
+ for (const machine of s.machines) {
254
+ const owner = s.moduleOf(machine);
255
+ if (owner?.id !== m.id) continue;
256
+ if (machine.feature_id && coveredFeatures.has(machine.feature_id)) continue;
257
+ add("state_machine", "state_machine", machine.id,
258
+ `${root}/workflows/${folderSlug(used.workflows, `${machine.entity_name}-states`, "states")}.md`,
259
+ "workflow-state-machine.md", AUTHORED, machine.entity_name);
260
+ }
261
+
262
+ for (const surface of s.surfaces.filter((x) => x.module_id === m.id)) {
263
+ add("surface", "surface", surface.id, `${root}/surfaces/${folderSlug(used.surfaces, surface.name, "surface")}.md`, "pages-ui-actions.md", AUTHORED, surface.name);
264
+ }
265
+ for (const api of s.apis.filter((x) => x.module_id === m.id)) {
266
+ add("api", "api_operation", api.id, `${root}/apis/${folderSlug(used.apis, api.name, "operation")}.md`, "api.md", AUTHORED, api.name);
267
+ }
268
+ for (const entity of s.entities.filter((x) => x.module_id === m.id)) {
269
+ add("data_entity", "data_entity", entity.id, `${root}/data/${folderSlug(used.data, entity.name, "entity")}.md`, "data-schema.md", AUTHORED, entity.name);
270
+ }
271
+ for (const integration of s.integrations.filter((x) => x.module_id === m.id)) {
272
+ add("integration", "integration", integration.id, `${root}/integrations/${folderSlug(used.integrations, integration.name, "integration")}.md`, "integration.md", AUTHORED, integration.name);
273
+ }
274
+ for (const job of s.jobs.filter((x) => x.module_id === m.id)) {
275
+ add("job", "job", job.id, `${root}/jobs/${folderSlug(used.jobs, job.name, "job")}.md`, "jobs-webhooks.md", AUTHORED, job.name);
276
+ }
277
+ for (const hook of s.webhooks.filter((x) => x.module_id === m.id)) {
278
+ add("webhook", "webhook", hook.id, `${root}/jobs/${folderSlug(used.jobs, hook.name, "webhook")}.md`, "jobs-webhooks.md", AUTHORED, hook.name);
279
+ }
280
+
281
+ if (observabilityData(s, m, "").signals.length) {
282
+ add("observability", "module", m.id, `${root}/observability.md`, "observability.md", AUTHORED, `${m.name} observability`);
283
+ }
284
+ if (moduleFeatureIds.size) {
285
+ add("test_plan", "module", m.id, `${root}/test-plan.md`, "test-plan.md", AUTHORED, `${m.name} test plan`);
286
+ }
287
+ }
288
+
289
+ const usedDecisions = new Set();
290
+ for (const d of s.decisions) {
291
+ const slug = folderSlug(usedDecisions, `${d.id}-${d.title}`, d.id.toLowerCase());
292
+ add("adr", "decision", d.id, `${ROOT_DIR}/decisions/${slug}.md`, "adr.md", AUTHORED, d.id);
293
+ }
294
+
295
+ add("changelog", "project", null, `${ROOT_DIR}/changes/changelog.md`, "changelog.md", DERIVED, "Changelog");
296
+ add("project_summary", "project", null, `${ROOT_DIR}/reports/project-summary.md`, "project-summary.md", DERIVED, "Project summary");
297
+ add("status", "project", null, `${ROOT_DIR}/reports/status.md`, "status.md", DERIVED, "Status");
298
+ add("drift", "project", null, `${ROOT_DIR}/reports/drift.md`, "change-impact-drift-report.md", DERIVED, "Drift");
299
+
300
+ plan.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
301
+ s.plan = plan;
302
+ if (!includeReports) return plan.filter((entry) => !REPORT_KINDS.has(entry.kind));
303
+ // Keyed by kind, not scope type: a module owns three documents (module,
304
+ // observability, test plan) and they must not overwrite each other here.
305
+ s.pathOf = new Map(plan.map((p) => [`${p.kind}:${p.scopeId ?? ""}`, p]));
306
+ return plan;
307
+ }
308
+
309
+ /** Link from one document to another, relative, or a bare label when unplanned. */
310
+ function refTo(s, fromPath, kind, scopeId, label) {
311
+ const target = s.pathOf?.get(`${kind}:${scopeId ?? ""}`);
312
+ if (!target) return { label: label ?? scopeId ?? "not documented" };
313
+ return { label: label ?? target.label ?? scopeId, link: posix.relative(posix.dirname(fromPath), target.path) };
314
+ }
315
+
316
+ // -------------------------------------------------------------------- render
317
+
318
+ /**
319
+ * Render one planned document. Returns the body without the marker, plus a
320
+ * fingerprint of the records that fed it so staleness can be judged without
321
+ * re-rendering.
322
+ */
323
+ export async function renderDocument(db, plan) {
324
+ const s = await snapshot(db, plan.projectId);
325
+ if (!s.plan) await documentPlan(db, plan.projectId);
326
+ const data = buildData(s, plan);
327
+ const body = RENDERERS[plan.kind](data);
328
+ // ponytail: fingerprint over the assembled input. It moves when an unrelated
329
+ // column on a source row moves, which costs one harmless re-render; a
330
+ // per-column fingerprint would be the upgrade if that ever shows up as churn.
331
+ return { path: plan.path, body: normalizeBody(body), sourceFingerprint: fingerprint(data) };
332
+ }
333
+
334
+ const fingerprint = (data) => createHash("sha256").update(JSON.stringify(data)).digest("hex");
335
+
336
+ const RENDERERS = {
337
+ foundations_product: T.foundationsProduct,
338
+ foundations_users: T.foundationsUsersAndRoles,
339
+ roles_permissions: T.foundationsRolesAndPermissions,
340
+ foundations_scope: T.foundationsScope,
341
+ architecture: T.foundationsArchitecture,
342
+ foundations_stack: T.foundationsStack,
343
+ foundations_glossary: T.foundationsGlossary,
344
+ nfrs: T.foundationsNfrs,
345
+ compliance: T.foundationsCompliance,
346
+ module_inventory: T.moduleInventory,
347
+ module: T.moduleSpec,
348
+ feature: T.featureSpec,
349
+ workflow: T.workflowSpec,
350
+ state_machine: T.workflowSpec,
351
+ surface: T.surfaceSpec,
352
+ api: T.apiSpec,
353
+ data_entity: T.dataEntitySpec,
354
+ integration: T.integrationSpec,
355
+ job: T.asyncSpec,
356
+ webhook: T.asyncSpec,
357
+ observability: T.observabilitySpec,
358
+ test_plan: T.testPlan,
359
+ adr: T.adr,
360
+ changelog: T.changelog,
361
+ project_summary: T.projectSummary,
362
+ status: T.statusReport,
363
+ drift: T.driftReport,
364
+ };
365
+
366
+ const scopeItems = (s, direction) => s.scopeItems.filter((i) => i.direction === direction);
367
+ const capabilities = (s, catalog) => s.capabilities.filter((c) => c.catalog === catalog);
368
+ const slot = (s, pattern) => capabilities(s, "stack_slot").find((c) => pattern.test(c.area));
369
+ const complianceDecisions = (s) =>
370
+ s.decisions.filter((d) => d.scope_type === "compliance" || /compliance|privacy|gdpr|regime/i.test(d.title));
371
+ const dateOf = (value) => (value ? String(value).slice(0, 10) : null);
372
+ const readable = (value) => String(value ?? "").replace(/_/g, " ");
373
+
374
+ function buildData(s, plan) {
375
+ const p = plan.path;
376
+ switch (plan.kind) {
377
+ case "foundations_product": return productData(s);
378
+ case "foundations_users": return usersData(s, p);
379
+ case "roles_permissions": return permissionsData(s, p);
380
+ case "foundations_scope": return scopeData(s, p);
381
+ case "architecture": return architectureData(s);
382
+ case "foundations_stack": return stackData(s);
383
+ case "foundations_glossary": return { project: s.project, terms: s.glossary };
384
+ case "nfrs": return nfrData(s);
385
+ case "compliance": return complianceData(s);
386
+ case "module_inventory": return inventoryData(s, p);
387
+ case "module": return moduleData(s, s.byId.get(plan.scopeId), p);
388
+ case "feature": return featureData(s, s.byId.get(plan.scopeId), p);
389
+ case "workflow": return workflowData(s, s.byId.get(plan.scopeId), null, p);
390
+ case "state_machine": return workflowData(s, null, s.byId.get(plan.scopeId), p);
391
+ case "surface": return surfaceData(s, s.byId.get(plan.scopeId), p);
392
+ case "api": return apiData(s, s.byId.get(plan.scopeId), p);
393
+ case "data_entity": return entityData(s, s.byId.get(plan.scopeId), p);
394
+ case "integration": return integrationData(s, s.byId.get(plan.scopeId), p);
395
+ case "job": return asyncData(s, s.byId.get(plan.scopeId), null, p);
396
+ case "webhook": return asyncData(s, null, s.byId.get(plan.scopeId), p);
397
+ case "observability": return observabilityData(s, s.byId.get(plan.scopeId), p);
398
+ case "test_plan": return testPlanData(s, s.byId.get(plan.scopeId));
399
+ case "adr": return adrData(s, s.byId.get(plan.scopeId), p);
400
+ case "changelog": return changelogData(s);
401
+ case "project_summary": return summaryData(s, p);
402
+ case "status": return statusData(s, p);
403
+ case "drift": return driftData(s, p);
404
+ default: throw new Error(`no renderer for document kind: ${plan.kind}`);
405
+ }
406
+ }
407
+
408
+ // ------------------------------------------------------------ project sources
409
+
410
+ function productData(s) {
411
+ const goalName = new Map(s.goals.map((g) => [g.id, g.name]));
412
+ return {
413
+ project: s.project,
414
+ nonGoals: scopeItems(s, "non_goal"),
415
+ goals: s.goals,
416
+ successCriteria: s.goalCriteria
417
+ .filter((c) => goalName.has(c.goal_id))
418
+ .map((c) => ({ ...c, goal: goalName.get(c.goal_id) })),
419
+ milestones: s.milestones.map((m) => ({
420
+ ...m, entry: json(m.entry_conditions_json), exit: json(m.exit_conditions_json),
421
+ })),
422
+ };
423
+ }
424
+
425
+ function usersData(s, path) {
426
+ return {
427
+ project: s.project,
428
+ roles: s.roles,
429
+ users: s.discovery.filter((d) => d.kind === "user" && d.status !== "rejected"),
430
+ permissionsDoc: refTo(s, path, "roles_permissions", null, "roles and permissions"),
431
+ };
432
+ }
433
+
434
+ function permissionsData(s, path) {
435
+ const roleName = new Map(s.roles.map((r) => [r.id, r.name]));
436
+ const moduleName = new Map(s.modules.map((m) => [m.id, m.name]));
437
+
438
+ const visibility = s.modules.map((m) => {
439
+ const access = {};
440
+ for (const perm of s.permissions) {
441
+ if (perm.scope_type !== "module" || perm.scope_id !== m.id) continue;
442
+ const role = roleName.get(perm.role_id);
443
+ if (role) access[role] = perm.access_level;
444
+ }
445
+ return { module: m.name, access };
446
+ });
447
+
448
+ const entityName = new Map(s.entities.map((e) => [e.id, e.name]));
449
+ const sensitiveFields = s.fields
450
+ .filter((f) => f.sensitivity_class !== "none" && entityName.has(f.entity_id))
451
+ .map((f) => {
452
+ const access = {};
453
+ for (const perm of s.permissions) {
454
+ if (perm.scope_type !== "field" || perm.scope_id !== f.id) continue;
455
+ const role = roleName.get(perm.role_id);
456
+ if (role) access[role] = perm.access_level;
457
+ }
458
+ return { field: `${entityName.get(f.entity_id)}.${f.name}`, sensitivity_class: f.sensitivity_class, access };
459
+ });
460
+
461
+ return {
462
+ project: s.project,
463
+ roles: s.roles,
464
+ visibility,
465
+ capabilities: s.permissions.map((perm) => ({
466
+ role: roleName.get(perm.role_id) ?? perm.role_id,
467
+ scope: perm.scope_id ? `${perm.scope_type} ${perm.scope_id}` : perm.scope_type,
468
+ capability: perm.capability,
469
+ access_level: perm.access_level,
470
+ enforcement_point: perm.enforcement_point,
471
+ })),
472
+ sensitiveFields,
473
+ moduleDocs: s.surfaces.length
474
+ ? s.modules.map((m) => refTo(s, path, "module", m.id, moduleName.get(m.id))) : [],
475
+ enforcementPoints: [...new Set(s.permissions.map((perm) => perm.enforcement_point).filter(Boolean))],
476
+ };
477
+ }
478
+
479
+ function scopeData(s, path) {
480
+ return {
481
+ project: s.project,
482
+ inScope: scopeItems(s, "in"),
483
+ outScope: scopeItems(s, "out"),
484
+ nonGoals: scopeItems(s, "non_goal"),
485
+ featureScope: s.features.map((f) => ({
486
+ doc: refTo(s, path, "feature", f.id, f.name),
487
+ module: s.featureModule(f)?.name,
488
+ in: json(f.scope_in_json),
489
+ out: json(f.scope_out_json),
490
+ })),
491
+ };
492
+ }
493
+
494
+ function architectureData(s) {
495
+ const pieceName = new Map(s.pieces.map((x) => [x.id, x.name]));
496
+ const moduleBySlug = new Map(s.modules.map((m) => [slugify(m.name, m.slug), m]));
497
+
498
+ const moduleEdges = [];
499
+ for (const m of s.modules) {
500
+ for (const consumed of json(m.consumes_json)) {
501
+ const target = moduleBySlug.get(slugify(String(consumed), "")) ??
502
+ s.modules.find((x) => x.name === consumed || x.slug === consumed);
503
+ if (target) moduleEdges.push({ from: m.slug, to: target.slug });
504
+ }
505
+ }
506
+
507
+ const firstWorkflow = s.workflows[0];
508
+ const criticalPath = firstWorkflow
509
+ ? s.wfSteps.filter((step) => step.workflow_id === firstWorkflow.id).map((step, i, all) => ({
510
+ from: step.owner_ref || step.owner_type,
511
+ to: all[i + 1]?.owner_ref || all[i + 1]?.owner_type || step.owner_ref || step.owner_type,
512
+ action: step.action,
513
+ }))
514
+ : [];
515
+
516
+ return {
517
+ project: s.project,
518
+ shape: s.project.statement,
519
+ actors: s.roles.map((r) => r.name),
520
+ externals: s.integrations.map((i) => i.provider || i.name),
521
+ contextClaim: `${s.roles.length} recorded roles reach the product, which depends on ${s.integrations.length} recorded external integrations.`,
522
+ pieces: s.pieces.map((piece) => ({
523
+ ...piece,
524
+ talksTo: s.pieceEdges.filter((e) => e.from_piece_id === piece.id)
525
+ .map((e) => `${pieceName.get(e.to_piece_id) ?? e.to_piece_id}${e.protocol ? ` (${e.protocol})` : ""}`),
526
+ })),
527
+ modules: s.modules,
528
+ moduleEdges,
529
+ moduleClaim: moduleEdges.length
530
+ ? "Every arrow is a recorded consumes relationship. An arrow the code adds is drift."
531
+ : "No module declares a dependency on another.",
532
+ ownership: s.modules.map((m) => ({
533
+ module: m.name,
534
+ entities: s.entities.filter((e) => e.module_id === m.id).map((e) => e.name),
535
+ consumers: json(m.consumes_json),
536
+ })).filter((o) => o.entities.length),
537
+ criticalPath,
538
+ criticalClaim: firstWorkflow ? `The ${firstWorkflow.name} workflow, step by step.` : "No workflow recorded yet.",
539
+ boundaries: capabilities(s, "boundary"),
540
+ };
541
+ }
542
+
543
+ function stackData(s) {
544
+ const slots = capabilities(s, "stack_slot");
545
+ const design = slots.find((c) => /design|accessib/i.test(c.area));
546
+ return {
547
+ project: s.project,
548
+ slots,
549
+ designDirection: design ? (design.state === "specified" ? design.choice : `${readable(design.state)}: ${design.reason}`) : null,
550
+ };
551
+ }
552
+
553
+ function nfrData(s) {
554
+ const scopeLabel = (n) => {
555
+ if (n.feature_id) return s.byId.get(n.feature_id)?.name ?? n.feature_id;
556
+ if (n.module_id) return s.byId.get(n.module_id)?.name ?? n.module_id;
557
+ return "project";
558
+ };
559
+ const nfrs = s.nfrs.map((n) => ({ ...n, scope: scopeLabel(n) }));
560
+ return { project: s.project, nfrs, open: nfrs.filter((n) => !n.target_source || !n.target) };
561
+ }
562
+
563
+ function complianceData(s) {
564
+ const entityName = new Map(s.entities.map((e) => [e.id, e.name]));
565
+ const moduleName = new Map(s.modules.map((m) => [m.id, m.name]));
566
+ const inventory = s.fields
567
+ .filter((f) => f.sensitivity_class !== "none")
568
+ .map((f) => {
569
+ const entity = s.entities.find((e) => e.id === f.entity_id);
570
+ return {
571
+ field: `${entityName.get(f.entity_id) ?? f.entity_id}.${f.name}`,
572
+ sensitivity_class: f.sensitivity_class,
573
+ modules: entity ? [moduleName.get(entity.module_id) ?? entity.module_id] : [],
574
+ };
575
+ });
576
+
577
+ const rules = [];
578
+ for (const e of s.entities.filter((x) => x.retention_rule || x.deletion_semantics)) {
579
+ if (e.retention_rule) rules.push({ concern: `Retention of ${e.name}`, rule: e.retention_rule, enforced_at: e.store });
580
+ if (e.deletion_semantics) rules.push({ concern: `Deletion of ${e.name}`, rule: e.deletion_semantics, enforced_at: e.store });
581
+ }
582
+ for (const perm of s.permissions.filter((x) => x.access_level === "redacted")) {
583
+ rules.push({ concern: `Access control on ${perm.capability}`, rule: "redacted for this role", enforced_at: perm.enforcement_point });
584
+ }
585
+
586
+ const gaps = s.questions
587
+ .filter((q) => q.status === "open" && /privacy|compliance|retention|security|personal|regulat/i.test(`${q.question} ${q.why_it_matters}`))
588
+ .map((q) => ({ gap: q.question, risk: q.why_it_matters, question: q.recommendation ?? q.id }));
589
+ for (const c of capabilities(s, "readiness").filter((x) => /compliance|privacy|security/i.test(x.area) && x.state !== "specified")) {
590
+ gaps.push({ gap: c.area, risk: c.consequence ?? c.reason, question: c.revisit_trigger ?? c.reason });
591
+ }
592
+
593
+ return {
594
+ project: s.project,
595
+ regimes: complianceDecisions(s).map((d) => ({ label: `${d.id} ${d.title}` })),
596
+ inventory,
597
+ rules,
598
+ gaps,
599
+ };
600
+ }
601
+
602
+ // -------------------------------------------------------------------- modules
603
+
604
+ function inventoryData(s, path) {
605
+ return {
606
+ project: s.project,
607
+ modules: s.modules.map((m) => ({
608
+ ...m,
609
+ primary_users: json(m.primary_users_json),
610
+ owns: json(m.owns_json),
611
+ doc: refTo(s, path, "module", m.id, `modules/${m.slug}/module.md`),
612
+ })),
613
+ exclusions: s.discovery.filter((d) => d.kind === "exclusion").map((d) => d.statement),
614
+ };
615
+ }
616
+
617
+ function moduleData(s, m, path) {
618
+ const features = s.features.filter((f) => f.module_id === m.id);
619
+ const featureIds = new Set(features.map((f) => f.id));
620
+ const surfaces = s.surfaces.filter((x) => x.module_id === m.id);
621
+ const actionsBySurface = by(s.actions, "surface_id");
622
+
623
+ const wiring = [];
624
+ for (const surface of surfaces) {
625
+ for (const action of pick(actionsBySurface, surface.id)) {
626
+ const effects = json(action.side_effects_json);
627
+ wiring.push({
628
+ action: action.label ?? action.name,
629
+ path: [surface.name, action.effect ?? "no handler recorded", effects.length ? effects.join(" and ") : "no side effects recorded"]
630
+ .join(" -> "),
631
+ });
632
+ }
633
+ }
634
+
635
+ const machines = s.machines.filter((x) => s.moduleOf(x)?.id === m.id);
636
+ const statesByMachine = by(s.states, "state_machine_id");
637
+
638
+ const events = [
639
+ ...s.webhooks.filter((x) => x.module_id === m.id).map((w) => ({
640
+ event: w.name, direction: w.direction === "incoming" ? "consumes" : "emits",
641
+ owner: m.name, consumers: [w.endpoint_or_registration].filter(Boolean),
642
+ })),
643
+ ...s.jobs.filter((x) => x.module_id === m.id).map((j) => ({
644
+ event: j.name, direction: "job", owner: m.name, consumers: [j.trigger].filter(Boolean),
645
+ })),
646
+ ];
647
+
648
+ const edgeByCategory = new Map();
649
+ for (const ec of s.edgeCases.filter((x) => featureIds.has(x.feature_id))) {
650
+ if (!edgeByCategory.has(ec.category)) edgeByCategory.set(ec.category, []);
651
+ edgeByCategory.get(ec.category).push(ec);
652
+ }
653
+ const edgeCases = EDGE_CASE_CATEGORIES.filter((c) => edgeByCategory.has(c)).map((category) => {
654
+ const list = edgeByCategory.get(category);
655
+ const applicable = list.filter((x) => x.applicability === "applicable");
656
+ return {
657
+ category,
658
+ outcome: applicable.length
659
+ ? applicable.map((x) => x.behavior).filter(Boolean).join("; ") || "applicable, no behavior recorded"
660
+ : `N/A - ${list[0].reason_not_applicable}`,
661
+ features: [...new Set(list.map((x) => s.byId.get(x.feature_id)?.name).filter(Boolean))],
662
+ };
663
+ });
664
+
665
+ const stored = new Map(s.completeness.filter((c) => c.module_id === m.id).map((c) => [c.step, c]));
666
+ const steps = MODULE_STEPS.map((name, i) => stored.get(i + 1) ?? { step: i + 1, step_name: name, state: "open" });
667
+
668
+ return {
669
+ module: m,
670
+ primaryUsers: json(m.primary_users_json),
671
+ owns: json(m.owns_json),
672
+ consumes: json(m.consumes_json),
673
+ surfaces: surfaces.map((x) => ({ ...x, doc: refTo(s, path, "surface", x.id, x.name) })),
674
+ apis: s.apis.filter((x) => x.module_id === m.id).map((x) => ({ ...x, doc: refTo(s, path, "api", x.id, x.name) })),
675
+ entities: s.entities.filter((x) => x.module_id === m.id)
676
+ .map((x) => ({ ...x, role: "owner", doc: refTo(s, path, "data_entity", x.id, x.name) })),
677
+ wiring,
678
+ stateMachines: machines.map((x) => ({
679
+ ...x, stateCount: pick(statesByMachine, x.id).length,
680
+ doc: refTo(s, path, x.feature_id && s.workflows.some((w) => w.feature_id === x.feature_id) ? "workflow" : "state_machine",
681
+ x.feature_id && s.workflows.some((w) => w.feature_id === x.feature_id)
682
+ ? s.workflows.find((w) => w.feature_id === x.feature_id).id : x.id,
683
+ x.entity_name),
684
+ })),
685
+ events,
686
+ edgeCases,
687
+ steps,
688
+ };
689
+ }
690
+
691
+ // -------------------------------------------------------------------- feature
692
+
693
+ function featureData(s, f, path) {
694
+ const module = s.featureModule(f);
695
+ const surfaces = s.surfaces.filter((x) => x.feature_id === f.id);
696
+ const actionsBySurface = by(s.actions, "surface_id");
697
+ const apis = s.apis.filter((x) => x.feature_id === f.id);
698
+ const entities = s.entities.filter((x) => x.feature_id === f.id);
699
+ const workflows = s.workflows.filter((x) => x.feature_id === f.id);
700
+ const machines = s.machines.filter((x) => x.feature_id === f.id);
701
+ const roleName = new Map(s.roles.map((r) => [r.id, r.name]));
702
+ const goalIds = new Set(s.featureGoals.filter((g) => g.feature_id === f.id).map((g) => g.goal_id));
703
+
704
+ const branches = s.wfBranches.filter((b) => workflows.some((w) => w.id === b.workflow_id));
705
+ const stepName = new Map(s.wfSteps.map((step) => [step.id, `${step.sequence} ${step.action}`]));
706
+
707
+ const entityIds = new Set(entities.map((e) => e.id));
708
+
709
+ return {
710
+ feature: f,
711
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
712
+ milestone: f.milestone_id ? s.byId.get(f.milestone_id)?.name ?? f.milestone_id : null,
713
+ goals: s.goals.filter((g) => goalIds.has(g.id)).map((g) => ({ label: `${g.id} ${g.name}` })),
714
+ scopeIn: json(f.scope_in_json),
715
+ scopeOut: json(f.scope_out_json),
716
+ contracts: [
717
+ ...apis.map((a) => refTo(s, path, "api", a.id, a.name)),
718
+ ...entities.map((e) => refTo(s, path, "data_entity", e.id, e.name)),
719
+ ...surfaces.map((x) => refTo(s, path, "surface", x.id, x.name)),
720
+ ],
721
+ flow: s.flows.filter((x) => x.feature_id === f.id).map((x) => x.step),
722
+ acceptance: s.acceptance.filter((x) => x.feature_id === f.id),
723
+ edgeCases: s.edgeCases.filter((x) => x.feature_id === f.id),
724
+ evidence: s.evidence.filter((x) => x.feature_id === f.id),
725
+ surfaces: surfaces.map((x) => ({
726
+ ...x, actionCount: pick(actionsBySurface, x.id).length, doc: refTo(s, path, "surface", x.id, x.name),
727
+ })),
728
+ apiAndData: [
729
+ ...apis.map((a) => ({ kind: "API", name: a.name, purpose: a.purpose, doc: refTo(s, path, "api", a.id, a.name) })),
730
+ ...entities.map((e) => ({ kind: "Entity", name: e.name, purpose: e.purpose, doc: refTo(s, path, "data_entity", e.id, e.name) })),
731
+ ],
732
+ permissions: s.permissions.filter((perm) => perm.scope_type === "feature" && perm.scope_id === f.id)
733
+ .map((perm) => ({ ...perm, role: roleName.get(perm.role_id) ?? perm.role_id })),
734
+ workflows: [
735
+ ...workflows.map((w) => ({ name: w.name, trigger: w.trigger, doc: refTo(s, path, "workflow", w.id, w.name) })),
736
+ ...machines.map((x) => ({
737
+ name: `${x.entity_name} states`, trigger: x.initial_state,
738
+ doc: workflows.length ? refTo(s, path, "workflow", workflows[0].id, workflows[0].name) : refTo(s, path, "state_machine", x.id, x.entity_name),
739
+ })),
740
+ ],
741
+ failurePaths: branches.filter((b) => b.branch_type !== "alternate").map((b) => ({
742
+ workflow: s.byId.get(b.workflow_id)?.name,
743
+ step: stepName.get(b.from_step_id),
744
+ condition: b.condition,
745
+ behavior: b.failure_policy ?? b.branch_type,
746
+ })),
747
+ observability: [
748
+ ...workflows.map((w) => w.observability).filter(Boolean),
749
+ ...s.jobs.filter((j) => j.feature_id === f.id).map((j) => j.observability).filter(Boolean),
750
+ ...s.nfrs.filter((n) => n.feature_id === f.id && /observab|telemetry|monitor/i.test(n.category)).map((n) => n.requirement),
751
+ ],
752
+ rollout: null,
753
+ testPlanDoc: refTo(s, path, "test_plan", module?.id, `${module?.name} test plan`),
754
+ decisions: s.decisions.filter((d) => (d.scope_type === "feature" && d.scope_id === f.id) ||
755
+ s.decisionLinks.some((l) => l.decision_id === d.id && l.target_type === "feature" && l.target_id === f.id))
756
+ .map((d) => ({ ...d, doc: refTo(s, path, "adr", d.id, `${d.id} ${d.title}`) })),
757
+ migrations: s.migrations.filter((m) => m.feature_id === f.id),
758
+ sensitive: s.fields.filter((x) => entityIds.has(x.entity_id) && x.sensitivity_class !== "none").map((x) => ({
759
+ field: `${s.byId.get(x.entity_id)?.name ?? x.entity_id}.${x.name}`,
760
+ sensitivity_class: x.sensitivity_class,
761
+ retention_rule: s.byId.get(x.entity_id)?.retention_rule,
762
+ })),
763
+ nfrs: s.nfrs.filter((n) => n.feature_id === f.id),
764
+ recovery: branches.map((b) => b.failure_policy).filter(Boolean),
765
+ compatibility: apis.map((a) => a.versioning).filter(Boolean),
766
+ architectureNote: null,
767
+ };
768
+ }
769
+
770
+ // ------------------------------------------------------- workflow and machine
771
+
772
+ function machineBlock(s, machine) {
773
+ const states = s.states.filter((x) => x.state_machine_id === machine.id);
774
+ const stateName = new Map(states.map((x) => [x.id, x.name]));
775
+ const transitions = s.transitions.filter((x) => x.state_machine_id === machine.id).map((t) => ({
776
+ ...t,
777
+ from: stateName.get(t.from_state_id) ?? t.from_state_id,
778
+ to: stateName.get(t.to_state_id) ?? t.to_state_id,
779
+ }));
780
+ return {
781
+ machine,
782
+ initialState: machine.initial_state,
783
+ states: states.map((x) => ({ ...x, permitted: json(x.permitted_actions_json) })),
784
+ transitions,
785
+ terminalStates: states.filter((x) => x.terminal).map((x) => x.name),
786
+ timeouts: transitions.filter((t) => t.timeout_rule).map((t) => `${t.from} ${t.timeout_rule}`),
787
+ enforcementPoints: [...new Set(transitions.map((t) => t.enforcement_point).filter(Boolean))],
788
+ };
789
+ }
790
+
791
+ function workflowData(s, workflow, machine, path) {
792
+ const feature = workflow ? s.workflowFeature(workflow) : (machine?.feature_id ? s.byId.get(machine.feature_id) : null);
793
+ const module = workflow || machine ? s.moduleOf(workflow ?? machine) : null;
794
+ const machines = workflow
795
+ ? s.machines.filter((x) => x.feature_id === workflow.feature_id)
796
+ : machine ? [machine] : [];
797
+
798
+ const stepName = new Map(s.wfSteps.map((step) => [step.id, `${step.sequence} ${step.action}`]));
799
+
800
+ return {
801
+ workflow: workflow ?? null,
802
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
803
+ featureDoc: feature ? refTo(s, path, "feature", feature.id, feature.name) : null,
804
+ actors: workflow ? s.wfActors.filter((a) => a.workflow_id === workflow.id) : [],
805
+ preconditions: workflow ? json(workflow.preconditions_json) : [],
806
+ steps: workflow ? s.wfSteps.filter((x) => x.workflow_id === workflow.id) : [],
807
+ branches: workflow
808
+ ? s.wfBranches.filter((b) => b.workflow_id === workflow.id).map((b) => ({
809
+ ...b, from: stepName.get(b.from_step_id) ?? b.from_step_id, to: stepName.get(b.to_step_id) ?? "end",
810
+ }))
811
+ : [],
812
+ machines: machines.map((x) => machineBlock(s, x)),
813
+ };
814
+ }
815
+
816
+ // ------------------------------------------------------- surfaces and actions
817
+
818
+ const REQUIRED_SURFACE_STATES = ["loading", "empty", "error", "success"];
819
+
820
+ function surfaceData(s, surface, path) {
821
+ const module = s.byId.get(surface.module_id);
822
+ const feature = surface.feature_id ? s.byId.get(surface.feature_id) : null;
823
+ const states = s.surfaceStates.filter((x) => x.surface_id === surface.id);
824
+ const stateBehavior = new Map(states.map((x) => [x.state_type, x.behavior]));
825
+ const roleName = new Map(s.roles.map((r) => [r.id, r.name]));
826
+
827
+ const acceptance = feature
828
+ ? s.acceptance.filter((a) => a.feature_id === feature.id).map((a) => a.id).join(", ")
829
+ : null;
830
+ const telemetry = feature
831
+ ? s.nfrs.filter((n) => n.feature_id === feature.id && /observab|telemetry/i.test(n.category)).map((n) => n.requirement).join("; ")
832
+ : null;
833
+
834
+ const actions = s.actions.filter((x) => x.surface_id === surface.id).map((a) => ({
835
+ ...a,
836
+ who: `${a.role ?? "any role"}, enforced at ${a.enforcement_point ?? "no enforcement point recorded"}`,
837
+ input: [a.input_contract, a.validation].filter(Boolean).join("; "),
838
+ side_effects: json(a.side_effects_json).join(", "),
839
+ empty: stateBehavior.get("empty"),
840
+ offline: stateBehavior.get("offline"),
841
+ responsive: surface.responsive_behavior,
842
+ telemetry: telemetry || "none approved",
843
+ acceptance,
844
+ }));
845
+
846
+ const matrix = actions.map((a) => {
847
+ const access = {};
848
+ for (const perm of s.permissions) {
849
+ if (perm.scope_type !== "ui_action" || perm.scope_id !== a.id) continue;
850
+ const role = roleName.get(perm.role_id);
851
+ if (role) access[role] = perm.access_level;
852
+ }
853
+ for (const r of s.roles) {
854
+ if (access[r.name]) continue;
855
+ if (a.role && a.role === r.name) access[r.name] = "full";
856
+ }
857
+ return { action: a.label ?? a.name, access };
858
+ });
859
+
860
+ return {
861
+ surface,
862
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
863
+ featureDoc: feature ? refTo(s, path, "feature", feature.id, feature.name) : null,
864
+ components: json(surface.components_json),
865
+ entities: json(surface.entities_shown_json),
866
+ actions,
867
+ roles: s.roles,
868
+ matrix,
869
+ states,
870
+ missingStates: REQUIRED_SURFACE_STATES.filter((x) => !stateBehavior.has(x)),
871
+ };
872
+ }
873
+
874
+ // ------------------------------------------------------------------------ api
875
+
876
+ /** Contract JSON arrives in several honest shapes. Read them all, invent none. */
877
+ function contractRows(value, keys) {
878
+ const parsed = json(value, null) ?? json(value, {});
879
+ const list = Array.isArray(parsed) ? parsed : Array.isArray(parsed?.fields) ? parsed.fields
880
+ : Object.entries(parsed ?? {}).map(([name, spec]) => (spec && typeof spec === "object" ? { name, ...spec } : { name, type: spec }));
881
+ return list.map((item) => {
882
+ if (typeof item === "string") return { [keys[0]]: item };
883
+ const row = {};
884
+ for (const key of keys) row[key] = item[key] ?? null;
885
+ row[keys[0]] = row[keys[0]] ?? item.name ?? item.field ?? item.code ?? null;
886
+ return row;
887
+ });
888
+ }
889
+
890
+ function apiData(s, api, path) {
891
+ const module = s.byId.get(api.module_id);
892
+ const feature = api.feature_id ? s.byId.get(api.feature_id) : null;
893
+ const callers = s.actions.filter((a) => `${a.effect ?? ""}`.includes(api.id) || `${a.effect ?? ""}`.includes(api.name));
894
+ const surfaceOf = new Map(s.surfaces.map((x) => [x.id, x]));
895
+
896
+ return {
897
+ api,
898
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
899
+ featureDoc: feature ? refTo(s, path, "feature", feature.id, feature.name) : null,
900
+ callers: callers.map((a) => refTo(s, path, "surface", a.surface_id, `${surfaceOf.get(a.surface_id)?.name ?? "surface"}: ${a.label ?? a.name}`)),
901
+ sideEffects: json(api.side_effects_json),
902
+ request: contractRows(api.request_contract_json, ["name", "type", "required", "rules"]),
903
+ response: contractRows(api.response_contract_json, ["name", "type", "notes"]),
904
+ errors: contractRows(api.error_contract_json, ["code", "meaning", "behavior"]),
905
+ tests: feature ? s.evidence.filter((e) => e.feature_id === feature.id) : [],
906
+ };
907
+ }
908
+
909
+ // ---------------------------------------------------------------- data entity
910
+
911
+ function entityData(s, entity, path) {
912
+ const module = s.byId.get(entity.module_id);
913
+ const name = new Map(s.entities.map((e) => [e.id, e.name]));
914
+ const fields = s.fields.filter((f) => f.entity_id === entity.id).map((f) => ({ ...f, constraints: json(f.constraints_json) }));
915
+
916
+ const relationships = [
917
+ ...s.relationships.filter((r) => r.from_entity_id === entity.id).map((r) => ({
918
+ ...r, direction: "outgoing", target: name.get(r.to_entity_id) ?? r.to_entity_id,
919
+ fromName: entity.name, toName: name.get(r.to_entity_id) ?? r.to_entity_id,
920
+ })),
921
+ ...s.relationships.filter((r) => r.to_entity_id === entity.id).map((r) => ({
922
+ ...r, direction: "incoming", target: name.get(r.from_entity_id) ?? r.from_entity_id,
923
+ fromName: name.get(r.from_entity_id) ?? r.from_entity_id, toName: entity.name,
924
+ })),
925
+ ];
926
+
927
+ const touching = s.apis.filter((a) =>
928
+ a.module_id === entity.module_id &&
929
+ new RegExp(entity.name.replace(/[^A-Za-z0-9]+/g, ".?"), "i").test(`${a.name} ${a.purpose ?? ""} ${a.path_or_topic ?? ""}`));
930
+ const creates = (a) => /^(post|put)$/i.test(a.method_or_procedure ?? "") || /create|add|register/i.test(a.name);
931
+
932
+ const migrationIds = new Set(s.migrationEntities.filter((m) => m.entity_id === entity.id).map((m) => m.migration_id));
933
+
934
+ return {
935
+ entity,
936
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
937
+ fields,
938
+ relationships,
939
+ createdBy: touching.filter(creates).map((a) => refTo(s, path, "api", a.id, a.name)),
940
+ updatedBy: touching.filter((a) => !creates(a)).map((a) => refTo(s, path, "api", a.id, a.name)),
941
+ constraints: fields.flatMap((f) => f.constraints.map((c) => `${f.name}: ${c}`)),
942
+ migrations: s.migrations.filter((m) => migrationIds.has(m.id)),
943
+ };
944
+ }
945
+
946
+ // ---------------------------------------------------------------- integration
947
+
948
+ function integrationData(s, integration, path) {
949
+ const module = s.byId.get(integration.module_id);
950
+ const feature = integration.feature_id ? s.byId.get(integration.feature_id) : null;
951
+ return {
952
+ integration,
953
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
954
+ featureDoc: feature ? refTo(s, path, "feature", feature.id, feature.name) : null,
955
+ environments: json(integration.environments_json),
956
+ contractRefs: json(integration.contract_refs_json),
957
+ evidence: feature ? s.evidence.filter((e) => e.feature_id === feature.id) : [],
958
+ };
959
+ }
960
+
961
+ // ---------------------------------------------------------- jobs and webhooks
962
+
963
+ function asyncData(s, job, webhook, path) {
964
+ const row = job ?? webhook;
965
+ const module = s.byId.get(row.module_id);
966
+ const feature = row.feature_id ? s.byId.get(row.feature_id) : null;
967
+ const mechanism = slot(s, /async|queue|job|event/i);
968
+ return {
969
+ job: job ?? null,
970
+ webhook: webhook ?? null,
971
+ moduleDoc: refTo(s, path, "module", module?.id, module?.name),
972
+ featureDoc: feature ? refTo(s, path, "feature", feature.id, feature.name) : null,
973
+ mechanism: mechanism?.state === "specified" ? mechanism.choice : null,
974
+ input: job ? contractRows(job.input_contract_json, ["name", "type"]).map((f) => `${f.name}${f.type ? `: ${f.type}` : ""}`) : [],
975
+ };
976
+ }
977
+
978
+ // -------------------------------------------------------------- observability
979
+
980
+ function observabilityData(s, module, path) {
981
+ const featureIds = new Set(s.features.filter((f) => f.module_id === module.id).map((f) => f.id));
982
+ const tooling = slot(s, /observab|monitor|logging|telemetry/i);
983
+
984
+ const signals = [
985
+ ...s.workflows.filter((w) => featureIds.has(w.feature_id) && w.observability)
986
+ .map((w) => ({ capability: w.name, signal: w.observability, where: module.name })),
987
+ ...s.jobs.filter((j) => j.module_id === module.id && j.observability)
988
+ .map((j) => ({ capability: j.name, signal: j.observability, where: module.name })),
989
+ ...s.nfrs.filter((n) => n.module_id === module.id && /observab|telemetry|monitor|log/i.test(n.category))
990
+ .map((n) => ({ capability: n.category, signal: n.requirement, where: n.measurement_method })),
991
+ ];
992
+
993
+ const alerts = s.nfrs
994
+ .filter((n) => n.module_id === module.id && /availab|error|latency|alert|uptime/i.test(`${n.category} ${n.requirement}`))
995
+ .map((n) => ({ condition: n.requirement, threshold: `${n.target ?? "no target"} (${n.target_source ?? "no source"})`, destination: "not recorded" }));
996
+
997
+ return {
998
+ scopeName: module.name,
999
+ tooling: tooling?.state === "specified" ? [tooling.choice] : [],
1000
+ signals,
1001
+ views: s.surfaces.filter((x) => x.module_id === module.id && /dashboard|report|monitor/i.test(`${x.surface_type} ${x.name}`))
1002
+ .map((x) => ({ view: x.name, shows: x.purpose, where: x.route })),
1003
+ alerts,
1004
+ firstResponse: s.wfSteps
1005
+ .filter((step) => step.failure_behavior && s.workflows.some((w) => w.id === step.workflow_id && featureIds.has(w.feature_id)))
1006
+ .map((step) => ({ failure: step.action, signal: step.failure_behavior })),
1007
+ path,
1008
+ };
1009
+ }
1010
+
1011
+ // ------------------------------------------------------------------ test plan
1012
+
1013
+ function testPlanData(s, module) {
1014
+ const features = s.features.filter((f) => f.module_id === module.id);
1015
+ const featureIds = new Set(features.map((f) => f.id));
1016
+ const featureName = new Map(features.map((f) => [f.id, f.name]));
1017
+ const acceptance = s.acceptance.filter((a) => featureIds.has(a.feature_id))
1018
+ .map((a) => ({ ...a, feature: featureName.get(a.feature_id) }));
1019
+ const evidence = s.evidence.filter((e) => featureIds.has(e.feature_id));
1020
+ const edges = s.edgeCases.filter((e) => featureIds.has(e.feature_id) && e.applicability === "applicable");
1021
+ const permissions = s.permissions.filter((perm) => perm.scope_type === "module" && perm.scope_id === module.id);
1022
+ const machines = s.machines.filter((x) => s.moduleOf(x)?.id === module.id);
1023
+ const transitions = s.transitions.filter((t) => machines.some((m) => m.id === t.state_machine_id));
1024
+ const levels = [...new Set(evidence.map((e) => e.evidence_type))].join(", ") || "not recorded";
1025
+ const state = (n) => (n === 0 ? "missing" : evidence.length ? "exists" : "planned");
1026
+ const tooling = slot(s, /test|quality|ci/i);
1027
+
1028
+ return {
1029
+ module,
1030
+ tooling: tooling?.state === "specified" ? [tooling.choice] : [],
1031
+ acceptance,
1032
+ coverage: [
1033
+ { area: "Happy paths per feature", level: levels, cases: features.length, status: state(features.length) },
1034
+ { area: "Applicable edge-case categories", level: levels, cases: edges.length, status: state(edges.length) },
1035
+ { area: "Permission boundaries", level: levels, cases: permissions.length, status: state(permissions.length) },
1036
+ { area: "State machines including illegal transitions", level: levels, cases: transitions.length, status: state(transitions.length) },
1037
+ ],
1038
+ evidence,
1039
+ };
1040
+ }
1041
+
1042
+ // ------------------------------------------------------------------------ adr
1043
+
1044
+ function adrData(s, d, path) {
1045
+ const links = s.decisionLinks.filter((l) => l.decision_id === d.id);
1046
+ const linkRef = (l) => {
1047
+ const label = l.scope_note ? `${l.target_id} (${l.scope_note})` : l.target_id;
1048
+ return l.target_type === "decision" ? refTo(s, path, "adr", l.target_id, label) : { label: `${l.target_type} ${label}` };
1049
+ };
1050
+ const consequences = json(d.consequences_json, {});
1051
+
1052
+ return {
1053
+ decision: d,
1054
+ date: dateOf(d.accepted_at ?? d.created_at),
1055
+ evidence: json(d.evidence_json),
1056
+ criteria: json(d.criteria_json),
1057
+ options: json(d.options_json),
1058
+ consequences: {
1059
+ positive: [].concat(consequences.positive ?? []),
1060
+ negative: [].concat(consequences.negative ?? []),
1061
+ neutral: [].concat(consequences.neutral ?? []),
1062
+ },
1063
+ risks: json(d.risks_json),
1064
+ enforcement: json(d.enforcement_json),
1065
+ revisitTriggers: json(d.revisit_triggers_json),
1066
+ supersedes: [
1067
+ ...(d.supersedes_id ? [refTo(s, path, "adr", d.supersedes_id, d.supersedes_id)] : []),
1068
+ ...links.filter((l) => l.relationship === "supersedes").map(linkRef),
1069
+ ],
1070
+ supersededBy: d.superseded_by_id ? [refTo(s, path, "adr", d.superseded_by_id, d.superseded_by_id)] : [],
1071
+ partiallySupersedes: links.filter((l) => l.relationship === "partially_supersedes").map(linkRef),
1072
+ related: links.filter((l) => !["supersedes", "partially_supersedes"].includes(l.relationship)).map(linkRef),
1073
+ transitions: s.decisionTransitions.filter((t) => t.decision_id === d.id),
1074
+ };
1075
+ }
1076
+
1077
+ // -------------------------------------------------------------- derived views
1078
+
1079
+ function changelogData(s) {
1080
+ return {
1081
+ project: s.project,
1082
+ entries: s.events.map((e) => ({
1083
+ sequence: e.sequence, date: dateOf(e.created_at), summary: e.summary, actor: e.actor_label,
1084
+ })),
1085
+ };
1086
+ }
1087
+
1088
+ function summaryData(s, path) {
1089
+ const complete = s.tasks.filter((t) => t.status === "complete").length;
1090
+ const machines = s.machines.length;
1091
+ return {
1092
+ project: s.project,
1093
+ roles: s.roles,
1094
+ modules: s.modules.map((m) => ({
1095
+ name: m.name,
1096
+ features: s.features.filter((f) => f.module_id === m.id).map((f) => f.name),
1097
+ doc: refTo(s, path, "module", m.id, m.name),
1098
+ })),
1099
+ stack: capabilities(s, "stack_slot").map((c) => ({
1100
+ area: c.area, choice: c.state === "specified" ? c.choice : `${readable(c.state)}: ${c.reason}`,
1101
+ evidence: c.decision_id ?? c.evidence_ref ?? "none",
1102
+ })),
1103
+ pieces: s.pieces.map((x) => `${x.name} (${x.runs_where ?? "runtime not recorded"})`),
1104
+ environments: [...new Set(s.integrations.flatMap((i) => json(i.environments_json)))],
1105
+ integrations: s.integrations.map((i) => i.name),
1106
+ state: [
1107
+ { measure: "Modules", value: s.modules.length },
1108
+ { measure: "Features", value: s.features.length },
1109
+ { measure: "Acceptance criteria met", value: `${s.acceptance.filter((a) => a.status === "met").length} of ${s.acceptance.length}` },
1110
+ { measure: "Verification evidence", value: s.evidence.length },
1111
+ { measure: "Tasks complete", value: `${complete} of ${s.tasks.length}` },
1112
+ { measure: "State machines", value: machines },
1113
+ { measure: "Open questions", value: s.questions.filter((q) => q.status === "open").length },
1114
+ { measure: "Documents pending review", value: s.documents.filter((d) => d.sync_status === "manual_edit_pending").length },
1115
+ ],
1116
+ unknowns: [
1117
+ ...s.questions.filter((q) => q.status === "open").map((q) => ({ item: q.question, resolution: q.recommendation })),
1118
+ ...capabilities(s, "readiness").filter((c) => c.state === "awaiting_decision")
1119
+ .map((c) => ({ item: c.area, resolution: c.reason })),
1120
+ ],
1121
+ };
1122
+ }
1123
+
1124
+ function statusData(s, path) {
1125
+ const openTasks = s.tasks.filter((t) => !["complete", "cancelled", "superseded"].includes(t.status));
1126
+ const stepsFilled = (moduleId) => {
1127
+ const list = s.completeness.filter((c) => c.module_id === moduleId && c.state !== "open");
1128
+ return `${list.length} of ${MODULE_STEPS.length}`;
1129
+ };
1130
+ return {
1131
+ project: s.project,
1132
+ headline: [
1133
+ { measure: "Modules", value: s.modules.length },
1134
+ { measure: "Features", value: s.features.length },
1135
+ { measure: "Features accepted", value: s.features.filter((f) => f.accepted_at).length },
1136
+ { measure: "Open tasks", value: openTasks.length },
1137
+ { measure: "Blocked tasks", value: s.tasks.filter((t) => t.status === "blocked").length },
1138
+ { measure: "Open questions", value: s.questions.filter((q) => q.status === "open").length },
1139
+ { measure: "Decisions accepted", value: s.decisions.filter((d) => d.status === "accepted").length },
1140
+ ],
1141
+ milestones: s.milestones.map((m) => {
1142
+ const features = s.features.filter((f) => f.milestone_id === m.id);
1143
+ return {
1144
+ name: m.name, status: m.status, features: features.length,
1145
+ complete: features.filter((f) => f.status === "implemented" || f.accepted_at).length,
1146
+ };
1147
+ }),
1148
+ modules: s.modules.map((m) => ({
1149
+ name: m.name, status: m.status,
1150
+ features: s.features.filter((f) => f.module_id === m.id).length,
1151
+ steps: stepsFilled(m.id),
1152
+ })),
1153
+ attention: s.features.filter((f) => f.next_action || f.status === "blocked")
1154
+ .map((f) => ({ doc: refTo(s, path, "feature", f.id, f.name), status: f.status, next_action: f.next_action })),
1155
+ questions: s.questions.filter((q) => q.status === "open")
1156
+ .map((q) => ({ question: q.question, why_it_matters: q.why_it_matters, scope: `${q.scope_type} ${q.scope_id ?? ""}`.trim() })),
1157
+ };
1158
+ }
1159
+
1160
+ function driftData(s, path) {
1161
+ const impact = s.documents
1162
+ .filter((d) => d.sync_status !== "generated" && d.sync_status !== "accepted")
1163
+ .map((d) => ({
1164
+ doc: { label: d.path, link: posix.relative(posix.dirname(path), d.path) },
1165
+ sync_status: d.sync_status,
1166
+ scope: `${d.scope_type} ${d.scope_id ?? ""}`.trim(),
1167
+ action: d.sync_status === "manual_edit_pending" ? "review the proposed edit" : "regenerate",
1168
+ }));
1169
+
1170
+ const findings = [];
1171
+ for (const f of s.features) {
1172
+ const open = s.acceptance.filter((a) => a.feature_id === f.id && a.status === "unmet");
1173
+ if (f.status === "implemented" && open.length) {
1174
+ findings.push({
1175
+ class: "implemented-without-evidence", database: `${f.id} ${f.name}`,
1176
+ document: `${open.length} unmet acceptance criteria`, owner: "code",
1177
+ });
1178
+ }
1179
+ }
1180
+ for (const c of capabilities(s, "readiness").filter((x) => x.state === "awaiting_decision")) {
1181
+ findings.push({ class: "capability-awaiting-decision", database: c.area, document: c.reason ?? "no reason recorded", owner: "decision" });
1182
+ }
1183
+ for (const m of s.modules) {
1184
+ const open = s.completeness.filter((x) => x.module_id === m.id && x.state === "open");
1185
+ if (open.length) {
1186
+ findings.push({
1187
+ class: "module-loop-incomplete", database: m.name,
1188
+ document: `${open.length} of ${MODULE_STEPS.length} steps still open`, owner: "docs",
1189
+ });
1190
+ }
1191
+ }
1192
+ for (const n of s.nfrs.filter((x) => x.status === "unmeasured")) {
1193
+ findings.push({ class: "requirement-unmeasured", database: n.id, document: n.requirement, owner: "code" });
1194
+ }
1195
+ for (const d of s.decisions.filter((x) => x.status === "revisit_required" || (x.expires_at && x.status === "time_boxed"))) {
1196
+ findings.push({ class: "decision-needs-revisit", database: `${d.id} ${d.title}`, document: d.expires_at ?? "revisit requested", owner: "decision" });
1197
+ }
1198
+ // A planned document that is not on disk yet is not drift: the run that
1199
+ // renders this report is the run that creates it. Reporting it would make the
1200
+ // first generation contradict itself and force a second write.
1201
+
1202
+ return {
1203
+ project: s.project,
1204
+ impact,
1205
+ findings,
1206
+ contradictions: s.discovery.filter((d) => d.epistemic_status === "contradicted")
1207
+ .map((d) => ({ sides: d.kind, detail: d.statement, blocking: d.status === "proposed" ? "yes" : "no" })),
1208
+ pending: s.documents.filter((d) => d.sync_status === "manual_edit_pending").map((d) => d.path),
1209
+ };
1210
+ }
1211
+
1212
+ // ------------------------------------------------------------------- generate
1213
+
1214
+ const matches = (plan, only) => {
1215
+ if (!only) return true;
1216
+ const needles = (Array.isArray(only) ? only : [only]).map((x) => String(x));
1217
+ return needles.some((n) => plan.kind === n || plan.path.includes(n) || plan.scopeId === n);
1218
+ };
1219
+
1220
+ const readIfPresent = async (file) => {
1221
+ try {
1222
+ return await readFile(file, "utf8");
1223
+ } catch (err) {
1224
+ if (err.code === "ENOENT") return null;
1225
+ throw err;
1226
+ }
1227
+ };
1228
+
1229
+ /**
1230
+ * Render the whole project. With `apply` the files are written and the document
1231
+ * rows recorded; without it nothing is touched and the caller sees what would
1232
+ * happen. `only` narrows the run to matching kinds, paths or record ids.
1233
+ */
1234
+ export async function generate(root, { apply = false, only = null, includeReports = false } = {}) {
1235
+ const prepared = await query(root, async (db) => {
1236
+ const project = await currentProject(db);
1237
+ if (!project) return null;
1238
+ const plan = (await documentPlan(db, project.id, { includeReports })).filter((entry) => matches(entry, only));
1239
+ const s = await snapshot(db, project.id);
1240
+ const documents = new Map(s.documents.map((d) => [d.path, d]));
1241
+ const rendered = [];
1242
+ for (const entry of plan) rendered.push({ entry, ...(await renderDocument(db, entry)) });
1243
+ return {
1244
+ project, plan, rendered, documents, revision: s.revision,
1245
+ allPaths: new Set(s.plan.map((x) => x.path)),
1246
+ };
1247
+ });
1248
+
1249
+ const result = { written: [], unchanged: [], proposals: [], skipped: [], retired: [] };
1250
+ if (!prepared) return result;
1251
+
1252
+ const { project, rendered, documents, revision, allPaths } = prepared;
1253
+ const writes = [];
1254
+
1255
+ for (const { entry, path, body, sourceFingerprint } of rendered) {
1256
+ const file = join(root, path);
1257
+ const onDisk = await readIfPresent(file);
1258
+ const parsed = onDisk === null ? null : parseMarker(onDisk);
1259
+ const diskBody = onDisk === null ? null : parsed ? parsed.body : normalizeBody(onDisk);
1260
+ const diskHash = diskBody === null ? null : bodyHash(diskBody);
1261
+ const hash = bodyHash(body);
1262
+ const row = documents.get(path);
1263
+
1264
+ const write = { entry, path, file, body, hash, sourceFingerprint, row };
1265
+
1266
+ // Identical body: leave the file alone even when the marker's revision has
1267
+ // moved on. Rewriting a file to bump a number nobody reads is pure churn.
1268
+ // The row is still refreshed, which is what clears a proposal the author
1269
+ // resolved by reverting their edit.
1270
+ if (diskHash === hash) {
1271
+ result.unchanged.push(path);
1272
+ // The row is refreshed when it disagrees with what is on disk, and also
1273
+ // when it is simply stamped with an older revision. Re-rendering at this
1274
+ // revision produced an identical body, so the document is current as of
1275
+ // now; leaving the old number behind would report it as permanently out
1276
+ // of date and no later run could ever clear it.
1277
+ if (!row || row.sync_status !== "generated" || row.generated_hash !== hash
1278
+ || (row.database_revision ?? 0) < revision) {
1279
+ writes.push({ ...write, mode: "record" });
1280
+ }
1281
+ continue;
1282
+ }
1283
+
1284
+ if (diskHash !== null && entry.regenerationMode !== DERIVED) {
1285
+ const untouched = row?.generated_hash ? diskHash === row.generated_hash : parsed?.hash === diskHash;
1286
+ if (!untouched) {
1287
+ result.proposals.push({ path, kind: entry.kind, generatedHash: hash, onDiskHash: diskHash });
1288
+ writes.push({ ...write, mode: "proposal", manualHash: diskHash });
1289
+ continue;
1290
+ }
1291
+ }
1292
+
1293
+ result.written.push(path);
1294
+ writes.push({ ...write, mode: "write" });
1295
+ }
1296
+
1297
+ for (const d of documents.values()) {
1298
+ if (!allPaths.has(d.path)) {
1299
+ // A document whose record is gone is not merely skipped. Left as
1300
+ // generated it keeps the revision it was written at, so freshness counts
1301
+ // it behind forever and no later run can clear it: five documents from
1302
+ // an earlier schema sat at revision 681 while every other document and
1303
+ // the documentation check agreed the projection matched the database.
1304
+ //
1305
+ // Retired says what is true. The row stays queryable so the history of
1306
+ // what was once documented is not lost, and the file is removed because
1307
+ // a document describing a record that no longer exists is a false
1308
+ // statement sitting in the repository.
1309
+ result.retired.push({ path: d.path, id: d.id, reason: "the record it described no longer exists" });
1310
+ }
1311
+ }
1312
+
1313
+ if (!apply) return result;
1314
+
1315
+ // Files first. No filesystem work may happen inside the write transaction.
1316
+ for (const w of writes) {
1317
+ if (w.mode !== "write") continue;
1318
+ await mkdir(dirname(w.file), { recursive: true });
1319
+ await writeFile(w.file, withMarker({ sourceId: w.entry.sourceId, revision, body: w.body }), "utf8");
1320
+ }
1321
+
1322
+ // A document whose record is gone describes something that does not exist, so
1323
+ // the file goes. A hand edited one is left alone: the edit is somebody's work
1324
+ // and deleting it would destroy it without asking.
1325
+ for (const r of result.retired) {
1326
+ const row = documents.get(r.path);
1327
+ if (row?.sync_status === "manual_edit_pending") { r.keptFile = "it carries an unresolved hand edit"; continue; }
1328
+ await rm(join(root, r.path), { force: true }).catch(() => {});
1329
+ }
1330
+
1331
+ const at = new Date().toISOString();
1332
+ await mutate(root, async (db) => {
1333
+ for (const r of result.retired) {
1334
+ // Kept queryable rather than deleted, so what was once documented stays
1335
+ // answerable, and marked so freshness stops counting it behind.
1336
+ await db.run(
1337
+ "UPDATE documents SET sync_status = 'retired', database_revision = ? WHERE id = ?",
1338
+ revision, r.id,
1339
+ );
1340
+ }
1341
+ for (const w of writes) {
1342
+ if (w.mode === "proposal") {
1343
+ if (w.row) {
1344
+ await db.run(
1345
+ "UPDATE documents SET sync_status = 'manual_edit_pending', manual_hash = ? WHERE id = ?",
1346
+ w.manualHash, w.row.id,
1347
+ );
1348
+ } else {
1349
+ await create(db, "document", {
1350
+ project_id: project.id, kind: w.entry.kind, scope_type: w.entry.scopeType,
1351
+ scope_id: w.entry.scopeId, path: w.path, template: w.entry.template,
1352
+ regeneration_mode: w.entry.regenerationMode, sync_status: "manual_edit_pending",
1353
+ manual_hash: w.manualHash, database_revision: revision,
1354
+ }, { projectId: project.id, activity: false });
1355
+ }
1356
+ continue;
1357
+ }
1358
+
1359
+ const values = {
1360
+ database_revision: revision,
1361
+ generated_body: storable(w.body),
1362
+ generated_hash: w.hash,
1363
+ manual_hash: null,
1364
+ source_fingerprint: w.sourceFingerprint,
1365
+ sync_status: "generated",
1366
+ generated_at: at,
1367
+ template: w.entry.template,
1368
+ regeneration_mode: w.entry.regenerationMode,
1369
+ };
1370
+ if (w.row) {
1371
+ // `patch` cannot be used here: documents has no optimistic version
1372
+ // column, so a versioned update would fail. The activity event for the
1373
+ // whole run is recorded below instead.
1374
+ const cols = Object.keys(values);
1375
+ await db.run(
1376
+ `UPDATE documents SET ${cols.map((c) => `${c} = ?`).join(", ")} WHERE id = ?`,
1377
+ ...cols.map((c) => values[c]), w.row.id,
1378
+ );
1379
+ } else {
1380
+ await create(db, "document", {
1381
+ project_id: project.id, kind: w.entry.kind, scope_type: w.entry.scopeType,
1382
+ scope_id: w.entry.scopeId, path: w.path, ...values,
1383
+ }, { projectId: project.id, activity: false });
1384
+ }
1385
+ }
1386
+
1387
+ // A run that changed nothing records nothing. Otherwise every `docs
1388
+ // generate` and every `doctor` would append an event, moving the project
1389
+ // revision for no change in content and making the timeline unreadable.
1390
+ if (result.written.length || result.proposals.length) {
1391
+ await recordActivity(db, project.id, {
1392
+ type: "documentation_generated",
1393
+ summary: `Generated ${result.written.length} documents, ${result.unchanged.length} unchanged, ${result.proposals.length} awaiting review`,
1394
+ metadata: { written: result.written, unchanged: result.unchanged.length, proposals: result.proposals.map((p) => p.path) },
1395
+ });
1396
+ }
1397
+
1398
+ if (result.proposals.length) {
1399
+ await recordActivity(db, project.id, {
1400
+ type: "documentation_proposal_raised",
1401
+ summary: `${result.proposals.length} generated documents were edited by hand and were not overwritten`,
1402
+ metadata: { paths: result.proposals.map((p) => p.path) },
1403
+ });
1404
+ }
1405
+ });
1406
+
1407
+ return result;
1408
+ }
1409
+
1410
+ /**
1411
+ * The body is cached in the row so a proposal can be diffed without a re-render.
1412
+ *
1413
+ * A body that fails the storage screen used to be dropped to null while the row
1414
+ * was still written as a healthy generated document. That was worse than
1415
+ * failing: the file was already on disk, so only the evidence was lost, and the
1416
+ * document became permanently unrejectable because rejecting needs the cached
1417
+ * body it no longer had. The renderer builds bodies out of already-screened
1418
+ * records, so a failure here means a real defect in a renderer, and it is
1419
+ * reported rather than hidden.
1420
+ */
1421
+ function storable(body) {
1422
+ assertStorable("generated_body", body);
1423
+ return body;
1424
+ }