@polymorphism-tech/morph-spec 8.1.2 → 8.2.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 (45) hide show
  1. package/README.md +2 -2
  2. package/bin/morph-spec.js +16 -1
  3. package/claude-plugin.json +1 -1
  4. package/docs/CHEATSHEET.md +6 -4
  5. package/docs/QUICKSTART.md +1 -1
  6. package/framework/CLAUDE.md +8 -5
  7. package/framework/MORPH.md +28 -4
  8. package/framework/hooks/claude-code/core/protect-spec-files.js +7 -1
  9. package/framework/hooks/claude-code/core/worktree-context.js +43 -0
  10. package/framework/hooks/shared/build-check.js +8 -1
  11. package/framework/hooks/shared/payload-utils.js +16 -2
  12. package/framework/hooks/shared/phase-utils.js +2 -0
  13. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  14. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  15. package/framework/skills/core/morph-apply/SKILL.md +13 -8
  16. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  17. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  18. package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
  19. package/framework/skills/core/morph-implement/SKILL.md +22 -10
  20. package/framework/skills/core/morph-plan/SKILL.md +1 -1
  21. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  22. package/framework/skills/core/morph-review/SKILL.md +19 -12
  23. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  24. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  25. package/package.json +1 -1
  26. package/src/commands/project/fleet.js +54 -0
  27. package/src/commands/project/worktree.js +206 -43
  28. package/src/commands/state/advance.js +8 -0
  29. package/src/commands/state/create.js +6 -0
  30. package/src/commands/state/handoff.js +49 -0
  31. package/src/commands/state/score.js +13 -2
  32. package/src/commands/tasks/dag.js +11 -3
  33. package/src/core/paths/output-schema.js +28 -0
  34. package/src/lib/generators/handoff-generator.js +267 -0
  35. package/src/lib/generators/index.js +1 -3
  36. package/src/lib/stack/stack-profile.js +40 -9
  37. package/src/lib/tasks/tasks-json-parser.js +21 -0
  38. package/src/lib/tasks/test-runner.js +26 -4
  39. package/src/lib/validators/validation-runner.js +4 -1
  40. package/src/lib/worktree/env-allocator.js +149 -0
  41. package/src/lib/worktree/fleet-scanner.js +156 -0
  42. package/src/lib/context/context-builder.js +0 -289
  43. package/src/lib/generators/context-generator.js +0 -519
  44. package/src/lib/generators/mandate-generator.js +0 -468
  45. package/src/lib/generators/recap-generator.js +0 -285
@@ -49,6 +49,11 @@ export const OUTPUT_SCHEMA = {
49
49
 
50
50
  // 4-review/
51
51
  reviewReport: { filename: 'review-report.md', phaseDir: '4-review', phase: 'review', protected: false },
52
+ // Durable report the dispatched morph-evaluator sub-agent writes itself
53
+ // (MORPH.md §6) — the orchestrator reads it and synthesizes reviewReport
54
+ // from it; the two never collide because one is raw sub-agent output and
55
+ // the other is the orchestrator's curated Gate 3 presentation.
56
+ evaluatorReport:{ filename: 'evaluator-report.md', phaseDir: '4-review', phase: 'review', protected: false },
52
57
  };
53
58
 
54
59
  // ============================================================================
@@ -89,6 +94,29 @@ export function getFeatureDir(featureName) {
89
94
  return join('.morph', 'features', featureName).replace(/\\/g, '/');
90
95
  }
91
96
 
