@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.
@@ -10,6 +10,8 @@
10
10
  */
11
11
  /** One declared dependency and every place it was actually seen. */
12
12
  export interface DepFact {
13
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
14
+ id?: string;
13
15
  name: string;
14
16
  version: string;
15
17
  /** Which manifest declared it, repository-relative. */
@@ -53,14 +55,27 @@ export interface VendorFact {
53
55
  }
54
56
  /** Two or more services doing the same category of work. */
55
57
  export interface OverlapFact {
58
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
59
+ id?: string;
56
60
  category: VendorCategory;
57
61
  services: string[];
58
62
  call_sites: number;
63
+ /** What each service was seen doing inside the category, when the code says. */
64
+ roles?: Record<string, string[]>;
65
+ /**
66
+ * Set when the code shows the services doing DIFFERENT jobs inside one
67
+ * category - Stripe processing on a merchant's own account beside Square
68
+ * billing the platform, Gemini writing text beside Replicate making images.
69
+ * A category is not a job. Listed, never deducted; the text is the reason.
70
+ */
71
+ distinct?: string;
59
72
  on_record?: OnRecord;
60
73
  }
61
74
  export type RailCategory = "pdf" | "rate-limiting" | "email-templating" | "auth-session" | "queue-scheduler" | "search" | "payments-logic" | "webhook-plumbing" | "parsing-ocr";
62
75
  /** A subsystem built by hand where the market sells a rail. */
63
76
  export interface HandrolledFact {
77
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
78
+ id?: string;
64
79
  rail: RailCategory;
65
80
  files: string[];
66
81
  loc: number;
@@ -76,6 +91,8 @@ export interface HandrolledFact {
76
91
  }
77
92
  /** A cluster of near-identical code living in more than one file. */
78
93
  export interface DuplicateFact {
94
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
95
+ id?: string;
79
96
  files: string[];
80
97
  /** Lines in the repeated block, after normalization. */
81
98
  lines: number;
@@ -95,15 +112,25 @@ export interface DuplicateFact {
95
112
  }
96
113
  /** A runtime file no entrypoint reaches. */
97
114
  export interface DeadFact {
115
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
116
+ id?: string;
98
117
  file: string;
99
118
  loc: number;
100
119
  /** Why the detector believes nothing reaches it. */
101
120
  note: string;
121
+ /**
122
+ * A UI-kit scaffold component (shadcn's `components.json` beside it) that no
123
+ * file imports. Never bundled, so it does not ship; still read and searched.
124
+ * Listed at a quarter of the weight.
125
+ */
126
+ scaffold?: boolean;
102
127
  on_record?: OnRecord;
103
128
  touches?: string;
104
129
  }
105
130
  /** A call whose cost multiplies: per request, per row, or on a clock. */
106
131
  export interface CostFact {
132
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
133
+ id?: string;
107
134
  file: string;
108
135
  shape: "per-request" | "per-row" | "per-schedule";
109
136
  /** What is being called - a vendor host or an SDK call name. */
@@ -130,6 +157,8 @@ export interface NorthStar {
130
157
  }
131
158
  /** A shape in the migrations worth a human minute. Never a verdict. */
132
159
  export interface DatabaseFinding {
160
+ /** Stable id, so a verdict can name this finding: see verdicts.ts. */
161
+ id?: string;
133
162
  kind: "table_without_rls" | "rls_not_forced" | "definer_without_check" | "policy_reaches_anon";
134
163
  /** The table, function or policy, schema-qualified where it has one. */
135
164
  subject: string;
@@ -145,5 +174,9 @@ export interface DatabaseReading {
145
174
  tables: number;
146
175
  policies: number;
147
176
  definer_functions: number;
177
+ /** Definer functions whose EXECUTE the migrations revoke from public, anon or authenticated. */
178
+ definer_execute_revoked: number;
179
+ /** A guard function the repository carries to check definer grants at runtime, when one is named. */
180
+ guard?: string;
148
181
  findings: DatabaseFinding[];
149
182
  }
@@ -53,6 +53,43 @@ const KNOWN = [
53
53
  { service: "Cal.com", category: "calendar", hosts: /api\.cal\.com$/, env: /^CAL_/ },
54
54
  { service: "Calendly", category: "calendar", hosts: /api\.calendly\.com$/, env: /^CALENDLY_/ },
55
55
  ];
56
+ const ROLES = {
57
+ payments: [
58
+ {
59
+ role: "processes on a merchant's own account (Connect or OAuth markers)",
60
+ merchant: true,
61
+ re: /\/v1\/accounts\b|stripe[-_]account|on_behalf_of|transfer_data|application_fee|oauth2\/(token|authorize|revoke)|refresh_token|merchant_id|connected_account/i,
62
+ },
63
+ {
64
+ role: "bills the platform's own account (subscriptions, its own checkout)",
65
+ re: /\bsubscriptions?\b|\/v1\/checkout\/sessions|\/v2\/(checkout|subscriptions|invoices)|\binvoices?\b|price_id|\bplan_id\b/i,
66
+ },
67
+ ],
68
+ ai: [
69
+ { role: "writes text", re: /generateContent|chat\/completions|\/v1\/messages\b|messages\.create|\/completions\b|\bembeddings\b|responses\.create/i },
70
+ { role: "makes images", re: /\/predictions\b|images\.(generate|edit)|\/images\/generations|\bimagen\b|nano-banana|stable-diffusion|\bsdxl\b|\bflux[-/]|dall-e/i },
71
+ { role: "makes speech or audio", re: /text-to-speech|\/v1\/audio\/|speech\.create|audio\.transcriptions/i },
72
+ ],
73
+ };
74
+ /**
75
+ * A role belongs to a vendor only when it sits ON that vendor's call: the line
76
+ * that names the vendor, or the few lines under it that finish the same call.
77
+ * "Nearest vendor within forty lines" credited Gemini with making images because
78
+ * a `predictions:` type field sat thirty lines from its URL.
79
+ */
80
+ const ROLE_REACH = 30; // lines under the vendor's line; the role regexes are endpoint-shaped, so this reach stays safe
81
+ /** Every KNOWN service a single line names, by host, env var or import. */
82
+ function vendorsOnLine(line, only) {
83
+ const out = [];
84
+ const host = /https?:\/\/([a-z0-9][a-z0-9.-]+\.[a-z]{2,})/i.exec(line)?.[1]?.toLowerCase();
85
+ const env = /([A-Z][A-Z0-9_]{2,})/.exec(line)?.[1];
86
+ const spec = /["']((?:npm:)?@?[a-z0-9][a-z0-9._/-]*)["']/i.exec(line)?.[1]?.replace(/^npm:/, "");
87
+ for (const k of only) {
88
+ if ((host && k.hosts?.test(host)) || (env && k.env?.test(env)) || (spec && k.sdks?.test(spec)))
89
+ out.push(k);
90
+ }
91
+ return out;
92
+ }
56
93
  /** Hosts that are content, not services: fonts, CDNs of the app's own assets, social links. */
57
94
  const NOISE_HOST = /(^|\.)(esm\.sh|cdn\.jsdelivr\.net|unpkg\.com|fonts\.(googleapis|gstatic)\.com|githubusercontent\.com|github\.com|linkedin\.com|x\.com|twitter\.com|instagram\.com|facebook\.com|youtube\.com|tiktok\.com|schema\.org|w3\.org|localhost)$/i;
58
95
  export async function detectVendors(repo) {
@@ -118,11 +155,67 @@ export async function detectVendors(repo) {
118
155
  for (const [category, list] of byCategory) {
119
156
  if (category === "other" || list.length < 2)
120
157
  continue;
121
- overlaps.push({
158
+ const o = {
122
159
  category,
123
160
  services: list.map((v) => v.service),
124
161
  call_sites: list.reduce((t, v) => t + v.call_sites, 0),
125
- });
162
+ };
163
+ const roleTable = ROLES[category];
164
+ if (roleTable) {
165
+ // Roles, read from the lines near each vendor's own markers.
166
+ const known = list.map((v) => KNOWN.find((k) => k.service === v.service));
167
+ const roles = new Map(); // service -> role -> where
168
+ const evidenceFiles = new Set(list.flatMap((v) => v.evidence.map((e) => e.file)));
169
+ for (const f of evidenceFiles) {
170
+ const text = await repo.read(f);
171
+ if (!text)
172
+ continue;
173
+ const lines = text.split("\n");
174
+ const marks = [];
175
+ lines.forEach((line, i) => {
176
+ for (const k of vendorsOnLine(line, known))
177
+ marks.push({ at: i, k });
178
+ });
179
+ if (!marks.length)
180
+ continue;
181
+ lines.forEach((line, i) => {
182
+ for (const r of roleTable) {
183
+ if (!r.re.test(line))
184
+ continue;
185
+ let best = null;
186
+ for (const m of marks) {
187
+ const d = i - m.at; // the vendor line itself, or lines under it
188
+ if (d >= 0 && d <= ROLE_REACH && (!best || d < best.d))
189
+ best = { d, k: m.k };
190
+ }
191
+ if (!best)
192
+ continue;
193
+ const mine = roles.get(best.k.service) ?? new Map();
194
+ if (!mine.has(r.role))
195
+ mine.set(r.role, `${f}:${i + 1}`);
196
+ roles.set(best.k.service, mine);
197
+ }
198
+ });
199
+ }
200
+ if (roles.size)
201
+ o.roles = Object.fromEntries([...roles].map(([s, m]) => [s, [...m.keys()]]));
202
+ const merchantRole = roleTable.find((r) => r.merchant);
203
+ const merchants = merchantRole ? o.services.filter((s) => roles.get(s)?.has(merchantRole.role)) : [];
204
+ if (merchants.length) {
205
+ const where = merchants.map((s) => `${s} (${roles.get(s).get(merchantRole.role)})`).join(" and ");
206
+ o.distinct = `${where} ${merchants.length > 1 ? "carry" : "carries"} Connect or OAuth markers: charging on a merchant's own account. A second processor beside that is a merchant's choice of where their sales land, not the platform paying twice.`;
207
+ }
208
+ else if (o.services.every((s) => roles.get(s)?.size)) {
209
+ const sets = o.services.map((s) => new Set(roles.get(s).keys()));
210
+ const disjoint = sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((r) => b.has(r))));
211
+ if (disjoint) {
212
+ o.distinct =
213
+ o.services.map((s) => `${s} ${[...roles.get(s).entries()].map(([r, w]) => `${r} (${w})`).join(" and ")}`).join("; ") +
214
+ ". One category, two jobs.";
215
+ }
216
+ }
217
+ }
218
+ overlaps.push(o);
126
219
  }
127
220
  const unrecognised = [...unknownHosts.entries()]
128
221
  .map(([host, set]) => ({ host, files: [...set].slice(0, 5) }))
package/dist/index.d.ts CHANGED
@@ -6,6 +6,8 @@ import { type ProspectScore } from "./score.js";
6
6
  import type { CostFact, DeadFact, DepFact, DatabaseReading, DuplicateFact, Fingerprint, HandrolledFact, NorthStar, OverlapFact, VendorFact } from "./detect/types.js";
7
7
  export { toMarkdown, secretShaped } from "./report.js";
8
8
  export { checkReport } from "./check.js";
9
+ export { checkVerdicts, rescore, showMath, findingIds } from "./verdicts.js";
10
+ export type { Verdicts, VerdictRecord, EvidenceEntry, Verdict } from "./verdicts.js";
9
11
  export type { ProspectScore } from "./score.js";
10
12
  export interface Prospect {
11
13
  format: "bigsteele-prospect/1";
@@ -43,5 +45,5 @@ export interface Prospect {
43
45
  entrypoints: number;
44
46
  };
45
47
  }
46
- export declare const VERSION = "0.2.0";
48
+ export declare const VERSION = "0.3.0";
47
49
  export declare function runProspect(root: string): Promise<Prospect>;
package/dist/index.js CHANGED
@@ -36,7 +36,8 @@ import { Decisions, touches } from "./decisions.js";
36
36
  import { scoreProspect } from "./score.js";
37
37
  export { toMarkdown, secretShaped } from "./report.js";
38
38
  export { checkReport } from "./check.js";
39
- export const VERSION = "0.2.0";
39
+ export { checkVerdicts, rescore, showMath, findingIds } from "./verdicts.js";
40
+ export const VERSION = "0.3.0";
40
41
  export async function runProspect(root) {
41
42
  const repo = await openRepo(root);
42
43
  const runtime = runtimeCode(repo.files);
@@ -94,6 +95,28 @@ export async function runProspect(root) {
94
95
  d.on_record = record.explains(frag(d.file)) ?? undefined;
95
96
  d.touches = touches(d.file, few);
96
97
  }
98
+ // EVERY FINDING GETS A NAME (0.3). The protocol has to rule on each one -
99
+ // confirmed, refuted, on record, unknown - and a verdict needs something to
100
+ // point at. Ids are stable across runs of the same repository.
101
+ for (const d of deps)
102
+ if (d.no_reference_found)
103
+ d.id = `dep:${d.name}`;
104
+ for (const o of vendorsReading.overlaps)
105
+ o.id = `overlap:${o.category}`;
106
+ for (const o of stack.overlaps)
107
+ o.id = `overlap:${o.category}`;
108
+ for (const h of handrolled)
109
+ h.id = `hand:${h.rail}`;
110
+ for (const c of stack.consolidations)
111
+ c.id = `cut:${c.candidate}`;
112
+ duplicates.forEach((d, i) => { if (!d.deliberate && !d.parallel)
113
+ d.id = `dup:${i + 1}:${d.files[0] ?? ""}`; });
114
+ for (const d of deadReading.dead)
115
+ d.id = `dead:${d.file}`;
116
+ for (const c of costs)
117
+ c.id = `cost:${c.file}:${c.line.split(":")[0]}`;
118
+ for (const f of database.findings)
119
+ f.id = `db:${f.kind}:${f.subject}`;
97
120
  // Last, and after every detector, so the ledger describes the run that just
98
121
  // happened rather than a plan for one.
99
122
  const coverage = await coverageOf(repo);
package/dist/report.js CHANGED
@@ -17,13 +17,17 @@ export function toMarkdown(p) {
17
17
  const accidental = p.duplicates.filter((d) => !d.deliberate);
18
18
  const highHand = p.handrolled.filter((h) => h.confidence === "high");
19
19
  const cuts = p.stack.consolidations;
20
+ // A category is not a job: pairs the code shows doing different work are
21
+ // listed under their own heading and never counted as paid twice.
22
+ const paidTwice = p.overlaps.filter((o) => !o.distinct);
23
+ const twoJobs = p.overlaps.filter((o) => o.distinct);
20
24
  L.push(`# The Prospect: ${p.app}`, "");
21
25
  // The headline is an argument, not a metric.
22
26
  const claims = [];
23
27
  if (noRef.length)
24
28
  claims.push(`**${noRef.length} of ${p.totals.runtime_deps}** dependencies show no reference anywhere`);
25
- if (p.overlaps.length)
26
- claims.push(`**${p.overlaps.length}** job${p.overlaps.length > 1 ? "s are" : " is"} paid for twice`);
29
+ if (paidTwice.length)
30
+ claims.push(`**${paidTwice.length}** job${paidTwice.length > 1 ? "s are" : " is"} paid for twice`);
27
31
  if (highHand.length)
28
32
  claims.push(`**${highHand.length}** subsystem${highHand.length > 1 ? "s" : ""} built by hand where the market sells a rail`);
29
33
  if (cuts.length)
@@ -51,7 +55,7 @@ export function toMarkdown(p) {
51
55
  retest: "install, build and run the test suite with it gone",
52
56
  });
53
57
  }
54
- for (const o of p.overlaps.filter((x) => !x.on_record)) {
58
+ for (const o of paidTwice.filter((x) => !x.on_record)) {
55
59
  actions.push({
56
60
  what: `${prose(o.services)} both do ${label(o.category)} work`,
57
61
  touches: "one bill and one failure surface per vendor",
@@ -91,7 +95,7 @@ export function toMarkdown(p) {
91
95
  const onRecord = [];
92
96
  for (const d of noRef.filter((x) => x.on_record))
93
97
  onRecord.push({ what: `\`${d.name}\` shows no reference`, where: `${d.on_record.file}:${d.on_record.line} - ${d.on_record.excerpt}` });
94
- for (const o of p.overlaps.filter((x) => x.on_record))
98
+ for (const o of paidTwice.filter((x) => x.on_record))
95
99
  onRecord.push({ what: `${prose(o.services)} both do ${label(o.category)} work`, where: `${o.on_record.file}:${o.on_record.line} - ${o.on_record.excerpt}` });
96
100
  for (const c of cuts.filter((x) => x.on_record))
97
101
  onRecord.push({ what: `${c.candidate} beside ${c.keep}`, where: `${c.on_record.file}:${c.on_record.line} - ${c.on_record.excerpt}` });
@@ -121,6 +125,16 @@ export function toMarkdown(p) {
121
125
  L.push(`${parallels.length} parallel adapter block(s), ${total} lines in total, at the same relative path under sibling adapter directories. A generated file cannot import from the generator, so these duplicate by design; the one question is whether the generator could render them from a single partial.`, "");
122
126
  }
123
127
  }
128
+ // TWO VENDORS, TWO JOBS. A category shared is not a bill doubled; the code
129
+ // showed each doing different work. Listed so the reader sees the pair was
130
+ // weighed, charged nothing, and asked to be written down once.
131
+ if (twoJobs.length) {
132
+ L.push(`## Two vendors, two jobs`, "");
133
+ L.push(`Same category, different work by the look of the code. Not charged. A line in DECISIONS.md naming both keeps the next scan from asking.`, "");
134
+ for (const o of twoJobs)
135
+ L.push(`- **${label(o.category)}**: ${o.services.join(" + ")}. ${o.distinct}${o.on_record ? ` On record: ${o.on_record.file}:${o.on_record.line}.` : ""}`);
136
+ L.push("");
137
+ }
124
138
  // UNKNOWN. What the scan could not settle, stated rather than scored.
125
139
  const unknowns = [];
126
140
  if (p.north_star.confidence !== "high")
@@ -196,7 +210,10 @@ export function toMarkdown(p) {
196
210
  if (db.files > 0) {
197
211
  L.push(`### The database`, "");
198
212
  L.push(`${db.files} migration file(s): ${db.tables} table(s), ${db.policies} policy/policies, ` +
199
- `${db.definer_functions} function(s) running as definer. ` +
213
+ `${db.definer_functions} function(s) running as definer` +
214
+ (db.definer_execute_revoked ? `, ${db.definer_execute_revoked} of them with EXECUTE revoked from public, anon or authenticated in the migrations` : "") +
215
+ `. ` +
216
+ (db.guard ? `The repository carries \`${db.guard}\`, which checks the live grants; this scan reads only what the migrations state. ` : "") +
200
217
  (db.findings.length === 0
201
218
  ? `Nothing below stood out.`
202
219
  : `${db.findings.length} shape(s) worth a minute.`), "");
@@ -233,9 +250,9 @@ export function toMarkdown(p) {
233
250
  L.push("");
234
251
  L.push(`A package can earn its keep without an import (a CLI run from a script, a plugin loaded by name at runtime). This table is the list to answer for, not a kill list.`, "");
235
252
  }
236
- if (p.overlaps.length) {
253
+ if (paidTwice.length) {
237
254
  L.push(`### Jobs paid for twice`, "");
238
- for (const o of p.overlaps) {
255
+ for (const o of paidTwice) {
239
256
  L.push(`- **${label(o.category)}**: ${o.services.join(" + ")} (${o.call_sites} call sites). One category of work, ${o.services.length} vendors, ${o.services.length} invoices, ${o.services.length} places a key can leak.`);
240
257
  }
241
258
  L.push("");
@@ -278,9 +295,11 @@ export function toMarkdown(p) {
278
295
  }
279
296
  if (p.dead.length) {
280
297
  L.push(`### Files no entrypoint reaches`, "");
281
- L.push(`From ${p.totals.entrypoints} entrypoints, the import walk never arrived at these. A file loaded by a path built at runtime can still be alive - this is the list to answer for.`, "");
298
+ const scaffold = p.dead.filter((d) => d.scaffold).length;
299
+ L.push(`From ${p.totals.entrypoints} entrypoints, the import walk never arrived at these, and no reached file names them by path. A file loaded by a path built at runtime can still be alive - this is the list to answer for.` +
300
+ (scaffold ? ` ${scaffold} are UI-kit scaffold components (a components.json sits beside them) that no file imports: never bundled, only read and searched, and weighed a quarter.` : ""), "");
282
301
  for (const d of p.dead.slice(0, 20))
283
- L.push(`- \`${d.file}\` (${d.loc} lines)`);
302
+ L.push(`- \`${d.file}\` (${d.loc} lines${d.scaffold ? ", scaffold" : ""})`);
284
303
  if (p.dead.length > 20)
285
304
  L.push(`- …and ${p.dead.length - 20} more in the JSON`);
286
305
  L.push("");
package/dist/score.js CHANGED
@@ -51,16 +51,20 @@ export function scoreProspect(input) {
51
51
  const noRef = runtimeDeps.filter((d) => d.no_reference_found && !d.on_record);
52
52
  // Unreferenced runtime dependencies: up to 20.
53
53
  ding("dependencies with no reference found", Math.min(20, noRef.length * 2), noRef.length ? `${noRef.length} of ${runtimeDeps.length} runtime dependencies` : "");
54
- // Files no entrypoint reaches: up to 20, by share of runtime files.
54
+ // Files no entrypoint reaches: up to 20, by share of runtime files. A UI-kit
55
+ // scaffold component nothing imports is never bundled, so it weighs a quarter.
55
56
  const dead = input.dead.filter((d) => !d.on_record);
56
- const deadShare = input.runtime_files > 0 ? dead.length / input.runtime_files : 0;
57
- ding("files no entrypoint reaches", Math.min(20, Math.round(deadShare * 100)), dead.length ? `${dead.length} files (${Math.round(deadShare * 100)} in 100)` : "");
57
+ const scaffold = dead.filter((d) => d.scaffold).length;
58
+ const deadWeight = dead.length - scaffold + scaffold / 4;
59
+ const deadShare = input.runtime_files > 0 ? deadWeight / input.runtime_files : 0;
60
+ ding("files no entrypoint reaches", Math.min(20, Math.round(deadShare * 100)), dead.length ? `${dead.length} files (${Math.round(deadShare * 100)} in 100${scaffold ? `; ${scaffold} are scaffold components, weighed a quarter` : ""})` : "");
58
61
  // Accidental duplicate clusters: up to 20. Deliberate copies cost nothing.
59
62
  const accidental = input.duplicates.filter((d) => !d.deliberate && !d.parallel && !d.on_record);
60
63
  const dupLines = accidental.reduce((t, d) => t + d.lines * (d.files.length - 1), 0);
61
64
  ding("duplicated blocks not marked deliberate", Math.min(20, Math.round(dupLines / 40)), accidental.length ? `${accidental.length} clusters, ${dupLines} repeated lines` : "");
62
- // Vendor overlap: up to 20. Two vendors in one category is a doubled bill.
63
- const overlaps = input.overlaps.filter((o) => !o.on_record);
65
+ // Vendor overlap: up to 20. Two vendors doing ONE JOB is a doubled bill; two
66
+ // vendors the code shows doing different jobs inside a category is not (0.2.1).
67
+ const overlaps = input.overlaps.filter((o) => !o.on_record && !o.distinct);
64
68
  ding("two or more vendors doing one job", Math.min(20, overlaps.length * 7), overlaps.length ? overlaps.map((o) => `${o.category}: ${o.services.join(" + ")}`).join("; ") : "");
65
69
  // Hand-rolled where a rail exists: up to 12, high-confidence only.
66
70
  const high = input.handrolled.filter((h) => h.confidence === "high" && !h.on_record);
@@ -0,0 +1,80 @@
1
+ /**
2
+ * The verdicts: what the agent decided about every Step 0 finding, and the
3
+ * score recomputed from those decisions.
4
+ *
5
+ * Step 0 is a deterministic scan, and a deterministic scan is a hypothesis
6
+ * machine: it reads shapes and prints them. The protocol's first job is to
7
+ * take every finding to the code and rule on it, one of four ways -
8
+ * CONFIRMED with evidence, REFUTED with the reason the shape lied, ON_RECORD
9
+ * with the line that decided it, or UNKNOWN with what would settle it. A
10
+ * finding no verdict covers is a finding nobody checked, and the gate says so.
11
+ *
12
+ * The score then follows the verdicts, not the scan: a refuted finding costs
13
+ * nothing, a recorded one costs nothing, and the math is printed so the number
14
+ * can be argued with rather than trusted. Same rule as The Big Sean's
15
+ * validator: if this and the agent disagree, believe this and fix the file.
16
+ *
17
+ * PURE MODULE: no filesystem, no process. The CLI feeds it parsed JSON.
18
+ */
19
+ import type { Prospect } from "./index.js";
20
+ import { type ProspectScore } from "./score.js";
21
+ export type Verdict = "CONFIRMED" | "REFUTED" | "ON_RECORD" | "UNKNOWN";
22
+ export interface VerdictRecord {
23
+ /** The finding's id from the scan JSON: dep:zod, overlap:ai, hand:search, dead:src/x.ts, cost:file:line, db:kind:subject, cut:Vercel, dup:1:file */
24
+ id: string;
25
+ verdict: Verdict;
26
+ /** Evidence ids (EV-nn) from the register. Required for CONFIRMED and REFUTED. */
27
+ evidence?: string[];
28
+ /** Why - the shape that lied, the record that decided, or what would settle it. */
29
+ reason?: string;
30
+ /** For ON_RECORD: the decision line, file:line. */
31
+ record?: string;
32
+ }
33
+ export interface EvidenceEntry {
34
+ id: string;
35
+ /** What was done: the file read, the command run, the page loaded. */
36
+ what: string;
37
+ /** Where: file:line, a sanitised command, or a URL. */
38
+ where: string;
39
+ /** What was observed, in one line. Secrets and personal data never. */
40
+ observed: string;
41
+ }
42
+ export interface Verdicts {
43
+ format: "bigsteele-prospect-verdicts/1";
44
+ /** The scan these verdicts rule on. */
45
+ scan?: string;
46
+ commit?: string;
47
+ findings: VerdictRecord[];
48
+ evidence: EvidenceEntry[];
49
+ }
50
+ /** Every finding id the scan carries, in the order the report prints them. */
51
+ export declare function findingIds(p: Prospect): string[];
52
+ export interface VerdictCheck {
53
+ ok: boolean;
54
+ problems: string[];
55
+ counts: Record<Verdict, number> & {
56
+ total: number;
57
+ unruled: number;
58
+ };
59
+ }
60
+ /**
61
+ * Validate the verdicts against the scan they claim to rule on. Every scan
62
+ * finding needs a verdict; every CONFIRMED and REFUTED needs evidence that
63
+ * exists in the register; every UNKNOWN needs a reason; every ON_RECORD needs
64
+ * the line. A report text may be passed so that cited evidence is also
65
+ * required to appear in the report.
66
+ */
67
+ export declare function checkVerdicts(p: Prospect, v: Verdicts, reportMd?: string): VerdictCheck;
68
+ export interface Rescore {
69
+ before: ProspectScore;
70
+ after: ProspectScore;
71
+ dropped: {
72
+ refuted: string[];
73
+ on_record: string[];
74
+ };
75
+ unknown: string[];
76
+ }
77
+ /** The score with refuted and recorded findings removed. UNKNOWN still counts: unproven is not innocent. */
78
+ export declare function rescore(p: Prospect, v: Verdicts): Rescore;
79
+ /** The math, printed. Nothing on the page is typed by hand. */
80
+ export declare function showMath(p: Prospect, v: Verdicts, r: Rescore, c: VerdictCheck): string;
@@ -0,0 +1,144 @@
1
+ import { scoreProspect } from "./score.js";
2
+ const VERDICTS = new Set(["CONFIRMED", "REFUTED", "ON_RECORD", "UNKNOWN"]);
3
+ /** Every finding id the scan carries, in the order the report prints them. */
4
+ export function findingIds(p) {
5
+ const ids = [];
6
+ for (const d of p.deps)
7
+ if (d.id)
8
+ ids.push(d.id);
9
+ for (const o of p.overlaps)
10
+ if (o.id)
11
+ ids.push(o.id);
12
+ for (const h of p.handrolled)
13
+ if (h.id)
14
+ ids.push(h.id);
15
+ for (const c of p.stack.consolidations)
16
+ if (c.id)
17
+ ids.push(c.id);
18
+ for (const d of p.duplicates)
19
+ if (d.id)
20
+ ids.push(d.id);
21
+ for (const d of p.dead)
22
+ if (d.id)
23
+ ids.push(d.id);
24
+ for (const c of p.cost_surfaces)
25
+ if (c.id)
26
+ ids.push(c.id);
27
+ for (const f of p.database.findings)
28
+ if (f.id)
29
+ ids.push(f.id);
30
+ return ids;
31
+ }
32
+ /**
33
+ * Validate the verdicts against the scan they claim to rule on. Every scan
34
+ * finding needs a verdict; every CONFIRMED and REFUTED needs evidence that
35
+ * exists in the register; every UNKNOWN needs a reason; every ON_RECORD needs
36
+ * the line. A report text may be passed so that cited evidence is also
37
+ * required to appear in the report.
38
+ */
39
+ export function checkVerdicts(p, v, reportMd) {
40
+ const problems = [];
41
+ const counts = { CONFIRMED: 0, REFUTED: 0, ON_RECORD: 0, UNKNOWN: 0, total: 0, unruled: 0 };
42
+ if (v.format !== "bigsteele-prospect-verdicts/1")
43
+ problems.push(`format is "${String(v.format)}", expected "bigsteele-prospect-verdicts/1"`);
44
+ const evidence = new Map((v.evidence ?? []).map((e) => [e.id, e]));
45
+ for (const e of v.evidence ?? []) {
46
+ if (!/^EV-\d+$/.test(e.id))
47
+ problems.push(`evidence "${e.id}" is not named EV-<n>`);
48
+ if (!e.where || !e.observed)
49
+ problems.push(`evidence ${e.id} lacks where or observed`);
50
+ }
51
+ const byId = new Map();
52
+ for (const r of v.findings ?? []) {
53
+ if (byId.has(r.id))
54
+ problems.push(`finding ${r.id} has two verdicts`);
55
+ byId.set(r.id, r);
56
+ }
57
+ const ids = findingIds(p);
58
+ const known = new Set(ids);
59
+ for (const r of v.findings ?? []) {
60
+ if (!known.has(r.id))
61
+ problems.push(`verdict on "${r.id}", which the scan did not report`);
62
+ }
63
+ for (const id of ids) {
64
+ const r = byId.get(id);
65
+ counts.total++;
66
+ if (!r) {
67
+ counts.unruled++;
68
+ problems.push(`no verdict on ${id}: a finding nobody checked`);
69
+ continue;
70
+ }
71
+ if (!VERDICTS.has(r.verdict)) {
72
+ problems.push(`${id}: verdict "${String(r.verdict)}" is not CONFIRMED, REFUTED, ON_RECORD or UNKNOWN`);
73
+ continue;
74
+ }
75
+ counts[r.verdict]++;
76
+ if (r.verdict === "CONFIRMED" || r.verdict === "REFUTED") {
77
+ if (!r.evidence?.length)
78
+ problems.push(`${id}: ${r.verdict} with no evidence entry`);
79
+ for (const ev of r.evidence ?? []) {
80
+ if (!evidence.has(ev))
81
+ problems.push(`${id}: cites ${ev}, which is not in the register`);
82
+ else if (reportMd && !reportMd.includes(ev))
83
+ problems.push(`${id}: cites ${ev}, which the report never shows`);
84
+ }
85
+ }
86
+ if (r.verdict === "REFUTED" && !r.reason)
87
+ problems.push(`${id}: REFUTED without saying what shape lied`);
88
+ if (r.verdict === "UNKNOWN" && !r.reason)
89
+ problems.push(`${id}: UNKNOWN without saying what would settle it`);
90
+ if (r.verdict === "ON_RECORD" && !r.record)
91
+ problems.push(`${id}: ON_RECORD without the deciding line`);
92
+ }
93
+ return { ok: problems.length === 0, problems, counts };
94
+ }
95
+ /** The score with refuted and recorded findings removed. UNKNOWN still counts: unproven is not innocent. */
96
+ export function rescore(p, v) {
97
+ const verdictOf = new Map((v.findings ?? []).map((r) => [r.id, r.verdict]));
98
+ const keep = (id) => !id || !(verdictOf.get(id) === "REFUTED" || verdictOf.get(id) === "ON_RECORD");
99
+ const refuted = [...verdictOf].filter(([, x]) => x === "REFUTED").map(([id]) => id);
100
+ const onRecord = [...verdictOf].filter(([, x]) => x === "ON_RECORD").map(([id]) => id);
101
+ const unknown = [...verdictOf].filter(([, x]) => x === "UNKNOWN").map(([id]) => id);
102
+ const deps = p.deps.map((d) => (keep(d.id) ? d : { ...d, no_reference_found: false }));
103
+ const unusedPaid = deps.find((d) => !d.dev && d.no_reference_found && /^(openai|stripe|twilio|resend|@sendgrid\/|@anthropic-ai\/|@clerk\/|algoliasearch|cloudinary)/.test(d.name))?.name ?? null;
104
+ const after = scoreProspect({
105
+ not_asked: p.score.not_asked,
106
+ deps,
107
+ overlaps: p.overlaps.filter((o) => keep(o.id)),
108
+ consolidations: p.stack.consolidations.filter((c) => keep(c.id)),
109
+ handrolled: p.handrolled.filter((h) => keep(h.id)),
110
+ duplicates: p.duplicates.filter((d) => keep(d.id)),
111
+ dead: p.dead.filter((d) => keep(d.id)),
112
+ costs: p.cost_surfaces.filter((c) => keep(c.id)),
113
+ runtime_files: p.totals.runtime_files,
114
+ unused_paid_service: unusedPaid,
115
+ });
116
+ return { before: p.score, after, dropped: { refuted, on_record: onRecord }, unknown };
117
+ }
118
+ /** The math, printed. Nothing on the page is typed by hand. */
119
+ export function showMath(p, v, r, c) {
120
+ const L = [];
121
+ L.push(`THE PROSPECT - recomputed from ${v.scan ?? "the scan"}${v.commit ? ` at ${v.commit}` : ""}`);
122
+ L.push(`findings: ${c.counts.total} CONFIRMED ${c.counts.CONFIRMED} REFUTED ${c.counts.REFUTED} ON_RECORD ${c.counts.ON_RECORD} UNKNOWN ${c.counts.UNKNOWN} unruled ${c.counts.unruled}`);
123
+ L.push(`evidence entries: ${(v.evidence ?? []).length}`);
124
+ L.push("");
125
+ L.push(`Step 0 score ${String(r.before.total).padStart(3)}/100 (${r.before.grade}) Level ${r.before.level.n} ${r.before.level.name}`);
126
+ L.push(`After verdicts ${String(r.after.total).padStart(3)}/100 (${r.after.grade}) Level ${r.after.level.n} ${r.after.level.name}`);
127
+ L.push("");
128
+ L.push("Deductions after verdicts:");
129
+ if (!r.after.deductions.length)
130
+ L.push(" none");
131
+ for (const d of r.after.deductions)
132
+ L.push(` ${String(d.points).padStart(4)} ${d.what} (${d.evidence})`);
133
+ for (const f of r.after.floors)
134
+ L.push(` floor: ${f}`);
135
+ if (r.dropped.refuted.length)
136
+ L.push("", `Refuted, cost nothing: ${r.dropped.refuted.join(", ")}`);
137
+ if (r.dropped.on_record.length)
138
+ L.push(`On record, cost nothing: ${r.dropped.on_record.join(", ")}`);
139
+ if (r.unknown.length)
140
+ L.push(`Still unknown, still charged: ${r.unknown.join(", ")}`);
141
+ L.push("");
142
+ L.push(c.ok ? "verdicts: complete" : `verdicts: ${c.problems.length} problem(s) - the number above is not final until they are fixed`);
143
+ return L.join("\n");
144
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bigsteele/the-prospect",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "A prospector's read of your codebase and your market. Digs the ground you own: every dependency that does no work, every vendor you pay twice, every subsystem you built by hand where a rail now exists. Then surveys the territory: what your industry ships by API that your code still does the hard way. Every suggestion stands on three legs - a fact read from your code, a fact researched from your market with a source and a date, and the thing your product exists to do. Standalone: one npx, no other scan required.",
5
5
  "type": "module",
6
6
  "license": "UNLICENSED",