@massu/core 1.4.0-soak.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/commands/README.md +0 -3
  2. package/dist/cli.js +9423 -5453
  3. package/dist/hooks/auto-learning-pipeline.js +27 -1
  4. package/dist/hooks/classify-failure.js +27 -1
  5. package/dist/hooks/cost-tracker.js +27 -1
  6. package/dist/hooks/fix-detector.js +27 -1
  7. package/dist/hooks/incident-pipeline.js +27 -1
  8. package/dist/hooks/post-edit-context.js +27 -1
  9. package/dist/hooks/post-tool-use.js +27 -1
  10. package/dist/hooks/pre-compact.js +27 -1
  11. package/dist/hooks/pre-delete-check.js +27 -1
  12. package/dist/hooks/quality-event.js +27 -1
  13. package/dist/hooks/rule-enforcement-pipeline.js +27 -1
  14. package/dist/hooks/session-end.js +27 -1
  15. package/dist/hooks/session-start.js +2677 -2675
  16. package/dist/hooks/user-prompt.js +27 -1
  17. package/docs/AUTHORING-ADAPTERS.md +207 -0
  18. package/docs/SECURITY.md +250 -0
  19. package/package.json +10 -3
  20. package/src/adapter.ts +90 -0
  21. package/src/cli.ts +7 -0
  22. package/src/commands/adapters.ts +824 -0
  23. package/src/commands/config-check-drift.ts +1 -0
  24. package/src/commands/config-refresh.ts +4 -3
  25. package/src/commands/config-upgrade.ts +1 -0
  26. package/src/commands/doctor.ts +2 -0
  27. package/src/commands/init.ts +3 -1
  28. package/src/commands/template-engine.ts +0 -2
  29. package/src/commands/watch.ts +1 -1
  30. package/src/config.ts +71 -0
  31. package/src/detect/adapters/aspnet.ts +293 -0
  32. package/src/detect/adapters/discover.ts +469 -0
  33. package/src/detect/adapters/go-chi.ts +261 -0
  34. package/src/detect/adapters/index.ts +49 -0
  35. package/src/detect/adapters/phoenix.ts +277 -0
  36. package/src/detect/adapters/python-flask.ts +235 -0
  37. package/src/detect/adapters/rails.ts +279 -0
  38. package/src/detect/adapters/runner.ts +32 -0
  39. package/src/detect/adapters/spring.ts +284 -0
  40. package/src/detect/adapters/tree-sitter-loader.ts +171 -2
  41. package/src/detect/adapters/types.ts +19 -2
  42. package/src/detect/migrate.ts +4 -4
  43. package/src/detect/monorepo-detector.ts +1 -0
  44. package/src/hooks/post-tool-use.ts +1 -0
  45. package/src/hooks/session-start.ts +1 -0
  46. package/src/lib/fileLock.ts +203 -0
  47. package/src/lib/installLock.ts +31 -144
  48. package/src/lsp/auto-detect.ts +10 -1
  49. package/src/lsp/client.ts +188 -2
  50. package/src/memory-file-ingest.ts +1 -0
  51. package/src/security/adapter-origin.ts +130 -0
  52. package/src/security/adapter-verifier.ts +319 -0
  53. package/src/security/atomic-write.ts +164 -0
  54. package/src/security/fetcher.ts +200 -0
  55. package/src/security/install-tracking.ts +319 -0
  56. package/src/security/local-fingerprint.ts +225 -0
  57. package/src/security/manifest-cache.ts +333 -0
  58. package/src/security/manifest-schema.ts +129 -0
  59. package/src/security/registry-pubkey.generated.ts +35 -0
  60. package/src/security/telemetry.ts +320 -0
  61. package/src/watch/daemon.ts +1 -1
  62. package/src/watch/paths.ts +2 -2
  63. package/templates/aspnet/massu.config.yaml +57 -0
  64. package/templates/go-chi/massu.config.yaml +52 -0
  65. package/templates/phoenix/massu.config.yaml +54 -0
  66. package/templates/python-flask/massu.config.yaml +51 -0
  67. package/templates/rails/massu.config.yaml +56 -0
  68. package/templates/spring/massu.config.yaml +56 -0
