@chappibunny/repolens 1.15.4 → 1.16.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,36 @@
1
+ /**
2
+ * GitHub App manifest. Minimal permissions for architecture check runs
3
+ * and the existing pull request comment. `metadata: read` is required by
4
+ * GitHub on every app.
5
+ *
6
+ * `hook_attributes.url` is a placeholder. Replace it with the public URL of
7
+ * `repolens app` before registering. This repository does not register the app.
8
+ */
9
+ export const GITHUB_APP_WEBHOOK_PATH = "/api/github/webhooks";
10
+
11
+ export const GITHUB_APP_MANIFEST = {
12
+ name: "RepoLens",
13
+ description: "Posts an architecture diff as a check run and one sticky pull request comment.",
14
+ url: "https://github.com/CHAPIBUNNY/repolens",
15
+ hook_attributes: {
16
+ url: `https://example.com${GITHUB_APP_WEBHOOK_PATH}`,
17
+ active: true,
18
+ },
19
+ redirect_url: "https://github.com/CHAPIBUNNY/repolens",
20
+ public: false,
21
+ default_permissions: {
22
+ checks: "write",
23
+ pull_requests: "write",
24
+ contents: "read",
25
+ metadata: "read",
26
+ },
27
+ default_events: [
28
+ "installation",
29
+ "installation_repositories",
30
+ "pull_request",
31
+ ],
32
+ };
33
+
34
+ export function manifestDocument() {
35
+ return JSON.stringify(GITHUB_APP_MANIFEST, null, 2);
36
+ }
@@ -0,0 +1,67 @@
1
+ import { manifestDocument } from "./manifest.js";
2
+ import { readAppPrivateKey } from "./auth.js";
3
+ import { createGitHubAppServer, listenGitHubApp } from "./server.js";
4
+ import { GITHUB_APP_WEBHOOK_PATH } from "./manifest.js";
5
+
6
+ function positiveInteger(value, fallback) {
7
+ if (value == null || value === "") return fallback;
8
+ if (!/^[0-9]+$/.test(String(value))) return null;
9
+ const parsed = Number(value);
10
+ if (!Number.isInteger(parsed) || parsed < 0 || parsed > 65535) return null;
11
+ return parsed;
12
+ }
13
+
14
+ /**
15
+ * `repolens app` self-hosts the webhook.
16
+ * `--manifest` prints the manifest JSON and does not need credentials.
17
+ * Returns a process exit code. The server process stays up on success.
18
+ */
19
+ export async function runGitHubApp({
20
+ manifest = false,
21
+ port,
22
+ host,
23
+ webhookPath,
24
+ env = process.env,
25
+ stdout = console.log,
26
+ stderr = console.error,
27
+ } = {}) {
28
+ if (manifest) {
29
+ stdout(`${manifestDocument()}\n`);
30
+ return 0;
31
+ }
32
+
33
+ const appId = env.REPOLENS_APP_ID || "";
34
+ const webhookSecret = env.REPOLENS_WEBHOOK_SECRET || "";
35
+ let privateKey = "";
36
+ try {
37
+ privateKey = readAppPrivateKey(env);
38
+ } catch (error) {
39
+ stderr(`Could not read REPOLENS_APP_PRIVATE_KEY_PATH: ${error.message}`);
40
+ return 2;
41
+ }
42
+ if (!appId || !webhookSecret || !privateKey) {
43
+ stderr("repolens app needs REPOLENS_APP_ID, REPOLENS_WEBHOOK_SECRET, and REPOLENS_APP_PRIVATE_KEY or REPOLENS_APP_PRIVATE_KEY_PATH.");
44
+ stderr("Print the manifest with: repolens app --manifest");
45
+ return 2;
46
+ }
47
+
48
+ const chosenPort = positiveInteger(port ?? env.REPOLENS_APP_PORT, 8787);
49
+ if (chosenPort == null) {
50
+ stderr("--port must be an integer from 0 to 65535");
51
+ return 2;
52
+ }
53
+ const chosenHost = host || env.REPOLENS_APP_HOST || "127.0.0.1";
54
+ const chosenPath = webhookPath || env.REPOLENS_APP_WEBHOOK_PATH || GITHUB_APP_WEBHOOK_PATH;
55
+
56
+ const server = createGitHubAppServer({
57
+ webhookSecret,
58
+ webhookPath: chosenPath,
59
+ appId,
60
+ privateKey,
61
+ });
62
+ const bound = await listenGitHubApp(server, { port: chosenPort, host: chosenHost });
63
+ stderr(`RepoLens GitHub App listening on http://${chosenHost}:${bound}${chosenPath}`);
64
+ stderr("GitHub must reach that URL over HTTPS. The process stays running until you stop it.");
65
+ await new Promise(() => {});
66
+ return 0;
67
+ }
@@ -0,0 +1,120 @@
1
+ import http from "node:http";
2
+ import { GITHUB_APP_WEBHOOK_PATH } from "./manifest.js";
3
+ import { verifyHubSignature256 } from "./signature.js";
4
+ import { handleGitHubAppEvent } from "./handler.js";
5
+
6
+ const MAX_BODY_BYTES = 2_000_000;
7
+
8
+ function sendJson(res, status, body) {
9
+ const payload = JSON.stringify(body);
10
+ res.writeHead(status, {
11
+ "Content-Type": "application/json; charset=utf-8",
12
+ "Content-Length": Buffer.byteLength(payload),
13
+ });
14
+ res.end(payload);
15
+ }
16
+
17
+ function readBody(req) {
18
+ return new Promise((resolve, reject) => {
19
+ const chunks = [];
20
+ let size = 0;
21
+ req.on("data", (chunk) => {
22
+ size += chunk.length;
23
+ if (size > MAX_BODY_BYTES) {
24
+ reject(Object.assign(new Error("Payload too large"), { status: 413 }));
25
+ req.destroy();
26
+ return;
27
+ }
28
+ chunks.push(chunk);
29
+ });
30
+ req.on("end", () => resolve(Buffer.concat(chunks)));
31
+ req.on("error", reject);
32
+ });
33
+ }
34
+
35
+ /**
36
+ * HTTP server for the GitHub App webhook.
37
+ *
38
+ * GitHub gives a webhook about 10 seconds. Analysis can take longer, so the
39
+ * default response is 202 and the check run is posted afterwards. Tests pass
40
+ * `inline: true` and wait for the handler before the response is sent.
41
+ */
42
+ export function createGitHubAppServer(options) {
43
+ const webhookPath = options.webhookPath || GITHUB_APP_WEBHOOK_PATH;
44
+ const server = http.createServer(async (req, res) => {
45
+ const url = new URL(req.url || "/", "http://127.0.0.1");
46
+ if (req.method === "GET" && (url.pathname === "/health" || url.pathname === "/")) {
47
+ sendJson(res, 200, { ok: true, service: "repolens-github-app" });
48
+ return;
49
+ }
50
+ if (req.method !== "POST" || url.pathname !== webhookPath) {
51
+ sendJson(res, 404, { ok: false, error: "Not found" });
52
+ return;
53
+ }
54
+
55
+ let raw;
56
+ try {
57
+ raw = await readBody(req);
58
+ } catch (error) {
59
+ sendJson(res, error.status || 400, { ok: false, error: error.message || "Could not read the body" });
60
+ return;
61
+ }
62
+
63
+ if (!verifyHubSignature256(raw, req.headers["x-hub-signature-256"], options.webhookSecret)) {
64
+ sendJson(res, 401, { ok: false, error: "Invalid signature" });
65
+ return;
66
+ }
67
+
68
+ let payload;
69
+ try {
70
+ payload = JSON.parse(raw.toString("utf8") || "null");
71
+ } catch {
72
+ sendJson(res, 400, { ok: false, error: "Invalid JSON" });
73
+ return;
74
+ }
75
+
76
+ const eventName = req.headers["x-github-event"];
77
+ const run = () => handleGitHubAppEvent({
78
+ eventName: typeof eventName === "string" ? eventName : "",
79
+ payload,
80
+ appId: options.appId,
81
+ privateKey: options.privateKey,
82
+ prepareRepo: options.prepareRepo,
83
+ analyze: options.analyze,
84
+ request: options.request,
85
+ getInstallationToken: options.getInstallationToken,
86
+ fetchImpl: options.fetchImpl,
87
+ });
88
+
89
+ if (options.inline) {
90
+ try {
91
+ const result = await run();
92
+ sendJson(res, result.status, result.body);
93
+ } catch (error) {
94
+ sendJson(res, 500, { ok: false, error: error.message || "Webhook failed" });
95
+ }
96
+ return;
97
+ }
98
+
99
+ sendJson(res, 202, { ok: true, accepted: true });
100
+ run().then((result) => {
101
+ if (result?.body?.ok === false) {
102
+ console.error(`RepoLens GitHub App: ${result.body.error || "event failed"}`);
103
+ }
104
+ }).catch((error) => {
105
+ console.error(`RepoLens GitHub App: ${error.message || error}`);
106
+ });
107
+ });
108
+ return server;
109
+ }
110
+
111
+ export function listenGitHubApp(server, { port, host }) {
112
+ return new Promise((resolve, reject) => {
113
+ server.once("error", reject);
114
+ server.listen(port, host, () => {
115
+ server.removeListener("error", reject);
116
+ const address = server.address();
117
+ resolve(typeof address === "object" && address ? address.port : port);
118
+ });
119
+ });
120
+ }
@@ -0,0 +1,22 @@
1
+ import { createHmac, timingSafeEqual } from "node:crypto";
2
+
3
+ /**
4
+ * Verify a GitHub `X-Hub-Signature-256` header against the raw request body.
5
+ * The comparison is constant-time when the header has the expected length.
6
+ */
7
+ export function verifyHubSignature256(rawBody, signatureHeader, secret) {
8
+ if (!secret || typeof secret !== "string") return false;
9
+ if (typeof signatureHeader !== "string" || !signatureHeader.startsWith("sha256=")) return false;
10
+ const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody));
11
+ const digest = createHmac("sha256", secret).update(body).digest("hex");
12
+ const expected = Buffer.from(`sha256=${digest}`, "utf8");
13
+ const actual = Buffer.from(signatureHeader, "utf8");
14
+ if (actual.length !== expected.length) return false;
15
+ return timingSafeEqual(actual, expected);
16
+ }
17
+
18
+ export function signHubBody(rawBody, secret) {
19
+ const body = Buffer.isBuffer(rawBody) ? rawBody : Buffer.from(String(rawBody));
20
+ const digest = createHmac("sha256", secret).update(body).digest("hex");
21
+ return `sha256=${digest}`;
22
+ }
package/src/help.js CHANGED
@@ -8,6 +8,7 @@ const EXIT_CODES = `Exit codes:
8
8
  badge scored 0 source files,
9
9
  comment is missing a base or head ref,
10
10
  publish --strict is missing publisher credentials,
11
+ app is missing its id, webhook secret, or private key,
11
12
  or feedback has no terminal`;
