@massu/core 1.5.0 → 1.5.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.
@@ -15,6 +15,10 @@ var __require = /* @__PURE__ */ ((x) => typeof require !== "undefined" ? require
15
15
  var __commonJS = (cb, mod) => function __require2() {
16
16
  return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
17
17
  };
18
+ var __export = (target, all) => {
19
+ for (var name2 in all)
20
+ __defProp(target, name2, { get: all[name2], enumerable: true });
21
+ };
18
22
  var __copyProps = (to, from, except, desc) => {
19
23
  if (from && typeof from === "object" || typeof from === "function") {
20
24
  for (let key of __getOwnPropNames(from))
@@ -7589,6 +7593,160 @@ function parse(toml, { maxDepth = 1e3, integersAsBigInt } = {}) {
7589
7593
  return res;
7590
7594
  }
7591
7595
 
7596
+ // src/detect/manifest-registry.ts
7597
+ var manifest_registry_exports = {};
7598
+ __export(manifest_registry_exports, {
7599
+ getManifestPatterns: () => getManifestPatterns,
7600
+ getManifestRegistry: () => getManifestRegistry,
7601
+ matchManifestPattern: () => matchManifestPattern
7602
+ });
7603
+ function matchManifestPattern(name2, pattern) {
7604
+ if (pattern.startsWith("*")) {
7605
+ const suffix = pattern.slice(1);
7606
+ if (suffix.includes("*")) {
7607
+ throw new Error(
7608
+ `[manifest-registry] pattern "${pattern}" has more than one wildcard. Only "*.<ext>" extension-globs are supported.`
7609
+ );
7610
+ }
7611
+ return name2.endsWith(suffix);
7612
+ }
7613
+ return name2 === pattern;
7614
+ }
7615
+ var _registryCache = null;
7616
+ function getManifestRegistry() {
7617
+ if (_registryCache !== null) return _registryCache;
7618
+ _registryCache = [
7619
+ {
7620
+ pattern: "package.json",
7621
+ manifestType: "package.json",
7622
+ language: "typescript",
7623
+ runtime: "node",
7624
+ parse: parsePackageJson,
7625
+ signalKey: "packageJson",
7626
+ signalShape: "json"
7627
+ },
7628
+ {
7629
+ pattern: "pyproject.toml",
7630
+ manifestType: "pyproject.toml",
7631
+ language: "python",
7632
+ runtime: "python3",
7633
+ parse: parsePyproject,
7634
+ signalKey: "pyprojectToml",
7635
+ signalShape: "toml"
7636
+ },
7637
+ {
7638
+ pattern: "requirements.txt",
7639
+ manifestType: "requirements.txt",
7640
+ language: "python",
7641
+ runtime: "python3",
7642
+ parse: parseRequirementsTxt,
7643
+ // Captured via pyprojectToml sibling already; no separate signal.
7644
+ signalKey: null,
7645
+ signalShape: "string"
7646
+ },
7647
+ {
7648
+ pattern: "Pipfile",
7649
+ manifestType: "Pipfile",
7650
+ language: "python",
7651
+ runtime: "python3",
7652
+ parse: parsePipfile,
7653
+ // Captured via pyprojectToml sibling already; no separate signal.
7654
+ signalKey: null,
7655
+ signalShape: "string"
7656
+ },
7657
+ {
7658
+ pattern: "Cargo.toml",
7659
+ manifestType: "Cargo.toml",
7660
+ language: "rust",
7661
+ runtime: "cargo",
7662
+ parse: parseCargoToml,
7663
+ signalKey: "cargoToml",
7664
+ signalShape: "toml"
7665
+ },
7666
+ {
7667
+ pattern: "Package.swift",
7668
+ manifestType: "Package.swift",
7669
+ language: "swift",
7670
+ runtime: "xcode",
7671
+ parse: parsePackageSwift,
7672
+ // No AST adapter consumer yet (swift-swiftui doesn't need it).
7673
+ signalKey: null,
7674
+ signalShape: "string"
7675
+ },
7676
+ {
7677
+ pattern: "go.mod",
7678
+ manifestType: "go.mod",
7679
+ language: "go",
7680
+ runtime: "go",
7681
+ parse: parseGoMod,
7682
+ signalKey: "goMod",
7683
+ signalShape: "string"
7684
+ },
7685
+ {
7686
+ pattern: "pom.xml",
7687
+ manifestType: "pom.xml",
7688
+ language: "java",
7689
+ runtime: "jvm",
7690
+ parse: parsePomXml,
7691
+ signalKey: "pomXml",
7692
+ signalShape: "string"
7693
+ },
7694
+ {
7695
+ pattern: "build.gradle",
7696
+ manifestType: "build.gradle",
7697
+ language: "java",
7698
+ runtime: "jvm",
7699
+ parse: parseBuildGradle,
7700
+ signalKey: "gradleBuild",
7701
+ signalShape: "string"
7702
+ },
7703
+ {
7704
+ pattern: "build.gradle.kts",
7705
+ manifestType: "build.gradle",
7706
+ language: "java",
7707
+ runtime: "jvm",
7708
+ parse: parseBuildGradle,
7709
+ signalKey: "gradleBuild",
7710
+ signalShape: "string"
7711
+ },
7712
+ {
7713
+ pattern: "Gemfile",
7714
+ manifestType: "Gemfile",
7715
+ language: "ruby",
7716
+ runtime: "ruby",
7717
+ parse: parseGemfile,
7718
+ signalKey: "gemfile",
7719
+ signalShape: "string"
7720
+ },
7721
+ // Plan 1.5.1 — closes CR-39 violation (1.5.0 init failed for Phoenix
7722
+ // + ASP.NET fixtures). Both rely on AST adapters that already work
7723
+ // in introspect; the gap was solely package-detector unaware of the
7724
+ // manifest filenames.
7725
+ {
7726
+ pattern: "mix.exs",
7727
+ manifestType: "mix.exs",
7728
+ language: "elixir",
7729
+ runtime: "beam",
7730
+ parse: parseMixExs,
7731
+ signalKey: "mixExs",
7732
+ signalShape: "string"
7733
+ },
7734
+ {
7735
+ pattern: "*.csproj",
7736
+ manifestType: "*.csproj",
7737
+ language: "csharp",
7738
+ runtime: "dotnet",
7739
+ parse: parseCsproj,
7740
+ signalKey: "csproj",
7741
+ signalShape: "string"
7742
+ }
7743
+ ];
7744
+ return _registryCache;
7745
+ }
7746
+ function getManifestPatterns() {
7747
+ return getManifestRegistry().map((e) => e.pattern);
7748
+ }
7749
+
7592
7750
  // src/detect/package-detector.ts
7593
7751
  var WORKSPACE_DIRS = ["apps", "packages", "services", "libs", "modules"];
7594
7752
  var IGNORED_DIRS = /* @__PURE__ */ new Set([
@@ -7612,19 +7770,6 @@ var IGNORED_DIRS = /* @__PURE__ */ new Set([
7612
7770
  "DerivedData",
7613
7771
  "Pods"
7614
7772
  ]);
7615
- var MANIFEST_FILES = [
7616
- "package.json",
7617
- "pyproject.toml",
7618
- "requirements.txt",
7619
- "Pipfile",
7620
- "Cargo.toml",
7621
- "Package.swift",
7622
- "go.mod",
7623
- "pom.xml",
7624
- "build.gradle",
7625
- "build.gradle.kts",
7626
- "Gemfile"
7627
- ];
7628
7773
  function safeRead(path) {
7629
7774
  try {
7630
7775
  if (!existsSync3(path)) return null;
@@ -8029,46 +8174,86 @@ function parseGemfile(path, directory, root, _warnings) {
8029
8174
  manifestType: "Gemfile"
8030
8175
  };
8031
8176
  }
8177
+ function parseMixExs(path, directory, root, _warnings) {
8178
+ const raw = safeRead(path);
8179
+ if (raw === null) return null;
8180
+ const deps = [];
8181
+ const depPattern = /\{\s*:([a-z][a-z0-9_]*)\s*,/g;
8182
+ let m;
8183
+ while ((m = depPattern.exec(raw)) !== null) {
8184
+ if (!deps.includes(m[1])) deps.push(m[1]);
8185
+ }
8186
+ const appMatch = /\bapp\s*:\s*:([a-z][a-z0-9_]*)/.exec(raw);
8187
+ const name2 = appMatch ? appMatch[1] : null;
8188
+ return {
8189
+ path,
8190
+ relativePath: normalizeRelative(root, path),
8191
+ directory,
8192
+ language: "elixir",
8193
+ runtime: "beam",
8194
+ name: name2,
8195
+ version: null,
8196
+ dependencies: deps,
8197
+ devDependencies: [],
8198
+ scripts: [],
8199
+ manifestType: "mix.exs"
8200
+ };
8201
+ }
8202
+ function parseCsproj(path, directory, root, _warnings) {
8203
+ const raw = safeRead(path);
8204
+ if (raw === null) return null;
8205
+ const deps = [];
8206
+ const pkgRefPattern = /<PackageReference\s+[^>]*Include\s*=\s*"([^"]+)"/gi;
8207
+ let m;
8208
+ while ((m = pkgRefPattern.exec(raw)) !== null) {
8209
+ if (!deps.includes(m[1])) deps.push(m[1]);
8210
+ }
8211
+ const sdkMatch = /<Project\s+[^>]*Sdk\s*=\s*"([^"]+)"/i.exec(raw);
8212
+ if (sdkMatch && !deps.includes(sdkMatch[1])) {
8213
+ deps.push(sdkMatch[1]);
8214
+ }
8215
+ const fname = path.split(/[/\\]/).pop() ?? "";
8216
+ const name2 = fname.endsWith(".csproj") ? fname.slice(0, -".csproj".length) : null;
8217
+ return {
8218
+ path,
8219
+ relativePath: normalizeRelative(root, path),
8220
+ directory,
8221
+ language: "csharp",
8222
+ runtime: "dotnet",
8223
+ name: name2,
8224
+ version: null,
8225
+ dependencies: deps,
8226
+ devDependencies: [],
8227
+ scripts: [],
8228
+ manifestType: "*.csproj"
8229
+ };
8230
+ }
8032
8231
  function detectManifestsInDir(dir, root, warnings) {
8232
+ const { getManifestRegistry: getManifestRegistry2, matchManifestPattern: matchManifestPattern2 } = manifest_registry_exports;
8033
8233
  const out2 = [];
8034
- for (const fname of MANIFEST_FILES) {
8035
- const path = join(dir, fname);
8036
- if (!existsSync3(path)) continue;
8037
- let m = null;
8038
- switch (fname) {
8039
- case "package.json":
8040
- m = parsePackageJson(path, dir, root, warnings);
8041
- break;
8042
- case "pyproject.toml":
8043
- m = parsePyproject(path, dir, root, warnings);
8044
- break;
8045
- case "requirements.txt":
8046
- m = parseRequirementsTxt(path, dir, root, warnings);
8047
- break;
8048
- case "Pipfile":
8049
- m = parsePipfile(path, dir, root, warnings);
8050
- break;
8051
- case "Cargo.toml":
8052
- m = parseCargoToml(path, dir, root, warnings);
8053
- break;
8054
- case "Package.swift":
8055
- m = parsePackageSwift(path, dir, root, warnings);
8056
- break;
8057
- case "go.mod":
8058
- m = parseGoMod(path, dir, root, warnings);
8059
- break;
8060
- case "pom.xml":
8061
- m = parsePomXml(path, dir, root, warnings);
8062
- break;
8063
- case "build.gradle":
8064
- case "build.gradle.kts":
8065
- m = parseBuildGradle(path, dir, root, warnings);
8066
- break;
8067
- case "Gemfile":
8068
- m = parseGemfile(path, dir, root, warnings);
8069
- break;
8234
+ let dirEntries = null;
8235
+ for (const entry of getManifestRegistry2()) {
8236
+ if (!entry.pattern.startsWith("*")) {
8237
+ const path = join(dir, entry.pattern);
8238
+ if (!existsSync3(path)) continue;
8239
+ const m = entry.parse(path, dir, root, warnings);
8240
+ if (m !== null) out2.push(m);
8241
+ } else {
8242
+ if (dirEntries === null) {
8243
+ try {
8244
+ dirEntries = readdirSync(dir);
8245
+ } catch {
8246
+ dirEntries = [];
8247
+ }
8248
+ }
8249
+ for (const fname of dirEntries) {
8250
+ if (!matchManifestPattern2(fname, entry.pattern)) continue;
8251
+ const path = join(dir, fname);
8252
+ if (!existsSync3(path)) continue;
8253
+ const m = entry.parse(path, dir, root, warnings);
8254
+ if (m !== null) out2.push(m);
8255
+ }
8070
8256
  }
8071
- if (m !== null) out2.push(m);
8072
8257
  }
8073
8258
  return out2;
8074
8259
  }
@@ -8181,6 +8366,13 @@ var DETECTION_RULES = [
8181
8366
  { language: "go", kind: "framework", keyword: "github.com/labstack/echo", value: "echo", priority: 10 },
8182
8367
  { language: "go", kind: "framework", keyword: "github.com/gofiber/fiber", value: "fiber", priority: 10 },
8183
8368
  { language: "go", kind: "framework", keyword: "github.com/go-chi/chi", value: "chi", priority: 9 },
8369
+ // chi versioned import paths (Go convention: github.com/<org>/<name>/v<N>).
8370
+ // matchRule does exact case-insensitive set lookup, so the unversioned and
8371
+ // each major-version path each need their own rule.
8372
+ { language: "go", kind: "framework", keyword: "github.com/go-chi/chi/v2", value: "chi", priority: 9 },
8373
+ { language: "go", kind: "framework", keyword: "github.com/go-chi/chi/v3", value: "chi", priority: 9 },
8374
+ { language: "go", kind: "framework", keyword: "github.com/go-chi/chi/v4", value: "chi", priority: 9 },
8375
+ { language: "go", kind: "framework", keyword: "github.com/go-chi/chi/v5", value: "chi", priority: 9 },
8184
8376
  { language: "go", kind: "test_framework", keyword: "github.com/stretchr/testify", value: "testify", priority: 8 },
8185
8377
  { language: "go", kind: "orm", keyword: "gorm.io/gorm", value: "gorm", priority: 10 },
8186
8378
  // Swift (SPM dependency names, best-effort)
@@ -8196,7 +8388,26 @@ var DETECTION_RULES = [
8196
8388
  { language: "ruby", kind: "framework", keyword: "rails", value: "rails", priority: 10 },
8197
8389
  { language: "ruby", kind: "framework", keyword: "sinatra", value: "sinatra", priority: 9 },
8198
8390
  { language: "ruby", kind: "test_framework", keyword: "rspec", value: "rspec", priority: 10 },
8199
- { language: "ruby", kind: "orm", keyword: "activerecord", value: "activerecord", priority: 10 }
8391
+ { language: "ruby", kind: "orm", keyword: "activerecord", value: "activerecord", priority: 10 },
8392
+ // Plan 1.5.1: elixir + csharp framework rules. Closes the CR-39 gap
8393
+ // where Phoenix + ASP.NET projects produced `framework.languages.<lang>`
8394
+ // entries WITHOUT a `framework:` value, which prevented variant
8395
+ // templates from being looked up.
8396
+ { language: "elixir", kind: "framework", keyword: "phoenix", value: "phoenix", priority: 10 },
8397
+ { language: "elixir", kind: "test_framework", keyword: "ex_unit", value: "ex-unit", priority: 10 },
8398
+ { language: "elixir", kind: "orm", keyword: "ecto", value: "ecto", priority: 10 },
8399
+ // ASP.NET Core surfaces via several PackageReference names; the canonical
8400
+ // ones in modern .NET projects are .App and .Mvc. matchRule does exact
8401
+ // (case-insensitive) lookup against the deps set parseCsproj extracts.
8402
+ { language: "csharp", kind: "framework", keyword: "Microsoft.AspNetCore.App", value: "aspnet-core", priority: 10 },
8403
+ { language: "csharp", kind: "framework", keyword: "Microsoft.AspNetCore.Mvc", value: "aspnet-core", priority: 10 },
8404
+ { language: "csharp", kind: "framework", keyword: "Microsoft.AspNetCore", value: "aspnet-core", priority: 9 },
8405
+ // SDK-style projects: `<Project Sdk="Microsoft.NET.Sdk.Web">` is the
8406
+ // canonical ASP.NET Core declaration in modern .NET. parseCsproj
8407
+ // surfaces the Sdk attribute as a dep so this rule can match.
8408
+ { language: "csharp", kind: "framework", keyword: "Microsoft.NET.Sdk.Web", value: "aspnet-core", priority: 10 },
8409
+ { language: "csharp", kind: "test_framework", keyword: "xunit", value: "xunit", priority: 10 },
8410
+ { language: "csharp", kind: "orm", keyword: "EntityFrameworkCore", value: "ef-core", priority: 10 }
8200
8411
  ];
8201
8412
  function matchRule(rules, language, kind, deps) {
8202
8413
  let best = null;
@@ -8312,7 +8523,10 @@ var EXTENSIONS = {
8312
8523
  swift: ["swift"],
8313
8524
  go: ["go"],
8314
8525
  java: ["java", "kt"],
8315
- ruby: ["rb"]
8526
+ ruby: ["rb"],
8527
+ // Plan 1.5.1 — closing CR-39 init gap for Phoenix + ASP.NET projects.
8528
+ elixir: ["ex", "exs"],
8529
+ csharp: ["cs"]
8316
8530
  };
8317
8531
  var TEST_FILE_PATTERNS = {
8318
8532
  python: [/_test\.py$/, /test_[^/]*\.py$/],
@@ -8322,7 +8536,11 @@ var TEST_FILE_PATTERNS = {
8322
8536
  swift: [/Tests\//],
8323
8537
  go: [/_test\.go$/],
8324
8538
  java: [/Test[^/]*\.(java|kt)$/, /[^/]*Test\.(java|kt)$/],
8325
- ruby: [/_spec\.rb$/, /_test\.rb$/]
8539
+ ruby: [/_spec\.rb$/, /_test\.rb$/],
8540
+ // Phoenix/ExUnit canonical: `test/**_test.exs`. ASP.NET / xUnit
8541
+ // canonical: `*Tests.cs` or `*.Tests/...`.
8542
+ elixir: [/_test\.exs$/, /\/test\//],
8543
+ csharp: [/Tests?\.cs$/, /\.Tests?\//]
8326
8544
  };
8327
8545
  var TEST_DIR_KEYWORDS = ["tests", "test", "__tests__", "spec", "specs"];
8328
8546
  function extsFor(language) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@massu/core",
3
- "version": "1.5.0",
3
+ "version": "1.5.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",
@@ -543,6 +543,145 @@ export function buildConfigFromDetection(
543
543
  return config;
544
544
  }
545
545
 
546
+ /**
547
+ * Plan 1.5.1 §3 — variant template merge.
548
+ *
549
+ * Map from detected `framework.languages.<lang>.framework` value → variant
550
+ * template directory under `packages/core/templates/`. Most detected
551
+ * frameworks map 1:1 to a template dir of the same name, but a few have
552
+ * naming divergence (e.g., detection emits `spring-boot` but the template
553
+ * dir is `spring`; detection emits `chi` but the template dir is `go-chi`).
554
+ *
555
+ * The mapping is intentionally tight — only frameworks with an actual
556
+ * variant template under `templates/` are listed. Adding a new framework
557
+ * = one entry here + one templates/<id>/massu.config.yaml file. The
558
+ * `manifest-registry-drift.test.ts` already gates the manifest side; the
559
+ * `init-end-to-end.test.ts` gates this map.
560
+ */
561
+ const FRAMEWORK_TO_TEMPLATE_ID: Record<string, string> = {
562
+ rails: 'rails',
563
+ phoenix: 'phoenix',
564
+ 'aspnet-core': 'aspnet',
565
+ 'spring-boot': 'spring',
566
+ chi: 'go-chi',
567
+ flask: 'python-flask',
568
+ };
569
+
570
+ /**
571
+ * After `buildConfigFromDetection` produces a baseline config, look up the
572
+ * variant template for the detected framework (if any) and selectively
573
+ * merge fields. The variant wins on a small allowlist:
574
+ * - `framework.router`
575
+ * - `framework.orm`
576
+ * - `framework.ui`
577
+ * - `paths.source`
578
+ * - `verification.<lang>.{lint,syntax,test,type,build}` — variant lint
579
+ * is the canonical project-style command (rubocop, credo, etc.) and
580
+ * should not be overridden by the generic detection default.
581
+ *
582
+ * The variant template's `framework.type`, `framework.languages`,
583
+ * `project.name`, `domains`, and `rules` are NOT merged — those come from
584
+ * detection and reflect the actual repo state. Allowlist keeps the merge
585
+ * precise; future fields require an explicit decision.
586
+ */
587
+ export function applyVariantTemplate(
588
+ config: Record<string, unknown>,
589
+ templatesDir: string | null,
590
+ ): Record<string, unknown> {
591
+ if (!templatesDir) return config;
592
+ const fw = config.framework as Record<string, unknown> | undefined;
593
+ if (!fw) return config;
594
+ const langs = fw.languages as Record<string, unknown> | undefined;
595
+ if (!langs || typeof langs !== 'object') return config;
596
+
597
+ // Find the first language that has a `framework` value with a known
598
+ // variant template. Most projects have ONE primary language; in
599
+ // monorepos the detection-driven primary is what we honor.
600
+ let templateId: string | null = null;
601
+ for (const langEntry of Object.values(langs)) {
602
+ if (langEntry && typeof langEntry === 'object') {
603
+ const fwName = (langEntry as Record<string, unknown>).framework;
604
+ if (typeof fwName === 'string' && FRAMEWORK_TO_TEMPLATE_ID[fwName]) {
605
+ templateId = FRAMEWORK_TO_TEMPLATE_ID[fwName];
606
+ break;
607
+ }
608
+ }
609
+ }
610
+ if (templateId === null) return config;
611
+
612
+ const templatePath = resolve(templatesDir, templateId, 'massu.config.yaml');
613
+ if (!existsSync(templatePath)) return config;
614
+
615
+ let template: Record<string, unknown>;
616
+ try {
617
+ // pattern-scanner-allow: yaml-parse — reason: this loads a per-framework
618
+ // variant config-template shipped inside @massu/core. getConfig() reads
619
+ // the project's massu.config.yaml from cwd; this is a SEPARATE file
620
+ // (the template) that doesn't pass through that cache and isn't a Zod-
621
+ // validated config — it's a partial override map.
622
+ template = yamlParse(readFileSync(templatePath, 'utf-8')) as Record<string, unknown>;
623
+ } catch {
624
+ return config;
625
+ }
626
+
627
+ const out = { ...config };
628
+ const tplFw = template.framework as Record<string, unknown> | undefined;
629
+ const outFw = (out.framework as Record<string, unknown>) ?? {};
630
+ if (tplFw) {
631
+ if (typeof tplFw.router === 'string' && (!outFw.router || outFw.router === 'none')) {
632
+ outFw.router = tplFw.router;
633
+ }
634
+ if (typeof tplFw.orm === 'string' && (!outFw.orm || outFw.orm === 'none')) {
635
+ outFw.orm = tplFw.orm;
636
+ }
637
+ if (typeof tplFw.ui === 'string' && (!outFw.ui || outFw.ui === 'none')) {
638
+ outFw.ui = tplFw.ui;
639
+ }
640
+ }
641
+ out.framework = outFw;
642
+
643
+ const tplPaths = template.paths as Record<string, unknown> | undefined;
644
+ const outPaths = (out.paths as Record<string, unknown>) ?? {};
645
+ if (tplPaths && typeof tplPaths.source === 'string' && tplPaths.source) {
646
+ outPaths.source = tplPaths.source;
647
+ }
648
+ out.paths = outPaths;
649
+
650
+ const tplVerify = template.verification as Record<string, Record<string, unknown>> | undefined;
651
+ const outVerify = (out.verification as Record<string, Record<string, unknown>>) ?? {};
652
+ if (tplVerify) {
653
+ for (const [lang, tplLangVerify] of Object.entries(tplVerify)) {
654
+ if (!tplLangVerify || typeof tplLangVerify !== 'object') continue;
655
+ const outLangVerify = outVerify[lang] ?? {};
656
+ // Variant wins on lint + syntax (canonical project commands like
657
+ // rubocop, credo, golangci-lint that the generic detection layer
658
+ // doesn't know to suggest). For test/type/build, prefer detection-
659
+ // derived values when present (e.g., monorepo `cd packages/foo`
660
+ // prefixing) and fall back to variant template otherwise.
661
+ for (const key of ['lint', 'syntax']) {
662
+ if (typeof tplLangVerify[key] === 'string' && tplLangVerify[key]) {
663
+ outLangVerify[key] = tplLangVerify[key];
664
+ }
665
+ }
666
+ for (const key of ['test', 'type', 'build']) {
667
+ if (
668
+ typeof tplLangVerify[key] === 'string' &&
669
+ tplLangVerify[key] &&
670
+ !outLangVerify[key]
671
+ ) {
672
+ outLangVerify[key] = tplLangVerify[key];
673
+ }
674
+ }
675
+ outVerify[lang] = outLangVerify;
676
+ }
677
+ }
678
+ if (Object.keys(outVerify).length > 0) {
679
+ out.verification = outVerify;
680
+ }
681
+
682
+ return out;
683
+ }
684
+
546
685
  /**
547
686
  * Serialize a built config object into YAML with a header comment.
548
687
  * Safe for `writeConfigAtomic` and for `fs.writeFileSync` directly.
@@ -1228,8 +1367,16 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
1228
1367
  }
1229
1368
  }
1230
1369
 
1231
- // Build config + write atomically.
1232
- const config = buildConfigFromDetection({ projectRoot, detection });
1370
+ // Build config + apply variant template + write atomically. Plan 1.5.1
1371
+ // §3: the framework-specific variant template under
1372
+ // packages/core/templates/<id>/massu.config.yaml supplies router,
1373
+ // paths.source, and verification.<lang>.lint that the generic
1374
+ // detection-derived baseline doesn't know to set. Pre-1.5.1 init
1375
+ // emitted configs with `router: none` even for clear-Rails / clear-
1376
+ // Phoenix / clear-Spring projects (CR-39 violation per the Plan 1.5.1
1377
+ // 5-fixture verification).
1378
+ const baseConfig = buildConfigFromDetection({ projectRoot, detection });
1379
+ const config = applyVariantTemplate(baseConfig, resolveTemplatesDir());
1233
1380
  const content = renderConfigYaml(config);
1234
1381
  const writeRes = writeConfigAtomic(configPath, content);
1235
1382
  if (!writeRes.validated) {
@@ -138,6 +138,13 @@ export const DETECTION_RULES: DetectionRule[] = [
138
138
  { language: 'go', kind: 'framework', keyword: 'github.com/labstack/echo', value: 'echo', priority: 10 },
139
139
  { language: 'go', kind: 'framework', keyword: 'github.com/gofiber/fiber', value: 'fiber', priority: 10 },
140
140
  { language: 'go', kind: 'framework', keyword: 'github.com/go-chi/chi', value: 'chi', priority: 9 },
141
+ // chi versioned import paths (Go convention: github.com/<org>/<name>/v<N>).
142
+ // matchRule does exact case-insensitive set lookup, so the unversioned and
143
+ // each major-version path each need their own rule.
144
+ { language: 'go', kind: 'framework', keyword: 'github.com/go-chi/chi/v2', value: 'chi', priority: 9 },
145
+ { language: 'go', kind: 'framework', keyword: 'github.com/go-chi/chi/v3', value: 'chi', priority: 9 },
146
+ { language: 'go', kind: 'framework', keyword: 'github.com/go-chi/chi/v4', value: 'chi', priority: 9 },
147
+ { language: 'go', kind: 'framework', keyword: 'github.com/go-chi/chi/v5', value: 'chi', priority: 9 },
141
148
  { language: 'go', kind: 'test_framework', keyword: 'github.com/stretchr/testify', value: 'testify', priority: 8 },
142
149
  { language: 'go', kind: 'orm', keyword: 'gorm.io/gorm', value: 'gorm', priority: 10 },
143
150
 
@@ -157,6 +164,25 @@ export const DETECTION_RULES: DetectionRule[] = [
157
164
  { language: 'ruby', kind: 'framework', keyword: 'sinatra', value: 'sinatra', priority: 9 },
158
165
  { language: 'ruby', kind: 'test_framework', keyword: 'rspec', value: 'rspec', priority: 10 },
159
166
  { language: 'ruby', kind: 'orm', keyword: 'activerecord', value: 'activerecord', priority: 10 },
167
+ // Plan 1.5.1: elixir + csharp framework rules. Closes the CR-39 gap
168
+ // where Phoenix + ASP.NET projects produced `framework.languages.<lang>`
169
+ // entries WITHOUT a `framework:` value, which prevented variant
170
+ // templates from being looked up.
171
+ { language: 'elixir', kind: 'framework', keyword: 'phoenix', value: 'phoenix', priority: 10 },
172
+ { language: 'elixir', kind: 'test_framework', keyword: 'ex_unit', value: 'ex-unit', priority: 10 },
173
+ { language: 'elixir', kind: 'orm', keyword: 'ecto', value: 'ecto', priority: 10 },
174
+ // ASP.NET Core surfaces via several PackageReference names; the canonical
175
+ // ones in modern .NET projects are .App and .Mvc. matchRule does exact
176
+ // (case-insensitive) lookup against the deps set parseCsproj extracts.
177
+ { language: 'csharp', kind: 'framework', keyword: 'Microsoft.AspNetCore.App', value: 'aspnet-core', priority: 10 },
178
+ { language: 'csharp', kind: 'framework', keyword: 'Microsoft.AspNetCore.Mvc', value: 'aspnet-core', priority: 10 },
179
+ { language: 'csharp', kind: 'framework', keyword: 'Microsoft.AspNetCore', value: 'aspnet-core', priority: 9 },
180
+ // SDK-style projects: `<Project Sdk="Microsoft.NET.Sdk.Web">` is the
181
+ // canonical ASP.NET Core declaration in modern .NET. parseCsproj
182
+ // surfaces the Sdk attribute as a dep so this rule can match.
183
+ { language: 'csharp', kind: 'framework', keyword: 'Microsoft.NET.Sdk.Web', value: 'aspnet-core', priority: 10 },
184
+ { language: 'csharp', kind: 'test_framework', keyword: 'xunit', value: 'xunit', priority: 10 },
185
+ { language: 'csharp', kind: 'orm', keyword: 'EntityFrameworkCore', value: 'ef-core', priority: 10 },
160
186
  ];
161
187
 
162
188
  /**