@polymorphism-tech/morph-spec 9.0.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 (304) hide show
  1. package/CLAUDE.md +8 -9
  2. package/README.md +38 -36
  3. package/bin/morph-spec.js +4 -62
  4. package/bin/validate.js +1 -39
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +1 -1
  9. package/framework/MORPH.md +1 -1
  10. package/framework/agents.json +12 -4
  11. package/framework/commands/morph-apply.md +1 -1
  12. package/framework/commands/morph-status.md +1 -1
  13. package/framework/docs/CLI.md +6 -17
  14. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  15. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  16. package/framework/hooks/claude-code/core/chain.js +4 -65
  17. package/framework/hooks/claude-code/core/comment-guard.js +2 -16
  18. package/framework/hooks/claude-code/core/enforce-phase-writes.js +6 -50
  19. package/framework/hooks/claude-code/core/gate-guard.js +7 -67
  20. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  21. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  22. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  23. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  24. package/framework/hooks/claude-code/core/pre-bash-denylist.js +0 -4
  25. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -67
  26. package/framework/hooks/claude-code/core/state-sync.js +11 -67
  27. package/framework/hooks/claude-code/core/telemetry-log.js +12 -93
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -57
  29. package/framework/hooks/claude-code/core/warn-bash-source-write.js +1 -9
  30. package/framework/hooks/claude-code/core/worktree-context.js +10 -110
  31. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  32. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  33. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  34. package/framework/hooks/shared/activity-logger.js +9 -89
  35. package/framework/hooks/shared/agent-reap.js +2 -17
  36. package/framework/hooks/shared/build-check.js +6 -75
  37. package/framework/hooks/shared/claim-registry.js +9 -119
  38. package/framework/hooks/shared/command-tokens.js +6 -51
  39. package/framework/hooks/shared/comment-rules.js +5 -20
  40. package/framework/hooks/shared/comment-scanner.js +6 -25
  41. package/framework/hooks/shared/comment-stripper.js +11 -85
  42. package/framework/hooks/shared/e2e-check.js +0 -0
  43. package/framework/hooks/shared/errata.js +2 -15
  44. package/framework/hooks/shared/feature-locator.js +11 -95
  45. package/framework/hooks/shared/frontend-locations.js +1 -3
  46. package/framework/hooks/shared/gate-question.js +1 -7
  47. package/framework/hooks/shared/git-exclude.js +3 -20
  48. package/framework/hooks/shared/git-paths.js +8 -104
  49. package/framework/hooks/shared/graph-freshness.js +22 -166
  50. package/framework/hooks/shared/graph-refresh.js +21 -149
  51. package/framework/hooks/shared/heavy-command.js +4 -12
  52. package/framework/hooks/shared/hook-response.js +8 -83
  53. package/framework/hooks/shared/morph-cli.js +8 -52
  54. package/framework/hooks/shared/payload-utils.js +10 -132
  55. package/framework/hooks/shared/phase-utils.js +1 -42
  56. package/framework/hooks/shared/redact-secrets.js +4 -15
  57. package/framework/hooks/shared/reparse-scan.js +6 -66
  58. package/framework/hooks/shared/slice-progress.js +4 -23
  59. package/framework/hooks/shared/source-files.js +4 -17
  60. package/framework/hooks/shared/state-reader.js +13 -183
  61. package/framework/hooks/shared/state-writer.js +8 -52
  62. package/framework/hooks/shared/stdin-reader.js +1 -11
  63. package/framework/hooks/shared/telemetry-logger.js +7 -67
  64. package/framework/hooks/shared/transcript-usage.js +7 -29
  65. package/framework/hooks/shared/worktree-root.js +7 -29
  66. package/framework/rules/csharp-standards.md +1 -1
  67. package/framework/rules/frontend-standards.md +1 -0
  68. package/framework/rules/hangfire-standards.md +1 -1
  69. package/framework/rules/neon-standards.md +1 -1
  70. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  71. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  72. package/framework/skills/core/morph-apply/SKILL.md +2 -2
  73. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  74. package/framework/skills/core/morph-machine/SKILL.md +1 -1
  75. package/framework/skills/core/morph-media/SKILL.md +1 -1
  76. package/framework/skills/core/morph-plan/SKILL.md +2 -2
  77. package/framework/skills/core/morph-review/SKILL.md +1 -1
  78. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  79. package/framework/skills/core/morph-uiux/SKILL.md +29 -12
  80. package/framework/standards/STANDARDS.json +34 -12
  81. package/framework/standards/STANDARDS.meta.json +16 -2
  82. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  83. package/framework/standards/ai-agents/agent-session.md +1 -1
  84. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  85. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  86. package/framework/standards/ai-agents/guardrails.md +1 -1
  87. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  88. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  89. package/framework/standards/ai-agents/production.md +1 -1
  90. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  91. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  92. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  93. package/framework/standards/ai-agents/setup.md +2 -2
  94. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  95. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  96. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  97. package/framework/standards/ai-agents/workflows.md +1 -1
  98. package/framework/standards/architecture/code-comments.md +1 -1
  99. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  100. package/framework/standards/backend/api/validation.md +1 -1
  101. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  102. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  103. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  104. package/framework/standards/data/nosql/cache/redis.md +1 -1
  105. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  106. package/framework/standards/frontend/admin-app/forms.md +1 -1
  107. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  108. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  109. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  110. package/framework/templates/CLAUDE.md.core.template +3 -3
  111. package/framework/templates/REGISTRY.json +9 -9
  112. package/framework/templates/docs/ui-mockups.md +4 -0
  113. package/package.json +1 -1
  114. package/src/commands/context/context-refresh.js +0 -16
  115. package/src/commands/dashboard/dashboard.js +2 -8
  116. package/src/commands/e2e/e2e.js +17 -104
  117. package/src/commands/graph/affected.js +0 -14
  118. package/src/commands/graph/build.js +11 -50
  119. package/src/commands/graph/explain.js +0 -16
  120. package/src/commands/graph/status.js +1 -12
  121. package/src/commands/mcp/mcp-setup.js +4 -62
  122. package/src/commands/observability/cost.js +5 -57
  123. package/src/commands/observability/telemetry.js +1 -14
  124. package/src/commands/project/branch.js +4 -45
  125. package/src/commands/project/claim.js +4 -48
  126. package/src/commands/project/doctor.js +48 -302
  127. package/src/commands/project/env.js +1 -27
  128. package/src/commands/project/fleet.js +3 -32
  129. package/src/commands/project/init.js +4 -68
  130. package/src/commands/project/install-plugin-cmd.js +0 -1
  131. package/src/commands/project/standards.js +1 -62
  132. package/src/commands/project/status.js +0 -53
  133. package/src/commands/project/update.js +31 -239
  134. package/src/commands/project/worktree.js +108 -882
  135. package/src/commands/scaffold/scaffold.js +0 -25
  136. package/src/commands/state/advance.js +14 -85
  137. package/src/commands/state/approve.js +14 -96
  138. package/src/commands/state/archive.js +12 -64
  139. package/src/commands/state/auto-approval.js +2 -5
  140. package/src/commands/state/create.js +22 -109
  141. package/src/commands/state/delete.js +11 -63
  142. package/src/commands/state/finish.js +98 -475
  143. package/src/commands/state/gate-check.js +22 -132
  144. package/src/commands/state/gate-decision.js +3 -39
  145. package/src/commands/state/handoff.js +3 -30
  146. package/src/commands/state/list.js +14 -84
  147. package/src/commands/state/report-check.js +8 -58
  148. package/src/commands/state/retype.js +4 -26
  149. package/src/commands/validation/index.js +0 -3
  150. package/src/commands/validation/mutate.js +2 -5
  151. package/src/commands/validation/validate-feature.js +5 -94
  152. package/src/commands/validation/verify.js +15 -98
  153. package/src/core/paths/output-schema.js +2 -101
  154. package/src/core/state/feature-identity.js +1 -25
  155. package/src/core/state/gate-order.js +0 -27
  156. package/src/core/state/lifecycle-audit.js +8 -100
  157. package/src/core/state/state-manager.js +22 -304
  158. package/src/core/templates/template-registry.js +2 -97
  159. package/src/core/templates/template-renderer.js +13 -93
  160. package/src/core/templates/template-seed.js +0 -1
  161. package/src/core/work-types.js +1 -36
  162. package/src/dashboard/app/App.jsx +1 -8
  163. package/src/dashboard/app/components/ActivityFeed.jsx +0 -5
  164. package/src/dashboard/app/components/CommandPalette.jsx +0 -7
  165. package/src/dashboard/app/components/DetailPanel.jsx +1 -5
  166. package/src/dashboard/app/components/EntityLink.jsx +0 -12
  167. package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
  168. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
  169. package/src/dashboard/app/components/Markdown.jsx +0 -9
  170. package/src/dashboard/app/components/PhaseRail.jsx +0 -11
  171. package/src/dashboard/app/components/SearchFilter.jsx +0 -4
  172. package/src/dashboard/app/components/Sidebar.jsx +0 -5
  173. package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
  174. package/src/dashboard/app/hooks/usePref.js +1 -5
  175. package/src/dashboard/app/lib/format.js +0 -2
  176. package/src/dashboard/app/lib/phases.js +1 -5
  177. package/src/dashboard/app/lib/project.js +0 -12
  178. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  179. package/src/dashboard/app/pages/FeatureDetail.jsx +1 -5
  180. package/src/dashboard/app/pages/Features.jsx +1 -8
  181. package/src/dashboard/app/pages/Guide.jsx +0 -1
  182. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  183. package/src/dashboard/app/pages/PhasePipeline.jsx +0 -3
  184. package/src/dashboard/app/pages/Portfolio.jsx +0 -1
  185. package/src/dashboard/app/pages/RulesMap.jsx +1 -16
  186. package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
  187. package/src/dashboard/app/pages/Standards.jsx +2 -17
  188. package/src/dashboard/scanner/activity-scanner.js +10 -69
  189. package/src/dashboard/scanner/agents-scanner.js +1 -23
  190. package/src/dashboard/scanner/docs-scanner.js +0 -6
  191. package/src/dashboard/scanner/feature-status.js +2 -5
  192. package/src/dashboard/scanner/features-scanner.js +10 -66
  193. package/src/dashboard/scanner/hooks-scanner.js +5 -28
  194. package/src/dashboard/scanner/index.js +0 -4
  195. package/src/dashboard/scanner/portfolio.js +5 -29
  196. package/src/dashboard/scanner/project-api.js +8 -54
  197. package/src/dashboard/scanner/rules-scanner.js +1 -2
  198. package/src/dashboard/scanner/skills-scanner.js +2 -12
  199. package/src/dashboard/scanner/standards-scanner.js +2 -6
  200. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  201. package/src/lib/ai/ai-checks.js +7 -52
  202. package/src/lib/ai/ai-project-scan.js +14 -108
  203. package/src/lib/ai/code-audit.js +7 -63
  204. package/src/lib/ai/pin-drift.js +8 -51
  205. package/src/lib/ai/registry-audit.js +11 -90
  206. package/src/lib/ci/ci-verify.js +4 -19
  207. package/src/lib/ci/github-ci.js +9 -37
  208. package/src/lib/ci/workflow-scaffold.js +2 -10
  209. package/src/lib/detectors/claude-config-detector.js +13 -74
  210. package/src/lib/detectors/index.js +4 -50
  211. package/src/lib/e2e/compose-env.js +5 -66
  212. package/src/lib/e2e/stack-teardown.js +6 -40
  213. package/src/lib/generators/architecture-scanner.js +18 -156
  214. package/src/lib/generators/handoff-generator.js +9 -53
  215. package/src/lib/generators/scaffold-generator.js +5 -101
  216. package/src/lib/generators/settings-generator.js +4 -37
  217. package/src/lib/git/branch-classifier.js +11 -84
  218. package/src/lib/git/default-branch.js +14 -169
  219. package/src/lib/git/evidence-staleness.js +1 -0
  220. package/src/lib/git/gh.js +2 -17
  221. package/src/lib/git/pending-prs.js +6 -37
  222. package/src/lib/git/pr-files.js +3 -28
  223. package/src/lib/git/pr-index.js +3 -34
  224. package/src/lib/git/tree-fingerprint.js +3 -15
  225. package/src/lib/git/tree-state.js +6 -33
  226. package/src/lib/graph/god-nodes.js +2 -22
  227. package/src/lib/graph/graph-health.js +3 -24
  228. package/src/lib/graph/graph-loader.js +5 -56
  229. package/src/lib/graph/graphify-runtime.js +7 -37
  230. package/src/lib/graph/graphifyignore.js +8 -74
  231. package/src/lib/graph/sanitize.js +16 -136
  232. package/src/lib/heavy/cleanup.js +1 -1
  233. package/src/lib/installers/effects.js +8 -93
  234. package/src/lib/installers/mcp-installer.js +9 -169
  235. package/src/lib/installers/memory-installer.js +4 -27
  236. package/src/lib/installers/templates-reconcile.js +5 -60
  237. package/src/lib/mutation/dotnet-introspection.js +20 -106
  238. package/src/lib/mutation/subset.js +1 -4
  239. package/src/lib/plan/artifact-vocabulary.js +5 -25
  240. package/src/lib/plan/checks.js +5 -31
  241. package/src/lib/plan/plan-parser.js +1 -6
  242. package/src/lib/projects/registry.js +7 -44
  243. package/src/lib/review-reports.js +5 -23
  244. package/src/lib/risk-detector.js +39 -173
  245. package/src/lib/specs/adr-index.js +6 -34
  246. package/src/lib/specs/frontmatter.js +2 -15
  247. package/src/lib/specs/gfm-table.js +1 -7
  248. package/src/lib/specs/revocations.js +1 -14
  249. package/src/lib/specs/screen-checklist.js +47 -124
  250. package/src/lib/stack/dotnet-build-ignores.js +2 -6
  251. package/src/lib/stack/stack-profile.js +1 -28
  252. package/src/lib/stack/stack-scan.js +1 -8
  253. package/src/lib/stack-filter.js +4 -27
  254. package/src/lib/standards/registry.js +1 -7
  255. package/src/lib/standards/sections.js +2 -13
  256. package/src/lib/standards/verified-against.js +19 -191
  257. package/src/lib/tasks/eval-node.js +21 -169
  258. package/src/lib/tasks/linux-runner.js +18 -142
  259. package/src/lib/tasks/test-nodes.js +43 -240
  260. package/src/lib/tasks/test-runner.js +43 -341
  261. package/src/lib/telemetry/metrics.js +7 -36
  262. package/src/lib/telemetry/telemetry-reader.js +13 -125
  263. package/src/lib/telemetry/verify-event.js +2 -30
  264. package/src/lib/validators/code-budget/code-budget-validator.js +3 -43
  265. package/src/lib/validators/code-budget/index.js +0 -7
  266. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -24
  267. package/src/lib/validators/contracts/index.js +0 -6
  268. package/src/lib/validators/nextjs/index.js +0 -5
  269. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  270. package/src/lib/validators/packages/index.js +0 -6
  271. package/src/lib/validators/packages/package-validator.js +6 -56
  272. package/src/lib/validators/reuse/index.js +0 -8
  273. package/src/lib/validators/reuse/reuse-validator.js +11 -101
  274. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  275. package/src/lib/validators/traceability-check.js +5 -63
  276. package/src/lib/validators/validation-runner.js +20 -125
  277. package/src/lib/validators/verify-runner.js +52 -358
  278. package/src/lib/work-type-detector.js +5 -67
  279. package/src/lib/worktree/docker-audit.js +17 -136
  280. package/src/lib/worktree/env-allocator.js +10 -98
  281. package/src/lib/worktree/feature-truth.js +12 -115
  282. package/src/lib/worktree/finish-backup.js +32 -242
  283. package/src/lib/worktree/fleet-scanner.js +13 -95
  284. package/src/lib/worktree/ignored-triage.js +15 -91
  285. package/src/lib/worktree/vscode-settings.js +2 -3
  286. package/src/lib/worktree/worktree-audit.js +21 -213
  287. package/src/lib/worktree/worktree-include.js +3 -5
  288. package/src/scripts/global-install.js +3 -9
  289. package/src/scripts/install-plugin.js +1 -13
  290. package/src/scripts/setup-infra.js +17 -147
  291. package/src/utils/agents-installer.js +28 -275
  292. package/src/utils/banner.js +4 -25
  293. package/src/utils/claude-md-injector.js +1 -37
  294. package/src/utils/claude-settings-manager.js +1 -52
  295. package/src/utils/file-copier.js +11 -95
  296. package/src/utils/hooks-installer.js +21 -298
  297. package/src/utils/skills-installer.js +4 -101
  298. package/src/utils/version-checker.js +4 -83
  299. package/framework/commands/morph-proposal.md +0 -9
  300. package/src/commands/utils/index.js +0 -3
  301. package/src/lib/detectors/design-system-detector.js +0 -188
  302. package/src/lib/generators/design-system-generator.js +0 -508
  303. package/src/lib/specs/old-format.js +0 -68
  304. package/src/utils/agent-id-mapper.js +0 -11