97
+ /**
98
+ * Per-task sub-agent report — the durable "return value" channel for a
99
+ * dispatched implementation task (MORPH.md §6). A sub-agent's final text
100
+ * message may never reach the orchestrator (idle_notification with no text
101
+ * is an observed failure mode); the file it writes here always does. Doesn't
102
+ * fit OUTPUT_SCHEMA's one-fixed-filename-per-type model since the filename
103
+ * is parameterized by taskId, so it's a sibling helper instead of a schema
104
+ * entry.
105
+ *
106
+ * @param {string} featureName
107
+ * @param {string} taskId - e.g. "T7"
108
+ * @returns {string} morph-relative, forward-slash path
109
+ */
110
+ export function getTaskReportPath(featureName, taskId) {
111
+ return join('.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`)
112
+ .replace(/\\/g, '/');
113
+ }
114
+
115
+ /** Absolute filesystem variant of {@link getTaskReportPath}. */
116
+ export function getAbsoluteTaskReportPath(projectRoot, featureName, taskId) {
117
+ return join(projectRoot, '.morph', 'features', featureName, '3-implement', 'reports', `${taskId}.md`);
118
+ }
119
+
92
120
  /**
93
121
  * Get all output paths for a feature, ready for state.json `outputs` block.
94
122
  */
@@ -0,0 +1,267 @@
1
+ /**
2
+ * handoff-generator.js — compressed session-handoff document (fleet).
3
+ *
4
+ * A fleet runs one Claude Code instance per feature worktree. What travels
5
+ * between sessions must be a HANDOFF, not an export: a compact document that
6
+ * points at the feature's own files instead of copying their contents, so a
7
+ * fresh instance starts focused instead of inheriting a wall of stale context.
8
+ *
9
+ * Deterministic by construction — everything here is derived from feature.json,
10
+ * tasks.json, decisions.md and the worktree env manifest. The document is
11
+ * therefore REGENERABLE (never hand-edited, never merged): `morph-spec handoff`
12
+ * rebuilds it, and setup/create/advance regenerate it at the moments a new
13
+ * session is most likely to pick the feature up.
14
+ *
15
+ * Every section is fail-open: a missing tasks.json or a corrupt decisions.md
16
+ * degrades that section, never the document.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync } from 'fs';
20
+ import { join, resolve } from 'path';
21
+ import { execSync } from 'child_process';
22
+ import { derivePhaseForFeature, getPendingGates } from '../../../framework/hooks/shared/state-reader.js';
23
+
24
+ const PHASE_DIRS = {
25
+ proposal: '0-proposal',
26
+ uiux: '1-design',
27
+ plan: '2-plan',
28
+ implement: '3-implement',
29
+ review: '4-review',
30
+ };
31
+
32
+ /** Ordered pointer candidates — only existing files make it into the doc. */
33
+ const POINTER_CANDIDATES = [
34
+ ['0-proposal/proposal.md', 'proposta e critérios de aceite'],
35
+ ['2-plan/spec.md', 'especificação técnica'],
36
+ ['2-plan/mandate.md', 'contrato de implementação (leia antes de cada task)'],
37
+ ['2-plan/tasks.json', 'DAG de tasks (status vivo)'],
38
+ ['decisions.md', 'decisões registradas'],
39
+ ['3-implement/recap.md', 'recap da implementação'],
40
+ ['4-review/review-report.md', 'relatório do Gate 3'],
41
+ ];
42
+
43
+ function readJson(path) {
44
+ return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
45
+ }
46
+
47
+ function taskSummary(featureDir) {
48
+ try {
49
+ const tasksPath = join(featureDir, '2-plan', 'tasks.json');
50
+ if (!existsSync(tasksPath)) return null;
51
+ const data = readJson(tasksPath);
52
+ if (!Array.isArray(data.tasks)) return null;
53
+ const done = data.tasks.filter((t) => t.status === 'done').length;
54
+ const doneIds = new Set(data.tasks.filter((t) => t.status === 'done').map((t) => t.id));
55
+ const ready = data.tasks.filter(
56
+ (t) => t.status === 'pending' && Array.isArray(t.dependencies) && t.dependencies.every((d) => doneIds.has(d)),
57
+ );
58
+ return { done, total: data.tasks.length, ready };
59
+ } catch {
60
+ return null;
61
+ }
62
+ }
63
+
64
+ function decisionTitles(featureDir, limit = 5) {
65
+ try {
66
+ const path = join(featureDir, 'decisions.md');
67
+ if (!existsSync(path)) return [];
68
+ const titles = [...readFileSync(path, 'utf8').matchAll(/^##\s+(.+)$/gm)].map((m) => m[1].trim());
69
+ return titles.slice(-limit);
70
+ } catch {
71
+ return [];
72
+ }
73
+ }
74
+
75
+ function readWorktreeEnv(cwd) {
76
+ try {
77
+ const path = join(cwd, '.morph', 'worktree.env');
78
+ if (!existsSync(path)) return null;
79
+ const content = readFileSync(path, 'utf8');
80
+ const get = (key) => content.match(new RegExp(`^${key}=(.+)$`, 'm'))?.[1] || null;
81
+ const port = get('PORT');
82
+ return port ? { port, range: get('MORPH_PORT_RANGE') } : null;
83
+ } catch {
84
+ return null;
85
+ }
86
+ }
87
+
88
+ function nextAction(feature, phase, tasks, featureName) {
89
+ const gates = feature.approvals || feature.approvalGates || {};
90
+ const approved = (g) => gates[g]?.approved === true;
91
+
92
+ switch (phase) {
93
+ case 'proposal':
94
+ // An approved Gate 1 with no plan artifact yet means "plan work is open":
95
+ // the derived phase only flips when 2-plan/spec.md lands (derivePhase is
96
+ // artifact-first). Pointing back at the proposal here would be wrong.
97
+ return approved('proposal')
98
+ ? `Gate 1 aprovado — produza \`2-plan/spec.md\` + \`mandate.md\` + \`tasks.json\` (a fase avança com o primeiro artefato; Gate 2 em seguida).`
99
+ : `Complete \`0-proposal/proposal.md\` e aprove o Gate 1 (\`morph-spec advance ${featureName}\`). Skill: /morph-proposal ${featureName}.`;
100
+ case 'uiux':
101
+ return approved('uiux')
102
+ ? `Gate de uiux aprovado — produza \`2-plan/spec.md\` + \`mandate.md\` + \`tasks.json\` (Gate 2 em seguida).`
103
+ : `Complete \`1-design/design-system.md\` e aprove o gate de uiux (\`morph-spec advance ${featureName}\`).`;
104
+ case 'plan':
105
+ return `Produza \`2-plan/spec.md\` + \`mandate.md\` + \`tasks.json\` e aprove o Gate 2 (\`morph-spec advance ${featureName}\`).`;
106
+ case 'implement': {
107
+ const ready = tasks?.ready?.map((t) => `${t.id} "${t.title}"`).join(', ');
108
+ return ready
109
+ ? `Execute as tasks ready — ${ready} — via /morph-apply ${featureName} (verify antes do juiz, barra 9).`
110
+ : `Todas as tasks concluídas ou bloqueadas — rode \`morph-spec advance ${featureName}\` para escancarar a review, ou destrave dependências. Skill: /morph-apply ${featureName}.`;
111
+ }
112
+ case 'review':
113
+ return approved('review')
114
+ ? `Gate 3 aprovado — feche: \`morph-spec worktree finish ${featureName}\` e depois \`morph-spec archive ${featureName}\` na raiz.`
115
+ : `Produza \`4-review/review-report.md\` (avaliador independente) e aprove o Gate 3; depois \`worktree finish\` + \`archive\`.`;
116
+ default:
117
+ return `Rode \`morph-spec status ${featureName}\` para orientar o próximo passo.`;
118
+ }
119
+ }
120
+
121
+ /**
122
+ * Build the handoff document for a feature, resolved against `cwd` (primary
123
+ * root or worktree — wherever the feature's `.morph/features/{f}` lives).
124
+ *
125
+ * @param {string} featureName
126
+ * @param {string} [cwd]
127
+ * @returns {{ markdown: string, meta: { generatedAt: string, phase: string, tasksDone: number, tasksTotal: number } } | null}
128
+ */
129
+ export function buildHandoff(featureName, cwd = process.cwd()) {
130
+ const featureDir = join(cwd, '.morph', 'features', featureName);
131
+ if (!existsSync(join(featureDir, 'feature.json'))) return null;
132
+
133
+ let feature;
134
+ try {
135
+ feature = readJson(join(featureDir, 'feature.json'));
136
+ } catch {
137
+ return null;
138
+ }
139
+
140
+ let phase = 'proposal';
141
+ let pendingGates = [];
142
+ try {
143
+ phase = derivePhaseForFeature(featureName, cwd) || 'proposal';
144
+ pendingGates = getPendingGates(featureName, cwd) || [];
145
+ } catch { /* fail-open — proposal defaults keep the doc useful */ }
146
+
147
+ const generatedAt = new Date().toISOString();
148
+ const tasks = taskSummary(featureDir);
149
+ const env = readWorktreeEnv(cwd);
150
+ const decisions = decisionTitles(featureDir);
151
+ const worktree = feature.worktree || null;
152
+
153
+ const gates = feature.approvals || feature.approvalGates || {};
154
+ const gateLine = Object.entries(gates)
155
+ .map(([name, g]) => (g?.approved ? `${name} ✔${g.approvedBy ? ` (${g.approvedBy})` : ''}` : `${name} pendente`))
156
+ .join(' · ') || '—';
157
+
158
+ const relFeatureDir = `.morph/features/${featureName}`;
159
+ const pointers = POINTER_CANDIDATES
160
+ .filter(([rel]) => existsSync(join(featureDir, rel)))
161
+ .map(([rel, why], i) => `${i + 1}. \`${relFeatureDir}/${rel}\` — ${why}`);
162
+
163
+ const lines = [
164
+ `# Handoff — ${featureName}`,
165
+ '',
166
+ `> Documento derivado, gerado em ${generatedAt} por \`morph-spec handoff\`.`,
167
+ '> Regenere com o comando — nunca edite à mão. Aponta para os arquivos da',
168
+ '> feature em vez de copiá-los: leia os ponteiros, não confie em resumo velho.',
169
+ '',
170
+ '## Identidade',
171
+ '',
172
+ `- Feature: **${featureName}** (${feature.workType || 'feature'}) · fase: **${phase}** · status: ${feature.status || '—'}`,
173
+ worktree?.path
174
+ ? `- Local: worktree \`${worktree.path}\` · branch \`${worktree.branch || `morph/${featureName}`}\``
175
+ : '- Local: raiz primária (sem worktree registrado)',
176
+ env
177
+ ? `- Ambiente: PORT ${env.port}${env.range ? ` (bloco ${env.range})` : ''} — fonte \`.morph/worktree.env\`; suba dev servers SEMPRE nessas portas`
178
+ : null,
179
+ '',
180
+ '## Estado',
181
+ '',
182
+ `- Gates: ${gateLine}`,
183
+ tasks
184
+ ? `- Tasks: ${tasks.done}/${tasks.total} done · ready agora: ${tasks.ready.length ? tasks.ready.map((t) => `**${t.id}** "${t.title}"`).join(', ') : 'nenhuma'}`
185
+ : '- Tasks: sem tasks.json ainda (fase de planejamento não concluída)',
186
+ pendingGates.length ? `- Gates pendentes: ${pendingGates.join(', ')}` : null,
187
+ '',
188
+ '## Leia nesta ordem (ponteiros — nada copiado)',
189
+ '',
190
+ ...(pointers.length ? pointers : ['1. (nenhum artefato ainda — feature recém-criada)']),
191
+ decisions.length ? '' : null,
192
+ decisions.length ? `Últimas decisões: ${decisions.map((d) => `"${d}"`).join(' · ')}` : null,
193
+ '',
194
+ '## Próxima ação',
195
+ '',
196
+ nextAction(feature, phase, tasks, featureName),
197
+ '',
198
+ '## Retomar',
199
+ '',
200
+ worktree?.path ? `- \`cd ${worktree.path}\` e abra o Claude Code (uma instância por worktree).` : '- Abra o Claude Code na raiz do projeto.',
201
+ `- Visão da frota: \`morph-spec fleet\` · status: \`morph-spec status ${featureName}\``,
202
+ '',
203
+ ].filter((l) => l !== null);
204
+
205
+ return {
206
+ markdown: `${lines.join('\n')}`,
207
+ meta: {
208
+ generatedAt,
209
+ phase,
210
+ tasksDone: tasks?.done ?? 0,
211
+ tasksTotal: tasks?.total ?? 0,
212
+ },
213
+ };
214
+ }
215
+
216
+ /** Marks the exclude block this generator owns in `.git/info/exclude`. */
217
+ const HANDOFF_EXCLUDE_MARKER = '# morph-spec: derived handoff docs (managed — see handoff-generator)';
218
+ const HANDOFF_EXCLUDE_PATTERN = '/.morph/**/handoff.md';
219
+
220
+ /**
221
+ * The handoff is DERIVED from committed sources, so it is never committed
222
+ * itself — regenerate > synchronize, the same principle as the materialized
223
+ * state.json. Left visible, every `worktree setup`/`advance` would drop an
224
+ * untracked file into the very `git status` the finish gates read. The
225
+ * common-dir `.git/info/exclude` hides it in every worktree of the repo
226
+ * (including the primary root); `**` also covers a feature folder after
227
+ * `archive` moves it. Idempotent and fail-open — a handoff that shows up as
228
+ * untracked is noise, never data loss.
229
+ */
230
+ function ensureHandoffExcluded(cwd) {
231
+ try {
232
+ const common = execSync('git rev-parse --git-common-dir', { cwd, stdio: 'pipe' }).toString().trim();
233
+ if (!common) return;
234
+ const infoDir = join(resolve(cwd, common), 'info');
235
+ const excludePath = join(infoDir, 'exclude');
236
+ const current = existsSync(excludePath) ? readFileSync(excludePath, 'utf8') : '';
237
+ const present = new Set(current.split(/\r?\n/).map((l) => l.trim()));
238
+ if (present.has(HANDOFF_EXCLUDE_PATTERN)) return;
239
+ mkdirSync(infoDir, { recursive: true });
240
+ appendFileSync(excludePath, [
241
+ current && !current.endsWith('\n') ? '\n' : '',
242
+ present.has(HANDOFF_EXCLUDE_MARKER) ? '' : `${HANDOFF_EXCLUDE_MARKER}\n`,
243
+ `${HANDOFF_EXCLUDE_PATTERN}\n`,
244
+ ].join(''));
245
+ } catch { /* fail-open */ }
246
+ }
247
+
248
+ /**
249
+ * Write `.morph/features/{f}/handoff.md`. Returns the written path, or null
250
+ * when the feature does not exist (nothing is created).
251
+ *
252
+ * @param {string} featureName
253
+ * @param {string} [cwd]
254
+ * @returns {string|null}
255
+ */
256
+ export function writeHandoff(featureName, cwd = process.cwd()) {
257
+ const result = buildHandoff(featureName, cwd);
258
+ if (!result) return null;
259
+ const path = join(cwd, '.morph', 'features', featureName, 'handoff.md');
260
+ try {
261
+ ensureHandoffExcluded(cwd);
262
+ writeFileSync(path, result.markdown, 'utf8');
263
+ return path;
264
+ } catch {
265
+ return null;
266
+ }
267
+ }
@@ -5,9 +5,7 @@
5
5
  */
6
6
 
7
7
  export * from './design-system-generator.js';
8
- export * from './context-generator.js';
9
- export * from './recap-generator.js';
8
+ export * from './handoff-generator.js';
10
9
  export * from './metadata-extractor.js';
11
10
  export * from './architecture-scanner.js';
12
11
  export * from './scaffold-generator.js';
13
- export * from './mandate-generator.js';
@@ -51,24 +51,55 @@ function readStack(cwd) {
51
51
  }
52
52
  }
53
53
 
54
+ /** Merge two or more profiles' validator lists (deduped) and boolean flags. */
55
+ function mergeProfiles(profiles) {
56
+ return {
57
+ isDotnet: profiles.some((p) => p.isDotnet),
58
+ isNextjs: profiles.some((p) => p.isNextjs),
59
+ defaultValidators: [...new Set(profiles.flatMap((p) => p.defaultValidators))],
60
+ checkpointValidators: [...new Set(profiles.flatMap((p) => p.checkpointValidators))],
61
+ buildCommand: profiles.map((p) => p.buildCommand).find(Boolean) ?? null,
62
+ };
63
+ }
64
+
54
65
  /**
55
66
  * Get the StackProfile for the current project.
67
+ *
68
+ * `stack` may be a compound value (e.g. "dotnet-nextjs", "dotnet+nextjs") —
69
+ * the framework's own convention for monorepos (see stack-filter.js's
70
+ * getProjectTags, which splits project.stack the same way). Each segment is
71
+ * resolved independently and matching profiles are merged, instead of the
72
+ * whole compound string failing every exact-match check and silently
73
+ * collapsing to the empty profile (0 default validators).
56
74
  */
57
75
  export function getStackProfile(cwd, stackOverride) {
58
76
  const root = cwd ?? process.cwd();
59
77
  const raw = stackOverride ?? readStack(root);
60
78
  const stack = raw?.toLowerCase() ?? 'unknown';
61
79
 
62
- let profile;
63
- if (NEXTJS_STACKS.has(stack)) {
64
- profile = PROFILES.nextjs;
65
- } else if (NO_VALIDATOR_STACKS.has(stack)) {
66
- profile = PROFILES.empty;
67
- } else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
68
- profile = PROFILES.dotnet;
69
- } else {
70
- profile = PROFILES.empty;
80
+ const segments = stack.split(/[-+]/).filter(Boolean);
81
+
82
+ // Single-segment stack (the overwhelming common case): resolve exactly as
83
+ // before, byte-for-byte, including the 'unknown' → dotnet fallback.
84
+ if (segments.length <= 1) {
85
+ let profile;
86
+ if (NEXTJS_STACKS.has(stack)) {
87
+ profile = PROFILES.nextjs;
88
+ } else if (NO_VALIDATOR_STACKS.has(stack)) {
89
+ profile = PROFILES.empty;
90
+ } else if (DOTNET_STACKS.has(stack) || stack === 'unknown') {
91
+ profile = PROFILES.dotnet;
92
+ } else {
93
+ profile = PROFILES.empty;
94
+ }
95
+ return { stack, ...profile };
71
96
  }
72
97
 
98
+ // Compound stack: resolve each recognized segment and merge.
99
+ const matched = [];
100
+ if (segments.some((s) => DOTNET_STACKS.has(s))) matched.push(PROFILES.dotnet);
101
+ if (segments.some((s) => NEXTJS_STACKS.has(s))) matched.push(PROFILES.nextjs);
102
+
103
+ const profile = matched.length > 0 ? mergeProfiles(matched) : PROFILES.empty;
73
104
  return { stack, ...profile };
74
105
  }
@@ -98,6 +98,27 @@ export async function parseTasksJson(filePath) {
98
98
  return data;
99
99
  }
100
100
 
101
+ /**
102
+ * Lenient variant for read-only analysis commands (e.g. `morph-spec dag`):
103
+ * a missing `version` field is defaulted to "1.0" instead of failing
104
+ * validation — legacy tasks.json predating the field is still analyzable.
105
+ * All other schema errors still throw exactly like `parseTasksJson`.
106
+ *
107
+ * @param {string} filePath - Absolute path to tasks.json
108
+ * @returns {Promise<{ data: object, versionDefaulted: boolean }>}
109
+ */
110
+ export async function parseTasksJsonLenient(filePath) {
111
+ const data = await fs.readJson(filePath);
112
+ const versionDefaulted = data.version === undefined;
113
+ if (versionDefaulted) data.version = '1.0';
114
+
115
+ const { valid, errors } = validateTasksJson(data);
116
+ if (!valid) {
117
+ throw new Error(`Invalid tasks.json:\n ${errors.join('\n ')}`);
118
+ }
119
+ return { data, versionDefaulted };
120
+ }
121
+
101
122
  /**
102
123
  * Synchronous variant — used by hooks.
103
124
  */
@@ -4,13 +4,16 @@
4
4
  * Resolution order for test command:
5
5
  * 1. .morph/config/config.json → project.testCommand
6
6
  * 2. .sln or .csproj present at project root → "dotnet test"
7
- * 3. package.json present AND has scripts.test → "npm test"
8
- * 4. null (no test suite detected)
7
+ * 3. Nested *Tests.csproj anywhere in the tree (monorepo without a root
8
+ * .sln) → "dotnet test <proj> --nologo -v q" for each match
9
+ * 4. package.json present AND has scripts.test → "npm test"
10
+ * 5. null (no test suite detected)
9
11
  */
10
12
 
11
13
  import { execSync } from 'child_process';
12
14
  import { existsSync, readFileSync, readdirSync } from 'fs';
13
15
  import { join } from 'path';
16
+ import { globSync } from 'glob';
14
17
 
15
18
  /**
16
19
  * Resolve the test command for a project.
@@ -46,7 +49,26 @@ export function resolveTestCommand(projectPath = '.') {
46
49
  // Unreadable project directory — fall through to next detection
47
50
  }
48
51
 
49
- // 3. package.json present AND has scripts.test → npm test
52
+ // 3. No root-level project file — look for a nested test project
53
+ // (e.g. tests/MyApp.Tests/MyApp.Tests.csproj), common in monorepos that
54
+ // have no root .sln. Run each matched project explicitly rather than a
55
+ // bare `dotnet test` (which finds nothing to build at the root).
56
+ try {
57
+ const testProjects = globSync('**/*Tests.csproj', {
58
+ cwd: projectPath,
59
+ ignore: ['**/bin/**', '**/obj/**', '**/node_modules/**'],
60
+ });
61
+ if (testProjects.length > 0) {
62
+ return testProjects
63
+ .sort()
64
+ .map((p) => `dotnet test "${p}" --nologo -v q`)
65
+ .join(' && ');
66
+ }
67
+ } catch {
68
+ // Glob failure — fall through to next detection
69
+ }
70
+
71
+ // 4. package.json present AND has scripts.test → npm test
50
72
  const packageJsonPath = join(projectPath, 'package.json');