12
13
 
13
14
  const COMMAND_HELP = {
@@ -283,6 +284,42 @@ ${EXIT_CODES}
283
284
  Examples:
284
285
  repolens comment --base origin/main --head HEAD
285
286
  `,
287
+
288
+ app: `
289
+ RepoLens app — self-host the GitHub App webhook
290
+
291
+ Usage:
292
+ repolens app [--port <n>] [--host <address>] [--webhook-path <path>]
293
+ repolens app --manifest
294
+
295
+ Options:
296
+ --port <n> Listen port (default 8787, or REPOLENS_APP_PORT)
297
+ --host <address> Listen address (default 127.0.0.1, or REPOLENS_APP_HOST)
298
+ --webhook-path <path> Path GitHub posts to (default /api/github/webhooks)
299
+ --manifest Print the GitHub App manifest JSON and exit
300
+ --help, -h Show this help
301
+
302
+ Verifies X-Hub-Signature-256, acknowledges installation events, and on
303
+ pull_request opened, synchronize, reopened, and ready_for_review runs the
304
+ architecture diff. It posts a Check Run named RepoLens and updates the
305
+ sticky pull request comment. The check is informational: a completed diff
306
+ is success, and a failed analysis is a failed check.
307
+
308
+ \`--manifest\` needs no credentials. Running the server needs
309
+ REPOLENS_APP_ID, REPOLENS_WEBHOOK_SECRET, and either
310
+ REPOLENS_APP_PRIVATE_KEY or REPOLENS_APP_PRIVATE_KEY_PATH.
311
+ Replace hook_attributes.url in the manifest before you register the app.
312
+ This command does not register an app on GitHub.
313
+
314
+ GitHub allows about 10 seconds for the HTTP response. The server answers
315
+ 202 and posts the check after the analysis finishes.
316
+
317
+ ${EXIT_CODES}
318
+
319
+ Examples:
320
+ repolens app --manifest
321
+ repolens app --port 8787
322
+ `,
286
323
  };
