@skyramp/mcp 0.2.1 → 0.2.2

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.
@@ -0,0 +1,319 @@
1
+ /**
2
+ * Dart route extraction for Flutter apps using the `go_router` package.
3
+ *
4
+ * Walks the repo's Dart sources looking for GoRoute declarations with
5
+ * string-literal `path:` arguments:
6
+ *
7
+ * GoRoute(path: '/login', builder: (ctx, st) => LoginScreen())
8
+ * GoRoute(path: "/profile/:id", builder: ...)
9
+ *
10
+ * Why regex (not a Dart compiler): Dart parsers (analyzer, dart-analyzer
11
+ * package) require a Dart SDK install. Regex on the GoRoute string-literal-path
12
+ * subset gives ~95% coverage of real customer apps with zero install cost.
13
+ * Constants like `path: _kHome` are deliberately skipped — emitting them
14
+ * as a URL `_kHome` would mislead the agent worse than dropping them.
15
+ *
16
+ * Each route also carries metadata to support diff-matching in the caller
17
+ * (findCandidatePagesByDartRoute):
18
+ *
19
+ * - `screenWidgets`: identifiers referenced as builder/pageBuilder targets,
20
+ * e.g. `AuthorsScreen`. Resolved through the file's imports to absolute
21
+ * paths in `screenFiles`.
22
+ * - `screenFiles`: the imported source files those identifiers come from.
23
+ * - `isRedirectOnly`: true if the GoRoute has `redirect:` but no
24
+ * `builder:` or `pageBuilder:` — these don't render UI, just redirect.
25
+ *
26
+ * Scope choices for slice 1:
27
+ * - GoRouter only (`MaterialApp.routes` and `auto_route` deferred)
28
+ * - String-literal `path:` only (no constant resolution)
29
+ * - Nested GoRoutes emit each declaration verbatim — the absolute parent
30
+ * plus relative children. Caller may compose them later.
31
+ */
32
+ import * as fs from "fs";
33
+ import * as path from "path";
34
+ const MAX_CANDIDATE_FILES = 200;
35
+ const MAX_WALK_DEPTH = 10;
36
+ const SKIP_DIRS = new Set([
37
+ "build",
38
+ ".dart_tool",
39
+ ".git",
40
+ ".idea",
41
+ "android",
42
+ "ios",
43
+ "macos",
44
+ "linux",
45
+ "windows",
46
+ "web",
47
+ "node_modules",
48
+ "test",
49
+ ".pub-cache",
50
+ ]);
51
+ /**
52
+ * Match a GoRoute declaration's body — from the opening `(` through to its
53
+ * matching `)`. We need the body to extract `path:`, screen widgets, and
54
+ * the redirect/builder distinction.
55
+ *
56
+ * The regex captures the literal string after `path:` and the start of the
57
+ * GoRoute call body; we then walk forward manually to find the matching
58
+ * close paren (regex can't match balanced parens reliably).
59
+ */
60
+ const GO_ROUTE_PATH_REGEX = /\bGoRoute\s*\(\s*(?:<[^>]+>\s*)?[\s\S]*?path\s*:\s*(['"])([^'"\\]*)\1/g;
61
+ /** Identifier reference inside builder:/pageBuilder: bodies — `AuthorsScreen`, `Authors`. */
62
+ const SCREEN_WIDGET_REGEX = /\b([A-Z][A-Za-z0-9_]*)\s*\(/g;
63
+ /** Top-level Dart import: `import 'src/screens/authors.dart';` or `package:foo/bar.dart`. */
64
+ const IMPORT_REGEX = /^\s*import\s+['"]([^'"]+)['"]/gm;
65
+ /** Identifiers we never treat as screen widgets — Flutter framework + go_router internals. */
66
+ const NON_SCREEN_IDENTIFIERS = new Set([
67
+ "BuildContext", "GoRouterState", "GoRoute", "GoRouter", "ShellRoute",
68
+ "StatefulShellRoute", "ShellRouteBase", "MaterialPage", "CupertinoPage",
69
+ "CustomTransitionPage", "FadeTransitionPage", "Page", "Widget", "Key",
70
+ "ValueKey", "LocalKey", "Future", "Animation", "Duration",
71
+ "MaterialApp", "CupertinoApp", "Scaffold", "AppBar",
72
+ ]);
73
+ export function extractDartRoutes(repositoryPath) {
74
+ const candidates = findDartFiles(repositoryPath);
75
+ const seen = new Set();
76
+ const routes = [];
77
+ for (const file of candidates) {
78
+ let source;
79
+ try {
80
+ source = fs.readFileSync(file, "utf-8");
81
+ }
82
+ catch {
83
+ continue;
84
+ }
85
+ const imports = collectImports(source, file, repositoryPath);
86
+ GO_ROUTE_PATH_REGEX.lastIndex = 0;
87
+ let match;
88
+ while ((match = GO_ROUTE_PATH_REGEX.exec(source)) !== null) {
89
+ const pathValue = match[2];
90
+ if (seen.has(pathValue))
91
+ continue;
92
+ seen.add(pathValue);
93
+ const body = extractGoRouteBody(source, match.index);
94
+ const inspect = inspectGoRouteBody(body);
95
+ const screenFiles = [];
96
+ for (const widget of inspect.screenWidgets) {
97
+ const resolved = imports.get(widget);
98
+ if (resolved)
99
+ screenFiles.push(resolved);
100
+ }
101
+ routes.push({
102
+ path: pathValue,
103
+ declaredIn: file,
104
+ screenWidgets: inspect.screenWidgets,
105
+ screenFiles,
106
+ isRedirectOnly: inspect.isRedirectOnly,
107
+ });
108
+ }
109
+ }
110
+ return routes;
111
+ }
112
+ /**
113
+ * Starting at `start` (the index of the matched `GoRoute` token), find the
114
+ * end of the GoRoute call by counting balanced `(` / `)`. Stops at the
115
+ * matching close paren of the outermost GoRoute(...). Strings are skipped
116
+ * so we don't mis-count a `)` inside a string literal.
117
+ *
118
+ * Returns the call body (between the outer parens) — empty string on failure.
119
+ */
120
+ function extractGoRouteBody(source, start) {
121
+ // Find the first '(' after `GoRoute`
122
+ const openParen = source.indexOf("(", start);
123
+ if (openParen < 0)
124
+ return "";
125
+ let depth = 1;
126
+ let i = openParen + 1;
127
+ let inString = null;
128
+ while (i < source.length && depth > 0) {
129
+ const ch = source[i];
130
+ if (inString) {
131
+ if (ch === "\\") {
132
+ i += 2;
133
+ continue;
134
+ }
135
+ if (ch === inString)
136
+ inString = null;
137
+ }
138
+ else {
139
+ if (ch === '"' || ch === "'")
140
+ inString = ch;
141
+ else if (ch === "(")
142
+ depth++;
143
+ else if (ch === ")")
144
+ depth--;
145
+ }
146
+ i++;
147
+ }
148
+ if (depth !== 0)
149
+ return "";
150
+ return source.slice(openParen + 1, i - 1);
151
+ }
152
+ /**
153
+ * Inspect a GoRoute body to find:
154
+ * - screen widget identifiers from builder:/pageBuilder:
155
+ * - whether the route is redirect-only (no builder/pageBuilder)
156
+ *
157
+ * The `routes:` nested GoRoute siblings are stripped before scanning so
158
+ * we don't pick up child route widgets as part of the parent's screens.
159
+ */
160
+ function inspectGoRouteBody(body) {
161
+ const builderPart = extractKeyValue(body, "builder");
162
+ const pageBuilderPart = extractKeyValue(body, "pageBuilder");
163
+ const redirectPart = extractKeyValue(body, "redirect");
164
+ const screenWidgets = [];
165
+ for (const part of [builderPart, pageBuilderPart]) {
166
+ if (!part)
167
+ continue;
168
+ SCREEN_WIDGET_REGEX.lastIndex = 0;
169
+ let m;
170
+ while ((m = SCREEN_WIDGET_REGEX.exec(part)) !== null) {
171
+ const id = m[1];
172
+ if (NON_SCREEN_IDENTIFIERS.has(id))
173
+ continue;
174
+ if (!screenWidgets.includes(id))
175
+ screenWidgets.push(id);
176
+ }
177
+ }
178
+ const hasBuilder = !!builderPart || !!pageBuilderPart;
179
+ const hasRedirect = !!redirectPart;
180
+ const isRedirectOnly = hasRedirect && !hasBuilder;
181
+ return { screenWidgets, isRedirectOnly };
182
+ }
183
+ /**
184
+ * Find a `key:` value in a GoRoute body. Returns the substring after `key:`
185
+ * up to the next top-level comma (depth 0 brace/paren/bracket), or `null`
186
+ * when the key isn't present. Strings and nested groups are skipped.
187
+ */
188
+ function extractKeyValue(body, key) {
189
+ const re = new RegExp(`\\b${key}\\s*:`, "g");
190
+ const m = re.exec(body);
191
+ if (!m)
192
+ return null;
193
+ let i = m.index + m[0].length;
194
+ // Skip whitespace
195
+ while (i < body.length && /\s/.test(body[i]))
196
+ i++;
197
+ const start = i;
198
+ let depth = 0;
199
+ let inString = null;
200
+ while (i < body.length) {
201
+ const ch = body[i];
202
+ if (inString) {
203
+ if (ch === "\\") {
204
+ i += 2;
205
+ continue;
206
+ }
207
+ if (ch === inString)
208
+ inString = null;
209
+ }
210
+ else {
211
+ if (ch === '"' || ch === "'")
212
+ inString = ch;
213
+ else if (ch === "(" || ch === "[" || ch === "{")
214
+ depth++;
215
+ else if (ch === ")" || ch === "]" || ch === "}") {
216
+ if (depth === 0)
217
+ break;
218
+ depth--;
219
+ }
220
+ else if (ch === "," && depth === 0)
221
+ break;
222
+ }
223
+ i++;
224
+ }
225
+ return body.slice(start, i).trim();
226
+ }
227
+ /**
228
+ * Build a `local-name → absolute-file-path` map from the file's imports.
229
+ *
230
+ * Dart imports two ways:
231
+ * import 'src/screens/authors.dart'; → relative within current package
232
+ * import 'package:foo/screens/x.dart'; → cross-package, not resolved here
233
+ *
234
+ * We only resolve relative imports (the common case for in-app screens).
235
+ * The "local name" for a relative import is taken as ALL identifiers that
236
+ * the file exports — but since we don't parse the imported file's exports,
237
+ * we use a coarse approximation: the importing file's `import 'X.dart';`
238
+ * makes available any PascalCase identifier referenced from the file's
239
+ * scope. We map every identifier we see in builder/pageBuilder bodies to
240
+ * the union of relative imports — first matching one wins.
241
+ *
242
+ * For the common Flutter pattern (one screen widget per screen file with
243
+ * a matching name like `AuthorsScreen`), we approximate via filename:
244
+ * import 'src/screens/authors.dart' → expose `AuthorsScreen`, `Authors`
245
+ *
246
+ * False positives on this approximation just mean we surface a route that
247
+ * shouldn't have been; false negatives just mean we fall back to "no
248
+ * diff-match" and surface all routes (the previous behavior).
249
+ */
250
+ function collectImports(source, filePath, repoPath) {
251
+ const result = new Map();
252
+ const fileDir = path.dirname(filePath);
253
+ IMPORT_REGEX.lastIndex = 0;
254
+ let match;
255
+ while ((match = IMPORT_REGEX.exec(source)) !== null) {
256
+ const spec = match[1];
257
+ if (spec.startsWith("package:") || spec.startsWith("dart:"))
258
+ continue;
259
+ if (!spec.endsWith(".dart"))
260
+ continue;
261
+ const resolved = path.resolve(fileDir, spec);
262
+ if (!resolved.startsWith(repoPath))
263
+ continue;
264
+ // Filename-based heuristic: `authors.dart` exposes `Authors`, `AuthorsScreen`,
265
+ // `AuthorsScreenState`, etc. We register a few common shapes derived from
266
+ // the file's basename. Conflicts (the same identifier present in two
267
+ // imports) resolve to the first registration.
268
+ const base = path.basename(spec, ".dart");
269
+ const camel = snakeToCamel(base);
270
+ const candidates = [
271
+ camel,
272
+ camel + "Screen",
273
+ camel + "Page",
274
+ camel + "View",
275
+ ];
276
+ for (const id of candidates) {
277
+ if (!result.has(id))
278
+ result.set(id, resolved);
279
+ }
280
+ }
281
+ return result;
282
+ }
283
+ function snakeToCamel(s) {
284
+ return s
285
+ .split("_")
286
+ .map((p, i) => p.charAt(0).toUpperCase() + p.slice(1))
287
+ .join("");
288
+ }
289
+ function findDartFiles(repoPath) {
290
+ const matches = [];
291
+ function walk(dir, depth) {
292
+ if (depth > MAX_WALK_DEPTH)
293
+ return;
294
+ if (matches.length >= MAX_CANDIDATE_FILES)
295
+ return;
296
+ let entries;
297
+ try {
298
+ entries = fs.readdirSync(dir, { withFileTypes: true });
299
+ }
300
+ catch {
301
+ return;
302
+ }
303
+ for (const entry of entries) {
304
+ if (matches.length >= MAX_CANDIDATE_FILES)
305
+ return;
306
+ const full = path.join(dir, entry.name);
307
+ if (entry.isDirectory()) {
308
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith("."))
309
+ continue;
310
+ walk(full, depth + 1);
311
+ }
312
+ else if (entry.isFile() && entry.name.endsWith(".dart")) {
313
+ matches.push(full);
314
+ }
315
+ }
316
+ }
317
+ walk(repoPath, 0);
318
+ return matches;
319
+ }
@@ -0,0 +1,307 @@
1
+ // No external mocks needed — the extractor is pure fs + regex.
2
+ import * as fs from "fs";
3
+ import * as os from "os";
4
+ import * as path from "path";
5
+ import { extractDartRoutes } from "./dartRouteExtractor.js";
6
+ function makeTmpRepo(files) {
7
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "skyramp-dart-route-test-"));
8
+ for (const f of files) {
9
+ const fullPath = path.join(dir, f.path);
10
+ fs.mkdirSync(path.dirname(fullPath), { recursive: true });
11
+ fs.writeFileSync(fullPath, f.content);
12
+ }
13
+ return dir;
14
+ }
15
+ function cleanup(dir) {
16
+ fs.rmSync(dir, { recursive: true, force: true });
17
+ }
18
+ describe("extractDartRoutes", () => {
19
+ it("returns empty when repo has no .dart files", () => {
20
+ const repo = makeTmpRepo([
21
+ { path: "lib/main.dart", content: "void main() => runApp(MyApp());" },
22
+ ]);
23
+ expect(extractDartRoutes(repo)).toEqual([]);
24
+ cleanup(repo);
25
+ });
26
+ it("extracts a single GoRoute with double-quoted path", () => {
27
+ const repo = makeTmpRepo([
28
+ {
29
+ path: "lib/router.dart",
30
+ content: `
31
+ import 'package:go_router/go_router.dart';
32
+
33
+ final router = GoRouter(routes: [
34
+ GoRoute(path: "/login", builder: (ctx, st) => LoginScreen()),
35
+ ]);
36
+ `,
37
+ },
38
+ ]);
39
+ const routes = extractDartRoutes(repo);
40
+ expect(routes).toHaveLength(1);
41
+ expect(routes[0].path).toBe("/login");
42
+ expect(routes[0].declaredIn).toMatch(/router\.dart$/);
43
+ cleanup(repo);
44
+ });
45
+ it("extracts a single GoRoute with single-quoted path", () => {
46
+ const repo = makeTmpRepo([
47
+ {
48
+ path: "lib/app.dart",
49
+ content: `GoRoute(path: '/home', builder: (c, s) => Home())`,
50
+ },
51
+ ]);
52
+ const routes = extractDartRoutes(repo);
53
+ expect(routes).toHaveLength(1);
54
+ expect(routes[0].path).toBe("/home");
55
+ cleanup(repo);
56
+ });
57
+ it("extracts multiple GoRoutes from one file", () => {
58
+ const repo = makeTmpRepo([
59
+ {
60
+ path: "lib/router.dart",
61
+ content: `
62
+ final router = GoRouter(routes: [
63
+ GoRoute(path: '/login', builder: (c, s) => LoginScreen()),
64
+ GoRoute(path: '/dashboard', builder: (c, s) => DashboardScreen()),
65
+ GoRoute(path: '/profile/:id', builder: (c, s) => ProfileScreen()),
66
+ ]);
67
+ `,
68
+ },
69
+ ]);
70
+ const routes = extractDartRoutes(repo);
71
+ expect(routes.map((r) => r.path).sort()).toEqual([
72
+ "/dashboard",
73
+ "/login",
74
+ "/profile/:id",
75
+ ]);
76
+ cleanup(repo);
77
+ });
78
+ it("extracts nested GoRoutes (sub-routes inside parent)", () => {
79
+ const repo = makeTmpRepo([
80
+ {
81
+ path: "lib/router.dart",
82
+ content: `
83
+ GoRoute(
84
+ path: '/settings',
85
+ builder: (c, s) => SettingsScreen(),
86
+ routes: [
87
+ GoRoute(path: 'account', builder: (c, s) => AccountScreen()),
88
+ GoRoute(path: 'privacy', builder: (c, s) => PrivacyScreen()),
89
+ ],
90
+ );
91
+ `,
92
+ },
93
+ ]);
94
+ const routes = extractDartRoutes(repo);
95
+ expect(routes.map((r) => r.path).sort()).toEqual([
96
+ "/settings",
97
+ "account",
98
+ "privacy",
99
+ ]);
100
+ cleanup(repo);
101
+ });
102
+ it("walks lib/ and skips build, .dart_tool, ios, android, macos, web", () => {
103
+ const repo = makeTmpRepo([
104
+ {
105
+ path: "lib/router.dart",
106
+ content: `GoRoute(path: '/real', builder: (c, s) => X())`,
107
+ },
108
+ {
109
+ path: "build/app/intermediates/router.dart",
110
+ content: `GoRoute(path: '/build-fake', builder: (c, s) => X())`,
111
+ },
112
+ {
113
+ path: ".dart_tool/something.dart",
114
+ content: `GoRoute(path: '/tool-fake', builder: (c, s) => X())`,
115
+ },
116
+ {
117
+ path: "ios/Generated.dart",
118
+ content: `GoRoute(path: '/ios-fake', builder: (c, s) => X())`,
119
+ },
120
+ ]);
121
+ const routes = extractDartRoutes(repo);
122
+ expect(routes.map((r) => r.path)).toEqual(["/real"]);
123
+ cleanup(repo);
124
+ });
125
+ it("ignores GoRoute calls without a string-literal path (constants, expressions)", () => {
126
+ const repo = makeTmpRepo([
127
+ {
128
+ path: "lib/router.dart",
129
+ content: `
130
+ const _kHome = '/home';
131
+ final router = GoRouter(routes: [
132
+ GoRoute(path: _kHome, builder: (c, s) => H()),
133
+ GoRoute(path: '/login', builder: (c, s) => L()),
134
+ ]);
135
+ `,
136
+ },
137
+ ]);
138
+ const routes = extractDartRoutes(repo);
139
+ expect(routes.map((r) => r.path)).toEqual(["/login"]);
140
+ cleanup(repo);
141
+ });
142
+ it("returns empty for non-Flutter Dart files (no GoRoute calls)", () => {
143
+ const repo = makeTmpRepo([
144
+ {
145
+ path: "bin/server.dart",
146
+ content: `
147
+ void main() async {
148
+ final server = await HttpServer.bind('localhost', 8080);
149
+ await for (final req in server) { /* ... */ }
150
+ }
151
+ `,
152
+ },
153
+ ]);
154
+ expect(extractDartRoutes(repo)).toEqual([]);
155
+ cleanup(repo);
156
+ });
157
+ it("deduplicates identical GoRoute paths declared multiple times", () => {
158
+ const repo = makeTmpRepo([
159
+ {
160
+ path: "lib/a.dart",
161
+ content: `GoRoute(path: '/login', builder: (c, s) => A())`,
162
+ },
163
+ {
164
+ path: "lib/b.dart",
165
+ content: `GoRoute(path: '/login', builder: (c, s) => B())`,
166
+ },
167
+ ]);
168
+ const routes = extractDartRoutes(repo);
169
+ expect(routes).toHaveLength(1);
170
+ expect(routes[0].path).toBe("/login");
171
+ cleanup(repo);
172
+ });
173
+ // Real-world: extract all routes from the books example we built locally.
174
+ // This guards the regex against the actual customer-style GoRouter shape
175
+ // — multi-line declarations with redirect+pageBuilder mixed in.
176
+ it("extracts the books example's six routes", () => {
177
+ const repo = makeTmpRepo([
178
+ {
179
+ path: "lib/main.dart",
180
+ content: `
181
+ import 'package:go_router/go_router.dart';
182
+
183
+ late final GoRouter _router = GoRouter(
184
+ routes: <GoRoute>[
185
+ GoRoute(path: '/', redirect: (_, _) => '/books'),
186
+ GoRoute(
187
+ path: '/signin',
188
+ pageBuilder: (BuildContext context, GoRouterState state) => SignIn(),
189
+ ),
190
+ GoRoute(path: '/books', redirect: (_, _) => '/books/popular'),
191
+ GoRoute(
192
+ path: '/book/:bookId',
193
+ redirect: (BuildContext context, GoRouterState state) =>
194
+ '/books/all/' + state.pathParameters['bookId']!,
195
+ ),
196
+ GoRoute(
197
+ path: '/books/:kind(new|all|popular)',
198
+ pageBuilder: (BuildContext context, GoRouterState state) => Books(),
199
+ routes: <GoRoute>[
200
+ GoRoute(
201
+ path: ':bookId',
202
+ builder: (BuildContext context, GoRouterState state) => BookDetails(),
203
+ ),
204
+ ],
205
+ ),
206
+ GoRoute(
207
+ path: '/author/:authorId',
208
+ redirect: (BuildContext context, GoRouterState state) =>
209
+ '/authors/' + state.pathParameters['authorId']!,
210
+ ),
211
+ GoRoute(
212
+ path: '/authors',
213
+ pageBuilder: (BuildContext context, GoRouterState state) => Authors(),
214
+ routes: <GoRoute>[
215
+ GoRoute(
216
+ path: ':authorId',
217
+ builder: (BuildContext context, GoRouterState state) => AuthorDetails(),
218
+ ),
219
+ ],
220
+ ),
221
+ GoRoute(
222
+ path: '/settings',
223
+ pageBuilder: (BuildContext context, GoRouterState state) => Settings(),
224
+ ),
225
+ ],
226
+ );
227
+ `,
228
+ },
229
+ ]);
230
+ const routes = extractDartRoutes(repo);
231
+ const paths = routes.map((r) => r.path).sort();
232
+ // Top-level absolute paths.
233
+ expect(paths).toEqual([
234
+ "/",
235
+ "/author/:authorId",
236
+ "/authors",
237
+ "/book/:bookId",
238
+ "/books",
239
+ "/books/:kind(new|all|popular)",
240
+ "/settings",
241
+ "/signin",
242
+ ":authorId",
243
+ ":bookId",
244
+ ].sort());
245
+ cleanup(repo);
246
+ });
247
+ // ── New shape: screenWidgets, screenFiles, isRedirectOnly ─────────────────
248
+ it("flags redirect-only routes (no builder/pageBuilder)", () => {
249
+ const repo = makeTmpRepo([
250
+ {
251
+ path: "lib/router.dart",
252
+ content: `
253
+ GoRoute(path: '/', redirect: (_, _) => '/home'),
254
+ GoRoute(path: '/home', builder: (c, s) => Home()),
255
+ `,
256
+ },
257
+ ]);
258
+ const routes = extractDartRoutes(repo);
259
+ const root = routes.find((r) => r.path === "/");
260
+ const home = routes.find((r) => r.path === "/home");
261
+ expect(root?.isRedirectOnly).toBe(true);
262
+ expect(home?.isRedirectOnly).toBe(false);
263
+ cleanup(repo);
264
+ });
265
+ it("captures screen widget identifiers from builder/pageBuilder", () => {
266
+ const repo = makeTmpRepo([
267
+ {
268
+ path: "lib/router.dart",
269
+ content: `
270
+ GoRoute(path: '/login', builder: (c, s) => LoginScreen()),
271
+ GoRoute(path: '/profile', pageBuilder: (c, s) => MaterialPage(child: ProfileScreen())),
272
+ `,
273
+ },
274
+ ]);
275
+ const routes = extractDartRoutes(repo);
276
+ const login = routes.find((r) => r.path === "/login");
277
+ const profile = routes.find((r) => r.path === "/profile");
278
+ expect(login?.screenWidgets).toContain("LoginScreen");
279
+ expect(profile?.screenWidgets).toContain("ProfileScreen");
280
+ // Ignored framework identifiers should NOT appear.
281
+ expect(login?.screenWidgets).not.toContain("BuildContext");
282
+ expect(profile?.screenWidgets).not.toContain("MaterialPage");
283
+ cleanup(repo);
284
+ });
285
+ it("resolves screenWidgets to imported file paths via filename heuristic", () => {
286
+ const repo = makeTmpRepo([
287
+ {
288
+ path: "lib/main.dart",
289
+ content: `
290
+ import 'src/screens/authors.dart';
291
+ import 'src/screens/settings.dart';
292
+
293
+ GoRoute(path: '/authors', builder: (c, s) => AuthorsScreen()),
294
+ GoRoute(path: '/settings', builder: (c, s) => SettingsScreen()),
295
+ `,
296
+ },
297
+ { path: "lib/src/screens/authors.dart", content: "// AuthorsScreen widget" },
298
+ { path: "lib/src/screens/settings.dart", content: "// SettingsScreen widget" },
299
+ ]);
300
+ const routes = extractDartRoutes(repo);
301
+ const authors = routes.find((r) => r.path === "/authors");
302
+ const settings = routes.find((r) => r.path === "/settings");
303
+ expect(authors?.screenFiles[0]).toMatch(/lib\/src\/screens\/authors\.dart$/);
304
+ expect(settings?.screenFiles[0]).toMatch(/lib\/src\/screens\/settings\.dart$/);
305
+ cleanup(repo);
306
+ });
307
+ });