@screenbook/cli 1.4.0 → 1.6.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.
package/dist/index.mjs CHANGED
@@ -1,17 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { createRequire } from "node:module";
3
3
  import { copyFileSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
4
- import { basename, dirname, join, relative, resolve } from "node:path";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { cli, define } from "gunshi";
7
- import { createJiti } from "jiti";
8
7
  import { glob } from "tinyglobby";
9
8
  import { defineConfig } from "@screenbook/core";
9
+ import { createJiti } from "jiti";
10
10
  import pc from "picocolors";
11
11
  import { execSync, spawn } from "node:child_process";
12
12
  import prompts from "prompts";
13
13
  import { parse } from "@babel/parser";
14
+ import { Parser } from "htmlparser2";
15
+ import { NodeTypes } from "@vue/compiler-core";
16
+ import { parse as parse$1 } from "@vue/compiler-sfc";
14
17
  import { minimatch } from "minimatch";
18
+ import SwaggerParser from "@apidevtools/swagger-parser";
15
19
 
16
20
  //#region src/utils/config.ts
17
21
  const CONFIG_FILES$1 = [
@@ -38,129 +42,6 @@ async function importConfig(absolutePath, cwd) {
38
42
  throw new Error(`Config file must have a default export: ${absolutePath}`);
39
43
  }
40
44
 
41
- //#endregion
42
- //#region src/utils/cycleDetection.ts
43
- var Color = /* @__PURE__ */ function(Color$1) {
44
- Color$1[Color$1["White"] = 0] = "White";
45
- Color$1[Color$1["Gray"] = 1] = "Gray";
46
- Color$1[Color$1["Black"] = 2] = "Black";
47
- return Color$1;
48
- }(Color || {});
49
- /**
50
- * Detect circular navigation dependencies in screen definitions.
51
- * Uses DFS with coloring algorithm: O(V + E) complexity.
52
- *
53
- * @example
54
- * ```ts
55
- * const screens = [
56
- * { id: "A", next: ["B"] },
57
- * { id: "B", next: ["C"] },
58
- * { id: "C", next: ["A"] }, // Creates cycle A → B → C → A
59
- * ]
60
- * const result = detectCycles(screens)
61
- * // result.hasCycles === true
62
- * // result.cycles[0].cycle === ["A", "B", "C", "A"]
63
- * ```
64
- */
65
- function detectCycles(screens) {
66
- const screenMap = /* @__PURE__ */ new Map();
67
- const duplicateIds = [];
68
- for (const screen of screens) {
69
- if (!screen.id || typeof screen.id !== "string") continue;
70
- if (screenMap.has(screen.id)) duplicateIds.push(screen.id);
71
- screenMap.set(screen.id, screen);
72
- }
73
- const color = /* @__PURE__ */ new Map();
74
- const parent = /* @__PURE__ */ new Map();
75
- const cycles = [];
76
- for (const id of screenMap.keys()) color.set(id, Color.White);
77
- for (const id of screenMap.keys()) if (color.get(id) === Color.White) dfs(id, null);
78
- function dfs(nodeId, parentId) {
79
- color.set(nodeId, Color.Gray);
80
- parent.set(nodeId, parentId);
81
- const neighbors = screenMap.get(nodeId)?.next ?? [];
82
- for (const neighborId of neighbors) {
83
- const neighborColor = color.get(neighborId);
84
- if (neighborColor === Color.Gray) {
85
- const cyclePath = reconstructCycle(nodeId, neighborId);
86
- const allowed = isCycleAllowed(cyclePath, screenMap);
87
- cycles.push({
88
- cycle: cyclePath,
89
- allowed
90
- });
91
- } else if (neighborColor === Color.White) {
92
- if (screenMap.has(neighborId)) dfs(neighborId, nodeId);
93
- }
94
- }
95
- color.set(nodeId, Color.Black);
96
- }
97
- /**
98
- * Reconstruct cycle path from back edge
99
- */
100
- function reconstructCycle(from, to) {
101
- const path = [];
102
- let current = from;
103
- const visited = /* @__PURE__ */ new Set();
104
- const maxIterations = screenMap.size + 1;
105
- while (current && current !== to && !visited.has(current) && path.length < maxIterations) {
106
- visited.add(current);
107
- path.unshift(current);
108
- current = parent.get(current);
109
- }
110
- path.unshift(to);
111
- path.push(to);
112
- return path;
113
- }
114
- const disallowedCycles = cycles.filter((c) => !c.allowed);
115
- return {
116
- hasCycles: cycles.length > 0,
117
- cycles,
118
- disallowedCycles,
119
- duplicateIds
120
- };
121
- }
122
- /**
123
- * Check if any screen in the cycle has allowCycles: true
124
- */
125
- function isCycleAllowed(cyclePath, screenMap) {
126
- const uniqueNodes = cyclePath.slice(0, -1);
127
- for (const nodeId of uniqueNodes) if (screenMap.get(nodeId)?.allowCycles === true) return true;
128
- return false;
129
- }
130
- /**
131
- * Format cycle information for console output
132
- *
133
- * @example
134
- * ```
135
- * Cycle 1: A → B → C → A
136
- * Cycle 2 (allowed): D → E → D
137
- * ```
138
- */
139
- function formatCycleWarnings(cycles) {
140
- if (cycles.length === 0) return "";
141
- const lines = [];
142
- for (let i = 0; i < cycles.length; i++) {
143
- const cycle = cycles[i];
144
- if (!cycle) continue;
145
- const cycleStr = cycle.cycle.join(" → ");
146
- const allowedSuffix = cycle.allowed ? " (allowed)" : "";
147
- lines.push(` Cycle ${i + 1}${allowedSuffix}: ${cycleStr}`);
148
- }
149
- return lines.join("\n");
150
- }
151
- /**
152
- * Get a summary of cycle detection results
153
- */
154
- function getCycleSummary(result) {
155
- if (!result.hasCycles) return "No circular navigation detected";
156
- const total = result.cycles.length;
157
- const disallowed = result.disallowedCycles.length;
158
- const allowed = total - disallowed;
159
- if (disallowed === 0) return `${total} circular navigation${total > 1 ? "s" : ""} detected (all allowed)`;
160
- if (allowed === 0) return `${total} circular navigation${total > 1 ? "s" : ""} detected`;
161
- return `${total} circular navigation${total > 1 ? "s" : ""} detected (${disallowed} not allowed, ${allowed} allowed)`;
162
- }
163
-
164
45
  //#endregion
165
46
  //#region src/utils/errors.ts
166
47
  /**
@@ -205,6 +86,15 @@ export default defineConfig({
205
86
  metaPattern: "src/**/screen.meta.ts",
206
87
  })`
207
88
  },
89
+ NO_ROUTES_FOUND: (pattern) => ({
90
+ title: `No routes found matching pattern: ${pattern}`,
91
+ suggestion: "Check your routesPattern in screenbook.config.ts.",
92
+ example: `// Common patterns:
93
+ "src/app/**/page.tsx" // Next.js App Router
94
+ "src/pages/**/*.tsx" // Pages Router
95
+ "src/pages/**/*.vue" // Vue/Nuxt
96
+ "src/routes/**/*.tsx" // SolidStart/QwikCity`
97
+ }),
208
98
  SCREENS_NOT_FOUND: {
209
99
  title: "screens.json not found",
210
100
  suggestion: "Run 'screenbook build' first to generate the screen catalog.",
@@ -225,6 +115,21 @@ export const screen = defineScreen({
225
115
  route: "/example",
226
116
  })`
227
117
  }),
118
+ FILE_READ_ERROR: (filePath, error) => ({
119
+ title: `Failed to read file: ${filePath}`,
120
+ message: error,
121
+ suggestion: "Check if the file exists and you have read permissions."
122
+ }),
123
+ PARSE_ERROR: (filePath, error) => ({
124
+ title: `Failed to parse: ${filePath}`,
125
+ message: error,
126
+ suggestion: "Check for syntax errors in the file."
127
+ }),
128
+ SCREEN_NOT_FOUND: (screenId, suggestions) => ({
129
+ title: `Screen "${screenId}" not found`,
130
+ message: suggestions && suggestions.length > 0 ? `Did you mean one of these?\n${suggestions.map((s) => ` - ${s}`).join("\n")}` : void 0,
131
+ suggestion: "Check the screen ID in your screen.meta.ts file."
132
+ }),
228
133
  API_NAME_REQUIRED: {
229
134
  title: "API name is required",
230
135
  suggestion: "Provide the API name as an argument.",
@@ -249,6 +154,19 @@ screenbook impact "PaymentAPI.*" # Use quotes for patterns`
249
154
  title: `Validation failed with ${errorCount} error${errorCount === 1 ? "" : "s"}`,
250
155
  suggestion: "Fix the validation errors above. Screen references must point to existing screens."
251
156
  }),
