mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-26 12:30:04 +08:00
fix(swift): what a SwiftUI app's viewer showed that was not so (#2109)
Three things IceCubesApp showed in `codegraph ui`:
Steps labelled calls that leave the index by other stacks' names:
`rawValue.data(using: .utf8)` network (25 boxes), Foundation's `Timer`
telemetry, `Calendar` and a SiriKit `intent` device, the app's own
`Notifications` endpoint enum device (expo-notifications' row), and
`viewModel.votes.firstIndex(of:)` a database read (the `\w*Model` row).
Now: a Swift `.data/.upload/.download/.bytes` is network only on a
`*session` receiver; Timer/Calendar/intent are no effect in Swift; a
`viewModel` is not a table (any language); a keychain receiver is
storage ahead of Swift's generic `x.delete` database row. A call whose
receiver is a type the Swift project declares — not an `extension X {}`,
which declares nothing (read through the site reader) — no longer
matches the library-name rows; the model and reply rows, written for
project types, still do.
"Files that run something" listed every view with a `#Preview { … }`:
the macro sits at a file's top level, so its constructions were
module-level edges. Entry points now subtract calls inside a Swift
file's top-level previews (trailing closures included — WidgetKit's
`} timeline: {`), drop a file left with none, and re-rank.
Every `struct X: View` had a one-line `component` twin, and `@main`
apps, view controllers and UIView subclasses a `class` twin, from the
SwiftUI/UIKit resolvers' regex extract(): 188 nodes on IceCubesApp, 184
on mastodon-ios, zero edges into any of them — dead ends in search and
the symbol view. The two extract()s are removed.
IceCubesApp's effect sweep is now URLSession-only network, no
telemetry, no database, keychain storage; its entry files drop from
eleven preview files to its one real script. SwiftPackageIndex's Fluent
database and reply steps are unchanged.
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
e9be68b61d
commit
7639c787e4
@@ -112,9 +112,14 @@ export const EFFECT_RULES: ReadonlyArray<EffectRule> = [
|
||||
{ category: 'response', test: /^(?:HttpResponse|Json|StatusCode|Redirect|NamedFile|HttpResponseBuilder)(?:::\w+)*$/, only: ['rs'] },
|
||||
|
||||
// ---------------------------------------------------------------- database --
|
||||
// A keychain is storage, whatever its method is named: Swift's generic
|
||||
// `x.delete` database row below would take `keychain.delete(key)`.
|
||||
// The receiver is the keychain (`keychain`, `appKeychain`, `KeychainSwift`), not `keychainAccounts`.
|
||||
{ category: 'storage', test: /^(?:self\.)?(?:\w*[kK]eychain|Keychain\w*)(?:\.\w+)+$/, only: ['swift'] },
|
||||
{ category: 'database', test: /^(?:this\.)?(?:prisma|db|database|orm|em|entityManager|dataSource|queryRunner|knex|kysely|sequelize|mongoose|drizzle|sql|pool|pg|pgClient|conn|connection|repo|repository|collection|trx|tx|typeorm|dbClient|mongo|mongoClient)\.(?:\w+\.)?\$?\w+$/, only: ['js'] },
|
||||
{ category: 'database', test: /^(?:this\.)?_?\w*(?:Repository|Repo|Dao|DAO|Mapper|EntityManager|DataSource|Knex|Prisma|Kysely|Drizzle|Sequelize|DbContext|DbSet)\.(?:\w+\.)?\w+$/, only: ['js', 'jvm', 'cs', 'go', 'rs', 'swift', 'rb'] },
|
||||
{ category: 'database', test: new RegExp(`^(?:this\\.)?_?\\w*(?:Model|Entity|Collection|Table|Db|DB|Database|Datastore)\\.(?:\\w+\\.)?${DB_OP}$`), only: ['js', 'jvm', 'cs', 'go', 'rs', 'swift', 'rb'] },
|
||||
// `viewModel.votes.firstIndex(of:)`: a view model is UI state, not a table.
|
||||
{ category: 'database', test: new RegExp(`^(?:this\\.)?_?\\w*(?<![Vv]iew)(?:Model|Entity|Collection|Table|Db|DB|Database|Datastore)\\.(?:\\w+\\.)?${DB_OP}$`), only: ['js', 'jvm', 'cs', 'go', 'rs', 'swift', 'rb'] },
|
||||
{ category: 'database', test: new RegExp(`^(?!${BUILTIN_RECEIVERS.source.slice(1, -1)}\\b)[A-Z]\\w*\\.${ORM_STATIC_OPS}$`), only: ['js', 'rb', 'swift', 'php'] },
|
||||
{ category: 'database', test: /^(?:self\.)?(?:\w*_)?(?:session|db|database|engine|cursor|conn|connection|Session)\.(?:session\.)?(?:query|add|add_all|commit|execute|executemany|exec|delete|refresh|flush|rollback|get|merge|scalars?|scalar_one\w*|one|one_or_none|first|all|begin|close|expunge|bulk_\w+|select|insert|update|fetchone|fetchall|fetchmany|create_all|drop_all|run_sync)$/, only: ['py'] },
|
||||
{ category: 'database', test: /^[A-Z]\w*\.(?:objects|query|_default_manager)(?:\.\w+)*$|^\w+\.objects\.\w+$|^(?:select|insert|update|delete|text|func\.\w+|bulk_create|bulk_update|get_object_or_404)$|^\w+\.(?:save|delete|refresh_from_db|get_or_create|update_or_create|filter|exclude|annotate|aggregate|values|values_list|select_related|prefetch_related|bulk_create|bulk_update|create|update|count|exists)$/, only: ['py'] },
|
||||
@@ -192,7 +197,7 @@ export const EFFECT_RULES: ReadonlyArray<EffectRule> = [
|
||||
{ category: 'network', test: /^(?:Net::HTTP(?:\.\w+)*|HTTParty\.\w+|Faraday(?:\.\w+)*|RestClient\.\w+|HTTP\.\w+|Excon\.\w+|Typhoeus\.\w+|OpenURI\.open_uri|URI\.open|open-uri|Socket\.\w+|TCPSocket\.\w+|WebSocket::\w+|ActionCable\.server\.broadcast|\w+Channel\.broadcast_to|\w+Channel\.broadcast|\w+\.broadcast)$|^\w+\.(?:get|post|put|patch|delete|head|request)$/, only: ['rb'] },
|
||||
{ category: 'network', test: /^(?:Http::\w+|Http::\w+::\w+|curl_init|curl_exec|curl_setopt\w*|curl_close|file_get_contents|fsockopen|stream_socket_client|socket_create|socket_connect|socket_send|socket_recv|\$client->(?:request|get|post|put|patch|delete|send|sendAsync|requestAsync)|\$guzzle->\w+|\$http->\w+)(?:\(\)->\w+)*$/, only: ['php'] },
|
||||
{ category: 'network', test: /^(?:reqwest|hyper|ureq|isahc|surf|tonic|tungstenite|tokio_tungstenite|websocket|TcpStream|TcpListener|UdpSocket|Client|ClientBuilder|Request|awc)(?:::\w+)*$|^\w+\.(?:get|post|put|patch|delete|head|send|execute|connect|bind|send_to|recv_from|write_all|read_to_end)$/, only: ['rs'] },
|
||||
{ category: 'network', test: /^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask|webSocketTask|data|upload|download|responseDecodable|responseJSON|responseData)$|^(?:NWConnection|NWListener|NWBrowser|URLSessionWebSocketTask|WebSocket|Starscream|SocketManager|SocketIOClient|Socket)\b|^(?:this\.|self\.)?(?:client|api|apiClient|http|httpClient|networkService|network|session)\.(?:get|post|put|patch|delete|request|send|fetch|perform|execute|call|data|upload|download)$/, only: ['swift'] },
|
||||
{ category: 'network', test: /^URLSession(?:\.|$)|^(?:Alamofire|AF)\.|\.(?:dataTask|uploadTask|downloadTask|webSocketTask|responseDecodable|responseJSON|responseData)$|(?:^|\.)\w*[sS]ession\.(?:data|upload|download|bytes)$|^(?:NWConnection|NWListener|NWBrowser|URLSessionWebSocketTask|WebSocket|Starscream|SocketManager|SocketIOClient|Socket)\b|^(?:this\.|self\.)?(?:client|api|apiClient|http|httpClient|networkService|network|session)\.(?:get|post|put|patch|delete|request|send|fetch|perform|execute|call|data|upload|download)$/, only: ['swift'] },
|
||||
|
||||
// ------------------------------------------------------------------ device --
|
||||
{ category: 'device', test: /^(?:Linking|Share|Clipboard|Notifications|Camera|ImagePicker|MediaLibrary|Haptics|Alert|Vibration|Location|Geolocation|Permissions|UIApplication|AVCaptureSession|AVAudioSession|CLLocationManager|UNUserNotificationCenter|Battery|Brightness|Sensors|Accelerometer|Gyroscope|Magnetometer|Pedometer|Contacts|Calendar|LocalAuthentication|BiometricAuth|DocumentPicker|Print|ScreenOrientation|StatusBar|BackHandler|Appearance|Dimensions|PixelRatio|Keyboard|PushNotification|PushNotificationIOS|messaging|Bluetooth|BleManager|NfcManager|navigator\.\w+|window\.(?:open|print|alert|confirm|prompt)|Notification|speechSynthesis|WebAuthn|Intent|intent|context\.startActivity|startActivity|startService|sendBroadcast|registerReceiver|NotificationManager|notificationManager|NotificationCompat|LocationManager|locationManager|fusedLocationClient|SensorManager|sensorManager|CameraX|cameraProvider|MediaPlayer|mediaPlayer|AudioManager|audioManager|Vibrator|vibrator|ClipboardManager|clipboardManager|UIDevice|UIPasteboard|UIImpactFeedbackGenerator|UINotificationFeedbackGenerator|AVAudioPlayer|AVPlayer|CMMotionManager|PHPhotoLibrary|UIImagePickerController|LAContext|WKWebView|Process\.Start|Environment\.Exit|Clipboard\.\w+|Console\.\w+)\b/ },
|
||||
@@ -242,6 +247,13 @@ export interface EffectInput {
|
||||
receiverType?: string | null;
|
||||
/** The argument list, abbreviated, when it was read — where the model of a `knex('users')` comes from. */
|
||||
args?: string | null;
|
||||
/**
|
||||
* The call's receiver is a type the project declares — IceCubesApp's
|
||||
* `Notifications` endpoint enum, not expo-notifications. A library name the
|
||||
* table keys on is shadowed then; only the model and reply rows, which are
|
||||
* written for project types, still apply.
|
||||
*/
|
||||
projectType?: boolean;
|
||||
}
|
||||
|
||||
export interface Effect {
|
||||
@@ -270,6 +282,13 @@ export function normaliseCall(text: string): string {
|
||||
.replace(/<[^<>]*>/g, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Swift names the table knows from other stacks: Foundation's `Timer`
|
||||
* schedules work (Micrometer's is telemetry), `Calendar` does date math
|
||||
* (expo-calendar's is the device), and an `intent` is SiriKit configuration.
|
||||
*/
|
||||
const SWIFT_NOT_AN_EFFECT = /^(?:Timer|Calendar|Intent|intent)\b/;
|
||||
|
||||
/**
|
||||
* What a call is, when it is one of the things the table names. `null` for
|
||||
* everything else — a plain call into a library is not an effect.
|
||||
@@ -282,8 +301,10 @@ export function classifyEffect(input: EffectInput): Effect | null {
|
||||
if (text === '') return null;
|
||||
const families = familiesOf(input.language);
|
||||
const project = input.project ?? 'app';
|
||||
if (families.includes('swift') && SWIFT_NOT_AN_EFFECT.test(text)) return null;
|
||||
|
||||
for (const rule of EFFECT_RULES) {
|
||||
if (input.projectType && rule.category !== 'database' && rule.category !== 'response') continue;
|
||||
if (rule.only && !rule.only.some((f) => families.includes(f))) {
|
||||
// An ungated language (no family) still gets the JS rows — the table
|
||||
// grew up on them and the old tests call with no language.
|
||||
|
||||
@@ -39,6 +39,7 @@ import { intParam } from './respond';
|
||||
import { buildRoutes, type WireRoute } from './routes';
|
||||
import { isTestFile, isTestPath } from '../../search/query-utils';
|
||||
import { toNodeRef, toPosixPath, wireList, type WireList, type WireNodeRef } from './wire';
|
||||
import { readIndexedFileText } from './source';
|
||||
|
||||
/** Rows per derived list, and the default for `limit`. */
|
||||
const DEFAULT_LIMIT = 12;
|
||||
@@ -240,7 +241,7 @@ function routeEntries(cg: CodeGraph, limit: number): WireEntryPoints['routes'] {
|
||||
* otherwise answer with an empty list.
|
||||
*/
|
||||
function executableFiles(cg: CodeGraph, limit: number): WireList<WireEntryFile> {
|
||||
const ranked = cg.getTopCallingFiles(SCAN_ROWS);
|
||||
const ranked = withoutPreviews(cg, cg.getTopCallingFiles(SCAN_ROWS));
|
||||
|
||||
const kept: Array<{ node: Node; calls: number; reaches: number }> = [];
|
||||
const perDir = new Map<string, number>();
|
||||
@@ -272,6 +273,84 @@ function executableFiles(cg: CodeGraph, limit: number): WireList<WireEntryFile>
|
||||
return wireList(items, Math.max(eligible, items.length));
|
||||
}
|
||||
|
||||
/** A Swift file read for its `#Preview` blocks, at most. */
|
||||
const MAX_PREVIEW_SCAN = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* The ranking with a Swift file's `#Preview { … }` calls taken out.
|
||||
*
|
||||
* A preview sits at the top level of a SwiftUI view's file, so its body's
|
||||
* constructions are module-level edges out of the file — and every view with
|
||||
* a preview ranked as "a file that runs something". A preview is Xcode's
|
||||
* canvas, never code the app runs. A file whose only module-level calls are
|
||||
* previews leaves the list; the rest are re-ranked on what is left.
|
||||
*/
|
||||
function withoutPreviews<T extends { nodeId: string; filePath: string; calls: number; reaches: number; score: number }>(cg: CodeGraph, rows: T[]): T[] {
|
||||
let adjusted = false;
|
||||
const out: T[] = [];
|
||||
for (const row of rows) {
|
||||
if (!row.filePath.endsWith('.swift')) {
|
||||
out.push(row);
|
||||
continue;
|
||||
}
|
||||
const text = readIndexedFileText(cg, cg.getProjectRoot(), row.filePath, MAX_PREVIEW_SCAN);
|
||||
const spans = text && text.includes('#Preview') ? swiftPreviewSpans(text) : [];
|
||||
if (spans.length === 0) {
|
||||
out.push(row);
|
||||
continue;
|
||||
}
|
||||
const inPreview = cg
|
||||
.getOutgoingEdges(row.nodeId)
|
||||
.filter((e) => (e.kind === 'calls' || e.kind === 'instantiates') && e.line != null && spans.some(([from, to]) => e.line! >= from && e.line! <= to)).length;
|
||||
if (inPreview === 0) {
|
||||
out.push(row);
|
||||
continue;
|
||||
}
|
||||
adjusted = true;
|
||||
const calls = row.calls - inPreview;
|
||||
if (calls > 0) out.push({ ...row, calls, score: calls * (1 + row.reaches) });
|
||||
}
|
||||
if (adjusted) out.sort((a, b) => b.score - a.score || b.calls - a.calls || (a.filePath < b.filePath ? -1 : a.filePath > b.filePath ? 1 : 0));
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* The line spans (1-based, inclusive) of a Swift file's top-level
|
||||
* `#Preview { … }` macros, trailing closures included — WidgetKit's
|
||||
* `#Preview(as: .systemSmall) { … } timeline: { … }` is one preview.
|
||||
*/
|
||||
export function swiftPreviewSpans(text: string): Array<[number, number]> {
|
||||
const lines = text.split(/\r?\n/);
|
||||
const spans: Array<[number, number]> = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (!/^#Preview\b/.test(lines[i]!)) continue;
|
||||
let depth = 0;
|
||||
let opened = false;
|
||||
let end = -1;
|
||||
for (let j = i; j < lines.length && end < 0; j++) {
|
||||
const line = lines[j]!;
|
||||
for (let c = 0; c < line.length; c++) {
|
||||
const ch = line[c];
|
||||
if (ch === '{') {
|
||||
depth++;
|
||||
opened = true;
|
||||
} else if (ch === '}' && opened && --depth === 0) {
|
||||
// Another trailing closure (`} timeline: {`) continues the macro.
|
||||
const rest = `${line.slice(c + 1)} ${lines[j + 1] ?? ''}`;
|
||||
if (!/^\s*\w+\s*:\s*\{/.test(rest)) {
|
||||
end = j;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (end < 0) break;
|
||||
spans.push([i + 1, end + 1]);
|
||||
i = end;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
/**
|
||||
* The suites that exercise the most of the project, widest first.
|
||||
*
|
||||
|
||||
@@ -534,6 +534,28 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
}
|
||||
return cls;
|
||||
};
|
||||
// Swift has no per-symbol imports: a type the project declares shadows the
|
||||
// SDK name the effect table keys on — IceCubesApp's `Notifications`
|
||||
// endpoint enum is not expo-notifications. An `extension Timer {}` declares
|
||||
// nothing, so it does not count.
|
||||
const projectTypes = new Map<string, boolean>();
|
||||
const declaresSwiftType = async (callee: string): Promise<boolean> => {
|
||||
const root = callee.replace(/^(?:self|Self)\./, '').split(/[.(:<]/)[0] ?? '';
|
||||
if (!/^[A-Z]\w*$/.test(root)) return false;
|
||||
let known = projectTypes.get(root);
|
||||
if (known === undefined) {
|
||||
known = false;
|
||||
for (const n of cg.getNodesByName(root)) {
|
||||
if (n.language !== 'swift' || !['class', 'struct', 'enum', 'interface', 'protocol'].includes(n.kind)) continue;
|
||||
if (!(await reader.swiftExtension(n))) {
|
||||
known = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
projectTypes.set(root, known);
|
||||
}
|
||||
return known;
|
||||
};
|
||||
const resolveByReceiver = async (caller: Node, callee: string): Promise<Node | null> => {
|
||||
const segments = callee.replace(/\([^()]*\)/g, '').split(/[.:]+/).filter(Boolean);
|
||||
if (segments.length < 2) return null;
|
||||
@@ -898,6 +920,7 @@ export async function buildSteps(cg: CodeGraph, projectRoot: string, query: URLS
|
||||
project,
|
||||
receiverType,
|
||||
args,
|
||||
projectType: fold.node.language === 'swift' && (await declaresSwiftType(text)),
|
||||
});
|
||||
}
|
||||
if (effect === null) return false;
|
||||
|
||||
+25
-10
@@ -12,7 +12,7 @@
|
||||
|
||||
import * as fs from 'fs';
|
||||
import type CodeGraph from '../../index';
|
||||
import type { Language } from '../../types';
|
||||
import type { Language, Node } from '../../types';
|
||||
import {
|
||||
callArgumentsForFile,
|
||||
callSitesForFile,
|
||||
@@ -33,6 +33,7 @@ import {
|
||||
type SiteTrigger,
|
||||
} from '../../graph/branch-guards';
|
||||
import { resolveProjectFile } from '../security';
|
||||
import { declaresExtension } from '../../resolution/swift-type-visibility';
|
||||
import { findIndexedFile, hasDriftedOnDisk } from './source';
|
||||
import type { WireEdge } from './wire';
|
||||
|
||||
@@ -130,6 +131,8 @@ export interface SiteReader {
|
||||
* action declared inline). Nothing for other languages or unreadable files.
|
||||
*/
|
||||
directive(definition: { filePath: string; language: Language; startLine: number }): Promise<{ file: 'server' | 'client' | null; own: boolean }>;
|
||||
/** A Swift `extension X {}` node — the index keeps it as a class, but it declares no type. */
|
||||
swiftExtension(definition: Pick<Node, 'filePath' | 'language' | 'kind' | 'startLine' | 'startColumn' | 'endLine'>): Promise<boolean>;
|
||||
}
|
||||
|
||||
const JS_FAMILY: ReadonlySet<string> = new Set(['javascript', 'typescript', 'tsx', 'jsx']);
|
||||
@@ -165,6 +168,19 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
|
||||
}
|
||||
return file;
|
||||
};
|
||||
/** A file's text, once, for the directive and declaration reads; null past the size cap or unreadable. */
|
||||
const readText = (abs: string): string | null => {
|
||||
let text = texts.get(abs);
|
||||
if (text === undefined) {
|
||||
try {
|
||||
text = fs.statSync(abs).size <= MAX_DIRECTIVE_FILE ? fs.readFileSync(abs, 'utf8') : null;
|
||||
} catch {
|
||||
text = null;
|
||||
}
|
||||
texts.set(abs, text);
|
||||
}
|
||||
return text;
|
||||
};
|
||||
// Named rather than a method, because `createWhenReader` hands `when` out
|
||||
// detached: it must not depend on `this`.
|
||||
const guards = async (
|
||||
@@ -243,15 +259,7 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
|
||||
if (!JS_FAMILY.has(definition.language)) return none;
|
||||
const file = resolve(definition);
|
||||
if (!file) return none;
|
||||
let text = texts.get(file.abs);
|
||||
if (text === undefined) {
|
||||
try {
|
||||
text = fs.statSync(file.abs).size <= MAX_DIRECTIVE_FILE ? fs.readFileSync(file.abs, 'utf8') : null;
|
||||
} catch {
|
||||
text = null;
|
||||
}
|
||||
texts.set(file.abs, text);
|
||||
}
|
||||
const text = readText(file.abs);
|
||||
if (text === null) return none;
|
||||
const head = FILE_DIRECTIVE.exec(text);
|
||||
const fileDirective = head ? (head[2] as 'server' | 'client') : null;
|
||||
@@ -262,6 +270,13 @@ export function createSiteReader(cg: CodeGraph, projectRoot: string, maxSites =
|
||||
}
|
||||
return { file: fileDirective, own };
|
||||
},
|
||||
async swiftExtension(definition) {
|
||||
if (definition.language !== 'swift' || definition.kind !== 'class') return false;
|
||||
const file = resolve(definition);
|
||||
if (!file) return false;
|
||||
const text = readText(file.abs);
|
||||
return text !== null && declaresExtension(text.split(/\r?\n/), definition);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user