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,977 @@
|
|
|
1
|
+
// Task derivation, brief section 11.
|
|
2
|
+
//
|
|
3
|
+
// Work comes from accepted specifications, never from a feature title. The unit
|
|
4
|
+
// is an outcome that can be assigned, implemented and verified, so rows that
|
|
5
|
+
// cannot ship apart are combined into one task, and rows that can be delivered
|
|
6
|
+
// or blocked on their own stay separate.
|
|
7
|
+
//
|
|
8
|
+
// Re-derivation is a diff, not a rewrite. A task nobody has started is brought
|
|
9
|
+
// back in line with the specification; a task already in flight only gains the
|
|
10
|
+
// contract links it is missing and its wording drift is reported; a completed
|
|
11
|
+
// task is never edited, it is followed by a new task or left alone.
|
|
12
|
+
|
|
13
|
+
import { DbError, create, mutate, patch, query, recordActivity, setStatus } from "../db/store.mjs";
|
|
14
|
+
|
|
15
|
+
/** Where a contract link points. Used to validate links and detect dead ones. */
|
|
16
|
+
export const TARGET_TABLE = {
|
|
17
|
+
workflow_step: "workflow_steps",
|
|
18
|
+
ui_action: "ui_actions",
|
|
19
|
+
api_operation: "api_operations",
|
|
20
|
+
data_entity: "data_entities",
|
|
21
|
+
schema_migration: "schema_migrations",
|
|
22
|
+
integration: "integrations",
|
|
23
|
+
job: "jobs",
|
|
24
|
+
webhook: "webhooks",
|
|
25
|
+
nfr: "non_functional_requirements",
|
|
26
|
+
document: "documents",
|
|
27
|
+
decision: "decisions",
|
|
28
|
+
acceptance_criterion: "feature_acceptance_criteria",
|
|
29
|
+
surface: "surfaces",
|
|
30
|
+
state_transition: "state_transitions",
|
|
31
|
+
permission: "permissions",
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const TERMINAL = new Set(["complete", "cancelled", "superseded"]);
|
|
35
|
+
/** Only a task nobody has picked up yet may have its derived wording replaced. */
|
|
36
|
+
const REWRITABLE = new Set(["draft", "ready"]);
|
|
37
|
+
/** A specification row in one of these states is no longer work to be done. */
|
|
38
|
+
const DROPPED = new Set(["rejected", "superseded", "deprecated", "cancelled", "not_applicable"]);
|
|
39
|
+
/** A group bundling this many independently implementable rows earns subtasks. */
|
|
40
|
+
const SPLIT_AT = 4;
|
|
41
|
+
|
|
42
|
+
const alive = (rows) => rows.filter((r) => !DROPPED.has(r.status));
|
|
43
|
+
const ids = (rows) => rows.map((r) => r.id).join(", ");
|
|
44
|
+
const listOf = (values) => values.filter(Boolean).join(" ");
|
|
45
|
+
|
|
46
|
+
// -------------------------------------------------------------- loading spec
|
|
47
|
+
|
|
48
|
+
async function loadSpec(db, featureId) {
|
|
49
|
+
const feature = await db.get("SELECT * FROM features WHERE id = ?", featureId);
|
|
50
|
+
if (!feature) throw new DbError("E_NOT_FOUND", `Feature ${featureId} does not exist.`);
|
|
51
|
+
const module = await db.get("SELECT * FROM modules WHERE id = ?", feature.module_id);
|
|
52
|
+
const projectId = feature.project_id;
|
|
53
|
+
|
|
54
|
+
const workflows = alive(await db.all("SELECT * FROM workflows WHERE feature_id = ? ORDER BY id", featureId));
|
|
55
|
+
const steps = new Map();
|
|
56
|
+
for (const wf of workflows) {
|
|
57
|
+
steps.set(wf.id, alive(await db.all("SELECT * FROM workflow_steps WHERE workflow_id = ? ORDER BY sequence", wf.id)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const surfaces = alive(await db.all("SELECT * FROM surfaces WHERE feature_id = ? ORDER BY id", featureId));
|
|
61
|
+
const actions = new Map();
|
|
62
|
+
const surfaceStates = new Map();
|
|
63
|
+
for (const s of surfaces) {
|
|
64
|
+
actions.set(s.id, alive(await db.all("SELECT * FROM ui_actions WHERE surface_id = ? ORDER BY id", s.id)));
|
|
65
|
+
surfaceStates.set(s.id, await db.all("SELECT state_type FROM surface_states WHERE surface_id = ? ORDER BY state_type", s.id));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const machines = alive(await db.all("SELECT * FROM state_machines WHERE feature_id = ? ORDER BY id", featureId));
|
|
69
|
+
const transitions = new Map();
|
|
70
|
+
for (const m of machines) {
|
|
71
|
+
transitions.set(m.id, await db.all("SELECT * FROM state_transitions WHERE state_machine_id = ? ORDER BY id", m.id));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const entities = alive(await db.all("SELECT * FROM data_entities WHERE feature_id = ? ORDER BY id", featureId));
|
|
75
|
+
const fields = new Map();
|
|
76
|
+
for (const e of entities) {
|
|
77
|
+
fields.set(e.id, await db.all("SELECT name, sensitivity_class FROM data_fields WHERE entity_id = ? ORDER BY sequence, name", e.id));
|
|
78
|
+
}
|
|
79
|
+
const migrations = alive(await db.all("SELECT * FROM schema_migrations WHERE feature_id = ? ORDER BY sequence, id", featureId));
|
|
80
|
+
const migrationEntities = await db.all(
|
|
81
|
+
`SELECT me.migration_id, me.entity_id FROM schema_migration_entities me
|
|
82
|
+
JOIN schema_migrations m ON m.id = me.migration_id
|
|
83
|
+
WHERE m.feature_id = ?`,
|
|
84
|
+
featureId,
|
|
85
|
+
);
|
|
86
|
+
|
|
87
|
+
return {
|
|
88
|
+
feature,
|
|
89
|
+
module,
|
|
90
|
+
projectId,
|
|
91
|
+
criteria: (await db.all(
|
|
92
|
+
"SELECT * FROM feature_acceptance_criteria WHERE feature_id = ? ORDER BY sequence, id", featureId,
|
|
93
|
+
)).filter((c) => c.status !== "waived"),
|
|
94
|
+
workflows, steps, surfaces, actions, surfaceStates, machines, transitions,
|
|
95
|
+
apis: alive(await db.all("SELECT * FROM api_operations WHERE feature_id = ? ORDER BY id", featureId)),
|
|
96
|
+
entities, fields, migrations, migrationEntities,
|
|
97
|
+
integrations: alive(await db.all("SELECT * FROM integrations WHERE feature_id = ? ORDER BY id", featureId)),
|
|
98
|
+
jobs: alive(await db.all("SELECT * FROM jobs WHERE feature_id = ? ORDER BY id", featureId)),
|
|
99
|
+
webhooks: alive(await db.all("SELECT * FROM webhooks WHERE feature_id = ? ORDER BY id", featureId)),
|
|
100
|
+
nfrs: alive(await db.all(
|
|
101
|
+
"SELECT * FROM non_functional_requirements WHERE feature_id = ? ORDER BY category, id", featureId,
|
|
102
|
+
)),
|
|
103
|
+
permissions: await db.all(
|
|
104
|
+
`SELECT p.*, r.name AS role_name FROM permissions p JOIN roles r ON r.id = p.role_id
|
|
105
|
+
WHERE r.project_id = ? AND p.scope_type = 'feature' AND p.scope_id = ?
|
|
106
|
+
ORDER BY r.name, p.id`,
|
|
107
|
+
projectId, featureId,
|
|
108
|
+
),
|
|
109
|
+
documents: await db.all(
|
|
110
|
+
"SELECT * FROM documents WHERE project_id = ? AND scope_type = 'feature' AND scope_id = ? ORDER BY path",
|
|
111
|
+
projectId, featureId,
|
|
112
|
+
),
|
|
113
|
+
decisions: await db.all(
|
|
114
|
+
`SELECT id, title FROM decisions
|
|
115
|
+
WHERE project_id = ? AND scope_type = 'feature' AND scope_id = ?
|
|
116
|
+
AND status IN ('accepted','time_boxed') ORDER BY id`,
|
|
117
|
+
projectId, featureId,
|
|
118
|
+
),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* A non functional requirement is filed under what it is about, not under a
|
|
124
|
+
* catch-all. Quality remains the honest answer when the category names nothing
|
|
125
|
+
* more specific.
|
|
126
|
+
*/
|
|
127
|
+
function nfrCategory(category) {
|
|
128
|
+
const c = String(category ?? "").toLowerCase();
|
|
129
|
+
if (/secur|privac/.test(c)) return "Security";
|
|
130
|
+
if (/perf|latency|throughput|capacity|speed/.test(c)) return "Performance";
|
|
131
|
+
if (/accessib|a11y/.test(c)) return "Accessibility";
|
|
132
|
+
if (/observab|monitor|telemetry|logging|alert/.test(c)) return "Observability";
|
|
133
|
+
if (/complian|regulat|retention|gdpr|hipaa|pci/.test(c)) return "Compliance";
|
|
134
|
+
return "Quality";
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// ------------------------------------------------------------ plan item shape
|
|
138
|
+
|
|
139
|
+
function item(key, anchor, over) {
|
|
140
|
+
return {
|
|
141
|
+
key,
|
|
142
|
+
anchor,
|
|
143
|
+
category: "Feature",
|
|
144
|
+
priority: "normal",
|
|
145
|
+
links: [anchor],
|
|
146
|
+
dependsOn: [],
|
|
147
|
+
subtasks: [],
|
|
148
|
+
boundaries: [],
|
|
149
|
+
criteria: [],
|
|
150
|
+
verification: [],
|
|
151
|
+
children: [],
|
|
152
|
+
enabling: false,
|
|
153
|
+
...over,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const link = (target_type, target_id, relationship = "implements") => ({ target_type, target_id, relationship });
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Split a bundled group into subtasks only when it carries enough independently
|
|
161
|
+
* implementable rows that a single task would hide the real work.
|
|
162
|
+
*/
|
|
163
|
+
function withSubtasks(it) {
|
|
164
|
+
const kids = it.children ?? [];
|
|
165
|
+
it.children = [];
|
|
166
|
+
if (kids.length < SPLIT_AT) return it;
|
|
167
|
+
it.subtasks = kids.map((c) => item(`${it.key}/${c.id}`, link(c.type, c.id), {
|
|
168
|
+
name: c.name,
|
|
169
|
+
category: it.category,
|
|
170
|
+
priority: it.priority,
|
|
171
|
+
risk: it.risk,
|
|
172
|
+
why_needed: `Part of "${it.name}". It is split out because it can be picked up and verified on its own.`,
|
|
173
|
+
expected_outcome: c.outcome,
|
|
174
|
+
description: `Implements ${c.type.replace(/_/g, " ")} ${c.id}.`,
|
|
175
|
+
criteria: [`${c.id} behaves exactly as the accepted specification states.`],
|
|
176
|
+
verification: [`Exercise ${c.id} against its specification and record the observed result.`],
|
|
177
|
+
boundaries: it.boundaries,
|
|
178
|
+
}));
|
|
179
|
+
return it;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// ------------------------------------------------------------------ builders
|
|
183
|
+
|
|
184
|
+
/**
|
|
185
|
+
* ponytail: nothing in the schema links a UI action to the API operation behind
|
|
186
|
+
* it, so the pairing is inferred from the action naming the operation in its
|
|
187
|
+
* own text. Ceiling: an action that describes its call in other words leaves the
|
|
188
|
+
* operation as a separate task, which loses a grouping but never invents a wrong
|
|
189
|
+
* one. Upgrade path is a real column on ui_actions.
|
|
190
|
+
*/
|
|
191
|
+
function apiReferencedBy(actions, api) {
|
|
192
|
+
const needles = [api.id, api.name, api.path_or_topic]
|
|
193
|
+
.filter((v) => typeof v === "string" && v.length > 2)
|
|
194
|
+
.map((v) => v.toLowerCase());
|
|
195
|
+
return actions.some((a) => {
|
|
196
|
+
const hay = `${a.effect ?? ""} ${a.side_effects_json ?? ""} ${a.input_contract ?? ""}`.toLowerCase();
|
|
197
|
+
return needles.some((n) => hay.includes(n));
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** Entities and the migrations that touch them must ship together, so they are one task. */
|
|
202
|
+
function dataComponents(spec) {
|
|
203
|
+
const parent = new Map();
|
|
204
|
+
for (const e of spec.entities) parent.set(e.id, e.id);
|
|
205
|
+
for (const m of spec.migrations) parent.set(m.id, m.id);
|
|
206
|
+
const find = (x) => {
|
|
207
|
+
let cur = x;
|
|
208
|
+
while (parent.get(cur) !== cur) cur = parent.get(cur);
|
|
209
|
+
return cur;
|
|
210
|
+
};
|
|
211
|
+
for (const l of spec.migrationEntities) {
|
|
212
|
+
if (!parent.has(l.entity_id) || !parent.has(l.migration_id)) continue;
|
|
213
|
+
const a = find(l.entity_id);
|
|
214
|
+
const b = find(l.migration_id);
|
|
215
|
+
if (a !== b) parent.set(a, b);
|
|
216
|
+
}
|
|
217
|
+
const groups = new Map();
|
|
218
|
+
const push = (row, kind) => {
|
|
219
|
+
const root = find(row.id);
|
|
220
|
+
if (!groups.has(root)) groups.set(root, { entities: [], migrations: [] });
|
|
221
|
+
groups.get(root)[kind].push(row);
|
|
222
|
+
};
|
|
223
|
+
for (const e of spec.entities) push(e, "entities");
|
|
224
|
+
for (const m of spec.migrations) push(m, "migrations");
|
|
225
|
+
return [...groups.values()];
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
function dataItems(spec) {
|
|
229
|
+
const out = [];
|
|
230
|
+
for (const group of dataComponents(spec)) {
|
|
231
|
+
const head = group.entities[0] ?? group.migrations[0];
|
|
232
|
+
const anchor = group.entities[0]
|
|
233
|
+
? link("data_entity", group.entities[0].id)
|
|
234
|
+
: link("schema_migration", group.migrations[0].id);
|
|
235
|
+
const names = group.entities.map((e) => e.name);
|
|
236
|
+
const sensitive = group.entities.filter((e) => e.sensitivity_class !== "none");
|
|
237
|
+
const it = item(`data:${head.id}`, anchor, {
|
|
238
|
+
category: group.migrations.length && !group.entities.length ? "Migration" : "Data",
|
|
239
|
+
name: names.length
|
|
240
|
+
? `Establish the ${names.join(" and ")} data model`
|
|
241
|
+
: `Apply migration ${group.migrations[0].name}`,
|
|
242
|
+
why_needed: `${spec.feature.name} cannot store or read what it promises until this data exists and can be migrated forward and back.`,
|
|
243
|
+
expected_outcome: names.length
|
|
244
|
+
? `${names.join(", ")} exist with their fields, relationships and deletion semantics as specified, and the migration applies and rolls back cleanly.`
|
|
245
|
+
: `The migration applies and rolls back cleanly with no loss of data already stored.`,
|
|
246
|
+
description: listOf([
|
|
247
|
+
names.length ? `Implements data entities ${ids(group.entities)}.` : "",
|
|
248
|
+
group.migrations.length ? `Covers migrations ${ids(group.migrations)}.` : "",
|
|
249
|
+
]),
|
|
250
|
+
links: [
|
|
251
|
+
...group.entities.map((e) => link("data_entity", e.id)),
|
|
252
|
+
...group.migrations.map((m) => link("schema_migration", m.id)),
|
|
253
|
+
],
|
|
254
|
+
criteria: [
|
|
255
|
+
...group.entities.map((e) => `${e.name} stores every declared field with its declared nullability and constraints.`),
|
|
256
|
+
...group.migrations.map((m) => `Migration ${m.id} applies forward and rolls back to the previous shape without data loss.`),
|
|
257
|
+
...sensitive.map((e) => `${e.name} honours its ${e.sensitivity_class} retention rule and deletion semantics.`),
|
|
258
|
+
],
|
|
259
|
+
verification: [
|
|
260
|
+
"Apply the migration on a copy of real-shaped data, then roll it back, and record both results.",
|
|
261
|
+
...group.entities.map((e) => `Read and write ${e.name} through the code path that owns it and record the observed result.`),
|
|
262
|
+
],
|
|
263
|
+
boundaries: [
|
|
264
|
+
`Module ${spec.module?.name ?? spec.feature.module_id}`,
|
|
265
|
+
...group.entities.map((e) => `Data entity ${e.name} in ${e.store ?? "the project store"}`),
|
|
266
|
+
],
|
|
267
|
+
children: group.entities.map((e) => ({
|
|
268
|
+
id: e.id, type: "data_entity",
|
|
269
|
+
name: `Implement the ${e.name} entity`,
|
|
270
|
+
outcome: `${e.name} exists with its declared fields and relationships.`,
|
|
271
|
+
})),
|
|
272
|
+
});
|
|
273
|
+
out.push(withSubtasks(it));
|
|
274
|
+
}
|
|
275
|
+
return out;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function surfaceItems(spec, usedApiIds) {
|
|
279
|
+
return spec.surfaces.map((s) => {
|
|
280
|
+
const acts = spec.actions.get(s.id) ?? [];
|
|
281
|
+
const folded = spec.apis.filter((api) => apiReferencedBy(acts, api));
|
|
282
|
+
for (const api of folded) usedApiIds.add(api.id);
|
|
283
|
+
const states = (spec.surfaceStates.get(s.id) ?? []).map((r) => r.state_type);
|
|
284
|
+
return withSubtasks(item(`surface:${s.id}`, link("surface", s.id), {
|
|
285
|
+
category: "Design",
|
|
286
|
+
name: `Deliver the ${s.name} ${s.surface_type ?? "surface"}`,
|
|
287
|
+
why_needed: `${spec.feature.name} is not usable until ${s.name} exists and every action it offers works.`,
|
|
288
|
+
expected_outcome: s.purpose
|
|
289
|
+
? s.purpose
|
|
290
|
+
: `A person in the ${s.primary_role ?? "intended"} role can open ${s.name} and complete every action it declares.`,
|
|
291
|
+
description: listOf([
|
|
292
|
+
`Implements surface ${s.id} (${s.name})${s.route ? ` at ${s.route}` : ""}.`,
|
|
293
|
+
acts.length ? `Includes UI actions ${ids(acts)}.` : "It declares no actions of its own.",
|
|
294
|
+
folded.length ? `The operations behind those actions (${ids(folded)}) ship with it because they cannot be released apart.` : "",
|
|
295
|
+
]),
|
|
296
|
+
links: [
|
|
297
|
+
link("surface", s.id),
|
|
298
|
+
...acts.map((a) => link("ui_action", a.id)),
|
|
299
|
+
...folded.map((a) => link("api_operation", a.id)),
|
|
300
|
+
],
|
|
301
|
+
criteria: [
|
|
302
|
+
...acts.map((a) => `${a.name} enforces ${a.permission ?? "its declared permission"} at ${a.enforcement_point ?? "the server"} and reports success and failure as specified.`),
|
|
303
|
+
states.length
|
|
304
|
+
? `The ${states.join(", ")} states of ${s.name} each render their specified behaviour and copy.`
|
|
305
|
+
: `Loading, empty and error states of ${s.name} are specified before this task closes.`,
|
|
306
|
+
s.accessibility_notes
|
|
307
|
+
? `Accessibility holds: ${s.accessibility_notes}`
|
|
308
|
+
: `Every action has an accessible name, a visible focus state and works from the keyboard.`,
|
|
309
|
+
s.responsive_behavior ? `Responsive behaviour holds: ${s.responsive_behavior}` : null,
|
|
310
|
+
].filter(Boolean),
|
|
311
|
+
verification: [
|
|
312
|
+
`Walk ${s.name} through each declared state and record what was observed.`,
|
|
313
|
+
...acts.map((a) => `Trigger ${a.name} as a permitted and as a forbidden role and record both outcomes.`),
|
|
314
|
+
],
|
|
315
|
+
boundaries: [
|
|
316
|
+
`Module ${spec.module?.name ?? spec.feature.module_id}`,
|
|
317
|
+
s.route ? `Route ${s.route}` : `Surface ${s.name}`,
|
|
318
|
+
...folded.map((a) => `Operation ${a.method_or_procedure ?? a.style} ${a.path_or_topic ?? a.name}`),
|
|
319
|
+
],
|
|
320
|
+
children: acts.map((a) => ({
|
|
321
|
+
id: a.id, type: "ui_action",
|
|
322
|
+
name: `Implement the ${a.name} action on ${s.name}`,
|
|
323
|
+
outcome: a.effect ?? `${a.name} produces its declared effect and reports the result to the person.`,
|
|
324
|
+
})),
|
|
325
|
+
}));
|
|
326
|
+
});
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function apiItems(spec, usedApiIds) {
|
|
330
|
+
return spec.apis.filter((a) => !usedApiIds.has(a.id)).map((a) =>
|
|
331
|
+
item(`api:${a.id}`, link("api_operation", a.id), {
|
|
332
|
+
name: `Implement ${a.method_or_procedure ?? a.style} ${a.path_or_topic ?? a.name}`,
|
|
333
|
+
why_needed: a.purpose
|
|
334
|
+
? `${a.purpose} Nothing that depends on this contract can be built before it exists.`
|
|
335
|
+
: `${spec.feature.name} depends on this operation, and no caller can be built against a contract that does not exist yet.`,
|
|
336
|
+
expected_outcome: `${a.name} accepts its declared request, returns its declared response, and returns every declared error rather than a generic failure.`,
|
|
337
|
+
description: `Implements API operation ${a.id} (${a.name}).`,
|
|
338
|
+
criteria: [
|
|
339
|
+
`The request and response contracts match ${a.id} exactly, including the declared error cases.`,
|
|
340
|
+
`${a.auth_requirement ?? "The declared authentication"} is required and ${a.permission ?? "the declared permission"} is enforced at ${a.enforcement_point ?? "the server"}.`,
|
|
341
|
+
a.idempotency ? `Idempotency holds: ${a.idempotency}` : null,
|
|
342
|
+
a.limits ? `Declared limits hold: ${a.limits}` : null,
|
|
343
|
+
].filter(Boolean),
|
|
344
|
+
verification: [
|
|
345
|
+
`Call ${a.id} with a valid request, an unauthorized request and each declared error case, and record the responses.`,
|
|
346
|
+
],
|
|
347
|
+
boundaries: [
|
|
348
|
+
`Module ${spec.module?.name ?? spec.feature.module_id}`,
|
|
349
|
+
a.implementation_path ? `Implementation ${a.implementation_path}` : `Operation ${a.name}`,
|
|
350
|
+
],
|
|
351
|
+
}),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
function workflowItems(spec) {
|
|
356
|
+
return spec.workflows.map((wf) => {
|
|
357
|
+
const wfSteps = spec.steps.get(wf.id) ?? [];
|
|
358
|
+
return withSubtasks(item(`workflow:${wf.id}`, link("workflow_step", wfSteps[0]?.id ?? wf.id), {
|
|
359
|
+
name: `Make the ${wf.name} flow work end to end`,
|
|
360
|
+
why_needed: wf.purpose
|
|
361
|
+
? `${wf.purpose} A flow that only works step by step is not a flow.`
|
|
362
|
+
: `${spec.feature.name} promises this flow, and the steps only deliver value when they run end to end.`,
|
|
363
|
+
expected_outcome: wf.completion_criteria ?? `${wf.name} runs from its trigger to its completion for every declared actor.`,
|
|
364
|
+
description: listOf([
|
|
365
|
+
`Implements workflow ${wf.id} (${wf.name}), steps ${ids(wfSteps)}.`,
|
|
366
|
+
wf.trigger ? `Triggered by ${wf.trigger}.` : "",
|
|
367
|
+
wf.observability ? `Instrumentation required: ${wf.observability}` : "",
|
|
368
|
+
]),
|
|
369
|
+
links: wfSteps.length ? wfSteps.map((s) => link("workflow_step", s.id)) : [link("workflow_step", wf.id)],
|
|
370
|
+
criteria: [
|
|
371
|
+
...wfSteps.map((s) => `Step ${s.sequence} (${s.action}) produces ${s.expected_result ?? "its declared result"} and handles failure by ${s.failure_behavior ?? "its declared failure behaviour"}.`),
|
|
372
|
+
wf.observability ? `The flow emits ${wf.observability} so a failure is visible without reading the code.` : null,
|
|
373
|
+
].filter(Boolean),
|
|
374
|
+
verification: [
|
|
375
|
+
`Run ${wf.name} end to end for each declared actor and record where it succeeded and where it stopped.`,
|
|
376
|
+
`Force each declared failure branch and record that the specified failure behaviour happened.`,
|
|
377
|
+
],
|
|
378
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `Workflow ${wf.name}`],
|
|
379
|
+
children: wfSteps.map((s) => ({
|
|
380
|
+
id: s.id, type: "workflow_step",
|
|
381
|
+
name: `Implement step ${s.sequence} of ${wf.name}: ${s.action}`,
|
|
382
|
+
outcome: s.expected_result ?? `Step ${s.sequence} produces its declared result.`,
|
|
383
|
+
})),
|
|
384
|
+
}));
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function stateMachineItems(spec) {
|
|
389
|
+
return spec.machines.map((m) => {
|
|
390
|
+
const trans = spec.transitions.get(m.id) ?? [];
|
|
391
|
+
if (!trans.length) return null;
|
|
392
|
+
return item(`state:${m.id}`, link("state_transition", trans[0].id), {
|
|
393
|
+
name: `Enforce the ${m.entity_name} state machine`,
|
|
394
|
+
why_needed: `Without enforcement, ${m.entity_name} can reach a state the specification forbids, and every downstream rule stops being true.`,
|
|
395
|
+
expected_outcome: `Only declared transitions of ${m.entity_name} are possible, and a forbidden transition is refused with a reason.`,
|
|
396
|
+
description: `Implements state machine ${m.id} for ${m.entity_name}, transitions ${ids(trans)}.`,
|
|
397
|
+
links: trans.map((t) => link("state_transition", t.id)),
|
|
398
|
+
criteria: [
|
|
399
|
+
`${m.entity_name} starts at ${m.initial_state ?? "its declared initial state"}.`,
|
|
400
|
+
...trans.map((t) => `The ${t.event} transition is allowed only when ${t.guard ?? "its declared guard"} holds, and is enforced at ${t.enforcement_point ?? "the server"}.`),
|
|
401
|
+
`A transition that is not declared is refused and the refusal says why.`,
|
|
402
|
+
],
|
|
403
|
+
verification: [
|
|
404
|
+
`Attempt every declared transition and at least one undeclared transition, and record what happened.`,
|
|
405
|
+
],
|
|
406
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `Entity ${m.entity_name}`],
|
|
407
|
+
});
|
|
408
|
+
}).filter(Boolean);
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
function integrationItems(spec) {
|
|
412
|
+
return spec.integrations.map((i) =>
|
|
413
|
+
item(`integration:${i.id}`, link("integration", i.id), {
|
|
414
|
+
category: "Integration",
|
|
415
|
+
name: `Wire the ${i.name} integration`,
|
|
416
|
+
why_needed: i.purpose
|
|
417
|
+
? `${i.purpose} It stays unverified until it is configured and proven against the real provider.`
|
|
418
|
+
: `${spec.feature.name} depends on ${i.provider ?? i.name}, and an unverified integration is an outage waiting for a user to find it.`,
|
|
419
|
+
expected_outcome: `${i.name} is configured for every declared environment, verified against the provider, and fails the way the specification says it should.`,
|
|
420
|
+
description: `Implements integration ${i.id} with ${i.provider ?? "the declared provider"}.`,
|
|
421
|
+
criteria: [
|
|
422
|
+
`Credentials are supplied by configuration and never stored in the repository or in a record.`,
|
|
423
|
+
`Authentication uses ${i.auth_approach ?? "the declared approach"} and refreshes without manual work.`,
|
|
424
|
+
`Failure behaviour holds: ${i.failure_behavior ?? "the declared failure behaviour"}.`,
|
|
425
|
+
],
|
|
426
|
+
verification: [
|
|
427
|
+
`Call ${i.name} against the provider in each declared environment and record the result, then move verification_status off unverified.`,
|
|
428
|
+
`Force a provider failure and record that the declared failure behaviour happened.`,
|
|
429
|
+
],
|
|
430
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `Provider ${i.provider ?? i.name}`],
|
|
431
|
+
}),
|
|
432
|
+
);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
function jobItems(spec) {
|
|
436
|
+
const jobs = spec.jobs.map((j) =>
|
|
437
|
+
item(`job:${j.id}`, link("job", j.id), {
|
|
438
|
+
category: "Infrastructure",
|
|
439
|
+
name: `Run the ${j.name} job reliably`,
|
|
440
|
+
why_needed: `Background work that runs twice, or silently not at all, is worse than no background work, so this ships with its retry and failure route.`,
|
|
441
|
+
expected_outcome: `${j.name} runs on ${j.trigger ?? "its declared trigger"}, is ${j.idempotency ?? "idempotent"}, and a failure lands somewhere a human will see it.`,
|
|
442
|
+
description: `Implements job ${j.id} (${j.name}).`,
|
|
443
|
+
criteria: [
|
|
444
|
+
`Delivery is ${j.delivery_guarantee ?? "undeclared"} and the implementation matches that claim.`,
|
|
445
|
+
`Retries follow ${j.retry_policy ?? "the declared retry policy"} and exhausted work lands in ${j.failure_destination ?? "the declared failure destination"}.`,
|
|
446
|
+
j.timeout ? `The job stops at ${j.timeout} rather than running forever.` : null,
|
|
447
|
+
j.observability ? `The job emits ${j.observability}.` : null,
|
|
448
|
+
].filter(Boolean),
|
|
449
|
+
verification: [
|
|
450
|
+
`Run the job twice with the same input and record that the second run changed nothing.`,
|
|
451
|
+
`Force a failure and record where the work landed and what a human would see.`,
|
|
452
|
+
],
|
|
453
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `Job ${j.name}`],
|
|
454
|
+
}),
|
|
455
|
+
);
|
|
456
|
+
const hooks = spec.webhooks.map((w) =>
|
|
457
|
+
item(`webhook:${w.id}`, link("webhook", w.id), {
|
|
458
|
+
category: "Integration",
|
|
459
|
+
name: `Handle the ${w.name} ${w.direction} webhook`,
|
|
460
|
+
why_needed: `A webhook without identity checks and replay protection is an open endpoint, and out of order delivery corrupts state quietly.`,
|
|
461
|
+
expected_outcome: `${w.name} verifies identity, refuses replays, and handles out of order delivery as specified.`,
|
|
462
|
+
description: `Implements webhook ${w.id} (${w.name}), direction ${w.direction}.`,
|
|
463
|
+
criteria: [
|
|
464
|
+
`Identity is verified by ${w.identity_verification ?? "the declared method"} before any work happens.`,
|
|
465
|
+
`Replay protection holds: ${w.replay_protection ?? "the declared protection"}.`,
|
|
466
|
+
`Ordering behaviour holds: ${w.ordering_behavior ?? "the declared behaviour"}.`,
|
|
467
|
+
`A failure is visible through ${w.failure_visibility ?? "the declared failure visibility"}.`,
|
|
468
|
+
],
|
|
469
|
+
verification: [
|
|
470
|
+
`Deliver a valid payload, a replayed payload and a payload with a bad signature, and record all three outcomes.`,
|
|
471
|
+
],
|
|
472
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `Webhook ${w.name}`],
|
|
473
|
+
}),
|
|
474
|
+
);
|
|
475
|
+
return [...jobs, ...hooks];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function permissionItems(spec) {
|
|
479
|
+
if (!spec.permissions.length) return [];
|
|
480
|
+
const roles = [...new Set(spec.permissions.map((p) => p.role_name))];
|
|
481
|
+
return [item(`permissions:${spec.feature.id}`, link("permission", spec.permissions[0].id), {
|
|
482
|
+
category: "Security",
|
|
483
|
+
name: `Enforce ${spec.feature.name} permissions for ${roles.join(", ")}`,
|
|
484
|
+
why_needed: `Permissions written down but not enforced read as a promise the product does not keep, and the gap is only found by whoever exploits it.`,
|
|
485
|
+
expected_outcome: `Every declared capability is allowed at its declared access level and refused otherwise, enforced on the server rather than only in the interface.`,
|
|
486
|
+
description: `Implements permissions ${ids(spec.permissions)} for roles ${roles.join(", ")}.`,
|
|
487
|
+
links: spec.permissions.map((p) => link("permission", p.id)),
|
|
488
|
+
criteria: spec.permissions.map((p) =>
|
|
489
|
+
`${p.role_name} has ${p.access_level} access to ${p.capability}, enforced at ${p.enforcement_point ?? "the server"}.`),
|
|
490
|
+
verification: [
|
|
491
|
+
`Exercise each capability as a permitted role and as a role without that access, and record both results.`,
|
|
492
|
+
],
|
|
493
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, "Authorization enforcement"],
|
|
494
|
+
})];
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
function nfrItems(spec) {
|
|
498
|
+
const byCategory = new Map();
|
|
499
|
+
for (const n of spec.nfrs) {
|
|
500
|
+
if (!byCategory.has(n.category)) byCategory.set(n.category, []);
|
|
501
|
+
byCategory.get(n.category).push(n);
|
|
502
|
+
}
|
|
503
|
+
return [...byCategory].map(([category, rows]) =>
|
|
504
|
+
item(`nfr:${category}:${rows[0].id}`, link("nfr", rows[0].id), {
|
|
505
|
+
category: nfrCategory(category),
|
|
506
|
+
name: `Meet the ${category} requirements of ${spec.feature.name}`,
|
|
507
|
+
why_needed: `An unmeasured requirement is an assumption. These carry targets, so they can be measured and either met or reported honestly as unmet.`,
|
|
508
|
+
expected_outcome: `Each ${category} requirement is measured by its declared method and its measured value is recorded against its target.`,
|
|
509
|
+
description: `Implements non functional requirements ${ids(rows)} in category ${category}.`,
|
|
510
|
+
links: rows.map((n) => link("nfr", n.id)),
|
|
511
|
+
criteria: rows.map((n) => `${n.requirement} reaches ${n.target ?? "its declared target"}.`),
|
|
512
|
+
verification: rows.map((n) =>
|
|
513
|
+
`Measure ${n.id} using ${n.measurement_method ?? "its declared measurement method"} and record the value, not an impression.`),
|
|
514
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `${category} of ${spec.feature.name}`],
|
|
515
|
+
}),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function documentationItems(spec) {
|
|
520
|
+
const pending = spec.documents.filter((d) => d.sync_status === "manual_edit_pending" || d.sync_status === "missing");
|
|
521
|
+
if (!pending.length) return [];
|
|
522
|
+
return [item(`docs:${spec.feature.id}`, link("document", pending[0].id), {
|
|
523
|
+
category: "Documentation",
|
|
524
|
+
name: `Reconcile the ${spec.feature.name} documentation`,
|
|
525
|
+
why_needed: `A document that is missing, or edited by hand and never folded back into the record, makes the generated set untrustworthy for everyone after you.`,
|
|
526
|
+
expected_outcome: `Every document for this feature is generated from the record, and any manual edit has been accepted into the record or rejected with a reason.`,
|
|
527
|
+
description: `Covers documents ${pending.map((d) => d.path).join(", ")}.`,
|
|
528
|
+
links: pending.map((d) => link("document", d.id, "synchronizes")),
|
|
529
|
+
criteria: pending.map((d) =>
|
|
530
|
+
d.sync_status === "missing"
|
|
531
|
+
? `${d.path} is generated and its hash matches the record.`
|
|
532
|
+
: `The manual edit in ${d.path} is accepted into the record or rejected with a stated reason.`),
|
|
533
|
+
verification: ["Regenerate the documents and record that no proposal remains open for this feature."],
|
|
534
|
+
boundaries: pending.map((d) => `Document ${d.path}`),
|
|
535
|
+
})];
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function rolloutItems(spec) {
|
|
539
|
+
const risky = ["R2", "R3", "R4"].includes(spec.feature.risk_level);
|
|
540
|
+
if (!risky && !spec.migrations.length) return [];
|
|
541
|
+
const links = [
|
|
542
|
+
...spec.migrations.map((m) => link("schema_migration", m.id, "rolls_back")),
|
|
543
|
+
...spec.integrations.map((i) => link("integration", i.id, "rolls_out")),
|
|
544
|
+
...spec.decisions.map((d) => link("decision", d.id, "governed_by")),
|
|
545
|
+
];
|
|
546
|
+
if (!links.length) return [];
|
|
547
|
+
return [item(`rollout:${spec.feature.id}`, links[0], {
|
|
548
|
+
category: "Release",
|
|
549
|
+
name: `Prove rollout and rollback for ${spec.feature.name}`,
|
|
550
|
+
why_needed: `This is ${spec.feature.risk_level} work. A release that cannot be reversed is a decision made once, under pressure, with no way back.`,
|
|
551
|
+
expected_outcome: `The feature can be released in stages and withdrawn without data loss, and the withdrawal has been performed at least once rather than assumed.`,
|
|
552
|
+
description: `Covers rollout and rollback for ${spec.feature.id}.`,
|
|
553
|
+
links,
|
|
554
|
+
criteria: [
|
|
555
|
+
"The release order is written down and each step names what proves it worked.",
|
|
556
|
+
"Rollback has been performed on real-shaped data and the result recorded.",
|
|
557
|
+
spec.migrations.length ? `Migrations ${ids(spec.migrations)} roll back without losing data written after they applied.` : null,
|
|
558
|
+
].filter(Boolean),
|
|
559
|
+
verification: ["Perform the rollback end to end and record the observed state before and after."],
|
|
560
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, "Release and rollback"],
|
|
561
|
+
})];
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/**
|
|
565
|
+
* Acceptance and product tests are one outcome: the feature is demonstrably
|
|
566
|
+
* accepted. Product test work comes from the accepted test plan, never from a
|
|
567
|
+
* blanket instruction to write tests.
|
|
568
|
+
*/
|
|
569
|
+
function acceptanceItems(spec) {
|
|
570
|
+
const testPlans = spec.documents.filter((d) => /test/i.test(d.kind));
|
|
571
|
+
const unmet = spec.criteria.filter((c) => c.status !== "met");
|
|
572
|
+
if (!unmet.length && !testPlans.length) return [];
|
|
573
|
+
const links = [
|
|
574
|
+
...unmet.map((c) => link("acceptance_criterion", c.id, "verifies")),
|
|
575
|
+
...testPlans.map((d) => link("document", d.id, "executes")),
|
|
576
|
+
];
|
|
577
|
+
return [item(`acceptance:${spec.feature.id}`, links[0], {
|
|
578
|
+
category: "Quality",
|
|
579
|
+
category: "Testing",
|
|
580
|
+
name: `Prove ${spec.feature.name} meets its acceptance criteria`,
|
|
581
|
+
why_needed: `A feature is finished when its acceptance criteria are shown to hold with evidence, not when the last implementation task closes.`,
|
|
582
|
+
expected_outcome: `Every acceptance criterion is met or waived with a reason, each backed by recorded evidence.`,
|
|
583
|
+
description: listOf([
|
|
584
|
+
unmet.length ? `Verifies acceptance criteria ${ids(unmet)}.` : "",
|
|
585
|
+
testPlans.length ? `Executes the accepted product test plan in ${testPlans.map((d) => d.path).join(", ")}.` : "",
|
|
586
|
+
]),
|
|
587
|
+
links,
|
|
588
|
+
criteria: [
|
|
589
|
+
...unmet.map((c) => `${c.criterion} is shown to hold by ${c.verification_method ?? "its declared verification method"}.`),
|
|
590
|
+
...testPlans.map((d) => `The product tests described in ${d.path} run and their result is recorded.`),
|
|
591
|
+
],
|
|
592
|
+
verification: [
|
|
593
|
+
"Attach evidence for each criterion and record a pass, a fail or an inconclusive result rather than an opinion.",
|
|
594
|
+
],
|
|
595
|
+
boundaries: [`Module ${spec.module?.name ?? spec.feature.module_id}`, `Acceptance of ${spec.feature.name}`],
|
|
596
|
+
})];
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
// --------------------------------------------------------------- the plan
|
|
600
|
+
|
|
601
|
+
function buildPlan(spec) {
|
|
602
|
+
const usedApiIds = new Set();
|
|
603
|
+
const surfaces = surfaceItems(spec, usedApiIds);
|
|
604
|
+
const data = dataItems(spec);
|
|
605
|
+
const apis = apiItems(spec, usedApiIds);
|
|
606
|
+
const workflows = workflowItems(spec);
|
|
607
|
+
const machines = stateMachineItems(spec);
|
|
608
|
+
const integrations = integrationItems(spec);
|
|
609
|
+
const jobs = jobItems(spec);
|
|
610
|
+
const permissions = permissionItems(spec);
|
|
611
|
+
const nfrs = nfrItems(spec);
|
|
612
|
+
const docs = documentationItems(spec);
|
|
613
|
+
const rollout = rolloutItems(spec);
|
|
614
|
+
const acceptance = acceptanceItems(spec);
|
|
615
|
+
|
|
616
|
+
// Order is delivery order, and it is also where the dependencies come from:
|
|
617
|
+
// nothing above a task can be waited on by it.
|
|
618
|
+
const items = [
|
|
619
|
+
...data, ...apis, ...surfaces, ...workflows, ...machines,
|
|
620
|
+
...integrations, ...jobs, ...permissions, ...nfrs, ...docs, ...rollout, ...acceptance,
|
|
621
|
+
].filter((it) => it.links.length);
|
|
622
|
+
|
|
623
|
+
const keys = (list) => list.map((it) => it.key);
|
|
624
|
+
const dependOn = (list, on) => {
|
|
625
|
+
for (const it of list) it.dependsOn = [...new Set([...it.dependsOn, ...on])].filter((k) => k !== it.key);
|
|
626
|
+
};
|
|
627
|
+
dependOn(apis, keys(data));
|
|
628
|
+
dependOn(surfaces, [...keys(data), ...keys(apis)]);
|
|
629
|
+
dependOn(workflows, [...keys(surfaces), ...keys(apis)]);
|
|
630
|
+
dependOn(machines, keys(data));
|
|
631
|
+
dependOn(jobs, keys(integrations));
|
|
632
|
+
dependOn(permissions, [...keys(apis), ...keys(surfaces)]);
|
|
633
|
+
dependOn(rollout, [...keys(data), ...keys(integrations)]);
|
|
634
|
+
dependOn(acceptance, keys(items).filter((k) => !k.startsWith("acceptance:")));
|
|
635
|
+
|
|
636
|
+
const risk = spec.feature.risk_level;
|
|
637
|
+
const priority = spec.feature.priority ?? "normal";
|
|
638
|
+
const decisions = spec.decisions.length
|
|
639
|
+
? ` Governing decisions: ${spec.decisions.map((d) => d.id).join(", ")}.`
|
|
640
|
+
: "";
|
|
641
|
+
const docImpact = `Documentation for ${spec.feature.id} is regenerated and no edit proposal is left open.`;
|
|
642
|
+
for (const it of items) {
|
|
643
|
+
for (const node of [it, ...it.subtasks]) {
|
|
644
|
+
node.risk = node.risk ?? risk;
|
|
645
|
+
node.priority = node.priority === "normal" ? priority : node.priority;
|
|
646
|
+
node.description = `${node.description}${decisions}`;
|
|
647
|
+
node.criteria = [...node.criteria, docImpact];
|
|
648
|
+
if (!node.verification.length) node.verification = [`Demonstrate the outcome of ${node.key} and record what was observed.`];
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
return items;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
/** The proposed task set for one accepted feature. Read only, no writes. */
|
|
655
|
+
export async function derivationPlan(db, featureId) {
|
|
656
|
+
const spec = await loadSpec(db, featureId);
|
|
657
|
+
const accepted = spec.feature.status === "accepted";
|
|
658
|
+
return {
|
|
659
|
+
featureId,
|
|
660
|
+
feature: spec.feature,
|
|
661
|
+
accepted,
|
|
662
|
+
reason: accepted ? null : `Feature ${featureId} is ${spec.feature.status}. Tasks are derived from accepted specifications only.`,
|
|
663
|
+
items: accepted ? buildPlan(spec) : [],
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
// ------------------------------------------------------------------- the diff
|
|
668
|
+
|
|
669
|
+
const COMPARED = [
|
|
670
|
+
"name", "description", "expected_outcome", "why_needed",
|
|
671
|
+
"completion_criteria_json", "verification_requirements_json", "affected_boundaries_json",
|
|
672
|
+
"priority", "risk",
|
|
673
|
+
];
|
|
674
|
+
|
|
675
|
+
function columnsFor(it, feature) {
|
|
676
|
+
return {
|
|
677
|
+
name: it.name,
|
|
678
|
+
description: it.description,
|
|
679
|
+
expected_outcome: it.expected_outcome,
|
|
680
|
+
why_needed: it.why_needed,
|
|
681
|
+
completion_criteria_json: JSON.stringify(it.criteria),
|
|
682
|
+
verification_requirements_json: JSON.stringify(it.verification),
|
|
683
|
+
affected_boundaries_json: JSON.stringify(it.boundaries),
|
|
684
|
+
priority: it.priority,
|
|
685
|
+
risk: it.risk ?? null,
|
|
686
|
+
enabling: it.enabling ? 1 : 0,
|
|
687
|
+
enabled_feature_id: it.enabling ? feature.id : null,
|
|
688
|
+
enabling_rationale: it.enabling ? it.enabling_rationale ?? null : null,
|
|
689
|
+
};
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
const sameLink = (a, b) => a.target_type === b.target_type && a.target_id === b.target_id;
|
|
693
|
+
|
|
694
|
+
function compare(task, it, existingLinks, feature) {
|
|
695
|
+
const wanted = columnsFor(it, feature);
|
|
696
|
+
const fields = {};
|
|
697
|
+
for (const key of COMPARED) {
|
|
698
|
+
if ((task[key] ?? null) !== (wanted[key] ?? null)) fields[key] = wanted[key];
|
|
699
|
+
}
|
|
700
|
+
const addLinks = it.links.filter((l) => !existingLinks.some((e) => sameLink(e, l)));
|
|
701
|
+
const dropLinks = existingLinks.filter((e) => !it.links.some((l) => sameLink(e, l)));
|
|
702
|
+
return { fields, addLinks, dropLinks };
|
|
703
|
+
}
|
|
704
|
+
|
|
705
|
+
async function deadLinks(db, links) {
|
|
706
|
+
const dead = [];
|
|
707
|
+
for (const l of links) {
|
|
708
|
+
const table = TARGET_TABLE[l.target_type];
|
|
709
|
+
if (!table) continue;
|
|
710
|
+
const row = await db.get(`SELECT * FROM ${table} WHERE id = ?`, l.target_id);
|
|
711
|
+
if (!row || DROPPED.has(row.status)) dead.push(l);
|
|
712
|
+
}
|
|
713
|
+
return dead;
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
async function diff(db, featureId) {
|
|
717
|
+
const plan = await derivationPlan(db, featureId);
|
|
718
|
+
const feature = plan.feature;
|
|
719
|
+
const out = {
|
|
720
|
+
featureId, feature, accepted: plan.accepted, reason: plan.reason,
|
|
721
|
+
created: [], updated: [], recreated: [], driftOnly: [], unchanged: [],
|
|
722
|
+
superseded: [], orphaned: [], applied: false,
|
|
723
|
+
};
|
|
724
|
+
if (!plan.accepted) return out;
|
|
725
|
+
|
|
726
|
+
// Open tasks claim a plan item before finished ones, and a parent before its
|
|
727
|
+
// children, so re-derivation converges instead of oscillating.
|
|
728
|
+
const existing = await db.all(
|
|
729
|
+
`SELECT * FROM tasks WHERE feature_id = ?
|
|
730
|
+
ORDER BY (parent_task_id IS NOT NULL), (status IN ('complete','cancelled','superseded')), id`,
|
|
731
|
+
featureId,
|
|
732
|
+
);
|
|
733
|
+
const linksByTask = new Map(existing.map((t) => [t.id, []]));
|
|
734
|
+
if (existing.length) {
|
|
735
|
+
const rows = await db.all(
|
|
736
|
+
`SELECT * FROM task_contract_links WHERE task_id IN (${existing.map(() => "?").join(",")})`,
|
|
737
|
+
...existing.map((t) => t.id),
|
|
738
|
+
);
|
|
739
|
+
for (const r of rows) linksByTask.get(r.task_id).push(r);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
// The position in delivery order is the task's sequence, so a task added by a
|
|
743
|
+
// later derivation still sorts where the work actually belongs.
|
|
744
|
+
const flat = [];
|
|
745
|
+
for (const it of plan.items) {
|
|
746
|
+
it.order = flat.length;
|
|
747
|
+
flat.push(it);
|
|
748
|
+
for (const sub of it.subtasks) flat.push({ ...sub, parentKey: it.key, order: flat.length });
|
|
749
|
+
}
|
|
750
|
+
|
|
751
|
+
const claimed = new Map();
|
|
752
|
+
const matched = new Map();
|
|
753
|
+
for (const t of existing) {
|
|
754
|
+
const tlinks = linksByTask.get(t.id) ?? [];
|
|
755
|
+
const found = flat.find((it) => !claimed.has(it.key) && tlinks.some((l) => sameLink(l, it.anchor)));
|
|
756
|
+
if (!found) continue;
|
|
757
|
+
claimed.set(found.key, t);
|
|
758
|
+
matched.set(t.id, found);
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
for (const it of flat) {
|
|
762
|
+
const task = claimed.get(it.key);
|
|
763
|
+
if (!task) {
|
|
764
|
+
out.created.push({ item: it, reason: "the specification declares work that no task covers" });
|
|
765
|
+
continue;
|
|
766
|
+
}
|
|
767
|
+
const changes = compare(task, it, linksByTask.get(task.id) ?? [], feature);
|
|
768
|
+
const textChanged = Object.keys(changes.fields).length > 0;
|
|
769
|
+
const contractChanged = changes.addLinks.length > 0 || changes.dropLinks.length > 0;
|
|
770
|
+
if (!textChanged && !contractChanged) {
|
|
771
|
+
out.unchanged.push({ item: it, task });
|
|
772
|
+
} else if (TERMINAL.has(task.status)) {
|
|
773
|
+
// A finished task is never edited. Only a contract change earns a follow-on.
|
|
774
|
+
if (contractChanged) {
|
|
775
|
+
out.recreated.push({ item: it, follows: task, reason: `${task.id} is ${task.status} and its contract changed` });
|
|
776
|
+
} else {
|
|
777
|
+
out.unchanged.push({ item: it, task, drift: changes.fields });
|
|
778
|
+
}
|
|
779
|
+
} else if (REWRITABLE.has(task.status)) {
|
|
780
|
+
out.updated.push({ item: it, task, changes });
|
|
781
|
+
} else {
|
|
782
|
+
out.driftOnly.push({ item: it, task, changes, reason: `${task.id} is ${task.status}, so its wording is left alone` });
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
for (const t of existing) {
|
|
787
|
+
if (matched.has(t.id)) continue;
|
|
788
|
+
if (TERMINAL.has(t.status)) continue;
|
|
789
|
+
const tlinks = linksByTask.get(t.id) ?? [];
|
|
790
|
+
const dead = await deadLinks(db, tlinks);
|
|
791
|
+
const orphanedByDelete = tlinks.length > 0 && dead.length === tlinks.length;
|
|
792
|
+
// Only a never-started task whose whole contract has been deleted is
|
|
793
|
+
// superseded. Anything else may be work a person added on purpose.
|
|
794
|
+
if (orphanedByDelete && REWRITABLE.has(t.status) && !t.enabling) {
|
|
795
|
+
out.superseded.push({ task: t, reason: "every contract row this task implemented has been removed from the specification" });
|
|
796
|
+
} else {
|
|
797
|
+
out.orphaned.push({ task: t, reason: "no derived plan item matches this task, so it is left for a person to judge" });
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
return out;
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
// ------------------------------------------------------------------ applying
|
|
804
|
+
|
|
805
|
+
async function categoryMap(db, projectId) {
|
|
806
|
+
const rows = await db.all("SELECT id, name FROM task_categories WHERE project_id = ? AND active = 1", projectId);
|
|
807
|
+
return new Map(rows.map((r) => [r.name, r.id]));
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
async function writeLinks(db, taskId, links) {
|
|
811
|
+
for (const l of links) {
|
|
812
|
+
if (!TARGET_TABLE[l.target_type]) continue;
|
|
813
|
+
await db.run(
|
|
814
|
+
`INSERT OR IGNORE INTO task_contract_links (task_id, target_type, target_id, relationship) VALUES (?,?,?,?)`,
|
|
815
|
+
taskId, l.target_type, l.target_id, l.relationship ?? "implements",
|
|
816
|
+
);
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function createFromItem(db, it, feature, categories, sequence, parentId, actor) {
|
|
821
|
+
const row = await create(db, "task", {
|
|
822
|
+
...columnsFor(it, feature),
|
|
823
|
+
project_id: feature.project_id,
|
|
824
|
+
feature_id: feature.id,
|
|
825
|
+
parent_task_id: parentId ?? null,
|
|
826
|
+
category_id: categories.get(it.category) ?? null,
|
|
827
|
+
sequence,
|
|
828
|
+
}, {
|
|
829
|
+
projectId: feature.project_id,
|
|
830
|
+
actor,
|
|
831
|
+
activityType: "task_created",
|
|
832
|
+
activitySummary: `Derived ${it.name}`,
|
|
833
|
+
});
|
|
834
|
+
await writeLinks(db, row.id, it.links);
|
|
835
|
+
// The creation event already told the story; the move out of draft only needs
|
|
836
|
+
// its history row, so it does not repeat itself in the activity feed.
|
|
837
|
+
return setStatus(db, "task", row.id, "ready", {
|
|
838
|
+
projectId: feature.project_id,
|
|
839
|
+
actor,
|
|
840
|
+
activity: false,
|
|
841
|
+
note: "Derived from the accepted specification.",
|
|
842
|
+
});
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
async function applyDelta(db, delta, actor) {
|
|
846
|
+
if (!delta.accepted) return delta;
|
|
847
|
+
const feature = delta.feature;
|
|
848
|
+
const projectId = feature.project_id;
|
|
849
|
+
const categories = await categoryMap(db, projectId);
|
|
850
|
+
const idByKey = new Map();
|
|
851
|
+
for (const e of [...delta.unchanged, ...delta.updated, ...delta.driftOnly]) idByKey.set(e.item.key, e.task.id);
|
|
852
|
+
|
|
853
|
+
for (const entry of [...delta.created, ...delta.recreated]) {
|
|
854
|
+
const it = entry.item;
|
|
855
|
+
const parentId = it.parentKey ? idByKey.get(it.parentKey) ?? null : null;
|
|
856
|
+
const row = await createFromItem(db, it, feature, categories, it.order ?? 0, parentId, actor);
|
|
857
|
+
idByKey.set(it.key, row.id);
|
|
858
|
+
entry.taskId = row.id;
|
|
859
|
+
if (entry.follows) {
|
|
860
|
+
await recordActivity(db, projectId, {
|
|
861
|
+
type: "task_created",
|
|
862
|
+
actor,
|
|
863
|
+
taskId: row.id,
|
|
864
|
+
featureId: feature.id,
|
|
865
|
+
summary: `${row.id} follows ${entry.follows.id}, which stays ${entry.follows.status} because its contract changed after it closed.`,
|
|
866
|
+
metadata: { follows: entry.follows.id },
|
|
867
|
+
});
|
|
868
|
+
}
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
for (const entry of delta.updated) {
|
|
872
|
+
const { task, item: it, changes } = entry;
|
|
873
|
+
if (changes.addLinks.length) await writeLinks(db, task.id, changes.addLinks);
|
|
874
|
+
if (Object.keys(changes.fields).length) {
|
|
875
|
+
entry.task = await patch(db, "task", task.id, task.version, changes.fields, {
|
|
876
|
+
projectId, actor,
|
|
877
|
+
activityType: "task_updated",
|
|
878
|
+
activitySummary: `Re-derived ${task.id} from the accepted specification`,
|
|
879
|
+
});
|
|
880
|
+
}
|
|
881
|
+
await dropStaleLinks(db, task.id, changes.dropLinks);
|
|
882
|
+
}
|
|
883
|
+
|
|
884
|
+
for (const entry of delta.driftOnly) {
|
|
885
|
+
if (entry.changes.addLinks.length) await writeLinks(db, entry.task.id, entry.changes.addLinks);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
for (const entry of delta.superseded) {
|
|
889
|
+
entry.task = await setStatus(db, "task", entry.task.id, "superseded", {
|
|
890
|
+
projectId, actor, note: entry.reason,
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
|
|
894
|
+
// Dependencies last, when every plan key has a task id.
|
|
895
|
+
for (const entry of [...delta.created, ...delta.recreated, ...delta.updated, ...delta.unchanged, ...delta.driftOnly]) {
|
|
896
|
+
const taskId = entry.taskId ?? entry.task?.id;
|
|
897
|
+
if (!taskId) continue;
|
|
898
|
+
for (const key of entry.item.dependsOn) {
|
|
899
|
+
const dependsOn = idByKey.get(key);
|
|
900
|
+
if (!dependsOn || dependsOn === taskId) continue;
|
|
901
|
+
await db.run(
|
|
902
|
+
`INSERT OR IGNORE INTO task_dependencies (task_id, depends_on_task_id, dependency_type) VALUES (?,?,'blocks')`,
|
|
903
|
+
taskId, dependsOn,
|
|
904
|
+
);
|
|
905
|
+
}
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
await recordActivity(db, projectId, {
|
|
909
|
+
type: "tasks_derived",
|
|
910
|
+
actor,
|
|
911
|
+
featureId: feature.id,
|
|
912
|
+
summary: `Derived tasks for ${feature.id}: ${delta.created.length} new, ${delta.updated.length} updated, ${delta.superseded.length} superseded.`,
|
|
913
|
+
metadata: {
|
|
914
|
+
created: delta.created.length,
|
|
915
|
+
recreated: delta.recreated.length,
|
|
916
|
+
updated: delta.updated.length,
|
|
917
|
+
driftOnly: delta.driftOnly.length,
|
|
918
|
+
unchanged: delta.unchanged.length,
|
|
919
|
+
superseded: delta.superseded.length,
|
|
920
|
+
orphaned: delta.orphaned.length,
|
|
921
|
+
},
|
|
922
|
+
});
|
|
923
|
+
delta.applied = true;
|
|
924
|
+
return delta;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/** Never drop a link if it would leave a live task with no contract at all. */
|
|
928
|
+
async function dropStaleLinks(db, taskId, links) {
|
|
929
|
+
if (!links.length) return;
|
|
930
|
+
const total = await db.value("SELECT count(*) FROM task_contract_links WHERE task_id = ?", taskId);
|
|
931
|
+
if (total - links.length < 1) return;
|
|
932
|
+
for (const l of links) {
|
|
933
|
+
await db.run(
|
|
934
|
+
"DELETE FROM task_contract_links WHERE task_id = ? AND target_type = ? AND target_id = ?",
|
|
935
|
+
taskId, l.target_type, l.target_id,
|
|
936
|
+
);
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
// -------------------------------------------------------------------- public
|
|
941
|
+
|
|
942
|
+
/**
|
|
943
|
+
* Derive the task set for one feature. With `apply` false this is the plan
|
|
944
|
+
* presented for acceptance and nothing is written; with `apply` true the plan is
|
|
945
|
+
* the acceptance and the delta is committed in one transaction.
|
|
946
|
+
*/
|
|
947
|
+
export async function deriveTasks(root, featureId, { apply = false, actor = "superdev" } = {}) {
|
|
948
|
+
if (!apply) return query(root, (db) => diff(db, featureId));
|
|
949
|
+
return mutate(root, async (db) => applyDelta(db, await diff(db, featureId), actor));
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
/** The same, across every accepted feature, in one transaction when applying. */
|
|
953
|
+
export async function deriveAll(root, { apply = false, actor = "superdev" } = {}) {
|
|
954
|
+
const run = async (db) => {
|
|
955
|
+
const features = await db.all("SELECT id FROM features WHERE status = 'accepted' ORDER BY id");
|
|
956
|
+
const results = [];
|
|
957
|
+
for (const f of features) {
|
|
958
|
+
const delta = await diff(db, f.id);
|
|
959
|
+
results.push(apply ? await applyDelta(db, delta, actor) : delta);
|
|
960
|
+
}
|
|
961
|
+
return {
|
|
962
|
+
features: features.length,
|
|
963
|
+
created: results.reduce((n, r) => n + r.created.length, 0),
|
|
964
|
+
updated: results.reduce((n, r) => n + r.updated.length, 0),
|
|
965
|
+
superseded: results.reduce((n, r) => n + r.superseded.length, 0),
|
|
966
|
+
applied: apply,
|
|
967
|
+
results,
|
|
968
|
+
};
|
|
969
|
+
};
|
|
970
|
+
return apply ? mutate(root, run) : query(root, run);
|
|
971
|
+
}
|
|
972
|
+
|
|
973
|
+
/** What re-derivation would change. Read only, so it is safe to show anywhere. */
|
|
974
|
+
export function derivationDelta(root, featureId) {
|
|
975
|
+
if (!featureId) return deriveAll(root, { apply: false });
|
|
976
|
+
return query(root, (db) => diff(db, featureId));
|
|
977
|
+
}
|