@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 +104 -0
- package/dist/check.d.ts +26 -0
- package/dist/check.js +77 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +132 -0
- package/dist/detect/costs.d.ts +14 -0
- package/dist/detect/costs.js +46 -0
- package/dist/detect/deadweight.d.ts +22 -0
- package/dist/detect/deadweight.js +137 -0
- package/dist/detect/deps.d.ts +25 -0
- package/dist/detect/deps.js +198 -0
- package/dist/detect/duplication.d.ts +17 -0
- package/dist/detect/duplication.js +79 -0
- package/dist/detect/fingerprint.d.ts +16 -0
- package/dist/detect/fingerprint.js +95 -0
- package/dist/detect/handrolled.d.ts +23 -0
- package/dist/detect/handrolled.js +112 -0
- package/dist/detect/stack.d.ts +66 -0
- package/dist/detect/stack.js +164 -0
- package/dist/detect/types.d.ts +106 -0
- package/dist/detect/types.js +11 -0
- package/dist/detect/vendors.d.ts +26 -0
- package/dist/detect/vendors.js +125 -0
- package/dist/index.d.ts +36 -0
- package/dist/index.js +93 -0
- package/dist/northstar.d.ts +11 -0
- package/dist/northstar.js +95 -0
- package/dist/report.d.ts +16 -0
- package/dist/report.js +166 -0
- package/dist/score.d.ts +47 -0
- package/dist/score.js +93 -0
- package/dist/walk.d.ts +44 -0
- package/dist/walk.js +123 -0
- package/package.json +49 -0
- package/prompt/THE-PROSPECT.md +125 -0
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
|
package/dist/check.d.ts
ADDED
|
@@ -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
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[]>;
|