@bigsteele/the-prospect 0.1.1 → 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/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)
@@ -38,29 +42,123 @@ export function toMarkdown(p) {
38
42
  else {
39
43
  L.push(claims.slice(0, 2).join(", and ") + ".", "");
40
44
  }
41
- L.push(`## Today / After`, "");
42
- L.push(`| Today | After |`);
43
- L.push(`| --- | --- |`);
44
- if (noRef.length)
45
- L.push(`| ${noRef.length} packages installed, updated and audited that nothing imports | They are gone, and every install, update and security audit is smaller |`);
46
- if (p.overlaps.length) {
47
- const o = p.overlaps[0];
48
- L.push(`| ${prose(o.services)} ${o.services.length > 2 ? "all" : "both"} do ${label(o.category)} work | One vendor does it, one invoice, one integration to maintain |`);
45
+ // NEXT ACTIONS, NOT TODAY/AFTER (0.2). The Big Sean ends every finding with
46
+ // the workflow it protects, who does it, and how long; the Today/After table
47
+ // ended with "Suggestions arrive as opinions". A reader acts on the first
48
+ // shape and skims the second. Only what is NOT on record reaches this list.
49
+ const actions = [];
50
+ for (const d of noRef.filter((x) => !x.on_record).slice(0, 3)) {
51
+ actions.push({
52
+ what: `\`${d.name}\` is declared in \`${d.manifest}\` and nothing references it`,
53
+ touches: d.touches ?? "",
54
+ task: `grep the repository for its name outside \`${d.manifest}\`; if nothing loads it by name at runtime, remove it`,
55
+ retest: "install, build and run the test suite with it gone",
56
+ });
49
57
  }
50
- if (highHand.length) {
51
- const h = highHand[0];
52
- L.push(`| ${h.loc} lines of hand-rolled ${h.rail} code you maintain alone | A decision on record: keep it on purpose, or a rail carries it |`);
58
+ for (const o of paidTwice.filter((x) => !x.on_record)) {
59
+ actions.push({
60
+ what: `${prose(o.services)} both do ${label(o.category)} work`,
61
+ touches: "one bill and one failure surface per vendor",
62
+ task: `name which one carries this job, or record in DECISIONS.md why both stay`,
63
+ retest: "the next scan lists the pair under On record",
64
+ });
53
65
  }
54
- if (cuts.length) {
55
- const c = cuts[0];
56
- L.push(`| ${c.candidate} runs beside ${c.keep}, which already covers ${c.covers.split(" (")[0]} | One platform, one bill, one place a deploy can fail |`);
66
+ for (const h of highHand.filter((x) => !x.on_record)) {
67
+ actions.push({
68
+ what: `${h.loc} lines of hand-rolled ${h.rail} in \`${h.signal.file}\``,
69
+ touches: h.touches ?? "",
70
+ task: "decide once: keep it on purpose and record why, or let a rail carry it (Lane 2 names the rails)",
71
+ retest: "the decision appears in the record, or the subsystem is gone",
72
+ });
73
+ }
74
+ const bigAccidental = accidental.filter((d) => !d.parallel && !d.on_record).slice(0, 2);
75
+ for (const d of bigAccidental) {
76
+ actions.push({
77
+ what: `${d.lines} lines repeated across ${d.files.length} files, opening \`${d.opens_with.slice(0, 60)}\``,
78
+ touches: d.touches ?? "",
79
+ task: "lift to one place, or mark the copy deliberate in its header",
80
+ retest: "the cluster is gone from the next scan",
81
+ });
82
+ }
83
+ if (actions.length) {
84
+ L.push(`## Your next actions`, "");
85
+ L.push(`Only what no recorded decision explains. Each names the workflow it touches - by path, a heuristic - and how to know it is done.`, "");
86
+ actions.forEach((a, i) => {
87
+ L.push(`### ${i + 1}. ${a.what}`);
88
+ L.push(`**Touches.** ${a.touches}`);
89
+ L.push(`**Task.** ${a.task}`);
90
+ L.push(`**Retest.** ${a.retest}`, "");
91
+ });
92
+ }
93
+ // ON RECORD. What the scan found and the record already explains. Listed so
94
+ // the reader sees the tool looked, and charged nothing.
95
+ const onRecord = [];
96
+ for (const d of noRef.filter((x) => x.on_record))
97
+ onRecord.push({ what: `\`${d.name}\` shows no reference`, where: `${d.on_record.file}:${d.on_record.line} - ${d.on_record.excerpt}` });
98
+ for (const o of paidTwice.filter((x) => x.on_record))
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}` });
100
+ for (const c of cuts.filter((x) => x.on_record))
101
+ onRecord.push({ what: `${c.candidate} beside ${c.keep}`, where: `${c.on_record.file}:${c.on_record.line} - ${c.on_record.excerpt}` });
102
+ for (const h of highHand.filter((x) => x.on_record))
103
+ onRecord.push({ what: `hand-rolled ${h.rail}`, where: `${h.on_record.file}:${h.on_record.line} - ${h.on_record.excerpt}` });
104
+ for (const d of accidental.filter((x) => x.on_record))
105
+ onRecord.push({ what: `${d.lines} lines shared by ${d.files.map((f) => f.split("/").pop()).join(", ")}`, where: `${d.on_record.file}:${d.on_record.line} - ${d.on_record.excerpt}` });
106
+ const parallels = p.duplicates.filter((d) => d.parallel);
107
+ if (onRecord.length || parallels.length) {
108
+ L.push(`## On record`, "");
109
+ // Say what the record EXPLAINED, not what was read. An empty table under "already
110
+ // explained by CHECKPOINT-QUEUE.md" told the owner their notes covered the findings
111
+ // when they did not - and the gap between what a record holds and what the scan
112
+ // found is itself the useful fact here.
113
+ const explainedBy = [...new Set(onRecord.map((r) => r.where.split(":")[0]))];
114
+ L.push(onRecord.length
115
+ ? `Found, and already explained by ${explainedBy.slice(0, 3).map((f) => `\`${f}\``).join(", ")}. Nothing here costs points.`
116
+ : `${p.decisions.files.length} decision file(s) read, ${p.decisions.entries} lines. None explains a finding above: what the scan found is not yet on the record, which is the case for a line in DECISIONS.md rather than a fix.`, "");
117
+ if (onRecord.length) {
118
+ L.push(`| Finding | Where it is decided |`, `| --- | --- |`);
119
+ for (const r of onRecord)
120
+ L.push(`| ${r.what} | ${r.where.replace(/\|/g, "\\|")} |`);
121
+ L.push("");
122
+ }
123
+ if (parallels.length) {
124
+ const total = parallels.reduce((t, d) => t + d.lines * (d.files.length - 1), 0);
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.`, "");
126
+ }
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
+ }
138
+ // UNKNOWN. What the scan could not settle, stated rather than scored.
139
+ const unknowns = [];
140
+ if (p.north_star.confidence !== "high")
141
+ unknowns.push(`the North Star: ${p.north_star.note}`);
142
+ if (!p.decisions.files.length)
143
+ unknowns.push("whether any of the above is on purpose: no decision record was found (DECISIONS.md, ADRs, CLAUDE.md), so nothing could be marked on record");
144
+ for (const n of p.profile.not_applicable)
145
+ unknowns.push(`${n.question}: ${n.why}`);
146
+ if (unknowns.length) {
147
+ L.push(`## Unknown`, "");
148
+ for (const u of unknowns)
149
+ L.push(`- ${u}`);
150
+ L.push("");
57
151
  }
