@massu/core 1.2.0 → 1.3.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.
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -225,6 +225,7 @@ var PythonConfigSchema = z.object({
225
225
  var PathsConfigSchema = z.object({
226
226
  source: z.string().default("src"),
227
227
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
228
+ monorepo_roots: z.array(z.string()).optional(),
228
229
  routers: z.string().optional(),
229
230
  routerRoot: z.string().optional(),
230
231
  pages: z.string().optional(),
@@ -383,13 +384,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
383
384
  name: parsed.project.name,
384
385
  root: projectRoot
385
386
  },
387
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
388
+ // `framework.python`) survive into the consumer-visible Config. Then override
389
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
390
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
391
+ // top-level passthrough language blocks.
386
392
  framework: {
387
- type: fw.type,
393
+ ...fw,
388
394
  router,
389
395
  orm,
390
- ui,
391
- primary: fw.primary,
392
- languages: fw.languages
396
+ ui
393
397
  },
394
398
  paths: parsed.paths,
395
399
  toolPrefix: parsed.toolPrefix,
@@ -224,6 +224,7 @@ var PythonConfigSchema = z.object({
224
224
  var PathsConfigSchema = z.object({
225
225
  source: z.string().default("src"),
226
226
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
227
+ monorepo_roots: z.array(z.string()).optional(),
227
228
  routers: z.string().optional(),
228
229
  routerRoot: z.string().optional(),
229
230
  pages: z.string().optional(),
@@ -382,13 +383,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
382
383
  name: parsed.project.name,
383
384
  root: projectRoot
384
385
  },
386
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
387
+ // `framework.python`) survive into the consumer-visible Config. Then override
388
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
389
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
390
+ // top-level passthrough language blocks.
385
391
  framework: {
386
- type: fw.type,
392
+ ...fw,
387
393
  router,
388
394
  orm,
389
- ui,
390
- primary: fw.primary,
391
- languages: fw.languages
395
+ ui
392
396
  },
393
397
  paths: parsed.paths,
394
398
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -225,6 +225,7 @@ var PythonConfigSchema = z.object({
225
225
  var PathsConfigSchema = z.object({
226
226
  source: z.string().default("src"),
227
227
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
228
+ monorepo_roots: z.array(z.string()).optional(),
228
229
  routers: z.string().optional(),
229
230
  routerRoot: z.string().optional(),
230
231
  pages: z.string().optional(),
@@ -383,13 +384,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
383
384
  name: parsed.project.name,
384
385
  root: projectRoot
385
386
  },
387
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
388
+ // `framework.python`) survive into the consumer-visible Config. Then override
389
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
390
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
391
+ // top-level passthrough language blocks.
386
392
  framework: {
387
- type: fw.type,
393
+ ...fw,
388
394
  router,
389
395
  orm,
390
- ui,
391
- primary: fw.primary,
392
- languages: fw.languages
396
+ ui
393
397
  },
394
398
  paths: parsed.paths,
395
399
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -224,6 +224,7 @@ var PythonConfigSchema = z.object({
224
224
  var PathsConfigSchema = z.object({
225
225
  source: z.string().default("src"),
226
226
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
227
+ monorepo_roots: z.array(z.string()).optional(),
227
228
  routers: z.string().optional(),
228
229
  routerRoot: z.string().optional(),
229
230
  pages: z.string().optional(),
@@ -382,13 +383,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
382
383
  name: parsed.project.name,
383
384
  root: projectRoot
384
385
  },
386
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
387
+ // `framework.python`) survive into the consumer-visible Config. Then override
388
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
389
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
390
+ // top-level passthrough language blocks.
385
391
  framework: {
386
- type: fw.type,
392
+ ...fw,
387
393
  router,
388
394
  orm,
389
- ui,
390
- primary: fw.primary,
391
- languages: fw.languages
395
+ ui
392
396
  },
393
397
  paths: parsed.paths,
394
398
  toolPrefix: parsed.toolPrefix,
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
@@ -6002,6 +6002,7 @@ var PythonConfigSchema = z.object({
6002
6002
  var PathsConfigSchema = z.object({
6003
6003
  source: z.string().default("src"),
6004
6004
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
6005
+ monorepo_roots: z.array(z.string()).optional(),
6005
6006
  routers: z.string().optional(),
6006
6007
  routerRoot: z.string().optional(),
6007
6008
  pages: z.string().optional(),
@@ -6160,13 +6161,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
6160
6161
  name: parsed.project.name,
6161
6162
  root: projectRoot
6162
6163
  },
6164
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
6165
+ // `framework.python`) survive into the consumer-visible Config. Then override
6166
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
6167
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
6168
+ // top-level passthrough language blocks.
6163
6169
  framework: {
6164
- type: fw.type,
6170
+ ...fw,
6165
6171
  router,
6166
6172
  orm,
6167
- ui,
6168
- primary: fw.primary,
6169
- languages: fw.languages
6173
+ ui
6170
6174
  },
6171
6175
  paths: parsed.paths,
6172
6176
  toolPrefix: parsed.toolPrefix,
@@ -8263,6 +8267,13 @@ var TEST_DIR_KEYWORDS = ["tests", "test", "__tests__", "spec", "specs"];
8263
8267
  function extsFor(language) {
8264
8268
  return EXTENSIONS[language] ?? [];
8265
8269
  }
8270
+ function extsWithFallback(language, fallbackTsForJs) {
8271
+ const base = extsFor(language);
8272
+ if (language === "javascript" && fallbackTsForJs) {
8273
+ return [...base, "ts", "tsx"];
8274
+ }
8275
+ return base;
8276
+ }
8266
8277
  function isTestPath(language, path) {
8267
8278
  const segments = path.split("/");
8268
8279
  for (const seg of segments) {
@@ -8284,10 +8295,11 @@ function isInsideRoot(root, candidate) {
8284
8295
  return false;
8285
8296
  }
8286
8297
  }
8287
- function detectSourceDirs(projectRoot, languages) {
8298
+ function detectSourceDirs(projectRoot, languages, opts) {
8299
+ const fallbackTsForJs = opts?.fallbackTsForJs ?? false;
8288
8300
  const out = {};
8289
8301
  for (const lang of languages) {
8290
- const exts = extsFor(lang);
8302
+ const exts = extsWithFallback(lang, fallbackTsForJs);
8291
8303
  if (exts.length === 0) continue;
8292
8304
  const patterns = exts.map((e) => `**/*.${e}`);
8293
8305
  let files;
@@ -8808,8 +8820,9 @@ async function runDetection(projectRoot, overrides) {
8808
8820
  const languages = Array.from(
8809
8821
  new Set(pkg.manifests.map((m) => m.language))
8810
8822
  );
8823
+ const fallbackTsForJs = languages.includes("javascript") && !languages.includes("typescript");
8811
8824
  const [sourceDirs, monorepo] = await Promise.all([
8812
- Promise.resolve(detectSourceDirs(projectRoot, languages)),
8825
+ Promise.resolve(detectSourceDirs(projectRoot, languages, { fallbackTsForJs })),
8813
8826
  Promise.resolve(detectMonorepo(projectRoot))
8814
8827
  ]);
8815
8828
  const domains = inferDomains(projectRoot, monorepo, sourceDirs);
@@ -226,6 +226,7 @@ var PythonConfigSchema = z.object({
226
226
  var PathsConfigSchema = z.object({
227
227
  source: z.string().default("src"),
228
228
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
229
+ monorepo_roots: z.array(z.string()).optional(),
229
230
  routers: z.string().optional(),
230
231
  routerRoot: z.string().optional(),
231
232
  pages: z.string().optional(),
@@ -384,13 +385,16 @@ Hint: run \`massu config refresh\` to regenerate a valid config or fix the liste
384
385
  name: parsed.project.name,
385
386
  root: projectRoot
386
387
  },
388
+ // Spread `fw` first so zod-`.passthrough()` extras (e.g., `framework.swift`,
389
+ // `framework.python`) survive into the consumer-visible Config. Then override
390
+ // the v2-backcompat-mirrored router/orm/ui values. Without the spread, the
391
+ // variant-resolution `pickVariant` (install-commands.ts) cannot see the
392
+ // top-level passthrough language blocks.
387
393
  framework: {
388
- type: fw.type,
394
+ ...fw,
389
395
  router,
390
396
  orm,
391
- ui,
392
- primary: fw.primary,
393
- languages: fw.languages
397
+ ui
394
398
  },
395
399
  paths: parsed.paths,
396
400
  toolPrefix: parsed.toolPrefix,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.2.0",
3
+ "version": "1.3.0",
4
4
  "type": "module",
5
5
  "description": "AI Engineering Governance MCP Server - Session memory, knowledge system, feature registry, code intelligence, rule enforcement, tiered tooling (12 free / 72 total), 55+ workflow commands, 11 agents, 20+ patterns",
6
6
  "main": "src/server.ts",
package/src/cli.ts CHANGED
@@ -47,6 +47,11 @@ async function main(): Promise<void> {
47
47
  await runInstallCommands();
48
48
  break;
49
49
  }
50
+ case 'show-template': {
51
+ const { runShowTemplate } = await import('./commands/show-template.ts');
52
+ await runShowTemplate(args.slice(1));
53
+ break;
54
+ }
50
55
  case 'validate-config': {
51
56
  const { runValidateConfig } = await import('./commands/doctor.ts');
52
57
  await runValidateConfig();
@@ -136,6 +141,7 @@ Commands:
136
141
  doctor Check installation health
137
142
  install-hooks Install/update Claude Code hooks
138
143
  install-commands Install/update slash commands
144
+ show-template Print the resolved variant of a bundled template (e.g. for diffs)
139
145
  validate-config Validate massu.config.yaml (alias: config validate)
140
146
  config <sub> Config lifecycle: refresh | validate | upgrade | doctor | check-drift
141
147
 
@@ -231,7 +231,18 @@ export function detectPython(projectRoot: string): PythonDetection {
231
231
  // Legacy Config File Generation (preserved for cli.test.ts)
232
232
  // ============================================================
233
233
 
234
+ /**
235
+ * @deprecated Since @massu/core@1.2.1. Use {@link buildConfigFromDetection}
236
+ * with {@link runDetection} for monorepo-aware path resolution and
237
+ * schema_version=2 output. This path hardcodes `paths.source = 'src'` and
238
+ * cannot emit `paths.monorepo_roots`, so it would roll back on every
239
+ * non-`src/` layout. Kept only for the legacy `cli.test.ts` smoke tests;
240
+ * new callers must use the v2 builder.
241
+ */
234
242
  export function generateConfig(projectRoot: string, framework: FrameworkDetection): boolean {
243
+ console.warn(
244
+ '[@massu/core] generateConfig() is deprecated since 1.2.1 — use buildConfigFromDetection instead. It cannot produce valid configs for monorepos.'
245
+ );
235
246
  const configPath = resolve(projectRoot, 'massu.config.yaml');
236
247
 
237
248
  if (existsSync(configPath)) {
@@ -294,6 +305,37 @@ ${yamlStringify(config)}`;
294
305
  // V2 Config Builder (detection-driven)
295
306
  // ============================================================
296
307
 
308
+ /**
309
+ * Return the common top-level parent directory across every workspace
310
+ * package. Returns `'.'` when packages span multiple parents (e.g. a repo
311
+ * with both `apps/*` and `packages/*`) — the project root is always a valid
312
+ * paths.source value (see validateWrittenConfig at init.ts:572).
313
+ */
314
+ function monorepoCommonRoot(
315
+ packages: ReadonlyArray<{ path: string }>
316
+ ): string {
317
+ const roots = monorepoDistinctRoots(packages);
318
+ return roots.length === 1 ? roots[0] : '.';
319
+ }
320
+
321
+ /**
322
+ * Return the distinct top-level parent directories of every workspace
323
+ * package (e.g. `['apps', 'packages']` when both are present). Sorted for
324
+ * determinism. Excludes root-level ('.') workspaces.
325
+ */
326
+ function monorepoDistinctRoots(
327
+ packages: ReadonlyArray<{ path: string }>
328
+ ): string[] {
329
+ const set = new Set<string>();
330
+ for (const p of packages) {
331
+ const parts = p.path.split('/');
332
+ if (parts.length > 1 && parts[0] !== '' && parts[0] !== '.') {
333
+ set.add(parts[0]);
334
+ }
335
+ }
336
+ return [...set].sort();
337
+ }
338
+
297
339
  /**
298
340
  * Build a schema_version=2 config object from a DetectionResult.
299
341
  *
@@ -364,11 +406,38 @@ export function buildConfigFromDetection(
364
406
  const legacyUi = (primaryEntry?.ui as string | undefined) ?? 'none';
365
407
 
366
408
  // Determine paths.source from primary language's dominant source dir.
409
+ // P1-003: when the primary language has no detectable source dir AND the
410
+ // repo is a monorepo, fall back to the common parent of workspace packages
411
+ // (e.g. 'apps' for turbo + apps/*, 'packages' for pnpm + packages/*). This
412
+ // prevents the validator from rejecting a nonexistent top-level 'src/' on
413
+ // monorepo shapes where code actually lives under apps/ or packages/.
367
414
  let pathsSource = 'src';
368
415
  if (primary) {
369
416
  const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
370
417
  if (primaryDirs.length > 0) {
371
418
  pathsSource = primaryDirs[0];
419
+ } else if (
420
+ detection.monorepo.type !== 'single' &&
421
+ detection.monorepo.packages.length > 0
422
+ ) {
423
+ pathsSource = monorepoCommonRoot(detection.monorepo.packages);
424
+ }
425
+ }
426
+
427
+ // P1-005: emit `paths.monorepo_roots` as the distinct parent directories of
428
+ // every workspace package when this is a monorepo. Optional + additive;
429
+ // v1 consumers ignore it. When detection identified a monorepo type
430
+ // (turbo/nx/pnpm/etc) but no manifested workspace packages were found
431
+ // (e.g. fresh-install fixtures with apps/*/main.py that haven't declared
432
+ // sub-manifests yet), fall back to deriving roots from the resolved
433
+ // paths.source so the field is still accurate for monorepo-aware tools.
434
+ let monorepoRoots: string[] | undefined;
435
+ if (detection.monorepo.type !== 'single') {
436
+ if (detection.monorepo.packages.length > 0) {
437
+ monorepoRoots = monorepoDistinctRoots(detection.monorepo.packages);
438
+ } else if (pathsSource !== 'src' && pathsSource !== '.') {
439
+ // Derive from paths.source when no workspace manifests exist.
440
+ monorepoRoots = [pathsSource];
372
441
  }
373
442
  }
374
443
 
@@ -411,6 +480,14 @@ export function buildConfigFromDetection(
411
480
  frameworkBlock.languages = languageEntries;
412
481
  }
413
482
 
483
+ const pathsBlock: Record<string, unknown> = {
484
+ source: pathsSource,
485
+ aliases: { '@': pathsSource },
486
+ };
487
+ if (monorepoRoots && monorepoRoots.length > 0) {
488
+ pathsBlock.monorepo_roots = monorepoRoots;
489
+ }
490
+
414
491
  const config: Record<string, unknown> = {
415
492
  schema_version: 2,
416
493
  project: {
@@ -418,10 +495,7 @@ export function buildConfigFromDetection(
418
495
  root: 'auto',
419
496
  },
420
497
  framework: frameworkBlock,
421
- paths: {
422
- source: pathsSource,
423
- aliases: { '@': pathsSource },
424
- },
498
+ paths: pathsBlock,
425
499
  toolPrefix: 'massu',
426
500
  domains,
427
501
  rules: [],
@@ -588,6 +662,17 @@ export function validateWrittenConfig(
588
662
  }
589
663
  }
590
664
  }
665
+ // P2-001: verify paths.monorepo_roots entries exist on disk (parity
666
+ // with paths.source existence check at line 624-631 above).
667
+ const mRoots = (cfg.paths as Record<string, unknown>).monorepo_roots;
668
+ if (Array.isArray(mRoots)) {
669
+ for (const r of mRoots) {
670
+ if (typeof r !== 'string' || r === '.') continue;
671
+ if (!existsSync(resolve(projectRoot, r))) {
672
+ return `paths.monorepo_roots '${r}' does not exist on disk`;
673
+ }
674
+ }
675
+ }
591
676
  }
592
677
  } catch (err) {
593
678
  return err instanceof Error ? err.message : String(err);