@polymorphism-tech/morph-spec 8.4.0 → 8.6.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/README.md +8 -5
- package/bin/morph-spec.js +13 -10
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +7 -4
- package/docs/QUICKSTART.md +7 -4
- package/framework/CLAUDE.md +11 -8
- package/framework/MORPH.md +24 -4
- package/framework/commands/morph-archive.md +11 -4
- package/framework/evals/rubrics/test-coverage.md +4 -1
- package/framework/hooks/shared/phase-utils.js +1 -0
- package/framework/rules/morph-workflow.md +2 -2
- package/framework/schemas/feature.schema.json +2 -2
- package/framework/schemas/tasks.schema.json +6 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +8 -5
- package/framework/skills/core/morph-brainstorming/SKILL.md +4 -3
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
- package/framework/skills/core/morph-implement/SKILL.md +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +83 -13
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +4 -2
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/package.json +1 -1
- package/src/commands/project/worktree.js +154 -357
- package/src/commands/state/archive.js +186 -69
- package/src/commands/state/create.js +46 -2
- package/src/commands/state/finish.js +433 -0
- package/src/commands/tasks/dag.js +25 -2
- package/src/commands/validation/validate-feature.js +9 -2
- package/src/core/paths/output-schema.js +66 -6
- package/src/core/state/state-manager.js +2 -2
- package/src/dashboard/scanner/state-scanner.js +1 -1
- package/src/lib/agents/output-collector.js +5 -0
- package/src/lib/generators/handoff-generator.js +6 -4
- package/src/lib/git/default-branch.js +73 -0
- package/src/lib/specs/usecase-check.js +173 -0
- package/src/lib/specs/usecase-promoter.js +120 -0
- package/src/lib/tasks/tasks-json-parser.js +8 -0
- package/src/lib/worktree/env-allocator.js +1 -1
- package/src/utils/file-copier.js +2 -2
|
@@ -123,7 +123,7 @@ export function atomicWriteJson(targetPath, data) {
|
|
|
123
123
|
* A formatting mismatch is NOT free, though: the guard compares bytes, so a
|
|
124
124
|
* sibling committed with a trailing newline fails it and gets rewritten. The
|
|
125
125
|
* content is equivalent, but git sees ` M` on a file this caller never meant to
|
|
126
|
-
* touch — enough to fail a whole-tree clean check (see
|
|
126
|
+
* touch — enough to fail a whole-tree clean check (see the finish teardown's clearRootWorktreeRegistration).
|
|
127
127
|
* And the guard only skips IDENTICAL content: a concurrent instance's CHANGED
|
|
128
128
|
* write to a sibling would still be clobbered by the sweep.
|
|
129
129
|
*/
|
|
@@ -373,7 +373,7 @@ export function saveState(state) {
|
|
|
373
373
|
// and shows up as ` M` — dirtying the tree on behalf of a feature the
|
|
374
374
|
// caller never touched. Callers that need to change ONE feature's field
|
|
375
375
|
// should write that feature.json directly instead of round-tripping state
|
|
376
|
-
// through here (see
|
|
376
|
+
// through here (see the finish teardown's clearRootWorktreeRegistration).
|
|
377
377
|
const thinFeatures = {};
|
|
378
378
|
for (const [name, feature] of Object.entries(features)) {
|
|
379
379
|
writeFeatureJson(name, feature);
|
|
@@ -35,7 +35,7 @@ export function scanState(projectDir) {
|
|
|
35
35
|
// (.worktrees/{wt}/.morph/features/*/feature.json). In the v8 multi-instance
|
|
36
36
|
// model a feature's authoritative feature.json is committed on its
|
|
37
37
|
// `morph/{feature}` branch and only lands in the root .morph/features/ after
|
|
38
|
-
// `
|
|
38
|
+
// `morph-spec finish`. This is read-only visibility for the dashboard — root
|
|
39
39
|
// features (the merged, authoritative state) always take precedence, and
|
|
40
40
|
// worktree-sourced entries are tagged with `_worktree` so the UI can
|
|
41
41
|
// distinguish them.
|
|
@@ -42,6 +42,11 @@ export function collectPhaseOutputs(projectRoot, featureName) {
|
|
|
42
42
|
const results = [];
|
|
43
43
|
|
|
44
44
|
for (const [key, entry] of Object.entries(OUTPUT_SCHEMA)) {
|
|
45
|
+
// Multi-file types resolve to a DIRECTORY — there is no single document to
|
|
46
|
+
// weigh as one source. Skipped explicitly rather than left to fail as an
|
|
47
|
+
// EISDIR swallowed by the catch below.
|
|
48
|
+
if (entry.multi) continue;
|
|
49
|
+
|
|
45
50
|
const absPath = getAbsoluteOutputPath(projectRoot, featureName, key);
|
|
46
51
|
if (!existsSync(absPath)) continue;
|
|
47
52
|
|
|
@@ -29,9 +29,11 @@ const PHASE_DIRS = {
|
|
|
29
29
|
review: '4-review',
|
|
30
30
|
};
|
|
31
31
|
|
|
32
|
-
/** Ordered pointer candidates — only existing
|
|
32
|
+
/** Ordered pointer candidates — only existing paths make it into the doc. */
|
|
33
33
|
const POINTER_CANDIDATES = [
|
|
34
|
-
['0-proposal/proposal.md', 'proposta
|
|
34
|
+
['0-proposal/proposal.md', 'proposta: problema, escopo e riscos'],
|
|
35
|
+
// Behaviour before solution — the same order morph-plan writes them in.
|
|
36
|
+
['2-plan/usecases', 'use cases: comportamento e pós-condições testáveis (pasta)'],
|
|
35
37
|
['2-plan/spec.md', 'especificação técnica'],
|
|
36
38
|
['2-plan/mandate.md', 'contrato de implementação (leia antes de cada task)'],
|
|
37
39
|
['2-plan/tasks.json', 'DAG de tasks (status vivo)'],
|
|
@@ -111,8 +113,8 @@ function nextAction(feature, phase, tasks, featureName) {
|
|
|
111
113
|
}
|
|
112
114
|
case 'review':
|
|
113
115
|
return approved('review')
|
|
114
|
-
? `Gate 3 aprovado — feche: \`morph-spec
|
|
115
|
-
: `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`
|
|
116
|
+
? `Gate 3 aprovado — feche: \`morph-spec finish ${featureName} --pr\` (abre PR) ou \`--merge\` (merge local na default). Arquiva + integra num passo.`
|
|
117
|
+
: `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`morph-spec finish ${featureName} --pr|--merge\`.`;
|
|
116
118
|
default:
|
|
117
119
|
return `Rode \`morph-spec status ${featureName}\` para orientar o próximo passo.`;
|
|
118
120
|
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { execFileSync } from 'child_process';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Git helpers shared by `create` (which branches `morph/{f}` off the default)
|
|
5
|
+
* and `finish` (which merges/PRs back into it). Kept tiny and pure so both
|
|
6
|
+
* call sites read the same source of truth for "what is the default branch"
|
|
7
|
+
* and "is there a remote to push to".
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Does a local branch exist? execFile (argv, never a cmd.exe string) so the
|
|
12
|
+
* name reaches git as one token — the same anti-injection discipline the rest
|
|
13
|
+
* of the worktree/finish path uses.
|
|
14
|
+
*
|
|
15
|
+
* @param {string} branch
|
|
16
|
+
* @param {string} cwd
|
|
17
|
+
* @returns {boolean}
|
|
18
|
+
*/
|
|
19
|
+
function localBranchExists(branch, cwd) {
|
|
20
|
+
try {
|
|
21
|
+
execFileSync('git', ['show-ref', '--verify', '--quiet', `refs/heads/${branch}`], { cwd, stdio: 'pipe' });
|
|
22
|
+
return true;
|
|
23
|
+
} catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Resolve the repository's default branch NAME (e.g. `main`), never assuming
|
|
30
|
+
* `master`. Strategy, in order:
|
|
31
|
+
* 1. `refs/remotes/origin/HEAD` symbolic ref — the remote's declared default,
|
|
32
|
+
* the authoritative answer when a remote is configured.
|
|
33
|
+
* 2. A local `main`, then `master` — the fallback for a repo with no remote
|
|
34
|
+
* (or one whose `origin/HEAD` was never set).
|
|
35
|
+
*
|
|
36
|
+
* Returns `null` when none of these resolve (a repo whose default is some other
|
|
37
|
+
* name, with no `origin/HEAD` set) — the caller decides: `create` fails open
|
|
38
|
+
* (no branch), `finish` refuses with a clear message.
|
|
39
|
+
*
|
|
40
|
+
* @param {string} [cwd]
|
|
41
|
+
* @returns {string|null} the default branch name, or null if undeterminable
|
|
42
|
+
*/
|
|
43
|
+
export function detectDefaultBranch(cwd = process.cwd()) {
|
|
44
|
+
try {
|
|
45
|
+
const ref = execFileSync('git', ['symbolic-ref', '--quiet', 'refs/remotes/origin/HEAD'], { cwd, stdio: 'pipe' })
|
|
46
|
+
.toString()
|
|
47
|
+
.trim();
|
|
48
|
+
// e.g. "refs/remotes/origin/main" → "main"
|
|
49
|
+
const m = ref.match(/^refs\/remotes\/origin\/(.+)$/);
|
|
50
|
+
if (m && m[1]) return m[1];
|
|
51
|
+
} catch { /* no origin/HEAD — fall back to a local default */ }
|
|
52
|
+
|
|
53
|
+
for (const candidate of ['main', 'master']) {
|
|
54
|
+
if (localBranchExists(candidate, cwd)) return candidate;
|
|
55
|
+
}
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Is an `origin` remote configured? `--pr` needs one to push the feature branch;
|
|
61
|
+
* `--merge` is purely local and never calls this.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} [cwd]
|
|
64
|
+
* @returns {boolean}
|
|
65
|
+
*/
|
|
66
|
+
export function hasOriginRemote(cwd = process.cwd()) {
|
|
67
|
+
try {
|
|
68
|
+
const url = execFileSync('git', ['remote', 'get-url', 'origin'], { cwd, stdio: 'pipe' }).toString().trim();
|
|
69
|
+
return url.length > 0;
|
|
70
|
+
} catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
@@ -0,0 +1,173 @@
|
|
|
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. What IS decidable is the plumbing, and the plumbing
|
|
7
|
+
* is where the requirement actually leaked before:
|
|
8
|
+
*
|
|
9
|
+
* - a task pointing at a use case that does not exist → ERROR (a broken plan)
|
|
10
|
+
* - a post-condition no task ever mentions → warning (orphan)
|
|
11
|
+
* - a task on a use case whose `doneCriteria` carries → warning (paraphrased
|
|
12
|
+
* none of its post-conditions verbatim instead of copied)
|
|
13
|
+
*
|
|
14
|
+
* Two deliberate asymmetries:
|
|
15
|
+
*
|
|
16
|
+
* **Coverage is computed from the post-condition's side, never the task's.**
|
|
17
|
+
* `usecase` is optional by design — a technical task with no observable
|
|
18
|
+
* behaviour has none — so "task without a use case" is never a finding.
|
|
19
|
+
*
|
|
20
|
+
* **Matching normalizes case and whitespace.** The rule is "copy the text
|
|
21
|
+
* verbatim", but a plan that prefixes the label (`"P2 — cada veterinário…"`)
|
|
22
|
+
* naturally lowercases the first letter. A raw `includes()` would reject the
|
|
23
|
+
* very example the `morph-plan` skill teaches. Accents and wording still have
|
|
24
|
+
* to match: that is the part worth being strict about.
|
|
25
|
+
*
|
|
26
|
+
* Fail-open like the promoter: no `usecases/` folder means nothing to check.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { existsSync, readdirSync, readFileSync } from 'fs';
|
|
30
|
+
import { join } from 'path';
|
|
31
|
+
import { useCaseId } from './usecase-promoter.js';
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* A post-condition line: `- **P2** — Cada veterinário traz suas especialidades`.
|
|
35
|
+
* The separator is tolerant (em dash, en dash or hyphen) because the character
|
|
36
|
+
* is a typing accident, not a decision.
|
|
37
|
+
*/
|
|
38
|
+
const POSTCONDITION_RE = /^[ \t]*[-*][ \t]*\*\*(P\d+)\*\*[ \t]*[—–-][ \t]*(.+?)[ \t]*$/gm;
|
|
39
|
+
|
|
40
|
+
/** Case-folded, whitespace-collapsed form used for containment tests. */
|
|
41
|
+
function normalize(text) {
|
|
42
|
+
return String(text).toLowerCase().replace(/\s+/g, ' ').trim();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Parse one use-case document into its post-conditions.
|
|
47
|
+
*
|
|
48
|
+
* @param {string} content
|
|
49
|
+
* @returns {Array<{ label: string, text: string }>}
|
|
50
|
+
*/
|
|
51
|
+
export function parsePostConditions(content) {
|
|
52
|
+
const out = [];
|
|
53
|
+
const seen = new Set();
|
|
54
|
+
for (const m of String(content).matchAll(POSTCONDITION_RE)) {
|
|
55
|
+
const [, label, text] = m;
|
|
56
|
+
if (seen.has(label)) continue; // first wins — a repeated label is one post-condition
|
|
57
|
+
seen.add(label);
|
|
58
|
+
out.push({ label, text: text.trim() });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Read every use case of a feature, keyed by ID.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
67
|
+
* @returns {Map<string, { id: string, file: string, postConditions: Array<{label: string, text: string}> }>}
|
|
68
|
+
*/
|
|
69
|
+
export function loadUseCases(usecasesDir) {
|
|
70
|
+
const byId = new Map();
|
|
71
|
+
if (!existsSync(usecasesDir)) return byId;
|
|
72
|
+
|
|
73
|
+
let names;
|
|
74
|
+
try {
|
|
75
|
+
names = readdirSync(usecasesDir).sort();
|
|
76
|
+
} catch {
|
|
77
|
+
return byId; // unreadable folder is "no use cases", never a crash
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
for (const file of names) {
|
|
81
|
+
const id = useCaseId(file);
|
|
82
|
+
if (!id || !file.endsWith('.md')) continue;
|
|
83
|
+
try {
|
|
84
|
+
const content = readFileSync(join(usecasesDir, file), 'utf8');
|
|
85
|
+
byId.set(id, { id, file, postConditions: parsePostConditions(content) });
|
|
86
|
+
} catch { /* one unreadable use case must not sink the rest */ }
|
|
87
|
+
}
|
|
88
|
+
return byId;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Cross-check `tasks.json` against the feature's use cases.
|
|
93
|
+
*
|
|
94
|
+
* @param {Object} tasksData - parsed tasks.json (already schema-valid)
|
|
95
|
+
* @param {string} usecasesDir - absolute path to `2-plan/usecases`
|
|
96
|
+
* @returns {{ errors: string[], warnings: string[], checked: boolean }}
|
|
97
|
+
* `checked: false` means there were no use cases to check against.
|
|
98
|
+
*/
|
|
99
|
+
export function checkUseCases(tasksData, usecasesDir) {
|
|
100
|
+
const errors = [];
|
|
101
|
+
const warnings = [];
|
|
102
|
+
const byId = loadUseCases(usecasesDir);
|
|
103
|
+
const tasks = Array.isArray(tasksData?.tasks) ? tasksData.tasks : [];
|
|
104
|
+
|
|
105
|
+
// A task referencing a use case that does not exist is a broken plan, and the
|
|
106
|
+
// check is exact: the file is there or it is not. This is the only finding
|
|
107
|
+
// that earns an error — everything below is advisory by construction.
|
|
108
|
+
for (const task of tasks) {
|
|
109
|
+
if (!task?.usecase) continue;
|
|
110
|
+
if (!byId.has(task.usecase)) {
|
|
111
|
+
const known = [...byId.keys()].sort().join(', ') || 'nenhum';
|
|
112
|
+
errors.push(
|
|
113
|
+
`${task.id}.usecase → ${task.usecase} não existe em 2-plan/usecases/ (encontrados: ${known})`
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
if (byId.size === 0) return { errors, warnings, checked: false };
|
|
119
|
+
|
|
120
|
+
// Pre-normalize every doneCriteria once: this is O(tasks × postConditions)
|
|
121
|
+
// and both are small, but the normalization itself is not free.
|
|
122
|
+
const criteria = tasks.map((t) => ({ id: t?.id, text: normalize(t?.doneCriteria || ''), usecase: t?.usecase }));
|
|
123
|
+
|
|
124
|
+
// Orphan post-conditions — computed from the post-condition's side, so a task
|
|
125
|
+
// without a `usecase` never counts against anyone.
|
|
126
|
+
//
|
|
127
|
+
// A use case NOBODY touches collapses into a single aggregated warning. The
|
|
128
|
+
// per-post-condition list is the useful shape while a plan is being closed
|
|
129
|
+
// (three of four covered → fix the fourth), but `dag` also runs on every
|
|
130
|
+
// implement cycle, where an untouched use case would repeat N identical lines
|
|
131
|
+
// forever. Losing the finding entirely would be worse: a use case written and
|
|
132
|
+
// then ignored by the whole plan is the failure this layer exists to catch.
|
|
133
|
+
for (const uc of byId.values()) {
|
|
134
|
+
const withText = uc.postConditions.filter((pc) => normalize(pc.text));
|
|
135
|
+
if (withText.length === 0) continue;
|
|
136
|
+
|
|
137
|
+
const orphans = withText.filter((pc) => !criteria.some((c) => c.text.includes(normalize(pc.text))));
|
|
138
|
+
if (orphans.length === 0) continue;
|
|
139
|
+
|
|
140
|
+
const untouched = orphans.length === withText.length && !criteria.some((c) => c.usecase === uc.id);
|
|
141
|
+
if (untouched) {
|
|
142
|
+
warnings.push(
|
|
143
|
+
`${uc.id} não é referenciado por nenhuma task — ${withText.length} pós-condição(ões) sem cobertura ` +
|
|
144
|
+
`(${withText.map((p) => p.label).join('/')})`
|
|
145
|
+
);
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
for (const pc of orphans) {
|
|
149
|
+
warnings.push(
|
|
150
|
+
`${uc.id} ${pc.label} não é coberta por nenhuma task — nenhum doneCriteria carrega "${pc.text}"`
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Tasks that claim a use case but paraphrased it instead of copying it.
|
|
156
|
+
for (const c of criteria) {
|
|
157
|
+
if (!c.usecase) continue;
|
|
158
|
+
const uc = byId.get(c.usecase);
|
|
159
|
+
if (!uc || uc.postConditions.length === 0) continue;
|
|
160
|
+
const carries = uc.postConditions.some((pc) => {
|
|
161
|
+
const needle = normalize(pc.text);
|
|
162
|
+
return needle && c.text.includes(needle);
|
|
163
|
+
});
|
|
164
|
+
if (!carries) {
|
|
165
|
+
warnings.push(
|
|
166
|
+
`${c.id}.doneCriteria não carrega nenhuma pós-condição de ${c.usecase} literalmente ` +
|
|
167
|
+
`(esperado o texto de ${uc.postConditions.map((p) => p.label).join('/')})`
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return { errors, warnings, checked: true };
|
|
173
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
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.** `UC-07-old-slug.md` in the archive is
|
|
16
|
+
* REPLACED by an incoming `UC-07-new-slug.md`. Keying on the filename would
|
|
17
|
+
* leave two files claiming UC-07 the first time a slug is reworded — and the
|
|
18
|
+
* `bug` work type exists precisely to correct an existing use case.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
22
|
+
import { join } from 'path';
|
|
23
|
+
import { OUTPUT_SCHEMA } from '../../core/paths/output-schema.js';
|
|
24
|
+
|
|
25
|
+
const UC = OUTPUT_SCHEMA.usecases;
|
|
26
|
+
const UC_FILENAME_RE = new RegExp(UC.filenamePattern);
|
|
27
|
+
|
|
28
|
+
/** Project-relative path of the permanent use-case archive (git pathspec-ready). */
|
|
29
|
+
export const SPECS_USECASES_PATHSPEC = join('.morph', 'specs', 'usecases');
|
|
30
|
+
|
|
31
|
+
/** `UC-07-listar-veterinarios.md` → `UC-07`. Null when the name isn't a use case. */
|
|
32
|
+
export function useCaseId(filename) {
|
|
33
|
+
const m = /^(UC-\d+)-/.exec(filename);
|
|
34
|
+
return m ? m[1] : null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Rewrite the frontmatter `status:` to `implemented`.
|
|
39
|
+
*
|
|
40
|
+
* A file with no frontmatter is copied verbatim: the use case belongs to the
|
|
41
|
+
* author, and inventing a header would be a silent edit of their document.
|
|
42
|
+
*
|
|
43
|
+
* @param {string} content
|
|
44
|
+
* @returns {string}
|
|
45
|
+
*/
|
|
46
|
+
export function stampImplemented(content) {
|
|
47
|
+
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
|
|
48
|
+
if (!m || m.index !== 0) return content;
|
|
49
|
+
|
|
50
|
+
const [full, open, body, close] = m;
|
|
51
|
+
const eol = full.includes('\r\n') ? '\r\n' : '\n';
|
|
52
|
+
const statusLine = /^status:[ \t]*.*$/m;
|
|
53
|
+
const next = statusLine.test(body)
|
|
54
|
+
? body.replace(statusLine, 'status: implemented')
|
|
55
|
+
: `${body}${eol}status: implemented`;
|
|
56
|
+
|
|
57
|
+
return open + next + close + content.slice(full.length);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Copy every use case of a feature into the project's permanent archive.
|
|
62
|
+
*
|
|
63
|
+
* @param {string} cwd - project root that owns `.morph/specs/`
|
|
64
|
+
* @param {string} featureDir - the feature folder holding `2-plan/usecases/`
|
|
65
|
+
* @returns {{ promoted: string[], replaced: string[] }} filenames, always arrays
|
|
66
|
+
*/
|
|
67
|
+
export function promoteUseCases(cwd, featureDir) {
|
|
68
|
+
const empty = { promoted: [], replaced: [] };
|
|
69
|
+
const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
|
|
70
|
+
|
|
71
|
+
let names;
|
|
72
|
+
try {
|
|
73
|
+
if (!existsSync(srcDir)) return empty;
|
|
74
|
+
names = readdirSync(srcDir).filter((n) => UC_FILENAME_RE.test(n)).sort();
|
|
75
|
+
} catch {
|
|
76
|
+
return empty;
|
|
77
|
+
}
|
|
78
|
+
if (!names.length) return empty;
|
|
79
|
+
|
|
80
|
+
const destDir = join(cwd, '.morph', 'specs', 'usecases');
|
|
81
|
+
const promoted = [];
|
|
82
|
+
const replaced = [];
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(destDir, { recursive: true });
|
|
86
|
+
} catch {
|
|
87
|
+
return empty;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Existing archive entries, indexed by use-case ID — the key the overwrite
|
|
91
|
+
// rule is written against.
|
|
92
|
+
const existingById = new Map();
|
|
93
|
+
try {
|
|
94
|
+
for (const name of readdirSync(destDir)) {
|
|
95
|
+
const id = UC_FILENAME_RE.test(name) ? useCaseId(name) : null;
|
|
96
|
+
if (id) existingById.set(id, name);
|
|
97
|
+
}
|
|
98
|
+
} catch { /* unreadable archive — treat as empty, never fatal */ }
|
|
99
|
+
|
|
100
|
+
for (const name of names) {
|
|
101
|
+
try {
|
|
102
|
+
const content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
|
|
103
|
+
const id = useCaseId(name);
|
|
104
|
+
const stale = id ? existingById.get(id) : null;
|
|
105
|
+
|
|
106
|
+
// Same ID under a different filename: drop the old file, or the archive
|
|
107
|
+
// would end up with two documents claiming the same UC number.
|
|
108
|
+
if (stale && stale !== name) {
|
|
109
|
+
rmSync(join(destDir, stale), { force: true });
|
|
110
|
+
replaced.push(stale);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
writeFileSync(join(destDir, name), content, 'utf8');
|
|
114
|
+
promoted.push(name);
|
|
115
|
+
if (id) existingById.set(id, name);
|
|
116
|
+
} catch { /* one unreadable use case must not sink the rest */ }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return { promoted, replaced };
|
|
120
|
+
}
|
|
@@ -26,6 +26,8 @@ export function getSchema() {
|
|
|
26
26
|
const VALID_STATUS = ['pending', 'in_progress', 'done', 'skipped', 'blocked'];
|
|
27
27
|
const VALID_EFFORT = ['S', 'M', 'L', 'XL'];
|
|
28
28
|
const VALID_GROUP = ['backend', 'frontend', 'tests', 'infra', 'docs'];
|
|
29
|
+
/** Use-case reference: the ID only (`UC-07`), never the filename. */
|
|
30
|
+
const USECASE_RE = /^UC-\d+$/;
|
|
29
31
|
|
|
30
32
|
/**
|
|
31
33
|
* Validate a tasks.json structure.
|
|
@@ -79,6 +81,12 @@ export function validateTasksJson(data) {
|
|
|
79
81
|
if (task.group !== undefined && !VALID_GROUP.includes(task.group)) {
|
|
80
82
|
errors.push(`${ctx}.group must be one of ${VALID_GROUP.join(', ')}`);
|
|
81
83
|
}
|
|
84
|
+
// Optional: a task with no observable behaviour references no use case.
|
|
85
|
+
// When present it must be the bare ID — `morph-spec dag` is the gate that
|
|
86
|
+
// catches "UC-07-listar.md" or "UC7" before the plan reaches Gate 2.
|
|
87
|
+
if (task.usecase !== undefined && (typeof task.usecase !== 'string' || !USECASE_RE.test(task.usecase))) {
|
|
88
|
+
errors.push(`${ctx}.usecase must match /^UC-\\d+$/ (the ID alone, e.g. "UC-07")`);
|
|
89
|
+
}
|
|
82
90
|
}
|
|
83
91
|
|
|
84
92
|
return { valid: errors.length === 0, errors };
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
* worktree of the repo shares.
|
|
15
15
|
*
|
|
16
16
|
* Allocation is deterministic (first free index), idempotent per feature, and
|
|
17
|
-
* released only when `
|
|
17
|
+
* released only when `morph-spec finish` actually removed the worktree. Writes
|
|
18
18
|
* are tmp+rename so a crash never leaves a torn registry; a corrupt registry
|
|
19
19
|
* is treated as empty (fail-open — the cost is a re-allocation, never a crash).
|
|
20
20
|
*/
|
package/src/utils/file-copier.js
CHANGED
|
@@ -225,7 +225,7 @@ export async function updateGitignore(projectPath) {
|
|
|
225
225
|
* Windows with `core.autocrlf=true` (the Git-for-Windows default) and no
|
|
226
226
|
* attribute pinning EOL, Git flags EVERY rewritten feature.json as modified
|
|
227
227
|
* even when the content is byte-identical. That silently breaks the whole-root
|
|
228
|
-
* clean-check in `morph-spec
|
|
228
|
+
* clean-check in `morph-spec finish` as soon as a project has more
|
|
229
229
|
* than one feature — precisely the multi-instance / parallel-worktree flow,
|
|
230
230
|
* where one `saveState` touches sibling feature.json files and marks them dirty.
|
|
231
231
|
* This mirrors the .gitattributes this framework repo already carries for its
|
|
@@ -242,7 +242,7 @@ export async function ensureGitattributes(projectPath) {
|
|
|
242
242
|
`${MARKER} — LF-pin morph state so core.autocrlf never churns it`,
|
|
243
243
|
'# (feature.json is committed + rewritten by saveState with LF; without this,',
|
|
244
244
|
'# Windows autocrlf marks every feature.json dirty and breaks the whole-root',
|
|
245
|
-
'# clean-check in `morph-spec
|
|
245
|
+
'# clean-check in `morph-spec finish`).',
|
|
246
246
|
'.morph/** text=auto eol=lf',
|
|
247
247
|
];
|
|
248
248
|
|