@polymorphism-tech/morph-spec 8.37.1 → 8.40.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 (317) hide show
  1. package/CLAUDE.md +37 -13
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +75 -49
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/CLAUDE.md +6 -5
  8. package/framework/MORPH.md +25 -22
  9. package/framework/agents.json +96 -36
  10. package/framework/docs/CLI.md +54 -17
  11. package/framework/evals/rubrics/code-quality.md +5 -0
  12. package/framework/evals/rubrics/condensed.md +6 -2
  13. package/framework/evals/rubrics/contracts.md +20 -0
  14. package/framework/evals/rubrics/test-coverage.md +17 -6
  15. package/framework/hooks/README.md +9 -3
  16. package/framework/hooks/claude-code/core/graph-refresh-kick.js +52 -0
  17. package/framework/hooks/claude-code/core/telemetry-log.js +172 -87
  18. package/framework/hooks/claude-code/core/validate-completion.js +16 -90
  19. package/framework/hooks/claude-code/core/worktree-context.js +10 -0
  20. package/framework/hooks/shared/command-tokens.js +96 -0
  21. package/framework/hooks/shared/git-exclude.js +44 -0
  22. package/framework/hooks/shared/git-paths.js +52 -0
  23. package/framework/hooks/shared/graph-freshness.js +380 -0
  24. package/framework/hooks/shared/graph-refresh.js +416 -0
  25. package/framework/hooks/shared/morph-cli.js +17 -5
  26. package/framework/hooks/shared/redact-secrets.js +55 -0
  27. package/framework/hooks/shared/telemetry-logger.js +25 -2
  28. package/framework/hooks/shared/transcript-usage.js +80 -0
  29. package/framework/mcp/registry.json +33 -2
  30. package/framework/rubrics/backend.json +2 -2
  31. package/framework/rubrics/default.json +2 -2
  32. package/framework/rubrics/frontend.json +2 -2
  33. package/framework/rules/csharp-standards.md +1 -0
  34. package/framework/rules/infrastructure-standards.md +0 -1
  35. package/framework/rules/morph-workflow.md +10 -4
  36. package/framework/rules/nextjs-standards.md +1 -0
  37. package/framework/schemas/config.schema.json +41 -1
  38. package/framework/schemas/feature.schema.json +5 -1
  39. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  40. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  41. package/framework/skills/core/morph-apply/SKILL.md +40 -11
  42. package/framework/skills/core/morph-brainstorming/SKILL.md +16 -7
  43. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  44. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  45. package/framework/skills/core/morph-implement/SKILL.md +44 -10
  46. package/framework/skills/core/morph-media/SKILL.md +1 -1
  47. package/framework/skills/core/morph-plan/SKILL.md +73 -15
  48. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  49. package/framework/skills/core/morph-review/SKILL.md +51 -13
  50. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  51. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  52. package/framework/skills/core/morph-uiux/SKILL.md +39 -13
  53. package/framework/standards/STANDARDS.json +189 -14
  54. package/framework/standards/ai-agents/context-providers.md +16 -26
  55. package/framework/standards/ai-agents/durable-workflows-hitl.md +15 -21
  56. package/framework/standards/ai-agents/modalities-image-gen.md +21 -25
  57. package/framework/standards/ai-agents/modalities-speech-to-text.md +4 -15
  58. package/framework/standards/ai-agents/modalities-text-to-speech.md +2 -11
  59. package/framework/standards/ai-agents/modalities-vision.md +1 -18
  60. package/framework/standards/ai-agents/prompt-sources.md +0 -4
  61. package/framework/standards/ai-agents/testing-ai.md +2 -1
  62. package/framework/standards/architecture/code-comments.md +100 -0
  63. package/framework/standards/backend/dotnet/testing.md +22 -0
  64. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +63 -50
  65. package/framework/standards/frontend/admin-app/data-table.md +562 -0
  66. package/framework/standards/frontend/admin-app/feedback.md +236 -0
  67. package/framework/standards/frontend/admin-app/forms.md +232 -0
  68. package/framework/standards/frontend/admin-app/page-structure.md +203 -0
  69. package/framework/standards/frontend/design-system/ai-image-generation.md +5 -5
  70. package/framework/standards/frontend/nextjs/components.md +37 -58
  71. package/framework/standards/frontend/nextjs/data-fetching.md +1 -0
  72. package/framework/standards/frontend/nextjs/forms.md +31 -30
  73. package/framework/standards/frontend/nextjs/state-management.md +2 -1
  74. package/framework/standards/frontend/nextjs/testing.md +18 -0
  75. package/framework/standards/integration/mcp/mcp-tools.md +48 -117
  76. package/framework/templates/code/dotnet/abstractions/result.cs +0 -2
  77. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +26 -39
  78. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +28 -68
  79. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +99 -117
  80. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +2 -44
  81. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +40 -61
  82. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +54 -107
  83. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +53 -58
  84. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +192 -281
  85. package/framework/templates/code/dotnet/database/migration.cs +49 -83
  86. package/framework/templates/code/dotnet/database/vector-entity.cs +2 -46
  87. package/framework/templates/code/dotnet/extensions/result-extensions.cs +0 -2
  88. package/framework/templates/code/dotnet/feature/validator.cs.hbs +11 -15
  89. package/framework/templates/code/dotnet/jobs/agent.cs +3 -51
  90. package/framework/templates/code/dotnet/jobs/job.cs +2 -97
  91. package/framework/templates/code/dotnet/test.cs +2 -87
  92. package/framework/templates/code/typescript/contracts.ts +140 -167
  93. package/framework/templates/context/design-system.md +3 -3
  94. package/framework/templates/docs/proposal.md +11 -0
  95. package/framework/templates/docs/spec.md +15 -0
  96. package/framework/templates/docs/ui-mockups.md +115 -98
  97. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentApi.cs +1 -48
  98. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactory.cs +3 -79
  99. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentFactoryOptions.cs +1 -70
  100. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpec.cs +1 -47
  101. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Agents/AgentSpecOptions.cs +2 -99
  102. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/AiKitAssemblyMarker.cs +0 -6
  103. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/ConversationStore.cs +3 -88
  104. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/IConversationStore.cs +1 -41
  105. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Conversation/InMemoryConversationStore.cs +0 -21
  106. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/CountingChatClient.cs +1 -25
  107. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalAssertion.cs +2 -63
  108. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheMissException.cs +0 -30
  109. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheOptions.cs +1 -52
  110. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalCacheStamp.cs +3 -78
  111. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalFixture.cs +4 -109
  112. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalMode.cs +0 -22
  113. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalQualityEvaluators.cs +1 -36
  114. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalScenario.cs +2 -57
  115. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/EvalSummary.cs +0 -75
  116. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/OfflineChatClient.cs +2 -59
  117. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Evals/ShortPathResponseCache.cs +4 -111
  118. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaGenerator.cs +0 -34
  119. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/IMediaProvider.cs +1 -57
  120. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaArtifact.cs +0 -19
  121. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaDisclosure.cs +2 -74
  122. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaGenerator.cs +2 -33
  123. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaIdempotencyKey.cs +1 -43
  124. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJob.cs +1 -96
  125. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobId.cs +0 -13
  126. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaJobState.cs +0 -27
  127. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaPricing.cs +0 -33
  128. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaProvenance.cs +0 -31
  129. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaRequest.cs +3 -75
  130. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaResult.cs +0 -42
  131. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaReview.cs +0 -39
  132. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaSeams.cs +1 -55
  133. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MediaStore.cs +3 -59
  134. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/MorphMedia.cs +0 -46
  135. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/GoogleImageProvider.cs +3 -28
  136. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/OpenAIImageProvider.cs +7 -61
  137. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Media/Providers/VeoVideoProvider.cs +2 -37
  138. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/IUsageCostSink.cs +1 -23
  139. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostMiddleware.cs +2 -82
  140. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Middleware/UsageCostRecord.cs +1 -68
  141. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphOpenTelemetryExtensions.cs +1 -62
  142. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Observability/MorphTelemetryOptions.cs +2 -62
  143. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/IProviderClientFactory.cs +1 -25
  144. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelAlias.cs +0 -124
  145. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Providers/ModelRegistry.cs +1 -73
  146. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaIngest.cs +1 -66
  147. package/framework/templates/dotnet/ai-kit/src/Morph.AiKit/Rag/DeltaPlan.cs +0 -41
  148. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentFactoryTests.cs +2 -112
  149. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/AgentSpecTests.cs +2 -43
  150. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ConversationStoreTests.cs +5 -110
  151. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/DeltaIngestTests.cs +7 -103
  152. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalAssertionTests.cs +1 -42
  153. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheMissTests.cs +1 -30
  154. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalCacheStampTests.cs +1 -19
  155. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFailuresTests.cs +0 -48
  156. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalFixtureTests.cs +1 -29
  157. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalRefreshTests.cs +2 -38
  158. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/EvalWorkspace.cs +1 -14
  159. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/KitEvals.cs +4 -69
  160. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Evals/OfflineChatClientTests.cs +1 -10
  161. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClient.cs +1 -57
  162. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeChatClientTests.cs +0 -26
  163. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeClock.cs +1 -19
  164. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeMediaProvider.cs +0 -32
  165. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeProviderClientFactory.cs +0 -14
  166. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/FakeUsageCostSink.cs +0 -24
  167. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Fakes/InMemoryMediaStore.cs +0 -32
  168. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAccessibilityTests.cs +0 -37
  169. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaAliasIsNotAnAgentTests.cs +0 -24
  170. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaDisclosureTests.cs +0 -78
  171. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaGeneratorTests.cs +0 -53
  172. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaIdempotencyKeyTests.cs +0 -44
  173. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaJobStateTests.cs +0 -44
  174. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaReviewTests.cs +0 -19
  175. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/MediaStoreTests.cs +0 -24
  176. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/ModelRegistryTests.cs +6 -48
  177. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/SkeletonSmokeTests.cs +1 -13
  178. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/TelemetryTests.cs +2 -71
  179. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/UsageCostMiddlewareTests.cs +5 -100
  180. package/framework/templates/feature/decisions.md +124 -122
  181. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +39 -43
  182. package/framework/templates/frontend/nextjs/env.mjs.hbs +29 -32
  183. package/framework/templates/frontend/nextjs/page.tsx.hbs +21 -22
  184. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +42 -54
  185. package/framework/templates/integrations/asaas-client.cs +332 -387
  186. package/framework/templates/integrations/asaas-webhook.cs +290 -351
  187. package/framework/templates/integrations/neon-auth-config.cs +9 -105
  188. package/framework/templates/project/validate.js +97 -120
  189. package/package.json +2 -2
  190. package/src/commands/dashboard/dashboard.js +80 -53
  191. package/src/commands/graph/affected.js +4 -3
  192. package/src/commands/graph/build.js +109 -18
  193. package/src/commands/graph/explain.js +8 -5
  194. package/src/commands/graph/status.js +14 -8
  195. package/src/commands/mcp/mcp-setup.js +37 -9
  196. package/src/commands/observability/cost.js +148 -18
  197. package/src/commands/project/doctor.js +85 -15
  198. package/src/commands/project/init.js +6 -3
  199. package/src/commands/project/install-plugin-cmd.js +1 -1
  200. package/src/commands/project/update.js +30 -0
  201. package/src/commands/project/worktree.js +73 -90
  202. package/src/commands/state/advance.js +18 -0
  203. package/src/commands/state/approve.js +15 -0
  204. package/src/commands/state/archive.js +62 -9
  205. package/src/commands/state/finish.js +62 -14
  206. package/src/commands/state/gate-check.js +8 -1
  207. package/src/commands/state/gate-decision.js +8 -4
  208. package/src/commands/state/report-check.js +90 -9
  209. package/src/commands/state/usecase.js +66 -0
  210. package/src/commands/tasks/dag.js +17 -2
  211. package/src/commands/validation/validate-feature.js +6 -1
  212. package/src/commands/validation/verify.js +20 -1
  213. package/src/dashboard/app/App.jsx +80 -16
  214. package/src/dashboard/app/components/ActivityFeed.jsx +172 -0
  215. package/src/dashboard/app/components/CommandPalette.jsx +155 -0
  216. package/src/dashboard/app/components/DetailPanel.jsx +47 -11
  217. package/src/dashboard/app/components/EntityLink.jsx +55 -0
  218. package/src/dashboard/app/components/ErrorBoundary.jsx +54 -0
  219. package/src/dashboard/app/components/FlowGraph.jsx +19 -17
  220. package/src/dashboard/app/components/KnowledgeMarkdown.jsx +53 -0
  221. package/src/dashboard/app/components/Markdown.jsx +47 -0
  222. package/src/dashboard/app/components/PhaseRail.jsx +111 -0
  223. package/src/dashboard/app/components/SearchFilter.jsx +40 -22
  224. package/src/dashboard/app/components/Sidebar.jsx +146 -37
  225. package/src/dashboard/app/hooks/useMorphQuery.js +54 -0
  226. package/src/dashboard/app/hooks/usePref.js +38 -0
  227. package/src/dashboard/app/lib/format.js +69 -0
  228. package/src/dashboard/app/lib/phases.js +56 -0
  229. package/src/dashboard/app/lib/project.js +50 -0
  230. package/src/dashboard/app/pages/Activity.jsx +24 -0
  231. package/src/dashboard/app/pages/AgentMap.jsx +371 -370
  232. package/src/dashboard/app/pages/FeatureDetail.jsx +604 -0
  233. package/src/dashboard/app/pages/Features.jsx +400 -0
  234. package/src/dashboard/app/pages/Guide.jsx +146 -0
  235. package/src/dashboard/app/pages/HookInspector.jsx +357 -322
  236. package/src/dashboard/app/pages/PhasePipeline.jsx +277 -408
  237. package/src/dashboard/app/pages/Portfolio.jsx +156 -0
  238. package/src/dashboard/app/pages/Rubrics.jsx +101 -0
  239. package/src/dashboard/app/pages/RulesMap.jsx +300 -273
  240. package/src/dashboard/app/pages/SkillsLibrary.jsx +483 -366
  241. package/src/dashboard/app/pages/Standards.jsx +407 -332
  242. package/src/dashboard/app/styles/index.css +187 -47
  243. package/src/dashboard/index.html +14 -12
  244. package/src/dashboard/package.json +32 -24
  245. package/src/dashboard/scanner/activity-scanner.js +351 -0
  246. package/src/dashboard/scanner/agents-scanner.js +63 -1
  247. package/src/dashboard/scanner/docs-scanner.js +59 -0
  248. package/src/dashboard/scanner/feature-status.js +10 -0
  249. package/src/dashboard/scanner/features-scanner.js +360 -0
  250. package/src/dashboard/scanner/hooks-scanner.js +8 -1
  251. package/src/dashboard/scanner/index.js +14 -7
  252. package/src/dashboard/scanner/phases-scanner.js +14 -15
  253. package/src/dashboard/scanner/portfolio.js +153 -0
  254. package/src/dashboard/scanner/project-api.js +195 -0
  255. package/src/dashboard/scanner/rules-scanner.js +42 -37
  256. package/src/dashboard/scanner/skills-scanner.js +105 -43
  257. package/src/dashboard/scanner/standards-scanner.js +4 -1
  258. package/src/dashboard/vite-plugin-morph-data.js +134 -17
  259. package/src/lib/ai/comment-stripper.js +101 -4
  260. package/src/lib/detectors/claude-config-detector.js +2 -7
  261. package/src/lib/detectors/index.js +5 -1
  262. package/src/lib/evaluator-report.js +47 -0
  263. package/src/lib/generators/handoff-generator.js +4 -18
  264. package/src/lib/generators/settings-generator.js +208 -209
  265. package/src/lib/git/default-branch.js +52 -0
  266. package/src/lib/git/tree-fingerprint.js +58 -0
  267. package/src/lib/git/tree-state.js +167 -0
  268. package/src/lib/graph/god-nodes.js +12 -3
  269. package/src/lib/graph/graph-health.js +48 -2
  270. package/src/lib/graph/graph-loader.js +99 -25
  271. package/src/lib/graph/graphifyignore.js +145 -28
  272. package/src/lib/graph/sanitize.js +117 -11
  273. package/src/lib/installers/mcp-installer.js +724 -719
  274. package/src/lib/installers/memory-installer.js +1 -2
  275. package/src/lib/projects/registry.js +154 -0
  276. package/src/lib/risk-detector.js +90 -0
  277. package/src/lib/specs/acervo-format.js +70 -0
  278. package/src/lib/specs/acervo-lifecycle.js +264 -0
  279. package/src/lib/specs/adr-index.js +169 -0
  280. package/src/lib/specs/revocations.js +156 -0
  281. package/src/lib/specs/screen-checklist.js +357 -0
  282. package/src/lib/specs/usecase-check.js +20 -16
  283. package/src/lib/specs/usecase-promoter.js +103 -38
  284. package/src/lib/specs/usecase-registry.js +7 -7
  285. package/src/lib/tasks/tasks-json-parser.js +2 -3
  286. package/src/lib/telemetry/metrics.js +386 -0
  287. package/src/lib/telemetry/telemetry-reader.js +161 -51
  288. package/src/lib/telemetry/verify-event.js +79 -0
  289. package/src/lib/validators/code-budget/code-budget-validator.js +200 -0
  290. package/src/lib/validators/code-budget/comment-scanner.js +75 -0
  291. package/src/lib/validators/code-budget/index.js +9 -0
  292. package/src/lib/validators/reuse/reuse-validator.js +4 -2
  293. package/src/lib/validators/reuse/symbol-scanner.js +27 -22
  294. package/src/lib/validators/validation-runner.js +21 -5
  295. package/src/lib/validators/verify-runner.js +11 -0
  296. package/src/lib/worktree/finish-backup.js +739 -0
  297. package/src/lib/worktree/ignored-triage.js +15 -8
  298. package/src/lib/worktree/worktree-audit.js +5 -0
  299. package/src/scripts/install-plugin.js +1 -1
  300. package/src/scripts/setup-infra.js +31 -22
  301. package/src/utils/agents-installer.js +338 -24
  302. package/src/utils/banner.js +1 -1
  303. package/src/utils/hooks-installer.js +75 -15
  304. package/src/commands/index.js +0 -13
  305. package/src/commands/project/checkpoint.js +0 -209
  306. package/src/core/state/phase-state-machine.js +0 -197
  307. package/src/dashboard/app/hooks/useDetailPanel.js +0 -15
  308. package/src/dashboard/app/pages/LiveState.jsx +0 -206
  309. package/src/dashboard/scanner/squads-scanner.js +0 -13
  310. package/src/dashboard/scanner/state-scanner.js +0 -85
  311. package/src/lib/generators/index.js +0 -10
  312. package/src/lib/hooks/hook-executor.js +0 -167
  313. package/src/lib/validators/checkpoint-persistence.js +0 -39
  314. package/src/lib/validators/shared/emit-validator-dispatch.js +0 -14
  315. package/src/lib/validators/shared/index.js +0 -12
  316. package/src/lib/validators/shared/issue-counter.js +0 -14
  317. package/src/lib/validators/shared/result-formatter.js +0 -124
