@polymorphism-tech/morph-spec 8.21.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.
Files changed (43) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +43 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +1 -1
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +2 -1
  7. package/framework/MORPH.md +7 -3
  8. package/framework/agents.json +3 -1
  9. package/framework/docs/CLI.md +5 -3
  10. package/framework/hooks/shared/e2e-check.js +0 -0
  11. package/framework/schemas/feature.schema.json +5 -3
  12. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  13. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  14. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  15. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  16. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  17. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  18. package/framework/skills/core/morph-implement/SKILL.md +14 -2
  19. package/framework/skills/core/morph-plan/SKILL.md +27 -4
  20. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  21. package/framework/skills/core/morph-review/SKILL.md +60 -8
  22. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  23. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  24. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  25. package/framework/standards/STANDARDS.json +28 -0
  26. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +5 -2
  27. package/framework/standards/backend/dotnet/testing.md +127 -0
  28. package/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
  29. package/package.json +1 -1
  30. package/src/commands/e2e/e2e.js +318 -0
  31. package/src/commands/state/approve.js +11 -0
  32. package/src/commands/state/archive.js +12 -3
  33. package/src/commands/state/finish.js +47 -4
  34. package/src/commands/state/handoff.js +3 -2
  35. package/src/commands/state/usecase.js +77 -0
  36. package/src/commands/validation/validate-feature.js +7 -1
  37. package/src/commands/validation/verify.js +13 -0
  38. package/src/lib/e2e/compose-env.js +74 -0
  39. package/src/lib/generators/handoff-generator.js +2 -2
  40. package/src/lib/specs/usecase-promoter.js +135 -19
  41. package/src/lib/specs/usecase-registry.js +237 -0
  42. package/src/lib/validators/traceability-check.js +198 -0
  43. package/src/lib/validators/verify-runner.js +135 -2
