@bigsteele/the-prospect 0.2.0 → 0.3.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/README.md +40 -17
- package/dist/check.js +3 -2
- package/dist/cli.js +93 -27
- package/dist/detect/costs.js +48 -6
- package/dist/detect/database.js +30 -5
- package/dist/detect/deadweight.js +60 -15
- package/dist/detect/handrolled.js +23 -14
- package/dist/detect/stack.d.ts +2 -0
- package/dist/detect/types.d.ts +33 -0
- package/dist/detect/vendors.js +95 -2
- package/dist/index.d.ts +3 -1
- package/dist/index.js +24 -1
- package/dist/report.js +28 -9
- package/dist/score.js +9 -5
- package/dist/verdicts.d.ts +80 -0
- package/dist/verdicts.js +144 -0
- package/package.json +1 -1
- package/prompt/THE-PROSPECT.md +162 -118
- package/dist/detect/deps 2.d.ts +0 -25
- package/dist/detect/deps 2.js +0 -198
- package/dist/detect/types 2.d.ts +0 -106
- package/dist/detect/types 2.js +0 -11
- package/dist/walk 2.d.ts +0 -44
- package/dist/walk 2.js +0 -123
package/README.md
CHANGED
|
@@ -48,32 +48,55 @@ Step 0 runs offline, reads everything, and writes two files into your repo:
|
|
|
48
48
|
- **Your industry fingerprint** - the domain vocabulary your own schema and
|
|
49
49
|
routes use, which is what the second half researches against.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Step 0 takes seconds, and it is a hypothesis machine: it reads shapes and
|
|
52
|
+
prints them, every one with an id. The read is the second command.
|
|
52
53
|
|
|
53
54
|
```
|
|
54
|
-
npx @bigsteele/the-prospect --run
|
|
55
|
+
npx @bigsteele/the-prospect --run # or --protocol, to paste THE-PROSPECT.md into any agent
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
This is a large read, not a quick check, and it says so before it starts.
|
|
59
|
+
Claude Code opens with the protocol and the agent, read-only: establishes
|
|
60
|
+
your North Star from the pricing page, the schema and the code before it
|
|
61
|
+
judges anything; takes every Step 0 finding to the code and rules on it,
|
|
62
|
+
CONFIRMED with evidence, REFUTED with the sentence that names the shape
|
|
63
|
+
that lied, ON_RECORD with the deciding line, or UNKNOWN with what would
|
|
64
|
+
settle it; walks the whole tree for the inventories the scan cannot finish
|
|
65
|
+
on its own - every vendor with the job it does and whose money it touches,
|
|
66
|
+
every subsystem built by hand and what keeping it buys, every dependency's
|
|
67
|
+
job, every unreached file's real status, every multiplying call with a
|
|
68
|
+
price where one is public, the database rulings against the grants, and
|
|
69
|
+
the shape of the bill; researches your market in five lanes with sources
|
|
70
|
+
and dates; ranks everything against the North Star; and writes
|
|
71
|
+
**The Prospect - <App Name>.md** at the repository root with the next ten
|
|
72
|
+
actions, the inventories in full, the evidence register, and the math.
|
|
73
|
+
|
|
74
|
+
Every suggestion keeps the fixed shape - *Since you* (a fact from your
|
|
75
|
+
code, file cited), *Have you considered* (always two options, or one vendor
|
|
76
|
+
against building it yourself), *Because* (the industry fact, with a source
|
|
77
|
+
URL and the year it was checked), *Your customer gets* (the benefit in the
|
|
78
|
+
customer's terms), *First test* (a one-week test needing nobody's
|
|
79
|
+
permission, and the observation that would kill the idea). A suggestion
|
|
80
|
+
missing any leg is cut, not softened. And:
|
|
69
81
|
|
|
70
82
|
```
|
|
71
83
|
npx @bigsteele/the-prospect --check
|
|
72
84
|
```
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
is the second opinion. It reads the deliverable and
|
|
87
|
+
`.planning/prospect/VERDICTS.json`, fails any finding without a ruling,
|
|
88
|
+
any ruling without evidence, any evidence the report does not show, any
|
|
89
|
+
unsourced or undated claim, any single-vendor pitch, any advice verb, and
|
|
90
|
+
recomputes the score from the verdicts with the math printed: a refuted
|
|
91
|
+
finding costs nothing, a recorded one costs nothing, an unknown one still
|
|
92
|
+
does. Exit 0 when it holds, 2 when it does not.
|
|
93
|
+
|
|
94
|
+
The scan's Lane 1 is calibrated on real repositories and the calibration
|
|
95
|
+
notes say what it got wrong and when it was fixed: a category is not a job
|
|
96
|
+
(Stripe running Connect beside Square billing the platform is two jobs),
|
|
97
|
+
one `.ilike()` is not a search engine, a socket is not a paid call, a
|
|
98
|
+
`revoke ... from anon, authenticated` in a migration is a caller check, and
|
|
99
|
+
a shadcn component nobody imports is scaffold, weighed a quarter.
|
|
77
100
|
|
|
78
101
|
## Standalone
|
|
79
102
|
|
package/dist/check.js
CHANGED
|
@@ -37,8 +37,9 @@ export function checkReport(md) {
|
|
|
37
37
|
findings.push({ where: "whole report", problem: "no [READ] badge - the repository half is missing or unmarked" });
|
|
38
38
|
}
|
|
39
39
|
// Lane 2: each suggestion block (### heading under the territory section).
|
|
40
|
-
|
|
41
|
-
const
|
|
40
|
+
// Step 0 titles the lane "what the territory holds"; the protocol's deliverable titles it "Lane 2, researched".
|
|
41
|
+
const lane2Heading = /^## .*(territory|lane 2).*$/im.exec(md)?.[0] ?? "";
|
|
42
|
+
const lane2 = md.split(/^## .*(?:territory|lane 2).*$/im)[1]?.split(/^## /m)[0] ?? "";
|
|
42
43
|
const ranProtocol = !/not yet run/i.test(lane2Heading + lane2);
|
|
43
44
|
if (ranProtocol && lane2.trim()) {
|
|
44
45
|
if (!/\[RESEARCHED\]/.test(lane2Heading + lane2)) {
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
|
|
|
17
17
|
import { dirname, join, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { runProspect, toMarkdown, secretShaped, checkReport, VERSION } from "./index.js";
|
|
20
|
+
import { runProspect, toMarkdown, secretShaped, checkReport, checkVerdicts, rescore, showMath, VERSION } from "./index.js";
|
|
21
21
|
const log = (s = "") => process.stdout.write(s + "\n");
|
|
22
22
|
const args = process.argv.slice(2);
|
|
23
23
|
const has = (f) => args.includes(f);
|
|
@@ -27,15 +27,17 @@ const valueOf = (f) => {
|
|
|
27
27
|
};
|
|
28
28
|
const HELP = `The Prospect ${VERSION} - a prospector's read of your codebase and your market.
|
|
29
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
|
|
32
|
-
npx @bigsteele/the-prospect --
|
|
30
|
+
npx @bigsteele/the-prospect [dir] Step 0: scan and report (offline, read-only, seconds)
|
|
31
|
+
npx @bigsteele/the-prospect --run open Claude Code with the protocol (the deep read: long)
|
|
32
|
+
npx @bigsteele/the-prospect --protocol drop THE-PROSPECT.md into the repo to paste into any agent
|
|
33
|
+
npx @bigsteele/the-prospect --check rule on a finished report: verdicts, evidence, legs, the math
|
|
33
34
|
|
|
34
|
-
--repo <dir>
|
|
35
|
-
--out <dir>
|
|
36
|
-
--report <file>
|
|
37
|
-
--
|
|
38
|
-
--
|
|
35
|
+
--repo <dir> the repository to read (default: here)
|
|
36
|
+
--out <dir> where the report goes (default: the repository root)
|
|
37
|
+
--report <file> the report --check should measure (default: the deliverable, else the newest scan)
|
|
38
|
+
--verdicts <file> the verdicts --check should re-score from (default: .planning/prospect/VERDICTS.json)
|
|
39
|
+
--stdout print the report (or, with --protocol, the protocol) instead of writing it
|
|
40
|
+
--yes skip the confirmation on --run
|
|
39
41
|
|
|
40
42
|
Exit 0 when it passes, 2 when it does not, 1 when it refuses.`;
|
|
41
43
|
const slug = (s) => s.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "app";
|
|
@@ -47,42 +49,101 @@ async function main() {
|
|
|
47
49
|
const flagsWithValue = ["--repo", "--out", "--report"];
|
|
48
50
|
const positional = args.find((a) => !a.startsWith("-") && !flagsWithValue.includes(args[args.indexOf(a) - 1] ?? ""));
|
|
49
51
|
const repoDir = resolve(valueOf("--repo") ?? positional ?? process.cwd());
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
|
|
54
|
+
if (has("--protocol")) {
|
|
55
|
+
// The protocol is a file, and the file is the product: drop it where any
|
|
56
|
+
// agent can read it, the way The Big Sean does.
|
|
57
|
+
const text = await readFile(protocol, "utf8");
|
|
58
|
+
if (has("--stdout")) {
|
|
59
|
+
log(text);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
await writeFile(join(repoDir, "THE-PROSPECT.md"), text, "utf8");
|
|
63
|
+
log(`Wrote THE-PROSPECT.md. Open Claude Code in this folder and paste the whole file in, or run --run.`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
50
66
|
if (has("--check")) {
|
|
51
67
|
const file = valueOf("--report");
|
|
52
68
|
let path = file ? resolve(file) : "";
|
|
53
69
|
if (!path) {
|
|
54
|
-
// The newest
|
|
70
|
+
// The deliverable if the protocol wrote one, else the newest Step 0 report.
|
|
55
71
|
const { readdir } = await import("node:fs/promises");
|
|
56
|
-
const
|
|
57
|
-
|
|
72
|
+
const all = await readdir(repoDir);
|
|
73
|
+
const deliverable = all.filter((n) => /^The Prospect - .*\.md$/.test(n)).sort();
|
|
74
|
+
const step0 = all.filter((n) => /^the-prospect-.*\.md$/.test(n)).sort();
|
|
75
|
+
const pick = deliverable[deliverable.length - 1] ?? step0[step0.length - 1];
|
|
76
|
+
if (!pick) {
|
|
58
77
|
log("No report to check. Run the scan first, then the protocol, then this.");
|
|
59
78
|
return 2;
|
|
60
79
|
}
|
|
61
|
-
path = join(repoDir,
|
|
80
|
+
path = join(repoDir, pick);
|
|
62
81
|
}
|
|
63
82
|
const md = await readFile(path, "utf8");
|
|
64
83
|
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
84
|
for (const f of findings)
|
|
70
85
|
log(`FAIL ${f.where}: ${f.problem}${f.line ? ` | ${f.line}` : ""}`);
|
|
86
|
+
// THE VERDICTS, WHEN THE PROTOCOL LEFT THEM (0.3). Every Step 0 finding
|
|
87
|
+
// ruled on, every ruling with evidence, and the score recomputed from the
|
|
88
|
+
// rulings with the math printed. Missing verdicts fail the check the moment
|
|
89
|
+
// a verdicts file exists at all: half a ruling is no ruling.
|
|
90
|
+
const verdictsPath = resolve(valueOf("--verdicts") ?? join(repoDir, ".planning", "prospect", "VERDICTS.json"));
|
|
91
|
+
let verdictProblems = 0;
|
|
92
|
+
let scanPath = "";
|
|
93
|
+
try {
|
|
94
|
+
const v = JSON.parse(await readFile(verdictsPath, "utf8"));
|
|
95
|
+
scanPath = v.scan ? resolve(repoDir, v.scan) : "";
|
|
96
|
+
if (!scanPath) {
|
|
97
|
+
const { readdir } = await import("node:fs/promises");
|
|
98
|
+
const names = (await readdir(repoDir)).filter((n) => /^the-prospect-.*\.json$/.test(n)).sort();
|
|
99
|
+
scanPath = names.length ? join(repoDir, names[names.length - 1]) : "";
|
|
100
|
+
}
|
|
101
|
+
if (!scanPath) {
|
|
102
|
+
log(`FAIL verdicts: no scan JSON to rule on (the-prospect-<app>.json)`);
|
|
103
|
+
verdictProblems++;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const scan = JSON.parse(await readFile(scanPath, "utf8"));
|
|
107
|
+
const c = checkVerdicts(scan, v, md);
|
|
108
|
+
for (const p of c.problems)
|
|
109
|
+
log(`FAIL verdict: ${p}`);
|
|
110
|
+
verdictProblems = c.problems.length;
|
|
111
|
+
const r = rescore(scan, v);
|
|
112
|
+
log("");
|
|
113
|
+
log(showMath(scan, v, r, c));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e.code !== "ENOENT") {
|
|
118
|
+
log(`FAIL verdicts: ${verdictsPath} could not be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
119
|
+
verdictProblems++;
|
|
120
|
+
}
|
|
121
|
+
else if (/^The Prospect - /.test(path.split("/").pop() ?? "")) {
|
|
122
|
+
log(`FAIL verdicts: the protocol's deliverable exists but ${verdictsPath} does not; every finding needs a ruling`);
|
|
123
|
+
verdictProblems++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (pass && verdictProblems === 0) {
|
|
127
|
+
log("");
|
|
128
|
+
log(`${path} holds the law: every finding ruled on with evidence, every suggestion on three legs, every claim sourced.`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
71
131
|
log("");
|
|
72
|
-
log(`${findings.length} finding(s). A suggestion missing a leg is cut, not softened.`);
|
|
132
|
+
log(`${findings.length + verdictProblems} finding(s). A suggestion missing a leg is cut, not softened; a finding without a ruling is unchecked.`);
|
|
73
133
|
return 2;
|
|
74
134
|
}
|
|
75
135
|
if (has("--run")) {
|
|
76
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
77
|
-
const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
|
|
78
136
|
if (!has("--yes")) {
|
|
79
137
|
log("");
|
|
80
|
-
log("
|
|
81
|
-
log("It
|
|
82
|
-
log("
|
|
138
|
+
log("BEFORE YOU RUN THIS, KNOW WHAT IT IS. This is a large scan, not a quick check.");
|
|
139
|
+
log("It opens Claude Code with the protocol: the agent reads everything in this repository it can");
|
|
140
|
+
log("reach, takes every Step 0 finding to the code and rules on it with evidence, inventories every");
|
|
141
|
+
log("vendor, subsystem and dependency, researches your market with sources and dates, and writes a");
|
|
142
|
+
log("long report. Expect a long session and a lot of tokens. It changes nothing: no edits, no commits,");
|
|
143
|
+
log("no deploys, no messages, no spending. Run the scan first so the facts exist. Ctrl-C to stop.");
|
|
83
144
|
log("");
|
|
84
145
|
}
|
|
85
|
-
const child = spawn("claude", [`Follow the protocol in ${protocol}. The repository is ${repoDir}.`], {
|
|
146
|
+
const child = spawn("claude", [`Follow the protocol in ${protocol}, every step, to the end. The repository is ${repoDir}.`], {
|
|
86
147
|
stdio: "inherit",
|
|
87
148
|
shell: false,
|
|
88
149
|
});
|
|
@@ -141,11 +202,16 @@ async function main() {
|
|
|
141
202
|
log(`${p.score.total}/100 (${p.score.grade}) - Level ${p.score.level.n}: ${p.score.level.name}.`);
|
|
142
203
|
if (noRef)
|
|
143
204
|
log(`${noRef} of ${p.totals.runtime_deps} runtime dependencies show no reference anywhere.`);
|
|
144
|
-
|
|
145
|
-
|
|
205
|
+
const paidTwice = p.overlaps.filter((o) => !o.distinct).length;
|
|
206
|
+
const twoJobs = p.overlaps.length - paidTwice;
|
|
207
|
+
if (paidTwice)
|
|
208
|
+
log(`${paidTwice} categor${paidTwice > 1 ? "ies" : "y"} of work paid for twice.`);
|
|
209
|
+
if (twoJobs)
|
|
210
|
+
log(`${twoJobs} vendor pair${twoJobs > 1 ? "s" : ""} sharing a category but doing different jobs: listed, not charged.`);
|
|
146
211
|
if (p.handrolled.length)
|
|
147
212
|
log(`${p.handrolled.length} subsystem(s) built by hand where the market sells a rail.`);
|
|
148
|
-
log(`
|
|
213
|
+
log(`This was the scan: seconds, shapes, every finding with an id. The read is the next command.`);
|
|
214
|
+
log(`Next: npx @bigsteele/the-prospect --run (the deep read: every finding ruled on, every vendor and subsystem inventoried, the market researched)`);
|
|
149
215
|
return 0;
|
|
150
216
|
}
|
|
151
217
|
main().then((code) => process.exit(code), (err) => {
|
package/dist/detect/costs.js
CHANGED
|
@@ -1,15 +1,57 @@
|
|
|
1
1
|
import { scopeFor } from "../walk.js";
|
|
2
|
-
|
|
3
|
-
|
|
2
|
+
/**
|
|
3
|
+
* A PAID call, which is the whole point (0.2.1). `\.send\(` on its own matched
|
|
4
|
+
* `ws.send(...)` to a local Chrome DevTools socket in four build scripts, and
|
|
5
|
+
* the report billed them as four paid loops. A call is paid when it names a
|
|
6
|
+
* paid host, a paid SDK method, or a sender object that is plainly a mail or
|
|
7
|
+
* SMS client. A socket is not a vendor.
|
|
8
|
+
*/
|
|
9
|
+
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|predictions)\.create\(|generateContent|\b(sendEmail|sendMail|sendSms)\(|\b(resend|sendgrid|sgMail|postmark|mailgun|transporter|mailer|twilio|ses|sns)\.\w*[sS]end\w*\(|\.emails\.send\(/;
|
|
10
|
+
const LOOP_HEAD = /\bfor(\s+await)?\s*\(|\bwhile\s*\(|\.(map|forEach|flatMap)\(\s*(async\b|\()/;
|
|
4
11
|
const HANDLER = /(^|\/)(functions|api|routes?|handlers?)\//i;
|
|
5
12
|
const SCHEDULE = /\bsetInterval\s*\(|\bcron\b|schedule/i;
|
|
6
13
|
function hostOrCall(line) {
|
|
7
14
|
const host = /https?:\/\/([a-z0-9.-]+)/i.exec(line)?.[1];
|
|
8
15
|
if (host)
|
|
9
16
|
return host;
|
|
10
|
-
const call = /\.((?:messages|completions|chat|embeddings|images)\.create|generateContent|send
|
|
17
|
+
const call = /\.((?:messages|completions|chat|embeddings|images|predictions)\.create|generateContent|emails\.send|\w*[sS]end\w*)\(/.exec(line)?.[1];
|
|
11
18
|
return call ?? "external call";
|
|
12
19
|
}
|
|
20
|
+
const count = (s, re) => (s.match(re) ?? []).length;
|
|
21
|
+
/**
|
|
22
|
+
* INSIDE the loop, not below it (0.2.1). "A loop head within the twelve lines
|
|
23
|
+
* above" read `slots.map((s) => s.role).join(', ')` - a prompt being built - as
|
|
24
|
+
* the loop around the Gemini call three lines later, and reported the call as
|
|
25
|
+
* per-row twice on the first repository it met. A loop encloses the call only
|
|
26
|
+
* if the brackets it opened are still open where the call is made.
|
|
27
|
+
*/
|
|
28
|
+
function insideLoop(lines, at) {
|
|
29
|
+
for (let j = at - 1; j >= Math.max(0, at - 80); j--) {
|
|
30
|
+
if (!LOOP_HEAD.test(lines[j]))
|
|
31
|
+
continue;
|
|
32
|
+
// Walk forward from the loop head until the brackets it opened close. If
|
|
33
|
+
// they have not closed by the call, the call is inside. Stopping at the
|
|
34
|
+
// close means the call's OWN `fetch(` on the line above its URL is never
|
|
35
|
+
// mistaken for the loop still being open.
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let opened = false;
|
|
38
|
+
let closed = false;
|
|
39
|
+
for (let k = j; k < at && !closed; k++) {
|
|
40
|
+
const opens = count(lines[k], /[{(]/g);
|
|
41
|
+
depth += opens - count(lines[k], /[})]/g);
|
|
42
|
+
// The head line opening and closing on the same line (`.map((x) => x.id).join(', ')`)
|
|
43
|
+
// is a loop that already closed; without this, the walk ran on past it and
|
|
44
|
+
// tripped over a prose parenthesis inside a prompt string forty lines later.
|
|
45
|
+
if ((k === j && opens > 0) || depth > 0)
|
|
46
|
+
opened = true;
|
|
47
|
+
if (opened && depth <= 0)
|
|
48
|
+
closed = true;
|
|
49
|
+
}
|
|
50
|
+
if (opened && !closed)
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
13
55
|
export async function detectCosts(repo) {
|
|
14
56
|
const out = [];
|
|
15
57
|
for (const f of scopeFor(repo.files, "implementation")) {
|
|
@@ -23,13 +65,13 @@ export async function detectCosts(repo) {
|
|
|
23
65
|
continue;
|
|
24
66
|
const target = hostOrCall(line);
|
|
25
67
|
const evidence = `${i + 1}: ${line.trim().slice(0, 110)}`;
|
|
26
|
-
// per-row:
|
|
27
|
-
|
|
28
|
-
if (LOOP_HEAD.test(above)) {
|
|
68
|
+
// per-row: the call sits inside a loop that has not closed.
|
|
69
|
+
if (insideLoop(lines, i)) {
|
|
29
70
|
out.push({ file: f, shape: "per-row", target, line: evidence });
|
|
30
71
|
continue;
|
|
31
72
|
}
|
|
32
73
|
// per-schedule: the call sits under an interval or a cron marker.
|
|
74
|
+
const above = lines.slice(Math.max(0, i - 12), i).join("\n");
|
|
33
75
|
if (SCHEDULE.test(above) || (SCHEDULE.test(text.slice(0, 800)) && SCHEDULE.test(f))) {
|
|
34
76
|
out.push({ file: f, shape: "per-schedule", target, line: evidence });
|
|
35
77
|
continue;
|
package/dist/detect/database.js
CHANGED
|
@@ -75,9 +75,21 @@ export async function detectDatabase(repo) {
|
|
|
75
75
|
const findings = [];
|
|
76
76
|
let policies = 0;
|
|
77
77
|
let definerFunctions = 0;
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
/**
|
|
79
|
+
* THE GRANTS ARE IN THE MIGRATIONS (0.2.1). The caller check was looked for
|
|
80
|
+
* inside the function body only, and 60 definer functions on the first real
|
|
81
|
+
* repository were reported as stating no check when each one was followed by
|
|
82
|
+
* `revoke all on function ... from public, anon, authenticated` - a caller
|
|
83
|
+
* check by another statement. Postgres grants EXECUTE to public by default,
|
|
84
|
+
* so a revoke from public/anon/authenticated is the statement that narrows
|
|
85
|
+
* who may call; a bare `grant execute ... to service_role` narrows nothing.
|
|
86
|
+
*/
|
|
87
|
+
const executeRevoked = new Set();
|
|
88
|
+
let guard;
|
|
89
|
+
// Two passes: every `alter table` and every `revoke` in the repository is
|
|
90
|
+
// collected first, because a table is very often created in one migration
|
|
91
|
+
// and secured in the next, and reading them in file order would flag every
|
|
92
|
+
// such table.
|
|
81
93
|
for (const f of files) {
|
|
82
94
|
const raw = await repo.read(f);
|
|
83
95
|
if (!raw)
|
|
@@ -90,6 +102,16 @@ export async function detectDatabase(repo) {
|
|
|
90
102
|
if (/enable/i.test(m[2]))
|
|
91
103
|
rlsEnabled.add(t);
|
|
92
104
|
}
|
|
105
|
+
for (const m of sql.matchAll(/revoke\s+(?:all|execute)(?:\s+privileges)?\s+on\s+function\s+([a-z0-9_."]+)\s*(?:\([^)]*\))?\s+from\s+([a-z_,\s"]+)/gi)) {
|
|
106
|
+
if (/\b(public|anon|authenticated)\b/i.test(m[2]))
|
|
107
|
+
executeRevoked.add(qualify(m[1]));
|
|
108
|
+
}
|
|
109
|
+
for (const g of sql.matchAll(/create\s+(?:or\s+replace\s+)?function\s+([a-z0-9_."]*(?:definer_guard|guard_scan|grant_scan)[a-z0-9_]*)\s*\(/gi)) {
|
|
110
|
+
const name = `${qualify(g[1])}()`;
|
|
111
|
+
// The one that names definers wins; otherwise the first guard seen.
|
|
112
|
+
if (!guard || (/definer/i.test(name) && !/definer/i.test(guard)))
|
|
113
|
+
guard = name;
|
|
114
|
+
}
|
|
93
115
|
}
|
|
94
116
|
for (const f of files) {
|
|
95
117
|
const raw = await repo.read(f);
|
|
@@ -134,12 +156,13 @@ export async function detectDatabase(repo) {
|
|
|
134
156
|
if (/create\s+(or\s+replace\s+)?function/i.test(stmt) && /security\s+definer/i.test(stmt)) {
|
|
135
157
|
definerFunctions++;
|
|
136
158
|
const name = /create\s+(?:or\s+replace\s+)?function\s+([a-z0-9_."]+)\s*\(/i.exec(stmt)?.[1];
|
|
137
|
-
if (name && !CALLER_CHECK.test(withComments)) {
|
|
159
|
+
if (name && !CALLER_CHECK.test(withComments) && !executeRevoked.has(qualify(name))) {
|
|
138
160
|
findings.push({
|
|
139
161
|
kind: "definer_without_check",
|
|
140
162
|
subject: qualify(name),
|
|
141
163
|
file: f,
|
|
142
|
-
note: "runs with the definer's rights
|
|
164
|
+
note: "runs with the definer's rights, states no caller check in its body, and no migration revokes its EXECUTE from public, anon or authenticated. Either it is meant to be callable by anyone, or a grant applied outside the migrations is the only thing standing between it and one." +
|
|
165
|
+
(guard ? ` The repository carries \`${guard}\`, which checks the live grants; this scan reads only what the migrations state.` : ""),
|
|
143
166
|
});
|
|
144
167
|
}
|
|
145
168
|
}
|
|
@@ -168,6 +191,8 @@ export async function detectDatabase(repo) {
|
|
|
168
191
|
tables: tablesSeen.size,
|
|
169
192
|
policies,
|
|
170
193
|
definer_functions: definerFunctions,
|
|
194
|
+
definer_execute_revoked: executeRevoked.size,
|
|
195
|
+
guard,
|
|
171
196
|
findings: findings.sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject)).slice(0, 60),
|
|
172
197
|
};
|
|
173
198
|
}
|
|
@@ -13,9 +13,12 @@ function entrypoints(repo, manifests) {
|
|
|
13
13
|
for (const f of repo.files) {
|
|
14
14
|
if (/(^|\/)src\/(main|index|app|App)\.(t|j)sx?$/.test(f) ||
|
|
15
15
|
/(^|\/)(functions|api)\/[^/]+\/index\.(t|j)s$/.test(f) ||
|
|
16
|
+
/^api\/.*\.(m|c)?(t|j)sx?$/.test(f) || // a root api/ tree is a serverless router (Vercel): every file is a function
|
|
17
|
+
/(^|\/)netlify\/functions\/[^/]+\.(m|c)?(t|j)s$/.test(f) ||
|
|
16
18
|
/(^|\/)(pages|app)\/.*\.(t|j)sx?$/.test(f) || // file-based routers import nothing by name
|
|
17
19
|
/(^|\/)scripts?\/[^/]+\.(m|c)?(t|j)s$/.test(f) ||
|
|
18
20
|
/(^|\/)(index|server|worker|cli)\.(m|c)?(t|j)s$/.test(f) ||
|
|
21
|
+
NOT_BEHAVIOUR.test(f) || // a config file imports things (a playwright fixture, a vite plugin); it seeds the walk and is never itself listed
|
|
19
22
|
TEST_FILE.test(f) // tests reach code; code only tests reach is a different finding
|
|
20
23
|
) {
|
|
21
24
|
out.add(f);
|
|
@@ -101,32 +104,74 @@ export async function detectDeadweight(repo) {
|
|
|
101
104
|
const roots = entrypoints(repo, manifests);
|
|
102
105
|
const reached = new Set(roots);
|
|
103
106
|
const queue = [...roots];
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
reached.
|
|
113
|
-
|
|
107
|
+
const drain = async () => {
|
|
108
|
+
while (queue.length) {
|
|
109
|
+
const f = queue.pop();
|
|
110
|
+
const text = await repo.read(f);
|
|
111
|
+
if (!text)
|
|
112
|
+
continue;
|
|
113
|
+
for (const spec of specifiers(text)) {
|
|
114
|
+
const to = resolve(f, spec);
|
|
115
|
+
if (to && !reached.has(to)) {
|
|
116
|
+
reached.add(to);
|
|
117
|
+
queue.push(to);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
await drain();
|
|
123
|
+
const candidates = repo.files.filter((f) => CODE.test(f) && !TEST_FILE.test(f) && !NOT_RUNTIME.test(f) && !NOT_BEHAVIOUR.test(f));
|
|
124
|
+
// NAMED BY PATH (0.2.1). A build script that does
|
|
125
|
+
// `readFileSync(join(ROOT, 'kit', 'modes.src.js'))` reaches that file as
|
|
126
|
+
// surely as an import does, and the first real repository had its whole
|
|
127
|
+
// colour pipeline listed as unreached on exactly that. A file whose name a
|
|
128
|
+
// reached file quotes is reached, and its own imports are walked in turn.
|
|
129
|
+
const GENERIC_BASENAME = /^(index|main|app|utils?|types?|config|client|server|helpers?|constants?)\./i;
|
|
130
|
+
const namedBy = new Map();
|
|
131
|
+
for (let grew = true; grew;) {
|
|
132
|
+
grew = false;
|
|
133
|
+
for (const f of candidates) {
|
|
134
|
+
if (reached.has(f))
|
|
135
|
+
continue;
|
|
136
|
+
const base = f.split("/").pop();
|
|
137
|
+
if (base.length < 8 || GENERIC_BASENAME.test(base))
|
|
138
|
+
continue;
|
|
139
|
+
const re = new RegExp(`['"\`][^'"\`\\n]*(?<![a-z0-9_-])${base.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}['"\`]`);
|
|
140
|
+
for (const r of reached) {
|
|
141
|
+
if (r === f)
|
|
142
|
+
continue;
|
|
143
|
+
const text = await repo.read(r);
|
|
144
|
+
if (text && re.test(text)) {
|
|
145
|
+
reached.add(f);
|
|
146
|
+
namedBy.set(f, r);
|
|
147
|
+
queue.push(f);
|
|
148
|
+
grew = true;
|
|
149
|
+
break;
|
|
150
|
+
}
|
|
114
151
|
}
|
|
115
152
|
}
|
|
153
|
+
await drain();
|
|
116
154
|
}
|
|
155
|
+
void namedBy;
|
|
156
|
+
// SERVED, NOT IMPORTED. Everything under public/ or static/ is handed to the
|
|
157
|
+
// browser as-is; nothing imports it because nothing has to.
|
|
158
|
+
const SERVED = /(^|\/)(public|static)\//i;
|
|
159
|
+
// A UI-kit scaffold beside a components.json: installed wholesale, imported
|
|
160
|
+
// one component at a time, never bundled when unimported.
|
|
161
|
+
const kit = repo.files.some((f) => /(^|\/)components\.json$/.test(f) && !/node_modules|fixtures?/.test(f));
|
|
117
162
|
const dead = [];
|
|
118
|
-
for (const f of
|
|
119
|
-
if (
|
|
120
|
-
continue;
|
|
121
|
-
if (reached.has(f))
|
|
163
|
+
for (const f of candidates) {
|
|
164
|
+
if (reached.has(f) || SERVED.test(f))
|
|
122
165
|
continue;
|
|
123
166
|
const text = await repo.read(f);
|
|
124
167
|
if (!text)
|
|
125
168
|
continue;
|
|
169
|
+
const scaffold = kit && /(^|\/)components\/ui\/[^/]+\.(t|j)sx?$/.test(f);
|
|
126
170
|
dead.push({
|
|
127
171
|
file: f,
|
|
128
172
|
loc: text.split("\n").length,
|
|
129
|
-
note: "no import path found from any entrypoint",
|
|
173
|
+
note: scaffold ? "a UI-kit scaffold component no file imports: never bundled, only read and searched" : "no import path found from any entrypoint",
|
|
174
|
+
...(scaffold ? { scaffold: true } : {}),
|
|
130
175
|
});
|
|
131
176
|
}
|
|
132
177
|
return {
|
|
@@ -20,17 +20,20 @@ function legs(r, file, text) {
|
|
|
20
20
|
*/
|
|
21
21
|
function regionLines(r, text) {
|
|
22
22
|
const lines = text.split("\n");
|
|
23
|
-
const
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
23
|
+
const starts = [];
|
|
24
|
+
const ends = [];
|
|
25
|
+
// CONTENT ONLY (0.2.1). `requires` testifies that the state the job needs
|
|
26
|
+
// exists somewhere in the file; it does not bound the subsystem. Spanning to
|
|
27
|
+
// it turned one `.ilike(` on line 266 and the word "query" near the bottom
|
|
28
|
+
// into "1,002 lines of hand-rolled search" in an invite handler.
|
|
29
|
+
const g = new RegExp(r.content.source, r.content.flags.includes("g") ? r.content.flags : r.content.flags + "g");
|
|
30
|
+
for (const m of text.matchAll(g)) {
|
|
31
|
+
starts.push(text.slice(0, m.index).split("\n").length);
|
|
32
|
+
ends.push(text.slice(0, m.index + m[0].length).split("\n").length);
|
|
28
33
|
}
|
|
29
|
-
if (
|
|
34
|
+
if (starts.length === 0)
|
|
30
35
|
return 0;
|
|
31
|
-
|
|
32
|
-
const last = Math.max(...hits);
|
|
33
|
-
return Math.min(lines.length, last - first + 1);
|
|
36
|
+
return Math.min(lines.length, Math.max(...ends) - Math.min(...starts) + 1);
|
|
34
37
|
}
|
|
35
38
|
const RULES = [
|
|
36
39
|
{
|
|
@@ -82,12 +85,18 @@ const RULES = [
|
|
|
82
85
|
},
|
|
83
86
|
{
|
|
84
87
|
rail: "search",
|
|
85
|
-
// NAME REQUIRED
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
+
// NAME REQUIRED, and `index` and `query` are not names (0.2.1): with them
|
|
89
|
+
// every `index.ts` announced itself as search, and one PostgREST
|
|
90
|
+
// `.ilike('email', email)` looking up an invitee was reported as 1,002 lines
|
|
91
|
+
// of a search engine. `tokenize(query)` inside a help-centre page is a
|
|
92
|
+
// filter box, not a search engine, and was reported as 260 lines of one.
|
|
93
|
+
name: /search|lookup|finder|indexer|full.?text/i,
|
|
88
94
|
name_required: true,
|
|
89
|
-
|
|
90
|
-
|
|
95
|
+
// AN ENGINE, NOT A FILTER. Something is tokenised and scored, an index is
|
|
96
|
+
// built and walked, or similarity is computed by hand. One LIKE is a WHERE
|
|
97
|
+
// clause; a database doing it is the rail, not the hand.
|
|
98
|
+
content: /\btokeni[sz]e\w*\b[\s\S]{0,600}\b(score|rank)\w*\b|\b(levenshtein|trigram|jaro|bm25|tf.?idf|inverted.?index|n.?grams?)\b|\b(build|make|create)(Search)?Index\s*\(|\bsearchIndex\b/i,
|
|
99
|
+
requires: /\b(query|term|needle|q)\b[\s\S]{0,600}\b(results?|hits|matches|ranked)\b/i,
|
|
91
100
|
rail_sdk: /["'](algoliasearch|meilisearch|typesense|@elastic|flexsearch|minisearch|fuse\.js)["']/,
|
|
92
101
|
},
|
|
93
102
|
{
|
package/dist/detect/stack.d.ts
CHANGED
|
@@ -45,6 +45,8 @@ export interface WorkflowFact {
|
|
|
45
45
|
script_only: boolean;
|
|
46
46
|
}
|
|
47
47
|
export interface ConsolidationFact {
|
|
48
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
49
|
+
id?: string;
|
|
48
50
|
/** A recorded decision that explains keeping both. On record is not a deduction. */
|
|
49
51
|
on_record?: {
|
|
50
52
|
file: string;
|