58
- if (p.dead.length)
59
- L.push(`| ${p.dead.length} files everyone reads, searches and ships but nothing runs | Deleted, with this report as the receipt |`);
60
- L.push(`| Suggestions arrive as opinions | Every suggestion stands on a fact in your code, a sourced fact from your market, and your North Star |`);
61
- L.push("");
62
152
  L.push(`## Score`, "");
63
153
  L.push(`**${p.score.total}/100 (${p.score.grade})** - Level ${p.score.level.n}: **${p.score.level.name}**. ${p.score.level.meaning}`, "");
154
+ // THE NUMBER NEVER TRAVELS ALONE (0.2). 96/100 on a six-file scraper and
155
+ // 96/100 on a four-thousand-file monorepo are not the same claim, and the
156
+ // report used to print them identically.
157
+ L.push(`_${p.profile.depth.why}_`, "");
158
+ if (p.score.not_asked.length) {
159
+ L.push(`**Not asked of this repository:** ${p.score.not_asked.join(", ")}. ` +
160
+ `Neither credited nor penalised - see What was read.`, "");
161
+ }
64
162
  for (const f of p.score.floors)
65
163
  L.push(`- ${f}`);
66
164
  if (p.score.floors.length)
@@ -72,6 +170,65 @@ export function toMarkdown(p) {
72
170
  L.push(`| ${d.what} | ${d.points} | ${d.evidence} |`);
73
171
  L.push("");
74
172
  }
