@polymorphism-tech/morph-spec 8.46.0 → 9.0.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 +53 -44
  2. package/README.md +3 -4
  3. package/bin/morph-spec.js +38 -55
  4. package/bin/validate.js +5 -134
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +2 -2
  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 +75 -18
  11. package/framework/commands/create-umbraco-lp.md +2 -0
  12. package/framework/commands/migrate-lp-to-umbraco.md +2 -0
  13. package/framework/commands/morph-apply.md +5 -28
  14. package/framework/commands/morph-archive.md +2 -2
  15. package/framework/commands/morph-hotfix.md +8 -8
  16. package/framework/commands/morph-infra.md +17 -203
  17. package/framework/commands/morph-media.md +3 -0
  18. package/framework/commands/morph-preflight.md +7 -31
  19. package/framework/commands/morph-proposal.md +2 -50
  20. package/framework/commands/morph-status.md +3 -3
  21. package/framework/commands/morph-troubleshoot.md +2 -14
  22. package/framework/docs/CLI.md +585 -286
  23. package/framework/evals/review-checklist.md +82 -41
  24. package/framework/hooks/README.md +10 -11
  25. package/framework/hooks/claude-code/core/comment-guard.js +1 -1
  26. package/framework/hooks/claude-code/core/denylist.json +33 -3
  27. package/framework/hooks/claude-code/core/enforce-phase-writes.js +16 -20
  28. package/framework/hooks/claude-code/core/gate-guard.js +2 -11
  29. package/framework/hooks/claude-code/core/pre-bash-denylist.js +38 -0
  30. package/framework/hooks/claude-code/core/protect-spec-files.js +30 -242
  31. package/framework/hooks/claude-code/core/state-sync.js +17 -19
  32. package/framework/hooks/claude-code/core/telemetry-log.js +3 -3
  33. package/framework/hooks/claude-code/core/validate-completion.js +33 -97
  34. package/framework/hooks/claude-code/core/warn-bash-source-write.js +31 -0
  35. package/framework/hooks/claude-code/core/worktree-context.js +5 -4
  36. package/framework/hooks/claude-code/statusline.py +55 -41
  37. package/framework/hooks/package.json +3 -0
  38. package/framework/hooks/shared/activity-logger.js +1 -1
  39. package/framework/hooks/shared/build-check.js +10 -6
  40. package/framework/hooks/shared/command-tokens.js +169 -0
  41. package/framework/hooks/shared/comment-rules.js +1 -1
  42. package/framework/hooks/shared/comment-scanner.js +10 -23
  43. package/framework/hooks/shared/comment-stripper.js +1 -1
  44. package/framework/hooks/shared/e2e-check.js +67 -31
  45. package/framework/hooks/shared/errata.js +72 -0
  46. package/framework/hooks/shared/feature-locator.js +1 -1
  47. package/framework/hooks/shared/frontend-locations.js +6 -0
  48. package/framework/hooks/shared/gate-question.js +18 -0
  49. package/framework/hooks/shared/phase-utils.js +15 -32
  50. package/framework/hooks/shared/slice-progress.js +118 -0
  51. package/framework/hooks/shared/spawn-command.js +23 -0
  52. package/framework/hooks/shared/state-reader.js +61 -87
  53. package/framework/hooks/shared/state-writer.js +3 -3
  54. package/framework/mcp/registry.json +3 -4
  55. package/framework/phases.json +23 -39
  56. package/framework/rules/ai-agents-standards.md +18 -16
  57. package/framework/rules/asaas-standards.md +3 -1
  58. package/framework/rules/csharp-standards.md +10 -8
  59. package/framework/rules/data-standards.md +5 -5
  60. package/framework/rules/dotnet-templates.md +1 -1
  61. package/framework/rules/frontend-standards.md +4 -2
  62. package/framework/rules/gsap-standards.md +10 -5
  63. package/framework/rules/hangfire-standards.md +3 -1
  64. package/framework/rules/infrastructure-standards.md +4 -2
  65. package/framework/rules/neon-auth-standards.md +3 -1
  66. package/framework/rules/neon-standards.md +6 -4
  67. package/framework/rules/nextjs-standards.md +13 -11
  68. package/framework/rules/nextjs-templates.md +1 -1
  69. package/framework/rules/resend-standards.md +3 -1
  70. package/framework/schemas/config.schema.json +14 -9
  71. package/framework/schemas/feature.schema.json +15 -52
  72. package/framework/skills/README.md +4 -5
  73. package/framework/skills/core/create-umbraco-lp/SKILL.md +3 -3
  74. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +3 -3
  75. package/framework/skills/core/morph-apply/SKILL.md +150 -223
  76. package/framework/skills/core/morph-hotfix/SKILL.md +34 -43
  77. package/framework/skills/core/morph-machine/SKILL.md +4 -4
  78. package/framework/skills/core/morph-media/SKILL.md +3 -3
  79. package/framework/skills/core/morph-plan/SKILL.md +138 -300
  80. package/framework/skills/core/morph-review/SKILL.md +112 -137
  81. package/framework/skills/core/morph-standards/SKILL.md +29 -41
  82. package/framework/skills/core/morph-uiux/SKILL.md +78 -153
  83. package/framework/standards/STANDARDS.json +90 -1219
  84. package/framework/standards/STANDARDS.meta.json +1305 -0
  85. package/framework/standards/ai-agents/evals-with-cache.md +2 -3
  86. package/framework/standards/ai-agents/llm-runtime-defaults.md +1 -1
  87. package/framework/standards/ai-agents/modalities-speech-to-text.md +36 -26
  88. package/framework/standards/ai-agents/prompt-sources.md +6 -4
  89. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  90. package/framework/standards/ai-agents/rag-custom-pgvector.md +67 -39
  91. package/framework/standards/ai-agents/setup.md +2 -2
  92. package/framework/standards/ai-agents/structured-output.md +1 -1
  93. package/framework/standards/ai-agents/sweet-spot.md +1 -1
  94. package/framework/standards/architecture/code-comments.md +8 -8
  95. package/framework/standards/architecture/vertical-slice/vertical-slice.md +18 -9
  96. package/framework/standards/backend/api/validation.md +135 -47
  97. package/framework/standards/backend/database/postgresql/database.md +11 -7
  98. package/framework/standards/backend/database/vector-search-rag.md +207 -324
  99. package/framework/standards/backend/dotnet/fluent-validation-vsa.md +14 -8
  100. package/framework/standards/backend/dotnet/result-pattern.md +40 -6
  101. package/framework/standards/backend/dotnet/scalar-ui.md +13 -7
  102. package/framework/standards/backend/dotnet/scrutor.md +1 -1
  103. package/framework/standards/backend/dotnet/testing.md +1 -1
  104. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +7 -7
  105. package/framework/standards/backend/integrations/asaas/asaas-api.md +309 -165
  106. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +134 -154
  107. package/framework/standards/data/nosql/cache/redis.md +120 -44
  108. package/framework/standards/data/nosql/cosmos-db.md +114 -71
  109. package/framework/standards/frontend/design-system/animations.md +1 -1
  110. package/framework/standards/frontend/design-system/naming.md +1 -1
  111. package/framework/standards/frontend/nextjs/motion-patterns.md +45 -29
  112. package/framework/standards/frontend/nextjs/naming-conventions.md +1 -1
  113. package/framework/standards/infrastructure/docker/coolify-deploy.md +8 -6
  114. package/framework/standards/infrastructure/neon/neon-pgvector.md +120 -75
  115. package/framework/standards/infrastructure/neon/neon-setup.md +53 -31
  116. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  117. package/framework/templates/README.md +2 -3
  118. package/framework/templates/REGISTRY.json +6 -170
  119. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +2 -2
  120. package/framework/templates/code/dotnet/extensions/database-extensions.cs +1 -1
  121. package/framework/templates/code/dotnet/extensions/result-extensions.cs +27 -0
  122. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +2 -2
  123. package/framework/templates/docs/plan.md +72 -0
  124. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  125. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +1 -1
  126. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  127. package/framework/templates/infrastructure/docker/coolify.template.json +3 -3
  128. package/framework/templates/infrastructure/github/README.md +2 -1
  129. package/framework/templates/infrastructure/github/workflows/deploy-coolify.yml.hbs +2 -1
  130. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  131. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  132. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  133. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  134. package/package.json +1 -1
  135. package/src/commands/e2e/e2e.js +2 -2
  136. package/src/commands/observability/cost.js +18 -60
  137. package/src/commands/observability/telemetry.js +3 -5
  138. package/src/commands/project/doctor-procs.js +1 -1
  139. package/src/commands/project/doctor.js +9 -9
  140. package/src/commands/project/fleet.js +4 -4
  141. package/src/commands/project/heavy.js +3 -3
  142. package/src/commands/project/init.js +6 -1
  143. package/src/commands/project/setup-infra-cmd.js +2 -1
  144. package/src/commands/project/stack-scan.js +58 -0
  145. package/src/commands/project/standards.js +79 -25
  146. package/src/commands/project/status.js +29 -69
  147. package/src/commands/project/update.js +33 -19
  148. package/src/commands/project/worktree.js +1 -1
  149. package/src/commands/state/advance.js +62 -46
  150. package/src/commands/state/approve.js +95 -138
  151. package/src/commands/state/archive.js +20 -84
  152. package/src/commands/state/auto-approval.js +68 -0
  153. package/src/commands/state/create.js +28 -1
  154. package/src/commands/state/delete.js +1 -1
  155. package/src/commands/state/finish.js +11 -23
  156. package/src/commands/state/gate-check.js +86 -95
  157. package/src/commands/state/gate-decision.js +6 -4
  158. package/src/commands/state/handoff.js +1 -1
  159. package/src/commands/state/list.js +9 -9
  160. package/src/commands/state/report-check.js +157 -311
  161. package/src/commands/state/retype.js +9 -3
  162. package/src/commands/validation/mutate.js +77 -14
  163. package/src/commands/validation/plan-check.js +81 -0
  164. package/src/commands/validation/validate-feature.js +60 -135
  165. package/src/commands/validation/verify.js +41 -82
  166. package/src/core/paths/output-schema.js +20 -67
  167. package/src/core/state/gate-order.js +5 -6
  168. package/src/core/state/lifecycle-audit.js +2 -2
  169. package/src/core/state/state-manager.js +17 -98
  170. package/src/core/templates/template-registry.js +2 -2
  171. package/src/core/templates/template-seed.js +22 -0
  172. package/src/core/work-types.js +10 -10
  173. package/src/dashboard/app/App.jsx +0 -2
  174. package/src/dashboard/app/components/ActivityFeed.jsx +2 -3
  175. package/src/dashboard/app/components/CommandPalette.jsx +7 -8
  176. package/src/dashboard/app/components/EntityLink.jsx +1 -2
  177. package/src/dashboard/app/components/Markdown.jsx +1 -1
  178. package/src/dashboard/app/components/Sidebar.jsx +1 -2
  179. package/src/dashboard/app/hooks/useMorphQuery.js +1 -1
  180. package/src/dashboard/app/lib/phases.js +2 -4
  181. package/src/dashboard/app/pages/Activity.jsx +1 -1
  182. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -129
  183. package/src/dashboard/app/pages/Features.jsx +9 -9
  184. package/src/dashboard/app/pages/Guide.jsx +2 -2
  185. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -4
  186. package/src/dashboard/app/pages/RulesMap.jsx +3 -3
  187. package/src/dashboard/app/pages/SkillsLibrary.jsx +3 -3
  188. package/src/dashboard/app/pages/Standards.jsx +1 -1
  189. package/src/dashboard/scanner/activity-scanner.js +6 -16
  190. package/src/dashboard/scanner/agents-scanner.js +1 -1
  191. package/src/dashboard/scanner/docs-scanner.js +5 -23
  192. package/src/dashboard/scanner/features-scanner.js +95 -60
  193. package/src/dashboard/scanner/index.js +0 -2
  194. package/src/dashboard/scanner/project-api.js +1 -1
  195. package/src/dashboard/scanner/rules-scanner.js +15 -10
  196. package/src/dashboard/scanner/skills-scanner.js +2 -3
  197. package/src/dashboard/scanner/standards-scanner.js +2 -1
  198. package/src/lib/ci/ci-verify.js +17 -1
  199. package/src/lib/ci/github-ci.js +1 -1
  200. package/src/lib/detectors/index.js +3 -8
  201. package/src/lib/generators/handoff-generator.js +34 -65
  202. package/src/lib/generators/settings-generator.js +11 -89
  203. package/src/lib/git/evidence-staleness.js +1 -1
  204. package/src/lib/git/tree-fingerprint.js +19 -7
  205. package/src/lib/git/tree-state.js +2 -2
  206. package/src/lib/heavy/cleanup.js +1 -5
  207. package/src/lib/installers/memory-installer.js +6 -8
  208. package/src/lib/mutation/commands.js +3 -2
  209. package/src/lib/{tasks/dotnet-test-introspection.js → mutation/dotnet-introspection.js} +17 -282
  210. package/src/lib/mutation/plan.js +14 -6
  211. package/src/lib/mutation/runner.js +1 -1
  212. package/src/lib/mutation/subset.js +46 -9
  213. package/src/lib/plan/artifact-vocabulary.js +60 -0
  214. package/src/lib/plan/checks.js +234 -0
  215. package/src/lib/plan/plan-parser.js +209 -0
  216. package/src/lib/plan/plan-template.js +18 -0
  217. package/src/lib/review-reports.js +222 -0
  218. package/src/lib/risk-detector.js +134 -135
  219. package/src/lib/specs/adr-index.js +50 -69
  220. package/src/lib/specs/design-seed.js +18 -0
  221. package/src/lib/specs/frontmatter.js +44 -0
  222. package/src/lib/specs/gfm-table.js +17 -0
  223. package/src/lib/specs/old-format.js +68 -0
  224. package/src/lib/specs/revocations.js +37 -95
  225. package/src/lib/specs/screen-checklist.js +18 -17
  226. package/src/lib/stack/dotnet-build-ignores.js +47 -0
  227. package/src/lib/stack/stack-profile.js +1 -1
  228. package/src/lib/stack/stack-scan.js +267 -0
  229. package/src/lib/standards/registry.js +43 -0
  230. package/src/lib/standards/sections.js +58 -0
  231. package/src/lib/tasks/eval-node.js +4 -12
  232. package/src/lib/tasks/test-nodes.js +1 -74
  233. package/src/lib/tasks/test-runner.js +75 -117
  234. package/src/lib/telemetry/metrics.js +8 -69
  235. package/src/lib/telemetry/telemetry-reader.js +5 -118
  236. package/src/lib/telemetry/verify-event.js +5 -7
  237. package/src/lib/validators/code-budget/code-budget-validator.js +1 -1
  238. package/src/lib/validators/contracts/contract-compliance-validator.js +2 -2
  239. package/src/lib/validators/reuse/reuse-validator.js +24 -48
  240. package/src/lib/validators/reuse/symbol-scanner.js +2 -4
  241. package/src/lib/validators/traceability-check.js +85 -129
  242. package/src/lib/validators/validation-runner.js +8 -88
  243. package/src/lib/validators/verify-runner.js +134 -301
  244. package/src/lib/worktree/docker-audit.js +1 -2
  245. package/src/lib/worktree/fleet-scanner.js +25 -38
  246. package/src/scripts/setup-infra.js +42 -22
  247. package/src/utils/agents-installer.js +92 -13
  248. package/src/utils/claude-settings-manager.js +0 -35
  249. package/src/utils/file-copier.js +34 -6
  250. package/src/utils/hooks-installer.js +16 -9
  251. package/src/utils/skills-installer.js +1 -2
  252. package/framework/commands/commit.md +0 -166
  253. package/framework/evals/trace-schema.md +0 -67
  254. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  255. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  256. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  257. package/framework/hooks/shared/tasks-validator.js +0 -111
  258. package/framework/rubrics/backend.json +0 -41
  259. package/framework/rubrics/default.json +0 -36
  260. package/framework/rubrics/frontend.json +0 -36
  261. package/framework/rules/morph-task-tracking.md +0 -54
  262. package/framework/rules/morph-workflow.md +0 -244
  263. package/framework/schemas/tasks.schema.json +0 -79
  264. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  265. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  266. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  267. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  268. package/framework/templates/docs/proposal.md +0 -192
  269. package/framework/templates/docs/spec.md +0 -57
  270. package/framework/templates/docs/ui-components.md +0 -52
  271. package/framework/templates/docs/ui-design-system.md +0 -76
  272. package/framework/templates/docs/ui-flows.md +0 -167
  273. package/framework/templates/examples/spec-examples.md +0 -91
  274. package/framework/templates/feature/decisions.md +0 -124
  275. package/framework/templates/feature/recap.md +0 -145
  276. package/framework/templates/project/validate.js +0 -97
  277. package/framework/templates/tasks.template.json +0 -17
  278. package/src/commands/state/usecase.js +0 -143
  279. package/src/commands/tasks/dag.js +0 -181
  280. package/src/dashboard/app/pages/Rubrics.jsx +0 -97
  281. package/src/dashboard/scanner/rubrics-scanner.js +0 -12
  282. package/src/lib/agents/output-collector.js +0 -76
  283. package/src/lib/evaluator-report.js +0 -91
  284. package/src/lib/specs/acervo-format.js +0 -70
  285. package/src/lib/specs/acervo-lifecycle.js +0 -264
  286. package/src/lib/specs/done-criteria-check.js +0 -200
  287. package/src/lib/specs/usecase-check.js +0 -301
  288. package/src/lib/specs/usecase-promoter.js +0 -301
  289. package/src/lib/specs/usecase-registry.js +0 -237
  290. package/src/lib/tasks/dispatch-planner.js +0 -319
  291. package/src/lib/tasks/mermaid-renderer.js +0 -132
  292. package/src/lib/tasks/task-classifier.js +0 -30
  293. package/src/lib/tasks/task-parser.js +0 -209
  294. package/src/lib/tasks/tasks-json-parser.js +0 -296
  295. package/src/lib/tasks/test-scope.js +0 -597
  296. package/src/lib/validators/architecture/architecture-validator.js +0 -270
  297. package/src/lib/validators/architecture/index.js +0 -7
  298. package/src/lib/validators/css/css-validator.js +0 -340
  299. package/src/lib/validators/css/index.js +0 -7
  300. package/src/lib/validators/design-system/design-system-validator.js +0 -237
  301. package/src/lib/validators/design-system/index.js +0 -7
  302. package/src/lib/validators/score-aggregator.js +0 -244
  303. package/src/lib/validators/ui/index.js +0 -7
  304. package/src/lib/validators/ui/ui-contrast-validator.js +0 -422
