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.
- package/.claude-plugin/marketplace.json +33 -0
- package/.claude-plugin/plugin.json +21 -0
- package/.codex-plugin/plugin.json +27 -0
- package/CODE_OF_CONDUCT.md +109 -0
- package/CONTRIBUTING.md +205 -0
- package/LICENSE +202 -0
- package/NOTICE +11 -0
- package/README.md +1051 -0
- package/SECURITY.md +135 -0
- package/THIRD-PARTY-NOTICES.md +253 -0
- package/hooks/hooks.json +61 -0
- package/hooks/run.mjs +88 -0
- package/package.json +65 -0
- package/references/confidentiality.md +48 -0
- package/references/evidence-and-risk.md +73 -0
- package/references/operating-model.md +105 -0
- package/references/platform-capabilities.md +51 -0
- package/references/project-record.md +91 -0
- package/references/provider-contracts.md +102 -0
- package/scripts/doctor/doctor.mjs +539 -0
- package/scripts/privacy/scan-history.mjs +163 -0
- package/scripts/privacy/scan.mjs +368 -0
- package/scripts/validate/README.md +94 -0
- package/scripts/validate/common.mjs +149 -0
- package/scripts/validate/data-model.mjs +225 -0
- package/scripts/validate/dependencies.mjs +69 -0
- package/scripts/validate/docs-templates.mjs +99 -0
- package/scripts/validate/footprint.mjs +78 -0
- package/scripts/validate/imports.mjs +61 -0
- package/scripts/validate/manifests.mjs +173 -0
- package/scripts/validate/markdown.mjs +123 -0
- package/scripts/validate/migrations.mjs +187 -0
- package/scripts/validate/no-tests.mjs +136 -0
- package/scripts/validate/privacy.mjs +115 -0
- package/scripts/validate/record-links.mjs +127 -0
- package/scripts/validate/skill-commands.mjs +190 -0
- package/scripts/validate/skills.mjs +112 -0
- package/scripts/validate/specification.mjs +88 -0
- package/scripts/validate/style.mjs +78 -0
- package/scripts/validate/validate-all.mjs +175 -0
- package/skills/debug/SKILL.md +68 -0
- package/skills/decision/SKILL.md +99 -0
- package/skills/docs/SKILL.md +86 -0
- package/skills/docs/assets/fragments/api/events.md +14 -0
- package/skills/docs/assets/fragments/api/graphql.md +14 -0
- package/skills/docs/assets/fragments/api/local-only.md +13 -0
- package/skills/docs/assets/fragments/api/rest.md +13 -0
- package/skills/docs/assets/fragments/api/rpc.md +13 -0
- package/skills/docs/assets/fragments/async/event-bus.md +13 -0
- package/skills/docs/assets/fragments/async/none.md +11 -0
- package/skills/docs/assets/fragments/async/platform-jobs.md +13 -0
- package/skills/docs/assets/fragments/async/queue.md +13 -0
- package/skills/docs/assets/fragments/async/scheduler.md +13 -0
- package/skills/docs/assets/fragments/auth/detected-provider.md +15 -0
- package/skills/docs/assets/fragments/auth/neutral-base.md +14 -0
- package/skills/docs/assets/fragments/data/document.md +13 -0
- package/skills/docs/assets/fragments/data/external-saas.md +13 -0
- package/skills/docs/assets/fragments/data/key-value.md +13 -0
- package/skills/docs/assets/fragments/data/none.md +12 -0
- package/skills/docs/assets/fragments/data/sql-orm.md +13 -0
- package/skills/docs/assets/fragments/data/sql-plain.md +13 -0
- package/skills/docs/assets/fragments/env/conventional.md +13 -0
- package/skills/docs/assets/fragments/env/envx.md +13 -0
- package/skills/docs/assets/fragments/env/managed-platform.md +13 -0
- package/skills/docs/assets/fragments/env/none.md +11 -0
- package/skills/docs/assets/fragments/ui/api-only.md +13 -0
- package/skills/docs/assets/fragments/ui/cli.md +14 -0
- package/skills/docs/assets/fragments/ui/desktop.md +14 -0
- package/skills/docs/assets/fragments/ui/mobile.md +14 -0
- package/skills/docs/assets/fragments/ui/web.md +14 -0
- package/skills/docs/assets/templates/adr.md +62 -0
- package/skills/docs/assets/templates/api.md +30 -0
- package/skills/docs/assets/templates/architecture.md +52 -0
- package/skills/docs/assets/templates/change-impact-drift-report.md +29 -0
- package/skills/docs/assets/templates/compliance.md +29 -0
- package/skills/docs/assets/templates/data-schema.md +43 -0
- package/skills/docs/assets/templates/feature.md +43 -0
- package/skills/docs/assets/templates/foundations.md +55 -0
- package/skills/docs/assets/templates/jobs-webhooks.md +36 -0
- package/skills/docs/assets/templates/module-inventory.md +19 -0
- package/skills/docs/assets/templates/module.md +50 -0
- package/skills/docs/assets/templates/nfr.md +22 -0
- package/skills/docs/assets/templates/observability.md +28 -0
- package/skills/docs/assets/templates/pages-ui-actions.md +47 -0
- package/skills/docs/assets/templates/project-summary.md +44 -0
- package/skills/docs/assets/templates/roles-permissions.md +26 -0
- package/skills/docs/assets/templates/test-plan.md +23 -0
- package/skills/docs/assets/templates/workflow-state-machine.md +43 -0
- package/skills/docs/references/adr-authoring.md +24 -0
- package/skills/docs/references/apis-and-data.md +23 -0
- package/skills/docs/references/capability-fragments.md +25 -0
- package/skills/docs/references/change-tracking.md +62 -0
- package/skills/docs/references/diagrams.md +31 -0
- package/skills/docs/references/discovery.md +59 -0
- package/skills/docs/references/edge-cases.md +45 -0
- package/skills/docs/references/foundations-modules.md +18 -0
- package/skills/docs/references/ingestion.md +52 -0
- package/skills/docs/references/initialize-adopt.md +25 -0
- package/skills/docs/references/module-decomposition.md +38 -0
- package/skills/docs/references/profiles.md +60 -0
- package/skills/docs/references/quality-attributes.md +27 -0
- package/skills/docs/references/reverse-engineer.md +21 -0
- package/skills/docs/references/spec-depths.md +31 -0
- package/skills/docs/references/summarize.md +18 -0
- package/skills/docs/references/surfaces-and-actions.md +24 -0
- package/skills/docs/references/validation.md +47 -0
- package/skills/docs/references/workflows-and-jobs.md +25 -0
- package/skills/docs/scripts/ingest.mjs +984 -0
- package/skills/docs/scripts/profile-detect.mjs +299 -0
- package/skills/docs/scripts/screen.mjs +96 -0
- package/skills/docs/scripts/template-lint.mjs +143 -0
- package/skills/docs/scripts/validate-docs.mjs +363 -0
- package/skills/doctor/SKILL.md +167 -0
- package/skills/feature/SKILL.md +132 -0
- package/skills/init/SKILL.md +137 -0
- package/skills/project/SKILL.md +261 -0
- package/skills/project/references/commands.md +213 -0
- package/skills/resume/SKILL.md +79 -0
- package/skills/review/SKILL.md +91 -0
- package/skills/status/SKILL.md +85 -0
- package/skills/task/SKILL.md +183 -0
- package/src/cli/product-map.mjs +468 -0
- package/src/cli/render.mjs +544 -0
- package/src/cli.mjs +2774 -0
- package/src/cloud/crypto.mjs +118 -0
- package/src/cloud/merge.mjs +186 -0
- package/src/cloud/policy.mjs +115 -0
- package/src/cloud/sync.mjs +512 -0
- package/src/cloud/transport.mjs +116 -0
- package/src/db/connect.mjs +189 -0
- package/src/db/maintenance.mjs +419 -0
- package/src/db/migrate.mjs +188 -0
- package/src/db/migrations/001_initial.sql +1024 -0
- package/src/db/migrations/002_docs_coverage.sql +126 -0
- package/src/db/migrations/003_memory_retrieval_and_integrity.sql +168 -0
- package/src/db/migrations/004_task_categories.sql +59 -0
- package/src/db/migrations/005_executable_evidence.sql +32 -0
- package/src/db/migrations/006_module_and_feature_boundaries.sql +27 -0
- package/src/db/migrations/007_drop_redundant_module_users.sql +13 -0
- package/src/db/migrations/008_changes_assumptions_test_plans_api_services.sql +166 -0
- package/src/db/migrations/009_retired_documents.sql +54 -0
- package/src/db/migrations/010_test_plan_evidence.sql +20 -0
- package/src/db/migrations/011_criterion_waiver.sql +13 -0
- package/src/db/migrations/012_synchronization.sql +56 -0
- package/src/db/store.mjs +327 -0
- package/src/decisions/record.mjs +167 -0
- package/src/docs/proposals.mjs +871 -0
- package/src/docs/render.mjs +1424 -0
- package/src/docs/templates.mjs +1281 -0
- package/src/features/acceptance.mjs +315 -0
- package/src/features/specify.mjs +248 -0
- package/src/init/discovery.mjs +901 -0
- package/src/init/index.mjs +784 -0
- package/src/init/questions.mjs +562 -0
- package/src/memory/benchmark.mjs +171 -0
- package/src/memory/capture.mjs +135 -0
- package/src/memory/consolidate.mjs +255 -0
- package/src/memory/index.mjs +810 -0
- package/src/model/ids.mjs +185 -0
- package/src/model/screening.mjs +150 -0
- package/src/model/toolkit.mjs +258 -0
- package/src/model/vocabulary.mjs +190 -0
- package/src/product/assumptions.mjs +120 -0
- package/src/product/changes.mjs +180 -0
- package/src/product/test-plans.mjs +183 -0
- package/src/progress/index.mjs +1364 -0
- package/src/runtime/harness.mjs +264 -0
- package/src/runtime/hooks.mjs +1021 -0
- package/src/runtime/identity.mjs +305 -0
- package/src/runtime/resume.mjs +343 -0
- package/src/runtime/session.mjs +679 -0
- package/src/runtime/version.mjs +315 -0
- package/src/service/assets/control-center.html +206 -0
- package/src/service/assets/control-center.manifest.json +7 -0
- package/src/service/manage.mjs +542 -0
- package/src/service/mutations.mjs +860 -0
- package/src/service/read-model.mjs +1557 -0
- package/src/service/server.mjs +783 -0
- package/src/tasks/categories.mjs +154 -0
- package/src/tasks/derive.mjs +977 -0
- package/src/tasks/lifecycle.mjs +830 -0
- 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
|
+
}
|