@bigsteele/the-prospect 0.1.1

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/README.md ADDED
@@ -0,0 +1,104 @@
1
+ # The Prospect
2
+
3
+ A prospector's read of your codebase and your market.
4
+
5
+ You built fast, and the codebase remembers every day of it. Dependencies
6
+ that stopped working for a living. The same block of code living in three
7
+ files. Two vendors billing you for one job. A subsystem you wrote by hand
8
+ in a weekend that the market now sells as an API. None of it was a bad
9
+ decision. It accreted, and it has been nobody's job to notice.
10
+
11
+ That is the ground you own. Then there is the territory: what your industry
12
+ ships today that your code shows you are still doing the hard way. The
13
+ credit-repair app parsing PDF reports by regex while report-access APIs
14
+ deliver structured bureau data with no PDFs anywhere. Every vertical has
15
+ its version of that, and finding yours is a research job, not a vibe.
16
+
17
+ The Prospect does both halves.
18
+
19
+ ## What one run gets you
20
+
21
+ ```
22
+ npx @bigsteele/the-prospect
23
+ ```
24
+
25
+ Step 0 runs offline, reads everything, and writes two files into your repo:
26
+ `the-prospect-<app>.md` and `.json`. In them:
27
+
28
+ - **Lane 1 - what to subtract.** Dependencies with no reference found
29
+ anywhere, files no entrypoint reaches, duplicated blocks (copies that
30
+ announce themselves as deliberate are honoured, not flagged), jobs paid
31
+ for twice, subsystems built by hand where a rail exists, and paid calls
32
+ that multiply per row, per request, or on a clock. Every finding cites
33
+ its file. Every line is badged READ.
34
+ - **The stack cut.** Platforms whose job a platform you already run can
35
+ carry: a vercel.json beside the Cloudflare account that already hosts
36
+ things, a Clerk bill beside the Supabase plan that includes auth, CI
37
+ workflows whose every step is an npm script the pushing machine runs
38
+ free. Both sides of each pair are read from your config files; whether
39
+ the kept platform's current plan truly carries the load is what the
40
+ research half verifies, with a source. Publish workflows with a registry
41
+ identity are never flagged: that is a job a laptop cannot do.
42
+ - **A score out of 100** and a level, 0 Overgrown to 4 Sharp, with floors a
43
+ score cannot fake: an unreferenced paid service caps you at 2 no matter
44
+ how tidy the rest is.
45
+ - **Your North Star, read, never invented** - from NORTH-STAR.md, your
46
+ planning docs, or PRODUCT.md, with the source named. If none exists, the
47
+ scan says UNKNOWN instead of guessing.
48
+ - **Your industry fingerprint** - the domain vocabulary your own schema and
49
+ routes use, which is what the second half researches against.
50
+
51
+ Then:
52
+
53
+ ```
54
+ npx @bigsteele/the-prospect --run
55
+ ```
56
+
57
+ opens Claude Code with the research protocol. It confirms your vertical
58
+ with you, researches the actual vendor and API landscape with sources and
59
+ dates, verifies every stack-cut pair against the kept platform's current
60
+ plan, and fills Lane 2: at most five suggestions, each in a fixed shape -
61
+ *Since you* (a fact from your code, file cited), *Have you considered*
62
+ (always two options, or one vendor against building it yourself), *Because*
63
+ (the industry fact, with a source URL and the year it was checked), *Your
64
+ customer gets* (the benefit in the customer's terms), *First test* (a
65
+ one-week test needing nobody's permission, and the observation that would
66
+ kill the idea).
67
+
68
+ A suggestion missing any leg is cut, not softened. And:
69
+
70
+ ```
71
+ npx @bigsteele/the-prospect --check
72
+ ```
73
+
74
+ fails the finished report mechanically if it breaks the law: unsourced
75
+ claims, undated claims, single-vendor pitches, advice verbs, missing legs.
76
+ Exit 0 when it holds, 2 when it does not.
77
+
78
+ ## Standalone
79
+
80
+ One npx on a cold repository produces the full scan. No sibling scan
81
+ installed, run, or required.
82
+
83
+ ## What it deliberately is not
84
+
85
+ Not a linter, not a security review, not a design review; it runs nothing.
86
+ Not a rewrite tool: it writes a report and changes no code. Not a vendor
87
+ ad: two options minimum, always, with the trade stated. And Step 0 never
88
+ touches the network - the research half runs only in the agent protocol,
89
+ where every claim carries a source and a date, and the report marks every
90
+ line as READ (from your repository) or RESEARCHED (from your market) so
91
+ you always know which kind of fact you are holding.
92
+
93
+ Read-only and offline. No network, no database, no shell, and it never
94
+ opens an `.env` file. If any output string is shaped like a credential,
95
+ nothing is written at all: a committed secret is the thing to fix first.
96
+
97
+ ## After the report
98
+
99
+ **bigsteele.com/scan** - upload the report. You get a written read of the
100
+ three moves worth making first and what each one buys. No call required to
101
+ get it, and no pitch inside it. When you want the plan argued with a
102
+ person: the $497 Scan Analysis call.
103
+
104
+ Big Steele · bigsteele.com
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The gate: measure a FINISHED Prospect report - the one the agent wrote -
3
+ * and fail it mechanically before a founder ever reads it.
4
+ *
5
+ * The family lesson from every scan before this one: a quality bar nothing
6
+ * measures drifts straight back to the writer's habits, and the writer here
7
+ * is a model. So the three-legged law is not a request in a prompt, it is
8
+ * a set of greps:
9
+ *
10
+ * - every R&D suggestion carries all five anatomy lines
11
+ * - every "Because" line carries a source URL and a year
12
+ * - every "Have you considered" line carries at least two options
13
+ * - banned register never appears (advice verbs, em dashes, horoscope)
14
+ * - READ and RESEARCHED badges both exist - a report with no RESEARCHED
15
+ * line skipped the research, and a report with no READ line skipped
16
+ * the repository
17
+ */
18
+ export interface CheckFinding {
19
+ where: string;
20
+ problem: string;
21
+ line?: string;
22
+ }
23
+ export declare function checkReport(md: string): {
24
+ pass: boolean;
25
+ findings: CheckFinding[];
26
+ };
package/dist/check.js ADDED
@@ -0,0 +1,77 @@
1
+ /**
2
+ * The gate: measure a FINISHED Prospect report - the one the agent wrote -
3
+ * and fail it mechanically before a founder ever reads it.
4
+ *
5
+ * The family lesson from every scan before this one: a quality bar nothing
6
+ * measures drifts straight back to the writer's habits, and the writer here
7
+ * is a model. So the three-legged law is not a request in a prompt, it is
8
+ * a set of greps:
9
+ *
10
+ * - every R&D suggestion carries all five anatomy lines
11
+ * - every "Because" line carries a source URL and a year
12
+ * - every "Have you considered" line carries at least two options
13
+ * - banned register never appears (advice verbs, em dashes, horoscope)
14
+ * - READ and RESEARCHED badges both exist - a report with no RESEARCHED
15
+ * line skipped the research, and a report with no READ line skipped
16
+ * the repository
17
+ */
18
+ const BANNED = [
19
+ { name: "advice (you should)", re: /\byou should\b/i },
20
+ { name: "advice (you must)", re: /\byou must\b/i },
21
+ { name: "advice (you need to)", re: /\byou need to\b/i },
22
+ { name: "em dash", re: /—/ },
23
+ { name: "certainty about the future", re: /\bwill (always|never|definitely)\b/i },
24
+ { name: "horoscope register", re: /\b(game.?changer|revolutioni[sz]e|unlock the power|supercharge|10x your)\b/i },
25
+ ];
26
+ const ANATOMY = ["Since you", "Have you considered", "Because", "Your customer gets", "First test"];
27
+ export function checkReport(md) {
28
+ const findings = [];
29
+ for (const b of BANNED) {
30
+ const lines = md.split("\n");
31
+ for (let i = 0; i < lines.length; i++) {
32
+ if (b.re.test(lines[i]))
33
+ findings.push({ where: `line ${i + 1}`, problem: b.name, line: lines[i].trim().slice(0, 100) });
34
+ }
35
+ }
36
+ if (!/\[READ\]/.test(md)) {
37
+ findings.push({ where: "whole report", problem: "no [READ] badge - the repository half is missing or unmarked" });
38
+ }
39
+ // Lane 2: each suggestion block (### heading under the territory section).
40
+ const lane2Heading = /^## .*territory.*$/im.exec(md)?.[0] ?? "";
41
+ const lane2 = md.split(/^## .*territory.*$/im)[1]?.split(/^## /m)[0] ?? "";
42
+ const ranProtocol = !/not yet run/i.test(lane2Heading + lane2);
43
+ if (ranProtocol && lane2.trim()) {
44
+ if (!/\[RESEARCHED\]/.test(lane2Heading + lane2)) {
45
+ findings.push({ where: "Lane 2", problem: "no [RESEARCHED] badge - industry claims must be marked as researched" });
46
+ }
47
+ const blocks = lane2.split(/^### /m).slice(1);
48
+ if (blocks.length === 0) {
49
+ findings.push({ where: "Lane 2", problem: "protocol ran but no suggestion blocks found (### headings)" });
50
+ }
51
+ blocks.forEach((block, i) => {
52
+ const name = block.split("\n")[0]?.trim().slice(0, 60) ?? `suggestion ${i + 1}`;
53
+ for (const part of ANATOMY) {
54
+ if (!new RegExp(`\\*\\*${part}`, "i").test(block) && !new RegExp(`^${part}`, "im").test(block)) {
55
+ findings.push({ where: name, problem: `missing anatomy line: "${part}"` });
56
+ }
57
+ }
58
+ // The Because leg needs a source and a date, or it is a vibe.
59
+ const because = /(?:\*\*Because\*\*|^Because)[:\s]([\s\S]*?)(?=\n\s*(?:\*\*|$))/im.exec(block)?.[1] ?? "";
60
+ if (because && !/https?:\/\//.test(because)) {
61
+ findings.push({ where: name, problem: "Because line has no source URL" });
62
+ }
63
+ if (because && !/\b20\d{2}\b/.test(because)) {
64
+ findings.push({ where: name, problem: "Because line has no date - an undated industry claim is a rumour" });
65
+ }
66
+ // Two options, or it reads as an ad.
67
+ const considered = /(?:\*\*Have you considered\*\*|^Have you considered)[:\s]([\s\S]*?)(?=\n\s*(?:\*\*|$))/im.exec(block)?.[1] ?? "";
68
+ if (considered && !/\bor\b/i.test(considered)) {
69
+ findings.push({ where: name, problem: "only one option offered - two vendors, or a vendor and the build-it-yourself path" });
70
+ }
71
+ });
72
+ if (blocks.length > 5) {
73
+ findings.push({ where: "Lane 2", problem: `${blocks.length} suggestions - the strongest five belong here, the rest in an appendix` });
74
+ }
75
+ }
76
+ return { pass: findings.length === 0, findings };
77
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env node
2
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,132 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The command line. Three things, meant to be run in this order:
4
+ *
5
+ * (default) Step 0: scan and report. Offline, read-only, writes only
6
+ * the two report files.
7
+ * --run open Claude Code with the protocol: the industry half,
8
+ * researched with sources and dates, against Step 0's facts.
9
+ * --check measure a finished report and fail it mechanically -
10
+ * missing legs, unsourced claims, single-vendor pitches,
11
+ * banned register.
12
+ *
13
+ * --check is the one that matters six months from now. The three-legged
14
+ * law is only a law while something fails reports that break it.
15
+ */
16
+ import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
17
+ import { dirname, join, resolve } from "node:path";
18
+ import { fileURLToPath } from "node:url";
19
+ import { spawn } from "node:child_process";
20
+ import { runProspect, toMarkdown, secretShaped, checkReport, VERSION } from "./index.js";
21
+ const log = (s = "") => process.stdout.write(s + "\n");
22
+ const args = process.argv.slice(2);
23
+ const has = (f) => args.includes(f);
24
+ const valueOf = (f) => {
25
+ const i = args.indexOf(f);
26
+ return i >= 0 ? args[i + 1] : undefined;
27
+ };
28
+ const HELP = `The Prospect ${VERSION} - a prospector's read of your codebase and your market.
29
+
30
+ npx @bigsteele/the-prospect [dir] Step 0: scan and report (offline, read-only)
31
+ npx @bigsteele/the-prospect --run open Claude Code with the research protocol
32
+ npx @bigsteele/the-prospect --check fail a finished report that breaks the law
33
+
34
+ --repo <dir> the repository to read (default: here)
35
+ --out <dir> where the report goes (default: the repository root)
36
+ --report <file> the report --check should measure (default: the newest one here)
37
+ --stdout print the report instead of writing it
38
+ --yes skip the confirmation on --run
39
+
40
+ Exit 0 when it passes, 2 when it does not, 1 when it refuses.`;
41
+ const slug = (s) => s.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "app";
42
+ async function main() {
43
+ if (has("--help") || has("-h")) {
44
+ log(HELP);
45
+ return 0;
46
+ }
47
+ const flagsWithValue = ["--repo", "--out", "--report"];
48
+ const positional = args.find((a) => !a.startsWith("-") && !flagsWithValue.includes(args[args.indexOf(a) - 1] ?? ""));
49
+ const repoDir = resolve(valueOf("--repo") ?? positional ?? process.cwd());
50
+ if (has("--check")) {
51
+ const file = valueOf("--report");
52
+ let path = file ? resolve(file) : "";
53
+ if (!path) {
54
+ // The newest prospect report beside us.
55
+ const { readdir } = await import("node:fs/promises");
56
+ const names = (await readdir(repoDir)).filter((n) => /^the-prospect-.*\.md$/.test(n)).sort();
57
+ if (names.length === 0) {
58
+ log("No report to check. Run the scan first, then the protocol, then this.");
59
+ return 2;
60
+ }
61
+ path = join(repoDir, names[names.length - 1]);
62
+ }
63
+ const md = await readFile(path, "utf8");
64
+ const { pass, findings } = checkReport(md);
65
+ if (pass) {
66
+ log(`${path} holds the law: every suggestion stands on three legs, every claim carries a source.`);
67
+ return 0;
68
+ }
69
+ for (const f of findings)
70
+ log(`FAIL ${f.where}: ${f.problem}${f.line ? ` | ${f.line}` : ""}`);
71
+ log("");
72
+ log(`${findings.length} finding(s). A suggestion missing a leg is cut, not softened.`);
73
+ return 2;
74
+ }
75
+ if (has("--run")) {
76
+ const here = dirname(fileURLToPath(import.meta.url));
77
+ const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
78
+ if (!has("--yes")) {
79
+ log("");
80
+ log("This opens Claude Code and asks it to research your industry against Step 0's facts.");
81
+ log("It reads the scan output, verifies findings in the repository, and searches the web.");
82
+ log("It changes no source code. Run the scan first so the facts exist. Ctrl-C to stop.");
83
+ log("");
84
+ }
85
+ const child = spawn("claude", [`Follow the protocol in ${protocol}. The repository is ${repoDir}.`], {
86
+ stdio: "inherit",
87
+ shell: false,
88
+ });
89
+ return new Promise((res) => child.on("exit", (c) => res(c ?? 0)));
90
+ }
91
+ const p = await runProspect(repoDir);
92
+ const leak = secretShaped(p);
93
+ if (leak) {
94
+ log(`Refusing to write anything: the output contains something shaped like a credential (${leak}).`);
95
+ log("That means a secret is committed in this repository. Fix that first.");
96
+ return 1;
97
+ }
98
+ const md = toMarkdown(p);
99
+ if (has("--stdout")) {
100
+ log(md);
101
+ }
102
+ else {
103
+ const outDir = resolve(valueOf("--out") ?? repoDir);
104
+ const name = `the-prospect-${slug(p.app)}`;
105
+ await mkdir(outDir, { recursive: true });
106
+ await writeFile(join(outDir, `${name}.md`), md, "utf8");
107
+ await writeFile(join(outDir, `${name}.json`), JSON.stringify(p, null, 2), "utf8");
108
+ // Keep the reports out of the founder's diff without touching their .gitignore.
109
+ try {
110
+ await appendFile(join(repoDir, ".git", "info", "exclude"), `\n${name}.md\n${name}.json\n`);
111
+ }
112
+ catch {
113
+ // not a git repository, nothing to exclude
114
+ }
115
+ log(`Wrote ${name}.md and ${name}.json`);
116
+ }
117
+ const noRef = p.deps.filter((d) => !d.dev && d.no_reference_found).length;
118
+ log("");
119
+ log(`${p.score.total}/100 (${p.score.grade}) - Level ${p.score.level.n}: ${p.score.level.name}.`);
120
+ if (noRef)
121
+ log(`${noRef} of ${p.totals.runtime_deps} runtime dependencies show no reference anywhere.`);
122
+ if (p.overlaps.length)
123
+ log(`${p.overlaps.length} categor${p.overlaps.length > 1 ? "ies" : "y"} of work paid for twice.`);
124
+ if (p.handrolled.length)
125
+ log(`${p.handrolled.length} subsystem(s) built by hand where the market sells a rail.`);
126
+ log(`Next: npx @bigsteele/the-prospect --run (the industry half, researched)`);
127
+ return 0;
128
+ }
129
+ main().then((code) => process.exit(code), (err) => {
130
+ log(`the-prospect failed: ${err instanceof Error ? err.message : String(err)}`);
131
+ process.exit(1);
132
+ });
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Cost surfaces: calls whose price multiplies.
3
+ *
4
+ * A model call costs a fraction of a cent. A model call in a request handler
5
+ * costs a fraction of a cent times every request, forever, and the invoice
6
+ * arrives a month after the code review that could have caught it. Three
7
+ * shapes multiply: per-request (inside a handler), per-row (inside a loop),
8
+ * and per-schedule (on a clock). The detector reports the shape and the
9
+ * line; whether the multiplication is worth it is the founder's call, made
10
+ * once, on purpose, instead of never.
11
+ */
12
+ import type { Repo } from "../walk.js";
13
+ import type { CostFact } from "./types.js";
14
+ export declare function detectCosts(repo: Repo): Promise<CostFact[]>;
@@ -0,0 +1,46 @@
1
+ import { runtimeCode } from "../walk.js";
2
+ const PAID_CALL = /\bfetch\(\s*[`"']https?:\/\/(api\.openai\.com|api\.anthropic\.com|generativelanguage\.googleapis\.com|api\.stripe\.com|api\.twilio\.com|api\.resend\.com|api\.sendgrid\.com|api\.cloudflare\.com|api\.replicate\.com|api\.elevenlabs\.io)|\.(messages|completions|chat|embeddings|images)\.create\(|generateContent|\.send(Email|Mail|Sms)?\(/;
3
+ const LOOP_HEAD = /\bfor(\s+await)?\s*\(|\bwhile\s*\(|\.(map|forEach)\(\s*(async\b|\()/;
4
+ const HANDLER = /(^|\/)(functions|api|routes?|handlers?)\//i;
5
+ const SCHEDULE = /\bsetInterval\s*\(|\bcron\b|schedule/i;
6
+ function hostOrCall(line) {
7
+ const host = /https?:\/\/([a-z0-9.-]+)/i.exec(line)?.[1];
8
+ if (host)
9
+ return host;
10
+ const call = /\.((?:messages|completions|chat|embeddings|images)\.create|generateContent|send(?:Email|Mail|Sms)?)\(/.exec(line)?.[1];
11
+ return call ?? "external call";
12
+ }
13
+ export async function detectCosts(repo) {
14
+ const out = [];
15
+ for (const f of runtimeCode(repo.files)) {
16
+ const text = await repo.read(f);
17
+ if (!text || !PAID_CALL.test(text))
18
+ continue;
19
+ const lines = text.split("\n");
20
+ for (let i = 0; i < lines.length; i++) {
21
+ const line = lines[i];
22
+ if (!PAID_CALL.test(line))
23
+ continue;
24
+ const target = hostOrCall(line);
25
+ const evidence = `${i + 1}: ${line.trim().slice(0, 110)}`;
26
+ // per-row: a loop head within the twelve lines above the call.
27
+ const above = lines.slice(Math.max(0, i - 12), i).join("\n");
28
+ if (LOOP_HEAD.test(above)) {
29
+ out.push({ file: f, shape: "per-row", target, line: evidence });
30
+ continue;
31
+ }
32
+ // per-schedule: the call sits under an interval or a cron marker.
33
+ if (SCHEDULE.test(above) || (SCHEDULE.test(text.slice(0, 800)) && SCHEDULE.test(f))) {
34
+ out.push({ file: f, shape: "per-schedule", target, line: evidence });
35
+ continue;
36
+ }
37
+ // per-request: the file is a request handler.
38
+ if (HANDLER.test(f)) {
39
+ out.push({ file: f, shape: "per-request", target, line: evidence });
40
+ }
41
+ }
42
+ }
43
+ // The loudest first: per-row beats per-request beats per-schedule.
44
+ const rank = { "per-row": 0, "per-request": 1, "per-schedule": 2 };
45
+ return out.sort((a, b) => rank[a.shape] - rank[b.shape]).slice(0, 40);
46
+ }
@@ -0,0 +1,22 @@
1
+ /**
2
+ * Runtime files no entrypoint reaches.
3
+ *
4
+ * Dead code is the quietest cost in a codebase: it is read by every new
5
+ * hire, matched by every search, weighed by every refactor, and it ships.
6
+ * Nobody deletes it because nobody can prove nothing uses it. This detector
7
+ * builds the import graph from every entrypoint and walks it; what the walk
8
+ * never touches is listed with the honest caption "no import path found
9
+ * from any entrypoint" - not "dead", because a file loaded by a string
10
+ * built at runtime imports nothing and lives anyway.
11
+ *
12
+ * Anything resembling config, declaration, or content is excluded before
13
+ * the walk: the detector's job is unreached BEHAVIOUR, not unreferenced
14
+ * markdown.
15
+ */
16
+ import type { Repo } from "../walk.js";
17
+ import type { DeadFact } from "./types.js";
18
+ export declare function detectDeadweight(repo: Repo): Promise<{
19
+ dead: DeadFact[];
20
+ entrypoints: number;
21
+ aliases: Record<string, string>;
22
+ }>;
@@ -0,0 +1,137 @@
1
+ import { CODE, TEST_FILE, NOT_RUNTIME } from "../walk.js";
2
+ const NOT_BEHAVIOUR = /(\.d\.ts$)|(^|\/)((tailwind|postcss|vite|next|astro|svelte|eslint|prettier|babel|jest|vitest|playwright|tsup|drizzle)[^/]*\.config|vite-env|main\.d)/i;
3
+ /** Entrypoints: where a runtime starts without any import naming the file. */
4
+ function entrypoints(repo, manifests) {
5
+ const out = new Set();
6
+ for (const m of manifests) {
7
+ const dir = m.file.replace(/package\.json$/, "");
8
+ for (const b of m.bin)
9
+ out.add(dir + b.replace(/^\.\//, ""));
10
+ if (m.main)
11
+ out.add(dir + m.main.replace(/^\.\//, ""));
12
+ }
13
+ for (const f of repo.files) {
14
+ if (/(^|\/)src\/(main|index|app|App)\.(t|j)sx?$/.test(f) ||
15
+ /(^|\/)(functions|api)\/[^/]+\/index\.(t|j)s$/.test(f) ||
16
+ /(^|\/)(pages|app)\/.*\.(t|j)sx?$/.test(f) || // file-based routers import nothing by name
17
+ /(^|\/)scripts?\/[^/]+\.(m|c)?(t|j)s$/.test(f) ||
18
+ /(^|\/)(index|server|worker|cli)\.(m|c)?(t|j)s$/.test(f) ||
19
+ TEST_FILE.test(f) // tests reach code; code only tests reach is a different finding
20
+ ) {
21
+ out.add(f);
22
+ }
23
+ }
24
+ return [...out].filter((f) => repo.files.includes(f));
25
+ }
26
+ function specifiers(text) {
27
+ const out = [];
28
+ for (const re of [
29
+ /\bimport\s+(?:[^"'`]*?\s+from\s+)?["']([^"'\n]+)["']/g,
30
+ /\brequire\(\s*["']([^"'\n]+)["']\s*\)/g,
31
+ /\bimport\(\s*["']([^"'\n]+)["']\s*\)/g,
32
+ /\bexport\s+[^"'`\n]*?\s+from\s+["']([^"'\n]+)["']/g,
33
+ ]) {
34
+ for (const m of text.matchAll(re))
35
+ out.push(m[1]);
36
+ }
37
+ return out;
38
+ }
39
+ const EXTS = ["", ".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", "/index.ts", "/index.tsx", "/index.js"];
40
+ export async function detectDeadweight(repo) {
41
+ const fileSet = new Set(repo.files);
42
+ // Path aliases: the common `@/* -> src/*` and friends, read from tsconfig.
43
+ const aliases = {};
44
+ for (const tc of repo.files.filter((f) => /(^|\/)tsconfig[^/]*\.json$/.test(f))) {
45
+ const text = await repo.read(tc);
46
+ if (!text)
47
+ continue;
48
+ const paths = /"paths"\s*:\s*\{([\s\S]*?)\}/.exec(text)?.[1] ?? "";
49
+ for (const m of paths.matchAll(/"([^"]+)\/\*"\s*:\s*\[\s*"([^"]+)\/\*"/g)) {
50
+ const baseDir = tc.replace(/tsconfig[^/]*\.json$/, "");
51
+ aliases[m[1]] = (baseDir + m[2].replace(/^\.\//, "")).replace(/^\/+/, "");
52
+ }
53
+ }
54
+ const manifests = [];
55
+ for (const f of repo.files.filter((x) => /(^|\/)package\.json$/.test(x) && !/node_modules|fixtures?/.test(x))) {
56
+ const text = await repo.read(f);
57
+ if (!text)
58
+ continue;
59
+ try {
60
+ const j = JSON.parse(text);
61
+ const bin = typeof j.bin === "string" ? [j.bin] : Object.values(j.bin ?? {}).map(String);
62
+ manifests.push({ file: f, bin, main: typeof j.main === "string" ? j.main : undefined });
63
+ }
64
+ catch {
65
+ // a manifest that does not parse declares nothing
66
+ }
67
+ }
68
+ const resolve = (from, spec) => {
69
+ let path = null;
70
+ if (spec.startsWith(".")) {
71
+ const parts = from.split("/").slice(0, -1);
72
+ for (const seg of spec.split("/")) {
73
+ if (seg === "." || seg === "")
74
+ continue;
75
+ else if (seg === "..")
76
+ parts.pop();
77
+ else
78
+ parts.push(seg);
79
+ }
80
+ path = parts.join("/");
81
+ }
82
+ else {
83
+ for (const [alias, target] of Object.entries(aliases)) {
84
+ if (spec === alias || spec.startsWith(alias + "/")) {
85
+ path = spec.replace(alias, target);
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ if (!path)
91
+ return null;
92
+ const base = path.replace(/\.(js|mjs|cjs)$/, ""); // TS emits .js specifiers for .ts files
93
+ for (const candidate of [path, base]) {
94
+ for (const ext of EXTS) {
95
+ if (fileSet.has(candidate + ext))
96
+ return candidate + ext;
97
+ }
98
+ }
99
+ return null;
100
+ };
101
+ const roots = entrypoints(repo, manifests);
102
+ const reached = new Set(roots);
103
+ const queue = [...roots];
104
+ while (queue.length) {
105
+ const f = queue.pop();
106
+ const text = await repo.read(f);
107
+ if (!text)
108
+ continue;
109
+ for (const spec of specifiers(text)) {
110
+ const to = resolve(f, spec);
111
+ if (to && !reached.has(to)) {
112
+ reached.add(to);
113
+ queue.push(to);
114
+ }
115
+ }
116
+ }
117
+ const dead = [];
118
+ for (const f of repo.files) {
119
+ if (!CODE.test(f) || TEST_FILE.test(f) || NOT_RUNTIME.test(f) || NOT_BEHAVIOUR.test(f))
120
+ continue;
121
+ if (reached.has(f))
122
+ continue;
123
+ const text = await repo.read(f);
124
+ if (!text)
125
+ continue;
126
+ dead.push({
127
+ file: f,
128
+ loc: text.split("\n").length,
129
+ note: "no import path found from any entrypoint",
130
+ });
131
+ }
132
+ return {
133
+ dead: dead.sort((a, b) => b.loc - a.loc).slice(0, 60),
134
+ entrypoints: roots.length,
135
+ aliases,
136
+ };
137
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The dependency roster: declared against actually seen.
3
+ *
4
+ * The exemplar repo declared 94 runtime dependencies. Nobody could say which
5
+ * ones worked for a living, because the only ledger was package.json and
6
+ * package.json records intentions, not usage. This detector reads every
7
+ * import and require in the repository and every config file that names a
8
+ * package as a string, and reports the difference.
9
+ *
10
+ * Honesty rule: the flag is `no_reference_found`, never "unused". A CLI tool
11
+ * invoked from an npm script, a peer dependency a plugin loads by name at
12
+ * runtime - these import nothing and still work. The report says what was
13
+ * looked for and not found, and lets the founder answer for the rest.
14
+ *
15
+ * The ways a package works without an import are looked for too, because each
16
+ * one was a false flag on a real app before it was: react-dom in a Next app
17
+ * (a peer of next), tw-animate-css (an `@import` in a stylesheet), @tiptap/pm
18
+ * (a peer of @tiptap/react), react-email (run as `email` from a script), and
19
+ * Capacitor plugins (registered by the native build from package.json).
20
+ */
21
+ import type { Repo } from "../walk.js";
22
+ import type { DepFact } from "./types.js";
23
+ /** `@scope/pkg/deep/path` -> `@scope/pkg`; `pkg/deep` -> `pkg`. Relative and URL imports return null. */
24
+ export declare function packageOf(spec: string): string | null;
25
+ export declare function detectDeps(repo: Repo): Promise<DepFact[]>;