@polymorphism-tech/morph-spec 8.47.0 → 9.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (222) hide show
  1. package/CLAUDE.md +48 -39
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +37 -51
  4. package/bin/validate.js +3 -111
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +75 -18
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-proposal.md +2 -50
  17. package/framework/commands/morph-status.md +3 -3
  18. package/framework/docs/CLI.md +585 -286
  19. package/framework/evals/review-checklist.md +82 -41
  20. package/framework/hooks/README.md +10 -11
  21. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +33 -3
  23. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  24. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  25. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  26. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  27. package/framework/hooks/claude-code/core/state-sync.js +7 -21
  28. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  29. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  30. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  31. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  32. package/framework/hooks/claude-code/statusline.py +55 -41
  33. package/framework/hooks/package.json +3 -0
  34. package/framework/hooks/shared/activity-logger.js +1 -1
  35. package/framework/hooks/shared/build-check.js +10 -6
  36. package/framework/hooks/shared/command-tokens.js +169 -0
  37. package/framework/hooks/shared/comment-rules.js +1 -1
  38. package/framework/hooks/shared/comment-scanner.js +10 -23
  39. package/framework/hooks/shared/comment-stripper.js +1 -1
  40. package/framework/hooks/shared/e2e-check.js +64 -27
  41. package/framework/hooks/shared/errata.js +72 -0
  42. package/framework/hooks/shared/feature-locator.js +1 -1
  43. package/framework/hooks/shared/frontend-locations.js +6 -0
  44. package/framework/hooks/shared/gate-question.js +18 -0
  45. package/framework/hooks/shared/phase-utils.js +15 -32
  46. package/framework/hooks/shared/slice-progress.js +118 -0
  47. package/framework/hooks/shared/spawn-command.js +23 -0
  48. package/framework/hooks/shared/state-reader.js +61 -87
  49. package/framework/hooks/shared/state-writer.js +2 -3
  50. package/framework/mcp/registry.json +3 -4
  51. package/framework/phases.json +23 -39
  52. package/framework/schemas/config.schema.json +14 -9
  53. package/framework/schemas/feature.schema.json +15 -50
  54. package/framework/skills/README.md +3 -4
  55. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  56. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  57. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  58. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  59. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  60. package/framework/skills/core/morph-media/SKILL.md +1 -1
  61. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  62. package/framework/skills/core/morph-review/SKILL.md +111 -136
  63. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  64. package/framework/skills/core/morph-uiux/SKILL.md +77 -152
  65. package/framework/standards/STANDARDS.json +86 -1218
  66. package/framework/standards/STANDARDS.meta.json +1305 -0
  67. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  68. package/framework/standards/ai-agents/setup.md +1 -1
  69. package/framework/standards/architecture/code-comments.md +6 -6
  70. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  71. package/framework/templates/README.md +2 -3
  72. package/framework/templates/REGISTRY.json +6 -170
  73. package/framework/templates/docs/plan.md +72 -0
  74. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  75. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  76. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  77. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  78. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  79. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  80. package/package.json +1 -1
  81. package/src/commands/e2e/e2e.js +2 -2
  82. package/src/commands/observability/cost.js +18 -60
  83. package/src/commands/project/doctor-procs.js +1 -1
  84. package/src/commands/project/doctor.js +9 -9
  85. package/src/commands/project/fleet.js +4 -4
  86. package/src/commands/project/heavy.js +3 -3
  87. package/src/commands/project/init.js +4 -0
  88. package/src/commands/project/setup-infra-cmd.js +2 -1
  89. package/src/commands/project/stack-scan.js +58 -0
  90. package/src/commands/project/standards.js +79 -25
  91. package/src/commands/project/status.js +29 -69
  92. package/src/commands/project/update.js +17 -1
  93. package/src/commands/project/worktree.js +1 -1
  94. package/src/commands/state/advance.js +62 -46
  95. package/src/commands/state/approve.js +81 -141
  96. package/src/commands/state/archive.js +20 -84
  97. package/src/commands/state/auto-approval.js +68 -0
  98. package/src/commands/state/create.js +28 -1
  99. package/src/commands/state/delete.js +1 -1
  100. package/src/commands/state/finish.js +11 -23
  101. package/src/commands/state/gate-check.js +85 -84
  102. package/src/commands/state/gate-decision.js +6 -4
  103. package/src/commands/state/handoff.js +1 -1
  104. package/src/commands/state/list.js +9 -9
  105. package/src/commands/state/report-check.js +157 -311
  106. package/src/commands/state/retype.js +9 -3
  107. package/src/commands/validation/mutate.js +77 -14
  108. package/src/commands/validation/plan-check.js +81 -0
  109. package/src/commands/validation/validate-feature.js +60 -135
  110. package/src/commands/validation/verify.js +40 -72
  111. package/src/core/paths/output-schema.js +19 -66
  112. package/src/core/state/gate-order.js +5 -6
  113. package/src/core/state/lifecycle-audit.js +2 -2
  114. package/src/core/state/state-manager.js +17 -98
  115. package/src/core/templates/template-registry.js +2 -2
  116. package/src/core/templates/template-seed.js +22 -0
  117. package/src/core/work-types.js +10 -10
  118. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  119. package/src/dashboard/app/components/CommandPalette.jsx +5 -5
  120. package/src/dashboard/app/components/EntityLink.jsx +1 -1
  121. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  122. package/src/dashboard/app/lib/phases.js +2 -4
  123. package/src/dashboard/app/pages/Activity.jsx +1 -1
  124. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  125. package/src/dashboard/app/pages/Features.jsx +9 -9
  126. package/src/dashboard/app/pages/Guide.jsx +2 -2
  127. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  128. package/src/dashboard/app/pages/Standards.jsx +1 -1
  129. package/src/dashboard/scanner/activity-scanner.js +5 -15
  130. package/src/dashboard/scanner/agents-scanner.js +1 -1
  131. package/src/dashboard/scanner/docs-scanner.js +2 -2
  132. package/src/dashboard/scanner/features-scanner.js +95 -60
  133. package/src/dashboard/scanner/project-api.js +1 -1
  134. package/src/dashboard/scanner/standards-scanner.js +2 -1
  135. package/src/lib/ci/ci-verify.js +17 -1
  136. package/src/lib/ci/github-ci.js +1 -1
  137. package/src/lib/detectors/index.js +3 -8
  138. package/src/lib/generators/handoff-generator.js +34 -65
  139. package/src/lib/generators/settings-generator.js +11 -89
  140. package/src/lib/git/evidence-staleness.js +1 -1
  141. package/src/lib/git/tree-fingerprint.js +19 -7
  142. package/src/lib/git/tree-state.js +2 -2
  143. package/src/lib/heavy/cleanup.js +1 -5
  144. package/src/lib/installers/memory-installer.js +6 -8
  145. package/src/lib/mutation/commands.js +3 -2
  146. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  147. package/src/lib/mutation/plan.js +14 -6
  148. package/src/lib/mutation/runner.js +1 -1
  149. package/src/lib/mutation/subset.js +46 -9
  150. package/src/lib/plan/artifact-vocabulary.js +60 -0
  151. package/src/lib/plan/checks.js +234 -0
  152. package/src/lib/plan/plan-parser.js +209 -0
  153. package/src/lib/plan/plan-template.js +18 -0
  154. package/src/lib/review-reports.js +222 -0
  155. package/src/lib/risk-detector.js +131 -140
  156. package/src/lib/specs/adr-index.js +50 -69
  157. package/src/lib/specs/design-seed.js +18 -0
  158. package/src/lib/specs/frontmatter.js +44 -0
  159. package/src/lib/specs/gfm-table.js +17 -0
  160. package/src/lib/specs/old-format.js +68 -0
  161. package/src/lib/specs/revocations.js +37 -95
  162. package/src/lib/specs/screen-checklist.js +18 -17
  163. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  164. package/src/lib/stack/stack-scan.js +267 -0
  165. package/src/lib/standards/registry.js +43 -0
  166. package/src/lib/standards/sections.js +58 -0
  167. package/src/lib/tasks/eval-node.js +4 -12
  168. package/src/lib/tasks/test-nodes.js +1 -74
  169. package/src/lib/tasks/test-runner.js +75 -117
  170. package/src/lib/telemetry/metrics.js +8 -69
  171. package/src/lib/telemetry/telemetry-reader.js +5 -118
  172. package/src/lib/telemetry/verify-event.js +5 -7
  173. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  174. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  175. package/src/lib/validators/reuse/reuse-validator.js +22 -45
  176. package/src/lib/validators/traceability-check.js +85 -129
  177. package/src/lib/validators/verify-runner.js +118 -235
  178. package/src/lib/worktree/docker-audit.js +1 -2
  179. package/src/lib/worktree/fleet-scanner.js +25 -38
  180. package/src/scripts/setup-infra.js +9 -0
  181. package/src/utils/agents-installer.js +76 -9
  182. package/src/utils/claude-settings-manager.js +0 -35
  183. package/src/utils/file-copier.js +27 -1
  184. package/src/utils/hooks-installer.js +16 -9
  185. package/src/utils/skills-installer.js +1 -2
  186. package/framework/evals/trace-schema.md +0 -67
  187. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  188. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  189. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  190. package/framework/hooks/shared/tasks-validator.js +0 -111
  191. package/framework/rules/morph-task-tracking.md +0 -54
  192. package/framework/rules/morph-workflow.md +0 -244
  193. package/framework/schemas/tasks.schema.json +0 -79
  194. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  195. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  196. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  197. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  198. package/framework/templates/docs/proposal.md +0 -192
  199. package/framework/templates/docs/spec.md +0 -57
  200. package/framework/templates/docs/ui-components.md +0 -52
  201. package/framework/templates/docs/ui-design-system.md +0 -76
  202. package/framework/templates/docs/ui-flows.md +0 -167
  203. package/framework/templates/examples/spec-examples.md +0 -91
  204. package/framework/templates/feature/decisions.md +0 -124
  205. package/framework/templates/feature/recap.md +0 -145
  206. package/framework/templates/tasks.template.json +0 -17
  207. package/src/commands/state/usecase.js +0 -143
  208. package/src/commands/tasks/dag.js +0 -181
  209. package/src/lib/agents/output-collector.js +0 -76
  210. package/src/lib/evaluator-report.js +0 -104
  211. package/src/lib/specs/acervo-format.js +0 -70
  212. package/src/lib/specs/acervo-lifecycle.js +0 -264
  213. package/src/lib/specs/done-criteria-check.js +0 -200
  214. package/src/lib/specs/usecase-check.js +0 -301
  215. package/src/lib/specs/usecase-promoter.js +0 -301
  216. package/src/lib/specs/usecase-registry.js +0 -237
  217. package/src/lib/tasks/dispatch-planner.js +0 -319
  218. package/src/lib/tasks/mermaid-renderer.js +0 -132
  219. package/src/lib/tasks/task-classifier.js +0 -30
  220. package/src/lib/tasks/task-parser.js +0 -209
  221. package/src/lib/tasks/tasks-json-parser.js +0 -296
  222. package/src/lib/tasks/test-scope.js +0 -597
