@polymorphism-tech/morph-spec 8.33.0 → 8.34.2
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.
- package/CLAUDE.md +3 -0
- package/README.md +2 -2
- package/bin/morph-spec.js +25 -1
- package/bin/validate.js +379 -379
- package/claude-plugin.json +1 -1
- package/docs/CHEATSHEET.md +1 -1
- package/docs/QUICKSTART.md +1 -1
- package/framework/CLAUDE.md +2 -2
- package/framework/MORPH.md +4 -5
- package/framework/agents.json +1 -0
- package/framework/docs/CLI.md +5 -2
- package/framework/hooks/README.md +11 -1
- package/framework/hooks/claude-code/core/block-destructive-worktree-removal.js +5 -8
- package/framework/hooks/claude-code/core/block-raw-worktree-add.js +79 -0
- package/framework/hooks/claude-code/core/chain.js +27 -7
- package/framework/hooks/claude-code/core/worktree-context.js +83 -14
- package/framework/hooks/claude-code/core/worktree-create.js +206 -0
- package/framework/hooks/claude-code/core/worktree-env-inject.js +87 -0
- package/framework/hooks/claude-code/core/worktree-remove.js +81 -0
- package/framework/hooks/claude-code/statusline.sh +11 -11
- package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
- package/framework/hooks/git/pre-commit/agents.sh +25 -25
- package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
- package/framework/hooks/git/pre-commit/specs.sh +50 -50
- package/framework/hooks/git/pre-push/run-tests.sh +44 -44
- package/framework/hooks/shared/command-tokens.js +23 -0
- package/framework/hooks/shared/e2e-check.js +0 -0
- package/framework/hooks/shared/git-paths.js +25 -0
- package/framework/hooks/shared/hook-response.js +17 -0
- package/framework/hooks/shared/morph-cli.js +134 -0
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/rules/infrastructure-standards.md +3 -1
- package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
- package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
- package/framework/skills/core/morph-apply/SKILL.md +3 -3
- package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
- package/framework/skills/core/morph-eval/SKILL.md +1 -1
- package/framework/skills/core/morph-hotfix/SKILL.md +1 -1
- package/framework/skills/core/morph-implement/SKILL.md +3 -1
- package/framework/skills/core/morph-plan/SKILL.md +6 -3
- package/framework/skills/core/morph-proposal/SKILL.md +1 -1
- package/framework/skills/core/morph-review/SKILL.md +1 -1
- package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
- package/framework/skills/core/morph-standards/SKILL.md +1 -1
- package/framework/skills/core/morph-uiux/SKILL.md +1 -1
- package/framework/standards/STANDARDS.json +30 -0
- package/framework/standards/ai-agents/agent-session.md +160 -160
- package/framework/standards/ai-agents/batching.md +193 -193
- package/framework/standards/ai-agents/context-providers.md +188 -188
- package/framework/standards/ai-agents/mcp-server.md +250 -250
- package/framework/standards/ai-agents/mcp-tools.md +197 -197
- package/framework/standards/ai-agents/middleware-patterns.md +207 -207
- package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
- package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
- package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
- package/framework/standards/ai-agents/modalities-vision.md +201 -201
- package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
- package/framework/standards/ai-agents/production.md +231 -231
- package/framework/standards/ai-agents/prompts-in-db.md +214 -214
- package/framework/standards/ai-agents/providers/model-registry.md +158 -158
- package/framework/standards/ai-agents/service-tier-flex.md +135 -135
- package/framework/standards/ai-agents/structured-output.md +249 -249
- package/framework/standards/ai-agents/sweet-spot.md +109 -109
- package/framework/standards/infrastructure/docker/local-compose-isolation.md +88 -0
- package/framework/templates/IDE-SNIPPETS.md +262 -262
- package/framework/templates/REGISTRY.json +1 -1
- package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
- package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
- package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
- package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
- package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
- package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
- package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
- package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
- package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
- package/framework/templates/code/dotnet/database/migration.cs +83 -83
- package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
- package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
- package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
- package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
- package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
- package/framework/templates/code/typescript/contracts.ts +167 -167
- package/framework/templates/docs/clarifications.md +253 -253
- package/framework/templates/docs/schema-analysis.md +119 -119
- package/framework/templates/docs/ui-components.md +52 -52
- package/framework/templates/docs/ui-design-system.md +76 -76
- package/framework/templates/docs/ui-flows.md +167 -167
- package/framework/templates/docs/ui-mockups.md +98 -98
- package/framework/templates/feature/decisions.md +122 -122
- package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
- package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
- package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
- package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
- package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
- package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
- package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
- package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
- package/framework/templates/infrastructure/docker/docker-compose.template.yml +24 -14
- package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
- package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
- package/framework/templates/infrastructure/github/README.md +56 -56
- package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
- package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
- package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
- package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
- package/framework/templates/integrations/asaas-client.cs +387 -387
- package/framework/templates/integrations/asaas-webhook.cs +351 -351
- package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
- package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
- package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
- package/framework/templates/project/validate.js +120 -120
- package/framework/templates/state.template.json +17 -17
- package/framework/templates/ui/design-system.css +226 -226
- package/package.json +1 -1
- package/src/commands/dashboard/dashboard.js +53 -53
- package/src/commands/e2e/e2e.js +77 -39
- package/src/commands/index.js +13 -13
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/doctor.js +8 -6
- package/src/commands/project/env.js +95 -0
- package/src/commands/project/worktree.js +266 -52
- package/src/commands/state/delete.js +8 -0
- package/src/commands/state/finish.js +29 -34
- package/src/core/state/phase-state-machine.js +197 -197
- package/src/core/templates/template-renderer.js +477 -477
- package/src/dashboard/app/components/DetailPanel.jsx +11 -11
- package/src/dashboard/app/components/FlowGraph.jsx +17 -17
- package/src/dashboard/app/components/SearchFilter.jsx +22 -22
- package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
- package/src/dashboard/app/main.jsx +13 -13
- package/src/dashboard/app/pages/AgentMap.jsx +370 -370
- package/src/dashboard/app/pages/HookInspector.jsx +430 -418
- package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
- package/src/dashboard/app/pages/RulesMap.jsx +273 -273
- package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
- package/src/dashboard/app/pages/Standards.jsx +332 -332
- package/src/dashboard/app/styles/index.css +47 -47
- package/src/dashboard/index.html +12 -12
- package/src/dashboard/package.json +24 -24
- package/src/dashboard/scanner/hooks-scanner.js +5 -1
- package/src/dashboard/scanner/phases-scanner.js +15 -15
- package/src/dashboard/scanner/rubrics-scanner.js +12 -12
- package/src/dashboard/scanner/rules-scanner.js +37 -37
- package/src/dashboard/scanner/skills-scanner.js +43 -43
- package/src/dashboard/scanner/squads-scanner.js +13 -13
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/detectors/design-system-detector.js +188 -188
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/e2e/compose-env.js +6 -11
- package/src/lib/e2e/stack-teardown.js +115 -0
- package/src/lib/generators/architecture-scanner.js +2 -1
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/settings-generator.js +209 -209
- package/src/lib/graph/graphifyignore.js +2 -1
- package/src/lib/hooks/hook-executor.js +167 -167
- package/src/lib/installers/mcp-installer.js +719 -719
- package/src/lib/tasks/task-classifier.js +30 -30
- package/src/lib/tasks/task-parser.js +209 -209
- package/src/lib/tasks/test-runner.js +21 -4
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
- package/src/lib/validators/shared/issue-counter.js +14 -14
- package/src/lib/validators/verify-runner.js +7 -0
- package/src/lib/worktree/dirs.js +32 -0
- package/src/lib/worktree/env-allocator.js +45 -11
- package/src/lib/worktree/worktree-audit.js +40 -2
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/file-copier.js +18 -13
- package/src/utils/hooks-installer.js +46 -3
- package/src/utils/skills-installer.js +187 -187
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
|
@@ -1,217 +1,217 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @fileoverview Project Detector
|
|
3
|
-
*
|
|
4
|
-
* Scans a project directory for stack, architecture, UI library, and integrations.
|
|
5
|
-
* Used by mcp-setup, morph-init, and session-start hooks.
|
|
6
|
-
*
|
|
7
|
-
* @module detectors/index
|
|
8
|
-
*/
|
|
9
|
-
|
|
10
|
-
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
11
|
-
import { join } from 'path';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Detect project structure from filesystem evidence.
|
|
15
|
-
*
|
|
16
|
-
* @param {string} targetPath - Root path of the project
|
|
17
|
-
* @param {Object} [opts] - Options
|
|
18
|
-
* @param {boolean} [opts.generateStandards=false] - Whether to generate inferred markdown
|
|
19
|
-
* @returns {{ structure: { stack: string, architecture: string|null, uiLibrary: string|null, integrations: string[], hasVercel: boolean }, inferred: { markdown: string } }}
|
|
20
|
-
*/
|
|
21
|
-
export function detectProject(targetPath, opts = {}) {
|
|
22
|
-
const structure = {
|
|
23
|
-
stack: 'unknown',
|
|
24
|
-
architecture: null,
|
|
25
|
-
uiLibrary: null,
|
|
26
|
-
integrations: [],
|
|
27
|
-
hasVercel: false,
|
|
28
|
-
};
|
|
29
|
-
|
|
30
|
-
try {
|
|
31
|
-
// ── Package.json analysis ──────────────────────────────────────────────
|
|
32
|
-
// Collect deps from root and common monorepo subfolders
|
|
33
|
-
const pkgLocations = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
|
|
34
|
-
const allDeps = {};
|
|
35
|
-
let rootPkg = null;
|
|
36
|
-
let scripts = [];
|
|
37
|
-
for (const loc of pkgLocations) {
|
|
38
|
-
const pkgPath = join(targetPath, loc, 'package.json');
|
|
39
|
-
if (!existsSync(pkgPath)) continue;
|
|
40
|
-
try {
|
|
41
|
-
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
42
|
-
if (loc === '.') rootPkg = pkg;
|
|
43
|
-
Object.assign(allDeps, pkg.dependencies, pkg.devDependencies);
|
|
44
|
-
scripts.push(...Object.keys(pkg.scripts || {}));
|
|
45
|
-
// Track frontend path for config
|
|
46
|
-
if (loc !== '.' && (pkg.dependencies?.next || pkg.dependencies?.react || pkg.dependencies?.vue)) {
|
|
47
|
-
structure.frontendPath = loc;
|
|
48
|
-
}
|
|
49
|
-
} catch { /* invalid JSON */ }
|
|
50
|
-
}
|
|
51
|
-
const depNames = Object.keys(allDeps);
|
|
52
|
-
|
|
53
|
-
if (depNames.length > 0) {
|
|
54
|
-
|
|
55
|
-
// Stack detection from deps
|
|
56
|
-
if (depNames.includes('next')) {
|
|
57
|
-
structure.stack = 'nextjs';
|
|
58
|
-
} else if (depNames.includes('react')) {
|
|
59
|
-
structure.stack = 'react';
|
|
60
|
-
} else if (depNames.includes('vue')) {
|
|
61
|
-
structure.stack = 'vue';
|
|
62
|
-
} else if (depNames.includes('express') || depNames.includes('fastify') || depNames.includes('hono')) {
|
|
63
|
-
structure.stack = 'nodejs';
|
|
64
|
-
} else if (rootPkg?.name) {
|
|
65
|
-
structure.stack = 'nodejs';
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
// UI library detection
|
|
69
|
-
if (existsSync(join(targetPath, 'components.json')) || existsSync(join(targetPath, structure.frontendPath || '.', 'components.json')) || depNames.includes('@shadcn/ui')) {
|
|
70
|
-
structure.uiLibrary = 'shadcn';
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
// Integrations
|
|
74
|
-
if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
|
|
75
|
-
structure.integrations.push('neon');
|
|
76
|
-
}
|
|
77
|
-
if (depNames.includes('@neondatabase/auth')) {
|
|
78
|
-
structure.integrations.push('neon-auth');
|
|
79
|
-
}
|
|
80
|
-
if (depNames.includes('stripe')) {
|
|
81
|
-
structure.integrations.push('stripe');
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
// Vercel detection
|
|
85
|
-
if (depNames.includes('vercel') || scripts.includes('vercel')) {
|
|
86
|
-
structure.hasVercel = true;
|
|
87
|
-
}
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
// ── .csproj detection ──────────────────────────────────────────────────
|
|
91
|
-
let hasDotnet = false;
|
|
92
|
-
const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
|
|
93
|
-
|
|
94
|
-
function checkCsproj(filePath) {
|
|
95
|
-
hasDotnet = true;
|
|
96
|
-
try {
|
|
97
|
-
const content = readFileSync(filePath, 'utf8');
|
|
98
|
-
if (content.includes('Hangfire')) {
|
|
99
|
-
if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
|
|
100
|
-
}
|
|
101
|
-
} catch { /* ignore */ }
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
for (const loc of csprojLocations) {
|
|
105
|
-
try {
|
|
106
|
-
const dir = join(targetPath, loc);
|
|
107
|
-
if (!existsSync(dir)) continue;
|
|
108
|
-
const entries = readdirSyncSafe(dir);
|
|
109
|
-
// Direct .csproj files
|
|
110
|
-
for (const e of entries.filter(f => f.endsWith('.csproj'))) {
|
|
111
|
-
checkCsproj(join(dir, e));
|
|
112
|
-
}
|
|
113
|
-
// One level deeper (e.g., src/backend/MyProject/*.csproj)
|
|
114
|
-
for (const sub of entries) {
|
|
115
|
-
const subDir = join(dir, sub);
|
|
116
|
-
for (const e of readdirSyncSafe(subDir).filter(f => f.endsWith('.csproj'))) {
|
|
117
|
-
checkCsproj(join(subDir, e));
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
} catch { /* ignore */ }
|
|
121
|
-
}
|
|
122
|
-
|
|
123
|
-
// Determine stack: monorepo (nextjs+dotnet), dotnet, or keep frontend stack
|
|
124
|
-
if (hasDotnet) {
|
|
125
|
-
if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
|
|
126
|
-
// Monorepo: frontend + .NET backend
|
|
127
|
-
structure.stack = `${structure.stack}+dotnet`;
|
|
128
|
-
} else if (structure.stack === 'unknown') {
|
|
129
|
-
structure.stack = 'dotnet';
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
// ── vercel.json detection ──────────────────────────────────────────────
|
|
134
|
-
if (existsSync(join(targetPath, 'vercel.json'))) {
|
|
135
|
-
structure.hasVercel = true;
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
// ── tsconfig.json detection (fallback for stack) ────────────────────────
|
|
139
|
-
if (structure.stack === 'unknown' && existsSync(join(targetPath, 'tsconfig.json'))) {
|
|
140
|
-
structure.stack = 'nodejs';
|
|
141
|
-
}
|
|
142
|
-
|
|
143
|
-
// ── Architecture detection ─────────────────────────────────────────────
|
|
144
|
-
try {
|
|
145
|
-
const configPath = join(targetPath, '.morph/config/config.json');
|
|
146
|
-
if (existsSync(configPath)) {
|
|
147
|
-
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
148
|
-
if (config.architecture?.style) {
|
|
149
|
-
structure.architecture = config.architecture.style;
|
|
150
|
-
}
|
|
151
|
-
}
|
|
152
|
-
} catch { /* ignore */ }
|
|
153
|
-
|
|
154
|
-
// ── Docker detection ───────────────────────────────────────────────────
|
|
155
|
-
if (existsSync(join(targetPath, 'docker-compose.yml')) ||
|
|
156
|
-
existsSync(join(targetPath, 'docker-compose.yaml')) ||
|
|
157
|
-
existsSync(join(targetPath, 'Dockerfile'))) {
|
|
158
|
-
if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
// ── Azure detection ────────────────────────────────────────────────────
|
|
162
|
-
const azureSignals = [
|
|
163
|
-
'.azure',
|
|
164
|
-
'azure-pipelines.yml',
|
|
165
|
-
'infra/main.bicep',
|
|
166
|
-
'infra',
|
|
167
|
-
];
|
|
168
|
-
const hasAzure = azureSignals.some(signal => {
|
|
169
|
-
const p = join(targetPath, signal);
|
|
170
|
-
if (!existsSync(p)) return false;
|
|
171
|
-
if (signal === 'infra') {
|
|
172
|
-
// Check for .bicep files inside infra/
|
|
173
|
-
return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
|
|
174
|
-
}
|
|
175
|
-
return true;
|
|
176
|
-
});
|
|
177
|
-
if (hasAzure && !structure.integrations.includes('azure')) {
|
|
178
|
-
structure.integrations.push('azure');
|
|
179
|
-
}
|
|
180
|
-
} catch {
|
|
181
|
-
// Fail-safe — return defaults
|
|
182
|
-
}
|
|
183
|
-
|
|
184
|
-
return {
|
|
185
|
-
structure,
|
|
186
|
-
inferred: {
|
|
187
|
-
markdown: opts.generateStandards ? generateInferredMarkdown(structure) : '',
|
|
188
|
-
},
|
|
189
|
-
};
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
/**
|
|
193
|
-
* Safe readdir that returns [] on error.
|
|
194
|
-
* @param {string} dir
|
|
195
|
-
* @returns {string[]}
|
|
196
|
-
*/
|
|
197
|
-
function readdirSyncSafe(dir) {
|
|
198
|
-
try {
|
|
199
|
-
return readdirSync(dir);
|
|
200
|
-
} catch {
|
|
201
|
-
return [];
|
|
202
|
-
}
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
/**
|
|
206
|
-
* Generate a markdown summary from detected structure.
|
|
207
|
-
* @param {Object} structure
|
|
208
|
-
* @returns {string}
|
|
209
|
-
*/
|
|
210
|
-
function generateInferredMarkdown(structure) {
|
|
211
|
-
const lines = [`## Detected Stack: ${structure.stack}`];
|
|
212
|
-
if (structure.architecture) lines.push(`Architecture: ${structure.architecture}`);
|
|
213
|
-
if (structure.uiLibrary) lines.push(`UI Library: ${structure.uiLibrary}`);
|
|
214
|
-
if (structure.integrations.length > 0) lines.push(`Integrations: ${structure.integrations.join(', ')}`);
|
|
215
|
-
if (structure.hasVercel) lines.push(`Vercel: detected`);
|
|
216
|
-
return lines.join('\n');
|
|
217
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Project Detector
|
|
3
|
+
*
|
|
4
|
+
* Scans a project directory for stack, architecture, UI library, and integrations.
|
|
5
|
+
* Used by mcp-setup, morph-init, and session-start hooks.
|
|
6
|
+
*
|
|
7
|
+
* @module detectors/index
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { existsSync, readFileSync, readdirSync } from 'fs';
|
|
11
|
+
import { join } from 'path';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Detect project structure from filesystem evidence.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} targetPath - Root path of the project
|
|
17
|
+
* @param {Object} [opts] - Options
|
|
18
|
+
* @param {boolean} [opts.generateStandards=false] - Whether to generate inferred markdown
|
|
19
|
+
* @returns {{ structure: { stack: string, architecture: string|null, uiLibrary: string|null, integrations: string[], hasVercel: boolean }, inferred: { markdown: string } }}
|
|
20
|
+
*/
|
|
21
|
+
export function detectProject(targetPath, opts = {}) {
|
|
22
|
+
const structure = {
|
|
23
|
+
stack: 'unknown',
|
|
24
|
+
architecture: null,
|
|
25
|
+
uiLibrary: null,
|
|
26
|
+
integrations: [],
|
|
27
|
+
hasVercel: false,
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
// ── Package.json analysis ──────────────────────────────────────────────
|
|
32
|
+
// Collect deps from root and common monorepo subfolders
|
|
33
|
+
const pkgLocations = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
|
|
34
|
+
const allDeps = {};
|
|
35
|
+
let rootPkg = null;
|
|
36
|
+
let scripts = [];
|
|
37
|
+
for (const loc of pkgLocations) {
|
|
38
|
+
const pkgPath = join(targetPath, loc, 'package.json');
|
|
39
|
+
if (!existsSync(pkgPath)) continue;
|
|
40
|
+
try {
|
|
41
|
+
const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
|
|
42
|
+
if (loc === '.') rootPkg = pkg;
|
|
43
|
+
Object.assign(allDeps, pkg.dependencies, pkg.devDependencies);
|
|
44
|
+
scripts.push(...Object.keys(pkg.scripts || {}));
|
|
45
|
+
// Track frontend path for config
|
|
46
|
+
if (loc !== '.' && (pkg.dependencies?.next || pkg.dependencies?.react || pkg.dependencies?.vue)) {
|
|
47
|
+
structure.frontendPath = loc;
|
|
48
|
+
}
|
|
49
|
+
} catch { /* invalid JSON */ }
|
|
50
|
+
}
|
|
51
|
+
const depNames = Object.keys(allDeps);
|
|
52
|
+
|
|
53
|
+
if (depNames.length > 0) {
|
|
54
|
+
|
|
55
|
+
// Stack detection from deps
|
|
56
|
+
if (depNames.includes('next')) {
|
|
57
|
+
structure.stack = 'nextjs';
|
|
58
|
+
} else if (depNames.includes('react')) {
|
|
59
|
+
structure.stack = 'react';
|
|
60
|
+
} else if (depNames.includes('vue')) {
|
|
61
|
+
structure.stack = 'vue';
|
|
62
|
+
} else if (depNames.includes('express') || depNames.includes('fastify') || depNames.includes('hono')) {
|
|
63
|
+
structure.stack = 'nodejs';
|
|
64
|
+
} else if (rootPkg?.name) {
|
|
65
|
+
structure.stack = 'nodejs';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// UI library detection
|
|
69
|
+
if (existsSync(join(targetPath, 'components.json')) || existsSync(join(targetPath, structure.frontendPath || '.', 'components.json')) || depNames.includes('@shadcn/ui')) {
|
|
70
|
+
structure.uiLibrary = 'shadcn';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// Integrations
|
|
74
|
+
if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
|
|
75
|
+
structure.integrations.push('neon');
|
|
76
|
+
}
|
|
77
|
+
if (depNames.includes('@neondatabase/auth')) {
|
|
78
|
+
structure.integrations.push('neon-auth');
|
|
79
|
+
}
|
|
80
|
+
if (depNames.includes('stripe')) {
|
|
81
|
+
structure.integrations.push('stripe');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Vercel detection
|
|
85
|
+
if (depNames.includes('vercel') || scripts.includes('vercel')) {
|
|
86
|
+
structure.hasVercel = true;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ── .csproj detection ──────────────────────────────────────────────────
|
|
91
|
+
let hasDotnet = false;
|
|
92
|
+
const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
|
|
93
|
+
|
|
94
|
+
function checkCsproj(filePath) {
|
|
95
|
+
hasDotnet = true;
|
|
96
|
+
try {
|
|
97
|
+
const content = readFileSync(filePath, 'utf8');
|
|
98
|
+
if (content.includes('Hangfire')) {
|
|
99
|
+
if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
|
|
100
|
+
}
|
|
101
|
+
} catch { /* ignore */ }
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
for (const loc of csprojLocations) {
|
|
105
|
+
try {
|
|
106
|
+
const dir = join(targetPath, loc);
|
|
107
|
+
if (!existsSync(dir)) continue;
|
|
108
|
+
const entries = readdirSyncSafe(dir);
|
|
109
|
+
// Direct .csproj files
|
|
110
|
+
for (const e of entries.filter(f => f.endsWith('.csproj'))) {
|
|
111
|
+
checkCsproj(join(dir, e));
|
|
112
|
+
}
|
|
113
|
+
// One level deeper (e.g., src/backend/MyProject/*.csproj)
|
|
114
|
+
for (const sub of entries) {
|
|
115
|
+
const subDir = join(dir, sub);
|
|
116
|
+
for (const e of readdirSyncSafe(subDir).filter(f => f.endsWith('.csproj'))) {
|
|
117
|
+
checkCsproj(join(subDir, e));
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
} catch { /* ignore */ }
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// Determine stack: monorepo (nextjs+dotnet), dotnet, or keep frontend stack
|
|
124
|
+
if (hasDotnet) {
|
|
125
|
+
if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
|
|
126
|
+
// Monorepo: frontend + .NET backend
|
|
127
|
+
structure.stack = `${structure.stack}+dotnet`;
|
|
128
|
+
} else if (structure.stack === 'unknown') {
|
|
129
|
+
structure.stack = 'dotnet';
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// ── vercel.json detection ──────────────────────────────────────────────
|
|
134
|
+
if (existsSync(join(targetPath, 'vercel.json'))) {
|
|
135
|
+
structure.hasVercel = true;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// ── tsconfig.json detection (fallback for stack) ────────────────────────
|
|
139
|
+
if (structure.stack === 'unknown' && existsSync(join(targetPath, 'tsconfig.json'))) {
|
|
140
|
+
structure.stack = 'nodejs';
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Architecture detection ─────────────────────────────────────────────
|
|
144
|
+
try {
|
|
145
|
+
const configPath = join(targetPath, '.morph/config/config.json');
|
|
146
|
+
if (existsSync(configPath)) {
|
|
147
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
148
|
+
if (config.architecture?.style) {
|
|
149
|
+
structure.architecture = config.architecture.style;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch { /* ignore */ }
|
|
153
|
+
|
|
154
|
+
// ── Docker detection ───────────────────────────────────────────────────
|
|
155
|
+
if (existsSync(join(targetPath, 'docker-compose.yml')) ||
|
|
156
|
+
existsSync(join(targetPath, 'docker-compose.yaml')) ||
|
|
157
|
+
existsSync(join(targetPath, 'Dockerfile'))) {
|
|
158
|
+
if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// ── Azure detection ────────────────────────────────────────────────────
|
|
162
|
+
const azureSignals = [
|
|
163
|
+
'.azure',
|
|
164
|
+
'azure-pipelines.yml',
|
|
165
|
+
'infra/main.bicep',
|
|
166
|
+
'infra',
|
|
167
|
+
];
|
|
168
|
+
const hasAzure = azureSignals.some(signal => {
|
|
169
|
+
const p = join(targetPath, signal);
|
|
170
|
+
if (!existsSync(p)) return false;
|
|
171
|
+
if (signal === 'infra') {
|
|
172
|
+
// Check for .bicep files inside infra/
|
|
173
|
+
return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
|
|
174
|
+
}
|
|
175
|
+
return true;
|
|
176
|
+
});
|
|
177
|
+
if (hasAzure && !structure.integrations.includes('azure')) {
|
|
178
|
+
structure.integrations.push('azure');
|
|
179
|
+
}
|
|
180
|
+
} catch {
|
|
181
|
+
// Fail-safe — return defaults
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
return {
|
|
185
|
+
structure,
|
|
186
|
+
inferred: {
|
|
187
|
+
markdown: opts.generateStandards ? generateInferredMarkdown(structure) : '',
|
|
188
|
+
},
|
|
189
|
+
};
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* Safe readdir that returns [] on error.
|
|
194
|
+
* @param {string} dir
|
|
195
|
+
* @returns {string[]}
|
|
196
|
+
*/
|
|
197
|
+
function readdirSyncSafe(dir) {
|
|
198
|
+
try {
|
|
199
|
+
return readdirSync(dir);
|
|
200
|
+
} catch {
|
|
201
|
+
return [];
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Generate a markdown summary from detected structure.
|
|
207
|
+
* @param {Object} structure
|
|
208
|
+
* @returns {string}
|
|
209
|
+
*/
|
|
210
|
+
function generateInferredMarkdown(structure) {
|
|
211
|
+
const lines = [`## Detected Stack: ${structure.stack}`];
|
|
212
|
+
if (structure.architecture) lines.push(`Architecture: ${structure.architecture}`);
|
|
213
|
+
if (structure.uiLibrary) lines.push(`UI Library: ${structure.uiLibrary}`);
|
|
214
|
+
if (structure.integrations.length > 0) lines.push(`Integrations: ${structure.integrations.join(', ')}`);
|
|
215
|
+
if (structure.hasVercel) lines.push(`Vercel: detected`);
|
|
216
|
+
return lines.join('\n');
|
|
217
|
+
}
|
|
@@ -21,14 +21,15 @@
|
|
|
21
21
|
import { join } from 'path';
|
|
22
22
|
|
|
23
23
|
import { loadEnvFile } from '../../../framework/hooks/shared/e2e-check.js';
|
|
24
|
-
import { allocateEnvBlock, resolveFleetConfig } from '../worktree/env-allocator.js';
|
|
24
|
+
import { allocateEnvBlock, portLayout, resolveFleetConfig } from '../worktree/env-allocator.js';
|
|
25
25
|
|
|
26
26
|
/**
|
|
27
27
|
* Build the environment a `docker compose` invocation should carry.
|
|
28
28
|
*
|
|
29
|
-
* Port variables
|
|
30
|
-
*
|
|
31
|
-
*
|
|
29
|
+
* Port variables come from `portLayout()` in env-allocator.js — the SAME
|
|
30
|
+
* function that renders `.morph/worktree.env` — so a compose file and the
|
|
31
|
+
* worktree manifest describe the same world by construction (`PORT`,
|
|
32
|
+
* `API_PORT`/`ASPNETCORE_URLS`, `POSTGRES_PORT`, `COMPOSE_PROJECT_NAME`).
|
|
32
33
|
*
|
|
33
34
|
* `PLAYWRIGHT_BASE_URL` is derived here rather than left to the spec config —
|
|
34
35
|
* the suite has to be told where the app actually landed, otherwise a feature
|
|
@@ -53,14 +54,8 @@ export function buildComposeEnv({ cwd, config, featureName, gitCommonDir }) {
|
|
|
53
54
|
const { basePort, blockSize } = resolveFleetConfig(cwd);
|
|
54
55
|
const block = allocateEnvBlock(featureName, { gitCommonDir, basePort, blockSize });
|
|
55
56
|
|
|
56
|
-
env
|
|
57
|
-
env.MORPH_PORT_RANGE = `${block.range[0]}-${block.range[1]}`;
|
|
58
|
-
env.PORT = String(block.base);
|
|
57
|
+
Object.assign(env, portLayout(block, { stack: config?.project?.stack ?? '', feature: featureName }));
|
|
59
58
|
env.PLAYWRIGHT_BASE_URL = `http://localhost:${block.base}`;
|
|
60
|
-
|
|
61
|
-
if (/\bdotnet\b/i.test(String(config?.project?.stack ?? ''))) {
|
|
62
|
-
env.ASPNETCORE_URLS = `http://localhost:${block.base + 1}`;
|
|
63
|
-
}
|
|
64
59
|
} catch {
|
|
65
60
|
// No block → default ports. Surfaces later as a port conflict, never here.
|
|
66
61
|
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Reclaim a feature's verification stack — containers, ports, named volumes
|
|
3
|
+
* and the freshness stamp — from the tree that owns the feature.
|
|
4
|
+
*
|
|
5
|
+
* `morph-spec verify` leaves the stack UP on purpose (cold-starting a
|
|
6
|
+
* container per task is what makes the loop unusable), so something has to be
|
|
7
|
+
* the one place that brings it down when the feature closes. That place is
|
|
8
|
+
* this module, shared by `finish` and `delete --with-worktree`.
|
|
9
|
+
*
|
|
10
|
+
* Three things the old root-side teardown got wrong, each pinned by a test:
|
|
11
|
+
* 1. It ran from the ROOT. A compose that exists only in the worktree was
|
|
12
|
+
* never found, and a bind mount resolved to the wrong checkout.
|
|
13
|
+
* 2. It passed a bare `COMPOSE_PROJECT_NAME`. Without the port block
|
|
14
|
+
* (`PORT`, `POSTGRES_PORT`, …) compose resolves a different config than
|
|
15
|
+
* the one that came up, and `down` misses the containers.
|
|
16
|
+
* 3. It never removed volumes or the stamp. Postgres data and a stamp saying
|
|
17
|
+
* "source unchanged since build" outlived the feature.
|
|
18
|
+
*
|
|
19
|
+
* Entirely fail-open: no compose, no docker, or a stubborn container are all
|
|
20
|
+
* non-events for the caller — a stack that refuses to come down must never
|
|
21
|
+
* fail a finish that already merged.
|
|
22
|
+
*
|
|
23
|
+
* @module lib/e2e/stack-teardown
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { execFileSync } from 'child_process';
|
|
27
|
+
import { existsSync, readFileSync } from 'fs';
|
|
28
|
+
import { join, resolve } from 'path';
|
|
29
|
+
|
|
30
|
+
import {
|
|
31
|
+
composeProjectName,
|
|
32
|
+
defaultExec,
|
|
33
|
+
deleteStamp,
|
|
34
|
+
resolveComposeFile,
|
|
35
|
+
tearDownStack,
|
|
36
|
+
} from '../../../framework/hooks/shared/e2e-check.js';
|
|
37
|
+
import { buildComposeEnv } from './compose-env.js';
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} feature
|
|
41
|
+
* @param {object} params
|
|
42
|
+
* @param {string} params.root - primary root (absolute)
|
|
43
|
+
* @param {string|null} [params.worktreePath] - the feature's live worktree, if any
|
|
44
|
+
* @param {boolean} [params.volumes=true] - also remove named volumes (`down -v`)
|
|
45
|
+
* @param {Function} [params._exec] - injectable executor (testing)
|
|
46
|
+
* @returns {{ attempted: boolean, cwd: string, status: 'pass'|'fail'|'skip', stampDeleted: boolean, output?: string }}
|
|
47
|
+
*/
|
|
48
|
+
export function tearDownFeatureStack(feature, { root, worktreePath = null, volumes = true, _exec = defaultExec } = {}) {
|
|
49
|
+
const cwd = worktreePath && existsSync(worktreePath) ? worktreePath : root;
|
|
50
|
+
const projectName = composeProjectName(feature);
|
|
51
|
+
const gitCommonDir = resolveGitCommonDir(root);
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const config = readConfig(cwd) ?? readConfig(root);
|
|
55
|
+
|
|
56
|
+
// The owning tree's compose first; the root's copy is the fallback for a
|
|
57
|
+
// worktree already half torn down (versioned file, so equivalent).
|
|
58
|
+
let composeCwd = cwd;
|
|
59
|
+
let composeFile = resolveComposeFile(cwd, config);
|
|
60
|
+
if (!composeFile && cwd !== root) {
|
|
61
|
+
composeCwd = root;
|
|
62
|
+
composeFile = resolveComposeFile(root, config);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!composeFile) {
|
|
66
|
+
// Nothing to bring down — but a stamp left behind would tell the next
|
|
67
|
+
// `up` that a stack it cannot see is still fresh.
|
|
68
|
+
return { attempted: false, cwd, status: 'skip', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const env = {
|
|
72
|
+
COMPOSE_PROJECT_NAME: projectName,
|
|
73
|
+
...buildComposeEnv({ cwd: composeCwd, config, featureName: feature, gitCommonDir }),
|
|
74
|
+
};
|
|
75
|
+
const result = tearDownStack(composeCwd, { composeFile, projectName, env, volumes }, _exec);
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
attempted: true,
|
|
79
|
+
cwd: composeCwd,
|
|
80
|
+
status: result.status,
|
|
81
|
+
stampDeleted: deleteStamp(gitCommonDir, projectName),
|
|
82
|
+
...(result.status === 'fail' && result.output ? { output: result.output } : {}),
|
|
83
|
+
};
|
|
84
|
+
} catch {
|
|
85
|
+
// Unconditional even when the teardown itself blew up: the stamp must not
|
|
86
|
+
// outlive the stack it described.
|
|
87
|
+
return { attempted: false, cwd, status: 'fail', stampDeleted: deleteStamp(gitCommonDir, projectName) };
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** BOM-tolerant config read; null when absent or malformed. */
|
|
92
|
+
function readConfig(dir) {
|
|
93
|
+
try {
|
|
94
|
+
const path = join(dir, '.morph', 'config', 'config.json');
|
|
95
|
+
if (!existsSync(path)) return null;
|
|
96
|
+
return JSON.parse(readFileSync(path, 'utf8').replace(/^/, ''));
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** Same resolution (and same fail-open) as verify-runner's. */
|
|
103
|
+
function resolveGitCommonDir(cwd) {
|
|
104
|
+
try {
|
|
105
|
+
const common = execFileSync('git', ['rev-parse', '--git-common-dir'], {
|
|
106
|
+
cwd,
|
|
107
|
+
stdio: 'pipe',
|
|
108
|
+
encoding: 'utf-8',
|
|
109
|
+
windowsHide: true,
|
|
110
|
+
}).trim();
|
|
111
|
+
return common ? resolve(cwd, common) : null;
|
|
112
|
+
} catch {
|
|
113
|
+
return null;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
@@ -20,8 +20,9 @@ import { fileURLToPath } from 'url';
|
|
|
20
20
|
import { existsSync, readFileSync } from 'fs';
|
|
21
21
|
import { scanGraph } from '../graph/god-nodes.js';
|
|
22
22
|
import { renderTemplate } from '../../core/templates/template-renderer.js';
|
|
23
|
+
import { WORKTREE_DIR_NAMES } from '../worktree/dirs.js';
|
|
23
24
|
|
|
24
|
-
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out',
|
|
25
|
+
export const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', ...WORKTREE_DIR_NAMES]);
|
|
25
26
|
const MAX_FILES_PER_SCANNER = 200;
|
|
26
27
|
|
|
27
28
|
/**
|