@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,285 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* registry-audit.js — o registry de modelos do projeto: tarifa por alias e
|
|
3
|
+
* protocolo (`api`) por alias.
|
|
4
|
+
*
|
|
5
|
+
* As duas perguntas saem do standard `ai-agents/providers/model-registry.md`,
|
|
6
|
+
* não da memória de quem implementa:
|
|
7
|
+
*
|
|
8
|
+
* - **tarifa por alias** — um alias sem tarifa é um custo que ninguém
|
|
9
|
+
* consegue prever antes de a chamada ser paga. O caso medido no acervo é o
|
|
10
|
+
* GHLBrain, cujo mapa `Pricing.Models` é literalmente `{}`.
|
|
11
|
+
* - **`api` por alias** — o standard mede, sobre o pin, que
|
|
12
|
+
* `AgentApi.Responses = 0`, ou seja `default(AgentApi)` é Responses, e que
|
|
13
|
+
* `ModelRegistry.ReadApi` devolve `AgentApi.Responses` quando o campo está
|
|
14
|
+
* ausente. A ausência do campo, portanto, NÃO é defeito: é uma decisão
|
|
15
|
+
* tomada por omissão. O achado NOMEIA o default assumido e pede
|
|
16
|
+
* confirmação — nunca afirma defeito.
|
|
17
|
+
*
|
|
18
|
+
* Severidade: `warn` nas duas. Só `pin-drift` produz `error`.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { readFileSync } from 'node:fs';
|
|
22
|
+
import { existsSync } from 'node:fs';
|
|
23
|
+
import { isAbsolute, join, basename } from 'node:path';
|
|
24
|
+
import { collectProjectFiles } from './ai-project-scan.js';
|
|
25
|
+
import { jsoncToJson } from './comment-stripper.js';
|
|
26
|
+
|
|
27
|
+
/** Limite de tamanho para um JSON candidato (bytes de texto) — evita ler dumps. */
|
|
28
|
+
const MAX_JSON_CHARS = 512 * 1024;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Lê um candidato a registry aceitando **JSONC**.
|
|
32
|
+
*
|
|
33
|
+
* Não é indulgência: é paridade com o leitor de produção. O exemplar canônico
|
|
34
|
+
* `framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/model-registry.v2.json`
|
|
35
|
+
* — que diz na primeira linha "copie para `config/model-registry.json` no
|
|
36
|
+
* projeto" — tem 20 linhas de `//`, e `ModelRegistry.FromJson` do kit declara
|
|
37
|
+
* `CommentHandling = JsonCommentHandling.Skip` + `AllowTrailingCommas = true`.
|
|
38
|
+
* Com `JSON.parse` cru, as duas checagens de registry saíam `skip` sobre a
|
|
39
|
+
* ÚNICA forma que existem para auditar — e `skip` não muda o código de saída,
|
|
40
|
+
* então o silêncio era total. A auditoria tem de enxergar a mesma forma que o
|
|
41
|
+
* leitor de produção enxerga.
|
|
42
|
+
*/
|
|
43
|
+
function readJsonSafe(file) {
|
|
44
|
+
try {
|
|
45
|
+
const raw = readFileSync(file, 'utf8');
|
|
46
|
+
if (raw.length > MAX_JSON_CHARS) return null;
|
|
47
|
+
const text = raw.charCodeAt(0) === 0xfeff ? raw.slice(1) : raw;
|
|
48
|
+
try {
|
|
49
|
+
return JSON.parse(text);
|
|
50
|
+
} catch {
|
|
51
|
+
return JSON.parse(jsoncToJson(text));
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function isPlainObject(value) {
|
|
59
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** A forma que `ai-agents/providers/model-registry.md` define. */
|
|
63
|
+
function asAliasSource(file, data) {
|
|
64
|
+
if (!isPlainObject(data)) return null;
|
|
65
|
+
if (typeof data.defaultAlias !== 'string') return null;
|
|
66
|
+
if (!isPlainObject(data.aliases)) return null;
|
|
67
|
+
return { file, aliases: data.aliases, defaultAlias: data.defaultAlias, data };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A forma medida no GHLBrain: a seção `Pricing` de um `appsettings*.json`. */
|
|
71
|
+
function asPricingSource(file, data) {
|
|
72
|
+
if (!isPlainObject(data)) return null;
|
|
73
|
+
const pricing = data.Pricing ?? data.pricing;
|
|
74
|
+
if (!isPlainObject(pricing)) return null;
|
|
75
|
+
const models = pricing.Models ?? pricing.models;
|
|
76
|
+
if (!isPlainObject(models)) return null;
|
|
77
|
+
return { file, models };
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Localiza o registry de modelos do projeto.
|
|
82
|
+
*
|
|
83
|
+
* Ordem, primeira ocorrência ganha (spec §3.5):
|
|
84
|
+
* 1. path declarado em `.morph/config/config.json → project.ai.modelRegistry`;
|
|
85
|
+
* 2. um `*.json` cujo objeto raiz tenha `defaultAlias` **e** um mapa `aliases`;
|
|
86
|
+
* 3. a seção `Pricing` de um `appsettings*.json`.
|
|
87
|
+
*
|
|
88
|
+
* @param {string} root - raiz do projeto
|
|
89
|
+
* @param {object|null} config - conteúdo de `.morph/config/config.json`, se houver
|
|
90
|
+
* @returns {null | { aliasSource: object|null, pricingSource: object|null }}
|
|
91
|
+
* `null` quando NENHUMA das três formas foi encontrada.
|
|
92
|
+
*/
|
|
93
|
+
export function locateAiRegistry(root, config = null) {
|
|
94
|
+
let aliasSource = null;
|
|
95
|
+
let pricingSource = null;
|
|
96
|
+
|
|
97
|
+
const declared = config?.project?.ai?.modelRegistry;
|
|
98
|
+
if (typeof declared === 'string' && declared.trim()) {
|
|
99
|
+
const file = isAbsolute(declared) ? declared : join(root, declared);
|
|
100
|
+
if (existsSync(file)) {
|
|
101
|
+
const data = readJsonSafe(file);
|
|
102
|
+
aliasSource = asAliasSource(file, data);
|
|
103
|
+
pricingSource = extractPricingFromRegistry(file, data) ?? pricingSource;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (!aliasSource) {
|
|
108
|
+
const candidates = collectProjectFiles(root, (name) => name.toLowerCase().endsWith('.json'));
|
|
109
|
+
for (const file of candidates) {
|
|
110
|
+
const data = readJsonSafe(file);
|
|
111
|
+
const found = asAliasSource(file, data);
|
|
112
|
+
if (found) {
|
|
113
|
+
aliasSource = found;
|
|
114
|
+
pricingSource = pricingSource ?? extractPricingFromRegistry(file, data);
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!pricingSource) {
|
|
121
|
+
const appsettings = collectProjectFiles(root, (name) => {
|
|
122
|
+
const lower = name.toLowerCase();
|
|
123
|
+
return lower.startsWith('appsettings') && lower.endsWith('.json');
|
|
124
|
+
});
|
|
125
|
+
for (const file of appsettings) {
|
|
126
|
+
const found = asPricingSource(file, readJsonSafe(file));
|
|
127
|
+
if (found) { pricingSource = found; break; }
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
if (!aliasSource && !pricingSource) return null;
|
|
132
|
+
return { aliasSource, pricingSource };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Mapa de tarifas embutido no próprio registry de modelos, quando existir. */
|
|
136
|
+
function extractPricingFromRegistry(file, data) {
|
|
137
|
+
if (!isPlainObject(data)) return null;
|
|
138
|
+
const pricing = data.pricing ?? data.Pricing;
|
|
139
|
+
if (isPlainObject(pricing)) {
|
|
140
|
+
const models = pricing.Models ?? pricing.models;
|
|
141
|
+
return { file, models: isPlainObject(models) ? models : pricing };
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** O alias declara tarifa **no próprio elemento** (a forma v2 do exemplar canônico)? */
|
|
147
|
+
function aliasHasPricing(def) {
|
|
148
|
+
return isPlainObject(def) && isPlainObject(def.pricing);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** O alias declara a AUSÊNCIA de tarifa por escrito? */
|
|
152
|
+
function aliasHasWaiver(def) {
|
|
153
|
+
return isPlainObject(def) && typeof def.pricingWaiver === 'string' && def.pricingWaiver.trim() !== '';
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* O documento usa a forma **por alias** (`pricing` / `pricingWaiver` dentro de
|
|
158
|
+
* cada alias) em vez da forma de mapa no topo?
|
|
159
|
+
*
|
|
160
|
+
* Basta UM alias declarar qualquer uma das duas: um documento meio-preenchido
|
|
161
|
+
* ainda é um documento na forma v2, e os aliases que faltam é que são o achado.
|
|
162
|
+
*/
|
|
163
|
+
export function usesPerAliasPricing(aliasSource) {
|
|
164
|
+
if (!aliasSource) return false;
|
|
165
|
+
return Object.values(aliasSource.aliases).some((d) => aliasHasPricing(d) || aliasHasWaiver(d));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Todo alias do registry tem uma DECISÃO de tarifa?
|
|
170
|
+
*
|
|
171
|
+
* Duas formas, e a checagem cobra a que o documento usa:
|
|
172
|
+
*
|
|
173
|
+
* 1. **Por alias** (`model-registry.v2.json`, o exemplar que o framework manda
|
|
174
|
+
* copiar) — a REGRA DURA escrita no próprio exemplar e imposta por
|
|
175
|
+
* `ModelRegistry.ReadPricing`: *"todo alias declara `pricing` OU
|
|
176
|
+
* `pricingWaiver` — nunca os dois, nunca nenhum"*. Um `pricingWaiver` é uma
|
|
177
|
+
* tarifa **decidida**: a ausência está escrita, não esquecida, e acusá-la
|
|
178
|
+
* seria gritar em código correto.
|
|
179
|
+
* 2. **Mapa no topo** (`pricing` na raiz, ou a seção `Pricing.Models` de um
|
|
180
|
+
* `appsettings*.json` — a forma medida no GHLBrain, cujo mapa é
|
|
181
|
+
* literalmente `{}`).
|
|
182
|
+
*
|
|
183
|
+
* Os achados de MAPA (`pricing-map-missing` / `pricing-map-empty`) só existem
|
|
184
|
+
* na forma 2: num documento v2 não há mapa a faltar, e emitir o achado ali era
|
|
185
|
+
* o primeiro dos seis falsos positivos sobre o exemplar canônico.
|
|
186
|
+
*
|
|
187
|
+
* @param {ReturnType<typeof locateAiRegistry>} registry
|
|
188
|
+
* @returns {null | Array<{ id: string, severity: 'warn', alias?: string, message: string }>}
|
|
189
|
+
*/
|
|
190
|
+
export function auditModelPricing(registry) {
|
|
191
|
+
if (!registry) return null;
|
|
192
|
+
const { aliasSource, pricingSource } = registry;
|
|
193
|
+
const findings = [];
|
|
194
|
+
|
|
195
|
+
const models = pricingSource?.models ?? null;
|
|
196
|
+
const tariffKeys = models ? new Set(Object.keys(models)) : new Set();
|
|
197
|
+
const perAlias = usesPerAliasPricing(aliasSource);
|
|
198
|
+
|
|
199
|
+
if (!perAlias) {
|
|
200
|
+
if (models === null) {
|
|
201
|
+
findings.push({
|
|
202
|
+
id: 'pricing-map-missing',
|
|
203
|
+
severity: 'warn',
|
|
204
|
+
message: 'Nenhuma tarifa encontrada: nem `pricing`/`pricingWaiver` por alias (a forma do '
|
|
205
|
+
+ 'exemplar `model-registry.v2.json`), nem `pricing` na raiz, nem `Pricing.Models` num '
|
|
206
|
+
+ 'appsettings. Sem tarifa não há previsão de custo antes de a chamada ser paga.',
|
|
207
|
+
});
|
|
208
|
+
} else if (tariffKeys.size === 0) {
|
|
209
|
+
findings.push({
|
|
210
|
+
id: 'pricing-map-empty',
|
|
211
|
+
severity: 'warn',
|
|
212
|
+
message: `O mapa de tarifas em \`${basename(pricingSource.file)}\` está vazio — declarado e `
|
|
213
|
+
+ 'sem uma única entrada. É a forma mais silenciosa de não ter tarifa: o campo existe, o '
|
|
214
|
+
+ 'dado não.',
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
if (aliasSource) {
|
|
220
|
+
for (const [alias, def] of Object.entries(aliasSource.aliases)) {
|
|
221
|
+
const model = isPlainObject(def) ? def.model : null;
|
|
222
|
+
const rotulo = `\`${alias}\`${model ? ` (modelo \`${model}\`)` : ''}`;
|
|
223
|
+
const hasPricing = aliasHasPricing(def);
|
|
224
|
+
const hasWaiver = aliasHasWaiver(def);
|
|
225
|
+
|
|
226
|
+
if (hasPricing && hasWaiver) {
|
|
227
|
+
findings.push({
|
|
228
|
+
id: 'alias-pricing-and-waiver',
|
|
229
|
+
severity: 'warn',
|
|
230
|
+
alias,
|
|
231
|
+
message: `O alias ${rotulo} declara \`pricing\` E \`pricingWaiver\`. A regra do exemplar `
|
|
232
|
+
+ 'é uma OU a outra, nunca as duas: com as duas presentes não dá para saber se a '
|
|
233
|
+
+ 'tarifa vale ou se a isenção vale.',
|
|
234
|
+
});
|
|
235
|
+
continue;
|
|
236
|
+
}
|
|
237
|
+
if (hasPricing || hasWaiver) continue; // decisão tomada — inclusive a de não haver tarifa
|
|
238
|
+
|
|
239
|
+
const cobertoPeloMapa = tariffKeys.has(alias) || (model !== null && tariffKeys.has(model));
|
|
240
|
+
if (cobertoPeloMapa) continue;
|
|
241
|
+
|
|
242
|
+
findings.push({
|
|
243
|
+
id: 'alias-without-pricing',
|
|
244
|
+
severity: 'warn',
|
|
245
|
+
alias,
|
|
246
|
+
message: `O alias ${rotulo} não declara \`pricing\` nem \`pricingWaiver\`, e não aparece em `
|
|
247
|
+
+ 'nenhum mapa de tarifas. Não é a tarifa que falta: falta a DECISÃO — um `pricingWaiver` '
|
|
248
|
+
+ 'com o motivo por extenso também fecha a questão.',
|
|
249
|
+
});
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
return findings;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Todo alias declara `api`?
|
|
258
|
+
*
|
|
259
|
+
* O default do standard é **`responses`**: medido sobre o pin, `AgentApi.Responses = 0`
|
|
260
|
+
* e `ModelRegistry.ReadApi` devolve Responses quando o campo está ausente. A mensagem
|
|
261
|
+
* nomeia esse default e PEDE CONFIRMAÇÃO — a ausência do campo é uma decisão por
|
|
262
|
+
* omissão, não um defeito. É a pergunta que o `ModelRegistry.cs` do ProspectPRO
|
|
263
|
+
* documenta ter custado um bug.
|
|
264
|
+
*
|
|
265
|
+
* @param {ReturnType<typeof locateAiRegistry>} registry
|
|
266
|
+
* @returns {null | Array<{ id: string, severity: 'warn', alias: string, message: string }>}
|
|
267
|
+
* `null` quando não há aliases para perguntar.
|
|
268
|
+
*/
|
|
269
|
+
export function auditApiPerAlias(registry) {
|
|
270
|
+
if (!registry || !registry.aliasSource) return null;
|
|
271
|
+
const findings = [];
|
|
272
|
+
for (const [alias, def] of Object.entries(registry.aliasSource.aliases)) {
|
|
273
|
+
const api = isPlainObject(def) ? def.api : undefined;
|
|
274
|
+
if (typeof api === 'string' && api.trim()) continue;
|
|
275
|
+
findings.push({
|
|
276
|
+
id: 'alias-without-api',
|
|
277
|
+
severity: 'warn',
|
|
278
|
+
alias,
|
|
279
|
+
message: `O alias \`${alias}\` não declara o campo \`api\`. O registry trata a omissão como `
|
|
280
|
+
+ '`responses` (Responses API) — confirme que este alias não precisa falar `chat` '
|
|
281
|
+
+ '(Chat Completions). Não é acusação: é a decisão que está sendo tomada por omissão.',
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
return findings;
|
|
285
|
+
}
|
|
@@ -20,23 +20,37 @@
|
|
|
20
20
|
|
|
21
21
|
import { join } from 'path';
|
|
22
22
|
|
|
23
|
+
import chalk from 'chalk';
|
|
24
|
+
|
|
23
25
|
import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
|
|
24
|
-
import { allocateEnvBlock, resolveFleetConfig } from '../worktree/env-allocator.js';
|
|
26
|
+
import { allocateEnvBlock, portLayout, resolveFleetConfig } from '../worktree/env-allocator.js';
|
|
25
27
|
|
|
26
28
|
/**
|
|
27
29
|
* Build the environment a `docker compose` invocation should carry.
|
|
28
30
|
*
|
|
29
|
-
* Port variables
|
|
30
|
-
*
|
|
31
|
-
*
|
|
31
|
+
* Port variables come from `portLayout()` in env-allocator.js — the SAME
|
|
32
|
+
* function that renders `.morph/worktree.env` — so a compose file and the
|
|
33
|
+
* worktree manifest describe the same world by construction (`PORT`,
|
|
34
|
+
* `API_PORT`/`ASPNETCORE_URLS`, `POSTGRES_PORT`, `COMPOSE_PROJECT_NAME`).
|
|
32
35
|
*
|
|
33
36
|
* `PLAYWRIGHT_BASE_URL` is derived here rather than left to the spec config —
|
|
34
37
|
* the suite has to be told where the app actually landed, otherwise a feature
|
|
35
38
|
* on port 4020 gets tested against a hardcoded localhost:3000 and fails with
|
|
36
39
|
* ECONNREFUSED that looks exactly like a broken app.
|
|
37
40
|
*
|
|
41
|
+
* The credentials env file loads AFTER the layout but cannot outrank it: a
|
|
42
|
+
* `.env.e2e` copied from a sibling worktree carries THAT worktree's block, and
|
|
43
|
+
* letting it win points this stack at the neighbour's postgres while every
|
|
44
|
+
* other surface still reports the allocated port. The protected keys are the
|
|
45
|
+
* ones actually assigned above — never a second literal list, which is exactly
|
|
46
|
+
* how an offset lands in one writer and not the other. A key that gets ignored
|
|
47
|
+
* is announced on stderr (never stdout: `morph-spec env` writes shell-eval
|
|
48
|
+
* output there), because swapping a silent override for a silent discard just
|
|
49
|
+
* moves the debugging session.
|
|
50
|
+
*
|
|
38
51
|
* Fail-open throughout: with no git dir (or a corrupt registry) the caller gets
|
|
39
|
-
* default ports, which only matters when two stacks run at once.
|
|
52
|
+
* default ports, which only matters when two stacks run at once. With no layout
|
|
53
|
+
* applied nothing is filtered — there the env file is the only port source.
|
|
40
54
|
*
|
|
41
55
|
* @param {object} params
|
|
42
56
|
* @param {string} params.cwd - project root
|
|
@@ -47,20 +61,20 @@ import { allocateEnvBlock, resolveFleetConfig } from '../worktree/env-allocator.
|
|
|
47
61
|
*/
|
|
48
62
|
export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
49
63
|
const env = {};
|
|
64
|
+
/** @type {Set<string>|null} - null while no layout has been applied */
|
|
65
|
+
let layoutKeys = null;
|
|
50
66
|
|
|
51
67
|
if (gitCommonDir) {
|
|
52
68
|
try {
|
|
53
69
|
const { basePort, blockSize } = resolveFleetConfig(cwd);
|
|
54
70
|
const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
|
|
55
71
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
env.ASPNETCORE_URLS = `http://localhost:${block.base + 1}`;
|
|
63
|
-
}
|
|
72
|
+
const layout = {
|
|
73
|
+
...portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName }),
|
|
74
|
+
PLAYWRIGHT_BASE_URL: `http://localhost:${block.base}`,
|
|
75
|
+
};
|
|
76
|
+
Object.assign(env, layout);
|
|
77
|
+
layoutKeys = new Set(Object.keys(layout));
|
|
64
78
|
} catch {
|
|
65
79
|
// No block → default ports. Surfaces later as a port conflict, never here.
|
|
66
80
|
}
|
|
@@ -68,7 +82,34 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
|
68
82
|
|
|
69
83
|
// Credentials live in a gitignored env file, never in the committed config.
|
|
70
84
|
const envFile = config?.e2e?.auth?.envFile;
|
|
71
|
-
if (envFile)
|
|
85
|
+
if (envFile) {
|
|
86
|
+
const ignored = [];
|
|
87
|
+
for (const [key, value] of Object.entries(loadEnvFile(join(cwd, envFile)))) {
|
|
88
|
+
if (layoutKeys?.has(key)) ignored.push(key);
|
|
89
|
+
else env[key] = value;
|
|
90
|
+
}
|
|
91
|
+
if (ignored.length) reportIgnoredLayoutKeys(envFile, ignored, env);
|
|
92
|
+
}
|
|
72
93
|
|
|
73
94
|
return env;
|
|
74
95
|
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Tell the caller — `e2e up`, the `e2e` node of `verify`, `stack-teardown` and
|
|
99
|
+
* `morph-spec env` all reach this through `buildComposeEnv` — which keys of the
|
|
100
|
+
* credentials file were dropped and what won instead.
|
|
101
|
+
*
|
|
102
|
+
* stderr, always: `morph-spec env` prints `export K=v` on stdout for a
|
|
103
|
+
* shell to eval, and `verify` prints JSON there.
|
|
104
|
+
*
|
|
105
|
+
* @param {string} envFile
|
|
106
|
+
* @param {string[]} ignored
|
|
107
|
+
* @param {Record<string,string>} env - the layout that won
|
|
108
|
+
*/
|
|
109
|
+
function reportIgnoredLayoutKeys(envFile, ignored, env) {
|
|
110
|
+
const shown = ignored.map((key) => `${key}=${env[key]}`).join(', ');
|
|
111
|
+
console.warn(chalk.yellow(
|
|
112
|
+
` ⚠ ${envFile}: ${ignored.join(', ')} ignorada(s) — o bloco de portas desta worktree prevalece (${shown}). `
|
|
113
|
+
+ 'O envFile carrega credenciais; portas vêm do bloco alocado.',
|
|
114
|
+
));
|
|
115
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reclaim a feature's verification stack — containers, ports, named volumes
|
|
3
|
+
* and the freshness stamp — from the tree that owns the feature.
|
|
4
|
+
*
|
|
5
|
+
* `morph-spec verify` leaves the stack UP on purpose (cold-starting a
|
|
6
|
+
* container per task is what makes the loop unusable), so something has to be
|
|
7
|
+
* the one place that brings it down when the feature closes. That place is
|
|
8
|
+
* this module, shared by `finish` and `delete --with-worktree`.
|
|
9
|
+
*
|
|
10
|
+
* Three things the old root-side teardown got wrong, each pinned by a test:
|
|
11
|
+
* 1. It ran from the ROOT. A compose that exists only in the worktree was
|
|
12
|
+
* never found, and a bind mount resolved to the wrong checkout.
|
|
13
|
+
* 2. It passed a bare `COMPOSE_PROJECT_NAME`. Without the port block
|
|
14
|
+
* (`PORT`, `POSTGRES_PORT`, …) compose resolves a different config than
|
|
15
|
+
* the one that came up, and `down` misses the containers.
|
|
16
|
+
* 3. It never removed volumes or the stamp. Postgres data and a stamp saying
|
|
17
|
+
* "source unchanged since build" outlived the feature.
|
|
18
|
+
*
|
|
19
|
+
* Entirely fail-open: no compose, no docker, or a stubborn container are all
|
|
20
|
+
* non-events for the caller — a stack that refuses to come down must never
|
|
21
|
+
* fail a finish that already merged.
|
|
22
|
+
*
|
|
23
|
+
* @module lib/e2e/stack-teardown
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'child_process';
|
|
27
|
+
import { existsSync, readFileSync } from 'fs';
|
|
28
|
+
import { join, resolve } from 'path';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
composeProjectName,
|
|
32
|
+
defaultExec,
|
|
33
|
+
deleteStamp,
|
|
34
|
+
resolveComposeFile,
|
|
35
|
+
tearDownStack,
|
|
36
|
+
} from '../../../framework/hooks/shared/e2e-check.js';
|
|
37
|
+
import { buildComposeEnv } from './compose-env.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} feature
|
|
41
|
+
* @param {object} params
|
|
42
|
+
* @param {string} params.root - primary root (absolute)
|
|
43
|
+
* @param {string|null} [params.worktreePath] - the feature's live worktree, if any
|
|
44
|
+
* @param {boolean} [params.volumes=true] - also remove named volumes (`down -v`)
|
|
45
|
+
* @param {Function} [params._exec] - injectable executor (testing)
|
|
46
|
+
* @returns {{ attempted: boolean, cwd: string, status: 'pass'|'fail'|'skip', stampDeleted: boolean, output?: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, _exec = defaultExec } = {}) {
|
|
49
|
+
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : root;
|
|
50
|
+
const projectName = composeProjectName(feature);
|
|
51
|
+
const gitCommonDir = resolveGitCommonDir(root);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const config = readConfig(cwd) ?? readConfig(root);
|
|
55
|
+
|
|
56
|
+
// The owning tree's compose first; the root's copy is the fallback for a
|
|
57
|
+
// worktree already half torn down (versioned file, so equivalent).
|
|
58
|
+
let composeCwd = cwd;
|
|
59
|
+
let composeFile = resolveComposeFile(cwd, config);
|
|
60
|
+
if (!composeFile && cwd !== root) {
|
|
61
|
+
composeCwd = root;
|
|
62
|
+
composeFile = resolveComposeFile(root, config);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!composeFile) {
|
|
66
|
+
// Nothing to bring down — but a stamp left behind would tell the next
|
|
67
|
+
// `up` that a stack it cannot see is still fresh.
|
|
68
|
+
return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const env = {
|
|
72
|
+
COMPOSE_PROJECT_NAME: projectName,
|
|
73
|
+
...buildComposeEnv({ cwd: composeCwd, config, featureName: feature, gitCommonDir }),
|
|
74
|
+
};
|
|
75
|
+
const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes }, _exec);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
attempted: true,
|
|
79
|
+
cwd: composeCwd,
|
|
80
|
+
status: result.status,
|
|
81
|
+
stampDeleted: deleteStamp(gitCommonDir, projectName),
|
|
82
|
+
...(result.status === 'fail' && result.output ? { output: result.output } : {}),
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
// Unconditional even when the teardown itself blew up: the stamp must not
|
|
86
|
+
// outlive the stack it described.
|
|
87
|
+
return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** BOM-tolerant config read; null when absent or malformed. */
|
|
92
|
+
function readConfig(dir) {
|
|
93
|
+
try {
|
|
94
|
+
const path = join(dir, '.morph', 'config', 'config.json');
|
|
95
|
+
if (!existsSync(path)) return null;
|
|
96
|
+
return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Same resolution (and same fail-open) as verify-runner's. */
|
|
103
|
+
function resolveGitCommonDir(cwd) {
|
|
104
|
+
try {
|
|
105
|
+
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
106
|
+
cwd,
|
|
107
|
+
stdio: 'pipe',
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
}).trim();
|
|
111
|
+
return common ? resolve(cwd, common) : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|