@@ -0,0 +1,167 @@
1
+ /**
2
+ * The working tree as the orchestrator needs it when it takes control back
3
+ * from a dispatch, in one call instead of `git status` + `git diff --stat` +
4
+ * `git log` + a marker grep. Everything is read outside `.morph/`, the same
5
+ * cut the tree fingerprint makes.
6
+ */
7
+
8
+ import { existsSync } from 'fs';
9
+ import { join } from 'path';
10
+ import { OUTSIDE_MORPH, treeFingerprint, git as gitBuffer } from './tree-fingerprint.js';
11
+ import { detectDefaultBranch } from './default-branch.js';
12
+ import { resolveBaseRef } from './branch-classifier.js';
13
+
14
+ /** What an evaluator writes next to a mutation it has not reverted yet. */
15
+ export const MUTATION_MARKER = 'MUTACAO';
16
+ const MAX_LISTED = 50;
17
+ const MAX_COMMITS = 10;
18
+
19
+ function git(cwd, args, input) {
20
+ return gitBuffer(cwd, args, input).toString();
21
+ }
22
+
23
+ function headSha(cwd) {
24
+ try { return git(cwd, ['rev-parse', 'HEAD']).trim(); } catch { return null; }
25
+ }
26
+
27
+ /** Every file that differs from HEAD outside `.morph/`, untracked ones one by one. */
28
+ function changedFiles(cwd) {
29
+ return parsePorcelain(git(cwd, ['status', '--porcelain=v1', '-z', '--untracked-files=all', ...OUTSIDE_MORPH]))
30
+ .map((e) => e.path);
31
+ }
32
+
33
+ /** Blob id of each file as it is on disk; null for a file that is gone. */
34
+ function diskBlobs(cwd, paths) {
35
+ const present = paths.filter((p) => existsSync(join(cwd, p)));
36
+ const ids = present.length ? git(cwd, ['hash-object', '--stdin-paths'], present.join('\n')).split('\n') : [];
37
+ const out = Object.fromEntries(paths.map((p) => [p, null]));
38
+ present.forEach((p, i) => { out[p] = ids[i].trim(); });
39
+ return out;
40
+ }
41
+
42
+ /** Blob id of each path in a commit; null where the commit does not have it. */
43
+ function commitBlobs(cwd, commit, paths) {
44
+ const out = Object.fromEntries(paths.map((p) => [p, null]));
45
+ if (!commit || !paths.length) return out;
46
+ const lines = git(cwd, ['cat-file', '--batch-check=%(objectname)'], paths.map((p) => `${commit}:${p}`).join('\n')).split('\n');
47
+ paths.forEach((p, i) => { out[p] = /^[0-9a-f]{40,64}$/.test(lines[i]?.trim()) ? lines[i].trim() : null; });
48
+ return out;
49
+ }
50
+
51
+ /**
52
+ * What the tree is before a dispatch whose work must leave it unchanged (the
53
+ * evaluator mutates and reverts): HEAD plus the blob of every file that
54
+ * differs from it. Kept on disk, so a session that resumes after the agent
55
+ * died compares against the tree before the dispatch, not its own start.
56
+ * @returns {{ at: string, head: string|null, files: Object<string, string|null> }|null}
57
+ */
58
+ export function snapshotTree(cwd) {
59
+ try {
60
+ return { at: new Date().toISOString(), head: headSha(cwd), files: diskBlobs(cwd, changedFiles(cwd)) };
61
+ } catch {
62
+ return null;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Files whose content on disk is not what it was in the snapshot. Content,
68
+ * not status: a mutation committed since shows up, and a file committed
69
+ * unchanged does not.
70
+ * @returns {{ at: string, headChanged: boolean, changed: string[], changedCount: number }}
71
+ */
72
+ export function compareToSnapshot(cwd, snapshot) {
73
+ const head = headSha(cwd);
74
+ const candidates = new Set([...Object.keys(snapshot.files || {}), ...changedFiles(cwd)]);
75
+ if (snapshot.head && head && snapshot.head !== head) {
76
+ for (const p of git(cwd, ['diff', '--name-only', '-z', snapshot.head, head, ...OUTSIDE_MORPH]).split('\0')) {
77
+ if (p) candidates.add(p);
78
+ }
79
+ }
80
+ const paths = [...candidates];
81
+ const now = diskBlobs(cwd, paths);
82
+ const unlisted = paths.filter((p) => !(p in (snapshot.files || {})));
83
+ const before = { ...commitBlobs(cwd, snapshot.head, unlisted), ...snapshot.files };
84
+ const changed = paths.filter((p) => before[p] !== now[p]).sort();
85
+ return { at: snapshot.at, headChanged: snapshot.head !== head, changed: changed.slice(0, MAX_LISTED), changedCount: changed.length };
86
+ }
87
+
88
+ /** `git status --porcelain -z`: a rename carries its old path as an extra entry. */
89
+ function parsePorcelain(out) {
90
+ const entries = [];
91
+ const parts = out.split('\0');
92
+ for (let i = 0; i < parts.length; i++) {
93
+ const item = parts[i];
94
+ if (!item) continue;
95
+ const status = item.slice(0, 2);
96
+ entries.push({ status: status.trim(), path: item.slice(3) });
97
+ if (status[0] === 'R' || status[0] === 'C') i++;
98
+ }
99
+ return entries;
100
+ }
101
+
102
+ function diffstat(cwd) {
103
+ const stat = { files: 0, insertions: 0, deletions: 0 };
104
+ for (const line of git(cwd, ['diff', 'HEAD', '--numstat', ...OUTSIDE_MORPH]).split('\n')) {
105
+ const [ins, del] = line.split('\t');
106
+ if (del === undefined) continue;
107
+ stat.files += 1;
108
+ stat.insertions += Number(ins) || 0;
109
+ stat.deletions += Number(del) || 0;
110
+ }
111
+ return stat;
112
+ }
113
+
114
+ function commitsOnTop(cwd) {
115
+ const base = resolveBaseRef(detectDefaultBranch(cwd), cwd);
116
+ if (!base) return null;
117
+ return git(cwd, ['log', `--max-count=${MAX_COMMITS}`, '--format=%h %s', `${base}..HEAD`])
118
+ .split('\n').filter(Boolean);
119
+ }
120
+
121
+ /** Untracked files count too; gitignored ones (build output) do not. */
122
+ function markers(cwd) {
123
+ try {
124
+ const out = git(cwd, ['grep', '-n', '-I', '--untracked', '-F', '-e', MUTATION_MARKER, ...OUTSIDE_MORPH]);
125
+ return out.split('\n').filter(Boolean).slice(0, MAX_LISTED).map((line) => {
126
+ const [file, lineNo] = line.split(':');
127
+ return { file, line: Number(lineNo) };
128
+ });
129
+ } catch (err) {
130
+ if (err.status === 1) return [];
131
+ throw err;
132
+ }
133
+ }
134
+
135
+ /**
136
+ * @param {string} cwd
137
+ * @param {{ snapshot?: object|null }} [opts] - a `snapshotTree()` to compare with
138
+ * @returns {{ head: string|null, branch: string|null, fingerprint: string|null,
139
+ * dirty: {status: string, path: string}[], dirtyCount: number,
140
+ * diffstat: {files: number, insertions: number, deletions: number}|null,
141
+ * commits: string[]|null, markers: {file: string, line: number}[],
142
+ * sinceBeforeEval: ReturnType<typeof compareToSnapshot>|null }|null}
143
+ * null when git cannot answer (outside a repo, or an error): unknown, never
144
+ * a clean tree. `commits` is null when there is no default branch to count
145
+ * from; `diffstat` is null before the first commit; `sinceBeforeEval` is
146
+ * null without a snapshot.
147
+ */
148
+ export function treeState(cwd, { snapshot = null } = {}) {
149
+ try {
150
+ const dirty = parsePorcelain(git(cwd, ['status', '--porcelain=v1', '-z', ...OUTSIDE_MORPH]));
151
+ let head = null;
152
+ try { head = git(cwd, ['rev-parse', '--short', 'HEAD']).trim(); } catch { /* no commit yet */ }
153
+ return {
154
+ head,
155
+ branch: head ? git(cwd, ['rev-parse', '--abbrev-ref', 'HEAD']).trim() : null,
156
+ fingerprint: treeFingerprint(cwd),
157
+ dirty: dirty.slice(0, MAX_LISTED),
158
+ dirtyCount: dirty.length,
159
+ diffstat: head ? diffstat(cwd) : null,
160
+ commits: head ? commitsOnTop(cwd) : null,
161
+ markers: markers(cwd),
162
+ sinceBeforeEval: snapshot ? compareToSnapshot(cwd, snapshot) : null,
163
+ };
164
+ } catch {
165
+ return null;
166
+ }
167
+ }
@@ -7,11 +7,16 @@
7
7
  * substituí-lo, porque exaustividade é um valor que o grafo não tem.
8
8
  *
9
9
  * Só entram nós COM source_file: um god node sem arquivo não orienta ninguém.
10
+ * E nada de teste: dublês e fixtures estão no grafo para serem achados, não
11
+ * para disputar centralidade — sem o filtro o `FakeGhlToolbelt` entra no top
12
+ * 10 do MORPH_GHLBrain e as arestas vindas de teste inflam o grau de `Result`
13
+ * de 380 para 443. Aresta com uma ponta em teste não conta para ninguém, e
14
+ * comunidade só de teste some da lista.
10
15
  *
11
16
  * @module god-nodes
12
17
  */
13
18
  import { loadGraph } from './graph-loader.js';
14
- import { buildIndex, resolveEdgeTarget } from './sanitize.js';
19
+ import { buildIndex, resolveEdgeTarget, linksOf, isTestPath } from './sanitize.js';
15
20
 
16
21
  /** Sem `graphify label`, as comunidades se chamam Community_N — ruído puro. */
17
22
  const PLACEHOLDER_LABEL = /^Community[_ ]?\d+$/i;
@@ -37,9 +42,12 @@ export async function scanGraph(projectPath, opts = {}) {
37
42
  const bump = (id) => {
38
43
  if (id) degree.set(id, (degree.get(id) ?? 0) + 1);
39
44
  };
40
- for (const link of graph.links ?? []) {
45
+ const isTest = (id) => isTestPath(index.byId.get(id)?.source_file);
46
+ for (const link of linksOf(index, graph)) {
47
+ const target = resolveEdgeTarget(index, link).id;
48
+ if (isTest(link.source) || (target && isTest(target))) continue;
41
49
  bump(link.source);
42
- bump(resolveEdgeTarget(index, link).id);
50
+ bump(target);
43
51
  }
44
52
 
45
53
  const godNodes = [...degree.entries()]
@@ -57,6 +65,7 @@ export async function scanGraph(projectPath, opts = {}) {
57
65
  const labels = new Map();
58
66
  for (const node of graph.nodes ?? []) {
59
67
  if (node.community === undefined || node.community === null) continue;
68
+ if (isTestPath(node.source_file)) continue;
60
69
  sizes.set(node.community, (sizes.get(node.community) ?? 0) + 1);
61
70
  if (node.community_label && !labels.has(node.community)) {
62
71
  labels.set(node.community, node.community_label);
@@ -6,9 +6,46 @@
6
6
  *
7
7
  * @module graph-health
8
8
  */
9
- import { loadGraph, getFreshness } from './graph-loader.js';
9
+ import { execFileSync } from 'node:child_process';
10
+ import { readFileSync } from 'node:fs';
11
+ import { join, resolve } from 'node:path';
12
+ import {
13
+ loadGraph, getFreshness, describeStale, staleCause,
14
+ } from './graph-loader.js';
10
15
  import { GRAPHIFY_VERSION, detectGraphifyVersion } from './graphify-runtime.js';
11
16
 
17
+ const GRAPHIFY_HOOK_MARKER = /# graphify-(?:checkout-)?hook-start/;
18
+
19
+ /**
20
+ * O hook git do próprio graphify (`graphify hook install`, que o setup-infra
21
+ * instalava até a 8.37) está no repositório? Ele é danoso aqui: num commit
22
+ * depois de um fast-forward carimba o HEAD num grafo SEM os arquivos que o
23
+ * merge trouxe — "fresh" falso — e disputa o graph.json com o refresh do
24
+ * morph-spec. Lê o diretório de hooks que o git usa (`core.hooksPath`
25
+ * incluído).
26
+ *
27
+ * @param {string} projectPath
28
+ * @returns {boolean}
29
+ */
30
+ export function graphifyGitHookInstalled(projectPath) {
31
+ let dir;
32
+ try {
33
+ const rel = execFileSync('git', ['rev-parse', '--git-path', 'hooks'], {
34
+ cwd: projectPath, encoding: 'utf-8', stdio: ['ignore', 'pipe', 'ignore'], windowsHide: true,
35
+ }).trim();
36
+ dir = resolve(projectPath, rel);
37
+ } catch {
38
+ return false;
39
+ }
40
+ return ['post-commit', 'post-checkout'].some((hook) => {
41
+ try {
42
+ return GRAPHIFY_HOOK_MARKER.test(readFileSync(join(dir, hook), 'utf-8'));
43
+ } catch {
44
+ return false;
45
+ }
46
+ });
47
+ }
48
+
12
49
  /**
13
50
  * @param {string} projectPath
14
51
  * @param {{_version?: () => string|null}} [deps]
@@ -41,7 +78,16 @@ export function auditGraph(projectPath, deps = {}) {
41
78
  if (freshness.state === 'stale') {
42
79
  status = 'warn';
43
80
  details.push(
44
- `stale: construído em ${freshness.builtAtCommit.slice(0, 8)}, HEAD é ${freshness.headCommit.slice(0, 8)} — morph-spec graph refresh`,
81
+ `stale: ${staleCause(freshness)} — ${describeStale(projectPath, freshness)}`,
82
+ );
83
+ }
84
+
85
+ if (graphifyGitHookInstalled(projectPath)) {
86
+ status = 'warn';
87
+ details.push(
88
+ 'hook git do graphify instalado: depois de um fast-forward ele carimba o HEAD num grafo sem os arquivos do merge ' +
89
+ 'e disputa o graph.json com o refresh do morph-spec — remova com `graphify hook uninstall` ' +
90
+ '(tira também o merge driver do .gitattributes)',
45
91
  );
46
92
  }
47
93
 
@@ -12,7 +12,14 @@
12
12
  */
13
13
  import { readFileSync, existsSync } from 'node:fs';
14
14
  import { join } from 'node:path';
15
- import { execFileSync } from 'node:child_process';
15
+ import {
16
+ graphFreshness, readRefreshRecord,
17
+ } from '../../../framework/hooks/shared/graph-freshness.js';
18
+ import {
19
+ isPrimaryTree, readRefreshLock, resolveGraphify,
20
+ } from '../../../framework/hooks/shared/graph-refresh.js';
21
+ import { isSecondaryWorktree, workTreeTop } from '../../../framework/hooks/shared/git-paths.js';
22
+ import { GRAPHIFY_PACKAGE, GRAPHIFY_VERSION } from './graphify-runtime.js';
16
23
 
17
24
  /**
18
25
  * @param {string} projectPath
@@ -50,37 +57,104 @@ export function loadGraph(projectPath) {
50
57
  }
51
58
  }
52
59
 
53
- function readHead(projectPath) {
54
- try {
55
- return execFileSync('git', ['rev-parse', 'HEAD'], {
56
- cwd: projectPath,
57
- encoding: 'utf-8',
58
- stdio: ['ignore', 'pipe', 'ignore'],
59
- }).trim() || null;
60
- } catch {
61
- return null;
62
- }
63
- }
64
-
65
60
  /**
66
- * O hook post-commit do graphify rebuilda em BACKGROUND: logo depois de um
67
- * commit o grafo ainda é o do commit anterior. Quem consome precisa saber, ou
68
- * conclui "ninguém usa isso" a partir de um grafo velho.
61
+ * O frescor do grafo, pela regra única (framework/hooks/shared/graph-freshness.js):
62
+ * o carimbo e o registro da última rodada contra o HEAD, e as regras do
63
+ * `.graphifyignore` por conteúdo. Um grafo velho diz "não existe" sobre o que
64
+ * o último commit trouxe — quem consome precisa saber, ou conclui "ninguém usa
65
+ * isso" a partir dele.
69
66
  *
70
67
  * @param {string} projectPath
71
- * @param {object} graph
68
+ * @param {object} graph - o graph.json já parseado (o carimbo sai dele)
72
69
  * @param {{_head?: (projectPath: string) => string|null}} [deps]
73
- * @returns {{builtAtCommit: string|null, headCommit: string|null, state: 'fresh'|'stale'|'unknown'}}
70
+ * @returns {{builtAtCommit: string|null, headCommit: string|null, state: 'fresh'|'stale'|'unknown', reason?: 'ignore-changed'|'rules-unknown'}}
74
71
  */
75
72
  export function getFreshness(projectPath, graph, deps = {}) {
76
- const headCommit = (deps._head ?? readHead)(projectPath);
77
- const builtAtCommit = graph?.built_at_commit ?? null;
78
- if (!headCommit || !builtAtCommit) {
79
- return { builtAtCommit, headCommit, state: 'unknown' };
73
+ const f = graphFreshness(projectPath, {
74
+ graph,
75
+ ...(deps._head ? { head: deps._head(projectPath) } : {}),
76
+ });
77
+ return {
78
+ builtAtCommit: f.built,
79
+ headCommit: f.head,
80
+ state: f.state === 'missing' ? 'unknown' : f.state,
81
+ ...(f.reason ? { reason: f.reason } : {}),
82
+ };
83
+ }
84
+
85
+ const short = (sha) => (sha ? String(sha).slice(0, 8) : '?');
86
+
87
+ /**
88
+ * O porquê de um grafo stale, em uma linha — carimbo antigo ou regras.
89
+ *
90
+ * @param {{builtAtCommit: string|null, headCommit: string|null, reason?: string}} freshness
91
+ * @returns {string}
92
+ */
93
+ export function staleCause(freshness) {
94
+ if (freshness.reason === 'ignore-changed') {
95
+ return 'as regras do .graphifyignore mudaram depois da extração';
96
+ }
97
+ if (freshness.reason === 'rules-unknown') {
98
+ return 'sem registro das regras do .graphifyignore com que o grafo foi extraído';
80
99
  }
100
+ return `construído em ${short(freshness.builtAtCommit)}, HEAD é ${short(freshness.headCommit)}`;
101
+ }
102
+
103
+ /**
104
+ * O que o refresh automático faz por esta árvore. Ele cobre só a árvore
105
+ * principal (`auto`); numa worktree, `running` é sempre false — um lock vivo
106
+ * ali seria o refresh da raiz. `last` é a última rodada registrada para esta
107
+ * árvore: em background, manual ou a semeadura.
108
+ *
109
+ * @param {string} projectPath
110
+ * @returns {{auto: boolean, running: boolean, since: string|null, pid: number|null, last: {ok: boolean, exit: number|null, head: string|null, ts: string, unavailable?: boolean, seeded?: boolean}|null}}
111
+ */
112
+ export function refreshState(projectPath) {
113
+ const auto = isPrimaryTree(projectPath);
114
+ const lock = auto ? readRefreshLock(projectPath) : null;
115
+ const record = readRefreshRecord(projectPath);
81
116
  return {
82
- builtAtCommit,
83
- headCommit,
84
- state: builtAtCommit === headCommit ? 'fresh' : 'stale',
117
+ auto,
118
+ running: Boolean(lock?.live),
119
+ since: lock?.live ? lock.since : null,
120
+ pid: lock?.live ? lock.pid : null,
121
+ last: record ? {
122
+ ok: record.exit === 0,
123
+ exit: record.exit ?? null,
124
+ head: record.head ?? null,
125
+ ts: record.at,
126
+ ...(record.unavailable ? { unavailable: true } : {}),
127
+ ...(record.seeded ? { seeded: true } : {}),
128
+ } : null,
85
129
  };
86
130
  }
131
+
132
+ /**
133
+ * A frase de grafo stale — a mesma no status, no explain, no affected e no
134
+ * doctor, e verdadeira sobre o mecanismo: o refresh em background só cobre a
135
+ * raiz, disparado pelos hooks do morph-spec; a worktree só anda à mão.
136
+ *
137
+ * @param {string} projectPath
138
+ * @param {{builtAtCommit?: string|null, headCommit?: string|null}} [freshness]
139
+ * @returns {string}
140
+ */
141
+ export function describeStale(projectPath, freshness = {}) {
142
+ const tree = workTreeTop(projectPath) ?? projectPath;
143
+ const state = refreshState(projectPath);
144
+ if (!state.auto && isSecondaryWorktree(tree)) {
145
+ return state.last?.seeded
146
+ ? `grafo semeado da raiz em ${short(state.last.head ?? freshness.builtAtCommit)}; \`morph-spec graph refresh\` atualiza esta worktree`
147
+ : 'o refresh automático só cobre a raiz; `morph-spec graph refresh` atualiza esta worktree';
148
+ }
149
+ if (state.running) {
150
+ return `refresh em background em andamento desde ${new Date(state.since).toLocaleTimeString()} — confirme no código antes de agir`;
151
+ }
152
+ // Registrado como ausente mas já de volta ao PATH: o próximo disparo roda.
153
+ if (state.last?.unavailable && !resolveGraphify()) {
154
+ return `graphify fora do PATH: o refresh automático está parado até ele voltar (uv tool install ${GRAPHIFY_PACKAGE}==${GRAPHIFY_VERSION}); depois, morph-spec graph refresh`;
155
+ }
156
+ if (state.last && !state.last.ok && state.last.head === (freshness.headCommit ?? null)) {
157
+ return `o último refresh falhou neste HEAD (exit ${state.last.exit ?? '?'}) e só se repete quando o HEAD andar — rode morph-spec graph refresh e leia a saída`;
158
+ }
159
+ return 'o refresh roda em background depois dos comandos git/gh/morph-spec do agente e no início da sessão; para já: morph-spec graph refresh';
160
+ }
@@ -2,30 +2,53 @@
2
2
  * Geração stack-aware do .graphifyignore (ADR-004).
3
3
  *
4
4
  * Filtrar teste não é cosmético: sem isso o god node #1 do MORPH_GHLBrain é
5
- * `FakeGhlToolbelt`, um dublê. Pior, os dublês criam um segundo implementador e
6
- * quebram o guard de "implementador único" do `dispatches_to` — a aresta que
7
- * atravessa a injeção de dependência cai de 109 para 1.
5
+ * `FakeGhlToolbelt`, um dublê. E o padrão genérico `tests/` NÃO serve em .NET:
6
+ * no ProspectPRO os testes vivem em `src/backend/ProspectPRO.Tests/`. Com
7
+ * `**\/*.Tests/` o grafo caiu de 10.450 para 7.384 nós — 30% era ruído de teste.
8
8
  *
9
- * E o padrão genérico `tests/` NÃO serve em .NET: no ProspectPRO os testes
10
- * vivem em `src/backend/ProspectPRO.Tests/`. Com `**\/*.Tests/` o grafo caiu de
11
- * 10.450 para 7.384 nós — 30% era ruído de teste.
9
+ * Mas dublê, fixture e helper de teste precisam ser ACHÁVEIS: são o que um
10
+ * teste novo deveria reusar, e fora do grafo `graph explain FakeX` dá
11
+ * not-found. Por isso o conteúdo dos projetos de teste sai e esses arquivos
12
+ * voltam. A forma é ditada pela regra do gitignore que o graphify 0.9.50 segue
13
+ * (detect.py `_is_ignored`): um `!` não reinclui nada sob um diretório já
14
+ * excluído. Medido num fixture: `**\/*.Tests/` + `!**\/Fakes/` não traz nada de
15
+ * volta; excluir o CONTEÚDO (`**\/*.Tests/**`), reincluir os diretórios
16
+ * (`!**\/*.Tests/**\/`) e então os helpers traz. `bin/`/`obj/` vêm DEPOIS das
17
+ * reinclusões — antes, a reinclusão de diretório os devolveria (`obj/` tem
18
+ * `.cs` gerado). No MORPH_GHLBrain: +9,6% de nós (11.591 → 12.706), 94
19
+ * arquivos de teste, nenhum de bin/obj.
20
+ *
21
+ * Os dublês quebram o guard de "implementador único" do `dispatches_to` do
22
+ * graphify (de 122 para 5 arestas no GHLBrain); o saneamento as recupera
23
+ * (sanitize.js `recoverDispatch`) e tira o teste dos god nodes.
12
24
  *
13
25
  * Em Next.js o problema é outro: metade dos nós vinha de JSON de configuração,
14
26
  * e os god nodes viravam `dependencies` e `compilerOptions`.
15
27
  *
16
28
  * @module graphifyignore
17
29
  */
18
- import { writeFileSync, existsSync } from 'node:fs';
30
+ import { writeFileSync, existsSync, readFileSync } from 'node:fs';
31
+ import { execFileSync } from 'node:child_process';
19
32
  import { join } from 'node:path';
20
33
  import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
34
+ import { ignoreRules } from '../../../framework/hooks/shared/graph-freshness.js';
21
35
 
22
36
  export const ALWAYS_IGNORED = [
23
37
  '.git/', '.morph/', 'artifacts/', ...WORKTREE_DIR_NAMES.map((d) => `${d}/`),
24
38
  'coverage/', 'graphify-out/', 'dist/', 'out/',
25
39
  ];
26
40
 
41
+ const TEST_PROJECTS = ['**/*.Tests', '**/*Tests', '**/*.Test'];
42
+ const TEST_HELPER_DIRS = ['TestDoubles', 'Fakes', 'Stubs', 'Mocks', 'Fixtures', 'Helpers', 'Builders'];
43
+ const TEST_HELPER_FILES = ['*Fixture.cs', '*Fixtures.cs', 'Fake*.cs', 'Stub*.cs', '*Stub.cs'];
44
+ const DOTNET_BUILD_OUTPUT = ['bin/', 'obj/', 'TestResults/'];
45
+
27
46
  const DOTNET_IGNORED = [
28
- '**/*.Tests/', '**/*Tests/', '**/*.Test/', 'bin/', 'obj/', 'TestResults/',
47
+ ...TEST_PROJECTS.map((p) => `${p}/**`),
48
+ ...TEST_PROJECTS.map((p) => `!${p}/**/`),
49
+ ...TEST_HELPER_DIRS.map((d) => `!**/${d}/**`),
50
+ ...TEST_HELPER_FILES.map((f) => `!**/${f}`),
51
+ ...DOTNET_BUILD_OUTPUT,
29
52
  ];
30
53
 
31
54
  const WEB_IGNORED = [
@@ -35,31 +58,35 @@ const WEB_IGNORED = [
35
58
  '/next.config.js', '/next.config.mjs', '/tailwind.config.js', '/tailwind.config.ts',
36
59
  ];
37
60
 
61
+ const HEADER = '# Gerado por morph-spec (ADR-004). Editável: um arquivo editado nunca é sobrescrito.';
62
+
63
+ /**
64
+ * O que a versão anterior gerava — os testes .NET inteiros fora. Só existe
65
+ * para reconhecer um arquivo que ninguém editou e trocá-lo pelo atual.
66
+ */
67
+ const PREVIOUS = {
68
+ header: '# Gerado por morph-spec (ADR-004). Editável: o CLI não sobrescreve.',
69
+ dotnetTitle: '# .NET — testes em qualquer nível (tests/ genérico não serve)',
70
+ dotnet: ['**/*.Tests/', '**/*Tests/', '**/*.Test/', ...DOTNET_BUILD_OUTPUT],
71
+ };
72
+
38
73
  const DOTNET_TAGS = new Set(['dotnet', 'aspnet']);
39
74
  const WEB_TAGS = new Set([
40
75
  'nextjs', 'next', 'next.js', 'react', 'vue', 'nodejs', 'nodejs-cli', 'node-cli',
41
76
  ]);
42
77
 
43
- /**
44
- * @param {string[]} [projectTags] saída de `getProjectTags(config)` — stack
45
- * splitado por `-`/`+` mais as integrations
46
- * @returns {string} conteúdo completo do .graphifyignore
47
- */
48
- export function buildGraphifyignore(projectTags = []) {
78
+ const hasDotnetTag = (tags) => [...new Set(tags)].some((t) => DOTNET_TAGS.has(t));
79
+
80
+ function render(projectTags, { header, dotnetTitle, dotnet }) {
49
81
  const tags = new Set(projectTags);
50
- const hasDotnet = [...tags].some((t) => DOTNET_TAGS.has(t));
51
82
  const hasWeb = [...tags].some((t) => WEB_TAGS.has(t));
52
83
 
53
84
  const sections = [
54
- ['# Gerado por morph-spec (ADR-004). Editável: o CLI não sobrescreve.', ''],
85
+ [header, ''],
55
86
  ['# Sempre', ...ALWAYS_IGNORED],
56
87
  ];
57
- if (hasDotnet) {
58
- sections.push(['', '# .NET — testes em qualquer nível (tests/ genérico não serve)', ...DOTNET_IGNORED]);
59
- }
60
- if (hasWeb) {
61
- sections.push(['', '# Web — dependências e JSON de configuração de raiz', ...WEB_IGNORED]);
62
- }
88
+ if (hasDotnetTag(tags)) sections.push(['', dotnetTitle, ...dotnet]);
89
+ if (hasWeb) sections.push(['', '# Web — dependências e JSON de configuração de raiz', ...WEB_IGNORED]);
63
90
 
64
91
  const seen = new Set();
65
92
  const lines = [];
@@ -73,15 +100,105 @@ export function buildGraphifyignore(projectTags = []) {
73
100
  }
74
101
 
75
102
  /**
76
- * Escreve o .graphifyignore. Arquivo já existente é do usuário: não é tocado.
103
+ * @param {string[]} [projectTags] saída de `getProjectTags(config)` — stack
104
+ * splitado por `-`/`+` mais as integrations
105
+ * @returns {string} conteúdo completo do .graphifyignore
106
+ */
107
+ export function buildGraphifyignore(projectTags = []) {
108
+ return render(projectTags, {
109
+ header: HEADER,
110
+ dotnetTitle: '# .NET — conteúdo dos projetos de teste fora; dublês, fixtures e helpers dentro',
111
+ dotnet: DOTNET_IGNORED,
112
+ });
113
+ }
114
+
115
+ const normalize = (text) => String(text).replace(/^/, '').replace(/\r\n/g, '\n');
116
+
117
+ /**
118
+ * Escreve ou migra o .graphifyignore.
119
+ *
120
+ * ausente → escreve o atual
121
+ * idêntico ao que a versão anterior
122
+ * gerava para as mesmas tags → troca pelo atual (`migrated`) —
123
+ * só se as REGRAS mudam (em
124
+ * projeto sem .NET a diferença é
125
+ * só o cabeçalho, e reescrever
126
+ * seria um diff à toa num arquivo
127
+ * versionado)
128
+ * qualquer outra coisa → é do usuário: não toca, e em
129
+ * projeto .NET que ainda exclui os
130
+ * testes inteiros devolve em
131
+ * `outdated` a troca a fazer
77
132
  *
78
133
  * @param {string} projectPath
79
134
  * @param {string[]} [projectTags]
80
- * @returns {{written: boolean, path: string}}
135
+ * @param {{fx?: {writeFile: (path: string, content: string) => void}}} [opts]
136
+ * coletor de efeitos do `update` — com ele, o `--dry-run` prevê a escrita
137
+ * @returns {{written: boolean, migrated?: boolean, outdated?: {remove: string[], add: string[]}, path: string}}
81
138
  */
82
- export function writeGraphifyignore(projectPath, projectTags = []) {
139
+ export function writeGraphifyignore(projectPath, projectTags = [], { fx } = {}) {
83
140
  const path = join(projectPath, '.graphifyignore');
84
- if (existsSync(path)) return { written: false, path };
85
- writeFileSync(path, buildGraphifyignore(projectTags), 'utf-8');
86
- return { written: true, path };
141
+ const current = buildGraphifyignore(projectTags);
142
+ const write = () => (fx ? fx.writeFile(path, current) : writeFileSync(path, current, 'utf-8'));
143
+
144
+ if (!existsSync(path)) {
145
+ write();
146
+ return { written: true, path };
147
+ }
148
+
149
+ let existing;
150
+ try {
151
+ existing = normalize(readFileSync(path, 'utf-8'));
152
+ } catch {
153
+ return { written: false, path };
154
+ }
155
+ if (existing === current) return { written: false, path };
156
+ if (existing === render(projectTags, PREVIOUS)) {
157
+ // A régua é a mesma do frescor do grafo (graph-freshness.js): o que o
158
+ // graphify lê, sem cabeçalho nem comentário.
159
+ if (ignoreRules(existing) === ignoreRules(current)) return { written: false, path };
160
+ write();
161
+ return { written: true, migrated: true, path };
162
+ }
163
+
164
+ // Acrescentar não basta: enquanto `**/*.Tests/` excluir o DIRETÓRIO, nenhum
165
+ // `!` reinclui o que está dentro dele.
166
+ const lines = new Set(existing.split('\n').map((l) => l.trim()));
167
+ const remove = PREVIOUS.dotnet.slice(0, 3).filter((l) => lines.has(l));
168
+ if (hasDotnetTag(projectTags) && remove.length) {
169
+ const add = DOTNET_IGNORED.filter((l) => !DOTNET_BUILD_OUTPUT.includes(l) && !lines.has(l));
170
+ return { written: false, outdated: { remove, add }, path };
171
+ }
172
+ return { written: false, path };
173
+ }
174
+
175
+ /**
176
+ * A frase que todo chamador imprime para um `outdated` — uma só.
177
+ *
178
+ * @param {{remove: string[], add: string[]}} outdated
179
+ * @returns {string}
180
+ */
181
+ export function describeOutdatedIgnore(outdated) {
182
+ return `.graphifyignore editado mantém os testes .NET inteiros fora do grafo ` +
183
+ `(dublês e fixtures dão not-found). Troque ${outdated.remove.join(' ')} por, nesta ordem e ` +
184
+ `antes de bin/ e obj/: ${outdated.add.join(' ')} — depois, morph-spec graph refresh`;
185
+ }
186
+
187
+ /**
188
+ * O `.graphifyignore` é versionado? Então uma migração só chega às worktrees
189
+ * e ao resto do time depois de commitada — cada checkout lê o seu. Fail-open:
190
+ * sem git, false.
191
+ *
192
+ * @param {string} projectPath
193
+ * @returns {boolean}
194
+ */
195
+ export function graphifyignoreTracked(projectPath) {
196
+ try {
197
+ execFileSync('git', ['ls-files', '--error-unmatch', '--', '.graphifyignore'], {
198
+ cwd: projectPath, stdio: 'ignore', windowsHide: true, timeout: 10000,
199
+ });
200
+ return true;
201
+ } catch {
202
+ return false;
203
+ }
87
204
  }