@polymorphism-tech/morph-spec 8.1.0 → 8.1.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/README.md +2 -2
- 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/commands/commit.md +166 -166
- package/framework/commands/morph-infra.md +215 -215
- package/framework/commands/morph-preflight.md +205 -205
- package/framework/commands/morph-troubleshoot.md +124 -124
- package/framework/hooks/claude-code/core/validate-completion.js +2 -1
- package/framework/hooks/claude-code/statusline.py +679 -679
- 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/activity-logger.js +160 -160
- package/framework/hooks/shared/build-check.js +97 -24
- package/framework/hooks/shared/stdin-reader.js +26 -26
- package/framework/rubrics/backend.json +41 -41
- package/framework/rules/ai-agents-standards.md +45 -45
- package/framework/rules/dotnet-templates.md +86 -86
- package/framework/rules/frontend-standards.md +13 -13
- package/framework/rules/infrastructure-standards.md +27 -27
- package/framework/rules/neon-standards.md +25 -25
- package/framework/rules/nextjs-standards.md +20 -20
- package/framework/rules/nextjs-templates.md +40 -40
- 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 +1 -1
- 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 +1 -1
- package/framework/skills/core/morph-plan/SKILL.md +1 -1
- 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/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 +275 -275
- 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/templates/IDE-SNIPPETS.md +262 -262
- 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 +50 -50
- 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/index.js +13 -13
- package/src/commands/mcp/mcp-setup.js +519 -519
- package/src/commands/project/checkpoint.js +209 -209
- package/src/commands/project/worktree.js +83 -1
- 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 +418 -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 +41 -41
- 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/scanner/standards-scanner.js +26 -26
- package/src/dashboard/vite-plugin-morph-data.js +17 -17
- package/src/dashboard/vite.config.js +24 -24
- package/src/lib/agents/metrics-aggregator.js +56 -56
- package/src/lib/agents/metrics-calculator.js +125 -125
- package/src/lib/agents/output-collector.js +73 -73
- package/src/lib/detectors/claude-config-detector.js +152 -152
- package/src/lib/detectors/design-system-detector.js +231 -231
- package/src/lib/detectors/index.js +217 -217
- package/src/lib/generators/architecture-scanner.js +639 -639
- package/src/lib/generators/design-system-generator.js +508 -508
- package/src/lib/generators/index.js +13 -13
- package/src/lib/generators/settings-generator.js +209 -209
- 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/validators/architecture/architecture-validator.js +259 -259
- package/src/lib/validators/checkpoint-persistence.js +39 -39
- package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
- package/src/lib/validators/css/css-validator.js +340 -340
- package/src/lib/validators/packages/package-validator.js +347 -347
- 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/validation-runner.js +349 -349
- package/src/lib/validators/verify-runner.js +24 -7
- package/src/utils/agent-id-mapper.js +11 -11
- package/src/utils/claude-settings-manager.js +300 -300
- package/src/utils/skills-installer.js +174 -174
- package/framework/templates/.idea/morph-templates.xml +0 -92
- package/framework/templates/.vscode/morph-templates.code-snippets +0 -186
|
@@ -1,639 +1,639 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Architecture Scanner - Generates rich architectural context from project filesystem.
|
|
3
|
-
*
|
|
4
|
-
* Performs fast (<5s) glob-based scanning to produce a snapshot of:
|
|
5
|
-
* - .NET entities (sealed class/record)
|
|
6
|
-
* - API endpoints (IApiEndpoint implementations)
|
|
7
|
-
* - Next.js pages and components
|
|
8
|
-
* - Integrations (from appsettings.json)
|
|
9
|
-
* - Database schema (from EF migrations)
|
|
10
|
-
* - Stack dependencies (.csproj + package.json)
|
|
11
|
-
*
|
|
12
|
-
* All scanning is regex-based (no AST parsing) for speed.
|
|
13
|
-
*
|
|
14
|
-
* @module architecture-scanner
|
|
15
|
-
*/
|
|
16
|
-
|
|
17
|
-
import fs from 'fs/promises';
|
|
18
|
-
import path from 'path';
|
|
19
|
-
import { existsSync, readFileSync } from 'fs';
|
|
20
|
-
import { renderTemplate } from '../../core/templates/template-renderer.js';
|
|
21
|
-
|
|
22
|
-
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', '.worktrees']);
|
|
23
|
-
const MAX_FILES_PER_SCANNER = 200;
|
|
24
|
-
|
|
25
|
-
/**
|
|
26
|
-
* Recursively walk a directory, returning files matching a filter.
|
|
27
|
-
* Skips known heavy directories for performance.
|
|
28
|
-
*
|
|
29
|
-
* @param {string} dir - Directory to walk
|
|
30
|
-
* @param {function} filter - (relativePath) => boolean
|
|
31
|
-
* @param {string} [base] - Base path for relative calculation
|
|
32
|
-
* @param {number} [limit] - Max files to return
|
|
33
|
-
* @returns {Promise<Array<{abs: string, rel: string}>>}
|
|
34
|
-
*/
|
|
35
|
-
async function walkDir(dir, filter, base = dir, limit = MAX_FILES_PER_SCANNER) {
|
|
36
|
-
const results = [];
|
|
37
|
-
|
|
38
|
-
async function recurse(currentDir) {
|
|
39
|
-
if (results.length >= limit) return;
|
|
40
|
-
|
|
41
|
-
let entries;
|
|
42
|
-
try {
|
|
43
|
-
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
44
|
-
} catch {
|
|
45
|
-
return;
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
for (const entry of entries) {
|
|
49
|
-
if (results.length >= limit) break;
|
|
50
|
-
|
|
51
|
-
if (entry.isDirectory()) {
|
|
52
|
-
if (SKIP_DIRS.has(entry.name)) continue;
|
|
53
|
-
await recurse(path.join(currentDir, entry.name));
|
|
54
|
-
} else if (entry.isFile()) {
|
|
55
|
-
const abs = path.join(currentDir, entry.name);
|
|
56
|
-
const rel = path.relative(base, abs).replace(/\\/g, '/');
|
|
57
|
-
if (filter(rel, entry.name)) {
|
|
58
|
-
results.push({ abs, rel });
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
await recurse(dir);
|
|
65
|
-
return results;
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
/**
|
|
69
|
-
* Scan .NET entities from Entities/ or Features/ directories.
|
|
70
|
-
*
|
|
71
|
-
* @param {string} projectPath
|
|
72
|
-
* @returns {Promise<Array<{name: string, type: string, path: string}>>}
|
|
73
|
-
*/
|
|
74
|
-
export async function scanEntities(projectPath) {
|
|
75
|
-
try {
|
|
76
|
-
const files = await walkDir(
|
|
77
|
-
projectPath,
|
|
78
|
-
(rel, name) => name.endsWith('.cs') && (rel.includes('Entities/') || rel.includes('Features/') || rel.includes('Models/'))
|
|
79
|
-
);
|
|
80
|
-
|
|
81
|
-
const entities = [];
|
|
82
|
-
const entityRegex = /(?:public\s+)?(?:sealed\s+)?(class|record)\s+(\w+)/g;
|
|
83
|
-
|
|
84
|
-
for (const file of files) {
|
|
85
|
-
try {
|
|
86
|
-
const content = await fs.readFile(file.abs, 'utf8');
|
|
87
|
-
let match;
|
|
88
|
-
entityRegex.lastIndex = 0;
|
|
89
|
-
while ((match = entityRegex.exec(content)) !== null) {
|
|
90
|
-
const line = content.slice(Math.max(0, match.index - 80), match.index + match[0].length);
|
|
91
|
-
// Only capture sealed/record types or classes in entity-like directories
|
|
92
|
-
if (/sealed|record/.test(line) || file.rel.includes('Entities/')) {
|
|
93
|
-
entities.push({
|
|
94
|
-
name: match[2],
|
|
95
|
-
type: match[1],
|
|
96
|
-
path: file.rel
|
|
97
|
-
});
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
} catch { /* skip unreadable files */ }
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
return entities;
|
|
104
|
-
} catch {
|
|
105
|
-
return [];
|
|
106
|
-
}
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
/**
|
|
110
|
-
* Scan API endpoints implementing IApiEndpoint.
|
|
111
|
-
*
|
|
112
|
-
* @param {string} projectPath
|
|
113
|
-
* @returns {Promise<Array<{name: string, route: string, method: string, path: string}>>}
|
|
114
|
-
*/
|
|
115
|
-
export async function scanEndpoints(projectPath) {
|
|
116
|
-
try {
|
|
117
|
-
const files = await walkDir(
|
|
118
|
-
projectPath,
|
|
119
|
-
(rel, name) => name.endsWith('.cs') && (rel.includes('Endpoints/') || rel.includes('Features/'))
|
|
120
|
-
);
|
|
121
|
-
|
|
122
|
-
const endpoints = [];
|
|
123
|
-
const classRegex = /class\s+(\w+)[^{]*(?:IApiEndpoint|IEndpoint|Endpoint)/;
|
|
124
|
-
const routeRegex = /Route\s*(?:=>|=)\s*"([^"]+)"/;
|
|
125
|
-
const methodRegex = /(?:Http(Get|Post|Put|Delete|Patch))|(?:\.Map(Get|Post|Put|Delete|Patch))/i;
|
|
126
|
-
|
|
127
|
-
for (const file of files) {
|
|
128
|
-
try {
|
|
129
|
-
const content = await fs.readFile(file.abs, 'utf8');
|
|
130
|
-
const classMatch = classRegex.exec(content);
|
|
131
|
-
if (!classMatch) continue;
|
|
132
|
-
|
|
133
|
-
const routeMatch = routeRegex.exec(content);
|
|
134
|
-
const methodMatch = methodRegex.exec(content);
|
|
135
|
-
|
|
136
|
-
endpoints.push({
|
|
137
|
-
name: classMatch[1],
|
|
138
|
-
route: routeMatch ? routeMatch[1] : '(unknown)',
|
|
139
|
-
method: methodMatch ? (methodMatch[1] || methodMatch[2] || '').toUpperCase() : '',
|
|
140
|
-
path: file.rel
|
|
141
|
-
});
|
|
142
|
-
} catch { /* skip */ }
|
|
143
|
-
}
|
|
144
|
-
|
|
145
|
-
return endpoints;
|
|
146
|
-
} catch {
|
|
147
|
-
return [];
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
/**
|
|
152
|
-
* Scan Next.js pages and components.
|
|
153
|
-
*
|
|
154
|
-
* @param {string} projectPath
|
|
155
|
-
* @returns {Promise<{pages: Array<{route: string, path: string}>, layouts: Array<{path: string}>, components: Array<{name: string, path: string}>}>}
|
|
156
|
-
*/
|
|
157
|
-
export async function scanNextjsComponents(projectPath) {
|
|
158
|
-
try {
|
|
159
|
-
const result = { pages: [], layouts: [], components: [] };
|
|
160
|
-
|
|
161
|
-
// Scan pages and layouts from app directory
|
|
162
|
-
const appFiles = await walkDir(
|
|
163
|
-
projectPath,
|
|
164
|
-
(rel, name) =>
|
|
165
|
-
(name === 'page.tsx' || name === 'page.jsx' || name === 'layout.tsx' || name === 'layout.jsx') &&
|
|
166
|
-
(rel.startsWith('app/') || rel.startsWith('src/app/'))
|
|
167
|
-
);
|
|
168
|
-
|
|
169
|
-
for (const file of appFiles) {
|
|
170
|
-
const isLayout = file.rel.includes('layout.');
|
|
171
|
-
const routeDir = path.dirname(file.rel).replace(/^(?:src\/)?app/, '').replace(/\\/g, '/');
|
|
172
|
-
const route = routeDir || '/';
|
|
173
|
-
|
|
174
|
-
if (isLayout) {
|
|
175
|
-
result.layouts.push({ path: file.rel });
|
|
176
|
-
} else {
|
|
177
|
-
result.pages.push({ route: route === '' ? '/' : route, path: file.rel });
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
// Scan components
|
|
182
|
-
const componentFiles = await walkDir(
|
|
183
|
-
projectPath,
|
|
184
|
-
(rel, name) =>
|
|
185
|
-
(name.endsWith('.tsx') || name.endsWith('.jsx')) &&
|
|
186
|
-
(rel.startsWith('src/components/') || rel.startsWith('components/') || rel.includes('/components/'))
|
|
187
|
-
);
|
|
188
|
-
|
|
189
|
-
for (const file of componentFiles) {
|
|
190
|
-
const name = path.basename(file.rel, path.extname(file.rel));
|
|
191
|
-
// Skip index files and test files
|
|
192
|
-
if (name === 'index' || name.includes('.test') || name.includes('.spec')) continue;
|
|
193
|
-
result.components.push({ name, path: file.rel });
|
|
194
|
-
}
|
|
195
|
-
|
|
196
|
-
return result;
|
|
197
|
-
} catch {
|
|
198
|
-
return { pages: [], layouts: [], components: [] };
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* Scan integrations from appsettings*.json files.
|
|
204
|
-
*
|
|
205
|
-
* @param {string} projectPath
|
|
206
|
-
* @returns {Promise<Array<{name: string, type: string, configured: boolean}>>}
|
|
207
|
-
*/
|
|
208
|
-
export async function scanIntegrations(projectPath) {
|
|
209
|
-
try {
|
|
210
|
-
const files = await walkDir(
|
|
211
|
-
projectPath,
|
|
212
|
-
(rel, name) => /^appsettings(?:\.\w+)?\.json$/.test(name),
|
|
213
|
-
projectPath,
|
|
214
|
-
20
|
|
215
|
-
);
|
|
216
|
-
|
|
217
|
-
// Also check .env files for common integrations
|
|
218
|
-
const envFiles = await walkDir(
|
|
219
|
-
projectPath,
|
|
220
|
-
(rel, name) => name === '.env' || name === '.env.local' || name === '.env.production',
|
|
221
|
-
projectPath,
|
|
222
|
-
5
|
|
223
|
-
);
|
|
224
|
-
|
|
225
|
-
const knownServices = {
|
|
226
|
-
'NeonAuth': 'auth',
|
|
227
|
-
'AsaaS': 'payment',
|
|
228
|
-
'Resend': 'email',
|
|
229
|
-
'Hangfire': 'background-jobs',
|
|
230
|
-
'Neon': 'database',
|
|
231
|
-
'ConnectionStrings': 'database',
|
|
232
|
-
'Redis': 'cache',
|
|
233
|
-
'SendGrid': 'email',
|
|
234
|
-
'Stripe': 'payment',
|
|
235
|
-
'AzureAd': 'auth',
|
|
236
|
-
'OpenAI': 'ai',
|
|
237
|
-
'AzureOpenAI': 'ai'
|
|
238
|
-
};
|
|
239
|
-
|
|
240
|
-
const detected = new Map();
|
|
241
|
-
|
|
242
|
-
// Scan appsettings files
|
|
243
|
-
for (const file of files) {
|
|
244
|
-
try {
|
|
245
|
-
const content = JSON.parse(await fs.readFile(file.abs, 'utf8'));
|
|
246
|
-
for (const [service, type] of Object.entries(knownServices)) {
|
|
247
|
-
if (content[service] || content[service.toLowerCase()]) {
|
|
248
|
-
const hasValues = content[service] && typeof content[service] === 'object'
|
|
249
|
-
? Object.values(content[service]).some(v => v && v !== '' && !String(v).startsWith('{{'))
|
|
250
|
-
: true;
|
|
251
|
-
if (!detected.has(service) || hasValues) {
|
|
252
|
-
detected.set(service, { name: service, type, configured: hasValues });
|
|
253
|
-
}
|
|
254
|
-
}
|
|
255
|
-
}
|
|
256
|
-
// Check ConnectionStrings specifically
|
|
257
|
-
if (content.ConnectionStrings) {
|
|
258
|
-
for (const [key, val] of Object.entries(content.ConnectionStrings)) {
|
|
259
|
-
if (val && !String(val).startsWith('{{')) {
|
|
260
|
-
detected.set(`DB:${key}`, { name: `ConnectionString:${key}`, type: 'database', configured: true });
|
|
261
|
-
}
|
|
262
|
-
}
|
|
263
|
-
}
|
|
264
|
-
} catch { /* skip invalid JSON */ }
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Scan .env files for known prefixes
|
|
268
|
-
const envPrefixes = {
|
|
269
|
-
'NEON_AUTH_': 'NeonAuth',
|
|
270
|
-
'DATABASE_URL': 'Neon',
|
|
271
|
-
'NEON_': 'Neon',
|
|
272
|
-
'RESEND_': 'Resend',
|
|
273
|
-
'STRIPE_': 'Stripe',
|
|
274
|
-
'OPENAI_': 'OpenAI',
|
|
275
|
-
'ASAAS_': 'AsaaS'
|
|
276
|
-
};
|
|
277
|
-
|
|
278
|
-
for (const file of envFiles) {
|
|
279
|
-
try {
|
|
280
|
-
const content = await fs.readFile(file.abs, 'utf8');
|
|
281
|
-
for (const [prefix, service] of Object.entries(envPrefixes)) {
|
|
282
|
-
if (content.includes(prefix)) {
|
|
283
|
-
const type = knownServices[service] || 'integration';
|
|
284
|
-
if (!detected.has(service)) {
|
|
285
|
-
detected.set(service, { name: service, type, configured: true });
|
|
286
|
-
}
|
|
287
|
-
}
|
|
288
|
-
}
|
|
289
|
-
} catch { /* skip */ }
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return Array.from(detected.values());
|
|
293
|
-
} catch {
|
|
294
|
-
return [];
|
|
295
|
-
}
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
/**
|
|
299
|
-
* Scan database schema from EF Core migrations.
|
|
300
|
-
*
|
|
301
|
-
* @param {string} projectPath
|
|
302
|
-
* @returns {Promise<Array<{table: string, migration: string}>>}
|
|
303
|
-
*/
|
|
304
|
-
export async function scanDatabaseSchema(projectPath) {
|
|
305
|
-
try {
|
|
306
|
-
const files = await walkDir(
|
|
307
|
-
projectPath,
|
|
308
|
-
(rel, name) => name.endsWith('.cs') && rel.includes('Migrations/') && !name.endsWith('.Designer.cs'),
|
|
309
|
-
projectPath,
|
|
310
|
-
100
|
|
311
|
-
);
|
|
312
|
-
|
|
313
|
-
const tables = new Map();
|
|
314
|
-
const toTableRegex = /\.ToTable\("(\w+)"/g;
|
|
315
|
-
const createTableRegex = /migrationBuilder\.CreateTable\(\s*name:\s*"(\w+)"/g;
|
|
316
|
-
|
|
317
|
-
for (const file of files) {
|
|
318
|
-
try {
|
|
319
|
-
const content = await fs.readFile(file.abs, 'utf8');
|
|
320
|
-
const migrationName = path.basename(file.rel, '.cs');
|
|
321
|
-
|
|
322
|
-
for (const regex of [toTableRegex, createTableRegex]) {
|
|
323
|
-
regex.lastIndex = 0;
|
|
324
|
-
let match;
|
|
325
|
-
while ((match = regex.exec(content)) !== null) {
|
|
326
|
-
if (!tables.has(match[1])) {
|
|
327
|
-
tables.set(match[1], { table: match[1], migration: migrationName });
|
|
328
|
-
}
|
|
329
|
-
}
|
|
330
|
-
}
|
|
331
|
-
} catch { /* skip */ }
|
|
332
|
-
}
|
|
333
|
-
|
|
334
|
-
// Also scan database migrations (SQL)
|
|
335
|
-
const sqlFiles = await walkDir(
|
|
336
|
-
projectPath,
|
|
337
|
-
(rel, name) => name.endsWith('.sql') && rel.includes('migrations/'),
|
|
338
|
-
projectPath,
|
|
339
|
-
100
|
|
340
|
-
);
|
|
341
|
-
|
|
342
|
-
const createSqlRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/gi;
|
|
343
|
-
|
|
344
|
-
for (const file of sqlFiles) {
|
|
345
|
-
try {
|
|
346
|
-
const content = await fs.readFile(file.abs, 'utf8');
|
|
347
|
-
const migrationName = path.basename(file.rel, '.sql');
|
|
348
|
-
createSqlRegex.lastIndex = 0;
|
|
349
|
-
let match;
|
|
350
|
-
while ((match = createSqlRegex.exec(content)) !== null) {
|
|
351
|
-
if (!tables.has(match[1])) {
|
|
352
|
-
tables.set(match[1], { table: match[1], migration: migrationName });
|
|
353
|
-
}
|
|
354
|
-
}
|
|
355
|
-
} catch { /* skip */ }
|
|
356
|
-
}
|
|
357
|
-
|
|
358
|
-
return Array.from(tables.values());
|
|
359
|
-
} catch {
|
|
360
|
-
return [];
|
|
361
|
-
}
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
/**
|
|
365
|
-
* Detect stack from .csproj PackageReference and package.json dependencies.
|
|
366
|
-
*
|
|
367
|
-
* @param {string} projectPath
|
|
368
|
-
* @returns {Promise<{dotnet: Array<{name: string, version: string}>, npm: Array<{name: string, version: string}>}>}
|
|
369
|
-
*/
|
|
370
|
-
export async function scanStackDependencies(projectPath) {
|
|
371
|
-
try {
|
|
372
|
-
const result = { dotnet: [], npm: [] };
|
|
373
|
-
|
|
374
|
-
// Scan .csproj files
|
|
375
|
-
const csprojFiles = await walkDir(
|
|
376
|
-
projectPath,
|
|
377
|
-
(rel, name) => name.endsWith('.csproj'),
|
|
378
|
-
projectPath,
|
|
379
|
-
10
|
|
380
|
-
);
|
|
381
|
-
|
|
382
|
-
const packageRefRegex = /<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/g;
|
|
383
|
-
|
|
384
|
-
for (const file of csprojFiles) {
|
|
385
|
-
try {
|
|
386
|
-
const content = await fs.readFile(file.abs, 'utf8');
|
|
387
|
-
packageRefRegex.lastIndex = 0;
|
|
388
|
-
let match;
|
|
389
|
-
while ((match = packageRefRegex.exec(content)) !== null) {
|
|
390
|
-
result.dotnet.push({ name: match[1], version: match[2] });
|
|
391
|
-
}
|
|
392
|
-
} catch { /* skip */ }
|
|
393
|
-
}
|
|
394
|
-
|
|
395
|
-
// Scan package.json (root only)
|
|
396
|
-
const pkgPath = path.join(projectPath, 'package.json');
|
|
397
|
-
if (existsSync(pkgPath)) {
|
|
398
|
-
try {
|
|
399
|
-
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
|
|
400
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
401
|
-
for (const [name, version] of Object.entries(allDeps)) {
|
|
402
|
-
result.npm.push({ name, version: String(version) });
|
|
403
|
-
}
|
|
404
|
-
} catch { /* skip */ }
|
|
405
|
-
}
|
|
406
|
-
|
|
407
|
-
// Also check web/ or frontend/ subdirectory package.json
|
|
408
|
-
for (const subdir of ['web', 'frontend', 'client', 'src']) {
|
|
409
|
-
const subPkgPath = path.join(projectPath, subdir, 'package.json');
|
|
410
|
-
if (existsSync(subPkgPath)) {
|
|
411
|
-
try {
|
|
412
|
-
const pkg = JSON.parse(await fs.readFile(subPkgPath, 'utf8'));
|
|
413
|
-
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
414
|
-
for (const [name, version] of Object.entries(allDeps)) {
|
|
415
|
-
// Avoid duplicates
|
|
416
|
-
if (!result.npm.some(d => d.name === name)) {
|
|
417
|
-
result.npm.push({ name, version: String(version) });
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
} catch { /* skip */ }
|
|
421
|
-
}
|
|
422
|
-
}
|
|
423
|
-
|
|
424
|
-
return result;
|
|
425
|
-
} catch {
|
|
426
|
-
return { dotnet: [], npm: [] };
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
|
|
430
|
-
/**
|
|
431
|
-
* Master scan — runs all scanners in parallel.
|
|
432
|
-
*
|
|
433
|
-
* @param {string} projectPath
|
|
434
|
-
* @returns {Promise<Object>} ArchitectureScanResult
|
|
435
|
-
*/
|
|
436
|
-
export async function scanArchitecture(projectPath) {
|
|
437
|
-
const [entities, endpoints, nextjs, integrations, database, stack] = await Promise.all([
|
|
438
|
-
scanEntities(projectPath),
|
|
439
|
-
scanEndpoints(projectPath),
|
|
440
|
-
scanNextjsComponents(projectPath),
|
|
441
|
-
scanIntegrations(projectPath),
|
|
442
|
-
scanDatabaseSchema(projectPath),
|
|
443
|
-
scanStackDependencies(projectPath)
|
|
444
|
-
]);
|
|
445
|
-
|
|
446
|
-
return {
|
|
447
|
-
timestamp: new Date().toISOString(),
|
|
448
|
-
entities,
|
|
449
|
-
endpoints,
|
|
450
|
-
nextjs,
|
|
451
|
-
integrations,
|
|
452
|
-
database,
|
|
453
|
-
stack
|
|
454
|
-
};
|
|
455
|
-
}
|
|
456
|
-
|
|
457
|
-
/**
|
|
458
|
-
* Resolve a template path: check .morph/framework/templates/ first, then framework/templates/,
|
|
459
|
-
* then fall back to the CLI package's own framework/templates/.
|
|
460
|
-
*
|
|
461
|
-
* @param {string} projectPath
|
|
462
|
-
* @param {string} templateRelPath
|
|
463
|
-
* @returns {string|null}
|
|
464
|
-
*/
|
|
465
|
-
function resolveTemplatePath(projectPath, templateRelPath) {
|
|
466
|
-
// 1. Project-local override
|
|
467
|
-
const projectLocal = path.join(projectPath, '.morph/framework/templates', templateRelPath);
|
|
468
|
-
if (existsSync(projectLocal)) return projectLocal;
|
|
469
|
-
|
|
470
|
-
// 2. CLI package framework/ (for running from source or during init)
|
|
471
|
-
const cliFramework = path.join(
|
|
472
|
-
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')),
|
|
473
|
-
'..', '..', '..', 'framework', 'templates', templateRelPath
|
|
474
|
-
);
|
|
475
|
-
if (existsSync(cliFramework)) return cliFramework;
|
|
476
|
-
|
|
477
|
-
return null;
|
|
478
|
-
}
|
|
479
|
-
|
|
480
|
-
/**
|
|
481
|
-
* Render architecture context to .morph/context/README.md
|
|
482
|
-
*
|
|
483
|
-
* @param {string} projectPath
|
|
484
|
-
* @param {Object} scanResult - Result from scanArchitecture()
|
|
485
|
-
* @param {Object} [options]
|
|
486
|
-
* @param {string} [options.projectName] - Override project name
|
|
487
|
-
* @returns {Promise<string>} Path to written file
|
|
488
|
-
*/
|
|
489
|
-
export async function renderArchitectureContext(projectPath, scanResult, options = {}) {
|
|
490
|
-
const templatePath = resolveTemplatePath(projectPath, 'context/ARCHITECTURE.md');
|
|
491
|
-
|
|
492
|
-
// Determine project name
|
|
493
|
-
let projectName = options.projectName;
|
|
494
|
-
if (!projectName) {
|
|
495
|
-
// Try config.json
|
|
496
|
-
const configPath = path.join(projectPath, '.morph/config/config.json');
|
|
497
|
-
if (existsSync(configPath)) {
|
|
498
|
-
try {
|
|
499
|
-
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
500
|
-
projectName = config.project?.name;
|
|
501
|
-
} catch { /* ignore */ }
|
|
502
|
-
}
|
|
503
|
-
// Fallback to directory name
|
|
504
|
-
if (!projectName) {
|
|
505
|
-
projectName = path.basename(projectPath);
|
|
506
|
-
}
|
|
507
|
-
}
|
|
508
|
-
|
|
509
|
-
// Get morph-spec version
|
|
510
|
-
let morphVersion = 'unknown';
|
|
511
|
-
try {
|
|
512
|
-
const pkgPath = path.join(
|
|
513
|
-
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')),
|
|
514
|
-
'..', '..', '..', 'package.json'
|
|
515
|
-
);
|
|
516
|
-
if (existsSync(pkgPath)) {
|
|
517
|
-
morphVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
518
|
-
}
|
|
519
|
-
} catch { /* ignore */ }
|
|
520
|
-
|
|
521
|
-
const variables = {
|
|
522
|
-
projectName,
|
|
523
|
-
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0],
|
|
524
|
-
morphVersion,
|
|
525
|
-
// Entities
|
|
526
|
-
hasDotnet: scanResult.entities.length > 0 || scanResult.endpoints.length > 0,
|
|
527
|
-
entities: scanResult.entities,
|
|
528
|
-
entityCount: scanResult.entities.length,
|
|
529
|
-
endpoints: scanResult.endpoints,
|
|
530
|
-
endpointCount: scanResult.endpoints.length,
|
|
531
|
-
// Next.js
|
|
532
|
-
hasNextjs: scanResult.nextjs.pages.length > 0 || scanResult.nextjs.components.length > 0,
|
|
533
|
-
pages: scanResult.nextjs.pages,
|
|
534
|
-
pageCount: scanResult.nextjs.pages.length,
|
|
535
|
-
layouts: scanResult.nextjs.layouts,
|
|
536
|
-
components: scanResult.nextjs.components,
|
|
537
|
-
componentCount: scanResult.nextjs.components.length,
|
|
538
|
-
// Integrations
|
|
539
|
-
hasIntegrations: scanResult.integrations.length > 0,
|
|
540
|
-
integrations: scanResult.integrations,
|
|
541
|
-
// Database
|
|
542
|
-
hasTables: scanResult.database.length > 0,
|
|
543
|
-
tables: scanResult.database,
|
|
544
|
-
tableCount: scanResult.database.length,
|
|
545
|
-
// Stack
|
|
546
|
-
hasDotnetPackages: scanResult.stack.dotnet.length > 0,
|
|
547
|
-
dotnetPackages: scanResult.stack.dotnet,
|
|
548
|
-
hasNpmPackages: scanResult.stack.npm.length > 0,
|
|
549
|
-
npmPackages: scanResult.stack.npm
|
|
550
|
-
};
|
|
551
|
-
|
|
552
|
-
let content;
|
|
553
|
-
if (templatePath) {
|
|
554
|
-
content = renderTemplate(templatePath, variables);
|
|
555
|
-
} else {
|
|
556
|
-
// Fallback: generate minimal markdown without template
|
|
557
|
-
content = generateFallbackMarkdown(variables);
|
|
558
|
-
}
|
|
559
|
-
|
|
560
|
-
// Write to .morph/context/README.md
|
|
561
|
-
const contextDir = path.join(projectPath, '.morph/context');
|
|
562
|
-
await fs.mkdir(contextDir, { recursive: true });
|
|
563
|
-
const outputPath = path.join(contextDir, 'README.md');
|
|
564
|
-
await fs.writeFile(outputPath, content, 'utf8');
|
|
565
|
-
|
|
566
|
-
return outputPath;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
/**
|
|
570
|
-
* Generate minimal markdown when template is not available.
|
|
571
|
-
* @param {Object} vars
|
|
572
|
-
* @returns {string}
|
|
573
|
-
*/
|
|
574
|
-
function generateFallbackMarkdown(vars) {
|
|
575
|
-
const lines = [`# ${vars.projectName} — Architecture Context\n`];
|
|
576
|
-
lines.push(`> Auto-generated by morph-spec context refresh`);
|
|
577
|
-
lines.push(`> Last updated: ${vars.timestamp}\n`);
|
|
578
|
-
|
|
579
|
-
if (vars.hasDotnet) {
|
|
580
|
-
lines.push('## .NET Entities\n');
|
|
581
|
-
for (const e of vars.entities) {
|
|
582
|
-
lines.push(`- \`${e.name}\` (${e.type}) — ${e.path}`);
|
|
583
|
-
}
|
|
584
|
-
lines.push('\n## API Endpoints\n');
|
|
585
|
-
for (const ep of vars.endpoints) {
|
|
586
|
-
lines.push(`- ${ep.method ? ep.method + ' ' : ''}\`${ep.route}\` — ${ep.name} (${ep.path})`);
|
|
587
|
-
}
|
|
588
|
-
lines.push('');
|
|
589
|
-
}
|
|
590
|
-
|
|
591
|
-
if (vars.hasNextjs) {
|
|
592
|
-
lines.push('## Next.js Pages\n');
|
|
593
|
-
for (const p of vars.pages) {
|
|
594
|
-
lines.push(`- \`${p.route}\` — ${p.path}`);
|
|
595
|
-
}
|
|
596
|
-
if (vars.components.length > 0) {
|
|
597
|
-
lines.push('\n## Components\n');
|
|
598
|
-
for (const c of vars.components) {
|
|
599
|
-
lines.push(`- \`${c.name}\` — ${c.path}`);
|
|
600
|
-
}
|
|
601
|
-
}
|
|
602
|
-
lines.push('');
|
|
603
|
-
}
|
|
604
|
-
|
|
605
|
-
if (vars.hasIntegrations) {
|
|
606
|
-
lines.push('## Integrations\n');
|
|
607
|
-
for (const i of vars.integrations) {
|
|
608
|
-
lines.push(`- **${i.name}** (${i.type}) — ${i.configured ? 'configured' : 'not configured'}`);
|
|
609
|
-
}
|
|
610
|
-
lines.push('');
|
|
611
|
-
}
|
|
612
|
-
|
|
613
|
-
if (vars.hasTables) {
|
|
614
|
-
lines.push('## Database Tables\n');
|
|
615
|
-
for (const t of vars.tables) {
|
|
616
|
-
lines.push(`- \`${t.table}\` (from ${t.migration})`);
|
|
617
|
-
}
|
|
618
|
-
lines.push('');
|
|
619
|
-
}
|
|
620
|
-
|
|
621
|
-
if (vars.hasDotnetPackages) {
|
|
622
|
-
lines.push('## .NET Packages\n');
|
|
623
|
-
for (const p of vars.dotnetPackages) {
|
|
624
|
-
lines.push(`- \`${p.name}\` ${p.version}`);
|
|
625
|
-
}
|
|
626
|
-
lines.push('');
|
|
627
|
-
}
|
|
628
|
-
|
|
629
|
-
if (vars.hasNpmPackages) {
|
|
630
|
-
lines.push('## NPM Packages\n');
|
|
631
|
-
for (const p of vars.npmPackages) {
|
|
632
|
-
lines.push(`- \`${p.name}\` ${p.version}`);
|
|
633
|
-
}
|
|
634
|
-
lines.push('');
|
|
635
|
-
}
|
|
636
|
-
|
|
637
|
-
lines.push(`---\n*Generated by morph-spec v${vars.morphVersion}*`);
|
|
638
|
-
return lines.join('\n');
|
|
639
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Architecture Scanner - Generates rich architectural context from project filesystem.
|
|
3
|
+
*
|
|
4
|
+
* Performs fast (<5s) glob-based scanning to produce a snapshot of:
|
|
5
|
+
* - .NET entities (sealed class/record)
|
|
6
|
+
* - API endpoints (IApiEndpoint implementations)
|
|
7
|
+
* - Next.js pages and components
|
|
8
|
+
* - Integrations (from appsettings.json)
|
|
9
|
+
* - Database schema (from EF migrations)
|
|
10
|
+
* - Stack dependencies (.csproj + package.json)
|
|
11
|
+
*
|
|
12
|
+
* All scanning is regex-based (no AST parsing) for speed.
|
|
13
|
+
*
|
|
14
|
+
* @module architecture-scanner
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import fs from 'fs/promises';
|
|
18
|
+
import path from 'path';
|
|
19
|
+
import { existsSync, readFileSync } from 'fs';
|
|
20
|
+
import { renderTemplate } from '../../core/templates/template-renderer.js';
|
|
21
|
+
|
|
22
|
+
const SKIP_DIRS = new Set(['node_modules', 'bin', 'obj', '.git', '.morph', '.next', 'dist', 'out', '.worktrees']);
|
|
23
|
+
const MAX_FILES_PER_SCANNER = 200;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Recursively walk a directory, returning files matching a filter.
|
|
27
|
+
* Skips known heavy directories for performance.
|
|
28
|
+
*
|
|
29
|
+
* @param {string} dir - Directory to walk
|
|
30
|
+
* @param {function} filter - (relativePath) => boolean
|
|
31
|
+
* @param {string} [base] - Base path for relative calculation
|
|
32
|
+
* @param {number} [limit] - Max files to return
|
|
33
|
+
* @returns {Promise<Array<{abs: string, rel: string}>>}
|
|
34
|
+
*/
|
|
35
|
+
async function walkDir(dir, filter, base = dir, limit = MAX_FILES_PER_SCANNER) {
|
|
36
|
+
const results = [];
|
|
37
|
+
|
|
38
|
+
async function recurse(currentDir) {
|
|
39
|
+
if (results.length >= limit) return;
|
|
40
|
+
|
|
41
|
+
let entries;
|
|
42
|
+
try {
|
|
43
|
+
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
44
|
+
} catch {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
for (const entry of entries) {
|
|
49
|
+
if (results.length >= limit) break;
|
|
50
|
+
|
|
51
|
+
if (entry.isDirectory()) {
|
|
52
|
+
if (SKIP_DIRS.has(entry.name)) continue;
|
|
53
|
+
await recurse(path.join(currentDir, entry.name));
|
|
54
|
+
} else if (entry.isFile()) {
|
|
55
|
+
const abs = path.join(currentDir, entry.name);
|
|
56
|
+
const rel = path.relative(base, abs).replace(/\\/g, '/');
|
|
57
|
+
if (filter(rel, entry.name)) {
|
|
58
|
+
results.push({ abs, rel });
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
await recurse(dir);
|
|
65
|
+
return results;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Scan .NET entities from Entities/ or Features/ directories.
|
|
70
|
+
*
|
|
71
|
+
* @param {string} projectPath
|
|
72
|
+
* @returns {Promise<Array<{name: string, type: string, path: string}>>}
|
|
73
|
+
*/
|
|
74
|
+
export async function scanEntities(projectPath) {
|
|
75
|
+
try {
|
|
76
|
+
const files = await walkDir(
|
|
77
|
+
projectPath,
|
|
78
|
+
(rel, name) => name.endsWith('.cs') && (rel.includes('Entities/') || rel.includes('Features/') || rel.includes('Models/'))
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const entities = [];
|
|
82
|
+
const entityRegex = /(?:public\s+)?(?:sealed\s+)?(class|record)\s+(\w+)/g;
|
|
83
|
+
|
|
84
|
+
for (const file of files) {
|
|
85
|
+
try {
|
|
86
|
+
const content = await fs.readFile(file.abs, 'utf8');
|
|
87
|
+
let match;
|
|
88
|
+
entityRegex.lastIndex = 0;
|
|
89
|
+
while ((match = entityRegex.exec(content)) !== null) {
|
|
90
|
+
const line = content.slice(Math.max(0, match.index - 80), match.index + match[0].length);
|
|
91
|
+
// Only capture sealed/record types or classes in entity-like directories
|
|
92
|
+
if (/sealed|record/.test(line) || file.rel.includes('Entities/')) {
|
|
93
|
+
entities.push({
|
|
94
|
+
name: match[2],
|
|
95
|
+
type: match[1],
|
|
96
|
+
path: file.rel
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch { /* skip unreadable files */ }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return entities;
|
|
104
|
+
} catch {
|
|
105
|
+
return [];
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Scan API endpoints implementing IApiEndpoint.
|
|
111
|
+
*
|
|
112
|
+
* @param {string} projectPath
|
|
113
|
+
* @returns {Promise<Array<{name: string, route: string, method: string, path: string}>>}
|
|
114
|
+
*/
|
|
115
|
+
export async function scanEndpoints(projectPath) {
|
|
116
|
+
try {
|
|
117
|
+
const files = await walkDir(
|
|
118
|
+
projectPath,
|
|
119
|
+
(rel, name) => name.endsWith('.cs') && (rel.includes('Endpoints/') || rel.includes('Features/'))
|
|
120
|
+
);
|
|
121
|
+
|
|
122
|
+
const endpoints = [];
|
|
123
|
+
const classRegex = /class\s+(\w+)[^{]*(?:IApiEndpoint|IEndpoint|Endpoint)/;
|
|
124
|
+
const routeRegex = /Route\s*(?:=>|=)\s*"([^"]+)"/;
|
|
125
|
+
const methodRegex = /(?:Http(Get|Post|Put|Delete|Patch))|(?:\.Map(Get|Post|Put|Delete|Patch))/i;
|
|
126
|
+
|
|
127
|
+
for (const file of files) {
|
|
128
|
+
try {
|
|
129
|
+
const content = await fs.readFile(file.abs, 'utf8');
|
|
130
|
+
const classMatch = classRegex.exec(content);
|
|
131
|
+
if (!classMatch) continue;
|
|
132
|
+
|
|
133
|
+
const routeMatch = routeRegex.exec(content);
|
|
134
|
+
const methodMatch = methodRegex.exec(content);
|
|
135
|
+
|
|
136
|
+
endpoints.push({
|
|
137
|
+
name: classMatch[1],
|
|
138
|
+
route: routeMatch ? routeMatch[1] : '(unknown)',
|
|
139
|
+
method: methodMatch ? (methodMatch[1] || methodMatch[2] || '').toUpperCase() : '',
|
|
140
|
+
path: file.rel
|
|
141
|
+
});
|
|
142
|
+
} catch { /* skip */ }
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return endpoints;
|
|
146
|
+
} catch {
|
|
147
|
+
return [];
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Scan Next.js pages and components.
|
|
153
|
+
*
|
|
154
|
+
* @param {string} projectPath
|
|
155
|
+
* @returns {Promise<{pages: Array<{route: string, path: string}>, layouts: Array<{path: string}>, components: Array<{name: string, path: string}>}>}
|
|
156
|
+
*/
|
|
157
|
+
export async function scanNextjsComponents(projectPath) {
|
|
158
|
+
try {
|
|
159
|
+
const result = { pages: [], layouts: [], components: [] };
|
|
160
|
+
|
|
161
|
+
// Scan pages and layouts from app directory
|
|
162
|
+
const appFiles = await walkDir(
|
|
163
|
+
projectPath,
|
|
164
|
+
(rel, name) =>
|
|
165
|
+
(name === 'page.tsx' || name === 'page.jsx' || name === 'layout.tsx' || name === 'layout.jsx') &&
|
|
166
|
+
(rel.startsWith('app/') || rel.startsWith('src/app/'))
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
for (const file of appFiles) {
|
|
170
|
+
const isLayout = file.rel.includes('layout.');
|
|
171
|
+
const routeDir = path.dirname(file.rel).replace(/^(?:src\/)?app/, '').replace(/\\/g, '/');
|
|
172
|
+
const route = routeDir || '/';
|
|
173
|
+
|
|
174
|
+
if (isLayout) {
|
|
175
|
+
result.layouts.push({ path: file.rel });
|
|
176
|
+
} else {
|
|
177
|
+
result.pages.push({ route: route === '' ? '/' : route, path: file.rel });
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// Scan components
|
|
182
|
+
const componentFiles = await walkDir(
|
|
183
|
+
projectPath,
|
|
184
|
+
(rel, name) =>
|
|
185
|
+
(name.endsWith('.tsx') || name.endsWith('.jsx')) &&
|
|
186
|
+
(rel.startsWith('src/components/') || rel.startsWith('components/') || rel.includes('/components/'))
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
for (const file of componentFiles) {
|
|
190
|
+
const name = path.basename(file.rel, path.extname(file.rel));
|
|
191
|
+
// Skip index files and test files
|
|
192
|
+
if (name === 'index' || name.includes('.test') || name.includes('.spec')) continue;
|
|
193
|
+
result.components.push({ name, path: file.rel });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return result;
|
|
197
|
+
} catch {
|
|
198
|
+
return { pages: [], layouts: [], components: [] };
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Scan integrations from appsettings*.json files.
|
|
204
|
+
*
|
|
205
|
+
* @param {string} projectPath
|
|
206
|
+
* @returns {Promise<Array<{name: string, type: string, configured: boolean}>>}
|
|
207
|
+
*/
|
|
208
|
+
export async function scanIntegrations(projectPath) {
|
|
209
|
+
try {
|
|
210
|
+
const files = await walkDir(
|
|
211
|
+
projectPath,
|
|
212
|
+
(rel, name) => /^appsettings(?:\.\w+)?\.json$/.test(name),
|
|
213
|
+
projectPath,
|
|
214
|
+
20
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
// Also check .env files for common integrations
|
|
218
|
+
const envFiles = await walkDir(
|
|
219
|
+
projectPath,
|
|
220
|
+
(rel, name) => name === '.env' || name === '.env.local' || name === '.env.production',
|
|
221
|
+
projectPath,
|
|
222
|
+
5
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const knownServices = {
|
|
226
|
+
'NeonAuth': 'auth',
|
|
227
|
+
'AsaaS': 'payment',
|
|
228
|
+
'Resend': 'email',
|
|
229
|
+
'Hangfire': 'background-jobs',
|
|
230
|
+
'Neon': 'database',
|
|
231
|
+
'ConnectionStrings': 'database',
|
|
232
|
+
'Redis': 'cache',
|
|
233
|
+
'SendGrid': 'email',
|
|
234
|
+
'Stripe': 'payment',
|
|
235
|
+
'AzureAd': 'auth',
|
|
236
|
+
'OpenAI': 'ai',
|
|
237
|
+
'AzureOpenAI': 'ai'
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
const detected = new Map();
|
|
241
|
+
|
|
242
|
+
// Scan appsettings files
|
|
243
|
+
for (const file of files) {
|
|
244
|
+
try {
|
|
245
|
+
const content = JSON.parse(await fs.readFile(file.abs, 'utf8'));
|
|
246
|
+
for (const [service, type] of Object.entries(knownServices)) {
|
|
247
|
+
if (content[service] || content[service.toLowerCase()]) {
|
|
248
|
+
const hasValues = content[service] && typeof content[service] === 'object'
|
|
249
|
+
? Object.values(content[service]).some(v => v && v !== '' && !String(v).startsWith('{{'))
|
|
250
|
+
: true;
|
|
251
|
+
if (!detected.has(service) || hasValues) {
|
|
252
|
+
detected.set(service, { name: service, type, configured: hasValues });
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
// Check ConnectionStrings specifically
|
|
257
|
+
if (content.ConnectionStrings) {
|
|
258
|
+
for (const [key, val] of Object.entries(content.ConnectionStrings)) {
|
|
259
|
+
if (val && !String(val).startsWith('{{')) {
|
|
260
|
+
detected.set(`DB:${key}`, { name: `ConnectionString:${key}`, type: 'database', configured: true });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
} catch { /* skip invalid JSON */ }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Scan .env files for known prefixes
|
|
268
|
+
const envPrefixes = {
|
|
269
|
+
'NEON_AUTH_': 'NeonAuth',
|
|
270
|
+
'DATABASE_URL': 'Neon',
|
|
271
|
+
'NEON_': 'Neon',
|
|
272
|
+
'RESEND_': 'Resend',
|
|
273
|
+
'STRIPE_': 'Stripe',
|
|
274
|
+
'OPENAI_': 'OpenAI',
|
|
275
|
+
'ASAAS_': 'AsaaS'
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
for (const file of envFiles) {
|
|
279
|
+
try {
|
|
280
|
+
const content = await fs.readFile(file.abs, 'utf8');
|
|
281
|
+
for (const [prefix, service] of Object.entries(envPrefixes)) {
|
|
282
|
+
if (content.includes(prefix)) {
|
|
283
|
+
const type = knownServices[service] || 'integration';
|
|
284
|
+
if (!detected.has(service)) {
|
|
285
|
+
detected.set(service, { name: service, type, configured: true });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
} catch { /* skip */ }
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
return Array.from(detected.values());
|
|
293
|
+
} catch {
|
|
294
|
+
return [];
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Scan database schema from EF Core migrations.
|
|
300
|
+
*
|
|
301
|
+
* @param {string} projectPath
|
|
302
|
+
* @returns {Promise<Array<{table: string, migration: string}>>}
|
|
303
|
+
*/
|
|
304
|
+
export async function scanDatabaseSchema(projectPath) {
|
|
305
|
+
try {
|
|
306
|
+
const files = await walkDir(
|
|
307
|
+
projectPath,
|
|
308
|
+
(rel, name) => name.endsWith('.cs') && rel.includes('Migrations/') && !name.endsWith('.Designer.cs'),
|
|
309
|
+
projectPath,
|
|
310
|
+
100
|
|
311
|
+
);
|
|
312
|
+
|
|
313
|
+
const tables = new Map();
|
|
314
|
+
const toTableRegex = /\.ToTable\("(\w+)"/g;
|
|
315
|
+
const createTableRegex = /migrationBuilder\.CreateTable\(\s*name:\s*"(\w+)"/g;
|
|
316
|
+
|
|
317
|
+
for (const file of files) {
|
|
318
|
+
try {
|
|
319
|
+
const content = await fs.readFile(file.abs, 'utf8');
|
|
320
|
+
const migrationName = path.basename(file.rel, '.cs');
|
|
321
|
+
|
|
322
|
+
for (const regex of [toTableRegex, createTableRegex]) {
|
|
323
|
+
regex.lastIndex = 0;
|
|
324
|
+
let match;
|
|
325
|
+
while ((match = regex.exec(content)) !== null) {
|
|
326
|
+
if (!tables.has(match[1])) {
|
|
327
|
+
tables.set(match[1], { table: match[1], migration: migrationName });
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
} catch { /* skip */ }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// Also scan database migrations (SQL)
|
|
335
|
+
const sqlFiles = await walkDir(
|
|
336
|
+
projectPath,
|
|
337
|
+
(rel, name) => name.endsWith('.sql') && rel.includes('migrations/'),
|
|
338
|
+
projectPath,
|
|
339
|
+
100
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
const createSqlRegex = /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?(?:public\.)?["']?(\w+)["']?/gi;
|
|
343
|
+
|
|
344
|
+
for (const file of sqlFiles) {
|
|
345
|
+
try {
|
|
346
|
+
const content = await fs.readFile(file.abs, 'utf8');
|
|
347
|
+
const migrationName = path.basename(file.rel, '.sql');
|
|
348
|
+
createSqlRegex.lastIndex = 0;
|
|
349
|
+
let match;
|
|
350
|
+
while ((match = createSqlRegex.exec(content)) !== null) {
|
|
351
|
+
if (!tables.has(match[1])) {
|
|
352
|
+
tables.set(match[1], { table: match[1], migration: migrationName });
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
} catch { /* skip */ }
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return Array.from(tables.values());
|
|
359
|
+
} catch {
|
|
360
|
+
return [];
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Detect stack from .csproj PackageReference and package.json dependencies.
|
|
366
|
+
*
|
|
367
|
+
* @param {string} projectPath
|
|
368
|
+
* @returns {Promise<{dotnet: Array<{name: string, version: string}>, npm: Array<{name: string, version: string}>}>}
|
|
369
|
+
*/
|
|
370
|
+
export async function scanStackDependencies(projectPath) {
|
|
371
|
+
try {
|
|
372
|
+
const result = { dotnet: [], npm: [] };
|
|
373
|
+
|
|
374
|
+
// Scan .csproj files
|
|
375
|
+
const csprojFiles = await walkDir(
|
|
376
|
+
projectPath,
|
|
377
|
+
(rel, name) => name.endsWith('.csproj'),
|
|
378
|
+
projectPath,
|
|
379
|
+
10
|
|
380
|
+
);
|
|
381
|
+
|
|
382
|
+
const packageRefRegex = /<PackageReference\s+Include="([^"]+)"\s+Version="([^"]+)"/g;
|
|
383
|
+
|
|
384
|
+
for (const file of csprojFiles) {
|
|
385
|
+
try {
|
|
386
|
+
const content = await fs.readFile(file.abs, 'utf8');
|
|
387
|
+
packageRefRegex.lastIndex = 0;
|
|
388
|
+
let match;
|
|
389
|
+
while ((match = packageRefRegex.exec(content)) !== null) {
|
|
390
|
+
result.dotnet.push({ name: match[1], version: match[2] });
|
|
391
|
+
}
|
|
392
|
+
} catch { /* skip */ }
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
// Scan package.json (root only)
|
|
396
|
+
const pkgPath = path.join(projectPath, 'package.json');
|
|
397
|
+
if (existsSync(pkgPath)) {
|
|
398
|
+
try {
|
|
399
|
+
const pkg = JSON.parse(await fs.readFile(pkgPath, 'utf8'));
|
|
400
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
401
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
402
|
+
result.npm.push({ name, version: String(version) });
|
|
403
|
+
}
|
|
404
|
+
} catch { /* skip */ }
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Also check web/ or frontend/ subdirectory package.json
|
|
408
|
+
for (const subdir of ['web', 'frontend', 'client', 'src']) {
|
|
409
|
+
const subPkgPath = path.join(projectPath, subdir, 'package.json');
|
|
410
|
+
if (existsSync(subPkgPath)) {
|
|
411
|
+
try {
|
|
412
|
+
const pkg = JSON.parse(await fs.readFile(subPkgPath, 'utf8'));
|
|
413
|
+
const allDeps = { ...pkg.dependencies, ...pkg.devDependencies };
|
|
414
|
+
for (const [name, version] of Object.entries(allDeps)) {
|
|
415
|
+
// Avoid duplicates
|
|
416
|
+
if (!result.npm.some(d => d.name === name)) {
|
|
417
|
+
result.npm.push({ name, version: String(version) });
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
} catch { /* skip */ }
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
return result;
|
|
425
|
+
} catch {
|
|
426
|
+
return { dotnet: [], npm: [] };
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/**
|
|
431
|
+
* Master scan — runs all scanners in parallel.
|
|
432
|
+
*
|
|
433
|
+
* @param {string} projectPath
|
|
434
|
+
* @returns {Promise<Object>} ArchitectureScanResult
|
|
435
|
+
*/
|
|
436
|
+
export async function scanArchitecture(projectPath) {
|
|
437
|
+
const [entities, endpoints, nextjs, integrations, database, stack] = await Promise.all([
|
|
438
|
+
scanEntities(projectPath),
|
|
439
|
+
scanEndpoints(projectPath),
|
|
440
|
+
scanNextjsComponents(projectPath),
|
|
441
|
+
scanIntegrations(projectPath),
|
|
442
|
+
scanDatabaseSchema(projectPath),
|
|
443
|
+
scanStackDependencies(projectPath)
|
|
444
|
+
]);
|
|
445
|
+
|
|
446
|
+
return {
|
|
447
|
+
timestamp: new Date().toISOString(),
|
|
448
|
+
entities,
|
|
449
|
+
endpoints,
|
|
450
|
+
nextjs,
|
|
451
|
+
integrations,
|
|
452
|
+
database,
|
|
453
|
+
stack
|
|
454
|
+
};
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/**
|
|
458
|
+
* Resolve a template path: check .morph/framework/templates/ first, then framework/templates/,
|
|
459
|
+
* then fall back to the CLI package's own framework/templates/.
|
|
460
|
+
*
|
|
461
|
+
* @param {string} projectPath
|
|
462
|
+
* @param {string} templateRelPath
|
|
463
|
+
* @returns {string|null}
|
|
464
|
+
*/
|
|
465
|
+
function resolveTemplatePath(projectPath, templateRelPath) {
|
|
466
|
+
// 1. Project-local override
|
|
467
|
+
const projectLocal = path.join(projectPath, '.morph/framework/templates', templateRelPath);
|
|
468
|
+
if (existsSync(projectLocal)) return projectLocal;
|
|
469
|
+
|
|
470
|
+
// 2. CLI package framework/ (for running from source or during init)
|
|
471
|
+
const cliFramework = path.join(
|
|
472
|
+
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')),
|
|
473
|
+
'..', '..', '..', 'framework', 'templates', templateRelPath
|
|
474
|
+
);
|
|
475
|
+
if (existsSync(cliFramework)) return cliFramework;
|
|
476
|
+
|
|
477
|
+
return null;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Render architecture context to .morph/context/README.md
|
|
482
|
+
*
|
|
483
|
+
* @param {string} projectPath
|
|
484
|
+
* @param {Object} scanResult - Result from scanArchitecture()
|
|
485
|
+
* @param {Object} [options]
|
|
486
|
+
* @param {string} [options.projectName] - Override project name
|
|
487
|
+
* @returns {Promise<string>} Path to written file
|
|
488
|
+
*/
|
|
489
|
+
export async function renderArchitectureContext(projectPath, scanResult, options = {}) {
|
|
490
|
+
const templatePath = resolveTemplatePath(projectPath, 'context/ARCHITECTURE.md');
|
|
491
|
+
|
|
492
|
+
// Determine project name
|
|
493
|
+
let projectName = options.projectName;
|
|
494
|
+
if (!projectName) {
|
|
495
|
+
// Try config.json
|
|
496
|
+
const configPath = path.join(projectPath, '.morph/config/config.json');
|
|
497
|
+
if (existsSync(configPath)) {
|
|
498
|
+
try {
|
|
499
|
+
const config = JSON.parse(readFileSync(configPath, 'utf8'));
|
|
500
|
+
projectName = config.project?.name;
|
|
501
|
+
} catch { /* ignore */ }
|
|
502
|
+
}
|
|
503
|
+
// Fallback to directory name
|
|
504
|
+
if (!projectName) {
|
|
505
|
+
projectName = path.basename(projectPath);
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// Get morph-spec version
|
|
510
|
+
let morphVersion = 'unknown';
|
|
511
|
+
try {
|
|
512
|
+
const pkgPath = path.join(
|
|
513
|
+
path.dirname(new URL(import.meta.url).pathname.replace(/^\/([A-Z]:)/, '$1')),
|
|
514
|
+
'..', '..', '..', 'package.json'
|
|
515
|
+
);
|
|
516
|
+
if (existsSync(pkgPath)) {
|
|
517
|
+
morphVersion = JSON.parse(readFileSync(pkgPath, 'utf8')).version;
|
|
518
|
+
}
|
|
519
|
+
} catch { /* ignore */ }
|
|
520
|
+
|
|
521
|
+
const variables = {
|
|
522
|
+
projectName,
|
|
523
|
+
timestamp: new Date().toISOString().replace('T', ' ').split('.')[0],
|
|
524
|
+
morphVersion,
|
|
525
|
+
// Entities
|
|
526
|
+
hasDotnet: scanResult.entities.length > 0 || scanResult.endpoints.length > 0,
|
|
527
|
+
entities: scanResult.entities,
|
|
528
|
+
entityCount: scanResult.entities.length,
|
|
529
|
+
endpoints: scanResult.endpoints,
|
|
530
|
+
endpointCount: scanResult.endpoints.length,
|
|
531
|
+
// Next.js
|
|
532
|
+
hasNextjs: scanResult.nextjs.pages.length > 0 || scanResult.nextjs.components.length > 0,
|
|
533
|
+
pages: scanResult.nextjs.pages,
|
|
534
|
+
pageCount: scanResult.nextjs.pages.length,
|
|
535
|
+
layouts: scanResult.nextjs.layouts,
|
|
536
|
+
components: scanResult.nextjs.components,
|
|
537
|
+
componentCount: scanResult.nextjs.components.length,
|
|
538
|
+
// Integrations
|
|
539
|
+
hasIntegrations: scanResult.integrations.length > 0,
|
|
540
|
+
integrations: scanResult.integrations,
|
|
541
|
+
// Database
|
|
542
|
+
hasTables: scanResult.database.length > 0,
|
|
543
|
+
tables: scanResult.database,
|
|
544
|
+
tableCount: scanResult.database.length,
|
|
545
|
+
// Stack
|
|
546
|
+
hasDotnetPackages: scanResult.stack.dotnet.length > 0,
|
|
547
|
+
dotnetPackages: scanResult.stack.dotnet,
|
|
548
|
+
hasNpmPackages: scanResult.stack.npm.length > 0,
|
|
549
|
+
npmPackages: scanResult.stack.npm
|
|
550
|
+
};
|
|
551
|
+
|
|
552
|
+
let content;
|
|
553
|
+
if (templatePath) {
|
|
554
|
+
content = renderTemplate(templatePath, variables);
|
|
555
|
+
} else {
|
|
556
|
+
// Fallback: generate minimal markdown without template
|
|
557
|
+
content = generateFallbackMarkdown(variables);
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// Write to .morph/context/README.md
|
|
561
|
+
const contextDir = path.join(projectPath, '.morph/context');
|
|
562
|
+
await fs.mkdir(contextDir, { recursive: true });
|
|
563
|
+
const outputPath = path.join(contextDir, 'README.md');
|
|
564
|
+
await fs.writeFile(outputPath, content, 'utf8');
|
|
565
|
+
|
|
566
|
+
return outputPath;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
/**
|
|
570
|
+
* Generate minimal markdown when template is not available.
|
|
571
|
+
* @param {Object} vars
|
|
572
|
+
* @returns {string}
|
|
573
|
+
*/
|
|
574
|
+
function generateFallbackMarkdown(vars) {
|
|
575
|
+
const lines = [`# ${vars.projectName} — Architecture Context\n`];
|
|
576
|
+
lines.push(`> Auto-generated by morph-spec context refresh`);
|
|
577
|
+
lines.push(`> Last updated: ${vars.timestamp}\n`);
|
|
578
|
+
|
|
579
|
+
if (vars.hasDotnet) {
|
|
580
|
+
lines.push('## .NET Entities\n');
|
|
581
|
+
for (const e of vars.entities) {
|
|
582
|
+
lines.push(`- \`${e.name}\` (${e.type}) — ${e.path}`);
|
|
583
|
+
}
|
|
584
|
+
lines.push('\n## API Endpoints\n');
|
|
585
|
+
for (const ep of vars.endpoints) {
|
|
586
|
+
lines.push(`- ${ep.method ? ep.method + ' ' : ''}\`${ep.route}\` — ${ep.name} (${ep.path})`);
|
|
587
|
+
}
|
|
588
|
+
lines.push('');
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
if (vars.hasNextjs) {
|
|
592
|
+
lines.push('## Next.js Pages\n');
|
|
593
|
+
for (const p of vars.pages) {
|
|
594
|
+
lines.push(`- \`${p.route}\` — ${p.path}`);
|
|
595
|
+
}
|
|
596
|
+
if (vars.components.length > 0) {
|
|
597
|
+
lines.push('\n## Components\n');
|
|
598
|
+
for (const c of vars.components) {
|
|
599
|
+
lines.push(`- \`${c.name}\` — ${c.path}`);
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
lines.push('');
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
if (vars.hasIntegrations) {
|
|
606
|
+
lines.push('## Integrations\n');
|
|
607
|
+
for (const i of vars.integrations) {
|
|
608
|
+
lines.push(`- **${i.name}** (${i.type}) — ${i.configured ? 'configured' : 'not configured'}`);
|
|
609
|
+
}
|
|
610
|
+
lines.push('');
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
if (vars.hasTables) {
|
|
614
|
+
lines.push('## Database Tables\n');
|
|
615
|
+
for (const t of vars.tables) {
|
|
616
|
+
lines.push(`- \`${t.table}\` (from ${t.migration})`);
|
|
617
|
+
}
|
|
618
|
+
lines.push('');
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
if (vars.hasDotnetPackages) {
|
|
622
|
+
lines.push('## .NET Packages\n');
|
|
623
|
+
for (const p of vars.dotnetPackages) {
|
|
624
|
+
lines.push(`- \`${p.name}\` ${p.version}`);
|
|
625
|
+
}
|
|
626
|
+
lines.push('');
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
if (vars.hasNpmPackages) {
|
|
630
|
+
lines.push('## NPM Packages\n');
|
|
631
|
+
for (const p of vars.npmPackages) {
|
|
632
|
+
lines.push(`- \`${p.name}\` ${p.version}`);
|
|
633
|
+
}
|
|
634
|
+
lines.push('');
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
lines.push(`---\n*Generated by morph-spec v${vars.morphVersion}*`);
|
|
638
|
+
return lines.join('\n');
|
|
639
|
+
}
|