katman-mcp 0.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.
- package/LICENSE +13 -0
- package/README.md +160 -0
- package/dist/cli.js +71 -0
- package/dist/context.js +43 -0
- package/dist/crawlers.js +86 -0
- package/dist/index.js +25 -0
- package/dist/license.js +448 -0
- package/dist/meta.js +28 -0
- package/dist/net.js +128 -0
- package/dist/server.js +17 -0
- package/dist/state.js +81 -0
- package/dist/tools/account/account.js +64 -0
- package/dist/tools/account/cli.js +21 -0
- package/dist/tools/account/index.js +48 -0
- package/dist/tools/account/sync.js +142 -0
- package/dist/tools/account/upsell.js +290 -0
- package/dist/tools/crawl/audit/analyze.js +719 -0
- package/dist/tools/crawl/audit/bots.js +114 -0
- package/dist/tools/crawl/audit/checks.js +126 -0
- package/dist/tools/crawl/audit/html.js +382 -0
- package/dist/tools/crawl/audit/index.js +364 -0
- package/dist/tools/crawl/audit/model.js +1 -0
- package/dist/tools/crawl/audit/report.js +199 -0
- package/dist/tools/crawl/audit/robots.js +199 -0
- package/dist/tools/crawl/audit/sitemap.js +147 -0
- package/dist/tools/crawl/cli.js +106 -0
- package/dist/tools/crawl/common/advice.js +61 -0
- package/dist/tools/crawl/common/findings.js +57 -0
- package/dist/tools/crawl/common/routes.js +80 -0
- package/dist/tools/crawl/common/text.js +89 -0
- package/dist/tools/crawl/common/urls.js +64 -0
- package/dist/tools/crawl/index.js +226 -0
- package/dist/tools/crawl/indexnow.js +271 -0
- package/dist/tools/crawl/keywords.js +178 -0
- package/dist/tools/crawl/scan/framework.js +645 -0
- package/dist/tools/crawl/scan/index.js +175 -0
- package/dist/tools/crawl/scan/project.js +180 -0
- package/dist/tools/crawl/scan/routes.js +354 -0
- package/dist/tools/crawl/scan/signals.js +414 -0
- package/dist/tools/method/cli.js +42 -0
- package/dist/tools/method/core/brief.js +302 -0
- package/dist/tools/method/core/extract.js +435 -0
- package/dist/tools/method/core/generate.js +504 -0
- package/dist/tools/method/core/lint.js +556 -0
- package/dist/tools/method/core/plan.js +405 -0
- package/dist/tools/method/core/prompt.js +150 -0
- package/dist/tools/method/core/questions.js +196 -0
- package/dist/tools/method/core/report.js +182 -0
- package/dist/tools/method/core/research-run.js +148 -0
- package/dist/tools/method/core/research.js +448 -0
- package/dist/tools/method/core/snippets.js +292 -0
- package/dist/tools/method/core/start.js +277 -0
- package/dist/tools/method/data/anatomy.js +141 -0
- package/dist/tools/method/data/appendices.js +202 -0
- package/dist/tools/method/data/cloudflare.js +192 -0
- package/dist/tools/method/data/evidence.js +137 -0
- package/dist/tools/method/data/geo-jobs.js +128 -0
- package/dist/tools/method/data/index.js +19 -0
- package/dist/tools/method/data/landing.js +67 -0
- package/dist/tools/method/data/lessons.js +127 -0
- package/dist/tools/method/data/measurement.js +142 -0
- package/dist/tools/method/data/method.js +141 -0
- package/dist/tools/method/data/offsite.js +148 -0
- package/dist/tools/method/data/pages.js +167 -0
- package/dist/tools/method/data/prompts.js +633 -0
- package/dist/tools/method/data/publish.js +113 -0
- package/dist/tools/method/data/render.js +117 -0
- package/dist/tools/method/data/research.js +131 -0
- package/dist/tools/method/data/roadmap.js +105 -0
- package/dist/tools/method/data/traps.js +74 -0
- package/dist/tools/method/data/verify.js +88 -0
- package/dist/tools/method/data/viral-list.js +39 -0
- package/dist/tools/method/index.js +209 -0
- package/dist/tools/method/lib/calls.js +53 -0
- package/dist/tools/method/lib/framework.js +525 -0
- package/dist/tools/method/lib/l10n.js +17 -0
- package/dist/tools/method/lib/routes.js +118 -0
- package/dist/tools/method/lib/state.js +454 -0
- package/dist/tools/method/lib/text.js +165 -0
- package/dist/tools/method/mcp/prompts.js +79 -0
- package/dist/tools/method/mcp/resources.js +50 -0
- package/dist/tools/visibility/analyze.js +463 -0
- package/dist/tools/visibility/cli.js +86 -0
- package/dist/tools/visibility/estimate.js +105 -0
- package/dist/tools/visibility/index.js +108 -0
- package/dist/tools/visibility/judge.js +156 -0
- package/dist/tools/visibility/manual.js +275 -0
- package/dist/tools/visibility/models.js +277 -0
- package/dist/tools/visibility/monitoring.js +165 -0
- package/dist/tools/visibility/openrouter.js +449 -0
- package/dist/tools/visibility/render.js +295 -0
- package/dist/tools/visibility/run.js +684 -0
- package/dist/tools/visibility/summary.js +164 -0
- package/dist/types.js +3 -0
- package/dist/util.js +92 -0
- package/package.json +54 -0
- package/server.json +34 -0
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import { mdTable, sortFindings, truncate } from "../../../util.js";
|
|
2
|
+
import { CSR_ACCEPT, JS_RENDER_NOTE, csrFixFor } from "../common/advice.js";
|
|
3
|
+
import { findingsList, severityLine } from "../common/findings.js";
|
|
4
|
+
import { detect } from "./framework.js";
|
|
5
|
+
import { loadProject } from "./project.js";
|
|
6
|
+
import { scanRoutes } from "./routes.js";
|
|
7
|
+
import { detectAnalytics, detectFiles, detectHeadSource, detectI18n, detectIndexingPitfalls, detectLinksAndImages, jsonLdTypes } from "./signals.js";
|
|
8
|
+
const KIND_ORDER = { public: 0, legal: 1, search: 2, checkout: 3, auth: 4, app: 5, api: 6, unknown: 7 };
|
|
9
|
+
export async function runScan(root, opts = {}) {
|
|
10
|
+
const project = await loadProject(root, { maxFiles: opts.maxFiles });
|
|
11
|
+
const det = detect(project);
|
|
12
|
+
const rs = scanRoutes(project, det);
|
|
13
|
+
const files = detectFiles(project, det, rs);
|
|
14
|
+
const head = detectHeadSource(project, det, rs);
|
|
15
|
+
const i18n = detectI18n(project, det);
|
|
16
|
+
const analytics = detectAnalytics(project, det.pkg?.deps);
|
|
17
|
+
const findings = [
|
|
18
|
+
...det.findings,
|
|
19
|
+
...files.findings,
|
|
20
|
+
...head.findings,
|
|
21
|
+
...i18n.findings,
|
|
22
|
+
...analytics.findings,
|
|
23
|
+
...detectLinksAndImages(project),
|
|
24
|
+
...detectIndexingPitfalls(project),
|
|
25
|
+
...routeFindings(det, rs, files.files),
|
|
26
|
+
];
|
|
27
|
+
const routes = [...rs.routes]
|
|
28
|
+
.sort((a, b) => (KIND_ORDER[a.kind] ?? 9) - (KIND_ORDER[b.kind] ?? 9) || Number(a.dynamic) - Number(b.dynamic) || a.path.localeCompare(b.path))
|
|
29
|
+
.slice(0, 2000);
|
|
30
|
+
const notes = [...head.notes];
|
|
31
|
+
if (project.truncated)
|
|
32
|
+
notes.push(`File cap reached (${project.stats.listed} files): pass a larger max_files or scan the app folder.`);
|
|
33
|
+
const result = {
|
|
34
|
+
version: 1,
|
|
35
|
+
ts: (opts.now ?? new Date()).toISOString(),
|
|
36
|
+
root,
|
|
37
|
+
framework: det.framework,
|
|
38
|
+
rendering: det.rendering,
|
|
39
|
+
hosting: det.hosting,
|
|
40
|
+
...(det.domains.length ? { domains: det.domains } : {}),
|
|
41
|
+
staticDir: det.staticDir,
|
|
42
|
+
routes,
|
|
43
|
+
files: { ...files.files, headSource: head.info },
|
|
44
|
+
jsonLdTypes: jsonLdTypes(project),
|
|
45
|
+
i18n: i18n.i18n,
|
|
46
|
+
analytics: analytics.analytics,
|
|
47
|
+
findings: dedupe(sortFindings(findings)),
|
|
48
|
+
};
|
|
49
|
+
return { result, project, det, routeScan: rs, notes };
|
|
50
|
+
}
|
|
51
|
+
function dedupe(fs) {
|
|
52
|
+
const seen = new Set();
|
|
53
|
+
return fs.filter((f) => (seen.has(f.id) ? false : (seen.add(f.id), true)));
|
|
54
|
+
}
|
|
55
|
+
function routeFindings(det, rs, files) {
|
|
56
|
+
const out = [];
|
|
57
|
+
const csr = det.rendering.verdict === "csr";
|
|
58
|
+
if (rs.hashRouting.length) {
|
|
59
|
+
out.push({
|
|
60
|
+
id: "scan-hash-routing",
|
|
61
|
+
title: "Hash routing: pages live behind # (e.g. /#/pricing)",
|
|
62
|
+
severity: "critical",
|
|
63
|
+
gate: "read",
|
|
64
|
+
prompt: "P2",
|
|
65
|
+
detail: `${rs.hashRouting.map((h) => `${h.file}:${h.line} ${h.match}`).slice(0, 3).join("; ")}. Everything after # never reaches the server, so crawlers see one URL for the whole site. ${JS_RENDER_NOTE}`,
|
|
66
|
+
files: rs.hashRouting.map((h) => `${h.file}:${h.line}`),
|
|
67
|
+
fix: `Switch to path routing (BrowserRouter / createBrowserRouter / createWebHistory) and serve each public path with its own HTML. ${csrFixFor(det.framework.name, det.framework.router)}`,
|
|
68
|
+
accept: `Every public page has its own path (no #/…) and returns its own HTML; ${CSR_ACCEPT}`,
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
const fallback = det.spa.spaFallback;
|
|
72
|
+
if (csr || fallback) {
|
|
73
|
+
const ev = [...rs.catchAll.map((c) => `${c.file}:${c.line} ${c.match}`), ...(fallback ? [fallback] : [])];
|
|
74
|
+
out.push({
|
|
75
|
+
id: "scan-spa-soft-404",
|
|
76
|
+
title: "Unknown URLs will answer 200 (single-page-app fallback → soft 404)",
|
|
77
|
+
severity: "high",
|
|
78
|
+
gate: "read",
|
|
79
|
+
prompt: "P11",
|
|
80
|
+
detail: `The server returns index.html for every path and the app shows "not found" in the browser, with status 200. Google counts that as a soft 404 (book trap #3).${ev.length ? ` Evidence: ${ev.slice(0, 3).join("; ")}.` : ""}`,
|
|
81
|
+
files: [...rs.catchAll.map((c) => c.file)].slice(0, 5),
|
|
82
|
+
fix: "Return a real 404 for unknown paths: list the valid routes at the edge/server (or prerender them and add a 404.html; Cloudflare Workers assets: not_found_handling \"404-page\"), or at least put noindex on the not-found screen.",
|
|
83
|
+
accept: 'curl -s -o /dev/null -w "%{http_code}" https://<domain>/does-not-exist prints 404',
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
if (!files.robots.exists) {
|
|
87
|
+
out.push({ id: "scan-no-robots", title: "No robots.txt in the codebase", severity: "medium", gate: "read", prompt: "P4",
|
|
88
|
+
detail: "Neither a static robots.txt nor a generator was found. Not required, but the book's file names each AI bot, closes only app paths and points to the sitemap.",
|
|
89
|
+
fix: "Add robots.txt to the static folder (book appendix A; katman_generate can write it from research).", accept: "robots.txt has no `Disallow: /` for *, names the AI bots and has a Sitemap line" });
|
|
90
|
+
}
|
|
91
|
+
if (!files.sitemap.exists) {
|
|
92
|
+
out.push({ id: "scan-no-sitemap", title: "No sitemap in the codebase", severity: "high", gate: "read", prompt: "P4",
|
|
93
|
+
detail: "No static sitemap.xml, generator route, plugin or build script was found.",
|
|
94
|
+
fix: "Generate sitemap.xml at build time from the page list: only 200, canonical, indexable URLs with a real lastmod.", accept: "sitemap.xml lists every public page; every URL in it returns 200 and is canonical" });
|
|
95
|
+
}
|
|
96
|
+
if (!files.llms.exists) {
|
|
97
|
+
out.push({ id: "scan-no-llms", title: "No llms.txt", severity: "info", gate: "quote", prompt: "P8",
|
|
98
|
+
detail: "Optional: Google ignores it and most files get no AI-bot requests. If you add one, keep it short and generated from the same data as the pages.",
|
|
99
|
+
fix: "katman_generate can write llms.txt from research (book appendix B).", accept: "llms.txt and the pages do not contradict each other on any price or claim" });
|
|
100
|
+
}
|
|
101
|
+
if (!files.notFound.exists && !csr && det.framework.name !== "unknown") {
|
|
102
|
+
out.push({ id: "scan-no-404-page", title: "No custom 404 page", severity: "low", gate: "read", prompt: "P11",
|
|
103
|
+
detail: "The platform default still returns 404, but a custom page can link visitors (and crawlers) back to key pages.",
|
|
104
|
+
fix: "Add a 404 page (with noindex) linking to the homepage and main sections.", accept: "Unknown URLs return 404 with a helpful page" });
|
|
105
|
+
}
|
|
106
|
+
const bad = rs.routes.filter((r) => r.kind === "public" && !r.dynamic && (/[A-Z_]/.test(r.path) || r.path.length > 100));
|
|
107
|
+
if (bad.length) {
|
|
108
|
+
out.push({ id: "scan-route-hygiene", title: `${bad.length} public route${bad.length === 1 ? " uses" : "s use"} uppercase/underscores or very long paths`, severity: "low", gate: "read", prompt: "P11",
|
|
109
|
+
detail: bad.slice(0, 5).map((r) => r.path).join(", "), fix: "Use lowercase, hyphenated paths for new pages; 301 old ones if you rename.", accept: "Public paths are lowercase and hyphenated" });
|
|
110
|
+
}
|
|
111
|
+
return out;
|
|
112
|
+
}
|
|
113
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
114
|
+
// Markdown
|
|
115
|
+
const Y = "✓";
|
|
116
|
+
const N = "✗";
|
|
117
|
+
function fileRow(label, info, missingHint) {
|
|
118
|
+
return [label, info.exists ? Y : N, info.exists ? `${info.path ?? ""}${info.generated === true ? " (generated)" : info.generated === false ? " (static file)" : ""}` : missingHint];
|
|
119
|
+
}
|
|
120
|
+
export function scanMarkdown(run, opts = {}) {
|
|
121
|
+
const r = run.result;
|
|
122
|
+
const L = [];
|
|
123
|
+
L.push(`# Katman code scan — ${r.root}`);
|
|
124
|
+
if (opts.warning)
|
|
125
|
+
L.push(`> ${opts.warning}`);
|
|
126
|
+
const fw = r.framework;
|
|
127
|
+
L.push(`Framework: **${fw.name}${fw.version ? ` ${fw.version}` : ""}**${fw.router ? ` (${fw.router})` : ""}${fw.builder ? ` · builder: ${fw.builder}` : ""}${r.hosting.provider ? ` · hosting: ${r.hosting.provider}` : ""}${run.det.appDir ? ` · app folder: ${run.det.appDir}/` : ""}`);
|
|
128
|
+
L.push(`Rendering: **${r.rendering.verdict.toUpperCase()}** (${r.rendering.confidence} confidence)`);
|
|
129
|
+
for (const e of r.rendering.evidence.slice(0, 5))
|
|
130
|
+
L.push(`- ${e}`);
|
|
131
|
+
if (r.hosting.previewPatterns.length)
|
|
132
|
+
L.push(`Preview/default addresses to check: ${r.hosting.previewPatterns.join(", ")}`);
|
|
133
|
+
if (run.det.domains.length)
|
|
134
|
+
L.push(`Live site in config: ${run.det.domains.map((d) => `https://${d}`).join(", ")} → audit it with katman_audit_site.`);
|
|
135
|
+
if (r.staticDir)
|
|
136
|
+
L.push(`Static folder (robots.txt, llms.txt, IndexNow key file go here): \`${r.staticDir}/\``);
|
|
137
|
+
L.push("");
|
|
138
|
+
L.push("## Files");
|
|
139
|
+
const sd = r.staticDir ? `${r.staticDir}/` : "";
|
|
140
|
+
const rows = [
|
|
141
|
+
fileRow("robots.txt", r.files.robots, `missing → ${sd}robots.txt (P4)`),
|
|
142
|
+
fileRow("sitemap.xml", r.files.sitemap, "missing → generate at build time (P4)"),
|
|
143
|
+
fileRow("llms.txt", r.files.llms, `optional → ${sd}llms.txt (P8)`),
|
|
144
|
+
fileRow("llms-full.txt", r.files.llmsFull, "optional"),
|
|
145
|
+
fileRow("404 page", r.files.notFound, "missing (P11)"),
|
|
146
|
+
fileRow("Head tags source", r.files.headSource ?? { exists: false }, "none found (P3)"),
|
|
147
|
+
["JSON-LD", r.jsonLdTypes.length ? Y : N, r.jsonLdTypes.length ? truncate(r.jsonLdTypes.join(", "), 120) : "none (P5)"],
|
|
148
|
+
["i18n", r.i18n.detected ? Y : "–", `${r.i18n.locales.length ? r.i18n.locales.join(", ") : "single language"}${r.i18n.library ? ` (${r.i18n.library})` : ""} · <html lang>: ${r.i18n.htmlLang ?? "not found"}`],
|
|
149
|
+
["Analytics", r.analytics.providers.length ? Y : N, `${r.analytics.providers.join(", ") || "none"} · AI channel (chatgpt.com / utm_source) tracked: ${r.analytics.tracksAiChannel ? Y : N}`],
|
|
150
|
+
];
|
|
151
|
+
L.push(mdTable(["File / signal", "Found", "Where"], rows));
|
|
152
|
+
L.push("");
|
|
153
|
+
const kinds = {};
|
|
154
|
+
for (const x of r.routes)
|
|
155
|
+
kinds[x.kind] = (kinds[x.kind] ?? 0) + 1;
|
|
156
|
+
L.push(`## Routes — ${r.routes.length} (${Object.entries(kinds).map(([k, v]) => `${v} ${k}`).join(" · ") || "none found"})`);
|
|
157
|
+
if (r.routes.length) {
|
|
158
|
+
L.push(mdTable(["Path", "Kind", "Dynamic", "File"], r.routes.slice(0, 60).map((x) => [truncate(x.path, 60), x.kind, x.dynamic ? "yes" : "", truncate(x.file ?? "", 60)])));
|
|
159
|
+
if (r.routes.length > 60)
|
|
160
|
+
L.push(`…${r.routes.length - 60} more in .katman/scan.json`);
|
|
161
|
+
}
|
|
162
|
+
L.push("");
|
|
163
|
+
L.push(`## Findings — ${severityLine(r.findings)}`);
|
|
164
|
+
L.push(findingsList(r.findings, { limit: 40 }));
|
|
165
|
+
if (r.findings.length > 40)
|
|
166
|
+
L.push(`…${r.findings.length - 40} more in .katman/scan.json`);
|
|
167
|
+
L.push("");
|
|
168
|
+
const s = run.project.stats;
|
|
169
|
+
L.push(`Scanned ${s.listed} files (read ${s.read}, ${Math.round(s.bytesRead / 1024)} KB); skipped ${s.skippedSecret} secret-looking file(s) unopened, ${s.skippedSymlink} symlink(s), ${s.skippedLarge} large file(s).${opts.savedTo ? ` Saved: ${opts.savedTo}` : ""}`);
|
|
170
|
+
for (const n of run.notes)
|
|
171
|
+
L.push(`- ${n}`);
|
|
172
|
+
L.push("");
|
|
173
|
+
L.push("Next: katman_audit_site checks what the live site actually serves (bots, JS-off text, 404s, sitemap); then fix the findings in order with katman_prompt.");
|
|
174
|
+
return L.join("\n");
|
|
175
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// Safe, bounded project walker for katman_scan_code.
|
|
2
|
+
// - never follows symlinks
|
|
3
|
+
// - skips dependency/build/cache folders
|
|
4
|
+
// - NEVER opens secret-looking files (.env*, .dev.vars*, *.pem, *.key, *.p12, *secret*, *credential*, service
|
|
5
|
+
// account JSON, key files…) — not even to report that they exist
|
|
6
|
+
// - caps the number of files (default 5000), the size of each file (512 KB) and the total bytes read
|
|
7
|
+
import { promises as fs } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
export const SKIP_DIRS = new Set([
|
|
10
|
+
"node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".output", ".vercel", ".netlify",
|
|
11
|
+
".wrangler", "coverage", ".katman", ".turbo", ".cache",
|
|
12
|
+
// other generated / dependency / tooling folders
|
|
13
|
+
".parcel-cache", ".astro", ".docusaurus", ".expo", ".yarn", ".pnpm-store", "bower_components", "vendor", ".venv",
|
|
14
|
+
"venv", "__pycache__", ".gradle", "Pods", ".angular", ".vite", "storybook-static", ".firebase", ".serverless",
|
|
15
|
+
".terraform", ".idea", ".vscode", ".husky", ".changeset", "target", "tmp", ".tmp", "logs", "backups",
|
|
16
|
+
]);
|
|
17
|
+
/** Hidden folders worth walking (builder signatures, workflows); other dot-folders are only noted by name. */
|
|
18
|
+
const WALK_HIDDEN = new Set([".github", ".bolt", ".cursor", ".idx", ".well-known", ".lovable", ".storybook", ".claude"]);
|
|
19
|
+
export function isNeverRead(name) {
|
|
20
|
+
const n = name.toLowerCase();
|
|
21
|
+
if (n.startsWith(".env") || n.startsWith(".dev.vars"))
|
|
22
|
+
return true;
|
|
23
|
+
if (/\.(pem|key|p12|pfx|jks|keystore|ppk|kdbx|gpg|asc|der|crt|cer|csr)$/.test(n))
|
|
24
|
+
return true;
|
|
25
|
+
if (n.includes("secret") || n.includes("credential"))
|
|
26
|
+
return true;
|
|
27
|
+
if (/(^|[._-])(api[-_]?key|access[-_]?token|auth[-_]?token|private[-_]?key)([._-]|$)/.test(n))
|
|
28
|
+
return true;
|
|
29
|
+
if (/[-_]key$/.test(n) || n === "key" || n.startsWith(".key"))
|
|
30
|
+
return true;
|
|
31
|
+
if (/service[-_]?account|adminsdk|keyfile|(^|[-_.])keys?\.json$/.test(n))
|
|
32
|
+
return true;
|
|
33
|
+
if ([".npmrc", ".netrc", ".pypirc", ".htpasswd", ".git-credentials", "id_rsa", "id_ed25519", "id_ecdsa", ".replit"].includes(n))
|
|
34
|
+
return true;
|
|
35
|
+
if (/^id_(rsa|ed25519|ecdsa|dsa)/.test(n))
|
|
36
|
+
return true;
|
|
37
|
+
return false;
|
|
38
|
+
}
|
|
39
|
+
const TEXT_EXT = new Set([
|
|
40
|
+
".js", ".jsx", ".ts", ".tsx", ".mjs", ".cjs", ".mts", ".cts", ".vue", ".svelte", ".astro", ".html", ".htm", ".md",
|
|
41
|
+
".mdx", ".markdown", ".json", ".jsonc", ".json5", ".toml", ".yaml", ".yml", ".txt", ".xml", ".liquid", ".njk",
|
|
42
|
+
".hbs", ".handlebars", ".ejs", ".pug", ".erb", ".php", ".twig", ".webmanifest", ".svx", ".rb", ".py",
|
|
43
|
+
]);
|
|
44
|
+
const TEXT_NAMES = new Set(["_headers", "_redirects", "procfile", "gemfile", "cname", "dockerfile", ".htaccess", "robots.txt"]);
|
|
45
|
+
const NO_READ = new Set(["package-lock.json", "yarn.lock", "pnpm-lock.yaml", "bun.lock", "bun.lockb", "npm-shrinkwrap.json", "composer.lock", "gemfile.lock", "cargo.lock", "poetry.lock"]);
|
|
46
|
+
export async function loadProject(root, opts = {}) {
|
|
47
|
+
const maxFiles = opts.maxFiles ?? 5000;
|
|
48
|
+
const maxFileBytes = opts.maxFileBytes ?? 512 * 1024;
|
|
49
|
+
const maxTotalBytes = opts.maxTotalBytes ?? 96 * 1024 * 1024;
|
|
50
|
+
const p = {
|
|
51
|
+
root,
|
|
52
|
+
files: new Map(),
|
|
53
|
+
dirs: new Set([""]),
|
|
54
|
+
seenDirNames: new Set(),
|
|
55
|
+
truncated: false,
|
|
56
|
+
stats: { listed: 0, read: 0, bytesRead: 0, skippedSecret: 0, skippedSymlink: 0, skippedLarge: 0 },
|
|
57
|
+
};
|
|
58
|
+
const queue = [""];
|
|
59
|
+
while (queue.length) {
|
|
60
|
+
const rel = queue.shift();
|
|
61
|
+
let entries;
|
|
62
|
+
try {
|
|
63
|
+
entries = await fs.readdir(path.join(root, rel), { withFileTypes: true });
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue;
|
|
67
|
+
}
|
|
68
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
69
|
+
for (const e of entries) {
|
|
70
|
+
const childRel = rel ? `${rel}/${e.name}` : e.name;
|
|
71
|
+
if (e.isSymbolicLink()) {
|
|
72
|
+
p.stats.skippedSymlink++;
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
if (e.isDirectory()) {
|
|
76
|
+
if (isNeverRead(e.name)) {
|
|
77
|
+
p.stats.skippedSecret++;
|
|
78
|
+
continue;
|
|
79
|
+
}
|
|
80
|
+
if (SKIP_DIRS.has(e.name) || (e.name.startsWith(".") && !WALK_HIDDEN.has(e.name))) {
|
|
81
|
+
p.seenDirNames.add(e.name);
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
p.dirs.add(childRel);
|
|
85
|
+
queue.push(childRel);
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
if (!e.isFile())
|
|
89
|
+
continue;
|
|
90
|
+
if (isNeverRead(e.name)) {
|
|
91
|
+
p.stats.skippedSecret++;
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (p.stats.listed >= maxFiles) {
|
|
95
|
+
p.truncated = true;
|
|
96
|
+
queue.length = 0;
|
|
97
|
+
break;
|
|
98
|
+
}
|
|
99
|
+
let size = 0;
|
|
100
|
+
try {
|
|
101
|
+
size = (await fs.lstat(path.join(root, childRel))).size;
|
|
102
|
+
}
|
|
103
|
+
catch {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
p.stats.listed++;
|
|
107
|
+
const file = { rel: childRel, size };
|
|
108
|
+
p.files.set(childRel, file);
|
|
109
|
+
const lower = e.name.toLowerCase();
|
|
110
|
+
const ext = path.extname(lower);
|
|
111
|
+
if (NO_READ.has(lower) || !(TEXT_EXT.has(ext) || TEXT_NAMES.has(lower)))
|
|
112
|
+
continue;
|
|
113
|
+
if (size > maxFileBytes) {
|
|
114
|
+
p.stats.skippedLarge++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (p.stats.bytesRead + size > maxTotalBytes)
|
|
118
|
+
continue;
|
|
119
|
+
try {
|
|
120
|
+
const text = await fs.readFile(path.join(root, childRel), "utf8");
|
|
121
|
+
p.stats.read++;
|
|
122
|
+
p.stats.bytesRead += size;
|
|
123
|
+
file.text = text;
|
|
124
|
+
file.minified = /\.min\.(js|css)$/.test(lower) || (text.length > 20_000 && text.split("\n").length < text.length / 1500);
|
|
125
|
+
}
|
|
126
|
+
catch {
|
|
127
|
+
/* unreadable: keep as listed */
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
// ---------------------------------------------------------------------------------------------------------------
|
|
134
|
+
// Query helpers
|
|
135
|
+
export function has(p, rel) {
|
|
136
|
+
return p.files.has(rel);
|
|
137
|
+
}
|
|
138
|
+
export function text(p, rel) {
|
|
139
|
+
return p.files.get(rel)?.text ?? "";
|
|
140
|
+
}
|
|
141
|
+
export function hasDir(p, rel) {
|
|
142
|
+
return p.dirs.has(rel);
|
|
143
|
+
}
|
|
144
|
+
export function list(p, re) {
|
|
145
|
+
const out = [];
|
|
146
|
+
for (const k of p.files.keys())
|
|
147
|
+
if (re.test(k))
|
|
148
|
+
out.push(k);
|
|
149
|
+
return out;
|
|
150
|
+
}
|
|
151
|
+
export function first(p, rels) {
|
|
152
|
+
return rels.find((r) => p.files.has(r));
|
|
153
|
+
}
|
|
154
|
+
const CODE_EXT = /\.(jsx?|tsx?|mjs|cjs|mts|cts|vue|svelte|astro|html?|njk|liquid|hbs|ejs|mdx|svx)$/i;
|
|
155
|
+
const TEST_PATH = /(^|\/)(__tests__|__mocks__|tests?|e2e|cypress|playwright|spec|fixtures|stories|storybook-static)(\/|$)|\.(test|spec|stories)\.[a-z]+$/i;
|
|
156
|
+
/** Source files worth pattern-scanning: code, not tests, not minified bundles. */
|
|
157
|
+
export function codeFiles(p, opts = {}) {
|
|
158
|
+
const out = [];
|
|
159
|
+
for (const f of p.files.values()) {
|
|
160
|
+
if (!f.text || f.minified)
|
|
161
|
+
continue;
|
|
162
|
+
if (!CODE_EXT.test(f.rel))
|
|
163
|
+
continue;
|
|
164
|
+
if (TEST_PATH.test(f.rel))
|
|
165
|
+
continue;
|
|
166
|
+
if (!opts.includeScripts && /(^|\/)scripts\//.test(f.rel))
|
|
167
|
+
continue;
|
|
168
|
+
if (/(^|\/)(public|static)\/.*\.js$/.test(f.rel))
|
|
169
|
+
continue; // shipped bundles / vendored browser scripts
|
|
170
|
+
out.push(f);
|
|
171
|
+
}
|
|
172
|
+
return out;
|
|
173
|
+
}
|
|
174
|
+
export function lineOf(textIn, index) {
|
|
175
|
+
let n = 1;
|
|
176
|
+
for (let i = 0; i < index && i < textIn.length; i++)
|
|
177
|
+
if (textIn.charCodeAt(i) === 10)
|
|
178
|
+
n++;
|
|
179
|
+
return n;
|
|
180
|
+
}
|