@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.
- package/README.md +2 -2
- package/bin/morph-spec.js +43 -1
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -1
- package/framework/MORPH.md +7 -3
- package/framework/agents.json +3 -1
- package/framework/docs/CLI.md +5 -3
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/schemas/feature.schema.json +5 -3
- 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 +2 -2
- 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 +27 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +60 -8
- 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/framework/templates/infrastructure/docker/docker-compose.template.yml +29 -5
- package/package.json +1 -1
- package/src/commands/e2e/e2e.js +318 -0
- package/src/commands/state/approve.js +11 -0
- package/src/commands/state/archive.js +12 -3
- package/src/commands/state/finish.js +47 -4
- package/src/commands/state/handoff.js +3 -2
- package/src/commands/state/usecase.js +77 -0
- package/src/commands/validation/validate-feature.js +7 -1
- package/src/commands/validation/verify.js +13 -0
- package/src/lib/e2e/compose-env.js +74 -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 +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.
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
@@ -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('');
|