@polymorphism-tech/morph-spec 9.0.0 → 9.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (345) hide show
  1. package/CLAUDE.md +8 -9
  2. package/README.md +38 -36
  3. package/bin/morph-spec.js +11 -63
  4. package/bin/validate.js +1 -39
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +2 -1
  9. package/framework/MORPH.md +3 -3
  10. package/framework/agents.json +19 -11
  11. package/framework/commands/morph-apply.md +1 -1
  12. package/framework/commands/morph-status.md +1 -1
  13. package/framework/docs/CLI.md +22 -31
  14. package/framework/evals/review-checklist.md +12 -2
  15. package/framework/hooks/README.md +8 -44
  16. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  17. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  18. package/framework/hooks/claude-code/core/chain.js +4 -65
  19. package/framework/hooks/claude-code/core/comment-guard.js +2 -16
  20. package/framework/hooks/claude-code/core/enforce-phase-writes.js +6 -50
  21. package/framework/hooks/claude-code/core/gate-guard.js +7 -67
  22. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  23. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  24. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  25. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  26. package/framework/hooks/claude-code/core/pre-bash-denylist.js +0 -4
  27. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -67
  28. package/framework/hooks/claude-code/core/state-sync.js +12 -68
  29. package/framework/hooks/claude-code/core/telemetry-log.js +12 -93
  30. package/framework/hooks/claude-code/core/validate-completion.js +11 -57
  31. package/framework/hooks/claude-code/core/warn-bash-source-write.js +1 -9
  32. package/framework/hooks/claude-code/core/worktree-context.js +10 -110
  33. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  34. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  35. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  36. package/framework/hooks/claude-code/statusline.sh +11 -11
  37. package/framework/hooks/shared/activity-logger.js +9 -89
  38. package/framework/hooks/shared/agent-reap.js +2 -17
  39. package/framework/hooks/shared/build-check.js +6 -75
  40. package/framework/hooks/shared/claim-registry.js +9 -119
  41. package/framework/hooks/shared/command-tokens.js +6 -51
  42. package/framework/hooks/shared/comment-rules.js +5 -20
  43. package/framework/hooks/shared/comment-scanner.js +6 -25
  44. package/framework/hooks/shared/comment-stripper.js +11 -85
  45. package/framework/hooks/shared/e2e-check.js +0 -0
  46. package/framework/hooks/shared/errata.js +2 -15
  47. package/framework/hooks/shared/feature-locator.js +11 -95
  48. package/framework/hooks/shared/frontend-locations.js +1 -3
  49. package/framework/hooks/shared/gate-question.js +1 -7
  50. package/framework/hooks/shared/git-exclude.js +3 -20
  51. package/framework/hooks/shared/git-paths.js +8 -104
  52. package/framework/hooks/shared/graph-freshness.js +22 -166
  53. package/framework/hooks/shared/graph-refresh.js +21 -149
  54. package/framework/hooks/shared/heavy-command.js +4 -12
  55. package/framework/hooks/shared/hook-response.js +8 -83
  56. package/framework/hooks/shared/morph-cli.js +8 -52
  57. package/framework/hooks/shared/payload-utils.js +10 -132
  58. package/framework/hooks/shared/phase-utils.js +1 -42
  59. package/framework/hooks/shared/redact-secrets.js +4 -15
  60. package/framework/hooks/shared/reparse-scan.js +6 -66
  61. package/framework/hooks/shared/slice-progress.js +4 -23
  62. package/framework/hooks/shared/source-files.js +4 -17
  63. package/framework/hooks/shared/state-reader.js +13 -183
  64. package/framework/hooks/shared/state-writer.js +8 -52
  65. package/framework/hooks/shared/stdin-reader.js +1 -11
  66. package/framework/hooks/shared/telemetry-logger.js +7 -67
  67. package/framework/hooks/shared/transcript-usage.js +7 -29
  68. package/framework/hooks/shared/worktree-root.js +7 -29
  69. package/framework/rules/csharp-standards.md +1 -1
  70. package/framework/rules/frontend-standards.md +1 -0
  71. package/framework/rules/hangfire-standards.md +1 -1
  72. package/framework/rules/neon-standards.md +1 -1
  73. package/framework/rules/nextjs-standards.md +1 -1
  74. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  75. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  76. package/framework/skills/core/morph-apply/SKILL.md +4 -4
  77. package/framework/skills/core/morph-hotfix/SKILL.md +3 -3
  78. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  79. package/framework/skills/core/morph-media/SKILL.md +1 -1
  80. package/framework/skills/core/morph-plan/SKILL.md +9 -7
  81. package/framework/skills/core/morph-review/SKILL.md +9 -6
  82. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  83. package/framework/skills/core/morph-uiux/SKILL.md +29 -12
  84. package/framework/standards/STANDARDS.json +36 -14
  85. package/framework/standards/STANDARDS.meta.json +16 -2
  86. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  87. package/framework/standards/ai-agents/agent-session.md +1 -1
  88. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  89. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  90. package/framework/standards/ai-agents/guardrails.md +1 -1
  91. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  92. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  93. package/framework/standards/ai-agents/production.md +1 -1
  94. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  95. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  96. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  97. package/framework/standards/ai-agents/setup.md +2 -2
  98. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  99. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  100. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  101. package/framework/standards/ai-agents/workflows.md +1 -1
  102. package/framework/standards/architecture/code-comments.md +1 -1
  103. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  104. package/framework/standards/backend/api/validation.md +1 -1
  105. package/framework/standards/backend/dotnet/testing.md +10 -4
  106. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  107. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  108. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  109. package/framework/standards/data/nosql/cache/redis.md +1 -1
  110. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  111. package/framework/standards/frontend/admin-app/forms.md +1 -1
  112. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  113. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  114. package/framework/standards/frontend/nextjs/testing.md +10 -6
  115. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  116. package/framework/templates/.idea/morph-templates.xml +92 -0
  117. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  118. package/framework/templates/CLAUDE.md.core.template +3 -3
  119. package/framework/templates/IDE-SNIPPETS.md +262 -262
  120. package/framework/templates/REGISTRY.json +9 -9
  121. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  122. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  123. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  124. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  125. package/framework/templates/docs/plan.md +3 -1
  126. package/framework/templates/docs/ui-mockups.md +4 -0
  127. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  128. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  129. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  130. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  131. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  132. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  133. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  134. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  135. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  136. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  137. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  138. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  139. package/framework/templates/state.template.json +17 -17
  140. package/framework/templates/ui/design-system.css +226 -226
  141. package/package.json +1 -1
  142. package/src/commands/context/context-refresh.js +0 -16
  143. package/src/commands/dashboard/dashboard.js +2 -8
  144. package/src/commands/e2e/e2e.js +17 -104
  145. package/src/commands/graph/affected.js +0 -14
  146. package/src/commands/graph/build.js +11 -50
  147. package/src/commands/graph/explain.js +0 -16
  148. package/src/commands/graph/status.js +1 -12
  149. package/src/commands/mcp/mcp-setup.js +4 -62
  150. package/src/commands/observability/cost.js +5 -57
  151. package/src/commands/observability/telemetry.js +1 -14
  152. package/src/commands/project/branch.js +4 -45
  153. package/src/commands/project/claim.js +4 -48
  154. package/src/commands/project/doctor.js +48 -302
  155. package/src/commands/project/env.js +1 -27
  156. package/src/commands/project/fleet.js +3 -32
  157. package/src/commands/project/init.js +4 -68
  158. package/src/commands/project/install-plugin-cmd.js +0 -1
  159. package/src/commands/project/standards.js +1 -62
  160. package/src/commands/project/status.js +0 -53
  161. package/src/commands/project/update.js +31 -239
  162. package/src/commands/project/worktree.js +108 -882
  163. package/src/commands/scaffold/scaffold.js +0 -25
  164. package/src/commands/state/advance.js +14 -85
  165. package/src/commands/state/approve.js +15 -97
  166. package/src/commands/state/archive.js +12 -64
  167. package/src/commands/state/auto-approval.js +2 -5
  168. package/src/commands/state/create.js +22 -109
  169. package/src/commands/state/delete.js +11 -63
  170. package/src/commands/state/finish.js +98 -475
  171. package/src/commands/state/gate-check.js +22 -132
  172. package/src/commands/state/gate-decision.js +3 -39
  173. package/src/commands/state/handoff.js +3 -30
  174. package/src/commands/state/list.js +14 -84
  175. package/src/commands/state/report-check.js +8 -58
  176. package/src/commands/state/retype.js +4 -26
  177. package/src/commands/validation/index.js +0 -3
  178. package/src/commands/validation/mutate.js +11 -11
  179. package/src/commands/validation/plan-check.js +4 -2
  180. package/src/commands/validation/tests-audit.js +7 -0
  181. package/src/commands/validation/validate-feature.js +10 -97
  182. package/src/commands/validation/verify.js +30 -113
  183. package/src/core/paths/output-schema.js +2 -101
  184. package/src/core/state/feature-identity.js +1 -25
  185. package/src/core/state/gate-order.js +0 -27
  186. package/src/core/state/lifecycle-audit.js +8 -100
  187. package/src/core/state/state-manager.js +22 -304
  188. package/src/core/templates/template-registry.js +2 -97
  189. package/src/core/templates/template-renderer.js +13 -93
  190. package/src/core/templates/template-seed.js +0 -1
  191. package/src/core/work-types.js +1 -36
  192. package/src/dashboard/app/App.jsx +1 -8
  193. package/src/dashboard/app/components/ActivityFeed.jsx +0 -5
  194. package/src/dashboard/app/components/CommandPalette.jsx +0 -7
  195. package/src/dashboard/app/components/DetailPanel.jsx +1 -5
  196. package/src/dashboard/app/components/EntityLink.jsx +0 -12
  197. package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
  198. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
  199. package/src/dashboard/app/components/Markdown.jsx +0 -9
  200. package/src/dashboard/app/components/PhaseRail.jsx +0 -11
  201. package/src/dashboard/app/components/SearchFilter.jsx +0 -4
  202. package/src/dashboard/app/components/Sidebar.jsx +0 -5
  203. package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
  204. package/src/dashboard/app/hooks/usePref.js +1 -5
  205. package/src/dashboard/app/lib/format.js +0 -2
  206. package/src/dashboard/app/lib/phases.js +1 -5
  207. package/src/dashboard/app/lib/project.js +0 -12
  208. package/src/dashboard/app/main.jsx +13 -13
  209. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  210. package/src/dashboard/app/pages/FeatureDetail.jsx +1 -5
  211. package/src/dashboard/app/pages/Features.jsx +1 -8
  212. package/src/dashboard/app/pages/Guide.jsx +0 -1
  213. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  214. package/src/dashboard/app/pages/PhasePipeline.jsx +0 -3
  215. package/src/dashboard/app/pages/Portfolio.jsx +0 -1
  216. package/src/dashboard/app/pages/RulesMap.jsx +1 -16
  217. package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
  218. package/src/dashboard/app/pages/Standards.jsx +2 -17
  219. package/src/dashboard/scanner/activity-scanner.js +10 -69
  220. package/src/dashboard/scanner/agents-scanner.js +1 -23
  221. package/src/dashboard/scanner/docs-scanner.js +0 -6
  222. package/src/dashboard/scanner/feature-status.js +2 -5
  223. package/src/dashboard/scanner/features-scanner.js +10 -66
  224. package/src/dashboard/scanner/hooks-scanner.js +5 -28
  225. package/src/dashboard/scanner/index.js +0 -4
  226. package/src/dashboard/scanner/portfolio.js +5 -29
  227. package/src/dashboard/scanner/project-api.js +8 -54
  228. package/src/dashboard/scanner/rules-scanner.js +1 -2
  229. package/src/dashboard/scanner/skills-scanner.js +2 -12
  230. package/src/dashboard/scanner/standards-scanner.js +2 -6
  231. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  232. package/src/dashboard/vite.config.js +24 -24
  233. package/src/lib/ai/ai-checks.js +7 -52
  234. package/src/lib/ai/ai-project-scan.js +14 -108
  235. package/src/lib/ai/code-audit.js +7 -63
  236. package/src/lib/ai/pin-drift.js +8 -51
  237. package/src/lib/ai/registry-audit.js +11 -90
  238. package/src/lib/ci/ci-verify.js +4 -19
  239. package/src/lib/ci/github-ci.js +9 -37
  240. package/src/lib/ci/workflow-scaffold.js +14 -10
  241. package/src/lib/detectors/claude-config-detector.js +13 -74
  242. package/src/lib/detectors/index.js +4 -50
  243. package/src/lib/e2e/applicability.js +37 -0
  244. package/src/lib/e2e/compose-env.js +5 -66
  245. package/src/lib/e2e/stack-teardown.js +6 -40
  246. package/src/lib/generators/architecture-scanner.js +18 -156
  247. package/src/lib/generators/handoff-generator.js +9 -53
  248. package/src/lib/generators/scaffold-generator.js +5 -101
  249. package/src/lib/generators/settings-generator.js +4 -37
  250. package/src/lib/git/branch-classifier.js +11 -84
  251. package/src/lib/git/default-branch.js +14 -169
  252. package/src/lib/git/evidence-staleness.js +1 -0
  253. package/src/lib/git/gh.js +2 -17
  254. package/src/lib/git/pending-prs.js +6 -37
  255. package/src/lib/git/pr-files.js +3 -28
  256. package/src/lib/git/pr-index.js +3 -34
  257. package/src/lib/git/tree-fingerprint.js +3 -15
  258. package/src/lib/git/tree-state.js +6 -33
  259. package/src/lib/graph/god-nodes.js +2 -22
  260. package/src/lib/graph/graph-health.js +3 -24
  261. package/src/lib/graph/graph-loader.js +5 -56
  262. package/src/lib/graph/graphify-runtime.js +7 -37
  263. package/src/lib/graph/graphifyignore.js +8 -74
  264. package/src/lib/graph/sanitize.js +16 -136
  265. package/src/lib/heavy/cleanup.js +1 -1
  266. package/src/lib/installers/effects.js +8 -93
  267. package/src/lib/installers/mcp-installer.js +9 -169
  268. package/src/lib/installers/memory-installer.js +4 -27
  269. package/src/lib/installers/templates-reconcile.js +5 -60
  270. package/src/lib/mutation/adapters.js +6 -17
  271. package/src/lib/mutation/dotnet-introspection.js +20 -106
  272. package/src/lib/mutation/runner.js +23 -75
  273. package/src/lib/mutation/subset.js +1 -4
  274. package/src/lib/mutation/trx.js +0 -1
  275. package/src/lib/plan/artifact-vocabulary.js +5 -25
  276. package/src/lib/plan/checks.js +34 -33
  277. package/src/lib/plan/plan-parser.js +7 -8
  278. package/src/lib/projects/registry.js +7 -44
  279. package/src/lib/review-reports.js +11 -26
  280. package/src/lib/risk-detector.js +41 -173
  281. package/src/lib/specs/adr-index.js +6 -34
  282. package/src/lib/specs/frontmatter.js +2 -15
  283. package/src/lib/specs/gfm-table.js +1 -7
  284. package/src/lib/specs/revocations.js +1 -14
  285. package/src/lib/specs/screen-checklist.js +47 -124
  286. package/src/lib/stack/dotnet-build-ignores.js +2 -6
  287. package/src/lib/stack/stack-profile.js +1 -28
  288. package/src/lib/stack/stack-scan.js +1 -8
  289. package/src/lib/stack-filter.js +4 -27
  290. package/src/lib/standards/registry.js +1 -7
  291. package/src/lib/standards/sections.js +2 -13
  292. package/src/lib/standards/verified-against.js +19 -191
  293. package/src/lib/tasks/eval-node.js +21 -169
  294. package/src/lib/tasks/linux-runner.js +18 -142
  295. package/src/lib/tasks/test-nodes.js +43 -240
  296. package/src/lib/tasks/test-runner.js +43 -341
  297. package/src/lib/telemetry/metrics.js +7 -36
  298. package/src/lib/telemetry/telemetry-reader.js +13 -125
  299. package/src/lib/telemetry/verify-event.js +2 -30
  300. package/src/lib/tests-audit/audit.js +96 -0
  301. package/src/lib/validators/code-budget/code-budget-validator.js +4 -44
  302. package/src/lib/validators/code-budget/index.js +0 -7
  303. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -24
  304. package/src/lib/validators/contracts/index.js +0 -6
  305. package/src/lib/validators/nextjs/index.js +0 -5
  306. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  307. package/src/lib/validators/packages/index.js +0 -6
  308. package/src/lib/validators/packages/package-validator.js +6 -56
  309. package/src/lib/validators/reuse/index.js +0 -8
  310. package/src/lib/validators/reuse/reuse-validator.js +11 -101
  311. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  312. package/src/lib/validators/traceability-check.js +5 -63
  313. package/src/lib/validators/validation-runner.js +21 -158
  314. package/src/lib/validators/verify-runner.js +85 -376
  315. package/src/lib/work-type-detector.js +5 -67
  316. package/src/lib/worktree/docker-audit.js +17 -136
  317. package/src/lib/worktree/env-allocator.js +10 -98
  318. package/src/lib/worktree/feature-truth.js +12 -115
  319. package/src/lib/worktree/finish-backup.js +32 -242
  320. package/src/lib/worktree/fleet-scanner.js +13 -95
  321. package/src/lib/worktree/ignored-triage.js +15 -91
  322. package/src/lib/worktree/vscode-settings.js +2 -3
  323. package/src/lib/worktree/worktree-audit.js +21 -213
  324. package/src/lib/worktree/worktree-include.js +3 -5
  325. package/src/scripts/global-install.js +3 -9
  326. package/src/scripts/install-plugin.js +1 -13
  327. package/src/scripts/setup-infra.js +17 -147
  328. package/src/utils/agents-installer.js +29 -276
  329. package/src/utils/banner.js +4 -25
  330. package/src/utils/claude-md-injector.js +1 -37
  331. package/src/utils/claude-settings-manager.js +1 -52
  332. package/src/utils/file-copier.js +11 -95
  333. package/src/utils/hooks-installer.js +21 -298
  334. package/src/utils/skills-installer.js +4 -101
  335. package/src/utils/version-checker.js +4 -83
  336. package/framework/commands/morph-proposal.md +0 -9
  337. package/framework/hooks/git/commit-msg/conventional-commits.sh +0 -33
  338. package/framework/hooks/git/pre-commit/agents.sh +0 -25
  339. package/framework/hooks/git/pre-commit/orchestrator.sh +0 -64
  340. package/framework/hooks/git/pre-push/run-tests.sh +0 -44
  341. package/src/commands/utils/index.js +0 -3
  342. package/src/lib/detectors/design-system-detector.js +0 -188
  343. package/src/lib/generators/design-system-generator.js +0 -508
  344. package/src/lib/specs/old-format.js +0 -68
  345. package/src/utils/agent-id-mapper.js +0 -11
