@polymorphism-tech/morph-spec 8.46.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.
- package/CLAUDE.md +53 -44
- package/README.md +3 -4
- package/bin/morph-spec.js +38 -55
- package/bin/validate.js +5 -134
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +2 -2
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +41 -101
- package/framework/MORPH.md +95 -252
- package/framework/agents.json +75 -18
- package/framework/commands/create-umbraco-lp.md +2 -0
- package/framework/commands/migrate-lp-to-umbraco.md +2 -0
- package/framework/commands/morph-apply.md +5 -28
- package/framework/commands/morph-archive.md +2 -2
- package/framework/commands/morph-hotfix.md +8 -8
- package/framework/commands/morph-infra.md +17 -203
- package/framework/commands/morph-media.md +3 -0
- package/framework/commands/morph-preflight.md +7 -31
- package/framework/commands/morph-proposal.md +2 -50
- package/framework/commands/morph-status.md +3 -3
- package/framework/commands/morph-troubleshoot.md +2 -14
- package/framework/docs/CLI.md +585 -286
- package/framework/evals/review-checklist.md +82 -41
- package/framework/hooks/README.md +10 -11
- package/framework/hooks/claude-code/core/comment-guard.js +1 -1
- package/framework/hooks/claude-code/core/denylist.json +33 -3
- package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
- package/framework/hooks/claude-code/core/gate-guard.js +2 -11
- package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
- package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
- package/framework/hooks/claude-code/core/state-sync.js +17 -19
- package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
- package/framework/hooks/claude-code/core/validate-completion.js +33 -97
- package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
- package/framework/hooks/claude-code/core/worktree-context.js +5 -4
- package/framework/hooks/claude-code/statusline.py +55 -41
- package/framework/hooks/package.json +3 -0
- package/framework/hooks/shared/activity-logger.js +1 -1
- package/framework/hooks/shared/build-check.js +10 -6
- package/framework/hooks/shared/command-tokens.js +169 -0
- package/framework/hooks/shared/comment-rules.js +1 -1
- package/framework/hooks/shared/comment-scanner.js +10 -23
- package/framework/hooks/shared/comment-stripper.js +1 -1
- package/framework/hooks/shared/e2e-check.js +67 -31
- package/framework/hooks/shared/errata.js +72 -0
- package/framework/hooks/shared/feature-locator.js +1 -1
- package/framework/hooks/shared/frontend-locations.js +6 -0
- package/framework/hooks/shared/gate-question.js +18 -0
- package/framework/hooks/shared/phase-utils.js +15 -32
- package/framework/hooks/shared/slice-progress.js +118 -0
- package/framework/hooks/shared/spawn-command.js +23 -0
- package/framework/hooks/shared/state-reader.js +61 -87
- package/framework/hooks/shared/state-writer.js +3 -3
- package/framework/mcp/registry.json +3 -4
- package/framework/phases.json +23 -39
- package/framework/rules/ai-agents-standards.md +18 -16
- package/framework/rules/asaas-standards.md +3 -1
- package/framework/rules/csharp-standards.md +10 -8
- package/framework/rules/data-standards.md +5 -5
- package/framework/rules/dotnet-templates.md +1 -1
- package/framework/rules/frontend-standards.md +4 -2
- package/framework/rules/gsap-standards.md +10 -5
- package/framework/rules/hangfire-standards.md +3 -1
- package/framework/rules/infrastructure-standards.md +4 -2
- package/framework/rules/neon-auth-standards.md +3 -1
- package/framework/rules/neon-standards.md +6 -4
- package/framework/rules/nextjs-standards.md +13 -11
- package/framework/rules/nextjs-templates.md +1 -1
- package/framework/rules/resend-standards.md +3 -1
- package/framework/schemas/config.schema.json +14 -9
- package/framework/schemas/feature.schema.json +15 -52
- package/framework/skills/README.md +4 -5
- package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
- package/framework/skills/core/morph-apply/SKILL.md +150 -223
- package/framework/skills/core/morph-hotfix/SKILL.md +34 -43
- package/framework/skills/core/morph-machine/SKILL.md +4 -4
- package/framework/skills/core/morph-media/SKILL.md +3 -3
- package/framework/skills/core/morph-plan/SKILL.md +138 -300
- package/framework/skills/core/morph-review/SKILL.md +112 -137
- package/framework/skills/core/morph-standards/SKILL.md +29 -41
- package/framework/skills/core/morph-uiux/SKILL.md +78 -153
- package/framework/standards/STANDARDS.json +90 -1219
- package/framework/standards/STANDARDS.meta.json +1305 -0
- package/framework/standards/ai-agents/evals-with-cache.md +2 -3
- package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
- package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
- package/framework/standards/ai-agents/prompt-sources.md +6 -4
- package/framework/standards/ai-agents/providers/model-registry.md +1 -1
- package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
- package/framework/standards/ai-agents/setup.md +2 -2
- package/framework/standards/ai-agents/structured-output.md +1 -1
- package/framework/standards/ai-agents/sweet-spot.md +1 -1
- package/framework/standards/architecture/code-comments.md +8 -8
- package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
- package/framework/standards/backend/api/validation.md +135 -47
- package/framework/standards/backend/database/postgresql/database.md +11 -7
- package/framework/standards/backend/database/vector-search-rag.md +207 -324
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
- package/framework/standards/backend/dotnet/result-pattern.md +40 -6
- package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
- package/framework/standards/backend/dotnet/scrutor.md +1 -1
- package/framework/standards/backend/dotnet/testing.md +1 -1
- package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
- package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
- package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
- package/framework/standards/data/nosql/cache/redis.md +120 -44
- package/framework/standards/data/nosql/cosmos-db.md +114 -71
- package/framework/standards/frontend/design-system/animations.md +1 -1
- package/framework/standards/frontend/design-system/naming.md +1 -1
- package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
- package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
- package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
- package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
- package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
- package/framework/standards/integration/mcp/mcp-tools.md +7 -7
- package/framework/templates/README.md +2 -3
- package/framework/templates/REGISTRY.json +6 -170
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
- package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
- package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
- package/framework/templates/docs/plan.md +72 -0
- package/framework/templates/dotnet/ai-kit/README.md +1 -9
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
- package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
- package/framework/templates/infrastructure/github/README.md +2 -1
- package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
- package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
- package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
- package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +2 -2
- package/src/commands/observability/cost.js +18 -60
- package/src/commands/observability/telemetry.js +3 -5
- package/src/commands/project/doctor-procs.js +1 -1
- package/src/commands/project/doctor.js +9 -9
- package/src/commands/project/fleet.js +4 -4
- package/src/commands/project/heavy.js +3 -3
- package/src/commands/project/init.js +6 -1
- package/src/commands/project/setup-infra-cmd.js +2 -1
- package/src/commands/project/stack-scan.js +58 -0
- package/src/commands/project/standards.js +79 -25
- package/src/commands/project/status.js +29 -69
- package/src/commands/project/update.js +33 -19
- package/src/commands/project/worktree.js +1 -1
- package/src/commands/state/advance.js +62 -46
- package/src/commands/state/approve.js +95 -138
- package/src/commands/state/archive.js +20 -84
- package/src/commands/state/auto-approval.js +68 -0
- package/src/commands/state/create.js +28 -1
- package/src/commands/state/delete.js +1 -1
- package/src/commands/state/finish.js +11 -23
- package/src/commands/state/gate-check.js +86 -95
- package/src/commands/state/gate-decision.js +6 -4
- package/src/commands/state/handoff.js +1 -1
- package/src/commands/state/list.js +9 -9
- package/src/commands/state/report-check.js +157 -311
- package/src/commands/state/retype.js +9 -3
- package/src/commands/validation/mutate.js +77 -14
- package/src/commands/validation/plan-check.js +81 -0
- package/src/commands/validation/validate-feature.js +60 -135
- package/src/commands/validation/verify.js +41 -82
- package/src/core/paths/output-schema.js +20 -67
- package/src/core/state/gate-order.js +5 -6
- package/src/core/state/lifecycle-audit.js +2 -2
- package/src/core/state/state-manager.js +17 -98
- package/src/core/templates/template-registry.js +2 -2
- package/src/core/templates/template-seed.js +22 -0
- package/src/core/work-types.js +10 -10
- package/src/dashboard/app/App.jsx +0 -2
- package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
- package/src/dashboard/app/components/CommandPalette.jsx +7 -8
- package/src/dashboard/app/components/EntityLink.jsx +1 -2
- package/src/dashboard/app/components/Markdown.jsx +1 -1
- package/src/dashboard/app/components/Sidebar.jsx +1 -2
- package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
- package/src/dashboard/app/lib/phases.js +2 -4
- package/src/dashboard/app/pages/Activity.jsx +1 -1
- package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
- package/src/dashboard/app/pages/Features.jsx +9 -9
- package/src/dashboard/app/pages/Guide.jsx +2 -2
- package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
- package/src/dashboard/app/pages/RulesMap.jsx +3 -3
- package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
- package/src/dashboard/app/pages/Standards.jsx +1 -1
- package/src/dashboard/scanner/activity-scanner.js +6 -16
- package/src/dashboard/scanner/agents-scanner.js +1 -1
- package/src/dashboard/scanner/docs-scanner.js +5 -23
- package/src/dashboard/scanner/features-scanner.js +95 -60
- package/src/dashboard/scanner/index.js +0 -2
- package/src/dashboard/scanner/project-api.js +1 -1
- package/src/dashboard/scanner/rules-scanner.js +15 -10
- package/src/dashboard/scanner/skills-scanner.js +2 -3
- package/src/dashboard/scanner/standards-scanner.js +2 -1
- package/src/lib/ci/ci-verify.js +17 -1
- package/src/lib/ci/github-ci.js +1 -1
- package/src/lib/detectors/index.js +3 -8
- package/src/lib/generators/handoff-generator.js +34 -65
- package/src/lib/generators/settings-generator.js +11 -89
- package/src/lib/git/evidence-staleness.js +1 -1
- package/src/lib/git/tree-fingerprint.js +19 -7
- package/src/lib/git/tree-state.js +2 -2
- package/src/lib/heavy/cleanup.js +1 -5
- package/src/lib/installers/memory-installer.js +6 -8
- package/src/lib/mutation/commands.js +3 -2
- package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
- package/src/lib/mutation/plan.js +14 -6
- package/src/lib/mutation/runner.js +1 -1
- package/src/lib/mutation/subset.js +46 -9
- package/src/lib/plan/artifact-vocabulary.js +60 -0
- package/src/lib/plan/checks.js +234 -0
- package/src/lib/plan/plan-parser.js +209 -0
- package/src/lib/plan/plan-template.js +18 -0
- package/src/lib/review-reports.js +222 -0
- package/src/lib/risk-detector.js +134 -135
- package/src/lib/specs/adr-index.js +50 -69
- package/src/lib/specs/design-seed.js +18 -0
- package/src/lib/specs/frontmatter.js +44 -0
- package/src/lib/specs/gfm-table.js +17 -0
- package/src/lib/specs/old-format.js +68 -0
- package/src/lib/specs/revocations.js +37 -95
- package/src/lib/specs/screen-checklist.js +18 -17
- package/src/lib/stack/dotnet-build-ignores.js +47 -0
- package/src/lib/stack/stack-profile.js +1 -1
- package/src/lib/stack/stack-scan.js +267 -0
- package/src/lib/standards/registry.js +43 -0
- package/src/lib/standards/sections.js +58 -0
- package/src/lib/tasks/eval-node.js +4 -12
- package/src/lib/tasks/test-nodes.js +1 -74
- package/src/lib/tasks/test-runner.js +75 -117
- package/src/lib/telemetry/metrics.js +8 -69
- package/src/lib/telemetry/telemetry-reader.js +5 -118
- package/src/lib/telemetry/verify-event.js +5 -7
- package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
- package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
- package/src/lib/validators/reuse/reuse-validator.js +24 -48
- package/src/lib/validators/reuse/symbol-scanner.js +2 -4
- package/src/lib/validators/traceability-check.js +85 -129
- package/src/lib/validators/validation-runner.js +8 -88
- package/src/lib/validators/verify-runner.js +134 -301
- package/src/lib/worktree/docker-audit.js +1 -2
- package/src/lib/worktree/fleet-scanner.js +25 -38
- package/src/scripts/setup-infra.js +42 -22
- package/src/utils/agents-installer.js +92 -13
- package/src/utils/claude-settings-manager.js +0 -35
- package/src/utils/file-copier.js +34 -6
- package/src/utils/hooks-installer.js +16 -9
- package/src/utils/skills-installer.js +1 -2
- package/framework/commands/commit.md +0 -166
- package/framework/evals/trace-schema.md +0 -67
- package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
- package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
- package/framework/hooks/git/pre-commit/specs.sh +0 -50
- package/framework/hooks/shared/tasks-validator.js +0 -111
- package/framework/rubrics/backend.json +0 -41
- package/framework/rubrics/default.json +0 -36
- package/framework/rubrics/frontend.json +0 -36
- package/framework/rules/morph-task-tracking.md +0 -54
- package/framework/rules/morph-workflow.md +0 -244
- package/framework/schemas/tasks.schema.json +0 -79
- package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
- package/framework/skills/core/morph-implement/SKILL.md +0 -194
- package/framework/skills/core/morph-proposal/SKILL.md +0 -152
- package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
- package/framework/templates/docs/proposal.md +0 -192
- package/framework/templates/docs/spec.md +0 -57
- package/framework/templates/docs/ui-components.md +0 -52
- package/framework/templates/docs/ui-design-system.md +0 -76
- package/framework/templates/docs/ui-flows.md +0 -167
- package/framework/templates/examples/spec-examples.md +0 -91
- package/framework/templates/feature/decisions.md +0 -124
- package/framework/templates/feature/recap.md +0 -145
- package/framework/templates/project/validate.js +0 -97
- package/framework/templates/tasks.template.json +0 -17
- package/src/commands/state/usecase.js +0 -143
- package/src/commands/tasks/dag.js +0 -181
- package/src/dashboard/app/pages/Rubrics.jsx +0 -97
- package/src/dashboard/scanner/rubrics-scanner.js +0 -12
- package/src/lib/agents/output-collector.js +0 -76
- package/src/lib/evaluator-report.js +0 -91
- package/src/lib/specs/acervo-format.js +0 -70
- package/src/lib/specs/acervo-lifecycle.js +0 -264
- package/src/lib/specs/done-criteria-check.js +0 -200
- package/src/lib/specs/usecase-check.js +0 -301
- package/src/lib/specs/usecase-promoter.js +0 -301
- package/src/lib/specs/usecase-registry.js +0 -237
- package/src/lib/tasks/dispatch-planner.js +0 -319
- package/src/lib/tasks/mermaid-renderer.js +0 -132
- package/src/lib/tasks/task-classifier.js +0 -30
- package/src/lib/tasks/task-parser.js +0 -209
- package/src/lib/tasks/tasks-json-parser.js +0 -296
- package/src/lib/tasks/test-scope.js +0 -597
- package/src/lib/validators/architecture/architecture-validator.js +0 -270
- package/src/lib/validators/architecture/index.js +0 -7
- package/src/lib/validators/css/css-validator.js +0 -340
- package/src/lib/validators/css/index.js +0 -7
- package/src/lib/validators/design-system/design-system-validator.js +0 -237
- package/src/lib/validators/design-system/index.js +0 -7
- package/src/lib/validators/score-aggregator.js +0 -244
- package/src/lib/validators/ui/index.js +0 -7
- package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
|
@@ -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
|
-
}
|