@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
@@ -4,7 +4,7 @@ description: Conduz a fase opcional de UI/UX — design system, mockups, compone
4
4
  version: core
5
5
  when-to-invoke: ao tomar o ramo uiux (morph-spec advance --design), depois do Gate 1 e antes do morph-plan
6
6
  user-invokable: true
7
- cliVersion: "8.21.0"
7
+ cliVersion: "8.23.0"
8
8
  ---
9
9
 
10
10
  # morph-uiux
@@ -1779,6 +1779,34 @@
1779
1779
  "data-ingestion": "#microsoftextensionsdataingestion-preview--pipeline-pattern-para-adoção-futura"
1780
1780
  },
1781
1781
  "digest": "Microsoft.Extensions.VectorData.Abstractions (GA) is the provider-agnostic vector-store abstraction; Microsoft.Extensions.DataIngestion (preview) is the companion ETL pipeline. Concrete connectors (Postgres, Qdrant, SQL Server...) ship under Microsoft.SemanticKernel.Connectors.* package names despite having nothing to do with Semantic Kernel — an explicit exception to ai-agents-setup's SK ban. Default RAG storage stays ai-agents-rag-custom-pgvector; adopt VectorData only for genuine multi-backend portability, since the Postgres connector is still prerelease."
1782
+ },
1783
+ {
1784
+ "id": "backend-dotnet-testing",
1785
+ "name": "testing",
1786
+ "path": "backend/dotnet/testing.md",
1787
+ "category": "backend",
1788
+ "subcategory": "dotnet",
1789
+ "tags": [
1790
+ "backend",
1791
+ "dotnet"
1792
+ ],
1793
+ "stacks": [
1794
+ "dotnet"
1795
+ ],
1796
+ "aliases": [
1797
+ "xunit",
1798
+ "TimeProvider",
1799
+ "FakeTimeProvider",
1800
+ "fake clock",
1801
+ "integration test",
1802
+ "ProblemDetails",
1803
+ "error body assertion"
1804
+ ],
1805
+ "anchors": {
1806
+ "clock": "#rule-1--inject-timeprovider-fake-it-with-faketimeprovider-in-tests",
1807
+ "errorbody": "#rule-2--error-path-tests-assert-the-response-body-not-just-the-status-code"
1808
+ },
1809
+ "digest": "Inject TimeProvider (never DateTime.UtcNow / DateOnly.FromDateTime(DateTime.UtcNow) directly in a handler or validator) and fake it with FakeTimeProvider (Microsoft.Extensions.TimeProvider.Testing) in integration tests — deterministic, no midnight-UTC flake, boundary dates (today vs. yesterday) become facts about the fake clock instead of when the suite happens to run. xUnit + WebApplicationFactory error-path tests (400/404/409) must assert the ProblemDetails response BODY (title/detail/violated field or error code), never just the HTTP status code — a status-only assertion lets the wrong validation rule or the wrong error branch pass silently as long as the status matches."
1782
1810
  }
1783
1811
  ],
