@polymorphism-tech/morph-spec 8.47.0 → 9.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (377) hide show
  1. package/CLAUDE.md +51 -43
  2. package/README.md +39 -38
  3. package/bin/morph-spec.js +41 -113
  4. package/bin/validate.js +1 -147
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +1 -1
  7. package/docs/QUICKSTART.md +1 -1
  8. package/framework/CLAUDE.md +41 -101
  9. package/framework/MORPH.md +95 -252
  10. package/framework/agents.json +86 -21
  11. package/framework/commands/morph-apply.md +5 -28
  12. package/framework/commands/morph-archive.md +1 -1
  13. package/framework/commands/morph-hotfix.md +8 -8
  14. package/framework/commands/morph-infra.md +1 -1
  15. package/framework/commands/morph-preflight.md +2 -3
  16. package/framework/commands/morph-status.md +4 -4
  17. package/framework/docs/CLI.md +574 -286
  18. package/framework/evals/review-checklist.md +82 -41
  19. package/framework/hooks/README.md +10 -11
  20. package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +6 -49
  21. package/framework/hooks/claude-code/core/block-raw-worktree-add.js +2 -35
  22. package/framework/hooks/claude-code/core/chain.js +4 -65
  23. package/framework/hooks/claude-code/core/comment-guard.js +3 -17
  24. package/framework/hooks/claude-code/core/denylist.json +33 -3
  25. package/framework/hooks/claude-code/core/enforce-phase-writes.js +15 -63
  26. package/framework/hooks/claude-code/core/gate-guard.js +9 -78
  27. package/framework/hooks/claude-code/core/graph-refresh-kick.js +3 -24
  28. package/framework/hooks/claude-code/core/heavy-gate.js +3 -21
  29. package/framework/hooks/claude-code/core/heavy-release.js +2 -13
  30. package/framework/hooks/claude-code/core/loop-detect.js +2 -31
  31. package/framework/hooks/claude-code/core/pre-bash-denylist.js +36 -2
  32. package/framework/hooks/claude-code/core/protect-spec-files.js +25 -297
  33. package/framework/hooks/claude-code/core/state-sync.js +15 -85
  34. package/framework/hooks/claude-code/core/telemetry-log.js +15 -96
  35. package/framework/hooks/claude-code/core/validate-completion.js +37 -147
  36. package/framework/hooks/claude-code/core/warn-bash-source-write.js +23 -0
  37. package/framework/hooks/claude-code/core/worktree-context.js +15 -114
  38. package/framework/hooks/claude-code/core/worktree-create.js +6 -70
  39. package/framework/hooks/claude-code/core/worktree-env-inject.js +5 -56
  40. package/framework/hooks/claude-code/core/worktree-remove.js +5 -31
  41. package/framework/hooks/claude-code/statusline.py +55 -41
  42. package/framework/hooks/package.json +3 -0
  43. package/framework/hooks/shared/activity-logger.js +9 -89
  44. package/framework/hooks/shared/agent-reap.js +2 -17
  45. package/framework/hooks/shared/build-check.js +11 -76
  46. package/framework/hooks/shared/claim-registry.js +9 -119
  47. package/framework/hooks/shared/command-tokens.js +168 -44
  48. package/framework/hooks/shared/comment-rules.js +5 -20
  49. package/framework/hooks/shared/comment-scanner.js +11 -43
  50. package/framework/hooks/shared/comment-stripper.js +11 -85
  51. package/framework/hooks/shared/e2e-check.js +0 -0
  52. package/framework/hooks/shared/errata.js +59 -0
  53. package/framework/hooks/shared/feature-locator.js +11 -95
  54. package/framework/hooks/shared/frontend-locations.js +4 -0
  55. package/framework/hooks/shared/gate-question.js +12 -0
  56. package/framework/hooks/shared/git-exclude.js +3 -20
  57. package/framework/hooks/shared/git-paths.js +8 -104
  58. package/framework/hooks/shared/graph-freshness.js +22 -166
  59. package/framework/hooks/shared/graph-refresh.js +21 -149
  60. package/framework/hooks/shared/heavy-command.js +4 -12
  61. package/framework/hooks/shared/hook-response.js +8 -83
  62. package/framework/hooks/shared/morph-cli.js +8 -52
  63. package/framework/hooks/shared/payload-utils.js +10 -132
  64. package/framework/hooks/shared/phase-utils.js +14 -72
  65. package/framework/hooks/shared/redact-secrets.js +4 -15
  66. package/framework/hooks/shared/reparse-scan.js +6 -66
  67. package/framework/hooks/shared/slice-progress.js +99 -0
  68. package/framework/hooks/shared/source-files.js +4 -17
  69. package/framework/hooks/shared/spawn-command.js +23 -0
  70. package/framework/hooks/shared/state-reader.js +51 -247
  71. package/framework/hooks/shared/state-writer.js +9 -54
  72. package/framework/hooks/shared/stdin-reader.js +1 -11
  73. package/framework/hooks/shared/telemetry-logger.js +7 -67
  74. package/framework/hooks/shared/transcript-usage.js +7 -29
  75. package/framework/hooks/shared/worktree-root.js +7 -29
  76. package/framework/mcp/registry.json +3 -4
  77. package/framework/phases.json +23 -39
  78. package/framework/rules/csharp-standards.md +1 -1
  79. package/framework/rules/frontend-standards.md +1 -0
  80. package/framework/rules/hangfire-standards.md +1 -1
  81. package/framework/rules/neon-standards.md +1 -1
  82. package/framework/schemas/config.schema.json +14 -9
  83. package/framework/schemas/feature.schema.json +15 -50
  84. package/framework/skills/README.md +3 -4
  85. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  86. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  87. package/framework/skills/core/morph-apply/SKILL.md +149 -222
  88. package/framework/skills/core/morph-hotfix/SKILL.md +33 -42
  89. package/framework/skills/core/morph-machine/SKILL.md +2 -2
  90. package/framework/skills/core/morph-media/SKILL.md +1 -1
  91. package/framework/skills/core/morph-plan/SKILL.md +137 -299
  92. package/framework/skills/core/morph-review/SKILL.md +111 -136
  93. package/framework/skills/core/morph-standards/SKILL.md +27 -39
  94. package/framework/skills/core/morph-uiux/SKILL.md +94 -152
  95. package/framework/standards/STANDARDS.json +109 -1219
  96. package/framework/standards/STANDARDS.meta.json +1319 -0
  97. package/framework/standards/ai-agents/agent-archetypes.md +2 -2
  98. package/framework/standards/ai-agents/agent-session.md +1 -1
  99. package/framework/standards/ai-agents/conversational-agent-with-phases.md +4 -4
  100. package/framework/standards/ai-agents/durable-workflows-hitl.md +3 -3
  101. package/framework/standards/ai-agents/evals-with-cache.md +1 -2
  102. package/framework/standards/ai-agents/guardrails.md +1 -1
  103. package/framework/standards/ai-agents/multi-agent-patterns.md +2 -2
  104. package/framework/standards/ai-agents/observability-patterns.md +1 -1
  105. package/framework/standards/ai-agents/production.md +1 -1
  106. package/framework/standards/ai-agents/prompt-sources.md +2 -2
  107. package/framework/standards/ai-agents/providers/model-registry.md +1 -1
  108. package/framework/standards/ai-agents/rag-custom-pgvector.md +2 -2
  109. package/framework/standards/ai-agents/setup.md +3 -3
  110. package/framework/standards/ai-agents/sweet-spot.md +2 -2
  111. package/framework/standards/ai-agents/tool-adapters.md +0 -2
  112. package/framework/standards/ai-agents/vector-data-extensions.md +4 -4
  113. package/framework/standards/ai-agents/workflows.md +1 -1
  114. package/framework/standards/architecture/code-comments.md +7 -7
  115. package/framework/standards/architecture/vertical-slice/vertical-slice.md +1 -1
  116. package/framework/standards/backend/api/validation.md +1 -1
  117. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +5 -5
  118. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +1 -1
  119. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +2 -2
  120. package/framework/standards/data/nosql/cache/redis.md +1 -1
  121. package/framework/standards/data/nosql/cosmos-db.md +3 -3
  122. package/framework/standards/frontend/admin-app/forms.md +1 -1
  123. package/framework/standards/frontend/design-system/art-direction.md +88 -0
  124. package/framework/standards/frontend/design-system/premium-finish.md +77 -2
  125. package/framework/standards/infrastructure/neon/neon-pgvector.md +5 -5
  126. package/framework/standards/integration/mcp/mcp-tools.md +7 -7
  127. package/framework/templates/CLAUDE.md.core.template +3 -3
  128. package/framework/templates/README.md +2 -3
  129. package/framework/templates/REGISTRY.json +15 -179
  130. package/framework/templates/docs/plan.md +72 -0
  131. package/framework/templates/docs/ui-mockups.md +4 -0
  132. package/framework/templates/dotnet/ai-kit/README.md +1 -9
  133. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +0 -10
  134. package/framework/templates/meta-prompts/squad-leaders/backend-squad.md +1 -1
  135. package/framework/templates/meta-prompts/squad-leaders/frontend-squad.md +1 -1
  136. package/framework/templates/meta-prompts/squad-leaders/squad-leader.md +1 -1
  137. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +3 -4
  138. package/package.json +1 -1
  139. package/src/commands/context/context-refresh.js +0 -16
  140. package/src/commands/dashboard/dashboard.js +2 -8
  141. package/src/commands/e2e/e2e.js +18 -105
  142. package/src/commands/graph/affected.js +0 -14
  143. package/src/commands/graph/build.js +11 -50
  144. package/src/commands/graph/explain.js +0 -16
  145. package/src/commands/graph/status.js +1 -12
  146. package/src/commands/mcp/mcp-setup.js +4 -62
  147. package/src/commands/observability/cost.js +17 -111
  148. package/src/commands/observability/telemetry.js +1 -14
  149. package/src/commands/project/branch.js +4 -45
  150. package/src/commands/project/claim.js +4 -48
  151. package/src/commands/project/doctor-procs.js +1 -1
  152. package/src/commands/project/doctor.js +53 -307
  153. package/src/commands/project/env.js +1 -27
  154. package/src/commands/project/fleet.js +6 -35
  155. package/src/commands/project/heavy.js +3 -3
  156. package/src/commands/project/init.js +8 -68
  157. package/src/commands/project/install-plugin-cmd.js +0 -1
  158. package/src/commands/project/setup-infra-cmd.js +2 -1
  159. package/src/commands/project/stack-scan.js +58 -0
  160. package/src/commands/project/standards.js +65 -72
  161. package/src/commands/project/status.js +28 -121
  162. package/src/commands/project/update.js +31 -223
  163. package/src/commands/project/worktree.js +109 -883
  164. package/src/commands/scaffold/scaffold.js +0 -25
  165. package/src/commands/state/advance.js +65 -120
  166. package/src/commands/state/approve.js +87 -229
  167. package/src/commands/state/archive.js +23 -139
  168. package/src/commands/state/auto-approval.js +65 -0
  169. package/src/commands/state/create.js +49 -109
  170. package/src/commands/state/delete.js +11 -63
  171. package/src/commands/state/finish.js +105 -494
  172. package/src/commands/state/gate-check.js +74 -183
  173. package/src/commands/state/gate-decision.js +8 -42
  174. package/src/commands/state/handoff.js +4 -31
  175. package/src/commands/state/list.js +21 -91
  176. package/src/commands/state/report-check.js +134 -338
  177. package/src/commands/state/retype.js +12 -28
  178. package/src/commands/validation/index.js +0 -3
  179. package/src/commands/validation/mutate.js +79 -19
  180. package/src/commands/validation/plan-check.js +81 -0
  181. package/src/commands/validation/validate-feature.js +52 -216
  182. package/src/commands/validation/verify.js +45 -160
  183. package/src/core/paths/output-schema.js +9 -155
  184. package/src/core/state/feature-identity.js +1 -25
  185. package/src/core/state/gate-order.js +2 -30
  186. package/src/core/state/lifecycle-audit.js +8 -100
  187. package/src/core/state/state-manager.js +32 -395
  188. package/src/core/templates/template-registry.js +2 -97
  189. package/src/core/templates/template-renderer.js +13 -93
  190. package/src/core/templates/template-seed.js +21 -0
  191. package/src/core/work-types.js +10 -45
  192. package/src/dashboard/app/App.jsx +1 -8
  193. package/src/dashboard/app/components/ActivityFeed.jsx +2 -8
  194. package/src/dashboard/app/components/CommandPalette.jsx +4 -11
  195. package/src/dashboard/app/components/DetailPanel.jsx +1 -5
  196. package/src/dashboard/app/components/EntityLink.jsx +0 -12
  197. package/src/dashboard/app/components/ErrorBoundary.jsx +1 -8
  198. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +0 -11
  199. package/src/dashboard/app/components/Markdown.jsx +0 -9
  200. package/src/dashboard/app/components/PhaseRail.jsx +0 -11
  201. package/src/dashboard/app/components/SearchFilter.jsx +0 -4
  202. package/src/dashboard/app/components/Sidebar.jsx +0 -5
  203. package/src/dashboard/app/hooks/useMorphQuery.js +1 -11
  204. package/src/dashboard/app/hooks/usePref.js +1 -5
  205. package/src/dashboard/app/lib/format.js +0 -2
  206. package/src/dashboard/app/lib/phases.js +3 -9
  207. package/src/dashboard/app/lib/project.js +0 -12
  208. package/src/dashboard/app/pages/Activity.jsx +1 -1
  209. package/src/dashboard/app/pages/AgentMap.jsx +1 -8
  210. package/src/dashboard/app/pages/FeatureDetail.jsx +139 -133
  211. package/src/dashboard/app/pages/Features.jsx +9 -16
  212. package/src/dashboard/app/pages/Guide.jsx +2 -3
  213. package/src/dashboard/app/pages/HookInspector.jsx +2 -23
  214. package/src/dashboard/app/pages/PhasePipeline.jsx +3 -7
  215. package/src/dashboard/app/pages/Portfolio.jsx +0 -1
  216. package/src/dashboard/app/pages/RulesMap.jsx +1 -16
  217. package/src/dashboard/app/pages/SkillsLibrary.jsx +1 -16
  218. package/src/dashboard/app/pages/Standards.jsx +2 -17
  219. package/src/dashboard/scanner/activity-scanner.js +14 -83
  220. package/src/dashboard/scanner/agents-scanner.js +1 -23
  221. package/src/dashboard/scanner/docs-scanner.js +1 -7
  222. package/src/dashboard/scanner/feature-status.js +2 -5
  223. package/src/dashboard/scanner/features-scanner.js +92 -113
  224. package/src/dashboard/scanner/hooks-scanner.js +5 -28
  225. package/src/dashboard/scanner/index.js +0 -4
  226. package/src/dashboard/scanner/portfolio.js +5 -29
  227. package/src/dashboard/scanner/project-api.js +8 -54
  228. package/src/dashboard/scanner/rules-scanner.js +1 -2
  229. package/src/dashboard/scanner/skills-scanner.js +2 -12
  230. package/src/dashboard/scanner/standards-scanner.js +4 -7
  231. package/src/dashboard/vite-plugin-morph-data.js +5 -19
  232. package/src/lib/ai/ai-checks.js +7 -52
  233. package/src/lib/ai/ai-project-scan.js +14 -108
  234. package/src/lib/ai/code-audit.js +7 -63
  235. package/src/lib/ai/pin-drift.js +8 -51
  236. package/src/lib/ai/registry-audit.js +11 -90
  237. package/src/lib/ci/ci-verify.js +14 -13
  238. package/src/lib/ci/github-ci.js +9 -37
  239. package/src/lib/ci/workflow-scaffold.js +2 -10
  240. package/src/lib/detectors/claude-config-detector.js +13 -74
  241. package/src/lib/detectors/index.js +8 -59
  242. package/src/lib/e2e/compose-env.js +5 -66
  243. package/src/lib/e2e/stack-teardown.js +6 -40
  244. package/src/lib/generators/architecture-scanner.js +18 -156
  245. package/src/lib/generators/handoff-generator.js +37 -112
  246. package/src/lib/generators/scaffold-generator.js +5 -101
  247. package/src/lib/generators/settings-generator.js +11 -122
  248. package/src/lib/git/branch-classifier.js +11 -84
  249. package/src/lib/git/default-branch.js +14 -169
  250. package/src/lib/git/evidence-staleness.js +2 -1
  251. package/src/lib/git/gh.js +2 -17
  252. package/src/lib/git/pending-prs.js +6 -37
  253. package/src/lib/git/pr-files.js +3 -28
  254. package/src/lib/git/pr-index.js +3 -34
  255. package/src/lib/git/tree-fingerprint.js +17 -17
  256. package/src/lib/git/tree-state.js +6 -33
  257. package/src/lib/graph/god-nodes.js +2 -22
  258. package/src/lib/graph/graph-health.js +3 -24
  259. package/src/lib/graph/graph-loader.js +5 -56
  260. package/src/lib/graph/graphify-runtime.js +7 -37
  261. package/src/lib/graph/graphifyignore.js +8 -74
  262. package/src/lib/graph/sanitize.js +16 -136
  263. package/src/lib/heavy/cleanup.js +2 -6
  264. package/src/lib/installers/effects.js +8 -93
  265. package/src/lib/installers/mcp-installer.js +9 -169
  266. package/src/lib/installers/memory-installer.js +8 -33
  267. package/src/lib/installers/templates-reconcile.js +5 -60
  268. package/src/lib/mutation/commands.js +3 -2
  269. package/src/lib/mutation/dotnet-introspection.js +281 -0
  270. package/src/lib/mutation/plan.js +14 -6
  271. package/src/lib/mutation/runner.js +1 -1
  272. package/src/lib/mutation/subset.js +47 -13
  273. package/src/lib/plan/artifact-vocabulary.js +40 -0
  274. package/src/lib/plan/checks.js +208 -0
  275. package/src/lib/plan/plan-parser.js +204 -0
  276. package/src/lib/plan/plan-template.js +18 -0
  277. package/src/lib/projects/registry.js +7 -44
  278. package/src/lib/review-reports.js +204 -0
  279. package/src/lib/risk-detector.js +131 -274
  280. package/src/lib/specs/adr-index.js +38 -85
  281. package/src/lib/specs/design-seed.js +18 -0
  282. package/src/lib/specs/frontmatter.js +31 -0
  283. package/src/lib/specs/gfm-table.js +11 -0
  284. package/src/lib/specs/revocations.js +31 -102
  285. package/src/lib/specs/screen-checklist.js +56 -132
  286. package/src/lib/stack/dotnet-build-ignores.js +43 -0
  287. package/src/lib/stack/stack-profile.js +1 -28
  288. package/src/lib/stack/stack-scan.js +260 -0
  289. package/src/lib/stack-filter.js +4 -27
  290. package/src/lib/standards/registry.js +37 -0
  291. package/src/lib/standards/sections.js +47 -0
  292. package/src/lib/standards/verified-against.js +19 -191
  293. package/src/lib/tasks/eval-node.js +23 -179
  294. package/src/lib/tasks/linux-runner.js +18 -142
  295. package/src/lib/tasks/test-nodes.js +43 -313
  296. package/src/lib/tasks/test-runner.js +107 -447
  297. package/src/lib/telemetry/metrics.js +13 -103
  298. package/src/lib/telemetry/telemetry-reader.js +13 -238
  299. package/src/lib/telemetry/verify-event.js +4 -34
  300. package/src/lib/validators/code-budget/code-budget-validator.js +4 -44
  301. package/src/lib/validators/code-budget/index.js +0 -7
  302. package/src/lib/validators/contracts/contract-compliance-validator.js +4 -26
  303. package/src/lib/validators/contracts/index.js +0 -6
  304. package/src/lib/validators/nextjs/index.js +0 -5
  305. package/src/lib/validators/nextjs/next-component-validator.js +4 -81
  306. package/src/lib/validators/packages/index.js +0 -6
  307. package/src/lib/validators/packages/package-validator.js +6 -56
  308. package/src/lib/validators/reuse/index.js +0 -8
  309. package/src/lib/validators/reuse/reuse-validator.js +24 -137
  310. package/src/lib/validators/reuse/symbol-scanner.js +6 -62
  311. package/src/lib/validators/traceability-check.js +54 -156
  312. package/src/lib/validators/validation-runner.js +20 -125
  313. package/src/lib/validators/verify-runner.js +152 -575
  314. package/src/lib/work-type-detector.js +5 -67
  315. package/src/lib/worktree/docker-audit.js +17 -137
  316. package/src/lib/worktree/env-allocator.js +10 -98
  317. package/src/lib/worktree/feature-truth.js +12 -115
  318. package/src/lib/worktree/finish-backup.js +32 -242
  319. package/src/lib/worktree/fleet-scanner.js +31 -126
  320. package/src/lib/worktree/ignored-triage.js +15 -91
  321. package/src/lib/worktree/vscode-settings.js +2 -3
  322. package/src/lib/worktree/worktree-audit.js +21 -213
  323. package/src/lib/worktree/worktree-include.js +3 -5
  324. package/src/scripts/global-install.js +3 -9
  325. package/src/scripts/install-plugin.js +1 -13
  326. package/src/scripts/setup-infra.js +17 -138
  327. package/src/utils/agents-installer.js +92 -272
  328. package/src/utils/banner.js +4 -25
  329. package/src/utils/claude-md-injector.js +1 -37
  330. package/src/utils/claude-settings-manager.js +1 -87
  331. package/src/utils/file-copier.js +32 -90
  332. package/src/utils/hooks-installer.js +26 -296
  333. package/src/utils/skills-installer.js +4 -102
  334. package/src/utils/version-checker.js +4 -83
  335. package/framework/commands/morph-proposal.md +0 -57
  336. package/framework/evals/trace-schema.md +0 -67
  337. package/framework/hooks/claude-code/core/tasks-json-guard.js +0 -83
  338. package/framework/hooks/claude-code/core/trace-autogen.js +0 -277
  339. package/framework/hooks/git/pre-commit/specs.sh +0 -50
  340. package/framework/hooks/shared/tasks-validator.js +0 -111
  341. package/framework/rules/morph-task-tracking.md +0 -54
  342. package/framework/rules/morph-workflow.md +0 -244
  343. package/framework/schemas/tasks.schema.json +0 -79
  344. package/framework/skills/core/morph-brainstorming/SKILL.md +0 -161
  345. package/framework/skills/core/morph-implement/SKILL.md +0 -194
  346. package/framework/skills/core/morph-proposal/SKILL.md +0 -152
  347. package/framework/skills/core/morph-stack-scan/SKILL.md +0 -145
  348. package/framework/templates/docs/proposal.md +0 -192
  349. package/framework/templates/docs/spec.md +0 -57
  350. package/framework/templates/docs/ui-components.md +0 -52
  351. package/framework/templates/docs/ui-design-system.md +0 -76
  352. package/framework/templates/docs/ui-flows.md +0 -167
  353. package/framework/templates/examples/spec-examples.md +0 -91
  354. package/framework/templates/feature/decisions.md +0 -124
  355. package/framework/templates/feature/recap.md +0 -145
  356. package/framework/templates/tasks.template.json +0 -17
  357. package/src/commands/state/usecase.js +0 -143
  358. package/src/commands/tasks/dag.js +0 -181
  359. package/src/commands/utils/index.js +0 -3
  360. package/src/lib/agents/output-collector.js +0 -76
  361. package/src/lib/detectors/design-system-detector.js +0 -188
  362. package/src/lib/evaluator-report.js +0 -104
  363. package/src/lib/generators/design-system-generator.js +0 -508
  364. package/src/lib/specs/acervo-format.js +0 -70
  365. package/src/lib/specs/acervo-lifecycle.js +0 -264
  366. package/src/lib/specs/done-criteria-check.js +0 -200
  367. package/src/lib/specs/usecase-check.js +0 -301
  368. package/src/lib/specs/usecase-promoter.js +0 -301
  369. package/src/lib/specs/usecase-registry.js +0 -237
  370. package/src/lib/tasks/dispatch-planner.js +0 -319
  371. package/src/lib/tasks/dotnet-test-introspection.js +0 -632
  372. package/src/lib/tasks/mermaid-renderer.js +0 -132
  373. package/src/lib/tasks/task-classifier.js +0 -30
  374. package/src/lib/tasks/task-parser.js +0 -209
  375. package/src/lib/tasks/tasks-json-parser.js +0 -296
  376. package/src/lib/tasks/test-scope.js +0 -597
  377. package/src/utils/agent-id-mapper.js +0 -11