@@ -1,26 +1,3 @@
1
- /**
2
- * Verify Runner — deterministic build+tests+validators+e2e cadence.
3
- *
4
- * v8 adds a DETERMINISTIC verification node that runs BEFORE the LLM judge
5
- * in the implement loop: build + tests + validators (code, free) first; the
6
- * LLM judge is only invoked once verify passes. This is a BINARY gate
7
- * (pass/fail per node).
8
- *
9
- * The `e2e` node closes the last gap in that chain: build proves the code
10
- * COMPILES, tests prove units behave, validators read structure — none of
11
- * them prove the application RUNS. See e2e-check.js for why that distinction
12
- * is load-bearing rather than academic.
13
- *
14
- * Pure orchestration — reuses (never reimplements):
15
- * - runBuildCheck() from framework/hooks/shared/build-check.js
16
- * - runTestSuite() from src/lib/tasks/test-runner.js (one node)
17
- * - resolveTestNodes() from src/lib/tasks/test-nodes.js (the LIST of suites)
18
- * - runValidation() from ./validation-runner.js
19
- * - runE2eCheck() from framework/hooks/shared/e2e-check.js
20
- *
21
- * @module verify-runner
22
- */
23
-
24
1
  import { execFileSync } from 'child_process';
25
2
  import { existsSync, readFileSync, rmSync } from 'fs';
