scenescout 1.0.0 → 1.2.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/CHANGELOG.md +32 -0
- package/README.md +186 -65
- package/dist/browsers.js +185 -0
- package/dist/cli.js +167 -39
- package/dist/clients.js +213 -0
- package/dist/code-routes.js +462 -0
- package/dist/engine/browser.js +88 -38
- package/dist/engine/collector.js +148 -7
- package/dist/engine/hover.js +42 -0
- package/dist/engine/launch.js +12 -5
- package/dist/engine/policy.js +55 -3
- package/dist/engine/probes.js +4 -1
- package/dist/engine/report.js +6 -1
- package/dist/installer.js +40 -12
- package/dist/mcp-server.js +61 -6
- package/dist/playbook.js +83 -0
- package/dist/scan.js +26 -2
- package/package.json +19 -7
- package/skills/scenescout/SKILL.md +11 -8
package/dist/clients.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Registering the MCP server with clients other than Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* Each client keeps its server list somewhere different. Where a client has a
|
|
5
|
+
* command for adding a server, that command is used: it knows its own config
|
|
6
|
+
* format and location. Where it has none, the JSON file it reads is edited in
|
|
7
|
+
* place, keeping every other entry.
|
|
8
|
+
*
|
|
9
|
+
* Nothing here launches a process or touches the home directory on its own:
|
|
10
|
+
* the runner, the home directory and the platform are passed in, so every rule
|
|
11
|
+
* can be table-tested.
|
|
12
|
+
*/
|
|
13
|
+
import fs from "node:fs";
|
|
14
|
+
import path from "node:path";
|
|
15
|
+
import { MCP_NAME } from "./installer.js";
|
|
16
|
+
export const OTHER_CLIENTS = ["cursor", "vscode", "codex", "gemini", "copilot", "windsurf"];
|
|
17
|
+
export const CLIENTS = ["claude-code", ...OTHER_CLIENTS];
|
|
18
|
+
export const CLIENT_LABELS = {
|
|
19
|
+
"claude-code": "Claude Code",
|
|
20
|
+
cursor: "Cursor",
|
|
21
|
+
vscode: "VS Code (GitHub Copilot agent mode)",
|
|
22
|
+
codex: "Codex CLI",
|
|
23
|
+
gemini: "Gemini CLI",
|
|
24
|
+
copilot: "GitHub Copilot CLI",
|
|
25
|
+
windsurf: "Windsurf",
|
|
26
|
+
};
|
|
27
|
+
/** Read the value of `--client`. Absent means Claude Code, which is what install has always set up. */
|
|
28
|
+
export function parseClients(value) {
|
|
29
|
+
if (value === undefined)
|
|
30
|
+
return { clients: ["claude-code"] };
|
|
31
|
+
const names = value
|
|
32
|
+
.split(",")
|
|
33
|
+
.map((s) => s.trim().toLowerCase())
|
|
34
|
+
.filter(Boolean);
|
|
35
|
+
const choices = CLIENTS.join(", ");
|
|
36
|
+
if (names.length === 0)
|
|
37
|
+
return { error: `--client needs a value. Choose from: ${choices}.` };
|
|
38
|
+
const picked = new Set();
|
|
39
|
+
for (const name of names) {
|
|
40
|
+
if (!CLIENTS.includes(name))
|
|
41
|
+
return { error: `"${name}" is not a client install knows how to set up. Choose from: ${choices}.` };
|
|
42
|
+
picked.add(name);
|
|
43
|
+
}
|
|
44
|
+
return { clients: CLIENTS.filter((c) => picked.has(c)) };
|
|
45
|
+
}
|
|
46
|
+
const STRATEGIES = {
|
|
47
|
+
cursor: { kind: "file", file: (home) => path.join(home, ".cursor", "mcp.json"), key: "mcpServers" },
|
|
48
|
+
windsurf: { kind: "file", file: (home) => path.join(home, ".codeium", "windsurf", "mcp_config.json"), key: "mcpServers" },
|
|
49
|
+
// `add` replaces an entry of the same name.
|
|
50
|
+
codex: { kind: "command", binary: "codex", add: (launch) => ["mcp", "add", MCP_NAME, "--", ...launch] },
|
|
51
|
+
// `add` updates an entry of the same name. The default scope is the project; a tool like this belongs to the user.
|
|
52
|
+
gemini: { kind: "command", binary: "gemini", add: (launch) => ["mcp", "add", "--scope", "user", MCP_NAME, ...launch] },
|
|
53
|
+
// `add` refuses a name that already exists, so the old entry is removed first.
|
|
54
|
+
copilot: { kind: "command", binary: "copilot", add: (launch) => ["mcp", "add", MCP_NAME, "--", ...launch], removeFirst: ["mcp", "remove", MCP_NAME] },
|
|
55
|
+
};
|
|
56
|
+
const quote = (s) => (/^[\w@%+=:,./-]+$/.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`);
|
|
57
|
+
/** The entry every `mcpServers`-style file takes. */
|
|
58
|
+
export function serverEntry(launch) {
|
|
59
|
+
return { command: launch[0], args: launch.slice(1) };
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Put the server into a client's JSON server list, keeping everything else in
|
|
63
|
+
* the file. A file that is not valid JSON is left exactly as it is: rewriting
|
|
64
|
+
* it would discard whatever the person had in it.
|
|
65
|
+
*/
|
|
66
|
+
export function registerInFile(file, key, launch) {
|
|
67
|
+
const entry = serverEntry(launch);
|
|
68
|
+
const manual = `add this under "${key}" in ${file}:\n ${JSON.stringify({ [MCP_NAME]: entry })}`;
|
|
69
|
+
let config = {};
|
|
70
|
+
// A config kept in a dotfiles repository is a link. Renaming over the link
|
|
71
|
+
// would replace it with a plain file and leave the real one unchanged, so
|
|
72
|
+
// the write goes to whatever the link points at.
|
|
73
|
+
let target = file;
|
|
74
|
+
let mode = 0o600;
|
|
75
|
+
try {
|
|
76
|
+
if (fs.existsSync(file)) {
|
|
77
|
+
target = fs.realpathSync(file);
|
|
78
|
+
mode = fs.statSync(target).mode & 0o777;
|
|
79
|
+
// Some editors save with a byte-order mark, which JSON.parse rejects.
|
|
80
|
+
const raw = fs.readFileSync(target, "utf8").replace(/^\uFEFF/, "");
|
|
81
|
+
if (raw.trim().length > 0) {
|
|
82
|
+
let parsed;
|
|
83
|
+
try {
|
|
84
|
+
parsed = JSON.parse(raw);
|
|
85
|
+
}
|
|
86
|
+
catch (err) {
|
|
87
|
+
return {
|
|
88
|
+
status: "failed",
|
|
89
|
+
detail: `${file} is not valid JSON (${err instanceof Error ? err.message : String(err)}), so it was left untouched`,
|
|
90
|
+
manual,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
94
|
+
return { status: "failed", detail: `${file} does not hold a JSON object, so it was left untouched`, manual };
|
|
95
|
+
}
|
|
96
|
+
config = parsed;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
return { status: "failed", detail: `${file} could not be read (${err instanceof Error ? err.message : String(err)})`, manual };
|
|
102
|
+
}
|
|
103
|
+
const existing = config[key];
|
|
104
|
+
if (existing !== undefined && (existing === null || typeof existing !== "object" || Array.isArray(existing))) {
|
|
105
|
+
return { status: "failed", detail: `"${key}" in ${file} is not an object, so the file was left untouched`, manual };
|
|
106
|
+
}
|
|
107
|
+
const servers = (existing ?? {});
|
|
108
|
+
const before = servers[MCP_NAME];
|
|
109
|
+
const notes = [];
|
|
110
|
+
if (before !== undefined && JSON.stringify(before) !== JSON.stringify(entry)) {
|
|
111
|
+
notes.push(`the previous "${MCP_NAME}" entry was replaced; it ran: ${JSON.stringify(before)}`);
|
|
112
|
+
}
|
|
113
|
+
config[key] = { ...servers, [MCP_NAME]: entry };
|
|
114
|
+
// Written beside the target and renamed over it, so a crash cannot leave half a file.
|
|
115
|
+
const tmp = `${target}.scenescout-${process.pid}.tmp`;
|
|
116
|
+
try {
|
|
117
|
+
fs.mkdirSync(path.dirname(target), { recursive: true });
|
|
118
|
+
fs.writeFileSync(tmp, `${JSON.stringify(config, null, 2)}\n`, { mode });
|
|
119
|
+
fs.renameSync(tmp, target);
|
|
120
|
+
}
|
|
121
|
+
catch (err) {
|
|
122
|
+
// The temporary file is a full copy of the config, which can hold secrets in `env`; it must not stay behind.
|
|
123
|
+
fs.rmSync(tmp, { force: true });
|
|
124
|
+
return { status: "failed", detail: `${file} could not be written (${err instanceof Error ? err.message : String(err)}), so it was left untouched`, manual };
|
|
125
|
+
}
|
|
126
|
+
return { status: "registered", where: file, replaced: before !== undefined, notes };
|
|
127
|
+
}
|
|
128
|
+
function registerWithCommand(client, launch, run) {
|
|
129
|
+
const manual = [client.binary, ...client.add(launch)].map(quote).join(" ");
|
|
130
|
+
let removed = false;
|
|
131
|
+
if (client.removeFirst) {
|
|
132
|
+
const removal = run(client.binary, client.removeFirst);
|
|
133
|
+
if (removal.missing)
|
|
134
|
+
return { status: "client-missing", manual };
|
|
135
|
+
// A non-zero exit here only means there was nothing of that name to remove.
|
|
136
|
+
removed = removal.status === 0;
|
|
137
|
+
}
|
|
138
|
+
const added = run(client.binary, client.add(launch));
|
|
139
|
+
if (added.missing)
|
|
140
|
+
return { status: "client-missing", manual };
|
|
141
|
+
if (added.status !== 0) {
|
|
142
|
+
const reason = (added.stdout + added.stderr).trim().split("\n")[0] || `exit code ${added.status}`;
|
|
143
|
+
// Having removed the old entry to make room, say so: the person now has no registration at all.
|
|
144
|
+
const lost = removed ? ` The previous "${MCP_NAME}" entry had already been removed to make room, so ${client.binary} now has none.` : "";
|
|
145
|
+
return { status: "failed", detail: reason + lost, manual };
|
|
146
|
+
}
|
|
147
|
+
return { status: "registered", where: `${client.binary} mcp`, replaced: removed, notes: [] };
|
|
148
|
+
}
|
|
149
|
+
/**
|
|
150
|
+
* The VS Code command line, or null when only a fork is installed. Cursor and
|
|
151
|
+
* Windsurf both install a `code` command of their own, and running that one
|
|
152
|
+
* registers the server in the wrong editor: it reports success and VS Code
|
|
153
|
+
* never sees the entry. A `code` that resolves into another editor's files is
|
|
154
|
+
* therefore not VS Code.
|
|
155
|
+
*
|
|
156
|
+
* The real path is only inspected. What gets run is the command as found on
|
|
157
|
+
* PATH: some installs (snap) link `code` to a launcher that decides what to
|
|
158
|
+
* start from the name it was called by, and running the link's target directly
|
|
159
|
+
* starts the wrong thing.
|
|
160
|
+
*/
|
|
161
|
+
export function vscodeBinary(opts) {
|
|
162
|
+
if (opts.platform === "darwin") {
|
|
163
|
+
for (const root of ["/Applications", path.posix.join(opts.home, "Applications")]) {
|
|
164
|
+
const bundled = path.posix.join(root, "Visual Studio Code.app", "Contents", "Resources", "app", "bin", "code");
|
|
165
|
+
if (opts.exists(bundled))
|
|
166
|
+
return bundled;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (!opts.codeOnPath)
|
|
170
|
+
return null;
|
|
171
|
+
// Looked for below the home directory's own name, so an account called "cursor" does not disqualify every install under it.
|
|
172
|
+
const real = opts.codeOnPath.realPath;
|
|
173
|
+
const belowHome = real.toLowerCase().startsWith(opts.home.toLowerCase()) ? real.slice(opts.home.length) : real;
|
|
174
|
+
return /cursor|windsurf|codeium|vscodium/i.test(belowHome) ? null : opts.codeOnPath.command;
|
|
175
|
+
}
|
|
176
|
+
/** What `code --add-mcp` takes: the entry plus its name. */
|
|
177
|
+
export function vscodeAddArgs(launch) {
|
|
178
|
+
return ["--add-mcp", JSON.stringify({ name: MCP_NAME, ...serverEntry(launch) })];
|
|
179
|
+
}
|
|
180
|
+
export function registerWithClient(client, opts) {
|
|
181
|
+
if (client === "vscode") {
|
|
182
|
+
const manual = `in VS Code run "MCP: Add Server…" and choose a command (stdio) server, or run:\n code ${vscodeAddArgs(opts.launch).map(quote).join(" ")}`;
|
|
183
|
+
if (!opts.vscode)
|
|
184
|
+
return { status: "client-missing", manual };
|
|
185
|
+
const added = opts.run(opts.vscode, vscodeAddArgs(opts.launch));
|
|
186
|
+
if (added.missing)
|
|
187
|
+
return { status: "client-missing", manual };
|
|
188
|
+
const output = (added.stdout + added.stderr).trim();
|
|
189
|
+
if (added.status !== 0)
|
|
190
|
+
return { status: "failed", detail: output.split("\n")[0] || `exit code ${added.status}`, manual };
|
|
191
|
+
// This command replaces an entry of the same name and does not say whether there was one.
|
|
192
|
+
return { status: "registered", where: "VS Code's user profile", replaced: false, notes: [] };
|
|
193
|
+
}
|
|
194
|
+
const strategy = STRATEGIES[client];
|
|
195
|
+
return strategy.kind === "file" ? registerInFile(strategy.file(opts.home), strategy.key, opts.launch) : registerWithCommand(strategy, opts.launch, opts.run);
|
|
196
|
+
}
|
|
197
|
+
/** How to register with a client by hand, without running anything. */
|
|
198
|
+
export function manualFor(client, launch, home) {
|
|
199
|
+
if (client === "vscode")
|
|
200
|
+
return `code ${vscodeAddArgs(launch).map(quote).join(" ")}`;
|
|
201
|
+
const strategy = STRATEGIES[client];
|
|
202
|
+
if (strategy.kind === "command")
|
|
203
|
+
return [strategy.binary, ...strategy.add(launch)].map(quote).join(" ");
|
|
204
|
+
return `add under "${strategy.key}" in ${strategy.file(home)}: ${JSON.stringify({ [MCP_NAME]: serverEntry(launch) })}`;
|
|
205
|
+
}
|
|
206
|
+
/** What to tell the person once their clients are set up: how the method reaches an agent that has no skill. */
|
|
207
|
+
export function firstMessageHint(clients) {
|
|
208
|
+
const names = clients.map((c) => CLIENT_LABELS[c]);
|
|
209
|
+
const list = names.length > 1 ? `${names.slice(0, -1).join(", ")} and ${names[names.length - 1]}` : names[0];
|
|
210
|
+
return (`Restart ${list} (or reload the MCP servers there), then ask the agent:\n` +
|
|
211
|
+
` Use SceneScout to test http://localhost:3000\n` +
|
|
212
|
+
`The server hands the agent the testing method through its scout_playbook tool.`);
|
|
213
|
+
}
|
|
@@ -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
|
+
}
|