@@ -0,0 +1,281 @@
1
+ import { existsSync, readFileSync, readdirSync } from 'fs';
2
+ import { dirname, join, resolve } from 'path';
3
+
4
+ import { isInside } from '../tasks/test-runner.js';
5
+ import { jsoncToJson } from '../../../framework/hooks/shared/comment-stripper.js';
6
+
7
+ const MTP_RUNNER = 'Microsoft.Testing.Platform';
8
+ const TYPE_HEADER = /\b(?:class|struct|record|interface)\b/;
9
+ // `@` = verbatim identifier, `\` = unicode escape.
10
+ const NAME_START = '[\\p{L}\\p{Nl}_@\\\\]';
11
+ // TRIPWIRE with NO context rule: any exclusion based on what precedes the keyword can be
12
+ // forged by a comment or exposed string. The quote in the lookahead makes every keyword
13
+ // the blanked code counts also counted in the raw text, at the same offset.
14
+ const TYPE_KEYWORD = new RegExp(`\\b(?:record\\s+(?:class|struct)|class|record|struct|interface)\\b(?=\\s*(?:${NAME_START}|["']|\\/[/*]|#))`, 'gu');
15
+ const TYPE_DECL = new RegExp(`(?:^|[;{}\\]\\n])[ \\t]*(?:\\[[^\\]\\n]*\\][ \\t]*)*(?:(?:public|internal|private|protected|sealed|static|partial|abstract|unsafe|file|new|readonly|ref)\\s+)*(record\\s+(?:class|struct)|class|record|struct|interface)\\s+(${NAME_START}[^\\s:<({;,=]*)`, 'gu');
16
+ const IDENT = '[\\p{L}_][\\p{L}\\p{N}_]*';
17
+ const QUALIFIER = `(?:${IDENT}\\s*(?:\\.|::)\\s*)*`;
18
+ const ATTRIBUTE_ARGS = '(?:\\((?:[^()\\]]|\\([^()\\]]*\\))*\\))?';
19
+ const OTHER_ATTRIBUTE = `${QUALIFIER}${IDENT}\\s*${ATTRIBUTE_ARGS}`;
20
+ const TEST_ATTRIBUTE_NAME = '(?:Fact|Theory|Test|TestMethod|DataTestMethod|TestCase|TestCaseSource)(?:Attribute)?';
21
+ // A false POSITIVE is the unsafe side (a helper taken for a test file hides the tests that
22
+ // use it), so the whole name must sit in an attribute section that CLOSES: a C# 12
23
+ // collection such as `[\n Env.Test,\n];` must not match. Residual cases: csharpClasses.
24
+ export const CS_TEST_ATTRIBUTE = new RegExp(
25
+ '(?:^|[;{}\\]]|\\*\\/)[ \\t]*\\['
26
+ + `\\s*(?:${IDENT}\\s*:(?!:)\\s*)?`
27
+ + `(?:${OTHER_ATTRIBUTE}\\s*,\\s*)*`
28
+ + `${QUALIFIER}${TEST_ATTRIBUTE_NAME}(?![\\p{L}\\p{N}_])\\s*${ATTRIBUTE_ARGS}`
29
+ + `(?:\\s*,\\s*${OTHER_ATTRIBUTE})*`
30
+ + '\\s*\\]\\s*(?:[\\[\\p{L}_@/#]|(?![\\s\\S]))',
31
+ 'mu',
32
+ );
33
+
34
+ // Roslyn reads CR/NEL/LS/PS as new-lines and U+001A as whitespace; JS `\s` misses NEL
35
+ // and U+001A, which hid `class<NEL>Name`. Mapped 1:1 so offsets stay aligned with the code.
36
+ const readText = (path) => {
37
+ try { return readFileSync(path, 'utf8').replace(/\x1A/g, ' ').replace(/^\uFEFF/, '').replace(/[\r\u0085\u2028\u2029]/g, '\n'); } catch { return null; }
38
+ };
39
+
40
+ function nearestProjectFile(fileAbs, rootAbs, projectName) {
41
+ for (let dir = dirname(fileAbs); isInside(dir, rootAbs); dir = dirname(dir)) {
42
+ let names = [];
43
+ try { names = readdirSync(dir).filter((n) => projectName.test(n)).sort(); } catch { /* unreadable dir */ }
44
+ if (names.length > 0) return join(dir, names[0]);
45
+ if (dirname(dir) === dir) break;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ export function nearestProjectOf(fileAbs, rootAbs) {
51
+ return nearestProjectFile(fileAbs, rootAbs, /\.(?:cs|fs|vb)proj$/i);
52
+ }
53
+
54
+ const NON_CSHARP_SOURCE = /\.(?:razor|cshtml|fs|fsi|fsx|vb)$/i;
55
+
56
+ /** No type scan, hence no class selector, for these. */
57
+ export const isNonCSharpSource = (path) => NON_CSHARP_SOURCE.test(String(path));
58
+
59
+ export const isDotnetSource = (path) => /\.cs$/i.test(String(path)) || isNonCSharpSource(path);
60
+
61
+ /**
62
+ * Nearest global.json walking up from the CWD, as the SDK resolves it (never from the
63
+ * `.sln` argument). null when one exists but cannot be read — never a guess.
64
+ */
65
+ export function dotnetTestMode(absCwd) {
66
+ for (let dir = resolve(absCwd); ; dir = dirname(dir)) {
67
+ const file = join(dir, 'global.json');
68
+ if (existsSync(file)) {
69
+ try {
70
+ const parsed = JSON.parse(jsoncToJson(readFileSync(file, 'utf8')));
71
+ return parsed?.test?.runner === MTP_RUNNER ? 'mtp' : 'vstest';
72
+ } catch {
73
+ return null;
74
+ }
75
+ }
76
+ if (dirname(dir) === dir) return 'vstest';
77
+ }
78
+ }
79
+
80
+ const lineEnd = (text, i) => { const e = text.indexOf('\n', i); return e < 0 ? text.length : e; };
81
+
82
+ function closeOnLine(text, start, quote) {
83
+ for (let j = start + 1; j < text.length && text[j] !== '\n'; j++) {
84
+ if (text[j] === '\\') j++;
85
+ else if (text[j] === quote) return j + 1;
86
+ }
87
+ return -1;
88
+ }
89
+
90
+ // A `"` inside a hole (`$"{d["k"]}"`) ends the literal as the scanner reads it,
91
+ // leaving the hole open. `{{`/`}}` outside a hole are escapes.
92
+ function holeLeftOpen(content) {
93
+ let depth = 0;
94
+ for (let k = 0; k < content.length; k++) {
95
+ const c = content[k];
96
+ if (depth === 0 && (c === '{' || c === '}') && content[k + 1] === c) { k++; continue; }
97
+ if (c === '{') depth++;
98
+ else if (c === '}' && depth > 0) depth--;
99
+ }
100
+ return depth > 0;
101
+ }
102
+
103
+ /**
104
+ * Comments, preprocessor lines and literals blanked, length and newlines kept; null when
105
+ * a literal never ends or an interpolation hole is cut. A heuristic lexer that CAN misread:
106
+ * csharpClasses never trusts it alone (it checks against the raw text).
107
+ */
108
+ export function blankCSharpLiterals(text) {
109
+ const out = text.split('');
110
+ const blank = (from, to) => { for (let k = from; k < to; k++) if (out[k] !== '\n') out[k] = ' '; };
111
+ for (let i = 0; i < text.length;) {
112
+ const c = text[i];
113
+ let end = -1;
114
+ if (c === '#' && /^[ \t]*$/.test(text.slice(text.lastIndexOf('\n', i - 1) + 1, i))) {
115
+ end = lineEnd(text, i);
116
+ } else if (c === '/' && text[i + 1] === '/') {
117
+ end = lineEnd(text, i);
118
+ } else if (c === '/' && text[i + 1] === '*') {
119
+ const close = text.indexOf('*/', i + 2);
120
+ if (close < 0) return null;
121
+ end = close + 2;
122
+ } else if (c === '"' && (text[i - 1] === '@' || (text[i - 1] === '$' && text[i - 2] === '@'))) {
123
+ let j = i + 1;
124
+ while (j < text.length && !(text[j] === '"' && text[j + 1] !== '"')) j += text[j] === '"' ? 2 : 1;
125
+ if (j >= text.length) return null;
126
+ end = j + 1;
127
+ const interpolated = text[i - 1] === '$' || text[i - 2] === '$';
128
+ if (interpolated && holeLeftOpen(text.slice(i + 1, end - 1))) return null;
129
+ } else if (c === '"') {
130
+ let quotes = 0;
131
+ while (text[i + quotes] === '"') quotes++;
132
+ if (quotes >= 3) {
133
+ const close = text.indexOf('"'.repeat(quotes), i + quotes);
134
+ if (close < 0) return null;
135
+ end = close + quotes;
136
+ } else {
137
+ end = closeOnLine(text, i, '"');
138
+ if (end < 0) return null;
139
+ if (text[i - 1] === '$' && holeLeftOpen(text.slice(i + 1, end - 1))) return null;
140
+ }
141
+ } else if (c === "'") {
142
+ end = closeOnLine(text, i, "'");
143
+ if (end < 0) return null;
144
+ }
145
+ if (end < 0) { i++; continue; }
146
+ blank(i, end);
147
+ i = end;
148
+ }
149
+ return out.join('');
150
+ }
151
+
152
+ const typeKeywordCount = (text) => [...text.matchAll(TYPE_KEYWORD)].length;
153
+
154
+ /**
155
+ * The types of a C# file, read FAIL-SAFE.
156
+ *
157
+ * `types` lists the BARE name of EVERY type declaration (class, record, struct,
158
+ * interface) at ANY depth, in source order, with no namespace. A selection built
159
+ * from these names is namespace-free (`--filter-class "*T*"`, measured, or the
160
+ * substring `FullyQualifiedName~T`): any namespace, `Outer+Inner`, `Name`1` — a
161
+ * selection that can only run MORE tests than the file's, never fewer.
162
+ *
163
+ * THE GUARANTEE (what `scannable: true` means), and why it holds:
164
+ * every type-keyword token of the RAW text is a declaration in `types`.
165
+ * - R = keyword tokens of the raw text (TYPE_KEYWORD, no context rule); C = the
166
+ * same on the blanked code; D = the declarations named (TYPE_DECL on the code).
167
+ * `scannable` requires |R| = |D| and |C| = |D|. By construction D ⊆ C ⊆ R as
168
+ * sets of offsets: blanking keeps length and only replaces characters by spaces,
169
+ * a blanked region starts at `"` `'` `/` `#` and ends at `"` `'` `*` `/` or a
170
+ * line end (never inside an identifier), and the raw lookahead accepts every
171
+ * character the code lookahead can be looking past (a name, a comment, `#`, a
172
+ * quote). So |R| = |D| forces R = D. A real declaration's keyword is always in R
173
+ * (it is followed by its name, or by a comment/directive before the name) —
174
+ * UNDER ONE ASSUMPTION: every character Roslyn reads as whitespace or a new-line
175
+ * between a keyword and its name is whitespace to JS `\s` once readText has run.
176
+ * Roslyn's whitespace is Unicode Zs, TAB, VT, FF and U+001A; its new-lines are
177
+ * CR, LF, NEL, LS and PS. JS `\s` already covers Zs, TAB, VT, FF and LF, and
178
+ * readText maps CR/NEL/LS/PS to `\n` and U+001A to a space. A separator outside
179
+ * that shared set would hide a declaration from R; none is known, and that is a
180
+ * statement about the two lexers as read, not a proof.
181
+ * Hence: a declaration swallowed by ANY misread — `"/*"` taken for a comment
182
+ * opener, a quote inside an interpolation hole, a char literal — is still in R
183
+ * and cannot be compensated by anything (a fake the code exposes is in R too).
184
+ * - `abstract` lists the abstract types: their tests run under the names of DERIVED
185
+ * classes, possibly in other files.
186
+ * - `hasTestAttribute`: a test attribute (CS_TEST_ATTRIBUTE: a whole name inside an
187
+ * attribute section) is present in the blanked code (a file without one is a
188
+ * helper/fake/fixture: the tests using it are elsewhere).
189
+ * - kept fail-safes: braces balance and blankCSharpLiterals returning null → unscannable.
190
+ *
191
+ * NOT guaranteed (stated, not hidden):
192
+ * - completeness: keyword-shaped text in a real comment or string (`// the
193
+ * class under test`), a constraint keyword followed by a name-shaped token
194
+ * (`where T : class where …`, `: class // note`), `record with`, a directive
195
+ * splitting a name — all make the file unscannable;
196
+ * - which scope a type was read in;
197
+ * - a NON-abstract class whose tests are inherited by derived classes declared in
198
+ * OTHER files: the touched class runs; the derived copies only if their names
199
+ * contain it;
200
+ * - a test attribute reached through an alias (`using F = Xunit.FactAttribute; [F]`)
201
+ * or a custom one (`[SkippableFact]`) is not seen: the file counts as a helper and
202
+ * it is left out of a selection;
203
+ * - `hasTestAttribute` is still a heuristic, and ITS false positive is the unsafe
204
+ * side. The section must close; what still matches in a
205
+ * helper — probed — is a LIST PATTERN opening a line (or after `;` `{` `}` `]`)
206
+ * whose element is named exactly `Test`/`Fact`/… (qualified or not) and whose `]`
207
+ * is followed by a combinator: `[Env.Test] or [Env.Dev] => true`. Such a helper is
208
+ * taken for a test file. (`[Env.Test] => 1` does not match: `=` starts no declaration.)
209
+ *
210
+ * @param {string} csFileAbs
211
+ * @returns {{ names: string[], types: string[], abstract: string[], hasTestClass: boolean, hasTestAttribute: boolean, scannable: boolean, why: string|null }}
212
+ * `names`: top-level `class` names (classNamesOf). `abstract`: the abstract
213
+ * types. `hasTestClass`: some plain `class` is declared (records, structs and
214
+ * interfaces hold no tests). `hasTestAttribute`: see above. `why`: the reason
215
+ * when `scannable` is false.
216
+ */
217
+ export function csharpClasses(csFileAbs) {
218
+ const unscannable = (why) => ({ names: [], types: [], abstract: [], hasTestClass: false, hasTestAttribute: false, scannable: false, why });
219
+ const text = readText(csFileAbs);
220
+ if (text === null) return { names: [], types: [], abstract: [], hasTestClass: false, hasTestAttribute: false, scannable: true, why: null };
221
+ const code = blankCSharpLiterals(text);
222
+ if (code === null) return unscannable('a literal or comment never ends, or an interpolation hole is cut by an inner quote');
223
+
224
+ const decls = [...code.matchAll(TYPE_DECL)].map((m) => ({
225
+ at: m.index + m[0].length - m[2].length,
226
+ kind: m[1].split(/\s+/)[0],
227
+ name: m[2],
228
+ abstract: /\babstract\b/.test(m[0]),
229
+ gap: m[0].slice(0, m[0].length - m[2].length).match(/\s*$/)[0].length,
230
+ }));
231
+ // A preprocessor line between a keyword and its name (`class\n#if X\nA\n#else\nB…`)
232
+ // makes the compiled name depend on a symbol this scan cannot see. Blanking keeps
233
+ // length, so the raw text at the same offsets shows the directive.
234
+ const split = decls.find((d) => /(^|\n)[ \t]*#/.test(text.slice(d.at - d.gap, d.at)));
235
+ if (split) return unscannable(`a preprocessor directive stands between a type keyword and the name "${split.name}"`);
236
+ // The raw text is the lexer-independent truth about how many type keywords the file
237
+ // carries (the JSDoc explains why equal counts mean equal sets).
238
+ const rawKeywords = typeKeywordCount(text);
239
+ if (rawKeywords !== decls.length) {
240
+ return unscannable(`${rawKeywords} type keyword(s) in the file but ${decls.length} declaration(s) recognised`);
241
+ }
242
+ const codeKeywords = typeKeywordCount(code);
243
+ if (codeKeywords !== decls.length) {
244
+ return unscannable(`${codeKeywords} type keyword(s) in the code but ${decls.length} declaration(s) recognised`);
245
+ }
246
+
247
+ const names = [];
248
+ const types = [];
249
+ const abstractTypes = [];
250
+ const scopes = [];
251
+ let hasTestClass = false;
252
+ let header = 0;
253
+ for (let i = 0, d = 0; i < code.length; i++) {
254
+ for (; d < decls.length && decls[d].at === i; d++) {
255
+ types.push(decls[d].name);
256
+ if (decls[d].abstract) abstractTypes.push(decls[d].name);
257
+ if (decls[d].kind === 'class') {
258
+ hasTestClass = true;
259
+ if (!scopes.some((s) => s.type)) names.push(decls[d].name);
260
+ }
261
+ }
262
+ const c = code[i];
263
+ if (c === '{') {
264
+ scopes.push({ type: TYPE_HEADER.test(code.slice(header, i)) });
265
+ } else if (c === '}' && scopes.pop() === undefined) {
266
+ return unscannable('a `}` closes nothing');
267
+ }
268
+ if (c === '{' || c === '}' || c === ';') header = i + 1;
269
+ }
270
+ if (scopes.length > 0) return unscannable('braces left open at end of file');
271
+ return {
272
+ names: [...new Set(names)],
273
+ types: [...new Set(types)],
274
+ abstract: [...new Set(abstractTypes)],
275
+ hasTestClass,
276
+ hasTestAttribute: CS_TEST_ATTRIBUTE.test(code),
277
+ scannable: true,
278
+ why: null,
279
+ };
280
+ }
281
+
@@ -1,7 +1,7 @@
1
1
  import { existsSync, readFileSync } from 'node:fs';
2
2
  import { join, resolve } from 'node:path';
3
3
  import { isInside } from '../tasks/test-runner.js';
4
- import { isDotnetSource } from '../tasks/dotnet-test-introspection.js';
4
+ import { isDotnetSource } from './dotnet-introspection.js';
5
5
  import { resolveRegions, validateManifest } from './manifest.js';
6
6
 
7
7
  const JS_RUNNERS = new Set(['vitest', 'jest', 'node-test']);
@@ -14,23 +14,31 @@ export function loadPlan({ root, planPath }) {
14
14
  });
15
15
  }
16
16
 
17
- export function pickNode(file, nodes, root) {
17
+ function ownersOf(file, nodes, root) {
18
+ if (isDotnetSource(file)) return nodes.filter((n) => n.runner === 'dotnet');
18
19
  const abs = resolve(root, file);
19
- if (isDotnetSource(file)) return nodes.find((n) => n.runner === 'dotnet') ?? null;
20
- const owners = nodes
20
+ return nodes
21
21
  .filter((n) => JS_RUNNERS.has(n.runner) && isInside(abs, resolve(root, n.cwd || '.')))
22
22
  .sort((a, b) => resolve(root, b.cwd || '.').length - resolve(root, a.cwd || '.').length);
23
- return owners[0] ?? null;
23
+ }
24
+
25
+ // An `e2e: true` suite tests the running compose stack, which mutate never
26
+ // rebuilds: it is picked only when no other suite owns the file, and the caller refuses.
27
+ export function pickNode(file, nodes, root) {
28
+ const owners = ownersOf(file, nodes, root);
29
+ return owners.find((n) => !n.e2e) ?? owners[0] ?? null;
24
30
  }
25
31
 
26
32
  export function groupByNode(regions, nodes, root) {
27
33
  const groups = new Map();
28
34
  const orphans = [];
35
+ const stackOnly = [];
29
36
  for (const region of regions) {
30
37
  const node = pickNode(region.file, nodes, root);
31
38
  if (!node) { orphans.push(region); continue; }
39
+ if (node.e2e) { stackOnly.push({ region, node }); continue; }
32
40
  if (!groups.has(node.id)) groups.set(node.id, { node, regions: [] });
33
41
  groups.get(node.id).regions.push(region);
34
42
  }
35
- return { groups: [...groups.values()], orphans };
43
+ return { groups: [...groups.values()], orphans, stackOnly };
36
44
  }
@@ -5,7 +5,7 @@ import { blame, parseCompilerErrors, patchFile, switchClassName, switchSource, S
5
5
  import { md5Of, md5OfFile, openSnapshot, restoreSnapshot, trackGenerated } from './snapshot.js';
6
6
  import { parseBuiltDlls } from './commands.js';
7
7
  import { firstLines, methodOf } from './trx.js';
8
- import { nearestProjectOf } from '../tasks/dotnet-test-introspection.js';
8
+ import { nearestProjectOf } from './dotnet-introspection.js';
9
9
 
10
10
  const posix = (p) => p.replace(/\\/g, '/');
11
11
  const MAX_SCHEMATA_ATTEMPTS = 3;
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs';
2
2
  import { dirname, join, relative, resolve } from 'node:path';
3
- import { csharpClasses } from '../tasks/dotnet-test-introspection.js';
3
+ import { csharpClasses } from './dotnet-introspection.js';
4
4
  import { relatedCommand } from './commands.js';
5
5
 
6
6
  const SKIP_DIRS = new Set(['bin', 'obj', 'node_modules', '.git', 'worktrees', 'TestResults']);
@@ -24,18 +24,37 @@ function walkCs(dir, out = []) {
24
24
 
25
25
  const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
26
26
 
27
- /**
28
- * The tests to run FIRST, chosen by the tool: files the feature changed, and files naming a
29
- * type the mutated files declare. A mutant the subset does not kill goes to the whole suite.
30
- */
27
+ export function mutatedTypesOf(root, files) {
28
+ const types = new Set();
29
+ for (const file of files) {
30
+ for (const t of csharpClasses(resolve(root, file)).types) if (t.length >= MIN_TYPE_LENGTH) types.add(t);
31
+ }
32
+ return [...types];
33
+ }
34
+
35
+ const mentionOf = (types) => (types.length > 0 ? new RegExp(`\\b(?:${types.map(escapeRe).join('|')})\\b`) : null);
36
+
37
+ /** Test files (a test attribute in the code) under `testProjectDirs` naming one of `types` as a whole word. */
38
+ export function testFilesCiting({ root, testProjectDirs, types, exclude = [] }) {
39
+ const mention = mentionOf(types);
40
+ if (mention === null) return [];
41
+ const skip = new Set(exclude.map(posix));
42
+ const found = [];
43
+ for (const dir of testProjectDirs) {
44
+ for (const abs of walkCs(resolve(root, dir))) {
45
+ const rel = posix(relative(root, abs));
46
+ if (skip.has(rel)) continue;
47
+ const info = csharpClasses(abs);
48
+ if (info.scannable && info.hasTestAttribute && mention.test(readFileSync(abs, 'utf8'))) found.push(rel);
49
+ }
50
+ }
51
+ return found.sort();
52
+ }
53
+
54
+ /** Chosen by the tool, never the verifier: test files the feature changed or that name a mutated type. */
31
55
  export function dotnetSubset({ root, testProjectDirs, mutatedFiles, changed = [] }) {
32
56
  const changedSet = new Set(changed.map(posix));
33
- const mutatedTypes = new Set();
34
- for (const file of mutatedFiles) {
35
- for (const t of csharpClasses(resolve(root, file)).types) if (t.length >= MIN_TYPE_LENGTH) mutatedTypes.add(t);
36
- }
37
- const mention = mutatedTypes.size > 0
38
- ? new RegExp(`\\b(?:${[...mutatedTypes].map(escapeRe).join('|')})\\b`) : null;
57
+ const mention = mentionOf(mutatedTypesOf(root, mutatedFiles));
39
58
 
40
59
  const byChange = new Set();
41
60
  const byMention = new Set();
@@ -70,6 +89,21 @@ export function jsSubset({ runner, command, root, mutatedFiles }) {
70
89
  return { usable: true, command: cmd, files: { related: files } };
71
90
  }
72
91
 
73
- export function testProjectDirsOf(root, csprojs) {
74
- return [...new Set(csprojs.map((c) => posix(relative(root, dirname(resolve(root, c))))))];
92
+ const SLN_PROJECT = /^Project\("[^"]*"\)\s*=\s*"[^"]*",\s*"([^"]+\.csproj)"/gim;
93
+ const SLNX_PROJECT = /<Project\b[^>]*\bPath="([^"]+\.csproj)"/gi;
94
+
95
+ function solutionProjects(solutionAbs) {
96
+ let text;
97
+ try { text = readFileSync(solutionAbs, 'utf8'); } catch { return []; }
98
+ const pattern = /\.slnx$/i.test(solutionAbs) ? SLNX_PROJECT : SLN_PROJECT;
99
+ return [...text.matchAll(pattern)].map((m) => resolve(dirname(solutionAbs), posix(m[1])));
100
+ }
101
+
102
+ /** A solution stands for every project it lists; one that lists none yields no directory. */
103
+ export function testProjectDirsOf(root, targets, cwd = root) {
104
+ const projects = targets.flatMap((t) => {
105
+ const abs = resolve(cwd, t);
106
+ return /\.slnx?$/i.test(t) ? solutionProjects(abs) : [abs];
107
+ });
108
+ return [...new Set(projects.map((p) => posix(relative(root, dirname(p)))))];
75
109
  }
@@ -0,0 +1,40 @@
1
+ /** The inline justification that lets a criterion talk about an artifact on purpose. */
2
+ export const ARTIFACT_MARKER_RE = /por\s+que\s+artefato\s*:/i;
3
+
4
+ // Node 18 has no inline `(?i:…)` modifier, and the rest of the regex must stay case-sensitive.
5
+ const anyCase = (word) => [...word].map((c) => {
6
+ const lo = c.toLowerCase();
7
+ const up = c.toUpperCase();
8
+ return lo === up ? c : `[${lo}${up}]`;
9
+ }).join('');
10
+
11
+ // Case-sensitive on purpose: `src`, `docs` or `lib` after "arquivo em" are places, and places are artifacts.
12
+ const FORMAT_TOKEN = String.raw`\s+(?:[A-Z]{2,5}(?![\p{L}\d_])|${anyCase('formato')}(?![\p{L}]))`;
13
+
14
+ // `term` is cited verbatim by the `morph-plan` skill (a parity test imports it): keep it stable.
15
+ // Unicode lookarounds, never `\b`: JS `\b` false-boundaries inside "não" or "binário".
16
+ export const ARTIFACT_VOCABULARY = [
17
+ { term: 'grep', re: /(?<![\p{L}\d_])grep(?![\p{L}\d_])/iu },
18
+ { term: 'contém a string', re: /cont[ée]m\s+as?\s+strings?/iu },
19
+ { term: 'o binário', re: /(?<![\p{L}])o\s+bin[áa]rio(?![\p{L}])/iu },
20
+ { term: 'nenhum arquivo', re: /(?<![\p{L}])nenhum\s+arquivo(?![\p{L}])/iu },
21
+ {
22
+ term: 'arquivo/pasta/diretório em',
23
+ re: new RegExp(
24
+ String.raw`(?<![\p{L}])(?:${anyCase('arquivo')}|${anyCase('pasta')}|${anyCase('diret')}[óoÓO]${anyCase('rio')})` +
25
+ String.raw`[sS]?\s+${anyCase('em')}(?![\p{L}])(?!${FORMAT_TOKEN})`,
26
+ 'u',
27
+ ),
28
+ },
29
+ { term: 'glob **/', re: /\*\*\/|\/\*\*/ },
30
+ ];
31
+
32
+ /** Each term at most once, in vocabulary order. */
33
+ export function findArtifactTerms(text) {
34
+ const s = String(text ?? '');
35
+ const found = [];
36
+ for (const { term, re } of ARTIFACT_VOCABULARY) {
37
+ if (re.test(s)) found.push(term);
38
+ }
39
+ return found;
40
+ }
@@ -0,0 +1,208 @@
1
+ import { createHash } from 'crypto';
2
+ import { existsSync, readFileSync } from 'fs';
3
+ import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
4
+ import { frozenBody } from '../../../framework/hooks/shared/errata.js';
5
+ import { readAgentsJson } from '../specs/screen-checklist.js';
6
+ import { findArtifactTerms } from './artifact-vocabulary.js';
7
+ import { PROOF_KINDS, REUSE_VERDICTS, ADR_SCOPES, SECTIONS, CONFLICTS_HEADING } from './plan-parser.js';
8
+
9
+ export const CHECKS_VERSION = 1;
10
+ export const MAX_SLICE_FILES = 8;
11
+ export const MAX_SLICES = 12;
12
+ export const MAX_PARALLEL = 4;
13
+
14
+ /** sha256 of the plan without its errata: appending a correction never invalidates the extracted checks. */
15
+ export function planHash(text) {
16
+ return createHash('sha256').update(frozenBody(text), 'utf8').digest('hex');
17
+ }
18
+
19
+ /** The installed agents.json, else the package's; null when unreadable (personas then go unchecked). */
20
+ export function knownPersonas(cwd) {
21
+ const agents = readAgentsJson(cwd)?.agents;
22
+ return agents && typeof agents === 'object' ? Object.keys(agents) : null;
23
+ }
24
+
25
+ /** The ids on a dependency cycle, or null. */
26
+ export function dependencyCycle(slices) {
27
+ const deps = new Map(slices.map((s) => [s.id, s.depends ?? []]));
28
+ const state = new Map();
29
+ const stack = [];
30
+ const visit = (id) => {
31
+ if (state.get(id) === 'done' || !deps.has(id)) return null;
32
+ if (state.get(id) === 'open') return stack.slice(stack.indexOf(id)).concat(id);
33
+ state.set(id, 'open');
34
+ stack.push(id);
35
+ for (const dep of deps.get(id)) {
36
+ const cycle = visit(dep);
37
+ if (cycle) return cycle;
38
+ }
39
+ stack.pop();
40
+ state.set(id, 'done');
41
+ return null;
42
+ };
43
+ for (const id of deps.keys()) {
44
+ const cycle = visit(id);
45
+ if (cycle) return cycle;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ const fileKey = (f) => f.replace(/\\/g, '/').replace(/^\.\//, '').toLowerCase();
51
+
52
+ // A slice joins a wave only after its dependencies ran, never with one touching its files, and at most
53
+ // MAX_PARALLEL per wave. Empty on a dependency cycle.
54
+ export function parallelGroups(slices) {
55
+ if (dependencyCycle(slices)) return [];
56
+ const ids = new Set(slices.map((s) => s.id));
57
+ const depth = new Map();
58
+ const depthOf = (slice) => {
59
+ if (depth.has(slice.id)) return depth.get(slice.id);
60
+ const parents = (slice.depends ?? []).filter((d) => ids.has(d)).map((d) => slices.find((s) => s.id === d));
61
+ const value = parents.length === 0 ? 0 : 1 + Math.max(...parents.map(depthOf));
62
+ depth.set(slice.id, value);
63
+ return value;
64
+ };
65
+ const layers = [];
66
+ for (const slice of slices) (layers[depthOf(slice)] ??= []).push(slice);
67
+
68
+ const groups = [];
69
+ for (const layer of layers.filter(Boolean)) {
70
+ const open = [];
71
+ for (const slice of layer) {
72
+ const files = new Set((slice.files ?? []).map(fileKey));
73
+ const group = open.find((g) => g.ids.length < MAX_PARALLEL && ![...files].some((f) => g.files.has(f)));
74
+ if (group) {
75
+ group.ids.push(slice.id);
76
+ files.forEach((f) => group.files.add(f));
77
+ } else {
78
+ open.push({ ids: [slice.id], files });
79
+ }
80
+ }
81
+ groups.push(...open.map((g) => g.ids));
82
+ }
83
+ return groups;
84
+ }
85
+
86
+ export function validatePlan(plan, { personas = null, surfaces = null } = {}) {
87
+ const errors = [];
88
+ const warnings = [];
89
+ const at = (line) => (line ? ` (linha ${line})` : '');
90
+
91
+ for (const key of ['problem', 'scenarios', 'decisions', 'slices']) {
92
+ if (!plan.present[key]) errors.push(`seção obrigatória ausente: ## ${SECTIONS[key]}`);
93
+ }
94
+ if (plan.present.decisions && !plan.conflicts) {
95
+ errors.push(`### ${CONFLICTS_HEADING} ausente em ## ${SECTIONS.decisions} — escreva "nenhum" quando não houver`);
96
+ } else if (plan.conflicts && !plan.conflicts.text) {
97
+ errors.push(`### ${CONFLICTS_HEADING} está vazio${at(plan.conflicts.line)} — escreva "nenhum" quando não houver`);
98
+ }
99
+ if (plan.sectionsAfterErrata.length > 0) {
100
+ errors.push(`## ${SECTIONS.errata} precisa ser a última seção — depois dela: ${plan.sectionsAfterErrata.join(', ')}`);
101
+ }
102
+
103
+ const duplicated = (items) => items.map((i) => i.id).filter((id, i, all) => all.indexOf(id) !== i);
104
+ for (const [label, items] of [['cenário', plan.scenarios], ['fatia', plan.slices], ['ADR', plan.adrs]]) {
105
+ for (const id of new Set(duplicated(items))) errors.push(`${label} duplicado: ${id}`);
106
+ }
107
+
108
+ if (plan.present.scenarios && plan.scenarios.length === 0) {
109
+ errors.push(`## ${SECTIONS.scenarios} sem nenhum cenário no formato "- **C1** — Dado …, quando …, então …"`);
110
+ }
111
+ for (const c of plan.scenarios) {
112
+ if (c.proof === null) {
113
+ errors.push(`${c.id} sem "prova:" (${PROOF_KINDS.join(' | ')})${at(c.line)}`);
114
+ } else if (!PROOF_KINDS.includes(c.proof.toLowerCase())) {
115
+ errors.push(`${c.id}: prova "${c.proof}" fora de ${PROOF_KINDS.join(' | ')}${at(c.line)}`);
116
+ }
117
+ const terms = findArtifactTerms(c.text);
118
+ if (terms.length > 0 && !c.artifactWhy) {
119
+ errors.push(`${c.id} descreve artefato ("${terms.join(', ')}") sem "> Por que artefato:" — reescreva como efeito observável ou justifique${at(c.line)}`);
120
+ }
121
+ }
122
+
123
+ const scenarioIds = new Set(plan.scenarios.map((c) => c.id));
124
+ const sliceIds = new Set(plan.slices.map((s) => s.id));
125
+ const covered = new Set();
126
+ for (const s of plan.slices) {
127
+ if (!s.persona) {
128
+ errors.push(`${s.id} sem "- persona:"${at(s.line)}`);
129
+ } else if (personas && !personas.includes(s.persona)) {
130
+ errors.push(`${s.id}: persona "${s.persona}" não existe em agents.json (${personas.join(', ')})${at(s.line)}`);
131
+ }
132
+ if (s.files.length > MAX_SLICE_FILES) {
133
+ errors.push(`${s.id} toca ${s.files.length} arquivos — o teto é ${MAX_SLICE_FILES}; divida a fatia${at(s.line)}`);
134
+ }
135
+ for (const c of s.covers) {
136
+ if (scenarioIds.has(c)) covered.add(c);
137
+ else errors.push(`${s.id} cobre ${c}, que não existe em ## ${SECTIONS.scenarios}${at(s.line)}`);
138
+ }
139
+ for (const d of s.depends) {
140
+ if (!sliceIds.has(d)) errors.push(`${s.id} depende de ${d}, que não existe${at(s.line)}`);
141
+ }
142
+ if (s.covers.length === 0) warnings.push(`${s.id} não cobre nenhum cenário${at(s.line)}`);
143
+ if (s.unknownKeys.length > 0) warnings.push(`${s.id}: chave desconhecida ${s.unknownKeys.join(', ')}${at(s.line)}`);
144
+ if (s.surface && surfaces && !surfaces.includes(s.surface)) {
145
+ warnings.push(`${s.id}: superfície "${s.surface}" não é uma de ${surfaces.join(', ')}${at(s.line)}`);
146
+ }
147
+ }
148
+ for (const c of plan.scenarios) {
149
+ if (!covered.has(c.id)) errors.push(`${c.id} não é coberto por nenhuma fatia ("- cobre: ${c.id}")`);
150
+ }
151
+ const cycle = dependencyCycle(plan.slices);
152
+ if (cycle) errors.push(`ciclo em "depende": ${cycle.join(' → ')}`);
153
+ if (plan.slices.length > MAX_SLICES) warnings.push(`${plan.slices.length} fatias — acima de ${MAX_SLICES}, considere dividir a feature`);
154
+
155
+ for (const r of plan.reuse) {
156
+ if (!REUSE_VERDICTS.includes(r.verdict)) {
157
+ errors.push(`## ${SECTIONS.reuse}: veredito "${r.verdict}" de "${r.concept}" fora de ${REUSE_VERDICTS.join(' | ')}${at(r.line)}`);
158
+ } else if (r.verdict === 'CRIAR' && !r.why) {
159
+ warnings.push(`## ${SECTIONS.reuse}: CRIAR "${r.concept}" sem why${at(r.line)}`);
160
+ }
161
+ }
162
+ for (const a of plan.adrs) {
163
+ if (!ADR_SCOPES.includes(a.scope)) warnings.push(`${a.id} sem **Alcance:** ${ADR_SCOPES.join(' | ')}${at(a.line)}`);
164
+ }
165
+
166
+ return { errors, warnings };
167
+ }
168
+
169
+ export function buildChecks(feature, text, plan) {
170
+ return {
171
+ version: CHECKS_VERSION,
172
+ feature,
173
+ planHash: planHash(text),
174
+ checks: plan.scenarios.map((c) => ({
175
+ id: c.id, text: c.text, proof: c.proof ? c.proof.toLowerCase() : null, artifactWhy: c.artifactWhy || null,
176
+ })),
177
+ slices: plan.slices.map((s) => ({
178
+ id: s.id, title: s.title, persona: s.persona, surface: s.surface,
179
+ standards: s.standards, files: s.files, depends: s.depends, covers: s.covers,
180
+ })),
181
+ reuse: plan.reuse.map(({ concept, verdict, target, why }) => ({ concept, verdict, target, why })),
182
+ adrs: plan.adrs.map((a) => ({ id: a.id, title: a.title, scope: ADR_SCOPES.includes(a.scope) ? a.scope : null })),
183
+ };
184
+ }
185
+
186
+ /** plan-check writes the checks only with zero errors, so `fresh` also means the plan passed every rule. */
187
+ export function readPlanChecks(cwd, featureName) {
188
+ const planPath = getAbsoluteOutputPath(cwd, featureName, 'plan');
189
+ const checksPath = getAbsoluteOutputPath(cwd, featureName, 'checks');
190
+ if (!existsSync(planPath)) return { status: 'no-plan', checks: null };
191
+ if (!existsSync(checksPath)) return { status: 'no-checks', checks: null };
192
+ let checks;
193
+ try {
194
+ checks = JSON.parse(readFileSync(checksPath, 'utf8'));
195
+ } catch {
196
+ return { status: 'unreadable', checks: null };
197
+ }
198
+ if (checks?.version !== CHECKS_VERSION || !Array.isArray(checks.slices) || !Array.isArray(checks.checks)) {
199
+ return { status: 'unreadable', checks: null };
200
+ }
201
+ let plan;
202
+ try {
203
+ plan = readFileSync(planPath, 'utf8');
204
+ } catch {
205
+ return { status: 'no-plan', checks };
206
+ }
207
+ return { status: checks.planHash === planHash(plan) ? 'fresh' : 'stale', checks };
208
+ }