@polymorphism-tech/morph-spec 8.22.0 → 8.23.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 +2 -2
- package/bin/morph-spec.js +14 -0
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/MORPH.md +1 -1
- package/framework/agents.json +1 -0
- package/framework/docs/CLI.md +3 -2
- 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 +1 -1
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +14 -2
- package/framework/skills/core/morph-plan/SKILL.md +6 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +29 -3
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +28 -0
- package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
- package/framework/standards/backend/dotnet/testing.md +127 -0
- package/package.json +1 -1
- package/src/commands/state/approve.js +11 -0
- package/src/commands/state/archive.js +12 -3
- package/src/commands/state/finish.js +9 -4
- package/src/commands/state/handoff.js +3 -2
- package/src/commands/state/usecase.js +77 -0
- package/src/commands/validation/verify.js +6 -0
- package/src/lib/generators/handoff-generator.js +2 -2
- package/src/lib/specs/usecase-promoter.js +135 -19
- package/src/lib/specs/usecase-registry.js +237 -0
- package/src/lib/validators/traceability-check.js +198 -0
- package/src/lib/validators/verify-runner.js +14 -0
|
@@ -405,12 +405,17 @@ async function integratePr({ emit, root, feature, expectedBranch, defaultBranch,
|
|
|
405
405
|
// ── output helpers ────────────────────────────────────────────────────────
|
|
406
406
|
|
|
407
407
|
/**
|
|
408
|
-
* Report the use cases the archive promoted into `.morph/specs/usecases
|
|
409
|
-
*
|
|
410
|
-
*
|
|
408
|
+
* Report the use cases the archive promoted into `.morph/specs/usecases/`, and
|
|
409
|
+
* any that were REFUSED for a numbering collision (`ucCollisions` — see
|
|
410
|
+
* `usecase-promoter.js`'s reservation-backed guard). Both absent when there is
|
|
411
|
+
* nothing to report, so a feature with no use cases keeps the exact output
|
|
412
|
+
* shape it had before either existed.
|
|
411
413
|
*/
|
|
412
414
|
function promotedNote(arch) {
|
|
413
|
-
return
|
|
415
|
+
return {
|
|
416
|
+
...(arch?.promotedUseCases?.length ? { promotedUseCases: arch.promotedUseCases } : {}),
|
|
417
|
+
...(arch?.refusedUseCases?.length ? { ucCollisions: arch.refusedUseCases } : {}),
|
|
418
|
+
};
|
|
414
419
|
}
|
|
415
420
|
|
|
416
421
|
function mergeOutput({ feature, branch, worktreePath, teardown, shedBackup }) {
|
|
@@ -6,8 +6,9 @@
|
|
|
6
6
|
* worktree session, a resumed session after a phase transition). The handoff
|
|
7
7
|
* is the sanctioned way to carry context across: derived, regenerable, and
|
|
8
8
|
* pointer-based (see handoff-generator.js). This command is also called
|
|
9
|
-
* in-process by `worktree setup`, `create --worktree
|
|
10
|
-
* surface exists for the LLM/user to refresh the
|
|
9
|
+
* in-process by `worktree setup`, `create --worktree`, `advance` and
|
|
10
|
+
* `approve` — the CLI surface exists for the LLM/user to refresh the
|
|
11
|
+
* document on demand.
|
|
11
12
|
*/
|
|
12
13
|
|
|
13
14
|
import chalk from 'chalk';
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `morph-spec usecase reserve [--count <n>] [--feature <name>] [--json]` —
|
|
3
|
+
* atomically mint the next N `UC-{nn}` numbers, shared across every worktree
|
|
4
|
+
* of this repo. See `usecase-registry.js` for why this exists (worktrees only
|
|
5
|
+
* ever see their own tree, so an independent max+1 scan collides).
|
|
6
|
+
*
|
|
7
|
+
* `--feature` is more than an audit label: `usecase-promoter.js`'s collision
|
|
8
|
+
* guard uses the reservation record to tell a legitimate cross-feature
|
|
9
|
+
* correction (never reserved — reusing a published number by design) from an
|
|
10
|
+
* accidental collision (both features reserved the same id). Passing it is
|
|
11
|
+
* how `morph-plan` gets that protection; omitting it degrades to "reserved,
|
|
12
|
+
* no owner recorded" — still collision-free, just unable to name the
|
|
13
|
+
* conflicting feature if one ever surfaces.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import chalk from 'chalk';
|
|
17
|
+
import { existsSync, readdirSync } from 'fs';
|
|
18
|
+
import { join } from 'path';
|
|
19
|
+
import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
|
|
20
|
+
import { reserveUseCases } from '../../lib/specs/usecase-registry.js';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Best-effort feature-name inference when `--feature` is omitted: only when
|
|
24
|
+
* exactly one active feature exists is the guess unambiguous. Anything else
|
|
25
|
+
* (zero, or more than one) is left null rather than guessed wrong — a wrong
|
|
26
|
+
* guess would poison the collision guard's provenance record.
|
|
27
|
+
*/
|
|
28
|
+
function inferActiveFeature(cwd) {
|
|
29
|
+
const featuresDir = join(cwd, '.morph', 'features');
|
|
30
|
+
try {
|
|
31
|
+
if (!existsSync(featuresDir)) return null;
|
|
32
|
+
const entries = readdirSync(featuresDir, { withFileTypes: true }).filter((e) => e.isDirectory());
|
|
33
|
+
return entries.length === 1 ? entries[0].name : null;
|
|
34
|
+
} catch {
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {{ count?: string, feature?: string, json?: boolean, cwd?: string }} [options]
|
|
41
|
+
*/
|
|
42
|
+
export function usecaseReserveCommand(options = {}) {
|
|
43
|
+
const cwd = options.cwd || process.cwd();
|
|
44
|
+
const count = Number.parseInt(options.count, 10);
|
|
45
|
+
const n = Number.isInteger(count) && count > 0 ? count : 1;
|
|
46
|
+
const feature = options.feature || inferActiveFeature(cwd) || null;
|
|
47
|
+
|
|
48
|
+
let gitCommonDir = null;
|
|
49
|
+
try { gitCommonDir = resolveGitCommonDir(cwd); } catch { gitCommonDir = null; }
|
|
50
|
+
|
|
51
|
+
let result;
|
|
52
|
+
try {
|
|
53
|
+
result = reserveUseCases(cwd, { feature, count: n, gitCommonDir });
|
|
54
|
+
} catch (error) {
|
|
55
|
+
if (options.json) {
|
|
56
|
+
console.log(JSON.stringify({ reserved: false, error: error.message }));
|
|
57
|
+
process.exit(1);
|
|
58
|
+
}
|
|
59
|
+
console.error(chalk.red(`\n❌ ${error.message}\n`));
|
|
60
|
+
process.exit(1);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const warning = gitCommonDir
|
|
64
|
+
? null
|
|
65
|
+
: 'projeto fora de um repositório git — numeração protegida só localmente; paralelismo entre worktrees NÃO está garantido.';
|
|
66
|
+
|
|
67
|
+
if (options.json) {
|
|
68
|
+
console.log(JSON.stringify({ reserved: true, ...result, feature, warning }));
|
|
69
|
+
process.exit(0);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
console.log(chalk.green(`\n✅ Reservado: ${result.numbers.join(' ')}`));
|
|
73
|
+
if (feature) console.log(chalk.gray(` feature: ${feature}`));
|
|
74
|
+
if (warning) console.log(chalk.yellow(`⚠️ ${warning}\n`));
|
|
75
|
+
else console.log('');
|
|
76
|
+
process.exit(0);
|
|
77
|
+
}
|
|
@@ -53,6 +53,12 @@ function formatVerifyResult(result) {
|
|
|
53
53
|
for (const line of result.feedback) lines.push(` ${line}`);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
if (result.traceability?.warnings?.length > 0) {
|
|
57
|
+
lines.push('');
|
|
58
|
+
lines.push(chalk.yellow('Traceability warnings (advisory — never fails verify):'));
|
|
59
|
+
for (const w of result.traceability.warnings) lines.push(` ⚠ ${w}`);
|
|
60
|
+
}
|
|
61
|
+
|
|
56
62
|
lines.push('');
|
|
57
63
|
return lines.join('\n');
|
|
58
64
|
}
|
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* Deterministic by construction — everything here is derived from feature.json,
|
|
10
10
|
* tasks.json, decisions.md and the worktree env manifest. The document is
|
|
11
11
|
* therefore REGENERABLE (never hand-edited, never merged): `morph-spec handoff`
|
|
12
|
-
* rebuilds it, and setup/create/advance regenerate it at the moments a
|
|
13
|
-
* session is most likely to pick the feature up.
|
|
12
|
+
* rebuilds it, and setup/create/advance/approve regenerate it at the moments a
|
|
13
|
+
* new session is most likely to pick the feature up.
|
|
14
14
|
*
|
|
15
15
|
* Every section is fail-open: a missing tasks.json or a corrupt decisions.md
|
|
16
16
|
* degrades that section, never the document.
|
|
@@ -12,15 +12,29 @@
|
|
|
12
12
|
* as a no-op rather than an error.
|
|
13
13
|
* - **No-op without the folder.** A feature with no use cases promotes nothing.
|
|
14
14
|
* - **Idempotent.** Re-promoting the same use case rewrites the same content.
|
|
15
|
-
* - **Overwrite by ID, not by filename
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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.
|
|
19
31
|
*/
|
|
20
32
|
|
|
21
33
|
import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, rmSync } from 'fs';
|
|
22
|
-
import { join } from 'path';
|
|
34
|
+
import { basename, join, relative } from 'path';
|
|
23
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';
|
|
24
38
|
|
|
25
39
|
const UC = OUTPUT_SCHEMA.usecases;
|
|
26
40
|
const UC_FILENAME_RE = new RegExp(UC.filenamePattern);
|
|
@@ -35,38 +49,102 @@ export function useCaseId(filename) {
|
|
|
35
49
|
}
|
|
36
50
|
|
|
37
51
|
/**
|
|
38
|
-
*
|
|
52
|
+
* Set (or replace) a single top-level frontmatter field, preserving every
|
|
53
|
+
* other line, the original EOL style, and anything after the closing `---`.
|
|
39
54
|
*
|
|
40
|
-
* A file with no frontmatter is
|
|
41
|
-
* author, and inventing a header would be a silent edit of
|
|
55
|
+
* A file with no frontmatter block is returned UNCHANGED: the use case
|
|
56
|
+
* belongs to the author, and inventing a header would be a silent edit of
|
|
57
|
+
* their document.
|
|
42
58
|
*
|
|
43
59
|
* @param {string} content
|
|
60
|
+
* @param {string} key
|
|
61
|
+
* @param {string} value
|
|
44
62
|
* @returns {string}
|
|
45
63
|
*/
|
|
46
|
-
|
|
64
|
+
function setFrontmatterField(content, key, value) {
|
|
47
65
|
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
|
|
48
66
|
if (!m || m.index !== 0) return content;
|
|
49
67
|
|
|
50
68
|
const [full, open, body, close] = m;
|
|
51
69
|
const eol = full.includes('\r\n') ? '\r\n' : '\n';
|
|
52
|
-
const
|
|
53
|
-
const next =
|
|
54
|
-
? body.replace(
|
|
55
|
-
: `${body}${eol}
|
|
70
|
+
const fieldLine = new RegExp(`^${key}:[ \\t]*.*$`, 'm');
|
|
71
|
+
const next = fieldLine.test(body)
|
|
72
|
+
? body.replace(fieldLine, `${key}: ${value}`)
|
|
73
|
+
: `${body}${eol}${key}: ${value}`;
|
|
56
74
|
|
|
57
75
|
return open + next + close + content.slice(full.length);
|
|
58
76
|
}
|
|
59
77
|
|
|
78
|
+
/** Read a single top-level frontmatter field. `null` when absent or there is no frontmatter block. */
|
|
79
|
+
function readFrontmatterField(content, key) {
|
|
80
|
+
const m = /^(?---\r?\n)([\s\S]*?)(\r?\n---(?:\r?\n|$))/.exec(content);
|
|
81
|
+
if (!m || m.index !== 0) return null;
|
|
82
|
+
const line = new RegExp(`^${key}:[ \\t]*(.+?)[ \\t]*$`, 'm').exec(m[2]);
|
|
83
|
+
return line ? line[1].trim() : null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Rewrite the frontmatter `status:` to `implemented`.
|
|
88
|
+
*
|
|
89
|
+
* A file with no frontmatter is copied verbatim: the use case belongs to the
|
|
90
|
+
* author, and inventing a header would be a silent edit of their document.
|
|
91
|
+
*
|
|
92
|
+
* @param {string} content
|
|
93
|
+
* @returns {string}
|
|
94
|
+
*/
|
|
95
|
+
export function stampImplemented(content) {
|
|
96
|
+
return setFrontmatterField(content, 'status', 'implemented');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Stamp the promoting feature's name into `sourceFeature:` — the provenance
|
|
101
|
+
* record the collision guard (below) compares against a reservation to tell a
|
|
102
|
+
* legitimate cross-feature correction from an accidental numbering collision.
|
|
103
|
+
*
|
|
104
|
+
* @param {string} content
|
|
105
|
+
* @param {string} feature
|
|
106
|
+
* @returns {string}
|
|
107
|
+
*/
|
|
108
|
+
export function stampSourceFeature(content, feature) {
|
|
109
|
+
return setFrontmatterField(content, 'sourceFeature', feature);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Build the message for a refused promotion — named clearly enough that the
|
|
114
|
+
* next session (or human) can actually recover, not just "colisão".
|
|
115
|
+
*
|
|
116
|
+
* The recovery instruction has to be honest about WHERE the file sits: by the
|
|
117
|
+
* time this runs, `runArchive` has already moved the feature folder, and its
|
|
118
|
+
* own idempotent short-circuit (`existsSync(archivePath)`) means re-running
|
|
119
|
+
* `archive`/`finish` on this feature promotes NOTHING a second time — so
|
|
120
|
+
* "renumber and re-run finish" would be a lie. The file is wherever `srcDir`
|
|
121
|
+
* actually is (the feature's `2-plan/usecases/`, wherever that folder lives
|
|
122
|
+
* right now — pre- or post-archive, the caller decides), and recovery is
|
|
123
|
+
* either a fresh reservation + manual copy, or editing the archive in place.
|
|
124
|
+
*
|
|
125
|
+
* @returns {string}
|
|
126
|
+
*/
|
|
127
|
+
function collisionMessage({ id, incoming, existing, existingFeature, feature, relIncomingPath }) {
|
|
128
|
+
return `${id}: colisão de numeração — "${incoming}" (feature "${feature}") não pôde substituir "${existing}" ` +
|
|
129
|
+
`já promovido no acervo por "${existingFeature}". O arquivo continua em ${relIncomingPath}. ` +
|
|
130
|
+
'Se for um use case NOVO: renumere-o com `morph-spec usecase reserve` e promova manualmente para ' +
|
|
131
|
+
'.morph/specs/usecases/ (o finish/archive não reprocessa uma feature já arquivada). ' +
|
|
132
|
+
`Se for uma CORREÇÃO do mesmo UC publicado: edite .morph/specs/usecases/${existing} diretamente em vez de reservar um número novo.`;
|
|
133
|
+
}
|
|
134
|
+
|
|
60
135
|
/**
|
|
61
136
|
* Copy every use case of a feature into the project's permanent archive.
|
|
62
137
|
*
|
|
63
138
|
* @param {string} cwd - project root that owns `.morph/specs/`
|
|
64
139
|
* @param {string} featureDir - the feature folder holding `2-plan/usecases/`
|
|
65
|
-
* @returns {{ promoted: string[], replaced: string[]
|
|
140
|
+
* @returns {{ promoted: string[], replaced: string[], refused: Array<{id: string,
|
|
141
|
+
* incoming: string, existing: string, existingFeature: string, message: string}> }}
|
|
142
|
+
* filenames, always arrays
|
|
66
143
|
*/
|
|
67
144
|
export function promoteUseCases(cwd, featureDir) {
|
|
68
|
-
const empty = { promoted: [], replaced: [] };
|
|
145
|
+
const empty = { promoted: [], replaced: [], refused: [] };
|
|
69
146
|
const srcDir = join(featureDir, UC.phaseDir, UC.dirname);
|
|
147
|
+
const feature = basename(featureDir);
|
|
70
148
|
|
|
71
149
|
let names;
|
|
72
150
|
try {
|
|
@@ -80,6 +158,7 @@ export function promoteUseCases(cwd, featureDir) {
|
|
|
80
158
|
const destDir = join(cwd, '.morph', 'specs', 'usecases');
|
|
81
159
|
const promoted = [];
|
|
82
160
|
const replaced = [];
|
|
161
|
+
const refused = [];
|
|
83
162
|
|
|
84
163
|
try {
|
|
85
164
|
mkdirSync(destDir, { recursive: true });
|
|
@@ -87,6 +166,11 @@ export function promoteUseCases(cwd, featureDir) {
|
|
|
87
166
|
return empty;
|
|
88
167
|
}
|
|
89
168
|
|
|
169
|
+
// Resolved ONCE per call — pure filesystem, no subprocess (git-paths.js) —
|
|
170
|
+
// and reused for every collision check below.
|
|
171
|
+
let gitCommonDir = null;
|
|
172
|
+
try { gitCommonDir = resolveGitCommonDir(cwd); } catch { gitCommonDir = null; }
|
|
173
|
+
|
|
90
174
|
// Existing archive entries, indexed by use-case ID — the key the overwrite
|
|
91
175
|
// rule is written against.
|
|
92
176
|
const existingById = new Map();
|
|
@@ -99,22 +183,54 @@ export function promoteUseCases(cwd, featureDir) {
|
|
|
99
183
|
|
|
100
184
|
for (const name of names) {
|
|
101
185
|
try {
|
|
102
|
-
const content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
|
|
103
186
|
const id = useCaseId(name);
|
|
104
187
|
const stale = id ? existingById.get(id) : null;
|
|
105
188
|
|
|
106
|
-
// Same ID under a different filename:
|
|
107
|
-
//
|
|
189
|
+
// Same ID under a different filename: normally the old file is dropped
|
|
190
|
+
// (a slug reword, or the `bug` flow correcting a published UC), or the
|
|
191
|
+
// archive would end up with two documents claiming the same UC number.
|
|
192
|
+
//
|
|
193
|
+
// Backstop: refuse instead when BOTH are true —
|
|
194
|
+
// (a) THIS feature holds a reservation for `id` in usecase-registry.js
|
|
195
|
+
// (it believes it MINTED a new number), and
|
|
196
|
+
// (b) the archive entry already there was promoted by a DIFFERENT
|
|
197
|
+
// feature (its stamped `sourceFeature` says so).
|
|
198
|
+
// A feature correcting a published UC never reserved that UC's number —
|
|
199
|
+
// it reused one already in the acervo — so that legitimate flow never
|
|
200
|
+
// trips this. Two features that each reserved the SAME number (the
|
|
201
|
+
// collision this exists for) always do. No reservation record at all
|
|
202
|
+
// (no git common dir, or a legacy archive entry predating this stamp) is
|
|
203
|
+
// fail-open: nothing to compare against, so nothing is refused.
|
|
108
204
|
if (stale && stale !== name) {
|
|
205
|
+
let staleFeature = null;
|
|
206
|
+
try { staleFeature = readFrontmatterField(readFileSync(join(destDir, stale), 'utf8'), 'sourceFeature'); } catch { /* unreadable — no signal */ }
|
|
207
|
+
const incomingOwner = id ? reservationOwner(id, { cwd, gitCommonDir }) : null;
|
|
208
|
+
|
|
209
|
+
if (staleFeature && staleFeature !== feature && incomingOwner === feature) {
|
|
210
|
+
refused.push({
|
|
211
|
+
id,
|
|
212
|
+
incoming: name,
|
|
213
|
+
existing: stale,
|
|
214
|
+
existingFeature: staleFeature,
|
|
215
|
+
message: collisionMessage({
|
|
216
|
+
id, incoming: name, existing: stale, existingFeature: staleFeature, feature,
|
|
217
|
+
relIncomingPath: relative(cwd, join(srcDir, name)).replace(/\\/g, '/'),
|
|
218
|
+
}),
|
|
219
|
+
});
|
|
220
|
+
continue; // leave the existing archive entry untouched
|
|
221
|
+
}
|
|
222
|
+
|
|
109
223
|
rmSync(join(destDir, stale), { force: true });
|
|
110
224
|
replaced.push(stale);
|
|
111
225
|
}
|
|
112
226
|
|
|
227
|
+
let content = stampImplemented(readFileSync(join(srcDir, name), 'utf8'));
|
|
228
|
+
content = stampSourceFeature(content, feature);
|
|
113
229
|
writeFileSync(join(destDir, name), content, 'utf8');
|
|
114
230
|
promoted.push(name);
|
|
115
231
|
if (id) existingById.set(id, name);
|
|
116
232
|
} catch { /* one unreadable use case must not sink the rest */ }
|
|
117
233
|
}
|
|
118
234
|
|
|
119
|
-
return { promoted, replaced };
|
|
235
|
+
return { promoted, replaced, refused };
|
|
120
236
|
}
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* usecase-registry.js — atomic, cross-worktree UC-{nn} number allocation.
|
|
3
|
+
*
|
|
4
|
+
* Two parallel worktrees planning at the same time each read the acervo
|
|
5
|
+
* (`.morph/specs/usecases/`) plus their OWN feature's `2-plan/usecases/`, take
|
|
6
|
+
* the max `UC-{nn}` and add one — but a worktree only ever sees its OWN tree.
|
|
7
|
+
* Sibling features' use cases live in SIBLING worktrees (feature.json and every
|
|
8
|
+
* UC file travel on the branch, not through any shared filesystem). Two
|
|
9
|
+
* worktrees both computing max+1 from the same stale max both mint `UC-07` —
|
|
10
|
+
* and at promotion time `usecase-promoter.js` overwrites by ID: the SECOND
|
|
11
|
+
* feature to close silently destroys the FIRST one's use case. A user lost
|
|
12
|
+
* work this way in production.
|
|
13
|
+
*
|
|
14
|
+
* This module closes the gap the same way `env-allocator.js` (port blocks) and
|
|
15
|
+
* `claim-registry.js` (feature ownership) already do: a registry file under the
|
|
16
|
+
* git COMMON dir, the one location every linked worktree of a repo shares —
|
|
17
|
+
* `<git-common-dir>/morph-spec/uc-registry.json`. Writes are tmp+rename; a
|
|
18
|
+
* corrupt registry reads as empty (fail-open — the cost is a re-seed from disk,
|
|
19
|
+
* never a crash).
|
|
20
|
+
*
|
|
21
|
+
* Unlike the other two registries, correctness here cannot be advisory: the
|
|
22
|
+
* whole point is that two concurrent callers must never receive the same
|
|
23
|
+
* number. `env-allocator`/`claim-registry` key by FEATURE, so two different
|
|
24
|
+
* features racing never contend for the same key; two worktrees reserving UC
|
|
25
|
+
* numbers race for the SAME counter. So allocation runs inside a real mutual
|
|
26
|
+
* exclusion lock (`uc-registry.lock`, `open(..., 'wx')` — atomic create-if-absent
|
|
27
|
+
* on every platform Node targets, including Windows) instead of the bare
|
|
28
|
+
* read-modify-write the other two registries use.
|
|
29
|
+
*
|
|
30
|
+
* No git (no common dir resolvable): falls back to a project-local registry
|
|
31
|
+
* under `.morph/logs/` (already gitignored) — this still prevents a single
|
|
32
|
+
* session from reserving the same number twice across repeated calls, but
|
|
33
|
+
* carries none of the cross-worktree guarantee. Callers must surface that
|
|
34
|
+
* degradation to the user.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
closeSync, existsSync, mkdirSync, openSync, readdirSync, readFileSync,
|
|
39
|
+
renameSync, statSync, unlinkSync, writeFileSync,
|
|
40
|
+
} from 'fs';
|
|
41
|
+
import { join } from 'path';
|
|
42
|
+
|
|
43
|
+
/** A UC filename's leading `UC-{nn}` — deliberately NOT imported from
|
|
44
|
+
* `usecase-promoter.js`: that module will come to depend on THIS one (to look
|
|
45
|
+
* up who reserved a given number), and a two-way import would be circular. */
|
|
46
|
+
const UC_ID_RE = /^(UC-(\d+))-/;
|
|
47
|
+
|
|
48
|
+
/** `UC-7` (or any width) -> `UC-07` — the archive's zero-padded convention. */
|
|
49
|
+
function formatUcId(n) {
|
|
50
|
+
return `UC-${String(n).padStart(2, '0')}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Directory holding both the registry file and its lock. */
|
|
54
|
+
export function registryDir({ gitCommonDir, cwd }) {
|
|
55
|
+
return gitCommonDir ? join(gitCommonDir, 'morph-spec') : join(cwd, '.morph', 'logs');
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Registry file path — shared (git common dir) or local-fallback (no git). */
|
|
59
|
+
export function ucRegistryPath({ gitCommonDir, cwd }) {
|
|
60
|
+
return join(registryDir({ gitCommonDir, cwd }), 'uc-registry.json');
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function readRegistry(path) {
|
|
64
|
+
try {
|
|
65
|
+
const raw = readFileSync(path, 'utf8').replace(/^/, '');
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
if (parsed && typeof parsed.reservations === 'object' && parsed.reservations !== null) {
|
|
68
|
+
return {
|
|
69
|
+
version: 1,
|
|
70
|
+
counter: Number.isInteger(parsed.counter) ? parsed.counter : 0,
|
|
71
|
+
reservations: parsed.reservations,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
} catch { /* fail-open: absent or corrupt registry restarts empty */ }
|
|
75
|
+
return { version: 1, counter: 0, reservations: {} };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function writeRegistry(path, registry) {
|
|
79
|
+
mkdirSync(join(path, '..'), { recursive: true });
|
|
80
|
+
const tmp = `${path}.tmp.${process.pid}`;
|
|
81
|
+
writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
|
|
82
|
+
renameSync(tmp, path);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** Synchronous sleep — Node (unlike browsers) allows `Atomics.wait` on the main thread. */
|
|
86
|
+
function sleepSync(ms) {
|
|
87
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Run `fn` while holding an exclusive filesystem lock at `lockPath`.
|
|
92
|
+
*
|
|
93
|
+
* `openSync(path, 'wx')` is `O_CREAT | O_EXCL` — atomic create-or-fail, the
|
|
94
|
+
* only primitive this needs and one that behaves identically on POSIX and
|
|
95
|
+
* Windows. A lock file whose mtime is older than `staleMs` is assumed to be
|
|
96
|
+
* left behind by a crashed process and is force-recovered — an orphaned lock
|
|
97
|
+
* must never strand every future reservation.
|
|
98
|
+
*
|
|
99
|
+
* @param {string} lockPath
|
|
100
|
+
* @param {() => T} fn
|
|
101
|
+
* @returns {T}
|
|
102
|
+
* @template T
|
|
103
|
+
*/
|
|
104
|
+
function withLock(lockPath, fn, { timeoutMs = 5000, pollMs = 20, staleMs = 15000 } = {}) {
|
|
105
|
+
mkdirSync(join(lockPath, '..'), { recursive: true });
|
|
106
|
+
const deadline = Date.now() + timeoutMs;
|
|
107
|
+
for (;;) {
|
|
108
|
+
let fd;
|
|
109
|
+
try {
|
|
110
|
+
fd = openSync(lockPath, 'wx');
|
|
111
|
+
} catch (err) {
|
|
112
|
+
if (err.code !== 'EEXIST') throw err;
|
|
113
|
+
try {
|
|
114
|
+
const st = statSync(lockPath);
|
|
115
|
+
if (Date.now() - st.mtimeMs > staleMs) {
|
|
116
|
+
try { unlinkSync(lockPath); } catch { /* raced someone else's cleanup */ }
|
|
117
|
+
continue; // retry immediately — the stale lock is (probably) gone now
|
|
118
|
+
}
|
|
119
|
+
} catch { continue; } // lock vanished between openSync and statSync — retry
|
|
120
|
+
if (Date.now() >= deadline) {
|
|
121
|
+
throw new Error(`usecase-registry: could not acquire lock at ${lockPath} within ${timeoutMs}ms`);
|
|
122
|
+
}
|
|
123
|
+
sleepSync(pollMs);
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
try {
|
|
127
|
+
closeSync(fd);
|
|
128
|
+
return fn();
|
|
129
|
+
} finally {
|
|
130
|
+
try { unlinkSync(lockPath); } catch { /* best-effort */ }
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Highest `UC-{nn}` number found among files matching the pattern in `dir`. 0 if none/unreadable. */
|
|
136
|
+
function maxUcNumberInDir(dir) {
|
|
137
|
+
let max = 0;
|
|
138
|
+
try {
|
|
139
|
+
if (!existsSync(dir)) return 0;
|
|
140
|
+
for (const name of readdirSync(dir)) {
|
|
141
|
+
const m = UC_ID_RE.exec(name);
|
|
142
|
+
if (!m) continue;
|
|
143
|
+
const n = parseInt(m[2], 10);
|
|
144
|
+
if (Number.isFinite(n) && n > max) max = n;
|
|
145
|
+
}
|
|
146
|
+
} catch { /* unreadable dir — treat as empty, never fatal */ }
|
|
147
|
+
return max;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Highest UC number across every feature's `2-plan/usecases/`, under both `.morph/features/` and `.morph/archive/`, in THIS tree. */
|
|
151
|
+
function maxUcNumberLocalTree(cwd) {
|
|
152
|
+
let max = 0;
|
|
153
|
+
for (const bucket of ['features', 'archive']) {
|
|
154
|
+
const bucketDir = join(cwd, '.morph', bucket);
|
|
155
|
+
try {
|
|
156
|
+
if (!existsSync(bucketDir)) continue;
|
|
157
|
+
for (const entry of readdirSync(bucketDir, { withFileTypes: true })) {
|
|
158
|
+
if (!entry.isDirectory()) continue;
|
|
159
|
+
max = Math.max(max, maxUcNumberInDir(join(bucketDir, entry.name, '2-plan', 'usecases')));
|
|
160
|
+
}
|
|
161
|
+
} catch { /* unreadable bucket — treat as empty */ }
|
|
162
|
+
}
|
|
163
|
+
return max;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The highest UC number this project tree can see right now: the permanent
|
|
168
|
+
* acervo, plus every feature (active OR archived-but-not-yet-promoted, see
|
|
169
|
+
* `usecase-promoter.js`'s refusal path) in THIS tree. Exported for tests and
|
|
170
|
+
* for callers that want to preview the seed without reserving anything.
|
|
171
|
+
*
|
|
172
|
+
* @param {string} cwd
|
|
173
|
+
* @returns {number}
|
|
174
|
+
*/
|
|
175
|
+
export function scanLocalMax(cwd) {
|
|
176
|
+
const acervoMax = maxUcNumberInDir(join(cwd, '.morph', 'specs', 'usecases'));
|
|
177
|
+
return Math.max(acervoMax, maxUcNumberLocalTree(cwd));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Atomically reserve the next `count` UC numbers, shared across every worktree
|
|
182
|
+
* of this repo when a git common dir is resolvable.
|
|
183
|
+
*
|
|
184
|
+
* The seed is recomputed on EVERY call as `max(registry.counter, scanLocalMax(cwd))`
|
|
185
|
+
* — monotonic by construction (a max can only grow), so this doubles as both
|
|
186
|
+
* "seed the counter on the first-ever reservation" and "never regress even if
|
|
187
|
+
* the registry were somehow behind disk" without needing a separate seeded flag.
|
|
188
|
+
*
|
|
189
|
+
* @param {string} cwd
|
|
190
|
+
* @param {{ feature?: string|null, count?: number, gitCommonDir?: string|null }} opts
|
|
191
|
+
* @returns {{ numbers: string[], base: number, count: number, shared: boolean, registryPath: string }}
|
|
192
|
+
*/
|
|
193
|
+
export function reserveUseCases(cwd, { feature = null, count = 1, gitCommonDir = null } = {}) {
|
|
194
|
+
const n = Number.isInteger(count) && count > 0 ? count : 1;
|
|
195
|
+
const dir = registryDir({ gitCommonDir, cwd });
|
|
196
|
+
const path = ucRegistryPath({ gitCommonDir, cwd });
|
|
197
|
+
mkdirSync(dir, { recursive: true });
|
|
198
|
+
|
|
199
|
+
return withLock(join(dir, 'uc-registry.lock'), () => {
|
|
200
|
+
const registry = readRegistry(path);
|
|
201
|
+
const base = Math.max(registry.counter || 0, scanLocalMax(cwd));
|
|
202
|
+
|
|
203
|
+
// Test-only: widen the critical section so a concurrency test can prove
|
|
204
|
+
// two REAL processes racing for this lock actually serialize, instead of
|
|
205
|
+
// just happening not to overlap. Mirrors `finish.js`'s MORPH_GH_BIN test
|
|
206
|
+
// injection. Unset in every real invocation.
|
|
207
|
+
const testDelay = Number.parseInt(process.env.MORPH_UC_RESERVE_TEST_DELAY_MS, 10);
|
|
208
|
+
if (Number.isFinite(testDelay) && testDelay > 0) sleepSync(testDelay);
|
|
209
|
+
|
|
210
|
+
const numbers = [];
|
|
211
|
+
const now = new Date().toISOString();
|
|
212
|
+
for (let i = 1; i <= n; i += 1) {
|
|
213
|
+
const id = formatUcId(base + i);
|
|
214
|
+
numbers.push(id);
|
|
215
|
+
registry.reservations[id] = { feature: feature || null, reservedAt: now };
|
|
216
|
+
}
|
|
217
|
+
registry.counter = base + n;
|
|
218
|
+
writeRegistry(path, registry);
|
|
219
|
+
|
|
220
|
+
return { numbers, base, count: n, shared: !!gitCommonDir, registryPath: path };
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Which feature (if any) holds the reservation for a given `UC-{nn}` id.
|
|
226
|
+
* Read-only — no lock, a slightly stale read is harmless for this use (the
|
|
227
|
+
* promoter's collision backstop, never the allocation itself).
|
|
228
|
+
*
|
|
229
|
+
* @param {string} id - e.g. "UC-07"
|
|
230
|
+
* @param {{ cwd: string, gitCommonDir?: string|null }} opts
|
|
231
|
+
* @returns {string|null}
|
|
232
|
+
*/
|
|
233
|
+
export function reservationOwner(id, { cwd, gitCommonDir = null }) {
|
|
234
|
+
const path = ucRegistryPath({ gitCommonDir, cwd });
|
|
235
|
+
const registry = readRegistry(path);
|
|
236
|
+
return registry.reservations?.[id]?.feature ?? null;
|
|
237
|
+
}
|