173
+ // THE LEDGER GOES BEFORE THE FINDINGS (0.2). A reader deciding how much to
174
+ // trust a list of findings needs to know what was looked at to produce it, and
175
+ // the honest answer used to be unavailable: the scan read 473 of 2,077 files
176
+ // on the first repository it met and had no way to say so.
177
+ const c = p.coverage;
178
+ L.push(`## What was read`, "");
179
+ const pr = p.profile;
180
+ L.push(`A ${pr.languages[0] ?? "mixed"} repository` +
181
+ (pr.languages.length > 1 ? ` (also ${pr.languages.slice(1, 3).join(", ")})` : "") +
182
+ (pr.manifests.length ? `, declaring dependencies in ${pr.manifests.join(" and ")}` : `, with no dependency manifest found`) +
183
+ (pr.traits.length ? `. Shapes recognised: ${pr.traits.join(", ")}.` : "."), "");
184
+ if (pr.not_applicable.length) {
185
+ L.push(`Questions with no ground to stand on here:`, "");
186
+ for (const n of pr.not_applicable)
187
+ L.push(`- **${n.question}** - ${n.why}`);
188
+ L.push("");
189
+ }
190
+ L.push(`**${c.analysed} of ${c.walked} files analysed.** ` +
191
+ `${c.unreadable} unreadable (binaries and files over the size cap), ` +
192
+ `${c.walked - c.analysed - c.unreadable} excluded by a named rule, ` +
193
+ `${c.unaccounted} unaccounted for.`, "");
194
+ if (c.excluded.length) {
195
+ L.push(`| Excluded | Files | Why |`);
196
+ L.push(`| --- | --- | --- |`);
197
+ for (const e of c.excluded)
198
+ L.push(`| \`${e.rule}\` | ${e.files} | ${e.why} |`);
199
+ L.push("");
200
+ }
201
+ if (c.unclaimed.length) {
202
+ L.push(`Readable file classes no question claims. Some of these are right to ignore; ` +
203
+ `the list is here so the choice is visible rather than assumed.`, "");
204
+ for (const u of c.unclaimed) {
205
+ L.push(`- \`${u.ext}\` - ${u.files} files, none analysed (e.g. \`${u.examples[0] ?? ""}\`)`);
206
+ }
207
+ L.push("");
208
+ }
209
+ const db = p.database;
210
+ if (db.files > 0) {
211
+ L.push(`### The database`, "");
212
+ L.push(`${db.files} migration file(s): ${db.tables} table(s), ${db.policies} policy/policies, ` +
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. ` : "") +
217
+ (db.findings.length === 0
218
+ ? `Nothing below stood out.`
219
+ : `${db.findings.length} shape(s) worth a minute.`), "");
220
+ if (db.findings.length) {
221
+ L.push(`| Shape | Subject | Where |`);
222
+ L.push(`| --- | --- | --- |`);
223
+ for (const x of db.findings.slice(0, 15)) {
224
+ L.push(`| ${x.kind.replace(/_/g, " ")} | \`${x.subject}\` | \`${x.file}\` |`);
225
+ }
226
+ if (db.findings.length > 15)
227
+ L.push(`| ...and ${db.findings.length - 15} more | | in the JSON |`);
228
+ L.push("");
229
+ L.push(db.findings[0].note, "");
230
+ }
231
+ }
75
232
  L.push(`## North Star`, "");
