@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/README.md +40 -17
- package/dist/check.js +3 -2
- package/dist/cli.js +116 -28
- package/dist/decisions.d.ts +93 -0
- package/dist/decisions.js +143 -0
- package/dist/detect/costs.js +50 -8
- package/dist/detect/database.d.ts +28 -0
- package/dist/detect/database.js +198 -0
- package/dist/detect/deadweight.js +60 -15
- package/dist/detect/deps.js +56 -0
- package/dist/detect/duplication.js +25 -2
- package/dist/detect/handrolled.js +84 -5
- package/dist/detect/stack.d.ts +8 -0
- package/dist/detect/stack.js +10 -2
- package/dist/detect/types.d.ts +76 -0
- package/dist/detect/vendors.js +108 -8
- package/dist/index.d.ts +15 -2
- package/dist/index.js +88 -2
- package/dist/northstar.js +15 -2
- package/dist/profile.d.ts +55 -0
- package/dist/profile.js +106 -0
- package/dist/report.js +183 -24
- package/dist/score.d.ts +3 -0
- package/dist/score.js +30 -9
- package/dist/verdicts.d.ts +80 -0
- package/dist/verdicts.js +144 -0
- package/dist/walk.d.ts +85 -1
- package/dist/walk.js +189 -5
- package/package.json +1 -1
- package/prompt/THE-PROSPECT.md +162 -118
package/README.md
CHANGED
|
@@ -48,32 +48,55 @@ Step 0 runs offline, reads everything, and writes two files into your repo:
|
|
|
48
48
|
- **Your industry fingerprint** - the domain vocabulary your own schema and
|
|
49
49
|
routes use, which is what the second half researches against.
|
|
50
50
|
|
|
51
|
-
|
|
51
|
+
Step 0 takes seconds, and it is a hypothesis machine: it reads shapes and
|
|
52
|
+
prints them, every one with an id. The read is the second command.
|
|
52
53
|
|
|
53
54
|
```
|
|
54
|
-
npx @bigsteele/the-prospect --run
|
|
55
|
+
npx @bigsteele/the-prospect --run # or --protocol, to paste THE-PROSPECT.md into any agent
|
|
55
56
|
```
|
|
56
57
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
58
|
+
This is a large read, not a quick check, and it says so before it starts.
|
|
59
|
+
Claude Code opens with the protocol and the agent, read-only: establishes
|
|
60
|
+
your North Star from the pricing page, the schema and the code before it
|
|
61
|
+
judges anything; takes every Step 0 finding to the code and rules on it,
|
|
62
|
+
CONFIRMED with evidence, REFUTED with the sentence that names the shape
|
|
63
|
+
that lied, ON_RECORD with the deciding line, or UNKNOWN with what would
|
|
64
|
+
settle it; walks the whole tree for the inventories the scan cannot finish
|
|
65
|
+
on its own - every vendor with the job it does and whose money it touches,
|
|
66
|
+
every subsystem built by hand and what keeping it buys, every dependency's
|
|
67
|
+
job, every unreached file's real status, every multiplying call with a
|
|
68
|
+
price where one is public, the database rulings against the grants, and
|
|
69
|
+
the shape of the bill; researches your market in five lanes with sources
|
|
70
|
+
and dates; ranks everything against the North Star; and writes
|
|
71
|
+
**The Prospect - <App Name>.md** at the repository root with the next ten
|
|
72
|
+
actions, the inventories in full, the evidence register, and the math.
|
|
73
|
+
|
|
74
|
+
Every suggestion keeps the fixed shape - *Since you* (a fact from your
|
|
75
|
+
code, file cited), *Have you considered* (always two options, or one vendor
|
|
76
|
+
against building it yourself), *Because* (the industry fact, with a source
|
|
77
|
+
URL and the year it was checked), *Your customer gets* (the benefit in the
|
|
78
|
+
customer's terms), *First test* (a one-week test needing nobody's
|
|
79
|
+
permission, and the observation that would kill the idea). A suggestion
|
|
80
|
+
missing any leg is cut, not softened. And:
|
|
69
81
|
|
|
70
82
|
```
|
|
71
83
|
npx @bigsteele/the-prospect --check
|
|
72
84
|
```
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
86
|
+
is the second opinion. It reads the deliverable and
|
|
87
|
+
`.planning/prospect/VERDICTS.json`, fails any finding without a ruling,
|
|
88
|
+
any ruling without evidence, any evidence the report does not show, any
|
|
89
|
+
unsourced or undated claim, any single-vendor pitch, any advice verb, and
|
|
90
|
+
recomputes the score from the verdicts with the math printed: a refuted
|
|
91
|
+
finding costs nothing, a recorded one costs nothing, an unknown one still
|
|
92
|
+
does. Exit 0 when it holds, 2 when it does not.
|
|
93
|
+
|
|
94
|
+
The scan's Lane 1 is calibrated on real repositories and the calibration
|
|
95
|
+
notes say what it got wrong and when it was fixed: a category is not a job
|
|
96
|
+
(Stripe running Connect beside Square billing the platform is two jobs),
|
|
97
|
+
one `.ilike()` is not a search engine, a socket is not a paid call, a
|
|
98
|
+
`revoke ... from anon, authenticated` in a migration is a caller check, and
|
|
99
|
+
a shadcn component nobody imports is scaffold, weighed a quarter.
|
|
77
100
|
|
|
78
101
|
## Standalone
|
|
79
102
|
|
package/dist/check.js
CHANGED
|
@@ -37,8 +37,9 @@ export function checkReport(md) {
|
|
|
37
37
|
findings.push({ where: "whole report", problem: "no [READ] badge - the repository half is missing or unmarked" });
|
|
38
38
|
}
|
|
39
39
|
// Lane 2: each suggestion block (### heading under the territory section).
|
|
40
|
-
|
|
41
|
-
const
|
|
40
|
+
// Step 0 titles the lane "what the territory holds"; the protocol's deliverable titles it "Lane 2, researched".
|
|
41
|
+
const lane2Heading = /^## .*(territory|lane 2).*$/im.exec(md)?.[0] ?? "";
|
|
42
|
+
const lane2 = md.split(/^## .*(?:territory|lane 2).*$/im)[1]?.split(/^## /m)[0] ?? "";
|
|
42
43
|
const ranProtocol = !/not yet run/i.test(lane2Heading + lane2);
|
|
43
44
|
if (ranProtocol && lane2.trim()) {
|
|
44
45
|
if (!/\[RESEARCHED\]/.test(lane2Heading + lane2)) {
|
package/dist/cli.js
CHANGED
|
@@ -17,7 +17,7 @@ import { mkdir, writeFile, readFile, appendFile } from "node:fs/promises";
|
|
|
17
17
|
import { dirname, join, resolve } from "node:path";
|
|
18
18
|
import { fileURLToPath } from "node:url";
|
|
19
19
|
import { spawn } from "node:child_process";
|
|
20
|
-
import { runProspect, toMarkdown, secretShaped, checkReport, VERSION } from "./index.js";
|
|
20
|
+
import { runProspect, toMarkdown, secretShaped, checkReport, checkVerdicts, rescore, showMath, VERSION } from "./index.js";
|
|
21
21
|
const log = (s = "") => process.stdout.write(s + "\n");
|
|
22
22
|
const args = process.argv.slice(2);
|
|
23
23
|
const has = (f) => args.includes(f);
|
|
@@ -27,15 +27,17 @@ const valueOf = (f) => {
|
|
|
27
27
|
};
|
|
28
28
|
const HELP = `The Prospect ${VERSION} - a prospector's read of your codebase and your market.
|
|
29
29
|
|
|
30
|
-
npx @bigsteele/the-prospect [dir] Step 0: scan and report (offline, read-only)
|
|
31
|
-
npx @bigsteele/the-prospect --run open Claude Code with the
|
|
32
|
-
npx @bigsteele/the-prospect --
|
|
30
|
+
npx @bigsteele/the-prospect [dir] Step 0: scan and report (offline, read-only, seconds)
|
|
31
|
+
npx @bigsteele/the-prospect --run open Claude Code with the protocol (the deep read: long)
|
|
32
|
+
npx @bigsteele/the-prospect --protocol drop THE-PROSPECT.md into the repo to paste into any agent
|
|
33
|
+
npx @bigsteele/the-prospect --check rule on a finished report: verdicts, evidence, legs, the math
|
|
33
34
|
|
|
34
|
-
--repo <dir>
|
|
35
|
-
--out <dir>
|
|
36
|
-
--report <file>
|
|
37
|
-
--
|
|
38
|
-
--
|
|
35
|
+
--repo <dir> the repository to read (default: here)
|
|
36
|
+
--out <dir> where the report goes (default: the repository root)
|
|
37
|
+
--report <file> the report --check should measure (default: the deliverable, else the newest scan)
|
|
38
|
+
--verdicts <file> the verdicts --check should re-score from (default: .planning/prospect/VERDICTS.json)
|
|
39
|
+
--stdout print the report (or, with --protocol, the protocol) instead of writing it
|
|
40
|
+
--yes skip the confirmation on --run
|
|
39
41
|
|
|
40
42
|
Exit 0 when it passes, 2 when it does not, 1 when it refuses.`;
|
|
41
43
|
const slug = (s) => s.toLowerCase().replace(/^@[^/]+\//, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 60) || "app";
|
|
@@ -47,46 +49,127 @@ async function main() {
|
|
|
47
49
|
const flagsWithValue = ["--repo", "--out", "--report"];
|
|
48
50
|
const positional = args.find((a) => !a.startsWith("-") && !flagsWithValue.includes(args[args.indexOf(a) - 1] ?? ""));
|
|
49
51
|
const repoDir = resolve(valueOf("--repo") ?? positional ?? process.cwd());
|
|
52
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
53
|
+
const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
|
|
54
|
+
if (has("--protocol")) {
|
|
55
|
+
// The protocol is a file, and the file is the product: drop it where any
|
|
56
|
+
// agent can read it, the way The Big Sean does.
|
|
57
|
+
const text = await readFile(protocol, "utf8");
|
|
58
|
+
if (has("--stdout")) {
|
|
59
|
+
log(text);
|
|
60
|
+
return 0;
|
|
61
|
+
}
|
|
62
|
+
await writeFile(join(repoDir, "THE-PROSPECT.md"), text, "utf8");
|
|
63
|
+
log(`Wrote THE-PROSPECT.md. Open Claude Code in this folder and paste the whole file in, or run --run.`);
|
|
64
|
+
return 0;
|
|
65
|
+
}
|
|
50
66
|
if (has("--check")) {
|
|
51
67
|
const file = valueOf("--report");
|
|
52
68
|
let path = file ? resolve(file) : "";
|
|
53
69
|
if (!path) {
|
|
54
|
-
// The newest
|
|
70
|
+
// The deliverable if the protocol wrote one, else the newest Step 0 report.
|
|
55
71
|
const { readdir } = await import("node:fs/promises");
|
|
56
|
-
const
|
|
57
|
-
|
|
72
|
+
const all = await readdir(repoDir);
|
|
73
|
+
const deliverable = all.filter((n) => /^The Prospect - .*\.md$/.test(n)).sort();
|
|
74
|
+
const step0 = all.filter((n) => /^the-prospect-.*\.md$/.test(n)).sort();
|
|
75
|
+
const pick = deliverable[deliverable.length - 1] ?? step0[step0.length - 1];
|
|
76
|
+
if (!pick) {
|
|
58
77
|
log("No report to check. Run the scan first, then the protocol, then this.");
|
|
59
78
|
return 2;
|
|
60
79
|
}
|
|
61
|
-
path = join(repoDir,
|
|
80
|
+
path = join(repoDir, pick);
|
|
62
81
|
}
|
|
63
82
|
const md = await readFile(path, "utf8");
|
|
64
83
|
const { pass, findings } = checkReport(md);
|
|
65
|
-
if (pass) {
|
|
66
|
-
log(`${path} holds the law: every suggestion stands on three legs, every claim carries a source.`);
|
|
67
|
-
return 0;
|
|
68
|
-
}
|
|
69
84
|
for (const f of findings)
|
|
70
85
|
log(`FAIL ${f.where}: ${f.problem}${f.line ? ` | ${f.line}` : ""}`);
|
|
86
|
+
// THE VERDICTS, WHEN THE PROTOCOL LEFT THEM (0.3). Every Step 0 finding
|
|
87
|
+
// ruled on, every ruling with evidence, and the score recomputed from the
|
|
88
|
+
// rulings with the math printed. Missing verdicts fail the check the moment
|
|
89
|
+
// a verdicts file exists at all: half a ruling is no ruling.
|
|
90
|
+
const verdictsPath = resolve(valueOf("--verdicts") ?? join(repoDir, ".planning", "prospect", "VERDICTS.json"));
|
|
91
|
+
let verdictProblems = 0;
|
|
92
|
+
let scanPath = "";
|
|
93
|
+
try {
|
|
94
|
+
const v = JSON.parse(await readFile(verdictsPath, "utf8"));
|
|
95
|
+
scanPath = v.scan ? resolve(repoDir, v.scan) : "";
|
|
96
|
+
if (!scanPath) {
|
|
97
|
+
const { readdir } = await import("node:fs/promises");
|
|
98
|
+
const names = (await readdir(repoDir)).filter((n) => /^the-prospect-.*\.json$/.test(n)).sort();
|
|
99
|
+
scanPath = names.length ? join(repoDir, names[names.length - 1]) : "";
|
|
100
|
+
}
|
|
101
|
+
if (!scanPath) {
|
|
102
|
+
log(`FAIL verdicts: no scan JSON to rule on (the-prospect-<app>.json)`);
|
|
103
|
+
verdictProblems++;
|
|
104
|
+
}
|
|
105
|
+
else {
|
|
106
|
+
const scan = JSON.parse(await readFile(scanPath, "utf8"));
|
|
107
|
+
const c = checkVerdicts(scan, v, md);
|
|
108
|
+
for (const p of c.problems)
|
|
109
|
+
log(`FAIL verdict: ${p}`);
|
|
110
|
+
verdictProblems = c.problems.length;
|
|
111
|
+
const r = rescore(scan, v);
|
|
112
|
+
log("");
|
|
113
|
+
log(showMath(scan, v, r, c));
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
catch (e) {
|
|
117
|
+
if (e.code !== "ENOENT") {
|
|
118
|
+
log(`FAIL verdicts: ${verdictsPath} could not be read: ${e instanceof Error ? e.message : String(e)}`);
|
|
119
|
+
verdictProblems++;
|
|
120
|
+
}
|
|
121
|
+
else if (/^The Prospect - /.test(path.split("/").pop() ?? "")) {
|
|
122
|
+
log(`FAIL verdicts: the protocol's deliverable exists but ${verdictsPath} does not; every finding needs a ruling`);
|
|
123
|
+
verdictProblems++;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (pass && verdictProblems === 0) {
|
|
127
|
+
log("");
|
|
128
|
+
log(`${path} holds the law: every finding ruled on with evidence, every suggestion on three legs, every claim sourced.`);
|
|
129
|
+
return 0;
|
|
130
|
+
}
|
|
71
131
|
log("");
|
|
72
|
-
log(`${findings.length} finding(s). A suggestion missing a leg is cut, not softened.`);
|
|
132
|
+
log(`${findings.length + verdictProblems} finding(s). A suggestion missing a leg is cut, not softened; a finding without a ruling is unchecked.`);
|
|
73
133
|
return 2;
|
|
74
134
|
}
|
|
75
135
|
if (has("--run")) {
|
|
76
|
-
const here = dirname(fileURLToPath(import.meta.url));
|
|
77
|
-
const protocol = resolve(here, "..", "prompt", "THE-PROSPECT.md");
|
|
78
136
|
if (!has("--yes")) {
|
|
79
137
|
log("");
|
|
80
|
-
log("
|
|
81
|
-
log("It
|
|
82
|
-
log("
|
|
138
|
+
log("BEFORE YOU RUN THIS, KNOW WHAT IT IS. This is a large scan, not a quick check.");
|
|
139
|
+
log("It opens Claude Code with the protocol: the agent reads everything in this repository it can");
|
|
140
|
+
log("reach, takes every Step 0 finding to the code and rules on it with evidence, inventories every");
|
|
141
|
+
log("vendor, subsystem and dependency, researches your market with sources and dates, and writes a");
|
|
142
|
+
log("long report. Expect a long session and a lot of tokens. It changes nothing: no edits, no commits,");
|
|
143
|
+
log("no deploys, no messages, no spending. Run the scan first so the facts exist. Ctrl-C to stop.");
|
|
83
144
|
log("");
|
|
84
145
|
}
|
|
85
|
-
const child = spawn("claude", [`Follow the protocol in ${protocol}. The repository is ${repoDir}.`], {
|
|
146
|
+
const child = spawn("claude", [`Follow the protocol in ${protocol}, every step, to the end. The repository is ${repoDir}.`], {
|
|
86
147
|
stdio: "inherit",
|
|
87
148
|
shell: false,
|
|
88
149
|
});
|
|
89
|
-
|
|
150
|
+
// THE SUBPROCESS IS A CONVENIENCE, NOT THE PROTOCOL (0.2). Spawning `claude`
|
|
151
|
+
// couples this package to whatever version is installed, and on 2026-09-13
|
|
152
|
+
// that failed outright: "Claude Code 2.1.22 does not support this model;
|
|
153
|
+
// version 2.1.251 or newer is required". A blank error and no research.
|
|
154
|
+
//
|
|
155
|
+
// The protocol is a file. Any agent that can read it and search the web can
|
|
156
|
+
// run it, so a failure here names the file rather than ending the road.
|
|
157
|
+
child.on("error", () => {
|
|
158
|
+
log("");
|
|
159
|
+
log("Could not start Claude Code. It may not be installed, or may be too old.");
|
|
160
|
+
log(`The protocol is a file, and nothing here depends on that binary:`);
|
|
161
|
+
log(` ${protocol}`);
|
|
162
|
+
log("Open it in any agent that can read your repository and search the web.");
|
|
163
|
+
});
|
|
164
|
+
return new Promise((res) => child.on("exit", (c) => {
|
|
165
|
+
if (c !== 0) {
|
|
166
|
+
log("");
|
|
167
|
+
log(`Research did not complete. The protocol is at ${protocol} and can be run by hand:`);
|
|
168
|
+
log(` read the-prospect-<app>.json, then follow its six steps.`);
|
|
169
|
+
log(`If Claude Code reported a version error, \`claude update\` and try again.`);
|
|
170
|
+
}
|
|
171
|
+
res(c ?? 0);
|
|
172
|
+
}));
|
|
90
173
|
}
|
|
91
174
|
const p = await runProspect(repoDir);
|
|
92
175
|
const leak = secretShaped(p);
|
|
@@ -119,11 +202,16 @@ async function main() {
|
|
|
119
202
|
log(`${p.score.total}/100 (${p.score.grade}) - Level ${p.score.level.n}: ${p.score.level.name}.`);
|
|
120
203
|
if (noRef)
|
|
121
204
|
log(`${noRef} of ${p.totals.runtime_deps} runtime dependencies show no reference anywhere.`);
|
|
122
|
-
|
|
123
|
-
|
|
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.`);
|
|
124
211
|
if (p.handrolled.length)
|
|
125
212
|
log(`${p.handrolled.length} subsystem(s) built by hand where the market sells a rail.`);
|
|
126
|
-
log(`
|
|
213
|
+
log(`This was the scan: seconds, shapes, every finding with an id. The read is the next command.`);
|
|
214
|
+
log(`Next: npx @bigsteele/the-prospect --run (the deep read: every finding ruled on, every vendor and subsystem inventoried, the market researched)`);
|
|
127
215
|
return 0;
|
|
128
216
|
}
|
|
129
217
|
main().then((code) => process.exit(code), (err) => {
|
|
@@ -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
|
+
}
|
package/dist/detect/costs.js
CHANGED
|
@@ -1,18 +1,60 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
1
|
+
import { scopeFor } from "../walk.js";
|
|
2
|
+
/**
|
|
3
|
+
* A PAID call, which is the whole point (0.2.1). `\.send\(` on its own matched
|
|
4
|
+
* `ws.send(...)` to a local Chrome DevTools socket in four build scripts, and
|
|
5
|
+
* the report billed them as four paid loops. A call is paid when it names a
|
|
6
|
+
* paid host, a paid SDK method, or a sender object that is plainly a mail or
|
|
7
|
+
* SMS client. A socket is not a vendor.
|
|
8
|
+
*/
|
|
9
|
+
const PAID_CALL = /\bfetch\(\s*[`"']https?:\/\/(api\.openai\.com|api\.anthropic\.com|generativelanguage\.googleapis\.com|api\.stripe\.com|api\.twilio\.com|api\.resend\.com|api\.sendgrid\.com|api\.cloudflare\.com|api\.replicate\.com|api\.elevenlabs\.io)|\.(messages|completions|chat|embeddings|images|predictions)\.create\(|generateContent|\b(sendEmail|sendMail|sendSms)\(|\b(resend|sendgrid|sgMail|postmark|mailgun|transporter|mailer|twilio|ses|sns)\.\w*[sS]end\w*\(|\.emails\.send\(/;
|
|
10
|
+
const LOOP_HEAD = /\bfor(\s+await)?\s*\(|\bwhile\s*\(|\.(map|forEach|flatMap)\(\s*(async\b|\()/;
|
|
4
11
|
const HANDLER = /(^|\/)(functions|api|routes?|handlers?)\//i;
|
|
5
12
|
const SCHEDULE = /\bsetInterval\s*\(|\bcron\b|schedule/i;
|
|
6
13
|
function hostOrCall(line) {
|
|
7
14
|
const host = /https?:\/\/([a-z0-9.-]+)/i.exec(line)?.[1];
|
|
8
15
|
if (host)
|
|
9
16
|
return host;
|
|
10
|
-
const call = /\.((?:messages|completions|chat|embeddings|images)\.create|generateContent|send
|
|
17
|
+
const call = /\.((?:messages|completions|chat|embeddings|images|predictions)\.create|generateContent|emails\.send|\w*[sS]end\w*)\(/.exec(line)?.[1];
|
|
11
18
|
return call ?? "external call";
|
|
12
19
|
}
|
|
20
|
+
const count = (s, re) => (s.match(re) ?? []).length;
|
|
21
|
+
/**
|
|
22
|
+
* INSIDE the loop, not below it (0.2.1). "A loop head within the twelve lines
|
|
23
|
+
* above" read `slots.map((s) => s.role).join(', ')` - a prompt being built - as
|
|
24
|
+
* the loop around the Gemini call three lines later, and reported the call as
|
|
25
|
+
* per-row twice on the first repository it met. A loop encloses the call only
|
|
26
|
+
* if the brackets it opened are still open where the call is made.
|
|
27
|
+
*/
|
|
28
|
+
function insideLoop(lines, at) {
|
|
29
|
+
for (let j = at - 1; j >= Math.max(0, at - 80); j--) {
|
|
30
|
+
if (!LOOP_HEAD.test(lines[j]))
|
|
31
|
+
continue;
|
|
32
|
+
// Walk forward from the loop head until the brackets it opened close. If
|
|
33
|
+
// they have not closed by the call, the call is inside. Stopping at the
|
|
34
|
+
// close means the call's OWN `fetch(` on the line above its URL is never
|
|
35
|
+
// mistaken for the loop still being open.
|
|
36
|
+
let depth = 0;
|
|
37
|
+
let opened = false;
|
|
38
|
+
let closed = false;
|
|
39
|
+
for (let k = j; k < at && !closed; k++) {
|
|
40
|
+
const opens = count(lines[k], /[{(]/g);
|
|
41
|
+
depth += opens - count(lines[k], /[})]/g);
|
|
42
|
+
// The head line opening and closing on the same line (`.map((x) => x.id).join(', ')`)
|
|
43
|
+
// is a loop that already closed; without this, the walk ran on past it and
|
|
44
|
+
// tripped over a prose parenthesis inside a prompt string forty lines later.
|
|
45
|
+
if ((k === j && opens > 0) || depth > 0)
|
|
46
|
+
opened = true;
|
|
47
|
+
if (opened && depth <= 0)
|
|
48
|
+
closed = true;
|
|
49
|
+
}
|
|
50
|
+
if (opened && !closed)
|
|
51
|
+
return true;
|
|
52
|
+
}
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
13
55
|
export async function detectCosts(repo) {
|
|
14
56
|
const out = [];
|
|
15
|
-
for (const f of
|
|
57
|
+
for (const f of scopeFor(repo.files, "implementation")) {
|
|
16
58
|
const text = await repo.read(f);
|
|
17
59
|
if (!text || !PAID_CALL.test(text))
|
|
18
60
|
continue;
|
|
@@ -23,13 +65,13 @@ export async function detectCosts(repo) {
|
|
|
23
65
|
continue;
|
|
24
66
|
const target = hostOrCall(line);
|
|
25
67
|
const evidence = `${i + 1}: ${line.trim().slice(0, 110)}`;
|
|
26
|
-
// per-row:
|
|
27
|
-
|
|
28
|
-
if (LOOP_HEAD.test(above)) {
|
|
68
|
+
// per-row: the call sits inside a loop that has not closed.
|
|
69
|
+
if (insideLoop(lines, i)) {
|
|
29
70
|
out.push({ file: f, shape: "per-row", target, line: evidence });
|
|
30
71
|
continue;
|
|
31
72
|
}
|
|
32
73
|
// per-schedule: the call sits under an interval or a cron marker.
|
|
74
|
+
const above = lines.slice(Math.max(0, i - 12), i).join("\n");
|
|
33
75
|
if (SCHEDULE.test(above) || (SCHEDULE.test(text.slice(0, 800)) && SCHEDULE.test(f))) {
|
|
34
76
|
out.push({ file: f, shape: "per-schedule", target, line: evidence });
|
|
35
77
|
continue;
|
|
@@ -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>;
|