@printpractical/cruze 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +167 -0
  3. package/bin/cruze.mjs +17 -0
  4. package/dist/adapters/inbound/cli/cli_context.js +75 -0
  5. package/dist/adapters/inbound/cli/commands/code.js +30 -0
  6. package/dist/adapters/inbound/cli/commands/documents.js +52 -0
  7. package/dist/adapters/inbound/cli/commands/init.js +23 -0
  8. package/dist/adapters/inbound/cli/commands/install.js +15 -0
  9. package/dist/adapters/inbound/cli/commands/journal.js +31 -0
  10. package/dist/adapters/inbound/cli/commands/progress.js +51 -0
  11. package/dist/adapters/inbound/cli/commands/review.js +18 -0
  12. package/dist/adapters/inbound/cli/commands/status.js +45 -0
  13. package/dist/adapters/inbound/cli/commands.js +36 -0
  14. package/dist/adapters/inbound/cli/parse_command.js +90 -0
  15. package/dist/adapters/inbound/cli/run_cli.js +50 -0
  16. package/dist/adapters/outbound/git_repository.js +57 -0
  17. package/dist/adapters/outbound/node_project_files.js +101 -0
  18. package/dist/adapters/outbound/package_bundle.js +56 -0
  19. package/dist/adapters/outbound/process_agent_runner.js +26 -0
  20. package/dist/adapters/outbound/system_clock.js +14 -0
  21. package/dist/adapters/outbound/terminal_prompter.js +22 -0
  22. package/dist/app/ports/agent_runner.js +11 -0
  23. package/dist/app/ports/bundle.js +22 -0
  24. package/dist/app/ports/clock.js +6 -0
  25. package/dist/app/ports/project_files.js +20 -0
  26. package/dist/app/ports/prompter.js +5 -0
  27. package/dist/app/ports/repository.js +13 -0
  28. package/dist/app/project_context.js +45 -0
  29. package/dist/app/use_cases/abandon_work.js +42 -0
  30. package/dist/app/use_cases/approve_artifact.js +146 -0
  31. package/dist/app/use_cases/check_code.js +90 -0
  32. package/dist/app/use_cases/init_project.js +59 -0
  33. package/dist/app/use_cases/install_skills.js +93 -0
  34. package/dist/app/use_cases/journal_events.js +48 -0
  35. package/dist/app/use_cases/land_change.js +113 -0
  36. package/dist/app/use_cases/new_project_doc.js +40 -0
  37. package/dist/app/use_cases/new_work_item.js +75 -0
  38. package/dist/app/use_cases/project_status.js +73 -0
  39. package/dist/app/use_cases/record_progress.js +101 -0
  40. package/dist/app/use_cases/run_review.js +71 -0
  41. package/dist/app/use_cases/validate_project.js +22 -0
  42. package/dist/domain/agent_skill_homes.js +25 -0
  43. package/dist/domain/approvals/evaluate.js +36 -0
  44. package/dist/domain/approvals/fingerprint.js +52 -0
  45. package/dist/domain/approvals/records.js +50 -0
  46. package/dist/domain/architecture_sections.js +23 -0
  47. package/dist/domain/check/check_source.js +64 -0
  48. package/dist/domain/check/imports.js +81 -0
  49. package/dist/domain/check/rust_imports.js +84 -0
  50. package/dist/domain/check/type_count.js +17 -0
  51. package/dist/domain/config.js +128 -0
  52. package/dist/domain/cruze_error.js +10 -0
  53. package/dist/domain/edits/agents_file.js +44 -0
  54. package/dist/domain/edits/managed_tables.js +60 -0
  55. package/dist/domain/edits/progress_block.js +33 -0
  56. package/dist/domain/edits/status_lines.js +52 -0
  57. package/dist/domain/elements.js +126 -0
  58. package/dist/domain/glob.js +46 -0
  59. package/dist/domain/hashing.js +52 -0
  60. package/dist/domain/ids.js +37 -0
  61. package/dist/domain/journal.js +62 -0
  62. package/dist/domain/land/bookkeeping.js +54 -0
  63. package/dist/domain/land/merge_plan.js +132 -0
  64. package/dist/domain/land/text_ops.js +66 -0
  65. package/dist/domain/markdown.js +79 -0
  66. package/dist/domain/markdown_tables.js +34 -0
  67. package/dist/domain/project/artifact_ref.js +42 -0
  68. package/dist/domain/project/deltas.js +49 -0
  69. package/dist/domain/project/layout.js +40 -0
  70. package/dist/domain/project/living_model.js +54 -0
  71. package/dist/domain/project/new_items.js +48 -0
  72. package/dist/domain/project/plan_parts.js +111 -0
  73. package/dist/domain/project/progress.js +43 -0
  74. package/dist/domain/project/project_view.js +72 -0
  75. package/dist/domain/project/roadmap.js +43 -0
  76. package/dist/domain/project/work_items.js +68 -0
  77. package/dist/domain/project_name.js +20 -0
  78. package/dist/domain/scaffold.js +29 -0
  79. package/dist/domain/skill.js +106 -0
  80. package/dist/domain/skill_links.js +103 -0
  81. package/dist/domain/status/next_step.js +119 -0
  82. package/dist/domain/status/verification.js +16 -0
  83. package/dist/domain/status/work_status.js +101 -0
  84. package/dist/domain/trace.js +44 -0
  85. package/dist/domain/validation/delta_rules.js +79 -0
  86. package/dist/domain/validation/element_rules.js +99 -0
  87. package/dist/domain/validation/living_rules.js +97 -0
  88. package/dist/domain/validation/plan_rules.js +105 -0
  89. package/dist/domain/validation/problem.js +21 -0
  90. package/dist/domain/validation/project_rules.js +99 -0
  91. package/dist/domain/validation/validate_project.js +31 -0
  92. package/dist/domain/validation/work_item_rules.js +144 -0
  93. package/dist/main.js +32 -0
  94. package/package.json +40 -0
  95. package/scripts/build.mjs +37 -0
  96. package/skills/about/SKILL.md +21 -0
  97. package/skills/architect/SKILL.md +43 -0
  98. package/skills/architect/feature.md +29 -0
  99. package/skills/architect/project.md +58 -0
  100. package/skills/architect/tweak.md +22 -0
  101. package/skills/architect/walkthrough.md +29 -0
  102. package/skills/behavioural-testing/SKILL.md +63 -0
  103. package/skills/behavioural-testing/examples.md +102 -0
  104. package/skills/behavioural-testing/fakes.md +30 -0
  105. package/skills/build/SKILL.md +62 -0
  106. package/skills/dependency-approval/SKILL.md +48 -0
  107. package/skills/domain-language/SKILL.md +37 -0
  108. package/skills/envision/SKILL.md +29 -0
  109. package/skills/envision/feature.md +22 -0
  110. package/skills/envision/project.md +20 -0
  111. package/skills/explore/SKILL.md +39 -0
  112. package/skills/formats/SKILL.md +84 -0
  113. package/skills/formats/reference/architecture.md +80 -0
  114. package/skills/formats/reference/config.md +57 -0
  115. package/skills/formats/reference/durable-docs.md +71 -0
  116. package/skills/formats/reference/specs.md +65 -0
  117. package/skills/formats/reference/work-items.md +100 -0
  118. package/skills/formats/templates/adr.md +7 -0
  119. package/skills/formats/templates/architecture.md +100 -0
  120. package/skills/formats/templates/change.md +29 -0
  121. package/skills/formats/templates/feature.md +49 -0
  122. package/skills/formats/templates/glossary.md +8 -0
  123. package/skills/formats/templates/roadmap.md +32 -0
  124. package/skills/formats/templates/spec.md +11 -0
  125. package/skills/formats/templates/standalone-change.md +46 -0
  126. package/skills/formats/templates/vision.md +37 -0
  127. package/skills/grilling/SKILL.md +66 -0
  128. package/skills/hexagonal-design/SKILL.md +119 -0
  129. package/skills/hexagonal-design/contracts.md +68 -0
  130. package/skills/hexagonal-design/fundamentals.md +50 -0
  131. package/skills/hexagonal-design/languages/cpp.md +37 -0
  132. package/skills/hexagonal-design/languages/rust.md +40 -0
  133. package/skills/hexagonal-design/runtime.md +48 -0
  134. package/skills/land/SKILL.md +43 -0
  135. package/skills/next/SKILL.md +30 -0
  136. package/skills/plan/SKILL.md +52 -0
  137. package/skills/plan/tasks.md +26 -0
  138. package/skills/plan/test-plan.md +14 -0
  139. package/skills/research/SKILL.md +41 -0
  140. package/skills/research/evaluation.md +27 -0
  141. package/skills/rethink/SKILL.md +53 -0
  142. package/skills/rethink/levels.md +45 -0
  143. package/skills/roadmap/SKILL.md +51 -0
  144. package/skills/roles/SKILL.md +57 -0
  145. package/skills/roles/code-reviewer.md +55 -0
  146. package/skills/roles/design-reviewer.md +63 -0
  147. package/skills/roles/researcher.md +33 -0
  148. package/skills/roles/verifier.md +38 -0
  149. package/skills/triage/SKILL.md +35 -0
  150. package/skills/verify/SKILL.md +39 -0
  151. package/skills/verify/manual-test.md +23 -0
  152. package/src/adapters/inbound/cli/cli_context.ts +75 -0
  153. package/src/adapters/inbound/cli/commands/code.ts +30 -0
  154. package/src/adapters/inbound/cli/commands/documents.ts +52 -0
  155. package/src/adapters/inbound/cli/commands/init.ts +23 -0
  156. package/src/adapters/inbound/cli/commands/install.ts +15 -0
  157. package/src/adapters/inbound/cli/commands/journal.ts +31 -0
  158. package/src/adapters/inbound/cli/commands/progress.ts +51 -0
  159. package/src/adapters/inbound/cli/commands/review.ts +18 -0
  160. package/src/adapters/inbound/cli/commands/status.ts +45 -0
  161. package/src/adapters/inbound/cli/commands.ts +36 -0
  162. package/src/adapters/inbound/cli/parse_command.ts +90 -0
  163. package/src/adapters/inbound/cli/run_cli.ts +50 -0
  164. package/src/adapters/outbound/git_repository.ts +57 -0
  165. package/src/adapters/outbound/node_project_files.ts +101 -0
  166. package/src/adapters/outbound/package_bundle.ts +56 -0
  167. package/src/adapters/outbound/process_agent_runner.ts +26 -0
  168. package/src/adapters/outbound/system_clock.ts +14 -0
  169. package/src/adapters/outbound/terminal_prompter.ts +22 -0
  170. package/src/app/ports/agent_runner.ts +11 -0
  171. package/src/app/ports/bundle.ts +22 -0
  172. package/src/app/ports/clock.ts +6 -0
  173. package/src/app/ports/project_files.ts +20 -0
  174. package/src/app/ports/prompter.ts +5 -0
  175. package/src/app/ports/repository.ts +13 -0
  176. package/src/app/project_context.ts +45 -0
  177. package/src/app/use_cases/abandon_work.ts +42 -0
  178. package/src/app/use_cases/approve_artifact.ts +146 -0
  179. package/src/app/use_cases/check_code.ts +90 -0
  180. package/src/app/use_cases/init_project.ts +59 -0
  181. package/src/app/use_cases/install_skills.ts +93 -0
  182. package/src/app/use_cases/journal_events.ts +48 -0
  183. package/src/app/use_cases/land_change.ts +113 -0
  184. package/src/app/use_cases/new_project_doc.ts +40 -0
  185. package/src/app/use_cases/new_work_item.ts +75 -0
  186. package/src/app/use_cases/project_status.ts +73 -0
  187. package/src/app/use_cases/record_progress.ts +101 -0
  188. package/src/app/use_cases/run_review.ts +71 -0
  189. package/src/app/use_cases/validate_project.ts +22 -0
  190. package/src/domain/agent_skill_homes.ts +25 -0
  191. package/src/domain/approvals/evaluate.ts +36 -0
  192. package/src/domain/approvals/fingerprint.ts +52 -0
  193. package/src/domain/approvals/records.ts +50 -0
  194. package/src/domain/architecture_sections.ts +23 -0
  195. package/src/domain/check/check_source.ts +64 -0
  196. package/src/domain/check/imports.ts +81 -0
  197. package/src/domain/check/rust_imports.ts +84 -0
  198. package/src/domain/check/type_count.ts +17 -0
  199. package/src/domain/config.ts +128 -0
  200. package/src/domain/cruze_error.ts +10 -0
  201. package/src/domain/edits/agents_file.ts +44 -0
  202. package/src/domain/edits/managed_tables.ts +60 -0
  203. package/src/domain/edits/progress_block.ts +33 -0
  204. package/src/domain/edits/status_lines.ts +52 -0
  205. package/src/domain/elements.ts +126 -0
  206. package/src/domain/glob.ts +46 -0
  207. package/src/domain/hashing.ts +52 -0
  208. package/src/domain/ids.ts +37 -0
  209. package/src/domain/journal.ts +62 -0
  210. package/src/domain/land/bookkeeping.ts +54 -0
  211. package/src/domain/land/merge_plan.ts +132 -0
  212. package/src/domain/land/text_ops.ts +66 -0
  213. package/src/domain/markdown.ts +79 -0
  214. package/src/domain/markdown_tables.ts +34 -0
  215. package/src/domain/project/artifact_ref.ts +42 -0
  216. package/src/domain/project/deltas.ts +49 -0
  217. package/src/domain/project/layout.ts +40 -0
  218. package/src/domain/project/living_model.ts +54 -0
  219. package/src/domain/project/new_items.ts +48 -0
  220. package/src/domain/project/plan_parts.ts +111 -0
  221. package/src/domain/project/progress.ts +43 -0
  222. package/src/domain/project/project_view.ts +72 -0
  223. package/src/domain/project/roadmap.ts +43 -0
  224. package/src/domain/project/work_items.ts +68 -0
  225. package/src/domain/project_name.ts +20 -0
  226. package/src/domain/scaffold.ts +29 -0
  227. package/src/domain/skill.ts +106 -0
  228. package/src/domain/skill_links.ts +103 -0
  229. package/src/domain/status/next_step.ts +119 -0
  230. package/src/domain/status/verification.ts +16 -0
  231. package/src/domain/status/work_status.ts +101 -0
  232. package/src/domain/trace.ts +44 -0
  233. package/src/domain/validation/delta_rules.ts +79 -0
  234. package/src/domain/validation/element_rules.ts +99 -0
  235. package/src/domain/validation/living_rules.ts +97 -0
  236. package/src/domain/validation/plan_rules.ts +105 -0
  237. package/src/domain/validation/problem.ts +21 -0
  238. package/src/domain/validation/project_rules.ts +99 -0
  239. package/src/domain/validation/validate_project.ts +31 -0
  240. package/src/domain/validation/work_item_rules.ts +144 -0
  241. package/src/main.ts +32 -0
  242. package/templates/init/AGENTS.md +28 -0
  243. package/templates/init/CHANGELOG.md +8 -0
  244. package/templates/init/CLAUDE.md +1 -0
  245. package/templates/init/README.md +1 -0
  246. package/templates/init/ci.yml +23 -0
  247. package/templates/init/config.yaml +33 -0
  248. package/templates/init/gitattributes +2 -0
