@tyroneross/navgator 0.1.0 → 0.2.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 (62) hide show
  1. package/README.md +15 -9
  2. package/dist/cli/index.js +249 -10
  3. package/dist/cli/index.js.map +1 -1
  4. package/dist/diagram.d.ts.map +1 -1
  5. package/dist/diagram.js +33 -0
  6. package/dist/diagram.js.map +1 -1
  7. package/dist/scanner.d.ts +2 -0
  8. package/dist/scanner.d.ts.map +1 -1
  9. package/dist/scanner.js +131 -12
  10. package/dist/scanner.js.map +1 -1
  11. package/dist/scanners/connections/ast-scanner.d.ts.map +1 -1
  12. package/dist/scanners/connections/ast-scanner.js +0 -3
  13. package/dist/scanners/connections/ast-scanner.js.map +1 -1
  14. package/dist/scanners/connections/llm-call-tracer.d.ts +83 -0
  15. package/dist/scanners/connections/llm-call-tracer.d.ts.map +1 -0
  16. package/dist/scanners/connections/llm-call-tracer.js +801 -0
  17. package/dist/scanners/connections/llm-call-tracer.js.map +1 -0
  18. package/dist/scanners/connections/service-calls.d.ts.map +1 -1
  19. package/dist/scanners/connections/service-calls.js +44 -47
  20. package/dist/scanners/connections/service-calls.js.map +1 -1
  21. package/dist/scanners/infrastructure/index.d.ts.map +1 -1
  22. package/dist/scanners/infrastructure/index.js +34 -4
  23. package/dist/scanners/infrastructure/index.js.map +1 -1
  24. package/dist/scanners/packages/swift.d.ts +14 -0
  25. package/dist/scanners/packages/swift.d.ts.map +1 -0
  26. package/dist/scanners/packages/swift.js +320 -0
  27. package/dist/scanners/packages/swift.js.map +1 -0
  28. package/dist/scanners/prompts/detector.d.ts +16 -0
  29. package/dist/scanners/prompts/detector.d.ts.map +1 -1
  30. package/dist/scanners/prompts/detector.js +90 -1
  31. package/dist/scanners/prompts/detector.js.map +1 -1
  32. package/dist/scanners/prompts/types.d.ts +4 -0
  33. package/dist/scanners/prompts/types.d.ts.map +1 -1
  34. package/dist/scanners/prompts/types.js.map +1 -1
  35. package/dist/scanners/swift/code-scanner.d.ts +14 -0
  36. package/dist/scanners/swift/code-scanner.d.ts.map +1 -0
  37. package/dist/scanners/swift/code-scanner.js +803 -0
  38. package/dist/scanners/swift/code-scanner.js.map +1 -0
  39. package/dist/storage.d.ts +2 -2
  40. package/dist/storage.d.ts.map +1 -1
  41. package/dist/storage.js +41 -2
  42. package/dist/storage.js.map +1 -1
  43. package/dist/types.d.ts +26 -2
  44. package/dist/types.d.ts.map +1 -1
  45. package/dist/types.js.map +1 -1
  46. package/dist/ui-server.d.ts +7 -7
  47. package/dist/ui-server.d.ts.map +1 -1
  48. package/dist/ui-server.js +80 -792
  49. package/dist/ui-server.js.map +1 -1
  50. package/package.json +15 -10
  51. package/web/public/apple-icon.png +0 -0
  52. package/web/public/icon-dark-32x32.png +0 -0
  53. package/web/public/icon-light-32x32.png +0 -0
  54. package/web/public/icon.svg +26 -0
  55. package/web/public/navgator-logo.png +0 -0
  56. package/web/public/placeholder-logo.png +0 -0
  57. package/web/public/placeholder-logo.svg +1 -0
  58. package/web/public/placeholder-user.jpg +0 -0
  59. package/web/public/placeholder.jpg +0 -0
  60. package/web/public/placeholder.svg +1 -0
  61. package/scripts/ibr-ui-test.mjs +0 -359
  62. package/scripts/postinstall.cjs +0 -35