51
73
  if (existsSync(packageJsonPath)) {
52
74
  try {
@@ -59,7 +81,7 @@ export function resolveTestCommand(projectPath = '.') {
59
81
  }
60
82
  }
61
83
 
62
- // 4. Nothing found
84
+ // 5. Nothing found
63
85
  return null;
64
86
  }
65
87
 
@@ -124,7 +124,10 @@ export async function runValidation(projectPath, featureName, options = {}) {
124
124
  const _profileForContracts = getStackProfile(projectPath);
125
125
  const contractsType = resolveContractsOutputType(_profileForContracts.stack);
126
126
  const contractsPath = getAbsoluteOutputPath(projectPath, featureName, contractsType);
127
- if (existsSync(contractsPath) && !_profileForContracts.isNextjs) {
127
+ // Positive check on isDotnet (contract-compliance is a VSA/.NET concern) —
128
+ // NOT `!isNextjs`, which used to wrongly suppress it for a compound
129
+ // "dotnet-nextjs" stack that has a .NET backend but is ALSO isNextjs.
130
+ if (existsSync(contractsPath) && _profileForContracts.isDotnet) {
128
131
  validatorIds.push('contract-compliance');
129
132
  }
130
133
  }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * env-allocator.js — deterministic per-worktree port-block allocation (fleet).
3
+ *
4
+ * Two parallel features that both start `next dev` (or `dotnet run`) collide on
5
+ * the same default port, and the failure is invisible until one server kills
6
+ * the other. Worktrees isolate files and branches, not runtime — this module
7
+ * closes that gap by giving every feature worktree its own stable block of
8
+ * ports.
9
+ *
10
+ * The registry lives at `<git-common-dir>/morph-spec/env-registry.json` — under
11
+ * `.git/` on purpose, for the same reasons as `finish-backup/`: it can never
12
+ * dirty the `git status` the worktree-finish gates read, it survives
13
+ * `git clean -fdx`, and the COMMON dir is the one location every linked
14
+ * worktree of the repo shares.
15
+ *
16
+ * Allocation is deterministic (first free index), idempotent per feature, and
17
+ * released only when `worktree finish` actually removed the worktree. Writes
18
+ * are tmp+rename so a crash never leaves a torn registry; a corrupt registry
19
+ * is treated as empty (fail-open — the cost is a re-allocation, never a crash).
20
+ */
21
+
22
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
23
+ import { join } from 'path';
24
+
25
+ const DEFAULT_BASE_PORT = 4000;
26
+ const DEFAULT_BLOCK_SIZE = 10;
27
+ const MAX_PORT = 65535;
28
+
29
+ /** Registry file path for a repo's common git dir. */
30
+ export function registryPath(gitCommonDir) {
31
+ return join(gitCommonDir, 'morph-spec', 'env-registry.json');
32
+ }
33
+
34
+ function readRegistry(gitCommonDir) {
35
+ try {
36
+ const raw = readFileSync(registryPath(gitCommonDir), 'utf8').replace(/^/, '');
37
+ const parsed = JSON.parse(raw);
38
+ if (parsed && typeof parsed.allocations === 'object' && parsed.allocations !== null) {
39
+ return parsed;
40
+ }
41
+ } catch { /* fail-open: corrupt or absent registry restarts empty */ }
42
+ return { version: 1, allocations: {} };
43
+ }
44
+
45
+ function writeRegistry(gitCommonDir, registry) {
46
+ const path = registryPath(gitCommonDir);
47
+ mkdirSync(join(gitCommonDir, 'morph-spec'), { recursive: true });
48
+ const tmp = `${path}.tmp.${process.pid}`;
49
+ writeFileSync(tmp, `${JSON.stringify(registry, null, 2)}\n`, 'utf8');
50
+ renameSync(tmp, path);
51
+ }
52
+
53
+ /**
54
+ * Read fleet overrides from the project's committed config.json.
55
+ * Fail-open: missing/malformed config (or a Windows BOM) → defaults.
56
+ *
57
+ * @param {string} rootPath - primary root of the project
58
+ * @returns {{ basePort: number, blockSize: number }}
59
+ */
60
+ export function resolveFleetConfig(rootPath) {
61
+ const defaults = { basePort: DEFAULT_BASE_PORT, blockSize: DEFAULT_BLOCK_SIZE };
62
+ try {
63
+ const raw = readFileSync(join(rootPath, '.morph', 'config', 'config.json'), 'utf8').replace(/^/, '');
64
+ const cfg = JSON.parse(raw);
65
+ const fleet = cfg?.fleet || {};
66
+ return {
67
+ basePort: Number.isInteger(fleet.basePort) && fleet.basePort > 0 ? fleet.basePort : defaults.basePort,
68
+ blockSize: Number.isInteger(fleet.blockSize) && fleet.blockSize > 0 ? fleet.blockSize : defaults.blockSize,
69
+ };
70
+ } catch {
71
+ return defaults;
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Allocate (or return the existing) port block for a feature.
77
+ * First-free-index policy: released slots are reused, so long-lived repos
78
+ * don't creep toward the port ceiling.
79
+ *
80
+ * @param {string} feature
81
+ * @param {{ gitCommonDir: string, basePort?: number, blockSize?: number }} opts
82
+ * @returns {{ index: number, base: number, range: [number, number], created: boolean }}
83
+ */
84
+ export function allocateEnvBlock(feature, { gitCommonDir, basePort = DEFAULT_BASE_PORT, blockSize = DEFAULT_BLOCK_SIZE }) {
85
+ const registry = readRegistry(gitCommonDir);
86
+
87
+ const existing = registry.allocations[feature];
88
+ if (existing && Number.isInteger(existing.index)) {
89
+ const base = basePort + existing.index * blockSize;
90
+ return { index: existing.index, base, range: [base, base + blockSize - 1], created: false };
91
+ }
92
+
93
+ const used = new Set(
94
+ Object.values(registry.allocations)
95
+ .map((a) => a?.index)
96
+ .filter((i) => Number.isInteger(i)),
97
+ );
98
+ let index = 0;
99
+ while (used.has(index)) index += 1;
100
+
101
+ const base = basePort + index * blockSize;
102
+ if (base + blockSize - 1 > MAX_PORT) {
103
+ throw new Error(`env-allocator: port block ${base}-${base + blockSize - 1} exceeds ${MAX_PORT}`);
104
+ }
105
+
106
+ registry.allocations[feature] = { index, base, createdAt: new Date().toISOString() };
107
+ writeRegistry(gitCommonDir, registry);
108
+ return { index, base, range: [base, base + blockSize - 1], created: true };
109
+ }
110
+
111
+ /**
112
+ * Release a feature's block. Returns false when nothing was allocated.
113
+ *
114
+ * @param {string} feature
115
+ * @param {{ gitCommonDir: string }} opts
116
+ * @returns {boolean}
117
+ */
118
+ export function releaseEnvBlock(feature, { gitCommonDir }) {
119
+ const registry = readRegistry(gitCommonDir);
120
+ if (!registry.allocations[feature]) return false;
121
+ delete registry.allocations[feature];
122
+ writeRegistry(gitCommonDir, registry);
123
+ return true;
124
+ }
125
+
126
+ /**
127
+ * Render the `.morph/worktree.env` contents for a block, stack-aware.
128
+ * `PORT` covers next/vite/node dev servers; dotnet gets its own URL on base+1
129
+ * so a full-stack worktree runs web and api side by side inside one block.
130
+ *
131
+ * @param {string} feature
132
+ * @param {{ base: number, range: [number, number] }} block
133
+ * @param {string} [stack] - config.json `project.stack` (e.g. "nextjs+dotnet")
134
+ * @returns {string}
135
+ */
136
+ export function buildEnvFileContent(feature, block, stack = '') {
137
+ const lines = [
138
+ `# Managed by morph-spec — fleet env desta worktree (feature: ${feature}).`,
139
+ '# Derivado e local: nunca commitar, nunca editar à mão (regenerado pelo worktree setup).',
140
+ `MORPH_FEATURE=${feature}`,
141
+ `MORPH_PORT_BASE=${block.base}`,
142
+ `MORPH_PORT_RANGE=${block.range[0]}-${block.range[1]}`,
143
+ `PORT=${block.base}`,
144
+ ];
145
+ if (/\bdotnet\b/i.test(String(stack))) {
146
+ lines.push(`ASPNETCORE_URLS=http://localhost:${block.base + 1}`);
147
+ }
148
+ return `${lines.join('\n')}\n`;
149
+ }