@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 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
- Then:
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
- 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:
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
- 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.
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
- const lane2Heading = /^## .*territory.*$/im.exec(md)?.[0] ?? "";
41
- const lane2 = md.split(/^## .*territory.*$/im)[1]?.split(/^## /m)[0] ?? "";
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 research protocol
32
- npx @bigsteele/the-prospect --check fail a finished report that breaks the law
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> 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
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 prospect report beside us.
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 names = (await readdir(repoDir)).filter((n) => /^the-prospect-.*\.md$/.test(n)).sort();
57
- if (names.length === 0) {
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, names[names.length - 1]);
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("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.");
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
- if (p.overlaps.length)
145
- log(`${p.overlaps.length} categor${p.overlaps.length > 1 ? "ies" : "y"} of work paid for twice.`);
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(`Next: npx @bigsteele/the-prospect --run (the industry half, researched)`);
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) => {
@@ -1,15 +1,57 @@
1
1
  import { scopeFor } 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|\()/;
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(?:Email|Mail|Sms)?)\(/.exec(line)?.[1];
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: 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)) {
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;
@@ -75,9 +75,21 @@ export async function detectDatabase(repo) {
75
75
  const findings = [];
76
76
  let policies = 0;
77
77
  let definerFunctions = 0;
78
- // Two passes: every `alter table` in the repository is collected first,
79
- // because a table is very often created in one migration and secured in the
80
- // next, and reading them in file order would flag every such table.
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 and states no caller check. Either it is meant to be callable by anyone, or a grant is the only thing standing between it and one.",
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
- 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);
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 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))
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 hits = [];
24
- for (const re of [r.content, r.requires].filter(Boolean)) {
25
- const g = new RegExp(re.source, re.flags.includes("g") ? re.flags : re.flags + "g");
26
- for (const m of text.matchAll(g))
27
- hits.push(text.slice(0, m.index).split("\n").length);
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 (hits.length === 0)
34
+ if (starts.length === 0)
30
35
  return 0;
31
- const first = Math.min(...hits);
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. `tokenize(query)` inside a help-centre page is a filter box,
86
- // not a search engine, and the old rule reported it as 260 lines of one.
87
- name: /search|query|index|lookup|find/i,
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
- content: /(ilike|ILIKE|LIKE)\s*\(?\s*["'`]%|\.ilike\(|to_tsvector|\btokeni[sz]e\b[\s\S]{0,200}\bscore\b/i,
90
- requires: /(search|query|match|find)/i,
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
  {
@@ -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;