@massu/core 1.2.1 → 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.
Files changed (61) hide show
  1. package/README.md +40 -0
  2. package/commands/README.md +137 -0
  3. package/commands/massu-deploy.python-docker.md +170 -0
  4. package/commands/massu-deploy.python-fly.md +189 -0
  5. package/commands/massu-deploy.python-launchd.md +144 -0
  6. package/commands/massu-deploy.python-systemd.md +163 -0
  7. package/commands/massu-deploy.python.md +200 -0
  8. package/commands/massu-scaffold-page.md +172 -59
  9. package/commands/massu-scaffold-page.swift.md +121 -0
  10. package/commands/massu-scaffold-router.python-django.md +153 -0
  11. package/commands/massu-scaffold-router.python-fastapi.md +145 -0
  12. package/commands/massu-scaffold-router.python.md +143 -0
  13. package/dist/cli.js +10170 -4138
  14. package/dist/hooks/auto-learning-pipeline.js +44 -6
  15. package/dist/hooks/classify-failure.js +44 -6
  16. package/dist/hooks/cost-tracker.js +44 -6
  17. package/dist/hooks/fix-detector.js +44 -6
  18. package/dist/hooks/incident-pipeline.js +44 -6
  19. package/dist/hooks/post-edit-context.js +44 -6
  20. package/dist/hooks/post-tool-use.js +44 -6
  21. package/dist/hooks/pre-compact.js +44 -6
  22. package/dist/hooks/pre-delete-check.js +44 -6
  23. package/dist/hooks/quality-event.js +44 -6
  24. package/dist/hooks/rule-enforcement-pipeline.js +44 -6
  25. package/dist/hooks/session-end.js +44 -6
  26. package/dist/hooks/session-start.js +4789 -410
  27. package/dist/hooks/user-prompt.js +44 -6
  28. package/package.json +10 -4
  29. package/src/cli.ts +28 -2
  30. package/src/commands/config-refresh.ts +88 -20
  31. package/src/commands/init.ts +130 -23
  32. package/src/commands/install-commands.ts +482 -42
  33. package/src/commands/refresh-log.ts +37 -0
  34. package/src/commands/show-template.ts +65 -0
  35. package/src/commands/template-engine.ts +262 -0
  36. package/src/commands/watch.ts +430 -0
  37. package/src/config.ts +69 -3
  38. package/src/detect/adapters/nextjs-trpc.ts +166 -0
  39. package/src/detect/adapters/parse-guard.ts +133 -0
  40. package/src/detect/adapters/python-django.ts +208 -0
  41. package/src/detect/adapters/python-fastapi.ts +223 -0
  42. package/src/detect/adapters/query-helpers.ts +170 -0
  43. package/src/detect/adapters/runner.ts +252 -0
  44. package/src/detect/adapters/swift-swiftui.ts +171 -0
  45. package/src/detect/adapters/tree-sitter-loader.ts +348 -0
  46. package/src/detect/adapters/types.ts +174 -0
  47. package/src/detect/codebase-introspector.ts +190 -0
  48. package/src/detect/index.ts +28 -2
  49. package/src/detect/regex-fallback.ts +449 -0
  50. package/src/hooks/session-start.ts +94 -3
  51. package/src/lib/gitToplevel.ts +22 -0
  52. package/src/lib/installLock.ts +179 -0
  53. package/src/lib/pidLiveness.ts +67 -0
  54. package/src/lsp/auto-detect.ts +89 -0
  55. package/src/lsp/client.ts +590 -0
  56. package/src/lsp/enrich.ts +127 -0
  57. package/src/lsp/types.ts +221 -0
  58. package/src/watch/daemon.ts +385 -0
  59. package/src/watch/lockfile-detector.ts +65 -0
  60. package/src/watch/paths.ts +279 -0
  61. 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
+ }
@@ -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
- return {
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
+ }