scenescout 1.0.0 → 1.1.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.
@@ -0,0 +1,462 @@
1
+ /**
2
+ * Route discovery for frameworks that define routes in CODE rather than in the
3
+ * filesystem: React Router, Vue Router and Angular.
4
+ *
5
+ * Without this, those projects fell back to link discovery, so a page nothing
6
+ * linked to was outside the completion contract. The three routers share two
7
+ * shapes — route records written as object literals (`{ path, children }`) and,
8
+ * for React, `<Route path>` elements — so one reader covers them.
9
+ *
10
+ * It is a static READER, not an evaluator, and it prefers missing a route to
11
+ * inventing one: an invented route becomes a page the contract demands and the
12
+ * app does not have. So it only reads files that are recognisably router
13
+ * configuration, only accepts object literals that look like route records,
14
+ * and skips anything it cannot resolve (identifiers, spreads, computed paths).
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ /** Keys that make an object literal with a `path` a route record rather than, say, a build config. */
19
+ /** `name` and `meta` are deliberately absent: a sidebar or breadcrumb entry is `{ path, name }` too. */
20
+ const ROUTE_RECORD_KEYS = [
21
+ "element",
22
+ "Component",
23
+ "component",
24
+ "components",
25
+ "children",
26
+ "loadComponent",
27
+ "loadChildren",
28
+ "lazy",
29
+ "loader",
30
+ "redirect",
31
+ "redirectTo",
32
+ "index",
33
+ ];
34
+ /** Read a JS string literal starting at `i` (which points at the quote). Returns the value and the index after it, or null for a template literal with interpolation. */
35
+ function readString(src, i) {
36
+ const quote = src[i];
37
+ // A ' or " string cannot span lines. When there is no closing quote before
38
+ // the newline this is not a string at all — an apostrophe in JSX text
39
+ // ("couldn't"), a quote inside a regex (/['"]/) — and treating it as one
40
+ // swallowed the rest of the file, routes included.
41
+ if (quote !== "`") {
42
+ let k = i + 1;
43
+ while (k < src.length && src[k] !== quote && src[k] !== "\n")
44
+ k += src[k] === "\\" ? 2 : 1;
45
+ if (src[k] !== quote)
46
+ return { value: null, end: i + 1 };
47
+ }
48
+ let j = i + 1;
49
+ let value = "";
50
+ let interpolated = false;
51
+ while (j < src.length && src[j] !== quote) {
52
+ if (src[j] === "\\") {
53
+ value += src[j + 1] ?? "";
54
+ j += 2;
55
+ continue;
56
+ }
57
+ if (quote === "`" && src[j] === "$" && src[j + 1] === "{")
58
+ interpolated = true;
59
+ value += src[j];
60
+ j += 1;
61
+ }
62
+ return { value: interpolated ? null : value, end: j + 1 };
63
+ }
64
+ /** Route records written as object literals, in source order, with their nesting. */
65
+ export function readRouteObjects(src) {
66
+ const frames = [];
67
+ const stack = [];
68
+ let lastSignificant = "";
69
+ let i = 0;
70
+ const nearestObj = () => {
71
+ for (let k = stack.length - 1; k >= 0; k--)
72
+ if (frames[stack[k]].kind === "obj")
73
+ return stack[k];
74
+ return -1;
75
+ };
76
+ while (i < src.length) {
77
+ const c = src[i];
78
+ if (c === "/" && src[i + 1] === "/") {
79
+ while (i < src.length && src[i] !== "\n")
80
+ i += 1;
81
+ continue;
82
+ }
83
+ if (c === "/" && src[i + 1] === "*") {
84
+ const end = src.indexOf("*/", i + 2);
85
+ i = end === -1 ? src.length : end + 2;
86
+ continue;
87
+ }
88
+ if (c === '"' || c === "'" || c === "`") {
89
+ const { value, end } = readString(src, i);
90
+ const owner = nearestObj();
91
+ const top = stack[stack.length - 1];
92
+ if (owner !== -1 && value !== null) {
93
+ const f = frames[owner];
94
+ // A quoted KEY: `'path': '/x'`.
95
+ let k = end;
96
+ while (k < src.length && /\s/.test(src[k]))
97
+ k += 1;
98
+ if (top === owner && (lastSignificant === "{" || lastSignificant === ",") && src[k] === ":") {
99
+ f.currentKey = value;
100
+ f.keys.add(value);
101
+ }
102
+ else if (top === owner && f.currentKey === "path" && lastSignificant === ":") {
103
+ f.path = value;
104
+ }
105
+ else if (f.currentKey === "loadChildren" && /import\s*\(\s*$/.test(src.slice(Math.max(0, i - 12), i))) {
106
+ f.lazyImport = value;
107
+ }
108
+ }
109
+ lastSignificant = "s";
110
+ i = end;
111
+ continue;
112
+ }
113
+ if (c === "{" || c === "[" || c === "(") {
114
+ // `{` opens an object literal when it follows something a value can follow.
115
+ const isObject = c === "{" && /[[(,:=?]|^$|r/.test(lastSignificant === "return" ? "r" : lastSignificant);
116
+ const enclosing = nearestObj();
117
+ frames.push({ kind: isObject ? "obj" : "other", parent: enclosing, keys: new Set(), via: enclosing === -1 ? undefined : frames[enclosing].currentKey });
118
+ stack.push(frames.length - 1);
119
+ lastSignificant = c;
120
+ i += 1;
121
+ continue;
122
+ }
123
+ if (c === "}" || c === "]" || c === ")") {
124
+ const idx = stack.pop();
125
+ void idx;
126
+ lastSignificant = c;
127
+ i += 1;
128
+ continue;
129
+ }
130
+ if (/[A-Za-z_$]/.test(c)) {
131
+ let j = i;
132
+ while (j < src.length && /[\w$]/.test(src[j]))
133
+ j += 1;
134
+ const word = src.slice(i, j);
135
+ const owner = nearestObj();
136
+ const top = stack[stack.length - 1];
137
+ let k = j;
138
+ while (k < src.length && /\s/.test(src[k]))
139
+ k += 1;
140
+ if (owner !== -1 && top === owner && (lastSignificant === "{" || lastSignificant === ",")) {
141
+ if (src[k] === ":") {
142
+ frames[owner].currentKey = word;
143
+ frames[owner].keys.add(word);
144
+ }
145
+ else if (src[k] === "," || src[k] === "}") {
146
+ frames[owner].keys.add(word); // shorthand property, e.g. `{ path, component }` — no literal to read
147
+ frames[owner].currentKey = undefined;
148
+ }
149
+ }
150
+ lastSignificant = word === "return" ? "return" : "w";
151
+ i = j;
152
+ continue;
153
+ }
154
+ if (!/\s/.test(c))
155
+ lastSignificant = c;
156
+ i += 1;
157
+ }
158
+ // Keep the frames that are route records, in source order, and re-link parents among them.
159
+ const isRecord = (f) => f.path !== undefined && ROUTE_RECORD_KEYS.some((k) => f.keys.has(k));
160
+ // A record belongs to the route tree only when every object between it and
161
+ // the top was entered through `children` (or `routes`, the router's own
162
+ // option). `{ path, component }` under `meta.breadcrumbs` or `props.link` is
163
+ // data carried BY a route, not a route.
164
+ const inRouteTree = (f) => {
165
+ for (let cur = f; cur && cur.parent !== -1; cur = frames[cur.parent]) {
166
+ if (cur.via !== "children" && cur.via !== "routes")
167
+ return false;
168
+ }
169
+ return true;
170
+ };
171
+ const recordFrames = frames.map((f, index) => ({ f, index })).filter(({ f }) => f.kind === "obj" && isRecord(f) && inRouteTree(f));
172
+ const position = new Map(recordFrames.map(({ index }, pos) => [index, pos]));
173
+ return recordFrames.map(({ f }) => {
174
+ let p = f.parent;
175
+ while (p !== -1 && !position.has(p))
176
+ p = frames[p].parent;
177
+ const redirectOnly = (f.keys.has("redirect") || f.keys.has("redirectTo")) &&
178
+ !["element", "Component", "component", "components", "loadComponent", "lazy"].some((k) => f.keys.has(k));
179
+ return { path: f.path, parent: p === -1 ? -1 : position.get(p), redirectOnly, lazyImport: f.lazyImport };
180
+ });
181
+ }
182
+ /**
183
+ * The source with comments blanked out, offsets preserved. `<Route>` tags are
184
+ * found by pattern, so a commented-out route would otherwise be read as live.
185
+ */
186
+ export function maskComments(src) {
187
+ const out = src.split("");
188
+ let i = 0;
189
+ const blank = (from, to) => {
190
+ for (let k = from; k < to && k < out.length; k++)
191
+ if (out[k] !== "\n")
192
+ out[k] = " ";
193
+ };
194
+ while (i < src.length) {
195
+ const c = src[i];
196
+ if (c === '"' || c === "'" || c === "`") {
197
+ i = readString(src, i).end;
198
+ continue;
199
+ }
200
+ if (c === "/" && src[i + 1] === "/" && src[i - 1] !== ":") {
201
+ let j = i;
202
+ while (j < src.length && src[j] !== "\n")
203
+ j += 1;
204
+ blank(i, j);
205
+ i = j;
206
+ continue;
207
+ }
208
+ if (c === "/" && src[i + 1] === "*") {
209
+ const end = src.indexOf("*/", i + 2);
210
+ const j = end === -1 ? src.length : end + 2;
211
+ blank(i, j);
212
+ i = j;
213
+ continue;
214
+ }
215
+ i += 1;
216
+ }
217
+ return out.join("");
218
+ }
219
+ /** `<Route path="…">` elements (React Router), with their nesting. */
220
+ export function readRouteElements(source) {
221
+ const src = maskComments(source);
222
+ const out = [];
223
+ const open = [];
224
+ const tagRe = /<\/?Route\b/g;
225
+ let m;
226
+ while ((m = tagRe.exec(src))) {
227
+ if (m[0].startsWith("</")) {
228
+ open.pop();
229
+ continue;
230
+ }
231
+ // Scan to the end of the opening tag, stepping over `{…}` expressions and
232
+ // strings: `element={<Orders />}` contains a `>` that is not the tag's own.
233
+ let i = m.index + m[0].length;
234
+ let depth = 0;
235
+ let attrs = "";
236
+ while (i < src.length) {
237
+ const c = src[i];
238
+ if (c === '"' || c === "'" || c === "`") {
239
+ const { end } = readString(src, i);
240
+ attrs += src.slice(i, end);
241
+ i = end;
242
+ continue;
243
+ }
244
+ if (c === "{")
245
+ depth += 1;
246
+ else if (c === "}")
247
+ depth -= 1;
248
+ else if (c === ">" && depth === 0)
249
+ break;
250
+ attrs += c;
251
+ i += 1;
252
+ }
253
+ const selfClosing = attrs.trimEnd().endsWith("/");
254
+ const pathAttr = /(?:^|\s)path\s*=\s*(?:"([^"]*)"|'([^']*)'|\{\s*"([^"]*)"\s*\}|\{\s*'([^']*)'\s*\})/.exec(attrs);
255
+ const isIndex = /(?:^|\s)index(?=[\s/>=]|$)/.test(attrs);
256
+ const parent = open.length > 0 ? open[open.length - 1] : -1;
257
+ let self = -1;
258
+ if (pathAttr || isIndex) {
259
+ out.push({ path: pathAttr ? (pathAttr[1] ?? pathAttr[2] ?? pathAttr[3] ?? pathAttr[4] ?? "") : "", parent, redirectOnly: false });
260
+ self = out.length - 1;
261
+ }
262
+ // A pathless layout route still nests its children under ITS parent.
263
+ if (!selfClosing)
264
+ open.push(self === -1 ? parent : self);
265
+ tagRe.lastIndex = i + 1;
266
+ }
267
+ return out;
268
+ }
269
+ const tidy = (p) => `/${p}`.replace(/\/+/g, "/").replace(/(.)\/$/, "$1");
270
+ /** The absolute path of one record, or null when it (or an ancestor) is a wildcard. */
271
+ export function recordPath(records, idx, prefix = "", absoluteTopOnly = false) {
272
+ const r = records[idx];
273
+ if (/[*]/.test(r.path))
274
+ return null;
275
+ // An absolute child path is absolute (React Router and Vue Router both allow it).
276
+ if (r.path.startsWith("/"))
277
+ return tidy(r.path);
278
+ // A relative path at the top of a file that is not provably the router's
279
+ // root has an unknown parent; joining it onto "/" would invent a page.
280
+ if (r.parent === -1 && absoluteTopOnly)
281
+ return null;
282
+ const base = r.parent === -1 ? prefix : recordPath(records, r.parent, prefix, absoluteTopOnly);
283
+ return base === null ? null : tidy(`${base}/${r.path}`);
284
+ }
285
+ /** Join nested route records into absolute paths. Wildcards, redirect-only records and lazily loaded parents are not pages themselves. */
286
+ export function resolveRoutes(records, prefix = "", absoluteTopOnly = false) {
287
+ const out = new Set();
288
+ records.forEach((r, idx) => {
289
+ if (r.redirectOnly || r.lazyImport)
290
+ return;
291
+ const p = recordPath(records, idx, prefix, absoluteTopOnly);
292
+ if (p !== null)
293
+ out.add(p);
294
+ });
295
+ return [...out];
296
+ }
297
+ /**
298
+ * Text that marks a file as the ENTRY of a router: the place where the router
299
+ * is created or mounted at the app's root. A relative top-level path there
300
+ * means "relative to /".
301
+ */
302
+ const ENTRY_MARKERS = /createBrowserRouter\s*\(|createHashRouter\s*\(|createMemoryRouter\s*\(|<RouterProvider\b|<BrowserRouter\b|<HashRouter\b|<MemoryRouter\b|createRouter\s*\(|new\s+VueRouter\s*\(|RouterModule\s*\.\s*forRoot\s*\(|provideRouter\s*\(/;
303
+ /**
304
+ * `<Routes>` and `useRoutes()` mount routes wherever they are rendered — at
305
+ * the root, or inside a component reached through a splat route
306
+ * (`<Route path="admin/*">`). In the second case their paths are relative to
307
+ * that parent, which this reader cannot see, so a file with only these markers
308
+ * is trusted for relative paths only when it is the single such file in the
309
+ * project.
310
+ */
311
+ const MOUNT_MARKERS = /<Routes\b|useRoutes\s*\(/;
312
+ /** Angular's conventional root files: relative top-level paths there are relative to "/". */
313
+ const ANGULAR_ROOT_FILENAMES = /(^|[\\/])(app\.routes|app-routing\.module)\.(ts|js|mjs)$/;
314
+ /** Conventional names for router files in general. Read, but only their ABSOLUTE top-level paths are trusted. */
315
+ const ROUTER_FILENAMES = /(^|[\\/])(routes|router|router[\\/]index)\.(ts|tsx|js|jsx|mjs)$/;
316
+ const ROUTER_MENTION = /react-router|vue-router|@angular\/router|<Route\b|createBrowserRouter|RouterModule|provideRouter/;
317
+ const SOURCE_EXT = /\.(ts|tsx|js|jsx|mjs)$/;
318
+ /** Matched against the path RELATIVE to the scanned root: a checkout that happens to live under a directory called `build` or `tests` must still be read. */
319
+ const SKIP = /(^|[\\/])(node_modules|dist|build|coverage|\.next|\.nuxt|\.svelte-kit|__tests__|e2e|tests?)([\\/]|$)|\.(spec|test|stories|d)\.[a-z]+$/;
320
+ /** Files read for routes. Candidates are listed first, so the budget is spent on likely router files. */
321
+ const MAX_FILES = 600;
322
+ /** Paths listed before giving up on a very large tree. */
323
+ const MAX_LISTED = 20_000;
324
+ const MAX_DEPTH = 14;
325
+ const MAX_BYTES = 400_000;
326
+ const LIKELY_ROUTER_FILE = /(^|[\\/])(app\.routes|app-routing\.module|routes|router|index|main|App|app)\.(ts|tsx|js|jsx|mjs)$|rout/i;
327
+ function sourceFiles(root) {
328
+ const listed = [];
329
+ let truncated = false;
330
+ const walk = (dir, depth) => {
331
+ if (depth > MAX_DEPTH || listed.length >= MAX_LISTED) {
332
+ truncated = true;
333
+ return;
334
+ }
335
+ let entries;
336
+ try {
337
+ entries = fs.readdirSync(dir, { withFileTypes: true });
338
+ }
339
+ catch {
340
+ return; // unreadable directory: nothing to read routes from
341
+ }
342
+ for (const e of entries) {
343
+ const p = path.join(dir, e.name);
344
+ if (e.name.startsWith(".") || SKIP.test(path.relative(root, p)))
345
+ continue;
346
+ if (e.isDirectory())
347
+ walk(p, depth + 1);
348
+ else if (SOURCE_EXT.test(e.name))
349
+ listed.push(p);
350
+ }
351
+ };
352
+ walk(root, 0);
353
+ // Likely router files first; within each group, shallower paths first.
354
+ const depthOf = (f) => f.split(path.sep).length;
355
+ listed.sort((a, b) => Number(LIKELY_ROUTER_FILE.test(b)) - Number(LIKELY_ROUTER_FILE.test(a)) || depthOf(a) - depthOf(b) || a.localeCompare(b));
356
+ if (listed.length > MAX_FILES)
357
+ truncated = true;
358
+ return { files: listed.slice(0, MAX_FILES), truncated };
359
+ }
360
+ /** Resolve a relative import to a file INSIDE `root`. Bare and aliased specifiers, and anything outside the project, are not followed. */
361
+ function resolveImport(root, fromFile, spec) {
362
+ if (!spec.startsWith("."))
363
+ return null;
364
+ const base = path.resolve(path.dirname(fromFile), spec);
365
+ for (const candidate of [base, ...[".ts", ".tsx", ".js", ".jsx", ".mjs"].map((x) => base + x), ...["index.ts", "index.js"].map((x) => path.join(base, x))]) {
366
+ const rel = path.relative(root, candidate);
367
+ if (rel.startsWith("..") || path.isAbsolute(rel))
368
+ continue;
369
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile())
370
+ return candidate;
371
+ }
372
+ return null;
373
+ }
374
+ function readSource(file) {
375
+ try {
376
+ if (fs.statSync(file).size > MAX_BYTES)
377
+ return null;
378
+ return fs.readFileSync(file, "utf8");
379
+ }
380
+ catch {
381
+ return null;
382
+ }
383
+ }
384
+ /**
385
+ * The file that holds a lazily loaded branch's routes. Angular's classic shape
386
+ * points `loadChildren` at an NgModule (`admin.module`) whose routes live in
387
+ * its sibling `admin-routing.module`; the standalone shape points straight at
388
+ * the routes file.
389
+ */
390
+ function lazyRouteFile(root, fromFile, spec) {
391
+ const direct = resolveImport(root, fromFile, spec);
392
+ if (direct) {
393
+ const src = readSource(direct);
394
+ if (src !== null && readRouteObjects(src).length > 0)
395
+ return direct;
396
+ }
397
+ const sibling = /\.module$/.test(spec) ? resolveImport(root, fromFile, spec.replace(/\.module$/, "-routing.module")) : null;
398
+ return sibling ?? null;
399
+ }
400
+ /** Routes of one file, following Angular `loadChildren` imports: each child file's routes are prefixed with the path of the record that loads it. */
401
+ function routesOfFile(ctx, file, prefix, absoluteTopOnly) {
402
+ // A child loaded under two parents keeps the first prefix only: a miss, never an invention.
403
+ if (ctx.seen.has(file))
404
+ return [];
405
+ ctx.seen.add(file);
406
+ const src = readSource(file);
407
+ if (src === null)
408
+ return [];
409
+ const records = readRouteObjects(src);
410
+ const out = [...resolveRoutes(records, prefix, absoluteTopOnly), ...resolveRoutes(readRouteElements(src), prefix, absoluteTopOnly)];
411
+ records.forEach((r, idx) => {
412
+ if (!r.lazyImport)
413
+ return;
414
+ const lazyPrefix = recordPath(records, idx, prefix, absoluteTopOnly);
415
+ if (lazyPrefix === null)
416
+ return;
417
+ // The record's own path is a page whichever way the branch resolves: it is
418
+ // where the lazily loaded module mounts.
419
+ out.push(lazyPrefix);
420
+ const child = lazyRouteFile(ctx.root, file, r.lazyImport);
421
+ if (child)
422
+ out.push(...routesOfFile(ctx, child, lazyPrefix === "/" ? "" : lazyPrefix, absoluteTopOnly));
423
+ else
424
+ ctx.unresolved.push(r.lazyImport);
425
+ });
426
+ return out;
427
+ }
428
+ /** Read routes from router configuration under `frontendDir`. Empty when none is recognisable. */
429
+ export function codeRoutes(frontendDir) {
430
+ const srcDir = fs.existsSync(path.join(frontendDir, "src")) ? path.join(frontendDir, "src") : frontendDir;
431
+ const { files: candidates, truncated } = sourceFiles(srcDir);
432
+ const mentions = [];
433
+ for (const file of candidates) {
434
+ const src = readSource(file);
435
+ if (src !== null && ROUTER_MENTION.test(src))
436
+ mentions.push({ file, src: maskComments(src) });
437
+ }
438
+ const mountFiles = mentions.filter(({ src }) => MOUNT_MARKERS.test(src));
439
+ const roots = [];
440
+ for (const { file, src } of mentions) {
441
+ const entry = ENTRY_MARKERS.test(src) || ANGULAR_ROOT_FILENAMES.test(file);
442
+ // The only file that mounts routes must be the root, wherever the router itself is created.
443
+ const soleMount = MOUNT_MARKERS.test(src) && mountFiles.length === 1;
444
+ if (entry || soleMount)
445
+ roots.push({ file, trusted: true });
446
+ else if (MOUNT_MARKERS.test(src) || ROUTER_FILENAMES.test(file))
447
+ roots.push({ file, trusted: false });
448
+ }
449
+ // Trusted roots first, so a child file they load lazily is claimed with its prefix before it can be read bare.
450
+ roots.sort((a, b) => Number(b.trusted) - Number(a.trusted));
451
+ const ctx = { root: frontendDir, seen: new Set(), unresolved: [] };
452
+ const routes = new Set();
453
+ const files = [];
454
+ for (const { file, trusted } of roots) {
455
+ const found = routesOfFile(ctx, file, "", !trusted);
456
+ if (found.length > 0)
457
+ files.push(path.relative(frontendDir, file));
458
+ for (const r of found)
459
+ routes.add(r);
460
+ }
461
+ return { routes: [...routes].sort(), files, unresolved: [...new Set(ctx.unresolved)], truncated };
462
+ }