@polymorphism-tech/morph-spec 8.35.1 → 8.37.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 (51) hide show
  1. package/CLAUDE.md +10 -8
  2. package/README.md +2 -2
  3. package/bin/morph-spec.js +4 -3
  4. package/claude-plugin.json +1 -1
  5. package/docs/CHEATSHEET.md +1 -1
  6. package/docs/QUICKSTART.md +1 -1
  7. package/framework/MORPH.md +24 -23
  8. package/framework/docs/CLI.md +212 -15
  9. package/framework/evals/proof-rubrics/eval-de-modelo.md +16 -0
  10. package/framework/evals/rubrics/condensed.md +3 -1
  11. package/framework/evals/rubrics/test-coverage.md +8 -0
  12. package/framework/hooks/shared/phase-utils.js +1 -0
  13. package/framework/phases.json +1 -1
  14. package/framework/rules/morph-workflow.md +1 -1
  15. package/framework/schemas/config.schema.json +7 -0
  16. package/framework/skills/core/create-umbraco-lp/SKILL.md +1 -1
  17. package/framework/skills/core/migrate-lp-to-umbraco/SKILL.md +1 -1
  18. package/framework/skills/core/morph-apply/SKILL.md +21 -10
  19. package/framework/skills/core/morph-brainstorming/SKILL.md +1 -1
  20. package/framework/skills/core/morph-eval/SKILL.md +1 -1
  21. package/framework/skills/core/morph-hotfix/SKILL.md +2 -2
  22. package/framework/skills/core/morph-implement/SKILL.md +20 -3
  23. package/framework/skills/core/morph-media/SKILL.md +1 -1
  24. package/framework/skills/core/morph-plan/SKILL.md +41 -3
  25. package/framework/skills/core/morph-proposal/SKILL.md +1 -1
  26. package/framework/skills/core/morph-review/SKILL.md +11 -2
  27. package/framework/skills/core/morph-stack-scan/SKILL.md +1 -1
  28. package/framework/skills/core/morph-standards/SKILL.md +1 -1
  29. package/framework/skills/core/morph-uiux/SKILL.md +1 -1
  30. package/framework/standards/STANDARDS.json +2 -2
  31. package/framework/standards/ai-agents/prompt-sources.md +4 -3
  32. package/framework/standards/ai-agents/testing-ai.md +14 -13
  33. package/framework/templates/dotnet/ai-kit/README.md +22 -11
  34. package/framework/templates/dotnet/ai-kit/tests/Morph.AiKit.Tests/Morph.AiKit.Tests.csproj +23 -10
  35. package/package.json +1 -1
  36. package/src/commands/project/doctor.js +51 -2
  37. package/src/commands/tasks/dag.js +13 -1
  38. package/src/commands/validation/verify.js +59 -17
  39. package/src/core/paths/output-schema.js +7 -0
  40. package/src/lib/detectors/index.js +225 -217
  41. package/src/lib/risk-detector.js +38 -18
  42. package/src/lib/specs/done-criteria-check.js +200 -0
  43. package/src/lib/specs/usecase-check.js +129 -5
  44. package/src/lib/tasks/dotnet-test-introspection.js +632 -0
  45. package/src/lib/tasks/eval-node.js +3 -3
  46. package/src/lib/tasks/test-nodes.js +190 -20
  47. package/src/lib/tasks/test-runner.js +210 -17
  48. package/src/lib/tasks/test-scope.js +597 -0
  49. package/src/lib/validators/traceability-check.js +9 -2
  50. package/src/lib/validators/verify-runner.js +190 -27
  51. package/src/lib/worktree/docker-audit.js +5 -1
@@ -5,6 +5,7 @@ import { parseTasksJsonLenient } from '../../lib/tasks/tasks-json-parser.js';
5
5
  import { planDispatch } from '../../lib/tasks/dispatch-planner.js';
6
6
  import { renderMermaidFlowchart } from '../../lib/tasks/mermaid-renderer.js';
7
7
  import { checkUseCases } from '../../lib/specs/usecase-check.js';
8
+ import { checkArtifactCriteria } from '../../lib/specs/done-criteria-check.js';
8
9
 