@@ -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
+ }
@@ -0,0 +1,198 @@
1
+ /**
2
+ * traceability-check.js — advisory, feature-scope-only check: do the files
3
+ * cited in recap.md's "**Files:**" bullet lists have an owning task in
4
+ * tasks.json's `outputs`?
5
+ *
6
+ * This is the DETERMINISTIC half of a two-layer fix. The NLH half lives in
7
+ * `framework/skills/core/morph-implement/SKILL.md` §7 ("outputs DEVE listar
8
+ * TODO arquivo criado ou modificado pela task") and targets the root cause: a
9
+ * file shipped without EVER being declared anywhere (not in outputs, not in
10
+ * recap.md — the real-world case that motivated this module, see the finding
11
+ * below). This module cannot catch THAT class of drift — a file absent from
12
+ * both documents is invisible to a check that only compares the two documents
13
+ * against each other. What it DOES catch is a narrower, complementary class:
14
+ * recap.md and tasks.json DISAGREEING — an author cited a file in the recap's
15
+ * prose but forgot (or a later task re-touched it without) declaring it in any
16
+ * task's `outputs`. That disagreement is real signal even though it is not the
17
+ * same defect the SKILL.md rule targets.
18
+ *
19
+ * Motivating evidence: run 2026-08-12T133732Z-vet-visit-followup-reminder —
20
+ * `ApplicationDbContextFactory.cs` shipped in production with no owning task
21
+ * in `outputs` AND no mention in recap.md (out of this module's reach by
22
+ * construction). Separately, `src/VetClinic.Api/appsettings.json` and
23
+ * `Properties/launchSettings.json` WERE cited in T1's recap `**Files:**` list
24
+ * but never appear in any task's `outputs` — exactly the disagreement this
25
+ * module flags.
26
+ *
27
+ * Advisory, never blocking: the returned `status` is only ever 'pass' | 'warn'
28
+ * | 'skip' — never 'fail'. `runVerify()` keeps this result OUTSIDE `nodes` on
29
+ * purpose so it can never flip the overall build/tests/validators/e2e status
30
+ * computed from `nodes` alone (see verify-runner.js).
31
+ *
32
+ * Fail-open throughout: no recap.md, no tasks.json, or a parse error all
33
+ * degrade to `{ status: 'skip' }` rather than throwing or warning about the
34
+ * absence itself — this is rastreabilidade tooling, not a completeness gate.
35
+ *
36
+ * @module traceability-check
37
+ */
38
+
39
+ import { existsSync, readFileSync } from 'fs';
40
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
41
+
42
+ /** Matches a `**Files:**` marker followed by one or more `- ` bullet lines. */
43
+ const FILES_BLOCK_RE = /\*\*Files:\*\*[ \t]*\r?\n((?:^[ \t]*-[ \t].*\r?\n?)+)/gm;
44
+
45
+ /** Backtick-quoted spans within a bullet line. */
46
+ const BACKTICK_RE = /`([^`]+)`/g;
47
+
48
+ /**
49
+ * Extract the set of file paths cited under every "**Files:**" block in a
50
+ * recap.md's text, applying a conservative decomposition of the natural
51
+ * language shorthand real recaps use:
52
+ *
53
+ * - A line with exactly one backtick span: that span is a candidate,
54
+ * whether or not it contains a `/` (a legitimate top-level file, e.g.
55
+ * `` `VetClinic.sln` ``, has no slash).
56
+ * - A line with multiple comma-separated backtick spans (e.g.
57
+ * `` `a/b.json`, `c.json`, `d/e.json` ``): only the FIRST span and any
58
+ * LATER span that itself contains `/` are candidates. A later span with
59
+ * no `/` is ambiguous shorthand (a bare filename implicitly relative to
60
+ * the first span's directory, or a suffix like `` `.Designer.cs` ``) and
61
+ * is skipped rather than guessed — false negatives are the acceptable
62
+ * failure mode for advisory tooling, false positives are not.
63
+ * - Any span containing `{`/`}` (brace-expansion shorthand, e.g.
64
+ * `` `Foo/{A,B,C}.cs` ``) is skipped entirely — decomposing it correctly
65
+ * would require guessing at delimiters this format doesn't standardize.
66
+ * - Any span ending in `/` is a directory reference, not a file — skipped.
67
+ *
68
+ * @param {string} recapText
69
+ * @returns {string[]} de-duplicated, normalized (forward-slash) candidate paths
70
+ */
71
+ export function extractCitedFiles(recapText) {
72
+ const cited = new Set();
73
+ if (!recapText) return [];
74
+
75
+ FILES_BLOCK_RE.lastIndex = 0;
76
+ let blockMatch;
77
+ while ((blockMatch = FILES_BLOCK_RE.exec(recapText)) !== null) {
78
+ const block = blockMatch[1];
79
+ const lines = block.split(/\r?\n/).filter((l) => /^[ \t]*-[ \t]/.test(l));
80
+
81
+ for (const line of lines) {
82
+ const spans = [...line.matchAll(BACKTICK_RE)].map((m) => m[1].trim()).filter(Boolean);
83
+ spans.forEach((span, idx) => {
84
+ if (idx > 0 && !span.includes('/')) return; // ambiguous shorthand continuation
85
+ if (span.includes('{') || span.includes('}')) return; // brace-expansion, too ambiguous to decompose
86
+ if (span.endsWith('/')) return; // directory reference, not a file
87
+ cited.add(normalizePath(span));
88
+ });
89
+ }
90
+ }
91
+
92
+ return [...cited];
93
+ }
94
+
95
+ /** Backslashes to forward slashes, strip a leading `./`. */
96
+ function normalizePath(p) {
97
+ return String(p).replace(/\\/g, '/').replace(/^\.\//, '');
98
+ }
99
+
100
+ /**
101
+ * Union of `outputs` across every `done` task in tasks.json, normalized.
102
+ *
103
+ * @param {string} projectPath
104
+ * @param {string} featureName
105
+ * @returns {string[]|null} null when tasks.json is missing/unreadable/invalid
106
+ * (the caller treats null as "cannot judge coverage" and skips, not warns)
107
+ */
108
+ export function collectDoneOutputs(projectPath, featureName) {
109
+ let tasksPath;
110
+ try {
111
+ tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
112
+ } catch {
113
+ return null;
114
+ }
115
+ if (!existsSync(tasksPath)) return null;
116
+
117
+ let data;
118
+ try {
119
+ data = JSON.parse(readFileSync(tasksPath, 'utf8'));
120
+ } catch {
121
+ return null;
122
+ }
123
+
124
+ const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
125
+ const outputs = [];
126
+ for (const task of tasks) {
127
+ if (task?.status !== 'done') continue;
128
+ for (const output of task.outputs || []) {
129
+ if (typeof output === 'string' && output.trim()) outputs.push(normalizePath(output));
130
+ }
131
+ }
132
+ return outputs;
133
+ }
134
+
135
+ /**
136
+ * A cited file is covered if some done task's `outputs` names it exactly, or
137
+ * names a directory (an entry ending in `/`) that the cited file falls under.
138
+ *
139
+ * @param {string} file
140
+ * @param {string[]} outputs
141
+ * @returns {boolean}
142
+ */
143
+ function isCovered(file, outputs) {
144
+ for (const output of outputs) {
145
+ if (output === file) return true;
146
+ if (output.endsWith('/') && file.startsWith(output)) return true;
147
+ }
148
+ return false;
149
+ }
150
+
151
+ /**
152
+ * Run the recap-vs-outputs traceability check for a feature.
153
+ *
154
+ * @param {string} projectPath
155
+ * @param {string} featureName
156
+ * @returns {{ status: 'pass'|'warn'|'skip', warnings: string[], reason: string|null, citedCount: number }}
157
+ */
158
+ export function checkOutputsTraceability(projectPath, featureName) {
159
+ let recapPath;
160
+ try {
161
+ recapPath = getAbsoluteOutputPath(projectPath, featureName, 'recap');
162
+ } catch {
163
+ return { status: 'skip', warnings: [], reason: 'unknown feature', citedCount: 0 };
164
+ }
165
+
166
+ if (!existsSync(recapPath)) {
167
+ return { status: 'skip', warnings: [], reason: 'no recap.md', citedCount: 0 };
168
+ }
169
+
170
+ let recapText;
171
+ try {
172
+ recapText = readFileSync(recapPath, 'utf8');
173
+ } catch {
174
+ return { status: 'skip', warnings: [], reason: 'recap.md unreadable', citedCount: 0 };
175
+ }
176
+
177
+ const cited = extractCitedFiles(recapText);
178
+ if (cited.length === 0) {
179
+ return { status: 'skip', warnings: [], reason: 'no Files: entries found in recap.md', citedCount: 0 };
180
+ }
181
+
182
+ const outputs = collectDoneOutputs(projectPath, featureName);
183
+ if (outputs === null) {
184
+ return { status: 'skip', warnings: [], reason: 'no tasks.json', citedCount: cited.length };
185
+ }
186
+
187
+ const warnings = cited
188
+ .filter((file) => !isCovered(file, outputs))
189
+ .sort()
190
+ .map((file) => `${file} — cited in recap.md but not listed in any done task's outputs`);
191
+
192
+ return {
193
+ status: warnings.length > 0 ? 'warn' : 'pass',
194
+ warnings,
195
+ reason: null,
196
+ citedCount: cited.length,
197
+ };
198
+ }
@@ -1,27 +1,37 @@
1
1
  /**
2
- * Verify Runner — deterministic build+tests+validators cadence.
2
+ * Verify Runner — deterministic build+tests+validators+e2e cadence.
3
3
  *
4
4
  * v8 adds a DETERMINISTIC verification node that runs BEFORE the LLM judge
5
5
  * in the per-task loop: build + tests + validators (code, free) first; the
6
6
  * LLM judge is only invoked once verify passes. This is a BINARY gate
7
7
  * (pass/fail per node), NOT a second "bar 9" — the bar 9 remains morph-eval's.
8
8
  *
9
+ * The `e2e` node closes the last gap in that chain: build proves the code
10
+ * COMPILES, tests prove units behave, validators read structure — none of
11
+ * them prove the application RUNS. See e2e-check.js for why that distinction
12
+ * is load-bearing rather than academic.
13
+ *
9
14
  * Pure orchestration — reuses (never reimplements):
10
15
  * - runBuildCheck() from framework/hooks/shared/build-check.js
11
16
  * - runTestSuite() from src/lib/tasks/test-runner.js
12
17
  * - runValidation() from ./validation-runner.js
18
+ * - runE2eCheck() from framework/hooks/shared/e2e-check.js
13
19
  * - loadRubric() + aggregateScores() from ./score-aggregator.js
14
20
  *
15
21
  * @module verify-runner
16
22
  */
