@polymorphism-tech/morph-spec 8.37.0 → 8.38.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 (130) hide show
  1. package/CLAUDE.md +19 -6
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +16 -7
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +3 -3
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +76 -27
  10. package/framework/docs/CLI.md +55 -18
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/rubrics/backend.json +2 -2
  30. package/framework/rubrics/default.json +2 -2
  31. package/framework/rubrics/frontend.json +2 -2
  32. package/framework/rules/csharp-standards.md +1 -0
  33. package/framework/rules/morph-workflow.md +1 -0
  34. package/framework/rules/nextjs-standards.md +1 -0
  35. package/framework/schemas/config.schema.json +41 -1
  36. package/framework/schemas/feature.schema.json +5 -1
  37. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  38. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  39. package/framework/skills/core/morph-apply/SKILL.md +36 -11
  40. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  41. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  42. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  43. package/framework/skills/core/morph-implement/SKILL.md +39 -10
  44. package/framework/skills/core/morph-media/SKILL.md +1 -1
  45. package/framework/skills/core/morph-plan/SKILL.md +72 -15
  46. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  47. package/framework/skills/core/morph-review/SKILL.md +51 -13
  48. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  49. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  50. package/framework/skills/core/morph-uiux/SKILL.md +34 -9
  51. package/framework/standards/STANDARDS.json +187 -12
  52. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -3
  53. package/framework/standards/ai-agents/testing-ai.md +2 -1
  54. package/framework/standards/architecture/code-comments.md +64 -0
  55. package/framework/standards/backend/dotnet/testing.md +22 -0
  56. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  57. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  58. package/framework/standards/frontend/admin-app/forms.md +232 -0
  59. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  60. package/framework/standards/frontend/nextjs/components.md +37 -58
  61. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  62. package/framework/standards/frontend/nextjs/forms.md +31 -30
  63. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  64. package/framework/standards/frontend/nextjs/testing.md +18 -0
  65. package/framework/templates/context/design-system.md +3 -3
  66. package/framework/templates/docs/proposal.md +11 -0
  67. package/framework/templates/docs/spec.md +15 -0
  68. package/framework/templates/docs/ui-mockups.md +115 -98
  69. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +12 -6
  70. package/framework/templates/feature/decisions.md +124 -122
  71. package/package.json +1 -1
  72. package/src/commands/graph/affected.js +4 -3
  73. package/src/commands/graph/build.js +109 -18
  74. package/src/commands/graph/explain.js +8 -5
  75. package/src/commands/graph/status.js +14 -8
  76. package/src/commands/observability/cost.js +148 -18
  77. package/src/commands/project/doctor.js +82 -10
  78. package/src/commands/project/update.js +25 -0
  79. package/src/commands/project/worktree.js +73 -90
  80. package/src/commands/state/advance.js +18 -0
  81. package/src/commands/state/approve.js +15 -0
  82. package/src/commands/state/archive.js +62 -9
  83. package/src/commands/state/finish.js +62 -14
  84. package/src/commands/state/gate-check.js +8 -1
  85. package/src/commands/state/gate-decision.js +8 -4
  86. package/src/commands/state/report-check.js +90 -9
  87. package/src/commands/state/usecase.js +66 -0
  88. package/src/commands/tasks/dag.js +17 -2
  89. package/src/commands/validation/validate-feature.js +6 -1
  90. package/src/commands/validation/verify.js +20 -1
  91. package/src/dashboard/scanner/agents-scanner.js +5 -1
  92. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  93. package/src/dashboard/scanner/standards-scanner.js +2 -1
  94. package/src/lib/ai/comment-stripper.js +101 -4
  95. package/src/lib/evaluator-report.js +25 -0
  96. package/src/lib/generators/handoff-generator.js +4 -18
  97. package/src/lib/git/default-branch.js +52 -0
  98. package/src/lib/git/tree-fingerprint.js +58 -0
  99. package/src/lib/git/tree-state.js +167 -0
  100. package/src/lib/graph/god-nodes.js +12 -3
  101. package/src/lib/graph/graph-health.js +48 -2
  102. package/src/lib/graph/graph-loader.js +99 -25
  103. package/src/lib/graph/graphifyignore.js +145 -28
  104. package/src/lib/graph/sanitize.js +117 -11
  105. package/src/lib/risk-detector.js +90 -0
  106. package/src/lib/specs/acervo-format.js +70 -0
  107. package/src/lib/specs/acervo-lifecycle.js +264 -0
  108. package/src/lib/specs/adr-index.js +169 -0
  109. package/src/lib/specs/revocations.js +156 -0
  110. package/src/lib/specs/screen-checklist.js +357 -0
  111. package/src/lib/specs/usecase-check.js +20 -16
  112. package/src/lib/specs/usecase-promoter.js +103 -38
  113. package/src/lib/specs/usecase-registry.js +7 -7
  114. package/src/lib/tasks/tasks-json-parser.js +2 -3
  115. package/src/lib/telemetry/metrics.js +386 -0
  116. package/src/lib/telemetry/telemetry-reader.js +161 -51
  117. package/src/lib/telemetry/verify-event.js +79 -0
  118. package/src/lib/validators/code-budget/code-budget-validator.js +172 -0
  119. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  120. package/src/lib/validators/code-budget/index.js +9 -0
  121. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  122. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  123. package/src/lib/validators/validation-runner.js +21 -5
  124. package/src/lib/validators/verify-runner.js +11 -0
  125. package/src/lib/worktree/finish-backup.js +739 -0
  126. package/src/lib/worktree/ignored-triage.js +15 -8
  127. package/src/lib/worktree/worktree-audit.js +5 -0
  128. package/src/scripts/setup-infra.js +29 -18
  129. package/src/utils/agents-installer.js +281 -12
  130. package/src/utils/hooks-installer.js +75 -15
