@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,104 +0,0 @@
1
- import { existsSync, readFileSync } from 'fs';
2
- import { getAbsoluteOutputPath } from '../core/paths/output-schema.js';
3
-
4
- export const VERDICT_TOKEN = '**Verdict:**';
5
- export const VERDICTS = ['SHIP', 'FIX'];
6
- export const BLOCKER_TYPES = ['uc', 'teste', 'regressao', 'seguranca'];
7
-
8
- // O relatório é append-only: uma reavaliação acrescenta ao fim, e o token vigente é o ÚLTIMO.
9
- const VERDICT_RE = /\*\*Verdict:\*\*\s*([A-Za-z]+)/g;
10
- const BLOCKERS_SECTION_RE = /^##\s+Bloqueadores\s*$/gim;
11
- const MEDIA_SECTION_RE = /^##\s+M[íi]dia\s+gerada\s*$/m;
12
- const SCREEN_CHECKLIST_SECTION_RE = /^##\s+Checklist\s+de\s+tela\s*$/gim;
13
-
14
- export function evaluatorReportPath(cwd, featureName) {
15
- return getAbsoluteOutputPath(cwd, featureName, 'evaluatorReport');
16
- }
17
-
18
- function readReport(cwd, featureName) {
19
- try {
20
- const p = evaluatorReportPath(cwd, featureName);
21
- return existsSync(p) ? readFileSync(p, 'utf8') : null;
22
- } catch {
23
- return null;
24
- }
25
- }
26
-
27
- function lastSectionBody(text, re) {
28
- const matches = [...text.matchAll(re)];
29
- if (matches.length === 0) return null;
30
- const last = matches[matches.length - 1];
31
- const rest = text.slice(last.index + last[0].length);
32
- const next = rest.search(/^#{1,2}\s/m);
33
- return next === -1 ? rest : rest.slice(0, next);
34
- }
35
-
36
- const normalizeType = (s) => s.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').trim();
37
-
38
- /**
39
- * Linhas de tabela da última `## Bloqueadores`: `| id | tipo | onde | evidência |`.
40
- * Cabeçalho e separador não contam; `untyped` são as linhas cujo tipo não é de {@link BLOCKER_TYPES}.
41
- */
42
- function parseBlockers(body) {
43
- const blockers = [];
44
- const untyped = [];
45
- for (const line of body.split(/\r?\n/)) {
46
- if (!/^\s*\|/.test(line)) continue;
47
- const cells = line.trim().replace(/^\||\|$/g, '').split('|').map((c) => c.trim());
48
- if (cells.every((c) => /^:?-{2,}:?$/.test(c) || c === '')) continue;
49
- if (/^#$|^id$/i.test(cells[0]) && /^tipo$/i.test(cells[1] ?? '')) continue;
50
- const row = { id: cells[0], type: normalizeType(cells[1] ?? ''), where: cells[2] ?? '', evidence: cells[3] ?? '' };
51
- (BLOCKER_TYPES.includes(row.type) ? blockers : untyped).push(row);
52
- }
53
- return { blockers, untyped };
54
- }
55
-
56
- export function evaluatorVerdictFromText(text) {
57
- if (typeof text !== 'string') return { verdict: undefined, blockers: [], untyped: [], superseded: [] };
58
- const verdicts = [...text.matchAll(VERDICT_RE)].map((m) => m[1].toUpperCase());
59
- const body = lastSectionBody(text, BLOCKERS_SECTION_RE);
60
- const { blockers, untyped } = body === null ? { blockers: [], untyped: [] } : parseBlockers(body);
61
- return {
62
- verdict: verdicts.length ? verdicts[verdicts.length - 1] : undefined,
63
- blockers,
64
- untyped,
65
- superseded: verdicts.slice(0, -1),
66
- };
67
- }
68
-
69
- /**
70
- * `missing`: sem relatório. `unverdicted`: relatório sem `**Verdict:** SHIP|FIX` válido.
71
- * `ok`: veredito legível — quem decide se ele libera o ship é o risk-detector.
72
- */
73
- export function evaluatorReportState(cwd, featureName) {
74
- const path = evaluatorReportPath(cwd, featureName);
75
- const text = readReport(cwd, featureName);
76
- if (text === null) return { status: 'missing', path };
77
- const parsed = evaluatorVerdictFromText(text);
78
- if (!VERDICTS.includes(parsed.verdict)) return { status: 'unverdicted', path };
79
- return { status: 'ok', path, ...parsed };
80
- }
81
-
82
- /** O sinal `evaluatorVerdict` que `verdictPause` (risk-detector) lê — gate-check e approve leem o mesmo. */
83
- export function verdictSignal(report) {
84
- if (report.status !== 'ok') return { status: report.status };
85
- return {
86
- status: 'ok',
87
- verdict: report.verdict,
88
- blockers: report.blockers.length,
89
- blockerIds: report.blockers.map((b) => b.id),
90
- untyped: report.untyped.length,
91
- superseded: report.superseded,
92
- };
93
- }
94
-
95
- export function evaluatorMediaSection(cwd, featureName) {
96
- const text = readReport(cwd, featureName);
97
- return text !== null && MEDIA_SECTION_RE.test(text);
98
- }
99
-
100
- export function evaluatorScreenChecklist(cwd, featureName) {
101
- const text = readReport(cwd, featureName);
102
- const body = text === null ? null : lastSectionBody(text, SCREEN_CHECKLIST_SECTION_RE);
103
- return body === null ? { section: false, body: '' } : { section: true, body };
104
- }
@@ -1,70 +0,0 @@
1
- /**
2
- * The shapes every reader and writer of the acervo agrees on — one copy each.
3
- * A second copy of any of these is how two readers start disagreeing about the
4
- * same file: a use case one of them sees and the other does not, a table row
5
- * one of them splits differently.
6
- */
7
-
8
- import { existsSync, readdirSync } from 'fs';
9
- import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
10
-
11
- /** A use-case id: `UC-07`. */
12
- export const UC_ID_RE = /^UC-\d+$/;
13
-
14
- /** A use case no longer holds as the system's behaviour (or holds only in its `scope`). */
15
- export const SUPERSEDED = 'superseded';
16
-
17
- const UC_FILENAME_RE = new RegExp(OUTPUT_SCHEMA.usecases.filenamePattern);
18
-
19
- /** `UC-07-listar-veterinarios.md` → `UC-07`. Null when the name isn't a use case. */
20
- export function useCaseId(filename) {
21
- const m = /^(UC-\d+)-/.exec(filename);
22
- return m ? m[1] : null;
23
- }
24
-
25
- /** The name the promotion carries: `UC-{nn}-{slug}.md`, slug in lowercase kebab. */
26
- export const isCanonicalUseCaseName = (name) => UC_FILENAME_RE.test(name);
27
-
28
- /**
29
- * Every use case in `dir`, grouped by id. By default only the canonical names
30
- * — the ones the promotion ever carries. `canonicalOnly: false` also lists any
31
- * `UC-{nn}-….md`, for the plan-time check that has always accepted them and
32
- * now warns instead. An id with more than one file is a broken acervo, and
33
- * readers must be able to see it rather than silently pick one.
34
- *
35
- * @param {string} dir
36
- * @param {{ canonicalOnly?: boolean }} [options]
37
- * @returns {Map<string, string[]>} id → filenames, sorted; empty when the folder is missing or unreadable
38
- */
39
- export function useCaseFilesById(dir, { canonicalOnly = true } = {}) {
40
- const byId = new Map();
41
- try {
42
- if (!existsSync(dir)) return byId;
43
- for (const name of readdirSync(dir).sort()) {
44
- const id = useCaseId(name);
45
- if (!id || !name.endsWith('.md')) continue;
46
- if (canonicalOnly && !isCanonicalUseCaseName(name)) continue;
47
- if (!byId.has(id)) byId.set(id, []);
48
- byId.get(id).push(name);
49
- }
50
- } catch { /* unreadable — nothing to see */ }
51
- return byId;
52
- }
53
-
54
- /** A markdown table separator row: `|---|:--:|`, with or without the outer pipes. */
55
- export const SEPARATOR_ROW_RE = /^\s*\|?\s*:?-{3,}:?\s*(\|\s*:?-{3,}:?\s*)*\|?\s*$/;
56
-
57
- /** A line that belongs to a table: it carries an unescaped pipe (GFM does not require the leading one). */
58
- export const isTableLine = (line) => /(?<!\\)\|/.test(line);
59
-
60
- /**
61
- * Split a table row on unescaped pipes, outer pipes optional; `\|` inside a
62
- * cell is a literal pipe.
63
- *
64
- * @param {string} row
65
- * @returns {string[]} trimmed cells
66
- */
67
- export function tableCells(row) {
68
- const inner = row.trim().replace(/^\|/, '').replace(/(?<!\\)\|$/, '');
69
- return inner.split(/(?<!\\)\|/).map((c) => c.replace(/\\\|/g, '|').trim());
70
- }
@@ -1,264 +0,0 @@
1
- /**
2
- * Lifecycle of the project's acervo: a use case stops being the current
3
- * behaviour when a later feature supersedes it.
4
- *
5
- * status: superseded
6
- * superseded_by: UC-589
7
- * scope: "vitrine pública, fora de `.morph-admin-shell`" ← only when partial
8
- *
9
- * Three roles live here:
10
- * - the ONE rule for that frontmatter (`supersessionProblems`) — the check
11
- * reads it, and the writer below only ever produces what it accepts;
12
- * - the close-time writer (`applySpecRevocations`): what the feature's
13
- * `spec.md` declared it revokes lands on `.morph/specs/usecases/` and on
14
- * the ADR index, fail-open like the promotion it follows;
15
- * - the plan-time check (`checkLifecycle`), shared by `dag` and `usecase
16
- * check`, run while `spec.md` can still be fixed.
17
- */
18
-
19
- import { readFileSync, writeFileSync } from 'fs';
20
- import { join } from 'path';
21
- import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
22
- import {
23
- SPECS_USECASES_PATHSPEC,
24
- hasFrontmatter,
25
- readFrontmatterField,
26
- removeFrontmatterField,
27
- setFrontmatterField,
28
- } from './usecase-promoter.js';
29
- import { SUPERSEDED, UC_ID_RE, useCaseFilesById } from './acervo-format.js';
30
- import { parseRevocations } from './revocations.js';
31
- import { ADR_INDEX_PATHSPEC, readAdrIndex, updateAdrIndex } from './adr-index.js';
32
-
33
- export { SUPERSEDED };
34
-
35
- const UC = OUTPUT_SCHEMA.usecases;
36
- const ACERVO_LABEL = SPECS_USECASES_PATHSPEC.replace(/\\/g, '/');
37
- const ADR_INDEX_LABEL = `${ADR_INDEX_PATHSPEC.replace(/\\/g, '/')}/`;
38
-
39
- /** @returns {{ status: string|null, supersededBy: string|null, scope: string|null }} */
40
- export function lifecycleOf(content) {
41
- return {
42
- status: readFrontmatterField(content, 'status'),
43
- supersededBy: readFrontmatterField(content, 'superseded_by'),
44
- scope: readFrontmatterField(content, 'scope'),
45
- };
46
- }
47
-
48
- /**
49
- * The rule for a use case's supersession frontmatter.
50
- *
51
- * @param {string} content
52
- * @param {string} id - the use case's own id
53
- * @returns {string[]} one line per problem; `[]` = well formed
54
- */
55
- export function supersessionProblems(content, id) {
56
- const { status, supersededBy, scope } = lifecycleOf(content);
57
- const problems = [];
58
- if (status === SUPERSEDED) {
59
- if (!supersededBy) problems.push('status: superseded sem superseded_by');
60
- else if (!UC_ID_RE.test(supersededBy)) problems.push(`superseded_by "${supersededBy}" não é um UC-{nn}`);
61
- else if (supersededBy === id) problems.push('superseded_by aponta para o próprio use case');
62
- } else if (supersededBy !== null || scope !== null) {
63
- problems.push(`${supersededBy !== null ? 'superseded_by' : 'scope'} sem status: superseded`);
64
- }
65
- if (scope !== null && !String(scope).trim()) problems.push('scope vazio');
66
- return problems;
67
- }
68
-
69
- function readOrNull(path) {
70
- try { return readFileSync(path, 'utf8'); } catch { return null; }
71
- }
72
-
73
- const specPathOf = (featureDir) => join(featureDir, OUTPUT_SCHEMA.spec.phaseDir, OUTPUT_SCHEMA.spec.filename);
74
-
75
- /** A finished feature cannot be re-closed: the fix is the next feature's revocation row. */
76
- const FIX_NEXT_TIME = 'corrija a linha na seção de revogações do spec.md — a feature já arquivada não é reprocessada, ' +
77
- 'então a revogação corrigida entra no spec.md da próxima feature';
78
-
79
- /**
80
- * Mark the acervo use cases the entries revoke. Runs after the promotion, so
81
- * the acervo already holds every substitute this feature brought — except the
82
- * ones the promotion refused, which only LOOK present (the file under that id
83
- * belongs to another feature).
84
- *
85
- * @returns {{ superseded: Array<{id: string, file: string, supersededBy: string, scope?: string}>,
86
- * problems: Array<{id: string, reason: string, message: string}> }}
87
- */
88
- function supersedeUseCases(cwd, entries, refusedIds) {
89
- const superseded = [];
90
- const problems = [];
91
- if (!entries.length) return { superseded, problems };
92
-
93
- const dir = join(cwd, SPECS_USECASES_PATHSPEC);
94
- const files = useCaseFilesById(dir);
95
- const problem = (id, reason, message) => problems.push({ id, reason, message });
96
-
97
- for (const e of entries) {
98
- const targets = files.get(e.id) ?? [];
99
- if (targets.length === 0) {
100
- problem(e.id, 'not-in-acervo', `${e.id} não está no acervo (${ACERVO_LABEL}/) — nada foi marcado; ${FIX_NEXT_TIME}`);
101
- continue;
102
- }
103
- if (targets.length > 1) {
104
- problem(e.id, 'duplicate-in-acervo',
105
- `${e.id} tem ${targets.length} arquivos no acervo (${targets.join(', ')}) — nenhum foi marcado; o acervo precisa de um arquivo por UC`);
106
- continue;
107
- }
108
- if (refusedIds.has(e.substitute)) {
109
- problem(e.id, 'substitute-refused',
110
- `o substituto ${e.substitute} de ${e.id} não foi promovido (colisão de numeração) — ${e.id} não foi marcado`);
111
- continue;
112
- }
113
- if (!files.has(e.substitute)) {
114
- problem(e.id, 'substitute-not-found',
115
- `o substituto ${e.substitute} de ${e.id} não está no acervo — ${e.id} não foi marcado; ${FIX_NEXT_TIME}`);
116
- continue;
117
- }
118
-
119
- const file = targets[0];
120
- try {
121
- const path = join(dir, file);
122
- const before = readFileSync(path, 'utf8');
123
- if (!hasFrontmatter(before)) {
124
- problem(e.id, 'no-frontmatter', `${ACERVO_LABEL}/${file} não tem frontmatter — o status não foi aplicado`);
125
- continue;
126
- }
127
- let next = setFrontmatterField(before, 'status', SUPERSEDED);
128
- next = setFrontmatterField(next, 'superseded_by', e.substitute);
129
- // JSON-quoted: free text with `: ` or a leading backtick breaks a bare YAML scalar.
130
- next = e.scope
131
- ? setFrontmatterField(next, 'scope', JSON.stringify(e.scope))
132
- : removeFrontmatterField(next, 'scope');
133
- if (next !== before) writeFileSync(path, next, 'utf8');
134
- superseded.push({ id: e.id, file, supersededBy: e.substitute, ...(e.scope ? { scope: e.scope } : {}) });
135
- } catch {
136
- problem(e.id, 'unwritable', `${ACERVO_LABEL}/${file} não pôde ser lido/gravado — o status não foi aplicado`);
137
- }
138
- }
139
- return { superseded, problems };
140
- }
141
-
142
- /**
143
- * Apply, at close time, what the feature's `spec.md` declared it revokes.
144
- * Fail-open: nothing here may refuse the close; what did not apply comes back
145
- * in `problems`, next to the row's own parse problems.
146
- *
147
- * @param {string} cwd - project root that owns `.morph/specs/`
148
- * @param {string} featureDir - the feature folder (already moved to the archive)
149
- * @param {{ refused?: Array<{ id: string }> }} [promotion] - the promotion's result, run just before
150
- * @returns {{ superseded: Array<{id: string, file: string, supersededBy: string, scope?: string}>,
151
- * adrIndex: ReturnType<typeof updateAdrIndex>|null,
152
- * problems: Array<{id: string|null, reason: string, message: string}> }}
153
- */
154
- export function applySpecRevocations(cwd, featureDir, promotion = {}) {
155
- const out = { superseded: [], adrIndex: null, problems: [] };
156
-
157
- const spec = readOrNull(specPathOf(featureDir));
158
- const parsed = spec === null ? { entries: [], problems: [] } : parseRevocations(spec);
159
- out.problems.push(...parsed.problems);
160
-
161
- try {
162
- const refusedIds = new Set((promotion.refused ?? []).map((r) => r.id));
163
- const uc = supersedeUseCases(cwd, parsed.entries.filter((e) => e.kind === 'uc'), refusedIds);
164
- out.superseded = uc.superseded;
165
- out.problems.push(...uc.problems);
166
- } catch { /* fail-open */ }
167
-
168
- try {
169
- out.adrIndex = updateAdrIndex(cwd, featureDir, parsed.entries.filter((e) => e.kind === 'adr'));
170
- out.problems.push(...out.adrIndex.problems);
171
- } catch { /* fail-open */ }
172
-
173
- return out;
174
- }
175
-
176
- /**
177
- * Plan-time check of a feature against the acervo's lifecycle.
178
- *
179
- * Warnings only, never errors: a superseded use case is a question for the
180
- * plan, and the revocation rows are re-reported at close time anyway.
181
- *
182
- * @param {{ cwd: string, featureDir: string, tasksData?: object|null }} input
183
- * @returns {{ warnings: string[] }}
184
- */
185
- export function checkLifecycle({ cwd, featureDir, tasksData = null }) {
186
- const warnings = [];
187
- const acervoDir = join(cwd, SPECS_USECASES_PATHSPEC);
188
- const ownDir = join(featureDir, UC.phaseDir, UC.dirname);
189
- const acervo = useCaseFilesById(acervoDir);
190
- const own = useCaseFilesById(ownDir);
191
-
192
- const ownLife = new Map();
193
- for (const [id, files] of own) {
194
- const file = files[files.length - 1];
195
- const content = readOrNull(join(ownDir, file));
196
- if (content === null) continue;
197
- ownLife.set(id, lifecycleOf(content));
198
- for (const p of supersessionProblems(content, id)) warnings.push(`2-plan/usecases/${file}: ${p}`);
199
- }
200
-
201
- // The acervo can hold hundreds of use cases: read only the ones this plan touches.
202
- const acervoLife = new Map();
203
- const acervoOf = (id) => {
204
- if (acervoLife.has(id)) return acervoLife.get(id);
205
- const files = acervo.get(id) ?? [];
206
- if (files.length > 1) warnings.push(`${ACERVO_LABEL}/: ${id} tem ${files.length} arquivos (${files.join(', ')}) — o acervo precisa de um arquivo por UC`);
207
- const file = files[files.length - 1];
208
- const content = file ? readOrNull(join(acervoDir, file)) : null;
209
- const life = content === null ? null : lifecycleOf(content);
210
- if (content !== null) {
211
- for (const p of supersessionProblems(content, id)) warnings.push(`${ACERVO_LABEL}/${file}: ${p}`);
212
- }
213
- acervoLife.set(id, life);
214
- return life;
215
- };
216
-
217
- const spec = readOrNull(specPathOf(featureDir));
218
- const rev = spec === null ? { entries: [], problems: [] } : parseRevocations(spec);
219
- for (const p of rev.problems) warnings.push(`spec.md, revogações: ${p.message}`);
220
-
221
- const revokedHere = new Map();
222
- let indexedAdrs = null;
223
- for (const e of rev.entries) {
224
- if (e.kind === 'adr') {
225
- indexedAdrs ??= new Set(readAdrIndex(cwd).map((r) => r.ref));
226
- if (!indexedAdrs.has(e.id)) {
227
- warnings.push(`spec.md, revogações: ${e.id} não está no índice de ADRs ativos (${ADR_INDEX_LABEL}) — o finish não terá o que retirar`);
228
- }
229
- continue;
230
- }
231
- revokedHere.set(e.id, e);
232
- if (!acervo.has(e.id)) {
233
- warnings.push(`spec.md, revogações: ${e.id} não está no acervo (${ACERVO_LABEL}/) — o finish não terá o que marcar`);
234
- } else {
235
- acervoOf(e.id);
236
- }
237
- if (!acervo.has(e.substitute) && !own.has(e.substitute)) {
238
- warnings.push(`spec.md, revogações: o substituto ${e.substitute} de ${e.id} não existe no acervo nem em 2-plan/usecases/ — o finish não aplicará a linha`);
239
- }
240
- }
241
-
242
- const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
243
- for (const task of tasks) {
244
- const id = task?.usecase;
245
- if (!id) continue;
246
- const inAcervo = acervoOf(id);
247
- const inOwn = ownLife.get(id);
248
- const here = revokedHere.get(id);
249
-
250
- let where = null;
251
- if (inAcervo?.status === SUPERSEDED) where = { src: 'está superseded no acervo', ...inAcervo };
252
- else if (inOwn?.status === SUPERSEDED) where = { src: 'está superseded na cópia da feature', ...inOwn };
253
- else if (here) where = { src: 'é revogado por esta feature (spec.md)', supersededBy: here.substitute, scope: here.scope };
254
- if (!where) continue;
255
-
256
- const by = where.supersededBy;
257
- const detail = [by ? `substituto: ${by}` : 'sem substituto declarado', where.scope ? `ainda vale em: ${where.scope}` : null]
258
- .filter(Boolean).join('; ');
259
- const advice = where.scope ? 'confira que a task realiza a parte que ainda vale' : by ? `cite ${by}` : 'confira o acervo';
260
- warnings.push(`${task.id}.usecase → ${id} ${where.src} (${detail}) — ${advice}`);
261
- }
262
-
263
- return { warnings };
264
- }
@@ -1,200 +0,0 @@
1
- /**
2
- * Lexical heuristic: does a task's `doneCriteria` describe an ARTIFACT (a
3
- * file, a grep, a binary) instead of behaviour observable at runtime?
4
- *
5
- * A literal `doneCriteria` travels verbatim into the test that verifies it
6
- * (`morph-implement` copies it, never paraphrases). A use-case post-condition
7
- * shaped like "nenhum arquivo em X contém Y" — even a literal `grep` command —
8
- * becomes a test that reads file text instead of exercising behaviour. This
9
- * is a WARNING, never an error: `dag`'s exit code never changes because of
10
- * it, the same way `checkUseCases`'s coverage/literalness findings do not.
11
- *
12
- * Runs over EVERY task, unlike `usecase-check.js#checkUseCases` (which
13
- * short-circuits when the feature has no use cases): a task's own
14
- * `doneCriteria` can describe an artifact with no use case in sight, and
15
- * that must still be caught. This is why it lives in its own file — it is a
16
- * standalone lexical pass, not wiring between use cases and tasks.
17
- *
18
- * Two ways a match is silenced:
19
- * - a task WITHOUT a use case can justify itself inline, with
20
- * `Por que artefato: …` anywhere in its own `doneCriteria`. A task WITH a
21
- * use case never can — it justifies in the use case, otherwise the marker
22
- * would silence every post-condition it carries at once;
23
- * - a task carrying a use-case post-condition literally is silenced only
24
- * if THAT post-condition was justified in the use case document (the
25
- * `> [!note] Por que artefato:` callout `usecase-check.js` parses).
26
- * Justified text is removed from the task's `doneCriteria` before scanning —
27
- * not skipped as a whole task — so a justified P1 never silences an
28
- * unjustified P2 carried in the same `doneCriteria`. The scan runs on the
29
- * whitespace-collapsed text with its CASE PRESERVED: an uppercase format
30
- * acronym (`arquivo em CSV`) is the only thing that tells an export from a
31
- * directory (`arquivo em src`), and lowercasing first would erase it.
32
- */
33
-
34
- import { ARTIFACT_MARKER_RE, loadUseCases, normalize } from './usecase-check.js';
35
-
36
- /**
37
- * Case-insensitive spelling of `word` inside a regex that must stay
38
- * case-SENSITIVE elsewhere — Node 18 has no inline `(?i:…)` modifier.
39
- */
40
- const anyCase = (word) => [...word].map((c) => {
41
- const lo = c.toLowerCase();
42
- const up = c.toUpperCase();
43
- return lo === up ? c : `[${lo}${up}]`;
44
- }).join('');
45
-
46
- /**
47
- * What follows "arquivo em" in a behavioural export criterion ("exporta o
48
- * arquivo em CSV", "salva o arquivo em formato aberto"): an uppercase file
49
- * format acronym or the word `formato`. Case-sensitive on purpose — `src`,
50
- * `docs` or `lib` after "arquivo em" are places, and places are artifacts.
51
- */
52
- const FORMAT_TOKEN = String.raw`\s+(?:[A-Z]{2,5}(?![\p{L}\d_])|${anyCase('formato')}(?![\p{L}]))`;
53
-
54
- /**
55
- * Vocabulary of artifact-shaped phrasing. `term` is the label surfaced in
56
- * warnings and cited by the `morph-plan` skill prose — keep it stable, a
57
- * later parity test imports it verbatim.
58
- *
59
- * Unicode lookarounds, never `\b`: in JS, `\b` does not treat accented
60
- * letters (é, á, ó…) as word characters, so it would false-boundary inside
61
- * "não" or "binário".
62
- */
63
- export const ARTIFACT_VOCABULARY = [
64
- { term: 'grep', re: /(?<![\p{L}\d_])grep(?![\p{L}\d_])/iu },
65
- { term: 'contém a string', re: /cont[ée]m\s+as?\s+strings?/iu },
66
- { term: 'o binário', re: /(?<![\p{L}])o\s+bin[áa]rio(?![\p{L}])/iu },
67
- { term: 'nenhum arquivo', re: /(?<![\p{L}])nenhum\s+arquivo(?![\p{L}])/iu },
68
- {
69
- term: 'arquivo/pasta/diretório em',
70
- re: new RegExp(
71
- String.raw`(?<![\p{L}])(?:${anyCase('arquivo')}|${anyCase('pasta')}|${anyCase('diret')}[óoÓO]${anyCase('rio')})` +
72
- String.raw`[sS]?\s+${anyCase('em')}(?![\p{L}])(?!${FORMAT_TOKEN})`,
73
- 'u',
74
- ),
75
- },
76
- { term: 'glob **/', re: /\*\*\/|\/\*\*/ },
77
- ];
78
-
79
- /**
80
- * Vocabulary terms matched in `text`, in vocabulary order, each at most once.
81
- *
82
- * @param {string} text
83
- * @returns {string[]}
84
- */
85
- export function findArtifactTerms(text) {
86
- const s = String(text ?? '');
87
- const found = [];
88
- for (const { term, re } of ARTIFACT_VOCABULARY) {
89
- if (re.test(s)) found.push(term);
90
- }
91
- return found;
92
- }
93
-
94
- /** Whitespace-collapsed, case PRESERVED — `normalize` without the lowercasing. */
95
- function collapse(text) {
96
- return String(text).replace(/\s+/g, ' ').trim();
97
- }
98
-
99
- /**
100
- * Remove every case-insensitive occurrence of `needle` (already normalized)
101
- * from `text`, keeping the case of what remains. Relies on `text` and its
102
- * lowercase form being index-aligned — the caller guarantees it.
103
- */
104
- function cut(text, needle) {
105
- if (!needle) return text;
106
- const lower = text.toLowerCase();
107
- let out = '';
108
- let from = 0;
109
- let at;
110
- while ((at = lower.indexOf(needle, from)) !== -1) {
111
- out += `${text.slice(from, at)} `;
112
- from = at + needle.length;
113
- }
114
- return out + text.slice(from);
115
- }
116
-
117
- /**
118
- * Flag every task whose `doneCriteria` describes an artifact rather than
119
- * behaviour, after removing use-case text the author already justified.
120
- *
121
- * Fail-open: a missing `usecasesDir` still checks every task (it just has no
122
- * justifications or post-conditions to consult); a malformed `tasksData`
123
- * (no `tasks` array) or a non-string `doneCriteria` never throws.
124
- *
125
- * @param {Object} tasksData - parsed tasks.json
126
- * @param {string} usecasesDir - absolute path to `2-plan/usecases`
127
- * @returns {{ warnings: string[], flagged: Array<{ task: string, usecase: string|null, labels: string[], terms: string[] }>, checked: true }}
128
- */
129
- export function checkArtifactCriteria(tasksData, usecasesDir) {
130
- const warnings = [];
131
- const flagged = [];
132
- const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
133
-
134
- let byId = new Map();
135
- if (typeof usecasesDir === 'string') {
136
- try { byId = loadUseCases(usecasesDir); } catch { byId = new Map(); }
137
- }
138
-
139
- for (const task of tasks) {
140
- if (typeof task?.doneCriteria !== 'string') continue;
141
-
142
- let D = collapse(task.doneCriteria);
143
- if (!D) continue;
144
- // `cut` needs `D` index-aligned with its lowercase form. The rare letter
145
- // whose lowercase is longer (`İ`) breaks that; fall back to the lowercase
146
- // text, which only costs the format-acronym exception for that task.
147
- if (D.toLowerCase().length !== D.length) D = D.toLowerCase();
148
-
149
- // Inline justification is for tasks WITHOUT a use case (morph-plan: "Task
150
- // sem `usecase` justifica no próprio doneCriteria"). On a use-case task it
151
- // would silence every post-condition carried, justified or not.
152
- if (!task.usecase && ARTIFACT_MARKER_RE.test(D)) continue;
153
-
154
- const uc = task.usecase ? byId.get(task.usecase) : null;
155
- const unjustifiedLabels = [];
156
-
157
- if (uc) {
158
- // Remove justified post-condition text first, so its own vocabulary
159
- // hits (if any) never surface — and so it cannot mask what remains.
160
- // A wrapped post-condition is removed in both forms a plan legitimately
161
- // carries: the whole block, or only its first line (what `checkUseCases`
162
- // treats as THE post-condition). Longest first.
163
- for (const pc of uc.postConditions) {
164
- if (!uc.artifactJustified.has(pc.label)) continue;
165
- for (const text of [uc.artifactJustified.get(pc.label), pc.text]) {
166
- D = cut(D, normalize(text));
167
- }
168
- }
169
- // Among what is left, name the unjustified post-conditions actually
170
- // carried into this doneCriteria AND that themselves read as artifact —
171
- // judged on the whole block, since a wrapped post-condition can carry
172
- // its vocabulary on the continuation line.
173
- const carried = D.toLowerCase();
174
- for (const pc of uc.postConditions) {
175
- if (uc.artifactJustified.has(pc.label)) continue;
176
- const needle = normalize(pc.text);
177
- const block = uc.fullText.get(pc.label) ?? pc.text;
178
- if (needle && carried.includes(needle) && findArtifactTerms(block).length > 0) {
179
- unjustifiedLabels.push(pc.label);
180
- }
181
- }
182
- }
183
-
184
- const terms = findArtifactTerms(D);
185
- if (terms.length === 0) continue;
186
-
187
- const quoted = terms.join(', ');
188
- const message = unjustifiedLabels.length > 0
189
- ? `${task.id}.doneCriteria descreve artefato, não comportamento ("${quoted}") — ` +
190
- `reescreva ${task.usecase} ${unjustifiedLabels.join('/')} como efeito observável em runtime, ` +
191
- `ou justifique com "> [!note] Por que artefato:" na linha abaixo dela`
192
- : `${task.id}.doneCriteria descreve artefato, não comportamento ("${quoted}") — ` +
193
- `reescreva como efeito observável em runtime, ou acrescente "Por que artefato: …" ao próprio critério`;
194
-
195
- warnings.push(message);
196
- flagged.push({ task: task.id, usecase: task.usecase || null, labels: unjustifiedLabels, terms });
197
- }
198
-
199
- return { warnings, flagged, checked: true };
200
- }