@massu/core 1.1.0 → 1.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -414,6 +414,7 @@ var init_config = __esm({
414
414
  PathsConfigSchema = z.object({
415
415
  source: z.string().default("src"),
416
416
  aliases: z.record(z.string(), z.string()).default({ "@": "src" }),
417
+ monorepo_roots: z.array(z.string()).optional(),
417
418
  routers: z.string().optional(),
418
419
  routerRoot: z.string().optional(),
419
420
  pages: z.string().optional(),
@@ -9022,6 +9023,13 @@ import { resolve as resolve4 } from "path";
9022
9023
  function extsFor(language) {
9023
9024
  return EXTENSIONS[language] ?? [];
9024
9025
  }
9026
+ function extsWithFallback(language, fallbackTsForJs) {
9027
+ const base = extsFor(language);
9028
+ if (language === "javascript" && fallbackTsForJs) {
9029
+ return [...base, "ts", "tsx"];
9030
+ }
9031
+ return base;
9032
+ }
9025
9033
  function isTestPath(language, path) {
9026
9034
  const segments = path.split("/");
9027
9035
  for (const seg of segments) {
@@ -9043,10 +9051,11 @@ function isInsideRoot(root, candidate) {
9043
9051
  return false;
9044
9052
  }
9045
9053
  }
9046
- function detectSourceDirs(projectRoot, languages) {
9054
+ function detectSourceDirs(projectRoot, languages, opts) {
9055
+ const fallbackTsForJs = opts?.fallbackTsForJs ?? false;
9047
9056
  const out = {};
9048
9057
  for (const lang of languages) {
9049
- const exts = extsFor(lang);
9058
+ const exts = extsWithFallback(lang, fallbackTsForJs);
9050
9059
  if (exts.length === 0) continue;
9051
9060
  const patterns = exts.map((e2) => `**/*.${e2}`);
9052
9061
  let files;
@@ -9644,8 +9653,9 @@ async function runDetection(projectRoot, overrides) {
9644
9653
  const languages = Array.from(
9645
9654
  new Set(pkg.manifests.map((m3) => m3.language))
9646
9655
  );
9656
+ const fallbackTsForJs = languages.includes("javascript") && !languages.includes("typescript");
9647
9657
  const [sourceDirs, monorepo] = await Promise.all([
9648
- Promise.resolve(detectSourceDirs(projectRoot, languages)),
9658
+ Promise.resolve(detectSourceDirs(projectRoot, languages, { fallbackTsForJs })),
9649
9659
  Promise.resolve(detectMonorepo(projectRoot))
9650
9660
  ]);
9651
9661
  const domains = inferDomains(projectRoot, monorepo, sourceDirs);
@@ -10978,6 +10988,9 @@ function detectPython(projectRoot) {
10978
10988
  return result;
10979
10989
  }
10980
10990
  function generateConfig(projectRoot, framework) {
10991
+ console.warn(
10992
+ "[@massu/core] generateConfig() is deprecated since 1.2.1 \u2014 use buildConfigFromDetection instead. It cannot produce valid configs for monorepos."
10993
+ );
10981
10994
  const configPath = resolve5(projectRoot, "massu.config.yaml");
10982
10995
  if (existsSync8(configPath)) {
10983
10996
  return false;
@@ -11028,6 +11041,20 @@ ${yamlStringify(config)}`;
11028
11041
  writeFileSync2(configPath, yamlContent, "utf-8");
11029
11042
  return true;
11030
11043
  }
11044
+ function monorepoCommonRoot(packages) {
11045
+ const roots = monorepoDistinctRoots(packages);
11046
+ return roots.length === 1 ? roots[0] : ".";
11047
+ }
11048
+ function monorepoDistinctRoots(packages) {
11049
+ const set = /* @__PURE__ */ new Set();
11050
+ for (const p19 of packages) {
11051
+ const parts = p19.path.split("/");
11052
+ if (parts.length > 1 && parts[0] !== "" && parts[0] !== ".") {
11053
+ set.add(parts[0]);
11054
+ }
11055
+ }
11056
+ return [...set].sort();
11057
+ }
11031
11058
  function buildConfigFromDetection(opts) {
11032
11059
  const { projectRoot, detection } = opts;
11033
11060
  if (!detection) {
@@ -11072,6 +11099,16 @@ function buildConfigFromDetection(opts) {
11072
11099
  const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
11073
11100
  if (primaryDirs.length > 0) {
11074
11101
  pathsSource = primaryDirs[0];
11102
+ } else if (detection.monorepo.type !== "single" && detection.monorepo.packages.length > 0) {
11103
+ pathsSource = monorepoCommonRoot(detection.monorepo.packages);
11104
+ }
11105
+ }
11106
+ let monorepoRoots;
11107
+ if (detection.monorepo.type !== "single") {
11108
+ if (detection.monorepo.packages.length > 0) {
11109
+ monorepoRoots = monorepoDistinctRoots(detection.monorepo.packages);
11110
+ } else if (pathsSource !== "src" && pathsSource !== ".") {
11111
+ monorepoRoots = [pathsSource];
11075
11112
  }
11076
11113
  }
11077
11114
  const verification = {};
@@ -11108,6 +11145,13 @@ function buildConfigFromDetection(opts) {
11108
11145
  if (Object.keys(languageEntries).length > 0) {
11109
11146
  frameworkBlock.languages = languageEntries;
11110
11147
  }
11148
+ const pathsBlock = {
11149
+ source: pathsSource,
11150
+ aliases: { "@": pathsSource }
11151
+ };
11152
+ if (monorepoRoots && monorepoRoots.length > 0) {
11153
+ pathsBlock.monorepo_roots = monorepoRoots;
11154
+ }
11111
11155
  const config = {
11112
11156
  schema_version: 2,
11113
11157
  project: {
@@ -11115,10 +11159,7 @@ function buildConfigFromDetection(opts) {
11115
11159
  root: "auto"
11116
11160
  },
11117
11161
  framework: frameworkBlock,
11118
- paths: {
11119
- source: pathsSource,
11120
- aliases: { "@": pathsSource }
11121
- },
11162
+ paths: pathsBlock,
11122
11163
  toolPrefix: "massu",
11123
11164
  domains,
11124
11165
  rules: []
@@ -11225,6 +11266,15 @@ function validateWrittenConfig(configPath, projectRoot, checkPaths = true) {
11225
11266
  }
11226
11267
  }
11227
11268
  }
11269
+ const mRoots = cfg.paths.monorepo_roots;
11270
+ if (Array.isArray(mRoots)) {
11271
+ for (const r2 of mRoots) {
11272
+ if (typeof r2 !== "string" || r2 === ".") continue;
11273
+ if (!existsSync8(resolve5(projectRoot, r2))) {
11274
+ return `paths.monorepo_roots '${r2}' does not exist on disk`;
11275
+ }
11276
+ }
11277
+ }
11228
11278
  }
11229
11279
  } catch (err) {
11230
11280
  return err instanceof Error ? err.message : String(err);
@@ -22570,6 +22620,48 @@ var init_server = __esm({
22570
22620
  }
22571
22621
  });
22572
22622
 
22623
+ // src/detect/passthrough.ts
22624
+ function copyUnknownKeys(source, target, handledKeys) {
22625
+ if (source === null || typeof source !== "object" || Array.isArray(source)) {
22626
+ return;
22627
+ }
22628
+ for (const k3 of Object.keys(source)) {
22629
+ if (UNSAFE_KEYS.has(k3)) continue;
22630
+ if (source[k3] === void 0) continue;
22631
+ if (handledKeys.has(k3)) continue;
22632
+ if (Object.prototype.hasOwnProperty.call(target, k3)) continue;
22633
+ target[k3] = safeClone(source[k3]);
22634
+ }
22635
+ }
22636
+ function preserveNestedSubkeys(sourceBlock, targetBlock) {
22637
+ if (sourceBlock === null || sourceBlock === void 0 || typeof sourceBlock !== "object" || Array.isArray(sourceBlock)) {
22638
+ return;
22639
+ }
22640
+ const src = sourceBlock;
22641
+ for (const k3 of Object.keys(src)) {
22642
+ if (UNSAFE_KEYS.has(k3)) continue;
22643
+ if (src[k3] === void 0) continue;
22644
+ if (Object.prototype.hasOwnProperty.call(targetBlock, k3)) continue;
22645
+ targetBlock[k3] = safeClone(src[k3]);
22646
+ }
22647
+ }
22648
+ function safeClone(v3) {
22649
+ if (typeof structuredClone === "function") {
22650
+ try {
22651
+ return structuredClone(v3);
22652
+ } catch {
22653
+ }
22654
+ }
22655
+ return v3;
22656
+ }
22657
+ var UNSAFE_KEYS;
22658
+ var init_passthrough = __esm({
22659
+ "src/detect/passthrough.ts"() {
22660
+ "use strict";
22661
+ UNSAFE_KEYS = /* @__PURE__ */ new Set(["__proto__", "constructor", "prototype"]);
22662
+ }
22663
+ });
22664
+
22573
22665
  // src/commands/config-refresh.ts
22574
22666
  var config_refresh_exports = {};
22575
22667
  __export(config_refresh_exports, {
@@ -22629,6 +22721,67 @@ function mergeRefresh(existing, refreshed) {
22629
22721
  out[field] = existing[field];
22630
22722
  }
22631
22723
  }
22724
+ if (typeof existing.toolPrefix === "string" && existing.toolPrefix !== "") {
22725
+ out.toolPrefix = existing.toolPrefix;
22726
+ }
22727
+ for (const block of ["framework", "paths", "project"]) {
22728
+ const existingBlock = existing[block];
22729
+ const outBlock = out[block];
22730
+ if (existingBlock && typeof existingBlock === "object" && !Array.isArray(existingBlock) && outBlock && typeof outBlock === "object" && !Array.isArray(outBlock)) {
22731
+ preserveNestedSubkeys(existingBlock, outBlock);
22732
+ }
22733
+ }
22734
+ const existingProject = existing.project;
22735
+ const outProject = out.project;
22736
+ if (existingProject && typeof existingProject === "object" && !Array.isArray(existingProject) && outProject && typeof outProject === "object" && !Array.isArray(outProject)) {
22737
+ const userRoot = existingProject.root;
22738
+ if (typeof userRoot === "string" && userRoot !== "") {
22739
+ outProject.root = userRoot;
22740
+ }
22741
+ }
22742
+ const existingPaths = existing.paths;
22743
+ const outPaths = out.paths;
22744
+ if (existingPaths && typeof existingPaths === "object" && !Array.isArray(existingPaths) && outPaths && typeof outPaths === "object" && !Array.isArray(outPaths)) {
22745
+ const existingAliases = existingPaths.aliases;
22746
+ const outAliases = outPaths.aliases;
22747
+ if (existingAliases && typeof existingAliases === "object" && !Array.isArray(existingAliases) && outAliases && typeof outAliases === "object" && !Array.isArray(outAliases)) {
22748
+ outPaths.aliases = {
22749
+ ...outAliases,
22750
+ ...existingAliases
22751
+ };
22752
+ } else if (existingAliases && typeof existingAliases === "object" && !Array.isArray(existingAliases)) {
22753
+ outPaths.aliases = existingAliases;
22754
+ }
22755
+ }
22756
+ const existingVer = existing.verification;
22757
+ const outVer = out.verification;
22758
+ if (existingVer && typeof existingVer === "object" && !Array.isArray(existingVer) && outVer && typeof outVer === "object" && !Array.isArray(outVer)) {
22759
+ const eVer = existingVer;
22760
+ const oVer = outVer;
22761
+ for (const lang of Object.keys(eVer)) {
22762
+ const userLang = eVer[lang];
22763
+ if (userLang === void 0) continue;
22764
+ if (!(lang in oVer)) {
22765
+ oVer[lang] = userLang;
22766
+ } else if (userLang && typeof userLang === "object" && !Array.isArray(userLang) && oVer[lang] && typeof oVer[lang] === "object" && !Array.isArray(oVer[lang])) {
22767
+ oVer[lang] = {
22768
+ ...oVer[lang],
22769
+ ...userLang
22770
+ };
22771
+ }
22772
+ }
22773
+ }
22774
+ const handledTopLevel = /* @__PURE__ */ new Set([
22775
+ "schema_version",
22776
+ "project",
22777
+ "framework",
22778
+ "paths",
22779
+ "toolPrefix",
22780
+ "verification",
22781
+ "detection",
22782
+ ...PRESERVED_FIELDS
22783
+ ]);
22784
+ copyUnknownKeys(existing, out, handledTopLevel);
22632
22785
  return out;
22633
22786
  }
22634
22787
  function renderDiff(diff) {
@@ -22721,6 +22874,7 @@ var init_config_refresh = __esm({
22721
22874
  "use strict";
22722
22875
  init_detect();
22723
22876
  init_drift();
22877
+ init_passthrough();
22724
22878
  init_init();
22725
22879
  PRESERVED_FIELDS = [
22726
22880
  "rules",
@@ -22738,7 +22892,8 @@ var init_config_refresh = __esm({
22738
22892
  "cloud",
22739
22893
  "conventions",
22740
22894
  "autoLearning",
22741
- "python"
22895
+ "python",
22896
+ "toolPrefix"
22742
22897
  ];
22743
22898
  }
22744
22899
  });
@@ -22849,26 +23004,38 @@ function migrateV1ToV2(v1Config, detection) {
22849
23004
  if (Object.keys(languageEntries).length > 0) {
22850
23005
  framework.languages = languageEntries;
22851
23006
  }
23007
+ preserveNestedSubkeys(v1Framework, framework);
22852
23008
  let pathsSource = typeof v1Paths.source === "string" ? v1Paths.source : "src";
22853
23009
  if (pathsSource === "src" && primary) {
22854
23010
  const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
22855
- if (primaryDirs.length > 0) pathsSource = primaryDirs[0];
23011
+ if (primaryDirs.length > 0) {
23012
+ pathsSource = primaryDirs[0];
23013
+ } else if (detection.monorepo?.type !== void 0 && detection.monorepo.type !== "single" && detection.monorepo.packages.length > 0) {
23014
+ pathsSource = monorepoCommonRootMigrate(detection.monorepo.packages);
23015
+ }
22856
23016
  }
22857
23017
  const aliases = v1Paths.aliases && typeof v1Paths.aliases === "object" ? v1Paths.aliases : { "@": pathsSource };
22858
23018
  const paths = {
22859
23019
  source: pathsSource,
22860
23020
  aliases
22861
23021
  };
23022
+ if (detection.monorepo?.type !== void 0 && detection.monorepo.type !== "single" && detection.monorepo.packages.length > 0 && !("monorepo_roots" in v1Paths)) {
23023
+ const roots = monorepoDistinctRootsMigrate(detection.monorepo.packages);
23024
+ if (roots.length > 0) paths.monorepo_roots = roots;
23025
+ }
22862
23026
  for (const k3 of ["routers", "routerRoot", "pages", "middleware", "schema", "components", "hooks"]) {
22863
23027
  if (typeof v1Paths[k3] === "string") paths[k3] = v1Paths[k3];
22864
23028
  }
23029
+ preserveNestedSubkeys(v1Paths, paths);
22865
23030
  const verification = buildVerificationBlock(detection, v1Verification);
23031
+ const project = {
23032
+ name: typeof v1Project.name === "string" ? v1Project.name : "my-project",
23033
+ root: typeof v1Project.root === "string" ? v1Project.root : "auto"
23034
+ };
23035
+ preserveNestedSubkeys(v1Project, project);
22866
23036
  const v22 = {
22867
23037
  schema_version: 2,
22868
- project: {
22869
- name: typeof v1Project.name === "string" ? v1Project.name : "my-project",
22870
- root: typeof v1Project.root === "string" ? v1Project.root : "auto"
22871
- },
23038
+ project,
22872
23039
  framework,
22873
23040
  paths,
22874
23041
  toolPrefix: typeof v1.toolPrefix === "string" ? v1.toolPrefix : "massu"
@@ -22878,6 +23045,17 @@ function migrateV1ToV2(v1Config, detection) {
22878
23045
  v22[field] = v1[field];
22879
23046
  }
22880
23047
  }
23048
+ const handledTopLevel = /* @__PURE__ */ new Set([
23049
+ "schema_version",
23050
+ "project",
23051
+ "framework",
23052
+ "paths",
23053
+ "toolPrefix",
23054
+ "verification",
23055
+ "python",
23056
+ ...PRESERVED_FIELDS2
23057
+ ]);
23058
+ copyUnknownKeys(v1, v22, handledTopLevel);
22881
23059
  if (!Array.isArray(v22.domains)) {
22882
23060
  v22.domains = [];
22883
23061
  }
@@ -22908,16 +23086,32 @@ function migrateV1ToV2(v1Config, detection) {
22908
23086
  } else if (existing.orm !== void 0) {
22909
23087
  pythonBlock.orm = existing.orm;
22910
23088
  }
23089
+ preserveNestedSubkeys(v1.python, pythonBlock);
22911
23090
  v22.python = pythonBlock;
22912
23091
  } else if (v1.python !== void 0) {
22913
23092
  v22.python = v1.python;
22914
23093
  }
22915
23094
  return v22;
22916
23095
  }
23096
+ function monorepoCommonRootMigrate(packages) {
23097
+ const roots = monorepoDistinctRootsMigrate(packages);
23098
+ return roots.length === 1 ? roots[0] : ".";
23099
+ }
23100
+ function monorepoDistinctRootsMigrate(packages) {
23101
+ const set = /* @__PURE__ */ new Set();
23102
+ for (const p19 of packages) {
23103
+ const parts = p19.path.split("/");
23104
+ if (parts.length > 1 && parts[0] !== "" && parts[0] !== ".") {
23105
+ set.add(parts[0]);
23106
+ }
23107
+ }
23108
+ return [...set].sort();
23109
+ }
22917
23110
  var PRESERVED_FIELDS2;
22918
23111
  var init_migrate = __esm({
22919
23112
  "src/detect/migrate.ts"() {
22920
23113
  "use strict";
23114
+ init_passthrough();
22921
23115
  PRESERVED_FIELDS2 = [
22922
23116
  "rules",
22923
23117
  "domains",
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -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(),
@@ -8263,6 +8264,13 @@ var TEST_DIR_KEYWORDS = ["tests", "test", "__tests__", "spec", "specs"];
8263
8264
  function extsFor(language) {
8264
8265
  return EXTENSIONS[language] ?? [];
8265
8266
  }
8267
+ function extsWithFallback(language, fallbackTsForJs) {
8268
+ const base = extsFor(language);
8269
+ if (language === "javascript" && fallbackTsForJs) {
8270
+ return [...base, "ts", "tsx"];
8271
+ }
8272
+ return base;
8273
+ }
8266
8274
  function isTestPath(language, path) {
8267
8275
  const segments = path.split("/");
8268
8276
  for (const seg of segments) {
@@ -8284,10 +8292,11 @@ function isInsideRoot(root, candidate) {
8284
8292
  return false;
8285
8293
  }
8286
8294
  }
8287
- function detectSourceDirs(projectRoot, languages) {
8295
+ function detectSourceDirs(projectRoot, languages, opts) {
8296
+ const fallbackTsForJs = opts?.fallbackTsForJs ?? false;
8288
8297
  const out = {};
8289
8298
  for (const lang of languages) {
8290
- const exts = extsFor(lang);
8299
+ const exts = extsWithFallback(lang, fallbackTsForJs);
8291
8300
  if (exts.length === 0) continue;
8292
8301
  const patterns = exts.map((e) => `**/*.${e}`);
8293
8302
  let files;
@@ -8808,8 +8817,9 @@ async function runDetection(projectRoot, overrides) {
8808
8817
  const languages = Array.from(
8809
8818
  new Set(pkg.manifests.map((m) => m.language))
8810
8819
  );
8820
+ const fallbackTsForJs = languages.includes("javascript") && !languages.includes("typescript");
8811
8821
  const [sourceDirs, monorepo] = await Promise.all([
8812
- Promise.resolve(detectSourceDirs(projectRoot, languages)),
8822
+ Promise.resolve(detectSourceDirs(projectRoot, languages, { fallbackTsForJs })),
8813
8823
  Promise.resolve(detectMonorepo(projectRoot))
8814
8824
  ]);
8815
8825
  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(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.1.0",
3
+ "version": "1.2.1",
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",
@@ -29,6 +29,7 @@ import { parse as parseYaml } from 'yaml';
29
29
  import { runDetection } from '../detect/index.ts';
30
30
  import { computeFingerprint } from '../detect/drift.ts';
31
31
  import type { AnyConfig } from '../detect/migrate.ts';
32
+ import { copyUnknownKeys, preserveNestedSubkeys } from '../detect/passthrough.ts';
32
33
  import { buildConfigFromDetection, renderConfigYaml, writeConfigAtomic } from './init.ts';
33
34
 
34
35
  const PRESERVED_FIELDS = [
@@ -48,6 +49,7 @@ const PRESERVED_FIELDS = [
48
49
  'conventions',
49
50
  'autoLearning',
50
51
  'python',
52
+ 'toolPrefix',
51
53
  ] as const;
52
54
 
53
55
  export interface ConfigRefreshOptions {
@@ -116,12 +118,113 @@ export function computeDiff(before: AnyConfig, after: AnyConfig): DiffLine[] {
116
118
  }
117
119
 
118
120
  export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConfig {
121
+ // P1-008: Start from detector output (fresh framework, paths.source, verification, detection).
119
122
  const out: AnyConfig = { ...refreshed };
123
+
124
+ // Restore user-authored top-level sections verbatim.
120
125
  for (const field of PRESERVED_FIELDS) {
121
126
  if (existing[field] !== undefined) {
122
127
  out[field] = existing[field];
123
128
  }
124
129
  }
130
+
131
+ // Restore toolPrefix from existing (never let detector-defaulted 'massu' overwrite a custom prefix).
132
+ if (typeof existing.toolPrefix === 'string' && existing.toolPrefix !== '') {
133
+ out.toolPrefix = existing.toolPrefix;
134
+ }
135
+
136
+ // For detector-owned blocks (framework, paths, project), preserve any user subkey the detector didn't emit.
137
+ for (const block of ['framework', 'paths', 'project'] as const) {
138
+ const existingBlock = existing[block];
139
+ const outBlock = out[block];
140
+ if (
141
+ existingBlock && typeof existingBlock === 'object' && !Array.isArray(existingBlock) &&
142
+ outBlock && typeof outBlock === 'object' && !Array.isArray(outBlock)
143
+ ) {
144
+ preserveNestedSubkeys(existingBlock, outBlock as Record<string, unknown>);
145
+ }
146
+ }
147
+
148
+ // Restore user-set project.root (detector at init.ts:418 always writes 'auto'; user value wins).
149
+ // Separated from the block loop above for readability (A-004 architecture-review follow-up).
150
+ const existingProject = existing.project;
151
+ const outProject = out.project;
152
+ if (
153
+ existingProject && typeof existingProject === 'object' && !Array.isArray(existingProject) &&
154
+ outProject && typeof outProject === 'object' && !Array.isArray(outProject)
155
+ ) {
156
+ const userRoot = (existingProject as Record<string, unknown>).root;
157
+ if (typeof userRoot === 'string' && userRoot !== '') {
158
+ (outProject as Record<string, unknown>).root = userRoot;
159
+ }
160
+ }
161
+
162
+ // paths.aliases is a 2-level-nested user block. Detector always writes
163
+ // { '@': <source-dir> }; user-authored alias map must survive. Spread user
164
+ // over detector so user keys win for any overlap AND user-only keys survive.
165
+ // (P5-002 discovery — hedge's paths.aliases['@'] was being overwritten.)
166
+ const existingPaths = existing.paths;
167
+ const outPaths = out.paths;
168
+ if (
169
+ existingPaths && typeof existingPaths === 'object' && !Array.isArray(existingPaths) &&
170
+ outPaths && typeof outPaths === 'object' && !Array.isArray(outPaths)
171
+ ) {
172
+ const existingAliases = (existingPaths as Record<string, unknown>).aliases;
173
+ const outAliases = (outPaths as Record<string, unknown>).aliases;
174
+ if (
175
+ existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases) &&
176
+ outAliases && typeof outAliases === 'object' && !Array.isArray(outAliases)
177
+ ) {
178
+ (outPaths as Record<string, unknown>).aliases = {
179
+ ...(outAliases as Record<string, unknown>),
180
+ ...(existingAliases as Record<string, unknown>),
181
+ };
182
+ } else if (existingAliases && typeof existingAliases === 'object' && !Array.isArray(existingAliases)) {
183
+ (outPaths as Record<string, unknown>).aliases = existingAliases;
184
+ }
185
+ }
186
+
187
+ // verification is the other 2-level-nested detector-owned block. Semantics
188
+ // mirror migrate.ts:132-138 buildVerificationBlock: user's custom language
189
+ // sections (e.g., hedge's `gateway`, `ios`, `runtime`, `web`) survive
190
+ // wholesale; user's command overrides on shared languages (e.g., `python`)
191
+ // win over detector defaults. (P5-002 discovery — hedge was losing 15
192
+ // verification command entries across 4 custom language sections plus
193
+ // having 4 python commands overwritten with detector defaults.)
194
+ const existingVer = existing.verification;
195
+ const outVer = out.verification;
196
+ if (
197
+ existingVer && typeof existingVer === 'object' && !Array.isArray(existingVer) &&
198
+ outVer && typeof outVer === 'object' && !Array.isArray(outVer)
199
+ ) {
200
+ const eVer = existingVer as Record<string, unknown>;
201
+ const oVer = outVer as Record<string, unknown>;
202
+ for (const lang of Object.keys(eVer)) {
203
+ const userLang = eVer[lang];
204
+ if (userLang === undefined) continue;
205
+ if (!(lang in oVer)) {
206
+ // User-custom language (no detector counterpart) → preserve wholesale.
207
+ oVer[lang] = userLang;
208
+ } else if (
209
+ userLang && typeof userLang === 'object' && !Array.isArray(userLang) &&
210
+ oVer[lang] && typeof oVer[lang] === 'object' && !Array.isArray(oVer[lang])
211
+ ) {
212
+ // Shared language → user commands win over detector defaults (spread).
213
+ oVer[lang] = {
214
+ ...(oVer[lang] as Record<string, unknown>),
215
+ ...(userLang as Record<string, unknown>),
216
+ };
217
+ }
218
+ }
219
+ }
220
+
221
+ // Preserve top-level user keys not handled above (mirrors P1-001 passthrough for upgrade).
222
+ const handledTopLevel = new Set<string>([
223
+ 'schema_version', 'project', 'framework', 'paths', 'toolPrefix', 'verification', 'detection',
224
+ ...PRESERVED_FIELDS,
225
+ ]);
226
+ copyUnknownKeys(existing, out, handledTopLevel);
227
+
125
228
  return out;
126
229
  }
127
230
 
@@ -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);
package/src/config.ts CHANGED
@@ -254,9 +254,14 @@ const PythonConfigSchema = z.object({
254
254
  export type PythonConfig = z.infer<typeof PythonConfigSchema>;
255
255
 
256
256
  // --- Paths Config ---
257
+ // `monorepo_roots` (P1-005): optional, additive. Emitted by `init --ci` when
258
+ // `monorepo.type !== 'single'` and at least one workspace package exists.
259
+ // Downstream tools may consume it for monorepo-aware scanning. Existing v1
260
+ // configs omit it — `.optional()` preserves full back-compat.
257
261
  const PathsConfigSchema = z.object({
258
262
  source: z.string().default('src'),
259
263
  aliases: z.record(z.string(), z.string()).default({ '@': 'src' }),
264
+ monorepo_roots: z.array(z.string()).optional(),
260
265
  routers: z.string().optional(),
261
266
  routerRoot: z.string().optional(),
262
267
  pages: z.string().optional(),
@@ -135,9 +135,18 @@ export async function runDetection(
135
135
  new Set(pkg.manifests.map((m) => m.language))
136
136
  ) as SupportedLanguage[];
137
137
 
138
+ // P1-002: when the repo has a `javascript` manifest but NO `typescript`
139
+ // manifest, still glob `.ts`/`.tsx` for the javascript slot. This fixes
140
+ // plain-JS monorepos (e.g. turbo + next in a package.json with no
141
+ // `typescript` dep and no `tsconfig.json`) that contain `.tsx` files
142
+ // under `apps/*/`. Without this, `init --ci` falls back to the nonexistent
143
+ // `src/` and rolls back with a validation error.
144
+ const fallbackTsForJs =
145
+ languages.includes('javascript') && !languages.includes('typescript');
146
+
138
147
  // 3b. run source-dir + monorepo detection in parallel (both pure fs).
139
148
  const [sourceDirs, monorepo] = await Promise.all([
140
- Promise.resolve(detectSourceDirs(projectRoot, languages)),
149
+ Promise.resolve(detectSourceDirs(projectRoot, languages, { fallbackTsForJs })),
141
150
  Promise.resolve(detectMonorepo(projectRoot)),
142
151
  ]);
143
152
 
@@ -22,6 +22,7 @@
22
22
  */
23
23
 
24
24
  import type { DetectionResult, SupportedLanguage, VRCommandSet } from './index.ts';
25
+ import { copyUnknownKeys, preserveNestedSubkeys } from './passthrough.ts';
25
26
 
26
27
  /**
27
28
  * Shape accepted for input. We intentionally use `Record<string, unknown>`
@@ -190,12 +191,26 @@ export function migrateV1ToV2(
190
191
  if (Object.keys(languageEntries).length > 0) {
191
192
  framework.languages = languageEntries;
192
193
  }
194
+ // P1-004: preserve any v1Framework subkey the explicit rebuild didn't emit
195
+ // (e.g., hedge's `framework.{python, rust, swift, typescript}` language sub-blocks).
196
+ preserveNestedSubkeys(v1Framework, framework);
193
197
 
194
198
  // Paths: preserve user-set fields; fill `source` from detection if user had 'src' default.
199
+ // P1-003 (mirror of init.ts:367-390): when primary language has no source dir
200
+ // AND this is a monorepo, fall back to the common parent of workspace packages
201
+ // instead of leaving the nonexistent default 'src' in place.
195
202
  let pathsSource: string = typeof v1Paths.source === 'string' ? v1Paths.source : 'src';
196
203
  if (pathsSource === 'src' && primary) {
197
204
  const primaryDirs = detection.sourceDirs[primary]?.source_dirs ?? [];
198
- if (primaryDirs.length > 0) pathsSource = primaryDirs[0];
205
+ if (primaryDirs.length > 0) {
206
+ pathsSource = primaryDirs[0];
207
+ } else if (
208
+ detection.monorepo?.type !== undefined &&
209
+ detection.monorepo.type !== 'single' &&
210
+ detection.monorepo.packages.length > 0
211
+ ) {
212
+ pathsSource = monorepoCommonRootMigrate(detection.monorepo.packages);
213
+ }
199
214
  }
200
215
  const aliases = v1Paths.aliases && typeof v1Paths.aliases === 'object'
201
216
  ? (v1Paths.aliases as Record<string, string>)
@@ -204,19 +219,38 @@ export function migrateV1ToV2(
204
219
  source: pathsSource,
205
220
  aliases,
206
221
  };
222
+ // P1-005 mirror: emit monorepo_roots for upgraded v2 configs (additive,
223
+ // only when monorepo + not already user-specified).
224
+ if (
225
+ detection.monorepo?.type !== undefined &&
226
+ detection.monorepo.type !== 'single' &&
227
+ detection.monorepo.packages.length > 0 &&
228
+ !('monorepo_roots' in v1Paths)
229
+ ) {
230
+ const roots = monorepoDistinctRootsMigrate(detection.monorepo.packages);
231
+ if (roots.length > 0) paths.monorepo_roots = roots;
232
+ }
207
233
  for (const k of ['routers', 'routerRoot', 'pages', 'middleware', 'schema', 'components', 'hooks']) {
208
234
  if (typeof v1Paths[k] === 'string') paths[k] = v1Paths[k];
209
235
  }
236
+ // P1-005: preserve any v1Paths subkey the explicit rebuild didn't emit
237
+ // (e.g., hedge's 19 custom `paths.*` entries like adr, plans, monorepo_root).
238
+ preserveNestedSubkeys(v1Paths, paths);
210
239
 
211
240
  const verification = buildVerificationBlock(detection, v1Verification);
212
241
 
242
+ // P1-006: build project block with nested passthrough so custom subkeys
243
+ // (e.g., hedge's `project.description`) survive the migration.
244
+ const project: Record<string, unknown> = {
245
+ name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
246
+ root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
247
+ };
248
+ preserveNestedSubkeys(v1Project, project);
249
+
213
250
  // Construct v2 output.
214
251
  const v2: AnyConfig = {
215
252
  schema_version: 2,
216
- project: {
217
- name: typeof v1Project.name === 'string' ? v1Project.name : 'my-project',
218
- root: typeof v1Project.root === 'string' ? v1Project.root : 'auto',
219
- },
253
+ project,
220
254
  framework,
221
255
  paths,
222
256
  toolPrefix: typeof v1.toolPrefix === 'string' ? v1.toolPrefix : 'massu',
@@ -229,6 +263,24 @@ export function migrateV1ToV2(
229
263
  }
230
264
  }
231
265
 
266
+ // P1-001: preserve any v1 top-level key not already handled by the explicit
267
+ // migrator. This is the generalization of PRESERVED_FIELDS — custom sections
268
+ // like `services`, `workflow`, `north_stars` (hedge) now pass through.
269
+ //
270
+ // `detection` is intentionally NOT in handledTopLevel: when a v2 config is
271
+ // fed back in (idempotence check at migrate.ts:16), the existing `detection`
272
+ // block round-trips via this passthrough path. It gets re-stamped with a
273
+ // fresh fingerprint by the caller at config-upgrade.ts:96-99 right after
274
+ // migrateV1ToV2 returns. Any future v2-only top-level key added here must
275
+ // either appear in this list (with explicit handling above) or round-trip
276
+ // through this passthrough — never add a v2-only key that does neither.
277
+ // (A-006 architecture-review follow-up.)
278
+ const handledTopLevel = new Set<string>([
279
+ 'schema_version', 'project', 'framework', 'paths', 'toolPrefix',
280
+ 'verification', 'python', ...PRESERVED_FIELDS,
281
+ ]);
282
+ copyUnknownKeys(v1, v2, handledTopLevel);
283
+
232
284
  // Ensure domains / rules exist as arrays (v2 requires them).
233
285
  if (!Array.isArray(v2.domains)) {
234
286
  v2.domains = [];
@@ -268,6 +320,9 @@ export function migrateV1ToV2(
268
320
  } else if (existing.orm !== undefined) {
269
321
  pythonBlock.orm = existing.orm;
270
322
  }
323
+ // P1-007: preserve any v1 python subkey not already handled above
324
+ // (e.g., `python.test_framework`, `python.database`).
325
+ preserveNestedSubkeys(v1.python, pythonBlock);
271
326
  v2.python = pythonBlock;
272
327
  } else if (v1.python !== undefined) {
273
328
  // Preserve even if detection didn't find python (e.g. non-monorepo-with-python).
@@ -276,3 +331,32 @@ export function migrateV1ToV2(
276
331
 
277
332
  return v2;
278
333
  }
334
+
335
+ /**
336
+ * Return the common top-level parent directory across every workspace
337
+ * package (mirror of init.ts:monorepoCommonRoot). Returns `'.'` when
338
+ * packages span multiple parents.
339
+ */
340
+ function monorepoCommonRootMigrate(
341
+ packages: ReadonlyArray<{ path: string }>
342
+ ): string {
343
+ const roots = monorepoDistinctRootsMigrate(packages);
344
+ return roots.length === 1 ? roots[0] : '.';
345
+ }
346
+
347
+ /**
348
+ * Return the distinct top-level parent directories of every workspace
349
+ * package (mirror of init.ts:monorepoDistinctRoots). Sorted for determinism.
350
+ */
351
+ function monorepoDistinctRootsMigrate(
352
+ packages: ReadonlyArray<{ path: string }>
353
+ ): string[] {
354
+ const set = new Set<string>();
355
+ for (const p of packages) {
356
+ const parts = p.path.split('/');
357
+ if (parts.length > 1 && parts[0] !== '' && parts[0] !== '.') {
358
+ set.add(parts[0]);
359
+ }
360
+ }
361
+ return [...set].sort();
362
+ }
@@ -0,0 +1,108 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Shared passthrough helpers for config migration + refresh.
6
+ *
7
+ * These helpers exist to prevent the class of bug fixed in @massu/core@1.2.0
8
+ * (incident 2026-04-19-config-upgrade-data-loss): a hand-maintained allow-list
9
+ * in `migrate.ts` silently dropped any v1 top-level key not on the list, and
10
+ * the parallel rebuild blocks inside `framework` / `paths` / `project` /
11
+ * `python` did the same thing at the nested level.
12
+ *
13
+ * Both helpers are TARGET-WINS: the migrator writes the keys it actively owns,
14
+ * then the helper fills in everything else the user had. A user-authored value
15
+ * in `target` is NEVER overwritten by the source.
16
+ *
17
+ * Why two exports instead of one:
18
+ * - `copyUnknownKeys` takes an explicit `handledKeys` set — used for TOP-LEVEL
19
+ * passthrough where the caller enumerates the keys it migrated explicitly
20
+ * (e.g., schema_version, project, framework, paths, toolPrefix, …).
21
+ * - `preserveNestedSubkeys` takes no handled-set — used for NESTED passthrough
22
+ * where the target block was just rebuilt, so `k in target` already skips
23
+ * any key the rebuild populated. Splitting the two makes callsites
24
+ * self-documenting without a verbose `new Set()` argument at every nested
25
+ * call (A-002 architecture-review follow-up).
26
+ */
27
+
28
+ /** Keys that would mutate Object.prototype if copied as own properties. Explicit
29
+ * denylist defense-in-depth on top of the existing `k in target` guard and the
30
+ * `yaml@2.8` parser's non-polluting behavior (S-001 security-review follow-up). */
31
+ const UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
32
+
33
+ /**
34
+ * Copy any key from `source` into `target` that target doesn't already have set,
35
+ * skipping keys listed in `handledKeys`. Target values ALWAYS win — this function
36
+ * never overwrites an existing target key.
37
+ *
38
+ * - If source[k] is undefined → skip (undefined is not a preservable value).
39
+ * - If k is an UNSAFE_KEYS entry → skip (prototype-pollution defense).
40
+ * - If handledKeys.has(k) → skip (caller has its own handling).
41
+ * - If target already owns k → skip (target wins).
42
+ * - Otherwise → target[k] = deepClone(source[k]).
43
+ *
44
+ * Values are DEEP-CLONED via structuredClone so that mutating the output v2
45
+ * object never reaches back into the v1 input (S-002 security-review follow-up).
46
+ * Preserves the migrator's "pure data in, pure data out" contract.
47
+ */
48
+ export function copyUnknownKeys(
49
+ source: Record<string, unknown>,
50
+ target: Record<string, unknown>,
51
+ handledKeys: ReadonlySet<string>
52
+ ): void {
53
+ if (source === null || typeof source !== 'object' || Array.isArray(source)) {
54
+ return;
55
+ }
56
+ for (const k of Object.keys(source)) {
57
+ if (UNSAFE_KEYS.has(k)) continue;
58
+ if (source[k] === undefined) continue;
59
+ if (handledKeys.has(k)) continue;
60
+ if (Object.prototype.hasOwnProperty.call(target, k)) continue;
61
+ target[k] = safeClone(source[k]);
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Preserve every subkey from sourceBlock into targetBlock that targetBlock
67
+ * doesn't already have. Target values ALWAYS win.
68
+ *
69
+ * If sourceBlock is not a plain object (string, array, null, undefined),
70
+ * return early — there are no subkeys to preserve. This matches the coercion
71
+ * semantics of `getRecord` in migrate.ts and prevents throws on loose v1 inputs
72
+ * like `framework: "typescript"`.
73
+ *
74
+ * Values are deep-cloned (see copyUnknownKeys); UNSAFE_KEYS are skipped.
75
+ */
76
+ export function preserveNestedSubkeys(
77
+ sourceBlock: unknown,
78
+ targetBlock: Record<string, unknown>
79
+ ): void {
80
+ if (
81
+ sourceBlock === null ||
82
+ sourceBlock === undefined ||
83
+ typeof sourceBlock !== 'object' ||
84
+ Array.isArray(sourceBlock)
85
+ ) {
86
+ return;
87
+ }
88
+ const src = sourceBlock as Record<string, unknown>;
89
+ for (const k of Object.keys(src)) {
90
+ if (UNSAFE_KEYS.has(k)) continue;
91
+ if (src[k] === undefined) continue;
92
+ if (Object.prototype.hasOwnProperty.call(targetBlock, k)) continue;
93
+ targetBlock[k] = safeClone(src[k]);
94
+ }
95
+ }
96
+
97
+ /** structuredClone with a fallback for environments without it (Node <17). */
98
+ function safeClone<T>(v: T): T {
99
+ if (typeof structuredClone === 'function') {
100
+ try {
101
+ return structuredClone(v);
102
+ } catch {
103
+ // structuredClone throws on functions, DOM nodes, etc. — YAML never produces
104
+ // those, but if a caller passes something exotic, fall through to shallow.
105
+ }
106
+ }
107
+ return v;
108
+ }
@@ -109,6 +109,25 @@ function extsFor(language: SupportedLanguage): string[] {
109
109
  return EXTENSIONS[language] ?? [];
110
110
  }
111
111
 
112
+ /**
113
+ * Extensions for a language, with optional fallback for javascript-only repos
114
+ * that still contain `.ts`/`.tsx` files (no `typescript` manifest, no
115
+ * `tsconfig.json`). Fixes the bug where `apps/web/page.tsx` in a plain-JS
116
+ * monorepo is invisible to the javascript glob, causing `init --ci` to fall
117
+ * back to the nonexistent `src/`. See plan item P1-001 and incident
118
+ * `2026-04-20-massu-core-monorepo-paths-source.md`.
119
+ */
120
+ function extsWithFallback(
121
+ language: SupportedLanguage,
122
+ fallbackTsForJs: boolean
123
+ ): string[] {
124
+ const base = extsFor(language);
125
+ if (language === 'javascript' && fallbackTsForJs) {
126
+ return [...base, 'ts', 'tsx'];
127
+ }
128
+ return base;
129
+ }
130
+
112
131
  function isTestPath(language: SupportedLanguage, path: string): boolean {
113
132
  // Any dedicated test-dir keyword in the path segments
114
133
  const segments = path.split('/');
@@ -144,14 +163,21 @@ function isInsideRoot(root: string, candidate: string): boolean {
144
163
  *
145
164
  * @param projectRoot absolute path to repo root
146
165
  * @param languages list of languages to probe (derived from P1-001 manifests)
166
+ * @param opts optional flags:
167
+ * - `fallbackTsForJs`: when true AND the language is `javascript` AND no
168
+ * `typescript` manifest was discovered, also glob `.ts`/`.tsx`. This is
169
+ * the P1-001 fix for plain-JS monorepos (e.g. turbo with `next` in a
170
+ * package.json that lacks a typescript dep) that still use `.tsx`.
147
171
  */
148
172
  export function detectSourceDirs(
149
173
  projectRoot: string,
150
- languages: SupportedLanguage[]
174
+ languages: SupportedLanguage[],
175
+ opts?: { fallbackTsForJs?: boolean }
151
176
  ): SourceDirMap {
177
+ const fallbackTsForJs = opts?.fallbackTsForJs ?? false;
152
178
  const out: SourceDirMap = {};
153
179
  for (const lang of languages) {
154
- const exts = extsFor(lang);
180
+ const exts = extsWithFallback(lang, fallbackTsForJs);
155
181
  if (exts.length === 0) continue;
156
182
  const patterns = exts.map((e) => `**/*.${e}`);
157
183
  let files: string[];