@massu/core 1.10.8 → 1.12.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (178) hide show
  1. package/CHANGELOG.md +1334 -0
  2. package/dist/adapter.js +6 -4
  3. package/dist/cli.js +581 -448
  4. package/dist/detect/adapters/.bundle-shasums.json +4 -7
  5. package/dist/detect/adapters/rails.js +6 -4
  6. package/dist/detect/adapters/spring.js +6 -4
  7. package/dist/detect/adapters/tree-sitter-loader.js +6 -4
  8. package/dist/hooks/auto-learning-pipeline.js +24 -1
  9. package/dist/hooks/classify-failure.js +32 -3
  10. package/dist/hooks/cost-tracker.js +24 -0
  11. package/dist/hooks/fix-detector.js +97 -1
  12. package/dist/hooks/incident-pipeline.js +30 -1
  13. package/dist/hooks/intent-suggester.js +6 -3
  14. package/dist/hooks/post-edit-context.js +24 -1
  15. package/dist/hooks/post-tool-use.js +67 -19
  16. package/dist/hooks/pre-compact.js +28 -3
  17. package/dist/hooks/pre-delete-check.js +53 -27
  18. package/dist/hooks/pre-tool-use-gate.js +962 -0
  19. package/dist/hooks/quality-event.js +24 -0
  20. package/dist/hooks/rule-enforcement-pipeline.js +24 -1
  21. package/dist/hooks/security-gate.js +29 -16
  22. package/dist/hooks/session-end.js +31 -6
  23. package/dist/hooks/session-start.js +8709 -8459
  24. package/dist/hooks/user-prompt.js +25 -1
  25. package/package.json +15 -18
  26. package/commands/README.md +0 -134
  27. package/src/adapter.ts +0 -121
  28. package/src/adr-generator.ts +0 -292
  29. package/src/analytics.ts +0 -373
  30. package/src/audit-trail.ts +0 -450
  31. package/src/backfill-sessions.ts +0 -182
  32. package/src/changelog-generator.ts +0 -178
  33. package/src/cli.ts +0 -246
  34. package/src/cloud-sync.ts +0 -234
  35. package/src/commands/adapters.ts +0 -824
  36. package/src/commands/changelog.ts +0 -165
  37. package/src/commands/config-check-drift.ts +0 -133
  38. package/src/commands/config-refresh.ts +0 -396
  39. package/src/commands/config-upgrade.ts +0 -127
  40. package/src/commands/doctor.ts +0 -485
  41. package/src/commands/hook-runner.ts +0 -145
  42. package/src/commands/init.ts +0 -1823
  43. package/src/commands/install-commands.ts +0 -692
  44. package/src/commands/install-hooks.ts +0 -27
  45. package/src/commands/permissions.ts +0 -150
  46. package/src/commands/refresh-log.ts +0 -37
  47. package/src/commands/show-template.ts +0 -65
  48. package/src/commands/template-engine.ts +0 -301
  49. package/src/commands/watch.ts +0 -430
  50. package/src/config.ts +0 -765
  51. package/src/cost-tracker.ts +0 -355
  52. package/src/db.ts +0 -370
  53. package/src/dependency-scorer.ts +0 -337
  54. package/src/detect/adapters/aspnet.ts +0 -4
  55. package/src/detect/adapters/discover.ts +0 -469
  56. package/src/detect/adapters/file-sampler.ts +0 -225
  57. package/src/detect/adapters/go-chi.ts +0 -4
  58. package/src/detect/adapters/index.ts +0 -51
  59. package/src/detect/adapters/nextjs-trpc.ts +0 -166
  60. package/src/detect/adapters/parse-guard.ts +0 -133
  61. package/src/detect/adapters/phoenix.ts +0 -4
  62. package/src/detect/adapters/python-django.ts +0 -208
  63. package/src/detect/adapters/python-fastapi.ts +0 -223
  64. package/src/detect/adapters/python-flask.ts +0 -235
  65. package/src/detect/adapters/query-helpers.ts +0 -170
  66. package/src/detect/adapters/rails.ts +0 -4
  67. package/src/detect/adapters/runner.ts +0 -284
  68. package/src/detect/adapters/spring.ts +0 -4
  69. package/src/detect/adapters/swift-swiftui.ts +0 -171
  70. package/src/detect/adapters/tree-sitter-loader.ts +0 -517
  71. package/src/detect/adapters/types.ts +0 -191
  72. package/src/detect/codebase-introspector.ts +0 -210
  73. package/src/detect/domain-inferrer.ts +0 -197
  74. package/src/detect/drift.ts +0 -199
  75. package/src/detect/framework-detector.ts +0 -307
  76. package/src/detect/index.ts +0 -209
  77. package/src/detect/manifest-registry.ts +0 -261
  78. package/src/detect/migrate.ts +0 -362
  79. package/src/detect/monorepo-detector.ts +0 -348
  80. package/src/detect/package-detector.ts +0 -828
  81. package/src/detect/passthrough.ts +0 -108
  82. package/src/detect/regex-fallback.ts +0 -449
  83. package/src/detect/source-dir-detector.ts +0 -297
  84. package/src/detect/vr-command-map.ts +0 -167
  85. package/src/docs-map.json +0 -100
  86. package/src/docs-tools.ts +0 -519
  87. package/src/domains.ts +0 -182
  88. package/src/hooks/auto-learning-pipeline.ts +0 -228
  89. package/src/hooks/classify-failure.ts +0 -259
  90. package/src/hooks/cost-tracker.ts +0 -66
  91. package/src/hooks/fix-detector.ts +0 -186
  92. package/src/hooks/incident-pipeline.ts +0 -190
  93. package/src/hooks/intent-suggester.ts +0 -131
  94. package/src/hooks/post-edit-context.ts +0 -91
  95. package/src/hooks/post-tool-use.ts +0 -413
  96. package/src/hooks/pre-compact.ts +0 -168
  97. package/src/hooks/pre-delete-check.ts +0 -243
  98. package/src/hooks/quality-event.ts +0 -127
  99. package/src/hooks/rule-enforcement-pipeline.ts +0 -159
  100. package/src/hooks/security-gate.ts +0 -153
  101. package/src/hooks/session-end.ts +0 -467
  102. package/src/hooks/session-start.ts +0 -436
  103. package/src/hooks/user-prompt.ts +0 -157
  104. package/src/import-resolver.ts +0 -226
  105. package/src/knowledge-db.ts +0 -176
  106. package/src/knowledge-indexer.ts +0 -708
  107. package/src/knowledge-tools.ts +0 -1414
  108. package/src/lib/fileLock.ts +0 -203
  109. package/src/lib/framework-supports.ts +0 -85
  110. package/src/lib/gitToplevel.ts +0 -22
  111. package/src/lib/hook-registry.ts +0 -43
  112. package/src/lib/installLock.ts +0 -66
  113. package/src/lib/memory-path.ts +0 -49
  114. package/src/lib/pidLiveness.ts +0 -67
  115. package/src/lib/settings-local.ts +0 -110
  116. package/src/lib/sql-table-names.ts +0 -71
  117. package/src/license.ts +0 -547
  118. package/src/lsp/auto-detect.ts +0 -98
  119. package/src/lsp/client.ts +0 -776
  120. package/src/lsp/enrich.ts +0 -127
  121. package/src/lsp/types.ts +0 -221
  122. package/src/memory-db.ts +0 -1614
  123. package/src/memory-file-ingest.ts +0 -128
  124. package/src/memory-tools.ts +0 -424
  125. package/src/middleware-tree.ts +0 -71
  126. package/src/observability-tools.ts +0 -343
  127. package/src/observation-extractor.ts +0 -418
  128. package/src/page-deps.ts +0 -285
  129. package/src/permissions.ts +0 -422
  130. package/src/prompt-analyzer.ts +0 -332
  131. package/src/python/coupling-detector.ts +0 -125
  132. package/src/python/domain-enforcer.ts +0 -84
  133. package/src/python/impact-analyzer.ts +0 -96
  134. package/src/python/import-parser.ts +0 -244
  135. package/src/python/import-resolver.ts +0 -136
  136. package/src/python/migration-indexer.ts +0 -116
  137. package/src/python/migration-parser.ts +0 -332
  138. package/src/python/model-indexer.ts +0 -71
  139. package/src/python/model-parser.ts +0 -279
  140. package/src/python/route-indexer.ts +0 -59
  141. package/src/python/route-parser.ts +0 -317
  142. package/src/python-tools.ts +0 -630
  143. package/src/regression-detector.ts +0 -319
  144. package/src/rules.ts +0 -57
  145. package/src/schema-mapper.ts +0 -232
  146. package/src/security/adapter-origin.ts +0 -130
  147. package/src/security/adapter-verifier.ts +0 -319
  148. package/src/security/atomic-write.ts +0 -164
  149. package/src/security/fetcher.ts +0 -200
  150. package/src/security/install-tracking.ts +0 -319
  151. package/src/security/license-pubkey.generated.ts +0 -30
  152. package/src/security/license-response-verifier.ts +0 -149
  153. package/src/security/local-fingerprint.ts +0 -225
  154. package/src/security/manifest-cache.ts +0 -333
  155. package/src/security/manifest-schema.ts +0 -129
  156. package/src/security/registry-pubkey.generated.ts +0 -35
  157. package/src/security/telemetry.ts +0 -320
  158. package/src/security-scorer.ts +0 -405
  159. package/src/security-utils.ts +0 -133
  160. package/src/sentinel-db.ts +0 -580
  161. package/src/sentinel-scanner.ts +0 -406
  162. package/src/sentinel-tools.ts +0 -513
  163. package/src/sentinel-types.ts +0 -140
  164. package/src/server-dispatch.ts +0 -225
  165. package/src/server.ts +0 -107
  166. package/src/session-archiver.ts +0 -111
  167. package/src/session-state-generator.ts +0 -174
  168. package/src/team-knowledge.ts +0 -407
  169. package/src/tool-db-needs.ts +0 -232
  170. package/src/tools.ts +0 -1206
  171. package/src/transcript-parser.ts +0 -458
  172. package/src/trpc-index.ts +0 -215
  173. package/src/validate-features-runner.ts +0 -107
  174. package/src/validation-engine.ts +0 -358
  175. package/src/watch/daemon.ts +0 -385
  176. package/src/watch/lockfile-detector.ts +0 -65
  177. package/src/watch/paths.ts +0 -279
  178. package/src/watch/state.ts +0 -178
