@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,34 +1,5 @@
1
- /**
2
- * Test Runner — the FALLBACK resolver and the executor of a single test node.
3
- *
4
- * Scope note: a project has a LIST of test suites, and that list is resolved by
5
- * `test-nodes.js` (`resolveTestNodes()`), which calls `resolveTestPlan()` below
6
- * as its last fallback. What lives here is (a) the four-step guess for a
7
- * project that declared nothing, and (b) `runTestSuite()`, which runs ONE node.
8
- * The plural/singular split matters: a runner that resolved its own single
9
- * command is what let a whole second suite sit outside the gate (defect F30).
10
- *
11
- * Resolution order for the fallback test command:
12
- * 1. .morph/config/config.json → project.testCommand
13
- * 2. .sln or .csproj present at project root → "dotnet test"
14
- * 3. Nested *Tests.csproj anywhere in the tree (monorepo without a root
15
- * .sln) → "dotnet test <proj> -v q" for each match
16
- * 4. package.json present AND has scripts.test → "npm test"
17
- * 5. null (no test suite detected)
18
- *
19
- * Step 3 deliberately does NOT see linked git worktrees. A worktree is another
20
- * feature's checkout of the SAME repo: its `tests/X.Tests.csproj` is a copy of
21
- * the one already matched at the root, so including it both doubles the wall
22
- * clock and reports on code that is not the tree under verification. In the
23
- * field this produced a red `tests` node — "fail" at exactly the timeout, with
24
- * the first run's own summary reading "Com falha: 0" — while nothing had
25
- * failed. Two independent filters carry that rule (see collectDotnetTestProjects):
26
- * the CONVENTION path (worktrees/, .worktrees/) and, for worktrees created
27
- * anywhere else, the registry git itself keeps.
28
- */
29
-
30
1
  import { execSync, execFileSync } from 'child_process';
31
- import { existsSync, readFileSync, readdirSync, realpathSync } from 'fs';
2
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'fs';
32
3
  import { join, resolve, relative, isAbsolute } from 'path';
33
4
  import { globSync } from 'glob';
34
5
 
@@ -36,30 +7,20 @@ import { WORKTREES_DIR, LEGACY_WORKTREES_DIR } from '../../commands/project/work
36
7
  import { getBuildSteps } from '../../../framework/hooks/shared/build-check.js';
37
8
  import { CONFIG_FILE_NAME } from '../../core/state/state-manager.js';
38
9
 
39
- /**
40
- * Timeout budget granted to ONE test project. The chained `a && b` command is
41
- * given this much per link: a fixed cap made every multi-project chain born
42
- * condemned — it timed out for being long, not for being broken.
43
- */
10
+ // Per chained project: a fixed cap timed a long `a && b` chain out for being long, not broken.
44
11
  export const DEFAULT_TEST_TIMEOUT_PER_PROJECT = 120_000;
45
12
 
46
13
  /**
47
- * Resolve the test command for a project, together with how many test projects
48
- * it chains — the timeout budget is derived from that count.
49
- *
50
- * @param {string} [projectPath] - Project root directory (defaults to '.')
51
- * @returns {{ command: string | null, projectCount: number, source: string }}
52
- * `source` is one of 'config' | 'dotnet-root' | 'dotnet-scan' | 'npm' | 'none'.
14
+ * Fallback test command when the project declared no nodes; `projectCount` scales the timeout.
15
+ * `source`: 'config' | 'dotnet-root' | 'dotnet-scan' | 'npm' | 'none'.
53
16
  */
