@polymorphism-tech/morph-spec 9.8.0 → 10.1.1

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 (202) hide show
  1. package/CLAUDE.md +12 -10
  2. package/README.md +4 -4
  3. package/bin/morph-spec.js +40 -43
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +28 -36
  6. package/docs/QUICKSTART.md +31 -191
  7. package/framework/CLAUDE.md +24 -30
  8. package/framework/MORPH.md +47 -123
  9. package/framework/agents.json +6 -374
  10. package/framework/commands/morph-apply.md +1 -1
  11. package/framework/commands/morph-archive.md +4 -3
  12. package/framework/commands/morph-hotfix.md +8 -30
  13. package/framework/commands/morph-infra.md +2 -2
  14. package/framework/commands/morph-media.md +10 -9
  15. package/framework/commands/morph-preflight.md +3 -3
  16. package/framework/commands/morph-review.md +15 -0
  17. package/framework/commands/morph-status.md +1 -1
  18. package/framework/docs/CLI.md +84 -95
  19. package/framework/evals/review-checklist.md +45 -53
  20. package/framework/hooks/README.md +4 -4
  21. package/framework/hooks/claude-code/core/README.md +1 -1
  22. package/framework/hooks/claude-code/core/denylist.json +4 -4
  23. package/framework/hooks/claude-code/core/gate-guard.js +9 -0
  24. package/framework/hooks/claude-code/core/masked-exit.js +49 -0
  25. package/framework/hooks/claude-code/core/state-sync.js +4 -1
  26. package/framework/hooks/claude-code/core/telemetry-log.js +1 -2
  27. package/framework/hooks/claude-code/core/test-budget.js +10 -70
  28. package/framework/hooks/claude-code/core/validate-completion.js +11 -24
  29. package/framework/hooks/claude-code/core/worktree-context.js +5 -22
  30. package/framework/hooks/claude-code/core/worktree-create.js +6 -45
  31. package/framework/hooks/claude-code/core/worktree-env-inject.js +2 -10
  32. package/framework/hooks/shared/e2e-check.js +0 -0
  33. package/framework/hooks/shared/gate-event.js +2 -2
  34. package/framework/hooks/shared/graph-refresh.js +20 -3
  35. package/framework/hooks/shared/heavy-command.js +32 -1
  36. package/framework/hooks/shared/heavy-config.js +1 -1
  37. package/framework/hooks/shared/next-step.js +63 -24
  38. package/framework/hooks/shared/phase-utils.js +6 -9
  39. package/framework/hooks/shared/report-progress.js +86 -0
  40. package/framework/hooks/shared/state-reader.js +3 -2
  41. package/framework/hooks/statusline.js +332 -0
  42. package/framework/mcp/registry.json +4 -4
  43. package/framework/phases.json +32 -10
  44. package/framework/rules/ai-agents-standards.md +10 -2
  45. package/framework/rules/csharp-standards.md +4 -0
  46. package/framework/rules/data-standards.md +1 -1
  47. package/framework/rules/ef-standards.md +20 -0
  48. package/framework/rules/frontend-standards.md +1 -1
  49. package/framework/rules/hangfire-standards.md +1 -1
  50. package/framework/rules/implement-report.md +25 -0
  51. package/framework/rules/infrastructure-standards.md +2 -0
  52. package/framework/rules/neon-auth-standards.md +1 -1
  53. package/framework/rules/neon-standards.md +3 -3
  54. package/framework/rules/nextjs-standards.md +7 -0
  55. package/framework/rules/plan-grammar.md +53 -0
  56. package/framework/rules/ui-design.md +27 -0
  57. package/framework/schemas/config.schema.json +11 -18
  58. package/framework/schemas/feature.schema.json +7 -1
  59. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  60. package/framework/skills/core/create-umbraco-lp/references/umbraco-lp-patterns.md +1 -1
  61. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +2 -2
  62. package/framework/skills/core/morph-apply/SKILL.md +39 -166
  63. package/framework/skills/core/morph-apply/references/debugging.md +8 -0
  64. package/framework/skills/core/morph-apply/references/hotfix.md +42 -0
  65. package/framework/skills/core/morph-apply/references/machine.md +29 -0
  66. package/framework/skills/core/{morph-media/SKILL.md → morph-apply/references/media.md} +11 -74
  67. package/framework/skills/core/morph-apply/references/review.md +65 -0
  68. package/framework/skills/core/morph-plan/SKILL.md +49 -109
  69. package/framework/skills/core/morph-plan/references/critique.md +43 -0
  70. package/framework/skills/core/morph-plan/references/interview.md +37 -0
  71. package/framework/skills/core/morph-uiux/SKILL.md +47 -64
  72. package/framework/skills/core/morph-uiux/surfaces.json +32 -0
  73. package/framework/standards/STANDARDS.json +51 -28
  74. package/framework/standards/STANDARDS.meta.json +16 -19
  75. package/framework/standards/ai-agents/agent-session.md +2 -3
  76. package/framework/standards/ai-agents/context-providers.md +2 -2
  77. package/framework/standards/ai-agents/durable-workflows-hitl.md +2 -2
  78. package/framework/standards/ai-agents/providers/model-registry.md +2 -2
  79. package/framework/standards/ai-agents/rag-custom-pgvector.md +7 -6
  80. package/framework/standards/ai-agents/setup.md +1 -1
  81. package/framework/standards/ai-agents/tool-adapters.md +2 -2
  82. package/framework/standards/architecture/stack-baseline.md +70 -0
  83. package/framework/standards/backend/database/postgresql/database.md +22 -1
  84. package/framework/standards/backend/dotnet/vsa-handler-patterns.md +33 -1
  85. package/framework/standards/backend/integrations/hangfire/hangfire-jobs.md +2 -1
  86. package/framework/standards/backend/integrations/neon-auth/neon-auth.md +12 -13
  87. package/framework/standards/frontend/design-system/ai-image-generation.md +51 -12
  88. package/framework/standards/frontend/design-system/ai-video-generation.md +50 -4
  89. package/framework/standards/frontend/design-system/art-direction.md +2 -2
  90. package/framework/standards/frontend/scroll-driven/frame-scrub.md +12 -2
  91. package/framework/standards/frontend/scroll-driven/scroll-components.md +76 -4
  92. package/framework/standards/infrastructure/docker/local-compose-isolation.md +2 -2
  93. package/framework/standards/infrastructure/neon/neon-pgvector.md +62 -20
  94. package/framework/standards/infrastructure/neon/neon-platform.md +61 -0
  95. package/framework/standards/infrastructure/neon/neon-setup.md +14 -13
  96. package/framework/standards/integration/mcp/mcp-tools.md +20 -20
  97. package/framework/templates/REGISTRY.json +1 -2
  98. package/framework/templates/context/ARCHITECTURE.md +1 -1
  99. package/framework/templates/docs/plan.md +15 -12
  100. package/framework/templates/docs/ui-mockups.md +17 -105
  101. package/framework/templates/infrastructure/docker/docker-compose.template.yml +1 -1
  102. package/package.json +1 -1
  103. package/src/commands/e2e/e2e.js +88 -23
  104. package/src/commands/graph/diff.js +74 -0
  105. package/src/commands/graph/status.js +1 -1
  106. package/src/commands/observability/cost.js +2 -4
  107. package/src/commands/observability/telemetry.js +1 -1
  108. package/src/commands/project/doctor.js +23 -39
  109. package/src/commands/project/fleet.js +5 -6
  110. package/src/commands/project/init.js +63 -36
  111. package/src/commands/project/stack-scan.js +3 -42
  112. package/src/commands/project/stack-set.js +26 -0
  113. package/src/commands/project/status.js +12 -13
  114. package/src/commands/project/update.js +39 -3
  115. package/src/commands/project/worktree.js +3 -14
  116. package/src/commands/state/advance.js +17 -17
  117. package/src/commands/state/approve.js +0 -6
  118. package/src/commands/state/archive.js +27 -1
  119. package/src/commands/state/auto-approval.js +2 -2
  120. package/src/commands/state/create.js +1 -6
  121. package/src/commands/state/gate-check.js +38 -31
  122. package/src/commands/state/gate-decision.js +3 -2
  123. package/src/commands/state/next.js +1 -0
  124. package/src/commands/state/recap.js +67 -0
  125. package/src/commands/state/report-check.js +36 -178
  126. package/src/commands/validation/plan-check.js +16 -11
  127. package/src/commands/validation/validate-feature.js +3 -3
  128. package/src/commands/validation/verify.js +3 -6
  129. package/src/core/paths/output-schema.js +3 -3
  130. package/src/core/state/state-manager.js +2 -4
  131. package/src/dashboard/app/components/CommandPalette.jsx +2 -5
  132. package/src/dashboard/app/components/GatePauses.jsx +116 -0
  133. package/src/dashboard/app/components/SessionMap.jsx +77 -0
  134. package/src/dashboard/app/pages/Activity.jsx +4 -0
  135. package/src/dashboard/app/pages/AgentMap.jsx +1 -2
  136. package/src/dashboard/app/pages/FeatureDetail.jsx +129 -186
  137. package/src/dashboard/app/pages/Features.jsx +7 -7
  138. package/src/dashboard/app/pages/PhasePipeline.jsx +1 -1
  139. package/src/dashboard/app/pages/Portfolio.jsx +40 -5
  140. package/src/dashboard/scanner/activity-scanner.js +29 -0
  141. package/src/dashboard/scanner/agents-scanner.js +11 -14
  142. package/src/dashboard/scanner/features-scanner.js +45 -24
  143. package/src/dashboard/scanner/project-api.js +11 -2
  144. package/src/dashboard/scanner/skills-scanner.js +11 -1
  145. package/src/lib/detectors/index.js +1 -1
  146. package/src/lib/e2e/applicability.js +3 -1
  147. package/src/lib/git/tree-state.js +1 -16
  148. package/src/lib/graph/diff-tree.js +127 -0
  149. package/src/lib/graph/graphify-runtime.js +12 -5
  150. package/src/lib/installers/memory-installer.js +3 -3
  151. package/src/lib/installers/rules-installer.js +57 -0
  152. package/src/lib/installers/stack-assets.js +17 -0
  153. package/src/lib/installers/stack-sync.js +219 -0
  154. package/src/lib/plan/checks.js +61 -109
  155. package/src/lib/plan/critique.js +28 -0
  156. package/src/lib/plan/media.js +42 -0
  157. package/src/lib/plan/plan-html.js +125 -0
  158. package/src/lib/plan/plan-parser.js +34 -45
  159. package/src/lib/recap/piper.js +36 -0
  160. package/src/lib/recap/recap.js +232 -0
  161. package/src/lib/review-reports.js +28 -4
  162. package/src/lib/risk-detector.js +90 -11
  163. package/src/lib/specs/screen-checklist.js +33 -48
  164. package/src/lib/stack/dotnet-build-ignores.js +8 -9
  165. package/src/lib/telemetry/metrics.js +27 -29
  166. package/src/lib/validators/reuse/reuse-validator.js +1 -9
  167. package/src/lib/validators/verify-runner.js +0 -7
  168. package/src/lib/worktree/fleet-scanner.js +7 -20
  169. package/src/lib/worktree/worktree-audit.js +0 -37
  170. package/src/scripts/setup-infra.js +15 -50
  171. package/src/utils/agents-installer.js +27 -201
  172. package/src/utils/hooks-installer.js +11 -95
  173. package/framework/hooks/claude-code/core/warn-bash-source-write.js +0 -23
  174. package/framework/hooks/claude-code/statusline.py +0 -699
  175. package/framework/hooks/claude-code/statusline.sh +0 -11
  176. package/framework/hooks/shared/agent-reap.js +0 -74
  177. package/framework/hooks/shared/slice-progress.js +0 -110
  178. package/framework/skills/core/morph-hotfix/SKILL.md +0 -133
  179. package/framework/skills/core/morph-machine/SKILL.md +0 -46
  180. package/framework/skills/core/morph-review/SKILL.md +0 -129
  181. package/framework/skills/core/morph-standards/SKILL.md +0 -52
  182. package/framework/standards/PROVENANCE.json +0 -119
  183. package/framework/standards/infrastructure/neon/neon-pgrag.md +0 -243
  184. package/src/commands/project/worktree-reap.js +0 -34
  185. package/src/commands/state/handoff.js +0 -65
  186. package/src/commands/validation/mutate.js +0 -263
  187. package/src/commands/validation/scout-check.js +0 -28
  188. package/src/lib/generators/handoff-generator.js +0 -172
  189. package/src/lib/mutation/adapters.js +0 -79
  190. package/src/lib/mutation/commands.js +0 -61
  191. package/src/lib/mutation/dotnet-introspection.js +0 -281
  192. package/src/lib/mutation/manifest.js +0 -144
  193. package/src/lib/mutation/plan.js +0 -44
  194. package/src/lib/mutation/report.js +0 -68
  195. package/src/lib/mutation/runner.js +0 -301
  196. package/src/lib/mutation/schemata.js +0 -60
  197. package/src/lib/mutation/snapshot.js +0 -65
  198. package/src/lib/mutation/subset.js +0 -109
  199. package/src/lib/mutation/trx.js +0 -50
  200. package/src/lib/scout/scout-check.js +0 -293
  201. package/src/lib/validators/traceability-check.js +0 -103
  202. package/src/lib/worktree/agent-reaper.js +0 -247
