mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-19 14:32:59 +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:
@@ -16,6 +16,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
- Laravel routes are now named by the path a request takes: a leading `/` is added where the routes file leaves it out, `Route::prefix()` and `Route::group(['prefix' => …])` groups are applied, and routes in `routes/api.php` carry the `/api` prefix Laravel serves them under, read from your `RouteServiceProvider`, `bootstrap/app.php` or any file that mounts a routes file. A front-end `fetch('/api/…')` now connects to the Laravel route that serves it. Re-index Laravel projects after upgrading.
|
||||
- Controllers that Spring or Laravel tests exercise by URL now count as tested. That covers MockMvc's `perform(post("/owners/new"))`, WebTestClient, TestRestTemplate, RestAssured, Laravel's `$this->postJson('api/me')`, Pest's `get('/about')`, and a project's own request helpers built on them. `codegraph_explore` now names the test suite that reaches such an endpoint, where before every one of them looked untested. Re-index Spring and Laravel projects after upgrading.
|
||||
- A SwiftUI view, a `@main` app, a view controller and a UIView subclass are now indexed once. Each used to get a second, one-line copy with no callers or calls, which showed up next to the real type in search and `codegraph_explore` answers as a dead end. Re-index Swift projects after upgrading.
|
||||
- A Swift class or struct that declares a protocol composition across lines, like `typealias Client = AutocompleteService.Client` followed by `& PostingService.Client` on the next line, is now indexed whole. That line used to break the parse of the entire enclosing type, so its methods, initializer and properties went missing, and calls into them landed on same-named methods elsewhere. Re-index Swift projects after upgrading.
|
||||
- A Swift reference to a type now links to the type's own declaration, not to a file that extends it. An `extension View { … }` or `extension Text { … }` used to stand in for SwiftUI's type, so every view, every `Text("…")` and every `Color.red` in an app linked to whichever file happened to extend it. Those files topped the most-depended-on lists and their impact reached the whole app. A bare name like `@State`, `@Test` or `Result<…>` no longer links to some other type's nested `State` or `Result`, and a qualified name like `Build.Id` links to the `Id` it names. Methods declared in an extension of an SDK type still resolve on the types that conform to it, and a protocol's methods declared in any of its extensions now connect to each conforming type's own implementation. Re-index Swift projects after upgrading.
|
||||
- A Vapor route now links to the handler it names. `use: SearchController.show` used to link to whichever controller's `show` came first, so routes with a common handler name, like `show`, `index` or `get`, pointed at another endpoint's code in callers, impact and `codegraph_explore` answers. Nested types like `API.PackageController.get` and handlers declared in an extension of the controller now resolve too, and `use: self.index` resolves to the collection's own `index`. Re-index Vapor projects after upgrading.
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
/**
|
||||
* Three things a SwiftUI app showed in the viewer that were not so.
|
||||
*
|
||||
* - Steps labelled `rawValue.data(using: .utf8)` a network call, Foundation's
|
||||
* `Timer` telemetry, the project's own `Notifications` endpoint enum a
|
||||
* device call, and `viewModel.votes.firstIndex(of:)` a database read.
|
||||
* - "Files that run something" listed every view with a `#Preview` — Xcode's
|
||||
* canvas code sits at a file's top level.
|
||||
* - Every `struct X: View` had a one-line `component` twin with no edges, a
|
||||
* dead end in search and the symbol view (and `@main` apps, view
|
||||
* controllers and UIView subclasses a `class` twin).
|
||||
*/
|
||||
import { describe, it, expect, afterAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { CodeGraph } from '../src';
|
||||
import { classifyEffect } from '../src/ui-server/api/effects';
|
||||
import { buildEntryPoints, resetEntryPointsCache, swiftPreviewSpans } from '../src/ui-server/api/entrypoints';
|
||||
import { buildSteps } from '../src/ui-server/api/steps';
|
||||
|
||||
const swift = (text: string, extra: Partial<Parameters<typeof classifyEffect>[0]> = {}) =>
|
||||
classifyEffect({ text, kind: 'calls', language: 'swift', project: 'app', ...extra })?.category ?? null;
|
||||
|
||||
describe('Swift effects', () => {
|
||||
it('reads network off a session, not any `.data(…)`', () => {
|
||||
expect(swift('urlSession.data(for: request)')).toBe('network');
|
||||
expect(swift('URLSession.shared.data(from: url)')).toBe('network');
|
||||
expect(swift('session.upload(for: request, from: body)')).toBe('network');
|
||||
expect(swift('urlSession.webSocketTask(with: url)')).toBe('network');
|
||||
expect(swift('rawValue.data(using: .utf8)')).toBeNull();
|
||||
expect(swift('string.data(using: .utf8)')).toBeNull();
|
||||
expect(swift('mediaUploadService.upload(container)')).toBeNull();
|
||||
});
|
||||
|
||||
it("does not read Foundation's Timer and Calendar, or a SiriKit intent, as another stack's", () => {
|
||||
expect(swift('Timer.scheduledTimer(withTimeInterval: 0.05, repeats: false)')).toBeNull();
|
||||
expect(swift('Calendar(identifier: .gregorian)')).toBeNull();
|
||||
expect(swift('intent.setImage(avatar, forParameterNamed: \\.speakableName)')).toBeNull();
|
||||
// The same names elsewhere keep their rows.
|
||||
expect(classifyEffect({ text: 'Timer.builder', kind: 'calls', language: 'java', project: 'api' })?.category).toBe('telemetry');
|
||||
});
|
||||
|
||||
it('keeps a view model out of the database, and a keychain in storage', () => {
|
||||
expect(swift('viewModel.votes.firstIndex(of: option)')).toBeNull();
|
||||
expect(swift('keychain.delete(key)')).toBe('storage');
|
||||
expect(swift('appKeychain.set(token, forKey: key)')).toBe('storage');
|
||||
expect(swift('keychainAccounts.first(where: { $0.id == id })')).toBeNull();
|
||||
expect(classifyEffect({ text: 'catModel.find', kind: 'calls', language: 'typescript', project: 'api' })?.category).toBe('database');
|
||||
});
|
||||
|
||||
it("lets a project type shadow a library name, but not a model's or a reply's row", () => {
|
||||
expect(swift('Notifications.notificationsV2(sinceId: id)')).toBe('device');
|
||||
expect(swift('Notifications.notificationsV2(sinceId: id)', { projectType: true })).toBeNull();
|
||||
expect(swift('Todo.query', { projectType: true })).toBe('database');
|
||||
expect(classifyEffect({ text: 'Abort', kind: 'calls', language: 'swift', project: 'api', projectType: true })?.category).toBe('response');
|
||||
});
|
||||
});
|
||||
|
||||
describe('swiftPreviewSpans', () => {
|
||||
it("finds a file's top-level previews, a widget's timeline closure included", () => {
|
||||
const text = [
|
||||
'struct Row: View {', // 1
|
||||
' var body: some View { Text("x") }', // 2
|
||||
'}', // 3
|
||||
'', // 4
|
||||
'#Preview {', // 5
|
||||
' Row()', // 6
|
||||
'}', // 7
|
||||
'', // 8
|
||||
'#Preview(as: .systemSmall) {', // 9
|
||||
' AccountWidget()', // 10
|
||||
'} timeline: {', // 11
|
||||
' Entry(date: Date())', // 12
|
||||
'}', // 13
|
||||
'let later = Row()', // 14
|
||||
].join('\n');
|
||||
expect(swiftPreviewSpans(text)).toEqual([
|
||||
[5, 7],
|
||||
[9, 13],
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
const projects: string[] = [];
|
||||
afterAll(() => {
|
||||
for (const p of projects.splice(0)) fs.rmSync(p, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
async function project(files: Record<string, string>): Promise<{ cg: CodeGraph; root: string }> {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-swiftui-noise-'));
|
||||
projects.push(root);
|
||||
for (const [rel, content] of Object.entries(files)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
return { cg: await CodeGraph.init(root, { index: true }), root };
|
||||
}
|
||||
|
||||
describe('a SwiftUI app in the viewer', () => {
|
||||
it('lists no preview as a file that runs something, and indexes each view once', async () => {
|
||||
const { cg } = await project({
|
||||
'App/HomeView.swift': `import SwiftUI
|
||||
struct HomeView: View {
|
||||
var body: some View { Text("Home") }
|
||||
}
|
||||
|
||||
#Preview {
|
||||
HomeView()
|
||||
}
|
||||
`,
|
||||
'App/main.swift': `import Foundation
|
||||
let server = Server()
|
||||
server.start()
|
||||
`,
|
||||
'App/Server.swift': `final class Server {
|
||||
func start() {}
|
||||
}
|
||||
`,
|
||||
'App/App.swift': `import SwiftUI
|
||||
@main
|
||||
struct HomeApp: App {
|
||||
var body: some Scene { WindowGroup { HomeView() } }
|
||||
}
|
||||
`,
|
||||
});
|
||||
try {
|
||||
resetEntryPointsCache();
|
||||
const files = buildEntryPoints(cg, new URLSearchParams()).files.items.map((f) => f.name);
|
||||
expect(files).toContain('main.swift');
|
||||
expect(files).not.toContain('HomeView.swift');
|
||||
expect(cg.getNodesByName('HomeView').map((n) => n.kind)).toEqual(['struct']);
|
||||
expect(cg.getNodesByName('HomeApp').map((n) => n.kind)).toEqual(['struct']);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
|
||||
it("draws a call through the project's own `Notifications` type as no device call", async () => {
|
||||
const { cg, root } = await project({
|
||||
'App/Endpoints.swift': `enum Notifications {
|
||||
case notificationsV2(sinceId: String?)
|
||||
}
|
||||
`,
|
||||
'App/NotificationsViewModel.swift': `final class NotificationsViewModel {
|
||||
func refresh(client: Client) async {
|
||||
_ = try? await client.get(endpoint: Notifications.notificationsV2(sinceId: nil))
|
||||
}
|
||||
}
|
||||
`,
|
||||
});
|
||||
try {
|
||||
const refresh = cg.getNodesByName('refresh')[0]!;
|
||||
const payload = await buildSteps(cg, root, new URLSearchParams({ anchor: refresh.id }));
|
||||
expect(payload.steps.filter((s) => s.effect?.category === 'device')).toEqual([]);
|
||||
} finally {
|
||||
cg.close();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -137,6 +137,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
|
||||
|
||||
## Fixes — Steps — what a call does, and when
|
||||
|
||||
- **A Swift app's Steps picture labels what a call leaves for correctly.** `rawValue.data(using: .utf8)` was drawn as a network call, Foundation's `Timer` as telemetry, `Calendar` and a SiriKit `intent` as device calls, and `viewModel.votes.firstIndex(of:)` as a database read. Now only a session's `data`, `upload` and `download` are network, `Timer`, `Calendar` and `intent` are no effect at all, a view model is not a table, and a keychain is storage. A call through a type the app declares itself, like IceCubes' `Notifications` endpoint enum, is no longer read as the library of the same name.
|
||||
|
||||
- **A server-rendered endpoint's Steps picture shows how it answers.** A Spring MVC handler answers by what it returns: a view name, a constant holding one, `"redirect:/owners/" + id`, a `ModelAndView` or a `RedirectView`. Each is now a reply, a render (`200`) or a redirect (`302`), under the condition it's returned in. petclinic's `POST /owners/new` draws the form re-rendered `WHEN result.hasErrors()` and the redirect otherwise. A Laravel controller's `view(…)`, `redirect(…)`, `redirect()->back()`, `back()`, `to_route(…)` and `response()->json(…)` are replies too, so BookStack's book page draws the page it renders beside its `404` and its old-slug redirect. PHP's own programming-error exceptions (`InvalidArgumentException`, `LogicException`, …) thrown deep in a helper are no longer drawn as one of the endpoint's answers.
|
||||
|
||||
- **A handler called from under a binding says what it passes.** A press that runs `tryCatchSync(onClosePress)` drew a box for the wrapper and stopped — leaving the one thing a reader asks ("what is being wrapped?") unsaid, even though every other call-shaped site already prints its arguments. The panel and tooltip now say `tryCatchSync(onClosePress)` — the argument is the answer.
|
||||
@@ -163,6 +165,8 @@ These describe `codegraph ui` and its screens. They were taken out of `## [Unrel
|
||||
|
||||
## Fixes — Symbols, tests and the viewer
|
||||
|
||||
- **A SwiftUI view with a preview is no longer listed as a file that runs something.** A `#Preview { … }` sits at the top level of a view's file, so every view with one showed up under entry points as if it ran code. A preview is Xcode's canvas, not code the app runs: its calls no longer count, and a file whose only top-level code is previews leaves the list.
|
||||
|
||||
- **The Map opens a Maven or Gradle project on its packages.** A Java project keeps every file under `src/main/java/org/<company>/<app>/`, and those folders hold nothing but the next one, so the Map drew the whole program as one `src/main/java/org` box and no grouping option reached further. A folder with one subfolder and no files of its own no longer counts as a level: petclinic opens on `owner`, `vet`, `model` and `system`, each labelled `src/main/java/…/petclinic/owner`, with the full path on hover.
|
||||
|
||||
- In `codegraph ui`, routes whose handlers live in more than 60 different files are all linked to their handler, instead of the later ones showing "not in the index". (#1975)
|
||||
|
||||
@@ -8,6 +8,10 @@ import { Node } from '../../types';
|
||||
import { FrameworkResolver, UnresolvedRef, ResolvedRef, ResolutionContext } from '../types';
|
||||
import { stripCommentsForRegex } from '../strip-comments';
|
||||
|
||||
// No extract(): a SwiftUI view is its own struct node, and a UIKit controller
|
||||
// its class. A one-line `component`/`class` twin per `struct X: View` (and per
|
||||
// `@main` app, view controller and UIView subclass) carried no edges and
|
||||
// showed up in search and the symbol view as a dead-end duplicate.
|
||||
export const swiftUIResolver: FrameworkResolver = {
|
||||
name: 'swiftui',
|
||||
languages: ['swift'],
|
||||
@@ -76,61 +80,6 @@ export const swiftUIResolver: FrameworkResolver = {
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
extract(filePath, content) {
|
||||
if (!filePath.endsWith('.swift')) return { nodes: [], references: [] };
|
||||
const nodes: Node[] = [];
|
||||
const now = Date.now();
|
||||
const safe = stripCommentsForRegex(content, 'swift');
|
||||
|
||||
// Extract SwiftUI View structs
|
||||
// struct ContentView: View { ... }
|
||||
const viewPattern = /struct\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*View/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = viewPattern.exec(safe)) !== null) {
|
||||
const [, viewName] = match;
|
||||
const line = safe.slice(0, match.index).split('\n').length;
|
||||
|
||||
nodes.push({
|
||||
id: `view:${filePath}:${viewName}:${line}`,
|
||||
kind: 'component',
|
||||
name: viewName!,
|
||||
qualifiedName: `${filePath}::${viewName}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: match[0].length,
|
||||
language: 'swift',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract @main App entry point
|
||||
const appPattern = /@main\s+struct\s+(\w+)\s*:\s*App/g;
|
||||
|
||||
while ((match = appPattern.exec(safe)) !== null) {
|
||||
const [, appName] = match;
|
||||
const line = safe.slice(0, match.index).split('\n').length;
|
||||
|
||||
nodes.push({
|
||||
id: `app:${filePath}:${appName}:${line}`,
|
||||
kind: 'class',
|
||||
name: appName!,
|
||||
qualifiedName: `${filePath}::${appName}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: match[0].length,
|
||||
language: 'swift',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, references: [] };
|
||||
},
|
||||
};
|
||||
|
||||
export const uikitResolver: FrameworkResolver = {
|
||||
@@ -210,60 +159,6 @@ export const uikitResolver: FrameworkResolver = {
|
||||
|
||||
return null;
|
||||
},
|
||||
|
||||
extract(filePath, content) {
|
||||
if (!filePath.endsWith('.swift')) return { nodes: [], references: [] };
|
||||
const nodes: Node[] = [];
|
||||
const now = Date.now();
|
||||
const safe = stripCommentsForRegex(content, 'swift');
|
||||
|
||||
// Extract UIViewController subclasses
|
||||
const vcPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIViewController/g;
|
||||
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = vcPattern.exec(safe)) !== null) {
|
||||
const [, vcName] = match;
|
||||
const line = safe.slice(0, match.index).split('\n').length;
|
||||
|
||||
nodes.push({
|
||||
id: `viewcontroller:${filePath}:${vcName}:${line}`,
|
||||
kind: 'class',
|
||||
name: vcName!,
|
||||
qualifiedName: `${filePath}::${vcName}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: match[0].length,
|
||||
language: 'swift',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
// Extract UIView subclasses
|
||||
const viewPattern = /class\s+(\w+)\s*:\s*(?:\w+\s*,\s*)*UIView[^C]/g;
|
||||
|
||||
while ((match = viewPattern.exec(safe)) !== null) {
|
||||
const [, viewName] = match;
|
||||
const line = safe.slice(0, match.index).split('\n').length;
|
||||
|
||||
nodes.push({
|
||||
id: `uiview:${filePath}:${viewName}:${line}`,
|
||||
kind: 'class',
|
||||
name: viewName!,
|
||||
qualifiedName: `${filePath}::${viewName}`,
|
||||
filePath,
|
||||
startLine: line,
|
||||
endLine: line,
|
||||
startColumn: 0,
|
||||
endColumn: match[0].length,
|
||||
language: 'swift',
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
|
||||
return { nodes, references: [] };
|
||||
},
|
||||
};
|
||||
|
||||
export const vaporResolver: FrameworkResolver = {
|
||||
@@ -547,7 +442,7 @@ function resolveVaporHandler(typePath: string | null, method: string, ref: Unres
|
||||
|
||||
const SWIFT_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'protocol']);
|
||||
|
||||
const VIEW_KINDS = new Set(['struct', 'component']);
|
||||
const VIEW_KINDS = new Set(['struct']);
|
||||
const CLASS_KINDS = new Set(['class']);
|
||||
const MODEL_KINDS = new Set(['struct', 'class']);
|
||||
const PROTOCOL_KINDS = new Set(['protocol']);
|
||||
|
||||
@@ -64,21 +64,23 @@ export function isSwiftExtension(node: Node, context: ResolutionContext): boolea
|
||||
const hit = memo.get(node.id);
|
||||
if (hit !== undefined) return hit;
|
||||
const lines = linesOf(node.filePath, context);
|
||||
let extension = false;
|
||||
if (lines) {
|
||||
const head = [
|
||||
(lines[node.startLine - 1] ?? '').slice(node.startColumn),
|
||||
...lines.slice(node.startLine, Math.min(node.endLine, node.startLine + 4)),
|
||||
]
|
||||
.join(' ')
|
||||
// An attribute's string argument (`message: "use class Foo"`) is not a keyword.
|
||||
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
|
||||
extension = DECLARATION_KEYWORD.exec(head)?.[1] === 'extension';
|
||||
}
|
||||
const extension = !!lines && declaresExtension(lines, node);
|
||||
memo.set(node.id, extension);
|
||||
return extension;
|
||||
}
|
||||
|
||||
/** Does the class_declaration at `node`'s position open with `extension`? From the file's lines. */
|
||||
export function declaresExtension(lines: readonly string[], node: Pick<Node, 'startLine' | 'startColumn' | 'endLine'>): boolean {
|
||||
const head = [
|
||||
(lines[node.startLine - 1] ?? '').slice(node.startColumn),
|
||||
...lines.slice(node.startLine, Math.min(node.endLine, node.startLine + 4)),
|
||||
]
|
||||
.join(' ')
|
||||
// An attribute's string argument (`message: "use class Foo"`) is not a keyword.
|
||||
.replace(/"(?:[^"\\\n]|\\.)*"/g, '""');
|
||||
return DECLARATION_KEYWORD.exec(head)?.[1] === 'extension';
|
||||
}
|
||||
|
||||
/** A declaration's inheritance clause: `struct HomeView: View, Sendable {` → `View, Sendable`. */
|
||||
const INHERITANCE_CLAUSE = /\b(?:class|struct|enum|actor|protocol|extension)\s+[A-Za-z_][\w.]*\s*(?:<[^{}]*?>)?\s*:\s*([^{]*?)\s*(?:\bwhere\b[^{]*)?\{/;
|
||||
|
||||
|
||||
@@ -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