@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 +1714 -210
- package/dist/index.mjs.map +1 -1
- package/package.json +5 -1
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(` })`));
|
|
492
|
+
} else if (format === "shields-json") {
|
|
493
|
+
logger.blank();
|
|
494
|
+
logger.log(logger.dim("Usage with shields.io:"));
|
|
495
|
+
logger.log(logger.dim(" "));
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
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
|
-
*
|
|
1276
|
-
*
|
|
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
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
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
|
-
|
|
1286
|
-
|
|
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
|
-
|
|
1290
|
-
|
|
2209
|
+
return {
|
|
2210
|
+
imports,
|
|
2211
|
+
warnings
|
|
2212
|
+
};
|
|
1291
2213
|
}
|
|
1292
2214
|
/**
|
|
1293
|
-
*
|
|
1294
|
-
*
|
|
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
|
|
1297
|
-
if (
|
|
1298
|
-
|
|
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
|
-
|
|
2592
|
-
|
|
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
|
-
|
|
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
|
});
|