@@ -0,0 +1,803 @@
1
+ /**
2
+ * Swift Code Scanner
3
+ * Detects runtime connections in .swift files:
4
+ * - String-keyed deps (UserDefaults, @AppStorage, NotificationCenter, asset names)
5
+ * - Protocol conformance
6
+ * - State observation (@Published, @Observable, @EnvironmentObject)
7
+ * - URLSession calls to LLM APIs
8
+ * - Entitlement requirements from framework usage
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import { glob } from 'glob';
13
+ import { generateComponentId, generateConnectionId, } from '../../types.js';
14
+ // =============================================================================
15
+ // FRAMEWORK → ENTITLEMENT MAP
16
+ // =============================================================================
17
+ const FRAMEWORK_ENTITLEMENTS = {
18
+ 'HealthKit': { entitlement: 'com.apple.developer.healthkit', plistKey: 'NSHealthShareUsageDescription' },
19
+ 'CloudKit': { entitlement: 'com.apple.developer.icloud-services' },
20
+ 'HomeKit': { entitlement: 'com.apple.developer.homekit', plistKey: 'NSHomeKitUsageDescription' },
21
+ 'CoreLocation': { plistKey: 'NSLocationWhenInUseUsageDescription' },
22
+ 'AVFoundation': { plistKey: 'NSCameraUsageDescription' },
23
+ 'Photos': { plistKey: 'NSPhotoLibraryUsageDescription' },
24
+ 'PhotosUI': { plistKey: 'NSPhotoLibraryUsageDescription' },
25
+ 'Contacts': { plistKey: 'NSContactsUsageDescription' },
26
+ 'EventKit': { plistKey: 'NSCalendarsUsageDescription' },
27
+ 'Speech': { plistKey: 'NSSpeechRecognitionUsageDescription' },
28
+ 'LocalAuthentication': { plistKey: 'NSFaceIDUsageDescription' },
29
+ 'CoreBluetooth': { plistKey: 'NSBluetoothAlwaysUsageDescription' },
30
+ 'CoreMotion': { plistKey: 'NSMotionUsageDescription' },
31
+ 'UserNotifications': { entitlement: 'com.apple.developer.push-notifications' },
32
+ 'StoreKit': { entitlement: 'com.apple.developer.in-app-payments' },
33
+ 'MapKit': { plistKey: 'NSLocationWhenInUseUsageDescription' },
34
+ 'NearbyInteraction': { plistKey: 'NSNearbyInteractionUsageDescription' },
35
+ };
36
+ // =============================================================================
37
+ // LLM API URL PATTERNS
38
+ // =============================================================================
39
+ const LLM_URL_PATTERNS = [
40
+ { pattern: /api\.anthropic\.com/, provider: 'Claude (Anthropic)' },
41
+ { pattern: /api\.openai\.com/, provider: 'OpenAI' },
42
+ { pattern: /generativelanguage\.googleapis\.com/, provider: 'Gemini (Google)' },
43
+ { pattern: /api\.groq\.com/, provider: 'Groq' },
44
+ { pattern: /api\.cohere\.ai/, provider: 'Cohere' },
45
+ { pattern: /api\.mistral\.ai/, provider: 'Mistral' },
46
+ { pattern: /api-inference\.huggingface\.co/, provider: 'HuggingFace' },
47
+ { pattern: /api\.replicate\.com/, provider: 'Replicate' },
48
+ { pattern: /api\.together\.xyz/, provider: 'Together AI' },
49
+ { pattern: /api\.fireworks\.ai/, provider: 'Fireworks AI' },
50
+ ];
51
+ // Swift SDK import patterns for LLMs
52
+ const LLM_IMPORT_PATTERNS = [
53
+ { pattern: /^import\s+OpenAI\b/, provider: 'OpenAI' },
54
+ { pattern: /^import\s+Anthropic\b/, provider: 'Claude (Anthropic)' },
55
+ { pattern: /^import\s+GoogleGenerativeAI\b/, provider: 'Gemini (Google)' },
56
+ { pattern: /^import\s+FoundationModels\b/, provider: 'Apple Intelligence' },
57
+ ];
58
+ // Swift SDK call patterns
59
+ const LLM_CALL_PATTERNS = [
60
+ { pattern: /ChatQuery\(/, provider: 'OpenAI' },
61
+ { pattern: /\.chats\(query:/, provider: 'OpenAI' },
62
+ { pattern: /\.completions\.create\(/, provider: 'OpenAI' },
63
+ { pattern: /AnthropicClient\(/, provider: 'Claude (Anthropic)' },
64
+ { pattern: /\.messages\.create\(/, provider: 'Claude (Anthropic)' },
65
+ { pattern: /GenerativeModel\(name:/, provider: 'Gemini (Google)' },
66
+ { pattern: /\.generateContent\(/, provider: 'Gemini (Google)' },
67
+ { pattern: /LanguageModelSession\(\)/, provider: 'Apple Intelligence' },
68
+ { pattern: /\.respond\(to:/, provider: 'Apple Intelligence' },
69
+ { pattern: /@Generable\b/, provider: 'Apple Intelligence' },
70
+ ];
71
+ // =============================================================================
72
+ // MAIN SCANNER
73
+ // =============================================================================
74
+ export async function scanSwiftCode(projectRoot) {
75
+ const components = [];
76
+ const connections = [];
77
+ const warnings = [];
78
+ const timestamp = Date.now();
79
+ // Load all Swift files
80
+ const swiftFiles = await glob('**/*.swift', {
81
+ cwd: projectRoot,
82
+ ignore: ['.build/**', 'DerivedData/**', '.swiftpm/**', 'Pods/**', 'Carthage/**', '*.playground/**'],
83
+ });
84
+ const files = [];
85
+ for (const relPath of swiftFiles) {
86
+ try {
87
+ const content = await fs.promises.readFile(path.join(projectRoot, relPath), 'utf-8');
88
+ files.push({ relativePath: relPath, content, lines: content.split('\n') });
89
+ }
90
+ catch {
91
+ // skip unreadable
92
+ }
93
+ }
94
+ if (files.length === 0) {
95
+ return { components, connections, warnings, projectMeta: {} };
96
+ }
97
+ // ---- String-keyed runtime deps ----
98
+ const stringKeys = scanStringKeys(files);
99
+ const fragileKeys = buildFragileKeys(stringKeys);
100
+ // Create components + connections for shared keys
101
+ const keyGroups = groupByKey(stringKeys);
102
+ for (const [groupKey, hits] of keyGroups) {
103
+ if (hits.length < 1)
104
+ continue;
105
+ const keyType = hits[0].type;
106
+ const rawKey = hits[0].key;
107
+ const compId = generateComponentId('other', groupKey);
108
+ components.push({
109
+ component_id: compId,
110
+ name: groupKey,
111
+ type: 'other',
112
+ role: { purpose: `${keyType} key "${rawKey}"`, layer: 'backend', critical: hits.length > 1 },
113
+ source: { detection_method: 'auto', config_files: [], confidence: 0.95 },
114
+ connects_to: [],
115
+ connected_from: [],
116
+ status: 'active',
117
+ tags: ['swift', 'string-key', keyType.toLowerCase(), hits.length > 1 ? 'shared' : 'single'],
118
+ metadata: { keyType, key: rawKey, fileCount: hits.length, files: [...new Set(hits.map(h => h.file))] },
119
+ timestamp,
120
+ last_updated: timestamp,
121
+ });
122
+ for (const hit of hits) {
123
+ connections.push({
124
+ connection_id: generateConnectionId('stores'),
125
+ from: { component_id: compId, location: { file: hit.file, line: hit.line } },
126
+ to: { component_id: compId },
127
+ connection_type: 'stores',
128
+ code_reference: {
129
+ file: hit.file,
130
+ symbol: hit.symbol,
131
+ symbol_type: 'variable',
132
+ line_start: hit.line,
133
+ code_snippet: hit.snippet.slice(0, 100),
134
+ },
135
+ description: `${hit.type} key "${rawKey}" in ${hit.file}`,
136
+ detected_from: 'swift-code-scanner',
137
+ confidence: 0.95,
138
+ timestamp,
139
+ last_verified: timestamp,
140
+ });
141
+ }
142
+ }
143
+ // ---- Protocol conformance ----
144
+ const conformances = scanProtocolConformance(files);
145
+ const protocolMap = new Map();
146
+ for (const c of conformances) {
147
+ for (const proto of c.protocols) {
148
+ if (!protocolMap.has(proto))
149
+ protocolMap.set(proto, []);
150
+ protocolMap.get(proto).push(c);
151
+ }
152
+ }
153
+ for (const [proto, conformers] of protocolMap) {
154
+ if (conformers.length < 1)
155
+ continue;
156
+ const compId = generateComponentId('other', `protocol:${proto}`);
157
+ components.push({
158
+ component_id: compId,
159
+ name: `protocol:${proto}`,
160
+ type: 'other',
161
+ role: { purpose: `Protocol ${proto} (${conformers.length} conformer${conformers.length > 1 ? 's' : ''})`, layer: 'backend', critical: conformers.length > 2 },
162
+ source: { detection_method: 'auto', config_files: [], confidence: 0.85 },
163
+ connects_to: [],
164
+ connected_from: [],
165
+ status: 'active',
166
+ tags: ['swift', 'protocol', conformers.length > 2 ? 'widely-used' : 'local'],
167
+ metadata: { conformers: conformers.map(c => ({ type: c.typeName, file: c.file, line: c.line })) },
168
+ timestamp,
169
+ last_updated: timestamp,
170
+ });
171
+ for (const conf of conformers) {
172
+ connections.push({
173
+ connection_id: generateConnectionId('conforms-to'),
174
+ from: { component_id: generateComponentId('other', conf.typeName), location: { file: conf.file, line: conf.line } },
175
+ to: { component_id: compId },
176
+ connection_type: 'conforms-to',
177
+ code_reference: {
178
+ file: conf.file,
179
+ symbol: conf.typeName,
180
+ symbol_type: 'class',
181
+ line_start: conf.line,
182
+ code_snippet: `${conf.typeName}: ${conf.protocols.join(', ')}`,
183
+ },
184
+ description: `${conf.typeName} conforms to ${proto}`,
185
+ detected_from: 'swift-code-scanner',
186
+ confidence: 0.85,
187
+ timestamp,
188
+ last_verified: timestamp,
189
+ });
190
+ }
191
+ }
192
+ // ---- State observation (@Published, @EnvironmentObject, etc.) ----
193
+ const observations = scanStateObservation(files);
194
+ // Group @Published → find consumers via @ObservedObject/@EnvironmentObject/@StateObject
195
+ const publishers = observations.filter(o => o.wrapper === '@Published');
196
+ const consumers = observations.filter(o => ['@ObservedObject', '@EnvironmentObject', '@StateObject'].includes(o.wrapper));
197
+ for (const pub of publishers) {
198
+ for (const con of consumers) {
199
+ // Match if the consumer type matches the publisher's owner type
200
+ if (con.ownerType !== pub.ownerType)
201
+ continue;
202
+ connections.push({
203
+ connection_id: generateConnectionId('observes'),
204
+ from: { component_id: generateComponentId('component', con.ownerType), location: { file: con.file, line: con.line } },
205
+ to: { component_id: generateComponentId('component', pub.ownerType), location: { file: pub.file, line: pub.line } },
206
+ connection_type: 'observes',
207
+ code_reference: {
208
+ file: con.file,
209
+ symbol: con.propertyName,
210
+ symbol_type: 'variable',
211
+ line_start: con.line,
212
+ code_snippet: `${con.wrapper} var ${con.propertyName}: ${con.ownerType}`,
213
+ },
214
+ description: `${con.file} observes ${pub.ownerType}.${pub.propertyName}`,
215
+ detected_from: 'swift-code-scanner',
216
+ confidence: 0.8,
217
+ timestamp,
218
+ last_verified: timestamp,
219
+ });
220
+ }
221
+ }
222
+ // ---- LLM API calls ----
223
+ const llmCalls = scanLLMCalls(files);
224
+ for (const call of llmCalls) {
225
+ const compId = generateComponentId('llm', call.provider);
226
+ // Only add component if not already present
227
+ if (!components.find(c => c.name === call.provider && c.type === 'llm')) {
228
+ components.push({
229
+ component_id: compId,
230
+ name: call.provider,
231
+ type: 'llm',
232
+ role: { purpose: `${call.provider} LLM API`, layer: 'external', critical: true },
233
+ source: { detection_method: 'auto', config_files: [], confidence: 0.9 },
234
+ connects_to: [],
235
+ connected_from: [],
236
+ status: 'active',
237
+ tags: ['swift', 'llm', 'external'],
238
+ timestamp,
239
+ last_updated: timestamp,
240
+ });
241
+ }
242
+ connections.push({
243
+ connection_id: generateConnectionId('service-call'),
244
+ from: { component_id: generateComponentId('other', call.file), location: { file: call.file, line: call.line } },
245
+ to: { component_id: compId },
246
+ connection_type: 'service-call',
247
+ code_reference: {
248
+ file: call.file,
249
+ symbol: call.symbol,
250
+ symbol_type: 'function',
251
+ line_start: call.line,
252
+ code_snippet: call.snippet.slice(0, 100),
253
+ },
254
+ description: `${call.provider} API call in ${call.file}`,
255
+ detected_from: 'swift-code-scanner',
256
+ confidence: 0.9,
257
+ timestamp,
258
+ last_verified: timestamp,
259
+ });
260
+ }
261
+ // ---- Entitlement requirements ----
262
+ const frameworkImports = scanFrameworkImports(files);
263
+ const entitlementReqs = [];
264
+ for (const imp of frameworkImports) {
265
+ const req = FRAMEWORK_ENTITLEMENTS[imp.framework];
266
+ if (req) {
267
+ if (req.entitlement) {
268
+ entitlementReqs.push({ key: req.entitlement, framework: imp.framework, file: imp.file, line: imp.line });
269
+ }
270
+ if (req.plistKey) {
271
+ entitlementReqs.push({ key: req.plistKey, framework: imp.framework, file: imp.file, line: imp.line });
272
+ }
273
+ }
274
+ }
275
+ for (const req of entitlementReqs) {
276
+ connections.push({
277
+ connection_id: generateConnectionId('requires-entitlement'),
278
+ from: { component_id: generateComponentId('framework', req.framework), location: { file: req.file, line: req.line } },
279
+ to: { component_id: generateComponentId('other', `entitlement:${req.key}`) },
280
+ connection_type: 'requires-entitlement',
281
+ code_reference: {
282
+ file: req.file,
283
+ symbol: `import ${req.framework}`,
284
+ symbol_type: 'import',
285
+ line_start: req.line,
286
+ code_snippet: `import ${req.framework} → requires ${req.key}`,
287
+ },
288
+ description: `${req.framework} requires entitlement/plist key: ${req.key}`,
289
+ detected_from: 'swift-code-scanner',
290
+ confidence: 0.85,
291
+ timestamp,
292
+ last_verified: timestamp,
293
+ });
294
+ }
295
+ // ---- Prompt patterns in Swift ----
296
+ const prompts = scanSwiftPrompts(files);
297
+ for (const prompt of prompts) {
298
+ const compId = generateComponentId('prompt', prompt.name);
299
+ components.push({
300
+ component_id: compId,
301
+ name: prompt.name,
302
+ type: 'prompt',
303
+ role: { purpose: `AI prompt: ${prompt.name}`, layer: 'backend', critical: false },
304
+ source: { detection_method: 'auto', config_files: [], confidence: prompt.confidence },
305
+ connects_to: [],
306
+ connected_from: [],
307
+ status: 'active',
308
+ tags: ['swift', 'prompt'],
309
+ metadata: { preview: prompt.preview },
310
+ timestamp,
311
+ last_updated: timestamp,
312
+ });
313
+ connections.push({
314
+ connection_id: generateConnectionId('prompt-location'),
315
+ from: { component_id: compId, location: { file: prompt.file, line: prompt.line } },
316
+ to: { component_id: compId },
317
+ connection_type: 'prompt-location',
318
+ code_reference: {
319
+ file: prompt.file,
320
+ symbol: prompt.name,
321
+ symbol_type: 'variable',
322
+ line_start: prompt.line,
323
+ code_snippet: prompt.preview.slice(0, 100),
324
+ },
325
+ description: `Prompt "${prompt.name}" defined in ${prompt.file}`,
326
+ detected_from: 'swift-code-scanner',
327
+ confidence: prompt.confidence,
328
+ timestamp,
329
+ last_verified: timestamp,
330
+ });
331
+ }
332
+ // ---- Build project metadata ----
333
+ const projectMeta = buildProjectMetadata(files, frameworkImports, projectRoot, fragileKeys, entitlementReqs);
334
+ return { components, connections, warnings, projectMeta };
335
+ }
336
+ // =============================================================================
337
+ // STRING KEY DETECTION
338
+ // =============================================================================
339
+ function scanStringKeys(files) {
340
+ const hits = [];
341
+ for (const file of files) {
342
+ for (let i = 0; i < file.lines.length; i++) {
343
+ const line = file.lines[i];
344
+ const trimmed = line.trim();
345
+ // Skip comments
346
+ if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*'))
347
+ continue;
348
+ // @AppStorage("key") — normalizes to UserDefaults since @AppStorage is a UserDefaults wrapper
349
+ const appStorageMatch = line.match(/@AppStorage\(["']([^"']+)["']\)/);
350
+ if (appStorageMatch) {
351
+ hits.push({
352
+ key: appStorageMatch[1],
353
+ type: 'UserDefaults',
354
+ file: file.relativePath,
355
+ line: i + 1,
356
+ symbol: extractNearestSymbol(file.lines, i) || appStorageMatch[1],
357
+ snippet: trimmed,
358
+ });
359
+ }
360
+ // UserDefaults.standard.set(..., forKey: "key") / .object(forKey: "key") / etc.
361
+ const udWriteMatch = line.match(/UserDefaults\.(?:standard|[a-zA-Z]+)\.(?:set|setValue|removeObject)\([^)]*forKey:\s*["']([^"']+)["']\)/);
362
+ if (udWriteMatch) {
363
+ hits.push({
364
+ key: udWriteMatch[1],
365
+ type: 'UserDefaults',
366
+ file: file.relativePath,
367
+ line: i + 1,
368
+ symbol: extractNearestSymbol(file.lines, i) || udWriteMatch[1],
369
+ snippet: trimmed,
370
+ });
371
+ }
372
+ const udReadMatch = line.match(/UserDefaults\.(?:standard|[a-zA-Z]+)\.(?:object|string|integer|bool|double|float|array|dictionary|data|url)\(forKey:\s*["']([^"']+)["']\)/);
373
+ if (udReadMatch) {
374
+ hits.push({
375
+ key: udReadMatch[1],
376
+ type: 'UserDefaults',
377
+ file: file.relativePath,
378
+ line: i + 1,
379
+ symbol: extractNearestSymbol(file.lines, i) || udReadMatch[1],
380
+ snippet: trimmed,
381
+ });
382
+ }
383
+ // NotificationCenter — .post(name: .someNotification) or Notification.Name("string")
384
+ const notifPostMatch = line.match(/\.post\(name:\s*(?:\.(\w+)|Notification\.Name\(["']([^"']+)["']\))/);
385
+ if (notifPostMatch) {
386
+ const key = notifPostMatch[1] || notifPostMatch[2];
387
+ hits.push({
388
+ key,
389
+ type: 'NotificationCenter',
390
+ file: file.relativePath,
391
+ line: i + 1,
392
+ symbol: extractNearestSymbol(file.lines, i) || key,
393
+ snippet: trimmed,
394
+ });
395
+ }
396
+ const notifObserveMatch = line.match(/\.addObserver\([^)]*name:\s*(?:\.(\w+)|Notification\.Name\(["']([^"']+)["']\))/);
397
+ if (notifObserveMatch) {
398
+ const key = notifObserveMatch[1] || notifObserveMatch[2];
399
+ hits.push({
400
+ key,
401
+ type: 'NotificationCenter',
402
+ file: file.relativePath,
403
+ line: i + 1,
404
+ symbol: extractNearestSymbol(file.lines, i) || key,
405
+ snippet: trimmed,
406
+ });
407
+ }
408
+ // Image("name") or Image(systemName: "name") — asset names
409
+ const imageMatch = line.match(/Image\(\s*["']([^"']+)["']\s*\)/);
410
+ if (imageMatch) {
411
+ hits.push({
412
+ key: imageMatch[1],
413
+ type: 'AssetName',
414
+ file: file.relativePath,
415
+ line: i + 1,
416
+ symbol: extractNearestSymbol(file.lines, i) || imageMatch[1],
417
+ snippet: trimmed,
418
+ });
419
+ }
420
+ // UIImage(named: "name")
421
+ const uiImageMatch = line.match(/UIImage\(named:\s*["']([^"']+)["']\)/);
422
+ if (uiImageMatch) {
423
+ hits.push({
424
+ key: uiImageMatch[1],
425
+ type: 'AssetName',
426
+ file: file.relativePath,
427
+ line: i + 1,
428
+ symbol: extractNearestSymbol(file.lines, i) || uiImageMatch[1],
429
+ snippet: trimmed,
430
+ });
431
+ }
432
+ // Color("name")
433
+ const colorMatch = line.match(/Color\(\s*["']([^"']+)["']\s*\)/);
434
+ if (colorMatch) {
435
+ hits.push({
436
+ key: colorMatch[1],
437
+ type: 'AssetName',
438
+ file: file.relativePath,
439
+ line: i + 1,
440
+ symbol: extractNearestSymbol(file.lines, i) || colorMatch[1],
441
+ snippet: trimmed,
442
+ });
443
+ }
444
+ // NSSound(named: .init("name"))
445
+ const nsSoundMatch = line.match(/NSSound\(named:\s*\.init\(["']([^"']+)["']\)\)/);
446
+ if (nsSoundMatch) {
447
+ hits.push({
448
+ key: nsSoundMatch[1],
449
+ type: 'AssetName',
450
+ file: file.relativePath,
451
+ line: i + 1,
452
+ symbol: extractNearestSymbol(file.lines, i) || nsSoundMatch[1],
453
+ snippet: trimmed,
454
+ });
455
+ }
456
+ // Keychain — kSecAttrService or KeychainAccess
457
+ const keychainMatch = line.match(/(?:kSecAttrService|kSecAttrAccount|Keychain\(service:)\s*(?::|as\s+.+?,\s*)?["']([^"']+)["']/);
458
+ if (keychainMatch) {
459
+ hits.push({
460
+ key: keychainMatch[1],
461
+ type: 'Keychain',
462
+ file: file.relativePath,
463
+ line: i + 1,
464
+ symbol: extractNearestSymbol(file.lines, i) || keychainMatch[1],
465
+ snippet: trimmed,
466
+ });
467
+ }
468
+ }
469
+ }
470
+ return hits;
471
+ }
472
+ // =============================================================================
473
+ // PROTOCOL CONFORMANCE
474
+ // =============================================================================
475
+ function scanProtocolConformance(files) {
476
+ const conformances = [];
477
+ // Well-known protocols worth tracking
478
+ const interestingProtocols = new Set([
479
+ 'ObservableObject', 'Observable', 'Codable', 'Decodable', 'Encodable',
480
+ 'Identifiable', 'Hashable', 'Equatable', 'Comparable',
481
+ 'View', 'App', 'Scene', 'Widget', 'TimelineProvider',
482
+ 'Sendable', 'Actor',
483
+ 'URLSessionDelegate', 'URLSessionDataDelegate',
484
+ ]);
485
+ for (const file of files) {
486
+ for (let i = 0; i < file.lines.length; i++) {
487
+ const line = file.lines[i];
488
+ // Match: struct/class/enum/actor TypeName: Protocol1, Protocol2 {
489
+ const match = line.match(/^\s*(?:(?:public|private|internal|open|final|@\w+)\s+)*(?:struct|class|enum|actor)\s+(\w+)\s*(?:<[^>]*>)?\s*:\s*([^{]+)/);
490
+ if (match) {
491
+ const typeName = match[1];
492
+ const rawProtocols = match[2].split(',').map(p => p.trim()).filter(Boolean);
493
+ const protocols = rawProtocols.filter(p => {
494
+ // Filter out generic superclasses (rough heuristic: known protocols or capitalized single words)
495
+ return interestingProtocols.has(p) || /^[A-Z]\w+(?:Protocol|Delegate|DataSource|able)$/.test(p);
496
+ });
497
+ if (protocols.length > 0) {
498
+ conformances.push({ typeName, protocols, file: file.relativePath, line: i + 1 });
499
+ }
500
+ }
501
+ }
502
+ }
503
+ return conformances;
504
+ }
505
+ // =============================================================================
506
+ // STATE OBSERVATION
507
+ // =============================================================================
508
+ function scanStateObservation(files) {
509
+ const observations = [];
510
+ const wrappers = ['@Published', '@ObservedObject', '@EnvironmentObject', '@StateObject', '@State', '@Binding'];
511
+ for (const file of files) {
512
+ let currentType = '';
513
+ for (let i = 0; i < file.lines.length; i++) {
514
+ const line = file.lines[i];
515
+ // Track current type context
516
+ const typeMatch = line.match(/^\s*(?:(?:public|private|internal|open|final|@\w+)\s+)*(?:struct|class|enum|actor)\s+(\w+)/);
517
+ if (typeMatch) {
518
+ currentType = typeMatch[1];
519
+ }
520
+ for (const wrapper of wrappers) {
521
+ if (!line.includes(wrapper))
522
+ continue;
523
+ // Match: @Published var name: Type
524
+ const propMatch = line.match(new RegExp(`${wrapper.replace('$', '\\$')}\\s+(?:private\\s+|private\\(set\\)\\s+)?var\\s+(\\w+)\\s*(?::\\s*(\\w+))?`));
525
+ if (propMatch) {
526
+ const propName = propMatch[1];
527
+ const propType = propMatch[2] || '';
528
+ // For @Published, the ownerType is the containing type's name (what gets observed)
529
+ // For @ObservedObject/@EnvironmentObject, the propType IS the observed type
530
+ const ownerType = wrapper === '@Published' ? currentType : propType;
531
+ observations.push({
532
+ propertyName: propName,
533
+ wrapper,
534
+ ownerType,
535
+ file: file.relativePath,
536
+ line: i + 1,
537
+ });
538
+ }
539
+ }
540
+ }
541
+ }
542
+ return observations;
543
+ }
544
+ // =============================================================================
545
+ // LLM CALL DETECTION
546
+ // =============================================================================
547
+ function scanLLMCalls(files) {
548
+ const calls = [];
549
+ for (const file of files) {
550
+ for (let i = 0; i < file.lines.length; i++) {
551
+ const line = file.lines[i];
552
+ const trimmed = line.trim();
553
+ // Skip comments
554
+ if (trimmed.startsWith('//') || trimmed.startsWith('*'))
555
+ continue;
556
+ // Check URL patterns in string literals
557
+ for (const { pattern, provider } of LLM_URL_PATTERNS) {
558
+ if (pattern.test(line)) {
559
+ calls.push({
560
+ provider,
561
+ url: line.match(/"([^"]*)"/)?.[1],
562
+ file: file.relativePath,
563
+ line: i + 1,
564
+ symbol: extractNearestSymbol(file.lines, i) || 'urlRequest',
565
+ snippet: trimmed,
566
+ });
567
+ }
568
+ }
569
+ // Check SDK import patterns
570
+ for (const { pattern, provider } of LLM_IMPORT_PATTERNS) {
571
+ if (pattern.test(trimmed)) {
572
+ calls.push({
573
+ provider,
574
+ file: file.relativePath,
575
+ line: i + 1,
576
+ symbol: `import ${provider}`,
577
+ snippet: trimmed,
578
+ });
579
+ }
580
+ }
581
+ // Check SDK call patterns
582
+ for (const { pattern, provider } of LLM_CALL_PATTERNS) {
583
+ if (pattern.test(line)) {
584
+ calls.push({
585
+ provider,
586
+ file: file.relativePath,
587
+ line: i + 1,
588
+ symbol: extractNearestSymbol(file.lines, i) || provider,
589
+ snippet: trimmed,
590
+ });
591
+ }
592
+ }
593
+ }
594
+ }
595
+ // Deduplicate by file+provider (keep first occurrence)
596
+ const seen = new Set();
597
+ return calls.filter(c => {
598
+ const key = `${c.file}:${c.provider}`;
599
+ if (seen.has(key))
600
+ return false;
601
+ seen.add(key);
602
+ return true;
603
+ });
604
+ }
605
+ // =============================================================================
606
+ // FRAMEWORK IMPORT SCANNING (for entitlement detection)
607
+ // =============================================================================
608
+ function scanFrameworkImports(files) {
609
+ const results = [];
610
+ const entitlementFrameworks = new Set(Object.keys(FRAMEWORK_ENTITLEMENTS));
611
+ for (const file of files) {
612
+ for (let i = 0; i < file.lines.length; i++) {
613
+ const match = file.lines[i].match(/^\s*import\s+(\w+)/);
614
+ if (match && entitlementFrameworks.has(match[1])) {
615
+ results.push({ framework: match[1], file: file.relativePath, line: i + 1 });
616
+ }
617
+ }
618
+ }
619
+ // Deduplicate by framework (keep first)
620
+ const seen = new Set();
621
+ return results.filter(r => {
622
+ if (seen.has(r.framework))
623
+ return false;
624
+ seen.add(r.framework);
625
+ return true;
626
+ });
627
+ }
628
+ // =============================================================================
629
+ // PROMPT DETECTION IN SWIFT
630
+ // =============================================================================
631
+ function scanSwiftPrompts(files) {
632
+ const prompts = [];
633
+ for (const file of files) {
634
+ for (let i = 0; i < file.lines.length; i++) {
635
+ const line = file.lines[i];
636
+ // Match: static let systemPrompt = """ ... """
637
+ // Match: let prompt = "You are a..."
638
+ // Match: var SYSTEM_PROMPT = """
639
+ const promptVarMatch = line.match(/(?:static\s+)?(?:let|var)\s+(\w*(?:[Pp]rompt|[Ss]ystem|[Ii]nstruction)\w*)\s*(?::\s*String)?\s*=\s*(".*"|""")/);
640
+ if (promptVarMatch) {
641
+ const name = promptVarMatch[1];
642
+ let preview = promptVarMatch[2];
643
+ // For multi-line strings, grab more lines
644
+ if (preview === '"""') {
645
+ const nextLines = [];
646
+ for (let j = i + 1; j < Math.min(i + 10, file.lines.length); j++) {
647
+ if (file.lines[j].includes('"""'))
648
+ break;
649
+ nextLines.push(file.lines[j].trim());
650
+ }
651
+ preview = nextLines.join(' ').slice(0, 200);
652
+ }
653
+ prompts.push({
654
+ name,
655
+ file: file.relativePath,
656
+ line: i + 1,
657
+ preview: preview.replace(/^"|"$/g, '').slice(0, 200),
658
+ confidence: 0.85,
659
+ });
660
+ }
661
+ // Match: "role": "system" or role: .system in messages arrays
662
+ if (/role.*system/i.test(line) && /content|message/i.test(file.lines[Math.min(i + 1, file.lines.length - 1)] || '')) {
663
+ const name = extractNearestSymbol(file.lines, i) || `prompt_${file.relativePath}:${i + 1}`;
664
+ prompts.push({
665
+ name,
666
+ file: file.relativePath,
667
+ line: i + 1,
668
+ preview: line.trim().slice(0, 200),
669
+ confidence: 0.7,
670
+ });
671
+ }
672
+ }
673
+ }
674
+ return prompts;
675
+ }
676
+ // =============================================================================
677
+ // PROJECT METADATA BUILDER
678
+ // =============================================================================
679
+ function buildProjectMetadata(files, frameworkImports, projectRoot, fragileKeys, entitlementReqs) {
680
+ const meta = { type: 'swift-app' };
681
+ // Detect platforms from framework usage
682
+ const platforms = new Set();
683
+ const allImports = new Set();
684
+ for (const file of files) {
685
+ for (const line of file.lines) {
686
+ const m = line.match(/^\s*import\s+(\w+)/);
687
+ if (m)
688
+ allImports.add(m[1]);
689
+ }
690
+ }
691
+ if (allImports.has('UIKit') || allImports.has('SwiftUI'))
692
+ platforms.add('iOS');
693
+ if (allImports.has('AppKit'))
694
+ platforms.add('macOS');
695
+ if (allImports.has('WatchKit'))
696
+ platforms.add('watchOS');
697
+ if (allImports.has('WidgetKit'))
698
+ platforms.add('iOS'); // Widgets are iOS typically
699
+ // SwiftUI can be any platform — check for platform-specific APIs
700
+ if (allImports.has('SwiftUI') && !allImports.has('UIKit') && !allImports.has('AppKit')) {
701
+ platforms.add('iOS'); // Default assumption for SwiftUI-only
702
+ platforms.add('macOS');
703
+ }
704
+ meta.platforms = [...platforms];
705
+ // Detect architecture pattern
706
+ if (allImports.has('ComposableArchitecture')) {
707
+ meta.architecture_pattern = 'TCA (Composable Architecture)';
708
+ }
709
+ else {
710
+ // Check for MVVM indicators: ObservableObject/Observable classes separate from Views
711
+ const hasObservableObjects = files.some(f => f.content.includes('ObservableObject') || f.content.includes('@Observable'));
712
+ const hasViews = files.some(f => f.content.includes(': View'));
713
+ if (hasObservableObjects && hasViews) {
714
+ meta.architecture_pattern = 'MVVM';
715
+ }
716
+ else if (allImports.has('UIKit') && files.some(f => f.content.includes(': UIViewController'))) {
717
+ meta.architecture_pattern = 'MVC';
718
+ }
719
+ }
720
+ // Detect deployment target from Package.swift
721
+ try {
722
+ const pkgSwiftPath = path.join(projectRoot, 'Package.swift');
723
+ if (fs.existsSync(pkgSwiftPath)) {
724
+ const pkgContent = fs.readFileSync(pkgSwiftPath, 'utf-8');
725
+ const deployments = {};
726
+ const platformMatches = pkgContent.matchAll(/\.(iOS|macOS|watchOS|tvOS|visionOS)\("([^"]+)"\)/g);
727
+ for (const m of platformMatches) {
728
+ deployments[m[1]] = m[2];
729
+ }
730
+ const platformMatchesV2 = pkgContent.matchAll(/\.(iOS|macOS|watchOS|tvOS|visionOS)\(\.v(\d+)/g);
731
+ for (const m of platformMatchesV2) {
732
+ deployments[m[1]] = `${m[2]}.0`;
733
+ }
734
+ if (Object.keys(deployments).length > 0) {
735
+ meta.min_deployment = deployments;
736
+ }
737
+ }
738
+ }
739
+ catch {
740
+ // Skip if unreadable
741
+ }
742
+ // Parse targets from Package.swift
743
+ try {
744
+ const pkgSwiftPath = path.join(projectRoot, 'Package.swift');
745
+ if (fs.existsSync(pkgSwiftPath)) {
746
+ const pkgContent = fs.readFileSync(pkgSwiftPath, 'utf-8');
747
+ const targets = [];
748
+ const targetMatches = pkgContent.matchAll(/\.(?:executableTarget|target|testTarget)\(\s*name:\s*"([^"]+)"/g);
749
+ for (const m of targetMatches) {
750
+ const targetType = m[0].includes('testTarget') ? 'test' : m[0].includes('executableTarget') ? 'executable' : 'library';
751
+ targets.push({ name: m[1], type: targetType, dependencies: [] });
752
+ }
753
+ if (targets.length > 0) {
754
+ meta.targets = targets;
755
+ }
756
+ }
757
+ }
758
+ catch {
759
+ // Skip
760
+ }
761
+ // Entitlements
762
+ if (entitlementReqs.length > 0) {
763
+ meta.entitlements = entitlementReqs.map(r => ({ key: r.key, file: r.framework }));
764
+ }
765
+ // Fragile keys
766
+ meta.fragile_keys = fragileKeys;
767
+ return meta;
768
+ }
769
+ // =============================================================================
770
+ // HELPERS
771
+ // =============================================================================
772
+ function extractNearestSymbol(lines, lineIndex) {
773
+ // Look backwards for func/var/let/class/struct declaration
774
+ for (let j = lineIndex; j >= Math.max(0, lineIndex - 5); j--) {
775
+ const funcMatch = lines[j].match(/(?:func|var|let|class|struct|enum)\s+(\w+)/);
776
+ if (funcMatch)
777
+ return funcMatch[1];
778
+ }
779
+ return undefined;
780
+ }
781
+ function groupByKey(hits) {
782
+ const groups = new Map();
783
+ for (const hit of hits) {
784
+ const key = `${hit.type}:${hit.key}`;
785
+ if (!groups.has(key))
786
+ groups.set(key, []);
787
+ groups.get(key).push(hit);
788
+ }
789
+ return groups;
790
+ }
791
+ function buildFragileKeys(hits) {
792
+ const groups = groupByKey(hits);
793
+ const fragile = [];
794
+ for (const [key, keyHits] of groups) {
795
+ fragile.push({
796
+ key,
797
+ type: keyHits[0].type,
798
+ files: [...new Set(keyHits.map(h => h.file))],
799
+ });
800
+ }
801
+ return fragile.length > 0 ? fragile : undefined;
802
+ }
803
+ //# sourceMappingURL=code-scanner.js.map