76
233
  if (p.north_star.sentence) {
77
234
  L.push(`> ${p.north_star.sentence}`, "");
@@ -93,9 +250,9 @@ export function toMarkdown(p) {
93
250
  L.push("");
94
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.`, "");
95
252
  }
96
- if (p.overlaps.length) {
253
+ if (paidTwice.length) {
97
254
  L.push(`### Jobs paid for twice`, "");
98
- for (const o of p.overlaps) {
255
+ for (const o of paidTwice) {
99
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.`);
100
257
  }
101
258
  L.push("");
@@ -138,9 +295,11 @@ export function toMarkdown(p) {
138
295
  }
139
296
  if (p.dead.length) {
140
297
  L.push(`### Files no entrypoint reaches`, "");
141
- 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.` : ""), "");
142
301
  for (const d of p.dead.slice(0, 20))
143
- L.push(`- \`${d.file}\` (${d.loc} lines)`);
302
+ L.push(`- \`${d.file}\` (${d.loc} lines${d.scaffold ? ", scaffold" : ""})`);
144
303
  if (p.dead.length > 20)
145
304
  L.push(`- …and ${p.dead.length - 20} more in the JSON`);
146
305
  L.push("");
package/dist/score.d.ts CHANGED
@@ -28,6 +28,7 @@ export interface ProspectScore {
28
28
  evidence: string;
29
29
  }>;
30
30
  floors: string[];
31
+ not_asked: string[];
31
32
  }
32
33
  export declare function grade(total: number): string;
33
34
  export interface ScoreInput {
@@ -41,6 +42,8 @@ export interface ScoreInput {
41
42
  consolidations?: ConsolidationFact[];
42
43
  /** Total runtime files, for shares. */
43
44
  runtime_files: number;
45
+ /** Questions the repository could not answer; carried through, never scored. */
46
+ not_asked?: string[];
44
47
  /** True when a vendor SDK dependency itself has no reference found - paying for a thing never called. */
45
48
  unused_paid_service: string | null;
46
49
  }
package/dist/score.js CHANGED
@@ -29,30 +29,49 @@ const LEVELS = [
29
29
  { n: 4, name: "Sharp", meaning: "Nothing unaccounted for: every dependency referenced, every file reached, one vendor per job, and every hand-rolled subsystem is a recorded decision." },
30
30
  ];
31
31
  export function scoreProspect(input) {
32
+ // WHAT THE SCORE IS NOT (0.2). A number out of 100 invites the reading "how
33
+ // healthy is this repository", and across five repositories of different
34
+ // shapes it was measuring something closer to "how much is here". A six-file
35
+ // scraper with no manifest, no migrations and nothing duplicated scored 96,
36
+ // because every question it could not answer returned nothing found, and
37
+ // nothing found scored as clean.
38
+ //
39
+ // The score still measures only what was ASKED. What changes is that the
40
+ // report now carries the profile beside it, so 96 out of 100 on a thin
41
+ // reading cannot be mistaken for 96 on a thorough one - and `reading` says
42
+ // which it is, in the same breath as the number.
32
43
  const deductions = [];
33
44
  const ding = (what, points, evidence) => {
34
45
  if (points > 0)
35
46
  deductions.push({ what, points: Math.round(points * 10) / 10, evidence });
36
47
  };
37
48
  const runtimeDeps = input.deps.filter((d) => !d.dev);
38
- const noRef = runtimeDeps.filter((d) => d.no_reference_found);
49
+ // ON RECORD IS NOT A DEDUCTION (0.2). What the decision record explains was
50
+ // chosen and written down; charging for it is charging for the choice.
51
+ const noRef = runtimeDeps.filter((d) => d.no_reference_found && !d.on_record);
39
52
  // Unreferenced runtime dependencies: up to 20.
40
53
  ding("dependencies with no reference found", Math.min(20, noRef.length * 2), noRef.length ? `${noRef.length} of ${runtimeDeps.length} runtime dependencies` : "");
41
- // Files no entrypoint reaches: up to 20, by share of runtime files.
42
- const deadShare = input.runtime_files > 0 ? input.dead.length / input.runtime_files : 0;
43
- ding("files no entrypoint reaches", Math.min(20, Math.round(deadShare * 100)), input.dead.length ? `${input.dead.length} files (${Math.round(deadShare * 100)} in 100)` : "");
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.
56
+ const dead = input.dead.filter((d) => !d.on_record);
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` : ""})` : "");
44
61
  // Accidental duplicate clusters: up to 20. Deliberate copies cost nothing.
45
- const accidental = input.duplicates.filter((d) => !d.deliberate);
62
+ const accidental = input.duplicates.filter((d) => !d.deliberate && !d.parallel && !d.on_record);
46
63
  const dupLines = accidental.reduce((t, d) => t + d.lines * (d.files.length - 1), 0);
47
64
  ding("duplicated blocks not marked deliberate", Math.min(20, Math.round(dupLines / 40)), accidental.length ? `${accidental.length} clusters, ${dupLines} repeated lines` : "");
48
- // Vendor overlap: up to 20. Two vendors in one category is a doubled bill.
49
- ding("two or more vendors doing one job", Math.min(20, input.overlaps.length * 7), input.overlaps.length ? input.overlaps.map((o) => `${o.category}: ${o.services.join(" + ")}`).join("; ") : "");
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);
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("; ") : "");
50
69
  // Hand-rolled where a rail exists: up to 12, high-confidence only.
51
- const high = input.handrolled.filter((h) => h.confidence === "high");
70
+ const high = input.handrolled.filter((h) => h.confidence === "high" && !h.on_record);
52
71
  ding("hand-rolled subsystems with rails available", Math.min(12, high.length * 4), high.length ? high.map((h) => h.rail).join(", ") : "");
53
72
  // The stack cut: up to 12. A platform another platform already covers is
54
73
  // a bill and a failure surface, and nobody decided to have both.
55
- const consolidations = input.consolidations ?? [];
74
+ const consolidations = (input.consolidations ?? []).filter((c) => !c.on_record);
56
75
  ding("platforms another platform already covers", Math.min(12, consolidations.length * 4), consolidations.length ? consolidations.map((c) => `${c.candidate} (covered by ${c.keep})`).join("; ") : "");
57
76
  // Multiplying cost shapes: up to 8; per-row is the loud one.
58
77
  const perRow = input.costs.filter((c) => c.shape === "per-row").length;
@@ -89,5 +108,7 @@ export function scoreProspect(input) {
89
108
  level: LEVELS[n],
90
109
  deductions: deductions.sort((a, b) => b.points - a.points),
91
110
  floors,
111
+ /** Questions with no ground in this repository, so no points either way. */
112
+ not_asked: input.not_asked ?? [],
92
113
  };
93
114
  }
@@ -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
+ }