@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.
@@ -0,0 +1,261 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Canonical manifest registry — Plan 1.5.1 §3 deliverable.
6
+ *
7
+ * Single source-of-truth for "what manifest files we recognize and how we
8
+ * read them." Both `package-detector.ts` (init's framework-detection layer)
9
+ * and `adapters/runner.ts:buildDetectionSignals` (AST adapter signal layer)
10
+ * consume from THIS registry. Adding a new manifest type = ONE entry; both
11
+ * consumers automatically pick it up.
12
+ *
13
+ * Pre-registry state (1.5.0) had TWO parallel lists that drifted:
14
+ * - `package-detector.ts:MANIFEST_FILES` — 11 entries (legacy)
15
+ * - `runner.ts:buildDetectionSignals` — 9 manifest reads (extended Phase 7)
16
+ * Phoenix + ASP.NET were unreachable via `npx massu init` because their
17
+ * manifest files (mix.exs, *.csproj) were in the runner's list but missing
18
+ * from package-detector's list. CR-39 violation; closed by this registry.
19
+ *
20
+ * Per CR-46 Rule 0 self-attest #3 ("does this add an N+1th alias map?"):
21
+ * this REPLACES the duplicated lists with one canonical map. The drift-
22
+ * prevention test `manifest-registry-drift.test.ts` fails the build if a
23
+ * registry entry is consumed by only one of the two layers.
24
+ *
25
+ * Plan 1.5.1 reference:
26
+ * `~/massu-internal/docs/plans/2026-05-08-1.5.1-init-end-to-end.md`.
27
+ */
28
+
29
+ import type { PackageManifest, DetectionWarning, SupportedLanguage } from './package-detector.ts';
30
+ import type { DetectionSignals } from './adapters/types.ts';
31
+ import * as parsers from './package-detector.ts';
32
+
33
+ /**
34
+ * The pattern by which the registry recognizes a manifest file:
35
+ * - exact filename: `'Gemfile'`, `'mix.exs'`, `'package.json'`
36
+ * - extension-glob: `'*.csproj'` (matches any file ending in `.csproj`)
37
+ * Only these two shapes are supported; arbitrary glob patterns are
38
+ * intentionally rejected to keep matching cheap and predictable.
39
+ */
40
+ export type ManifestPattern = string;
41
+
42
+ /**
43
+ * Match a candidate filename against a registry pattern.
44
+ * Returns true if `name` matches `pattern`.
45
+ */
46
+ export function matchManifestPattern(name: string, pattern: ManifestPattern): boolean {
47
+ if (pattern.startsWith('*')) {
48
+ const suffix = pattern.slice(1);
49
+ if (suffix.includes('*')) {
50
+ throw new Error(
51
+ `[manifest-registry] pattern "${pattern}" has more than one wildcard. ` +
52
+ `Only "*.<ext>" extension-globs are supported.`,
53
+ );
54
+ }
55
+ return name.endsWith(suffix);
56
+ }
57
+ return name === pattern;
58
+ }
59
+
60
+ /**
61
+ * Parser function signature — matches the existing parse* fns in
62
+ * package-detector.ts. Returns null on file-not-found or parse failure
63
+ * (with a warning pushed to `warnings`).
64
+ */
65
+ export type ManifestParser = (
66
+ path: string,
67
+ directory: string,
68
+ root: string,
69
+ warnings: DetectionWarning[],
70
+ ) => PackageManifest | null;
71
+
72
+ /**
73
+ * Single registry entry. One per manifest type.
74
+ */
75
+ export interface ManifestEntry {
76
+ /** Recognition pattern (see `ManifestPattern`). */
77
+ pattern: ManifestPattern;
78
+ /** Canonical manifest-type tag (matches `PackageManifest.manifestType`). */
79
+ manifestType: PackageManifest['manifestType'];
80
+ /** Default language this manifest implies. */
81
+ language: SupportedLanguage;
82
+ /** Runtime family hint. */
83
+ runtime: string;
84
+ /** Function that reads + parses the file into a `PackageManifest`. */
85
+ parse: ManifestParser;
86
+ /**
87
+ * The DetectionSignals key the runner uses to expose this manifest's
88
+ * contents to AST adapters. `null` when this manifest doesn't surface
89
+ * to the AST tier (e.g., requirements.txt is captured via pyprojectToml
90
+ * sibling already; Package.swift has no AST adapter consumer yet).
91
+ */
92
+ signalKey: keyof DetectionSignals | null;
93
+ /**
94
+ * Shape the runner stores under `signalKey`. Drives whether the runner
95
+ * calls `tryReadString` (string) or `tryReadToml` (toml) or
96
+ * `tryReadJson` (json) when populating signals. Ignored when
97
+ * `signalKey === null`.
98
+ */
99
+ signalShape: 'string' | 'toml' | 'json';
100
+ }
101
+
102
+ /**
103
+ * The canonical registry — the SINGLE source-of-truth for "what manifests
104
+ * we recognize."
105
+ *
106
+ * Lazy initializer: package-detector.ts re-exports the parsers we
107
+ * reference here, so eager top-level evaluation would risk an ESM
108
+ * circular-import undefined-symbol. Resolution: build the registry on
109
+ * first call (after both modules' top-level evaluations have completed).
110
+ *
111
+ * Adding a new manifest type:
112
+ * 1. Author a new `parseXxx()` function in `package-detector.ts`.
113
+ * 2. Add an entry to MANIFEST_REGISTRY referencing it.
114
+ * 3. (If needed) extend `SupportedLanguage` and
115
+ * `PackageManifest.manifestType` unions.
116
+ * 4. Add the new signal field to `DetectionSignals` if the AST adapter
117
+ * pipeline needs to read it.
118
+ * 5. The drift-prevention test will fail until both consumers see the
119
+ * new entry.
120
+ */
121
+ let _registryCache: ManifestEntry[] | null = null;
122
+
123
+ export function getManifestRegistry(): ManifestEntry[] {
124
+ if (_registryCache !== null) return _registryCache;
125
+ _registryCache = [
126
+ {
127
+ pattern: 'package.json',
128
+ manifestType: 'package.json',
129
+ language: 'typescript',
130
+ runtime: 'node',
131
+ parse: parsers.parsePackageJson,
132
+ signalKey: 'packageJson',
133
+ signalShape: 'json',
134
+ },
135
+ {
136
+ pattern: 'pyproject.toml',
137
+ manifestType: 'pyproject.toml',
138
+ language: 'python',
139
+ runtime: 'python3',
140
+ parse: parsers.parsePyproject,
141
+ signalKey: 'pyprojectToml',
142
+ signalShape: 'toml',
143
+ },
144
+ {
145
+ pattern: 'requirements.txt',
146
+ manifestType: 'requirements.txt',
147
+ language: 'python',
148
+ runtime: 'python3',
149
+ parse: parsers.parseRequirementsTxt,
150
+ // Captured via pyprojectToml sibling already; no separate signal.
151
+ signalKey: null,
152
+ signalShape: 'string',
153
+ },
154
+ {
155
+ pattern: 'Pipfile',
156
+ manifestType: 'Pipfile',
157
+ language: 'python',
158
+ runtime: 'python3',
159
+ parse: parsers.parsePipfile,
160
+ // Captured via pyprojectToml sibling already; no separate signal.
161
+ signalKey: null,
162
+ signalShape: 'string',
163
+ },
164
+ {
165
+ pattern: 'Cargo.toml',
166
+ manifestType: 'Cargo.toml',
167
+ language: 'rust',
168
+ runtime: 'cargo',
169
+ parse: parsers.parseCargoToml,
170
+ signalKey: 'cargoToml',
171
+ signalShape: 'toml',
172
+ },
173
+ {
174
+ pattern: 'Package.swift',
175
+ manifestType: 'Package.swift',
176
+ language: 'swift',
177
+ runtime: 'xcode',
178
+ parse: parsers.parsePackageSwift,
179
+ // No AST adapter consumer yet (swift-swiftui doesn't need it).
180
+ signalKey: null,
181
+ signalShape: 'string',
182
+ },
183
+ {
184
+ pattern: 'go.mod',
185
+ manifestType: 'go.mod',
186
+ language: 'go',
187
+ runtime: 'go',
188
+ parse: parsers.parseGoMod,
189
+ signalKey: 'goMod',
190
+ signalShape: 'string',
191
+ },
192
+ {
193
+ pattern: 'pom.xml',
194
+ manifestType: 'pom.xml',
195
+ language: 'java',
196
+ runtime: 'jvm',
197
+ parse: parsers.parsePomXml,
198
+ signalKey: 'pomXml',
199
+ signalShape: 'string',
200
+ },
201
+ {
202
+ pattern: 'build.gradle',
203
+ manifestType: 'build.gradle',
204
+ language: 'java',
205
+ runtime: 'jvm',
206
+ parse: parsers.parseBuildGradle,
207
+ signalKey: 'gradleBuild',
208
+ signalShape: 'string',
209
+ },
210
+ {
211
+ pattern: 'build.gradle.kts',
212
+ manifestType: 'build.gradle',
213
+ language: 'java',
214
+ runtime: 'jvm',
215
+ parse: parsers.parseBuildGradle,
216
+ signalKey: 'gradleBuild',
217
+ signalShape: 'string',
218
+ },
219
+ {
220
+ pattern: 'Gemfile',
221
+ manifestType: 'Gemfile',
222
+ language: 'ruby',
223
+ runtime: 'ruby',
224
+ parse: parsers.parseGemfile,
225
+ signalKey: 'gemfile',
226
+ signalShape: 'string',
227
+ },
228
+ // Plan 1.5.1 — closes CR-39 violation (1.5.0 init failed for Phoenix
229
+ // + ASP.NET fixtures). Both rely on AST adapters that already work
230
+ // in introspect; the gap was solely package-detector unaware of the
231
+ // manifest filenames.
232
+ {
233
+ pattern: 'mix.exs',
234
+ manifestType: 'mix.exs',
235
+ language: 'elixir',
236
+ runtime: 'beam',
237
+ parse: parsers.parseMixExs,
238
+ signalKey: 'mixExs',
239
+ signalShape: 'string',
240
+ },
241
+ {
242
+ pattern: '*.csproj',
243
+ manifestType: '*.csproj',
244
+ language: 'csharp',
245
+ runtime: 'dotnet',
246
+ parse: parsers.parseCsproj,
247
+ signalKey: 'csproj',
248
+ signalShape: 'string',
249
+ },
250
+ ];
251
+ return _registryCache;
252
+ }
253
+
254
+ /**
255
+ * Filename list for direct iteration callers (e.g., the existing
256
+ * package-detector.ts `MANIFEST_FILES` const). Derived from the registry
257
+ * so it stays in lockstep automatically.
258
+ */
259
+ export function getManifestPatterns(): ManifestPattern[] {
260
+ return getManifestRegistry().map((e) => e.pattern);
261
+ }
@@ -47,7 +47,9 @@ export type SupportedLanguage =
47
47
  | 'swift'