@@ -1,108 +0,0 @@
1
- // Copyright (c) 2026 Massu. All rights reserved.
2
- // Licensed under BSL 1.1 - see LICENSE file for details.
3
-
4
- /**
5
- * Shared passthrough helpers for config migration + refresh.
6
- *
7
- * These helpers exist to prevent the class of bug fixed in @massu/core@1.2.0
8
- * (incident 2026-04-19-config-upgrade-data-loss): a hand-maintained allow-list
9
- * in `migrate.ts` silently dropped any v1 top-level key not on the list, and
10
- * the parallel rebuild blocks inside `framework` / `paths` / `project` /
11
- * `python` did the same thing at the nested level.
12
- *
13
- * Both helpers are TARGET-WINS: the migrator writes the keys it actively owns,
14
- * then the helper fills in everything else the user had. A user-authored value
15
- * in `target` is NEVER overwritten by the source.
16
- *
17
- * Why two exports instead of one:
18
- * - `copyUnknownKeys` takes an explicit `handledKeys` set — used for TOP-LEVEL
19
- * passthrough where the caller enumerates the keys it migrated explicitly
20
- * (e.g., schema_version, project, framework, paths, toolPrefix, …).
21
- * - `preserveNestedSubkeys` takes no handled-set — used for NESTED passthrough
22
- * where the target block was just rebuilt, so `k in target` already skips
23
- * any key the rebuild populated. Splitting the two makes callsites
24
- * self-documenting without a verbose `new Set()` argument at every nested
25
- * call (A-002 architecture-review follow-up).
26
- */
27
-
28
- /** Keys that would mutate Object.prototype if copied as own properties. Explicit
29
- * denylist defense-in-depth on top of the existing `k in target` guard and the
30
- * `yaml@2.8` parser's non-polluting behavior (S-001 security-review follow-up). */
31
- const UNSAFE_KEYS: ReadonlySet<string> = new Set(['__proto__', 'constructor', 'prototype']);
32
-
33
- /**
34
- * Copy any key from `source` into `target` that target doesn't already have set,
35
- * skipping keys listed in `handledKeys`. Target values ALWAYS win — this function
36
- * never overwrites an existing target key.
37
- *
38
- * - If source[k] is undefined → skip (undefined is not a preservable value).
39
- * - If k is an UNSAFE_KEYS entry → skip (prototype-pollution defense).
40
- * - If handledKeys.has(k) → skip (caller has its own handling).
41
- * - If target already owns k → skip (target wins).
42
- * - Otherwise → target[k] = deepClone(source[k]).
43
- *
44
- * Values are DEEP-CLONED via structuredClone so that mutating the output v2
45
- * object never reaches back into the v1 input (S-002 security-review follow-up).
46
- * Preserves the migrator's "pure data in, pure data out" contract.
47
- */
48
- export function copyUnknownKeys(
49
- source: Record<string, unknown>,
50
- target: Record<string, unknown>,
51
- handledKeys: ReadonlySet<string>
52
- ): void {
53
- if (source === null || typeof source !== 'object' || Array.isArray(source)) {
54
- return;
55
- }
56
- for (const k of Object.keys(source)) {
57
- if (UNSAFE_KEYS.has(k)) continue;
58
- if (source[k] === undefined) continue;
59
- if (handledKeys.has(k)) continue;
60
- if (Object.prototype.hasOwnProperty.call(target, k)) continue;
61
- target[k] = safeClone(source[k]);
62
- }
63
- }
64
-
65
- /**
66
- * Preserve every subkey from sourceBlock into targetBlock that targetBlock
67
- * doesn't already have. Target values ALWAYS win.
68
- *
69
- * If sourceBlock is not a plain object (string, array, null, undefined),
70
- * return early — there are no subkeys to preserve. This matches the coercion
71
- * semantics of `getRecord` in migrate.ts and prevents throws on loose v1 inputs
72
- * like `framework: "typescript"`.
73
- *
74
- * Values are deep-cloned (see copyUnknownKeys); UNSAFE_KEYS are skipped.
75
- */
76
- export function preserveNestedSubkeys(
77
- sourceBlock: unknown,
78
- targetBlock: Record<string, unknown>
79
- ): void {
80
- if (
81
- sourceBlock === null ||
82
- sourceBlock === undefined ||
83
- typeof sourceBlock !== 'object' ||
84
- Array.isArray(sourceBlock)
85
- ) {
86
- return;
87
- }
88
- const src = sourceBlock as Record<string, unknown>;
89
- for (const k of Object.keys(src)) {
90
- if (UNSAFE_KEYS.has(k)) continue;
91
- if (src[k] === undefined) continue;
92
- if (Object.prototype.hasOwnProperty.call(targetBlock, k)) continue;
93
- targetBlock[k] = safeClone(src[k]);
94
- }
95
- }
96
-
97
- /** structuredClone with a fallback for environments without it (Node <17). */
98
- function safeClone<T>(v: T): T {
99
- if (typeof structuredClone === 'function') {
100
- try {
101
- return structuredClone(v);
102
- } catch {
103
- // structuredClone throws on functions, DOM nodes, etc. — YAML never produces
104
- // those, but if a caller passes something exotic, fall through to shallow.
105
- }
106
- }
107
- return v;
108
- }
@@ -1,449 +0,0 @@
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
- }