@@ -69,6 +69,7 @@ export async function runConfigCheckDrift(
69
69
  let config: AnyConfig;
70
70
  try {
71
71
  const content = readFileSync(configPath, 'utf-8');
72
+ // pattern-scanner-allow: yaml-parse — reason: v1.1.0 drift detector. Raw YAML compare needed at arbitrary cwd before getConfig() cache populates; the drift detector intentionally bypasses the cached parse to inspect on-disk state.
72
73
  const parsed = parseYaml(content);
73
74
  if (!parsed || typeof parsed !== 'object') {
74
75
  throw new Error('config is not a YAML object');
@@ -179,7 +179,7 @@ export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConf
179
179
  // paths.aliases is a 2-level-nested user block. Detector always writes
180
180
  // { '@': <source-dir> }; user-authored alias map must survive. Spread user
181
181
  // over detector so user keys win for any overlap AND user-only keys survive.
182
- // (P5-002 discovery — hedge's paths.aliases['@'] was being overwritten.)
182
+ // (P5-002 discovery — a downstream consumer's paths.aliases['@'] was being overwritten.)
183
183
  const existingPaths = existing.paths;
184
184
  const outPaths = out.paths;
185
185
  if (
@@ -203,9 +203,9 @@ export function mergeRefresh(existing: AnyConfig, refreshed: AnyConfig): AnyConf
203
203
 
204
204
  // verification is the other 2-level-nested detector-owned block. Semantics
205
205
  // mirror migrate.ts:132-138 buildVerificationBlock: user's custom language
206
- // sections (e.g., hedge's `gateway`, `ios`, `runtime`, `web`) survive
206
+ // sections (e.g., a multi-runtime monorepo's `gateway`, `ios`, `runtime`, `web`) survive
207
207
  // wholesale; user's command overrides on shared languages (e.g., `python`)
208
- // win over detector defaults. (P5-002 discovery — hedge was losing 15
208
+ // win over detector defaults. (P5-002 discovery — a downstream consumer was losing 15
209
209
  // verification command entries across 4 custom language sections plus
210
210
  // having 4 python commands overwritten with detector defaults.)
211
211
  const existingVer = existing.verification;
@@ -272,6 +272,7 @@ export async function runConfigRefresh(opts: ConfigRefreshOptions = {}): Promise
272
272
  let existing: AnyConfig;
273
273
  try {
274
274
  const content = readFileSync(configPath, 'utf-8');
275
+ // pattern-scanner-allow: yaml-parse — reason: v1.1.0 config lifecycle command. getConfig() Zod-rejects pre-migration v1 configs (which is exactly what `massu config refresh` is meant to read + migrate). Raw parse is required to inspect a pre-upgrade YAML structure.
275
276
  const parsed = parseYaml(content);
276
277
  if (!parsed || typeof parsed !== 'object') {
277
278
  throw new Error('config is not a YAML object');
@@ -74,6 +74,7 @@ export async function runConfigUpgrade(opts: ConfigUpgradeOptions = {}): Promise
74
74
  let existing: AnyConfig;
75
75
  try {
76
76
  const content = readFileSync(configPath, 'utf-8');
77
+ // pattern-scanner-allow: yaml-parse — reason: v1.1.0 config lifecycle command. Same pre-migration constraint as config-refresh: `massu config upgrade` operates on v1 configs that getConfig() Zod-rejects, so raw parse is required.
77
78
  const parsed = parseYaml(content);
78
79
  if (!parsed || typeof parsed !== 'object') {
79
80
  throw new Error('config is not a YAML object');
@@ -67,6 +67,7 @@ function checkConfig(projectRoot: string): CheckResult {
67
67
 
68
68
  try {
69
69
  const content = readFileSync(configPath, 'utf-8');
70
+ // pattern-scanner-allow: yaml-parse — reason: `massu doctor` config-integrity diagnostic; runs BEFORE getConfig() to verify the file is well-formed YAML so the user sees a parse error here, not a Zod-validation surprise downstream.
70
71
  const parsed = parseYaml(content);
71
72
  if (!parsed || typeof parsed !== 'object') {
72
73
  return { name: 'Configuration', status: 'fail', detail: 'massu.config.yaml is empty or invalid YAML' };
@@ -449,6 +450,7 @@ export async function runValidateConfig(): Promise<void> {
449
450
 
450
451
  try {
451
452
  const content = readFileSync(configPath, 'utf-8');
453
+ // pattern-scanner-allow: yaml-parse — reason: `massu doctor` deep-validation pass; same diagnostic-mode reasoning as the upper checkConfig() — surfaces YAML errors with operator-friendly stderr before any getConfig() call.
452
454
  const parsed = parseYaml(content);
453
455
 
454
456
  if (!parsed || typeof parsed !== 'object') {
@@ -5,7 +5,7 @@
5
5
  * `massu init` — One-command, detection-driven project setup.
6
6
  *
7
7
  * Phase 3 rewrite (2026-04-19): replaces the JS/TS template copier (old
8
- * detectFramework/generateConfig path, root cause of Hedge-style stale configs)
8
+ * detectFramework/generateConfig path, root cause of multi-runtime stale-config drift)
9
9
  * with a flow that runs the Phase 1 detection engine (`runDetection`) and
10
10
  * generates a v2 schema_version=2 `massu.config.yaml` that reflects the
11
11
  * actual repo layout (languages, source_dirs, verification commands, domains).
@@ -616,6 +616,7 @@ export function writeConfigAtomic(
616
616
  }
617
617
 
618
618
  // Validate YAML parses.
619
+ // pattern-scanner-allow: yaml-parse — reason: atomic-write post-validator. We just generated `content` and wrote it to a tmp path; before atomic rename to the final config path we re-parse to verify the bytes we serialized are valid YAML. Calling getConfig() here would re-read from cwd and miss the tmp file.
619
620
  const parsed = yamlParse(content);
620
621
  if (parsed === null || typeof parsed !== 'object') {
621
622
  throw new Error('Generated config is not a valid YAML object');
@@ -661,6 +662,7 @@ export function validateWrittenConfig(
661
662
  // getConfig caches against process.cwd() and we may be validating a config
662
663
  // outside the current working tree (tests, etc.).
663
664
  const content = readFileSync(configPath, 'utf-8');
665
+ // pattern-scanner-allow: yaml-parse — reason: see preceding comment block; getConfig() caches against process.cwd() and would either return a stale entry or fail to read a config at an arbitrary projectRoot.
664
666
  const parsed = yamlParse(content);
665
667
  if (parsed === null || typeof parsed !== 'object') {
666
668
  return 'Config is not a valid YAML object';
@@ -4,8 +4,6 @@
4
4
  /**
5
5
  * Massu codebase-aware templating engine — string substitution only.
6
6
  *
7
- * Spec: docs/internal/2026-04-26-codebase-aware-templates-spec.md
8
- *
9
7
  * Grammar (the entire surface):
10
8
  * {{path.to.var}} Look up + render
11
9
  * {{path.to.var | default("fallback")}} Look up; use literal on miss
@@ -72,7 +72,7 @@ function parseFlags(args: string[]): ParsedFlags {
72
72
  }
73
73
 
74
74
  function findClaudeBg(): string | null {
75
- // Prefer the well-known Hedge-author install path; fall back to PATH.
75
+ // Prefer the conventional ~/.claude/bin install path; fall back to PATH.
76
76
  const home = process.env.HOME ?? '';
77
77
  const fixed = home ? resolve(home, '.claude', 'bin', 'claude-bg') : null;
78
78
  if (fixed && existsSync(fixed)) return fixed;
package/src/config.ts CHANGED
@@ -372,6 +372,59 @@ const WatchConfigSchema = z.object({
372
372
  }).passthrough().optional();
373
373
  export type WatchConfig = z.infer<typeof WatchConfigSchema>;
374
374
 
375
+ // --- Adapters Config (Plan 3c — third-party adapter registry) ---
376
+ // Tunable knobs for the third-party adapter loading subsystem.
377
+ // `enabled` is the kill switch (default false for first 2 minor releases per
378
+ // Plan 3c gap-1 + audit-iteration-1 deliverable). When false, only the 35
379
+ // CORE-BUNDLED adapters in @massu/core itself load; @massu/adapter-* npm
380
+ // packages and adapters.local entries are ignored.
381
+ // `local` is the explicit per-path opt-in for project-local TS/JS adapters.
382
+ //
383
+ // Note: `allow_unsigned` was REMOVED 2026-05-07 (CR-9 audit C2 fix). The
384
+ // field had been parsed by the schema but never consulted at any callsite,
385
+ // creating a tripwire — a future contributor wiring it would silently
386
+ // disable signature verification. Per Rule 0 / drift-prevention, dead
387
+ // fields are removed, not documented. No v1 use case exists for unsigned
388
+ // adapter loading; if a future v2 adds one, it ships with documented
389
+ // callsite enforcement and a different field name to avoid the tripwire
390
+ // pattern.
391
+ //
392
+ // Path validation (gap-58): adapters.local entries are normalized to POSIX form
393
+ // for cross-platform fingerprint stability. Absolute paths and `..` traversal
394
+ // are rejected at schema validation (attack-surface defense — a malicious
395
+ // postinstall could add "/etc/passwd" or "../../../home/user/.ssh/" entries
396
+ // that bypass the project tree).
397
+ export const AdapterLocalPathSchema = z.string()
398
+ .refine((s) => !/^([A-Za-z]:[\\/]|[\\/])/.test(s), {
399
+ message: 'absolute paths are rejected; adapters.local entries must be relative to the massu.config.yaml directory',
400
+ })
401
+ .refine((s) => !s.split(/[\\/]/).includes('..'), {
402
+ message: 'parent-directory traversal (`..`) is rejected; adapters.local entries must stay inside the project tree',
403
+ })
404
+ // Normalize to POSIX form for stable cross-platform sha256 fingerprinting.
405
+ // Windows backslash inputs are converted to forward slashes; consecutive
406
+ // separators are collapsed; trailing `./` segments are removed.
407
+ .transform((s) => s.split(/[\\/]/).filter((part) => part !== '' && part !== '.').join('/'));
408
+
409
+ const AdaptersConfigSchema = z.object({
410
+ enabled: z.boolean().default(false),
411
+ local: z.array(AdapterLocalPathSchema).default([]),
412
+ }).passthrough().optional();
413
+ export type AdaptersConfig = z.infer<typeof AdaptersConfigSchema>;
414
+
415
+ // --- Telemetry Config (Plan 3c — adapter-discovery telemetry) ---
416
+ // Optional anonymous telemetry on adapter-discovery counts (NOT file paths,
417
+ // NOT symbol names, NOT source content). Strictly off by default; never
418
+ // enabled silently. When `adapters: true`, the telemetry writer at
419
+ // packages/core/src/security/telemetry.ts emits one JSONL line per discovery
420
+ // event matching AdapterDiscoveryPayloadSchema (defined inline in that
421
+ // module). PII guardrail: schema is `.strict()` so unknown keys are
422
+ // rejected at write time AND at replay time.
423
+ const TelemetryConfigSchema = z.object({
424
+ adapters: z.boolean().default(false),
425
+ }).passthrough().optional();
426
+ export type TelemetryConfig = z.infer<typeof TelemetryConfigSchema>;
427
+
375
428
  // --- LSP Config (Plan 3b — Phase 4: LSP integration) ---
376
429
  // Top-level optional `lsp:` block configuring optional LSP enrichment of AST
377
430
  // adapter results. Per VR-LSP-AUTODETECT-OFF-BY-DEFAULT (audit-iter-1 fix G4):
@@ -383,6 +436,14 @@ export const LSPConfigSchema = z.object({
383
436
  servers: z.array(z.object({
384
437
  language: z.string(),
385
438
  command: z.string(),
439
+ // F-014 (closed 2026-05-06): explicit opt-in to spawn SUID/SGID
440
+ // binaries. Default false — argv[0] with the SUID bit is rejected
441
+ // unless this is true. Decision is auditable in the YAML.
442
+ allow_setuid: z.boolean().default(false),
443
+ // F-015 (closed 2026-05-06): per-server RSS budget (MB). Watchdog
444
+ // SIGKILLs the server after sustained breach. Default 1024 MB.
445
+ // Set to 0 to disable the watchdog for this server.
446
+ max_rss_mb: z.number().int().nonnegative().default(1024),
386
447
  })).default([]),
387
448
  autoDetect: z.object({
388
449
  viaPortScan: z.boolean().default(false),
@@ -432,6 +493,10 @@ const RawConfigSchema = z.object({
432
493
  detected: DetectedConfigSchema,
433
494
  // Plan 3a: file-watcher daemon tunables
434
495
  watch: WatchConfigSchema,
496
+ // Plan 3c: third-party adapter registry kill-switch + signing override + local-path opt-in.
497
+ adapters: AdaptersConfigSchema,
498
+ // Plan 3c: anonymous adapter-discovery telemetry opt-in (default off).
499
+ telemetry: TelemetryConfigSchema,
435
500
  // Plan 3b Phase 4: optional LSP enrichment of AST adapter results.
436
501
  lsp: LSPConfigSchema.optional(),
437
502
  }).passthrough();
@@ -476,6 +541,10 @@ export interface Config {
476
541
  detected?: DetectedConfig;
477
542
  // Plan 3a: file-watcher daemon tunables
478
543
  watch?: WatchConfig;
544
+ // Plan 3c: third-party adapter registry config block.
545
+ adapters?: AdaptersConfig;
546
+ // Plan 3c: telemetry opt-in block.
547
+ telemetry?: TelemetryConfig;
479
548
  // Plan 3b Phase 4: optional LSP enrichment block (default disabled).
480
549
  lsp?: LSPConfig;
481
550
  }
@@ -627,6 +696,8 @@ export function getConfig(): Config {
627
696
  detection: parsed.detection,
628
697
  detected: parsed.detected,
629
698
  watch: parsed.watch,
699
+ adapters: parsed.adapters,
700
+ telemetry: parsed.telemetry,
630
701
  lsp: parsed.lsp,
631
702
  };
632
703
 
@@ -0,0 +1,293 @@
1
+ // Copyright (c) 2026 Massu. All rights reserved.
2
+ // Licensed under BSL 1.1 - see LICENSE file for details.
3
+
4
+ /**
5
+ * Plan 3c — Phase 7: ASP.NET Core AST adapter.
6
+ *
7
+ * Fifth Phase 7 framework after go-chi + Flask + Rails + Phoenix. First to
8
+ * consume the `csharp` Tree-sitter grammar entry from GRAMMAR_MANIFEST
9
+ * (commit fbb8aa9). All queries verified against actual tree-sitter-c-sharp
10
+ * AST shape via probe (R-011) BEFORE writing the adapter.
11
+ *
12
+ * ASP.NET Core supports two routing styles, both first-class per the
13
+ * official routing guide (https://learn.microsoft.com/aspnet/core/fundamentals/routing):
14
+ * 1. **Minimal API** (recommended for new projects since .NET 6):
15
+ * `app.MapGet("/path", handler)`, `app.MapPost(...)`, etc. in
16
+ * Program.cs.
17
+ * 2. **Attribute routing** (MVC controllers): `[HttpGet("{id}")]`,
18
+ * `[HttpPost]`, `[Route("api/[controller]")]` on controller classes
19
+ * and methods.
20
+ *
21
+ * The adapter handles BOTH styles uniformly — extracted route_method
22
+ * normalizes `MapGet`/`HttpGet` → `Get`, `MapPost`/`HttpPost` → `Post`,
23
+ * etc. so downstream consumers don't need to know which style produced
24
+ * the signal.
25
+ *
26
+ * Extracts:
27
+ * - route_method: most-common HTTP verb captured from EITHER `app.Map<Verb>`
28
+ * invocations (minimal API) OR `[Http<Verb>]` attributes (MVC).
29
+ * - route_prefix_base: first path segment from EITHER the first `MapGet`
30
+ * string-literal path arg OR the first class-level `[Route("template")]`
31
+ * attribute. Mirrors phoenix scope_prefix_base / rails api_namespace.
32
+ * - controller_class: name of the first class ending in `Controller`
33
+ * (attribute-routing style only — minimal API has no controllers).
34
+ * Mirrors python-flask app_factory / phoenix router_module.
35
+ *
36
+ * Confidence rules (mirror phoenix / rails / go-chi):
37
+ * - 'high' if exactly ONE distinct route_method seen.
38
+ * - 'low' if multiple distinct route_methods seen.
39
+ * - 'medium' if route_prefix_base or controller_class found but no
40
+ * route_method.
41
+ * - 'none' if no ASP.NET signals at all (regex fallback takes over).
42
+ *
43
+ * Tree-sitter-c-sharp AST shape (verified via probe 2026-05-07):
44
+ * - Method calls: `(invocation_expression function: (member_access_expression
45
+ * name: (identifier)) arguments: (argument_list (argument
46
+ * (string_literal)) ...))`.
47
+ * - Attributes: `(attribute name: (identifier) (attribute_argument_list
48
+ * (attribute_argument (string_literal))?))` — argument list is optional
49
+ * because attributes like `[HttpPost]` have no args.
50
+ * - Class declarations: `(class_declaration name: (identifier)
51
+ * bases: (base_list (identifier)?))` — bases optional.
52
+ *
53
+ * Does NOT use regex on file content — only Tree-sitter S-expression queries
54
+ * compiled via query-helpers.ts.
55
+ */
56
+
57
+ import { Parser } from 'web-tree-sitter';
58
+ import type { CodebaseAdapter, AdapterResult, DetectionSignals, Provenance, SourceFile } from './types.ts';
59
+ import { runQuery, InvalidQueryError } from './query-helpers.ts';
60
+ import { loadGrammar } from './tree-sitter-loader.ts';
61
+ import { isParsableSource, MAX_AST_FILE_BYTES } from './parse-guard.ts';
62
+
63
+ // ============================================================
64
+ // Tree-sitter S-expression queries (C# grammar)
65
+ // ============================================================
66
+
67
+ /**
68
+ * Minimal-API verb mapping: `app.MapGet("/path", handler)`,
69
+ * `app.MapPost(...)`, etc. Anchored on the first argument being a
70
+ * string_literal so we capture the route path together with the verb.
71
+ *
72
+ * The captured @method is the full method name like `MapGet` — the
73
+ * adapter strips the `Map` prefix in post-processing.
74
+ *
75
+ * Per ASP.NET Core minimal API docs:
76
+ * https://learn.microsoft.com/aspnet/core/fundamentals/minimal-apis
77
+ */
78
+ const MAP_VERB_QUERY = `
79
+ (invocation_expression
80
+ function: (member_access_expression
81
+ name: (identifier) @method (#match? @method "^Map(Get|Post|Put|Patch|Delete|Head|Options)$"))
82
+ arguments: (argument_list
83
+ .
84
+ (argument (string_literal) @route_path)))
85
+ `;
86
+
87
+ /**
88
+ * Attribute-routing HTTP verb attributes: `[HttpGet]`, `[HttpGet("{id}")]`,
89
+ * `[HttpPost]`, etc. Captures BOTH the parameterless and parameterized
90
+ * forms — the route path may or may not be present.
91
+ *
92
+ * The captured @attr_name is `HttpGet` etc. — the adapter strips the
93
+ * `Http` prefix in post-processing.
94
+ *
95
+ * Per ASP.NET Core attribute routing docs:
96
+ * https://learn.microsoft.com/aspnet/core/mvc/controllers/routing
97
+ */
98
+ const HTTP_ATTR_QUERY = `
99
+ (attribute
100
+ name: (identifier) @attr_name (#match? @attr_name "^Http(Get|Post|Put|Patch|Delete|Head|Options)$"))
101
+ `;
102
+
103
+ /**
104
+ * Class-level `[Route("api/[controller]")]` attribute. Captures the
105
+ * route template string so we can extract its first path segment.
106
+ * Tokens like `[controller]` inside the template are kept verbatim —
107
+ * the prefix-base extractor splits on `/` so `api/[controller]` → `/api`.
108
+ */
109
+ const ROUTE_ATTR_QUERY = `
110
+ (attribute
111
+ name: (identifier) @_attr_name (#eq? @_attr_name "Route")
112
+ (attribute_argument_list
113
+ (attribute_argument (string_literal) @route_template)))
114
+ `;
115
+
116
+ /**
117
+ * Controller class declaration: `class FooController : ControllerBase`.
118
+ * We restrict to class names ending in `Controller` to avoid every class
119
+ * in the project (canonical ASP.NET MVC naming convention).
120
+ */
121
+ const CONTROLLER_CLASS_QUERY = `
122
+ (class_declaration
123
+ name: (identifier) @class_name (#match? @class_name "Controller$"))
124
+ `;
125
+
126
+ // ============================================================
127
+ // Adapter
128
+ // ============================================================
129
+
130
+ export const aspnetAdapter: CodebaseAdapter = {
131
+ id: 'aspnet',
132
+ languages: ['csharp'],
133
+
134
+ matches(signals: DetectionSignals): boolean {
135
+ // Cheap signal-only check. No file IO. The canonical ASP.NET Core
136
+ // declaration is `<Project Sdk="Microsoft.NET.Sdk.Web">` in the .csproj
137
+ // file (per https://learn.microsoft.com/aspnet/core/fundamentals/target-aspnetcore).
138
+ // Fallback: presence of `Microsoft.AspNetCore.App` framework reference,
139
+ // which appears in older .csproj formats.
140
+ if (!signals.csproj) return false;
141
+ if (/Sdk\s*=\s*["']Microsoft\.NET\.Sdk\.Web["']/i.test(signals.csproj)) return true;
142
+ if (/Microsoft\.AspNetCore\.App/i.test(signals.csproj)) return true;
143
+ return false;
144
+ },
145
+
146
+ async introspect(files: SourceFile[], _rootDir: string): Promise<AdapterResult> {
147
+ if (files.length === 0) {
148
+ return { conventions: {}, provenance: [], confidence: 'none' };
149
+ }
150
+
151
+ let language;
152
+ try {
153
+ language = await loadGrammar('csharp');
154
+ } catch (e) {
155
+ // Grammar unavailable → adapter returns 'none' so regex fallback takes over.
156
+ return { conventions: {}, provenance: [], confidence: 'none' };
157
+ }
158
+
159
+ const parser = new Parser();
160
+ parser.setLanguage(language);
161
+
162
+ const routeMethods = new Map<string, { line: number; file: string }>();
163
+ const prefixBases = new Map<string, { line: number; file: string }>();
164
+ const controllerClasses = new Map<string, { line: number; file: string }>();
165
+
166
+ try {
167
+ for (const file of files) {
168
+ const skip = isParsableSource(file.content, file.size);
169
+ if (skip) {
170
+ process.stderr.write(
171
+ `[massu/ast] WARN: aspnet skipping ${file.path}: ${skip.reason} (${skip.detail}). Cap=${MAX_AST_FILE_BYTES}. (Phase 3.5 mitigation)\n`,
172
+ );
173
+ continue;
174
+ }
175
+ try {
176
+ // Minimal API: app.MapGet("/path", ...)
177
+ for (const hit of runQuery(parser, file.content, MAP_VERB_QUERY, 'aspnet-map-verb', file.path)) {
178
+ const methodRaw = hit.captures.method;
179
+ if (!methodRaw) continue;
180
+ const verb = methodRaw.replace(/^Map/, ''); // MapGet → Get
181
+ if (!routeMethods.has(verb)) {
182
+ routeMethods.set(verb, { line: hit.line, file: file.path });
183
+ }
184
+ // Also capture the route path for prefix base
185
+ const pathRaw = hit.captures.route_path;
186
+ if (pathRaw) {
187
+ const literal = pathRaw.replace(/^["']/, '').replace(/["']$/, '');
188
+ const base = extractPrefixBase(literal);
189
+ if (base && !prefixBases.has(base)) {
190
+ prefixBases.set(base, { line: hit.line, file: file.path });
191
+ }
192
+ }
193
+ }
194
+ // Attribute routing: [HttpGet], [HttpPost], etc.
195
+ for (const hit of runQuery(parser, file.content, HTTP_ATTR_QUERY, 'aspnet-http-attr', file.path)) {
196
+ const attrRaw = hit.captures.attr_name;
197
+ if (!attrRaw) continue;
198
+ const verb = attrRaw.replace(/^Http/, ''); // HttpGet → Get
199
+ if (!routeMethods.has(verb)) {
200
+ routeMethods.set(verb, { line: hit.line, file: file.path });
201
+ }
202
+ }
203
+ // Class-level [Route("api/[controller]")]
204
+ for (const hit of runQuery(parser, file.content, ROUTE_ATTR_QUERY, 'aspnet-route-attr', file.path)) {
205
+ const tplRaw = hit.captures.route_template;
206
+ if (!tplRaw) continue;
207
+ const literal = tplRaw.replace(/^["']/, '').replace(/["']$/, '');
208
+ const base = extractPrefixBase(literal);
209
+ if (base && !prefixBases.has(base)) {
210
+ prefixBases.set(base, { line: hit.line, file: file.path });
211
+ }
212
+ }
213
+ // Controller class: class FooController : ControllerBase
214
+ for (const hit of runQuery(parser, file.content, CONTROLLER_CLASS_QUERY, 'aspnet-controller-class', file.path)) {
215
+ const name = hit.captures.class_name;
216
+ if (name && !controllerClasses.has(name)) {
217
+ controllerClasses.set(name, { line: hit.line, file: file.path });
218
+ }
219
+ }
220
+ } catch (e) {
221
+ if (e instanceof InvalidQueryError) {
222
+ throw e;
223
+ }
224
+ continue;
225
+ }
226
+ }
227
+ } finally {
228
+ try { parser.delete(); } catch { /* ignore */ }
229
+ }
230
+
231
+ const conventions: Record<string, unknown> = {};
232
+ const provenance: Provenance[] = [];
233
+
234
+ if (routeMethods.size === 1) {
235
+ const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
236
+ conventions.route_method = name;
237
+ provenance.push({ field: 'route_method', sourceFile: file, line, query: 'aspnet-map-verb' });
238
+ } else if (routeMethods.size >= 2) {
239
+ const [name, { line, file }] = routeMethods.entries().next().value as [string, { line: number; file: string }];
240
+ conventions.route_method = name;
241
+ provenance.push({ field: 'route_method', sourceFile: file, line, query: 'aspnet-map-verb' });
242
+ }
243
+
244
+ if (prefixBases.size >= 1) {
245
+ const [base, { line, file }] = prefixBases.entries().next().value as [string, { line: number; file: string }];
246
+ conventions.route_prefix_base = base;
247
+ provenance.push({ field: 'route_prefix_base', sourceFile: file, line, query: 'aspnet-route-prefix' });
248
+ }
249
+
250
+ if (controllerClasses.size >= 1) {
251
+ const [name, { line, file }] = controllerClasses.entries().next().value as [string, { line: number; file: string }];
252
+ conventions.controller_class = name;
253
+ provenance.push({ field: 'controller_class', sourceFile: file, line, query: 'aspnet-controller-class' });
254
+ }
255
+
256
+ let confidence: AdapterResult['confidence'];
257
+ if (Object.keys(conventions).length === 0) {
258
+ confidence = 'none';
259
+ } else if (routeMethods.size === 1) {
260
+ confidence = 'high';
261
+ } else if (routeMethods.size >= 2) {
262
+ confidence = 'low';
263
+ } else {
264
+ confidence = 'medium';
265
+ }
266
+
267
+ return { conventions, provenance, confidence };
268
+ },
269
+ };
270
+
271
+ // ============================================================
272
+ // Helpers
273
+ // ============================================================
274
+
275
+ /**
276
+ * Extract the first path segment of an ASP.NET route template. Mirrors
277
+ * phoenix/rails/python-flask/go-chi prefix-base extractors. Returns null
278
+ * if input is empty or `/`-only.
279
+ *
280
+ * Examples (verified against test fixtures):
281
+ * "/health" → "/health"
282
+ * "api/[controller]" → "/api"
283
+ * "/api/v1/users" → "/api"
284
+ * "/" → null
285
+ * "" → null
286
+ */
287
+ function extractPrefixBase(prefix: string): string | null {
288
+ // ASP.NET route templates may or may not begin with `/`. Normalize.
289
+ const stripped = prefix.replace(/^\/+/, '');
290
+ const firstSeg = stripped.split('/')[0];
291
+ if (!firstSeg) return null;
292
+ return '/' + firstSeg;
293
+ }