@@ -1,597 +0,0 @@
1
- /**
2
- * Test Scope — which test nodes a TASK-scoped `verify {feature} {task}` runs,
3
- * and which tests inside each node (issue #87).
4
- *
5
- * GHLBrain ran its whole suite 121 times in 8 days from the per-task loop — about
6
- * five hours a week — because a task-scoped verify had no way to run less. The
7
- * feature-scoped verify (Gate 3) keeps running everything; this module only
8
- * DECIDES the task-scoped cut, verify-runner executes it (task B4).
9
- *
10
- * Two independent decisions:
11
- * 1. selection — the task's `group` picks the nodes (`effectiveGroups`);
12
- * 2. narrowing — the task's `outputs` narrow the command INSIDE each node.
13
- *
14
- * The declared command is literal and nobody's parser owns it, so narrowing is
15
- * string surgery on a short list of shapes that were read and, where it
16
- * mattered, measured (test/fixtures/test-output/README.md). Anything else runs
17
- * WHOLE with a reason: a narrowing that guesses runs the wrong tests or none,
18
- * and a run of none is the green that issue #87 must never buy.
19
- *
20
- * Pure: reads files (and one environment variable, TESTINGPLATFORM_EXITCODE_IGNORE),
21
- * never spawns a process.
22
- *
23
- * @module test-scope
24
- */
25
-
26
- import { readFileSync, statSync } from 'fs';
27
- import { dirname, relative, resolve } from 'path';
28
-
29
- import { effectiveGroups } from './test-nodes.js';
30
- import { canonicalPath, extractCsprojTargets, isInside } from './test-runner.js';
31
- import { normalizePath } from '../validators/traceability-check.js';
32
- import { samePathCase } from '../worktree/docker-audit.js';
33
- import {
34
- csharpClasses, declaresCaseFilter, dotnetTestMode, dotnetTestProjectOf, floorOverride, ignoresExitCode,
35
- imposesMinimumExpected, isDotnetSource, isDotnetTestProject, nearestCsprojOf, nearestProjectOf, readsLikeTestSource,
36
- } from './dotnet-test-introspection.js';
37
-
38
- // Part of this module's API (task B3); implemented beside the rest of the .NET introspection.
39
- export { dotnetTestMode, classNamesOf } from './dotnet-test-introspection.js';
40
-
41
- /**
42
- * The per-node decision B4 consumes (`preDecideRun` skips `out-of-scope`;
43
- * `judgeRun` reads `mode`/`zeroGuard`). `null` — in fact absent — at feature scope.
44
- *
45
- * @typedef {Object} TestSelection
46
- * @property {'narrowed'|'whole'|'out-of-scope'|'full'} mode
47
- * - `narrowed`: `node.command` was rewritten to run only the task's tests. Its
48
- * count is PARTIAL: never compare it with `expectedCount` (the whole-suite
49
- * baseline, left untouched on the node), and a pass without proof that tests
50
- * ran is not a pass — see `zeroGuard`.
51
- * - `whole`: selected, runs its declared command unchanged; `reason` says why.
52
- * - `out-of-scope`: must not run at this task scope; `reason` says why.
53
- * - `full`: `--full` — declared command unchanged, no recorte by request.
54
- * @property {string|null} reason - Why whole/out-of-scope; null when narrowed.
55
- * @property {string|null} declaredCommand - The command as declared, before narrowing.
56
- * @property {string[]} files - Task test files this node owns (project-relative,
57
- * `/`, spelled as on disk): inside its cwd, or — dotnet — belonging to a
58
- * `.csproj` a link of its command names, wherever they sit (ownership is the
59
- * link's project, never the cwd).
60
- * @property {string[]} selectors - What the narrowed command selects: BARE type
61
- * names (dotnet — every class/record/struct/interface of the task's test files,
62
- * no namespace, each matched namespace-free as `--filter-class "*T*"` (MTP +
63
- * xunit.v3, measured) or the substring `FullyQualifiedName~T`, so any namespace,
64
- * nested `Outer+Inner` and prefix siblings are covered: a SUPERSET of the file's
65
- * tests, never a subset) or paths relative to the node cwd (vitest/jest). `[]`
66
- * unless narrowed.
67
- * @property {'count'|'mtp-exit-8'|null} zeroGuard - How a narrowed run proves tests
68
- * ran: `count` = only a parsed count > 0 proves it (VSTest exits 0 on an empty
69
- * filter; vitest/jest print a string); `mtp-exit-8` = a parsed count > 0 proves it
70
- * (the MTP summary, pt-BR/en-US, #115) and, with no readable summary, exit 0 does —
71
- * MTP exits 8 on zero tests. A parsed 0 is red under both. null unless narrowed.
72
- */
73
-
74
- const NO_TEST_FILE = 'no test file from task outputs in this node — running it whole';
75
- const SHAPE = 'command shape not recognised for narrowing — running it whole';
76
- /**
77
- * Measured (test/fixtures/test-output/mtp-minimum-expected-cli-override-ignored.txt):
78
- * `--minimum-expected-tests 1` on the CLI does NOT override the project's floor —
79
- * a green narrowed run still exits 9. The global `-p:` property does, and since
80
- * #114 a floor floorOverride can re-pass is lowered to 1 for the recorte; every
81
- * other floor (VSTest, set elsewhere or conditionally) runs whole with this reason
82
- * rather than produce a red that no test caused. Wording pinned by the plan.
83
- */
84
- const MIN_EXPECTED = 'projeto impõe --minimum-expected-tests; recorte desligado';
85
- /** #114 review F6: `-p:` is global to ONE `dotnet test`, so it would replace the arguments of EVERY project the link runs. */
86
- const FLOOR_SHARED = 'a link runs several test projects and one has a floor — its -p: would replace the others\' arguments too; running it whole';
87
- /** #114 review F2: the recorte's `-p:` comes last, and the last one wins — the command's own value would be gone. */
88
- const COMMAND_SETS_ARGS = 'command sets TestingPlatformCommandLineArguments itself — the recorte\'s -p: would replace that value; running it whole';
89
- const FILTER_TAKEN = 'command already carries a test filter — a second one would replace it; running it whole';
90
- const CASE_FILTER = 'project declares <VSTestTestCaseFilter> — an appended --filter would replace it; running it whole';
91
- /**
92
- * Final review F3: under MTP, when the summary is NOT readable (a locale other than
93
- * pt-BR/en-US, #115), a narrowed run's proof that tests ran is exit 0 (zero tests
94
- * exit 8). Whatever makes MTP ignore an exit code takes that proof away — zero tests
95
- * then exit 0 — so the node runs whole, readable summary or not: this is decided
96
- * BEFORE the run, which is when nobody knows yet whether its summary will read.
97
- * The COMMAND is read by FLOOR_OR_IGNORE; these two are the project
98
- * (ignoresExitCode) and the environment.
99
- */
100
- const IGNORE_EXIT_PROJECT = 'project makes MTP ignore an exit code (--ignore-exit-code / TESTINGPLATFORM_EXITCODE_IGNORE in its MSBuild files or testconfig) — exit 0 would no longer prove that tests ran; running it whole';
101
- const IGNORE_EXIT_ENV = 'TESTINGPLATFORM_EXITCODE_IGNORE is set in the environment — MTP may exit 0 on zero tests, so exit 0 would no longer prove that tests ran; running it whole';
102
-
103
- const CLASS_NAME = /^[A-Za-z0-9_.]+$/;
104
- /** Characters that change meaning inside double quotes in `sh` (the `--linux` container) or `cmd.exe`. */
105
- const UNSAFE_IN_QUOTES = /["$`%!]/;
106
- const JS_TEST_FILE = /\.(test|spec)\.[cm]?[jt]sx?$|(^|\/)__tests__\/.+\.[cm]?[jt]sx?$/;
107
- const JS_SOURCE = /\.[cm]?[jt]sx?$/i;
108
- /** Read in the RAW text of a JS/TS output the recogniser did not classify as a test file (the .NET markers: readsLikeTestSource). */
109
- const JS_TEST_CALL = /^[ \t]*(?:describe|it|test)(?:\.\w+)*\s*\(/m;
110
- const EXISTING_FILTER = /(^|\s)--(?:treenode-)?filter(?=$|[\s=-])/;
111
- /** As a flag, or inside `-p:TestingPlatformCommandLineArguments=…` (preceded by `=` or a quote). */
112
- const FLOOR_OR_IGNORE = /(^|[\s="'])--(?:ignore-exit-code|minimum-expected-tests)(?=$|[\s="'])/;
113
- const MTP_FRAMEWORKS = new Set(['xunit.v3', 'mstest', 'nunit']);
114
-
115
- const toPosix = (p) => String(p).replace(/\\/g, '/');
116
- const samePath = (a, b) => isInside(a, b) && isInside(b, a);
117
- const whole = (reason) => ({ command: null, selectors: [], zeroGuard: null, reason });
118
-
119
- const isFile = (path) => { try { return statSync(path).isFile(); } catch { return false; } };
120
- const isDirectory = (path) => { try { return statSync(path).isDirectory(); } catch { return false; } };
121
-
122
- /**
123
- * The on-disk spelling of a path inside the root (#87 F4). A mis-cased output
124
- * resolves on a case-insensitive disk and was forwarded verbatim; the `--linux`
125
- * container is case-sensitive, and jest `--runTestsByPath` skips a path it cannot
126
- * find while a sibling keeps the count above zero. `canonicalPath` (test-runner.js)
127
- * is the one spelling, and only its CASE is taken: a path it moves ELSEWHERE — a
128
- * symlink or junction, inside the root or out of it — keeps the plain spelling, so a
129
- * file never changes node (re-review N3: `web/src/shared` → `libs/shared` left the
130
- * `web` node narrowed past it). Case is compared by `samePathCase` (docker-audit.js),
131
- * the one copy of that rule.
132
- */
133
- function spelled(abs, rootAbs) {
134
- const real = canonicalPath(abs);
135
- return isInside(real, rootAbs) && samePathCase(real) === samePathCase(abs) ? real : abs;
136
- }
137
-
138
- /** The project root as on disk: every path compared with it is spelled the same way (F4). */
139
- const rootOf = (projectPath) => canonicalPath(resolve(projectPath));
140
- const cwdOf = (node, rootAbs) => spelled(resolve(rootAbs, node?.cwd || '.'), rootAbs);
141
-
142
- /**
143
- * Task outputs → the existing FILES and DIRECTORIES among them, absolute and
144
- * spelled as on disk; ghosts dropped. A directory is kept apart and never
145
- * narrows: which of its files the task touched cannot be told (forcedWhole).
146
- */
147
- function existingEntries(outputs, rootAbs) {
148
- const files = new Set();
149
- const dirs = new Set();
150
- for (const o of outputs || []) {
151
- if (typeof o !== 'string' || !o.trim()) continue;
152
- const abs = resolve(rootAbs, normalizePath(o.trim()));
153
- if (isFile(abs)) files.add(spelled(abs, rootAbs));
154
- else if (isDirectory(abs)) dirs.add(spelled(abs, rootAbs));
155
- }
156
- return { files: [...files], dirs: [...dirs] };
157
- }
158
-
159
- /** Is this file a test file for THIS runner? (A `.cs` file is nothing to a vitest node.) */
160
- function isTestFileFor(runner, fileAbs, rootAbs) {
161
- const js = JS_TEST_FILE.test(toPosix(relative(rootAbs, fileAbs)));
162
- if (runner === 'dotnet' || runner === 'evals') return dotnetTestProjectOf(fileAbs, rootAbs) !== null;
163
- if (['vitest', 'jest', 'node-test', 'playwright'].includes(runner)) return js;
164
- return js || dotnetTestProjectOf(fileAbs, rootAbs) !== null;
165
- }
166
-
167
- const readRaw = (path) => { try { return readFileSync(path, 'utf8'); } catch { return null; } };
168
-
169
- /**
170
- * An output the recogniser did NOT classify as a test file for this runner but
171
- * that READS like one in its raw text: a `.cs` carrying a test marker
172
- * (readsLikeTestSource — loose on purpose), or a JS/TS file with a top-level
173
- * `describe`/`it`/`test` call. Recognition is a heuristic (csproj name or packages
174
- * listed in the csproj itself; file-name pattern) — a touched test file it misses
175
- * would be dropped from `owned`, and the recognised files would narrow the node
176
- * PAST it: a green run that skipped a touched class. Such a file is named and the
177
- * node runs whole (and is never scoped out). Non-C# .NET sources: forcedWhole.
178
- */
179
- function unrecognisedTestFile(runner, fileAbs, rootAbs) {
180
- if (isTestFileFor(runner, fileAbs, rootAbs)) return false;
181
- const dotnet = runner === 'dotnet' || runner === 'evals';
182
- if (/\.cs$/i.test(fileAbs)) return dotnet && readsLikeTestSource(fileAbs);
183
- if (JS_SOURCE.test(fileAbs)) return !dotnet && JS_TEST_CALL.test(readRaw(fileAbs) ?? '');
184
- return false;
185
- }
186
-
187
- /**
188
- * An output the recorte cannot narrow on, for THIS node: the reason to run it
189
- * whole — and never scope it out — or null. The final review of branch B found
190
- * both shapes narrowing PAST touched tests:
191
- * - F1: an existing DIRECTORY overlapping the node's cwd or the folder of a
192
- * project a link names (either one containing the other). Dropped as "not a
193
- * file", it let the task's other files narrow the node while the tests inside
194
- * it never ran — and the count guard passed on the ones that did.
195
- * - F2 (dotnet): ANY non-`.cs` file this node may run — inside its cwd, or its
196
- * nearest project is one a link names — whose nearest project is a TEST project
197
- * (a bUnit `.razor`, a Reqnroll/SpecFlow `.feature`, a Verify `.verified.txt`, test
198
- * data; re-review N2: a list of source kinds let the `.feature` through), or that
199
- * reads like a test (readsLikeTestSource: `.razor`/`.cshtml`/`.fs`/`.fsi`/`.fsx`/`.vb`
200
- * outside a recognised test project). It yields no selector, and the tests it feeds
201
- * may sit in files the task did not touch, so a recorte skips them.
202
- */
203
- function forcedWhole(node, entries, cwdAbs, rootAbs) {
204
- const rel = (abs) => toPosix(relative(rootAbs, abs)) || '.';
205
- const targets = node?.runner === 'dotnet' ? extractCsprojTargets(node.command).map((t) => resolve(cwdAbs, t)) : [];
206
- const areas = [cwdAbs, ...targets.map((t) => dirname(t))];
207
- const dir = entries.dirs.find((d) => areas.some((a) => isInside(d, a) || isInside(a, d)));
208
- if (dir) {
209
- return `output "${rel(dir)}" is a directory — which of its files the task touched cannot be told, and a recorte would skip them; running it whole`;
210
- }
211
- if (node?.runner !== 'dotnet') return null;
212
- const source = entries.files.find((abs) => {
213
- if (/\.cs$/i.test(abs)) return false;
214
- const project = nearestProjectOf(abs, rootAbs);
215
- const mayRun = isInside(abs, cwdAbs) || (project !== null && targets.some((t) => samePath(t, project)));
216
- return mayRun && (inTestProject(abs, rootAbs) || readsLikeTestSource(abs));
217
- });
218
- if (source) {
219
- return `output file "${rel(source)}" is a test source the recorte cannot read — not C#, and it belongs to a test project or reads like a test; running it whole`;
220
- }
221
- return null;
222
- }
223
-
224
- /** The file belongs to a .NET test project: its nearest `.csproj`/`.fsproj`/`.vbproj` is one (N2). */
225
- function inTestProject(abs, rootAbs) {
226
- const project = nearestProjectOf(abs, rootAbs);
227
- return project !== null && isDotnetTestProject(project);
228
- }
229
-
230
- /**
231
- * The `.cs` outputs a LINK of the command owns through its `.csproj` — wherever
232
- * the file sits, recognised as a test file or not (the caller decides which of
233
- * the two questions it is asking). A link's ownership is decided by the link's
234
- * project, never by the node cwd: `cd tests/A.Tests && dotnet test --project
235
- * ../B.Tests/B.Tests.csproj` runs B's tests, so a touched `tests/B.Tests/XTests.cs`
236
- * belongs to that link and must select on it (round 5: it was dropped as
237
- * "outside the cwd", and the link with it — XTests never ran). A bare link keeps
238
- * the cwd rule: it runs what its cwd resolves, and a file outside is not its business.
239
- */
240
- function linkedOutputs(command, filesAbs, cwdAbs, rootAbs) {
241
- const targets = extractCsprojTargets(command).map((t) => resolve(cwdAbs, t));
242
- if (targets.length === 0) return [];
243
- return filesAbs.filter((abs) => {
244
- const csproj = nearestCsprojOf(abs, rootAbs);
245
- return csproj !== null && targets.some((t) => samePath(t, csproj));
246
- });
247
- }
248
-
249
- /**
250
- * The first project target a command names that is NOT an existing `.csproj`
251
- * inside the project root — as `extractCsprojTargets` spells it — or null.
252
- *
253
- * Link ownership is decided by the link's csproj (linkedOutputs), so a target
254
- * the recorte cannot resolve turns every ownership answer into a guess, and the
255
- * guess was "owns nothing": the link was DROPPED, and with it the touched class
256
- * (B3 final review — compiled, exit 0, the class never ran). Two shapes reach
257
- * here: `--project=PATH` / `--project:PATH`, which `extractCsprojTargets` returns
258
- * as ONE token, flag included (resolved, it names nothing), and a csproj OUTSIDE
259
- * the root, which `nearestCsprojOf` never walks to. `extractCsprojTargets` keeps
260
- * its semantics for its other callers; this only refuses what it cannot match.
261
- */
262
- function unresolvedProjectTarget(command, cwdAbs, rootAbs) {
263
- return extractCsprojTargets(command).find((target) => {
264
- const abs = resolve(cwdAbs, target);
265
- return !/\.csproj$/i.test(abs) || !isFile(abs) || !isInside(abs, rootAbs);
266
- }) ?? null;
267
- }
268
-
269
- const strayProject = (target) => whole(`command names project "${target}", which is not an existing .csproj inside the project root — the task's files cannot be matched to its link; running it whole`);
270
-
271
- /**
272
- * The flags of a link that names no csproj, ready to follow a re-pointed
273
- * project — or null when it names a target that cannot be re-pointed.
274
- * Re-pointing keeps the class filter off projects that do not own the class:
275
- * under MTP each of them exits 8, under VSTest each prints "No test matches the
276
- * given testcase filter" — a zero-test signature on a green run (false red).
277
- * MTP: a solution/folder target → null. VSTest: a `.sln` token is dropped (the
278
- * csproj replaces it); a folder → null. A target FLAG — `--solution`,
279
- * `--directory`, `--test-modules`, `--project` — → null in either spelling: as
280
- * its own token, or with its value after `=`/`:` in ONE token (final review C2:
281
- * that spelling was kept, the link re-pointed and filtered, and the `dotnet` CLI
282
- * refused the narrowed command — a red with no culprit test). A `--project` left
283
- * for this function never names a csproj: extractCsprojTargets took those, and
284
- * `--project=X.csproj` runs whole by unresolvedProjectTarget before reaching here.
285
- */
286
- function retargetFlags(link, mode, cwdAbs) {
287
- let refused = false;
288
- const flags = link.replace(/^dotnet\s+test/, '').replace(/(\s*)("[^"]*"|'[^']*'|\S+)/g, (all, _ws, raw) => {
289
- const value = raw.replace(/^(["'])(.*)\1$/, '$2');
290
- if (value.startsWith('-')) {
291
- if (/^--(?:solution|directory|test-modules|project)(?:[=:]|$)/.test(value)) refused = true;
292
- return all;
293
- }
294
- if (/\.slnx?f?$/i.test(value)) {
295
- if (mode === 'mtp') refused = true;
296
- return '';
297
- }
298
- if (isDirectory(resolve(cwdAbs, value))) refused = true;
299
- return all;
300
- });
301
- return refused ? null : flags;
302
- }
303
-
304
- function selectorArgs(mode, framework, classes) {
305
- // Namespace-free by construction (R1). Measured (test/fixtures/test-output/
306
- // mtp-filter-class-any-namespace*.txt): `--filter-class "*Outer*"` runs Outer in
307
- // EVERY namespace, its nested Outer+Inner classes and prefix siblings; the exact
308
- // name runs Outer alone (mtp-filter-class-exact-skips-nested.txt) and a prefix
309
- // without the namespace runs NOTHING (mtp-filter-class-prefix-without-namespace-
310
- // zero.txt, exit 8). `FullyQualifiedName~T` is a substring of the FQN: same superset.
311
- if (mode === 'mtp' && framework === 'xunit.v3') return classes.map((c) => `--filter-class "*${c}*"`).join(' ');
312
- return `--filter "${classes.map((c) => `FullyQualifiedName~${c}`).join('|')}"`;
313
- }
314
-
315
- const classesOf = (files) => [...new Set(files.flatMap((f) => f.types))];
316
-
317
- function narrowDotnet(command, owned, cwdAbs, rootAbs) {
318
- if (/[;|]/.test(command) || /(^|[^&])&(?!&)/.test(command)) return whole(SHAPE);
319
- const links = command.split('&&').map((l) => l.trim());
320
- if (links.some((l) => !/^dotnet\s+test(\s|$)/.test(l))) return whole(SHAPE);
321
- if (links.some((l) => EXISTING_FILTER.test(l))) return whole(FILTER_TAKEN);
322
- if (links.some((l) => /(^|\s)--(\s|$)/.test(l))) return whole('command passes arguments after "--" — running it whole');
323
- if (links.some((l) => FLOOR_OR_IGNORE.test(l))) {
324
- return whole('command carries --ignore-exit-code/--minimum-expected-tests — a recorte would be judged by the wrong exit; running it whole');
325
- }
326
- if (process.env.TESTINGPLATFORM_EXITCODE_IGNORE?.trim()) return whole(IGNORE_EXIT_ENV);
327
-
328
- const mode = dotnetTestMode(cwdAbs);
329
- if (mode === null) return whole('global.json unreadable — cannot tell VSTest from MTP; running it whole');
330
-
331
- const files = owned.map((abs) => ({ abs, inside: isInside(abs, cwdAbs), ...dotnetTestProjectOf(abs, rootAbs), ...csharpClasses(abs) }));
332
- // The PROJECT binds the run, in either mode. A floor exits 9 on a green
333
- // recorte: under MTP it is lowered to 1 by a `-p:` that re-passes the rest of
334
- // the value when floorOverride can read it from ONE place (#114); otherwise —
335
- // VSTest (the MTP bridge, not measured), a floor set elsewhere or conditionally
336
- // — the node runs whole. A declared case filter is REPLACED by an appended
337
- // --filter (`Category!=Eval` silently gone).
338
- const projects = [...new Set(files.map((f) => f.csproj))];
339
- const floors = new Map();
340
- for (const project of projects.filter((p) => imposesMinimumExpected(p))) {
341
- const value = mode === 'mtp' ? floorOverride(project) : null;
342
- if (value === null) return whole(MIN_EXPECTED);
343
- if (links.some((l) => /TestingPlatformCommandLineArguments/i.test(l))) return whole(COMMAND_SETS_ARGS);
344
- floors.set(project, value);
345
- }
346
- // `-p:` is global to ONE `dotnet test` invocation: it lowers the floor of the project
347
- // it was read from and REPLACES the arguments of every other project the link runs.
348
- const floorArg = (owners, projectCount) => {
349
- const value = owners.map((c) => floors.get(c)).find((v) => v !== undefined);
350
- if (value === undefined) return '';
351
- return projectCount === 1 ? ` "-p:TestingPlatformCommandLineArguments=${value}"` : null;
352
- };
353
- if (projects.some((p) => ignoresExitCode(p))) return whole(IGNORE_EXIT_PROJECT);
354
- if (projects.some((p) => declaresCaseFilter(p))) return whole(CASE_FILTER);
355
- if (mode === 'mtp') {
356
- const other = files.find((f) => !MTP_FRAMEWORKS.has(f.framework));
357
- if (other) return whole(`MTP test project uses a framework with no known class filter (${other.framework ?? 'unknown'}) — running it whole`);
358
- }
359
-
360
- // Selectors are the files' BARE type names at any depth, matched namespace-free
361
- // (`--filter-class "*T*"` / `FullyQualifiedName~T`): no namespace, scope or depth
362
- // reading can misname a type, and a misread only ADDS a selector. A MISSED type
363
- // is what must never happen — and `scannable` means exactly that every type
364
- // keyword of the RAW text is a named declaration (csharpClasses states the
365
- // guarantee and its limits); else the file is untrusted and never narrows.
366
- const unscannable = files.find((f) => !f.scannable);
367
- if (unscannable) {
368
- const rel = toPosix(relative(rootAbs, unscannable.abs));
369
- return whole(`test file "${rel}" could not be scanned safely (${unscannable.why}) — running it whole`);
370
- }
371
- // R3: an abstract class's tests run under the names of its DERIVED classes —
372
- // declared in files the task may not have touched, so no selector here names them.
373
- const abstractFile = files.find((f) => f.abstract.length > 0);
374
- if (abstractFile) {
375
- const rel = toPosix(relative(rootAbs, abstractFile.abs));
376
- return whole(`test file "${rel}" declares an abstract class (${abstractFile.abstract.join(', ')}) — its tests run under derived classes' names, possibly in files the task did not touch; running it whole`);
377
- }
378
- if (!files.some((f) => f.hasTestClass)) return whole('no test class found in the task\'s test files — running it whole');
379
- // Round 5: a test-project file WITHOUT a test attribute is a helper, fake or
380
- // fixture. Its types select nothing (`*FakeClock*` → exit 8, a red no test
381
- // earned) while the tests that USE it live in files the task did not touch.
382
- const helper = files.find((f) => !f.hasTestAttribute);
383
- if (helper) {
384
- const rel = toPosix(relative(rootAbs, helper.abs));
385
- return whole(`test file "${rel}" carries no test attribute ([Fact]/[Theory]/[Test…]) — a helper, fake or fixture whose tests live in files the task did not touch; running it whole`);
386
- }
387
- const unsafe = classesOf(files).find((c) => !CLASS_NAME.test(c));
388
- if (unsafe) return whole(`type name "${unsafe}" is not safe to pass as a filter — running it whole`);
389
-
390
- const out = [];
391
- for (const link of links) {
392
- const targets = extractCsprojTargets(link).map((t) => resolve(cwdAbs, t));
393
- if (targets.length > 0) {
394
- const mine = files.filter((f) => targets.some((t) => samePath(t, f.csproj)));
395
- const classes = classesOf(mine);
396
- if (classes.length > 0) {
397
- const floor = floorArg([...new Set(mine.map((f) => f.csproj))], targets.length);
398
- if (floor === null) return whole(FLOOR_SHARED);
399
- out.push(`${link} ${selectorArgs(mode, mine[0].framework, classes)}${floor}`);
400
- }
401
- continue; // a link whose project owns none of the files is dropped
402
- }
403
- const flags = retargetFlags(link, mode, cwdAbs);
404
- if (flags === null) return whole('command targets a solution or folder the recorte cannot re-point — running it whole');
405
- // A bare link runs what its cwd resolves: re-point it to the projects of the
406
- // files INSIDE the cwd only (a linked file outside belongs to its own link).
407
- for (const csproj of [...new Set(files.filter((f) => f.inside).map((f) => f.csproj))]) {
408
- const rel = toPosix(relative(cwdAbs, csproj));
409
- if (UNSAFE_IN_QUOTES.test(rel)) return whole(`path "${rel}" has a character unsafe inside quotes — running it whole`);
410
- const mine = files.filter((f) => f.csproj === csproj && f.inside);
411
- const classes = classesOf(mine);
412
- const target = mode === 'mtp' ? `--project "${rel}"` : `"${rel}"`;
413
- if (classes.length > 0) out.push(`dotnet test ${target}${flags} ${selectorArgs(mode, mine[0].framework, classes)}${floorArg([csproj], 1)}`);
414
- }
415
- }
416
- if (out.length === 0) {
417
- return whole('no link of the command runs the test project owning the task\'s test files — running it whole');
418
- }
419
- return {
420
- command: out.join(' && '),
421
- selectors: classesOf(files),
422
- zeroGuard: mode === 'mtp' ? 'mtp-exit-8' : 'count',
423
- reason: null,
424
- };
425
- }
426
-
427
- function narrowJs(runner, command, owned, cwdAbs) {
428
- const single = !/[;&|]/.test(command) && !/^\s*(?:npm|pnpm|yarn|bun)\s+run(?:-script)?\b/.test(command);
429
- const shaped = runner === 'vitest'
430
- ? /\bvitest\b/.test(command) && /(^|\s)(?:--)?run(\s|$)/.test(command)
431
- : /\bjest\b/.test(command);
432
- if (!single || !shaped) return whole(SHAPE);
433
- const rels = owned.map((abs) => toPosix(relative(cwdAbs, abs)));
434
- const unsafe = rels.find((r) => UNSAFE_IN_QUOTES.test(r));
435
- if (unsafe) return whole(`path "${unsafe}" has a character unsafe inside quotes — running it whole`);
436
- const quoted = rels.map((r) => `"${r}"`).join(' ');
437
- return {
438
- command: `${command.trim()} ${runner === 'jest' ? `--runTestsByPath ${quoted}` : quoted}`,
439
- selectors: rels,
440
- zeroGuard: 'count',
441
- reason: null,
442
- };
443
- }
444
-
445
- /**
446
- * Narrow ONE node's command to the task's test files it owns.
447
- *
448
- * @param {import('./test-nodes.js').TestNode} node
449
- * @param {string[]} testFiles - Task output paths (project-relative or absolute),
450
- * spelled as on disk before anything reads them (F4). Only EXISTING files that
451
- * are test files for its runner are narrowed on: those inside the node cwd, plus
452
- * (dotnet) those a link's `.csproj` owns wherever they sit (linkedOutputs). The
453
- * node runs whole rather than skip anything when an output is a directory it
454
- * may hold, or (dotnet) a non-C# test source it may compile (forcedWhole); when
455
- * a candidate — inside the cwd OR compiled by a link's `.csproj` — merely READS
456
- * like a test file (see unrecognisedTestFile); and when a dotnet command names a
457
- * project that is not an existing `.csproj` inside the root
458
- * (unresolvedProjectTarget): no file could be matched to that link at all.
459
- * @param {string} projectPath
460
- * @returns {{ command: string|null, selectors: string[], zeroGuard: 'count'|'mtp-exit-8'|null, reason: string|null }}
461
- * `command` null ⇒ run the declared command whole, and `reason` says why.
462
- */
463
- export function narrowNode(node, testFiles, projectPath = '.') {
464
- const rootAbs = rootOf(projectPath);
465
- const cwdAbs = cwdOf(node, rootAbs);
466
- if (!['dotnet', 'vitest', 'jest'].includes(node?.runner)) {
467
- return whole(`runner "${node?.runner}" has no in-node narrowing — running it whole`);
468
- }
469
- if (typeof node.command !== 'string' || !node.command.trim()) return whole('node has no command to narrow');
470
- if (node.runner === 'dotnet') {
471
- const stray = unresolvedProjectTarget(node.command, cwdAbs, rootAbs);
472
- if (stray) return strayProject(stray);
473
- }
474
- const entries = existingEntries(testFiles, rootAbs);
475
- const forced = forcedWhole(node, entries, cwdAbs, rootAbs);
476
- if (forced) return whole(forced);
477
- const existing = entries.files;
478
- // This node's candidates: inside its cwd, plus (dotnet) whatever a link's csproj compiles.
479
- const candidates = existing.filter((abs) => isInside(abs, cwdAbs));
480
- if (node.runner === 'dotnet') {
481
- for (const abs of linkedOutputs(node.command, existing, cwdAbs, rootAbs)) if (!candidates.includes(abs)) candidates.push(abs);
482
- }
483
- const missed = candidates.find((abs) => unrecognisedTestFile(node.runner, abs, rootAbs));
484
- if (missed) {
485
- return whole(`output file "${toPosix(relative(rootAbs, missed))}" reads like a test file but is not recognised as one for runner "${node.runner}" — a recorte would skip it; running it whole`);
486
- }
487
- const owned = candidates.filter((abs) => isTestFileFor(node.runner, abs, rootAbs));
488
- if (owned.length === 0) return whole(NO_TEST_FILE);
489
- return node.runner === 'dotnet'
490
- ? narrowDotnet(node.command.trim(), owned, cwdAbs, rootAbs)
491
- : narrowJs(node.runner, node.command, owned, cwdAbs);
492
- }
493
-
494
- /**
495
- * How the node set was chosen, for the `--json` summary.
496
- * `feature` scope has none; `full`/`filter` switch group selection off.
497
- */
498
- function derivationOf({ full, filter, group, nodes }) {
499
- if (full) return 'full';
500
- if (filter) return 'filter';
501
- if (group === 'docs') return 'docs';
502
- if (!group) return 'no-group';
503
- return nodes.some((n) => effectiveGroups(n)?.includes(group)) ? 'group' : 'unclaimed-group';
504
- }
505
-
506
- /**
507
- * Scope a test plan to one task.
508
- *
509
- * - Feature scope (`taskId` empty): nodes returned as copies, deep-equal to the
510
- * input — no `selection` key at all (readers take absent as null).
511
- * - `full`: every node `mode: 'full'`, commands untouched.
512
- * - `filter`: the caller's `--filter` picks the nodes; here it only switches
513
- * group selection OFF — narrowing inside the nodes still applies.
514
- * - Otherwise: group `docs` runs only nodes owning an output file; a group some
515
- * node claims (`effectiveGroups`) runs the nodes serving it, the unmapped ones
516
- * (null) and any node owning one of the task's TEST files (safety net for a
517
- * mis-grouped task); an unclaimed or missing group runs every node.
518
- *
519
- * Input nodes are never mutated.
520
- *
521
- * @param {Object} args
522
- * @param {string} args.projectPath
523
- * @param {import('./test-nodes.js').TestNode[]} args.nodes
524
- * @param {{ group?: string, outputs?: string[] }|null} args.task
525
- * @param {string|null} args.taskId
526
- * @param {{ raw: string, tokens: string[] }|null} [args.filter]
527
- * @param {boolean} [args.full]
528
- * @returns {{ nodes: Array<import('./test-nodes.js').TestNode & { selection?: TestSelection }>,
529
- * summary: { scope: 'feature'|'task', derivation: string|null, group: string|null, testFiles: string[] } }}
530
- */
531
- export function applyTaskScope({ projectPath = '.', nodes = [], task = null, taskId = null, filter = null, full = false } = {}) {
532
- if (!taskId) {
533
- return { nodes: nodes.map((n) => ({ ...n })), summary: { scope: 'feature', derivation: null, group: null, testFiles: [] } };
534
- }
535
- const rootAbs = rootOf(projectPath);
536
- const rel = (abs) => toPosix(relative(rootAbs, abs));
537
- const group = typeof task?.group === 'string' ? task.group : null;
538
- const taskOutputs = Array.isArray(task?.outputs) ? task.outputs : [];
539
- const entries = existingEntries(taskOutputs, rootAbs);
540
- const outputs = entries.files;
541
- const testFiles = outputs.filter((abs) => isTestFileFor(null, abs, rootAbs));
542
- const derivation = derivationOf({ full, filter, group, nodes });
543
-
544
- const scoped = nodes.map((node) => {
545
- const cwdAbs = cwdOf(node, rootAbs);
546
- const ownOutputs = outputs.filter((abs) => isInside(abs, cwdAbs));
547
- // Dotnet: a file a LINK's csproj compiles is this node's too, wherever it sits.
548
- if (node.runner === 'dotnet') {
549
- for (const abs of linkedOutputs(node.command, outputs, cwdAbs, rootAbs)) if (!ownOutputs.includes(abs)) ownOutputs.push(abs);
550
- }
551
- // A link naming a project the recorte cannot resolve may compile ANY touched
552
- // .NET source outside the cwd, run any test project's file there (N2), or
553
- // anything inside a touched folder there: its ownership is unknowable, so the
554
- // node is never scoped out past one (narrowNode then runs it whole, naming it).
555
- const mayOwnOutside = node.runner === 'dotnet'
556
- && unresolvedProjectTarget(node.command, cwdAbs, rootAbs) !== null
557
- && (outputs.some((abs) => !isInside(abs, cwdAbs) && (isDotnetSource(abs) || inTestProject(abs, rootAbs)))
558
- || entries.dirs.some((d) => !isInside(d, cwdAbs)));
559
- // F1/F2: a folder it may hold, or a non-C# test source it may compile.
560
- const forced = forcedWhole(node, entries, cwdAbs, rootAbs) !== null;
561
- const ownTests = ownOutputs.filter((abs) => isTestFileFor(node.runner, abs, rootAbs));
562
- // A test-looking output the recogniser missed counts as owned for the pull-in
563
- // too: scoping the node out would skip it just as surely as narrowing past it.
564
- const ownsTests = ownTests.length > 0 || mayOwnOutside || forced
565
- || ownOutputs.some((abs) => unrecognisedTestFile(node.runner, abs, rootAbs));
566
- const select = (selection, command = node.command) => ({
567
- ...node,
568
- command,
569
- selection: {
570
- declaredCommand: node.command ?? null, files: ownTests.map(rel), selectors: [], zeroGuard: null, ...selection,
571
- },
572
- });
573
-
574
- if (derivation === 'full') return select({ mode: 'full', reason: '--full: task-scope narrowing switched off — running it whole' });
575
- // The resolver already decided (invalid config, "no suites"): preDecideRun
576
- // reports that verdict first, and a recorte would only disguise it.
577
- if (node.status) return select({ mode: 'whole', reason: node.reason ?? `pre-decided: ${node.status}` });
578
-
579
- // Ownership for docs is the SAME rule as for links (B3 carry-over): the cwd
580
- // contains the file OR a link's csproj compiles it — ownOutputs holds both;
581
- // a folder or non-C# test source it may run (forced) keeps it too.
582
- if (derivation === 'docs' && ownOutputs.length === 0 && !mayOwnOutside && !forced) {
583
- return select({ mode: 'out-of-scope', files: [], reason: 'out of task scope: task group "docs" runs only the nodes owning a file from the task\'s outputs' });
584
- }
585
- const groups = effectiveGroups(node);
586
- if (derivation === 'group' && groups !== null && !groups.includes(group) && !ownsTests) {
587
- return select({ mode: 'out-of-scope', files: [], reason: `out of task scope: node serves [${groups.join(', ')}], task group is "${group}"` });
588
- }
589
-
590
- // Every output: narrowNode keeps the cwd rule itself and adds the link-owned files.
591
- const narrowed = narrowNode(node, taskOutputs, rootAbs);
592
- if (narrowed.command === null) return select({ mode: 'whole', reason: narrowed.reason });
593
- return select({ mode: 'narrowed', reason: null, selectors: narrowed.selectors, zeroGuard: narrowed.zeroGuard }, narrowed.command);
594
- });
595
-
596
- return { nodes: scoped, summary: { scope: 'task', derivation, group, testFiles: testFiles.map(rel) } };
597
- }