9
10
  /**
10
11
  * `morph-spec dag <feature>` — analyze the tasks.json DAG and print the
@@ -51,10 +52,16 @@ export async function dagCommand(featureName, options = {}) {
51
52
  // against a plan that points at a use case nobody can read.
52
53
  const ucCheck = checkUseCases(data, join(planDir, 'usecases'));
53
54
 
55
+ // Artifact-postcondition warning (issue #87). Lexical, not wiring — it
56
+ // runs over ALL tasks even when the feature has no use cases at all
57
+ // (checkUseCases short-circuits above in that case), because a task's
58
+ // own doneCriteria can describe an artifact with no use case in sight.
59
+ const artifactCriteria = checkArtifactCriteria(data, join(planDir, 'usecases'));
60
+
54
61
  const plan = planDispatch(data);
55
62
 
56
63
  if (options.json) {
57
- console.log(JSON.stringify({ ...plan, useCases: ucCheck }, null, 2));
64
+ console.log(JSON.stringify({ ...plan, useCases: ucCheck, artifactCriteria }, null, 2));
58
65
  if (ucCheck.errors.length) process.exit(1);
59
66
  return;
60
67
  }
@@ -71,6 +78,11 @@ export async function dagCommand(featureName, options = {}) {
71
78
  console.error(chalk.yellow(`\n⚠ Use cases — ${plural(ucCheck.warnings.length, 'aviso', 'avisos')}`));
72
79
  for (const w of ucCheck.warnings) console.error(chalk.yellow(` ${w}`));
73
80
  }
81
+ if (artifactCriteria.warnings.length) {
82
+ console.error(chalk.yellow(`\n⚠ Critérios de artefato — ${plural(artifactCriteria.warnings.length, 'aviso', 'avisos')}`));
83
+ for (const w of artifactCriteria.warnings) console.error(chalk.yellow(` ${w}`));
84
+ console.error(chalk.gray(' Teste que lê texto de arquivo não prova comportamento (morph-plan §2, "Por que artefato").'));
85
+ }
74
86
 
75
87
  console.log(chalk.bold(`\n📊 DAG — ${featureName}`));
76
88
  console.log('━'.repeat(60));
@@ -1,9 +1,8 @@
1
1
  import chalk from 'chalk';
2
- import { existsSync, readFileSync } from 'fs';
2
+ import { readFileSync } from 'fs';
3
3
  import { resolve, join } from 'path';
4
4
  import { getFeature, updateFeature } from '../../core/state/state-manager.js';
5
- import { getAbsoluteOutputPath } from '../../core/paths/output-schema.js';
6
- import { runVerify, VerifyUsageError } from '../../lib/validators/verify-runner.js';
5
+ import { runVerify, readTask, VerifyUsageError } from '../../lib/validators/verify-runner.js';
7
6
  import { resolvePrimaryRoot } from '../project/worktree.js';
8
7
  import { getHeadSha } from '../../lib/git/default-branch.js';
9
8
  import { EVAL_RUNNER } from '../../lib/tasks/eval-node.js';
@@ -15,6 +14,28 @@ function statusIcon(status) {
15
14
  return chalk.gray('–');
16
15
  }
17
16
 
17
+ /**
18
+ * One line per test run naming what the task-scope recorte did to it (#87):
19
+ * narrowed (how many selectors, from how many test files), whole, out of scope
20
+ * or full, each with its reason. A green `tests` line alone cannot say that the
21
+ * task ran 12 tests instead of 3.082. Feature-scope runs carry no selection and
22
+ * print nothing extra; `feedback[]` is untouched either way.
23
+ *
24
+ * @param {Array<{id: string, selection: object|null}>} [runs]
25
+ * @returns {string[]}
26
+ */
27
+ function selectionLines(runs = []) {
28
+ return runs
29
+ .filter((run) => run.selection)
30
+ .map((run) => {
31
+ const { mode, reason, selectors = [], files = [] } = run.selection;
32
+ const detail = mode === 'narrowed'
33
+ ? `${selectors.length} selector(s) from ${files.length} test file(s)`
34
+ : (reason ?? '');
35
+ return chalk.gray(` ↳ ${run.id.padEnd(10)} ${mode.padEnd(12)} ${detail}`);
36
+ });
37
+ }
38
+
18
39
  /**
19
40
  * Human-readable rendering of a runVerify() result — mirrors the
20
41
  * ✓/✗-per-node + feedback-list style of formatScoreBreakdown() in
@@ -51,6 +72,7 @@ function formatVerifyResult(result) {
51
72
  detail = `${node.durationMs}ms`;
52
73
  }
53
74
  lines.push(` ${icon} ${name.padEnd(11)} ${node.status.padEnd(5)} ${chalk.gray(detail)}`);
75
+ if (name === 'tests') lines.push(...selectionLines(node.runs));
54
76
  }
55
77
 
56
78
  if (result.feedback.length > 0) {
@@ -166,16 +188,11 @@ function evalsStatusOf(result) {
166
188
  return 'skip';
167
189
  }
168
190
 
169
- /** Read tasks.json's task list for a feature; [] if missing/corrupt. */
170
- function readTasks(featureName) {
171
- try {
172
- const tasksPath = getAbsoluteOutputPath(process.cwd(), featureName, 'tasks');
173
- if (!existsSync(tasksPath)) return [];
174
- return JSON.parse(readFileSync(tasksPath, 'utf8')).tasks || [];
175
- } catch {
176
- return [];
177
- }
178
- }
191
+ /**
192
+ * The options that make a FEATURE-scope run partial, each with the `reason` it
193
+ * reports when it keeps the stamp from being written — first match wins.
194
+ */
195
+ const PARTIAL_RUN_FLAGS = [['filter', 'filtered'], ['skipTests', 'skip-tests'], ['skipBuild', 'skip-build']];
179
196
 