48
48
  | 'go'
49
49
  | 'java'
50
- | 'ruby';
50
+ | 'ruby'
51
+ | 'elixir'
52
+ | 'csharp';
51
53
 
52
54
  export interface PackageManifest {
53
55
  /** Absolute path to the manifest file. */
@@ -81,7 +83,9 @@ export interface PackageManifest {
81
83
  | 'go.mod'
82
84
  | 'pom.xml'
83
85
  | 'build.gradle'
84
- | 'Gemfile';
86
+ | 'Gemfile'
87
+ | 'mix.exs'
88
+ | '*.csproj';
85
89
  }
86
90
 
87
91
  export interface DetectionWarning {
@@ -118,19 +122,10 @@ const IGNORED_DIRS = new Set([
118
122
  'Pods',
119
123
  ]);
120
124
 
121
- const MANIFEST_FILES = [
122
- 'package.json',
123
- 'pyproject.toml',
124
- 'requirements.txt',
125
- 'Pipfile',
126
- 'Cargo.toml',
127
- 'Package.swift',
128
- 'go.mod',
129
- 'pom.xml',
130
- 'build.gradle',
131
- 'build.gradle.kts',
132
- 'Gemfile',
133
- ];
125
+ // MANIFEST_FILES removed Plan 1.5.1. The canonical list lives at
126
+ // `manifest-registry.ts:getManifestPatterns()`. `detectManifestsInDir`
127
+ // (below) iterates the registry directly so adding a new manifest type
128
+ // requires only a single registry entry — no second list to keep in sync.
134
129
 
135
130
  function safeRead(path: string): string | null {
136
131
  try {
@@ -153,7 +148,7 @@ function normalizeRelative(root: string, path: string): string {
153
148
  return rel.split(/[/\\]/).join('/');
154
149
  }
155
150
 
156
- function parsePackageJson(
151
+ export function parsePackageJson(
157
152
  path: string,
158
153
  directory: string,
159
154
  root: string,
@@ -204,7 +199,7 @@ function parsePackageJson(
204
199
  };
205
200
  }
206
201
 
207
- function parsePyproject(
202
+ export function parsePyproject(
208
203
  path: string,
209
204
  directory: string,
210
205
  root: string,
@@ -323,7 +318,7 @@ function normalizePyDep(spec: string): string {
323
318
  return name.trim();
324
319
  }
325
320
 
326
- function parseRequirementsTxt(
321
+ export function parseRequirementsTxt(
327
322
  path: string,
328
323
  directory: string,
329
324
  root: string,
@@ -355,7 +350,7 @@ function parseRequirementsTxt(
355
350
  };
356
351
  }
357
352
 
358
- function parsePipfile(
353
+ export function parsePipfile(
359
354
  path: string,
360
355
  directory: string,
361
356
  root: string,
@@ -392,7 +387,7 @@ function parsePipfile(
392
387
  };
393
388
  }
394
389
 
395
- function parseCargoToml(
390
+ export function parseCargoToml(
396
391
  path: string,
397
392
  directory: string,
398
393
  root: string,
@@ -430,7 +425,7 @@ function parseCargoToml(
430
425
  };
431
426
  }
432
427
 
433
- function parsePackageSwift(
428
+ export function parsePackageSwift(
434
429
  path: string,
435
430
  directory: string,
436
431
  root: string,
@@ -472,7 +467,7 @@ function parsePackageSwift(
472
467
  };
473
468
  }
474
469
 
475
- function parseGoMod(
470
+ export function parseGoMod(
476
471
  path: string,
477
472
  directory: string,
478
473
  root: string,
@@ -523,7 +518,7 @@ function parseGoMod(
523
518
  };
524
519
  }
525
520
 
526
- function parsePomXml(
521
+ export function parsePomXml(
527
522
  path: string,
528
523
  directory: string,
529
524
  root: string,
@@ -554,7 +549,7 @@ function parsePomXml(
554
549
  };
555
550
  }
556
551
 
557
- function parseBuildGradle(
552
+ export function parseBuildGradle(
558
553
  path: string,
559
554
  directory: string,
560
555
  root: string,
@@ -591,7 +586,7 @@ function parseBuildGradle(
591
586
  };
592
587
  }
593
588
 
594
- function parseGemfile(
589
+ export function parseGemfile(
595
590
  path: string,
596
591
  directory: string,
597
592
  root: string,
@@ -628,54 +623,159 @@ function parseGemfile(
628
623
  };
629
624
  }
630
625
 
626
+ /**
627
+ * Parse a `mix.exs` (Elixir / Mix) file. Plan 1.5.1 — closes CR-39
628
+ * gap where Phoenix projects failed `npx massu init` with "no languages
629
+ * detected" because the package-detector layer didn't recognize the
630
+ * manifest. The AST adapter at `detect/adapters/phoenix.ts` was already
631
+ * shipped in 1.5.0 and works correctly when given a SourceFile[] directly.
632
+ *
633
+ * mix.exs is Elixir source (not a declarative format), but for detection
634
+ * purposes we extract `{:dep, "~> X.Y"}` style declarations via a regex
635
+ * scan. False-positive risk is low (any `{:atom, ...}` pattern that's
636
+ * NOT a dep is rare in mix.exs files outside the deps function).
637
+ */
638
+ export function parseMixExs(
639
+ path: string,
640
+ directory: string,
641
+ root: string,
642
+ _warnings: DetectionWarning[],
643
+ ): PackageManifest | null {
644
+ const raw = safeRead(path);
645
+ if (raw === null) return null;
646
+ const deps: string[] = [];
647
+ // Match `{:dep_name, ...}` — atom name as first tuple element.
648
+ const depPattern = /\{\s*:([a-z][a-z0-9_]*)\s*,/g;
649
+ let m: RegExpExecArray | null;
650
+ while ((m = depPattern.exec(raw)) !== null) {
651
+ if (!deps.includes(m[1])) deps.push(m[1]);
652
+ }
653
+ // Best-effort `app: :name` extraction (the `def project` block usually
654
+ // declares this).
655
+ const appMatch = /\bapp\s*:\s*:([a-z][a-z0-9_]*)/.exec(raw);
656
+ const name = appMatch ? appMatch[1] : null;
657
+ return {
658
+ path,
659
+ relativePath: normalizeRelative(root, path),
660
+ directory,
661
+ language: 'elixir',
662
+ runtime: 'beam',
663
+ name,
664
+ version: null,
665
+ dependencies: deps,
666
+ devDependencies: [],
667
+ scripts: [],
668
+ manifestType: 'mix.exs',
669
+ };
670
+ }
671
+
672
+ /**
673
+ * Parse a `*.csproj` (C# / .NET project) file. Plan 1.5.1 — closes CR-39
674
+ * gap where ASP.NET projects failed `npx massu init`. The AST adapter at
675
+ * `detect/adapters/aspnet.ts` was already shipped in 1.5.0 and works
676
+ * correctly.
677
+ *
678
+ * .csproj is XML; we use a lightweight regex scan rather than pulling
679
+ * an XML parser because the only fields we need are `<PackageReference
680
+ * Include="X" />` (deps) and the `Sdk="..."` attribute (framework hint).
681
+ * Full XML parsing would be over-engineered for this surface and risks
682
+ * adding a dependency for marginal gain.
683
+ */
684
+ export function parseCsproj(
685
+ path: string,
686
+ directory: string,
687
+ root: string,
688
+ _warnings: DetectionWarning[],
689
+ ): PackageManifest | null {
690
+ const raw = safeRead(path);
691
+ if (raw === null) return null;
692
+ const deps: string[] = [];
693
+ // Match `<PackageReference Include="Foo.Bar" ... />` (Include attribute
694
+ // value is the package id; the Version attribute is captured but we
695
+ // discard it since runtime/build distinction isn't expressed via
696
+ // this schema).
697
+ const pkgRefPattern = /<PackageReference\s+[^>]*Include\s*=\s*"([^"]+)"/gi;
698
+ let m: RegExpExecArray | null;
699
+ while ((m = pkgRefPattern.exec(raw)) !== null) {
700
+ if (!deps.includes(m[1])) deps.push(m[1]);
701
+ }
702
+ // The `<Project Sdk="Microsoft.NET.Sdk.Web">` attribute is a strong
703
+ // ASP.NET Core indicator that doesn't appear as a PackageReference.
704
+ // Surface it as a dep so framework-detector rules (which match against
705
+ // the deps set) can fire on it. Same downstream consumer; no new
706
+ // signal channel needed.
707
+ const sdkMatch = /<Project\s+[^>]*Sdk\s*=\s*"([^"]+)"/i.exec(raw);
708
+ if (sdkMatch && !deps.includes(sdkMatch[1])) {
709
+ deps.push(sdkMatch[1]);
710
+ }
711
+ // Best-effort name from filename (Foo.csproj → "Foo").
712
+ const fname = path.split(/[/\\]/).pop() ?? '';
713
+ const name = fname.endsWith('.csproj') ? fname.slice(0, -'.csproj'.length) : null;
714
+ return {
715
+ path,
716
+ relativePath: normalizeRelative(root, path),
717
+ directory,
718
+ language: 'csharp',
719
+ runtime: 'dotnet',
720
+ name,
721
+ version: null,
722
+ dependencies: deps,
723
+ devDependencies: [],
724
+ scripts: [],
725
+ manifestType: '*.csproj',
726
+ };
727
+ }
728
+
631
729
  function detectManifestsInDir(
632
730
  dir: string,
633
731
  root: string,
634
732
  warnings: DetectionWarning[]
635
733
  ): PackageManifest[] {
734
+ // Plan 1.5.1: dispatch via the canonical MANIFEST_REGISTRY (single
735
+ // source-of-truth). The previous hand-rolled MANIFEST_FILES list +
736
+ // switch statement led to drift with runner.ts:buildDetectionSignals
737
+ // (Phoenix + ASP.NET were unreachable). Closed by registry.
738
+ // Lazy import to avoid ESM cycle; getManifestRegistry() is itself
739
+ // lazy-initialized.
740
+ const { getManifestRegistry, matchManifestPattern } = registryModule;
636
741
  const out: PackageManifest[] = [];
637
- for (const fname of MANIFEST_FILES) {
638
- const path = join(dir, fname);
639
- if (!existsSync(path)) continue;
640
- let m: PackageManifest | null = null;
641
- switch (fname) {
642
- case 'package.json':
643
- m = parsePackageJson(path, dir, root, warnings);
644
- break;
645
- case 'pyproject.toml':
646
- m = parsePyproject(path, dir, root, warnings);
647
- break;
648
- case 'requirements.txt':
649
- m = parseRequirementsTxt(path, dir, root, warnings);
650
- break;
651
- case 'Pipfile':
652
- m = parsePipfile(path, dir, root, warnings);
653
- break;
654
- case 'Cargo.toml':
655
- m = parseCargoToml(path, dir, root, warnings);
656
- break;
657
- case 'Package.swift':
658
- m = parsePackageSwift(path, dir, root, warnings);
659
- break;
660
- case 'go.mod':
661
- m = parseGoMod(path, dir, root, warnings);
662
- break;
663
- case 'pom.xml':
664
- m = parsePomXml(path, dir, root, warnings);
665
- break;
666
- case 'build.gradle':
667
- case 'build.gradle.kts':
668
- m = parseBuildGradle(path, dir, root, warnings);
669
- break;
670
- case 'Gemfile':
671
- m = parseGemfile(path, dir, root, warnings);
672
- break;
742
+ let dirEntries: string[] | null = null;
743
+ for (const entry of getManifestRegistry()) {
744
+ if (!entry.pattern.startsWith('*')) {
745
+ // Exact-filename pattern: O(1) existence check.
746
+ const path = join(dir, entry.pattern);
747
+ if (!existsSync(path)) continue;
748
+ const m = entry.parse(path, dir, root, warnings);
749
+ if (m !== null) out.push(m);
750
+ } else {
751
+ // Extension-glob pattern: scan dir for matches. Lazy-readdir so
752
+ // we pay the cost only when at least one glob entry exists.
753
+ if (dirEntries === null) {
754
+ try {
755
+ dirEntries = readdirSync(dir);
756
+ } catch {
757
+ dirEntries = [];
758
+ }
759
+ }
760
+ for (const fname of dirEntries) {
761
+ if (!matchManifestPattern(fname, entry.pattern)) continue;
762
+ const path = join(dir, fname);
763
+ if (!existsSync(path)) continue;
764
+ const m = entry.parse(path, dir, root, warnings);
765
+ if (m !== null) out.push(m);
766
+ }
673
767
  }
674
- if (m !== null) out.push(m);
675
768
  }
676
769
  return out;
677
770
  }
678
771
 
772
+ // Imported lazily-via-namespace to break the ESM cycle (manifest-registry
773
+ // imports parsers from THIS module). The namespace import is hoisted to
774
+ // the top of the module by ESM, but the named members are resolved on
775
+ // access — by the time `detectManifestsInDir` runs, the registry module's
776
+ // top-level evaluation has completed.
777
+ import * as registryModule from './manifest-registry.ts';
778
+
679
779
  function listSubdirs(dir: string): string[] {
680
780
  try {
681
781
  return readdirSync(dir, { withFileTypes: true })
@@ -90,6 +90,9 @@ const EXTENSIONS: Record<SupportedLanguage, string[]> = {
90
90
  go: ['go'],
91
91
  java: ['java', 'kt'],
92
92
  ruby: ['rb'],
93
+ // Plan 1.5.1 — closing CR-39 init gap for Phoenix + ASP.NET projects.
94
+ elixir: ['ex', 'exs'],
95
+ csharp: ['cs'],
93
96
  };
94
97
 
95
98
  const TEST_FILE_PATTERNS: Record<SupportedLanguage, RegExp[]> = {
@@ -101,6 +104,10 @@ const TEST_FILE_PATTERNS: Record<SupportedLanguage, RegExp[]> = {
101
104
  go: [/_test\.go$/],
102
105
  java: [/Test[^/]*\.(java|kt)$/, /[^/]*Test\.(java|kt)$/],
103
106
  ruby: [/_spec\.rb$/, /_test\.rb$/],
107
+ // Phoenix/ExUnit canonical: `test/**_test.exs`. ASP.NET / xUnit
108
+ // canonical: `*Tests.cs` or `*.Tests/...`.
109
+ elixir: [/_test\.exs$/, /\/test\//],
110
+ csharp: [/Tests?\.cs$/, /\.Tests?\//],
104
111
  };
105
112
 
106
113
  const TEST_DIR_KEYWORDS = ['tests', 'test', '__tests__', 'spec', 'specs'];
@@ -1,4 +1,4 @@
1
- // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-08T15:19:53.775Z.
1
+ // AUTO-GENERATED by scripts/bundle-pubkey.mjs at 2026-05-08T19:39:50.956Z.
2
2
  // Source pem: packages/core/security/registry-pubkey.pem
3
3
  // RAW-bytes sha256: 3b6226d036c472e533110d11a7d0cd2773ce1d7d4f1003517d5bd69c5418ed4c
4
4
  // DO NOT EDIT — regenerate via `node scripts/bundle-pubkey.mjs` or
@@ -26,7 +26,11 @@ framework:
26
26
  - Models
27
27
 
28
28
  paths:
29
- source: src
29
+ # ASP.NET Core convention: code lives at project root by default
30
+ # (Controllers/, Pages/, Models/, Program.cs). `src/` is not canonical
31
+ # — projects opt into it explicitly. Default to `.` so fresh `npx
32
+ # massu init` doesn't fail on `paths.source 'src' does not exist`.
33
+ source: .
30
34
  aliases: {}
31
35
 
32
36
  toolPrefix: massu