@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.
- package/commands/README.md +0 -3
- package/dist/cli.js +9423 -5453
- package/dist/hooks/auto-learning-pipeline.js +27 -1
- package/dist/hooks/classify-failure.js +27 -1
- package/dist/hooks/cost-tracker.js +27 -1
- package/dist/hooks/fix-detector.js +27 -1
- package/dist/hooks/incident-pipeline.js +27 -1
- package/dist/hooks/post-edit-context.js +27 -1
- package/dist/hooks/post-tool-use.js +27 -1
- package/dist/hooks/pre-compact.js +27 -1
- package/dist/hooks/pre-delete-check.js +27 -1
- package/dist/hooks/quality-event.js +27 -1
- package/dist/hooks/rule-enforcement-pipeline.js +27 -1
- package/dist/hooks/session-end.js +27 -1
- package/dist/hooks/session-start.js +2677 -2675
- package/dist/hooks/user-prompt.js +27 -1
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +10 -3
- package/src/adapter.ts +90 -0
- package/src/cli.ts +7 -0
- package/src/commands/adapters.ts +824 -0
- package/src/commands/config-check-drift.ts +1 -0
- package/src/commands/config-refresh.ts +4 -3
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +3 -1
- package/src/commands/template-engine.ts +0 -2
- package/src/commands/watch.ts +1 -1
- package/src/config.ts +71 -0
- package/src/detect/adapters/aspnet.ts +293 -0
- package/src/detect/adapters/discover.ts +469 -0
- package/src/detect/adapters/go-chi.ts +261 -0
- package/src/detect/adapters/index.ts +49 -0
- package/src/detect/adapters/phoenix.ts +277 -0
- package/src/detect/adapters/python-flask.ts +235 -0
- package/src/detect/adapters/rails.ts +279 -0
- package/src/detect/adapters/runner.ts +32 -0
- package/src/detect/adapters/spring.ts +284 -0
- package/src/detect/adapters/tree-sitter-loader.ts +171 -2
- package/src/detect/adapters/types.ts +19 -2
- package/src/detect/migrate.ts +4 -4
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/hooks/post-tool-use.ts +1 -0
- package/src/hooks/session-start.ts +1 -0
- package/src/lib/fileLock.ts +203 -0
- package/src/lib/installLock.ts +31 -144
- package/src/lsp/auto-detect.ts +10 -1
- package/src/lsp/client.ts +188 -2
- package/src/memory-file-ingest.ts +1 -0
- package/src/security/adapter-origin.ts +130 -0
- package/src/security/adapter-verifier.ts +319 -0
- package/src/security/atomic-write.ts +164 -0
- package/src/security/fetcher.ts +200 -0
- package/src/security/install-tracking.ts +319 -0
- package/src/security/local-fingerprint.ts +225 -0
- package/src/security/manifest-cache.ts +333 -0
- package/src/security/manifest-schema.ts +129 -0
- package/src/security/registry-pubkey.generated.ts +35 -0
- package/src/security/telemetry.ts +320 -0
- package/src/watch/daemon.ts +1 -1
- package/src/watch/paths.ts +2 -2
- package/templates/aspnet/massu.config.yaml +57 -0
- package/templates/go-chi/massu.config.yaml +52 -0
- package/templates/phoenix/massu.config.yaml +54 -0
- package/templates/python-flask/massu.config.yaml +51 -0
- package/templates/rails/massu.config.yaml +56 -0
- 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 —
|
|
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.,
|
|
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 —
|
|
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');
|
package/src/commands/doctor.ts
CHANGED
|
@@ -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') {
|
package/src/commands/init.ts
CHANGED
|
@@ -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
|
|
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
|
package/src/commands/watch.ts
CHANGED
|
@@ -72,7 +72,7 @@ function parseFlags(args: string[]): ParsedFlags {
|
|
|
72
72
|
}
|
|
73
73
|
|
|
74
74
|
function findClaudeBg(): string | null {
|
|
75
|
-
// Prefer the
|
|
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
|
+
}
|