@massu/core 1.3.0 → 1.4.0-soak.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 +23 -8
- package/commands/massu-deploy.python-docker.md +170 -0
- package/commands/massu-deploy.python-fly.md +189 -0
- package/commands/massu-deploy.python-launchd.md +144 -0
- package/commands/massu-deploy.python-systemd.md +163 -0
- package/commands/massu-scaffold-page.swift.md +10 -10
- package/commands/massu-scaffold-router.python-django.md +153 -0
- package/commands/massu-scaffold-router.python-fastapi.md +145 -0
- package/dist/cli.js +9906 -4133
- package/dist/hooks/auto-learning-pipeline.js +37 -2
- package/dist/hooks/classify-failure.js +37 -2
- package/dist/hooks/cost-tracker.js +37 -2
- package/dist/hooks/fix-detector.js +37 -2
- package/dist/hooks/incident-pipeline.js +37 -2
- package/dist/hooks/post-edit-context.js +37 -2
- package/dist/hooks/post-tool-use.js +37 -2
- package/dist/hooks/pre-compact.js +37 -2
- package/dist/hooks/pre-delete-check.js +37 -2
- package/dist/hooks/quality-event.js +37 -2
- package/dist/hooks/rule-enforcement-pipeline.js +37 -2
- package/dist/hooks/session-end.js +37 -2
- package/dist/hooks/session-start.js +4782 -406
- package/dist/hooks/user-prompt.js +37 -2
- package/package.json +10 -4
- package/src/cli.ts +22 -2
- package/src/commands/config-refresh.ts +88 -20
- package/src/commands/init.ts +130 -23
- package/src/commands/install-commands.ts +142 -26
- package/src/commands/refresh-log.ts +37 -0
- package/src/commands/template-engine.ts +262 -0
- package/src/commands/watch.ts +430 -0
- package/src/config.ts +63 -0
- package/src/detect/adapters/nextjs-trpc.ts +166 -0
- package/src/detect/adapters/parse-guard.ts +133 -0
- package/src/detect/adapters/python-django.ts +208 -0
- package/src/detect/adapters/python-fastapi.ts +223 -0
- package/src/detect/adapters/query-helpers.ts +170 -0
- package/src/detect/adapters/runner.ts +252 -0
- package/src/detect/adapters/swift-swiftui.ts +171 -0
- package/src/detect/adapters/tree-sitter-loader.ts +348 -0
- package/src/detect/adapters/types.ts +174 -0
- package/src/detect/codebase-introspector.ts +190 -0
- package/src/detect/index.ts +28 -2
- package/src/detect/regex-fallback.ts +449 -0
- package/src/hooks/session-start.ts +94 -3
- package/src/lib/gitToplevel.ts +22 -0
- package/src/lib/installLock.ts +179 -0
- package/src/lib/pidLiveness.ts +67 -0
- package/src/lsp/auto-detect.ts +89 -0
- package/src/lsp/client.ts +590 -0
- package/src/lsp/enrich.ts +127 -0
- package/src/lsp/types.ts +221 -0
- package/src/watch/daemon.ts +385 -0
- package/src/watch/lockfile-detector.ts +65 -0
- package/src/watch/paths.ts +279 -0
- package/src/watch/state.ts +178 -0
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Codebase Introspector — 2-tier dispatcher (Plan 3b Phase 1)
|
|
6
|
+
* ============================================================
|
|
7
|
+
*
|
|
8
|
+
* Public signature: `introspect(detection, projectRoot): DetectedConventions`.
|
|
9
|
+
* Byte-for-byte unchanged from Plan #2 — `detect/index.ts` calls this
|
|
10
|
+
* function as before.
|
|
11
|
+
*
|
|
12
|
+
* Internal change (Plan 3b Phase 1): the function is now a 2-tier dispatcher.
|
|
13
|
+
*
|
|
14
|
+
* Tier 1 — AST adapters (preferred). Run via `runner.ts` against the four
|
|
15
|
+
* first-party adapters: python-fastapi, python-django, nextjs-trpc,
|
|
16
|
+
* swift-swiftui. Each writes to its own `detected.<adapter.id>` block
|
|
17
|
+
* alongside the regex blocks. AST adapters use Tree-sitter S-expression
|
|
18
|
+
* queries — never regex. Confidence is per-field.
|
|
19
|
+
*
|
|
20
|
+
* Tier 2 — Regex fallback. For fields the AST adapters returned 'none'
|
|
21
|
+
* confidence on, the regex helpers in `regex-fallback.ts` (verbatim moved
|
|
22
|
+
* from this file's previous incarnation) take over. AST-wins rule: when
|
|
23
|
+
* both tiers produce a value for the same `detected.<lang>.<field>` slot,
|
|
24
|
+
* AST wins; the runner records both in provenance.
|
|
25
|
+
*
|
|
26
|
+
* Tier 3 — null. The template engine's `| default("...")` then takes over.
|
|
27
|
+
*
|
|
28
|
+
* The AST tier may degrade silently to regex-only when:
|
|
29
|
+
* - The Tree-sitter grammar is unavailable offline AND uncached
|
|
30
|
+
* - The adapter throws (per-adapter try/catch in `runner.ts`)
|
|
31
|
+
* - The grammar SHA-256 doesn't match the manifest (refused by loader)
|
|
32
|
+
*
|
|
33
|
+
* Exception: if a Tree-sitter query is malformed (developer bug), the loader
|
|
34
|
+
* propagates `InvalidQueryError` so we don't silently mask it.
|
|
35
|
+
*/
|
|
36
|
+
|
|
37
|
+
import type { DetectionResult } from './index.ts';
|
|
38
|
+
import {
|
|
39
|
+
introspectPython,
|
|
40
|
+
introspectSwift,
|
|
41
|
+
introspectTypeScript,
|
|
42
|
+
type DetectedPython,
|
|
43
|
+
type DetectedSwift,
|
|
44
|
+
type DetectedTypeScript,
|
|
45
|
+
} from './regex-fallback.ts';
|
|
46
|
+
import { runAdapters, buildDetectionSignals } from './adapters/runner.ts';
|
|
47
|
+
import { pythonFastApiAdapter } from './adapters/python-fastapi.ts';
|
|
48
|
+
import { pythonDjangoAdapter } from './adapters/python-django.ts';
|
|
49
|
+
import { nextjsTrpcAdapter } from './adapters/nextjs-trpc.ts';
|
|
50
|
+
import { swiftSwiftUiAdapter } from './adapters/swift-swiftui.ts';
|
|
51
|
+
import type { CodebaseAdapter, AdapterResolved } from './adapters/types.ts';
|
|
52
|
+
|
|
53
|
+
// ============================================================
|
|
54
|
+
// Public types — unchanged from Plan #2 to preserve consumers
|
|
55
|
+
// ============================================================
|
|
56
|
+
|
|
57
|
+
export type { DetectedPython, DetectedSwift, DetectedTypeScript };
|
|
58
|
+
|
|
59
|
+
export interface DetectedConventions {
|
|
60
|
+
python?: DetectedPython;
|
|
61
|
+
swift?: DetectedSwift;
|
|
62
|
+
typescript?: DetectedTypeScript;
|
|
63
|
+
/**
|
|
64
|
+
* AST adapter blocks live here (Plan 3b). Keys are adapter ids
|
|
65
|
+
* (`python-fastapi`, etc.). Values include both extracted conventions and
|
|
66
|
+
* a `_provenance` map.
|
|
67
|
+
*/
|
|
68
|
+
[adapterId: string]: unknown;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ============================================================
|
|
72
|
+
// Static adapter list (v1 — first-party only, per spec §6)
|
|
73
|
+
// ============================================================
|
|
74
|
+
|
|
75
|
+
const FIRST_PARTY_ADAPTERS: CodebaseAdapter[] = [
|
|
76
|
+
pythonFastApiAdapter,
|
|
77
|
+
pythonDjangoAdapter,
|
|
78
|
+
nextjsTrpcAdapter,
|
|
79
|
+
swiftSwiftUiAdapter,
|
|
80
|
+
];
|
|
81
|
+
|
|
82
|
+
// ============================================================
|
|
83
|
+
// Public entry point
|
|
84
|
+
// ============================================================
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Introspect the project's source files. Returns per-language conventions or
|
|
88
|
+
* an empty object if nothing was extracted.
|
|
89
|
+
*
|
|
90
|
+
* Synchronous signature is preserved — AST adapter execution is intentionally
|
|
91
|
+
* fire-and-forget at this layer. Phase 1 wires the adapter pipeline behind
|
|
92
|
+
* the existing sync function so `detect/index.ts` is byte-for-byte unchanged
|
|
93
|
+
* (plan line 137-142). The async adapter orchestration lives entirely inside
|
|
94
|
+
* `runIntrospect()`, which `introspect()` does NOT await — adapters either
|
|
95
|
+
* have their grammars cached (fast path, no async needed at the JS level by
|
|
96
|
+
* Phase 4 wiring) or degrade to regex.
|
|
97
|
+
*
|
|
98
|
+
* For Phase 1 Tier 1 to actually contribute values, callers must use the
|
|
99
|
+
* async variant `introspectAsync()`. The sync `introspect()` runs the regex
|
|
100
|
+
* tier ONLY for Phase 1; Phase 4 callers (LSP enrichment + adapter wiring)
|
|
101
|
+
* will switch to async.
|
|
102
|
+
*/
|
|
103
|
+
export function introspect(
|
|
104
|
+
detection: DetectionResult,
|
|
105
|
+
projectRoot: string,
|
|
106
|
+
): DetectedConventions {
|
|
107
|
+
const out: DetectedConventions = {};
|
|
108
|
+
const languages = Array.from(
|
|
109
|
+
new Set(detection.manifests.map(m => m.language)),
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
// Tier 2 — regex fallback. Always runs. AST adapters in the async variant
|
|
113
|
+
// (`introspectAsync`) populate `detected.<adapter-id>` alongside; for the
|
|
114
|
+
// sync entry point, only the regex tier participates so Plan #2 callers
|
|
115
|
+
// see no behavior change.
|
|
116
|
+
if (languages.includes('python')) {
|
|
117
|
+
const python = introspectPython(detection, projectRoot);
|
|
118
|
+
if (python !== null) out.python = python;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (languages.includes('swift')) {
|
|
122
|
+
const swift = introspectSwift(detection, projectRoot);
|
|
123
|
+
if (swift !== null) out.swift = swift;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (languages.includes('typescript') || languages.includes('javascript')) {
|
|
127
|
+
const ts = introspectTypeScript(detection, projectRoot);
|
|
128
|
+
if (ts !== null) out.typescript = ts;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ============================================================
|
|
135
|
+
// Async variant — used by callers who want AST tier participation
|
|
136
|
+
// ============================================================
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Async introspect: runs AST adapters first (Tier 1), then regex fallback
|
|
140
|
+
* (Tier 2) for fields the adapters returned 'none' on.
|
|
141
|
+
*
|
|
142
|
+
* Returns the same `DetectedConventions` shape as the sync `introspect()`,
|
|
143
|
+
* plus per-adapter blocks under their ids.
|
|
144
|
+
*
|
|
145
|
+
* Callers who can `await` (CLI commands, tests, etc.) should prefer this
|
|
146
|
+
* variant. The session-start hook keeps using sync `introspect()` for its
|
|
147
|
+
* 5s budget reason (P4-006).
|
|
148
|
+
*/
|
|
149
|
+
export async function introspectAsync(
|
|
150
|
+
detection: DetectionResult,
|
|
151
|
+
projectRoot: string,
|
|
152
|
+
): Promise<DetectedConventions> {
|
|
153
|
+
const out: DetectedConventions = introspect(detection, projectRoot);
|
|
154
|
+
|
|
155
|
+
// Build signals + run AST adapters
|
|
156
|
+
const signals = buildDetectionSignals(projectRoot);
|
|
157
|
+
let merged;
|
|
158
|
+
try {
|
|
159
|
+
merged = await runAdapters(FIRST_PARTY_ADAPTERS, projectRoot, signals, {
|
|
160
|
+
sampleFiles: async (_adapter, _root) => {
|
|
161
|
+
// Phase 1 placeholder: file sampling for adapters is wired in
|
|
162
|
+
// dedicated harnesses (per-adapter tests inject SourceFile[] directly).
|
|
163
|
+
// The introspector tier doesn't yet sample for AST adapters — that
|
|
164
|
+
// wiring lands together with Phase 4 LSP enrichment so the same path
|
|
165
|
+
// serves both. For now, returning [] keeps adapters at 'none' which
|
|
166
|
+
// means `out` is regex-only — consistent with the sync path and the
|
|
167
|
+
// pre-Phase-1 baseline test suite.
|
|
168
|
+
return [];
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
} catch {
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
for (const [adapterId, resolved] of Object.entries(merged.byAdapter)) {
|
|
176
|
+
if (resolved.confidence === 'none') continue;
|
|
177
|
+
out[adapterId] = serializeAdapterBlock(resolved);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function serializeAdapterBlock(r: AdapterResolved): Record<string, unknown> {
|
|
184
|
+
const block: Record<string, unknown> = { ...r.conventions };
|
|
185
|
+
if (Object.keys(r._provenance).length > 0) {
|
|
186
|
+
block._provenance = r._provenance;
|
|
187
|
+
}
|
|
188
|
+
block._confidence = r.confidence;
|
|
189
|
+
return block;
|
|
190
|
+
}
|
package/src/detect/index.ts
CHANGED
|
@@ -54,6 +54,10 @@ import {
|
|
|
54
54
|
type UserVerificationEntry,
|
|
55
55
|
} from './vr-command-map.ts';
|
|
56
56
|
import { inferDomains } from './domain-inferrer.ts';
|
|
57
|
+
import {
|
|
58
|
+
introspect,
|
|
59
|
+
type DetectedConventions,
|
|
60
|
+
} from './codebase-introspector.ts';
|
|
57
61
|
|
|
58
62
|
export type {
|
|
59
63
|
PackageManifest,
|
|
@@ -97,6 +101,18 @@ export interface DetectionResult {
|
|
|
97
101
|
verificationCommands: Partial<Record<SupportedLanguage, VRCommandSet>>;
|
|
98
102
|
/** Non-fatal warnings collected across all detectors. */
|
|
99
103
|
warnings: DetectionWarning[];
|
|
104
|
+
/** Plan #2 P3-001: per-language conventions sampled from existing source. */
|
|
105
|
+
detected?: DetectedConventions;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Plan #2 P3-002: opt-out of the codebase introspector. */
|
|
109
|
+
export interface RunDetectionOptions {
|
|
110
|
+
/**
|
|
111
|
+
* When true, skip the codebase introspector pass. Used by the session-start
|
|
112
|
+
* hook (P4-006) to keep its 5-second budget intact — the drift banner only
|
|
113
|
+
* needs the fingerprint, not introspection detail.
|
|
114
|
+
*/
|
|
115
|
+
skipIntrospect?: boolean;
|
|
100
116
|
}
|
|
101
117
|
|
|
102
118
|
function dominantDir(
|
|
@@ -122,7 +138,8 @@ function dominantDir(
|
|
|
122
138
|
*/
|
|
123
139
|
export async function runDetection(
|
|
124
140
|
projectRoot: string,
|
|
125
|
-
overrides?: DetectionConfigOverrides
|
|
141
|
+
overrides?: DetectionConfigOverrides,
|
|
142
|
+
options?: RunDetectionOptions,
|
|
126
143
|
): Promise<DetectionResult> {
|
|
127
144
|
// 1. packages
|
|
128
145
|
const pkg = detectPackageManifests(projectRoot);
|
|
@@ -170,7 +187,7 @@ export async function runDetection(
|
|
|
170
187
|
verificationCommands[lang] = getVRCommands(lang, fw, dir, userOverride);
|
|
171
188
|
}
|
|
172
189
|
|
|
173
|
-
|
|
190
|
+
const result: DetectionResult = {
|
|
174
191
|
projectRoot,
|
|
175
192
|
manifests: pkg.manifests,
|
|
176
193
|
frameworks,
|
|
@@ -180,4 +197,13 @@ export async function runDetection(
|
|
|
180
197
|
verificationCommands,
|
|
181
198
|
warnings: pkg.warnings,
|
|
182
199
|
};
|
|
200
|
+
|
|
201
|
+
// P3-002: codebase introspector pass. Skipped when the caller opts out
|
|
202
|
+
// (the session-start hook at hooks/session-start.ts:272 passes
|
|
203
|
+
// `{ skipIntrospect: true }` to keep its 5s budget intact — see P4-006).
|
|
204
|
+
if (!options?.skipIntrospect) {
|
|
205
|
+
result.detected = introspect(result, projectRoot);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
return result;
|
|
183
209
|
}
|
|
@@ -0,0 +1,449 @@
|
|
|
1
|
+
// Copyright (c) 2026 Massu. All rights reserved.
|
|
2
|
+
// Licensed under BSL 1.1 - see LICENSE file for details.
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Plan 3b — Phase 1: Regex Fallback Introspector.
|
|
6
|
+
*
|
|
7
|
+
* Verbatim move of the per-language regex helpers that were previously in
|
|
8
|
+
* `codebase-introspector.ts` (Plan #2 P3-001). NO regex logic changes — only
|
|
9
|
+
* the import path. This preserves Plan #2's 13 vitest cases as-is (re-imports
|
|
10
|
+
* are updated in `codebase-introspector.test.ts` only if needed; the moved
|
|
11
|
+
* functions retain their original signatures).
|
|
12
|
+
*
|
|
13
|
+
* The new 2-tier introspector (`codebase-introspector.ts`) calls these
|
|
14
|
+
* functions ONLY for fields where AST adapters returned 'none' confidence.
|
|
15
|
+
* AST adapters produce values into separate `detected.<adapter.id>` blocks;
|
|
16
|
+
* regex output continues to go into `detected.python` / `.swift` /
|
|
17
|
+
* `.typescript` blocks.
|
|
18
|
+
*
|
|
19
|
+
* Design rules carried over from Plan #2:
|
|
20
|
+
* - File size cap: 256KB per file (skip silently if larger).
|
|
21
|
+
* - Sample cap: at most 3 files per adapter (sorted, deterministic order).
|
|
22
|
+
* - Total wall-clock budget: <2s on a 10K-file repo.
|
|
23
|
+
* - Filesystem-only. No network, no child processes, no DB.
|
|
24
|
+
* - Returns `null` for any field it can't confidently extract.
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { existsSync, readdirSync, readFileSync, statSync } from 'fs';
|
|
28
|
+
import { resolve, join, basename } from 'path';
|
|
29
|
+
import type { DetectionResult } from './index.ts';
|
|
30
|
+
|
|
31
|
+
export const MAX_FILE_BYTES = 256 * 1024;
|
|
32
|
+
export const MAX_SAMPLES_PER_ADAPTER = 3;
|
|
33
|
+
export const MAX_DIR_DEPTH = 6;
|
|
34
|
+
|
|
35
|
+
// ============================================================
|
|
36
|
+
// Public types (re-exported from codebase-introspector for compat)
|
|
37
|
+
// ============================================================
|
|
38
|
+
|
|
39
|
+
export interface DetectedPython {
|
|
40
|
+
auth_dep?: string;
|
|
41
|
+
api_prefix_base?: string;
|
|
42
|
+
test_async_pattern?: string;
|
|
43
|
+
_provenance?: Record<string, string>;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface DetectedSwift {
|
|
47
|
+
api_client_class?: string;
|
|
48
|
+
biometric_policy?: string;
|
|
49
|
+
_provenance?: Record<string, string>;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DetectedTypeScript {
|
|
53
|
+
trpc_router_builder?: string;
|
|
54
|
+
procedure_pattern?: string;
|
|
55
|
+
_provenance?: Record<string, string>;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ============================================================
|
|
59
|
+
// Python adapter (FastAPI + Django)
|
|
60
|
+
// ============================================================
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Introspect Python sources. Probes both FastAPI router files (`routers/*.py`,
|
|
64
|
+
* `api/*.py`) and Django views (`views.py`). Returns the most-extracted shape.
|
|
65
|
+
*/
|
|
66
|
+
export function introspectPython(
|
|
67
|
+
detection: DetectionResult,
|
|
68
|
+
projectRoot: string,
|
|
69
|
+
): DetectedPython | null {
|
|
70
|
+
const sourceDir = resolveSourceDir(detection, 'python', projectRoot);
|
|
71
|
+
if (!sourceDir) return null;
|
|
72
|
+
|
|
73
|
+
// Sample router-shaped files first (FastAPI / Flask), then views.py (Django),
|
|
74
|
+
// then any .py file as a last resort. Match on PATH, not just filename:
|
|
75
|
+
// FastAPI projects typically name routers by resource (`options.py`, `tax.py`)
|
|
76
|
+
// and place them under a `routers/` directory, so basename-only matching
|
|
77
|
+
// misses them. Also accept files where the basename itself is router-shaped
|
|
78
|
+
// (e.g., `endpoints.py`, `api.py`).
|
|
79
|
+
const routerFiles = sampleFiles(sourceDir, /\.py$/, (absPath, name) =>
|
|
80
|
+
/\/(routers?|api|endpoints?|views)\//.test(absPath) ||
|
|
81
|
+
/^(routers?|api|endpoints?)\.py$/.test(name),
|
|
82
|
+
);
|
|
83
|
+
const viewFiles = sampleFiles(sourceDir, /^views\.py$/);
|
|
84
|
+
const fallbackFiles = routerFiles.length === 0 && viewFiles.length === 0
|
|
85
|
+
? sampleFiles(sourceDir, /\.py$/)
|
|
86
|
+
: [];
|
|
87
|
+
const candidates = [...routerFiles, ...viewFiles, ...fallbackFiles].slice(
|
|
88
|
+
0,
|
|
89
|
+
MAX_SAMPLES_PER_ADAPTER,
|
|
90
|
+
);
|
|
91
|
+
|
|
92
|
+
if (candidates.length === 0) return null;
|
|
93
|
+
|
|
94
|
+
const authDeps = new Map<string, string>(); // value → first source path
|
|
95
|
+
const prefixBases = new Map<string, string>();
|
|
96
|
+
const testAsyncPatterns = new Map<string, string>();
|
|
97
|
+
|
|
98
|
+
for (const path of candidates) {
|
|
99
|
+
const body = readSafe(path);
|
|
100
|
+
if (body === null) continue;
|
|
101
|
+
|
|
102
|
+
// Auth dependency: `Depends(<name>)` — capture the call's argument.
|
|
103
|
+
// ReDoS-safe: anchored, short window, no nested quantifiers.
|
|
104
|
+
const authRegex = /\bDepends\s*\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*\)/gu;
|
|
105
|
+
forEachMatch(authRegex, body, m => {
|
|
106
|
+
const name = m[1];
|
|
107
|
+
if (!authDeps.has(name)) authDeps.set(name, path);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
// Django: `@login_required` or `@permission_required` decorators.
|
|
111
|
+
const djangoAuthRegex = /^@\s*([a-z_][a-z0-9_]*(?:_required|_login))\b/gmu;
|
|
112
|
+
forEachMatch(djangoAuthRegex, body, m => {
|
|
113
|
+
const name = m[1];
|
|
114
|
+
if (!authDeps.has(name)) authDeps.set(name, path);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
// API prefix base: `APIRouter(prefix="/api/...")` — capture just the BASE
|
|
118
|
+
// (everything up to the second `/` from the start).
|
|
119
|
+
const prefixRegex = /\bAPIRouter\s*\(\s*[^)]*?prefix\s*=\s*["']([^"']+)["']/gu;
|
|
120
|
+
forEachMatch(prefixRegex, body, m => {
|
|
121
|
+
const fullPrefix = m[1];
|
|
122
|
+
const base = extractPrefixBase(fullPrefix);
|
|
123
|
+
if (base && !prefixBases.has(base)) prefixBases.set(base, path);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
// Test async pattern: `@pytest.mark.asyncio` (with possible parens).
|
|
127
|
+
const asyncRegex = /^(@pytest\.mark\.asyncio(?:\s*\([^)]*\))?)/gmu;
|
|
128
|
+
forEachMatch(asyncRegex, body, m => {
|
|
129
|
+
const pat = m[1].trim();
|
|
130
|
+
if (!testAsyncPatterns.has(pat)) testAsyncPatterns.set(pat, path);
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const authDep = pickBestSingleton(authDeps);
|
|
135
|
+
const apiPrefixBase = pickBestSingleton(prefixBases);
|
|
136
|
+
const testAsyncPattern = pickBestSingleton(testAsyncPatterns);
|
|
137
|
+
|
|
138
|
+
const result: DetectedPython = {};
|
|
139
|
+
const provenance: Record<string, string> = {};
|
|
140
|
+
|
|
141
|
+
if (authDep) {
|
|
142
|
+
result.auth_dep = authDep.value;
|
|
143
|
+
provenance.auth_dep_source = relativeTo(projectRoot, authDep.source);
|
|
144
|
+
}
|
|
145
|
+
if (apiPrefixBase) {
|
|
146
|
+
result.api_prefix_base = apiPrefixBase.value;
|
|
147
|
+
provenance.api_prefix_base_source = relativeTo(projectRoot, apiPrefixBase.source);
|
|
148
|
+
}
|
|
149
|
+
if (testAsyncPattern) {
|
|
150
|
+
result.test_async_pattern = testAsyncPattern.value;
|
|
151
|
+
provenance.test_async_pattern_source = relativeTo(projectRoot, testAsyncPattern.source);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Only emit a language block when at least one real field was extracted.
|
|
155
|
+
// An empty result (provenance-only) clutters the YAML for no value.
|
|
156
|
+
if (Object.keys(result).length === 0) return null;
|
|
157
|
+
if (Object.keys(provenance).length > 0) result._provenance = provenance;
|
|
158
|
+
return result;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Reduce `/api/foo/bar` to `/api`. Returns null if the path doesn't have at
|
|
163
|
+
* least one slash-segment.
|
|
164
|
+
*/
|
|
165
|
+
function extractPrefixBase(prefix: string): string | null {
|
|
166
|
+
if (!prefix.startsWith('/')) return null;
|
|
167
|
+
const stripped = prefix.replace(/^\/+/, '');
|
|
168
|
+
const firstSeg = stripped.split('/')[0];
|
|
169
|
+
if (!firstSeg) return null;
|
|
170
|
+
return '/' + firstSeg;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ============================================================
|
|
174
|
+
// Swift adapter
|
|
175
|
+
// ============================================================
|
|
176
|
+
|
|
177
|
+
export function introspectSwift(
|
|
178
|
+
detection: DetectionResult,
|
|
179
|
+
projectRoot: string,
|
|
180
|
+
): DetectedSwift | null {
|
|
181
|
+
const sourceDir = resolveSourceDir(detection, 'swift', projectRoot);
|
|
182
|
+
if (!sourceDir) return null;
|
|
183
|
+
|
|
184
|
+
// Prefer View files first, then any .swift. Path-aware: also match files
|
|
185
|
+
// under a `Views/` directory, since SwiftUI projects often name files by
|
|
186
|
+
// feature (e.g., `OrdersList.swift` inside `Features/Orders/Views/`).
|
|
187
|
+
const viewFiles = sampleFiles(sourceDir, /\.swift$/, (absPath, name) =>
|
|
188
|
+
/View\.swift$/.test(name) || /\/Views\//.test(absPath),
|
|
189
|
+
);
|
|
190
|
+
const fallbackFiles = viewFiles.length === 0
|
|
191
|
+
? sampleFiles(sourceDir, /\.swift$/)
|
|
192
|
+
: [];
|
|
193
|
+
const candidates = [...viewFiles, ...fallbackFiles].slice(
|
|
194
|
+
0,
|
|
195
|
+
MAX_SAMPLES_PER_ADAPTER,
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
if (candidates.length === 0) return null;
|
|
199
|
+
|
|
200
|
+
const apiClasses = new Map<string, string>();
|
|
201
|
+
const biometricPolicies = new Map<string, string>();
|
|
202
|
+
|
|
203
|
+
for (const path of candidates) {
|
|
204
|
+
const body = readSafe(path);
|
|
205
|
+
if (body === null) continue;
|
|
206
|
+
|
|
207
|
+
// API client class: looks like `let api = SomeAPI()` or
|
|
208
|
+
// `@StateObject var api: SomeAPI = .shared` etc.
|
|
209
|
+
// Extract `SomeAPI` from `SomeAPI(`/`SomeAPI.shared`/`: SomeAPI`.
|
|
210
|
+
const apiRegex = /\b([A-Z][A-Za-z0-9_]*API)\s*(?:\(|\.shared|\b)/gu;
|
|
211
|
+
forEachMatch(apiRegex, body, m => {
|
|
212
|
+
const name = m[1];
|
|
213
|
+
if (!apiClasses.has(name)) apiClasses.set(name, path);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// LocalAuthentication policy: `LAPolicy.<name>` or `.<name>` after
|
|
217
|
+
// `evaluatePolicy(`. Whitelist known good values to avoid false positives.
|
|
218
|
+
const policyRegex = /\.(deviceOwnerAuthentication(?:WithBiometrics)?)\b/gu;
|
|
219
|
+
forEachMatch(policyRegex, body, m => {
|
|
220
|
+
const name = m[1];
|
|
221
|
+
if (!biometricPolicies.has(name)) biometricPolicies.set(name, path);
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const apiClass = pickBestSingleton(apiClasses);
|
|
226
|
+
const biometricPolicy = pickBestSingleton(biometricPolicies);
|
|
227
|
+
|
|
228
|
+
const result: DetectedSwift = {};
|
|
229
|
+
const provenance: Record<string, string> = {};
|
|
230
|
+
|
|
231
|
+
if (apiClass) {
|
|
232
|
+
result.api_client_class = apiClass.value;
|
|
233
|
+
provenance.api_client_class_source = relativeTo(projectRoot, apiClass.source);
|
|
234
|
+
}
|
|
235
|
+
if (biometricPolicy) {
|
|
236
|
+
result.biometric_policy = biometricPolicy.value;
|
|
237
|
+
provenance.biometric_policy_source = relativeTo(projectRoot, biometricPolicy.source);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
if (Object.keys(result).length === 0) return null;
|
|
241
|
+
if (Object.keys(provenance).length > 0) result._provenance = provenance;
|
|
242
|
+
return result;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ============================================================
|
|
246
|
+
// TypeScript / Next.js + tRPC adapter
|
|
247
|
+
// ============================================================
|
|
248
|
+
|
|
249
|
+
export function introspectTypeScript(
|
|
250
|
+
detection: DetectionResult,
|
|
251
|
+
projectRoot: string,
|
|
252
|
+
): DetectedTypeScript | null {
|
|
253
|
+
const sourceDir = resolveSourceDir(detection, 'typescript', projectRoot)
|
|
254
|
+
?? resolveSourceDir(detection, 'javascript', projectRoot);
|
|
255
|
+
if (!sourceDir) return null;
|
|
256
|
+
|
|
257
|
+
// Look for tRPC router files first. Match on PATH or filename: tRPC
|
|
258
|
+
// projects typically place routers under `server/api/routers/<name>.ts`
|
|
259
|
+
// where filenames are resource-named (e.g., `accounts.ts`).
|
|
260
|
+
const routerFiles = sampleFiles(sourceDir, /\.tsx?$/, (absPath, name) =>
|
|
261
|
+
/(router|trpc)/i.test(name) ||
|
|
262
|
+
/\/(routers|trpc|server\/api)\//.test(absPath),
|
|
263
|
+
);
|
|
264
|
+
const candidates = routerFiles.slice(0, MAX_SAMPLES_PER_ADAPTER);
|
|
265
|
+
|
|
266
|
+
if (candidates.length === 0) return null;
|
|
267
|
+
|
|
268
|
+
const builders = new Map<string, string>();
|
|
269
|
+
const procedurePatterns = new Map<string, string>();
|
|
270
|
+
|
|
271
|
+
for (const path of candidates) {
|
|
272
|
+
const body = readSafe(path);
|
|
273
|
+
if (body === null) continue;
|
|
274
|
+
|
|
275
|
+
// tRPC router builder: `createTRPCRouter({ ... })`. Whitelist known names
|
|
276
|
+
// (createTRPCRouter, router, t.router) — never grep for arbitrary identifiers.
|
|
277
|
+
const builderRegex = /\b(createTRPCRouter|router|t\.router)\s*\(/gu;
|
|
278
|
+
forEachMatch(builderRegex, body, m => {
|
|
279
|
+
const name = m[1];
|
|
280
|
+
if (!builders.has(name)) builders.set(name, path);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
// Procedure pattern: `publicProcedure.input(...).query(...)` →
|
|
284
|
+
// capture just the procedure name (`publicProcedure`/`protectedProcedure`).
|
|
285
|
+
const procRegex = /\b([a-z]+Procedure)\b/gu;
|
|
286
|
+
forEachMatch(procRegex, body, m => {
|
|
287
|
+
const name = m[1];
|
|
288
|
+
if (!procedurePatterns.has(name)) procedurePatterns.set(name, path);
|
|
289
|
+
});
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const builder = pickBestSingleton(builders);
|
|
293
|
+
const proc = pickBestSingleton(procedurePatterns);
|
|
294
|
+
|
|
295
|
+
const result: DetectedTypeScript = {};
|
|
296
|
+
const provenance: Record<string, string> = {};
|
|
297
|
+
|
|
298
|
+
if (builder) {
|
|
299
|
+
result.trpc_router_builder = builder.value;
|
|
300
|
+
provenance.trpc_router_builder_source = relativeTo(projectRoot, builder.source);
|
|
301
|
+
}
|
|
302
|
+
if (proc) {
|
|
303
|
+
result.procedure_pattern = proc.value;
|
|
304
|
+
provenance.procedure_pattern_source = relativeTo(projectRoot, proc.source);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
if (Object.keys(result).length === 0) return null;
|
|
308
|
+
if (Object.keys(provenance).length > 0) result._provenance = provenance;
|
|
309
|
+
return result;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ============================================================
|
|
313
|
+
// Helpers
|
|
314
|
+
// ============================================================
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Resolve the dominant source directory for a language, falling back to the
|
|
318
|
+
* project root if detection didn't surface anything specific.
|
|
319
|
+
*/
|
|
320
|
+
function resolveSourceDir(
|
|
321
|
+
detection: DetectionResult,
|
|
322
|
+
lang: string,
|
|
323
|
+
projectRoot: string,
|
|
324
|
+
): string | null {
|
|
325
|
+
const dirs = (detection.sourceDirs as unknown as Record<string, { source_dirs?: string[] }>);
|
|
326
|
+
const info = dirs[lang];
|
|
327
|
+
const list = info?.source_dirs ?? [];
|
|
328
|
+
if (list.length > 0) {
|
|
329
|
+
const first = list[0];
|
|
330
|
+
const abs = resolve(projectRoot, first);
|
|
331
|
+
return existsSync(abs) ? abs : null;
|
|
332
|
+
}
|
|
333
|
+
return existsSync(projectRoot) ? projectRoot : null;
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Walk `dir` and return up to MAX_SAMPLES_PER_ADAPTER files matching `nameRegex`,
|
|
338
|
+
* filtered further by `pathFilter` (called with absolute path AND basename, so
|
|
339
|
+
* filters can match either the filename (e.g., `views.py`) or a path
|
|
340
|
+
* component (e.g., a `routers/` parent directory). Skips dot-dirs,
|
|
341
|
+
* node_modules, .venv, etc. Bounded depth.
|
|
342
|
+
*/
|
|
343
|
+
export function sampleFiles(
|
|
344
|
+
dir: string,
|
|
345
|
+
nameRegex: RegExp,
|
|
346
|
+
pathFilter?: (absPath: string, basename: string) => boolean,
|
|
347
|
+
): string[] {
|
|
348
|
+
const out: string[] = [];
|
|
349
|
+
const stack: { path: string; depth: number }[] = [{ path: dir, depth: 0 }];
|
|
350
|
+
|
|
351
|
+
while (stack.length > 0 && out.length < MAX_SAMPLES_PER_ADAPTER * 4) {
|
|
352
|
+
const { path, depth } = stack.pop()!;
|
|
353
|
+
if (depth > MAX_DIR_DEPTH) continue;
|
|
354
|
+
|
|
355
|
+
let entries: string[];
|
|
356
|
+
try {
|
|
357
|
+
entries = readdirSync(path);
|
|
358
|
+
} catch {
|
|
359
|
+
continue;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
for (const entry of entries) {
|
|
363
|
+
if (entry.startsWith('.')) continue;
|
|
364
|
+
if (entry === 'node_modules') continue;
|
|
365
|
+
if (entry === '__pycache__') continue;
|
|
366
|
+
if (entry === 'venv' || entry === '.venv') continue;
|
|
367
|
+
if (entry === 'dist' || entry === 'build') continue;
|
|
368
|
+
|
|
369
|
+
const child = join(path, entry);
|
|
370
|
+
let st;
|
|
371
|
+
try {
|
|
372
|
+
st = statSync(child);
|
|
373
|
+
} catch {
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
if (st.isDirectory()) {
|
|
378
|
+
stack.push({ path: child, depth: depth + 1 });
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
if (!nameRegex.test(entry)) continue;
|
|
383
|
+
if (pathFilter && !pathFilter(child, entry)) continue;
|
|
384
|
+
if (st.size > MAX_FILE_BYTES) continue;
|
|
385
|
+
out.push(child);
|
|
386
|
+
if (out.length >= MAX_SAMPLES_PER_ADAPTER * 4) break;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Stable ordering — deterministic test fixtures.
|
|
391
|
+
out.sort();
|
|
392
|
+
return out.slice(0, MAX_SAMPLES_PER_ADAPTER * 2);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** Read a file as UTF-8. Returns null on any error or if too large. */
|
|
396
|
+
export function readSafe(path: string): string | null {
|
|
397
|
+
try {
|
|
398
|
+
const st = statSync(path);
|
|
399
|
+
if (st.size > MAX_FILE_BYTES) return null;
|
|
400
|
+
return readFileSync(path, 'utf-8');
|
|
401
|
+
} catch {
|
|
402
|
+
return null;
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/**
|
|
407
|
+
* Run a global regex over `body` and call `cb` for each match. Caps iteration
|
|
408
|
+
* at 1000 matches per regex to defend against pathological inputs.
|
|
409
|
+
*/
|
|
410
|
+
export function forEachMatch(
|
|
411
|
+
re: RegExp,
|
|
412
|
+
body: string,
|
|
413
|
+
cb: (m: RegExpExecArray) => void,
|
|
414
|
+
): void {
|
|
415
|
+
if (!re.global) return;
|
|
416
|
+
re.lastIndex = 0;
|
|
417
|
+
let count = 0;
|
|
418
|
+
let m: RegExpExecArray | null;
|
|
419
|
+
while ((m = re.exec(body)) !== null) {
|
|
420
|
+
cb(m);
|
|
421
|
+
count++;
|
|
422
|
+
if (count > 1000) break;
|
|
423
|
+
// Defensive: zero-width match → bump lastIndex to avoid infinite loop.
|
|
424
|
+
if (m.index === re.lastIndex) re.lastIndex++;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* If `samples` has exactly one distinct value, return it.
|
|
430
|
+
* If it has 2 values, return the first-seen (stable, deterministic order from
|
|
431
|
+
* file walk).
|
|
432
|
+
* If it has 3+ distinct values, return null — Risk #6 (auth-dep ambiguity).
|
|
433
|
+
*/
|
|
434
|
+
export function pickBestSingleton(
|
|
435
|
+
samples: Map<string, string>,
|
|
436
|
+
): { value: string; source: string } | null {
|
|
437
|
+
if (samples.size === 0) return null;
|
|
438
|
+
if (samples.size >= 3) return null;
|
|
439
|
+
const [firstKey, firstSource] = samples.entries().next().value as [string, string];
|
|
440
|
+
return { value: firstKey, source: firstSource };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/** Make a file path relative to the project root. */
|
|
444
|
+
export function relativeTo(projectRoot: string, absPath: string): string {
|
|
445
|
+
if (absPath.startsWith(projectRoot + '/')) {
|
|
446
|
+
return absPath.slice(projectRoot.length + 1);
|
|
447
|
+
}
|
|
448
|
+
return basename(absPath);
|
|
449
|
+
}
|