54
17
  export function resolveTestPlan(projectPath = '.') {
55
- // 1. Read from .morph/config/config.json
56
18
  const config = readMorphConfig(projectPath);
57
19
  const configured = config?.project?.testCommand;
58
20
  if (configured && typeof configured === 'string') {
59
21
  return { command: configured, projectCount: 1, source: 'config' };
60
22
  }
61
23
 
62
- // 2. Detect .NET project (sln/csproj at root) → dotnet test
63
24
  try {
64
25
  const entries = readdirSync(projectPath);
65
26
  const hasDotnetProject = entries.some(
@@ -69,40 +30,19 @@ export function resolveTestPlan(projectPath = '.') {
69
30
  return { command: 'dotnet test', projectCount: 1, source: 'dotnet-root' };
70
31
  }
71
32
  } catch {
72
- // Unreadable project directory — fall through to next detection
73
33
  }
74
34
 
75
- // 3. No root-level project file — look for a nested test project
76
- // (e.g. tests/MyApp.Tests/MyApp.Tests.csproj), common in monorepos that
77
- // have no root .sln. Run each matched project explicitly rather than a
78
- // bare `dotnet test` (which finds nothing to build at the root).
79
35
  const testProjects = collectDotnetTestProjects(projectPath);
80
36
  if (testProjects.length > 0) {
81
37
  return {
82
- // NO `--nologo`, and that omission is load-bearing. `--nologo` is a
83
- // VSTest-MODE option of `dotnet test`. A project that opts into
84
- // Microsoft.Testing.Platform (`"test": {"runner":
85
- // "Microsoft.Testing.Platform"}` in global.json — what xunit.v3 4.x on
86
- // .NET 10 requires, and what the ai-agents standards prescribe) runs
87
- // `dotnet test` in MTP MODE, where `--nologo` is unknown and is forwarded
88
- // verbatim to the test application. The application answers
89
- // `error: unknown option: --nologo` and the session dies with MTP exit
90
- // code 5 (InvalidCommandLine) reporting ZERO tests — a red `tests` node
91
- // that looks exactly like a broken suite while every test passes.
92
- // Measured on framework/templates/dotnet/ai-kit (2026-09-08): with the
93
- // flag, "Zero testes executados" / exit 5; without it, 115 passed / exit 0.
94
- // The rest of the command is mode-agnostic on purpose: the POSITIONAL
95
- // project path and `-v q` are accepted by both modes, so this command
96
- // stays correct whether the target project is VSTest or MTP. Do not
97
- // "modernize" the path to `--project`: that spelling exists only in MTP
98
- // mode and would break every VSTest project instead.
38
+ // No `--nologo` (VSTest-only: MTP forwards it and dies with exit 5, zero tests)
39
+ // and no `--project` (MTP-only): positional path and `-v q` work in both modes.
99
40
  command: testProjects.map((p) => `dotnet test "${p}" -v q`).join(' && '),
100
41
  projectCount: testProjects.length,
101
42
  source: 'dotnet-scan',
102
43
  };
103
44
  }
104
45
 
105
- // 4. package.json present AND has scripts.test → npm test
106
46
  const packageJsonPath = join(projectPath, 'package.json');
107
47
  if (existsSync(packageJsonPath)) {
108
48
  try {
@@ -111,29 +51,15 @@ export function resolveTestPlan(projectPath = '.') {
111
51
  return { command: 'npm test', projectCount: 1, source: 'npm' };
112
52
  }
113
53
  } catch {
114
- // Malformed package.json — fall through to null
115
54
  }
116
55
  }
117
56
 
118
- // 5. Nothing found
119
57
  return { command: null, projectCount: 0, source: 'none' };
120
58
  }
121
59
 
122
- /**
123
- * Find the *Tests.csproj files that belong to THIS tree — never to a linked
124
- * worktree of the same repo.
125
- *
126
- * Two filters, because one is not enough:
127
- * - the glob ignore covers worktrees created by convention (`worktrees/{f}`
128
- * and the legacy `.worktrees/{f}`), which is where `morph-spec worktree
129
- * setup` puts them;
130
- * - `git worktree list --porcelain` covers every OTHER location, including a
131
- * worktree someone created by hand outside the convention. Fail-open: no
132
- * git, no answer, no filter — the convention filter still stands.
133
- *
134
- * @param {string} projectPath
135
- * @returns {string[]} Project-relative csproj paths, sorted
136
- */
60
+ // A linked worktree is another feature's checkout: scanning it doubles the clock and
61
+ // reports on code outside the tree. The glob ignore covers the convention dirs;
62
+ // `git worktree list` covers worktrees created anywhere else (fail-open).
137
63
  function collectDotnetTestProjects(projectPath) {
138
64
  let matches;
139
65
  try {
@@ -148,7 +74,6 @@ function collectDotnetTestProjects(projectPath) {
148
74
  ],
149
75
  });
150
76
  } catch {
151
- // Glob failure — caller falls through to the next detection step
152
77
  return [];
153
78
  }
154
79
 
@@ -162,20 +87,8 @@ function collectDotnetTestProjects(projectPath) {
162
87
  return matches.sort();
163
88
  }
164
89
 