@@ -1,263 +0,0 @@
1
- import chalk from 'chalk';
2
- import { execFileSync } from 'node:child_process';
3
- import { existsSync, mkdirSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
4
- import { join, resolve } from 'node:path';
5
- import { discoverDotnetBuildTargets } from '../../lib/tasks/test-runner.js';
6
- import { dotnetTestMode } from '../../lib/mutation/dotnet-introspection.js';
7
- import { dotnetBuildCommands } from '../../lib/mutation/commands.js';
8
- import { resolveTestNodes } from '../../lib/tasks/test-nodes.js';
9
- import { changedPathsAgainst, detectDefaultBranch, getHeadSha } from '../../lib/git/default-branch.js';
10
- import { createHeavyGate, HeavyBusyError } from '../../lib/heavy/gate.js';
11
- import { readHeavyConfig } from '../../../framework/hooks/shared/heavy-config.js';
12
- import { resolveGitCommonDir } from '../../../framework/hooks/shared/git-paths.js';
13
- import { BACKSLASH_TOKEN, ManifestError } from '../../lib/mutation/manifest.js';
14
- import { groupByNode, loadPlan } from '../../lib/mutation/plan.js';
15
- import { dotnetSubset, jsSubset, mutatedTypesOf, testFilesCiting, testProjectDirsOf } from '../../lib/mutation/subset.js';
16
- import { isFinished } from '../../dashboard/scanner/feature-status.js';
17
- import { dotnetAdapter, genericAdapter, spawnExec } from '../../lib/mutation/adapters.js';
18
- import { noSubsetReason, runMutation } from '../../lib/mutation/runner.js';
19
- import { renderSection, summarize } from '../../lib/mutation/report.js';
20
- import { readJournal, restoreSnapshot } from '../../lib/mutation/snapshot.js';
21
-
22
- const EXIT = { ok: 0, failed: 1, usage: 2, busy: 3, unsafe: 4 };
23
- const KEEP_RUNS = 2;
24
- const DEFAULT_JOBS = 2;
25
-
26
- // One heavy slot per worker: the first waits, the others are taken only if free now,
27
- // so a busy machine degrades to sequential instead of queueing.
28
- export function workerGate({ root, feature, jobs }) {
29
- const config = readHeavyConfig(root);
30
- const gates = [createHeavyGate({ projectPath: root, label: 'mutate', feature, config })];
31
- const quiet = { ...config, waitTimeoutSec: 0, warnings: [] };
32
- for (let i = 1; i < jobs; i++) {
33
- const { MORPH_HEAVY_LEASE: _inherited, ...env } = process.env;
34
- gates.push(createHeavyGate({ projectPath: root, label: 'mutate', feature, config: quiet, env }));
35
- }
36
- let acquired = 0;
37
- return {
38
- get jobs() { return acquired; },
39
- enter(node) {
40
- gates[0].enter(node);
41
- acquired = 1;
42
- for (const gate of gates.slice(1)) {
43
- try { gate.enter(node); acquired += 1; } catch (err) {
44
- if (!(err instanceof HeavyBusyError)) throw err;
45
- break;
46
- }
47
- }
48
- },
49
- release() {
50
- for (const gate of [...gates].reverse()) gate.release();
51
- acquired = 0;
52
- },
53
- };
54
- }
55
-
56
- const gitOut = (cwd, args) => execFileSync('git', args, { cwd, stdio: 'pipe', encoding: 'utf8', windowsHide: true });
57
-
58
- function workRoot(root) {
59
- const common = resolveGitCommonDir(root);
60
- return common ? join(common, 'morph-spec', 'mutation') : join(root, '.morph', 'tmp', 'mutation');
61
- }
62
-
63
- function pruneOldRuns(base, keep) {
64
- if (!existsSync(base)) return;
65
- const runs = readdirSync(base).sort();
66
- for (const name of runs.slice(0, Math.max(0, runs.length - keep))) {
67
- const done = ['g0', 'g1', 'g2', 'g3'].every((g) => {
68
- const journal = readJournal(join(base, name, g, 'snapshot'));
69
- return journal === null || journal.state === 'restored';
70
- });
71
- if (done) rmSync(join(base, name), { recursive: true, force: true });
72
- }
73
- }
74
-
75
- function fail(message, code) {
76
- console.error(chalk.red(message));
77
- process.exitCode = code;
78
- return null;
79
- }
80
-
81
- export function restoreLatest(root, out = console) {
82
- const base = workRoot(root);
83
- if (!existsSync(base)) { out.log('nada a restaurar: nenhuma rodada de mutação registrada'); return []; }
84
- const restored = [];
85
- for (const run of readdirSync(base).sort()) {
86
- for (const group of readdirSync(join(base, run))) {
87
- const journal = readJournal(join(base, run, group, 'snapshot'));
88
- if (!journal || journal.state === 'restored') continue;
89
- const report = restoreSnapshot(journal);
90
- restored.push({ run, group, files: report });
91
- out.log(`${run}/${group}: ${report.every((r) => r.ok) ? 'restaurado' : 'FALHOU'} — ${report.map((r) => `${r.file} ${r.ok ? 'ok' : 'md5 diverge'}`).join(', ')}`);
92
- }
93
- }
94
- if (restored.length === 0) out.log('nada a restaurar: toda rodada registrada já foi restaurada');
95
- return restored;
96
- }
97
-
98
- function testDirsOf(node, root, { projectsOnly = false } = {}) {
99
- const cwd = resolve(root, node.cwd || '.');
100
- const targets = discoverDotnetBuildTargets(node, cwd);
101
- return testProjectDirsOf(root, projectsOnly ? targets.filter((t) => !/\.slnx?$/i.test(t)) : targets, cwd);
102
- }
103
-
104
- function subsetFor({ node, group, root }) {
105
- const mutatedFiles = group.regions.map((r) => r.file);
106
- if (node.runner === 'dotnet') {
107
- const base = detectDefaultBranch(root);
108
- const changed = base ? changedPathsAgainst(base, root) : [];
109
- return dotnetSubset({ root, testProjectDirs: testDirsOf(node, root), mutatedFiles, changed });
110
- }
111
- return jsSubset({ runner: node.runner, command: node.command, root, mutatedFiles });
112
- }
113
-
114
- function e2eManualProtocol(feature, node) {
115
- const f = feature ?? '<feature>';
116
- const round = `eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f} && morph-spec heavy run -- ${node.command}`;
117
- return 'mutate só recompila localmente, e a suíte que alcança esse código testa o container do compose: o mutante sairia sobrevivente em falso. ' +
118
- `Prove à mão pelo nó "${node.id}", cada passo numa linha só e encadeado por \`&&\` (nunca \`;\`: um passo que falha não pode deixar a suíte rodar sobre o código original; ` +
119
- 'e o env da feature se perde entre chamadas de Bash): ' +
120
- `(1) base verde antes do 1º mutante: \`${round}\`; ` +
121
- `(2) por mutante: edite a linha && \`${round}\`; ` +
122
- `(3) restaure, confirme com \`git diff --quiet -- <arquivo>\` e suba a stack de novo (\`eval "$(morph-spec env ${f} --with-secrets)" && morph-spec e2e up ${f}\`). ` +
123
- 'Registre cada mutante em 4-review (expect, resultado, teste que o matou).';
124
- }
125
-
126
- // A .cs file belongs to every dotnet suite, by runner, not by project reference: a unit suite
127
- // "owns" code only an e2e suite executes. With an e2e node declared, a unit suite with no test
128
- // naming a type of the mutated file is not evidence, so the run is refused.
129
- function uncitedUnderE2e({ root, groups, nodes }) {
130
- const e2e = nodes.filter((n) => n.e2e);
131
- if (e2e.length === 0) return [];
132
- const e2eDirs = new Set(e2e.filter((n) => n.runner === 'dotnet').flatMap((n) => testDirsOf(n, root)));
133
- const uncited = [];
134
- for (const { node, regions } of groups.filter((g) => g.node.runner === 'dotnet')) {
135
- const direct = new Set(testDirsOf(node, root, { projectsOnly: true }));
136
- const dirs = testDirsOf(node, root).filter((d) => direct.has(d) || !e2eDirs.has(d));
137
- for (const region of regions) {
138
- const types = mutatedTypesOf(root, [region.file]);
139
- if (testFilesCiting({ root, testProjectDirs: dirs, types, exclude: [region.file] }).length === 0) uncited.push({ region, node, types, e2e });
140
- }
141
- }
142
- return uncited;
143
- }
144
-
145
- function soleActiveFeature(root) {
146
- const dir = join(root, '.morph', 'features');
147
- let names;
148
- try { names = readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name); } catch { return null; }
149
- const active = names.filter((name) => {
150
- try { return !isFinished(JSON.parse(readFileSync(join(dir, name, 'feature.json'), 'utf8'))); } catch { return false; }
151
- });
152
- return active.length === 1 ? active[0] : null;
153
- }
154
-
155
- export async function mutateCommand(featureArg, options = {}) {
156
- const root = resolve(options.cwd ?? process.cwd());
157
- if (options.restore) { restoreLatest(root); return null; }
158
-
159
- const feature = featureArg ?? soleActiveFeature(root);
160
- if (!feature && !options.plan) return fail('sem feature: passe <feature> (nenhuma, ou mais de uma, em andamento) ou o manifesto por --plan.', EXIT.usage);
161
- const planPath = resolve(root, options.plan ?? join('.morph', 'features', feature, '4-review', 'mutants.json'));
162
- if (!existsSync(planPath)) return fail(`manifesto não encontrado: ${planPath}\nEscreva-o com a ferramenta Write, se você a tem; só com Bash, heredoc de delimitador entre aspas simples, com a barra invertida do código como ${BACKSLASH_TOKEN} — formato em .morph/framework/CLI.md, seção "### mutate".`, EXIT.usage);
163
-
164
- let regions;
165
- try {
166
- regions = loadPlan({ root, planPath });
167
- } catch (err) {
168
- if (err instanceof ManifestError) return fail(err.message, EXIT.usage);
169
- if (err instanceof SyntaxError) return fail(`${err.message}\nSe o manifesto tem barra invertida, o transporte do Bash pode tê-la alterado: escreva cada uma como ${BACKSLASH_TOKEN}, que a ferramenta expande.`, EXIT.usage);
170
- throw err;
171
- }
172
-
173
- const { nodes } = resolveTestNodes(root);
174
- const { groups, orphans, stackOnly } = groupByNode(regions, nodes, root);
175
- if (orphans.length > 0) {
176
- return fail(`nenhuma suíte declarada roda: ${orphans.map((o) => `${o.id} (${o.file})`).join(', ')}\nDeclare o nó em project.tests[] (.morph/config/config.json).`, EXIT.usage);
177
- }
178
- if (stackOnly.length > 0) {
179
- const named = stackOnly.map(({ region, node }) => `${region.id} (${region.file}) → nó "${node.id}"`).join(', ');
180
- return fail(`só uma suíte e2e: true cobre: ${named}\nProve a guarda por um teste de unidade, ou: ${e2eManualProtocol(feature, stackOnly[0].node)}`, EXIT.usage);
181
- }
182
- const mtp = groups.find((g) => g.node.runner === 'dotnet' && dotnetTestMode(resolve(root, g.node.cwd || '.')) === 'mtp');
183
- if (mtp) return fail(`a suíte "${mtp.node.id}" roda no Microsoft.Testing.Platform (global.json): o TRX do VSTest, de onde o veredito e o motivo saem, não existe nela. Suporte a MTP ainda não há — rode a mutação com a suíte inteira à mão.`, EXIT.usage);
184
- const uncompilable = groups.find((g) => g.node.runner === 'dotnet' && dotnetBuildCommands(g.node, root).length === 0);
185
- if (uncompilable) return fail(`a suíte "${uncompilable.node.id}" não deixa descobrir o que compilar: o comando não nomeia .sln/.slnx/.csproj e o diretório alvo não tem uma única solution (ou um único projeto). Sem isso, todo mutante rodaria sobre o binário antigo e sairia "sobrevivente". Nomeie a solution no comando ou declare o campo build no nó (config.json, project.tests[]).`, EXIT.usage);
186
- const preDecided = groups.find((g) => g.node.status);
187
- if (preDecided) return fail(`a suíte "${preDecided.node.id}" já vem decidida como ${preDecided.node.status}: ${preDecided.node.reason ?? ''}`, EXIT.usage);
188
- const uncited = uncitedUnderE2e({ root, groups, nodes });
189
- if (uncited.length > 0) {
190
- const named = uncited.map(({ region, node, types }) => `${region.id} (${region.file}; tipos: ${types.join(', ') || 'nenhum legível'}) → nenhum teste da suíte "${node.id}" os cita`).join('\n');
191
- const e2eIds = uncited[0].e2e.map((n) => `"${n.id}"`).join(', ');
192
- return fail(`${named}\nNó e2e: true declarado: ${e2eIds}. ${e2eManualProtocol(feature, uncited[0].e2e[0])}`, EXIT.usage);
193
- }
194
-
195
- const subsets = groups.map((group) => subsetFor({ node: group.node, group, root }));
196
- const noSubset = subsets.findIndex((s) => !s.usable);
197
- if (noSubset >= 0) {
198
- return fail(`suíte "${groups[noSubset].node.id}": ${noSubsetReason(subsets[noSubset].reason)}.`, EXIT.usage);
199
- }
200
-
201
- if (!options.allowDirty) {
202
- const dirty = gitOut(root, ['status', '--porcelain', '--', ...new Set(regions.map((r) => r.file))]).trim();
203
- if (dirty) return fail(`há alteração não commitada nos arquivos a mutar:\n${dirty}\nCommite o fix ANTES de mutar (ou use --allow-dirty sabendo que o snapshot é o único caminho de volta).`, EXIT.usage);
204
- }
205
-
206
- const runId = `mut-${Date.now().toString(36)}`;
207
- const base = workRoot(root);
208
- pruneOldRuns(base, KEEP_RUNS);
209
- const git = { porcelain: () => gitOut(root, ['status', '--porcelain', '--', '.', ':(exclude).morph']), head: () => getHeadSha(root) };
210
- const jobs = Math.max(1, Number.isInteger(options.jobs) ? options.jobs : DEFAULT_JOBS);
211
- const gate = workerGate({ root, feature: feature ?? null, jobs });
212
- const exec = options.exec ?? spawnExec;
213
-
214
- const merged = { wallMs: 0, mutants: [], timings: [], restore: { files: [], ok: true, treeClean: true }, binary: { proof: 'unavailable', projects: [] }, baseline: {}, method: { schemata: 0, rebuild: 0, subset: {} } };
215
- try {
216
- for (const [index, group] of groups.entries()) {
217
- const workDir = join(base, runId, `g${index}`);
218
- mkdirSync(workDir, { recursive: true });
219
- const subset = subsets[index];
220
- const timeoutMs = options.timeoutMs ?? group.node.timeoutMs ?? 900_000;
221
- const make = group.node.runner === 'dotnet' ? dotnetAdapter : genericAdapter;
222
- const adapter = make({ node: group.node, root, workDir, exec, subset, timeoutMs });
223
- const result = await runMutation({ root, node: group.node, regions: group.regions, workDir, runId, exec, adapter, gate, git, options: { timeoutMs, jobs }, log: (m) => options.json || console.error(chalk.gray(m)) });
224
- merged.mutants.push(...result.mutants);
225
- merged.timings.push(...result.timings);
226
- merged.wallMs += result.wallMs;
227
- merged.restore.files.push(...result.restore.files);
228
- merged.restore.ok &&= result.restore.ok;
229
- merged.restore.treeClean &&= result.restore.treeClean;
230
- merged.restore.porcelainAfter = result.restore.porcelainAfter;
231
- if (result.binary.proof !== 'unavailable') merged.binary = ['mismatch', 'unlocated'].includes(merged.binary.proof) ? merged.binary : result.binary;
232
- merged.baseline = { ...merged.baseline, ...Object.fromEntries(Object.entries(result.baseline).filter(([, v]) => v)) };
233
- merged.method.schemata += result.method.schemata;
234
- merged.method.rebuild += result.method.rebuild;
235
- merged.method.subset = result.method.subset;
236
- merged.method.jobs = Math.max(merged.method.jobs ?? 1, result.method.jobs);
237
- }
238
- } catch (err) {
239
- if (err instanceof HeavyBusyError) return fail(`suíte não rodou: ${err.message}. A fila já esperou o prazo dela: não repita em laço — registre a mutação como não executada (morph-spec heavy status).`, EXIT.busy);
240
- if (err.code === 'baseline-build-failed') return fail(`${err.message}\n${String(err.output ?? '').split('\n').slice(-12).join('\n')}`, EXIT.failed);
241
- throw err;
242
- }
243
-
244
- const order = new Map(regions.map((r, i) => [r.id, i]));
245
- merged.mutants.sort((a, b) => order.get(a.id) - order.get(b.id));
246
- const section = renderSection(merged, { head: git.head(), date: new Date().toISOString().slice(0, 10), subset: subsets[0] ?? null });
247
- const outRel = options.out ?? (feature ? join('.morph', 'features', feature, '4-review', 'mutation-section.md') : null);
248
- const outPath = outRel ? resolve(root, outRel) : null;
249
- if (outPath) {
250
- mkdirSync(join(outPath, '..'), { recursive: true });
251
- writeFileSync(outPath, section);
252
- }
253
-
254
- const tally = summarize(merged);
255
- if (options.json) console.log(JSON.stringify({ ...merged, tally, section: outPath }, null, 2));
256
- else {
257
- console.log(section);
258
- console.log(chalk.gray(outPath ? `seção gravada em ${outPath}` : 'seção não gravada: sem feature nem --out'));
259
- console.log(`${chalk.green(`${tally.killed} mortos`)} · ${tally.survived ? chalk.yellow(`${tally.survived} sobreviveram`) : '0 sobreviveram'} · ${tally['build-error']} não compilaram · ${tally.inconclusive} inconclusivos`);
260
- }
261
- if (!merged.restore.ok || ['mismatch', 'unlocated'].includes(merged.binary.proof) || !merged.restore.treeClean) process.exitCode = EXIT.unsafe;
262
- return { ...merged, tally, section: outPath };
263
- }
@@ -1,28 +0,0 @@
1
- import chalk from 'chalk';
2
- import { existsSync, readFileSync } from 'fs';
3
- import { resolve } from 'path';
4
- import { checkScoutMap, resolveScoutRoot } from '../../lib/scout/scout-check.js';
5
-
6
- export function scoutCheckCommand(mapPath, options = {}) {
7
- const file = resolve(mapPath);
8
- if (!existsSync(file)) {
9
- if (options.json) console.log(JSON.stringify({ ok: false, error: 'map-not-found', map: file }));
10
- else console.error(chalk.red(`scout-check: mapa não encontrado: ${file}`));
11
- process.exit(2);
12
- }
13
- const result = { map: file, ...checkScoutMap(readFileSync(file, 'utf8'), { root: resolveScoutRoot(options.root) }) };
14
-
15
- if (options.json) {
16
- console.log(JSON.stringify(result, null, 2));
17
- } else if (result.ok) {
18
- console.log(chalk.green(`✓ scout-check: ${result.citations.length} citações conferidas contra ${result.root}`));
19
- } else {
20
- console.log(chalk.red(`✗ scout-check: ${result.failures.length} falha(s) em ${file} (raiz ${result.root})`));
21
- for (const f of result.failures) {
22
- const where = f.line ? `L${f.line}` : '—';
23
- console.log(` ${where} ${chalk.yellow(f.kind)} ${f.citation ?? ''} ${f.detail}`);
24
- }
25
- console.log(chalk.gray(' Mapa reprovado: descarte e re-despache o scout UMA vez com estas falhas no prompt (MORPH.md §6).'));
26
- }
27
- process.exit(result.ok ? 0 : 1);
28
- }
@@ -1,172 +0,0 @@
1
- // Derived only from committed sources, so it is regenerated, never hand-edited or merged.
2
- // Every section is fail-open.
3
-
4
- import { existsSync, readFileSync, writeFileSync } from 'fs';
5
- import { join } from 'path';
6
- import { ensureCommonExclude } from '../../../framework/hooks/shared/git-exclude.js';
7
- import { derivePhaseForFeature, getPendingGates } from '../../../framework/hooks/shared/state-reader.js';
8
- import { sliceProgress, readySlices } from '../../../framework/hooks/shared/slice-progress.js';
9
- import { formatNext } from '../../../framework/hooks/shared/next-step.js';
10
- import { nextStepFor } from '../next-step.js';
11
-
12
- const POINTER_CANDIDATES = [
13
- ['2-plan/plan.md', 'plano: problema, cenários com prova, decisões e fatias'],
14
- ['2-plan/checks.json', 'cenários e fatias extraídos pelo plan-check'],
15
- ['1-design/design-system.md', 'design system da feature'],
16
- ['3-implement/reports', 'relatórios das fatias (**Arquivos:**/**Standards:**): o registro da implementação'],
17
- ['4-review/verifier.md', 'verificador do Gate 3: cada cenário provado'],
18
- ['4-review/reviewer.md', 'revisor do Gate 3: escopo, arquitetura, regressão'],
19
- ['4-review/security.md', 'segurança do Gate 3 (só com caminho sensível)'],
20
- ];
21
-
22
- function readJson(path) {
23
- return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
24
- }
25
-
26
- function decisionTitles(featureDir, limit = 5) {
27
- try {
28
- const path = join(featureDir, '2-plan', 'checks.json');
29
- if (!existsSync(path)) return [];
30
- const adrs = readJson(path).adrs;
31
- if (!Array.isArray(adrs)) return [];
32
- return adrs.map((a) => `${a.id} — ${a.title}`).slice(-limit);
33
- } catch {
34
- return [];
35
- }
36
- }
37
-
38
- function readWorktreeEnv(cwd) {
39
- try {
40
- const path = join(cwd, '.morph', 'worktree.env');
41
- if (!existsSync(path)) return null;
42
- const content = readFileSync(path, 'utf8');
43
- const get = (key) => content.match(new RegExp(`^${key}=(.+)$`, 'm'))?.[1] || null;
44
- const port = get('PORT');
45
- return port ? { port, range: get('MORPH_PORT_RANGE') } : null;
46
- } catch {
47
- return null;
48
- }
49
- }
50
-
51
- function nextLine(featureName, cwd) {
52
- const fallback = `Rode \`morph-spec next ${featureName}\`.`;
53
- try {
54
- const next = nextStepFor(featureName, cwd);
55
- return next ? formatNext(next) : fallback;
56
- } catch {
57
- return fallback;
58
- }
59
- }
60
-
61
- // `opts.worktree` overrides `feature.worktree`: that field is root-owned and stripped from branch-local
62
- // copies, so a handoff built inside a worktree would otherwise say "no worktree registered".
63
- export function buildHandoff(featureName, cwd = process.cwd(), opts = {}) {
64
- const featureDir = join(cwd, '.morph', 'features', featureName);
65
- if (!existsSync(join(featureDir, 'feature.json'))) return null;
66
-
67
- let feature;
68
- try {
69
- feature = readJson(join(featureDir, 'feature.json'));
70
- } catch {
71
- return null;
72
- }
73
-
74
- let phase = 'plan';
75
- let pendingGates = [];
76
- try {
77
- phase = derivePhaseForFeature(featureName, cwd) || 'plan';
78
- pendingGates = getPendingGates(featureName, cwd) || [];
79
- } catch {}
80
-
81
- const generatedAt = new Date().toISOString();
82
- const slices = sliceProgress(featureDir);
83
- const env = readWorktreeEnv(cwd);
84
- const decisions = decisionTitles(featureDir);
85
- const worktree = opts.worktree !== undefined ? opts.worktree : (feature.worktree || null);
86
-
87
- const gates = feature.approvals || feature.approvalGates || {};
88
- const gateLine = Object.entries(gates)
89
- .map(([name, g]) => (g?.approved ? `${name} ✔${g.approvedBy ? ` (${g.approvedBy})` : ''}` : `${name} pendente`))
90
- .join(' · ') || '—';
91
-
92
- const relFeatureDir = `.morph/features/${featureName}`;
93
- const pointers = POINTER_CANDIDATES
94
- .filter(([rel]) => existsSync(join(featureDir, rel)))
95
- .map(([rel, why], i) => `${i + 1}. \`${relFeatureDir}/${rel}\` — ${why}`);
96
-
97
- const lines = [
98
- `# Handoff — ${featureName}`,
99
- '',
100
- `> Documento derivado, gerado em ${generatedAt} por \`morph-spec handoff\`.`,
101
- '> Regenere com o comando — nunca edite à mão. Aponta para os arquivos da',
102
- '> feature em vez de copiá-los: leia os ponteiros, não confie em resumo velho.',
103
- '',
104
- '## Identidade',
105
- '',
106
- `- Feature: **${featureName}** (${feature.workType || 'feature'}) · fase: **${phase}** · status: ${feature.status || '—'}`,
107
- worktree?.path
108
- ? `- Local: worktree \`${worktree.path}\` · branch \`${worktree.branch || `morph/${featureName}`}\``
109
- : '- Local: raiz primária (sem worktree registrado)',
110
- env
111
- ? `- Ambiente: PORT ${env.port}${env.range ? ` (bloco ${env.range})` : ''} — fonte \`.morph/worktree.env\`; suba dev servers SEMPRE nessas portas`
112
- : null,
113
- '',
114
- '## Estado',
115
- '',
116
- `- Gates: ${gateLine}`,
117
- slices
118
- ? `- Fatias: ${slices.done}/${slices.total} feitas${slices.partial ? ` · ${slices.partial} parcial(is)` : ''}${slices.blocked ? ` · ${slices.blocked} bloqueada(s)` : ''} · prontas agora: ${readySlices(slices).map((s) => `**${s.id}** "${s.title}"`).join(', ') || 'nenhuma'}`
119
- : '- Fatias: sem checks.json ainda (rode plan-check)',
120
- pendingGates.length ? `- Gates pendentes: ${pendingGates.join(', ')}` : null,
121
- '',
122
- '## Leia nesta ordem (ponteiros — nada copiado)',
123
- '',
124
- ...(pointers.length ? pointers : ['1. (nenhum artefato ainda — feature recém-criada)']),
125
- decisions.length ? '' : null,
126
- decisions.length ? `Últimas decisões: ${decisions.map((d) => `"${d}"`).join(' · ')}` : null,
127
- '',
128
- '## Próxima ação',
129
- '',
130
- nextLine(featureName, cwd),
131
- '',
132
- '## Retomar',
133
- '',
134
- worktree?.path ? `- \`cd ${worktree.path}\` e abra o Claude Code (uma instância por worktree).` : '- Abra o Claude Code na raiz do projeto.',
135
- `- Visão da frota: \`morph-spec fleet\` · status: \`morph-spec status ${featureName}\``,
136
- '',
137
- ].filter((l) => l !== null);
138
-
139
- return {
140
- markdown: `${lines.join('\n')}`,
141
- meta: {
142
- generatedAt,
143
- phase,
144
- slicesDone: slices?.done ?? 0,
145
- slicesTotal: slices?.total ?? 0,
146
- },
147
- };
148
- }
149
-
150
- /** Marks the exclude block this generator owns in `.git/info/exclude`. */
151
- const HANDOFF_EXCLUDE_MARKER = '# morph-spec: derived handoff docs (managed — see handoff-generator)';
152
- const HANDOFF_EXCLUDE_PATTERN = '/.morph/**/handoff.md';
153
-
154
- // Never committed: left visible it lands in the `git status` the finish gates read. The common-dir
155
- // exclude hides it in every worktree; `**` also covers a feature folder after `archive` moves it.
156
- function ensureHandoffExcluded(cwd) {
157
- ensureCommonExclude(cwd, [HANDOFF_EXCLUDE_PATTERN], HANDOFF_EXCLUDE_MARKER);
158
- }
159
-
160
- /** The written path, or null when the feature does not exist (nothing is created). */
161
- export function writeHandoff(featureName, cwd = process.cwd(), opts = {}) {
162
- const result = buildHandoff(featureName, cwd, opts);
163
- if (!result) return null;
164
- const path = join(cwd, '.morph', 'features', featureName, 'handoff.md');
165
- try {
166
- ensureHandoffExcluded(cwd);
167
- writeFileSync(path, result.markdown, 'utf8');
168
- return path;
169
- } catch {
170
- return null;
171
- }
172
- }
@@ -1,79 +0,0 @@
1
- import { existsSync, mkdirSync, readFileSync } from 'node:fs';
2
- import { join, resolve } from 'node:path';
3
- import { spawn } from 'node:child_process';
4
- import { dotnetBuildCommands, withFilter, withTrx, withoutBuild } from './commands.js';
5
- import { parseTrx } from './trx.js';
6
- import { stripAnsi } from '../tasks/test-runner.js';
7
-
8
- const MAX_CAPTURE = 16 * 1024 * 1024;
9
-
10
- export function spawnExec(command, { cwd, env, timeoutMs }) {
11
- return new Promise((done) => {
12
- const start = Date.now();
13
- let stdout = '';
14
- let stderr = '';
15
- let timedOut = false;
16
- const child = spawn(command, { cwd, env, shell: true, windowsHide: true });
17
- child.stdout.setEncoding('utf8');
18
- child.stderr.setEncoding('utf8');
19
- child.stdout.on('data', (chunk) => { if (stdout.length < MAX_CAPTURE) stdout += chunk; });
20
- child.stderr.on('data', (chunk) => { if (stderr.length < MAX_CAPTURE) stderr += chunk; });
21
- const timer = setTimeout(() => { timedOut = true; child.kill(); }, timeoutMs);
22
- const finish = (status) => { clearTimeout(timer); done({ status, stdout, stderr, durationMs: Date.now() - start, timedOut }); };
23
- child.on('error', (err) => { stderr += String(err.message); finish(null); });
24
- child.on('close', (status) => finish(status));
25
- });
26
- }
27
-
28
- const FAIL_LINE = /^\s*(?:FAIL|✗|×|✖|not ok)\b|AssertionError|Error:|Expected/i;
29
-
30
- function genericFailures(output) {
31
- const lines = stripAnsi(output).split(/\r?\n/).map((l) => l.trim()).filter(Boolean);
32
- const first = lines.findIndex((l) => FAIL_LINE.test(l));
33
- if (first === -1) return lines.slice(-3).map((l) => ({ name: l, message: '' }));
34
- return [{ name: lines[first], message: lines.slice(first + 1, first + 3).join(' ⏎ ') }];
35
- }
36
-
37
- export function dotnetAdapter({ node, root, workDir, exec, subset, timeoutMs }) {
38
- const cwd = resolve(root, node.cwd || '.');
39
- const trxDir = join(workDir, 'trx');
40
- mkdirSync(trxDir, { recursive: true });
41
- const base = withoutBuild(node.command);
42
- const commandFor = (label) => withTrx(withFilter(base, subset.filter), { dir: trxDir, name: `${label}.trx` });
43
- const runTests = async ({ label, env }) => {
44
- const trxPath = join(trxDir, `${label}.trx`);
45
- const r = await exec(commandFor(label), { cwd, env, timeoutMs, label });
46
- if (!existsSync(trxPath)) {
47
- return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: r.timedOut ? `timeout de ${timeoutMs} ms` : `sem TRX (exit ${r.status})` };
48
- }
49
- const t = parseTrx(readFileSync(trxPath, 'utf8'));
50
- if (!t.parsed || t.aborted > 0 || /abort/i.test(t.runOutcome ?? '')) {
51
- return { outcome: 'crash', executed: t.executed, total: t.total, failed: t.failed, failedTests: t.failedTests, durationMs: r.durationMs, note: 'a rodada foi abortada (testhost caiu ou estourou o tempo)' };
52
- }
53
- return { outcome: t.failed > 0 ? 'red' : 'green', executed: t.executed, total: t.total, failed: t.failed, failedTests: t.failedTests, durationMs: r.durationMs };
54
- };
55
- return {
56
- kind: 'dotnet',
57
- hasSubset: Boolean(subset?.usable),
58
- buildCommands: dotnetBuildCommands(node, root),
59
- cwd,
60
- runTests,
61
- };
62
- }
63
-
64
- export function genericAdapter({ node, root, exec, subset, timeoutMs }) {
65
- const cwd = resolve(root, node.cwd || '.');
66
- return {
67
- kind: node.runner,
68
- hasSubset: Boolean(subset?.usable),
69
- buildCommands: [],
70
- cwd,
71
- async runTests({ label, env }) {
72
- const r = await exec(subset.command, { cwd, env, timeoutMs, label });
73
- if (r.timedOut) return { outcome: 'crash', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs, note: `timeout de ${timeoutMs} ms` };
74
- if (r.status === 0) return { outcome: 'green', executed: null, total: null, failed: 0, failedTests: [], durationMs: r.durationMs };
75
- const failedTests = genericFailures(`${r.stdout}\n${r.stderr}`);
76
- return { outcome: 'red', executed: null, total: null, failed: failedTests.length, failedTests, durationMs: r.durationMs };
77
- },
78
- };
79
- }
@@ -1,61 +0,0 @@
1
- import { resolve } from 'node:path';
2
- import { discoverDotnetBuildTargets, dotnetBuildCommandFor } from '../tasks/test-runner.js';
3
-
4
- const FILTER = /--filter(?:[ =:])(?:"([^"]*)"|'([^']*)'|(\S+))/;
5
- const quote = (s) => `"${String(s).replace(/"/g, '\\"')}"`;
6
-
7
- export function declaredFilter(command) {
8
- const m = FILTER.exec(command);
9
- return m ? (m[1] ?? m[2] ?? m[3]) : null;
10
- }
11
-
12
- export function withFilter(command, expression) {
13
- const declared = declaredFilter(command);
14
- if (declared === null) return `${command} --filter ${quote(expression)}`;
15
- return command.replace(FILTER, () => `--filter ${quote(`(${declared})&(${expression})`)}`);
16
- }
17
-
18
- export function withTrx(command, { dir, name }) {
19
- return `${command} --results-directory ${quote(dir)} --logger ${quote(`trx;LogFileName=${name}`)}`;
20
- }
21
-
22
- export function withoutBuild(command) {
23
- return /(?:^|\s)--no-build(?:\s|$)/.test(command) ? command : `${command} --no-build`;
24
- }
25
-
26
- const CONFIGURATION = /(?:^|\s)(?:-c|--configuration)[ =:]?(?:"([^"]*)"|(\S+))/;
27
- const VERBOSITY = /\s(?:-v|--verbosity)[ =:]?\S+/g;
28
-
29
- /**
30
- * The verify's build command per target. The test command's `-c` follows it, and
31
- * verbosity is minimal: at `quiet` the `Project -> dll` line the binary proof reads is gone.
32
- */
33
- export function dotnetBuildCommands(node, projectPath) {
34
- const configuration = CONFIGURATION.exec(node.command ?? '');
35
- const flag = configuration ? ` -c ${configuration[1] ?? configuration[2]}` : '';
36
- return discoverDotnetBuildTargets(node, resolve(projectPath || '.', node.cwd || '.'))
37
- .map((target) => dotnetBuildCommandFor(target, projectPath))
38
- .filter(Boolean)
39
- .map((command) => `${command.replace(VERBOSITY, '')} -v m${flag}`);
40
- }
41
-
42
- const BUILT = /^\s*(?<project>\S.*?) -> (?<dll>.+\.dll)\s*$/gm;
43
-
44
- export function parseBuiltDlls(output) {
45
- const dlls = new Map();
46
- for (const m of String(output).matchAll(BUILT)) dlls.set(m.groups.project, m.groups.dll.trim());
47
- return dlls;
48
- }
49
-
50
- const VITEST_RUN = /(vitest(?:\.mjs|\.js)?)\s+run\b/;
51
-
52
- export function relatedCommand(runner, command, files) {
53
- const list = files.map(quote).join(' ');
54
- if (runner === 'vitest' && VITEST_RUN.test(command)) {
55
- return `${command.replace(/\s+run\b/, ' related')} ${list} --run`;
56
- }
57
- if (runner === 'jest' && /\bjest\b/.test(command) && !/[;&|]/.test(command)) {
58
- return `${command} --findRelatedTests ${list}`;
59
- }
60
- return null;
61
- }