287
324
 
288
325
  const GLOBAL_HELP = `
@@ -306,6 +343,7 @@ Commands:
306
343
  mcp Start the stdio MCP server (also available as repolens-mcp)
307
344
  badge Print a shields.io code-health badge (JSON, SVG, or markdown)
308
345
  comment Print or post an architecture diff for a pull request
346
+ app Self-host the GitHub App webhook (check run and PR comment)
309
347
  version Print the current RepoLens version
310
348
  help Show this help message
311
349
 
@@ -320,6 +358,10 @@ Options:
320
358
  --label <text> badge: shields.io label (default: code health)
321
359
  --base <ref> comment: base commit or ref
322
360
  --head <ref> comment: head commit or ref
361
+ --port <n> app: listen port (default 8787)
362
+ --host <address> app: listen address (default 127.0.0.1)
363
+ --webhook-path app: webhook path (default /api/github/webhooks)
364
+ --manifest app: print the GitHub App manifest JSON and exit
323
365
  --quick Scaffold without the wizard (also --non-interactive).
324
366
  Markdown only; globs follow the detected language.
325
367
  --strict publish: exit 2 when publisher credentials are missing
@@ -356,6 +398,8 @@ Examples:
356
398
  repolens badge
357
399
  repolens badge --format markdown --url https://example.github.io/repo/repolens-badge.json
358
400
  repolens comment --base origin/main --head HEAD
401
+ repolens app --manifest
402
+ repolens app --port 8787
359
403
  repolens --version
360
404
  `;