1784
1812
  "sharedAliases": {
@@ -23,7 +23,10 @@ One validator per request DTO. Lives in the same feature folder as the handler:
23
23
  // Features/BookFeature/CreateBook/CreateBookValidator.cs
24
24
  public class CreateBookValidator : AbstractValidator<CreateBookRequest>
25
25
  {
26
- public CreateBookValidator()
26
+ // TimeProvider injected, never DateTime.UtcNow read directly — see
27
+ // backend-dotnet-testing (Rule 1): a validator that reads the real clock
28
+ // cannot be tested deterministically with FakeTimeProvider.
29
+ public CreateBookValidator(TimeProvider timeProvider)
27
30
  {
28
31
  RuleFor(c => c.Title)
29
32
  .NotEmpty().WithMessage("Title is required")
@@ -41,7 +44,7 @@ public class CreateBookValidator : AbstractValidator<CreateBookRequest>
41
44
 
42
45
  RuleFor(c => c.PublishedYear)
43
46
  .GreaterThan(1000).WithMessage("Published year must be a valid year")
44
- .LessThanOrEqualTo(DateTime.UtcNow.Year)
47
+ .LessThanOrEqualTo(timeProvider.GetUtcNow().Year)
45
48
  .WithMessage("Published year cannot be in the future");
46
49
  }
47
50
  }
@@ -0,0 +1,127 @@
1
+ # .NET Integration Testing — VSA
2
+
3
+ > **Scope:** dotnet-vsa
4
+ > **Layer:** 0 (always load)
5
+ > **Keywords:** testing, xunit, integration test, TimeProvider, FakeTimeProvider, WebApplicationFactory, ProblemDetails, error body assertion
6
+ > **Load When:** writing or reviewing tests for a dotnet VSA project
7
+
8
+ **Verified against:** .NET 10 + xUnit 2 + `Microsoft.Extensions.TimeProvider.Testing`. Last-verified: 2026-08-12.
9
+
10
+ ---
11
+
12
+ ## Overview
13
+
14
+ Integration tests exercise the real ASP.NET Core pipeline (`WebApplicationFactory`) against
15
+ an in-memory/test database — the pattern used across `backend-dotnet-vsa-handler-patterns`'
16
+ handler examples. Two failure modes recur in generated test suites and each has a dedicated
17
+ rule below: tests that read the real system clock instead of an injected, fakeable one; and
18
+ error-path tests that assert only the HTTP status code, leaving the actual failure contract
19
+ (the response body) unverified.
20
+
21
+ ---
22
+
23
+ ## Rule 1 — Inject `TimeProvider`, fake it with `FakeTimeProvider` in tests
24
+
25
+ Any logic that compares against "now" (a past-date validation, an expiry check, a due-date
26
+ default) MUST read the clock through an injected `TimeProvider` — never `DateTime.UtcNow` or
27
+ `DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator. A validator that
28
+ reads the real clock makes "today" a moving target: a test asserting "today is valid, yesterday
29
+ is rejected" flakes for real around UTC midnight, and is untestable for the "exactly at the
30
+ boundary" case at all.
31
+
32
+ ```csharp
33
+ // Features/VisitFeature/CloseVisit/CloseVisitValidator.cs
34
+ public sealed class CloseVisitValidator : AbstractValidator<CloseVisitRequest>
35
+ {
36
+ public CloseVisitValidator(TimeProvider timeProvider)
37
+ {
38
+ RuleFor(c => c.FollowUpDate)
39
+ .Must(date => date!.Value >= Today(timeProvider))
40
+ .WithErrorCode(VisitErrors.FollowUpDateInPast.Code)
41
+ .WithMessage(VisitErrors.FollowUpDateInPast.Description)
42
+ .When(c => c.FollowUpDate.HasValue);
43
+ }
44
+
45
+ private static DateOnly Today(TimeProvider timeProvider) =>
46
+ DateOnly.FromDateTime(timeProvider.GetUtcNow().UtcDateTime);
47
+ }
48
+ ```
49
+
50
+ Register the real clock once in `Program.cs`:
51
+
52
+ ```csharp
53
+ builder.Services.AddSingleton(TimeProvider.System);
54
+ ```
55
+
56
+ In tests, swap it for `Microsoft.Extensions.TimeProvider.Testing`'s `FakeTimeProvider` —
57
+ deterministic, no dependency on when the suite happens to run:
58
+
59
+ ```csharp
60
+ // tests/.../Infrastructure/VetClinicApiFactory.cs
61
+ public sealed class VetClinicApiFactory : WebApplicationFactory<Program>
62
+ {
63
+ public FakeTimeProvider TimeProvider { get; } = new(new DateTimeOffset(2026, 6, 15, 0, 0, 0, TimeSpan.Zero));
64
+
65
+ protected override void ConfigureWebHost(IWebHostBuilder builder) =>
66
+ builder.ConfigureServices(services =>
67
+ {
68
+ services.RemoveAll<TimeProvider>();
69
+ services.AddSingleton<TimeProvider>(TimeProvider);
70
+ });
71
+ }
72
+ ```
73
+
74
+ ```csharp
75
+ // The boundary case ("today is accepted, yesterday is not") becomes a fact about
76
+ // the fake clock, not about when CI happens to run.
77
+ DateOnly today = DateOnly.FromDateTime(factory.TimeProvider.GetUtcNow().UtcDateTime);
78
+ DateOnly yesterday = today.AddDays(-1);
79
+ ```
80
+
81
+ ---
82
+
83
+ ## Rule 2 — Error-path tests assert the response BODY, not just the status code
84
+
85
+ A test that only checks `response.StatusCode` proves the endpoint returns *a* 400/404/409 — it
86
+ proves nothing about *why*, and lets the wrong validation rule or the wrong error code pass
87
+ silently as long as the status happens to match. Every error-path test MUST also assert the
88
+ `ProblemDetails` body: at minimum `title` (or `type`) and the specific field/reason that
89
+ triggered the failure.
90
+
91
+ ```csharp
92
+ // Wrong — proves only that SOMETHING was rejected, not what:
93
+ HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
94
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
95
+ ```
96
+
97
+ ```csharp
98
+ // Right — the body is the actual contract a client integrates against:
99
+ HttpResponseMessage response = await client.PostAsJsonAsync($"visits/{id}/close", body);
100
+ Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
101
+
102
+ ProblemDetails? problem = await response.Content.ReadFromJsonAsync<ProblemDetails>();
103
+ Assert.NotNull(problem);
104
+ Assert.Contains("FollowUpDate", problem!.Extensions["errors"]!.ToString());
105
+ Assert.Equal(VisitErrors.FollowUpDateInPast.Code, GetErrorCode(problem));
106
+ ```
107
+
108
+ The same discipline applies to 404 (`NotFound` — assert the entity/id named in the problem
109
+ detail) and 409 (`Conflict` — assert the conflict reason, e.g. `"Visit.AlreadyClosed"`, not just
110
+ that a conflict happened). A status-only assertion is a regression magnet: swapping
111
+ `VisitErrors.NotFound` for `VisitErrors.AlreadyClosed` in the wrong branch still returns 404/409
112
+ respectively and the test suite stays green.
113
+
114
+ ---
115
+
116
+ ## Anti-Patterns
117
+
118
+ | Do NOT | Do Instead |
119
+ |--------|------------|
120
+ | Read `DateTime.UtcNow` / `DateOnly.FromDateTime(DateTime.UtcNow)` directly in a handler or validator | Inject `TimeProvider`, read `timeProvider.GetUtcNow()` |
121
+ | Leave a test's "past date" derived from the real system clock (`DateTime.UtcNow.AddDays(-1)`) | Swap `TimeProvider` for `FakeTimeProvider` in the test factory and derive dates from it |
122
+ | Assert only `response.StatusCode` on an error path | Also deserialize and assert the `ProblemDetails` body (title/detail/violated field or error code) |
123
+ | Treat a passing status-only test as proof the error message/code is correct | Assert the specific error code/field — a wrong branch can still return the right status |
124
+
125
+ ---
126
+
127
+ *MORPH-SPEC by Polymorphism Tech*
@@ -1,7 +1,19 @@
1
1
  # Docker Compose for Local Development
2
2
  # Next.js + Neon Stack
3
-
4
- version: '3.8'
3
+ #
4
+ # Ports come from variables with sane defaults so `morph-spec e2e up` can hand
5
+ # each feature worktree its own port block (see env-allocator.js). Hardcode
6
+ # "3000:3000" here and two features running side by side collide on the host,
7
+ # no matter what the harness injects — the container names would be distinct
8
+ # and the ports would not.
9
+ #
10
+ # Healthchecks are what make `up --wait` meaningful: without them Compose
11
+ # reports "started" the moment the process launches, and the e2e node would
12
+ # navigate an application that is not serving yet.
13
+ #
14
+ # The harness also exports PLAYWRIGHT_BASE_URL for the same allocated port, so
15
+ # `playwright.config.ts` must read it instead of hardcoding a URL:
16
+ # baseURL: process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${process.env.PORT ?? 3000}`
5
17
 
6
18
  services:
7
19
  # Next.js Application
@@ -11,7 +23,7 @@ services:
11
23
  dockerfile: Dockerfile.dev
12
24
  container_name: {{kebabCase FEATURE_NAME}}-app
13
25
  ports:
14
- - "3000:3000"
26
+ - "${PORT:-3000}:3000"
15
27
  environment:
16
28
  - NODE_ENV=development
17
29
  - DATABASE_URL=${DATABASE_URL}
@@ -20,7 +32,14 @@ services:
20
32
  - /app/node_modules
21
33
  - /app/.next
22
34
  depends_on:
23
- - postgres
35
+ postgres:
36
+ condition: service_healthy
37
+ healthcheck:
38
+ test: ["CMD-SHELL", "node -e \"fetch('http://localhost:3000/').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))\""]
39
+ interval: 10s
40
+ timeout: 5s
41
+ retries: 12
42
+ start_period: 20s
24
43
  networks:
25
44
  - app-network
26
45
 
@@ -30,7 +49,7 @@ services:
30
49
  container_name: {{kebabCase FEATURE_NAME}}-postgres
31
50
  restart: unless-stopped
32
51
  ports:
33
- - "5432:5432"
52
+ - "${POSTGRES_PORT:-5432}:5432"
34
53
  environment:
35
54
  POSTGRES_USER: postgres
36
55
  POSTGRES_PASSWORD: postgres
@@ -38,6 +57,11 @@ services:
38
57
  volumes:
39
58
  - postgres-data:/var/lib/postgresql/data
40
59
  - ./migrations:/docker-entrypoint-initdb.d
60
+ healthcheck:
61
+ test: ["CMD-SHELL", "pg_isready -U postgres -d {{snakeCase FEATURE_NAME}}"]
62
+ interval: 5s
63
+ timeout: 5s
64
+ retries: 10
41
65
  networks:
42
66
  - app-network
43
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@polymorphism-tech/morph-spec",
3
- "version": "8.21.0",
3
+ "version": "8.23.0",
4
4
  "description": "MORPH-SPEC: NLH (Natural Language Harness) for spec-driven development with Claude Code",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -0,0 +1,318 @@
1
+ /**
2
+ * `morph-spec e2e up|status|down` — lifecycle of the verification stack.
3
+ *
4
+ * The stack is deliberately NOT torn down between verifies: cold-starting a
5
+ * .NET container per task is the difference between a loop people run and one
6
+ * they disable. `up` brings it (or reuses it), `status` says whether what is
7
+ * running still matches the working tree, and `down` releases it — which
8
+ * `morph-spec finish` also does as part of teardown.
9
+ *
10
+ * All the real logic lives in framework/hooks/shared/e2e-check.js, shared with
11
+ * the `verify` e2e node so the two can never drift.
12
+ *
13
+ * @module commands/e2e
14
+ */
15
+
16
+ import chalk from 'chalk';
17
+ import { execFileSync } from 'child_process';
18
+ import { existsSync, readFileSync } from 'fs';
19
+ import { join, resolve } from 'path';
20
+
21
+ import {
22
+ bringUpStack,
23
+ composeProjectName,
24
+ computeSourceStamp,
25
+ defaultExec,
26
+ getStackStatus,
27
+ isDockerAvailable,
28
+ prepareAuth,
29
+ readStamp,
30
+ resolveComposeFile,
31
+ resolveSpecCommand,
32
+ tearDownStack,
33
+ } from '../../../framework/hooks/shared/e2e-check.js';
34
+ import { buildComposeEnv } from '../../lib/e2e/compose-env.js';
35
+
36
+ /** Exit code for a usage error, matching `verify`'s convention. */
37
+ const EXIT_USAGE = 2;
38
+
39
+ // ============================================================================
40
+ // Shared context
41
+ // ============================================================================
42
+
43
+ /**
44
+ * Everything the three subcommands need, resolved once.
45
+ * Returns null (after printing) when the project has no compose file — that is
46
+ * a usage error here, unlike in the verify node where it is a graded skip.
47
+ */
48
+ function resolveContext(featureName, { json = false } = {}) {
49
+ const cwd = process.cwd();
50
+ const config = readConfig(cwd);
51
+ const composeFile = resolveComposeFile(cwd, config);
52
+
53
+ if (!composeFile) {
54
+ const message =
55
+ 'No compose file found (compose.yaml, docker-compose.yml, …). ' +
56
+ 'Set project.e2e.composeFile in .morph/config/config.json if it lives elsewhere.';
57
+ if (json) console.log(JSON.stringify({ error: message }, null, 2));
58
+ else console.error(chalk.red(`\n❌ ${message}\n`));
59
+ return null;
60
+ }
61
+
62
+ const projectName = composeProjectName(featureName);
63
+ const gitCommonDir = resolveGitCommonDir(cwd);
64
+
65
+ return {
66
+ cwd,
67
+ config,
68
+ composeFile,
69
+ projectName,
70
+ gitCommonDir,
71
+ env: buildEnv({ cwd, config, featureName, projectName, gitCommonDir }),
72
+ };
73
+ }
74
+
75
+ /**
76
+ * Compose environment: the port block this feature owns, plus any credentials
77
+ * from the gitignored auth env file.
78
+ *
79
+ * The port block comes from `buildComposeEnv` rather than being assembled here
80
+ * so this command and the `e2e` node inside `verify` agree by construction —
81
+ * isolation that exists only in the command nobody runs is no isolation at all.
82
+ * `COMPOSE_PROJECT_NAME` isolates container NAMES but not host ports; a compose
83
+ * that hardcodes ports instead of reading `${PORT:-3000}` cannot be isolated at
84
+ * all, which is surfaced as a warning rather than silently accepted.
85
+ */
86
+ function buildEnv({ cwd, config, featureName, projectName, gitCommonDir }) {
87
+ return {
88
+ COMPOSE_PROJECT_NAME: projectName,
89
+ ...buildComposeEnv({ cwd, config, featureName, gitCommonDir }),
90
+ };
91
+ }
92
+
93
+ /**
94
+ * Warn once when the compose file publishes fixed host ports: with a hardcoded
95
+ * `"3000:3000"` the fleet port block cannot take effect, so two features
96
+ * running simultaneously will collide no matter what we inject.
97
+ */
98
+ function warnOnFixedPorts(cwd, composeFile) {
99
+ try {
100
+ const raw = readFileSync(join(cwd, composeFile), 'utf8');
101
+ const publishesFixed = /^\s*-\s*["']?\d+:\d+["']?\s*$/m.test(raw);
102
+ if (publishesFixed) {
103
+ console.log(
104
+ chalk.yellow(
105
+ '⚠️ Compose publishes fixed host ports. Parallel worktrees will collide — ' +
106
+ 'use ${PORT:-3000}:3000 so the fleet port block applies.'
107
+ )
108
+ );
109
+ }
110
+ } catch {
111
+ // Unreadable compose is the caller's problem, not this warning's.
112
+ }
113
+ }
114
+
115
+ // ============================================================================
116
+ // up
117
+ // ============================================================================
118
+
119
+ /**
120
+ * `morph-spec e2e up <feature> [--fresh]`
121
+ *
122
+ * Exit codes: 0 = stack healthy · 1 = failed to come up (or auth broke) ·
123
+ * 2 = usage (no compose file / no docker).
124
+ */
125
+ export async function e2eUpCommand(featureName, options = {}) {
126
+ const ctx = resolveContext(featureName, options);
127
+ if (!ctx) process.exit(EXIT_USAGE);
128
+
129
+ if (!isDockerAvailable(ctx.cwd)) {
130
+ console.error(chalk.red('\n❌ Docker is not available in this environment.\n'));
131
+ process.exit(EXIT_USAGE);
132
+ }
133
+
134
+ if (!options.json) warnOnFixedPorts(ctx.cwd, ctx.composeFile);
135
+
136
+ const result = bringUpStack(ctx.cwd, {
137
+ composeFile: ctx.composeFile,
138
+ projectName: ctx.projectName,
139
+ gitCommonDir: ctx.gitCommonDir,
140
+ env: ctx.env,
141
+ forceFresh: Boolean(options.fresh),
142
+ });
143
+
144
+ const auth =
145
+ result.status === 'pass'
146
+ ? prepareAuth(ctx.cwd, { config: ctx.config, env: ctx.env })
147
+ : { status: 'skip', reason: null };
148
+
149
+ const failed = result.status === 'fail' || auth.status === 'fail';
150
+ const payload = {
151
+ feature: featureName,
152
+ project: ctx.projectName,
153
+ composeFile: ctx.composeFile,
154
+ status: failed ? 'fail' : 'pass',
155
+ reused: result.reused,
156
+ reason: result.reason,
157
+ auth: { status: auth.status, reason: auth.reason },
158
+ };
159
+
160
+ if (options.json) {
161
+ console.log(JSON.stringify(payload, null, 2));
162
+ } else if (failed) {
163
+ console.error(chalk.red(`\n✗ ${result.reason || auth.reason}\n`));
164
+ if (result.output) console.error(result.output);
165
+ } else {
166
+ const how = result.reused ? chalk.gray(`(${result.reason})`) : chalk.gray('(built)');
167
+ console.log(chalk.green(`\n✓ Stack up — ${ctx.projectName} ${how}`));
168
+ if (auth.reason) console.log(chalk.gray(` auth: ${auth.reason}`));
169
+ console.log('');
170
+ }
171
+
172
+ process.exit(failed ? 1 : 0);
173
+ }
174
+
175
+ // ============================================================================
176
+ // status
177
+ // ============================================================================
178
+
179
+ /**
180
+ * Where the app actually landed.
181
+ *
182
+ * The reviewer hands this to the evaluator instead of letting it guess
183
+ * `localhost:3000` — each feature owns a port block, so the default port is the
184
+ * one address most likely to be wrong. Mirrors what `playwright.config.ts` is
185
+ * asked to do, deliberately: one rule, two readers.
186
+ *
187
+ * @param {Record<string,string>} env
188
+ * @returns {string}
189
+ */
190
+ export function resolveBaseUrl(env = {}) {
191
+ return env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${env.PORT ?? 3000}`;
192
+ }
193
+
194
+ /**
195
+ * `morph-spec e2e status <feature> [--json]`
196
+ *
197
+ * Answers the question that matters before trusting a green run: is what is
198
+ * running still built from the code on disk? Exit 0 when up AND fresh, 1
199
+ * otherwise — so it can gate a script.
200
+ */
201
+ export async function e2eStatusCommand(featureName, options = {}) {
202
+ const ctx = resolveContext(featureName, options);
203
+ if (!ctx) process.exit(EXIT_USAGE);
204
+
205
+ const status = getStackStatus(ctx.cwd, {
206
+ composeFile: ctx.composeFile,
207
+ projectName: ctx.projectName,
208
+ env: ctx.env,
209
+ });
210
+
211
+ const stamp = computeSourceStamp(ctx.cwd);
212
+ const previous = ctx.gitCommonDir ? readStamp(ctx.gitCommonDir, ctx.projectName) : null;
213
+ const fresh = Boolean(stamp && previous?.stamp === stamp);
214
+
215
+ const spec = resolveSpecCommand(ctx.cwd, ctx.config, ctx.config?.project?.frontendPath);
216
+
217
+ const payload = {
218
+ feature: featureName,
219
+ project: ctx.projectName,
220
+ composeFile: ctx.composeFile,
221
+ up: status.up,
222
+ healthy: status.healthy,
223
+ fresh,
224
+ baseUrl: resolveBaseUrl(ctx.env),
225
+ builtAt: previous?.builtAt ?? null,
226
+ specs: spec ? `${spec.command} ${spec.args.join(' ')}` : null,
227
+ services: status.services.map((s) => ({
228
+ name: s.Service ?? s.Name ?? null,
229
+ state: s.State ?? null,
230
+ health: s.Health || null,
231
+ })),
232
+ };
233
+
234
+ if (options.json) {
235
+ console.log(JSON.stringify(payload, null, 2));
236
+ } else {
237
+ console.log('');
238
+ console.log(chalk.bold(`E2E stack — ${ctx.projectName}`));
239
+ console.log(chalk.gray(`compose: ${ctx.composeFile}`));
240
+ console.log(chalk.gray(`url: ${payload.baseUrl}`));
241
+ console.log('');
242
+ console.log(` up ${status.up ? chalk.green('yes') : chalk.gray('no')}`);
243
+ console.log(` healthy ${status.healthy ? chalk.green('yes') : chalk.gray('no')}`);
244
+ console.log(
245
+ ` fresh ${fresh ? chalk.green('yes') : chalk.yellow('no — source changed since build')}`
246
+ );
247
+ console.log(` specs ${spec ? payload.specs : chalk.gray('none (smoke only)')}`);
248
+ for (const s of payload.services) {
249
+ console.log(chalk.gray(` · ${s.name}: ${s.state}${s.health ? ` / ${s.health}` : ''}`));
250
+ }
251
+ console.log('');
252
+ }
253
+
254
+ process.exit(status.up && status.healthy && fresh ? 0 : 1);
255
+ }
256
+
257
+ // ============================================================================
258
+ // down
259
+ // ============================================================================
260
+
261
+ /**
262
+ * `morph-spec e2e down <feature> [--volumes]`
263
+ *
264
+ * Teardown is best-effort by design — a stack that will not come down must
265
+ * never block `finish`.
266
+ */
267
+ export async function e2eDownCommand(featureName, options = {}) {
268
+ const ctx = resolveContext(featureName, options);
269
+ if (!ctx) process.exit(EXIT_USAGE);
270
+
271
+ const result = tearDownStack(ctx.cwd, {
272
+ composeFile: ctx.composeFile,
273
+ projectName: ctx.projectName,
274
+ env: ctx.env,
275
+ volumes: Boolean(options.volumes),
276
+ });
277
+
278
+ if (options.json) {
279
+ console.log(JSON.stringify({ feature: featureName, project: ctx.projectName, status: result.status }, null, 2));
280
+ } else if (result.status === 'pass') {
281
+ console.log(chalk.green(`\n✓ Stack down — ${ctx.projectName}\n`));
282
+ } else {
283
+ console.log(chalk.yellow(`\n⚠️ Teardown reported a problem (not fatal):\n${result.output}\n`));
284
+ }
285
+
286
+ process.exit(0);
287
+ }
288
+
289
+ // ============================================================================
290
+ // Shared helpers
291
+ // ============================================================================
292
+
293
+ function readConfig(cwd) {
294
+ try {
295
+ const path = join(cwd, '.morph', 'config', 'config.json');
296
+ if (!existsSync(path)) return null;
297
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
298
+ } catch {
299
+ return null;
300
+ }
301
+ }
302
+
303
+ /** Same resolution (and same fail-open) as verify-runner's. */
304
+ function resolveGitCommonDir(cwd) {
305
+ try {
306
+ const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
307
+ cwd,
308
+ stdio: 'pipe',
309
+ encoding: 'utf-8',
310
+ windowsHide: true,
311
+ }).trim();
312
+ return common ? resolve(cwd, common) : null;
313
+ } catch {
314
+ return null;
315
+ }
316
+ }
317
+
318
+ export { defaultExec };
@@ -325,6 +325,17 @@ export async function approveCommand(featureName, gateName, options = {}) {
325
325
  ...(selfAssessed ? { selfAssessed: true } : {}),
326
326
  });
327
327
 
328
+ // Fleet handoff (mirrors advance.js): approving a gate is exactly when a
329
+ // feature hands over between sessions too — `approve` is the real path
330
+ // Gate 2 took in the field (`approve <f> plan --mode auto`), not just
331
+ // `advance`, so a handoff.md left stale after approve was the common
332
+ // case, not the edge case. Same helper, same fail-open contract: a
333
+ // regeneration failure must never fail the approval itself.
334
+ try {
335
+ const { writeHandoff } = await import('../../lib/generators/handoff-generator.js');
336
+ writeHandoff(featureName, process.cwd());
337
+ } catch { /* fail-open */ }
338
+
328
339
  if (selfAssessed) {
329
340
  console.log(chalk.yellow('\n⚠️ Gate 3 aprovado SEM avaliador independente (--self-assessed).'));
330
341
  console.log(chalk.gray(' Registrado em feature.json → approvalGates.review.selfAssessed.'));
@@ -63,7 +63,8 @@ function readReviewApproved(fjPath) {
63
63
  * @param {string} feature
64
64
  * @param {{ cwd?: string, force?: boolean, git?: boolean }} [opts]
65
65
  * @returns {{ ok: boolean, error?: string, archived?: boolean, alreadyArchived?: boolean,
66
- * committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[] }}
66
+ * committed?: boolean, staged?: boolean, archivePath?: string, promotedUseCases?: string[],
67
+ * refusedUseCases?: Array<{id: string, incoming: string, existing: string, existingFeature: string, message: string}> }}
67
68
  */
68
69
  export function runArchive(feature, opts = {}) {
69
70
  const cwd = opts.cwd || process.cwd();
@@ -156,9 +157,11 @@ export function runArchive(feature, opts = {}) {
156
157
  // (MORPH.md §2.2). Fail-open: any failure degrades to "promoted nothing",
157
158
  // never to a refused close.
158
159
  let promotedUseCases = [];
160
+ let refusedUseCases = [];
159
161
  try {
160
- const { promoted } = promoteUseCases(cwd, archivePath);
162
+ const { promoted, refused } = promoteUseCases(cwd, archivePath);
161
163
  promotedUseCases = promoted;
164
+ refusedUseCases = refused;
162
165
  if (promoted.length && useGit && staged) {
163
166
  // This staging exists for the NEW files: promoted use cases are untracked,
164
167
  // and the scoped `commit -- <pathspec>` below only knows paths git already
@@ -203,7 +206,7 @@ export function runArchive(feature, opts = {}) {
203
206
  } catch { /* index is derivable — non-fatal */ }
204
207
  }
205
208
 
206
- return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases };
209
+ return { ok: true, archived: true, committed, staged, archivePath, promotedUseCases, refusedUseCases };
207
210
  }
208
211
 
209
212
  /**
@@ -244,6 +247,12 @@ export async function archiveCommand(featureName, options = {}) {
244
247
  if (result.promotedUseCases?.length) {
245
248
  console.log(chalk.gray(` ${result.promotedUseCases.length} use case(s) promoted → .morph/specs/usecases/ (status: implemented).`));
246
249
  }
250
+ if (result.refusedUseCases?.length) {
251
+ console.log(chalk.yellow(`\n⚠️ ${result.refusedUseCases.length} use case(s) NOT promoted — numbering collision:`));
252
+ for (const r of result.refusedUseCases) {
253
+ console.log(chalk.yellow(` ${r.message}`));
254
+ }
255
+ }
247
256
  if (result.committed) console.log(chalk.gray(` Move committed on the current branch.\n`));
248
257
  else if (result.staged) console.log(chalk.gray(` Move staged in git; commit it when ready.\n`));
249
258
  else console.log('');