@@ -1,32 +1,3 @@
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
2
  import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from 'fs';
32
3
  import { join, resolve, relative, isAbsolute } from 'path';
@@ -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,15 +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
- */
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.
174
92
  export function canonicalPath(p) {
175
93
  try {
176
94
  return realpathSync.native(p);
@@ -179,14 +97,6 @@ export function canonicalPath(p) {
179
97
  }
180
98
  }
181
99
 
182
- /**
183
- * Absolute paths of the git worktrees linked to this repo, excluding the tree
184
- * being verified. Fail-open — a repo without git (or a git that errors) yields
185
- * an empty list, and discovery proceeds on the convention filter alone.
186
- *
187
- * @param {string} projectPath
188
- * @returns {string[]}
189
- */
190
100
  function listLinkedWorktreePaths(projectPath) {
191
101
  let out;
192
102
  try {
@@ -208,61 +118,24 @@ function listLinkedWorktreePaths(projectPath) {
208
118
  .filter((p) => p && p !== self);
209
119
  }
210
120
 
211
- /**
212
- * True when `child` is `parent` or lives under it.
213
- *
214
- * Exported because test-nodes.js asks the SAME question of a declared `cwd`
215
- * ("does this node point outside the tree under verification?"). One spelling
216
- * of containment, one place to fix it.
217
- *
218
- * @param {string} child
219
- * @param {string} parent
220
- * @returns {boolean}
221
- */
121
+ /** True when `child` is `parent` or lives under it. */
222
122
  export function isInside(child, parent) {
223
123
  const rel = relative(parent, child);
224
124
  return rel === '' || (!rel.startsWith('..') && !isAbsolute(rel));
225
125
  }
226
126
 
227
- /**
228
- * Read `.morph/config/config.json`, or null when it is absent or malformed.
229
- *
230
- * Exported so test-nodes.js reads the config through the SAME door: a second
231
- * reader would be a second place for "absent" and "malformed" to drift apart —
232
- * and both must keep meaning "fall back", never "fail".
233
- *
234
- * @param {string} projectPath
235
- * @returns {object|null}
236
- */
127
+ /** The parsed config, or null when absent OR malformed — both mean "fall back", never "fail". */
237
128
  export function readMorphConfig(projectPath) {
238
129
  const configPath = join(projectPath, CONFIG_FILE_NAME);
239
130
  if (!existsSync(configPath)) return null;
240
131
  try {
241
132
  return JSON.parse(readFileSync(configPath, 'utf8'));
242
133
  } catch {
243
- // Malformed config — treated as absent by every caller
244
134
  return null;
245
135
  }
246
136
  }
247
137
 
248
- /**
249
- * Resolve the timeout budget for ONE test node.
250
- *
251
- * Precedence: explicit override (`morph-spec verify --timeout`) > the node's
252
- * own `timeoutMs` > `config.project.testTimeoutMs` > default scaled by the
253
- * number of test projects the node's command chains.
254
- *
255
- * The per-node ceiling sits above the project-wide one because the derived
256
- * default (120s x chained projects) is shaped for `dotnet test`: it is hostile
257
- * to a Node suite that takes twelve minutes, and a project with both runners
258
- * cannot serve them with a single number. It sits BELOW the CLI override
259
- * because what the user typed is the most explicit statement in the room.
260
- *
261
- * @param {string} projectPath
262
- * @param {import('./test-nodes.js').TestNode} node - The node about to run
263
- * @param {number} [override] - Explicit override in ms, if the caller has one
264
- * @returns {number} Timeout in ms
265
- */
138
+ /** Precedence: CLI override > node `timeoutMs` > config `testTimeoutMs` > default × chained projects. */
266
139
  export function resolveTestTimeout(projectPath, node, override) {
267
140
  if (Number.isFinite(override) && override > 0) return override;
268
141
 
@@ -274,11 +147,7 @@ export function resolveTestTimeout(projectPath, node, override) {
274
147
  return DEFAULT_TEST_TIMEOUT_PER_PROJECT * Math.max(1, node?.projectCount ?? 1);
275
148
  }
276
149
 
277
- /**
278
- * Count the test runs that reported a summary before the command was cut off.
279
- * Heuristic by construction (the summary line is localized), and used only to
280
- * name WHICH link of the chain was still running when the clock ran out.
281
- */
150
+ // Heuristic: only names WHICH chain link was running when the clock ran out.
282
151
  function countCompletedRuns(output) {
283
152
  const matches = (output || '').match(
284
153
  /(?:^|\n)\s*(?:Aprovado|Passed|Failed|Reprovado|Com falha|Erro)!/gi
@@ -286,27 +155,9 @@ function countCompletedRuns(output) {
286
155
  return matches ? matches.length : 0;
287
156
  }
288
157
 
289
- // ============================================================================
290
- // Prebuild of the test projects, and the `--no-build` refusal (T5 / UC-08)
291
- // ============================================================================
292
-
293
- /**
294
- * `--no-build` as a standalone token. Anchored on whitespace so `--no-build-x`
295
- * (or a path that happens to contain the word) never matches.
296
- */
297
158
  const NO_BUILD_FLAG = /(^|\s)--no-build(\s|$)/;
298
159
 
299
- /**
300
- * The `.csproj` targets a command names, quotes removed and duplicates dropped.
301
- *
302
- * Finding none yields `[]`, and `[]` is the SAFE answer, not a shrug: it means
303
- * nothing was compiled here, which is precisely the condition that makes a
304
- * `--no-build` in the same command a refusal. Refusing is cheap; approving a
305
- * stale assembly is what F29 cost.
306
- *
307
- * @param {string|null|undefined} command
308
- * @returns {string[]}
309
- */
160
+ // `[]` is the safe answer: nothing compiled here, so a `--no-build` in the same command is refused.
310
161
  export function extractCsprojTargets(command) {
311
162
  if (typeof command !== 'string') return [];
312
163
  const found = [];
@@ -318,15 +169,7 @@ export function extractCsprojTargets(command) {
318
169
  return [...new Set(found)];
319
170
  }
320
171
 
321
- /**
322
- * Which test projects this node compiles before running: what it DECLARED, or
323
- * else what its command names. Only `runner: 'dotnet'` has a prebuild at all —
324
- * a vitest node has nothing to compile, and pretending otherwise would invent
325
- * a build step nobody asked for.
326
- *
327
- * @param {import('./test-nodes.js').TestNode} node
328
- * @returns {string[]}
329
- */
172
+ /** Declared `build`, else the csproj the command names; only `runner: 'dotnet'` prebuilds. */
330
173
  export function resolveTestBuildTargets(node) {
331
174
  if (node?.runner !== 'dotnet') return [];
332
175
  if (Array.isArray(node.build) && node.build.length > 0) return node.build;
@@ -355,7 +198,6 @@ export function discoverDotnetBuildTargets(node, cwd) {
355
198
  return only ? [dir === '.' ? only : join(dir, only).replace(/\\/g, '/')] : [];
356
199
  }
357
200
 
358
- /** The one solution among `files`, else the one project; `null` when `dotnet build` would have to guess. */
359
201
  function singleDotnetBuildable(files) {
360
202
  const solutions = files.filter((f) => /\.slnx?$/i.test(f));
361
203
  const projects = files.filter((f) => /\.csproj$/i.test(f));
@@ -380,12 +222,8 @@ function dotnetBuildablesUnder(root, rel = '', depth = 0, found = []) {
380
222
  }
381
223
 
382
224
  /**
383
- * What a root `dotnet build` compiles when the root itself holds no solution or
384
- * project: the repository's single .sln/.slnx, else its single .csproj, never
385
- * looking inside worktree containers, node_modules, bin or obj.
386
- *
387
- * @returns {{ target: string|null, candidates: string[] }} `target` relative to
388
- * `root` (`.` when the root already has one); `null` for zero or several, listed.
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`).
389
227
  */
390
228
  export function discoverDotnetSolution(root) {
391
229
  let rootEntries;
@@ -402,17 +240,8 @@ function isDirectory(path) {
402
240
  try { return statSync(path).isDirectory(); } catch { return false; }
403
241
  }
404
242
 
405
- /**
406
- * `dotnet build "<target>" …`, built from the SAME step build-check.js
407
- * resolves for a .NET stack — the string "dotnet build" is not typed a second
408
- * time anywhere in this repository.
409
- *
410
- * `--no-restore` is deliberately dropped: it is correct for the ROOT build
411
- * (whose restore already happened) and wrong here, because the whole reason a
412
- * test project needs its own prebuild is that it may live OUTSIDE the
413
- * application's solution and may therefore never have been restored at all.
414
- * F29's project was exactly that project.
415
- */
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.
416
245
  export function dotnetBuildCommandFor(target, projectPath) {
417
246
  const base = getBuildSteps('dotnet', { cwd: projectPath || '.' })[0]?.command;
418
247
  if (!base) return null;
@@ -421,21 +250,8 @@ export function dotnetBuildCommandFor(target, projectPath) {
421
250
  return `${verb} ${subcommand} "${target}"${kept.length ? ` ${kept.join(' ')}` : ''}`;
422
251
  }
423
252
 
424
- /**
425
- * Compile this node's test projects, and report whether a build ACTUALLY
426
- * happened in this invocation — the fact the `--no-build` refusal keys on.
427
- *
428
- * WHEN it runs: when the node declared `build[]` (an explicit statement that
429
- * these projects need compiling), or when the command carries `--no-build`
430
- * (the only case where the binary would otherwise be whatever the last run
431
- * left behind). A plain `dotnet test X.csproj` is NOT pre-compiled: `dotnet
432
- * test` builds it itself and a compile error already reddens the node, so
433
- * pre-compiling would only double the clock of the hot path — and the clock is
434
- * what D13 is about.
435
- *
436
- * @returns {{ built: boolean, failure: object|null }} `failure` is a full
437
- * runTestSuite result, ready to be returned as-is.
438
- */
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.
439
255
  function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
440
256
  if (node.runner !== 'dotnet') return { built: false, failure: null };
441
257
 
@@ -471,25 +287,13 @@ function runTestPrebuild({ node, cwd, exec, timeout, projectPath }) {
471
287
  return { built: targets.length > 0, failure: null };
472
288
  }
473
289
 
474
- // ============================================================================
475
- // How many tests actually ran (T4 / UC-08)
476
- // ============================================================================
477
-
478
- /** VSTest: one summary line per project, STARTING with the verdict. */
479
290
  const VSTEST_SUMMARY = /^\s*(?:Aprovado|Passed|Com falha|Failed|Reprovado|Erro)!/i;
480
- /**
481
- * Microsoft.Testing.Platform: the header line, in the two locales MEASURED (pt-BR and
482
- * en-US). `\S+` spans `ção` — and a mis-decoded version of it — without naming a byte.
483
- */
291
+ // `\S+` spans `ção`, and a mis-decoded version of it, without naming a byte.
484
292
  const MTP_SUMMARY_HEADER = /^(?:Test run summary|Resumo da execu\S+ de teste):/;
485
293
  const MTP_TOTAL = /^\s+total:\s*(\d+)\s*$/;
486
294
  const MTP_FAILED = /^\s+(?:failed|falhou):\s*(\d+)\s*$/;
487
295
  const MTP_PASSED = /^\s+(?:succeeded|conclu\S*\s+com\s+\S*xito):\s*(\d+)\s*$/;
488
296
 
489
- /**
490
- * The indented block under an MTP summary header. It ends at the first line that
491
- * starts at column 0; a block with no `total:` line yields `total: null`.
492
- */
493
297
  function mtpSummaryBlock(lines, from) {
494
298
  const block = { total: null, passed: 0, failed: 0 };
495
299
  for (let i = from; i < lines.length && /^(?:\s|$)/.test(lines[i]); i++) {
@@ -502,28 +306,9 @@ function mtpSummaryBlock(lines, from) {
502
306
  return block;
503
307
  }
504
308
 
505
- /**
506
- * Sum every summary `dotnet test` printed, in either of its two formats.
507
- *
508
- * - **VSTest**: a line per project that starts with the verdict —
509
- * `Aprovado! - Com falha: 0, Aprovado: 57, Ignorado: 1, Total: 58, …`.
510
- * - **Microsoft.Testing.Platform** (xunit.v3 4.x under `global.json`
511
- * `test.runner`, #115): the verdict does NOT start the line —
512
- * `Test run summary: Passed!` / `Resumo da execução de teste: Aprovado!` —
513
- * and the count comes in an indented block below it, with a lowercase
514
- * `total:`, `failed:`/`falhou:` and `succeeded:`/`concluído com êxito:`.
515
- * Measured in both locales (test/fixtures/test-output/mtp-*.txt). ONE block
516
- * per `dotnet test` invocation: a solution of two projects prints a single
517
- * aggregated block (mtp-solution-fail-skip.txt: `total: 6` = 4 + 2), so
518
- * blocks add up only across invocations. `total` counts skipped cases, as
519
- * VSTest's `Total:` does. Another locale is not read: UNKNOWN, never 0.
520
- *
521
- * SUM, not "the last one": a chained `a && b` command prints one summary per
522
- * invocation, and reading a single one would report half a suite as the whole
523
- * of it — which, against a recorded baseline, is an invented count drop.
524
- * `countCompletedRuns()` above counts the VSTest lines for a different question
525
- * (WHICH link was running when the clock died); this one reads their numbers.
526
- */
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.
527
312
  function parseDotnetCount(output) {
528
313
  const lines = output.split('\n');
529
314
  let seen = false;
@@ -552,7 +337,6 @@ function parseDotnetCount(output) {
552
337
  return seen ? sum : null;
553
338
  }
554
339
 
555
- /** Last value of a `# key N` (TAP) or `ℹ key N` (spec reporter) summary line. */
556
340
  function lastSummaryValue(output, key) {
557
341
  const re = new RegExp(`^\\s*[#ℹ]\\s*${key}\\s+(\\d+)\\s*$`, 'gim');
558
342
  let match;
@@ -561,7 +345,6 @@ function lastSummaryValue(output, key) {
561
345
  return value;
562
346
  }
563
347
 
564
- /** `node --test`, in either of the two reporters the runner may pick. */
565
348
  function parseNodeTestCount(output) {
566
349
  const total = lastSummaryValue(output, 'tests');
567
350
  if (total === null) return null;
@@ -572,13 +355,7 @@ function parseNodeTestCount(output) {
572
355
  };
573
356
  }
574
357
 
575
- /**
576
- * Vitest: `Tests 3082 passed (3082)` / `Tests 1 failed | 3081 passed (3082)`.
577
- *
578
- * The line must start with `Tests`, never `Test Files` — the file count sitting
579
- * right above it has the same shape, and reading it would report 27 where 3082
580
- * ran. A fabricated drop is worse than no count at all.
581
- */
358
+ // `Tests`, never `Test Files`: the file-count line above has the same shape.
582
359
  function parseVitestCount(output) {
583
360
  const line = output.split('\n').find((l) => /^\s*Tests\s{1,}\d/.test(l));
584
361
  if (!line) return null;
@@ -591,7 +368,6 @@ function parseVitestCount(output) {
591
368
  return { total: declaredTotal ? Number(declaredTotal[1]) : p + f, passed: p, failed: f };
592
369
  }
593
370
 
594
- /** Jest: `Tests: 2 failed, 118 passed, 120 total`. */
595
371
  function parseJestCount(output) {
596
372
  const line = output.split('\n').find((l) => /^\s*Tests:\s/.test(l));
597
373
  if (!line) return null;
@@ -606,33 +382,14 @@ function parseJestCount(output) {
606
382
  };
607
383
  }
608
384
 
609
- /**
610
- * CSI (`ESC [ ... <final byte @-~>`, e.g. SGR color codes) and OSC (`ESC ] ...
611
- * BEL|ESC\`, e.g. hyperlinks/terminal titles) escape sequences — the two
612
- * shapes a captured terminal transcript actually carries (issue #110: a
613
- * vitest summary piped through `execSync` kept its SGR bytes, and every count
614
- * regex in this file is anchored on `^` / exact literals, so a color code
615
- * sitting between "Tests" and the number silently defeated the whole line).
616
- */
617
385
  const ANSI_PATTERN = /\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\))/g;
618
386
 
619
- /**
620
- * Strip ANSI escape sequences (CSI/SGR and OSC) from captured process output.
621
- *
622
- * Non-string input is returned UNCHANGED, never coerced to `''` — the caller
623
- * that matters (`parseTestCount`) already treats a non-string
624
- * output as "nothing to read" on their own; making this function opinionated
625
- * about that too would be a second place for the same rule to drift.
626
- *
627
- * @param {string} text
628
- * @returns {string} `text` with escape sequences removed; unchanged if not a string
629
- */
387
+ /** Strips CSI/SGR and OSC sequences; non-string input is returned unchanged, never coerced. */
630
388
  export function stripAnsi(text) {
631
389
  if (typeof text !== 'string') return text;
632
390
  return text.replace(ANSI_PATTERN, '');
633
391
  }
634
392
 
635
- /** Playwright: `8 passed (12.4s)` and/or `2 failed`, on their own lines. */
636
393
  function parsePlaywrightCount(output) {
637
394
  const passed = /^\s*(\d+)\s+passed\b/im.exec(output);
638
395
  const failed = /^\s*(\d+)\s+failed\b/im.exec(output);
@@ -642,15 +399,10 @@ function parsePlaywrightCount(output) {
642
399
  return { total: p + f, passed: p, failed: f };
643
400
  }
644
401
 
645
- /** One extractor per runner. An absent entry is an honest "I cannot read this". */
646
402
  const COUNT_EXTRACTORS = {
647
403
  dotnet: parseDotnetCount,
648
- // An eval IS `dotnet test`, so it reads the same summary — but that number
649
- // counts xunit cases, not scenarios. What an eval node is judged and recorded
650
- // by is the `scenarios` field of the fixture's summary FILE, which this
651
- // function cannot see: `judgeEvalRun` replaces this count with it, or with
652
- // `null` (UNKNOWN, never zero) when the file is unreadable, and `judgeRun`
653
- // 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`.
654
406
  evals: parseDotnetCount,
655
407
  'node-test': parseNodeTestCount,
656
408
  vitest: parseVitestCount,
@@ -660,50 +412,25 @@ const COUNT_EXTRACTORS = {
660
412
  };
661
413
 
662
414
  /**
663
- * How many tests actually ran, read out of the runner's own summary.
664
- *
665
- * @param {string|null|undefined} output - Combined stdout+stderr of the run
666
- * @param {string} runner - One of TEST_RUNNERS (see test-nodes.js)
667
- * @returns {{ total: number, passed: number, failed: number }|null}
668
- * **null is UNKNOWN, never zero.** A summary nobody could read is not a
669
- * suite that ran zero tests: collapsing the two would manufacture a total
670
- * count drop against every recorded baseline out of a missing regex. Same
671
- * 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.
672
417
  */
673
418
  export function parseTestCount(output, runner) {
674
419
  if (typeof output !== 'string' || !output) return null;
675
420
  const extractor = COUNT_EXTRACTORS[runner];
676
421
  if (!extractor) return null;
677
- // SINGLE call site: every extractor reads ANSI-stripped text, never raw
678
- // bytes — a captured terminal transcript may carry SGR color codes right
679
- // inside a summary line (#110) and every regex above is anchored on exact
680
- // 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.
681
423
  return extractor(stripAnsi(output));
682
424
  }
683
425
 
684
426
  /**
685
- * Compare an executed count against the recorded baseline — asymmetrically,
686
- * and on purpose (D7).
687
- *
688
- * A count that DROPS is a finding: tests that used to run no longer do, which
689
- * is exactly the silent damage this feature exists to catch. A count that
690
- * RISES is the normal life of a suite that grows with every task. A symmetric
691
- * rule would turn each new test into a red gate and would teach the agent to
692
- * delete tests in order to "fix" it — the lesson MORPH.md already paid for
693
- * (a typed number goes stale on the first task).
694
- *
695
- * @param {number|null} count - Executed count; null is UNKNOWN
696
- * @param {number|null} expectedCount - Recorded baseline; null is "none recorded"
697
- * @returns {{ verdict: 'drop'|'rise'|'match'|'unknown'|'no-baseline', reason: string|null, warning: string|null }}
698
- * `reason` is non-null ONLY for a drop — it is the only verdict that fails a
699
- * 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.
700
429
  */
701
430
  export function compareTestCount(count, expectedCount) {
702
431
  const answer = (verdict, reason = null, warning = null) => ({ verdict, reason, warning });
703
432
 
704
- // `null` is "no baseline recorded"; a declared `0` is a real baseline. The
705
- // distinction has to survive here or "this suite runs zero tests" becomes
706
- // inexpressible.
433
+ // A declared `0` is a real baseline; only null means "none recorded".
707
434
  if (!Number.isInteger(expectedCount)) return answer('no-baseline');
708
435
 
709
436
  if (!Number.isInteger(count)) {
@@ -724,25 +451,8 @@ export function compareTestCount(count, expectedCount) {
724
451
  }
725
452
 
726
453
  /**
727
- * Run ONE test node.
728
- *
729
- * The node is a parameter rather than something resolved here: a project has a
730
- * LIST of suites (see test-nodes.js), and a runner that resolved its own single
731
- * command is precisely what let 3.082 vitest tests sit outside the gate (F30).
732
- * Resolution belongs to `resolveTestNodes()`; execution belongs here. One node,
733
- * one command, one cwd, one clock.
734
- *
735
- * @param {string} [projectPath] - Project root directory (defaults to '.')
736
- * @param {object} [opts]
737
- * @param {import('./test-nodes.js').TestNode} opts.node - The node to run; its
738
- * `cwd` is resolved against `projectPath`
739
- * @param {number} [opts.timeout] - Explicit timeout in ms (highest precedence)
740
- * @param {Record<string,string>} [opts.env] - Extra environment for THIS node, layered
741
- * over `process.env`. The eval runner's channel (`MORPH_EVAL_*`) travels here.
742
- * Absent leaves the child on the parent's environment, byte for byte — every
743
- * other runner keeps the exact behaviour it had.
744
- * @param {Function} [opts._execSync] - Override execSync for unit testing
745
- * @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).
746
456
  */
747
457
  export function runTestSuite(projectPath = '.', opts = {}) {
748
458
  const node = opts.node;
@@ -760,17 +470,12 @@ export function runTestSuite(projectPath = '.', opts = {}) {
760
470
  const projectCount = node.projectCount ?? 1;
761
471
  const cwd = resolve(projectPath, node.cwd || '.');
762
472
 
763
- // Compile the test projects FIRST, then decide whether `--no-build` has
764
- // 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.
765
474
  const prebuild = runTestPrebuild({ node, cwd, exec, timeout, projectPath });
766
475
  if (prebuild.failure) return prebuild.failure;
767
476
 
768
- // F29: `verify` reported `tests: pass` while the test csproj did not compile,
769
- // because `--no-build` ran last build's assembly and seven new tests never
770
- // executed. The refusal keys on whether a build HAPPENED here, never on the
771
- // flag alone — `--no-build` after a real build is the fast path (D11). The
772
- // command itself is never rewritten: a silent fix would hide the config
773
- // 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.
774
479
  if (node.runner === 'dotnet' && NO_BUILD_FLAG.test(cmd) && !prebuild.built) {
775
480
  return {
776
481
  passed: false,
@@ -782,8 +487,7 @@ export function runTestSuite(projectPath = '.', opts = {}) {
782
487
  };
783
488
  }
784
489
 
785
- // Layered OVER process.env, never replacing it: a child that lost PATH would
786
- // 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.
787
491
  const env = opts.env ? { ...process.env, ...opts.env } : undefined;
788
492
 
789
493
  try {
@@ -800,9 +504,7 @@ export function runTestSuite(projectPath = '.', opts = {}) {
800
504
  const output = (err.stdout || '') + (err.stderr || '')
801
505
  || [err.code, err.signal, err.message].filter(Boolean).join(' ');
802
506
 
803
- // A timeout is NOT a red suite: the command was interrupted, so no verdict
804
- // exists. Saying so by name is the whole point — the field diagnosis of
805
- // 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.
806
508
  if (err.code === 'ETIMEDOUT' || err.killed === true) {
807
509
  const total = Math.max(1, projectCount);
808
510
  const at = Math.min(countCompletedRuns(output) + 1, total);