17
23
 
24
+ import { execFileSync } from 'child_process';
18
25
  import { existsSync, readFileSync } from 'fs';
19
- import { join } from 'path';
26
+ import { join, resolve } from 'path';
20
27
 
21
28
  import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
29
+ import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
30
+ import { buildComposeEnv } from '../e2e/compose-env.js';
22
31
  import { runTestSuite, resolveTestCommand } from '../tasks/test-runner.js';
23
32
  import { runValidation } from './validation-runner.js';
24
33
  import { loadRubric, aggregateScores } from './score-aggregator.js';
34
+ import { checkOutputsTraceability } from './traceability-check.js';
25
35
  import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
26
36
 
27
37
  /** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
@@ -41,9 +51,13 @@ const OUTPUT_TAIL_LINES = 20;
41
51
  * @param {number} [options.timeout] - Build timeout in ms (default 120000)
42
52
  * @param {boolean} [options.skipTests] - Skip the tests node
43
53
  * @param {boolean} [options.skipBuild] - Skip the build node
54
+ * @param {boolean} [options.skipE2e] - Skip the e2e node
55
+ * @param {boolean} [options.freshE2e] - Force a stack rebuild instead of reusing a warm one
44
56
  * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
45
57
  * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
46
58
  * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
59
+ * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
60
+ * @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
47
61
  * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
48
62
  */
