@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.
- package/dist/cli.js +9431 -5167
- package/dist/hooks/auto-learning-pipeline.js +18 -0
- package/dist/hooks/classify-failure.js +18 -0
- package/dist/hooks/cost-tracker.js +18 -0
- package/dist/hooks/fix-detector.js +18 -0
- package/dist/hooks/incident-pipeline.js +18 -0
- package/dist/hooks/post-edit-context.js +18 -0
- package/dist/hooks/post-tool-use.js +18 -0
- package/dist/hooks/pre-compact.js +18 -0
- package/dist/hooks/pre-delete-check.js +18 -0
- package/dist/hooks/quality-event.js +18 -0
- package/dist/hooks/rule-enforcement-pipeline.js +18 -0
- package/dist/hooks/session-end.js +18 -0
- package/dist/hooks/session-start.js +2952 -2740
- package/dist/hooks/user-prompt.js +18 -0
- package/docs/AUTHORING-ADAPTERS.md +207 -0
- package/docs/SECURITY.md +250 -0
- package/package.json +7 -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 +1 -0
- package/src/commands/config-upgrade.ts +1 -0
- package/src/commands/doctor.ts +2 -0
- package/src/commands/init.ts +151 -2
- package/src/config.ts +63 -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 +50 -0
- package/src/detect/adapters/types.ts +18 -0
- package/src/detect/framework-detector.ts +26 -0
- package/src/detect/manifest-registry.ts +261 -0
- package/src/detect/monorepo-detector.ts +1 -0
- package/src/detect/package-detector.ts +162 -62
- package/src/detect/source-dir-detector.ts +7 -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/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/templates/aspnet/massu.config.yaml +61 -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');
|
|
@@ -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
|
@@ -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
|
-
|
|
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
|
+
}
|