@polymorphism-tech/morph-spec 7.9.4 → 8.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (212) hide show
  1. package/CLAUDE.md +2 -2
  2. package/README.md +31 -2
  3. package/bin/morph-spec.js +50 -2
  4. package/bin/validate.js +379 -379
  5. package/claude-plugin.json +1 -1
  6. package/docs/CHEATSHEET.md +38 -1
  7. package/docs/QUICKSTART.md +60 -1
  8. package/framework/CLAUDE.md +14 -3
  9. package/framework/MORPH.md +41 -5
  10. package/framework/agents.json +22 -3
  11. package/framework/commands/commit.md +166 -166
  12. package/framework/commands/morph-hotfix.md +39 -0
  13. package/framework/commands/morph-infra.md +215 -215
  14. package/framework/commands/morph-preflight.md +205 -205
  15. package/framework/commands/morph-troubleshoot.md +124 -124
  16. package/framework/hooks/README.md +25 -11
  17. package/framework/hooks/claude-code/core/gate-guard.js +4 -2
  18. package/framework/hooks/claude-code/core/loop-detect.js +2 -1
  19. package/framework/hooks/claude-code/core/post-edit-typecheck.js +14 -5
  20. package/framework/hooks/claude-code/core/tasks-json-guard.js +4 -2
  21. package/framework/hooks/claude-code/core/validate-completion.js +1 -1
  22. package/framework/hooks/claude-code/core/worktree-context.js +160 -0
  23. package/framework/hooks/claude-code/statusline.py +679 -679
  24. package/framework/hooks/claude-code/statusline.sh +11 -11
  25. package/framework/hooks/git/commit-msg/conventional-commits.sh +33 -33
  26. package/framework/hooks/git/pre-commit/agents.sh +25 -25
  27. package/framework/hooks/git/pre-commit/orchestrator.sh +64 -64
  28. package/framework/hooks/git/pre-commit/specs.sh +50 -50
  29. package/framework/hooks/git/pre-push/run-tests.sh +44 -44
  30. package/framework/hooks/shared/activity-logger.js +160 -160
  31. package/framework/hooks/shared/build-check.js +2 -2
  32. package/framework/hooks/shared/hook-response.js +17 -5
  33. package/framework/hooks/shared/payload-utils.js +42 -0
  34. package/framework/hooks/shared/state-reader.js +22 -2
  35. package/framework/hooks/shared/stdin-reader.js +26 -26
  36. package/framework/rubrics/backend.json +41 -41
  37. package/framework/rules/ai-agents-standards.md +45 -45
  38. package/framework/rules/dotnet-templates.md +86 -86
  39. package/framework/rules/frontend-standards.md +13 -13
  40. package/framework/rules/infrastructure-standards.md +27 -27
  41. package/framework/rules/neon-standards.md +25 -25
  42. package/framework/rules/nextjs-standards.md +20 -20
  43. package/framework/rules/nextjs-templates.md +40 -40
  44. package/framework/schemas/feature.schema.json +95 -1
  45. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  46. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  47. package/framework/skills/core/morph-apply/SKILL.md +49 -27
  48. package/framework/skills/core/morph-brainstorming/SKILL.md +9 -1
  49. package/framework/skills/core/morph-eval/SKILL.md +6 -1
  50. package/framework/skills/core/morph-hotfix/SKILL.md +134 -0
  51. package/framework/skills/core/morph-implement/SKILL.md +17 -22
  52. package/framework/skills/core/morph-plan/SKILL.md +5 -1
  53. package/framework/skills/core/morph-proposal/SKILL.md +24 -1
  54. package/framework/skills/core/morph-review/SKILL.md +13 -16
  55. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  56. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  57. package/framework/standards/ai-agents/agent-session.md +160 -160
  58. package/framework/standards/ai-agents/batching.md +193 -193
  59. package/framework/standards/ai-agents/context-providers.md +188 -188
  60. package/framework/standards/ai-agents/mcp-server.md +250 -250
  61. package/framework/standards/ai-agents/mcp-tools.md +197 -197
  62. package/framework/standards/ai-agents/middleware-patterns.md +207 -207
  63. package/framework/standards/ai-agents/modalities-image-gen.md +173 -173
  64. package/framework/standards/ai-agents/modalities-speech-to-text.md +171 -171
  65. package/framework/standards/ai-agents/modalities-text-to-speech.md +181 -181
  66. package/framework/standards/ai-agents/modalities-vision.md +201 -201
  67. package/framework/standards/ai-agents/multi-agent-patterns.md +155 -155
  68. package/framework/standards/ai-agents/production.md +275 -275
  69. package/framework/standards/ai-agents/prompts-in-db.md +214 -214
  70. package/framework/standards/ai-agents/providers/model-registry.md +158 -158
  71. package/framework/standards/ai-agents/service-tier-flex.md +135 -135
  72. package/framework/standards/ai-agents/structured-output.md +249 -249
  73. package/framework/standards/ai-agents/sweet-spot.md +109 -109
  74. package/framework/templates/.idea/morph-templates.xml +92 -0
  75. package/framework/templates/.vscode/morph-templates.code-snippets +186 -0
  76. package/framework/templates/IDE-SNIPPETS.md +262 -262
  77. package/framework/templates/REGISTRY.json +0 -62
  78. package/framework/templates/code/dotnet/abstractions/api-endpoint.cs +7 -7
  79. package/framework/templates/code/dotnet/ai-agents/AgentBootstrap.cs.template +39 -39
  80. package/framework/templates/code/dotnet/ai-agents/EmbeddingService.cs.template +68 -68
  81. package/framework/templates/code/dotnet/ai-agents/ModelRegistry.cs.template +117 -117
  82. package/framework/templates/code/dotnet/ai-agents/PromptRepository.cs.template +95 -95
  83. package/framework/templates/code/dotnet/ai-agents/ResilientToolMiddleware.cs.template +61 -61
  84. package/framework/templates/code/dotnet/ai-agents/VectorSearchTool.cs.template +107 -107
  85. package/framework/templates/code/dotnet/ai-agents/model-registry.json.template +58 -58
  86. package/framework/templates/code/dotnet/contracts/contracts-vsa.cs +281 -281
  87. package/framework/templates/code/dotnet/database/migration.cs +83 -83
  88. package/framework/templates/code/dotnet/feature/endpoint.cs.hbs +21 -21
  89. package/framework/templates/code/dotnet/feature/request.cs.hbs +4 -4
  90. package/framework/templates/code/dotnet/feature/response.cs.hbs +4 -4
  91. package/framework/templates/code/dotnet/feature/validator.cs.hbs +15 -15
  92. package/framework/templates/code/sql/ai-agents/agent-prompts-migration.sql.template +58 -58
  93. package/framework/templates/code/sql/ai-agents/pgvector-embeddings-migration.sql.template +91 -91
  94. package/framework/templates/code/typescript/contracts.ts +167 -167
  95. package/framework/templates/docs/clarifications.md +253 -253
  96. package/framework/templates/docs/schema-analysis.md +119 -119
  97. package/framework/templates/docs/ui-components.md +52 -52
  98. package/framework/templates/docs/ui-design-system.md +76 -76
  99. package/framework/templates/docs/ui-flows.md +167 -167
  100. package/framework/templates/docs/ui-mockups.md +98 -98
  101. package/framework/templates/feature/decisions.md +122 -122
  102. package/framework/templates/frontend/nextjs/Dockerfile.nextjs.hbs +43 -43
  103. package/framework/templates/frontend/nextjs/client-component.tsx.hbs +26 -26
  104. package/framework/templates/frontend/nextjs/env.mjs.hbs +32 -32
  105. package/framework/templates/frontend/nextjs/feature-form.tsx.hbs +56 -56
  106. package/framework/templates/frontend/nextjs/page.tsx.hbs +22 -22
  107. package/framework/templates/frontend/nextjs/tsconfig.json.hbs +26 -26
  108. package/framework/templates/frontend/nextjs/use-feature.ts.hbs +54 -54
  109. package/framework/templates/infrastructure/docker/Dockerfile.template +58 -58
  110. package/framework/templates/infrastructure/docker/docker-compose.template.yml +50 -50
  111. package/framework/templates/infrastructure/docker/dockerfile-api.dockerfile +38 -38
  112. package/framework/templates/infrastructure/docker/dockerfile-web.dockerfile +48 -48
  113. package/framework/templates/infrastructure/github/README.md +56 -56
  114. package/framework/templates/infrastructure/github/actions/docker-build-push/action.yml.hbs +45 -45
  115. package/framework/templates/infrastructure/github/actions/health-check/action.yml.hbs +27 -27
  116. package/framework/templates/infrastructure/github/workflows/docker-build-push.yml.hbs +59 -59
  117. package/framework/templates/infrastructure/github/workflows/dotnet-build.yml.hbs +39 -39
  118. package/framework/templates/integrations/asaas-client.cs +387 -387
  119. package/framework/templates/integrations/asaas-webhook.cs +351 -351
  120. package/framework/templates/meta-prompts/fusion/fusion-agent.md +76 -76
  121. package/framework/templates/meta-prompts/fusion/fusion-aggregator.md +100 -100
  122. package/framework/templates/meta-prompts/validators/pre-commit-validator.md +95 -95
  123. package/framework/templates/project/validate.js +120 -120
  124. package/framework/templates/state.template.json +17 -17
  125. package/framework/templates/ui/design-system.css +226 -226
  126. package/package.json +1 -1
  127. package/src/commands/dashboard/dashboard.js +53 -53
  128. package/src/commands/index.js +13 -13
  129. package/src/commands/mcp/mcp-setup.js +519 -519
  130. package/src/commands/project/checkpoint.js +209 -209
  131. package/src/commands/project/doctor.js +4 -2
  132. package/src/commands/project/init.js +46 -2
  133. package/src/commands/project/update.js +5 -3
  134. package/src/commands/project/worktree.js +1005 -46
  135. package/src/commands/state/advance.js +17 -2
  136. package/src/commands/state/approve.js +18 -1
  137. package/src/commands/state/create.js +158 -14
  138. package/src/commands/state/retype.js +126 -0
  139. package/src/commands/state/score.js +4 -0
  140. package/src/commands/tasks/dag.js +32 -0
  141. package/src/commands/validation/index.js +1 -0
  142. package/src/commands/validation/validate-feature.js +33 -5
  143. package/src/commands/validation/verify.js +190 -0
  144. package/src/core/state/phase-state-machine.js +197 -197
  145. package/src/core/state/state-manager.js +35 -10
  146. package/src/core/templates/template-renderer.js +477 -477
  147. package/src/core/work-types.js +79 -0
  148. package/src/dashboard/app/components/DetailPanel.jsx +11 -11
  149. package/src/dashboard/app/components/FlowGraph.jsx +17 -17
  150. package/src/dashboard/app/components/SearchFilter.jsx +22 -22
  151. package/src/dashboard/app/hooks/useDetailPanel.js +15 -15
  152. package/src/dashboard/app/main.jsx +13 -13
  153. package/src/dashboard/app/pages/AgentMap.jsx +370 -370
  154. package/src/dashboard/app/pages/HookInspector.jsx +418 -418
  155. package/src/dashboard/app/pages/LiveState.jsx +11 -1
  156. package/src/dashboard/app/pages/PhasePipeline.jsx +408 -408
  157. package/src/dashboard/app/pages/RulesMap.jsx +273 -273
  158. package/src/dashboard/app/pages/SkillsLibrary.jsx +366 -366
  159. package/src/dashboard/app/pages/Standards.jsx +332 -332
  160. package/src/dashboard/app/styles/index.css +47 -47
  161. package/src/dashboard/index.html +12 -12
  162. package/src/dashboard/package.json +24 -24
  163. package/src/dashboard/scanner/hooks-scanner.js +41 -41
  164. package/src/dashboard/scanner/phases-scanner.js +15 -15
  165. package/src/dashboard/scanner/rubrics-scanner.js +12 -12
  166. package/src/dashboard/scanner/rules-scanner.js +37 -37
  167. package/src/dashboard/scanner/skills-scanner.js +43 -43
  168. package/src/dashboard/scanner/squads-scanner.js +13 -13
  169. package/src/dashboard/scanner/standards-scanner.js +26 -26
  170. package/src/dashboard/scanner/state-scanner.js +42 -0
  171. package/src/dashboard/vite-plugin-morph-data.js +17 -17
  172. package/src/dashboard/vite.config.js +24 -24
  173. package/src/lib/agents/metrics-aggregator.js +56 -56
  174. package/src/lib/agents/metrics-calculator.js +125 -125
  175. package/src/lib/agents/output-collector.js +73 -73
  176. package/src/lib/detectors/claude-config-detector.js +152 -152
  177. package/src/lib/detectors/design-system-detector.js +231 -231
  178. package/src/lib/detectors/index.js +217 -217
  179. package/src/lib/generators/architecture-scanner.js +639 -639
  180. package/src/lib/generators/design-system-generator.js +508 -508
  181. package/src/lib/generators/index.js +13 -13
  182. package/src/lib/generators/settings-generator.js +209 -209
  183. package/src/lib/hooks/hook-executor.js +167 -167
  184. package/src/lib/installers/mcp-installer.js +719 -719
  185. package/src/lib/installers/memory-installer.js +1 -1
  186. package/src/lib/risk-detector.js +16 -0
  187. package/src/lib/tasks/dispatch-planner.js +122 -4
  188. package/src/lib/tasks/mermaid-renderer.js +132 -0
  189. package/src/lib/tasks/task-classifier.js +30 -30
  190. package/src/lib/tasks/task-parser.js +209 -209
  191. package/src/lib/tasks/test-runner.js +27 -6
  192. package/src/lib/validators/architecture/architecture-validator.js +259 -259
  193. package/src/lib/validators/checkpoint-persistence.js +39 -39
  194. package/src/lib/validators/contracts/contract-compliance-validator.js +274 -274
  195. package/src/lib/validators/css/css-validator.js +340 -340
  196. package/src/lib/validators/packages/package-validator.js +347 -347
  197. package/src/lib/validators/shared/emit-validator-dispatch.js +14 -14
  198. package/src/lib/validators/shared/issue-counter.js +14 -14
  199. package/src/lib/validators/validation-runner.js +349 -349
  200. package/src/lib/validators/verify-runner.js +322 -0
  201. package/src/lib/work-type-detector.js +143 -0
  202. package/src/scripts/setup-infra.js +32 -5
  203. package/src/utils/agent-id-mapper.js +11 -11
  204. package/src/utils/agents-installer.js +10 -6
  205. package/src/utils/claude-settings-manager.js +300 -300
  206. package/src/utils/file-copier.js +43 -0
  207. package/src/utils/hooks-installer.js +43 -34
  208. package/src/utils/skills-installer.js +174 -174
  209. package/framework/hooks/claude-code/core/subagent-report-guard.js +0 -95
  210. package/framework/templates/meta-prompts/parallel-workers/parallel-coordinator.md +0 -113
  211. package/framework/templates/meta-prompts/parallel-workers/parallel-worker.md +0 -80
  212. package/src/lib/tasks/wave-resolver.js +0 -353
@@ -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
+ }