@massu/core 1.4.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.
Files changed (64) hide show
  1. package/dist/cli.js +9431 -5167
  2. package/dist/hooks/auto-learning-pipeline.js +18 -0
  3. package/dist/hooks/classify-failure.js +18 -0
  4. package/dist/hooks/cost-tracker.js +18 -0
  5. package/dist/hooks/fix-detector.js +18 -0
  6. package/dist/hooks/incident-pipeline.js +18 -0
  7. package/dist/hooks/post-edit-context.js +18 -0
  8. package/dist/hooks/post-tool-use.js +18 -0
  9. package/dist/hooks/pre-compact.js +18 -0
  10. package/dist/hooks/pre-delete-check.js +18 -0
  11. package/dist/hooks/quality-event.js +18 -0
  12. package/dist/hooks/rule-enforcement-pipeline.js +18 -0
  13. package/dist/hooks/session-end.js +18 -0
  14. package/dist/hooks/session-start.js +2952 -2740
  15. package/dist/hooks/user-prompt.js +18 -0
  16. package/docs/AUTHORING-ADAPTERS.md +207 -0
  17. package/docs/SECURITY.md +250 -0
  18. package/package.json +7 -3
  19. package/src/adapter.ts +90 -0
  20. package/src/cli.ts +7 -0
  21. package/src/commands/adapters.ts +824 -0
  22. package/src/commands/config-check-drift.ts +1 -0
  23. package/src/commands/config-refresh.ts +1 -0
  24. package/src/commands/config-upgrade.ts +1 -0
  25. package/src/commands/doctor.ts +2 -0
  26. package/src/commands/init.ts +151 -2
  27. package/src/config.ts +63 -0
  28. package/src/detect/adapters/aspnet.ts +293 -0
  29. package/src/detect/adapters/discover.ts +469 -0
  30. package/src/detect/adapters/go-chi.ts +261 -0
  31. package/src/detect/adapters/index.ts +49 -0
  32. package/src/detect/adapters/phoenix.ts +277 -0
  33. package/src/detect/adapters/python-flask.ts +235 -0
  34. package/src/detect/adapters/rails.ts +279 -0
  35. package/src/detect/adapters/runner.ts +32 -0
  36. package/src/detect/adapters/spring.ts +284 -0
  37. package/src/detect/adapters/tree-sitter-loader.ts +50 -0
  38. package/src/detect/adapters/types.ts +18 -0
  39. package/src/detect/framework-detector.ts +26 -0
  40. package/src/detect/manifest-registry.ts +261 -0
  41. package/src/detect/monorepo-detector.ts +1 -0
  42. package/src/detect/package-detector.ts +162 -62
  43. package/src/detect/source-dir-detector.ts +7 -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/memory-file-ingest.ts +1 -0
  49. package/src/security/adapter-origin.ts +130 -0
  50. package/src/security/adapter-verifier.ts +319 -0
  51. package/src/security/atomic-write.ts +164 -0
  52. package/src/security/fetcher.ts +200 -0
  53. package/src/security/install-tracking.ts +319 -0
  54. package/src/security/local-fingerprint.ts +225 -0
  55. package/src/security/manifest-cache.ts +333 -0
  56. package/src/security/manifest-schema.ts +129 -0
  57. package/src/security/registry-pubkey.generated.ts +35 -0
  58. package/src/security/telemetry.ts +320 -0
  59. package/templates/aspnet/massu.config.yaml +61 -0
  60. package/templates/go-chi/massu.config.yaml +52 -0
  61. package/templates/phoenix/massu.config.yaml +54 -0
  62. package/templates/python-flask/massu.config.yaml +51 -0
  63. package/templates/rails/massu.config.yaml +56 -0
  64. 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');
@@ -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') {
@@ -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.
@@ -616,6 +755,7 @@ export function writeConfigAtomic(
616
755
  }
617
756
 
618
757
  // Validate YAML parses.
758
+ // 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
759
  const parsed = yamlParse(content);
620
760
  if (parsed === null || typeof parsed !== 'object') {
621
761
  throw new Error('Generated config is not a valid YAML object');
@@ -661,6 +801,7 @@ export function validateWrittenConfig(
661
801
  // getConfig caches against process.cwd() and we may be validating a config
662
802
  // outside the current working tree (tests, etc.).
663
803
  const content = readFileSync(configPath, 'utf-8');
804
+ // 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
805
  const parsed = yamlParse(content);
665
806
  if (parsed === null || typeof parsed !== 'object') {
666
807
  return 'Config is not a valid YAML object';
@@ -1226,8 +1367,16 @@ export async function runInit(argv?: string[], overrides?: InitOptions): Promise
1226
1367
  }
1227
1368
  }
1228
1369
 
1229
- // Build config + write atomically.
1230
- 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());
1231
1380
  const content = renderConfigYaml(config);
1232
1381
  const writeRes = writeConfigAtomic(configPath, content);
1233
1382
  if (!writeRes.validated) {
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):
@@ -440,6 +493,10 @@ const RawConfigSchema = z.object({
440
493
  detected: DetectedConfigSchema,
441
494
  // Plan 3a: file-watcher daemon tunables
442
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,
443
500
  // Plan 3b Phase 4: optional LSP enrichment of AST adapter results.
444
501
  lsp: LSPConfigSchema.optional(),
445
502
  }).passthrough();
@@ -484,6 +541,10 @@ export interface Config {
484
541
  detected?: DetectedConfig;
485
542
  // Plan 3a: file-watcher daemon tunables
486
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;
487
548
  // Plan 3b Phase 4: optional LSP enrichment block (default disabled).
488
549
  lsp?: LSPConfig;
489
550
  }
@@ -635,6 +696,8 @@ export function getConfig(): Config {
635
696
  detection: parsed.detection,
636
697
  detected: parsed.detected,
637
698
  watch: parsed.watch,
699
+ adapters: parsed.adapters,
700
+ telemetry: parsed.telemetry,
638
701
  lsp: parsed.lsp,
639
702
  };
640
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
+ }