@@ -1,301 +0,0 @@
1
- /**
2
- * Cross-check between a feature's use cases and its `tasks.json`.
3
- *
4
- * This is the mechanical half of the use-case layer. Whether a post-condition is
5
- * *observable in a test* is semantic and stays with Gate 2 and the evaluator —
6
- * no checker can decide it. A narrower question IS decidable, though: a
7
- * post-condition phrased as "nenhum arquivo/grep/binário…" describes an
8
- * ARTIFACT, not behaviour, and that is a lexical warning `done-criteria-check.js`
9
- * raises against `dag`. A `> [!note] Por que artefato:` callout right below the
10
- * post-condition is how a use case justifies keeping it literal — this module
11
- * parses that callout (`parseArtifactJustifications`) so the lexical checker
12
- * can skip the labels an author already defended. What IS decidable beyond
13
- * that is the plumbing, and the plumbing is where the requirement actually
14
- * leaked before:
15
- *
16
- * - a task pointing at a use case that does not exist → ERROR (a broken plan)
17
- * - a post-condition no task ever mentions → warning (orphan)
18
- * - a task on a use case whose `doneCriteria` carries → warning (paraphrased
19
- * none of its post-conditions verbatim instead of copied)
20
- *
21
- * Two deliberate asymmetries:
22
- *
23
- * **Coverage is computed from the post-condition's side, never the task's.**
24
- * `usecase` is optional by design — a technical task with no observable
25
- * behaviour has none — so "task without a use case" is never a finding.
26
- *
27
- * **Matching normalizes case and whitespace.** The rule is "copy the text
28
- * verbatim", but a plan that prefixes the label (`"P2 — cada veterinário…"`)
29
- * naturally lowercases the first letter. A raw `includes()` would reject the
30
- * very example the `morph-plan` skill teaches. Accents and wording still have
31
- * to match: that is the part worth being strict about.
32
- *
33
- * Fail-open like the promoter: no `usecases/` folder means nothing to check.
34
- */
35
-
36
- import { readFileSync } from 'fs';
37
- import { join } from 'path';
38
- import { isCanonicalUseCaseName, useCaseFilesById } from './acervo-format.js';
39
-
40
- /**
41
- * A post-condition line: `- **P2** — Cada veterinário traz suas especialidades`.
42
- * The separator is tolerant (em dash, en dash or hyphen) because the character
43
- * is a typing accident, not a decision.
44
- */
45
- const POSTCONDITION_RE = /^[ \t]*[-*][ \t]*\*\*(P\d+)\*\*[ \t]*[—–-][ \t]*(.+?)[ \t]*$/gm;
46
-
47
- /**
48
- * Single-line form of `POSTCONDITION_RE`, used to walk a document line by line.
49
- * DERIVED, never retyped: two hand copies drift, and a bullet shape only one
50
- * of them accepts makes its justification stop working in silence.
51
- */
52
- const POSTCONDITION_LINE_RE = new RegExp(POSTCONDITION_RE.source);
53
-
54
- /** Any bullet (not just a labelled post-condition) — closes an open block. */
55
- const BULLET_LINE_RE = /^\s*[-*]\s/;
56
-
57
- /** A markdown heading — closes an open block. */
58
- const HEADING_LINE_RE = /^\s*#/;
59
-
60
- /** A blockquote line — it belongs to a callout, never to the post-condition text. */
61
- const BLOCKQUOTE_LINE_RE = /^\s*>/;
62
-
63
- /**
64
- * The justification marker, ONE spelling for both forms: the callout below a
65
- * use-case post-condition (here) and the inline form in a task's own
66
- * `doneCriteria` (`done-criteria-check.js`). Tolerant of spacing — including
67
- * before the colon — because the spacing is a typing accident, not a decision.
68
- */
69
- export const ARTIFACT_MARKER_RE = /por\s+que\s+artefato\s*:/i;
70
-
71
- /**
72
- * The artifact-justification callout. It must live on its OWN line right
73
- * below the post-condition — never inline, because `POSTCONDITION_RE`
74
- * captures to end of line and that text travels literally into `doneCriteria`.
75
- * The foldable forms (`[!note]-`, `[!note]+`) are the same callout.
76
- */
77
- const ARTIFACT_NOTE_RE = new RegExp(String.raw`^\s*>\s*\[!note\][-+]?\s*` + ARTIFACT_MARKER_RE.source, 'i');
78
-
79
- /** Case-folded, whitespace-collapsed form used for containment tests. */
80
- export function normalize(text) {
81
- return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
82
- }
83
-
84
- /**
85
- * Parse every post-condition of a use-case document as a BLOCK: its full text
86
- * (first line + wrapped continuation) and whether a `> [!note] Por que
87
- * artefato:` callout justifies it.
88
- *
89
- * A post-condition bullet opens a block for its label; continuation lines
90
- * (wrapped post-condition text) are appended to the block's text; the block
91
- * closes at a blank line, a new bullet, or a heading — same shape as a
92
- * post-condition itself, so a callout separated by a blank line or living
93
- * under the NEXT bullet never counts.
94
- *
95
- * Blockquote lines are never post-condition text: they belong to the callout,
96
- * and a justification wrapped over two `>` lines is ordinary Obsidian style.
97
- * Once the blockquote opens, a following line without `>` is the quote's lazy
98
- * continuation (CommonMark), so it stays out of the text too. Appending either
99
- * to the post-condition produced a text no `doneCriteria` ever carries.
100
- *
101
- * A repeated label counts once — first wins, as in `parsePostConditions`.
102
- *
103
- * @param {string} content
104
- * @returns {Map<string, { text: string, justified: boolean }>}
105
- */
106
- export function parsePostConditionBlocks(content) {
107
- const blocks = new Map();
108
- let current = null; // { label, textParts: string[], justified: boolean, quoted: boolean }
109
-
110
- const closeBlock = () => {
111
- if (current && !blocks.has(current.label)) {
112
- blocks.set(current.label, { text: current.textParts.join(' ').trim(), justified: current.justified });
113
- }
114
- current = null;
115
- };
116
-
117
- for (const line of String(content).split(/\r?\n/)) {
118
- const bulletMatch = line.match(POSTCONDITION_LINE_RE);
119
- if (bulletMatch) {
120
- closeBlock();
121
- current = { label: bulletMatch[1], textParts: [bulletMatch[2].trim()], justified: false, quoted: false };
122
- continue;
123
- }
124
- if (!current) continue; // prose outside any post-condition block
125
-
126
- if (line.trim() === '' || HEADING_LINE_RE.test(line) || BULLET_LINE_RE.test(line)) {
127
- closeBlock();
128
- continue;
129
- }
130
- if (BLOCKQUOTE_LINE_RE.test(line)) {
131
- if (ARTIFACT_NOTE_RE.test(line)) current.justified = true;
132
- current.quoted = true;
133
- continue; // callout lines are metadata, never part of the post-condition text
134
- }
135
- if (current.quoted) continue; // lazy continuation of the blockquote
136
- current.textParts.push(line.trim());
137
- }
138
- closeBlock();
139
-
140
- return blocks;
141
- }
142
-
143
- /**
144
- * The justified post-conditions of a use-case document — the labels whose
145
- * block (see `parsePostConditionBlocks`) carried the callout.
146
- *
147
- * @param {string} content
148
- * @returns {Map<string, string>} label → full post-condition text (first line + continuation)
149
- */
150
- export function parseArtifactJustifications(content) {
151
- const result = new Map();
152
- for (const [label, block] of parsePostConditionBlocks(content)) {
153
- if (block.justified) result.set(label, block.text);
154
- }
155
- return result;
156
- }
157
-
158
- /**
159
- * Parse one use-case document into its post-conditions.
160
- *
161
- * @param {string} content
162
- * @returns {Array<{ label: string, text: string }>}
163
- */
164
- export function parsePostConditions(content) {
165
- const out = [];
166
- const seen = new Set();
167
- for (const m of String(content).matchAll(POSTCONDITION_RE)) {
168
- const [, label, text] = m;
169
- if (seen.has(label)) continue; // first wins — a repeated label is one post-condition
170
- seen.add(label);
171
- out.push({ label, text: text.trim() });
172
- }
173
- return out;
174
- }
175
-
176
- /**
177
- * Read every use case of a feature, keyed by ID.
178
- *
179
- * `fullText` maps EVERY label to its whole block (first line + wrapped
180
- * continuation); `postConditions[].text` stays the first line, which is what
181
- * the literal-carry check compares against.
182
- *
183
- * Any `UC-{nn}-….md` counts, canonical or not — installed plans rely on it.
184
- * `nonCanonical` lists the files of that id the promotion will not carry, so
185
- * `checkUseCases` can say so instead of the finish dropping them in silence.
186
- *
187
- * @param {string} usecasesDir - absolute path to `2-plan/usecases`
188
- * @returns {Map<string, { id: string, file: string, nonCanonical: string[], postConditions: Array<{label: string, text: string}>, artifactJustified: Map<string, string>, fullText: Map<string, string> }>}
189
- */
190
- export function loadUseCases(usecasesDir) {
191
- const byId = new Map();
192
- for (const [id, files] of useCaseFilesById(usecasesDir, { canonicalOnly: false })) {
193
- const file = files[files.length - 1];
194
- const nonCanonical = files.filter((f) => !isCanonicalUseCaseName(f));
195
- try {
196
- const content = readFileSync(join(usecasesDir, file), 'utf8');
197
- const blocks = parsePostConditionBlocks(content);
198
- const artifactJustified = new Map();
199
- const fullText = new Map();
200
- for (const [label, block] of blocks) {
201
- fullText.set(label, block.text);
202
- if (block.justified) artifactJustified.set(label, block.text);
203
- }
204
- byId.set(id, { id, file, nonCanonical, postConditions: parsePostConditions(content), artifactJustified, fullText });
205
- } catch { /* one unreadable use case must not sink the rest */ }
206
- }
207
- return byId;
208
- }
209
-
210
- /**
211
- * Cross-check `tasks.json` against the feature's use cases.
212
- *
213
- * @param {Object} tasksData - parsed tasks.json (already schema-valid)
214
- * @param {string} usecasesDir - absolute path to `2-plan/usecases`
215
- * @returns {{ errors: string[], warnings: string[], checked: boolean }}
216
- * `checked: false` means there were no use cases to check against.
217
- */
218
- export function checkUseCases(tasksData, usecasesDir) {
219
- const errors = [];
220
- const warnings = [];
221
- const byId = loadUseCases(usecasesDir);
222
- const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
223
-
224
- // A task referencing a use case that does not exist is a broken plan, and the
225
- // check is exact: the file is there or it is not. This is the only finding
226
- // that earns an error — everything below is advisory by construction.
227
- for (const task of tasks) {
228
- if (!task?.usecase) continue;
229
- if (!byId.has(task.usecase)) {
230
- const known = [...byId.keys()].sort().join(', ') || 'nenhum';
231
- errors.push(
232
- `${task.id}.usecase → ${task.usecase} não existe em 2-plan/usecases/ (encontrados: ${known})`
233
- );
234
- }
235
- }
236
-
237
- if (byId.size === 0) return { errors, warnings, checked: false };
238
-
239
- for (const uc of byId.values()) {
240
- for (const file of uc.nonCanonical) {
241
- warnings.push(
242
- `${uc.id} está em 2-plan/usecases/${file}, nome fora do padrão UC-{nn}-{slug}.md: ` +
243
- 'o finish não o promove ao acervo — renomeie'
244
- );
245
- }
246
- }
247
-
248
- // Pre-normalize every doneCriteria once: this is O(tasks × postConditions)
249
- // and both are small, but the normalization itself is not free.
250
- const criteria = tasks.map((t) => ({ id: t?.id, text: normalize(t?.doneCriteria || ''), usecase: t?.usecase }));
251
-
252
- // Orphan post-conditions — computed from the post-condition's side, so a task
253
- // without a `usecase` never counts against anyone.
254
- //
255
- // A use case NOBODY touches collapses into a single aggregated warning. The
256
- // per-post-condition list is the useful shape while a plan is being closed
257
- // (three of four covered → fix the fourth), but `dag` also runs on every
258
- // implement cycle, where an untouched use case would repeat N identical lines
259
- // forever. Losing the finding entirely would be worse: a use case written and
260
- // then ignored by the whole plan is the failure this layer exists to catch.
261
- for (const uc of byId.values()) {
262
- const withText = uc.postConditions.filter((pc) => normalize(pc.text));
263
- if (withText.length === 0) continue;
264
-
265
- const orphans = withText.filter((pc) => !criteria.some((c) => c.text.includes(normalize(pc.text))));
266
- if (orphans.length === 0) continue;
267
-
268
- const untouched = orphans.length === withText.length && !criteria.some((c) => c.usecase === uc.id);
269
- if (untouched) {
270
- warnings.push(
271
- `${uc.id} não é referenciado por nenhuma task — ${withText.length} pós-condição(ões) sem cobertura ` +
272
- `(${withText.map((p) => p.label).join('/')})`
273
- );
274
- continue;
275
- }
276
- for (const pc of orphans) {
277
- warnings.push(
278
- `${uc.id} ${pc.label} não é coberta por nenhuma task — nenhum doneCriteria carrega "${pc.text}"`
279
- );
280
- }
281
- }
282
-
283
- // Tasks that claim a use case but paraphrased it instead of copying it.
284
- for (const c of criteria) {
285
- if (!c.usecase) continue;
286
- const uc = byId.get(c.usecase);
287
- if (!uc || uc.postConditions.length === 0) continue;
288
- const carries = uc.postConditions.some((pc) => {
289
- const needle = normalize(pc.text);
290
- return needle && c.text.includes(needle);
291
- });
292
- if (!carries) {
293
- warnings.push(
294
- `${c.id}.doneCriteria não carrega nenhuma pós-condição de ${c.usecase} literalmente ` +
295
- `(esperado o texto de ${uc.postConditions.map((p) => p.label).join('/')})`
296
- );
297
- }
298
- }
299
-
300
- return { errors, warnings, checked: true };
301
- }
@@ -1,301 +0,0 @@
1
- /**
2
- * Promotion of a feature's use cases into the project's permanent archive.
3
- *
4
- * A use case is born inside a feature (`2-plan/usecases/UC-{nn}-{slug}.md`) and
5
- * is promoted, at archive time, into `.morph/specs/usecases/` — the living
6
- * description of the system's behaviour, which outlives the feature that
7
- * introduced it. The archive is COMMITTED (no gitignore rule covers it).
8
- *
9
- * Contract, in the order it matters:
10
- * - **Fail-open.** Every failure degrades to "nothing promoted". Promotion may
11
- * never block the close of a feature — that is why the caller treats a throw
12
- * as a no-op rather than an error.
13
- * - **No-op without the folder.** A feature with no use cases promotes nothing.
14
- * - **Idempotent.** Re-promoting the same use case rewrites the same content.
15
- * - **Overwrite by ID, not by filename — with a collision backstop.**
16
- * `UC-07-old-slug.md` in the archive is REPLACED by an incoming
17
- * `UC-07-new-slug.md`. Keying on the filename would leave two files
18
- * claiming UC-07 the first time a slug is reworded — and the `bug` work
19
- * type exists precisely to correct an existing use case, from a DIFFERENT
20
- * feature than the one that first published it, sometimes rewording the
21
- * slug in the same pass. That legitimate case is INDISTINGUISHABLE, by
22
- * filename and slug alone, from two worktrees that independently minted
23
- * the SAME number for two UNRELATED use cases (the numbering-collision bug
24
- * this backstop exists for) — a user has already lost a use case this way
25
- * in production. The one signal that tells them apart is `usecase-registry.js`'s
26
- * reservation record: a feature that REPLACES a number it never reserved is
27
- * reusing a published UC by design (the bug/update flow); a feature that
28
- * REPLACES a number it DID reserve, and finds someone ELSE'S feature already
29
- * sitting on that ID, is the collision — refuse that one specific promotion
30
- * rather than silently destroy the other feature's use case.
31
- */
32
-
33
- import { mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
34
- import { basename, join, relative } from 'path';
35
- import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
36
- import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
37
- import { reservationOwner } from './usecase-registry.js';
38
- import { SUPERSEDED, useCaseFilesById, useCaseId } from './acervo-format.js';
39
-
40
- export { useCaseId };
41
-
42
- const UC = OUTPUT_SCHEMA.usecases;
43
-
44
- /** Project-relative path of the permanent use-case archive (git pathspec-ready). */
45
- export const SPECS_USECASES_PATHSPEC = join('.morph', 'specs', 'usecases');
46
-
47
- const FRONTMATTER_RE = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/;
48
-
49
- /** `true` when the document opens with a frontmatter block. */
50
- export function hasFrontmatter(content) {
51
- const m = FRONTMATTER_RE.exec(content);
52
- return !!m && m.index === 0;
53
- }
54
-
55
- /**
56
- * Set (or replace) a single top-level frontmatter field, preserving every
57
- * other line, the original EOL style, and anything after the closing `---`.
58
- *
59
- * A file with no frontmatter block is returned UNCHANGED: the use case
60
- * belongs to the author, and inventing a header would be a silent edit of
61
- * their document.
62
- *
63
- * @param {string} content
64
- * @param {string} key
65
- * @param {string} value - written as-is; quote it first when it is free text
66
- * @returns {string}
67
- */
68
- export function setFrontmatterField(content, key, value) {
69
- const m = FRONTMATTER_RE.exec(content);
70
- if (!m || m.index !== 0) return content;
71
-
72
- const [full, open, body, close] = m;
73
- const eol = full.includes('\r\n') ? '\r\n' : '\n';
74
- const fieldLine = new RegExp(`^${key}:[ \\t]*.*$`, 'm');
75
- // Function replacer: a value carrying `$&` or `$1` is text, not a pattern.
76
- const next = fieldLine.test(body)
77
- ? body.replace(fieldLine, () => `${key}: ${value}`)
78
- : `${body}${eol}${key}: ${value}`;
79
-
80
- return open + next + close + content.slice(full.length);
81
- }
82
-
83
- /**
84
- * Drop a single top-level frontmatter field (its whole line, EOL included).
85
- * Absent field or no frontmatter → content unchanged.
86
- *
87
- * @param {string} content
88
- * @param {string} key
89
- * @returns {string}
90
- */
91
- export function removeFrontmatterField(content, key) {
92
- const m = FRONTMATTER_RE.exec(content);
93
- if (!m || m.index !== 0) return content;
94
-
95
- const [full, open, body, close] = m;
96
- const lines = body.split(/(?<=\n)/);
97
- const kept = lines.filter((line) => !new RegExp(`^${key}:`).test(line));
98
- if (kept.length === lines.length) return content;
99
- // The last kept line lost its EOL when the removed one was the final line;
100
- // the closing delimiter carries its own leading EOL, so trim the dangling one.
101
- const next = kept.join('').replace(/\r?\n$/, '');
102
- return open + next + close + content.slice(full.length);
103
- }
104
-
105
- /**
106
- * Read a single top-level frontmatter field. `null` when absent or there is no
107
- * frontmatter block. A double-quoted value is read as the JSON string it is
108
- * written as (that is how free text such as `scope:` is stored); a
109
- * single-quoted one as YAML reads it (`''` is a quote).
110
- */
111
- export function readFrontmatterField(content, key) {
112
- const m = FRONTMATTER_RE.exec(content);
113
- if (!m || m.index !== 0) return null;
114
- const line = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(m[2]);
115
- if (!line) return null;
116
- const raw = line[1].trim();
117
- if (raw.length >= 2 && raw.startsWith('"') && raw.endsWith('"')) {
118
- try { return JSON.parse(raw); } catch { return raw; }
119
- }
120
- if (raw.length >= 2 && raw.startsWith("'") && raw.endsWith("'")) return raw.slice(1, -1).replace(/''/g, "'");
121
- return raw;
122
- }
123
-
124
- /**
125
- * Rewrite the frontmatter `status:` to `implemented` — unless the use case is
126
- * superseded (its status says so, or it carries a `superseded_by`): a
127
- * promotion, such as a `bug` fix on the part that still holds, must never
128
- * hand the rest of it back its authority.
129
- *
130
- * A file with no frontmatter is copied verbatim: the use case belongs to the
131
- * author, and inventing a header would be a silent edit of their document.
132
- *
133
- * @param {string} content
134
- * @returns {string}
135
- */
136
- export function stampImplemented(content) {
137
- const superseded = readFrontmatterField(content, 'status') === SUPERSEDED
138
- || readFrontmatterField(content, 'superseded_by') !== null;
139
- return setFrontmatterField(content, 'status', superseded ? SUPERSEDED : 'implemented');
140
- }
141
-
142
- /**
143
- * Carry the acervo's supersession onto an incoming copy that does not declare
144
- * one — a copy taken before the revocation landed, or stripped of the fields
145
- * on the way. Without this the promotion would publish the old use case as
146
- * `implemented` again.
147
- *
148
- * @param {string} content - the incoming copy
149
- * @param {string|null} previous - the acervo entry it replaces
150
- * @returns {{ content: string, supersededBy: string|null, scope: string|null }|null} null = nothing to carry
151
- */
152
- function inheritSupersession(content, previous) {
153
- if (!previous || readFrontmatterField(previous, 'status') !== SUPERSEDED) return null;
154
- if (readFrontmatterField(content, 'status') === SUPERSEDED || readFrontmatterField(content, 'superseded_by') !== null) return null;
155
- if (!hasFrontmatter(content)) return null;
156
-
157
- const supersededBy = readFrontmatterField(previous, 'superseded_by');
158
- const scope = readFrontmatterField(previous, 'scope');
159
- let next = setFrontmatterField(content, 'status', SUPERSEDED);
160
- if (supersededBy) next = setFrontmatterField(next, 'superseded_by', supersededBy);
161
- if (scope !== null) next = setFrontmatterField(next, 'scope', JSON.stringify(scope));
162
- return { content: next, supersededBy, scope };
163
- }
164
-
165
- /**
166
- * Stamp the promoting feature's name into `sourceFeature:` — the provenance
167
- * record the collision guard (below) compares against a reservation to tell a
168
- * legitimate cross-feature correction from an accidental numbering collision.
169
- *
170
- * @param {string} content
171
- * @param {string} feature
172
- * @returns {string}
173
- */
174
- export function stampSourceFeature(content, feature) {
175
- return setFrontmatterField(content, 'sourceFeature', feature);
176
- }
177
-
178
- /**
179
- * Build the message for a refused promotion — named clearly enough that the
180
- * next session (or human) can actually recover, not just "colisão".
181
- *
182
- * The recovery instruction has to be honest about WHERE the file sits: by the
183
- * time this runs, `runArchive` has already moved the feature folder, and its
184
- * own idempotent short-circuit (`existsSync(archivePath)`) means re-running
185
- * `archive`/`finish` on this feature promotes NOTHING a second time — so
186
- * "renumber and re-run finish" would be a lie. The file is wherever `srcDir`
187
- * actually is (the feature's `2-plan/usecases/`, wherever that folder lives
188
- * right now — pre- or post-archive, the caller decides), and recovery is
189
- * either a fresh reservation + manual copy, or editing the archive in place.
190
- *
191
- * @returns {string}
192
- */
193
- function collisionMessage({ id, incoming, existing, existingFeature, feature, relIncomingPath }) {
194
- return `${id}: colisão de numeração — "${incoming}" (feature "${feature}") não pôde substituir "${existing}" ` +
195
- `já promovido no acervo por "${existingFeature}". O arquivo continua em ${relIncomingPath}. ` +
196
- 'Se for um use case NOVO: renumere-o com `morph-spec usecase reserve` e promova manualmente para ' +
197
- '.morph/specs/usecases/ (o finish/archive não reprocessa uma feature já arquivada). ' +
198
- `Se for uma CORREÇÃO do mesmo UC publicado: edite .morph/specs/usecases/${existing} diretamente em vez de reservar um número novo.`;
199
- }
200
-
201
- /**
202
- * Copy every use case of a feature into the project's permanent archive.
203
- *
204
- * @param {string} cwd - project root that owns `.morph/specs/`
205
- * @param {string} featureDir - the feature folder holding `2-plan/usecases/`
206
- * @returns {{ promoted: string[], replaced: string[], refused: Array<{id: string,
207
- * incoming: string, existing: string, existingFeature: string, message: string}>,
208
- * keptSuperseded: Array<{id: string, file: string, supersededBy: string|null, scope?: string}> }}
209
- * always arrays; `keptSuperseded` = promoted copies that stayed superseded
210
- * because the acervo entry they replaced was
211
- */
212
- export function promoteUseCases(cwd, featureDir) {
213
- const empty = { promoted: [], replaced: [], refused: [], keptSuperseded: [] };
214
- const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
215
- const feature = basename(featureDir);
216
-
217
- const names = [...useCaseFilesById(srcDir).values()].flat().sort();
218
- if (!names.length) return empty;
219
-
220
- const destDir = join(cwd, '.morph', 'specs', 'usecases');
221
- const promoted = [];
222
- const replaced = [];
223
- const refused = [];
224
- const keptSuperseded = [];
225
-
226
- try {
227
- mkdirSync(destDir, { recursive: true });
228
- } catch {
229
- return empty;
230
- }
231
-
232
- // Resolved ONCE per call — pure filesystem, no subprocess (git-paths.js) —
233
- // and reused for every collision check below.
234
- let gitCommonDir = null;
235
- try { gitCommonDir = resolveGitCommonDir(cwd); } catch { gitCommonDir = null; }
236
-
237
- // Existing archive entries, indexed by use-case ID — the key the overwrite
238
- // rule is written against.
239
- const existingById = new Map([...useCaseFilesById(destDir)].map(([id, files]) => [id, files[files.length - 1]]));
240
-
241
- for (const name of names) {
242
- try {
243
- const id = useCaseId(name);
244
- const stale = id ? existingById.get(id) : null;
245
- let previous = null;
246
- if (stale) {
247
- try { previous = readFileSync(join(destDir, stale), 'utf8'); } catch { /* unreadable — no signal */ }
248
- }
249
-
250
- // Same ID under a different filename: normally the old file is dropped
251
- // (a slug reword, or the `bug` flow correcting a published UC), or the
252
- // archive would end up with two documents claiming the same UC number.
253
- //
254
- // Backstop: refuse instead when BOTH are true —
255
- // (a) THIS feature holds a reservation for `id` in usecase-registry.js
256
- // (it believes it MINTED a new number), and
257
- // (b) the archive entry already there was promoted by a DIFFERENT
258
- // feature (its stamped `sourceFeature` says so).
259
- // A feature correcting a published UC never reserved that UC's number —
260
- // it reused one already in the acervo — so that legitimate flow never
261
- // trips this. Two features that each reserved the SAME number (the
262
- // collision this exists for) always do. No reservation record at all
263
- // (no git common dir, or a legacy archive entry predating this stamp) is
264
- // fail-open: nothing to compare against, so nothing is refused.
265
- if (stale && stale !== name) {
266
- const staleFeature = previous === null ? null : readFrontmatterField(previous, 'sourceFeature');
267
- const incomingOwner = id ? reservationOwner(id, { cwd, gitCommonDir }) : null;
268
-
269
- if (staleFeature && staleFeature !== feature && incomingOwner === feature) {
270
- refused.push({
271
- id,
272
- incoming: name,
273
- existing: stale,
274
- existingFeature: staleFeature,
275
- message: collisionMessage({
276
- id, incoming: name, existing: stale, existingFeature: staleFeature, feature,
277
- relIncomingPath: relative(cwd, join(srcDir, name)).replace(/\\/g, '/'),
278
- }),
279
- });
280
- continue; // leave the existing archive entry untouched
281
- }
282
-
283
- rmSync(join(destDir, stale), { force: true });
284
- replaced.push(stale);
285
- }
286
-
287
- let content = readFileSync(join(srcDir, name), 'utf8');
288
- const kept = inheritSupersession(content, previous);
289
- if (kept) {
290
- content = kept.content;
291
- keptSuperseded.push({ id, file: name, supersededBy: kept.supersededBy, ...(kept.scope !== null ? { scope: kept.scope } : {}) });
292
- }
293
- content = stampSourceFeature(stampImplemented(content), feature);
294
- writeFileSync(join(destDir, name), content, 'utf8');
295
- promoted.push(name);
296
- if (id) existingById.set(id, name);
297
- } catch { /* one unreadable use case must not sink the rest */ }
298
- }
299
-
300
- return { promoted, replaced, refused, keptSuperseded };
301
- }