26
3
  import { join, resolve } from 'path';
@@ -28,6 +5,7 @@ import { join, resolve } from 'path';
28
5
  import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
29
6
  import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
30
7
  import { buildComposeEnv } from '../e2e/compose-env.js';
8
+ import { e2eApplicability } from '../e2e/applicability.js';
31
9
  import {
32
10
  runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
33
11
  } from '../tasks/test-runner.js';
@@ -44,24 +22,14 @@ import { runValidation } from './validation-runner.js';
44
22
  import { checkOutputsTraceability } from './traceability-check.js';
45
23
  import { createHeavyGate } from '../heavy/gate.js';
46
24
 
47
- /** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
25
+ // A cold dotnet build takes minutes, not seconds.
48
26
  const DEFAULT_BUILD_TIMEOUT = 120_000;
49
27
 
50
- // Validator issues surfaced in `feedback`, errors first; the full list stays on the node.
51
28
  const VALIDATOR_FEEDBACK_LIMIT = 10;
52
29
 
53
- /** Number of trailing output lines kept for feedback/reporting. */
54
30
  const OUTPUT_TAIL_LINES = 20;
55
31
 
56
- /**
57
- * The caller asked for something impossible — not a red verification, a wrong
58
- * invocation. The distinction is the exit code: `verify` answers 1 for "the
59
- * code is not ready" and 2 for "this command cannot mean what you typed".
60
- *
61
- * It exists because `--filter` can select nothing. Running every suite (or,
62
- * far worse, none of them) and reporting green would be F30 restated as a
63
- * one-word typo — so the runner refuses instead of guessing.
64
- */
32
+ /** A wrong invocation, not a red verification: `verify` exits 2 for it, 1 for "code not ready". */
65
33
  export class VerifyUsageError extends Error {
66
34
  constructor(message) {
67
35
  super(message);
@@ -71,29 +39,7 @@ export class VerifyUsageError extends Error {
71
39
  }
72
40
 
73
41
  /**
74
- * Run the deterministic verify cadence for a feature.
75
- *
76
- * @param {string} projectPath - Project root directory
77
- * @param {string} featureName - Feature name
78
- * @param {Object} [options]
79
- * @param {number} [options.timeout] - Timeout in ms for the build AND tests nodes
80
- * (default: 120000 for build; for tests, see resolveTestTimeout())
81
- * @param {boolean} [options.skipTests] - Skip the tests node
82
- * @param {boolean} [options.skipBuild] - Skip the build node
83
- * @param {boolean} [options.skipE2e] - Skip the e2e node
84
- * @param {boolean} [options.freshE2e] - Force a stack rebuild instead of reusing a warm one
85
- * @param {boolean} [options.recordBaseline] - Measure and write `expectedCount` back into
86
- * `project.tests[]` — only from a run whose every test node is green (D8)
87
- * @param {boolean} [options.linux] - Run the test nodes inside a Linux container, on a COPY
88
- * of the tree (see linux-runner.js for why a bind mount would prove nothing)
89
- * @param {string} [options.linuxImage] - Image override for --linux
90
- * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
91
- * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
92
- * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
93
- * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
94
- * @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
95
- * @param {Object} [options._heavyGate] - Injectable heavy-slot gate (testing); default is one per run
96
- * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
42
+ * @throws {VerifyUsageError} on an invocation that cannot mean what was typed (exit 2)
97
43
  * @throws {HeavyBusyError} when no heavy slot (or RAM) freed within `heavy.waitTimeoutSec`
98
44
  */
99
45
  export async function runVerify(projectPath, featureName, options = {}) {
@@ -122,16 +68,15 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
122
68
  _runTestSuite = runTestSuite,
123
69
  _runValidation = runValidation,
124
70
  _runE2eCheck = runE2eCheck,
71
+ _e2eApplicability = e2eApplicability,
125
72
  _checkOutputsTraceability = checkOutputsTraceability,
126
73
  } = options;
127
74
 
128
75
  const stack = readStack(projectPath);
129
76
  const paths = readProjectPaths(projectPath);
130
77
 
131
- // The suites are resolved ONCE, before anything is executed, because
132
- // `--filter` has to be judged before the first second of build time is
133
- // spent: a filter that matches nothing is a typo, and paying for a build to
134
- // discover a typo is the worst of both outcomes.
78
+ // Usage errors are judged before the build: paying for a build to discover a typo
79
+ // is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
135
80
  const testPlan = resolveTestNodes(projectPath);
136
81
  const filter = parseTestFilter(options.filter);
137
82
  if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
@@ -141,39 +86,23 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
141
86
  );
142
87
  }