49
63
  export async function runVerify(projectPath, featureName, taskId, options = {}) {
@@ -52,9 +66,13 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
52
66
  timeout = DEFAULT_BUILD_TIMEOUT,
53
67
  skipTests = false,
54
68
  skipBuild = false,
69
+ skipE2e = false,
70
+ freshE2e = false,
55
71
  _runBuildCheck = runBuildCheck,
56
72
  _runTestSuite = runTestSuite,
57
73
  _runValidation = runValidation,
74
+ _runE2eCheck = runE2eCheck,
75
+ _checkOutputsTraceability = checkOutputsTraceability,
58
76
  } = options;
59
77
 
60
78
  const stack = readStack(projectPath);
@@ -76,6 +94,30 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
76
94
  nodes.validators = await runValidatorsNode({ projectPath, featureName, rubricId, _runValidation });
77
95
  appendValidatorsFeedback(feedback, nodes.validators);
78
96
 
97
+ // 4. e2e — the only node that runs the application. Like tests, it is not
98
+ // invoked at all on a red build: a stack that cannot compile cannot come up,
99
+ // and the docker output would bury the actual compile error.
100
+ nodes.e2e = await runE2eNode({
101
+ projectPath,
102
+ featureName,
103
+ taskId,
104
+ skipE2e,
105
+ freshE2e,
106
+ buildNode: nodes.build,
107
+ _runE2eCheck,
108
+ });
109
+ appendE2eFeedback(feedback, nodes.e2e);
110
+
111
+ // 5. traceability — advisory-only, feature-scope only (see traceability-check.js
112
+ // for the recap-vs-outputs disagreement it flags, and what it deliberately
113
+ // cannot catch). Lives OUTSIDE `nodes` on purpose: `status` below is computed
114
+ // from `nodes` alone, so this can never flip build/tests/validators/e2e green
115
+ // to red — recap/outputs disagreement is a rastreabilidade signal, not a
116
+ // correctness gate.
117
+ const traceability = taskId
118
+ ? { status: 'skip', warnings: [], reason: 'task-scoped verify does not run traceability', citedCount: 0 }
119
+ : _checkOutputsTraceability(projectPath, featureName);
120
+
79
121
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
80
122
 
81
123
  return {
@@ -88,6 +130,7 @@ export async function runVerify(projectPath, featureName, taskId, options = {})
88
130
  status,
89
131
  nodes,
90
132
  feedback,
133
+ traceability,
91
134
  };
