@polymorphism-tech/morph-spec 8.33.0 → 8.35.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/CLAUDE.md +22 -2
- package/README.md +2 -2
- package/bin/morph-spec.js +43 -5
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +5 -4
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +20 -22
- package/framework/agents.json +198 -44
- package/framework/ai-pin.json +22 -0
- package/framework/commands/morph-media.md +45 -0
- package/framework/docs/CLI.md +238 -10
- package/framework/evals/proof-rubrics/README.md +76 -0
- package/framework/evals/proof-rubrics/eval-de-modelo.md +64 -0
- package/framework/evals/proof-rubrics/fiacao.md +54 -0
- package/framework/evals/proof-rubrics/guard-cego.md +52 -0
- package/framework/evals/proof-rubrics/media-claims.md +109 -0
- package/framework/evals/proof-rubrics/mutation-proof.md +77 -0
- package/framework/evals/proof-rubrics/observabilidade-genai.md +119 -0
- package/framework/evals/proof-rubrics/verde-nao-e-coberto.md +48 -0
- package/framework/evals/rubrics/architecture.md +30 -0
- package/framework/evals/rubrics/code-quality.md +26 -0
- package/framework/evals/rubrics/test-coverage.md +20 -0
- package/framework/hooks/README.md +11 -1
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
- package/framework/hooks/claude-code/core/chain.js +27 -7
- package/framework/hooks/claude-code/core/protect-spec-files.js +41 -4
- package/framework/hooks/claude-code/core/worktree-context.js +83 -14
- package/framework/hooks/claude-code/core/worktree-create.js +206 -0
- package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
- package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
- package/framework/hooks/shared/command-tokens.js +23 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/git-paths.js +25 -0
- package/framework/hooks/shared/hook-response.js +17 -0
- package/framework/hooks/shared/morph-cli.js +134 -0
- package/framework/rules/ai-agents-standards.md +14 -2
- package/framework/rules/infrastructure-standards.md +3 -1
- package/framework/schemas/ai-pin.schema.json +97 -0
- package/framework/schemas/config.schema.json +107 -0
- package/framework/schemas/feature.schema.json +23 -4
- 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 +28 -7
- 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 +2 -2
- package/framework/skills/core/morph-implement/SKILL.md +20 -2
- package/framework/skills/core/morph-media/SKILL.md +215 -0
- package/framework/skills/core/morph-plan/SKILL.md +38 -4
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +166 -9
- 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/PROVENANCE.json +81 -3
- package/framework/standards/STANDARDS.json +1504 -830
- package/framework/standards/ai-agents/agent-archetypes.md +160 -0
- package/framework/standards/ai-agents/agent-session.md +256 -160
- package/framework/standards/ai-agents/agent-spec.md +196 -0
- package/framework/standards/ai-agents/batching.md +217 -193
- package/framework/standards/ai-agents/context-providers.md +261 -188
- package/framework/standards/ai-agents/conversational-agent-with-phases.md +239 -0
- package/framework/standards/ai-agents/cost-and-budget.md +256 -0
- package/framework/standards/ai-agents/durable-workflows-hitl.md +98 -5
- package/framework/standards/ai-agents/evals-with-cache.md +289 -0
- package/framework/standards/ai-agents/guardrails.md +222 -0
- package/framework/standards/ai-agents/llm-runtime-defaults.md +226 -0
- package/framework/standards/ai-agents/mcp-server.md +351 -250
- package/framework/standards/ai-agents/mcp-tools.md +250 -197
- package/framework/standards/ai-agents/media-pipeline.md +352 -0
- package/framework/standards/ai-agents/media-video.md +267 -0
- package/framework/standards/ai-agents/middleware-patterns.md +241 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +422 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +177 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +189 -181
- package/framework/standards/ai-agents/modalities-vision.md +205 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +211 -155
- package/framework/standards/ai-agents/observability-patterns.md +227 -17
- package/framework/standards/ai-agents/production.md +260 -231
- package/framework/standards/ai-agents/prompt-sources.md +389 -0
- package/framework/standards/ai-agents/providers/model-registry.md +293 -158
- package/framework/standards/ai-agents/rag-custom-pgvector.md +69 -4
- package/framework/standards/ai-agents/service-tier-flex.md +143 -135
- package/framework/standards/ai-agents/setup.md +166 -50
- package/framework/standards/ai-agents/structured-output.md +280 -249
- package/framework/standards/ai-agents/sweet-spot.md +129 -109
- package/framework/standards/ai-agents/testing-ai.md +236 -0
- package/framework/standards/ai-agents/vector-data-extensions.md +50 -20
- package/framework/standards/ai-agents/workflows.md +27 -7
- package/framework/standards/frontend/design-system/ai-image-generation.md +12 -0
- package/framework/standards/frontend/design-system/ai-video-generation.md +13 -0
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +90 -0
- package/framework/templates/REGISTRY.json +14 -1
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/dotnet/ai-kit/Directory.Build.props +25 -0
- package/framework/templates/dotnet/ai-kit/Directory.Packages.props +26 -0
- package/framework/templates/dotnet/ai-kit/Morph.AiKit.sln +56 -0
- package/framework/templates/dotnet/ai-kit/NuGet.config +12 -0
- package/framework/templates/dotnet/ai-kit/README.md +128 -0
- package/framework/templates/dotnet/ai-kit/global.json +9 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +65 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +289 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +91 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +181 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +14 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +174 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +54 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +75 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +71 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +246 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +57 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +78 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +225 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +463 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +32 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +49 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +214 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +168 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +142 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +349 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +42 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +77 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +28 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +107 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +253 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +103 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +259 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +21 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +47 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +102 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +44 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +192 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +94 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +99 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +97 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +133 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +93 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +112 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +288 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +158 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +29 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +208 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +95 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Morph.AiKit.csproj +38 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +119 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +82 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +33 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +267 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +474 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/model-registry.v2.json +87 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +169 -0
- package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +49 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +495 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +260 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +405 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +379 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +197 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +139 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +157 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +178 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +153 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +194 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +86 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +213 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +95 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +225 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +215 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +55 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +60 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +131 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +133 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +127 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +216 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +264 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +148 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +184 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +89 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +98 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +451 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +76 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +33 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +261 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +374 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/.morph-eval-cache.json +12 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/orcamento.acme.a-resposta-estruturada/1/CF70A7CE27BBD66A/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.a-saudacao/1/19F631228C6E00BA/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.acme.b-escalonamento/1/A24A7C9BDD8239D8/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/contents.data +13 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-cache/cache/triagem.globex.a-saudacao/1/E38CD37DF003EDB7/entry.json +7 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/orcamento/acme/a-resposta-estruturada.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/a-saudacao.json +23 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/acme/b-escalonamento.json +24 -0
- package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/eval-datasets/triagem/globex/a-saudacao.json +23 -0
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
- package/package.json +3 -3
- package/src/commands/e2e/e2e.js +91 -40
- package/src/commands/project/doctor.js +143 -6
- package/src/commands/project/env.js +95 -0
- package/src/commands/project/update.js +469 -153
- package/src/commands/project/worktree.js +274 -53
- package/src/commands/state/advance.js +24 -2
- package/src/commands/state/approve.js +33 -1
- package/src/commands/state/create.js +72 -11
- package/src/commands/state/delete.js +8 -0
- package/src/commands/state/finish.js +360 -53
- package/src/commands/state/gate-check.js +190 -6
- package/src/commands/state/score.js +36 -1
- package/src/commands/validation/verify.js +134 -25
- package/src/core/state/state-manager.js +9 -1
- package/src/dashboard/app/pages/HookInspector.jsx +430 -418
- package/src/dashboard/scanner/hooks-scanner.js +5 -1
- package/src/lib/ai/ai-checks.js +182 -0
- package/src/lib/ai/ai-project-scan.js +347 -0
- package/src/lib/ai/code-audit.js +181 -0
- package/src/lib/ai/comment-stripper.js +184 -0
- package/src/lib/ai/pin-drift.js +138 -0
- package/src/lib/ai/registry-audit.js +285 -0
- package/src/lib/e2e/compose-env.js +55 -14
- package/src/lib/e2e/stack-teardown.js +115 -0
- package/src/lib/evaluator-report.js +269 -15
- package/src/lib/generators/architecture-scanner.js +2 -1
- package/src/lib/git/default-branch.js +92 -4
- package/src/lib/graph/graphifyignore.js +2 -1
- package/src/lib/installers/effects.js +193 -0
- package/src/lib/installers/templates-reconcile.js +3 -3
- package/src/lib/risk-detector.js +284 -0
- package/src/lib/standards/verified-against.js +276 -0
- package/src/lib/tasks/eval-node.js +485 -0
- package/src/lib/tasks/linux-runner.js +297 -0
- package/src/lib/tasks/test-nodes.js +486 -0
- package/src/lib/tasks/test-runner.js +431 -37
- package/src/lib/validators/verify-runner.js +590 -28
- package/src/lib/worktree/dirs.js +32 -0
- package/src/lib/worktree/docker-audit.js +287 -0
- package/src/lib/worktree/env-allocator.js +69 -11
- package/src/lib/worktree/worktree-audit.js +70 -3
- package/src/scripts/setup-infra.js +24 -11
- package/src/utils/agents-installer.js +18 -8
- package/src/utils/claude-md-injector.js +10 -5
- package/src/utils/file-copier.js +28 -16
- package/src/utils/hooks-installer.js +75 -29
- package/src/utils/skills-installer.js +194 -187
- package/src/utils/version-checker.js +68 -3
- package/framework/standards/ai-agents/prompts-in-db.md +0 -214
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dirs.js — the ONE place the worktree container directory is named.
|
|
3
|
+
*
|
|
4
|
+
* Every reader that resolves worktrees BY CONVENTION (test discovery, graph
|
|
5
|
+
* ignore list, the installed .gitignore, the architecture scanner, the
|
|
6
|
+
* dashboard scanner) imports from here. Duplicating the string is how the
|
|
7
|
+
* C1 bug comes back: a reader keeps the old name and silently goes blind.
|
|
8
|
+
*
|
|
9
|
+
* Readers that go through `git worktree list` (fleet, finish, worktree list)
|
|
10
|
+
* never depended on this — git records absolute paths.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* The directory new worktrees are created under, relative to the primary root.
|
|
15
|
+
*
|
|
16
|
+
* NO leading dot, deliberately (C1). Jest escapes the `.` when it interpolates
|
|
17
|
+
* an absolute `rootDir` into the `testMatch` glob, so `<root>/.worktrees/...`
|
|
18
|
+
* stops matching and an entire frontend suite reports **0 tests without an
|
|
19
|
+
* error** — the suite "passes" having tested nothing. The dot bought nothing:
|
|
20
|
+
* the directory is gitignored either way.
|
|
21
|
+
*/
|
|
22
|
+
export const WORKTREES_DIR = 'worktrees';
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The pre-C1 directory name. New worktrees never land here, but every reader
|
|
26
|
+
* that resolves worktrees BY CONVENTION must still accept it, so a machine
|
|
27
|
+
* that already had `.worktrees/{f}` keeps working with no manual migration.
|
|
28
|
+
*/
|
|
29
|
+
export const LEGACY_WORKTREES_DIR = '.worktrees';
|
|
30
|
+
|
|
31
|
+
/** Both names, new first — the read order for convention-based lookups. */
|
|
32
|
+
export const WORKTREE_DIR_NAMES = [WORKTREES_DIR, LEGACY_WORKTREES_DIR];
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* docker-audit.js — the part of "which worktrees exist" that the filesystem
|
|
3
|
+
* cannot answer: the docker stacks still running for them.
|
|
4
|
+
*
|
|
5
|
+
* `worktree-audit.js` reconciles git's registry, the disk and morph's features.
|
|
6
|
+
* None of the three can see a CONTAINER. A stack survives everything they look
|
|
7
|
+
* at: `finish` removes the worktree and the branch, the directory disappears,
|
|
8
|
+
* git forgets the registration — and `morph-{feature}` keeps running, holding
|
|
9
|
+
* the feature's whole port block (and its named volumes) against the next
|
|
10
|
+
* worktree that lands on the same block. The failure surfaces later, somewhere
|
|
11
|
+
* else, as `port is already allocated` during an `e2e up` that has nothing to
|
|
12
|
+
* do with the stack that is actually holding the port.
|
|
13
|
+
*
|
|
14
|
+
* Two findings, both `severity: 'warn'` — reported, never a failure of
|
|
15
|
+
* `doctor --worktrees` (`doctor.js` treats `info`/`warn` as non-problems):
|
|
16
|
+
*
|
|
17
|
+
* • docker-stack-orphan — a `morph-*` compose project of THIS repository
|
|
18
|
+
* running for a feature this tree does not know
|
|
19
|
+
* • docker-port-held — a port from a feature's allocated block published
|
|
20
|
+
* by a container belonging to a DIFFERENT compose project
|
|
21
|
+
*
|
|
22
|
+
* This module does not go into `doctor --full`, on purpose (D10): every check
|
|
23
|
+
* there must be satisfiable in the framework repo itself, and a docker check in
|
|
24
|
+
* a repo with no compose file could only be permanently yellow. A check that
|
|
25
|
+
* can never pass where it runs teaches nothing.
|
|
26
|
+
*
|
|
27
|
+
* Nothing here mutates anything — not the registry it reads (see
|
|
28
|
+
* `allocatedFeatures`), and certainly not docker. The `fix` strings name the
|
|
29
|
+
* destructive command; a human runs it.
|
|
30
|
+
*
|
|
31
|
+
* @module lib/worktree/docker-audit
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { execFileSync } from 'child_process';
|
|
35
|
+
import { resolve } from 'path';
|
|
36
|
+
|
|
37
|
+
// src/ → framework/hooks/shared/ is the allowed direction (hooks are installed
|
|
38
|
+
// standalone into target projects, where src/ does not exist).
|
|
39
|
+
import {
|
|
40
|
+
composeProjectName,
|
|
41
|
+
defaultExec,
|
|
42
|
+
isDockerAvailable,
|
|
43
|
+
parseComposePs,
|
|
44
|
+
} from '../../../framework/hooks/shared/e2e-check.js';
|
|
45
|
+
import { allocateEnvBlock, allocatedFeatures, portLayout, resolveFleetConfig } from './env-allocator.js';
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* The prefix `composeProjectName()` stamps on every stack morph brings up,
|
|
49
|
+
* derived from the function itself rather than retyped — the string `morph-`
|
|
50
|
+
* living in two places is how the two stop agreeing.
|
|
51
|
+
*/
|
|
52
|
+
const PROJECT_PROBE = 'probe';
|
|
53
|
+
const MORPH_PROJECT_PREFIX = composeProjectName(PROJECT_PROBE).slice(0, -PROJECT_PROBE.length);
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Same fail-open resolution the other callers use: the COMMON git dir is the
|
|
57
|
+
* one location every linked worktree shares, and it is where the port-block
|
|
58
|
+
* registry lives.
|
|
59
|
+
*/
|
|
60
|
+
function resolveGitCommonDir(cwd) {
|
|
61
|
+
try {
|
|
62
|
+
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
63
|
+
cwd, stdio: 'pipe', encoding: 'utf-8', windowsHide: true,
|
|
64
|
+
}).trim();
|
|
65
|
+
return common ? resolve(cwd, common) : null;
|
|
66
|
+
} catch {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Path comparison that matches the host's own rules. Windows paths differ in
|
|
73
|
+
* drive-letter case between what git reports and what docker echoes back;
|
|
74
|
+
* treating those as different paths would make every stack look foreign.
|
|
75
|
+
*/
|
|
76
|
+
const samePathCase = process.platform === 'win32' ? (s) => s.toLowerCase() : (s) => s;
|
|
77
|
+
const norm = (p) => samePathCase(resolve(String(p)).replace(/\\/g, '/'));
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Is this compose project OURS to talk about?
|
|
81
|
+
*
|
|
82
|
+
* The `morph-` prefix is global to the host, not to a repository: two different
|
|
83
|
+
* repos that use morph-spec and both have a feature named `triagem` produce the
|
|
84
|
+
* SAME compose project name. Without this test, `doctor` in repo A would report
|
|
85
|
+
* repo B's live stack as an orphan and hand the reader a `down -v` — volumes
|
|
86
|
+
* included — for somebody else's running database. The compose file's path is
|
|
87
|
+
* what ties a stack to a repository, and it keeps pointing inside the root even
|
|
88
|
+
* after the worktree directory it named is gone.
|
|
89
|
+
*
|
|
90
|
+
* Unattributable (no config file in the listing) counts as NOT ours: an
|
|
91
|
+
* unknown owner is not evidence of ownership.
|
|
92
|
+
*/
|
|
93
|
+
function belongsToRoot(entry, rootNorm) {
|
|
94
|
+
const configFiles = String(entry?.ConfigFiles ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
95
|
+
if (configFiles.length === 0) return false;
|
|
96
|
+
return configFiles.some((f) => {
|
|
97
|
+
try {
|
|
98
|
+
return norm(f).startsWith(`${rootNorm}/`);
|
|
99
|
+
} catch {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Host ports a `docker ps`/`docker compose ps` entry publishes.
|
|
107
|
+
* Both output shapes are accepted rather than pinning a docker version:
|
|
108
|
+
* `Ports` as the human string (`0.0.0.0:4002->5432/tcp`) and `Publishers` as
|
|
109
|
+
* the structured array compose emits.
|
|
110
|
+
*
|
|
111
|
+
* @returns {Set<number>}
|
|
112
|
+
*/
|
|
113
|
+
function publishedPorts(entry) {
|
|
114
|
+
const ports = new Set();
|
|
115
|
+
for (const pub of Array.isArray(entry?.Publishers) ? entry.Publishers : []) {
|
|
116
|
+
const p = Number(pub?.PublishedPort);
|
|
117
|
+
if (Number.isInteger(p) && p > 0) ports.add(p);
|
|
118
|
+
}
|
|
119
|
+
const raw = String(entry?.Ports ?? '');
|
|
120
|
+
for (const m of raw.matchAll(/:(\d{1,5})->/g)) {
|
|
121
|
+
const p = Number(m[1]);
|
|
122
|
+
if (Number.isInteger(p) && p > 0) ports.add(p);
|
|
123
|
+
}
|
|
124
|
+
return ports;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/** The compose project a container belongs to, from its labels. `null` = unknown. */
|
|
128
|
+
function composeProjectOf(entry) {
|
|
129
|
+
const labels = String(entry?.Labels ?? '');
|
|
130
|
+
for (const part of labels.split(',')) {
|
|
131
|
+
const idx = part.indexOf('=');
|
|
132
|
+
if (idx < 0) continue;
|
|
133
|
+
if (part.slice(0, idx).trim() === 'com.docker.compose.project') return part.slice(idx + 1).trim() || null;
|
|
134
|
+
}
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Readable container name for a `docker ps` entry (Names may be a list). */
|
|
139
|
+
function containerNameOf(entry) {
|
|
140
|
+
const names = String(entry?.Names ?? entry?.Name ?? '').split(',').map((s) => s.trim()).filter(Boolean);
|
|
141
|
+
return names[0] || String(entry?.ID ?? 'container desconhecido');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Parse a docker JSON listing that may be an array or NDJSON.
|
|
146
|
+
* `null` means the output could not be read — DISTINCT from `[]`, which means
|
|
147
|
+
* docker answered and there was nothing running. A caller that conflated the
|
|
148
|
+
* two would turn "could not ask" into "nothing to report", or worse, into a
|
|
149
|
+
* finding.
|
|
150
|
+
*
|
|
151
|
+
* @returns {Array<object>|null}
|
|
152
|
+
*/
|
|
153
|
+
function parseDockerJson(raw) {
|
|
154
|
+
const text = String(raw ?? '').trim();
|
|
155
|
+
if (!text) return [];
|
|
156
|
+
const parsed = parseComposePs(text);
|
|
157
|
+
if (parsed.length > 0) return parsed;
|
|
158
|
+
// parseComposePs (the ONE docker-JSON parser in this codebase) collapses two
|
|
159
|
+
// very different answers into `[]`: "docker listed nothing" and "this is not
|
|
160
|
+
// JSON". Only a literal empty array is the first one; everything else that
|
|
161
|
+
// parsed to nothing is garbage, and garbage must not read as "all clear".
|
|
162
|
+
return /^\[\s*\]$/.test(text) ? [] : null;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Audit the docker stacks around this repository.
|
|
167
|
+
*
|
|
168
|
+
* Returns `[]` — asking docker nothing at all — in three situations, and the
|
|
169
|
+
* silence is the point: this module is loaded by `doctor --worktrees` in every
|
|
170
|
+
* repo, including ones that never containerize anything.
|
|
171
|
+
*
|
|
172
|
+
* 1. no feature ever allocated a port block (no registry): there is no
|
|
173
|
+
* isolation contract to compare a running stack against;
|
|
174
|
+
* 2. the docker CLI is not usable here;
|
|
175
|
+
* 3. any docker call exits non-zero or answers with something that is not
|
|
176
|
+
* JSON — fail-open, the standing rule of this repo's diagnostics.
|
|
177
|
+
*
|
|
178
|
+
* `[]` here reads as "nothing to report", not as "unknown": with no docker or
|
|
179
|
+
* no blocks, nothing CAN be colliding. That is why this is not the `null`-is-
|
|
180
|
+
* UNKNOWN shape `pr-index.js` uses — there the question stays open, here the
|
|
181
|
+
* absence is itself the answer.
|
|
182
|
+
*
|
|
183
|
+
* @param {string} root - primary root of the repository
|
|
184
|
+
* @param {object} [opts]
|
|
185
|
+
* @param {(file: string, args: string[], o?: object) => {code: number, output: string}} [opts.exec]
|
|
186
|
+
* injectable executor, exactly like `e2e-check.js` — the CI runners have no
|
|
187
|
+
* guaranteed daemon, so every path here must be testable without docker
|
|
188
|
+
* @param {Iterable<string>} [opts.features] - feature names this tree knows.
|
|
189
|
+
* Passed IN rather than re-derived: `auditWorktrees` already read
|
|
190
|
+
* `.morph/features/` and `git worktree list`, and two scans of the same fact
|
|
191
|
+
* are two chances to disagree about whether a stack is orphaned
|
|
192
|
+
* @param {string|null} [opts.gitCommonDir]
|
|
193
|
+
* @returns {Array<{type: string, severity: string, detail: string, fix: string, feature?: string}>}
|
|
194
|
+
*/
|
|
195
|
+
export function auditDockerStacks(root, { exec = defaultExec, features = [], gitCommonDir } = {}) {
|
|
196
|
+
const commonDir = gitCommonDir === undefined ? resolveGitCommonDir(root) : gitCommonDir;
|
|
197
|
+
if (!commonDir) return [];
|
|
198
|
+
|
|
199
|
+
// (1) No port block was ever allocated → no fleet isolation to protect.
|
|
200
|
+
const allocated = allocatedFeatures(commonDir);
|
|
201
|
+
if (allocated.length === 0) return [];
|
|
202
|
+
|
|
203
|
+
// (2) No docker → nothing can be holding anything.
|
|
204
|
+
if (!isDockerAvailable(root, exec)) return [];
|
|
205
|
+
|
|
206
|
+
const known = new Set(features);
|
|
207
|
+
const rootNorm = norm(root);
|
|
208
|
+
const issues = [];
|
|
209
|
+
|
|
210
|
+
// ---- docker-stack-orphan ----------------------------------------------
|
|
211
|
+
const lsResult = exec('docker', ['compose', 'ls', '--format', 'json'], { cwd: root });
|
|
212
|
+
if (lsResult.code !== 0) return []; // (3) fail-open
|
|
213
|
+
const projects = parseDockerJson(lsResult.output);
|
|
214
|
+
if (projects === null) return []; // (3) unreadable answer is not a finding
|
|
215
|
+
|
|
216
|
+
const knownProjects = new Set([...known].map((f) => composeProjectName(f)));
|
|
217
|
+
for (const entry of projects) {
|
|
218
|
+
const name = String(entry?.Name ?? '').trim();
|
|
219
|
+
if (!name.startsWith(MORPH_PROJECT_PREFIX)) continue;
|
|
220
|
+
if (knownProjects.has(name)) continue;
|
|
221
|
+
if (!belongsToRoot(entry, rootNorm)) continue;
|
|
222
|
+
|
|
223
|
+
const slug = name.slice(MORPH_PROJECT_PREFIX.length);
|
|
224
|
+
issues.push({
|
|
225
|
+
type: 'docker-stack-orphan',
|
|
226
|
+
severity: 'warn',
|
|
227
|
+
feature: slug || null,
|
|
228
|
+
detail: `stack docker "${name}" está de pé (${String(entry?.Status ?? 'estado desconhecido')}) e não corresponde a nenhuma feature desta árvore — sobra de outra sessão, segurando o bloco de portas e os volumes de "${slug}"`,
|
|
229
|
+
// NEVER `morph-spec e2e down {slug}`: that command resolves the feature's
|
|
230
|
+
// compose file, and this stack's feature no longer exists here. The
|
|
231
|
+
// project name is the only handle that still works.
|
|
232
|
+
fix: `docker compose -p ${name} down -v`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ---- docker-port-held --------------------------------------------------
|
|
237
|
+
const psResult = exec('docker', ['ps', '--format', 'json'], { cwd: root });
|
|
238
|
+
if (psResult.code !== 0) return []; // (3) fail-open
|
|
239
|
+
const containers = parseDockerJson(psResult.output);
|
|
240
|
+
if (containers === null) return [];
|
|
241
|
+
|
|
242
|
+
const { basePort, blockSize } = resolveFleetConfig(root);
|
|
243
|
+
for (const feature of allocated) {
|
|
244
|
+
// A block allocated by a feature this tree does not know belongs to another
|
|
245
|
+
// worktree of the repo; its collisions are that worktree's to report.
|
|
246
|
+
if (!known.has(feature)) continue;
|
|
247
|
+
|
|
248
|
+
let layout;
|
|
249
|
+
try {
|
|
250
|
+
// `allocateEnvBlock` for a feature that is ALREADY in the registry takes
|
|
251
|
+
// the early-return path and writes nothing. The expected ports are
|
|
252
|
+
// DERIVED from portLayout — the same function that writes worktree.env
|
|
253
|
+
// and the compose env — never recomputed here.
|
|
254
|
+
const block = allocateEnvBlock(feature, { gitCommonDir: commonDir, basePort, blockSize });
|
|
255
|
+
layout = portLayout(block, { feature });
|
|
256
|
+
} catch {
|
|
257
|
+
continue; // block out of range / corrupt entry — nothing to compare
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
const ownProject = layout.COMPOSE_PROJECT_NAME;
|
|
261
|
+
const expected = new Map();
|
|
262
|
+
for (const key of ['PORT', 'API_PORT', 'POSTGRES_PORT', 'REDIS_PORT']) {
|
|
263
|
+
const port = Number(layout[key]);
|
|
264
|
+
if (Number.isInteger(port)) expected.set(port, key);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
for (const container of containers) {
|
|
268
|
+
const project = composeProjectOf(container);
|
|
269
|
+
if (project === ownProject) continue; // the feature's own stack, as intended
|
|
270
|
+
for (const port of publishedPorts(container)) {
|
|
271
|
+
const key = expected.get(port);
|
|
272
|
+
if (!key) continue;
|
|
273
|
+
issues.push({
|
|
274
|
+
type: 'docker-port-held',
|
|
275
|
+
severity: 'warn',
|
|
276
|
+
feature,
|
|
277
|
+
detail: `a porta ${port} (${key} do bloco de "${feature}") está publicada pelo container ${containerNameOf(container)}${project ? ` do projeto compose "${project}"` : ' (sem projeto compose)'}, não por "${ownProject}" — a stack de "${feature}" não sobe enquanto ela estiver presa`,
|
|
278
|
+
fix: project
|
|
279
|
+
? `docker compose -p ${project} down (ou pare o container ${containerNameOf(container)}) antes de subir a stack de "${feature}"`
|
|
280
|
+
: `docker stop ${containerNameOf(container)} antes de subir a stack de "${feature}"`,
|
|
281
|
+
});
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return issues;
|
|
287
|
+
}
|
|
@@ -21,6 +21,8 @@
|
|
|
21
21
|
|
|
22
22
|
import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
|
|
23
23
|
import { join } from 'path';
|
|
24
|
+
// src/ → hooks/shared/ is the allowed direction (hooks are installed standalone).
|
|
25
|
+
import { composeProjectName } from '../../../framework/hooks/shared/e2e-check.js';
|
|
24
26
|
|
|
25
27
|
const DEFAULT_BASE_PORT = 4000;
|
|
26
28
|
const DEFAULT_BLOCK_SIZE = 10;
|
|
@@ -108,6 +110,30 @@ export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BAS
|
|
|
108
110
|
return { index, base, range: [base, base + blockSize - 1], created: true };
|
|
109
111
|
}
|
|
110
112
|
|
|
113
|
+
/**
|
|
114
|
+
* The features that ALREADY own a block — read-only, allocating nothing.
|
|
115
|
+
*
|
|
116
|
+
* `allocateEnvBlock` is the only sanctioned way to get a block, and it WRITES
|
|
117
|
+
* when the feature has none. A diagnostic that has to ask "who owns a block?"
|
|
118
|
+
* must not answer by handing one out: `doctor` is read-only, and an audit that
|
|
119
|
+
* mutates the registry it audits invents the very state it then reports. So
|
|
120
|
+
* this reader exists to bound the question, and the block itself still comes
|
|
121
|
+
* from `allocateEnvBlock` (which, for a feature listed here, takes the
|
|
122
|
+
* early-return path and writes nothing).
|
|
123
|
+
*
|
|
124
|
+
* An absent or corrupt registry yields `[]` — "nobody allocated anything",
|
|
125
|
+
* which for every caller is the same as "nothing to compare against".
|
|
126
|
+
*
|
|
127
|
+
* @param {string} gitCommonDir
|
|
128
|
+
* @returns {string[]} feature names with an allocated block
|
|
129
|
+
*/
|
|
130
|
+
export function allocatedFeatures(gitCommonDir) {
|
|
131
|
+
const registry = readRegistry(gitCommonDir);
|
|
132
|
+
return Object.entries(registry.allocations)
|
|
133
|
+
.filter(([, a]) => Number.isInteger(a?.index))
|
|
134
|
+
.map(([feature]) => feature);
|
|
135
|
+
}
|
|
136
|
+
|
|
111
137
|
/**
|
|
112
138
|
* Release a feature's block. Returns false when nothing was allocated.
|
|
113
139
|
*
|
|
@@ -124,9 +150,46 @@ export function releaseEnvBlock(feature, { gitCommonDir }) {
|
|
|
124
150
|
}
|
|
125
151
|
|
|
126
152
|
/**
|
|
127
|
-
*
|
|
128
|
-
*
|
|
129
|
-
*
|
|
153
|
+
* Fixed offsets inside a block. The block has 10 ports; four are named, the
|
|
154
|
+
* rest (+4..+9) are the project's to use. A new offset added here MUST also be
|
|
155
|
+
* published by the compose template (`${VAR:-default}:container`), or it is a
|
|
156
|
+
* port nobody listens on.
|
|
157
|
+
*/
|
|
158
|
+
export const PORT_OFFSETS = Object.freeze({ web: 0, api: 1, postgres: 2, redis: 3 });
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The ONE port-layout contract. Two writers derive from it — the
|
|
162
|
+
* `.morph/worktree.env` manifest (`buildEnvFileContent`) and the env handed to
|
|
163
|
+
* `docker compose` (`src/lib/e2e/compose-env.js`) — so a worktree's file and
|
|
164
|
+
* its running stack can never describe different worlds.
|
|
165
|
+
*
|
|
166
|
+
* `COMPOSE_PROJECT_NAME` is part of the layout on purpose: a hand-run
|
|
167
|
+
* `docker compose up` inside the worktree must land in the isolated project,
|
|
168
|
+
* not in the root's. Every value is a string (env-safe); key order is stable.
|
|
169
|
+
*
|
|
170
|
+
* @param {{ base: number, range: [number, number] }} block
|
|
171
|
+
* @param {{ stack?: string, feature?: string }} [opts]
|
|
172
|
+
* @returns {Record<string,string>}
|
|
173
|
+
*/
|
|
174
|
+
export function portLayout(block, { stack = '', feature = '' } = {}) {
|
|
175
|
+
const env = {};
|
|
176
|
+
if (feature) env.COMPOSE_PROJECT_NAME = composeProjectName(feature);
|
|
177
|
+
env.MORPH_PORT_BASE = String(block.base);
|
|
178
|
+
env.MORPH_PORT_RANGE = `${block.range[0]}-${block.range[1]}`;
|
|
179
|
+
env.PORT = String(block.base + PORT_OFFSETS.web);
|
|
180
|
+
env.API_PORT = String(block.base + PORT_OFFSETS.api);
|
|
181
|
+
env.POSTGRES_PORT = String(block.base + PORT_OFFSETS.postgres);
|
|
182
|
+
env.REDIS_PORT = String(block.base + PORT_OFFSETS.redis);
|
|
183
|
+
if (/\bdotnet\b/i.test(String(stack))) {
|
|
184
|
+
env.ASPNETCORE_URLS = `http://localhost:${block.base + PORT_OFFSETS.api}`;
|
|
185
|
+
}
|
|
186
|
+
return env;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Render the `.morph/worktree.env` contents for a block: a header, the
|
|
191
|
+
* feature, and exactly the `portLayout()` pairs — nothing else, so the file
|
|
192
|
+
* cannot drift from what compose receives.
|
|
130
193
|
*
|
|
131
194
|
* @param {string} feature
|
|
132
195
|
* @param {{ base: number, range: [number, number] }} block
|
|
@@ -135,15 +198,10 @@ export function releaseEnvBlock(feature, { gitCommonDir }) {
|
|
|
135
198
|
*/
|
|
136
199
|
export function buildEnvFileContent(feature, block, stack = '') {
|
|
137
200
|
const lines = [
|
|
138
|
-
`# Managed by morph-spec —
|
|
139
|
-
'# Derivado
|
|
201
|
+
`# Managed by morph-spec — ambiente dedicado desta worktree (feature: ${feature}).`,
|
|
202
|
+
'# Derivado, git-invisível, regenerado por `worktree setup|link`; nunca editar nem commitar.',
|
|
140
203
|
`MORPH_FEATURE=${feature}`,
|
|
141
|
-
|
|
142
|
-
`MORPH_PORT_RANGE=${block.range[0]}-${block.range[1]}`,
|
|
143
|
-
`PORT=${block.base}`,
|
|
204
|
+
...Object.entries(portLayout(block, { stack, feature })).map(([k, v]) => `${k}=${v}`),
|
|
144
205
|
];
|
|
145
|
-
if (/\bdotnet\b/i.test(String(stack))) {
|
|
146
|
-
lines.push(`ASPNETCORE_URLS=http://localhost:${block.base + 1}`);
|
|
147
|
-
}
|
|
148
206
|
return `${lines.join('\n')}\n`;
|
|
149
207
|
}
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
import { detectDefaultBranch } from '../git/default-branch.js';
|
|
37
37
|
import { buildPrIndex } from '../git/pr-index.js';
|
|
38
38
|
import { classifyBranch, commitsAhead, resolveBaseRef } from '../git/branch-classifier.js';
|
|
39
|
+
import { auditDockerStacks } from './docker-audit.js';
|
|
39
40
|
|
|
40
41
|
const norm = (p) => resolve(p).replace(/\\/g, '/');
|
|
41
42
|
|
|
@@ -212,10 +213,24 @@ function dirsOnDisk(root) {
|
|
|
212
213
|
* directory that exists but git's own
|
|
213
214
|
* registry does not know as a worktree
|
|
214
215
|
*
|
|
216
|
+
* • docker-stack-orphan (warn) — a `morph-*` docker stack of this repository
|
|
217
|
+
* still running for a feature this tree does
|
|
218
|
+
* not know, and
|
|
219
|
+
* • docker-port-held (warn) — a port from a feature's block published by
|
|
220
|
+
* another compose project. Both come from
|
|
221
|
+
* `docker-audit.js`: the three sources above
|
|
222
|
+
* cannot see a CONTAINER, and a stack outlives
|
|
223
|
+
* every one of them
|
|
224
|
+
*
|
|
215
225
|
* @param {string} [cwd]
|
|
226
|
+
* @param {object} [opts]
|
|
227
|
+
* @param {Function} [opts.exec] - injectable executor handed to the docker
|
|
228
|
+
* audit, so the docker paths are testable with no daemon (the CI runners
|
|
229
|
+
* have none). Absent, the real `docker` CLI is used — and answers `[]` when
|
|
230
|
+
* it is not installed.
|
|
216
231
|
* @returns {Array<{ type: string, detail: string, path?: string, feature?: string, fix: string }>}
|
|
217
232
|
*/
|
|
218
|
-
export function auditWorktrees(cwd = process.cwd()) {
|
|
233
|
+
export function auditWorktrees(cwd = process.cwd(), { exec } = {}) {
|
|
219
234
|
if (!isGitRepo(cwd)) return [];
|
|
220
235
|
const root = resolvePrimaryRoot(cwd);
|
|
221
236
|
const rootNorm = norm(root);
|
|
@@ -247,9 +262,47 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
247
262
|
|
|
248
263
|
const feature = entry.branch?.match(/^refs\/heads\/morph\/(.+)$/)?.[1] || null;
|
|
249
264
|
if (!feature) {
|
|
250
|
-
// Only worth reporting when it sits in morph's own worktree container —
|
|
251
|
-
// a worktree the user keeps elsewhere is none of morph's business.
|
|
252
265
|
const inContainer = WORKTREE_DIR_NAMES.some((d) => path.startsWith(`${rootNorm}/${d}/`));
|
|
266
|
+
const task = entry.branch?.match(/^refs\/heads\/morph-task\/(.+)$/)?.[1] || null;
|
|
267
|
+
|
|
268
|
+
if (!inContainer) {
|
|
269
|
+
// A worktree OUTSIDE morph's container was created behind morph's back
|
|
270
|
+
// (a native Claude Code run before the WorktreeCreate hook, a raw
|
|
271
|
+
// `git worktree add ../x`, another skill). It has no junctions, no port
|
|
272
|
+
// block, and nothing morph runs there — the exact shape that let a
|
|
273
|
+
// root's `.claude/` be emptied. Reported as `warn` (never a failure,
|
|
274
|
+
// NEVER pruned): the work inside may be the only copy.
|
|
275
|
+
const work = unintegratedWork(root, entry.branch, prIndex);
|
|
276
|
+
issues.push({
|
|
277
|
+
type: 'out-of-container',
|
|
278
|
+
severity: 'warn',
|
|
279
|
+
path,
|
|
280
|
+
detail: `${path} é um worktree fora de worktrees/ (${entry.branch || 'HEAD destacado'}) — criado sem o morph: sem junctions, sem portas, invisível ao \`fleet\`${work.unintegrated ? `; carrega trabalho NÃO integrado (${work.evidence})` : ''}`,
|
|
281
|
+
fix: work.unintegrated
|
|
282
|
+
? `integre a branch primeiro; depois \`morph-spec worktree remove --target "${path}"\`. Novos worktrees: \`morph-spec worktree setup <f>\` ou \`worktree provision <n>\``
|
|
283
|
+
: `morph-spec worktree remove --target "${path}" — e crie os próximos por \`morph-spec worktree setup <f>\` / \`worktree provision <n>\``,
|
|
284
|
+
});
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
if (task) {
|
|
289
|
+
// `morph-task/*`: a task-isolation tree provisioned by the WorktreeCreate
|
|
290
|
+
// hook. Clean → nothing to say (the orchestrator removes it after the
|
|
291
|
+
// merge). With commits the default does not have → report, never
|
|
292
|
+
// suggest removal.
|
|
293
|
+
const work = unintegratedWork(root, entry.branch, prIndex);
|
|
294
|
+
if (work.unintegrated) {
|
|
295
|
+
issues.push({
|
|
296
|
+
type: 'unintegrated-work',
|
|
297
|
+
severity: 'info',
|
|
298
|
+
path,
|
|
299
|
+
detail: `${path} é um worktree de isolamento de task (${entry.branch}) com trabalho NÃO integrado (${work.evidence})`,
|
|
300
|
+
fix: `integre a branch (\`git merge --no-ff morph-task/${task}\` no orquestrador) e depois \`morph-spec worktree remove --target "${path}"\``,
|
|
301
|
+
});
|
|
302
|
+
}
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
253
306
|
if (inContainer) {
|
|
254
307
|
// Same hazard as `no-feature` below, and it needs the same guard:
|
|
255
308
|
// "invisible to fleet" says nothing about whether the WORK is safe
|
|
@@ -356,6 +409,20 @@ export function auditWorktrees(cwd = process.cwd()) {
|
|
|
356
409
|
}
|
|
357
410
|
}
|
|
358
411
|
|
|
412
|
+
// The fourth source, and the only one that is not a file: the docker stacks.
|
|
413
|
+
// A container survives the removal of the worktree, of the branch and of the
|
|
414
|
+
// feature — none of the three reconciliations above can see it, and it keeps
|
|
415
|
+
// holding the feature's port block. The feature names come from HERE (the
|
|
416
|
+
// ones morph knows plus the ones the registered `morph/*` worktrees carry)
|
|
417
|
+
// rather than from a second scan inside docker-audit: two readings of the
|
|
418
|
+
// same fact are two chances to disagree about which stack is orphaned.
|
|
419
|
+
const treeFeatures = new Set(features);
|
|
420
|
+
for (const entry of registered) {
|
|
421
|
+
const f = entry.branch?.match(/^refs\/heads\/morph\/(.+)$/)?.[1];
|
|
422
|
+
if (f) treeFeatures.add(f);
|
|
423
|
+
}
|
|
424
|
+
issues.push(...auditDockerStacks(root, { exec, features: treeFeatures }));
|
|
425
|
+
|
|
359
426
|
return issues;
|
|
360
427
|
}
|
|
361
428
|
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
import { join, dirname, resolve } from 'path';
|
|
15
15
|
import { fileURLToPath } from 'url';
|
|
16
16
|
import { execSync } from 'child_process';
|
|
17
|
-
import { readFileSync, readdirSync
|
|
17
|
+
import { readFileSync, readdirSync } from 'fs';
|
|
18
18
|
import {
|
|
19
19
|
copyDirectory,
|
|
20
20
|
copyFile,
|
|
@@ -33,6 +33,7 @@ import { installSkills } from '../utils/skills-installer.js';
|
|
|
33
33
|
import { installAgents, installDomainAgents } from '../utils/agents-installer.js';
|
|
34
34
|
import { resolvePrimaryRoot } from '../commands/project/worktree.js';
|
|
35
35
|
import { reconcileLegacyTemplatesCopy } from '../lib/installers/templates-reconcile.js';
|
|
36
|
+
import { realEffects } from '../lib/installers/effects.js';
|
|
36
37
|
|
|
37
38
|
const FRAMEWORK_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', '..', 'framework');
|
|
38
39
|
|
|
@@ -61,7 +62,7 @@ async function installRequiredPlugins(log, exec) {
|
|
|
61
62
|
* @param {string} rulesDest - Destination rules directory (.claude/rules/)
|
|
62
63
|
* @param {string[]} projectTags - Stack tags from config (e.g., ['dotnet', 'blazor'])
|
|
63
64
|
*/
|
|
64
|
-
export function installRulesForStack(rulesSrc, rulesDest, projectTags) {
|
|
65
|
+
export function installRulesForStack(rulesSrc, rulesDest, projectTags, fx = realEffects()) {
|
|
65
66
|
const sourceFiles = readdirSync(rulesSrc).filter(f => f.endsWith('.md'));
|
|
66
67
|
const installed = new Set();
|
|
67
68
|
|
|
@@ -69,15 +70,19 @@ export function installRulesForStack(rulesSrc, rulesDest, projectTags) {
|
|
|
69
70
|
const content = readFileSync(join(rulesSrc, file), 'utf-8');
|
|
70
71
|
const stacks = parseStacks(content);
|
|
71
72
|
if (shouldInstall(stacks, projectTags)) {
|
|
72
|
-
|
|
73
|
+
fx.copyFile(join(rulesSrc, file), join(rulesDest, file));
|
|
73
74
|
installed.add(file);
|
|
74
75
|
}
|
|
75
76
|
}
|
|
76
77
|
|
|
77
|
-
// Cleanup orphans
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// Cleanup orphans — calado em `dryRun` pelo mesmo motivo dos laços gêmeos em
|
|
79
|
+
// skills-installer.js e agents-installer.js (D6): `cleanFrameworkDirs()` já
|
|
80
|
+
// apagou `.claude/rules/` inteiro antes de este código rodar de verdade.
|
|
81
|
+
if (!fx.dryRun) {
|
|
82
|
+
for (const file of readdirSync(rulesDest).filter(f => f.endsWith('.md'))) {
|
|
83
|
+
if (!installed.has(file)) {
|
|
84
|
+
fx.remove(join(rulesDest, file));
|
|
85
|
+
}
|
|
81
86
|
}
|
|
82
87
|
}
|
|
83
88
|
|
|
@@ -134,12 +139,20 @@ async function installStackAwareAssets(targetPath, log) {
|
|
|
134
139
|
*
|
|
135
140
|
* @param {string} claudeMdPath - Absolute path to the installed .claude/CLAUDE.md
|
|
136
141
|
* @param {Array<{ name: string, title: string, domains: string[] }>} agents
|
|
142
|
+
* @param {object} [options]
|
|
143
|
+
* @param {ReturnType<import('../lib/installers/effects.js').createEffects>} [options.fx]
|
|
144
|
+
* @param {string} [options.readFrom] - De onde LER o conteúdo-base.
|
|
145
|
+
* Num `--dry-run` o arquivo de destino ainda não recebeu a cópia que o passo
|
|
146
|
+
* anterior previu, então ler o destino diria "sem marcadores" e o efeito de
|
|
147
|
+
* escrita sumiria da previsão — divergindo da execução real. Lendo a MESMA
|
|
148
|
+
* origem que seria copiada, previsão e execução decidem igual.
|
|
137
149
|
*/
|
|
138
|
-
export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = []) {
|
|
139
|
-
|
|
150
|
+
export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = [], options = {}) {
|
|
151
|
+
const { fx = realEffects(), readFrom = claudeMdPath } = options;
|
|
152
|
+
if (!(await pathExists(readFrom))) return;
|
|
140
153
|
const START = '<!-- morph-agents:start -->';
|
|
141
154
|
const END = '<!-- morph-agents:end -->';
|
|
142
|
-
const content = readFileSync(
|
|
155
|
+
const content = readFileSync(readFrom, 'utf-8');
|
|
143
156
|
if (!content.includes(START) || !content.includes(END)) return;
|
|
144
157
|
|
|
145
158
|
const list = agents.length > 0
|
|
@@ -151,7 +164,7 @@ export async function updateClaudeMdAgentsBlock(claudeMdPath, agents = []) {
|
|
|
151
164
|
new RegExp(`${START}[\\s\\S]*?${END}`),
|
|
152
165
|
block
|
|
153
166
|
);
|
|
154
|
-
|
|
167
|
+
fx.writeFile(claudeMdPath, updated);
|
|
155
168
|
}
|
|
156
169
|
|
|
157
170
|
/**
|