143
88
 
144
- // `--record-baseline` is judged in the same breath, and for the same reason:
145
- // discovering that there is nowhere to write AFTER running the whole fleet
146
- // would charge the user a full suite for a usage error. `config.json` is
147
- // never created here — this repository deliberately has none, and inventing
148
- // one would break `doctor --full`.
149
89
  if (recordBaseline) {
150
90
  const problem = baselineTargetProblem(projectPath, testPlan, { skipTests });
151
91
  if (problem) throw new VerifyUsageError(problem);
152
92
  }
153
93
 
154
- // `--refresh-cache` is judged in the same breath as `--filter` and
155
- // `--record-baseline`, and for the same reason — but here the stake is higher
156
- // than a wasted clock: a refusal AFTER the cache was touched would leave a
157
- // half-rewritten tree behind. Same discipline as `finish --merge`, which
158
- // validates the root BEFORE archiving.
159
94
  if (refreshCache) {
160
95
  const problem = refreshCacheProblem(projectPath, testPlan, filter, { skipTests });
161
96
  if (problem) throw new VerifyUsageError(problem);
162
97
  }
163
98
 
164
- // `--linux` fails FAST and LOUD when Docker is missing (exit 2). Never a
165
- // skip: "we could not check on Linux" reading as green is F35 itself.
99
+ // Missing Docker is a usage error, never a skip: "could not check on Linux" must not read as green.
166
100
  if (linux) {
167
101
  const problem = checkLinuxPreconditions({
168
102
  projectPath, skipTests, ...(_exec ? { exec: _exec } : {}),
169
103
  });
170
104
  if (problem) throw new VerifyUsageError(problem);
171
105
 
172
- // The MORPH_EVAL_* channel carries ABSOLUTE host paths, and the container
173
- // runs a COPY of the tree at /work — so those paths do not exist inside it.
174
- // Refusing by name is the only honest answer: running the node anyway would
175
- // fail with an unrelated message, and skipping it would be the green-over-
176
- // nothing shape this feature exists to remove. The escape is named.
177
106
  const evalNode = testPlan.nodes.find(
178
107
  (node) => node.runner === EVAL_RUNNER && nodeMatchesFilter(node, filter)
179
108
  );
@@ -189,15 +118,11 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
189
118
  const nodes = {};
190
119
  const feedback = [];
191
120
 
192
- // 1. build
193
121
  nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
194
122
  appendBuildFeedback(feedback, nodes.build);
195
123
 
196
- // 2. tests — skipped entirely (no invocation) when build already failed.
197
- // The tests node gets the SAME --timeout the user typed, but only when they
198
- // typed one: `timeout` above is defaulted for the build, and passing that
199
- // default down would silently outrank config.project.testTimeoutMs and the
200
- // per-project scaling (see resolveTestTimeout()).
124
+ // Only a TYPED --timeout reaches the tests: the build default would outrank
125
+ // config.project.testTimeoutMs and the per-project scaling.
201
126
  const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
202
127
  const stackNodes = [];
203
128
  nodes.tests = await runTestsNode({
@@ -218,26 +143,24 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
218
143
  });
219
144
  if (stackNodes.length === 0) appendTestsFeedback(feedback, nodes.tests);
220
145
 
221
- // 3. validators — ALWAYS runs, even on a red build (free static feedback).
222
146
  nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
223
147
  appendValidatorsFeedback(feedback, nodes.validators);
224
148
 
225
- // 4. e2e — the only node that runs the application. Like tests, it is not
226
- // invoked at all on a red build: a stack that cannot compile cannot come up,
227
- // and the docker output would bury the actual compile error.
228
149
  const e2e = await runE2eNode({
229
150
  projectPath,
230
151
  featureName,
231
152
  skipE2e,
232
153
  freshE2e,
233
154
  buildNode: nodes.build,
155
+ testsNode: nodes.tests,
156
+ hasStackNodes: stackNodes.length > 0,
234
157
  gate,
235
158
  _runE2eCheck,
159
+ _e2eApplicability,
236
160
  });
237
161
  nodes.e2e = e2e.node;
238
162
  appendE2eFeedback(feedback, nodes.e2e);
239
163
 
240
- // `e2e: true` suites need the stack the e2e node just brought up (and left up).
241
164
  if (stackNodes.length > 0) {
242
165
  nodes.tests = await runStackTestNodes({
243
166
  projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
@@ -246,18 +169,21 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
246
169
  appendTestsFeedback(feedback, nodes.tests);
247
170
  }
248
171
 
249
- // Fora de `nodes` de propósito: o status sai só dos nós, então a divergência
250
- // relatório × fatias (aviso de rastreabilidade) nunca vira vermelho de correção.
172
+ // Outside `nodes` on purpose: status comes only from the nodes, so a traceability
173
+ // warning never turns red.
251
174
  const traceability = _checkOutputsTraceability(projectPath, featureName);
252
175
 
253
- const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
254
-
255
- // Recording happens LAST and only from what actually ran — the number is a
256
- // measurement of this invocation, never a value carried in from anywhere.
257
176
  const baseline = recordBaseline ? recordBaselineFrom(projectPath, nodes.tests) : null;
258
177
  if (baseline && !baseline.recorded) {
259
178
  feedback.push(`[baseline] não gravada: ${baseline.reason}`);
260
179
  }
180
+ const lowered = baseline?.recorded ? baseline.updates.filter((u) => u.from !== null && u.to < u.from) : [];
181
+ if (lowered.length > 0) {
182
+ feedback.push(`[baseline] rebaixada: ${lowered.map((u) => `${u.id} (${u.from} → ${u.to})`).join(', ')}`);
183
+ nodes.tests = acceptRecordedDrops(nodes.tests);
184
+ }
185
+
186
+ const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
261
187
 
262
188
  return {
263
189
  version: 1,
@@ -271,10 +197,6 @@ async function runVerifyGated(projectPath, featureName, options, gate) {
271
197
  };
272
198
  }
273
199
 
274
- // ============================================================================
275
- // Build node
276
- // ============================================================================
277
-
278
200
  /**
279
201
  * A root `dotnet build` with no solution at the root fails MSB1003: build the
280
202
  * repository's single solution instead, or say which candidates and which key decide.
@@ -325,34 +247,13 @@ function appendBuildFeedback(feedback, buildNode) {
325
247
  }
326
248
  }
327
249
 
328
- // ============================================================================
329
- // Tests node
330
- // ============================================================================
331
-
332
- /**
333
- * Run EVERY test suite the project declares, and roll the results up.
334
- *
335
- * Why a rollup instead of one node: defect F30. The resolver answered
336
- * "dotnet test", the 3.082 vitest tests of the same repository never ran, and
337
- * the gate reported green — a suite nobody could name is a suite that cannot
338
- * fail. `resolveTestNodes()` now returns the LIST, each entry runs with its own
339
- * command, cwd and clock, and every one of them appears in `runs[]`.
340
- *
341
- * `nodes.tests` deliberately stays an OBJECT with `status` (D6): verify.js and
342
- * the persisted stamp read `nodes.tests.status`, and turning a verification
343
- * defect into a shape defect is not progress. The plural detail lives in
344
- * `runs[]` beside it.
345
- *
346
- * @returns {Object} The rollup: `status`, joined `command`, summed `durationMs`,
347
- * `source`, `environment` (null outside a container) and `runs[]`.
348
- */
250
+ // Every declared suite runs and appears in `runs[]`; the rollup stays an object
251
+ // with `status` because verify.js and the persisted stamp read `nodes.tests.status`.
349
252
  async function runTestsNode({
350
253
  projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
351
254
  linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
352
255
  }) {
353
256
  const { nodes, source } = testPlan;
354
- // Kept readable for whoever only prints the string — the same join the
355
- // fallback scan already used for a chain of test projects.
356
257
  const command = nodes.map((n) => n.command).filter(Boolean).join(' && ') || null;
357
258
  // A fresh `runs: []` per return — two rollups must never alias one array.
358
259
  const nothingRan = (reason) => ({
@@ -406,19 +307,8 @@ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode,
406
307
  return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
407
308
  }
408
309
 
409
- /**
410
- * Run the executable nodes inside ONE Linux container and judge them with the
411
- * SAME rules the local path uses.
412
- *
413
- * The pre-decisions (invalid config, `--filter`, the eval pre-flight) happen HERE, before the container exists: a node that must not run must not
414
- * be written into the container script either, or the flag would quietly run
415
- * what the user filtered out. What the container returns — an exit code and an
416
- * output — is then handed to `judgeRun()`, the same function the host path
417
- * calls, so a count drop means a count drop in both environments.
418
- *
419
- * The tree the container executes is a COPY on its own filesystem, never the
420
- * bind mount: see linux-runner.js. That is the whole reason this path exists.
421
- */
310
+ // Pre-decisions run before the container exists, so a filtered node is never written
311
+ // into its script; results go through the same `judgeRun()` as the host path.
422
312
  async function runNodesInLinux({
423
313
  projectPath, nodes, filter, timeout, linuxImage, gate, _runLinuxSuite, _exec,
424
314
  }) {
@@ -430,8 +320,7 @@ async function runNodesInLinux({
430
320
  else toRun.push(node);
431
321
  }
432
322
 
433
- // One container, one clock: the ceiling is the sum of what each node would
434
- // have been given locally, since they share the run.
323
+ // One container, one clock: the sum of each node's local timeout.
435
324
  const containerTimeout = toRun.reduce(
436
325
  (sum, node) => sum + resolveTestTimeout(projectPath, node, timeout), 0
437
326
  ) || undefined;
@@ -450,16 +339,13 @@ async function runNodesInLinux({
450
339
  if (decided.has(node.id)) return decided.get(node.id);
451
340
  const raw = byId.get(node.id);
452
341
  if (!raw) {
453
- // The container answered about a fleet that does not include this node.
454
- // Silence here would be a suite vanishing from the report.
455
342
  return { ...emptyRunEntry(node), status: 'fail', reason: 'o container não reportou este nó' };
456
343
  }
457
344
  if (raw.skipped) {
458
345
  return { ...emptyRunEntry(node), reason: raw.reason };
459
346
  }
460
347
  const result = raw.exitCode === null
461
- // No exit code is NOT a pass: the container died before this node
462
- // reported, and a green verdict over an unobserved suite is F30's shape.
348
+ // No exit code is NOT a pass: the container died before this node reported.
463
349
  ? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
464
350
  : { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
465
351
  return judgeRun({ node, result, durationMs: raw.durationMs ?? 0 });
@@ -468,30 +354,17 @@ async function runNodesInLinux({
468
354
  return { runs, environment: suite.environment };
469
355
  }
470
356
 
471
- /**
472
- * Execute (or decline to execute) one node, always producing a `runs[]` entry.
473
- *
474
- * Nothing is ever dropped: a node that must not run says so with a reason, in
475
- * the place the reader is already looking. A suite that silently disappears
476
- * from the report is the defect this whole feature exists to close.
477
- */
478
357
  async function runOneTestNode({
479
358
  projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
480
359
  }) {
481
360
  const decided = preDecideRun({ node, filter, projectPath, refreshCache });
482
361
  if (decided) return decided;
483
362
 
484
- // The eval channel: the CLI DICTATES where the cache, the dataset and the
485
- // summary live, so the same xunit suite is runnable by `verify` without a
486
- // second configuration path. `env` is undefined for every other runner, which
487
- // keeps `runTestSuite` on exactly the path it had before.
488
363
  const evalChannel = node.runner === EVAL_RUNNER
489
364
  ? evalNodeEnv(node, projectPath, { refresh: refreshCache })
490
365
  : null;
491
- // The summary path is fixed per node id and pid, and nothing else removes the
492
- // file: one left there by an earlier run (an earlier verify in the same
493
- // process, a recycled pid) was read as THIS run's, handing a harness that
494
- // wrote nothing the old scenario count — and hiding an empty run behind it.
366
+ // The summary path is fixed per node id and pid: a file left by an earlier run
367
+ // would be read as this run's and hide an empty run behind the old count.
495
368
  if (evalChannel) discardFile(evalChannel.summaryPath);
496
369
 
497
370
  gate.enter('tests');
@@ -508,26 +381,12 @@ async function runOneTestNode({
508
381
  return judged;
509
382
  }
510
383
 
511
- /** Remove a scratch file the CLI owns; a file that is not there (or will not go) changes nothing. */
512
384
  function discardFile(path) {
513
- try { rmSync(path, { force: true }); } catch { /* best effort — the next run removes it first */ }
385
+ try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
514
386
  }
515
387
 
516
- /**
517
- * Second half of an eval verdict: what the SUMMARY says, on top of what the exit
518
- * code said.
519
- *
520
- * Two things the exit code cannot see:
521
- * - the scenario COUNT, which is what `expectedCount` is compared against and
522
- * what `--record-baseline` writes. Read ONLY from the summary: the `dotnet`
523
- * extractor counts xunit cases, another unit — compared with (or recorded as)
524
- * a scenario baseline it failed green runs as drops (#110, #117 review). No
525
- * readable summary → **null, UNKNOWN, never 0** — an unreadable summary must
526
- * not manufacture a count drop;
527
- * - a suite that evaluated ZERO scenarios and exited 0. Nothing failed, because
528
- * nothing ran: the guardrail-inert shape this repository has already paid to
529
- * learn to detect.
530
- */
388
+ // The scenario count comes ONLY from the summary (the dotnet extractor counts xunit
389
+ // cases, another unit); no readable summary is null, UNKNOWN, never 0.
531
390
  function judgeEvalRun(entry, node, summaryPath) {
532
391
  const summary = readEvalSummary(summaryPath);
533
392
  const warnings = [...(entry.warnings || [])];
@@ -539,11 +398,8 @@ function judgeEvalRun(entry, node, summaryPath) {
539
398
  const verdict = compareTestCount(count, node.expectedCount);
540
399
  if (verdict.warning) warnings.push(verdict.warning);
541
400
 
542
- // As falhas NOMEADAS entram no buffer de saida do no, e entram na FRENTE.
543
- // `appendTestsFeedback` prefere `output` a `reason`, entao um nome so no
544
- // `reason` nunca chegaria ao `feedback[]`; e a cauda do `dotnet` e ruido
545
- // truncado exatamente onde o sumario ja tem a frase certa. Prepend, e nao
546
- // substituicao, porque um crash do runner tambem vive nessa cauda.
401
+ // Named failures go into `output` (feedback prefers it to `reason`), prepended
402
+ // rather than replacing it because a runner crash also lives in that tail.
547
403
  const named = evalFailureLines(summary);
548
404
  const withNames = named.length > 0
549
405
  ? { ...entry, output: [...named, ...(entry.output ? [entry.output] : [])].join('\n') }
@@ -555,14 +411,13 @@ function judgeEvalRun(entry, node, summaryPath) {
555
411
  }
556
412
 
557
413
  if (entry.status === 'pass' && verdict.verdict === 'drop') {
558
- return { ...withNames, status: 'fail', count, warnings, reason: verdict.reason };
414
+ return { ...withNames, status: 'fail', count, warnings, reason: verdict.reason, countDrop: true };
559
415
  }
560
416
  if (verdict.verdict === 'drop') warnings.push(verdict.reason);
561
417
 
562
418
  return { ...withNames, count, warnings };
563
419
  }
564
420
 
565
- /** The `runs[]` entry every path starts from — every field present, nothing implied. */
566
421
  function emptyRunEntry(node) {
567
422
  return {
568
423
  id: node.id,
@@ -570,8 +425,7 @@ function emptyRunEntry(node) {
570
425
  cwd: node.cwd,
571
426
  runner: node.runner,
572
427
  expectedCount: node.expectedCount,
573
- // Always present, so no reader has to tell "absent" from "unknown" by
574
- // optional chaining. **null is UNKNOWN, never 0** (D7).
428
+ // null is UNKNOWN, never 0.
575
429
  count: null,
576
430
  status: 'skip',
577
431
  durationMs: 0,
@@ -581,36 +435,22 @@ function emptyRunEntry(node) {
581
435
  };
582
436
  }
583
437
 
584
- /**
585
- * The verdicts that exist BEFORE anything is executed, or null when the node
586
- * has to run to be judged.
587
- *
588
- * Shared by the local loop and by `--linux`, so a node that must not run is
589
- * refused for the same reason and with the same words in both environments —
590
- * two copies of this ordering is how a filtered node ends up invisible in one
591
- * of them.
592
- */
438
+ // Verdicts decided before execution, shared by the host loop and `--linux` so both
439
+ // refuse for the same reason with the same words; null means the node must run.
593
440
  function preDecideRun({ node, filter, projectPath = '.', refreshCache = false }) {
594
441
  const entry = emptyRunEntry(node);
595
442
 
596
- // The resolver already decided: invalid config (fail-closed, D4) or an
597
- // explicit "no suites declared". Executing it would be asking a question that
598
- // has been answered — and, for an invalid node, answering it wrongly.
599
443
  if (node.status) {
600
444
  return { ...entry, status: node.status, reason: node.reason };
601
445
  }
602
446
 
603
- // Deliberately AFTER the pre-decided verdict above: `--filter` says "do not
604
- // spend time on this suite", not "do not tell me my config is broken". An
605
- // invalid node costs no clock and still reddens the gate (D4).
447
+ // After the pre-decided verdict on purpose: an invalid node costs no clock and
448
+ // still reddens the gate even when filtered out.
606
449
  if (!nodeMatchesFilter(node, filter)) {
607
450
  return { ...entry, reason: `filtered out (--filter ${filter.raw})` };
608
451
  }
609
452
 
610
- // The eval pre-flight lives HERE, beside the other pre-decisions, so the host
611
- // loop and the `--linux` path refuse for the same reason with the same words.
612
- // Its three cache verdicts are FAILURES, never skips: a skip over an unusable
613
- // cache is a green gate above a verification that never happened.
453
+ // A cache miss is a fail, never a skip: a skip would be green over nothing.
614
454
  if (node.runner === EVAL_RUNNER) {
615
455
  const pre = evalPreflight(projectPath, node, { refresh: refreshCache });
616
456
  if (pre.status) return { ...entry, status: pre.status, reason: pre.reason };
@@ -619,14 +459,7 @@ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false })
619
459
  return null;
620
460
  }
621
461
 
622
- /**
623
- * Turn ONE executor result into a `runs[]` entry — the count rule included.
624
- *
625
- * Called by both the local loop and the `--linux` container path on purpose:
626
- * a drop, a rise and an unknown count must mean the same thing whether the
627
- * suite ran on this host or inside a container. A second copy of this judgement
628
- * is how two environments start disagreeing about the same suite.
629
- */
462
+ // The single count rule for host and container runs; a second copy makes them disagree.
630
463
  function judgeRun({ node, result, durationMs }) {
631
464
  const entry = emptyRunEntry(node);
632
465
 
@@ -634,8 +467,7 @@ function judgeRun({ node, result, durationMs }) {
634
467
  return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
635
468
  }
636
469
 
637
- // A timeout has no verdict to report: the tail of a truncated log reads like
638
- // a passing run cut mid-sentence. Lead with what actually happened.
470
+ // A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
639
471
  if (result.timedOut) {
640
472
  const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
641
473
  return {
@@ -648,29 +480,19 @@ function judgeRun({ node, result, durationMs }) {
648
480
  };
649
481
  }
650
482
 
651
- // How many tests actually ran, and how that compares to the recorded
652
- // baseline. Read from the FULL output, before it is truncated to a tail —
653
- // the summary line is the last thing a runner prints, but a red run's tail
654
- // is chosen for the failures, not for the summary.
483
+ // Parsed from the FULL output, before truncation to a tail.
655
484
  const parsed = parseTestCount(result.output, node.runner);
656
485
  const count = parsed ? parsed.total : null;
657
486
 
658
- // An eval node's unit is the SCENARIO (#110 part 2): its baseline is the
659
- // summary's scenario count, and `judgeEvalRun` is the one that compares it.
660
- // Comparing here too, with the extractor's count of xunit cases, failed a
661
- // 24/24 run as "esperado 24, executado 2" — and `judgeEvalRun` only turns a
662
- // pass into a fail, never the reverse.
487
+ // An eval node's unit is the SCENARIO, compared only by `judgeEvalRun`; the
488
+ // extractor's xunit-case count against that baseline would fail green runs.
663
489
  const verdict = compareTestCount(count, node.runner === EVAL_RUNNER ? null : node.expectedCount);
664
490
  const warnings = verdict.warning ? [verdict.warning] : [];
665
491
 
666
- // A drop is the one count verdict that fails a node by itself: tests that
667
- // used to run no longer do. A RISE never fails (D7) — the suite grows with
668
- // every task, and failing on growth teaches deleting tests.
492
+ // Only a drop fails a node; a rise never does, or failing on growth teaches deleting tests.
669
493
  if (verdict.verdict === 'drop' && result.passed) {
670
- return { ...entry, status: 'fail', durationMs, count, warnings, reason: verdict.reason, output: '' };
494
+ return { ...entry, status: 'fail', durationMs, count, warnings, reason: verdict.reason, output: '', countDrop: true };
671
495
  }
672
- // Already red: the failing test is the better lead, so the drop travels
673
- // beside it instead of overwriting the reason.
674
496
  if (verdict.verdict === 'drop') warnings.push(verdict.reason);
675
497
 
676
498
  return {
@@ -679,23 +501,18 @@ function judgeRun({ node, result, durationMs }) {
679
501
  durationMs,
680
502
  count,
681
503
  warnings,
682
- // `test-build-failed` / `stale-binary`: the executor refused BEFORE any
683
- // test ran, and the reason is the only thing worth reading. Without this
684
- // the node would go red with a null reason and the reader would go hunting
685
- // through a compiler log for a suite that never started.
504
+ // `test-build-failed` / `stale-binary`: refused before any test ran.
686
505
  reason: result.reason ?? null,
687
506
  output: result.passed ? '' : tailLines(result.output, OUTPUT_TAIL_LINES),
688
507
  };
689
508
  }
690
509
 
691
- /**
692
- * Aggregate the per-node runs into the single node `verify` publishes.
693
- *
694
- * The rollup is pessimistic on purpose: ANY red run reddens it. A green
695
- * aggregate covering a red suite is F30 restated, so `some(fail)` is checked
696
- * before anything else. `skip` only survives when there is nothing else to
697
- * report — one green run among skips is a pass.
698
- */
510
+ function acceptRecordedDrops(testsNode) {
511
+ const runs = testsNode.runs.map(({ countDrop, ...run }) => (countDrop ? { ...run, status: 'pass', reason: null } : run));
512
+ return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
513
+ }
514
+
515
+ // Any red run reddens the rollup; one green run among skips is a pass.
699
516
  function rollUpTestRuns(runs, { command, source, environment = null }) {
700
517
  const failed = runs.filter((r) => r.status === 'fail');
701
518
  const status = failed.length > 0
@@ -709,13 +526,8 @@ function rollUpTestRuns(runs, { command, source, environment = null }) {
709
526
  reason: null,
710
527
  output: failed.map((r) => r.output).filter(Boolean).join('\n'),
711
528
  source,
712
- // Where these suites ran. `null` is the local host; a container run says so
713
- // by name and image, because a green from a container and a green from the
714
- // host are otherwise indistinguishable in feature.json — and the next
715
- // person would not know what was proven.
529
+ // null is the local host; a container run is named so feature.json says what was proven.
716
530
  environment,
717
- // Named by suite: a warning that does not say WHICH baseline went stale
718
- // sends the reader to read every one of them.
719
531
  warnings: runs.flatMap((r) => (r.warnings || []).map((w) => `${r.id}: ${w}`)),
720
532
  runs,
721
533
  };
@@ -729,17 +541,8 @@ function rollUpTestRuns(runs, { command, source, environment = null }) {
729
541
  return node;
730
542
  }
731
543
 
732
- /**
733
- * One feedback line per RED node, named by node id.
734
- *
735
- * The `[tests/{id}]` prefix replaces the old bare `[tests]`: with N suites, a
736
- * line that does not say which one failed sends the reader to the wrong repo
737
- * half.
738
- */
739
544
  function appendTestsFeedback(feedback, testsNode) {
740
- // Warnings are emitted whatever the verdict — a stale baseline lives on a
741
- // GREEN node by construction (the count rose), so a warning that only
742
- // printed on failure would never print at all.
545
+ // Warnings print whatever the verdict: a stale baseline lives on a GREEN node.
743
546
  for (const run of testsNode.runs || []) {
744
547
  for (const warning of run.warnings || []) {
745
548
  feedback.push(`[tests/${run.id}] warning: ${warning}`);
@@ -754,31 +557,7 @@ function appendTestsFeedback(feedback, testsNode) {
754
557
  }
755
558
  }
756
559
 
757
- /**
758
- * Why `--refresh-cache` cannot mean what it says, or null when it can.
759
- *
760
- * Judged BEFORE the build node, and the ordering is the whole point: a refusal
761
- * that arrived after the cache was touched would leave a half-rewritten tree
762
- * behind. Same discipline `finish --merge` learned — validate the precondition
763
- * before the mutation, never after.
764
- *
765
- * Three refusals, all usage errors (exit 2), never verdicts:
766
- * - no eval node declared: succeeding silently would teach the user to type a
767
- * flag that does nothing;
768
- * - `--skip-tests`: the two flags cancel each other, and the winner would be
769
- * whichever the code happened to check first;
770
- * - a filter that excludes every eval node: same shape as the first.
771
- *
772
- * The MISSING CREDENTIAL is deliberately NOT judged here: it is per node (each
773
- * declares its own `envFile`), so `evalPreflight` answers it — still before the
774
- * command runs, and still without touching the tree.
775
- *
776
- * @param {string} projectPath
777
- * @param {{nodes: Array}} testPlan
778
- * @param {{tokens: string[], raw: string}|null} filter
779
- * @param {{skipTests: boolean}} opts
780
- * @returns {string|null}
781
- */
560
+ // The missing credential is judged per node by `evalPreflight`, not here.
782
561
  function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
783
562
  if (skipTests) {
784
563
  return '--refresh-cache com --skip-tests: as duas flags se anulam — a regravação da cache É a suíte de eval';
@@ -798,30 +577,8 @@ function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
798
577
  return null;
799
578
  }
800
579
 
801
- // ============================================================================
802
- // Baseline recording (--record-baseline)
803
- // ============================================================================
804
-
805
- /**
806
- * Why `--record-baseline` cannot run here, or null when it can.
807
- *
808
- * Judged BEFORE the build node, beside the `--filter` check, because both are
809
- * statements about a command that cannot mean what it says. Paying for a full
810
- * suite to be told there was nowhere to write the result is the worst possible
811
- * ordering.
812
- *
813
- * The refusal for a project with no `project.tests[]` is deliberate rather
814
- * than helpful: the nodes the fallback synthesizes are not declared anywhere,
815
- * so there is no entry to update, and writing one would mean this command
816
- * inventing the declaration it came to maintain. `config.json` is likewise
817
- * never created — the framework repository has none on purpose (creating it
818
- * breaks `doctor --full`), so the honest answer is a named usage error.
819
- *
820
- * @param {string} projectPath
821
- * @param {{ nodes: Array, source: string }} testPlan
822
- * @param {{ skipTests: boolean }} opts
823
- * @returns {string|null}
824
- */
580
+ // Never creates config.json nor a `project.tests[]` entry: fallback nodes have no
581
+ // declaration to update, and inventing one is not this command's job.
825
582
  function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
826
583
  if (skipTests) {
827
584
  return '--record-baseline com --skip-tests não mede nada — uma baseline vem de uma execução, não de uma omissão';
@@ -837,27 +594,16 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
837
594
  return null;
838
595
  }
839
596
 
840
- /**
841
- * Turn a finished tests rollup into a baseline write, or into a refusal.
842
- *
843
- * D8 lives here: the number is only ever what a REAL run measured, and only
844
- * when every node of that run is green. A red run records nothing — writing a
845
- * lower count over a regression is how the regression becomes the new normal.
846
- * A PARTIAL run records nothing either: `--filter` and skips leave nodes
847
- * unmeasured, and a baseline covering half the fleet is a claim about the
848
- * other half that nobody made.
849
- *
850
- * @param {string} projectPath
851
- * @param {Object} testsNode - The rollup produced by runTestsNode()
852
- * @returns {{ recorded: boolean, reason: string|null, updates: Array, path?: string }}
853
- */
597
+ // Only a run with EVERY node green records: a red run would freeze the regression,
598
+ // a partial one would claim counts for nodes nobody measured. A count drop on a run that
599
+ // exited 0 is the one red it accepts: lowering the number is how deleting tests passes.
854
600
  function recordBaselineFrom(projectPath, testsNode) {
855
601
  const runs = testsNode.runs || [];
856
602
  const refuse = (reason) => ({ recorded: false, reason, updates: [] });
857
603
 
858
604
  if (runs.length === 0) return refuse('nenhum nó de teste executou');
859
605
 
860
- const notGreen = runs.filter((r) => r.status !== 'pass');
606
+ const notGreen = runs.filter((r) => r.status !== 'pass' && !r.countDrop);
861
607
  if (notGreen.length > 0) {
862
608
  const named = notGreen.map((r) => `${r.id} (${r.status}${r.reason ? `: ${r.reason}` : ''})`).join('; ');
863
609
  return refuse(
@@ -869,23 +615,17 @@ function recordBaselineFrom(projectPath, testsNode) {
869
615
  return recordTestBaselines(projectPath, runs.map((r) => ({ id: r.id, count: r.count })));
870
616
  }
871
617
 
872
- // ============================================================================
873
- // Validators node
874
- // ============================================================================
875
-
876
618
  async function runValidatorsNode({ projectPath, featureName, _runValidation }) {
877
619
  const start = Date.now();
878
620
 
879
- // runValidation() prints chalk-formatted progress to stdout. verify emits
880
- // JSON — silence console during the call and ALWAYS restore it, even if
881
- // runValidation throws.
621
+ // runValidation() prints progress to stdout, which would corrupt verify's JSON.
882
622
  const originalLog = console.log;
883
623
  const originalWarn = console.warn;
884
624
  let validationResult;
885
625
  try {
886
626
  console.log = () => {};
887
627
  console.warn = () => {};
888
- validationResult = await _runValidation(projectPath, featureName, { mode: 'full' });
628
+ validationResult = await _runValidation(projectPath, featureName);
889
629
  } finally {
890
630
  console.log = originalLog;
891
631
  console.warn = originalWarn;
@@ -917,8 +657,7 @@ async function runValidatorsNode({ projectPath, featureName, _runValidation }) {
917
657
  }
918
658
 
919
659
  function appendValidatorsFeedback(feedback, validatorsNode) {
920
- // A validator that could not check (reuse without a graph) is invisible in a
921
- // green node; when the whole node skipped, its own line already says why.
660
+ // A validator that could not check is invisible in a green node otherwise.
922
661
  if (validatorsNode.status !== 'skip') {
923
662
  for (const s of validatorsNode.skipped || []) {
924
663
  if (s.unavailable) feedback.push(`[validators/${s.validator}] não rodou: ${s.reason}`);
@@ -935,30 +674,22 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
935
674
  }
936
675
  }
937
676
 
938
- // ============================================================================
939
- // E2E node
940
- // ============================================================================
677
+ const skippedE2e = (reason) => ({ node: { status: 'skip', command: null, durationMs: 0, reason, output: '' }, stackEnv: null });
941
678
 
942
- async function runE2eNode({ projectPath, featureName, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
943
- if (buildNode.status === 'fail') {
944
- return {
945
- node: {
946
- status: 'skip',
947
- command: null,
948
- durationMs: 0,
949
- reason: 'build failed — the stack could not come up anyway',
950
- output: '',
951
- },
952
- stackEnv: null,
953
- };
954
- }
679
+ async function runE2eNode({
680
+ projectPath, featureName, skipE2e, freshE2e, buildNode, testsNode, hasStackNodes, gate, _runE2eCheck, _e2eApplicability,
681
+ }) {
682
+ if (buildNode.status === 'fail') return skippedE2e('build failed — the stack could not come up anyway');
683
+ if (testsNode?.status === 'fail') return skippedE2e('tests failed — the e2e does not run over a red suite');
955
684
 
956
685
  const config = readConfig(projectPath);
686
+ // An `e2e: true` test node is the backend's proof against the stack: it always brings the stack up.
687
+ if (!skipE2e && !hasStackNodes) {
688
+ const applicability = _e2eApplicability(projectPath, featureName, config);
689
+ if (!applicability.applicable) return skippedE2e(applicability.reason);
690
+ }
957
691
  const gitCommonDir = resolveGitCommonDir(projectPath);
958
- // The port block matters MORE here than in `e2e up`: this is the path that
959
- // runs in the loop, and two worktrees verifying at once would otherwise get
960
- // distinct container names and then collide on host port 3000 — a red gate
961
- // over an infrastructure clash instead of a defect.
692
+ // Without the port block, two worktrees verifying at once collide on host ports.
962
693
  const env = buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir });
963
694
 
964
695
  const start = Date.now();
@@ -971,9 +702,7 @@ async function runE2eNode({ projectPath, featureName, skipE2e, freshE2e, buildNo
971
702
  forceFresh: freshE2e,
972
703
  beforeHeavy: () => gate.enter('e2e'),
973
704
  countSpecs: (output) => parseTestCount(output, 'playwright')?.total ?? null,
974
- // The stack stays up between verifies on purpose: cold-starting a .NET
975
- // container per verify is the difference between a usable loop and one
976
- // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
705
+ // Cold-starting a .NET container per verify is too slow; `e2e down` and `finish` tear it down.
977
706
  keepUp: true,
978
707
  });
979
708
  const durationMs = Date.now() - start;
@@ -985,11 +714,7 @@ async function runE2eNode({ projectPath, featureName, skipE2e, freshE2e, buildNo
985
714
  durationMs,
986
715
  reason: result.reason ?? null,
987
716
  output: result.output || '',
988
- // Specs Playwright counted in this run; null = no specs ran or unreadable summary.
989
717
  count: Number.isInteger(result.count) ? result.count : null,
990
- // Non-fatal findings (e.g. a compose with fixed ports at the ROOT, where
991
- // nothing collides yet). Surfaced so the same compose does not fail
992
- // unannounced the day the feature moves into a worktree.
993
718
  warnings: Array.isArray(result.warnings) ? result.warnings : [],
994
719
  },
995
720
  stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
@@ -1007,13 +732,8 @@ function appendE2eFeedback(feedback, e2eNode) {
1007
732
  }
1008
733
  }
1009
734
 
1010
- // ============================================================================
1011
- // Shared helpers
1012
- // ============================================================================
1013
-
1014
- /** Read the project stack directly from config.json — never the stack-profile
1015
- * default (which falls back to 'unknown'→dotnet and would wrongly fire
1016
- * `dotnet build` on a greenfield JS project). */
735
+ // Never the stack-profile default: it falls back to dotnet and would fire
736
+ // `dotnet build` on a greenfield JS project.
1017
737
  function readStack(projectPath) {
1018
738
  try {
1019
739
  const configPath = join(projectPath, '.morph', 'config', 'config.json');
@@ -1025,10 +745,6 @@ function readStack(projectPath) {
1025
745
  }
1026
746
  }
1027
747
 
1028
- /** Read frontend/backend sub-paths for a monorepo, so the build node can run
1029
- * each stack's build command from its own directory instead of assuming a
1030
- * single root (see getBuildSteps() in build-check.js). Absent in single-stack
1031
- * projects — every consumer treats missing paths as "use projectPath". */
1032
748
  function readProjectPaths(projectPath) {
1033
749
  try {
1034
750
  const configPath = join(projectPath, '.morph', 'config', 'config.json');
@@ -1041,7 +757,6 @@ function readProjectPaths(projectPath) {
1041
757
  }
1042
758
  }
1043
759
 
1044
- /** Read the whole config.json — the e2e node needs `e2e` and `project` together. */
1045
760
  function readConfig(projectPath) {
1046
761
  try {
1047
762
  const configPath = join(projectPath, '.morph', 'config', 'config.json');
@@ -1052,14 +767,8 @@ function readConfig(projectPath) {
1052
767
  }
1053
768
  }
1054
769
 
1055
- /**
1056
- * Resolve the shared git dir, where the e2e freshness stamp lives — the same
1057
- * location and rationale as the fleet env-registry (see env-allocator.js): it
1058
- * is the one place every linked worktree of a repo shares, and writing there
1059
- * can never dirty the `git status` the worktree-finish gates read.
1060
- *
1061
- * Fail-open: no git, no stamp — the cost is a rebuild, never a crash.
1062
- */
770
+ // The e2e freshness stamp lives in the common dir: shared by every worktree and
771
+ // never dirties `git status`. Fail-open: no git costs a rebuild, never a crash.
1063
772
  function resolveGitCommonDir(projectPath) {
1064
773
  try {
1065
774
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {