@polymorphism-tech/morph-spec 8.47.0 → 9.1.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 (377) hide show
  1. package/CLAUDE.md +51 -43
  2. package/README.md +39 -38
  3. package/bin/morph-spec.js +41 -113
  4. package/bin/validate.js +1 -147
  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 +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +86 -21
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-status.md +4 -4
  17. package/framework/docs/CLI.md +574 -286
  18. package/framework/evals/review-checklist.md +82 -41
  19. package/framework/hooks/README.md +10 -11
  20. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  21. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  22. package/framework/hooks/claude-code/core/chain.js +4 -65
  23. package/framework/hooks/claude-code/core/comment-guard.js +3 -17
  24. package/framework/hooks/claude-code/core/denylist.json +33 -3
  25. package/framework/hooks/claude-code/core/enforce-phase-writes.js +15 -63
  26. package/framework/hooks/claude-code/core/gate-guard.js +9 -78
  27. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  28. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  29. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  30. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  31. package/framework/hooks/claude-code/core/pre-bash-denylist.js +36 -2
  32. package/framework/hooks/claude-code/core/protect-spec-files.js +25 -297
  33. package/framework/hooks/claude-code/core/state-sync.js +15 -85
  34. package/framework/hooks/claude-code/core/telemetry-log.js +15 -96
  35. package/framework/hooks/claude-code/core/validate-completion.js +37 -147
  36. package/framework/hooks/claude-code/core/warn-bash-source-write.js +23 -0
  37. package/framework/hooks/claude-code/core/worktree-context.js +15 -114
  38. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  39. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  40. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  41. package/framework/hooks/claude-code/statusline.py +55 -41
  42. package/framework/hooks/package.json +3 -0
  43. package/framework/hooks/shared/activity-logger.js +9 -89
  44. package/framework/hooks/shared/agent-reap.js +2 -17
  45. package/framework/hooks/shared/build-check.js +11 -76
  46. package/framework/hooks/shared/claim-registry.js +9 -119
  47. package/framework/hooks/shared/command-tokens.js +168 -44
  48. package/framework/hooks/shared/comment-rules.js +5 -20
  49. package/framework/hooks/shared/comment-scanner.js +11 -43
  50. package/framework/hooks/shared/comment-stripper.js +11 -85
  51. package/framework/hooks/shared/e2e-check.js +0 -0
  52. package/framework/hooks/shared/errata.js +59 -0
  53. package/framework/hooks/shared/feature-locator.js +11 -95
  54. package/framework/hooks/shared/frontend-locations.js +4 -0
  55. package/framework/hooks/shared/gate-question.js +12 -0
  56. package/framework/hooks/shared/git-exclude.js +3 -20
  57. package/framework/hooks/shared/git-paths.js +8 -104
  58. package/framework/hooks/shared/graph-freshness.js +22 -166
  59. package/framework/hooks/shared/graph-refresh.js +21 -149
  60. package/framework/hooks/shared/heavy-command.js +4 -12
  61. package/framework/hooks/shared/hook-response.js +8 -83
  62. package/framework/hooks/shared/morph-cli.js +8 -52
  63. package/framework/hooks/shared/payload-utils.js +10 -132
  64. package/framework/hooks/shared/phase-utils.js +14 -72
  65. package/framework/hooks/shared/redact-secrets.js +4 -15
  66. package/framework/hooks/shared/reparse-scan.js +6 -66
  67. package/framework/hooks/shared/slice-progress.js +99 -0
  68. package/framework/hooks/shared/source-files.js +4 -17
  69. package/framework/hooks/shared/spawn-command.js +23 -0
  70. package/framework/hooks/shared/state-reader.js +51 -247
  71. package/framework/hooks/shared/state-writer.js +9 -54
  72. package/framework/hooks/shared/stdin-reader.js +1 -11
  73. package/framework/hooks/shared/telemetry-logger.js +7 -67
  74. package/framework/hooks/shared/transcript-usage.js +7 -29
  75. package/framework/hooks/shared/worktree-root.js +7 -29
  76. package/framework/mcp/registry.json +3 -4
  77. package/framework/phases.json +23 -39
  78. package/framework/rules/csharp-standards.md +1 -1
  79. package/framework/rules/frontend-standards.md +1 -0
  80. package/framework/rules/hangfire-standards.md +1 -1
  81. package/framework/rules/neon-standards.md +1 -1
  82. package/framework/schemas/config.schema.json +14 -9
  83. package/framework/schemas/feature.schema.json +15 -50
  84. package/framework/skills/README.md +3 -4
  85. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  86. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  87. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  88. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  89. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  90. package/framework/skills/core/morph-media/SKILL.md +1 -1
  91. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  92. package/framework/skills/core/morph-review/SKILL.md +111 -136
  93. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  94. package/framework/skills/core/morph-uiux/SKILL.md +94 -152
  95. package/framework/standards/STANDARDS.json +109 -1219
  96. package/framework/standards/STANDARDS.meta.json +1319 -0
  97. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  98. package/framework/standards/ai-agents/agent-session.md +1 -1
  99. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  100. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  101. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  102. package/framework/standards/ai-agents/guardrails.md +1 -1
  103. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  104. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  105. package/framework/standards/ai-agents/production.md +1 -1
  106. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  107. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  108. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  109. package/framework/standards/ai-agents/setup.md +3 -3
  110. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  111. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  112. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  113. package/framework/standards/ai-agents/workflows.md +1 -1
  114. package/framework/standards/architecture/code-comments.md +7 -7
  115. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  116. package/framework/standards/backend/api/validation.md +1 -1
  117. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  118. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  119. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  120. package/framework/standards/data/nosql/cache/redis.md +1 -1
  121. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  122. package/framework/standards/frontend/admin-app/forms.md +1 -1
  123. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  124. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  125. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  126. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  127. package/framework/templates/CLAUDE.md.core.template +3 -3
  128. package/framework/templates/README.md +2 -3
  129. package/framework/templates/REGISTRY.json +15 -179
  130. package/framework/templates/docs/plan.md +72 -0
  131. package/framework/templates/docs/ui-mockups.md +4 -0
  132. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  133. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  134. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  135. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  136. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  137. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  138. package/package.json +1 -1
  139. package/src/commands/context/context-refresh.js +0 -16
  140. package/src/commands/dashboard/dashboard.js +2 -8
  141. package/src/commands/e2e/e2e.js +18 -105
  142. package/src/commands/graph/affected.js +0 -14
  143. package/src/commands/graph/build.js +11 -50
  144. package/src/commands/graph/explain.js +0 -16
  145. package/src/commands/graph/status.js +1 -12
  146. package/src/commands/mcp/mcp-setup.js +4 -62
  147. package/src/commands/observability/cost.js +17 -111
  148. package/src/commands/observability/telemetry.js +1 -14
  149. package/src/commands/project/branch.js +4 -45
  150. package/src/commands/project/claim.js +4 -48
  151. package/src/commands/project/doctor-procs.js +1 -1
  152. package/src/commands/project/doctor.js +53 -307
  153. package/src/commands/project/env.js +1 -27
  154. package/src/commands/project/fleet.js +6 -35
  155. package/src/commands/project/heavy.js +3 -3
  156. package/src/commands/project/init.js +8 -68
  157. package/src/commands/project/install-plugin-cmd.js +0 -1
  158. package/src/commands/project/setup-infra-cmd.js +2 -1
  159. package/src/commands/project/stack-scan.js +58 -0
  160. package/src/commands/project/standards.js +65 -72
  161. package/src/commands/project/status.js +28 -121
  162. package/src/commands/project/update.js +31 -223
  163. package/src/commands/project/worktree.js +109 -883
  164. package/src/commands/scaffold/scaffold.js +0 -25
  165. package/src/commands/state/advance.js +65 -120
  166. package/src/commands/state/approve.js +87 -229
  167. package/src/commands/state/archive.js +23 -139
  168. package/src/commands/state/auto-approval.js +65 -0
  169. package/src/commands/state/create.js +49 -109
  170. package/src/commands/state/delete.js +11 -63
  171. package/src/commands/state/finish.js +105 -494
  172. package/src/commands/state/gate-check.js +74 -183
  173. package/src/commands/state/gate-decision.js +8 -42
  174. package/src/commands/state/handoff.js +4 -31
  175. package/src/commands/state/list.js +21 -91
  176. package/src/commands/state/report-check.js +134 -338
  177. package/src/commands/state/retype.js +12 -28
  178. package/src/commands/validation/index.js +0 -3
  179. package/src/commands/validation/mutate.js +79 -19
  180. package/src/commands/validation/plan-check.js +81 -0
  181. package/src/commands/validation/validate-feature.js +52 -216
  182. package/src/commands/validation/verify.js +45 -160
  183. package/src/core/paths/output-schema.js +9 -155
  184. package/src/core/state/feature-identity.js +1 -25
  185. package/src/core/state/gate-order.js +2 -30
  186. package/src/core/state/lifecycle-audit.js +8 -100
  187. package/src/core/state/state-manager.js +32 -395
  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 +21 -0
  191. package/src/core/work-types.js +10 -45
  192. package/src/dashboard/app/App.jsx +1 -8
  193. package/src/dashboard/app/components/ActivityFeed.jsx +2 -8
  194. package/src/dashboard/app/components/CommandPalette.jsx +4 -11
  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 +3 -9
  207. package/src/dashboard/app/lib/project.js +0 -12
  208. package/src/dashboard/app/pages/Activity.jsx +1 -1
  209. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  210. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -133
  211. package/src/dashboard/app/pages/Features.jsx +9 -16
  212. package/src/dashboard/app/pages/Guide.jsx +2 -3
  213. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  214. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -7
  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 +14 -83
  220. package/src/dashboard/scanner/agents-scanner.js +1 -23
  221. package/src/dashboard/scanner/docs-scanner.js +1 -7
  222. package/src/dashboard/scanner/feature-status.js +2 -5
  223. package/src/dashboard/scanner/features-scanner.js +92 -113
  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 +4 -7
  231. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  232. package/src/lib/ai/ai-checks.js +7 -52
  233. package/src/lib/ai/ai-project-scan.js +14 -108
  234. package/src/lib/ai/code-audit.js +7 -63
  235. package/src/lib/ai/pin-drift.js +8 -51
  236. package/src/lib/ai/registry-audit.js +11 -90
  237. package/src/lib/ci/ci-verify.js +14 -13
  238. package/src/lib/ci/github-ci.js +9 -37
  239. package/src/lib/ci/workflow-scaffold.js +2 -10
  240. package/src/lib/detectors/claude-config-detector.js +13 -74
  241. package/src/lib/detectors/index.js +8 -59
  242. package/src/lib/e2e/compose-env.js +5 -66
  243. package/src/lib/e2e/stack-teardown.js +6 -40
  244. package/src/lib/generators/architecture-scanner.js +18 -156
  245. package/src/lib/generators/handoff-generator.js +37 -112
  246. package/src/lib/generators/scaffold-generator.js +5 -101
  247. package/src/lib/generators/settings-generator.js +11 -122
  248. package/src/lib/git/branch-classifier.js +11 -84
  249. package/src/lib/git/default-branch.js +14 -169
  250. package/src/lib/git/evidence-staleness.js +2 -1
  251. package/src/lib/git/gh.js +2 -17
  252. package/src/lib/git/pending-prs.js +6 -37
  253. package/src/lib/git/pr-files.js +3 -28
  254. package/src/lib/git/pr-index.js +3 -34
  255. package/src/lib/git/tree-fingerprint.js +17 -17
  256. package/src/lib/git/tree-state.js +6 -33
  257. package/src/lib/graph/god-nodes.js +2 -22
  258. package/src/lib/graph/graph-health.js +3 -24
  259. package/src/lib/graph/graph-loader.js +5 -56
  260. package/src/lib/graph/graphify-runtime.js +7 -37
  261. package/src/lib/graph/graphifyignore.js +8 -74
  262. package/src/lib/graph/sanitize.js +16 -136
  263. package/src/lib/heavy/cleanup.js +2 -6
  264. package/src/lib/installers/effects.js +8 -93
  265. package/src/lib/installers/mcp-installer.js +9 -169
  266. package/src/lib/installers/memory-installer.js +8 -33
  267. package/src/lib/installers/templates-reconcile.js +5 -60
  268. package/src/lib/mutation/commands.js +3 -2
  269. package/src/lib/mutation/dotnet-introspection.js +281 -0
  270. package/src/lib/mutation/plan.js +14 -6
  271. package/src/lib/mutation/runner.js +1 -1
  272. package/src/lib/mutation/subset.js +47 -13
  273. package/src/lib/plan/artifact-vocabulary.js +40 -0
  274. package/src/lib/plan/checks.js +208 -0
  275. package/src/lib/plan/plan-parser.js +204 -0
  276. package/src/lib/plan/plan-template.js +18 -0
  277. package/src/lib/projects/registry.js +7 -44
  278. package/src/lib/review-reports.js +204 -0
  279. package/src/lib/risk-detector.js +131 -274
  280. package/src/lib/specs/adr-index.js +38 -85
  281. package/src/lib/specs/design-seed.js +18 -0
  282. package/src/lib/specs/frontmatter.js +31 -0
  283. package/src/lib/specs/gfm-table.js +11 -0
  284. package/src/lib/specs/revocations.js +31 -102
  285. package/src/lib/specs/screen-checklist.js +56 -132
  286. package/src/lib/stack/dotnet-build-ignores.js +43 -0
  287. package/src/lib/stack/stack-profile.js +1 -28
  288. package/src/lib/stack/stack-scan.js +260 -0
  289. package/src/lib/stack-filter.js +4 -27
  290. package/src/lib/standards/registry.js +37 -0
  291. package/src/lib/standards/sections.js +47 -0
  292. package/src/lib/standards/verified-against.js +19 -191
  293. package/src/lib/tasks/eval-node.js +23 -179
  294. package/src/lib/tasks/linux-runner.js +18 -142
  295. package/src/lib/tasks/test-nodes.js +43 -313
  296. package/src/lib/tasks/test-runner.js +107 -447
  297. package/src/lib/telemetry/metrics.js +13 -103
  298. package/src/lib/telemetry/telemetry-reader.js +13 -238
  299. package/src/lib/telemetry/verify-event.js +4 -34
  300. package/src/lib/validators/code-budget/code-budget-validator.js +4 -44
  301. package/src/lib/validators/code-budget/index.js +0 -7
  302. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -26
  303. package/src/lib/validators/contracts/index.js +0 -6
  304. package/src/lib/validators/nextjs/index.js +0 -5
  305. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  306. package/src/lib/validators/packages/index.js +0 -6
  307. package/src/lib/validators/packages/package-validator.js +6 -56
  308. package/src/lib/validators/reuse/index.js +0 -8
  309. package/src/lib/validators/reuse/reuse-validator.js +24 -137
  310. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  311. package/src/lib/validators/traceability-check.js +54 -156
  312. package/src/lib/validators/validation-runner.js +20 -125
  313. package/src/lib/validators/verify-runner.js +152 -575
  314. package/src/lib/work-type-detector.js +5 -67
  315. package/src/lib/worktree/docker-audit.js +17 -137
  316. package/src/lib/worktree/env-allocator.js +10 -98
  317. package/src/lib/worktree/feature-truth.js +12 -115
  318. package/src/lib/worktree/finish-backup.js +32 -242
  319. package/src/lib/worktree/fleet-scanner.js +31 -126
  320. package/src/lib/worktree/ignored-triage.js +15 -91
  321. package/src/lib/worktree/vscode-settings.js +2 -3
  322. package/src/lib/worktree/worktree-audit.js +21 -213
  323. package/src/lib/worktree/worktree-include.js +3 -5
  324. package/src/scripts/global-install.js +3 -9
  325. package/src/scripts/install-plugin.js +1 -13
  326. package/src/scripts/setup-infra.js +17 -138
  327. package/src/utils/agents-installer.js +92 -272
  328. package/src/utils/banner.js +4 -25
  329. package/src/utils/claude-md-injector.js +1 -37
  330. package/src/utils/claude-settings-manager.js +1 -87
  331. package/src/utils/file-copier.js +32 -90
  332. package/src/utils/hooks-installer.js +26 -296
  333. package/src/utils/skills-installer.js +4 -102
  334. package/src/utils/version-checker.js +4 -83
  335. package/framework/commands/morph-proposal.md +0 -57
  336. package/framework/evals/trace-schema.md +0 -67
  337. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  338. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  339. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  340. package/framework/hooks/shared/tasks-validator.js +0 -111
  341. package/framework/rules/morph-task-tracking.md +0 -54
  342. package/framework/rules/morph-workflow.md +0 -244
  343. package/framework/schemas/tasks.schema.json +0 -79
  344. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  345. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  346. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  347. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  348. package/framework/templates/docs/proposal.md +0 -192
  349. package/framework/templates/docs/spec.md +0 -57
  350. package/framework/templates/docs/ui-components.md +0 -52
  351. package/framework/templates/docs/ui-design-system.md +0 -76
  352. package/framework/templates/docs/ui-flows.md +0 -167
  353. package/framework/templates/examples/spec-examples.md +0 -91
  354. package/framework/templates/feature/decisions.md +0 -124
  355. package/framework/templates/feature/recap.md +0 -145
  356. package/framework/templates/tasks.template.json +0 -17
  357. package/src/commands/state/usecase.js +0 -143
  358. package/src/commands/tasks/dag.js +0 -181
  359. package/src/commands/utils/index.js +0 -3
  360. package/src/lib/agents/output-collector.js +0 -76
  361. package/src/lib/detectors/design-system-detector.js +0 -188
  362. package/src/lib/evaluator-report.js +0 -104
  363. package/src/lib/generators/design-system-generator.js +0 -508
  364. package/src/lib/specs/acervo-format.js +0 -70
  365. package/src/lib/specs/acervo-lifecycle.js +0 -264
  366. package/src/lib/specs/done-criteria-check.js +0 -200
  367. package/src/lib/specs/usecase-check.js +0 -301
  368. package/src/lib/specs/usecase-promoter.js +0 -301
  369. package/src/lib/specs/usecase-registry.js +0 -237
  370. package/src/lib/tasks/dispatch-planner.js +0 -319
  371. package/src/lib/tasks/dotnet-test-introspection.js +0 -632
  372. package/src/lib/tasks/mermaid-renderer.js +0 -132
  373. package/src/lib/tasks/task-classifier.js +0 -30
  374. package/src/lib/tasks/task-parser.js +0 -209
  375. package/src/lib/tasks/tasks-json-parser.js +0 -296
  376. package/src/lib/tasks/test-scope.js +0 -597
  377. package/src/utils/agent-id-mapper.js +0 -11
@@ -1,41 +1,16 @@
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 per-task 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
- * - applyTaskScope() from src/lib/tasks/test-scope.js (the task-scoped cut, #87)
19
- * - runValidation() from ./validation-runner.js
20
- * - runE2eCheck() from framework/hooks/shared/e2e-check.js
21
- *
22
- * @module verify-runner
23
- */
24
-
25
1
  import { execFileSync } from 'child_process';
26
2
  import { existsSync, readFileSync, rmSync } from 'fs';
27
3
  import { join, resolve } from 'path';
28
4
 
29
- import { runBuildCheck, getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
30
- import { runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
5
+ import { runBuildCheck, getBuildSteps, isDotnetStack } from '../../../framework/hooks/shared/build-check.js';
6
+ import { composeProjectName, runE2eCheck } from '../../../framework/hooks/shared/e2e-check.js';
31
7
  import { buildComposeEnv } from '../e2e/compose-env.js';
32
8
  import {
33
- runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, isZeroTestRun, mtpSummaryTotals,
9
+ runTestSuite, parseTestCount, compareTestCount, resolveTestTimeout, discoverDotnetSolution,
34
10
  } from '../tasks/test-runner.js';
35
11
  import {
36
12
  resolveTestNodes, parseTestFilter, nodeMatchesFilter, recordTestBaselines, morphConfigPath,
37
13
  } from '../tasks/test-nodes.js';
38
- import { applyTaskScope } from '../tasks/test-scope.js';
39
14
  import { runLinuxSuite, checkLinuxPreconditions } from '../tasks/linux-runner.js';
40
15
  import {
41
16
  EVAL_RUNNER, evalPreflight, evalNodeEnv, readEvalSummary, evalScenarioCount,
@@ -44,35 +19,16 @@ import {
44
19
  } from '../tasks/eval-node.js';
45
20
  import { runValidation } from './validation-runner.js';
46
21
  import { checkOutputsTraceability } from './traceability-check.js';
47
- import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
48
22
  import { createHeavyGate } from '../heavy/gate.js';
49
23
 
50
- /** Default build timeout — dotnet cold build is slow; 30s (the Stop-hook default) is too short. */
24
+ // A dotnet cold build is slow; the Stop-hook default of 30s is too short.
51
25
  const DEFAULT_BUILD_TIMEOUT = 120_000;
52
26
 
53
- // Validator issues surfaced in `feedback`, errors first; the full list stays on the node.
54
27
  const VALIDATOR_FEEDBACK_LIMIT = 10;
55
28
 
56
- /** Number of trailing output lines kept for feedback/reporting. */
57
29
  const OUTPUT_TAIL_LINES = 20;
58
30
 
59
- /** A narrowed run that proved ZERO tests ran (#87) — wording pinned by the plan. */
60
- const NARROWED_ZERO_TESTS = 'narrowed-zero-tests: o recorte executou 0 testes';
61
- const NARROWED_ZERO_TESTS_LINK = 'narrowed-zero-tests: um link do recorte executou 0 testes (bloco MTP com total: 0)';
62
-
63
- /** A narrowed run that exited green with no count anyone could read (#87): null is UNKNOWN, and unknown is never green. */
64
- const NARROWED_COUNT_UNKNOWN = 'narrowed-count-unknown: o recorte saiu verde sem contagem legível — '
65
- + 'nada prova que algum teste rodou (desconhecido nunca vira verde)';
66
-
67
- /**
68
- * The caller asked for something impossible — not a red verification, a wrong
69
- * invocation. The distinction is the exit code: `verify` answers 1 for "the
70
- * code is not ready" and 2 for "this command cannot mean what you typed".
71
- *
72
- * It exists because `--filter` can select nothing. Running every suite (or,
73
- * far worse, none of them) and reporting green would be F30 restated as a
74
- * one-word typo — so the runner refuses instead of guessing.
75
- */
31
+ /** A wrong invocation, not a red verification: `verify` exits 2 for it, 1 for "code not ready". */
76
32
  export class VerifyUsageError extends Error {
77
33
  constructor(message) {
78
34
  super(message);
@@ -82,44 +38,19 @@ export class VerifyUsageError extends Error {
82
38
  }
83
39
 
84
40
  /**
85
- * Run the deterministic verify cadence for a feature (or a single task within it).
86
- *
87
- * @param {string} projectPath - Project root directory
88
- * @param {string} featureName - Feature name
89
- * @param {string|null} [taskId] - Task ID for task-scoped verify, or null/undefined for feature scope
90
- * @param {Object} [options]
91
- * @param {number} [options.timeout] - Timeout in ms for the build AND tests nodes
92
- * (default: 120000 for build; for tests, see resolveTestTimeout())
93
- * @param {boolean} [options.skipTests] - Skip the tests node
94
- * @param {boolean} [options.skipBuild] - Skip the build node
95
- * @param {boolean} [options.skipE2e] - Skip the e2e node
96
- * @param {boolean} [options.freshE2e] - Force a stack rebuild instead of reusing a warm one
97
- * @param {boolean} [options.recordBaseline] - Measure and write `expectedCount` back into
98
- * `project.tests[]` — only from a run whose every test node is green (D8)
99
- * @param {boolean} [options.linux] - Run the test nodes inside a Linux container, on a COPY
100
- * of the tree (see linux-runner.js for why a bind mount would prove nothing)
101
- * @param {string} [options.linuxImage] - Image override for --linux
102
- * @param {boolean} [options.full] - Task scope only: switch the recorte off and run every
103
- * node whole (#87). A documented no-op at feature scope, which never narrows anything.
104
- * @param {Function} [options._runBuildCheck] - Injectable override for runBuildCheck (testing)
105
- * @param {Function} [options._runTestSuite] - Injectable override for runTestSuite (testing)
106
- * @param {Function} [options._runValidation] - Injectable override for runValidation (testing)
107
- * @param {Function} [options._runE2eCheck] - Injectable override for runE2eCheck (testing)
108
- * @param {Function} [options._checkOutputsTraceability] - Injectable override for checkOutputsTraceability (testing)
109
- * @param {Object} [options._heavyGate] - Injectable heavy-slot gate (testing); default is one per run
110
- * @returns {Promise<Object>} Aggregated verify result — see module docs for shape
41
+ * @throws {VerifyUsageError} on an invocation that cannot mean what was typed (exit 2)
111
42
  * @throws {HeavyBusyError} when no heavy slot (or RAM) freed within `heavy.waitTimeoutSec`
112
43
  */
113
- export async function runVerify(projectPath, featureName, taskId, options = {}) {
44
+ export async function runVerify(projectPath, featureName, options = {}) {
114
45
  const gate = options._heavyGate ?? createHeavyGate({ projectPath, feature: featureName });
115
46
  try {
116
- return await runVerifyGated(projectPath, featureName, taskId, options, gate);
47
+ return await runVerifyGated(projectPath, featureName, options, gate);
117
48
  } finally {
118
49
  gate.release();
119
50
  }
120
51
  }
121
52
 
122
- async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
53
+ async function runVerifyGated(projectPath, featureName, options, gate) {
123
54
  const {
124
55
  timeout = DEFAULT_BUILD_TIMEOUT,
125
56
  skipTests = false,
@@ -130,7 +61,6 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
130
61
  refreshCache = false,
131
62
  linux = false,
132
63
  linuxImage = null,
133
- full = false,
134
64
  _runLinuxSuite = runLinuxSuite,
135
65
  _exec = undefined,
136
66
  _runBuildCheck = runBuildCheck,
@@ -143,10 +73,8 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
143
73
  const stack = readStack(projectPath);
144
74
  const paths = readProjectPaths(projectPath);
145
75
 
146
- // The suites are resolved ONCE, before anything is executed, because
147
- // `--filter` has to be judged before the first second of build time is
148
- // spent: a filter that matches nothing is a typo, and paying for a build to
149
- // discover a typo is the worst of both outcomes.
76
+ // Usage errors are judged before the build: paying for a build to discover a typo
77
+ // is the worst ordering, and a refusal after the cache was touched leaves a half-rewritten tree.
150
78
  const testPlan = resolveTestNodes(projectPath);
151
79
  const filter = parseTestFilter(options.filter);
152
80
  if (filter && !testPlan.nodes.some((node) => nodeMatchesFilter(node, filter))) {
@@ -156,44 +84,23 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
156
84
  );
157
85
  }
158
86
 
159
- // The task is read ONCE: its group picks the e2e applicability and, with its
160
- // outputs, the test scope. One reader of tasks.json for verify, so the two
161
- // can never disagree about the task.
162
- const task = taskId ? readTask(projectPath, featureName, taskId) : null;
163
-
164
- // `--record-baseline` is judged in the same breath, and for the same reason:
165
- // discovering that there is nowhere to write AFTER running the whole fleet
166
- // would charge the user a full suite for a usage error. `config.json` is
167
- // never created here — this repository deliberately has none, and inventing
168
- // one would break `doctor --full`.
169
87
  if (recordBaseline) {
170
- const problem = baselineTargetProblem(projectPath, testPlan, { skipTests, taskId });
88
+ const problem = baselineTargetProblem(projectPath, testPlan, { skipTests });
171
89
  if (problem) throw new VerifyUsageError(problem);
172
90
  }
173
91
 
174
- // `--refresh-cache` is judged in the same breath as `--filter` and
175
- // `--record-baseline`, and for the same reason — but here the stake is higher
176
- // than a wasted clock: a refusal AFTER the cache was touched would leave a
177
- // half-rewritten tree behind. Same discipline as `finish --merge`, which
178
- // validates the root BEFORE archiving.
179
92
  if (refreshCache) {
180
93
  const problem = refreshCacheProblem(projectPath, testPlan, filter, { skipTests });
181
94
  if (problem) throw new VerifyUsageError(problem);
182
95
  }
183
96
 
184
- // `--linux` fails FAST and LOUD when Docker is missing (exit 2). Never a
185
- // skip: "we could not check on Linux" reading as green is F35 itself.
97
+ // Missing Docker is a usage error, never a skip: "could not check on Linux" must not read as green.
186
98
  if (linux) {
187
99
  const problem = checkLinuxPreconditions({
188
100
  projectPath, skipTests, ...(_exec ? { exec: _exec } : {}),
189
101
  });
190
102
  if (problem) throw new VerifyUsageError(problem);
191
103
 
192
- // The MORPH_EVAL_* channel carries ABSOLUTE host paths, and the container
193
- // runs a COPY of the tree at /work — so those paths do not exist inside it.
194
- // Refusing by name is the only honest answer: running the node anyway would
195
- // fail with an unrelated message, and skipping it would be the green-over-
196
- // nothing shape this feature exists to remove. The escape is named.
197
104
  const evalNode = testPlan.nodes.find(
198
105
  (node) => node.runner === EVAL_RUNNER && nodeMatchesFilter(node, filter)
199
106
  );
@@ -206,85 +113,63 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
206
113
  }
207
114
  }
208
115
 
209
- // #87: at task scope the task's group picks the nodes and its outputs narrow
210
- // the command inside each; test-scope.js DECIDES, this runner executes. The
211
- // scoping happens ONCE, here, before runTestsNode, so the host loop and the
212
- // `--linux` container receive the SAME scoped nodes (the container script is
213
- // written from `node.command`) and preDecideRun/judgeRun stay the single copy
214
- // of each rule for both. `--filter` was judged above on the ORIGINAL ids: a
215
- // typo is a typo whatever the task. Feature scope gets the nodes back
216
- // unchanged, because Gate 3 always runs every suite, whole.
217
- const scoped = applyTaskScope({ projectPath, nodes: testPlan.nodes, task, taskId, filter, full });
218
-
219
116
  const nodes = {};
220
117
  const feedback = [];
221
118
 
222
- // 1. build
223
119
  nodes.build = await runBuildNode({ projectPath, stack, paths, timeout, skipBuild, gate, _runBuildCheck });
224
120
  appendBuildFeedback(feedback, nodes.build);
225
121
 
226
- // 2. tests — skipped entirely (no invocation) when build already failed.
227
- // The tests node gets the SAME --timeout the user typed, but only when they
228
- // typed one: `timeout` above is defaulted for the build, and passing that
229
- // default down would silently outrank config.project.testTimeoutMs and the
230
- // per-project scaling (see resolveTestTimeout()).
122
+ // Only a TYPED --timeout reaches the tests: the build default would outrank
123
+ // config.project.testTimeoutMs and the per-project scaling.
231
124
  const explicitTimeout = Number.isFinite(options.timeout) ? options.timeout : undefined;
125
+ const stackNodes = [];
232
126
  nodes.tests = await runTestsNode({
233
127
  projectPath,
234
- testPlan: { ...testPlan, nodes: scoped.nodes },
128
+ testPlan,
235
129
  filter,
236
130
  skipTests,
237
131
  buildNode: nodes.build,
238
132
  timeout: explicitTimeout,
239
- taskId,
240
133
  refreshCache,
241
134
  linux,
242
135
  linuxImage,
243
136
  gate,
137
+ stackNodes,
244
138
  _runTestSuite,
245
139
  _runLinuxSuite,
246
140
  _exec,
247
141
  });
248
- // How the nodes were chosen, for whoever reads --json. It lives on the rollup
249
- // and nowhere else: nodeStatuses() (verify.js) persists status strings only,
250
- // so no part of the selection ever reaches feature.json.
251
- nodes.tests.scope = scoped.summary;
252
- appendTestsFeedback(feedback, nodes.tests);
142
+ if (stackNodes.length === 0) appendTestsFeedback(feedback, nodes.tests);
253
143
 
254
- // 3. validators — ALWAYS runs, even on a red build (free static feedback).
255
144
  nodes.validators = await runValidatorsNode({ projectPath, featureName, _runValidation });
256
145
  appendValidatorsFeedback(feedback, nodes.validators);
257
146
 
258
- // 4. e2e — the only node that runs the application. Like tests, it is not
259
- // invoked at all on a red build: a stack that cannot compile cannot come up,
260
- // and the docker output would bury the actual compile error.
261
- nodes.e2e = await runE2eNode({
147
+ const e2e = await runE2eNode({
262
148
  projectPath,
263
149
  featureName,
264
- taskId,
265
- task,
266
150
  skipE2e,
267
151
  freshE2e,
268
152
  buildNode: nodes.build,
269
153
  gate,
270
154
  _runE2eCheck,
271
155
  });
156
+ nodes.e2e = e2e.node;
272
157
  appendE2eFeedback(feedback, nodes.e2e);
273
158
 
274
- // 5. traceability — advisory-only, feature-scope only (see traceability-check.js
275
- // for the recap-vs-outputs disagreement it flags, and what it deliberately
276
- // cannot catch). Lives OUTSIDE `nodes` on purpose: `status` below is computed
277
- // from `nodes` alone, so this can never flip build/tests/validators/e2e green
278
- // to red — recap/outputs disagreement is a rastreabilidade signal, not a
279
- // correctness gate.
280
- const traceability = taskId
281
- ? { status: 'skip', warnings: [], reason: 'task-scoped verify does not run traceability', citedCount: 0 }
282
- : _checkOutputsTraceability(projectPath, featureName);
159
+ if (stackNodes.length > 0) {
160
+ nodes.tests = await runStackTestNodes({
161
+ projectPath, testsNode: nodes.tests, stackNodes, e2eNode: nodes.e2e, stackEnv: e2e.stackEnv,
162
+ timeout: explicitTimeout, gate, _runTestSuite,
163
+ });
164
+ appendTestsFeedback(feedback, nodes.tests);
165
+ }
166
+
167
+ // Outside `nodes` on purpose: status comes only from the nodes, so a traceability
168
+ // warning never turns red.
169
+ const traceability = _checkOutputsTraceability(projectPath, featureName);
283
170
 
284
171
  const status = Object.values(nodes).some((n) => n.status === 'fail') ? 'fail' : 'pass';
285
172
 
286
- // Recording happens LAST and only from what actually ran — the number is a
287
- // measurement of this invocation, never a value carried in from anywhere.
288
173
  const baseline = recordBaseline ? recordBaselineFrom(projectPath, nodes.tests) : null;
289
174
  if (baseline && !baseline.recorded) {
290
175
  feedback.push(`[baseline] não gravada: ${baseline.reason}`);
@@ -293,8 +178,6 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
293
178
  return {
294
179
  version: 1,
295
180
  feature: featureName,
296
- task: taskId || null,
297
- scope: taskId ? 'task' : 'feature',
298
181
  stack,
299
182
  status,
300
183
  nodes,
@@ -304,16 +187,29 @@ async function runVerifyGated(projectPath, featureName, taskId, options, gate) {
304
187
  };
305
188
  }
306
189
 
307
- // ============================================================================
308
- // Build node
309
- // ============================================================================
190
+ /**
191
+ * A root `dotnet build` with no solution at the root fails MSB1003: build the
192
+ * repository's single solution instead, or say which candidates and which key decide.
193
+ */
194
+ export function resolveDotnetBuild(projectPath, stack, paths = {}) {
195
+ if (!stack || !isDotnetStack(stack.toLowerCase()) || paths.backendPath) return { paths, hint: null };
196
+ const { target, candidates } = discoverDotnetSolution(projectPath);
197
+ if (target === '.') return { paths, hint: null };
198
+ if (target) return { paths: { ...paths, backendTarget: target }, hint: null };
199
+ const found = candidates.length ? `candidatos: ${candidates.join(', ')}` : 'nenhum encontrado abaixo dela';
200
+ return {
201
+ paths,
202
+ hint: `nenhum .sln/.slnx/.csproj na raiz (${found}): declare project.backendPath em .morph/config/config.json com a pasta da solução, relativa à raiz`,
203
+ };
204
+ }
310
205
 
311
- async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild, gate, _runBuildCheck }) {
206
+ async function runBuildNode({ projectPath, stack, paths: declaredPaths = {}, timeout, skipBuild, gate, _runBuildCheck }) {
312
207
  if (skipBuild) {
313
208
  return { status: 'skip', command: null, durationMs: 0, reason: 'requested', output: '' };
314
209
  }
315
210
 
316
- const steps = getBuildSteps(stack, { cwd: projectPath, frontendPath: paths.frontendPath, backendPath: paths.backendPath });
211
+ const { paths, hint } = resolveDotnetBuild(projectPath, stack, declaredPaths);
212
+ const steps = getBuildSteps(stack, { cwd: projectPath, frontendPath: paths.frontendPath, backendPath: paths.backendPath, backendTarget: paths.backendTarget });
317
213
  if (steps.length === 0) {
318
214
  const reason = stack ? `no build command for stack "${stack}"` : 'no stack configured';
319
215
  return { status: 'skip', command: null, durationMs: 0, reason, output: '' };
@@ -330,7 +226,7 @@ async function runBuildNode({ projectPath, stack, paths = {}, timeout, skipBuild
330
226
  command,
331
227
  durationMs,
332
228
  reason: result.status === 'skip' ? 'no build command for stack' : null,
333
- output: result.output || '',
229
+ output: [result.output, result.status === 'fail' ? hint : null].filter(Boolean).join('\n'),
334
230
  };
335
231
  }
336
232
 
@@ -341,34 +237,13 @@ function appendBuildFeedback(feedback, buildNode) {
341
237
  }
342
238
  }
343
239
 
344
- // ============================================================================
345
- // Tests node
346
- // ============================================================================
347
-
348
- /**
349
- * Run EVERY test suite the project declares, and roll the results up.
350
- *
351
- * Why a rollup instead of one node: defect F30. The resolver answered
352
- * "dotnet test", the 3.082 vitest tests of the same repository never ran, and
353
- * the gate reported green — a suite nobody could name is a suite that cannot
354
- * fail. `resolveTestNodes()` now returns the LIST, each entry runs with its own
355
- * command, cwd and clock, and every one of them appears in `runs[]`.
356
- *
357
- * `nodes.tests` deliberately stays an OBJECT with `status` (D6): verify.js and
358
- * the persisted stamp read `nodes.tests.status`, and turning a verification
359
- * defect into a shape defect is not progress. The plural detail lives in
360
- * `runs[]` beside it.
361
- *
362
- * @returns {Object} The rollup: `status`, joined `command`, summed `durationMs`,
363
- * `source`, `environment` (null outside a container) and `runs[]`.
364
- */
240
+ // Every declared suite runs and appears in `runs[]`; the rollup stays an object
241
+ // with `status` because verify.js and the persisted stamp read `nodes.tests.status`.
365
242
  async function runTestsNode({
366
- projectPath, testPlan, filter, skipTests, buildNode, timeout, taskId, refreshCache = false,
367
- linux = false, linuxImage = null, gate, _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
243
+ projectPath, testPlan, filter, skipTests, buildNode, timeout, refreshCache = false,
244
+ linux = false, linuxImage = null, gate, stackNodes = [], _runTestSuite, _runLinuxSuite = runLinuxSuite, _exec,
368
245
  }) {
369
246
  const { nodes, source } = testPlan;
370
- // Kept readable for whoever only prints the string — the same join the
371
- // fallback scan already used for a chain of test projects.
372
247
  const command = nodes.map((n) => n.command).filter(Boolean).join(' && ') || null;
373
248
  // A fresh `runs: []` per return — two rollups must never alias one array.
374
249
  const nothingRan = (reason) => ({
@@ -384,49 +259,58 @@ async function runTestsNode({
384
259
 
385
260
  if (linux) {
386
261
  const { runs, environment } = await runNodesInLinux({
387
- projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
262
+ projectPath, nodes, filter, timeout, linuxImage, gate, _runLinuxSuite, _exec,
388
263
  });
389
264
  return rollUpTestRuns(runs, { command, source, environment });
390
265
  }
391
266
 
392
267
  const runs = [];
393
268
  for (const node of nodes) {
269
+ if (node.e2e && !preDecideRun({ node, filter, projectPath, refreshCache })) {
270
+ stackNodes.push(node);
271
+ runs.push(emptyRunEntry(node));
272
+ continue;
273
+ }
394
274
  runs.push(await runOneTestNode({
395
- projectPath, node, filter, taskId, timeout, refreshCache, gate, _runTestSuite,
275
+ projectPath, node, filter, timeout, refreshCache, gate, _runTestSuite,
396
276
  }));
397
277
  }
398
278
 
399
279
  return rollUpTestRuns(runs, { command, source });
400
280
  }
401
281
 
402
- /**
403
- * Run the executable nodes inside ONE Linux container and judge them with the
404
- * SAME rules the local path uses.
405
- *
406
- * The pre-decisions (invalid config, `--filter`, a node outside the task
407
- * scope, an e2e node at task scope)
408
- * happen HERE, before the container exists: a node that must not run must not
409
- * be written into the container script either, or the flag would quietly run
410
- * what the user filtered out. What the container returns — an exit code and an
411
- * output — is then handed to `judgeRun()`, the same function the host path
412
- * calls, so a count drop means a count drop in both environments.
413
- *
414
- * The tree the container executes is a COPY on its own filesystem, never the
415
- * bind mount: see linux-runner.js. That is the whole reason this path exists.
416
- */
282
+ // No stack, no run: the skip carries the e2e verdict as reason AND warning, so the
283
+ // suite never disappears behind a green rollup.
284
+ async function runStackTestNodes({ projectPath, testsNode, stackNodes, e2eNode, stackEnv, timeout, gate, _runTestSuite }) {
285
+ const byId = new Map(stackNodes.map((node) => [node.id, node]));
286
+ const runs = [];
287
+ for (const run of testsNode.runs) {
288
+ const node = byId.get(run.id);
289
+ if (!node) { runs.push(run); continue; }
290
+ if (e2eNode.status !== 'pass') {
291
+ const reason = `a stack do e2e não está de pé (e2e: ${e2eNode.status}${e2eNode.reason ? ` — ${e2eNode.reason}` : ''}): nó e2e: true não roda sem ela`;
292
+ runs.push({ ...run, reason, warnings: [reason] });
293
+ continue;
294
+ }
295
+ runs.push(await runOneTestNode({ projectPath, node, filter: null, timeout, gate, env: stackEnv, _runTestSuite }));
296
+ }
297
+ return rollUpTestRuns(runs, { command: testsNode.command, source: testsNode.source, environment: testsNode.environment });
298
+ }
299
+
300
+ // Pre-decisions run before the container exists, so a filtered node is never written
301
+ // into its script; results go through the same `judgeRun()` as the host path.
417
302
  async function runNodesInLinux({
418
- projectPath, nodes, filter, taskId, timeout, linuxImage, gate, _runLinuxSuite, _exec,
303
+ projectPath, nodes, filter, timeout, linuxImage, gate, _runLinuxSuite, _exec,
419
304
  }) {
420
305
  const decided = new Map();
421
306
  const toRun = [];
422
307
  for (const node of nodes) {
423
- const pre = preDecideRun({ node, filter, taskId, projectPath });
308
+ const pre = preDecideRun({ node, filter, projectPath });
424
309
  if (pre) decided.set(node.id, pre);
425
310
  else toRun.push(node);
426
311
  }
427
312
 
428
- // One container, one clock: the ceiling is the sum of what each node would
429
- // have been given locally, since they share the run.
313
+ // One container, one clock: the sum of each node's local timeout.
430
314
  const containerTimeout = toRun.reduce(
431
315
  (sum, node) => sum + resolveTestTimeout(projectPath, node, timeout), 0
432
316
  ) || undefined;
@@ -445,16 +329,13 @@ async function runNodesInLinux({
445
329
  if (decided.has(node.id)) return decided.get(node.id);
446
330
  const raw = byId.get(node.id);
447
331
  if (!raw) {
448
- // The container answered about a fleet that does not include this node.
449
- // Silence here would be a suite vanishing from the report.
450
332
  return { ...emptyRunEntry(node), status: 'fail', reason: 'o container não reportou este nó' };
451
333
  }
452
334
  if (raw.skipped) {
453
335
  return { ...emptyRunEntry(node), reason: raw.reason };
454
336
  }
455
337
  const result = raw.exitCode === null
456
- // No exit code is NOT a pass: the container died before this node
457
- // reported, and a green verdict over an unobserved suite is F30's shape.
338
+ // No exit code is NOT a pass: the container died before this node reported.
458
339
  ? { passed: false, output: raw.output || '', reason: raw.reason, exitCode: null }
459
340
  : { passed: raw.exitCode === 0, output: raw.output || '', exitCode: raw.exitCode };
460
341
  return judgeRun({ node, result, durationMs: raw.durationMs ?? 0 });
@@ -463,36 +344,24 @@ async function runNodesInLinux({
463
344
  return { runs, environment: suite.environment };
464
345
  }
465
346
 
466
- /**
467
- * Execute (or decline to execute) one node, always producing a `runs[]` entry.
468
- *
469
- * Nothing is ever dropped: a node that must not run says so with a reason, in
470
- * the place the reader is already looking. A suite that silently disappears
471
- * from the report is the defect this whole feature exists to close.
472
- */
473
347
  async function runOneTestNode({
474
- projectPath, node, filter, taskId, timeout, refreshCache = false, gate, _runTestSuite,
348
+ projectPath, node, filter, timeout, refreshCache = false, gate, env = null, _runTestSuite,
475
349
  }) {
476
- const decided = preDecideRun({ node, filter, taskId, projectPath, refreshCache });
350
+ const decided = preDecideRun({ node, filter, projectPath, refreshCache });
477
351
  if (decided) return decided;
478
352
 
479
- // The eval channel: the CLI DICTATES where the cache, the dataset and the
480
- // summary live, so the same xunit suite is runnable by `verify` without a
481
- // second configuration path. `env` is undefined for every other runner, which
482
- // keeps `runTestSuite` on exactly the path it had before.
483
353
  const evalChannel = node.runner === EVAL_RUNNER
484
354
  ? evalNodeEnv(node, projectPath, { refresh: refreshCache })
485
355
  : null;
486
- // The summary path is fixed per node id and pid, and nothing else removes the
487
- // file: one left there by an earlier run (an earlier verify in the same
488
- // process, a recycled pid) was read as THIS run's, handing a harness that
489
- // wrote nothing the old scenario count — and hiding an empty run behind it.
356
+ // The summary path is fixed per node id and pid: a file left by an earlier run
357
+ // would be read as this run's and hide an empty run behind the old count.
490
358
  if (evalChannel) discardFile(evalChannel.summaryPath);
491
359
 
492
360
  gate.enter('tests');
493
361
  const start = Date.now();
362
+ const childEnv = env || evalChannel ? { ...(env || {}), ...(evalChannel?.env || {}) } : null;
494
363
  const result = await _runTestSuite(projectPath, {
495
- timeout, node, ...(evalChannel ? { env: evalChannel.env } : {}),
364
+ timeout, node, ...(childEnv ? { env: childEnv } : {}),
496
365
  });
497
366
  const entry = judgeRun({ node, result, durationMs: Date.now() - start });
498
367
  if (!evalChannel) return entry;
@@ -502,26 +371,12 @@ async function runOneTestNode({
502
371
  return judged;
503
372
  }
504
373
 
505
- /** Remove a scratch file the CLI owns; a file that is not there (or will not go) changes nothing. */
506
374
  function discardFile(path) {
507
- try { rmSync(path, { force: true }); } catch { /* best effort — the next run removes it first */ }
375
+ try { rmSync(path, { force: true }); } catch { /* the next run removes it first */ }
508
376
  }
509
377
 
510
- /**
511
- * Second half of an eval verdict: what the SUMMARY says, on top of what the exit
512
- * code said.
513
- *
514
- * Two things the exit code cannot see:
515
- * - the scenario COUNT, which is what `expectedCount` is compared against and
516
- * what `--record-baseline` writes. Read ONLY from the summary: the `dotnet`
517
- * extractor counts xunit cases, another unit — compared with (or recorded as)
518
- * a scenario baseline it failed green runs as drops (#110, #117 review). No
519
- * readable summary → **null, UNKNOWN, never 0** — an unreadable summary must
520
- * not manufacture a count drop;
521
- * - a suite that evaluated ZERO scenarios and exited 0. Nothing failed, because
522
- * nothing ran: the guardrail-inert shape this repository has already paid to
523
- * learn to detect.
524
- */
378
+ // The scenario count comes ONLY from the summary (the dotnet extractor counts xunit
379
+ // cases, another unit); no readable summary is null, UNKNOWN, never 0.
525
380
  function judgeEvalRun(entry, node, summaryPath) {
526
381
  const summary = readEvalSummary(summaryPath);
527
382
  const warnings = [...(entry.warnings || [])];
@@ -533,11 +388,8 @@ function judgeEvalRun(entry, node, summaryPath) {
533
388
  const verdict = compareTestCount(count, node.expectedCount);
534
389
  if (verdict.warning) warnings.push(verdict.warning);
535
390
 
536
- // As falhas NOMEADAS entram no buffer de saida do no, e entram na FRENTE.
537
- // `appendTestsFeedback` prefere `output` a `reason`, entao um nome so no
538
- // `reason` nunca chegaria ao `feedback[]`; e a cauda do `dotnet` e ruido
539
- // truncado exatamente onde o sumario ja tem a frase certa. Prepend, e nao
540
- // substituicao, porque um crash do runner tambem vive nessa cauda.
391
+ // Named failures go into `output` (feedback prefers it to `reason`), prepended
392
+ // rather than replacing it because a runner crash also lives in that tail.
541
393
  const named = evalFailureLines(summary);
542
394
  const withNames = named.length > 0
543
395
  ? { ...entry, output: [...named, ...(entry.output ? [entry.output] : [])].join('\n') }
@@ -556,7 +408,6 @@ function judgeEvalRun(entry, node, summaryPath) {
556
408
  return { ...withNames, count, warnings };
557
409
  }
558
410
 
559
- /** The `runs[]` entry every path starts from — every field present, nothing implied. */
560
411
  function emptyRunEntry(node) {
561
412
  return {
562
413
  id: node.id,
@@ -564,82 +415,41 @@ function emptyRunEntry(node) {
564
415
  cwd: node.cwd,
565
416
  runner: node.runner,
566
417
  expectedCount: node.expectedCount,
567
- // Always present, so no reader has to tell "absent" from "unknown" by
568
- // optional chaining. **null is UNKNOWN, never 0** (D7).
418
+ // null is UNKNOWN, never 0.
569
419
  count: null,
570
420
  status: 'skip',
571
421
  durationMs: 0,
572
422
  reason: null,
573
423
  warnings: [],
574
424
  output: '',
575
- // What the task-scope recorte did to this node (#87): `command` above is
576
- // what actually ran, `selection.declaredCommand` what was declared. null at
577
- // feature scope, where applyTaskScope leaves the key ABSENT; absent is read
578
- // as null here so no reader has to tell the two apart.
579
- selection: node.selection ?? null,
580
425
  };
581
426
  }
582
427
 
583
- /**
584
- * The verdicts that exist BEFORE anything is executed, or null when the node
585
- * has to run to be judged.
586
- *
587
- * Shared by the local loop and by `--linux`, so a node that must not run is
588
- * refused for the same reason and with the same words in both environments —
589
- * two copies of this ordering is how a filtered node ends up invisible in one
590
- * of them.
591
- */
592
- function preDecideRun({ node, filter, taskId, projectPath = '.', refreshCache = false }) {
428
+ // Verdicts decided before execution, shared by the host loop and `--linux` so both
429
+ // refuse for the same reason with the same words; null means the node must run.
430
+ function preDecideRun({ node, filter, projectPath = '.', refreshCache = false }) {
593
431
  const entry = emptyRunEntry(node);
594
432
 
595
- // The resolver already decided: invalid config (fail-closed, D4) or an
596
- // explicit "no suites declared". Executing it would be asking a question that
597
- // has been answered — and, for an invalid node, answering it wrongly.
598
433
  if (node.status) {
599
434
  return { ...entry, status: node.status, reason: node.reason };
600
435
  }
601
436
 
602
- // Deliberately AFTER the pre-decided verdict above: `--filter` says "do not
603
- // spend time on this suite", not "do not tell me my config is broken". An
604
- // invalid node costs no clock and still reddens the gate (D4).
437
+ // After the pre-decided verdict on purpose: an invalid node costs no clock and
438
+ // still reddens the gate even when filtered out.
605
439
  if (!nodeMatchesFilter(node, filter)) {
606
440
  return { ...entry, reason: `filtered out (--filter ${filter.raw})` };
607
441
  }
608
442
 
609
- // The task's group scoped this node out (#87). AFTER the filter on purpose:
610
- // a node the user filtered out reports the filter, never a scope it was not
611
- // judged by. BEFORE the e2e and eval checks: a node outside the task scope
612
- // has nothing left to decide.
613
- if (node.selection?.mode === 'out-of-scope') {
614
- return { ...entry, reason: node.selection.reason };
615
- }
616
-
617
- if (node.e2e && taskId) {
618
- return { ...entry, reason: 'e2e node runs at feature scope only' };
619
- }
620
-
621
- // The eval pre-flight lives HERE, beside the other pre-decisions, so the host
622
- // loop and the `--linux` path refuse for the same reason with the same words.
623
- // Its three cache verdicts are FAILURES, never skips: a skip over an unusable
624
- // cache is a green gate above a verification that never happened. The only
625
- // skip it produces is scope — an eval suite does not pay for itself inside the
626
- // per-task loop, and the result Gate 3 reads is the feature-scoped one.
443
+ // A cache miss is a fail, never a skip: a skip would be green over nothing.
627
444
  if (node.runner === EVAL_RUNNER) {
628
- const pre = evalPreflight(projectPath, node, { taskId, refresh: refreshCache });
445
+ const pre = evalPreflight(projectPath, node, { refresh: refreshCache });
629
446
  if (pre.status) return { ...entry, status: pre.status, reason: pre.reason };
630
447
  }
631
448
 
632
449
  return null;
633
450
  }
634
451
 
635
- /**
636
- * Turn ONE executor result into a `runs[]` entry — the count rule included.
637
- *
638
- * Called by both the local loop and the `--linux` container path on purpose:
639
- * a drop, a rise and an unknown count must mean the same thing whether the
640
- * suite ran on this host or inside a container. A second copy of this judgement
641
- * is how two environments start disagreeing about the same suite.
642
- */
452
+ // The single count rule for host and container runs; a second copy makes them disagree.
643
453
  function judgeRun({ node, result, durationMs }) {
644
454
  const entry = emptyRunEntry(node);
645
455
 
@@ -647,8 +457,7 @@ function judgeRun({ node, result, durationMs }) {
647
457
  return { ...entry, durationMs, reason: result.reason || 'no test command detected' };
648
458
  }
649
459
 
650
- // A timeout has no verdict to report: the tail of a truncated log reads like
651
- // a passing run cut mid-sentence. Lead with what actually happened.
460
+ // A truncated log tail reads like a passing run cut mid-sentence: lead with the timeout.
652
461
  if (result.timedOut) {
653
462
  const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
654
463
  return {
@@ -661,35 +470,19 @@ function judgeRun({ node, result, durationMs }) {
661
470
  };
662
471
  }
663
472
 
664
- // How many tests actually ran, and how that compares to the recorded
665
- // baseline. Read from the FULL output, before it is truncated to a tail —
666
- // the summary line is the last thing a runner prints, but a red run's tail
667
- // is chosen for the failures, not for the summary.
473
+ // Parsed from the FULL output, before truncation to a tail.
668
474
  const parsed = parseTestCount(result.output, node.runner);
669
475
  const count = parsed ? parsed.total : null;
670
476
 
671
- // A narrowed run is PARTIAL by construction (#87): it has a verdict of its
672
- // own, and the count rule below never sees it.
673
- if (entry.selection?.mode === 'narrowed') {
674
- return judgeNarrowedRun({ entry, node, result, durationMs, count });
675
- }
676
-
677
- // An eval node's unit is the SCENARIO (#110 part 2): its baseline is the
678
- // summary's scenario count, and `judgeEvalRun` is the one that compares it.
679
- // Comparing here too, with the extractor's count of xunit cases, failed a
680
- // 24/24 run as "esperado 24, executado 2" — and `judgeEvalRun` only turns a
681
- // pass into a fail, never the reverse.
477
+ // An eval node's unit is the SCENARIO, compared only by `judgeEvalRun`; the
478
+ // extractor's xunit-case count against that baseline would fail green runs.
682
479
  const verdict = compareTestCount(count, node.runner === EVAL_RUNNER ? null : node.expectedCount);
683
480
  const warnings = verdict.warning ? [verdict.warning] : [];
684
481
 
685
- // A drop is the one count verdict that fails a node by itself: tests that
686
- // used to run no longer do. A RISE never fails (D7) — the suite grows with
687
- // every task, and failing on growth teaches deleting tests.
482
+ // Only a drop fails a node; a rise never does, or failing on growth teaches deleting tests.
688
483
  if (verdict.verdict === 'drop' && result.passed) {
689
484
  return { ...entry, status: 'fail', durationMs, count, warnings, reason: verdict.reason, output: '' };
690
485
  }
691
- // Already red: the failing test is the better lead, so the drop travels
692
- // beside it instead of overwriting the reason.
693
486
  if (verdict.verdict === 'drop') warnings.push(verdict.reason);
694
487
 
695
488
  return {
@@ -698,85 +491,13 @@ function judgeRun({ node, result, durationMs }) {
698
491
  durationMs,
699
492
  count,
700
493
  warnings,
701
- // `test-build-failed` / `stale-binary`: the executor refused BEFORE any
702
- // test ran, and the reason is the only thing worth reading. Without this
703
- // the node would go red with a null reason and the reader would go hunting
704
- // through a compiler log for a suite that never started.
494
+ // `test-build-failed` / `stale-binary`: refused before any test ran.
705
495
  reason: result.reason ?? null,
706
496
  output: result.passed ? '' : tailLines(result.output, OUTPUT_TAIL_LINES),
707
497
  };
708
498
  }
709
499
 
710
- /**
711
- * The verdict of a NARROWED run (#87): what `judgeRun` hands over instead of
712
- * its count rule. Reached from the host loop and from `--linux` alike, because
713
- * both go through `judgeRun`.
714
- *
715
- * Two things change, and both follow from "partial by construction":
716
- *
717
- * - NO `compareTestCount`. `expectedCount` is the WHOLE suite's baseline: a
718
- * recorte running 12 of 3.082 is not a drop, and a superset selection
719
- * running more than the baseline is not a rise. Either comparison would
720
- * report something about the suite that this run never measured.
721
- * - a pass needs PROOF that tests ran. A filter that matches nothing exits 0
722
- * under VSTest, and "zero tests, all green" is exactly the verdict issue #87
723
- * must never buy. What counts as proof is the selection's `zeroGuard`
724
- * (test-scope.js):
725
- * `count` only a parsed count > 0. **null is UNKNOWN, never green**;
726
- * `mtp-exit-8` a parsed count > 0 proves it (the MTP summary is read in
727
- * pt-BR and en-US, #115); with no readable summary (another
728
- * locale), exit 0 does — MTP exits 8 on zero tests.
729
- * A parsed 0 is refused under either guard: it is positive evidence. It is
730
- * also what closes limit (f): with exit 8 IGNORED, MTP exits 0 over a
731
- * summary that says `total: 0` (measured, mtp-ignore-exit-code-8-zero-tests.txt).
732
- * In a narrowed `&&` chain the SUM would hide that link behind another's
733
- * count, so under `mtp-exit-8` every MTP block is checked on its own — MTP
734
- * prints one block per invocation (measured), and a `total: 0` block is red.
735
- *
736
- * A FAILED run is renamed `narrowed-zero-tests (…)` only when it carries the
737
- * runner's zero-test signature AND no count proves tests ran. A multi-project
738
- * VSTest output may print "No test matches…" for one project while another
739
- * ran and failed; that failure is the lead, never a zero. Anything else,
740
- * exit 9 included, keeps the executor's own raw reason: a project that imposes
741
- * `--minimum-expected-tests` is narrowed only with its floor lowered to 1 by
742
- * `-p:` (#114, test-scope.js), where a run with tests cannot exit 9 — so a 9
743
- * here is not ours to reinterpret.
744
- *
745
- * Every red leads its output with the verdict, like a timeout does: the
746
- * feedback line reads `output` before `reason`, and a tail alone would bury it.
747
- */
748
- function judgeNarrowedRun({ entry, node, result, durationMs, count }) {
749
- const tail = tailLines(result.output, OUTPUT_TAIL_LINES);
750
- const base = { ...entry, durationMs, count, warnings: [] };
751
- const red = (reason) => ({ ...base, status: 'fail', reason, output: tail ? `${reason}\n${tail}` : reason });
752
- const green = () => ({ ...base, status: 'pass', reason: result.reason ?? null, output: '' });
753
- const ranTests = Number.isInteger(count) && count > 0;
754
-
755
- if (result.passed) {
756
- if (count === 0) return red(NARROWED_ZERO_TESTS);
757
- if (entry.selection.zeroGuard === 'mtp-exit-8' && mtpSummaryTotals(result.output).includes(0)) {
758
- return red(NARROWED_ZERO_TESTS_LINK);
759
- }
760
- if (ranTests) return green();
761
- if (entry.selection.zeroGuard === 'mtp-exit-8' && result.exitCode === 0) return green();
762
- return red(NARROWED_COUNT_UNKNOWN);
763
- }
764
-
765
- if (!ranTests && isZeroTestRun(result.output, node.runner, result.exitCode)) {
766
- return red(`narrowed-zero-tests (${node.runner}, exit ${result.exitCode ?? 'desconhecido'}): o recorte executou 0 testes`);
767
- }
768
-
769
- return { ...base, status: 'fail', reason: result.reason ?? null, output: tail };
770
- }
771
-
772
- /**
773
- * Aggregate the per-node runs into the single node `verify` publishes.
774
- *
775
- * The rollup is pessimistic on purpose: ANY red run reddens it. A green
776
- * aggregate covering a red suite is F30 restated, so `some(fail)` is checked
777
- * before anything else. `skip` only survives when there is nothing else to
778
- * report — one green run among skips is a pass.
779
- */
500
+ // Any red run reddens the rollup; one green run among skips is a pass.
780
501
  function rollUpTestRuns(runs, { command, source, environment = null }) {
781
502
  const failed = runs.filter((r) => r.status === 'fail');
782
503
  const status = failed.length > 0
@@ -790,13 +511,8 @@ function rollUpTestRuns(runs, { command, source, environment = null }) {
790
511
  reason: null,
791
512
  output: failed.map((r) => r.output).filter(Boolean).join('\n'),
792
513
  source,
793
- // Where these suites ran. `null` is the local host; a container run says so
794
- // by name and image, because a green from a container and a green from the
795
- // host are otherwise indistinguishable in feature.json — and the next
796
- // person would not know what was proven.
514
+ // null is the local host; a container run is named so feature.json says what was proven.
797
515
  environment,
798
- // Named by suite: a warning that does not say WHICH baseline went stale
799
- // sends the reader to read every one of them.
800
516
  warnings: runs.flatMap((r) => (r.warnings || []).map((w) => `${r.id}: ${w}`)),
801
517
  runs,
802
518
  };
@@ -810,17 +526,8 @@ function rollUpTestRuns(runs, { command, source, environment = null }) {
810
526
  return node;
811
527
  }
812
528
 
813
- /**
814
- * One feedback line per RED node, named by node id.
815
- *
816
- * The `[tests/{id}]` prefix replaces the old bare `[tests]`: with N suites, a
817
- * line that does not say which one failed sends the reader to the wrong repo
818
- * half.
819
- */
820
529
  function appendTestsFeedback(feedback, testsNode) {
821
- // Warnings are emitted whatever the verdict — a stale baseline lives on a
822
- // GREEN node by construction (the count rose), so a warning that only
823
- // printed on failure would never print at all.
530
+ // Warnings print whatever the verdict: a stale baseline lives on a GREEN node.
824
531
  for (const run of testsNode.runs || []) {
825
532
  for (const warning of run.warnings || []) {
826
533
  feedback.push(`[tests/${run.id}] warning: ${warning}`);
@@ -835,31 +542,7 @@ function appendTestsFeedback(feedback, testsNode) {
835
542
  }
836
543
  }
837
544
 
838
- /**
839
- * Why `--refresh-cache` cannot mean what it says, or null when it can.
840
- *
841
- * Judged BEFORE the build node, and the ordering is the whole point: a refusal
842
- * that arrived after the cache was touched would leave a half-rewritten tree
843
- * behind. Same discipline `finish --merge` learned — validate the precondition
844
- * before the mutation, never after.
845
- *
846
- * Three refusals, all usage errors (exit 2), never verdicts:
847
- * - no eval node declared: succeeding silently would teach the user to type a
848
- * flag that does nothing;
849
- * - `--skip-tests`: the two flags cancel each other, and the winner would be
850
- * whichever the code happened to check first;
851
- * - a filter that excludes every eval node: same shape as the first.
852
- *
853
- * The MISSING CREDENTIAL is deliberately NOT judged here: it is per node (each
854
- * declares its own `envFile`), so `evalPreflight` answers it — still before the
855
- * command runs, and still without touching the tree.
856
- *
857
- * @param {string} projectPath
858
- * @param {{nodes: Array}} testPlan
859
- * @param {{tokens: string[], raw: string}|null} filter
860
- * @param {{skipTests: boolean}} opts
861
- * @returns {string|null}
862
- */
545
+ // The missing credential is judged per node by `evalPreflight`, not here.
863
546
  function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
864
547
  if (skipTests) {
865
548
  return '--refresh-cache com --skip-tests: as duas flags se anulam — a regravação da cache É a suíte de eval';
@@ -879,41 +562,9 @@ function refreshCacheProblem(projectPath, testPlan, filter, { skipTests }) {
879
562
  return null;
880
563
  }
881
564
 
882
- // ============================================================================
883
- // Baseline recording (--record-baseline)
884
- // ============================================================================
885
-
886
- /**
887
- * Why `--record-baseline` cannot run here, or null when it can.
888
- *
889
- * Judged BEFORE the build node, beside the `--filter` check, because both are
890
- * statements about a command that cannot mean what it says. Paying for a full
891
- * suite to be told there was nowhere to write the result is the worst possible
892
- * ordering.
893
- *
894
- * The refusal for a project with no `project.tests[]` is deliberate rather
895
- * than helpful: the nodes the fallback synthesizes are not declared anywhere,
896
- * so there is no entry to update, and writing one would mean this command
897
- * inventing the declaration it came to maintain. `config.json` is likewise
898
- * never created — the framework repository has none on purpose (creating it
899
- * breaks `doctor --full`), so the honest answer is a named usage error.
900
- *
901
- * A TASK id is refused first (#87): at task scope the runner narrows the nodes
902
- * AND the commands, so what it measures is a recorte, while `expectedCount` is
903
- * the whole fleet's number. `recordTestBaselines` refuses a measurement that
904
- * carries a selection (the second barrier), but paying for the recorte to
905
- * learn that is the ordering this function exists to prevent.
906
- *
907
- * @param {string} projectPath
908
- * @param {{ nodes: Array, source: string }} testPlan
909
- * @param {{ skipTests: boolean, taskId?: string|null }} opts
910
- * @returns {string|null}
911
- */
912
- function baselineTargetProblem(projectPath, testPlan, { skipTests, taskId = null }) {
913
- if (taskId) {
914
- return `--record-baseline é manutenção da frota: rode sem {task} (recebeu "${taskId}") — `
915
- + 'no escopo de task o verify recorta nós e testes, e baseline de recorte não é baseline';
916
- }
565
+ // Never creates config.json nor a `project.tests[]` entry: fallback nodes have no
566
+ // declaration to update, and inventing one is not this command's job.
567
+ function baselineTargetProblem(projectPath, testPlan, { skipTests }) {
917
568
  if (skipTests) {
918
569
  return '--record-baseline com --skip-tests não mede nada — uma baseline vem de uma execução, não de uma omissão';
919
570
  }
@@ -928,20 +579,8 @@ function baselineTargetProblem(projectPath, testPlan, { skipTests, taskId = null
928
579
  return null;
929
580
  }
930
581
 
931
- /**
932
- * Turn a finished tests rollup into a baseline write, or into a refusal.
933
- *
934
- * D8 lives here: the number is only ever what a REAL run measured, and only
935
- * when every node of that run is green. A red run records nothing — writing a
936
- * lower count over a regression is how the regression becomes the new normal.
937
- * A PARTIAL run records nothing either: `--filter` and skips leave nodes
938
- * unmeasured, and a baseline covering half the fleet is a claim about the
939
- * other half that nobody made.
940
- *
941
- * @param {string} projectPath
942
- * @param {Object} testsNode - The rollup produced by runTestsNode()
943
- * @returns {{ recorded: boolean, reason: string|null, updates: Array, path?: string }}
944
- */
582
+ // Only a run with EVERY node green records: a red run would freeze the regression,
583
+ // a partial one would claim counts for nodes nobody measured.
945
584
  function recordBaselineFrom(projectPath, testsNode) {
946
585
  const runs = testsNode.runs || [];
947
586
  const refuse = (reason) => ({ recorded: false, reason, updates: [] });
@@ -957,24 +596,13 @@ function recordBaselineFrom(projectPath, testsNode) {
957
596
  );
958
597
  }
959
598
 
960
- // The selection travels with the count so the WRITER can refuse a recorte
961
- // (#87) — the second barrier behind baselineTargetProblem's usage error.
962
- return recordTestBaselines(
963
- projectPath,
964
- runs.map((r) => ({ id: r.id, count: r.count, selection: r.selection ?? null }))
965
- );
599
+ return recordTestBaselines(projectPath, runs.map((r) => ({ id: r.id, count: r.count })));
966
600
  }
967
601
 
968
- // ============================================================================
969
- // Validators node
970
- // ============================================================================
971
-
972
602
  async function runValidatorsNode({ projectPath, featureName, _runValidation }) {
973
603
  const start = Date.now();
974
604
 
975
- // runValidation() prints chalk-formatted progress to stdout. verify emits
976
- // JSON — silence console during the call and ALWAYS restore it, even if
977
- // runValidation throws.
605
+ // runValidation() prints progress to stdout, which would corrupt verify's JSON.
978
606
  const originalLog = console.log;
979
607
  const originalWarn = console.warn;
980
608
  let validationResult;
@@ -1013,8 +641,7 @@ async function runValidatorsNode({ projectPath, featureName, _runValidation }) {
1013
641
  }
1014
642
 
1015
643
  function appendValidatorsFeedback(feedback, validatorsNode) {
1016
- // A validator that could not check (reuse without a graph) is invisible in a
1017
- // green node; when the whole node skipped, its own line already says why.
644
+ // A validator that could not check is invisible in a green node otherwise.
1018
645
  if (validatorsNode.status !== 'skip') {
1019
646
  for (const s of validatorsNode.skipped || []) {
1020
647
  if (s.unavailable) feedback.push(`[validators/${s.validator}] não rodou: ${s.reason}`);
@@ -1031,57 +658,51 @@ function appendValidatorsFeedback(feedback, validatorsNode) {
1031
658
  }
1032
659
  }
1033
660
 
1034
- // ============================================================================
1035
- // E2E node
1036
- // ============================================================================
1037
-
1038
- async function runE2eNode({ projectPath, featureName, taskId, task = null, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
661
+ async function runE2eNode({ projectPath, featureName, skipE2e, freshE2e, buildNode, gate, _runE2eCheck }) {
1039
662
  if (buildNode.status === 'fail') {
1040
663
  return {
1041
- status: 'skip',
1042
- command: null,
1043
- durationMs: 0,
1044
- reason: 'build failed — the stack could not come up anyway',
1045
- output: '',
664
+ node: {
665
+ status: 'skip',
666
+ command: null,
667
+ durationMs: 0,
668
+ reason: 'build failed — the stack could not come up anyway',
669
+ output: '',
670
+ },
671
+ stackEnv: null,
1046
672
  };
1047
673
  }
1048
674
 
1049
675
  const config = readConfig(projectPath);
1050
- const taskGroup = task?.group ?? null;
1051
676
  const gitCommonDir = resolveGitCommonDir(projectPath);
677
+ // Without the port block, two worktrees verifying at once collide on host ports.
678
+ const env = buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir });
1052
679
 
1053
680
  const start = Date.now();
1054
681
  const result = _runE2eCheck(projectPath, {
1055
682
  config,
1056
683
  featureName,
1057
- taskGroup,
1058
- scope: taskId ? 'task' : 'feature',
1059
684
  gitCommonDir,
1060
- // The port block matters MORE here than in `e2e up`: this is the path that
1061
- // runs in the loop, and two worktrees verifying at once would otherwise get
1062
- // distinct container names and then collide on host port 3000 — a red gate
1063
- // over an infrastructure clash instead of a defect.
1064
- env: buildComposeEnv({ cwd: projectPath, config, featureName, gitCommonDir }),
685
+ env,
1065
686
  skipRequested: skipE2e,
1066
687
  forceFresh: freshE2e,
1067
688
  beforeHeavy: () => gate.enter('e2e'),
1068
- // The stack stays up between verifies on purpose: cold-starting a .NET
1069
- // container per task is the difference between a usable loop and one
1070
- // nobody runs. `morph-spec e2e down` (and `finish`) tear it down.
689
+ countSpecs: (output) => parseTestCount(output, 'playwright')?.total ?? null,
690
+ // Cold-starting a .NET container per verify is too slow; `e2e down` and `finish` tear it down.
1071
691
  keepUp: true,
1072
692
  });
1073
693
  const durationMs = Date.now() - start;
1074
694
 
1075
695
  return {
1076
- status: result.status,
1077
- command: result.command ?? null,
1078
- durationMs,
1079
- reason: result.reason ?? null,
1080
- output: result.output || '',
1081
- // Non-fatal findings (e.g. a compose with fixed ports at the ROOT, where
1082
- // nothing collides yet). Surfaced so the same compose does not fail
1083
- // unannounced the day the feature moves into a worktree.
1084
- warnings: Array.isArray(result.warnings) ? result.warnings : [],
696
+ node: {
697
+ status: result.status,
698
+ command: result.command ?? null,
699
+ durationMs,
700
+ reason: result.reason ?? null,
701
+ output: result.output || '',
702
+ count: Number.isInteger(result.count) ? result.count : null,
703
+ warnings: Array.isArray(result.warnings) ? result.warnings : [],
704
+ },
705
+ stackEnv: { COMPOSE_PROJECT_NAME: composeProjectName(featureName), ...env },
1085
706
  };
1086
707
  }
1087
708
 
@@ -1096,41 +717,8 @@ function appendE2eFeedback(feedback, e2eNode) {
1096
717
  }
1097
718
  }
1098
719
 
1099
- /**
1100
- * The task as verify needs it, or null when tasks.json or the task is missing.
1101
- *
1102
- * The ONE reader of tasks.json for verify: the group picks the e2e
1103
- * applicability, group + outputs pick the test scope, and the command uses the
1104
- * null to refuse an unknown task id.
1105
- *
1106
- * @param {string} projectPath
1107
- * @param {string} featureName
1108
- * @param {string} taskId
1109
- * @returns {{ group: string|null, outputs: string[] }|null}
1110
- */
1111
- export function readTask(projectPath, featureName, taskId) {
1112
- try {
1113
- const tasksPath = getAbsoluteOutputPath(projectPath, featureName, 'tasks');
1114
- if (!existsSync(tasksPath)) return null;
1115
- const tasksJson = JSON.parse(readFileSync(tasksPath, 'utf8'));
1116
- const task = (tasksJson.tasks || []).find((t) => t.id === taskId);
1117
- if (!task) return null;
1118
- return {
1119
- group: task.group ?? null,
1120
- outputs: Array.isArray(task.outputs) ? task.outputs : [],
1121
- };
1122
- } catch {
1123
- return null;
1124
- }
1125
- }
1126
-
1127
- // ============================================================================
1128
- // Shared helpers
1129
- // ============================================================================
1130
-
1131
- /** Read the project stack directly from config.json — never the stack-profile
1132
- * default (which falls back to 'unknown'→dotnet and would wrongly fire
1133
- * `dotnet build` on a greenfield JS project). */
720
+ // Never the stack-profile default: it falls back to dotnet and would fire
721
+ // `dotnet build` on a greenfield JS project.
1134
722
  function readStack(projectPath) {
1135
723
  try {
1136
724
  const configPath = join(projectPath, '.morph', 'config', 'config.json');
@@ -1142,10 +730,6 @@ function readStack(projectPath) {
1142
730
  }
1143
731
  }
1144
732
 
1145
- /** Read frontend/backend sub-paths for a monorepo, so the build node can run
1146
- * each stack's build command from its own directory instead of assuming a
1147
- * single root (see getBuildSteps() in build-check.js). Absent in single-stack
1148
- * projects — every consumer treats missing paths as "use projectPath". */
1149
733
  function readProjectPaths(projectPath) {
1150
734
  try {
1151
735
  const configPath = join(projectPath, '.morph', 'config', 'config.json');
@@ -1158,7 +742,6 @@ function readProjectPaths(projectPath) {
1158
742
  }
1159
743
  }
1160
744
 
1161
- /** Read the whole config.json — the e2e node needs `e2e` and `project` together. */
1162
745
  function readConfig(projectPath) {
1163
746
  try {
1164
747
  const configPath = join(projectPath, '.morph', 'config', 'config.json');
@@ -1169,14 +752,8 @@ function readConfig(projectPath) {
1169
752
  }
1170
753
  }
1171
754
 
1172
- /**
1173
- * Resolve the shared git dir, where the e2e freshness stamp lives — the same
1174
- * location and rationale as the fleet env-registry (see env-allocator.js): it
1175
- * is the one place every linked worktree of a repo shares, and writing there
1176
- * can never dirty the `git status` the worktree-finish gates read.
1177
- *
1178
- * Fail-open: no git, no stamp — the cost is a rebuild, never a crash.
1179
- */
755
+ // The e2e freshness stamp lives in the common dir: shared by every worktree and
756
+ // never dirties `git status`. Fail-open: no git costs a rebuild, never a crash.
1180
757
  function resolveGitCommonDir(projectPath) {
1181
758
  try {
1182
759
  const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {