@bigsteele/the-prospect 0.1.1 → 0.2.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/dist/cli.js CHANGED
@@ -86,7 +86,29 @@ async function main() {
86
86
  stdio: "inherit",
87
87
  shell: false,
88
88
  });
89
- return new Promise((res) => child.on("exit", (c) => res(c ?? 0)));
89
+ // THE SUBPROCESS IS A CONVENIENCE, NOT THE PROTOCOL (0.2). Spawning `claude`
90
+ // couples this package to whatever version is installed, and on 2026-09-13
91
+ // that failed outright: "Claude Code 2.1.22 does not support this model;
92
+ // version 2.1.251 or newer is required". A blank error and no research.
93
+ //
94
+ // The protocol is a file. Any agent that can read it and search the web can
95
+ // run it, so a failure here names the file rather than ending the road.
96
+ child.on("error", () => {
97
+ log("");
98
+ log("Could not start Claude Code. It may not be installed, or may be too old.");
99
+ log(`The protocol is a file, and nothing here depends on that binary:`);
100
+ log(` ${protocol}`);
101
+ log("Open it in any agent that can read your repository and search the web.");
102
+ });
103
+ return new Promise((res) => child.on("exit", (c) => {
104
+ if (c !== 0) {
105
+ log("");
106
+ log(`Research did not complete. The protocol is at ${protocol} and can be run by hand:`);
107
+ log(` read the-prospect-<app>.json, then follow its six steps.`);
108
+ log(`If Claude Code reported a version error, \`claude update\` and try again.`);
109
+ }
110
+ res(c ?? 0);
111
+ }));
90
112
  }
91
113
  const p = await runProspect(repoDir);
92
114
  const leak = secretShaped(p);
@@ -0,0 +1,93 @@
1
+ /**
2
+ * The decision record: what the repository says it decided, read before the
3
+ * scan reports what it built.
4
+ *
5
+ * WHY THIS EXISTS. The Big Sean reads the decision record and reports the
6
+ * difference between what a repository says and what it does. The Prospect
7
+ * read no `.md` at all - the ledger listed 415 of them as unclaimed on the first
8
+ * real repository, and the reasoning that explains the code sat in every one
9
+ * of them. So a copy inlined on purpose (decision 8: generated code lives in
10
+ * the customer's repo and cannot import from ours), a second hosting vendor
11
+ * kept on purpose (decision 19), and a dependency loaded by name all reported
12
+ * as drift, and all deducted.
13
+ *
14
+ * A subtract finding is a finding only when NO recorded decision explains it.
15
+ * What the record explains is listed as on record - the reader sees the tool
16
+ * looked, and the score does not charge for a choice somebody made and wrote
17
+ * down. That is the same rule The Big Sean applies to its own checks: "record
18
+ * that in DECISIONS.md and leave the check FAIL honestly".
19
+ *
20
+ * WHAT COUNTS AS THE RECORD. Files a person wrote to explain choices:
21
+ * DECISIONS.md and its variants, CLAUDE.md and AGENTS.md (instructions to an
22
+ * agent are decisions by another name), ADR trees, a `.planning/` directory,
23
+ * PITFALLS.md, and a prior Big Sean run under `.planning/launch-audit/`. Not
24
+ * README.md - that is what the product says to buyers, and the North Star reads
25
+ * it separately. Not `docs/` wholesale - most of it is reference, and a match in
26
+ * a runbook proves a term was mentioned, not that a choice was made.
27
+ */
28
+ import type { Repo } from "./walk.js";
29
+ export interface Citation {
30
+ file: string;
31
+ line: number;
32
+ /** The matching line, trimmed to fit a table cell. */
33
+ excerpt: string;
34
+ }
35
+ export interface DecisionRecord {
36
+ /** Files read as the record, repository-relative. */
37
+ files: string[];
38
+ /** Entries indexed: one per non-empty line. */
39
+ entries: number;
40
+ /**
41
+ * From a prior Big Sean run, when one exists: the workflows the business dies
42
+ * without, in its order. Used to say which one a finding touches.
43
+ */
44
+ critical_few: string[];
45
+ }
46
+ export declare const DECISION_FILES: RegExp;
47
+ export declare class Decisions {
48
+ private entries;
49
+ readonly files: string[];
50
+ readonly critical_few: string[];
51
+ static read(repo: Repo): Promise<Decisions>;
52
+ /**
53
+ * The Big Sean's "critical few" list, when it left one. It is a numbered list
54
+ * under that heading; the first clause of each item is the workflow's name.
55
+ */
56
+ private readCriticalFew;
57
+ /**
58
+ * The entry that DECIDES about a subject, or null. A mention is not a decision.
59
+ *
60
+ * The first version returned the first line anywhere in the record containing
61
+ * the word, and on a repository with 8,000 lines of planning notes that cited
62
+ * "hand-rolled search" to a line about invoice search, `session.ts` to "the
63
+ * Session pooler URL" and `shell.tsx` to "shell export". The family's oldest
64
+ * lesson - a name appearing is not the thing being used - arriving inside the
65
+ * reader written to apply it.
66
+ *
67
+ * Three rules, and an entry needs to clear them, not merely match:
68
+ *
69
+ * - WHERE it is said. A row of DECISIONS.md, an ADR, or a rule in CLAUDE.md
70
+ * is a decision by construction. A line in a checkpoint queue or a status
71
+ * file is context, and counts only when it also SAYS it is deciding.
72
+ * - HOW it is said. Outside a decision file, the entry must carry a decision
73
+ * word: decided, keep, on purpose, by design, deliberately, chosen, we use.
74
+ * - WHAT it names. A pair (two vendors, a kept-and-questioned platform) is
75
+ * decided by an entry naming BOTH. A generic file stem - session, shell,
76
+ * route, consent, index - is never matched as a bare word; the caller
77
+ * passes a path fragment instead.
78
+ */
79
+ explains(subjects: string[], opts?: {
80
+ all?: boolean;
81
+ }): Citation | null;
82
+ toJSON(): DecisionRecord;
83
+ }
84
+ /**
85
+ * Which workflow a path touches - a HEURISTIC, and named as one in the report.
86
+ *
87
+ * The Big Sean derives the critical few from the code and the live system, and
88
+ * every finding it prints says which one it protects. A static scan cannot
89
+ * derive them, but it can read the path: a file under `billing/` is on the
90
+ * money path whatever else is true of it. When a prior Big Sean run left its
91
+ * list, the names come from there; otherwise these are the generic five.
92
+ */
93
+ export declare function touches(path: string, criticalFew: string[]): string;
@@ -0,0 +1,143 @@
1
+ export const DECISION_FILES = /(^|\/)(DECISIONS?|DECISION-LOG|DECISION-REQUESTS?|CLAUDE|AGENTS|PITFALLS|ARCHITECTURE|CONVENTIONS)\.md$|(^|\/)(adr|adrs|decisions)\/[^/]+\.md$|^\.planning\/[^/]+\.md$|^\.planning\/launch-audit\/[^/]+\.md$|^docs\/adr\/[^/]+\.md$/i;
2
+ /** Files that ARE the decision record, as opposed to planning notes that may hold one. */
3
+ const DECISION_PROPER = /(^|\/)(DECISIONS?|DECISION-LOG|CLAUDE|AGENTS|ARCHITECTURE|CONVENTIONS)\.md$|(^|\/)(adr|adrs|decisions)\/[^/]+\.md$|^docs\/adr\/[^/]+\.md$/i;
4
+ /** The words a line uses when it is deciding rather than describing. */
5
+ const DECISION_WORDS = /\b(decided|decision|we (keep|use|chose|run|host)|keep(s|ing)? (it|this|both)|kept|on purpose|deliberate(ly)?|by design|chosen|default(s)? to|stays?\b|do not remove|never imported|copied from)\b/i;
6
+ /** File stems too common to match as a bare word. The caller passes a path fragment. */
7
+ const GENERIC_STEM = /^(session|shell|route|routes|consent|bridge|index|main|app|utils?|config|types?|client|server|handler|grant|mount|events?|api|lib|core|common|base|helpers?|store|state|auth|user|users|data|model|models|service|services|page|pages|layout|search|report|egress)$/i;
8
+ export class Decisions {
9
+ entries = [];
10
+ files = [];
11
+ critical_few = [];
12
+ static async read(repo) {
13
+ const d = new Decisions();
14
+ for (const f of repo.files.filter((x) => DECISION_FILES.test(x))) {
15
+ const text = await repo.read(f);
16
+ if (!text)
17
+ continue;
18
+ d.files.push(f);
19
+ text.split("\n").forEach((raw, i) => {
20
+ const t = raw.trim();
21
+ if (t.length < 12)
22
+ return;
23
+ d.entries.push({ file: f, line: i + 1, text: t, lower: t.toLowerCase() });
24
+ });
25
+ }
26
+ d.readCriticalFew();
27
+ return d;
28
+ }
29
+ /**
30
+ * The Big Sean's "critical few" list, when it left one. It is a numbered list
31
+ * under that heading; the first clause of each item is the workflow's name.
32
+ */
33
+ readCriticalFew() {
34
+ const inList = { on: false, file: "" };
35
+ for (const e of this.entries) {
36
+ if (/^#{1,4}\s+the critical few/i.test(e.text)) {
37
+ inList.on = true;
38
+ inList.file = e.file;
39
+ continue;
40
+ }
41
+ if (!inList.on || e.file !== inList.file)
42
+ continue;
43
+ if (/^#{1,4}\s/.test(e.text))
44
+ break;
45
+ const m = /^\d+\.\s+\*\*([^*]+)\*\*/.exec(e.text);
46
+ if (m)
47
+ this.critical_few.push(m[1].trim());
48
+ }
49
+ }
50
+ /**
51
+ * The entry that DECIDES about a subject, or null. A mention is not a decision.
52
+ *
53
+ * The first version returned the first line anywhere in the record containing
54
+ * the word, and on a repository with 8,000 lines of planning notes that cited
55
+ * "hand-rolled search" to a line about invoice search, `session.ts` to "the
56
+ * Session pooler URL" and `shell.tsx` to "shell export". The family's oldest
57
+ * lesson - a name appearing is not the thing being used - arriving inside the
58
+ * reader written to apply it.
59
+ *
60
+ * Three rules, and an entry needs to clear them, not merely match:
61
+ *
62
+ * - WHERE it is said. A row of DECISIONS.md, an ADR, or a rule in CLAUDE.md
63
+ * is a decision by construction. A line in a checkpoint queue or a status
64
+ * file is context, and counts only when it also SAYS it is deciding.
65
+ * - HOW it is said. Outside a decision file, the entry must carry a decision
66
+ * word: decided, keep, on purpose, by design, deliberately, chosen, we use.
67
+ * - WHAT it names. A pair (two vendors, a kept-and-questioned platform) is
68
+ * decided by an entry naming BOTH. A generic file stem - session, shell,
69
+ * route, consent, index - is never matched as a bare word; the caller
70
+ * passes a path fragment instead.
71
+ */
72
+ explains(subjects, opts = {}) {
73
+ const terms = subjects
74
+ .map((s) => s.toLowerCase().trim())
75
+ .filter((s) => s.length >= 4 && !GENERIC_STEM.test(s))
76
+ .map((s) => new RegExp(`(^|[^a-z0-9_/-])${s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}([^a-z0-9_-]|$)`));
77
+ if (terms.length === 0)
78
+ return null;
79
+ const need = opts.all ? terms.length : 1;
80
+ let best = null;
81
+ for (const e of this.entries) {
82
+ const hits = terms.filter((re) => re.test(e.lower)).length;
83
+ if (hits < need)
84
+ continue;
85
+ const inDecisionFile = DECISION_PROPER.test(e.file);
86
+ const decides = DECISION_WORDS.test(e.lower);
87
+ // A decision file's table rows and headings are decisions; its prose still
88
+ // has to say so. Outside a decision file, only a line that says so counts.
89
+ const structural = /^\|/.test(e.text) || /^#{1,4}\s/.test(e.text) || /^\d+\.\s/.test(e.text) || /^[-*]\s/.test(e.text);
90
+ let strength = 0;
91
+ if (inDecisionFile && (structural || decides))
92
+ strength = 3;
93
+ else if (decides)
94
+ strength = 2;
95
+ if (strength === 0)
96
+ continue;
97
+ if (!best || strength > best.strength)
98
+ best = { e, strength };
99
+ if (strength === 3)
100
+ break;
101
+ }
102
+ if (!best)
103
+ return null;
104
+ const t = best.e.text;
105
+ return { file: best.e.file, line: best.e.line, excerpt: t.length > 140 ? `${t.slice(0, 137)}...` : t };
106
+ }
107
+ toJSON() {
108
+ return { files: this.files, entries: this.entries.length, critical_few: this.critical_few };
109
+ }
110
+ }
111
+ /**
112
+ * Which workflow a path touches - a HEURISTIC, and named as one in the report.
113
+ *
114
+ * The Big Sean derives the critical few from the code and the live system, and
115
+ * every finding it prints says which one it protects. A static scan cannot
116
+ * derive them, but it can read the path: a file under `billing/` is on the
117
+ * money path whatever else is true of it. When a prior Big Sean run left its
118
+ * list, the names come from there; otherwise these are the generic five.
119
+ */
120
+ export function touches(path, criticalFew) {
121
+ const p = path.toLowerCase();
122
+ const pick = (re, generic) => {
123
+ if (!re.test(p))
124
+ return null;
125
+ const named = criticalFew.find((c) => re.test(c.toLowerCase()));
126
+ return named ?? generic;
127
+ };
128
+ // The path regex reads the FILE; the name regex reads the Big Sean's list. They
129
+ // differ because a workflow is named for what it does ("Turn a repository into
130
+ // a harness") and a path for where it lives (adapters/, generators/).
131
+ const pickBy = (pathRe, nameRe, generic) => {
132
+ if (!pathRe.test(p))
133
+ return null;
134
+ return criticalFew.find((c) => nameRe.test(c.toLowerCase())) ?? generic;
135
+ };
136
+ return (pickBy(/billing|invoice|charge|checkout|payment|dunning|ledger|meter|subscription/, /charge|bill|invoice|meter|pay|revenue|money/, "the money path") ??
137
+ pickBy(/session|mint|grant|jwks|token|auth|sign-?in|login/, /session|mint|grant|sign|auth|login/, "sign-in and the session") ??
138
+ pickBy(/migration|rls|policy|tenant|tenancy|isolation/, /tenant|isolat|data away|rls|leak/, "keeping one tenant's data from another") ??
139
+ pickBy(/adapters?|templates?|generators?|shells?|ingest|\.tmpl$/, /harness|repositor|ingest|generat|adapter|ship/, "what ships into a customer's repository") ??
140
+ pickBy(/console|landing|pages?\/|components?\/|\.tsx$/, /console|surface|page|screen|ui\b/, "the surface a person uses") ??
141
+ pickBy(/ops|guard|health|digest|cron|schedule|alert|monitor/, /alert|page|break|monitor|health|uptime/, "knowing when something breaks") ??
142
+ "no critical workflow this scan can name");
143
+ }
@@ -1,4 +1,4 @@
1
- import { runtimeCode } from "../walk.js";
1
+ import { scopeFor } from "../walk.js";
2
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
3
  const LOOP_HEAD = /\bfor(\s+await)?\s*\(|\bwhile\s*\(|\.(map|forEach)\(\s*(async\b|\()/;
4
4
  const HANDLER = /(^|\/)(functions|api|routes?|handlers?)\//i;
@@ -12,7 +12,7 @@ function hostOrCall(line) {
12
12
  }
13
13
  export async function detectCosts(repo) {
14
14
  const out = [];
15
- for (const f of runtimeCode(repo.files)) {
15
+ for (const f of scopeFor(repo.files, "implementation")) {
16
16
  const text = await repo.read(f);
17
17
  if (!text || !PAID_CALL.test(text))
18
18
  continue;
@@ -0,0 +1,28 @@
1
+ /**
2
+ * The database, which no question used to claim.
3
+ *
4
+ * The coverage ledger's `unclaimed` list is what surfaced this: 146 `.sql` files
5
+ * on the first real repository the scan met, holding 243 SECURITY DEFINER
6
+ * functions, 82 policies and 81 tables, analysed by nothing. The absence of a
7
+ * detector is invisible from inside the detectors, which is why it took a ledger
8
+ * to notice rather than a reading of the code.
9
+ *
10
+ * It matters more than the count suggests. On a Postgres-backed product the
11
+ * authorisation model does not live in the application - it lives in row-level
12
+ * security and in who may execute a definer function. A scan that reads every
13
+ * line of TypeScript and no line of SQL has read the part that asks permission
14
+ * and skipped the part that grants it.
15
+ *
16
+ * WHAT THIS IS NOT. Not a linter, and not a security review: it runs nothing,
17
+ * connects to nothing, and cannot see a migration that was applied by hand and
18
+ * never committed. It reads the migrations as text and reports three shapes that
19
+ * are worth a human minute, each one a question with a subject.
20
+ *
21
+ * THE COUNTS ARE PART OF THE FINDING. `tables`, `policies` and `definer_functions`
22
+ * ride alongside, because "no findings" and "never looked" must not read alike -
23
+ * a check that reports the same whether it works or not is the failure this
24
+ * whole version is about.
25
+ */
26
+ import type { Repo } from "../walk.js";
27
+ import type { DatabaseReading } from "./types.js";
28
+ export declare function detectDatabase(repo: Repo): Promise<DatabaseReading>;
@@ -0,0 +1,173 @@
1
+ import { scopeFor } from "../walk.js";
2
+ /**
3
+ * Strip comments so a shape quoted in prose is not read as a declaration.
4
+ *
5
+ * NOT used when looking for a caller check. The convention for stating who may
6
+ * call a definer function is a `-- caller-check:` MARKER COMMENT, so stripping
7
+ * comments first destroys the exact evidence being looked for - which it did,
8
+ * and the fixture's correctly-guarded function was flagged alongside the
9
+ * unguarded one. Declarations are read from stripped text; guards from raw.
10
+ */
11
+ function stripComments(sql) {
12
+ return sql.replace(/\/\*[\s\S]*?\*\//g, " ").replace(/--[^\n]*/g, " ");
13
+ }
14
+ /**
15
+ * Split into statements on semicolons that are not inside a dollar-quoted body.
16
+ * A function body is full of semicolons and is one statement; splitting naively
17
+ * turns every definer function into a dozen fragments and the caller check into
18
+ * a fragment of its own, which is how a guard goes missing.
19
+ */
20
+ function statements(sql) {
21
+ const out = [];
22
+ let buf = "";
23
+ let i = 0;
24
+ let tag = null;
25
+ while (i < sql.length) {
26
+ if (!tag) {
27
+ const m = /^\$([A-Za-z_]*)\$/.exec(sql.slice(i));
28
+ if (m) {
29
+ tag = m[0];
30
+ buf += tag;
31
+ i += tag.length;
32
+ continue;
33
+ }
34
+ if (sql[i] === ";") {
35
+ out.push(buf);
36
+ buf = "";
37
+ i++;
38
+ continue;
39
+ }
40
+ }
41
+ else if (sql.startsWith(tag, i)) {
42
+ buf += tag;
43
+ i += tag.length;
44
+ tag = null;
45
+ continue;
46
+ }
47
+ buf += sql[i];
48
+ i++;
49
+ }
50
+ if (buf.trim())
51
+ out.push(buf);
52
+ return out;
53
+ }
54
+ const QUALIFIED = /(?:([a-z_][a-z0-9_]*)\.)?([a-z_][a-z0-9_]*)/i;
55
+ function qualify(raw) {
56
+ const m = QUALIFIED.exec(raw.replace(/"/g, ""));
57
+ if (!m)
58
+ return raw;
59
+ return `${m[1] ?? "public"}.${m[2]}`;
60
+ }
61
+ /**
62
+ * A definer function states who may call it, one of two ways: a marker comment
63
+ * naming the predicate, or a runtime check in the body. Either is a statement
64
+ * somebody can be held to; neither is proof it is correct, and the finding says
65
+ * "states no caller check", never "is insecure".
66
+ */
67
+ const CALLER_CHECK = /caller-check|is_account_member|is_platform_staff|auth\.uid\s*\(\s*\)|auth\.role\s*\(\s*\)|current_setting\s*\(|has_role|raise\s+exception|assert\b/i;
68
+ export async function detectDatabase(repo) {
69
+ // Migrations wherever they live; a `.sql.tmpl` shipped to a customer is the
70
+ // customer's database, so the template scope rules apply here as everywhere.
71
+ const files = scopeFor(repo.files, "database");
72
+ const rlsEnabled = new Set();
73
+ const rlsForced = new Set();
74
+ const tablesSeen = new Map();
75
+ const findings = [];
76
+ let policies = 0;
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.
81
+ for (const f of files) {
82
+ const raw = await repo.read(f);
83
+ if (!raw)
84
+ continue;
85
+ const sql = stripComments(raw);
86
+ for (const m of sql.matchAll(/alter\s+table\s+(?:if\s+exists\s+)?([a-z0-9_."]+)\s+([a-z\s]*row\s+level\s+security)/gi)) {
87
+ const t = qualify(m[1]);
88
+ if (/force/i.test(m[2]))
89
+ rlsForced.add(t);
90
+ if (/enable/i.test(m[2]))
91
+ rlsEnabled.add(t);
92
+ }
93
+ }
94
+ for (const f of files) {
95
+ const raw = await repo.read(f);
96
+ if (!raw)
97
+ continue;
98
+ const sql = stripComments(raw);
99
+ for (const m of sql.matchAll(/create\s+table\s+(?:if\s+not\s+exists\s+)?([a-z0-9_."]+)/gi)) {
100
+ const t = qualify(m[1]);
101
+ // `create policy p on public.x for select to public` contains the literal
102
+ // `table` nowhere, but a looser earlier pattern produced a table called
103
+ // `public.public` from exactly that line. A schema is not a table.
104
+ if (/^(public|auth|storage|extensions)\.(public|auth|storage)$/.test(t))
105
+ continue;
106
+ if (!tablesSeen.has(t))
107
+ tablesSeen.set(t, f);
108
+ }
109
+ // SPLIT THE RAW SQL, STRIP PER STATEMENT.
110
+ //
111
+ // The first version split the stripped text and the raw text separately and
112
+ // paired them by index, which silently drifts: removing comments removes
113
+ // semicolons inside them, so the two lists stop describing the same
114
+ // statements. On a real repository that reported 60 definer functions as
115
+ // stating no caller check when every one of them carried the marker - the
116
+ // failure mode this whole version exists to remove, arriving in the detector
117
+ // written to find it.
118
+ for (const withComments of statements(raw)) {
119
+ const stmt = stripComments(withComments);
120
+ if (/create\s+(or\s+replace\s+)?policy/i.test(stmt)) {
121
+ policies++;
122
+ const name = /create\s+(?:or\s+replace\s+)?policy\s+("?[a-z0-9_]+"?)/i.exec(stmt)?.[1]?.replace(/"/g, "");
123
+ // `to anon` hands the policy to unauthenticated callers. `to public`
124
+ // is the same reach by another name, since anon is a member of public.
125
+ if (name && /\bto\s+(anon|public)\b/i.test(stmt)) {
126
+ findings.push({
127
+ kind: "policy_reaches_anon",
128
+ subject: name,
129
+ file: f,
130
+ note: "a policy granted to anon or public is readable by an unauthenticated caller. Deliberate for genuinely public rows, and worth one look per policy.",
131
+ });
132
+ }
133
+ }
134
+ if (/create\s+(or\s+replace\s+)?function/i.test(stmt) && /security\s+definer/i.test(stmt)) {
135
+ definerFunctions++;
136
+ 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)) {
138
+ findings.push({
139
+ kind: "definer_without_check",
140
+ subject: qualify(name),
141
+ 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.",
143
+ });
144
+ }
145
+ }
146
+ }
147
+ }
148
+ for (const [t, f] of tablesSeen) {
149
+ if (!rlsEnabled.has(t)) {
150
+ findings.push({
151
+ kind: "table_without_rls",
152
+ subject: t,
153
+ file: f,
154
+ note: "no `enable row level security` anywhere in the migrations. Right for a lookup table nobody owns; a question for anything carrying one customer's rows.",
155
+ });
156
+ }
157
+ else if (!rlsForced.has(t)) {
158
+ findings.push({
159
+ kind: "rls_not_forced",
160
+ subject: t,
161
+ file: f,
162
+ note: "row level security is enabled but not forced, so the table owner bypasses it. Forcing it is one line and closes the gap.",
163
+ });
164
+ }
165
+ }
166
+ return {
167
+ files: files.length,
168
+ tables: tablesSeen.size,
169
+ policies,
170
+ definer_functions: definerFunctions,
171
+ findings: findings.sort((a, b) => a.kind.localeCompare(b.kind) || a.subject.localeCompare(b.subject)).slice(0, 60),
172
+ };
173
+ }
@@ -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[]>;