157
+ INVALID_API_DEPENDENCIES: (count) => ({
158
+ title: `${count} invalid API ${count === 1 ? "dependency" : "dependencies"} detected`,
159
+ suggestion: "Fix the dependsOn values to match operationIds or HTTP endpoints from your OpenAPI spec.",
160
+ example: `// Using operationId
161
+ dependsOn: ["getInvoiceById", "updatePaymentStatus"]
162
+
163
+ // Using HTTP method + path
164
+ dependsOn: ["GET /api/invoices/{id}", "POST /api/payments"]`
165
+ }),
166
+ OPENAPI_PARSE_ERROR: (source) => ({
167
+ title: `Failed to parse OpenAPI spec: ${source}`,
168
+ suggestion: "Check that the file path is correct and the OpenAPI specification is valid YAML or JSON."
169
+ }),
252
170
  LINT_MISSING_META: (missingCount, totalRoutes) => ({
253
171
  title: `${missingCount} route${missingCount === 1 ? "" : "s"} missing screen.meta.ts`,
254
172
  message: `Found ${totalRoutes} route file${totalRoutes === 1 ? "" : "s"}, but ${missingCount} ${missingCount === 1 ? "is" : "are"} missing colocated screen.meta.ts.`,
@@ -268,6 +186,13 @@ export const screen = defineScreen({
268
186
 
269
187
  //#endregion
270
188
  //#region src/utils/logger.ts
189
+ let verboseMode = false;
190
+ /**
191
+ * Enable or disable verbose mode for detailed output
192
+ */
193
+ function setVerbose(verbose) {
194
+ verboseMode = verbose;
195
+ }
271
196
  /**
272
197
  * Logger utility for consistent, color-coded CLI output
273
198
  */
@@ -303,6 +228,17 @@ const logger = {
303
228
  }
304
229
  console.error();
305
230
  },
231
+ errorWithStack: (error, context) => {
232
+ const err = error instanceof Error ? error : new Error(String(error));
233
+ const message = context ? `${context}: ${err.message}` : err.message;
234
+ console.error(`${pc.red("✗")} ${pc.red(`Error: ${message}`)}`);
235
+ if (verboseMode && err.stack) {
236
+ console.error();
237
+ console.error(` ${pc.dim("Stack trace:")}`);
238
+ for (const line of err.stack.split("\n").slice(1)) console.error(` ${pc.dim(line)}`);
239
+ }
240
+ console.error();
241
+ },
306
242
  step: (msg) => {
307
243
  console.log(`${pc.dim("→")} ${msg}`);
308
244
  },
@@ -334,6 +270,405 @@ const logger = {
334
270
  yellow: (msg) => pc.yellow(msg)
335
271
  };
336
272
 
273
+ //#endregion
274
+ //#region src/commands/badge.ts
275
+ const VALID_FORMATS = [
276
+ "svg",
277
+ "json",
278
+ "shields-json"
279
+ ];
280
+ const VALID_STYLES = ["flat", "flat-square"];
281
+ /**
282
+ * Calculate coverage based on route files and meta files
283
+ */
284
+ async function calculateCoverage(routesPattern, metaPattern, ignore, cwd) {
285
+ const routeFiles = await glob(routesPattern, {
286
+ cwd,
287
+ ignore
288
+ });
289
+ if (routeFiles.length === 0) return {
290
+ total: 0,
291
+ covered: 0,
292
+ percentage: 0
293
+ };
294
+ const metaFiles = await glob(metaPattern, {
295
+ cwd,
296
+ ignore
297
+ });
298
+ const metaDirs = /* @__PURE__ */ new Set();
299
+ for (const metaFile of metaFiles) metaDirs.add(dirname(metaFile));
300
+ let covered = 0;
301
+ for (const routeFile of routeFiles) if (metaDirs.has(dirname(routeFile))) covered++;
302
+ const total = routeFiles.length;
303
+ const percentage = Math.round(covered / total * 100);
304
+ return {
305
+ total,
306
+ covered,
307
+ percentage
308
+ };
309
+ }
310
+ /**
311
+ * Get badge color based on coverage percentage
312
+ */
313
+ function getBadgeColor(percentage) {
314
+ if (percentage >= 80) return "#4c1";
315
+ if (percentage >= 50) return "#dfb317";
316
+ return "#e05d44";
317
+ }
318
+ /**
319
+ * Get shields.io color name based on coverage percentage
320
+ */
321
+ function getShieldsColorName(percentage) {
322
+ if (percentage >= 80) return "brightgreen";
323
+ if (percentage >= 50) return "yellow";
324
+ return "red";
325
+ }
326
+ /**
327
+ * Generate SVG badge
328
+ */
329
+ function generateSvgBadge(percentage, style = "flat") {
330
+ const color = getBadgeColor(percentage);
331
+ const label = "screenbook";
332
+ const value = `${percentage}%`;
333
+ const labelWidth = 70;
334
+ const valueWidth = 45;
335
+ const totalWidth = labelWidth + valueWidth;
336
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${totalWidth}" height="20">
337
+ <linearGradient id="b" x2="0" y2="100%">
338
+ <stop offset="0" stop-color="#bbb" stop-opacity=".1"/>
339
+ <stop offset="1" stop-opacity=".1"/>
340
+ </linearGradient>
341
+ <clipPath id="a">
342
+ <rect width="${totalWidth}" height="20" rx="${style === "flat" ? 3 : 0}" fill="#fff"/>
343
+ </clipPath>
344
+ <g clip-path="url(#a)">
345
+ <rect width="${labelWidth}" height="20" fill="#555"/>
346
+ <rect x="${labelWidth}" width="${valueWidth}" height="20" fill="${color}"/>
347
+ <rect width="${totalWidth}" height="20" fill="url(#b)"/>
348
+ </g>
349
+ <g fill="#fff" text-anchor="middle" font-family="Verdana,Geneva,sans-serif" font-size="11">
350
+ <text x="${labelWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${label}</text>
351
+ <text x="${labelWidth / 2}" y="14">${label}</text>
352
+ <text x="${labelWidth + valueWidth / 2}" y="15" fill="#010101" fill-opacity=".3">${value}</text>
353
+ <text x="${labelWidth + valueWidth / 2}" y="14">${value}</text>
354
+ </g>
355
+ </svg>`;
356
+ }
357
+ /**
358
+ * Generate shields.io endpoint JSON
359
+ */
360
+ function generateShieldsJson(percentage) {
361
+ return {
362
+ schemaVersion: 1,
363
+ label: "screenbook",
364
+ message: `${percentage}%`,
365
+ color: getShieldsColorName(percentage)
366
+ };
367
+ }
368
+ /**
369
+ * Generate simple JSON with coverage data
370
+ */
371
+ function generateSimpleJson(coverage) {
372
+ return {
373
+ percentage: coverage.percentage,
374
+ covered: coverage.covered,
375
+ total: coverage.total
376
+ };
377
+ }
378
+ const badgeCommand = define({
379
+ name: "badge",
380
+ description: "Generate coverage badge for README",
381
+ args: {
382
+ config: {
383
+ type: "string",
384
+ short: "c",
385
+ description: "Path to config file"
386
+ },
387
+ output: {
388
+ type: "string",
389
+ short: "o",
390
+ description: "Output file path"
391
+ },
392
+ format: {
393
+ type: "string",
394
+ short: "f",
395
+ description: "Output format: svg (default), json, shields-json",
396
+ default: "svg"
397
+ },
398
+ style: {
399
+ type: "string",
400
+ description: "Badge style: flat (default), flat-square",
401
+ default: "flat"
402
+ },
403
+ verbose: {
404
+ type: "boolean",
405
+ short: "v",
406
+ description: "Show detailed output including stack traces",
407
+ default: false
408
+ }
409
+ },
410
+ run: async (ctx) => {
411
+ setVerbose(ctx.values.verbose);
412
+ const cwd = process.cwd();
413
+ const formatInput = ctx.values.format ?? "svg";
414
+ const styleInput = ctx.values.style ?? "flat";
415
+ if (!VALID_FORMATS.includes(formatInput)) {
416
+ logger.error(`Invalid format: "${formatInput}"`);
417
+ logger.log(` ${logger.dim(`Valid formats: ${VALID_FORMATS.join(", ")}`)}`);
418
+ process.exit(1);
419
+ }
420
+ if (!VALID_STYLES.includes(styleInput)) {
421
+ logger.error(`Invalid style: "${styleInput}"`);
422
+ logger.log(` ${logger.dim(`Valid styles: ${VALID_STYLES.join(", ")}`)}`);
423
+ process.exit(1);
424
+ }
425
+ const format = formatInput;
426
+ const style = styleInput;
427
+ let config;
428
+ try {
429
+ config = await loadConfig(ctx.values.config);
430
+ } catch (error) {
431
+ if (ctx.values.config) logger.errorWithStack(error, `Failed to load config from ${ctx.values.config}`);
432
+ else logger.errorWithHelp(ERRORS.CONFIG_NOT_FOUND);
433
+ process.exit(1);
434
+ }
435
+ if (!config.routesPattern) {
436
+ logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
437
+ process.exit(1);
438
+ }
439
+ logger.info("Calculating coverage...");
440
+ let coverage;
441
+ try {
442
+ coverage = await calculateCoverage(config.routesPattern, config.metaPattern, config.ignore, cwd);
443
+ } catch (error) {
444
+ logger.errorWithStack(error, "Failed to calculate coverage");
445
+ logger.log(` ${logger.dim("Check that your routesPattern and metaPattern are valid glob patterns.")}`);
446
+ process.exit(1);
447
+ }
448
+ if (coverage.total === 0) {
449
+ logger.warn(`No route files found matching: ${config.routesPattern}`);
450
+ logger.log(` ${logger.dim("Badge will show 0% coverage for empty projects.")}`);
451
+ }
452
+ logger.log(`Coverage: ${coverage.covered}/${coverage.total} (${coverage.percentage}%)`);
453
+ let content;
454
+ let extension;
455
+ switch (format) {
456
+ case "shields-json":
457
+ content = JSON.stringify(generateShieldsJson(coverage.percentage), null, 2);
458
+ extension = "json";
459
+ break;
460
+ case "json":
461
+ content = JSON.stringify(generateSimpleJson(coverage), null, 2);
462
+ extension = "json";
463
+ break;
464
+ default:
465
+ content = generateSvgBadge(coverage.percentage, style);
466
+ extension = "svg";
467
+ break;
468
+ }
469
+ const defaultFileName = format === "shields-json" ? "coverage.json" : `coverage-badge.${extension}`;
470
+ const outputPath = ctx.values.output ?? join(cwd, config.outDir, defaultFileName);
471
+ const outputDir = dirname(outputPath);
472
+ try {
473
+ if (!existsSync(outputDir)) mkdirSync(outputDir, { recursive: true });
474
+ } catch (error) {
475
+ logger.errorWithStack(error, `Failed to create output directory: ${outputDir}`);
476
+ logger.log(` ${logger.dim("Check that you have write permissions to this location.")}`);
477
+ process.exit(1);
478
+ }
479
+ try {
480
+ writeFileSync(outputPath, content);
481
+ } catch (error) {
482
+ logger.errorWithStack(error, `Failed to write badge file: ${outputPath}`);
483
+ logger.log(` ${logger.dim("Check that you have write permissions and sufficient disk space.")}`);
484
+ process.exit(1);
485
+ }
486
+ logger.blank();
487
+ logger.success(`Generated ${logger.path(outputPath)}`);
488
+ if (format === "svg") {
489
+ logger.blank();
490
+ logger.log(logger.dim("Usage in README.md:"));
491
+ logger.log(logger.dim(` ![Screenbook Coverage](${outputPath.replace(cwd, ".")})`));
492
+ } else if (format === "shields-json") {
493
+ logger.blank();
494
+ logger.log(logger.dim("Usage with shields.io:"));
495
+ logger.log(logger.dim(" ![Coverage](https://img.shields.io/endpoint?url=YOUR_URL/coverage.json)"));
496
+ }
497
+ }
498
+ });
499
+
500
+ //#endregion
501
+ //#region src/utils/cycleDetection.ts
502
+ var Color = /* @__PURE__ */ function(Color$1) {
503
+ Color$1[Color$1["White"] = 0] = "White";
504
+ Color$1[Color$1["Gray"] = 1] = "Gray";
505
+ Color$1[Color$1["Black"] = 2] = "Black";
506
+ return Color$1;
507
+ }(Color || {});
508
+ /**
509
+ * Detect circular navigation dependencies in screen definitions.
510
+ * Uses DFS with coloring algorithm: O(V + E) complexity.
511
+ *
512
+ * @example
513
+ * ```ts
514
+ * const screens = [
515
+ * { id: "A", next: ["B"] },
516
+ * { id: "B", next: ["C"] },
517
+ * { id: "C", next: ["A"] }, // Creates cycle A → B → C → A
518
+ * ]
519
+ * const result = detectCycles(screens)
520
+ * // result.hasCycles === true
521
+ * // result.cycles[0].cycle === ["A", "B", "C", "A"]
522
+ * ```
523
+ */
524
+ function detectCycles(screens) {
525
+ const screenMap = /* @__PURE__ */ new Map();
526
+ const duplicateIds = [];
527
+ for (const screen of screens) {
528
+ if (!screen.id || typeof screen.id !== "string") continue;
529
+ if (screenMap.has(screen.id)) duplicateIds.push(screen.id);
530
+ screenMap.set(screen.id, screen);
531
+ }
532
+ const color = /* @__PURE__ */ new Map();
533
+ const parent = /* @__PURE__ */ new Map();
534
+ const cycles = [];
535
+ for (const id of screenMap.keys()) color.set(id, Color.White);
536
+ for (const id of screenMap.keys()) if (color.get(id) === Color.White) dfs(id, null);
537
+ function dfs(nodeId, parentId) {
538
+ color.set(nodeId, Color.Gray);
539
+ parent.set(nodeId, parentId);
540
+ const neighbors = screenMap.get(nodeId)?.next ?? [];
541
+ for (const neighborId of neighbors) {
542
+ const neighborColor = color.get(neighborId);
543
+ if (neighborColor === Color.Gray) {
544
+ const cyclePath = reconstructCycle(nodeId, neighborId);
545
+ const allowed = isCycleAllowed(cyclePath, screenMap);
546
+ cycles.push({
547
+ cycle: cyclePath,
548
+ allowed
549
+ });
550
+ } else if (neighborColor === Color.White) {
551
+ if (screenMap.has(neighborId)) dfs(neighborId, nodeId);
552
+ }
553
+ }
554
+ color.set(nodeId, Color.Black);
555
+ }
556
+ /**
557
+ * Reconstruct cycle path from back edge
558
+ */
559
+ function reconstructCycle(from, to) {
560
+ const path = [];
561
+ let current = from;
562
+ const visited = /* @__PURE__ */ new Set();
563
+ const maxIterations = screenMap.size + 1;
564
+ while (current && current !== to && !visited.has(current) && path.length < maxIterations) {
565
+ visited.add(current);
566
+ path.unshift(current);
567
+ current = parent.get(current);
568
+ }
569
+ path.unshift(to);
570
+ path.push(to);
571
+ return path;
572
+ }
573
+ const disallowedCycles = cycles.filter((c) => !c.allowed);
574
+ return {
575
+ hasCycles: cycles.length > 0,
576
+ cycles,
577
+ disallowedCycles,
578
+ duplicateIds
579
+ };
580
+ }
581
+ /**
582
+ * Check if any screen in the cycle has allowCycles: true
583
+ */
584
+ function isCycleAllowed(cyclePath, screenMap) {
585
+ const uniqueNodes = cyclePath.slice(0, -1);
586
+ for (const nodeId of uniqueNodes) if (screenMap.get(nodeId)?.allowCycles === true) return true;
587
+ return false;
588
+ }
589
+ /**
590
+ * Format cycle information for console output
591
+ *
592
+ * @example
593
+ * ```
594
+ * Cycle 1: A → B → C → A
595
+ * Cycle 2 (allowed): D → E → D
596
+ * ```
597
+ */
598
+ function formatCycleWarnings(cycles) {
599
+ if (cycles.length === 0) return "";
600
+ const lines = [];
601
+ for (let i = 0; i < cycles.length; i++) {
602
+ const cycle = cycles[i];
603
+ if (!cycle) continue;
604
+ const cycleStr = cycle.cycle.join(" → ");
605
+ const allowedSuffix = cycle.allowed ? " (allowed)" : "";
606
+ lines.push(` Cycle ${i + 1}${allowedSuffix}: ${cycleStr}`);
607
+ }
608
+ return lines.join("\n");
609
+ }
610
+ /**
611
+ * Get a summary of cycle detection results
612
+ */
613
+ function getCycleSummary(result) {
614
+ if (!result.hasCycles) return "No circular navigation detected";
615
+ const total = result.cycles.length;
616
+ const disallowed = result.disallowedCycles.length;
617
+ const allowed = total - disallowed;
618
+ if (disallowed === 0) return `${total} circular navigation${total > 1 ? "s" : ""} detected (all allowed)`;
619
+ if (allowed === 0) return `${total} circular navigation${total > 1 ? "s" : ""} detected`;
620
+ return `${total} circular navigation${total > 1 ? "s" : ""} detected (${disallowed} not allowed, ${allowed} allowed)`;
621
+ }
622
+
623
+ //#endregion
624
+ //#region src/utils/suggestions.ts
625
+ /**
626
+ * Find similar strings using Levenshtein distance
627
+ */
628
+ function findSimilar(target, candidates, options = {}) {
629
+ const { maxDistanceRatio = .4, maxSuggestions = 3 } = options;
630
+ const effectiveRatio = Math.max(0, Math.min(1, maxDistanceRatio));
631
+ const effectiveSuggestions = Math.max(1, Math.floor(maxSuggestions));
632
+ const candidateArray = Array.isArray(candidates) ? candidates : Array.from(candidates);
633
+ const maxDistance = Math.ceil(target.length * effectiveRatio);
634
+ const matches = [];
635
+ for (const candidate of candidateArray) {
636
+ const distance = levenshteinDistance(target, candidate);
637
+ if (distance <= maxDistance) matches.push({
638
+ candidate,
639
+ distance
640
+ });
641
+ }
642
+ return matches.sort((a, b) => a.distance - b.distance).slice(0, effectiveSuggestions).map((m) => m.candidate);
643
+ }
644
+ /**
645
+ * Find the single best match (convenience wrapper for findSimilar)
646
+ */
647
+ function findBestMatch(target, candidates, maxDistanceRatio = .4) {
648
+ return findSimilar(target, candidates, {
649
+ maxDistanceRatio,
650
+ maxSuggestions: 1
651
+ })[0];
652
+ }
653
+ /**
654
+ * Calculate Levenshtein distance between two strings
655
+ */
656
+ function levenshteinDistance(a, b) {
657
+ const matrix = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
658
+ const get = (i, j) => matrix[i]?.[j] ?? 0;
659
+ const set = (i, j, value) => {
660
+ const row = matrix[i];
661
+ if (row) row[j] = value;
662
+ };
663
+ for (let i = 0; i <= a.length; i++) set(i, 0, i);
664
+ for (let j = 0; j <= b.length; j++) set(0, j, j);
665
+ for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
666
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
667
+ set(i, j, Math.min(get(i - 1, j) + 1, get(i, j - 1) + 1, get(i - 1, j - 1) + cost));
668
+ }
669
+ return get(a.length, b.length);
670
+ }
671
+
337
672
  //#endregion
338
673
  //#region src/utils/validation.ts
339
674
  /**
@@ -348,7 +683,7 @@ function validateScreenReferences(screens) {
348
683
  screenId: screen.id,
349
684
  field: "next",
350
685
  invalidRef: nextId,
351
- suggestion: findSimilar(nextId, screenIds)
686
+ suggestion: findBestMatch(nextId, screenIds)
352
687
  });
353
688
  }
354
689
  if (screen.entryPoints) {
@@ -356,7 +691,7 @@ function validateScreenReferences(screens) {
356
691
  screenId: screen.id,
357
692
  field: "entryPoints",
358
693
  invalidRef: entryId,
359
- suggestion: findSimilar(entryId, screenIds)
694
+ suggestion: findBestMatch(entryId, screenIds)
360
695
  });
361
696
  }
362
697
  }
@@ -366,40 +701,6 @@ function validateScreenReferences(screens) {
366
701
  };
367
702
  }
368
703
  /**
369
- * Find similar screen ID using Levenshtein distance
370
- */
371
- function findSimilar(target, candidates) {
372
- let bestMatch;
373
- let bestDistance = Number.POSITIVE_INFINITY;
374
- const maxDistance = Math.ceil(target.length * .4);
375
- for (const candidate of candidates) {
376
- const distance = levenshteinDistance(target, candidate);
377
- if (distance < bestDistance && distance <= maxDistance) {
378
- bestDistance = distance;
379
- bestMatch = candidate;
380
- }
381
- }
382
- return bestMatch;
383
- }
384
- /**
385
- * Calculate Levenshtein distance between two strings
386
- */
387
- function levenshteinDistance(a, b) {
388
- const matrix = Array.from({ length: a.length + 1 }, () => Array.from({ length: b.length + 1 }, () => 0));
389
- const get = (i, j) => matrix[i]?.[j] ?? 0;
390
- const set = (i, j, value) => {
391
- const row = matrix[i];
392
- if (row) row[j] = value;
393
- };
394
- for (let i = 0; i <= a.length; i++) set(i, 0, i);
395
- for (let j = 0; j <= b.length; j++) set(0, j, j);
396
- for (let i = 1; i <= a.length; i++) for (let j = 1; j <= b.length; j++) {
397
- const cost = a[i - 1] === b[j - 1] ? 0 : 1;
398
- set(i, j, Math.min(get(i - 1, j) + 1, get(i, j - 1) + 1, get(i - 1, j - 1) + cost));
399
- }
400
- return get(a.length, b.length);
401
- }
402
- /**
403
704
  * Format validation errors for console output
404
705
  */
405
706
  function formatValidationErrors(errors) {
@@ -439,9 +740,16 @@ const buildCommand = define({
439
740
  type: "boolean",
440
741
  description: "Suppress all circular navigation warnings",
441
742
  default: false
743
+ },
744
+ verbose: {
745
+ type: "boolean",
746
+ short: "v",
747
+ description: "Show detailed output including stack traces",
748
+ default: false
442
749
  }
443
750
  },
444
751
  run: async (ctx) => {
752
+ setVerbose(ctx.values.verbose);
445
753
  const config = await loadConfig(ctx.values.config);
446
754
  const outDir = ctx.values.outDir ?? config.outDir;
447
755
  const cwd = process.cwd();
@@ -594,9 +902,16 @@ const devCommand = define({
594
902
  short: "p",
595
903
  description: "Port to run the server on",
596
904
  default: "4321"
905
+ },
906
+ verbose: {
907
+ type: "boolean",
908
+ short: "v",
909
+ description: "Show detailed output including stack traces",
910
+ default: false
597
911
  }
598
912
  },
599
913
  run: async (ctx) => {
914
+ setVerbose(ctx.values.verbose);
600
915
  const config = await loadConfig(ctx.values.config);
601
916
  const port = ctx.values.port ?? "4321";
602
917
  const cwd = process.cwd();
@@ -786,11 +1101,11 @@ async function checkDependencies(cwd) {
786
1101
  status: "pass",
787
1102
  message: `@screenbook/core@${coreVersion}, @screenbook/cli@${cliVersion}`
788
1103
  };
789
- } catch {
1104
+ } catch (error) {
790
1105
  return {
791
1106
  name: "Dependencies",
792
1107
  status: "fail",
793
- message: "Failed to read package.json",
1108
+ message: `Failed to read package.json: ${error instanceof Error ? error.message : String(error)}`,
794
1109
  suggestion: "Ensure package.json is valid JSON"
795
1110
  };
796
1111
  }
@@ -812,11 +1127,11 @@ async function checkMetaPattern(cwd, metaPattern, ignore) {
812
1127
  status: "pass",
813
1128
  message: `Found ${files.length} screen.meta.ts file${files.length > 1 ? "s" : ""}`
814
1129
  };
815
- } catch {
1130
+ } catch (error) {
816
1131
  return {
817
1132
  name: "Screen meta files",
818
1133
  status: "fail",
819
- message: `Invalid pattern: ${metaPattern}`,
1134
+ message: `Invalid pattern: ${metaPattern} (${error instanceof Error ? error.message : String(error)})`,
820
1135
  suggestion: "Check metaPattern in your config file"
821
1136
  };
822
1137
  }
@@ -844,11 +1159,11 @@ async function checkRoutesPattern(cwd, routesPattern, ignore) {
844
1159
  status: "pass",
845
1160
  message: `Found ${files.length} route file${files.length > 1 ? "s" : ""}`
846
1161
  };
847
- } catch {
1162
+ } catch (error) {
848
1163
  return {
849
1164
  name: "Routes pattern",
850
1165
  status: "fail",
851
- message: `Invalid pattern: ${routesPattern}`,
1166
+ message: `Invalid pattern: ${routesPattern} (${error instanceof Error ? error.message : String(error)})`,
852
1167
  suggestion: "Check routesPattern in your config file"
853
1168
  };
854
1169
  }
@@ -869,11 +1184,11 @@ async function checkBuildOutput(cwd, outDir) {
869
1184
  status: "pass",
870
1185
  message: `screens.json contains ${screens.length} screen${screens.length > 1 ? "s" : ""}`
871
1186
  };
872
- } catch {
1187
+ } catch (error) {
873
1188
  return {
874
1189
  name: "Build output",
875
1190
  status: "fail",
876
- message: "screens.json is corrupted",
1191
+ message: `screens.json is corrupted: ${error instanceof Error ? error.message : String(error)}`,
877
1192
  suggestion: "Run 'screenbook build' to regenerate"
878
1193
  };
879
1194
  }
@@ -919,11 +1234,12 @@ async function checkVersionCompatibility(cwd) {
919
1234
  status: "pass",
920
1235
  message: "Package versions are compatible"
921
1236
  };
922
- } catch {
1237
+ } catch (error) {
923
1238
  return {
924
1239
  name: "Version compatibility",
925
1240
  status: "fail",
926
- message: "Failed to read package.json"
1241
+ message: `Failed to read package.json: ${error instanceof Error ? error.message : String(error)}`,
1242
+ suggestion: "Ensure package.json is valid JSON"
927
1243
  };
928
1244
  }
929
1245
  }
@@ -1268,35 +1584,640 @@ function extractLazyComponent(node, baseDir, warnings) {
1268
1584
  return;
1269
1585
  }
1270
1586
  }
1271
- const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
1272
- warnings.push(`Unrecognized loadComponent pattern (${node.type})${loc}. Expected arrow function with import().then().`);
1587
+ const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
1588
+ warnings.push(`Unrecognized loadComponent pattern (${node.type})${loc}. Expected arrow function with import().then().`);
1589
+ }
1590
+ /**
1591
+ * Extract path from lazy loadChildren pattern
1592
+ * loadChildren: () => import('./path').then(m => m.routes)
1593
+ */
1594
+ function extractLazyPath(node, baseDir, warnings) {
1595
+ if (node.type === "ArrowFunctionExpression") {
1596
+ const body = node.body;
1597
+ if (body.type === "CallExpression" && body.callee?.type === "MemberExpression" && body.callee.property?.type === "Identifier" && body.callee.property.name === "then") {
1598
+ const importCall = body.callee.object;
1599
+ if (importCall?.type === "CallExpression" && importCall.callee?.type === "Import" && importCall.arguments[0]?.type === "StringLiteral") return resolveImportPath(importCall.arguments[0].value, baseDir);
1600
+ }
1601
+ if (body.type === "CallExpression" && body.callee?.type === "Import") {
1602
+ if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
1603
+ }
1604
+ }
1605
+ const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
1606
+ warnings.push(`Unrecognized loadChildren pattern (${node.type})${loc}. Expected arrow function with import().`);
1607
+ }
1608
+ /**
1609
+ * Detect if content is Angular Router based on patterns.
1610
+ * Checks for @angular/router import, RouterModule patterns, or Routes type annotation.
1611
+ */
1612
+ function isAngularRouterContent(content) {
1613
+ if (content.includes("@angular/router")) return true;
1614
+ if (content.includes("RouterModule.forRoot") || content.includes("RouterModule.forChild")) return true;
1615
+ if (/:\s*Routes\s*[=[]/.test(content)) return true;
1616
+ return false;
1617
+ }
1618
+
1619
+ //#endregion
1620
+ //#region src/utils/navigationAnalyzer.ts
1621
+ /**
1622
+ * Factory function to create DetectedNavigation with proper screenId derivation.
1623
+ * Ensures the screenId is always correctly derived from the path.
1624
+ */
1625
+ function createDetectedNavigation(path, type, line) {
1626
+ const pathWithoutQuery = path.split("?")[0] ?? path;
1627
+ return {
1628
+ path,
1629
+ screenId: pathToScreenId(pathWithoutQuery.split("#")[0] ?? pathWithoutQuery),
1630
+ type,
1631
+ line
1632
+ };
1633
+ }
1634
+ /**
1635
+ * Deduplicate navigations by screenId.
1636
+ * Exported for use by framework-specific analyzers.
1637
+ *
1638
+ * @param navigations - Array of detected navigations
1639
+ * @returns Array with duplicate screenIds removed (keeps first occurrence)
1640
+ */
1641
+ function deduplicateByScreenId(navigations) {
1642
+ const seen = /* @__PURE__ */ new Set();
1643
+ return navigations.filter((nav) => {
1644
+ if (seen.has(nav.screenId)) return false;
1645
+ seen.add(nav.screenId);
1646
+ return true;
1647
+ });
1648
+ }
1649
+ /**
1650
+ * Analyze a file's content for navigation patterns.
1651
+ *
1652
+ * Supports:
1653
+ * - Next.js: `<Link href="/path">`, `router.push("/path")`, `router.replace("/path")`, `redirect("/path")`
1654
+ * - React Router: `<Link to="/path">`, `navigate("/path")`
1655
+ * - Vue Router: `router.push("/path")`, `router.replace("/path")`, `router.push({ path: "/path" })`
1656
+ * - Angular: `router.navigate(['/path'])`, `router.navigateByUrl('/path')`
1657
+ * - Solid Router: `<A href="/path">`, `navigate("/path")`
1658
+ * - TanStack Router: `<Link to="/path">`, `navigate({ to: "/path" })`
1659
+ *
1660
+ * @param content - The file content to analyze
1661
+ * @param framework - The navigation framework to detect
1662
+ * @returns Analysis result with detected navigations and warnings
1663
+ */
1664
+ function analyzeNavigation(content, framework) {
1665
+ const navigations = [];
1666
+ const warnings = [];
1667
+ let ast;
1668
+ try {
1669
+ ast = parse(content, {
1670
+ sourceType: "module",
1671
+ plugins: [
1672
+ "typescript",
1673
+ "jsx",
1674
+ "decorators-legacy"
1675
+ ]
1676
+ });
1677
+ } catch (error) {
1678
+ if (error instanceof SyntaxError) {
1679
+ warnings.push(`Syntax error during navigation analysis: ${error.message}`);
1680
+ return {
1681
+ navigations,
1682
+ warnings
1683
+ };
1684
+ }
1685
+ const message = error instanceof Error ? error.message : String(error);
1686
+ warnings.push(`Failed to parse file for navigation analysis: ${message}`);
1687
+ return {
1688
+ navigations,
1689
+ warnings
1690
+ };
1691
+ }
1692
+ try {
1693
+ walkNode(ast.program, (node) => {
1694
+ if (node.type === "JSXOpeningElement") {
1695
+ const linkNav = extractLinkNavigation(node, framework, warnings);
1696
+ if (linkNav) navigations.push(linkNav);
1697
+ }
1698
+ if (node.type === "CallExpression") {
1699
+ const callNav = extractCallNavigation(node, framework, warnings);
1700
+ if (callNav) navigations.push(callNav);
1701
+ }
1702
+ });
1703
+ } catch (error) {
1704
+ if (error instanceof RangeError) {
1705
+ warnings.push(`File too complex for navigation analysis (${error.message}). Consider simplifying the file structure.`);
1706
+ return {
1707
+ navigations,
1708
+ warnings
1709
+ };
1710
+ }
1711
+ const message = error instanceof Error ? error.message : String(error);
1712
+ const errorType = error?.constructor?.name ?? "Unknown";
1713
+ warnings.push(`Unexpected ${errorType} during AST traversal: ${message}`);
1714
+ return {
1715
+ navigations,
1716
+ warnings
1717
+ };
1718
+ }
1719
+ const seen = /* @__PURE__ */ new Set();
1720
+ return {
1721
+ navigations: navigations.filter((nav) => {
1722
+ if (seen.has(nav.screenId)) return false;
1723
+ seen.add(nav.screenId);
1724
+ return true;
1725
+ }),
1726
+ warnings
1727
+ };
1728
+ }
1729
+ /**
1730
+ * Extract navigation from JSX Link component
1731
+ */
1732
+ function extractLinkNavigation(node, framework, warnings) {
1733
+ if (node.name?.type !== "JSXIdentifier") return null;
1734
+ const componentName = node.name.name;
1735
+ if (componentName !== "Link" && componentName !== "A") return null;
1736
+ const attrName = framework === "nextjs" || framework === "solid-router" ? "href" : "to";
1737
+ for (const attr of node.attributes || []) if (attr.type === "JSXAttribute" && attr.name?.type === "JSXIdentifier" && attr.name.name === attrName) {
1738
+ if (attr.value?.type === "StringLiteral") {
1739
+ const path = attr.value.value;
1740
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, "link", node.loc?.start.line ?? 0);
1741
+ }
1742
+ if (attr.value?.type === "JSXExpressionContainer") {
1743
+ const expr = attr.value.expression;
1744
+ if (expr.type === "StringLiteral") {
1745
+ const path = expr.value;
1746
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, "link", node.loc?.start.line ?? 0);
1747
+ }
1748
+ if (expr.type === "TemplateLiteral" && expr.expressions.length === 0 && expr.quasis.length === 1) {
1749
+ const path = expr.quasis[0].value.cooked;
1750
+ if (path && isValidInternalPath(path)) return createDetectedNavigation(path, "link", node.loc?.start.line ?? 0);
1751
+ }
1752
+ const line = node.loc?.start.line ?? 0;
1753
+ warnings.push(`Dynamic ${componentName} ${attrName} at line ${line} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1754
+ }
1755
+ }
1756
+ return null;
1757
+ }
1758
+ /**
1759
+ * Extract navigation from function calls (router.push, router.replace, navigate, redirect)
1760
+ */
1761
+ function extractCallNavigation(node, framework, warnings) {
1762
+ const callee = node.callee;
1763
+ if (framework === "nextjs" && callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "router" && callee.property?.type === "Identifier" && (callee.property.name === "push" || callee.property.name === "replace")) return extractPathFromCallArgs(node, "router-push", warnings);
1764
+ if (framework === "vue-router" && callee?.type === "MemberExpression" && callee.object?.type === "Identifier" && callee.object.name === "router" && callee.property?.type === "Identifier" && (callee.property.name === "push" || callee.property.name === "replace")) return extractPathFromCallArgs(node, "router-push", warnings);
1765
+ if ((framework === "react-router" || framework === "solid-router") && callee?.type === "Identifier" && callee.name === "navigate") return extractPathFromCallArgs(node, "navigate", warnings);
1766
+ if (framework === "tanstack-router" && callee?.type === "Identifier" && callee.name === "navigate") return extractPathFromObjectArg(node, "navigate", warnings, "to");
1767
+ if (framework === "nextjs" && callee?.type === "Identifier" && callee.name === "redirect") return extractPathFromCallArgs(node, "redirect", warnings);
1768
+ if (framework === "angular" && callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && callee.property.name === "navigate") {
1769
+ const obj = callee.object;
1770
+ if (obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "router" || obj?.type === "Identifier" && obj.name === "router") return extractPathFromArrayArg(node, "navigate", warnings);
1771
+ }
1772
+ if (framework === "angular" && callee?.type === "MemberExpression" && callee.property?.type === "Identifier" && callee.property.name === "navigateByUrl") {
1773
+ const obj = callee.object;
1774
+ if (obj?.type === "MemberExpression" && obj.property?.type === "Identifier" && obj.property.name === "router" || obj?.type === "Identifier" && obj.name === "router") return extractPathFromCallArgs(node, "navigate-by-url", warnings);
1775
+ }
1776
+ return null;
1777
+ }
1778
+ /**
1779
+ * Extract path from function call arguments
1780
+ */
1781
+ function extractPathFromCallArgs(node, type, warnings) {
1782
+ const firstArg = node.arguments?.[0];
1783
+ if (!firstArg) return null;
1784
+ if (firstArg.type === "StringLiteral") {
1785
+ const path = firstArg.value;
1786
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1787
+ }
1788
+ if (firstArg.type === "TemplateLiteral" && firstArg.expressions.length === 0 && firstArg.quasis.length === 1) {
1789
+ const path = firstArg.quasis[0].value.cooked;
1790
+ if (path && isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1791
+ }
1792
+ if (firstArg.type === "ObjectExpression") {
1793
+ for (const prop of firstArg.properties || []) if (prop.type === "ObjectProperty" && prop.key?.type === "Identifier" && prop.key.name === "path") {
1794
+ if (prop.value?.type === "StringLiteral") {
1795
+ const path = prop.value.value;
1796
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1797
+ }
1798
+ if (prop.value?.type === "TemplateLiteral" && prop.value.expressions.length === 0 && prop.value.quasis.length === 1) {
1799
+ const path = prop.value.quasis[0].value.cooked;
1800
+ if (path && isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1801
+ }
1802
+ }
1803
+ const line$1 = node.loc?.start.line ?? 0;
1804
+ warnings.push(`Object-based navigation at line ${line$1} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1805
+ return null;
1806
+ }
1807
+ const line = node.loc?.start.line ?? 0;
1808
+ warnings.push(`Dynamic navigation path at line ${line} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1809
+ return null;
1810
+ }
1811
+ /**
1812
+ * Extract path from object argument with a specific property.
1813
+ *
1814
+ * @param node - The AST CallExpression node
1815
+ * @param type - The navigation type to assign
1816
+ * @param warnings - Array to collect warnings
1817
+ * @param propertyName - The property name to look for (e.g., "to" for TanStack Router)
1818
+ * @returns DetectedNavigation if a valid path is found, null otherwise
1819
+ *
1820
+ * @example
1821
+ * // TanStack Router: navigate({ to: '/users' }) -> extracts '/users'
1822
+ */
1823
+ function extractPathFromObjectArg(node, type, warnings, propertyName) {
1824
+ const firstArg = node.arguments?.[0];
1825
+ if (!firstArg) {
1826
+ const line$1 = node.loc?.start.line ?? 0;
1827
+ warnings.push(`Navigation call at line ${line$1} has no arguments. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1828
+ return null;
1829
+ }
1830
+ if (firstArg.type === "ObjectExpression") {
1831
+ for (const prop of firstArg.properties || []) if (prop.type === "ObjectProperty" && prop.key?.type === "Identifier" && prop.key.name === propertyName) {
1832
+ if (prop.value?.type === "StringLiteral") {
1833
+ const path = prop.value.value;
1834
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1835
+ }
1836
+ if (prop.value?.type === "TemplateLiteral" && prop.value.expressions.length === 0 && prop.value.quasis.length === 1) {
1837
+ const path = prop.value.quasis[0].value.cooked;
1838
+ if (path && isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1839
+ }
1840
+ }
1841
+ const line$1 = node.loc?.start.line ?? 0;
1842
+ warnings.push(`Object-based navigation at line ${line$1} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1843
+ return null;
1844
+ }
1845
+ const line = node.loc?.start.line ?? 0;
1846
+ warnings.push(`navigate() at line ${line} expects an object argument with a '${propertyName}' property (e.g., navigate({ ${propertyName}: '/path' })). Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1847
+ return null;
1848
+ }
1849
+ /**
1850
+ * Extract path from array argument (Angular Router style)
1851
+ * e.g., router.navigate(['/users', userId]) -> extracts '/users'
1852
+ */
1853
+ function extractPathFromArrayArg(node, type, warnings) {
1854
+ const firstArg = node.arguments?.[0];
1855
+ if (!firstArg) {
1856
+ const line$1 = node.loc?.start.line ?? 0;
1857
+ warnings.push(`Navigation call at line ${line$1} has no arguments. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1858
+ return null;
1859
+ }
1860
+ if (firstArg.type === "ArrayExpression") {
1861
+ if (firstArg.elements.length === 0) {
1862
+ const line$2 = node.loc?.start.line ?? 0;
1863
+ warnings.push(`Navigation call at line ${line$2} has an empty array. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1864
+ return null;
1865
+ }
1866
+ const firstElement = firstArg.elements[0];
1867
+ if (firstElement?.type === "StringLiteral") {
1868
+ const path = firstElement.value;
1869
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1870
+ }
1871
+ if (firstElement?.type === "TemplateLiteral" && firstElement.expressions.length === 0 && firstElement.quasis.length === 1) {
1872
+ const path = firstElement.quasis[0].value.cooked;
1873
+ if (path && isValidInternalPath(path)) return createDetectedNavigation(path, type, node.loc?.start.line ?? 0);
1874
+ }
1875
+ const line$1 = node.loc?.start.line ?? 0;
1876
+ warnings.push(`Dynamic navigation path at line ${line$1} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1877
+ return null;
1878
+ }
1879
+ const line = node.loc?.start.line ?? 0;
1880
+ warnings.push(`Dynamic navigation path at line ${line} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
1881
+ return null;
1882
+ }
1883
+ /**
1884
+ * Check if a path is a valid internal path (not external URL or hash link)
1885
+ */
1886
+ function isValidInternalPath(path) {
1887
+ if (path.startsWith("http://") || path.startsWith("https://") || path.startsWith("//")) return false;
1888
+ if (path.startsWith("#")) return false;
1889
+ if (path.startsWith("mailto:") || path.startsWith("tel:")) return false;
1890
+ return path.startsWith("/");
1891
+ }
1892
+ /**
1893
+ * Walk AST node recursively
1894
+ */
1895
+ function walkNode(node, callback) {
1896
+ if (!node || typeof node !== "object") return;
1897
+ callback(node);
1898
+ for (const key of Object.keys(node)) {
1899
+ const child = node[key];
1900
+ if (Array.isArray(child)) for (const item of child) walkNode(item, callback);
1901
+ else if (child && typeof child === "object" && child.type) walkNode(child, callback);
1902
+ }
1903
+ }
1904
+ /**
1905
+ * Detect navigation framework from file content.
1906
+ * Returns both the framework and whether it was explicitly detected.
1907
+ */
1908
+ function detectNavigationFramework(content) {
1909
+ if (content.includes("next/link") || content.includes("next/navigation") || content.includes("next/router")) return {
1910
+ framework: "nextjs",
1911
+ detected: true
1912
+ };
1913
+ if (content.includes("vue-router")) return {
1914
+ framework: "vue-router",
1915
+ detected: true
1916
+ };
1917
+ if (content.includes("@angular/router")) return {
1918
+ framework: "angular",
1919
+ detected: true
1920
+ };
1921
+ if (content.includes("@solidjs/router")) return {
1922
+ framework: "solid-router",
1923
+ detected: true
1924
+ };
1925
+ if (content.includes("@tanstack/react-router")) return {
1926
+ framework: "tanstack-router",
1927
+ detected: true
1928
+ };
1929
+ if (content.includes("react-router") || content.includes("@remix-run/react")) return {
1930
+ framework: "react-router",
1931
+ detected: true
1932
+ };
1933
+ if (content.includes("useNavigate")) return {
1934
+ framework: "react-router",
1935
+ detected: true
1936
+ };
1937
+ return {
1938
+ framework: "nextjs",
1939
+ detected: false
1940
+ };
1941
+ }
1942
+
1943
+ //#endregion
1944
+ //#region src/utils/angularTemplateAnalyzer.ts
1945
+ /**
1946
+ * Analyze an Angular component for navigation patterns.
1947
+ *
1948
+ * Detects:
1949
+ * - Template: `<a routerLink="/path">`, `<a [routerLink]="'/path'">`, `<a [routerLink]="['/path']">`
1950
+ * - Script: `router.navigate(['/path'])`, `router.navigateByUrl('/path')`
1951
+ *
1952
+ * Results are deduplicated by screenId, keeping the first occurrence.
1953
+ *
1954
+ * @param content - The Angular component content to analyze
1955
+ * @param filePath - The file path (used for error messages and resolving templateUrl)
1956
+ * @param _cwd - The current working directory (unused, kept for API consistency with other analyzers)
1957
+ * @returns Analysis result with detected navigations and warnings
1958
+ *
1959
+ * @throws Never - All errors are caught and converted to warnings
1960
+ */
1961
+ function analyzeAngularComponent(content, filePath, _cwd) {
1962
+ const templateNavigations = [];
1963
+ const scriptNavigations = [];
1964
+ const warnings = [];
1965
+ try {
1966
+ const templateResult = extractTemplateContent(content, filePath);
1967
+ if (templateResult.warning) warnings.push(templateResult.warning);
1968
+ if (templateResult.content) {
1969
+ const templateNavs = analyzeTemplateHTML(templateResult.content, warnings);
1970
+ templateNavigations.push(...templateNavs);
1971
+ }
1972
+ const scriptResult = analyzeNavigation(content, "angular");
1973
+ scriptNavigations.push(...scriptResult.navigations);
1974
+ warnings.push(...scriptResult.warnings);
1975
+ } catch (error) {
1976
+ if (error instanceof SyntaxError) warnings.push(`Syntax error in ${filePath}: ${error.message}`);
1977
+ else if (error instanceof RangeError) warnings.push(`${filePath}: File too complex for navigation analysis. Consider simplifying the component structure.`);
1978
+ else {
1979
+ const message = error instanceof Error ? error.message : String(error);
1980
+ warnings.push(`${filePath}: Unexpected error during analysis: ${message}. Please report this as a bug.`);
1981
+ }
1982
+ }
1983
+ return {
1984
+ templateNavigations: deduplicateByScreenId(templateNavigations),
1985
+ scriptNavigations: deduplicateByScreenId(scriptNavigations),
1986
+ warnings
1987
+ };
1988
+ }
1989
+ /**
1990
+ * Extract template content from @Component decorator.
1991
+ *
1992
+ * Handles both inline `template` and external `templateUrl` properties.
1993
+ * Uses regex-based extraction for reliability with Angular's decorator syntax.
1994
+ *
1995
+ * Limitations:
1996
+ * - Does not handle templates containing unescaped backticks
1997
+ * - Does not handle escaped quotes within template strings
1998
+ * - Returns warning if templateUrl file cannot be read
1999
+ *
2000
+ * @param content - The component TypeScript content
2001
+ * @param filePath - The file path for resolving relative templateUrl
2002
+ * @returns Extraction result with content and optional warning
2003
+ */
2004
+ function extractTemplateContent(content, filePath) {
2005
+ const templateLiteralMatch = content.match(/template\s*:\s*`([^`]*)`/);
2006
+ if (templateLiteralMatch?.[1] !== void 0) return {
2007
+ content: templateLiteralMatch[1],
2008
+ warning: null
2009
+ };
2010
+ const singleQuoteMatch = content.match(/template\s*:\s*'([^']*)'/);
2011
+ if (singleQuoteMatch?.[1] !== void 0) return {
2012
+ content: singleQuoteMatch[1],
2013
+ warning: null
2014
+ };
2015
+ const doubleQuoteMatch = content.match(/template\s*:\s*"([^"]*)"/);
2016
+ if (doubleQuoteMatch?.[1] !== void 0) return {
2017
+ content: doubleQuoteMatch[1],
2018
+ warning: null
2019
+ };
2020
+ const templateUrlMatch = content.match(/templateUrl\s*:\s*['"]([^'"]+)['"]/);
2021
+ if (templateUrlMatch?.[1]) {
2022
+ const templatePath = resolve(dirname(filePath), templateUrlMatch[1]);
2023
+ try {
2024
+ return {
2025
+ content: readFileSync(templatePath, "utf-8"),
2026
+ warning: null
2027
+ };
2028
+ } catch (error) {
2029
+ const errorCode = error.code;
2030
+ let warning;
2031
+ if (errorCode === "ENOENT") warning = `Template file not found: ${templatePath}. Navigation in this template will not be detected. Add navigation targets manually to the 'next' field in screen.meta.ts.`;
2032
+ else if (errorCode === "EACCES") warning = `Permission denied reading template: ${templatePath}. Check file permissions to enable template analysis.`;
2033
+ else warning = `Failed to read template file ${templatePath}: ${error instanceof Error ? error.message : String(error)}`;
2034
+ return {
2035
+ content: null,
2036
+ warning
2037
+ };
2038
+ }
2039
+ }
2040
+ return {
2041
+ content: null,
2042
+ warning: null
2043
+ };
2044
+ }
2045
+ /**
2046
+ * Analyze HTML template for routerLink directives.
2047
+ *
2048
+ * Note: Line numbers are approximate as htmlparser2 only tracks newlines in text nodes,
2049
+ * not in tags or attributes. Line numbers may drift from actual positions.
2050
+ *
2051
+ * @param html - The HTML template content
2052
+ * @param warnings - Array to collect warnings (mutated)
2053
+ * @returns Array of detected navigation targets
2054
+ */
2055
+ function analyzeTemplateHTML(html, warnings) {
2056
+ const navigations = [];
2057
+ let currentLine = 1;
2058
+ const parserErrors = [];
2059
+ const parser = new Parser({
2060
+ onopentag(_name, attribs) {
2061
+ if ("routerlink" in attribs) {
2062
+ const value = attribs.routerlink;
2063
+ if (value) {
2064
+ if (isValidInternalPath(value)) navigations.push(createDetectedNavigation(value, "link", currentLine));
2065
+ else if (!value.startsWith("/") && !value.includes("://")) warnings.push(`routerLink at line ~${currentLine} has relative path "${value}". Angular routerLink paths should start with '/' for absolute routing. Navigation will not be detected for this link.`);
2066
+ }
2067
+ }
2068
+ if ("[routerlink]" in attribs) {
2069
+ const expression = attribs["[routerlink]"];
2070
+ const nav = extractRouterLinkPath(expression, currentLine, warnings);
2071
+ if (nav) navigations.push(nav);
2072
+ }
2073
+ },
2074
+ ontext(text) {
2075
+ currentLine += (text.match(/\n/g) || []).length;
2076
+ },
2077
+ onerror(error) {
2078
+ parserErrors.push(`HTML parsing error at line ~${currentLine}: ${error.message}. Some navigation targets may not be detected.`);
2079
+ }
2080
+ });
2081
+ parser.write(html);
2082
+ parser.end();
2083
+ warnings.push(...parserErrors);
2084
+ return navigations;
2085
+ }
2086
+ /**
2087
+ * Extract path from [routerLink] binding expression.
2088
+ *
2089
+ * Handles:
2090
+ * - String literals: `[routerLink]="'/path'"` or `[routerLink]='"/path"'`
2091
+ * - Array literals: `[routerLink]="['/path']"` or `[routerLink]="['/path', param]"`
2092
+ * - Warns on dynamic expressions: `[routerLink]="dynamicPath"`
2093
+ *
2094
+ * Note: Does not handle escaped quotes (e.g., \' or \"). This is acceptable
2095
+ * because Angular routerLink paths should not contain quotes.
2096
+ *
2097
+ * @param expression - The binding expression value
2098
+ * @param line - Line number for warning messages (approximate)
2099
+ * @param warnings - Array to collect warnings (mutated)
2100
+ * @returns Detected navigation or null if path cannot be extracted
2101
+ */
2102
+ function extractRouterLinkPath(expression, line, warnings) {
2103
+ if (!expression) {
2104
+ warnings.push(`Empty [routerLink] binding at line ~${line}. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
2105
+ return null;
2106
+ }
2107
+ const trimmed = expression.trim();
2108
+ if (trimmed.startsWith("'") && trimmed.endsWith("'") || trimmed.startsWith("\"") && trimmed.endsWith("\"")) {
2109
+ const path = trimmed.slice(1, -1);
2110
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, "link", line);
2111
+ if (!path.startsWith("/") && !path.includes("://")) warnings.push(`[routerLink] at line ~${line} has relative path "${path}". Angular routerLink paths should start with '/' for absolute routing. Navigation will not be detected for this link.`);
2112
+ return null;
2113
+ }
2114
+ if (trimmed.startsWith("[") && trimmed.endsWith("]")) {
2115
+ const arrayContent = trimmed.slice(1, -1).trim();
2116
+ const firstComma = findFirstCommaOutsideQuotes(arrayContent);
2117
+ const firstElement = firstComma >= 0 ? arrayContent.slice(0, firstComma).trim() : arrayContent.trim();
2118
+ if (firstElement.startsWith("'") && firstElement.endsWith("'") || firstElement.startsWith("\"") && firstElement.endsWith("\"")) {
2119
+ const path = firstElement.slice(1, -1);
2120
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, "link", line);
2121
+ if (!path.startsWith("/") && !path.includes("://")) warnings.push(`[routerLink] at line ~${line} has relative path "${path}". Angular routerLink paths should start with '/' for absolute routing. Navigation will not be detected for this link.`);
2122
+ }
2123
+ return null;
2124
+ }
2125
+ warnings.push(`Dynamic [routerLink] binding at line ~${line} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
2126
+ return null;
1273
2127
  }
1274
2128
  /**
1275
- * Extract path from lazy loadChildren pattern
1276
- * loadChildren: () => import('./path').then(m => m.routes)
2129
+ * Find the index of the first comma outside of quotes.
2130
+ *
2131
+ * Note: Does not handle escaped quotes (e.g., \' or \"). This is acceptable
2132
+ * because Angular routerLink paths should not contain quotes.
2133
+ *
2134
+ * @param str - The string to search
2135
+ * @returns Index of first comma, or -1 if not found
1277
2136
  */
1278
- function extractLazyPath(node, baseDir, warnings) {
1279
- if (node.type === "ArrowFunctionExpression") {
1280
- const body = node.body;
1281
- if (body.type === "CallExpression" && body.callee?.type === "MemberExpression" && body.callee.property?.type === "Identifier" && body.callee.property.name === "then") {
1282
- const importCall = body.callee.object;
1283
- if (importCall?.type === "CallExpression" && importCall.callee?.type === "Import" && importCall.arguments[0]?.type === "StringLiteral") return resolveImportPath(importCall.arguments[0].value, baseDir);
2137
+ function findFirstCommaOutsideQuotes(str) {
2138
+ let inSingleQuote = false;
2139
+ let inDoubleQuote = false;
2140
+ for (let i = 0; i < str.length; i++) {
2141
+ const char = str[i];
2142
+ if (char === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
2143
+ else if (char === "\"" && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
2144
+ else if (char === "," && !inSingleQuote && !inDoubleQuote) return i;
2145
+ }
2146
+ return -1;
2147
+ }
2148
+
2149
+ //#endregion
2150
+ //#region src/utils/apiImportAnalyzer.ts
2151
+ /**
2152
+ * Analyze a file's content for API client imports.
2153
+ *
2154
+ * Supports:
2155
+ * - Named imports: `import { getUsers, createUser } from "@api/client"`
2156
+ * - Default imports: `import api from "@api/client"` (with warning)
2157
+ * - Namespace imports: `import * as api from "@api/client"` (with warning)
2158
+ *
2159
+ * @param content - The file content to analyze
2160
+ * @param config - API integration configuration
2161
+ * @returns Analysis result with detected imports and warnings
2162
+ */
2163
+ function analyzeApiImports(content, config) {
2164
+ const imports = [];
2165
+ const warnings = [];
2166
+ let ast;
2167
+ try {
2168
+ ast = parse(content, {
2169
+ sourceType: "module",
2170
+ plugins: ["typescript", "jsx"]
2171
+ });
2172
+ } catch (error) {
2173
+ if (error instanceof SyntaxError) {
2174
+ warnings.push(`Syntax error during import analysis: ${error.message}`);
2175
+ return {
2176
+ imports,
2177
+ warnings
2178
+ };
1284
2179
  }
1285
- if (body.type === "CallExpression" && body.callee?.type === "Import") {
1286
- if (body.arguments[0]?.type === "StringLiteral") return resolveImportPath(body.arguments[0].value, baseDir);
2180
+ const message = error instanceof Error ? error.message : String(error);
2181
+ warnings.push(`Failed to parse file for import analysis: ${message}`);
2182
+ return {
2183
+ imports,
2184
+ warnings
2185
+ };
2186
+ }
2187
+ const clientPackages = new Set(config.clientPackages);
2188
+ for (const node of ast.program.body) {
2189
+ if (node.type !== "ImportDeclaration") continue;
2190
+ const source = node.source.value;
2191
+ const line = node.loc?.start.line ?? 0;
2192
+ if (!isMatchingPackage(source, clientPackages)) continue;
2193
+ if (node.importKind === "type") continue;
2194
+ for (const specifier of node.specifiers) {
2195
+ if (specifier.type === "ImportSpecifier" && specifier.importKind === "type") continue;
2196
+ if (specifier.type === "ImportSpecifier") {
2197
+ const importedName = specifier.imported.type === "Identifier" ? specifier.imported.name : specifier.imported.value;
2198
+ const transformedName = config.extractApiName ? config.extractApiName(importedName) : importedName;
2199
+ imports.push({
2200
+ importName: importedName,
2201
+ packageName: source,
2202
+ dependsOnName: `${source}/${transformedName}`,
2203
+ line
2204
+ });
2205
+ } else if (specifier.type === "ImportDefaultSpecifier") warnings.push(`Default import from "${source}" at line ${line} cannot be statically analyzed. Consider using named imports.`);
2206
+ else if (specifier.type === "ImportNamespaceSpecifier") warnings.push(`Namespace import from "${source}" at line ${line} cannot be statically analyzed. Consider using named imports.`);
1287
2207
  }
1288
2208
  }
1289
- const loc = node.loc ? ` at line ${node.loc.start.line}` : "";
1290
- warnings.push(`Unrecognized loadChildren pattern (${node.type})${loc}. Expected arrow function with import().`);
2209
+ return {
2210
+ imports,
2211
+ warnings
2212
+ };
1291
2213
  }
1292
2214
  /**
1293
- * Detect if content is Angular Router based on patterns.
1294
- * Checks for @angular/router import, RouterModule patterns, or Routes type annotation.
2215
+ * Check if a source path matches any of the configured client packages.
2216
+ * Supports exact match and path prefix matching.
1295
2217
  */
1296
- function isAngularRouterContent(content) {
1297
- if (content.includes("@angular/router")) return true;
1298
- if (content.includes("RouterModule.forRoot") || content.includes("RouterModule.forChild")) return true;
1299
- if (/:\s*Routes\s*[=[]/.test(content)) return true;
2218
+ function isMatchingPackage(source, clientPackages) {
2219
+ if (clientPackages.has(source)) return true;
2220
+ for (const pkg of clientPackages) if (source.startsWith(`${pkg}/`)) return true;
1300
2221
  return false;
1301
2222
  }
1302
2223
 
@@ -2213,6 +3134,175 @@ function extractComponentPath(node, baseDir) {
2213
3134
  }
2214
3135
  }
2215
3136
 
3137
+ //#endregion
3138
+ //#region src/utils/vueSFCTemplateAnalyzer.ts
3139
+ /**
3140
+ * Analyze a Vue Single File Component for navigation patterns.
3141
+ *
3142
+ * Detects:
3143
+ * - Template: `<RouterLink to="/path">`, `<router-link to="/path">`
3144
+ * - Template: `:to="'/path'"` (static string binding)
3145
+ * - Script: `router.push("/path")`, `router.replace("/path")`
3146
+ * - Script: `router.push({ path: "/path" })` (object-based navigation)
3147
+ *
3148
+ * @param content - The Vue SFC content to analyze
3149
+ * @param filePath - The file path (used for error messages)
3150
+ * @returns Analysis result with detected navigations and warnings
3151
+ */
3152
+ function analyzeVueSFC(content, filePath) {
3153
+ const templateNavigations = [];
3154
+ const scriptNavigations = [];
3155
+ const warnings = [];
3156
+ try {
3157
+ const { descriptor, errors } = parse$1(content, {
3158
+ filename: filePath,
3159
+ sourceMap: false
3160
+ });
3161
+ for (const error of errors) warnings.push(`SFC parse error: ${error.message}`);
3162
+ if (descriptor.template?.ast) {
3163
+ const templateResult = analyzeTemplateAST(descriptor.template.ast.children, warnings);
3164
+ templateNavigations.push(...templateResult);
3165
+ }
3166
+ const scriptContent = descriptor.scriptSetup?.content || descriptor.script?.content;
3167
+ if (scriptContent) {
3168
+ const scriptResult = analyzeNavigation(scriptContent, "vue-router");
3169
+ scriptNavigations.push(...scriptResult.navigations);
3170
+ warnings.push(...scriptResult.warnings);
3171
+ }
3172
+ } catch (error) {
3173
+ if (error instanceof SyntaxError) warnings.push(`SFC syntax error in ${filePath}: ${error.message}`);
3174
+ else if (error instanceof RangeError) warnings.push(`${filePath}: File too complex for navigation analysis. Consider simplifying the template structure.`);
3175
+ else {
3176
+ const message = error instanceof Error ? error.message : String(error);
3177
+ warnings.push(`${filePath}: Unexpected error during analysis: ${message}. Please report this as a bug.`);
3178
+ }
3179
+ }
3180
+ return {
3181
+ templateNavigations: deduplicateByScreenId(templateNavigations),
3182
+ scriptNavigations: deduplicateByScreenId(scriptNavigations),
3183
+ warnings
3184
+ };
3185
+ }
3186
+ /**
3187
+ * Analyze template AST for RouterLink components.
3188
+ *
3189
+ * @param nodes - Array of template child nodes to traverse
3190
+ * @param warnings - Array to collect warnings during analysis (mutated)
3191
+ * @returns Array of detected navigation targets from RouterLink components
3192
+ */
3193
+ function analyzeTemplateAST(nodes, warnings) {
3194
+ const navigations = [];
3195
+ walkTemplateNodes(nodes, (node) => {
3196
+ if (node.type === NodeTypes.ELEMENT) {
3197
+ const elementNode = node;
3198
+ if (isRouterLinkComponent(elementNode.tag)) {
3199
+ const nav = extractRouterLinkNavigation(elementNode, warnings);
3200
+ if (nav) navigations.push(nav);
3201
+ }
3202
+ }
3203
+ });
3204
+ return navigations;
3205
+ }
3206
+ /**
3207
+ * Check if a tag is a RouterLink component
3208
+ */
3209
+ function isRouterLinkComponent(tag) {
3210
+ return tag === "RouterLink" || tag === "router-link";
3211
+ }
3212
+ /**
3213
+ * Extract navigation from RouterLink component.
3214
+ *
3215
+ * @param node - The element node representing a RouterLink component
3216
+ * @param warnings - Array to collect warnings (mutated)
3217
+ * @returns Detected navigation or null if no valid path found
3218
+ */
3219
+ function extractRouterLinkNavigation(node, warnings) {
3220
+ for (const prop of node.props) {
3221
+ if (prop.type === NodeTypes.ATTRIBUTE && prop.name === "to") {
3222
+ if (prop.value) {
3223
+ const path = prop.value.content;
3224
+ if (isValidInternalPath(path)) return createDetectedNavigation(path, "link", prop.loc.start.line);
3225
+ }
3226
+ }
3227
+ if (prop.type === NodeTypes.DIRECTIVE && prop.name === "bind") {
3228
+ if (prop.arg?.type === NodeTypes.SIMPLE_EXPRESSION && prop.arg.content === "to") return extractDynamicToBinding(prop, node.loc.start.line, warnings);
3229
+ }
3230
+ }
3231
+ return null;
3232
+ }
3233
+ /**
3234
+ * Extract path from dynamic :to binding.
3235
+ *
3236
+ * Handles expressions like `:to="'/path'"` or `v-bind:to="'/path'"`.
3237
+ * Complex expressions that cannot be statically analyzed generate warnings.
3238
+ *
3239
+ * @param directive - The Vue directive AST node
3240
+ * @param line - Line number for warning messages
3241
+ * @param warnings - Array to collect warnings (mutated)
3242
+ * @returns Detected navigation or null if path cannot be extracted
3243
+ */
3244
+ function extractDynamicToBinding(directive, line, warnings) {
3245
+ const exp = directive.exp;
3246
+ if (!exp) {
3247
+ warnings.push(`Empty :to binding at line ${line}. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
3248
+ return null;
3249
+ }
3250
+ if (exp.type === NodeTypes.SIMPLE_EXPRESSION) {
3251
+ const content = exp.content.trim();
3252
+ if (isStaticStringLiteral(content)) {
3253
+ const path = extractStringValue(content);
3254
+ if (path && isValidInternalPath(path)) return createDetectedNavigation(path, "link", line);
3255
+ }
3256
+ warnings.push(`Dynamic :to binding at line ${line} cannot be statically analyzed. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
3257
+ } else warnings.push(`Complex :to binding at line ${line} uses an unsupported expression type. Add the target screen ID manually to the 'next' field in screen.meta.ts.`);
3258
+ return null;
3259
+ }
3260
+ /**
3261
+ * Check if expression content is a static string literal.
3262
+ *
3263
+ * Recognizes single quotes, double quotes, and template literals without interpolation.
3264
+ *
3265
+ * @param content - The expression content (e.g., "'/path'" or "`/home`")
3266
+ * @returns true if the content is a statically analyzable string literal
3267
+ */
3268
+ function isStaticStringLiteral(content) {
3269
+ if (content.startsWith("'") && content.endsWith("'")) return true;
3270
+ if (content.startsWith("\"") && content.endsWith("\"")) return true;
3271
+ if (content.startsWith("`") && content.endsWith("`") && !content.includes("${")) return true;
3272
+ return false;
3273
+ }
3274
+ /**
3275
+ * Extract string value from a quoted string
3276
+ */
3277
+ function extractStringValue(content) {
3278
+ return content.slice(1, -1);
3279
+ }
3280
+ /**
3281
+ * Walk template AST nodes recursively in pre-order traversal.
3282
+ *
3283
+ * Traverses element nodes, v-if branches, and v-for loop bodies.
3284
+ *
3285
+ * @param nodes - Array of template child nodes to traverse
3286
+ * @param callback - Function called for each node visited
3287
+ */
3288
+ function walkTemplateNodes(nodes, callback) {
3289
+ for (const node of nodes) {
3290
+ callback(node);
3291
+ if (node.type === NodeTypes.ELEMENT) {
3292
+ const elementNode = node;
3293
+ if (elementNode.children) walkTemplateNodes(elementNode.children, callback);
3294
+ }
3295
+ if (node.type === NodeTypes.IF) {
3296
+ const ifNode = node;
3297
+ for (const branch of ifNode.branches || []) if (branch.children) walkTemplateNodes(branch.children, callback);
3298
+ }
3299
+ if (node.type === NodeTypes.FOR) {
3300
+ const forNode = node;
3301
+ if (forNode.children) walkTemplateNodes(forNode.children, callback);
3302
+ }
3303
+ }
3304
+ }
3305
+
2216
3306
  //#endregion
2217
3307
  //#region src/commands/generate.ts
2218
3308
  const generateCommand = define({
@@ -2241,23 +3331,59 @@ const generateCommand = define({
2241
3331
  short: "i",
2242
3332
  description: "Interactively confirm or modify each screen",
2243
3333
  default: false
3334
+ },
3335
+ detectApi: {
3336
+ type: "boolean",
3337
+ short: "a",
3338
+ description: "Detect API dependencies from imports (requires apiIntegration config)",
3339
+ default: false
3340
+ },
3341
+ detectNavigation: {
3342
+ type: "boolean",
3343
+ short: "N",
3344
+ description: "Detect navigation targets from code (Link, router.push, navigate)",
3345
+ default: false
3346
+ },
3347
+ verbose: {
3348
+ type: "boolean",
3349
+ short: "v",
3350
+ description: "Show detailed output including stack traces",
3351
+ default: false
2244
3352
  }
2245
3353
  },
2246
3354
  run: async (ctx) => {
3355
+ setVerbose(ctx.values.verbose);
2247
3356
  const config = await loadConfig(ctx.values.config);
2248
3357
  const cwd = process.cwd();
2249
3358
  const dryRun = ctx.values.dryRun ?? false;
2250
3359
  const force = ctx.values.force ?? false;
2251
3360
  const interactive = ctx.values.interactive ?? false;
3361
+ const detectApi = ctx.values.detectApi ?? false;
3362
+ const detectNavigation = ctx.values.detectNavigation ?? false;
2252
3363
  if (!config.routesPattern && !config.routesFile) {
2253
3364
  logger.errorWithHelp(ERRORS.ROUTES_PATTERN_MISSING);
2254
3365
  process.exit(1);
2255
3366
  }
3367
+ if (detectApi && !config.apiIntegration) {
3368
+ logger.error(`${logger.bold("--detect-api")} requires ${logger.code("apiIntegration")} configuration`);
3369
+ logger.blank();
3370
+ logger.log("Add to your screenbook.config.ts:");
3371
+ logger.blank();
3372
+ logger.log(logger.dim(` export default defineConfig({
3373
+ apiIntegration: {
3374
+ clientPackages: ["@/api/generated"],
3375
+ },
3376
+ })`));
3377
+ process.exit(1);
3378
+ }
2256
3379
  if (config.routesFile) {
2257
3380
  await generateFromRoutesFile(config.routesFile, cwd, {
2258
3381
  dryRun,
2259
3382
  force,
2260
- interactive
3383
+ interactive,
3384
+ detectApi,
3385
+ detectNavigation,
3386
+ apiIntegration: config.apiIntegration
2261
3387
  });
2262
3388
  return;
2263
3389
  }
@@ -2265,7 +3391,10 @@ const generateCommand = define({
2265
3391
  dryRun,
2266
3392
  force,
2267
3393
  interactive,
2268
- ignore: config.ignore
3394
+ ignore: config.ignore,
3395
+ detectApi,
3396
+ detectNavigation,
3397
+ apiIntegration: config.apiIntegration
2269
3398
  });
2270
3399
  }
2271
3400
  });
@@ -2273,7 +3402,7 @@ const generateCommand = define({
2273
3402
  * Generate screen.meta.ts files from a router config file (Vue Router or React Router)
2274
3403
  */
2275
3404
  async function generateFromRoutesFile(routesFile, cwd, options) {
2276
- const { dryRun, force, interactive } = options;
3405
+ const { dryRun, force, interactive, detectApi, detectNavigation, apiIntegration } = options;
2277
3406
  const absoluteRoutesFile = resolve(cwd, routesFile);
2278
3407
  if (!existsSync(absoluteRoutesFile)) {
2279
3408
  logger.errorWithHelp(ERRORS.ROUTES_FILE_NOT_FOUND(routesFile));
@@ -2327,6 +3456,47 @@ async function generateFromRoutesFile(routesFile, cwd, options) {
2327
3456
  title: route.screenTitle,
2328
3457
  route: route.fullPath
2329
3458
  };
3459
+ let detectedApis = [];
3460
+ if (detectApi && apiIntegration && route.componentPath) {
3461
+ const componentAbsPath = resolve(cwd, route.componentPath);
3462
+ if (existsSync(componentAbsPath)) try {
3463
+ const result = analyzeApiImports(readFileSync(componentAbsPath, "utf-8"), apiIntegration);
3464
+ detectedApis = result.imports.map((i) => i.dependsOnName);
3465
+ for (const warning of result.warnings) logger.warn(`${logger.path(route.componentPath)}: ${warning}`);
3466
+ } catch (error) {
3467
+ const message = error instanceof Error ? error.message : String(error);
3468
+ logger.warn(`${logger.path(route.componentPath)}: Could not analyze for API imports: ${message}`);
3469
+ }
3470
+ }
3471
+ let detectedNext = [];
3472
+ if (detectNavigation && route.componentPath) {
3473
+ const componentAbsPath = resolve(cwd, route.componentPath);
3474
+ if (existsSync(componentAbsPath)) {
3475
+ let componentContent;
3476
+ try {
3477
+ componentContent = readFileSync(componentAbsPath, "utf-8");
3478
+ } catch (error) {
3479
+ const message = error instanceof Error ? error.message : String(error);
3480
+ logger.warn(`${logger.path(route.componentPath)}: Could not read file for navigation analysis: ${message}`);
3481
+ componentContent = "";
3482
+ }
3483
+ if (componentContent) if (route.componentPath.endsWith(".vue")) {
3484
+ const result = analyzeVueSFC(componentContent, route.componentPath);
3485
+ detectedNext = [...result.templateNavigations.map((n) => n.screenId), ...result.scriptNavigations.map((n) => n.screenId)];
3486
+ for (const warning of result.warnings) logger.warn(`${logger.path(route.componentPath)}: ${warning}`);
3487
+ } else if (route.componentPath.endsWith(".component.ts")) {
3488
+ const result = analyzeAngularComponent(componentContent, route.componentPath, cwd);
3489
+ detectedNext = [...result.templateNavigations.map((n) => n.screenId), ...result.scriptNavigations.map((n) => n.screenId)];
3490
+ for (const warning of result.warnings) logger.warn(`${logger.path(route.componentPath)}: ${warning}`);
3491
+ } else {
3492
+ const { framework, detected } = detectNavigationFramework(componentContent);
3493
+ if (!detected) logger.warn(`${logger.path(route.componentPath)}: Could not detect navigation framework, defaulting to Next.js patterns. Navigation detection may be incomplete.`);
3494
+ const result = analyzeNavigation(componentContent, framework);
3495
+ detectedNext = result.navigations.map((n) => n.screenId);
3496
+ for (const warning of result.warnings) logger.warn(`${logger.path(route.componentPath)}: ${warning}`);
3497
+ }
3498
+ }
3499
+ }
2330
3500
  if (interactive) {
2331
3501
  const result = await promptForScreen(route.fullPath, screenMeta);
2332
3502
  if (result.skip) {
@@ -2336,16 +3506,21 @@ async function generateFromRoutesFile(routesFile, cwd, options) {
2336
3506
  }
2337
3507
  const content = generateScreenMetaContent(result.meta, {
2338
3508
  owner: result.owner,
2339
- tags: result.tags
3509
+ tags: result.tags,
3510
+ dependsOn: detectedApis,
3511
+ next: detectedNext
2340
3512
  });
2341
3513
  if (dryRun) {
2342
- logDryRunOutput(metaPath, result.meta, result.owner, result.tags);
3514
+ logDryRunOutput(metaPath, result.meta, result.owner, result.tags, detectedApis, detectedNext);
2343
3515
  created++;
2344
3516
  } else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
2345
3517
  } else {
2346
- const content = generateScreenMetaContent(screenMeta);
3518
+ const content = generateScreenMetaContent(screenMeta, {
3519
+ dependsOn: detectedApis,
3520
+ next: detectedNext
3521
+ });
2347
3522
  if (dryRun) {
2348
- logDryRunOutput(metaPath, screenMeta);
3523
+ logDryRunOutput(metaPath, screenMeta, void 0, void 0, detectedApis, detectedNext);
2349
3524
  created++;
2350
3525
  } else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
2351
3526
  }
@@ -2356,7 +3531,7 @@ async function generateFromRoutesFile(routesFile, cwd, options) {
2356
3531
  * Generate screen.meta.ts files from route files matching a glob pattern
2357
3532
  */
2358
3533
  async function generateFromRoutesPattern(routesPattern, cwd, options) {
2359
- const { dryRun, force, interactive, ignore } = options;
3534
+ const { dryRun, force, interactive, ignore, detectApi, detectNavigation, apiIntegration } = options;
2360
3535
  logger.info("Scanning for route files...");
2361
3536
  logger.blank();
2362
3537
  const routeFiles = await glob(routesPattern, {
@@ -2385,6 +3560,39 @@ async function generateFromRoutesPattern(routesPattern, cwd, options) {
2385
3560
  continue;
2386
3561
  }
2387
3562
  const screenMeta = inferScreenMeta(routeDir, routesPattern);
3563
+ let detectedApis = [];
3564
+ if (detectApi && apiIntegration) {
3565
+ const absoluteRouteFile = join(cwd, routeFile);
3566
+ if (existsSync(absoluteRouteFile)) try {
3567
+ const result = analyzeApiImports(readFileSync(absoluteRouteFile, "utf-8"), apiIntegration);
3568
+ detectedApis = result.imports.map((i) => i.dependsOnName);
3569
+ for (const warning of result.warnings) logger.warn(`${logger.path(routeFile)}: ${warning}`);
3570
+ } catch (error) {
3571
+ const message = error instanceof Error ? error.message : String(error);
3572
+ logger.warn(`${logger.path(routeFile)}: Could not analyze for API imports: ${message}`);
3573
+ }
3574
+ }
3575
+ let detectedNext = [];
3576
+ if (detectNavigation) {
3577
+ const absoluteRouteFile = join(cwd, routeFile);
3578
+ if (existsSync(absoluteRouteFile)) {
3579
+ let routeContent;
3580
+ try {
3581
+ routeContent = readFileSync(absoluteRouteFile, "utf-8");
3582
+ } catch (error) {
3583
+ const message = error instanceof Error ? error.message : String(error);
3584
+ logger.warn(`${logger.path(routeFile)}: Could not read file for navigation analysis: ${message}`);
3585
+ routeContent = "";
3586
+ }
3587
+ if (routeContent) {
3588
+ const { framework, detected } = detectNavigationFramework(routeContent);
3589
+ if (!detected) logger.warn(`${logger.path(routeFile)}: Could not detect navigation framework, defaulting to Next.js patterns. Navigation detection may be incomplete.`);
3590
+ const result = analyzeNavigation(routeContent, framework);
3591
+ detectedNext = result.navigations.map((n) => n.screenId);
3592
+ for (const warning of result.warnings) logger.warn(`${logger.path(routeFile)}: ${warning}`);
3593
+ }
3594
+ }
3595
+ }
2388
3596
  if (interactive) {
2389
3597
  const result = await promptForScreen(routeFile, screenMeta);
2390
3598
  if (result.skip) {
@@ -2394,16 +3602,21 @@ async function generateFromRoutesPattern(routesPattern, cwd, options) {
2394
3602
  }
2395
3603
  const content = generateScreenMetaContent(result.meta, {
2396
3604
  owner: result.owner,
2397
- tags: result.tags
3605
+ tags: result.tags,
3606
+ dependsOn: detectedApis,
3607
+ next: detectedNext
2398
3608
  });
2399
3609
  if (dryRun) {
2400
- logDryRunOutput(metaPath, result.meta, result.owner, result.tags);
3610
+ logDryRunOutput(metaPath, result.meta, result.owner, result.tags, detectedApis, detectedNext);
2401
3611
  created++;
2402
3612
  } else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
2403
3613
  } else {
2404
- const content = generateScreenMetaContent(screenMeta);
3614
+ const content = generateScreenMetaContent(screenMeta, {
3615
+ dependsOn: detectedApis,
3616
+ next: detectedNext
3617
+ });
2405
3618
  if (dryRun) {
2406
- logDryRunOutput(metaPath, screenMeta);
3619
+ logDryRunOutput(metaPath, screenMeta, void 0, void 0, detectedApis, detectedNext);
2407
3620
  created++;
2408
3621
  } else if (safeWriteFile(absoluteMetaPath, metaPath, content)) created++;
2409
3622
  }
@@ -2451,13 +3664,15 @@ function safeWriteFile(absolutePath, relativePath, content) {
2451
3664
  /**
2452
3665
  * Log dry run output for a screen
2453
3666
  */
2454
- function logDryRunOutput(metaPath, meta, owner, tags) {
3667
+ function logDryRunOutput(metaPath, meta, owner, tags, dependsOn, next) {
2455
3668
  logger.step(`Would create: ${logger.path(metaPath)}`);
2456
3669
  logger.log(` ${logger.dim(`id: "${meta.id}"`)}`);
2457
3670
  logger.log(` ${logger.dim(`title: "${meta.title}"`)}`);
2458
3671
  logger.log(` ${logger.dim(`route: "${meta.route}"`)}`);
2459
3672
  if (owner && owner.length > 0) logger.log(` ${logger.dim(`owner: [${owner.map((o) => `"${o}"`).join(", ")}]`)}`);
2460
3673
  if (tags && tags.length > 0) logger.log(` ${logger.dim(`tags: [${tags.map((t) => `"${t}"`).join(", ")}]`)}`);
3674
+ if (dependsOn && dependsOn.length > 0) logger.log(` ${logger.dim(`dependsOn: [${dependsOn.map((d) => `"${d}"`).join(", ")}]`)}`);
3675
+ if (next && next.length > 0) logger.log(` ${logger.dim(`next: [${next.map((n) => `"${n}"`).join(", ")}]`)}`);
2461
3676
  logger.blank();
2462
3677
  }
2463
3678
  /**
@@ -2573,8 +3788,15 @@ function inferScreenMeta(routeDir, routesPattern) {
2573
3788
  function generateScreenMetaContent(meta, options) {
2574
3789
  const owner = options?.owner ?? [];
2575
3790
  const tags = options?.tags && options.tags.length > 0 ? options.tags : [meta.id.split(".")[0] || "general"];
3791
+ const dependsOn = options?.dependsOn ?? [];
3792
+ const next = options?.next ?? [];
2576
3793
  const ownerStr = owner.length > 0 ? `[${owner.map((o) => `"${o}"`).join(", ")}]` : "[]";
2577
3794
  const tagsStr = `[${tags.map((t) => `"${t}"`).join(", ")}]`;
3795
+ const dependsOnStr = dependsOn.length > 0 ? `[${dependsOn.map((d) => `"${d}"`).join(", ")}]` : "[]";
3796
+ const nextStr = next.length > 0 ? `[${next.map((n) => `"${n}"`).join(", ")}]` : "[]";
3797
+ const dependsOnComment = dependsOn.length > 0 ? "// Auto-detected API dependencies (add more as needed)" : `// APIs/services this screen depends on (for impact analysis)
3798
+ // Example: ["UserAPI.getProfile", "PaymentService.checkout"]`;
3799
+ const nextComment = next.length > 0 ? "// Auto-detected navigation targets (add more as needed)" : "// Screen IDs this screen can navigate to";
2578
3800
  return `import { defineScreen } from "@screenbook/core"
2579
3801
 
2580
3802
  export const screen = defineScreen({
@@ -2588,15 +3810,14 @@ export const screen = defineScreen({
2588
3810
  // Tags for filtering in the catalog
2589
3811
  tags: ${tagsStr},
2590
3812
 
2591
- // APIs/services this screen depends on (for impact analysis)
2592
- // Example: ["UserAPI.getProfile", "PaymentService.checkout"]
2593
- dependsOn: [],
3813
+ ${dependsOnComment}
3814
+ dependsOn: ${dependsOnStr},
2594
3815
 
2595
3816
  // Screen IDs that can navigate to this screen
2596
3817
  entryPoints: [],
2597
3818
 
2598
- // Screen IDs this screen can navigate to
2599
- next: [],
3819
+ ${nextComment}
3820
+ next: ${nextStr},
2600
3821
  })
2601
3822
  `;
2602
3823
  }
@@ -2776,9 +3997,16 @@ const impactCommand = define({
2776
3997
  short: "d",
2777
3998
  description: "Maximum depth for transitive dependencies",
2778
3999
  default: 3
4000
+ },
4001
+ verbose: {
4002
+ type: "boolean",
4003
+ short: "v",
4004
+ description: "Show detailed output including stack traces",
4005
+ default: false
2779
4006
  }
2780
4007
  },
2781
4008
  run: async (ctx) => {
4009
+ setVerbose(ctx.values.verbose);
2782
4010
  const config = await loadConfig(ctx.values.config);
2783
4011
  const cwd = process.cwd();
2784
4012
  const apiName = ctx.values.api;
@@ -3105,7 +4333,10 @@ async function runGenerate(routesPattern, cwd) {
3105
4333
  dryRun: false,
3106
4334
  force: false,
3107
4335
  interactive: false,
3108
- ignore: ["**/node_modules/**"]
4336
+ ignore: ["**/node_modules/**"],
4337
+ detectApi: false,
4338
+ detectNavigation: false,
4339
+ apiIntegration: void 0
3109
4340
  });
3110
4341
  }
3111
4342
  async function buildScreensForDev(metaPattern, outDir, cwd) {
@@ -3232,9 +4463,16 @@ const initCommand = define({
3232
4463
  short: "p",
3233
4464
  description: "Port for the dev server",
3234
4465
  default: "4321"
4466
+ },
4467
+ verbose: {
4468
+ type: "boolean",
4469
+ short: "v",
4470
+ description: "Show detailed output including stack traces",
4471
+ default: false
3235
4472
  }
3236
4473
  },
3237
4474
  run: async (ctx) => {
4475
+ setVerbose(ctx.values.verbose);
3238
4476
  const cwd = process.cwd();
3239
4477
  const force = ctx.values.force ?? false;
3240
4478
  const skipDetect = ctx.values.skipDetect ?? false;
@@ -3332,6 +4570,217 @@ const initCommand = define({
3332
4570
  }
3333
4571
  });
3334
4572
 
4573
+ //#endregion
4574
+ //#region src/utils/openApiParser.ts
4575
+ /**
4576
+ * Check if a string looks like a URL
4577
+ */
4578
+ function isUrl(source) {
4579
+ return source.startsWith("http://") || source.startsWith("https://");
4580
+ }
4581
+ /**
4582
+ * Resolve a source path relative to the current working directory
4583
+ */
4584
+ function resolveSource(source, cwd) {
4585
+ if (isUrl(source)) return source;
4586
+ if (isAbsolute(source)) return source;
4587
+ return resolve(cwd, source);
4588
+ }
4589
+ /**
4590
+ * HTTP methods to extract from OpenAPI specs
4591
+ */
4592
+ const HTTP_METHODS = [
4593
+ "get",
4594
+ "post",
4595
+ "put",
4596
+ "delete",
4597
+ "patch",
4598
+ "options",
4599
+ "head"
4600
+ ];
4601
+ /**
4602
+ * Extract API identifiers from a parsed OpenAPI document
4603
+ */
4604
+ function extractApiIdentifiers(api, source) {
4605
+ const operationIds = /* @__PURE__ */ new Set();
4606
+ const httpEndpoints = /* @__PURE__ */ new Set();
4607
+ const normalizedToOriginal = /* @__PURE__ */ new Map();
4608
+ const paths = api.paths;
4609
+ if (!paths) return {
4610
+ source,
4611
+ operationIds,
4612
+ httpEndpoints,
4613
+ normalizedToOriginal
4614
+ };
4615
+ for (const [path, pathItem] of Object.entries(paths)) {
4616
+ if (!pathItem || typeof pathItem !== "object") continue;
4617
+ for (const method of HTTP_METHODS) {
4618
+ const operation = pathItem[method];
4619
+ if (!operation) continue;
4620
+ if (operation.operationId) {
4621
+ operationIds.add(operation.operationId);
4622
+ normalizedToOriginal.set(operation.operationId.toLowerCase(), operation.operationId);
4623
+ }
4624
+ const httpEndpoint = `${method.toUpperCase()} ${path}`;
4625
+ httpEndpoints.add(httpEndpoint);
4626
+ normalizedToOriginal.set(httpEndpoint.toLowerCase(), httpEndpoint);
4627
+ }
4628
+ }
4629
+ return {
4630
+ source,
4631
+ operationIds,
4632
+ httpEndpoints,
4633
+ normalizedToOriginal
4634
+ };
4635
+ }
4636
+ /**
4637
+ * Parse a single OpenAPI source (file or URL)
4638
+ */
4639
+ async function parseOpenApiSource(source, cwd) {
4640
+ const resolvedSource = resolveSource(source, cwd);
4641
+ return extractApiIdentifiers(await SwaggerParser.parse(resolvedSource), source);
4642
+ }
4643
+ /**
4644
+ * Parse multiple OpenAPI specification sources
4645
+ *
4646
+ * @param sources - Array of file paths or URLs to OpenAPI specifications
4647
+ * @param cwd - Current working directory for resolving relative paths
4648
+ * @returns Parsed specifications and any errors encountered
4649
+ *
4650
+ * @example
4651
+ * ```ts
4652
+ * const result = await parseOpenApiSpecs(
4653
+ * ["./openapi.yaml", "https://api.example.com/openapi.json"],
4654
+ * process.cwd()
4655
+ * )
4656
+ *
4657
+ * for (const spec of result.specs) {
4658
+ * console.log(`Parsed ${spec.source}:`)
4659
+ * console.log(` ${spec.operationIds.size} operation IDs`)
4660
+ * console.log(` ${spec.httpEndpoints.size} HTTP endpoints`)
4661
+ * }
4662
+ *
4663
+ * for (const error of result.errors) {
4664
+ * console.error(`Failed to parse ${error.source}: ${error.message}`)
4665
+ * }
4666
+ * ```
4667
+ */
4668
+ async function parseOpenApiSpecs(sources, cwd) {
4669
+ const specs = [];
4670
+ const errors = [];
4671
+ for (const source of sources) try {
4672
+ const spec = await parseOpenApiSource(source, cwd);
4673
+ specs.push(spec);
4674
+ } catch (error) {
4675
+ errors.push({
4676
+ source,
4677
+ message: error instanceof Error ? error.message : String(error)
4678
+ });
4679
+ }
4680
+ return {
4681
+ specs,
4682
+ errors
4683
+ };
4684
+ }
4685
+ /**
4686
+ * Get all valid API identifiers from parsed specs (for suggestions)
4687
+ */
4688
+ function getAllApiIdentifiers(specs) {
4689
+ const identifiers = [];
4690
+ for (const spec of specs) {
4691
+ identifiers.push(...spec.operationIds);
4692
+ identifiers.push(...spec.httpEndpoints);
4693
+ }
4694
+ return identifiers;
4695
+ }
4696
+
4697
+ //#endregion
4698
+ //#region src/utils/dependsOnValidation.ts
4699
+ /**
4700
+ * Normalize a dependsOn value for matching
4701
+ * - HTTP format: lowercase the method, keep path as-is
4702
+ * - operationId: return as-is for exact match first, then case-insensitive fallback
4703
+ */
4704
+ function normalizeForMatching(value) {
4705
+ const httpMatch = value.match(/^(GET|POST|PUT|DELETE|PATCH|OPTIONS|HEAD)\s+/i);
4706
+ if (httpMatch?.[1]) return {
4707
+ normalized: `${httpMatch[1].toLowerCase()} ${value.slice(httpMatch[0].length)}`,
4708
+ isHttpFormat: true
4709
+ };
4710
+ return {
4711
+ normalized: value,
4712
+ isHttpFormat: false
4713
+ };
4714
+ }
4715
+ /**
4716
+ * Check if a dependsOn value matches any API in the parsed specs
4717
+ */
4718
+ function matchesOpenApiSpec(dependsOnValue, specs) {
4719
+ const { normalized, isHttpFormat } = normalizeForMatching(dependsOnValue);
4720
+ for (const spec of specs) if (isHttpFormat) {
4721
+ if (spec.normalizedToOriginal.has(normalized)) return true;
4722
+ } else {
4723
+ if (spec.operationIds.has(dependsOnValue)) return true;
4724
+ if (spec.normalizedToOriginal.has(normalized.toLowerCase())) return true;
4725
+ }
4726
+ return false;
4727
+ }
4728
+ /**
4729
+ * Find a suggestion for an invalid API reference using fuzzy matching
4730
+ */
4731
+ function findApiSuggestion(invalidApi, specs) {
4732
+ const allIdentifiers = getAllApiIdentifiers(specs);
4733
+ if (allIdentifiers.length === 0) return;
4734
+ return findBestMatch(invalidApi, allIdentifiers, .5);
4735
+ }
4736
+ /**
4737
+ * Validate dependsOn references against OpenAPI specifications
4738
+ *
4739
+ * @param screens - Array of screen definitions to validate
4740
+ * @param specs - Parsed OpenAPI specifications to validate against
4741
+ * @returns Validation result with any errors found
4742
+ *
4743
+ * @example
4744
+ * ```ts
4745
+ * const screens = [
4746
+ * { id: "invoice.detail", dependsOn: ["getInvoiceById", "unknownApi"] },
4747
+ * ]
4748
+ * const specs = await parseOpenApiSpecs(["./openapi.yaml"], cwd)
4749
+ *
4750
+ * const result = validateDependsOnReferences(screens, specs.specs)
4751
+ * if (!result.valid) {
4752
+ * for (const error of result.errors) {
4753
+ * console.log(`${error.screenId}: ${error.invalidApi}`)
4754
+ * if (error.suggestion) {
4755
+ * console.log(` Did you mean "${error.suggestion}"?`)
4756
+ * }
4757
+ * }
4758
+ * }
4759
+ * ```
4760
+ */
4761
+ function validateDependsOnReferences(screens, specs) {
4762
+ const errors = [];
4763
+ for (const screen of screens) {
4764
+ if (!screen.dependsOn || screen.dependsOn.length === 0) continue;
4765
+ for (const dep of screen.dependsOn) if (!matchesOpenApiSpec(dep, specs)) {
4766
+ const suggestion = findApiSuggestion(dep, specs);
4767
+ errors.push({
4768
+ screenId: screen.id,
4769
+ invalidApi: dep,
4770
+ suggestion
4771
+ });
4772
+ }
4773
+ }
4774
+ if (errors.length === 0) return {
4775
+ valid: true,
4776
+ errors: []
4777
+ };
4778
+ return {
4779
+ valid: false,
4780
+ errors
4781
+ };
4782
+ }
4783
+
3335
4784
  //#endregion
3336
4785
  //#region src/commands/lint.ts
3337
4786
  const lintCommand = define({
@@ -3353,9 +4802,16 @@ const lintCommand = define({
3353
4802
  short: "s",
3354
4803
  description: "Fail on disallowed cycles",
3355
4804
  default: false
4805
+ },
4806
+ verbose: {
4807
+ type: "boolean",
4808
+ short: "v",
4809
+ description: "Show detailed output including stack traces",
4810
+ default: false
3356
4811
  }
3357
4812
  },
3358
4813
  run: async (ctx) => {
4814
+ setVerbose(ctx.values.verbose);
3359
4815
  const config = await loadConfig(ctx.values.config);
3360
4816
  const cwd = process.cwd();
3361
4817
  const adoption = config.adoption ?? { mode: "full" };
@@ -3471,6 +4927,9 @@ const lintCommand = define({
3471
4927
  logger.blank();
3472
4928
  logger.log(` ${logger.dim("Check that these screen IDs exist in your codebase.")}`);
3473
4929
  }
4930
+ if (config.apiIntegration?.openapi?.sources?.length) {
4931
+ if ((await validateDependsOnAgainstOpenApi(screens, config.apiIntegration.openapi.sources, cwd, ctx.values.strict ?? false)).hasWarnings) hasWarnings = true;
4932
+ }
3474
4933
  } catch (error) {
3475
4934
  if (error instanceof SyntaxError) {
3476
4935
  logger.warn("Failed to parse screens.json - file may be corrupted");
@@ -3645,6 +5104,9 @@ async function lintRoutesFile(routesFile, cwd, config, adoption, allowCycles, st
3645
5104
  logger.blank();
3646
5105
  logger.log(` ${logger.dim("Check that these screen IDs exist in your codebase.")}`);
3647
5106
  }
5107
+ if (config.apiIntegration?.openapi?.sources?.length) {
5108
+ if ((await validateDependsOnAgainstOpenApi(screens, config.apiIntegration.openapi.sources, cwd, strict)).hasWarnings) hasWarnings = true;
5109
+ }
3648
5110
  } catch (error) {
3649
5111
  if (error instanceof SyntaxError) {
3650
5112
  logger.warn("Failed to parse screens.json - file may be corrupted");
@@ -3722,6 +5184,39 @@ function findOrphanScreens(screens) {
3722
5184
  }
3723
5185
  return orphans;
3724
5186
  }
5187
+ /**
5188
+ * Validate dependsOn references against OpenAPI specifications
5189
+ */
5190
+ async function validateDependsOnAgainstOpenApi(screens, sources, cwd, strict) {
5191
+ let hasWarnings = false;
5192
+ const parseResult = await parseOpenApiSpecs(sources, cwd);
5193
+ for (const error of parseResult.errors) {
5194
+ hasWarnings = true;
5195
+ logger.blank();
5196
+ logger.warn(`Failed to parse OpenAPI spec: ${error.source}`);
5197
+ logger.log(` ${logger.dim(error.message)}`);
5198
+ }
5199
+ if (parseResult.specs.length === 0) return { hasWarnings };
5200
+ const validationResult = validateDependsOnReferences(screens, parseResult.specs);
5201
+ if (validationResult.errors.length > 0) {
5202
+ hasWarnings = true;
5203
+ logger.blank();
5204
+ logger.warn(`Invalid API dependencies (${validationResult.errors.length}):`);
5205
+ logger.blank();
5206
+ logger.log(" These dependsOn references don't match any OpenAPI operation.");
5207
+ logger.blank();
5208
+ for (const error of validationResult.errors) {
5209
+ logger.itemWarn(`${error.screenId}: "${error.invalidApi}"`);
5210
+ if (error.suggestion) logger.log(` ${logger.dim(`Did you mean "${error.suggestion}"?`)}`);
5211
+ }
5212
+ if (strict) {
5213
+ logger.blank();
5214
+ logger.errorWithHelp(ERRORS.INVALID_API_DEPENDENCIES(validationResult.errors.length));
5215
+ process.exit(1);
5216
+ }
5217
+ }
5218
+ return { hasWarnings };
5219
+ }
3725
5220
 
3726
5221
  //#endregion
3727
5222
  //#region src/utils/prImpact.ts
@@ -3834,9 +5329,16 @@ const prImpactCommand = define({
3834
5329
  short: "d",
3835
5330
  description: "Maximum depth for transitive dependencies",
3836
5331
  default: 3
5332
+ },
5333
+ verbose: {
5334
+ type: "boolean",
5335
+ short: "v",
5336
+ description: "Show detailed output including stack traces",
5337
+ default: false
3837
5338
  }
3838
5339
  },
3839
5340
  run: async (ctx) => {
5341
+ setVerbose(ctx.values.verbose);
3840
5342
  const config = await loadConfig(ctx.values.config);
3841
5343
  const cwd = process.cwd();
3842
5344
  const baseBranch = ctx.values.base ?? "main";
@@ -3936,6 +5438,7 @@ const mainCommand = define({
3936
5438
  console.log(" lint Detect routes without screen.meta");
3937
5439
  console.log(" impact Analyze API dependency impact");
3938
5440
  console.log(" pr-impact Analyze PR changes impact");
5441
+ console.log(" badge Generate coverage badge for README");
3939
5442
  console.log(" doctor Diagnose common setup issues");
3940
5443
  console.log("");
3941
5444
  console.log("Run 'screenbook <command> --help' for more information");
@@ -3952,6 +5455,7 @@ await cli(process.argv.slice(2), mainCommand, {
3952
5455
  lint: lintCommand,
3953
5456
  impact: impactCommand,
3954
5457
  "pr-impact": prImpactCommand,
5458
+ badge: badgeCommand,
3955
5459
  doctor: doctorCommand
3956
5460
  }
3957
5461
  });