361
405
 
@@ -1,4 +1,4 @@
1
- import { computeModuleLevelMetrics, describeModuleDepRole } from "../analyzers/context-builder.js";
1
+ import { computeModuleLevelMetrics, describeModuleDepRole, isCliProject, isLibraryProject } from "../analyzers/context-builder.js";
2
2
  import { moduleEvidence, pathHasKeyword } from "../analyzers/domain-inference.js";
3
3
 
4
4
  export function renderSystemOverview(cfg, scan, depGraph = null) {
@@ -447,8 +447,8 @@ export function renderRouteMap(cfg, scan, aiContext = null) {
447
447
  if (!scan.pages?.length && !scan.api?.length) {
448
448
  // Determine project type for context-aware messaging
449
449
  const patterns = aiContext?.patterns || [];
450
- const isCLI = patterns.some(p => p.toLowerCase().includes("cli"));
451
- const isLibrary = patterns.some(p => p.toLowerCase().includes("library") || p.toLowerCase().includes("shared"));
450
+ const isCLI = isCliProject(aiContext);
451
+ const isLibrary = isLibraryProject(aiContext) || patterns.some(p => p.toLowerCase().includes("library") || p.toLowerCase().includes("shared"));
452
452
  const techStack = aiContext?.techStack || {};
453
453
  const hasWebFramework = (techStack.frameworks || []).some(f =>
454
454
  /next|react|vue|angular|express|fastify|hono|koa|django|flask|rails|spring/i.test(f)
@@ -343,7 +343,11 @@ export function renderDependencyGraph(depResult) {
343
343
  lines.push(`|--------|-------|`);
344
344
  lines.push(`| Source files | ${s.totalFiles} |`);
345
345
  lines.push(`| Import edges | ${s.totalEdges} |`);
346
- lines.push(`| External packages | ${s.externalDeps} |`);
346
+ if (Array.isArray(depResult.externalDev) && depResult.externalDev.length > 0) {
347
+ lines.push(`| External packages | ${s.externalDeps} (${depResult.externalRuntime.length} runtime, ${depResult.externalDev.length} dev/test tooling) |`);
348
+ } else {
349
+ lines.push(`| External packages | ${s.externalDeps} |`);
350
+ }
347
351
  lines.push(`| Circular dependencies | ${s.cycles} |`);
348
352
  lines.push(`| Orphan files (no imports or importers) | ${s.orphanFiles} |`);
349
353
  lines.push("");
@@ -352,7 +356,7 @@ export function renderDependencyGraph(depResult) {
352
356
  if (s.hubs.length > 0) {
353
357
  lines.push("## Hub Modules");
354
358
  lines.push("");
355
- lines.push("These are the most-imported modules in the codebase. Changes to hub modules have the widest blast radius and should be reviewed carefully:");
359
+ lines.push("These are the most-imported product modules (tests, examples, and build scripts are not counted). Changes to hub modules have the widest blast radius and should be reviewed carefully:");
356
360
  lines.push("");
357
361
  lines.push("| Module | Imported By |");
358
362
  lines.push("|--------|-------------|");
@@ -384,14 +388,29 @@ export function renderDependencyGraph(depResult) {
384
388
  lines.push("");
385
389
  lines.push(`The codebase imports **${depResult.externalDeps.length}** external package${depResult.externalDeps.length === 1 ? "" : "s"}. These are third-party modules (not the language standard library):`);
386
390
  lines.push("");
387
- // Render as a compact table for cleaner output
388
- const sorted = [...depResult.externalDeps].sort();
389
- lines.push("| Package |");
390
- lines.push("|---------|");
391
- for (const dep of sorted) {
392
- lines.push(`| \`${dep}\` |`);
391
+ const split = Array.isArray(depResult.externalDev) && depResult.externalDev.length > 0;
392
+ if (split) {
393
+ const renderTable = (title, names) => {
394
+ if (names.length === 0) return;
395
+ lines.push(`### ${title}`);
396
+ lines.push("");
397
+ lines.push("| Package |");
398
+ lines.push("|---------|");
399
+ for (const dep of [...names].sort()) lines.push(`| \`${dep}\` |`);
400
+ lines.push("");
401
+ };
402
+ renderTable(`Runtime (${depResult.externalRuntime.length})`, depResult.externalRuntime);
403
+ renderTable(`Development and test tooling (${depResult.externalDev.length})`, depResult.externalDev);
404
+ } else {
405
+ // Render as a compact table for cleaner output
406
+ const sorted = [...depResult.externalDeps].sort();
407
+ lines.push("| Package |");
408
+ lines.push("|---------|");
409
+ for (const dep of sorted) {
410
+ lines.push(`| \`${dep}\` |`);
411
+ }
412
+ lines.push("");
393
413
  }
394
- lines.push("");
395
414
  }
396
415
 
397
416
  lines.push("---");
@@ -1,3 +1,5 @@
1
+ import { replaceBracketParams } from "../utils/route-params.js";
2
+
1
3
  function isRouteFile(file) {
2
4
  return (
3
5
  file.includes("/pages/api/") ||
@@ -15,10 +17,10 @@ function routePathFromFile(file) {
15
17
  const appIndex = file.indexOf("/app/");
16
18
  const relative = file.slice(appIndex + 5);
17
19
 
18
- return "/" + relative
20
+ const cleaned = relative
19
21
  .replace(/\/page\.(ts|tsx|js|jsx)$/, "")
20
- .replace(/\/route\.(ts|tsx|js|jsx)$/, "")
21
- .replace(/\[(.*?)\]/g, ":$1");
22
+ .replace(/\/route\.(ts|tsx|js|jsx)$/, "");
23
+ return `/${replaceBracketParams(cleaned)}`;
22
24
  }
23
25
 
24
26
  if (file.includes("/pages/api/")) {
@@ -1,13 +1,12 @@
1
+ import { replaceLeadingAppGroup, replaceSlashGroups } from "../utils/route-params.js";
2
+
1
3
  function sanitizeNodeId(value) {
2
4
  return value.replace(/[^a-zA-Z0-9_]/g, "_");
3
5
  }
4
6
 
5
7
  function normalizeLabel(value) {
6
- return value
7
- .replace(/^src\//, "")
8
- .replace(/^app\/\((.*?)\)/, "app/$1")
9
- .replace(/\/\((.*?)\)/g, "/$1")
10
- .replace(/\/+/g, "/");
8
+ const withoutSrc = value.replace(/^src\//, "");
9
+ return replaceSlashGroups(replaceLeadingAppGroup(withoutSrc)).replace(/\/+/g, "/");
11
10
  }
12
11
 
13
12
  function buildModuleGraph(modules, depGraph) {
@@ -96,9 +95,11 @@ function buildModuleGraph(modules, depGraph) {
96
95
  */
97
96
  function findModuleForFile(fileKey, modules) {
98
97
  const normalized = fileKey.replace(/\\/g, "/");
98
+ const flat = modules.find((mod) => mod.flat) || modules.find((mod) => mod.key === "root");
99
99
  let bestMatch = null;
100
100
  for (const mod of modules) {
101
101
  const modKey = mod.key;
102
+ if (flat && modKey === flat.key) continue;
102
103
  // Direct match (with or without extension)
103
104
  if (normalized === modKey || normalized === modKey.replace(/\.[^/.]+$/, "")) {
104
105
  return modKey;
@@ -110,6 +111,7 @@ function findModuleForFile(fileKey, modules) {
110
111
  }
111
112
  }
112
113
  }
114
+ if (!bestMatch && flat && !normalized.includes("/")) return flat.key;
113
115
  return bestMatch;
114
116
  }
115
117
 
@@ -0,0 +1,58 @@
1
+ // Path roles: is a file test code, or tooling/example code rather than the product?
2
+ // Shared by the dependency graph, flow inference, pattern inference, and security scan
3
+ // so tests and examples never drive hub lists, coupling advice, or severity.
4
+
5
+ const TEST_DIRECTORIES = new Set([
6
+ "test", "tests", "spec", "specs", "__tests__", "__mocks__", "fixtures", "testdata", "e2e", "cypress",
7
+ ]);
8
+
9
+ // Directories holding tooling, samples, and docs. Code in here describes a build script
10
+ // or an example, not how the product works.
11
+ export const TOOLING_DIRECTORIES = new Set([
12
+ "doc", "docs", "doc-gen", "docgen", "scripts", "script", "tools", "tooling",
13
+ "example", "examples", "demo", "demos", "sample", "samples",
14
+ "benchmark", "benchmarks", "bench", "fixtures", "tasks",
15
+ ]);
16
+
17
+ function segments(filePath) {
18
+ return String(filePath || "").replace(/\\/g, "/").toLowerCase().split("/");
19
+ }
20
+
21
+ // Directory part of a path. A last segment without an extension (`test`, `examples/amd`)
22
+ // is a directory key such as a module, so it counts too.
23
+ function directorySegments(filePath) {
24
+ const parts = segments(filePath);
25
+ const last = parts[parts.length - 1] || "";
26
+ return /\.[a-z0-9]+$/.test(last) ? parts.slice(0, -1) : parts;
27
+ }
28
+
29
+ /** True for files in a test directory or named like a test (`a.test.js`, `a_test.go`, `test_a.py`). */
30
+ export function isTestPath(filePath) {
31
+ const parts = segments(filePath);
32
+ if (directorySegments(filePath).some((segment) => TEST_DIRECTORIES.has(segment))) return true;
33
+ const base = parts[parts.length - 1] || "";
34
+ if (/^(test_|spec_)/.test(base)) return true;
35
+ if (/(_test|_spec)\.[a-z0-9]+$/.test(base)) return true;
36
+ if (/\.(test|spec)\.[a-z0-9]+$/.test(base)) return true;
37
+ return false;
38
+ }
39
+
40
+ /** True when a directory above the file is a tooling, example, or docs directory. */
41
+ export function isToolingPath(filePath) {
42
+ return directorySegments(filePath).some((segment) => TOOLING_DIRECTORIES.has(segment));
43
+ }
44
+
45
+ /**
46
+ * Build-time or example code: runs on a maintainer's machine or in a browser demo, not in
47
+ * the shipped library. Includes `build*.js` scripts and anything under a tooling directory.
48
+ */
49
+ export function isBuildOrExamplePath(filePath) {
50
+ if (isToolingPath(filePath)) return true;
51
+ const base = segments(filePath).pop() || "";
52
+ return /^build([-_.]|$)/.test(base);
53
+ }
54
+
55
+ /** Test code or tooling/example code: not part of the product's own architecture. */
56
+ export function isNonProductPath(filePath) {
57
+ return isTestPath(filePath) || isToolingPath(filePath);
58
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Linear replacements for route and label text.
3
+ *
4
+ * A lazy `.*?` between two markers is polynomial: on a long string with no
5
+ * closer, each start position scans to the end (CodeQL js/polynomial-redos).
6
+ * These walks stop at the first closer and never re-scan, which matches the
7
+ * old `String.prototype.replace` result.
8
+ */
9
+
10
+ function replaceMarked(input, open, close, mapInner) {
11
+ const value = String(input ?? "");
12
+ let out = "";
13
+ let cursor = 0;
14
+ while (cursor < value.length) {
15
+ const start = value.indexOf(open, cursor);
16
+ if (start === -1) {
17
+ out += value.slice(cursor);
18
+ break;
19
+ }
20
+ const end = value.indexOf(close, start + open.length);
21
+ if (end === -1) {
22
+ out += value.slice(cursor);
23
+ break;
24
+ }
25
+ out += value.slice(cursor, start);
26
+ out += mapInner(value.slice(start + open.length, end));
27
+ cursor = end + close.length;
28
+ }
29
+ return out;
30
+ }
31
+
32
+ /** `[param]` → `:param`, including the first `]` only. Global, non-overlapping. */
33
+ export function replaceBracketParams(value) {
34
+ return replaceMarked(value, "[", "]", (inner) => `:${inner}`);
35
+ }
36
+
37
+ /** `/(group)` → `/group`. Used for Next.js route groups in diagram labels. */
38
+ export function replaceSlashGroups(value) {
39
+ return replaceMarked(value, "/(", ")", (inner) => `/${inner}`);
40
+ }
41
+
42
+ /** A leading `app/(group)` → `app/group`. Later groups use {@link replaceSlashGroups}. */
43
+ export function replaceLeadingAppGroup(value) {
44
+ const input = String(value ?? "");
45
+ const prefix = "app/(";
46
+ if (!input.startsWith(prefix)) return input;
47
+ const end = input.indexOf(")", prefix.length);
48
+ if (end === -1) return input;
49
+ return `app/${input.slice(prefix.length, end)}${input.slice(end + 1)}`;
50
+ }
package/src/utils/yaml.js CHANGED
@@ -1,14 +1,15 @@
1
- import yaml from "js-yaml";
1
+ import { CORE_SCHEMA, load, mergeTag } from "js-yaml";
2
2
 
3
3
  const MAX_YAML_CHARS = 1_000_000;
4
4
  const MAX_YAML_ALIASES = 2000;
5
5
 
6
6
  /**
7
7
  * Parse YAML without executing custom tags.
8
- * js-yaml 4 `load` is already safe (no JS types). This adds a size cap and
9
- * an alias cap so a config cannot expand into a billion-laughs document.
10
- * The js-yaml 4 default schema is unchanged (`true`/`false` are booleans;
11
- * the YAML 1.1 spellings `yes` and `no` stay strings).
8
+ * js-yaml 5 `load` uses the YAML 1.2 Core schema (`true`/`false` are booleans;
9
+ * the YAML 1.1 spellings `yes` and `no` stay strings). The merge key (`<<`)
10
+ * is added back so configs that relied on js-yaml 4's merge still parse.
11
+ * A size cap and `maxAliases` stop a billion-laughs document.
12
+ * An empty file returns `undefined`, matching the previous loader.
12
13
  */
13
14
  export function loadYaml(raw) {
14
15
  if (typeof raw !== "string") {
@@ -17,11 +18,15 @@ export function loadYaml(raw) {
17
18
  if (raw.length > MAX_YAML_CHARS) {
18
19
  throw new Error(`YAML document exceeds ${MAX_YAML_CHARS} characters`);
19
20
  }
21
+ if (raw.trim() === "") return undefined;
20
22
 
21
23
  const aliasUses = raw.match(/(^|[\s\[\{,])\*[A-Za-z_][\w-]*/g);
22
24
  if (aliasUses && aliasUses.length > MAX_YAML_ALIASES) {
23
25
  throw new Error(`YAML document has too many aliases (${aliasUses.length})`);
24
26
  }
25
27
 
26
- return yaml.load(raw);
28
+ return load(raw, {
29
+ schema: CORE_SCHEMA.withTags(mergeTag),
30
+ maxAliases: MAX_YAML_ALIASES,
31
+ });
27
32
  }