@@ -23,6 +23,10 @@
23
23
  * evaluator-report.md. No ship, relatório SEM a seção `## Mutações` numa
24
24
  * feature com código de produção pausa: score é opinião, mutação é prova
25
25
  * - mediaClaims + gate: object — { section, score, featureGeneratesMedia }
26
+ * - screenChecklist + gate: object — { mockups, legacy, screens, problems, exempt?,
27
+ * section?, uncovered? } derivado do 1-design/mockups.md. No uiux, checklist
28
+ * de tela incompleto pausa; no ship, item decidido sem a conferência do
29
+ * avaliador pausa. Mockup legado é isento nos dois, com a razão listada
26
30
  * - evalScore: number — morph-eval score (pause if < 9)
27
31
  * - taskCount: number — total tasks (pause if > 20)
28
32
  */
@@ -133,6 +137,35 @@ function e2eScopeRefusal(klass, scope, reason) {
133
137
  + ' rode `morph-spec verify {feature}` (sem task) antes do ship';
134
138
  }
135
139
 
140
+ /** A razão gravada no sinal quando o checklist de tela isenta um mockup legado. */
141
+ export const LEGACY_MOCKUP_EXEMPTION = 'checklist dispensado: mockup no formato antigo';
142
+
143
+ /** Os dois primeiros itens de uma lista, e quantos ficaram de fora. */
144
+ function firstOf(items) {
145
+ const shown = items.slice(0, 2).join('; ');
146
+ return items.length > 2 ? `${shown} (+${items.length - 2})` : shown;
147
+ }
148
+
149
+ /**
150
+ * Por que o checklist de tela pausa o Gate uiux, ou null. Exportada porque o
151
+ * `approve`/`advance` com assinatura `auto` recusam pela MESMA regra: uma
152
+ * aprovação automática que passasse onde o gate-check pausa seria o atalho.
153
+ *
154
+ * Mockup ausente (`mockups: false`) não conta — nem toda feature de UI tem
155
+ * tela —, e o legado chega sem problemas: a auditoria o isenta.
156
+ *
157
+ * @param {object|undefined} screenChecklist - o sinal de `collectScreenChecklist`
158
+ * @returns {string|null}
159
+ */
160
+ export function uiuxChecklistPause(screenChecklist) {
161
+ if (screenChecklist?.mockups !== true) return null;
162
+ const problems = screenChecklist.problems ?? [];
163
+ if (problems.length === 0) return null;
164
+ return `checklist de tela incompleto em 1-design/mockups.md: ${firstOf(problems)}`
165
+ + ' — volume, paginação, ordenação, filtro, os três estados, mobile e abas por tela'
166
+ + ' ("não se aplica" vale, se escrito)';
167
+ }
168
+
136
169
  /**
137
170
  * @param {object} config - .morph/config/config.json contents (uses `trust`)
138
171
  * @param {object} signals
@@ -362,6 +395,36 @@ export function shouldPauseGate(config, signals) {
362
395
  };
363
396
  }
364
397
 
398
+ // No fecho do DESIGN: o mockup declara telas sem o checklist de tela
399
+ // completo. É ele que faz paginação, ordenação, filtro e os três estados
400
+ // virarem `doneCriteria` — sem ele, uma lista de 2 000 linhas é aprovada
401
+ // listando tudo, e o implementador entrega exatamente o critério.
402
+ if (signals.gate === 'uiux') {
403
+ const reason = uiuxChecklistPause(signals.screenChecklist);
404
+ if (reason) return { pause: true, reason };
405
+ }
406
+
407
+ // No SHIP, depois da cláusula de mídia e pela mesma disciplina: o design
408
+ // decidiu itens de tela e o avaliador não registrou a conferência deles
409
+ // contra a tela entregue. Cobra-se o item DEVIDO (`uncovered`): tela toda
410
+ // "não se aplica", mockup legado ou ausente não devem nada.
411
+ if (signals.gate === 'review' && (signals.screenChecklist?.uncovered?.length ?? 0) > 0) {
412
+ const { section, uncovered } = signals.screenChecklist;
413
+ if (section !== true) {
414
+ return {
415
+ pause: true,
416
+ reason: `relatório do avaliador sem a seção \`## Checklist de tela\` — o design decidiu ${firstOf(uncovered)}`
417
+ + ' e a avaliação não registrou a conferência contra a tela entregue'
418
+ + ' (rubrica evals/rubrics/contracts.md, "Contrato de tela")',
419
+ };
420
+ }
421
+ return {
422
+ pause: true,
423
+ reason: `a seção \`## Checklist de tela\` não confere ${firstOf(uncovered)}`
424
+ + ' — uma linha por item decidido de cada tela, mesmo quando não verificável',
425
+ };
426
+ }
427
+
365
428
  if (typeof signals.evalScore === 'number' && signals.evalScore < MIN_EVAL_SCORE) {
366
429
  return {
367
430
  pause: true,
@@ -455,6 +518,33 @@ export function getRiskSignals(signals) {
455
518
  }
456
519
  }
457
520
 
521
+ // Listado mesmo quando NÃO pausa: a isenção (sem mockup, legado, nada
522
+ // devido) e o checklist conferido precisam ser distinguíveis de uma omissão.
523
+ if ((signals.gate === 'uiux' || signals.gate === 'review') && signals.screenChecklist) {
524
+ const { mockups, legacy, exempt, screens = [], problems = [], section, uncovered = [] } = signals.screenChecklist;
525
+ if (!mockups) {
526
+ out.push('sem 1-design/mockups.md — checklist de tela não se aplica');
527
+ } else if (legacy === true) {
528
+ out.push(exempt ?? LEGACY_MOCKUP_EXEMPTION);
529
+ } else if (signals.gate === 'uiux') {
530
+ out.push(problems.length > 0
531
+ ? `checklist de tela incompleto (${problems.length} problema(s))`
532
+ : `checklist de tela completo (${screens.length} tela(s))`);
533
+ } else if (uncovered.length > 0) {
534
+ out.push(section !== true
535
+ ? 'relatório sem a seção `## Checklist de tela`'
536
+ : `checklist de tela sem conferência em ${uncovered.length} tela(s)`);
537
+ } else if (screens.length === 0) {
538
+ // O design passou pela uiux sem tela nenhuma: nada a cobrar no ship, mas
539
+ // não pode ler igual a uma tela toda "não se aplica".
540
+ out.push(`checklist de tela: ${firstOf(problems)}`);
541
+ } else if (section === true) {
542
+ out.push(`checklist de tela conferido (${screens.length} tela(s))`);
543
+ } else {
544
+ out.push(`checklist de tela sem item a conferir (${screens.length} tela(s))`);
545
+ }
546
+ }
547
+
458
548
  return out;
459
549
  }
460
550
 
@@ -0,0 +1,70 @@
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
+ }
@@ -0,0 +1,264 @@
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
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * The index of the ADRs that still hold across the project:
3
+ * `.morph/specs/adrs/`, one file per ADR, committed next to the use-case acervo.
4
+ *
5
+ * Every feature writes ADRs into its own `decisions.md`, and most of them only
6
+ * matter inside that feature. The index keeps the few that set a project-wide
7
+ * rule, so brainstorming and plan read one folder instead of every archived
8
+ * `decisions.md` — those stay as history, not as instruction.
9
+ *
10
+ * One file per ADR (`{feature}--ADR-{nnn}.md`), like the use-case acervo:
11
+ * parallel features that index different ADRs never touch the same file, so
12
+ * their archive commits merge cleanly — a single shared page conflicted on the
13
+ * second merge.
14
+ *
15
+ * Maintained by finish/archive alone, inside the archive commit:
16
+ * - enters: an ADR of the closing feature whose block carries `**Alcance:** projeto`;
17
+ * - leaves: an ADR the closing feature's `spec.md` revokes in full (file deleted);
18
+ * - narrows: one it revokes in part keeps its file, with `scope:` where it still holds.
19
+ * Nothing to add or revoke → nothing is touched (nor created).
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'fs';
23
+ import { basename, join } from 'path';
24
+ import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
25
+ import { readFrontmatterField, setFrontmatterField } from './usecase-promoter.js';
26
+
27
+ /** Project-relative path of the index folder (git pathspec-ready). */
28
+ export const ADR_INDEX_PATHSPEC = join('.morph', 'specs', 'adrs');
29
+
30
+ const ADR_HEADING_RE = /^(#{2,6})\s+(ADR-\d+)\b[\s:—–-]*(.*?)\s*$/;
31
+ const ANY_HEADING_RE = /^(#{1,6})\s/;
32
+ /** `**Alcance:** projeto` — also `**Alcance**: projeto`. */
33
+ const PROJECT_SCOPE_RE = /^\s*\*\*Alcance:?\*\*:?\s*projeto\b/i;
34
+ const INDEX_FILE_RE = /^([a-z0-9][a-z0-9-]*)--(ADR-\d+)\.md$/;
35
+
36
+ /** `admin/ADR-001` ↔ `admin--ADR-001.md`. */
37
+ export const adrFileName = (ref) => `${ref.replace('/', '--')}.md`;
38
+ const refOf = (name) => {
39
+ const m = INDEX_FILE_RE.exec(name);
40
+ return m ? `${m[1]}/${m[2]}` : null;
41
+ };
42
+
43
+ /**
44
+ * The ADRs of one `decisions.md` marked as project-wide, each with its block.
45
+ *
46
+ * @param {string} content
47
+ * @returns {Array<{ adr: string, title: string, body: string }>}
48
+ */
49
+ export function projectAdrs(content) {
50
+ const out = [];
51
+ let current = null; // { adr, title, level, marked, lines }
52
+ const close = () => {
53
+ if (current?.marked) out.push({ adr: current.adr, title: current.title, body: current.lines.join('\n').trim() });
54
+ current = null;
55
+ };
56
+
57
+ for (const line of String(content ?? '').split(/\r?\n/)) {
58
+ const adr = ADR_HEADING_RE.exec(line);
59
+ if (adr) {
60
+ close();
61
+ current = { adr: adr[2], title: adr[3], level: adr[1].length, marked: false, lines: [] };
62
+ continue;
63
+ }
64
+ const heading = ANY_HEADING_RE.exec(line);
65
+ if (current && heading && heading[1].length <= current.level) { close(); continue; }
66
+ if (!current) continue;
67
+ if (PROJECT_SCOPE_RE.test(line)) current.marked = true;
68
+ current.lines.push(line);
69
+ }
70
+ close();
71
+ return out;
72
+ }
73
+
74
+ function renderAdrFile({ ref, title, body }, feature) {
75
+ const decisions = `.morph/archive/${feature}/${OUTPUT_SCHEMA.decisions.phaseDir}/${OUTPUT_SCHEMA.decisions.filename}`;
76
+ return [
77
+ '---',
78
+ `adr: ${ref}`,
79
+ `title: ${JSON.stringify(title)}`,
80
+ `source: ${decisions}`,
81
+ '---',
82
+ '',
83
+ `# ${ref}: ${title}`,
84
+ '',
85
+ '> Mantido pelo `morph-spec finish` — não edite à mão. Vale no projeto até que o `spec.md` de uma',
86
+ '> feature o revogue: por inteiro, e este arquivo sai; em parte, e `scope:` diz onde ele ainda vale.',
87
+ '',
88
+ body,
89
+ '',
90
+ ].join('\n');
91
+ }
92
+
93
+ /**
94
+ * The ADRs the index holds; `[]` when there is none or it cannot be read.
95
+ *
96
+ * @returns {Array<{ ref: string, file: string, title: string|null, scope: string|null }>}
97
+ */
98
+ export function readAdrIndex(cwd) {
99
+ const dir = join(cwd, ADR_INDEX_PATHSPEC);
100
+ try {
101
+ if (!existsSync(dir)) return [];
102
+ return readdirSync(dir).sort().flatMap((file) => {
103
+ const ref = refOf(file);
104
+ if (!ref) return [];
105
+ let content = '';
106
+ try { content = readFileSync(join(dir, file), 'utf8'); } catch { /* listed, unreadable */ }
107
+ return [{ ref, file, title: readFrontmatterField(content, 'title'), scope: readFrontmatterField(content, 'scope') }];
108
+ });
109
+ } catch {
110
+ return [];
111
+ }
112
+ }
113
+
114
+ /**
115
+ * Apply a closing feature to the index: its project-wide ADRs enter, and the
116
+ * ADR revocations its `spec.md` declared delete a file (total) or narrow it
117
+ * (parcial).
118
+ *
119
+ * @param {string} cwd
120
+ * @param {string} featureDir - the feature folder (already archived, at close time)
121
+ * @param {Array<{ id: string, change: 'total'|'parcial', scope: string|null }>} revocations - ADR entries of `parseRevocations`
122
+ * @returns {{ changed: boolean, added: string[], revoked: string[], narrowed: string[],
123
+ * problems: Array<{ id: string, reason: string, message: string }> }}
124
+ */
125
+ export function updateAdrIndex(cwd, featureDir, revocations = []) {
126
+ const result = { changed: false, added: [], revoked: [], narrowed: [], problems: [] };
127
+ const feature = basename(featureDir);
128
+ const dir = join(cwd, ADR_INDEX_PATHSPEC);
129
+
130
+ let marked = [];
131
+ try {
132
+ const decisions = join(featureDir, OUTPUT_SCHEMA.decisions.phaseDir, OUTPUT_SCHEMA.decisions.filename);
133
+ if (existsSync(decisions)) marked = projectAdrs(readFileSync(decisions, 'utf8'));
134
+ } catch { /* unreadable decisions.md — nothing enters */ }
135
+
136
+ for (const { adr, title, body } of marked) {
137
+ const ref = `${feature}/${adr}`;
138
+ const path = join(dir, adrFileName(ref));
139
+ const next = renderAdrFile({ ref, title, body }, feature);
140
+ const existed = existsSync(path);
141
+ if (existed && readFileSync(path, 'utf8') === next) continue;
142
+ mkdirSync(dir, { recursive: true });
143
+ writeFileSync(path, next, 'utf8');
144
+ result.changed = true;
145
+ if (!existed) result.added.push(ref);
146
+ }
147
+
148
+ for (const rev of revocations) {
149
+ const path = join(dir, adrFileName(rev.id));
150
+ if (!existsSync(path)) {
151
+ result.problems.push({
152
+ id: rev.id,
153
+ reason: 'adr-not-indexed',
154
+ message: `${rev.id} não está no índice de ADRs ativos (${ADR_INDEX_PATHSPEC.replace(/\\/g, '/')}/) — nada a retirar; o decisions.md arquivado segue como história`,
155
+ });
156
+ continue;
157
+ }
158
+ if (rev.change === 'total') {
159
+ rmSync(path, { force: true });
160
+ result.revoked.push(rev.id);
161
+ } else {
162
+ writeFileSync(path, setFrontmatterField(readFileSync(path, 'utf8'), 'scope', JSON.stringify(rev.scope)), 'utf8');
163
+ result.narrowed.push(rev.id);
164
+ }
165
+ result.changed = true;
166
+ }
167
+
168
+ return result;
169
+ }