180
197
  /**
181
198
  * `morph-spec verify <feature> [task]` — the deterministic code-before-judge
@@ -194,7 +211,8 @@ function readTasks(featureName) {
194
211
  * `morph-spec score` gives for the same situation);
195
212
  * - `--filter` that matches no declared node (a filtered-to-empty run is
196
213
  * green and proves nothing);
197
- * - `--record-baseline` with nowhere to write (no `config.json`);
214
+ * - `--record-baseline` with nowhere to write (no `config.json`), or with a
215
+ * task id (a recorte de task is never a fleet baseline, #87);
198
216
  * - `--linux` without Docker.
199
217
  * The last three arrive as `VerifyUsageError` from the runner.
200
218
  */
@@ -210,9 +228,10 @@ export async function verifyCommand(featureName, taskId, options = {}) {
210
228
  process.exit(2);
211
229
  }
212
230
 
231
+ // The runner's own reader (one reader of tasks.json for verify, #87): null
232
+ // means tasks.json or the task is missing, and both are this usage error.
213
233
  if (taskId) {
214
- const tasks = readTasks(featureName);
215
- if (!tasks.some((t) => t.id === taskId)) {
234
+ if (!readTask(process.cwd(), featureName, taskId)) {
216
235
  console.error(chalk.red(`\n❌ Task "${taskId}" not found in tasks.json for feature "${featureName}"\n`));
217
236
  process.exit(2);
218
237
  }
@@ -232,6 +251,9 @@ export async function verifyCommand(featureName, taskId, options = {}) {
232
251
  refreshCache: options.refreshCache,
233
252
  linux: options.linux,
234
253
  linuxImage: options.linuxImage,
254
+ // Task scope: switch the recorte off (#87). At feature scope nothing is
255
+ // ever narrowed, so the flag is a documented no-op there, not an error.
256
+ full: options.full,
235
257
  });
236
258
  } catch (err) {
237
259
  // A wrong invocation is exit 2, like an unknown feature or task — never a
@@ -249,9 +271,29 @@ export async function verifyCommand(featureName, taskId, options = {}) {
249
271
  // the suite in order to write `expectedCount`. Persisting a `verification`
250
272
  // stamp from it would let a maintenance run stand in for the proof a gate
251
273
  // asks for — the same class of confusion as a hand-typed baseline.
274
+ //
275
+ // `--filter` at FEATURE scope is the other side of the same hole (#87): the
276
+ // run is narrowed to a subset of nodes, but the stamp it would write carries
277
+ // `scope: 'feature'` — the one scope risk-detector.js trusts unconditionally.
278
+ // A partial run must not stand in for the proof a gate asks for, same as
279
+ // `--record-baseline` above. `--skip-tests` and `--skip-build` are the same
280
+ // partial run through another door (#113): a skipped node never reddens the
281
+ // rollup, so the stamp said `pass` over a suite (or a build) that never ran,
282
+ // and no gate rule reads a skipped `tests`/`build`. Task scope is unaffected:
283
+ // that stamp already says `scope: 'task:{id}'` (which pauses the ship gate on
284
+ // its own), and `taskVerifications` never claimed to cover more than the
285
+ // task. Checked AFTER `--no-persist` (via the `!options.noPersist` guard) so
286
+ // an explicit `--no-persist` keeps its own reason instead of being reported
287
+ // as one of these — the flags are all "don't write", but decidePersistence()
288
+ // already owns the former's reason.
289
+ const partialFeatureRun = (!options.noPersist && !taskId)
290
+ ? PARTIAL_RUN_FLAGS.find(([flag]) => options[flag])?.[1] ?? null
291
+ : null;
252
292
  const { persist, reason } = options.recordBaseline
253
293
  ? { persist: false, reason: 'record-baseline' }
254
- : decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
294
+ : partialFeatureRun
295
+ ? { persist: false, reason: partialFeatureRun }
296
+ : decidePersistence({ cwd: process.cwd(), featureName, noPersist: options.noPersist });
255
297
 
256
298
  let compact = null;
257
299
  if (persist) {
@@ -72,6 +72,13 @@ export const OUTPUT_SCHEMA = {
72
72
 
73
73
  // 3-implement/
74
74
  recap: { filename: 'recap.md', phaseDir: '3-implement', phase: 'implement', protected: false },
75
+ // N files, one per task whose doneCriteria judges what a MODEL answers
76
+ // (morph-plan §5). Runtime evidence: input, model, output, verdict — pointing
77
+ // at the `runner: "evals"` node when the stack declares one. CONDITIONAL by
78
+ // construction: never in phases.json requiredOutputs. Readers: morph-review
79
+ // dispatch prompt, proof rubric eval-de-modelo, test-coverage Q7.
80
+ evidencias: { dirname: 'evidencias', filenamePattern: '^T\\d+\\.md$', displayFilename: '{taskId}.md',
81
+ phaseDir: '3-implement', phase: 'implement', protected: false, multi: true },
75
82
 
76
83
  // 4-review/
77
84
  reviewReport: { filename: 'review-report.md', phaseDir: '4-review', phase: 'review', protected: false },
@@ -1,217 +1,225 @@
1
- /**
2
- * @fileoverview Project Detector
3
- *
4
- * Scans a project directory for stack, architecture, UI library, and integrations.
5
- * Used by mcp-setup, morph-init, and session-start hooks.
6
- *
7
- * @module detectors/index
8
- */
9
-
10
- import { existsSync, readFileSync, readdirSync } from 'fs';
11
- import { join } from 'path';
12
-
13
- /**
14
- * Detect project structure from filesystem evidence.
15
- *
16
- * @param {string} targetPath - Root path of the project
17
- * @param {Object} [opts] - Options
18
- * @param {boolean} [opts.generateStandards=false] - Whether to generate inferred markdown
19
- * @returns {{ structure: { stack: string, architecture: string|null, uiLibrary: string|null, integrations: string[], hasVercel: boolean }, inferred: { markdown: string } }}
20
- */
21
- export function detectProject(targetPath, opts = {}) {
22
- const structure = {
23
- stack: 'unknown',
24
- architecture: null,
25
- uiLibrary: null,
26
- integrations: [],
27
- hasVercel: false,
28
- };
29
-
30
- try {
31
- // ── Package.json analysis ──────────────────────────────────────────────
32
- // Collect deps from root and common monorepo subfolders
33
- const pkgLocations = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
34
- const allDeps = {};
35
- let rootPkg = null;
36
- let scripts = [];
37
- for (const loc of pkgLocations) {
38
- const pkgPath = join(targetPath, loc, 'package.json');
39
- if (!existsSync(pkgPath)) continue;
40
- try {
41
- const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
42
- if (loc === '.') rootPkg = pkg;
43
- Object.assign(allDeps, pkg.dependencies, pkg.devDependencies);
44
- scripts.push(...Object.keys(pkg.scripts || {}));
45
- // Track frontend path for config
46
- if (loc !== '.' && (pkg.dependencies?.next || pkg.dependencies?.react || pkg.dependencies?.vue)) {
47
- structure.frontendPath = loc;
48
- }
49
- } catch { /* invalid JSON */ }
50
- }
51
- const depNames = Object.keys(allDeps);
52
-
53
- if (depNames.length > 0) {
54
-
55
- // Stack detection from deps
56
- if (depNames.includes('next')) {
57
- structure.stack = 'nextjs';
58
- } else if (depNames.includes('react')) {
59
- structure.stack = 'react';
60
- } else if (depNames.includes('vue')) {
61
- structure.stack = 'vue';
62
- } else if (depNames.includes('express') || depNames.includes('fastify') || depNames.includes('hono')) {
63
- structure.stack = 'nodejs';
64
- } else if (rootPkg?.name) {
65
- structure.stack = 'nodejs';
66
- }
67
-
68
- // UI library detection
69
- if (existsSync(join(targetPath, 'components.json')) || existsSync(join(targetPath, structure.frontendPath || '.', 'components.json')) || depNames.includes('@shadcn/ui')) {
70
- structure.uiLibrary = 'shadcn';
71
- }
72
-
73
- // Integrations
74
- if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
75
- structure.integrations.push('neon');
76
- }
77
- if (depNames.includes('@neondatabase/auth')) {
78
- structure.integrations.push('neon-auth');
79
- }
80
- if (depNames.includes('stripe')) {
81
- structure.integrations.push('stripe');
82
- }
83
-
84
- // Vercel detection
85
- if (depNames.includes('vercel') || scripts.includes('vercel')) {
86
- structure.hasVercel = true;
87
- }
88
- }
89
-
90
- // ── .csproj detection ──────────────────────────────────────────────────
91
- let hasDotnet = false;
92
- const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
93
-
94
- function checkCsproj(filePath) {
95
- hasDotnet = true;
96
- try {
97
- const content = readFileSync(filePath, 'utf8');
98
- if (content.includes('Hangfire')) {
99
- if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
100
- }
101
- } catch { /* ignore */ }
102
- }
103
-
104
- for (const loc of csprojLocations) {
105
- try {
106
- const dir = join(targetPath, loc);
107
- if (!existsSync(dir)) continue;
108
- const entries = readdirSyncSafe(dir);
109
- // Direct .csproj files
110
- for (const e of entries.filter(f => f.endsWith('.csproj'))) {
111
- checkCsproj(join(dir, e));
112
- }
113
- // One level deeper (e.g., src/backend/MyProject/*.csproj)
114
- for (const sub of entries) {
115
- const subDir = join(dir, sub);
116
- for (const e of readdirSyncSafe(subDir).filter(f => f.endsWith('.csproj'))) {
117
- checkCsproj(join(subDir, e));
118
- }
119
- }
120
- } catch { /* ignore */ }
121
- }
122
-
123
- // Determine stack: monorepo (nextjs+dotnet), dotnet, or keep frontend stack
124
- if (hasDotnet) {
125
- if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
126
- // Monorepo: frontend + .NET backend
127
- structure.stack = `${structure.stack}+dotnet`;
128
- } else if (structure.stack === 'unknown') {
129
- structure.stack = 'dotnet';
130
- }
131
- }
132
-
133
- // ── vercel.json detection ──────────────────────────────────────────────
134
- if (existsSync(join(targetPath, 'vercel.json'))) {
135
- structure.hasVercel = true;
136
- }
137
-
138
- // ── tsconfig.json detection (fallback for stack) ────────────────────────
139
- if (structure.stack === 'unknown' && existsSync(join(targetPath, 'tsconfig.json'))) {
140
- structure.stack = 'nodejs';
141
- }
142
-
143
- // ── Architecture detection ─────────────────────────────────────────────
144
- try {
145
- const configPath = join(targetPath, '.morph/config/config.json');
146
- if (existsSync(configPath)) {
147
- const config = JSON.parse(readFileSync(configPath, 'utf8'));
148
- if (config.architecture?.style) {
149
- structure.architecture = config.architecture.style;
150
- }
151
- }
152
- } catch { /* ignore */ }
153
-
154
- // ── Docker detection ───────────────────────────────────────────────────
155
- if (existsSync(join(targetPath, 'docker-compose.yml')) ||
156
- existsSync(join(targetPath, 'docker-compose.yaml')) ||
157
- existsSync(join(targetPath, 'Dockerfile'))) {
158
- if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
159
- }
160
-
161
- // ── Azure detection ────────────────────────────────────────────────────
162
- const azureSignals = [
163
- '.azure',
164
- 'azure-pipelines.yml',
165
- 'infra/main.bicep',
166
- 'infra',
167
- ];
168
- const hasAzure = azureSignals.some(signal => {
169
- const p = join(targetPath, signal);
170
- if (!existsSync(p)) return false;
171
- if (signal === 'infra') {
172
- // Check for .bicep files inside infra/
173
- return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
174
- }
175
- return true;
176
- });
177
- if (hasAzure && !structure.integrations.includes('azure')) {
178
- structure.integrations.push('azure');
179
- }
180
- } catch {
181
- // Fail-safe — return defaults
182
- }
183
-
184
- return {
185
- structure,
186
- inferred: {
187
- markdown: opts.generateStandards ? generateInferredMarkdown(structure) : '',
188
- },
189
- };
190
- }
191
-
192
- /**
193
- * Safe readdir that returns [] on error.
194
- * @param {string} dir
195
- * @returns {string[]}
196
- */
197
- function readdirSyncSafe(dir) {
198
- try {
199
- return readdirSync(dir);
200
- } catch {
201
- return [];
202
- }
203
- }
204
-
205
- /**
206
- * Generate a markdown summary from detected structure.
207
- * @param {Object} structure
208
- * @returns {string}
209
- */
210
- function generateInferredMarkdown(structure) {
211
- const lines = [`## Detected Stack: ${structure.stack}`];
212
- if (structure.architecture) lines.push(`Architecture: ${structure.architecture}`);
213
- if (structure.uiLibrary) lines.push(`UI Library: ${structure.uiLibrary}`);
214
- if (structure.integrations.length > 0) lines.push(`Integrations: ${structure.integrations.join(', ')}`);
215
- if (structure.hasVercel) lines.push(`Vercel: detected`);
216
- return lines.join('\n');
217
- }
1
+ /**
2
+ * @fileoverview Project Detector
3
+ *
4
+ * Scans a project directory for stack, architecture, UI library, and integrations.
5
+ * Used by mcp-setup, morph-init, and session-start hooks.
6
+ *
7
+ * @module detectors/index
8
+ */
9
+
10
+ import { existsSync, readFileSync, readdirSync } from 'fs';
11
+ import { join } from 'path';
12
+
13
+ /**
14
+ * Common monorepo locations for a frontend `package.json`, root included.
15
+ *
16
+ * Exported so `discoverFrontendPackages()` (src/lib/tasks/test-nodes.js, #87
17
+ * PR B) scans the SAME candidate list this detector already uses — one
18
+ * spelling of "where a frontend package might live", not two that drift.
19
+ */
20
+ export const PACKAGE_LOCATIONS = ['.', 'src/frontend', 'src/web', 'frontend', 'apps/web', 'packages/app'];
21
+
22
+ /**
23
+ * Detect project structure from filesystem evidence.
24
+ *
25
+ * @param {string} targetPath - Root path of the project
26
+ * @param {Object} [opts] - Options
27
+ * @param {boolean} [opts.generateStandards=false] - Whether to generate inferred markdown
28
+ * @returns {{ structure: { stack: string, architecture: string|null, uiLibrary: string|null, integrations: string[], hasVercel: boolean }, inferred: { markdown: string } }}
29
+ */
30
+ export function detectProject(targetPath, opts = {}) {
31
+ const structure = {
32
+ stack: 'unknown',
33
+ architecture: null,
34
+ uiLibrary: null,
35
+ integrations: [],
36
+ hasVercel: false,
37
+ };
38
+
39
+ try {
40
+ // ── Package.json analysis ──────────────────────────────────────────────
41
+ // Collect deps from root and common monorepo subfolders
42
+ const allDeps = {};
43
+ let rootPkg = null;
44
+ let scripts = [];
45
+ for (const loc of PACKAGE_LOCATIONS) {
46
+ const pkgPath = join(targetPath, loc, 'package.json');
47
+ if (!existsSync(pkgPath)) continue;
48
+ try {
49
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf8'));
50
+ if (loc === '.') rootPkg = pkg;
51
+ Object.assign(allDeps, pkg.dependencies, pkg.devDependencies);
52
+ scripts.push(...Object.keys(pkg.scripts || {}));
53
+ // Track frontend path for config
54
+ if (loc !== '.' && (pkg.dependencies?.next || pkg.dependencies?.react || pkg.dependencies?.vue)) {
55
+ structure.frontendPath = loc;
56
+ }
57
+ } catch { /* invalid JSON */ }
58
+ }
59
+ const depNames = Object.keys(allDeps);
60
+
61
+ if (depNames.length > 0) {
62
+
63
+ // Stack detection from deps
64
+ if (depNames.includes('next')) {
65
+ structure.stack = 'nextjs';
66
+ } else if (depNames.includes('react')) {
67
+ structure.stack = 'react';
68
+ } else if (depNames.includes('vue')) {
69
+ structure.stack = 'vue';
70
+ } else if (depNames.includes('express') || depNames.includes('fastify') || depNames.includes('hono')) {
71
+ structure.stack = 'nodejs';
72
+ } else if (rootPkg?.name) {
73
+ structure.stack = 'nodejs';
74
+ }
75
+
76
+ // UI library detection
77
+ if (existsSync(join(targetPath, 'components.json')) || existsSync(join(targetPath, structure.frontendPath || '.', 'components.json')) || depNames.includes('@shadcn/ui')) {
78
+ structure.uiLibrary = 'shadcn';
79
+ }
80
+
81
+ // Integrations
82
+ if (depNames.includes('@neondatabase/serverless') || depNames.includes('postgres') || depNames.includes('pg')) {
83
+ structure.integrations.push('neon');
84
+ }
85
+ if (depNames.includes('@neondatabase/auth')) {
86
+ structure.integrations.push('neon-auth');
87
+ }
88
+ if (depNames.includes('stripe')) {
89
+ structure.integrations.push('stripe');
90
+ }
91
+
92
+ // Vercel detection
93
+ if (depNames.includes('vercel') || scripts.includes('vercel')) {
94
+ structure.hasVercel = true;
95
+ }
96
+ }
97
+
98
+ // ── .csproj detection ──────────────────────────────────────────────────
99
+ let hasDotnet = false;
100
+ const csprojLocations = ['.', 'src', 'src/Api', 'src/Web', 'src/backend'];
101
+
102
+ function checkCsproj(filePath) {
103
+ hasDotnet = true;
104
+ try {
105
+ const content = readFileSync(filePath, 'utf8');
106
+ if (content.includes('Hangfire')) {
107
+ if (!structure.integrations.includes('hangfire')) structure.integrations.push('hangfire');
108
+ }
109
+ } catch { /* ignore */ }
110
+ }
111
+
112
+ for (const loc of csprojLocations) {
113
+ try {
114
+ const dir = join(targetPath, loc);
115
+ if (!existsSync(dir)) continue;
116
+ const entries = readdirSyncSafe(dir);
117
+ // Direct .csproj files
118
+ for (const e of entries.filter(f => f.endsWith('.csproj'))) {
119
+ checkCsproj(join(dir, e));
120
+ }
121
+ // One level deeper (e.g., src/backend/MyProject/*.csproj)
122
+ for (const sub of entries) {
123
+ const subDir = join(dir, sub);
124
+ for (const e of readdirSyncSafe(subDir).filter(f => f.endsWith('.csproj'))) {
125
+ checkCsproj(join(subDir, e));
126
+ }
127
+ }
128
+ } catch { /* ignore */ }
129
+ }
130
+
131
+ // Determine stack: monorepo (nextjs+dotnet), dotnet, or keep frontend stack
132
+ if (hasDotnet) {
133
+ if (structure.stack === 'nextjs' || structure.stack === 'react' || structure.stack === 'vue') {
134
+ // Monorepo: frontend + .NET backend
135
+ structure.stack = `${structure.stack}+dotnet`;
136
+ } else if (structure.stack === 'unknown') {
137
+ structure.stack = 'dotnet';
138
+ }
139
+ }
140
+
141
+ // ── vercel.json detection ──────────────────────────────────────────────
142
+ if (existsSync(join(targetPath, 'vercel.json'))) {
143
+ structure.hasVercel = true;
144
+ }
145
+
146
+ // ── tsconfig.json detection (fallback for stack) ────────────────────────
147
+ if (structure.stack === 'unknown' && existsSync(join(targetPath, 'tsconfig.json'))) {
148
+ structure.stack = 'nodejs';
149
+ }
150
+
151
+ // ── Architecture detection ─────────────────────────────────────────────
152
+ try {
153
+ const configPath = join(targetPath, '.morph/config/config.json');
154
+ if (existsSync(configPath)) {
155
+ const config = JSON.parse(readFileSync(configPath, 'utf8'));
156
+ if (config.architecture?.style) {
157
+ structure.architecture = config.architecture.style;
158
+ }
159
+ }
160
+ } catch { /* ignore */ }
161
+
162
+ // ── Docker detection ───────────────────────────────────────────────────
163
+ if (existsSync(join(targetPath, 'docker-compose.yml')) ||
164
+ existsSync(join(targetPath, 'docker-compose.yaml')) ||
165
+ existsSync(join(targetPath, 'Dockerfile'))) {
166
+ if (!structure.integrations.includes('docker')) structure.integrations.push('docker');
167
+ }
168
+
169
+ // ── Azure detection ────────────────────────────────────────────────────
170
+ const azureSignals = [
171
+ '.azure',
172
+ 'azure-pipelines.yml',
173
+ 'infra/main.bicep',
174
+ 'infra',
175
+ ];
176
+ const hasAzure = azureSignals.some(signal => {
177
+ const p = join(targetPath, signal);
178
+ if (!existsSync(p)) return false;
179
+ if (signal === 'infra') {
180
+ // Check for .bicep files inside infra/
181
+ return readdirSyncSafe(p).some(f => f.endsWith('.bicep'));
182
+ }
183
+ return true;
184
+ });
185
+ if (hasAzure && !structure.integrations.includes('azure')) {
186
+ structure.integrations.push('azure');
187
+ }
188
+ } catch {
189
+ // Fail-safe — return defaults
190
+ }
191
+
192
+ return {
193
+ structure,
194
+ inferred: {
195
+ markdown: opts.generateStandards ? generateInferredMarkdown(structure) : '',
196
+ },
197
+ };
198
+ }
199
+
200
+ /**
201
+ * Safe readdir that returns [] on error.
202
+ * @param {string} dir
203
+ * @returns {string[]}
204
+ */
205
+ function readdirSyncSafe(dir) {
206
+ try {
207
+ return readdirSync(dir);
208
+ } catch {
209
+ return [];
210
+ }
211
+ }
212
+
213
+ /**
214
+ * Generate a markdown summary from detected structure.
215
+ * @param {Object} structure
216
+ * @returns {string}
217
+ */
218
+ function generateInferredMarkdown(structure) {
219
+ const lines = [`## Detected Stack: ${structure.stack}`];
220
+ if (structure.architecture) lines.push(`Architecture: ${structure.architecture}`);
221
+ if (structure.uiLibrary) lines.push(`UI Library: ${structure.uiLibrary}`);
222
+ if (structure.integrations.length > 0) lines.push(`Integrations: ${structure.integrations.join(', ')}`);
223
+ if (structure.hasVercel) lines.push(`Vercel: detected`);
224
+ return lines.join('\n');
225
+ }