165
- /**
166
- * One spelling per directory. Git prints the LONG form of a path; the tree
167
- * under verification may be addressed by its 8.3 SHORT form (`MORPHT~1` —
168
- * every GitHub Windows runner's temp dir, and any user path with a short
169
- * name). `path.resolve` does not expand aliases, so `isInside(long, short)`
170
- * says "not inside" for the same directory and a linked worktree leaks into
171
- * the test command. Only `realpathSync.native` asks the OS. Fail-open to the
172
- * plain resolve for a path that does not exist (yet).
173
- *
174
- * Exported because test-scope.js spells every task output with it (#87 F4): a
175
- * mis-cased output resolves on a case-insensitive disk and was forwarded to the
176
- * runner verbatim — and the `--linux` container is case-sensitive. The OS also
177
- * returns the real CASE of each component, so this one call is both answers.
178
- */
90
+ // Git prints the long path; the tree may be addressed by its 8.3 short form (`MORPHT~1`).
91
+ // `path.resolve` does not expand that alias, only `realpathSync.native` does.
179
92
  export function canonicalPath(p) {
180
93
  try {
181
94
  return realpathSync.native(p);
@@ -184,14 +97,6 @@ export function canonicalPath(p) {
184
97
  }
185
98
  }
186
99
 
187
- /**
188
- * Absolute paths of the git worktrees linked to this repo, excluding the tree
189
- * being verified. Fail-open — a repo without git (or a git that errors) yields
190
- * an empty list, and discovery proceeds on the convention filter alone.
191
- *
192
- * @param {string} projectPath
193
- * @returns {string[]}
194
- */
195
100
  function listLinkedWorktreePaths(projectPath) {
196
101
  let out;
197
102
  try {
@@ -213,61 +118,24 @@ function listLinkedWorktreePaths(projectPath) {
213
118
  .filter((p) => p && p !== self);
214
119
  }
215
120
 
216
- /**
217
- * True when `child` is `parent` or lives under it.
218
- *
219
- * Exported because test-nodes.js asks the SAME question of a declared `cwd`
220
- * ("does this node point outside the tree under verification?"). One spelling
221
- * of containment, one place to fix it.
222
- *
223
- * @param {string} child
224
- * @param {string} parent
225
- * @returns {boolean}
226
- */
121
+ /** True when `child` is `parent` or lives under it. */
227
122
  export function isInside(child, parent) {
228
123
  const rel = relative(parent, child);
229
124
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
230
125
  }
231
126
 
232
- /**
233
- * Read `.morph/config/config.json`, or null when it is absent or malformed.
234
- *
235
- * Exported so test-nodes.js reads the config through the SAME door: a second
236
- * reader would be a second place for "absent" and "malformed" to drift apart —
237
- * and both must keep meaning "fall back", never "fail".
238
- *
239
- * @param {string} projectPath
240
- * @returns {object|null}
241
- */
127
+ /** The parsed config, or null when absent OR malformed — both mean "fall back", never "fail". */
242
128
  export function readMorphConfig(projectPath) {
243
129
  const configPath = join(projectPath, CONFIG_FILE_NAME);
244
130
  if (!existsSync(configPath)) return null;
245
131
  try {
246
132
  return JSON.parse(readFileSync(configPath, 'utf8'));
247
133
  } catch {
248
- // Malformed config — treated as absent by every caller
249
134
  return null;
250
135
  }
251
136
  }
252
137
 
253
- /**
254
- * Resolve the timeout budget for ONE test node.
255
- *
256
- * Precedence: explicit override (`morph-spec verify --timeout`) > the node's
257
- * own `timeoutMs` > `config.project.testTimeoutMs` > default scaled by the
258
- * number of test projects the node's command chains.
259
- *
260
- * The per-node ceiling sits above the project-wide one because the derived
261
- * default (120s x chained projects) is shaped for `dotnet test`: it is hostile
262
- * to a Node suite that takes twelve minutes, and a project with both runners
263
- * cannot serve them with a single number. It sits BELOW the CLI override
264
- * because what the user typed is the most explicit statement in the room.
265
- *
266
- * @param {string} projectPath
267
- * @param {import('./test-nodes.js').TestNode} node - The node about to run
268
- * @param {number} [override] - Explicit override in ms, if the caller has one
269
- * @returns {number} Timeout in ms
270
- */
138
+ /** Precedence: CLI override > node `timeoutMs` > config `testTimeoutMs` > default × chained projects. */
271
139
  export function resolveTestTimeout(projectPath, node, override) {
272
140
  if (Number.isFinite(override) && override > 0) return override;
273
141
 
@@ -279,11 +147,7 @@ export function resolveTestTimeout(projectPath, node, override) {
279
147
  return DEFAULT_TEST_TIMEOUT_PER_PROJECT * Math.max(1, node?.projectCount ?? 1);
280
148
  }
281
149
 
282
- /**
283
- * Count the test runs that reported a summary before the command was cut off.
284
- * Heuristic by construction (the summary line is localized), and used only to
285
- * name WHICH link of the chain was still running when the clock ran out.
286
- */
150
+ // Heuristic: only names WHICH chain link was running when the clock ran out.
287
151
  function countCompletedRuns(output) {
288
152
  const matches = (output || '').match(
289
153
  /(?:^|\n)\s*(?:Aprovado|Passed|Failed|Reprovado|Com falha|Erro)!/gi
@@ -291,27 +155,9 @@ function countCompletedRuns(output) {
291
155
  return matches ? matches.length : 0;
292
156
  }
293
157
 
294
- // ============================================================================
295
- // Prebuild of the test projects, and the `--no-build` refusal (T5 / UC-08)
296
- // ============================================================================
297
-
298
- /**
299
- * `--no-build` as a standalone token. Anchored on whitespace so `--no-build-x`
300
- * (or a path that happens to contain the word) never matches.
301
- */
302
158
  const NO_BUILD_FLAG = /(^|\s)--no-build(\s|$)/;
303
159
 
304
- /**
305
- * The `.csproj` targets a command names, quotes removed and duplicates dropped.
306
- *
307
- * Finding none yields `[]`, and `[]` is the SAFE answer, not a shrug: it means
308
- * nothing was compiled here, which is precisely the condition that makes a
309
- * `--no-build` in the same command a refusal. Refusing is cheap; approving a
310
- * stale assembly is what F29 cost.
311
- *
312
- * @param {string|null|undefined} command
313
- * @returns {string[]}
314
- */
160
+ // `[]` is the safe answer: nothing compiled here, so a `--no-build` in the same command is refused.
315
161
  export function extractCsprojTargets(command) {
316
162
  if (typeof command !== 'string') return [];
317
163
  const found = [];
@@ -323,32 +169,79 @@ export function extractCsprojTargets(command) {
323
169
  return [...new Set(found)];
324
170
  }
325
171
 
326
- /**
327
- * Which test projects this node compiles before running: what it DECLARED, or
328
- * else what its command names. Only `runner: 'dotnet'` has a prebuild at all —
329
- * a vitest node has nothing to compile, and pretending otherwise would invent
330
- * a build step nobody asked for.
331
- *
332
- * @param {import('./test-nodes.js').TestNode} node
333
- * @returns {string[]}
334
- */
172
+ /** Declared `build`, else the csproj the command names; only `runner: 'dotnet'` prebuilds. */
335
173
  export function resolveTestBuildTargets(node) {
336
174
  if (node?.runner !== 'dotnet') return [];
337
175
  if (Array.isArray(node.build) && node.build.length > 0) return node.build;
338
176
  return extractCsprojTargets(node.command);
339
177
  }
340
178
 
179
+ const DOTNET_BUILDABLE = /"([^"]+\.(?:slnx?|csproj))"|'([^']+\.(?:slnx?|csproj))'|(\S+\.(?:slnx?|csproj))/gi;
180
+
181
+ /**
182
+ * What `dotnet test` compiles for this node, relative to its `cwd`: declared `build`,
183
+ * else what the command names, else the directory's single solution/project; `[]` = undiscoverable.
184
+ */
185
+ export function discoverDotnetBuildTargets(node, cwd) {
186
+ if (node?.runner !== 'dotnet') return [];
187
+ if (Array.isArray(node.build) && node.build.length > 0) return node.build;
188
+ const command = typeof node.command === 'string' ? node.command : '';
189
+ const named = [...command.matchAll(DOTNET_BUILDABLE)].map((m) => m[1] ?? m[2] ?? m[3]);
190
+ if (named.length > 0) return [...new Set(named)];
191
+
192
+ const dirArg = command.split(/\s+/).find((token) => token && !token.startsWith('-') && token !== 'dotnet' && token !== 'test'
193
+ && isDirectory(join(cwd, token)));
194
+ const dir = dirArg ?? '.';
195
+ let entries;
196
+ try { entries = readdirSync(join(cwd, dir)); } catch { return []; }
197
+ const only = singleDotnetBuildable(entries);
198
+ return only ? [dir === '.' ? only : join(dir, only).replace(/\\/g, '/')] : [];
199
+ }
200
+
201
+ function singleDotnetBuildable(files) {
202
+ const solutions = files.filter((f) => /\.slnx?$/i.test(f));
203
+ const projects = files.filter((f) => /\.csproj$/i.test(f));
204
+ if (solutions.length) return solutions.length === 1 ? solutions[0] : null;
205
+ return projects.length === 1 ? projects[0] : null;
206
+ }
207
+
208
+ const DOTNET_SCAN_SKIPPED = new Set([WORKTREES_DIR, LEGACY_WORKTREES_DIR, 'node_modules', 'bin', 'obj']);
209
+ const DOTNET_SCAN_DEPTH = 6;
210
+
211
+ function dotnetBuildablesUnder(root, rel = '', depth = 0, found = []) {
212
+ let entries;
213
+ try { entries = readdirSync(join(root, rel), { withFileTypes: true }); } catch { return found; }
214
+ for (const e of entries) {
215
+ const path = rel ? `${rel}/${e.name}` : e.name;
216
+ if (e.isFile() && /\.(?:slnx?|csproj)$/i.test(e.name)) found.push(path);
217
+ else if (e.isDirectory() && depth < DOTNET_SCAN_DEPTH && !e.name.startsWith('.') && !DOTNET_SCAN_SKIPPED.has(e.name)) {
218
+ dotnetBuildablesUnder(root, path, depth + 1, found);
219
+ }
220
+ }
221
+ return found;
222
+ }
223
+
341
224
  /**
342
- * `dotnet build "<target>" …`, built from the SAME step build-check.js
343
- * resolves for a .NET stack — the string "dotnet build" is not typed a second
344
- * time anywhere in this repository.
345
- *
346
- * `--no-restore` is deliberately dropped: it is correct for the ROOT build
347
- * (whose restore already happened) and wrong here, because the whole reason a
348
- * test project needs its own prebuild is that it may live OUTSIDE the
349
- * application's solution and may therefore never have been restored at all.
350
- * F29's project was exactly that project.
225
+ * The repo's single .sln/.slnx, else its single .csproj. `target` is `.` when the root
226
+ * already has one, `null` for zero or several (listed in `candidates`).
351
227
  */
228
+ export function discoverDotnetSolution(root) {
229
+ let rootEntries;
230
+ try { rootEntries = readdirSync(root); } catch { return { target: null, candidates: [] }; }
231
+ if (rootEntries.some((e) => /\.(?:slnx?|csproj)$/i.test(e))) return { target: '.', candidates: [] };
232
+ const all = dotnetBuildablesUnder(root).sort();
233
+ const solutions = all.filter((f) => /\.slnx?$/i.test(f));
234
+ const pool = solutions.length ? solutions : all;
235
+ const single = singleDotnetBuildable(pool);
236
+ return { target: single, candidates: pool };
237
+ }
238
+
239
+ function isDirectory(path) {
240
+ try { return statSync(path).isDirectory(); } catch { return false; }
241
+ }
242
+
243
+ // Derived from build-check.js, the single spelling of "dotnet build". `--no-restore` is
244
+ // dropped: a test project outside the solution may never have been restored.
352
245
  export function dotnetBuildCommandFor(target, projectPath) {
353
246
  const base = getBuildSteps('dotnet', { cwd: projectPath || '.' })[0]?.command;
354
247
  if (!base) return null;
@@ -357,21 +250,8 @@ export function dotnetBuildCommandFor(target, projectPath) {
357
250
  return `${verb} ${subcommand} "${target}"${kept.length ? ` ${kept.join(' ')}` : ''}`;
358
251
  }
359
252
 
360
- /**
361
- * Compile this node's test projects, and report whether a build ACTUALLY
362
- * happened in this invocation — the fact the `--no-build` refusal keys on.
363
- *
364
- * WHEN it runs: when the node declared `build[]` (an explicit statement that
365
- * these projects need compiling), or when the command carries `--no-build`
366
- * (the only case where the binary would otherwise be whatever the last run
367
- * left behind). A plain `dotnet test X.csproj` is NOT pre-compiled: `dotnet
368
- * test` builds it itself and a compile error already reddens the node, so
369
- * pre-compiling would only double the clock of the hot path — and the clock is
370
- * what D13 is about.
371
- *
372
- * @returns {{ built: boolean, failure: object|null }} `failure` is a full
373
- * runTestSuite result, ready to be returned as-is.
374
- */
253
+ // Runs only with declared `build[]` or `--no-build`: a plain `dotnet test` builds
254
+ // itself, and pre-compiling it would double the clock of the hot path.
375
255
  function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
376
256
  if (node.runner !== 'dotnet') return { built: false, failure: null };
377
257
 
@@ -407,25 +287,13 @@ function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
407
287
  return { built: targets.length > 0, failure: null };
408
288
  }
409
289
 
410
- // ============================================================================
411
- // How many tests actually ran (T4 / UC-08)
412
- // ============================================================================
413
-
414
- /** VSTest: one summary line per project, STARTING with the verdict. */
415
290
  const VSTEST_SUMMARY = /^\s*(?:Aprovado|Passed|Com falha|Failed|Reprovado|Erro)!/i;
416
- /**
417
- * Microsoft.Testing.Platform: the header line, in the two locales MEASURED (pt-BR and
418
- * en-US). `\S+` spans `ção` — and a mis-decoded version of it — without naming a byte.
419
- */
291
+ // `\S+` spans `ção`, and a mis-decoded version of it, without naming a byte.
420
292
  const MTP_SUMMARY_HEADER = /^(?:Test run summary|Resumo da execu\S+ de teste):/;
421
293
  const MTP_TOTAL = /^\s+total:\s*(\d+)\s*$/;
422
294
  const MTP_FAILED = /^\s+(?:failed|falhou):\s*(\d+)\s*$/;
423
295
  const MTP_PASSED = /^\s+(?:succeeded|conclu\S*\s+com\s+\S*xito):\s*(\d+)\s*$/;
424
296
 
425
- /**
426
- * The indented block under an MTP summary header. It ends at the first line that
427
- * starts at column 0; a block with no `total:` line yields `total: null`.
428
- */
429
297
  function mtpSummaryBlock(lines, from) {
430
298
  const block = { total: null, passed: 0, failed: 0 };
431
299
  for (let i = from; i < lines.length && /^(?:\s|$)/.test(lines[i]); i++) {
@@ -438,28 +306,9 @@ function mtpSummaryBlock(lines, from) {
438
306
  return block;
439
307
  }
440
308
 
441
- /**
442
- * Sum every summary `dotnet test` printed, in either of its two formats.
443
- *
444
- * - **VSTest**: a line per project that starts with the verdict —
445
- * `Aprovado! - Com falha: 0, Aprovado: 57, Ignorado: 1, Total: 58, …`.
446
- * - **Microsoft.Testing.Platform** (xunit.v3 4.x under `global.json`
447
- * `test.runner`, #115): the verdict does NOT start the line —
448
- * `Test run summary: Passed!` / `Resumo da execução de teste: Aprovado!` —
449
- * and the count comes in an indented block below it, with a lowercase
450
- * `total:`, `failed:`/`falhou:` and `succeeded:`/`concluído com êxito:`.
451
- * Measured in both locales (test/fixtures/test-output/mtp-*.txt). ONE block
452
- * per `dotnet test` invocation: a solution of two projects prints a single
453
- * aggregated block (mtp-solution-fail-skip.txt: `total: 6` = 4 + 2), so
454
- * blocks add up only across invocations. `total` counts skipped cases, as
455
- * VSTest's `Total:` does. Another locale is not read: UNKNOWN, never 0.
456
- *
457
- * SUM, not "the last one": a chained `a && b` command prints one summary per
458
- * invocation, and reading a single one would report half a suite as the whole
459
- * of it — which, against a recorded baseline, is an invented count drop.
460
- * `countCompletedRuns()` above counts the VSTest lines for a different question
461
- * (WHICH link was running when the clock died); this one reads their numbers.
462
- */
309
+ // SUM of every VSTest line and MTP block: a chained `a && b` prints one summary per
310
+ // invocation, and reading only one invents a count drop. MTP prints ONE aggregated
311
+ // block per invocation; an unmeasured locale is UNKNOWN, never 0.
463
312
  function parseDotnetCount(output) {
464
313
  const lines = output.split('\n');
465
314
  let seen = false;
@@ -488,30 +337,6 @@ function parseDotnetCount(output) {
488
337
  return seen ? sum : null;
489
338
  }
490
339
 
491
- /**
492
- * The `total` of every MTP summary block in `output`, ANSI stripped — one per
493
- * `dotnet test` invocation (measured: a solution prints ONE aggregated block).
494
- * `[]` when none is readable. Read by `judgeNarrowedRun` (verify-runner.js): in a
495
- * narrowed `&&` chain the SUM can be > 0 while one link ran nothing — its exit 8
496
- * ignored by a path the scanner cannot read — and that link's own block says
497
- * `total: 0`.
498
- *
499
- * @param {string|null|undefined} output
500
- * @returns {number[]}
501
- */
502
- export function mtpSummaryTotals(output) {
503
- if (typeof output !== 'string' || !output) return [];
504
- const lines = stripAnsi(output).split('\n');
505
- const totals = [];
506
- lines.forEach((line, i) => {
507
- if (!MTP_SUMMARY_HEADER.test(line)) return;
508
- const { total } = mtpSummaryBlock(lines, i + 1);
509
- if (total !== null) totals.push(total);
510
- });
511
- return totals;
512
- }
513
-
514
- /** Last value of a `# key N` (TAP) or `ℹ key N` (spec reporter) summary line. */
515
340
  function lastSummaryValue(output, key) {
516
341
  const re = new RegExp(`^\\s*[#ℹ]\\s*${key}\\s+(\\d+)\\s*$`, 'gim');
517
342
  let match;
@@ -520,7 +345,6 @@ function lastSummaryValue(output, key) {
520
345
  return value;
521
346
  }
522
347
 
523
- /** `node --test`, in either of the two reporters the runner may pick. */
524
348
  function parseNodeTestCount(output) {
525
349
  const total = lastSummaryValue(output, 'tests');
526
350
  if (total === null) return null;
@@ -531,13 +355,7 @@ function parseNodeTestCount(output) {
531
355
  };
532
356
  }
533
357
 
534
- /**
535
- * Vitest: `Tests 3082 passed (3082)` / `Tests 1 failed | 3081 passed (3082)`.
536
- *
537
- * The line must start with `Tests`, never `Test Files` — the file count sitting
538
- * right above it has the same shape, and reading it would report 27 where 3082
539
- * ran. A fabricated drop is worse than no count at all.
540
- */
358
+ // `Tests`, never `Test Files`: the file-count line above has the same shape.
541
359
  function parseVitestCount(output) {
542
360
  const line = output.split('\n').find((l) => /^\s*Tests\s{1,}\d/.test(l));
543
361
  if (!line) return null;
@@ -550,7 +368,6 @@ function parseVitestCount(output) {
550
368
  return { total: declaredTotal ? Number(declaredTotal[1]) : p + f, passed: p, failed: f };
551
369
  }
552
370
 
553
- /** Jest: `Tests: 2 failed, 118 passed, 120 total`. */
554
371
  function parseJestCount(output) {
555
372
  const line = output.split('\n').find((l) => /^\s*Tests:\s/.test(l));
556
373
  if (!line) return null;
@@ -565,33 +382,14 @@ function parseJestCount(output) {
565
382
  };
566
383
  }
567
384
 
568
- /**
569
- * CSI (`ESC [ ... <final byte @-~>`, e.g. SGR color codes) and OSC (`ESC ] ...
570
- * BEL|ESC\`, e.g. hyperlinks/terminal titles) escape sequences — the two
571
- * shapes a captured terminal transcript actually carries (issue #110: a
572
- * vitest summary piped through `execSync` kept its SGR bytes, and every count
573
- * regex in this file is anchored on `^` / exact literals, so a color code
574
- * sitting between "Tests" and the number silently defeated the whole line).
575
- */
576
385
  const ANSI_PATTERN = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
577
386
 
578
- /**
579
- * Strip ANSI escape sequences (CSI/SGR and OSC) from captured process output.
580
- *
581
- * Non-string input is returned UNCHANGED, never coerced to `''` — the callers
582
- * that matter (`parseTestCount`, `isZeroTestRun`) already treat a non-string
583
- * output as "nothing to read" on their own; making this function opinionated
584
- * about that too would be a second place for the same rule to drift.
585
- *
586
- * @param {string} text
587
- * @returns {string} `text` with escape sequences removed; unchanged if not a string
588
- */
387
+ /** Strips CSI/SGR and OSC sequences; non-string input is returned unchanged, never coerced. */
589
388
  export function stripAnsi(text) {
590
389
  if (typeof text !== 'string') return text;
591
390
  return text.replace(ANSI_PATTERN, '');
592
391
  }
593
392
 
594
- /** Playwright: `8 passed (12.4s)` and/or `2 failed`, on their own lines. */
595
393
  function parsePlaywrightCount(output) {
596
394
  const passed = /^\s*(\d+)\s+passed\b/im.exec(output);
597
395
  const failed = /^\s*(\d+)\s+failed\b/im.exec(output);
@@ -601,15 +399,10 @@ function parsePlaywrightCount(output) {
601
399
  return { total: p + f, passed: p, failed: f };
602
400
  }
603
401
 
604
- /** One extractor per runner. An absent entry is an honest "I cannot read this". */
605
402
  const COUNT_EXTRACTORS = {
606
403
  dotnet: parseDotnetCount,
607
- // An eval IS `dotnet test`, so it reads the same summary — but that number
608
- // counts xunit cases, not scenarios. What an eval node is judged and recorded
609
- // by is the `scenarios` field of the fixture's summary FILE, which this
610
- // function cannot see: `judgeEvalRun` replaces this count with it, or with
611
- // `null` (UNKNOWN, never zero) when the file is unreadable, and `judgeRun`
612
- // never compares an eval node against `expectedCount` (#110).
404
+ // Counts xunit cases, not scenarios: `judgeEvalRun` replaces it with the summary
405
+ // file's scenario count (or null), and `judgeRun` never compares it to `expectedCount`.
613
406
  evals: parseDotnetCount,
614
407
  'node-test': parseNodeTestCount,
615
408
  vitest: parseVitestCount,
@@ -619,135 +412,25 @@ const COUNT_EXTRACTORS = {
619
412
  };
620
413
 
621
414
  /**
622
- * How many tests actually ran, read out of the runner's own summary.
623
- *
624
- * @param {string|null|undefined} output - Combined stdout+stderr of the run
625
- * @param {string} runner - One of TEST_RUNNERS (see test-nodes.js)
626
- * @returns {{ total: number, passed: number, failed: number }|null}
627
- * **null is UNKNOWN, never zero.** A summary nobody could read is not a
628
- * suite that ran zero tests: collapsing the two would manufacture a total
629
- * count drop against every recorded baseline out of a missing regex. Same
630
- * doctrine as `null` in pr-index.js.
415
+ * Returns null when the summary is unreadable: null is UNKNOWN, never zero, or a
416
+ * missing regex would fabricate a count drop against every baseline.
631
417
  */
632
418
  export function parseTestCount(output, runner) {
633
419
  if (typeof output !== 'string' || !output) return null;
634
420
  const extractor = COUNT_EXTRACTORS[runner];
635
421
  if (!extractor) return null;
636
- // SINGLE call site: every extractor reads ANSI-stripped text, never raw
637
- // bytes — a captured terminal transcript may carry SGR color codes right
638
- // inside a summary line (#110) and every regex above is anchored on exact
639
- // literals that a stray `\x1b[32m` would defeat.
422
+ // Strip ANSI before EVERY extractor: an SGR code inside a summary line defeats the anchored regexes.
640
423
  return extractor(stripAnsi(output));
641
424
  }
642
425
 
643
- // ============================================================================
644
- // "Zero tests ran" signatures, per runner (#87 PR B / #110 parte 1)
645
- // ============================================================================
646
-
647
- /**
648
- * Per-runner signal for "the run matched/executed NOTHING" — a category
649
- * distinct from both a genuine `{ total: 0, ... }` summary (a suite that
650
- * legitimately declares zero cases) and from a summary `parseTestCount`
651
- * simply could not read (`null`, UNKNOWN). Its reader is `judgeNarrowedRun`
652
- * (verify-runner.js, #87): a narrowed `verify {feature} {task}` run that FAILED
653
- * with this signature, and with no count proving tests ran, is named
654
- * `narrowed-zero-tests (…)` instead of an anonymous red. (A PASSED narrowed run
655
- * needs its own proof there — a count > 0, or MTP's exit 0 — never this table.)
656
- *
657
- * Every entry was MEASURED against a real runner, not invented, and the two
658
- * dotnet entries disagree with each other on purpose:
659
- *
660
- * - **MTP** (`--filter-class`, this repo's own `ai-kit`, xunit.v3 4.x):
661
- * a non-matching filter exits **8** (`ZeroTests`) — reliable BY EXIT
662
- * CODE. See `test/fixtures/test-output/mtp-zero-tests.txt`.
663
- * - **VSTest** (`--filter "FullyQualifiedName~..."`, classic xunit 2.x):
664
- * a non-matching filter exits **0** — exit code CANNOT tell a zero-match
665
- * run from a green one here. Only the OUTPUT STRING can:
666
- * `test/fixtures/test-output/vstest-zero-tests.txt` (pt-BR, measured).
667
- * The en-US wording is the brief's documented string, not measured on
668
- * this pt-BR machine — kept alongside the pt-BR one because neither is
669
- * derivable from the other and the machine that runs `verify` may be in
670
- * either locale.
671
- *
672
- * Exit code 8 is therefore listed ONLY as a dotnet exit code, never paired
673
- * with a string requirement — and exit code 9
674
- * (`MinimumExpectedTestsPolicyViolation`, this repo's own
675
- * `--minimum-expected-tests 275`) is deliberately ABSENT: it fires on a run
676
- * that executed fewer cases than the configured floor, including a run that
677
- * was entirely green (measured:
678
- * `test/fixtures/test-output/mtp-filter-class-policy-violation.txt`, 10/10
679
- * passed, exit 9). Folding 9 into this table would misreport a real pass as
680
- * "zero tests ran".
681
- */
682
- export const ZERO_RUN_SIGNATURES = {
683
- dotnet: {
684
- exitCodes: [8],
685
- strings: [
686
- // en-US, VSTest — documented by the brief, not measured here (pt-BR machine).
687
- 'No test matches the given testcase filter',
688
- // pt-BR, VSTest — MEASURED, test/fixtures/test-output/vstest-zero-tests.txt.
689
- 'Nenhum teste corresponde ao filtro de testcase',
690
- ],
691
- },
692
- vitest: {
693
- exitCodes: [],
694
- strings: ['No test files found'],
695
- },
696
- jest: {
697
- exitCodes: [],
698
- strings: ['No tests found'],
699
- },
700
- };
701
-
702
- /**
703
- * True when `output`/`exitCode` carries this runner's "zero tests ran"
704
- * signature. Every other runner (`node-test`, `playwright`, `evals`, `none`,
705
- * or anything not in `ZERO_RUN_SIGNATURES`) answers `false` — an absent entry
706
- * is an honest "this primitive does not know that runner's zero-tests shape
707
- * yet", the same doctrine `COUNT_EXTRACTORS` uses for an unknown runner.
708
- *
709
- * Called by `judgeNarrowedRun` (verify-runner.js) on a FAILED narrowed run, to
710
- * name it `narrowed-zero-tests (…)`.
711
- *
712
- * @param {string|null|undefined} output - Combined stdout+stderr of the run
713
- * @param {string} runner - One of TEST_RUNNERS
714
- * @param {number|null|undefined} exitCode - Must be a NUMBER to match: the
715
- * exit-code check is a strict `includes`, so the string `'8'` never matches 8.
716
- * @returns {boolean}
717
- */
718
- export function isZeroTestRun(output, runner, exitCode) {
719
- const signature = ZERO_RUN_SIGNATURES[runner];
720
- if (!signature) return false;
721
-
722
- if (signature.exitCodes.includes(exitCode)) return true;
723
-
724
- const text = stripAnsi(typeof output === 'string' ? output : '');
725
- return signature.strings.some((needle) => text.includes(needle));
726
- }
727
-
728
426
  /**
729
- * Compare an executed count against the recorded baseline — asymmetrically,
730
- * and on purpose (D7).
731
- *
732
- * A count that DROPS is a finding: tests that used to run no longer do, which
733
- * is exactly the silent damage this feature exists to catch. A count that
734
- * RISES is the normal life of a suite that grows with every task. A symmetric
735
- * rule would turn each new test into a red gate and would teach the agent to
736
- * delete tests in order to "fix" it — the lesson MORPH.md already paid for
737
- * (a typed number goes stale on the first task).
738
- *
739
- * @param {number|null} count - Executed count; null is UNKNOWN
740
- * @param {number|null} expectedCount - Recorded baseline; null is "none recorded"
741
- * @returns {{ verdict: 'drop'|'rise'|'match'|'unknown'|'no-baseline', reason: string|null, warning: string|null }}
742
- * `reason` is non-null ONLY for a drop — it is the only verdict that fails a
743
- * node by itself. Everything else leaves the verdict to the exit code.
427
+ * Asymmetric on purpose: only a drop fails (non-null `reason`); a rise only warns,
428
+ * or every new test would redden the gate and teach deleting tests.
744
429
  */
745
430
  export function compareTestCount(count, expectedCount) {
746
431
  const answer = (verdict, reason = null, warning = null) => ({ verdict, reason, warning });
747
432
 
748
- // `null` is "no baseline recorded"; a declared `0` is a real baseline. The
749
- // distinction has to survive here or "this suite runs zero tests" becomes
750
- // inexpressible.
433
+ // A declared `0` is a real baseline; only null means "none recorded".
751
434
  if (!Number.isInteger(expectedCount)) return answer('no-baseline');
752
435
 
753
436
  if (!Number.isInteger(count)) {
@@ -768,25 +451,8 @@ export function compareTestCount(count, expectedCount) {
768
451
  }
769
452
 
770
453
  /**
771
- * Run ONE test node.
772
- *
773
- * The node is a parameter rather than something resolved here: a project has a
774
- * LIST of suites (see test-nodes.js), and a runner that resolved its own single
775
- * command is precisely what let 3.082 vitest tests sit outside the gate (F30).
776
- * Resolution belongs to `resolveTestNodes()`; execution belongs here. One node,
777
- * one command, one cwd, one clock.
778
- *
779
- * @param {string} [projectPath] - Project root directory (defaults to '.')
780
- * @param {object} [opts]
781
- * @param {import('./test-nodes.js').TestNode} opts.node - The node to run; its
782
- * `cwd` is resolved against `projectPath`
783
- * @param {number} [opts.timeout] - Explicit timeout in ms (highest precedence)
784
- * @param {Record<string,string>} [opts.env] - Extra environment for THIS node, layered
785
- * over `process.env`. The eval runner's channel (`MORPH_EVAL_*`) travels here.
786
- * Absent leaves the child on the parent's environment, byte for byte — every
787
- * other runner keeps the exact behaviour it had.
788
- * @param {Function} [opts._execSync] - Override execSync for unit testing
789
- * @returns {{ passed?: boolean, output?: string, exitCode?: number, skipped?: boolean, reason?: string, timedOut?: boolean, timeoutMs?: number, message?: string }}
454
+ * Runs ONE node; resolving the list of nodes belongs to `resolveTestNodes()`.
455
+ * `opts.env` is layered over `process.env` (the eval channel `MORPH_EVAL_*` travels there).
790
456
  */
791
457
  export function runTestSuite(projectPath = '.', opts = {}) {
792
458
  const node = opts.node;
@@ -804,17 +470,12 @@ export function runTestSuite(projectPath = '.', opts = {}) {
804
470
  const projectCount = node.projectCount ?? 1;
805
471
  const cwd = resolve(projectPath, node.cwd || '.');
806
472
 
807
- // Compile the test projects FIRST, then decide whether `--no-build` has
808
- // anything behind it. Order matters: the build is what makes the flag legal.
473
+ // Prebuild BEFORE judging `--no-build`: the build is what makes the flag legal.
809
474
  const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath });
810
475
  if (prebuild.failure) return prebuild.failure;
811
476
 
812
- // F29: `verify` reported `tests: pass` while the test csproj did not compile,
813
- // because `--no-build` ran last build's assembly and seven new tests never
814
- // executed. The refusal keys on whether a build HAPPENED here, never on the
815
- // flag alone — `--no-build` after a real build is the fast path (D11). The
816
- // command itself is never rewritten: a silent fix would hide the config
817
- // defect and make the reported `command` a lie about what ran.
477
+ // `--no-build` without a build here would run a stale assembly. The command is never
478
+ // rewritten: a silent fix would make the reported `command` a lie about what ran.
818
479
  if (node.runner === 'dotnet' && NO_BUILD_FLAG.test(cmd) && !prebuild.built) {
819
480
  return {
820
481
  passed: false,
@@ -826,8 +487,7 @@ export function runTestSuite(projectPath = '.', opts = {}) {
826
487
  };
827
488
  }
828
489
 
829
- // Layered OVER process.env, never replacing it: a child that lost PATH would
830
- // fail in a way that has nothing to do with the suite under test.
490
+ // Layered OVER process.env: a child that lost PATH fails for reasons unrelated to the suite.
831
491
  const env = opts.env ? { ...process.env, ...opts.env } : undefined;
832
492
 
833
493
  try {
@@ -840,11 +500,11 @@ export function runTestSuite(projectPath = '.', opts = {}) {
840
500
  });
841
501
  return { passed: true, output: output ?? '', exitCode: 0, timeoutMs: timeout };
842
502
  } catch (err) {
843
- const output = (err.stdout || '') + (err.stderr || '');
503
+ // A spawn failure or a kill leaves both streams empty; its own error is then the only lead.
504
+ const output = (err.stdout || '') + (err.stderr || '')
505
+ || [err.code, err.signal, err.message].filter(Boolean).join(' ');
844
506
 
845
- // A timeout is NOT a red suite: the command was interrupted, so no verdict
846
- // exists. Saying so by name is the whole point — the field diagnosis of
847
- // this cost a reading of durationMs against a truncated log.
507
+ // A timeout is NOT a red suite: the command was interrupted, so no verdict exists.
848
508
  if (err.code === 'ETIMEDOUT' || err.killed === true) {
849
509
  const total = Math.max(1, projectCount);
850
510
  const at = Math.min(countCompletedRuns(output) + 1, total);