@@ -0,0 +1,119 @@
1
+ import { evaluateApproval } from "../approvals/evaluate.ts";
2
+ import { PATHS } from "../project/layout.ts";
3
+ import { isPlanned, readChangesTable } from "../project/plan_parts.ts";
4
+ import { readProgress } from "../project/progress.ts";
5
+ import type { ProjectView } from "../project/project_view.ts";
6
+ import { readRoadmapItems, readRoadmapStatus } from "../project/roadmap.ts";
7
+ import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
8
+ import { isVerified } from "./verification.ts";
9
+ import { activeChange } from "./work_status.ts";
10
+
11
+ /** The Cruze step to run next, computed from the project's state and the current branch. */
12
+
13
+ export type Step = "envision" | "architect" | "roadmap" | "plan" | "build" | "verify" | "land" | "rethink" | "switch-branch";
14
+
15
+ export interface NextStep {
16
+ step: Step;
17
+ /** What the step works on: a feature or change ref, a roadmap slug, or a branch. */
18
+ target?: string;
19
+ reason: string;
20
+ }
21
+
22
+ export interface NextReport {
23
+ next: NextStep;
24
+ /** Other work that could proceed, such as changes waiting on their own branches. */
25
+ also: NextStep[];
26
+ }
27
+
28
+ export function nextSteps(view: ProjectView, branch: string | null): NextReport {
29
+ const project = projectStep(view);
30
+ if (project !== null) return { next: project, also: [] };
31
+ const active = activeChange(view, branch);
32
+ const inFlight = view.items.filter((item) => !item.archived && item.kind !== "change").flatMap((item) => itemSteps(view, item, branch));
33
+ const roadmap = roadmapStep(view);
34
+ const candidates = [...(active === undefined ? [] : [changeStep(view, active, branch)]), ...inFlight, ...(roadmap === null ? [] : [roadmap])];
35
+ const unique = candidates.filter((c, i) => candidates.findIndex((o) => o.step === c.step && o.target === c.target) === i);
36
+ const [next, ...also] = unique;
37
+ return { next: next ?? { step: "roadmap", reason: "every item on the roadmap has landed; close the release and plan the next" }, also };
38
+ }
39
+
40
+ /** The project documents come first: nothing else can proceed without them. */
41
+ function projectStep(view: ProjectView): NextStep | null {
42
+ const docs: Array<{ path: string; missing: NextStep; unapproved: NextStep; changed: NextStep }> = [
43
+ {
44
+ path: PATHS.vision,
45
+ missing: { step: "envision", reason: "the project has no vision yet" },
46
+ unapproved: { step: "envision", reason: "finish the vision and approve it" },
47
+ changed: { step: "envision", reason: "the vision changed since its approval; review the change and approve it again" },
48
+ },
49
+ {
50
+ path: PATHS.architecture,
51
+ missing: { step: "architect", reason: "the project has no architecture yet" },
52
+ unapproved: { step: "architect", reason: "finish the architecture, walk through it and approve it" },
53
+ changed: { step: "rethink", target: "architecture", reason: "the architecture changed since its approval" },
54
+ },
55
+ {
56
+ path: PATHS.roadmap,
57
+ missing: { step: "architect", reason: "the project has no roadmap yet" },
58
+ unapproved: { step: "roadmap", reason: "approve the release ordering" },
59
+ changed: { step: "roadmap", reason: "the roadmap changed since its approval; approve the new ordering" },
60
+ },
61
+ ];
62
+ for (const doc of docs) {
63
+ if (!view.snapshot.has(doc.path)) return doc.missing;
64
+ const state = evaluateApproval(view, doc.path).state;
65
+ if (state === "unapproved") return doc.unapproved;
66
+ if (state !== "approved") return doc.changed;
67
+ }
68
+ return null;
69
+ }
70
+
71
+ function itemSteps(view: ProjectView, item: WorkItem, branch: string | null): NextStep[] {
72
+ if (item.kind === "standalone") return [changeStep(view, item, branch)];
73
+ const state = evaluateApproval(view, item.path);
74
+ if (readChangesTable(item.doc).length === 0) return [{ step: "architect", target: item.ref, reason: "the feature has no design or changes yet" }];
75
+ if (state.state === "unapproved") return [{ step: "architect", target: item.ref, reason: "review the feature's design, walk through it and approve it" }];
76
+ if (state.state !== "approved") return [{ step: "rethink", target: item.ref, reason: `the feature changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` }];
77
+ const changes = changesOf(view.items, item);
78
+ const landed = new Set(changes.filter((c) => readProgress(c.doc).landed !== undefined).map((c) => c.folderName));
79
+ const steps: NextStep[] = [];
80
+ for (const row of readChangesTable(item.doc)) {
81
+ const change = changes.find((c) => c.folderName === row.change);
82
+ if (change !== undefined) {
83
+ if (!landed.has(row.change)) steps.push(changeStep(view, change, branch));
84
+ } else if (row.dependsOn.every((d) => landed.has(d))) {
85
+ steps.push({ step: "plan", target: `${item.ref}/${row.change}`, reason: "the next change of an approved feature, with its dependencies landed" });
86
+ }
87
+ }
88
+ return steps;
89
+ }
90
+
91
+ function changeStep(view: ProjectView, change: WorkItem, branch: string | null): NextStep {
92
+ const feature = featureOf(view.items, change);
93
+ const featureState = feature === undefined ? undefined : evaluateApproval(view, feature.path);
94
+ if (feature !== undefined && featureState !== undefined && featureState.state !== "approved") {
95
+ const why = featureState.changed.length > 0 ? ` (${featureState.changed.join(", ")})` : "";
96
+ return { step: "rethink", target: feature.ref, reason: `the feature of ${change.ref} changed since its approval${why}` };
97
+ }
98
+ const bound = readProgress(change.doc).branch;
99
+ const state = evaluateApproval(view, change.path);
100
+ if (!isPlanned(change.doc)) return { step: "plan", target: change.ref, reason: "the change is designed but has no test plan or tasks yet" };
101
+ if (state.state === "unapproved") return { step: "plan", target: change.ref, reason: "review the plan and approve it on the branch that will build it" };
102
+ if (state.state !== "approved") return { step: "rethink", target: change.ref, reason: `the change or something it cites changed since its approval${state.changed.length > 0 ? ` (${state.changed.join(", ")})` : ""}` };
103
+ if (bound !== undefined && bound !== branch) return { step: "switch-branch", target: bound, reason: `${change.ref} is built on branch ${bound}` };
104
+ const progress = readProgress(change.doc);
105
+ if ([...progress.tasks.values()].some((commit) => commit === null)) return { step: "build", target: change.ref, reason: "the change has open tasks" };
106
+ if (!isVerified(view.journal, change)) return { step: "verify", target: change.ref, reason: "every task is done; verify the change" };
107
+ return { step: "land", target: change.ref, reason: "the change is verified and accepted" };
108
+ }
109
+
110
+ /** The first roadmap item that hasn't started and whose blockers have all landed. */
111
+ function roadmapStep(view: ProjectView): NextStep | null {
112
+ const text = view.snapshot.get(PATHS.roadmap);
113
+ if (text === undefined) return null;
114
+ const status = new Map(readRoadmapStatus(text).map((row) => [row.slug, row.state]));
115
+ const item = readRoadmapItems(text).find((i) => !status.has(i.slug) && i.blockedBy.every((b) => status.get(b) === "landed"));
116
+ if (item === undefined) return null;
117
+ if (item.kind === "feature") return { step: "envision", target: item.slug, reason: "the next roadmap feature; skip to architect when its requirements are settled" };
118
+ return { step: "architect", target: item.slug, reason: "the next roadmap change; design it as a tweak" };
119
+ }
@@ -0,0 +1,16 @@
1
+ import type { JournalEntry } from "../journal.ts";
2
+ import type { WorkItem } from "../project/work_items.ts";
3
+
4
+ /**
5
+ * Whether the user accepted a change at verify. The latest verification recorded for the change
6
+ * counts. A later approval keeps it only when the change's own design is unchanged, as when it is
7
+ * re-approved because something upstream moved; a changed plan means verifying again.
8
+ */
9
+ export function isVerified(journal: JournalEntry[], change: WorkItem): boolean {
10
+ const byTime = (a: JournalEntry, b: JournalEntry): number => a.at.localeCompare(b.at);
11
+ const verification = journal.filter((e) => e.event === "verification" && e["item"] === change.ref).sort(byTime).at(-1);
12
+ if (verification?.["result"] !== "accepted") return false;
13
+ const approvals = journal.filter((e) => e.event === "approve" && e["artifact"] === change.path && e["basis"] === undefined).sort(byTime);
14
+ const verifiedHash = approvals.filter((e) => e.at < verification.at).at(-1)?.["hash"];
15
+ return approvals.filter((e) => e.at > verification.at).every((e) => e["hash"] === verifiedHash);
16
+ }
@@ -0,0 +1,101 @@
1
+ import { evaluateApproval, type ApprovalStatus } from "../approvals/evaluate.ts";
2
+ import { findIds } from "../ids.ts";
3
+ import { PATHS } from "../project/layout.ts";
4
+ import { readDelta } from "../project/deltas.ts";
5
+ import { readScope } from "../project/plan_parts.ts";
6
+ import { readProgress } from "../project/progress.ts";
7
+ import type { ProjectView } from "../project/project_view.ts";
8
+ import { changesOf, featureOf, type WorkItem } from "../project/work_items.ts";
9
+
10
+ export interface ChangeStatus {
11
+ ref: string;
12
+ path: string;
13
+ approval: ApprovalStatus;
14
+ branch?: string;
15
+ tasksDone: number;
16
+ tasksTotal: number;
17
+ /** When and in which commit the change landed, once it has. */
18
+ landed?: string;
19
+ }
20
+
21
+ export interface FeatureStatus {
22
+ ref: string;
23
+ path: string;
24
+ approval: ApprovalStatus;
25
+ changes: ChangeStatus[];
26
+ }
27
+
28
+ export interface ProjectStatus {
29
+ documents: ApprovalStatus[];
30
+ features: FeatureStatus[];
31
+ standalone: ChangeStatus[];
32
+ /** The change bound to the current branch, if any. */
33
+ active?: string;
34
+ }
35
+
36
+ export function projectStatus(view: ProjectView, branch: string | null): ProjectStatus {
37
+ const documents = [PATHS.vision, PATHS.architecture, PATHS.roadmap].filter((p) => view.snapshot.has(p)).map((p) => evaluateApproval(view, p));
38
+ const active = view.items.filter((item) => !item.archived);
39
+ const features = active
40
+ .filter((item) => item.kind === "feature")
41
+ .map((feature) => ({
42
+ ref: feature.ref,
43
+ path: feature.path,
44
+ approval: evaluateApproval(view, feature.path),
45
+ changes: changesOf(view.items, feature).map((change) => changeStatus(view, change)),
46
+ }));
47
+ const standalone = active.filter((item) => item.kind === "standalone").map((change) => changeStatus(view, change));
48
+ const status: ProjectStatus = { documents, features, standalone };
49
+ const bound = activeChange(view, branch);
50
+ if (bound !== undefined) status.active = bound.ref;
51
+ return status;
52
+ }
53
+
54
+ export function activeChange(view: ProjectView, branch: string | null): WorkItem | undefined {
55
+ if (branch === null) return undefined;
56
+ return view.items.find((item) => {
57
+ const progress = readProgress(item.doc);
58
+ return !item.archived && item.kind !== "feature" && progress.landed === undefined && progress.branch === branch;
59
+ });
60
+ }
61
+
62
+ function changeStatus(view: ProjectView, change: WorkItem): ChangeStatus {
63
+ const progress = readProgress(change.doc);
64
+ const status: ChangeStatus = {
65
+ ref: change.ref,
66
+ path: change.path,
67
+ approval: evaluateApproval(view, change.path),
68
+ tasksDone: [...progress.tasks.values()].filter((commit) => commit !== null).length,
69
+ tasksTotal: progress.tasks.size,
70
+ };
71
+ if (progress.branch !== undefined) status.branch = progress.branch;
72
+ if (progress.landed !== undefined) status.landed = progress.landed;
73
+ return status;
74
+ }
75
+
76
+ /** Why building may not start on this branch; empty when the build gate passes. */
77
+ export function buildGateReasons(view: ProjectView, branch: string | null): string[] {
78
+ const change = activeChange(view, branch);
79
+ if (change === undefined) return [`no change is bound to branch ${branch ?? "(none)"}; approve a planned change on this branch first`];
80
+ const required = [change.path];
81
+ const feature = featureOf(view.items, change);
82
+ if (feature !== undefined) required.push(feature.path);
83
+ if (view.snapshot.has(PATHS.architecture)) required.push(PATHS.architecture);
84
+ return required.flatMap((path) => {
85
+ const status = evaluateApproval(view, path);
86
+ if (status.state === "approved") return [];
87
+ const detail = status.changed.length > 0 ? `: ${status.changed.join(", ")} changed` : status.unjournaled === true ? ": the approval has no journal entry" : "";
88
+ return [`${path} is ${status.state}${detail}`];
89
+ });
90
+ }
91
+
92
+ /** The elements a change touches: its scope plus every ID its documents cite. */
93
+ export function touchedIds(view: ProjectView, change: WorkItem): Set<string> {
94
+ const feature = featureOf(view.items, change);
95
+ const scope = readScope(change.doc);
96
+ const ids = new Set([...(scope?.delivers ?? []), ...(scope?.builds ?? []), ...(scope?.removes ?? [])]);
97
+ const deltaDoc = feature?.doc ?? change.doc;
98
+ for (const id of readDelta(deltaDoc).ids) ids.add(id);
99
+ for (const id of findIds(change.doc.lines.join("\n"))) ids.add(id);
100
+ return new Set([...ids].filter((id) => !id.startsWith("RULE-")));
101
+ }
@@ -0,0 +1,44 @@
1
+ import { headingElements, statusOf } from "./elements.ts";
2
+ import { findIds } from "./ids.ts";
3
+ import { PATHS } from "./project/layout.ts";
4
+ import type { ProjectView } from "./project/project_view.ts";
5
+ import { readScope } from "./project/plan_parts.ts";
6
+ import { readDelta } from "./project/deltas.ts";
7
+ import type { WorkItem } from "./project/work_items.ts";
8
+
9
+ export interface TraceReport {
10
+ /** Scenarios that must have a test, and the test files that carry each one. */
11
+ scenarios: Array<{ id: string; tests: string[] }>;
12
+ missing: string[];
13
+ /** Scenario IDs in tests that no living doc or active delta defines. */
14
+ unknown: Array<{ id: string; path: string; line: number }>;
15
+ }
16
+
17
+ /**
18
+ * Links scenarios to the tests that prove them. For a change: every scenario it delivers.
19
+ * For the whole project: every built scenario in the living specs.
20
+ */
21
+ export function traceScenarios(view: ProjectView, tests: ReadonlyMap<string, string>, change?: WorkItem): TraceReport {
22
+ const required = change === undefined ? builtScenarios(view) : (readScope(change.doc)?.delivers ?? []).filter((id) => id.startsWith("SCN-"));
23
+ const known = new Set([...view.living.elements.keys()]);
24
+ for (const item of view.items.filter((i) => !i.archived)) for (const id of readDelta(item.doc).ids) known.add(id);
25
+
26
+ const carriers = new Map<string, string[]>();
27
+ const unknown: TraceReport["unknown"] = [];
28
+ for (const [path, text] of tests) {
29
+ text.split("\n").forEach((line, index) => {
30
+ for (const id of findIds(line).filter((id) => id.startsWith("SCN-"))) {
31
+ carriers.set(id, [...new Set([...(carriers.get(id) ?? []), path])]);
32
+ if (!known.has(id)) unknown.push({ id, path, line: index + 1 });
33
+ }
34
+ });
35
+ }
36
+ const scenarios = required.map((id) => ({ id, tests: carriers.get(id) ?? [] }));
37
+ return { scenarios, missing: scenarios.filter((s) => s.tests.length === 0).map((s) => s.id), unknown };
38
+ }
39
+
40
+ function builtScenarios(view: ProjectView): string[] {
41
+ return [...view.living.docs]
42
+ .filter(([path]) => path.startsWith(`${PATHS.specsDir}/`))
43
+ .flatMap(([, doc]) => headingElements(doc).filter((e) => e.kind === "SCN" && statusOf(doc, e) === "built").map((e) => e.id));
44
+ }
@@ -0,0 +1,79 @@
1
+ import { ARCHITECTURE_KINDS } from "../architecture_sections.ts";
2
+ import { scopeOf } from "../ids.ts";
3
+ import { specPath } from "../project/layout.ts";
4
+ import { orphanDeltaElements, readDelta, type Delta } from "../project/deltas.ts";
5
+ import type { ProjectView } from "../project/project_view.ts";
6
+ import type { WorkItem } from "../project/work_items.ts";
7
+ import { elementProblems } from "./element_rules.ts";
8
+ import { error, type Problem } from "./problem.ts";
9
+
10
+ /** Merge records use this key for a spec file a delta created. */
11
+ export function capabilityKey(capability: string): string {
12
+ return `CAPABILITY-${capability}`;
13
+ }
14
+
15
+ /** Marks an element a landed change removed. */
16
+ export const REMOVED_MARK = "removed";
17
+
18
+ /** Rules for the spec and architecture deltas of a feature or standalone change. */
19
+ export function deltaProblems(view: ProjectView, item: WorkItem): Problem[] {
20
+ const { path, doc } = item;
21
+ const delta = readDelta(doc);
22
+ const merged = view.approvals.get(item.folder)?.merged ?? {};
23
+ const problems: Problem[] = [];
24
+
25
+ for (const orphan of orphanDeltaElements(doc)) {
26
+ problems.push(error(path, orphan.start, "delta-shape", `${orphan.id} must sit under an ADDED, MODIFIED or REMOVED requirement`));
27
+ }
28
+ for (const { element, section, scenarios } of delta.entries) {
29
+ const allowed = section === "spec" ? element.kind === "REQ" : ARCHITECTURE_KINDS.includes(element.kind);
30
+ if (!allowed) problems.push(error(path, element.start, "delta-shape", `${element.id} does not belong in the ${section} delta`));
31
+ problems.push(...elementProblems(path, doc, element));
32
+ for (const scenario of scenarios) {
33
+ problems.push(...elementProblems(path, doc, scenario));
34
+ if (scopeOf(scenario.id) !== scopeOf(element.id)) {
35
+ problems.push(error(path, scenario.start, "id-scope", `${scenario.id} must share its requirement's scope`));
36
+ }
37
+ problems.push(...opProblems(view, path, scenario.id, "ADDED", scenario.start, merged, element.op === "ADDED"));
38
+ }
39
+ problems.push(...opProblems(view, path, element.id, element.op ?? "ADDED", element.start, merged, false));
40
+ if (element.kind === "REQ" && element.op === "ADDED") problems.push(...capabilityProblems(view, item, delta, element.id, element.start));
41
+ }
42
+ for (const op of delta.capabilities) {
43
+ if (view.snapshot.has(specPath(op.capability)) && merged[capabilityKey(op.capability)] === undefined) {
44
+ problems.push(error(path, op.start, "capability-exists", `capability ${op.capability} already has ${specPath(op.capability)}`));
45
+ }
46
+ }
47
+ return problems;
48
+ }
49
+
50
+ function opProblems(
51
+ view: ProjectView,
52
+ path: string,
53
+ id: string,
54
+ op: "ADDED" | "MODIFIED" | "REMOVED",
55
+ line: number,
56
+ merged: Record<string, string>,
57
+ underAddedRequirement: boolean,
58
+ ): Problem[] {
59
+ const exists = view.living.elements.has(id);
60
+ const mergedHere = merged[id] !== undefined;
61
+ if (view.retired.has(id)) return [error(path, line, "retired-id", `${id} was removed earlier and can never be reused`)];
62
+ if (op === "ADDED" && exists && !mergedHere) {
63
+ // A scenario under a MODIFIED requirement may already exist; only a new requirement's scenarios must be new.
64
+ if (!id.startsWith("SCN-") || underAddedRequirement) return [error(path, line, "delta-op", `ADDED ${id} already exists in the living docs`)];
65
+ }
66
+ if ((op === "MODIFIED" || op === "REMOVED") && !exists && merged[id] !== REMOVED_MARK) {
67
+ return [error(path, line, "delta-op", `${op} ${id} does not exist in the living docs`)];
68
+ }
69
+ return [];
70
+ }
71
+
72
+ function capabilityProblems(view: ProjectView, item: WorkItem, delta: Delta, id: string, line: number): Problem[] {
73
+ const capability = scopeOf(id);
74
+ if (capability === undefined) return [];
75
+ const hasFile = view.snapshot.has(specPath(capability));
76
+ const addsFile = delta.capabilities.some((op) => op.capability === capability);
77
+ if (hasFile || addsFile) return [];
78
+ return [error(item.path, line, "capability-missing", `${id} needs ${specPath(capability)}, or an "ADDED CAPABILITY ${capability}" operation`)];
79
+ }
@@ -0,0 +1,99 @@
1
+ import { factsOf, type Element } from "../elements.ts";
2
+ import { findIds, scopeOf, SCOPED_KINDS, type Kind } from "../ids.ts";
3
+ import type { MarkdownDoc } from "../markdown.ts";
4
+ import { error, type Problem } from "./problem.ts";
5
+
6
+ /** Rules for a single element's body, shared by living documents and deltas. */
7
+
8
+ const REQUIRED_FACTS: Partial<Record<Kind, string[]>> = {
9
+ CTX: ["Purpose"],
10
+ ENT: ["Kind", "Module", "File"],
11
+ UC: ["Input", "Output", "Errors", "Uses", "Module", "File"],
12
+ PORT: ["Direction", "Operations", "Module", "File"],
13
+ ADP: ["Technology", "Module", "File"],
14
+ FLOW: ["Elements"],
15
+ MOD: ["Path", "Layer"],
16
+ };
17
+
18
+ const ALLOWED_VALUES: Record<string, { kinds: Kind[]; values: string[] }> = {
19
+ Kind: { kinds: ["ENT"], values: ["aggregate", "entity", "value", "service", "policy", "event"] },
20
+ Direction: { kinds: ["PORT"], values: ["driven", "driving"] },
21
+ Layer: { kinds: ["MOD"], values: ["domain", "application", "adapter", "composition"] },
22
+ };
23
+
24
+ export function elementProblems(path: string, doc: MarkdownDoc, element: Element): Problem[] {
25
+ const problems: Problem[] = [];
26
+ if (SCOPED_KINDS.includes(element.kind) && scopeOf(element.id) === undefined) {
27
+ problems.push(error(path, element.start, "id-scope", `${element.id} needs a scope: ${element.kind}-<scope>.<name>`));
28
+ }
29
+ if (element.op === "REMOVED") return [...problems, ...removedProblems(path, doc, element)];
30
+
31
+ const facts = factsOf(doc, element);
32
+ for (const key of REQUIRED_FACTS[element.kind] ?? []) {
33
+ if (!facts.has(key)) problems.push(error(path, element.start, "required-fact", `${element.id} is missing "- ${key}:"`));
34
+ }
35
+ if (element.kind === "ADP" && !facts.has("Implements") && !facts.has("Drives")) {
36
+ problems.push(error(path, element.start, "required-fact", `${element.id} needs "- Implements:" or "- Drives:"`));
37
+ }
38
+ for (const [key, rule] of Object.entries(ALLOWED_VALUES)) {
39
+ const value = facts.get(key)?.[0];
40
+ if (rule.kinds.includes(element.kind) && value !== undefined && !rule.values.includes(value)) {
41
+ problems.push(error(path, element.start, "fact-value", `${element.id} ${key} must be one of ${rule.values.join(", ")}`));
42
+ }
43
+ }
44
+ const module = facts.get("Module")?.[0];
45
+ if (module !== undefined && !findIds(module).some((id) => id.startsWith("MOD-"))) {
46
+ problems.push(error(path, element.start, "fact-value", `${element.id} Module must name a MOD element`));
47
+ }
48
+ if (element.kind === "FLOW" && !bodyText(doc, element).includes("sequenceDiagram")) {
49
+ problems.push(error(path, element.start, "flow-diagram", `${element.id} needs a Mermaid sequenceDiagram`));
50
+ }
51
+ if (element.kind === "REQ") problems.push(...requirementProblems(path, doc, element));
52
+ if (element.kind === "SCN") problems.push(...scenarioProblems(path, doc, element));
53
+ return problems;
54
+ }
55
+
56
+ function removedProblems(path: string, doc: MarkdownDoc, element: Element): Problem[] {
57
+ const facts = factsOf(doc, element);
58
+ const problems: Problem[] = [];
59
+ if (!facts.has("Reason")) problems.push(error(path, element.start, "removed-reason", `REMOVED ${element.id} needs "- Reason:"`));
60
+ if (element.kind === "REQ" && !facts.has("Migration")) {
61
+ problems.push(error(path, element.start, "removed-reason", `REMOVED ${element.id} needs "- Migration:"`));
62
+ }
63
+ return problems;
64
+ }
65
+
66
+ function requirementProblems(path: string, doc: MarkdownDoc, element: Element): Problem[] {
67
+ const problems: Problem[] = [];
68
+ const ownBody = ownLines(doc, element).join("\n");
69
+ if (!/\b(SHALL|MUST)\b/.test(ownBody)) problems.push(error(path, element.start, "req-keyword", `${element.id} must state SHALL or MUST`));
70
+ const hasScenario = doc.headings.some((h) => h.line > element.start && h.line < element.end && h.level === element.level + 1 && h.text.startsWith("SCN-"));
71
+ if (!hasScenario) problems.push(error(path, element.start, "req-scenario", `${element.id} needs at least one scenario`));
72
+ return problems;
73
+ }
74
+
75
+ function scenarioProblems(path: string, doc: MarkdownDoc, element: Element): Problem[] {
76
+ const steps = ownLines(doc, element)
77
+ .map((line) => /^- (GIVEN|WHEN|THEN|AND) /.exec(line)?.[1])
78
+ .filter((step) => step !== undefined);
79
+ const order = steps.filter((step) => step !== "AND").join(" ");
80
+ if (!/^(GIVEN )*WHEN( THEN)+$/.test(order)) {
81
+ return [error(path, element.start, "scenario-steps", `${element.id} needs GIVEN steps (optional), one WHEN, then one or more THEN`)];
82
+ }
83
+ if (steps[0] === "AND") return [error(path, element.start, "scenario-steps", `${element.id} cannot start with AND`)];
84
+ return [];
85
+ }
86
+
87
+ /** The element's lines before its first nested heading, without the heading itself. */
88
+ function ownLines(doc: MarkdownDoc, element: Element): string[] {
89
+ const lines: string[] = [];
90
+ for (let i = element.start + 1; i < element.end; i++) {
91
+ if (!doc.inFence[i] && /^#{1,6} /.test(doc.lines[i] ?? "")) break;
92
+ lines.push(doc.lines[i] ?? "");
93
+ }
94
+ return lines;
95
+ }
96
+
97
+ function bodyText(doc: MarkdownDoc, element: Element): string {
98
+ return doc.lines.slice(element.start, element.end).join("\n");
99
+ }
@@ -0,0 +1,97 @@
1
+ import { ARCHITECTURE_KINDS, sectionForKind } from "../architecture_sections.ts";
2
+ import { headingElements, statusOf, type Element } from "../elements.ts";
3
+ import { findIds, scopeOf, STATUS_KINDS } from "../ids.ts";
4
+ import type { MarkdownDoc } from "../markdown.ts";
5
+ import { PATHS } from "../project/layout.ts";
6
+ import type { ProjectView } from "../project/project_view.ts";
7
+ import { elementProblems } from "./element_rules.ts";
8
+ import { error, warning, type Problem } from "./problem.ts";
9
+
10
+ /** Rules for the living docs: vision, architecture, specs, ADRs and the IDs they cite. */
11
+ export function livingProblems(view: ProjectView): Problem[] {
12
+ const problems: Problem[] = [];
13
+ for (const { id, places } of view.living.duplicates) {
14
+ problems.push(error(places[0]?.split(":")[0] ?? "", undefined, "duplicate-id", `${id} is defined more than once: ${places.join(", ")}`));
15
+ }
16
+ for (const [path, doc] of view.living.docs) {
17
+ problems.push(...malformedIdHeadings(path, doc));
18
+ if (path === PATHS.architecture) problems.push(...architectureProblems(path, doc, everApproved(view, path)));
19
+ else if (path.startsWith(`${PATHS.specsDir}/`)) problems.push(...specProblems(path, doc));
20
+ else if (path.startsWith(`${PATHS.adrDir}/`)) problems.push(...adrProblems(path, doc));
21
+ }
22
+ for (const path of [...view.living.docs.keys(), PATHS.roadmap]) {
23
+ const text = view.snapshot.get(path);
24
+ if (text === undefined) continue;
25
+ for (const id of findIds(text)) {
26
+ if (!view.living.elements.has(id)) problems.push(error(path, lineOf(text, id), "unknown-id", `${id} is cited but not defined in the living docs`));
27
+ }
28
+ }
29
+ return problems;
30
+ }
31
+
32
+ /** A heading shaped like `<KIND>-...: Title` whose ID breaks the grammar. */
33
+ export function malformedIdHeadings(path: string, doc: MarkdownDoc): Problem[] {
34
+ return doc.headings.flatMap((heading) => {
35
+ const match = /^(?:(?:ADDED|MODIFIED|REMOVED) )?([A-Z]{2,5}-\S*):\s/.exec(heading.text);
36
+ if (match?.[1] === undefined || findIds(match[1])[0] === match[1]) return [];
37
+ return [error(path, heading.line, "malformed-id", `"${match[1]}" is not a valid ID`)];
38
+ });
39
+ }
40
+
41
+ /** Statuses are stamped by the first approval, so their absence before it is expected. */
42
+ function everApproved(view: ProjectView, path: string): boolean {
43
+ return [...view.approvals.values()].some((file) => file.approvals.some((record) => record.artifact === path));
44
+ }
45
+
46
+ function architectureProblems(path: string, doc: MarkdownDoc, approved: boolean): Problem[] {
47
+ const problems: Problem[] = [];
48
+ for (const element of headingElements(doc)) {
49
+ const section = [...doc.headings].reverse().find((h) => h.level === 2 && h.line < element.start);
50
+ if (!ARCHITECTURE_KINDS.includes(element.kind)) {
51
+ problems.push(error(path, element.start, "misplaced-element", `${element.id} does not belong in the architecture`));
52
+ } else if (element.kind !== "VIEW" && section?.text !== sectionForKind(element.kind)) {
53
+ problems.push(error(path, element.start, "misplaced-element", `${element.id} belongs under "## ${sectionForKind(element.kind)}"`));
54
+ }
55
+ problems.push(...elementProblems(path, doc, element), ...(approved ? statusProblems(path, doc, element) : []));
56
+ }
57
+ return problems;
58
+ }
59
+
60
+ function specProblems(path: string, doc: MarkdownDoc): Problem[] {
61
+ const capability = path.slice(path.lastIndexOf("/") + 1, -".md".length);
62
+ const problems: Problem[] = [];
63
+ const elements = headingElements(doc);
64
+ for (const element of elements) {
65
+ const expectedLevel = element.kind === "REQ" ? 2 : element.kind === "SCN" ? 3 : 0;
66
+ if (expectedLevel === 0) problems.push(error(path, element.start, "misplaced-element", `${element.id} does not belong in a spec`));
67
+ else if (element.level !== expectedLevel) problems.push(error(path, element.start, "heading-level", `${element.id} must be a level ${expectedLevel} heading`));
68
+ if (scopeOf(element.id) !== undefined && scopeOf(element.id) !== capability) {
69
+ problems.push(error(path, element.start, "id-scope", `${element.id} must use the scope "${capability}", the capability of this file`));
70
+ }
71
+ problems.push(...elementProblems(path, doc, element), ...statusProblems(path, doc, element));
72
+ }
73
+ return problems;
74
+ }
75
+
76
+ function adrProblems(path: string, doc: MarkdownDoc): Problem[] {
77
+ const text = doc.lines.join("\n");
78
+ return ["Status", "Date"]
79
+ .filter((key) => !new RegExp(`^- ${key}: `, "m").test(text))
80
+ .map((key) => error(path, undefined, "adr-fact", `the ADR is missing "- ${key}:"`));
81
+ }
82
+
83
+ function statusProblems(path: string, doc: MarkdownDoc, element: Element): Problem[] {
84
+ const status = statusOf(doc, element);
85
+ if (STATUS_KINDS.includes(element.kind) && status === undefined) {
86
+ return [warning(path, element.start, "missing-status", `${element.id} has no status yet; approving the document adds it`)];
87
+ }
88
+ if (!STATUS_KINDS.includes(element.kind) && status !== undefined) {
89
+ return [error(path, element.start, "unexpected-status", `${element.id} is not a kind that carries a status`)];
90
+ }
91
+ return [];
92
+ }
93
+
94
+ export function lineOf(text: string, needle: string): number | undefined {
95
+ const index = text.indexOf(needle);
96
+ return index === -1 ? undefined : text.slice(0, index).split("\n").length - 1;
97
+ }