92
135
  }
93
136
 
@@ -239,6 +282,63 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
239
282
  }
240
283
  }
241
284
 
285
+ // ============================================================================
286
+ // E2E node
287
+ // ============================================================================
288
+
289
+ async function runE2eNode({ projectPath, featureName, taskId, skipE2e, freshE2e, buildNode, _runE2eCheck }) {
290
+ if (buildNode.status === 'fail') {
291
+ return {
292
+ status: 'skip',
293
+ command: null,
294
+ durationMs: 0,
295
+ reason: 'build failed — the stack could not come up anyway',
296
+ output: '',
297
+ };
298
+ }
299
+
300
+ const config = readConfig(projectPath);
301
+ const taskGroup = taskId ? readTaskGroup(projectPath, featureName, taskId) : null;
302
+ const gitCommonDir = resolveGitCommonDir(projectPath);
303
+
304
+ const start = Date.now();
305
+ const result = _runE2eCheck(projectPath, {
306
+ config,
307
+ featureName,
308
+ taskGroup,
309
+ scope: taskId ? 'task' : 'feature',
310
+ gitCommonDir,
311
+ // The port block matters MORE here than in `e2e up`: this is the path that
312
+ // runs in the loop, and two worktrees verifying at once would otherwise get
313
+ // distinct container names and then collide on host port 3000 — a red gate
314
+ // over an infrastructure clash instead of a defect.
315
+ env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
316
+ skipRequested: skipE2e,
317
+ forceFresh: freshE2e,
318
+ // The stack stays up between verifies on purpose: cold-starting a .NET
319
+ // container per task is the difference between a usable loop and one
320
+ // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
321
+ keepUp: true,
322
+ });
323
+ const durationMs = Date.now() - start;
324
+
325
+ return {
326
+ status: result.status,
327
+ command: result.command ?? null,
328
+ durationMs,
329
+ reason: result.reason ?? null,
330
+ output: result.output || '',
331
+ };
332
+ }
333
+
334
+ function appendE2eFeedback(feedback, e2eNode) {
335
+ if (e2eNode.status !== 'fail') return;
336
+ if (e2eNode.reason) feedback.push(`[e2e] ${e2eNode.reason}`);
337
+ for (const line of linesOf(e2eNode.output)) {
338
+ feedback.push(`[e2e] ${line}`);
339
+ }
340
+ }
341
+
242
342
  // ============================================================================
243
343
  // Rubric selection — flag > task group > config > default
244
344
  // ============================================================================
@@ -328,6 +428,39 @@ function readProjectPaths(projectPath) {
328
428
  }
329
429
  }
330
430
 
431
+ /** Read the whole config.json — the e2e node needs `e2e` and `project` together. */
432
+ function readConfig(projectPath) {
433
+ try {
434
+ const configPath = join(projectPath, '.morph', 'config', 'config.json');
435
+ if (!existsSync(configPath)) return null;
436
+ return JSON.parse(readFileSync(configPath, 'utf8').replace(/^/, ''));
437
+ } catch {
438
+ return null;
439
+ }
440
+ }
441
+
442
+ /**
443
+ * Resolve the shared git dir, where the e2e freshness stamp lives — the same
444
+ * location and rationale as the fleet env-registry (see env-allocator.js): it
445
+ * is the one place every linked worktree of a repo shares, and writing there
446
+ * can never dirty the `git status` the worktree-finish gates read.
447
+ *
448
+ * Fail-open: no git, no stamp — the cost is a rebuild, never a crash.
449
+ */
450
+ function resolveGitCommonDir(projectPath) {
451
+ try {
452
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
453
+ cwd: projectPath,
454
+ stdio: 'pipe',
455
+ encoding: 'utf-8',
456
+ windowsHide: true,
457
+ }).trim();
458
+ return common ? resolve(projectPath, common) : null;
459
+ } catch {
460
+ return null;
461
+ }
462
+ }
463
+
331
464
  function linesOf(text) {
332
465
  const trimmed = (text || '').trim();
333
466
  return trimmed ? trimmed.split('\n') : [];