@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/dist/walk.d.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
/** The path with any template suffix removed, so `x.ts.tmpl` reads as `x.ts`. */
|
|
2
|
+
export declare function baseName(rel: string): string;
|
|
1
3
|
export interface Repo {
|
|
2
4
|
root: string;
|
|
3
5
|
name: string;
|
|
@@ -29,8 +31,15 @@ export declare function grep(repo: Repo, files: string[], re: RegExp, limit?: nu
|
|
|
29
31
|
file: string;
|
|
30
32
|
count: number;
|
|
31
33
|
}>>;
|
|
32
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Code files only: what the detectors read for behaviour.
|
|
36
|
+
*
|
|
37
|
+
* `isCode` is the one to call - it sees through a template suffix, so the
|
|
38
|
+
* adapter templates the generator ships are code, which they are. `CODE` stays
|
|
39
|
+
* exported because callers match raw paths with it.
|
|
40
|
+
*/
|
|
33
41
|
export declare const CODE: RegExp;
|
|
42
|
+
export declare const isCode: (rel: string) => boolean;
|
|
34
43
|
/** Test files, which describe behaviour but do not run in production. */
|
|
35
44
|
export declare const TEST_FILE: RegExp;
|
|
36
45
|
/**
|
|
@@ -42,3 +51,78 @@ export declare const TEST_FILE: RegExp;
|
|
|
42
51
|
export declare const NOT_RUNTIME: RegExp;
|
|
43
52
|
/** Files the detectors read for behaviour: code, in the runtime tree, not tests. */
|
|
44
53
|
export declare function runtimeCode(files: string[]): string[];
|
|
54
|
+
/**
|
|
55
|
+
* EXCLUSION IS PER QUESTION, NOT GLOBAL (0.2).
|
|
56
|
+
*
|
|
57
|
+
* One list used to decide what every detector saw, and it conflated two
|
|
58
|
+
* different things: "this is not our stack" and "this is not worth looking at".
|
|
59
|
+
* A fixture of somebody else's codebase is genuinely not our vendor evidence.
|
|
60
|
+
* It is still our file, and it can still hold a duplicate block or a dead
|
|
61
|
+
* module. Excluding it from every question at once is how 1,604 of 2,077 files
|
|
62
|
+
* went unexamined while the report claimed to have read the repository.
|
|
63
|
+
*
|
|
64
|
+
* It is also how the Auth0 false positive survived four rounds of hardening:
|
|
65
|
+
* `runtimeCode` filtered test files and `grepAny` did not, because each call
|
|
66
|
+
* site re-derived its own scope. A scope is now named once and asked for by
|
|
67
|
+
* name, so a detector cannot forget a term.
|
|
68
|
+
*/
|
|
69
|
+
export type Question =
|
|
70
|
+
/** Which vendors does this product use? Someone else's code is not evidence. */
|
|
71
|
+
"vendor-presence"
|
|
72
|
+
/** What did we build by hand? Tests and fixtures describe, they do not implement. */
|
|
73
|
+
| "implementation"
|
|
74
|
+
/** What is duplicated? Fixtures and tests count: a copy is a copy. */
|
|
75
|
+
| "duplication"
|
|
76
|
+
/** What does no entrypoint reach? Everything shipped counts. */
|
|
77
|
+
| "reachability"
|
|
78
|
+
/** What does the database grant and to whom? Migrations, wherever they live. */
|
|
79
|
+
| "database"
|
|
80
|
+
/** What did the people who built this decide, and write down? */
|
|
81
|
+
| "decisions"
|
|
82
|
+
/** What vocabulary does this product speak? Someone else's domain is not ours. */
|
|
83
|
+
| "vocabulary";
|
|
84
|
+
/** The files one question considers. Ask by name; never re-derive a scope inline. */
|
|
85
|
+
export declare function scopeFor(files: string[], q: Question): string[];
|
|
86
|
+
export interface Coverage {
|
|
87
|
+
walked: number;
|
|
88
|
+
analysed: number;
|
|
89
|
+
unreadable: number;
|
|
90
|
+
unaccounted: number;
|
|
91
|
+
excluded: Array<{
|
|
92
|
+
rule: string;
|
|
93
|
+
why: string;
|
|
94
|
+
files: number;
|
|
95
|
+
examples: string[];
|
|
96
|
+
}>;
|
|
97
|
+
by_extension: Record<string, {
|
|
98
|
+
walked: number;
|
|
99
|
+
analysed: number;
|
|
100
|
+
}>;
|
|
101
|
+
questions: Record<string, number>;
|
|
102
|
+
/**
|
|
103
|
+
* Readable file classes no question claims, biggest first.
|
|
104
|
+
*
|
|
105
|
+
* The difference between "we decided not to look at this" and "nothing looks
|
|
106
|
+
* at this" is the whole point of the ledger, and only this field can show the
|
|
107
|
+
* second. On the first repository it reported 146 `.sql` files - a platform
|
|
108
|
+
* whose authorisation model lives in migrations, analysed by nothing - which
|
|
109
|
+
* no amount of reading the detector list would have revealed. `.md` and `.png`
|
|
110
|
+
* appear here too and are correctly ignored; the list is for a reader to
|
|
111
|
+
* judge, not a defect on its own.
|
|
112
|
+
*/
|
|
113
|
+
unclaimed: Array<{
|
|
114
|
+
ext: string;
|
|
115
|
+
files: number;
|
|
116
|
+
examples: string[];
|
|
117
|
+
}>;
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Every file walked lands in exactly one bucket, and the bucket names its rule.
|
|
121
|
+
*
|
|
122
|
+
* This is the claim "no stone unturned" actually rests on. Before it, the scan
|
|
123
|
+
* could not answer "what did you not look at" - which is the question the claim
|
|
124
|
+
* is asserting an answer to. A file is ANALYSED if any question admits it,
|
|
125
|
+
* UNREADABLE if the walker holds no text for it, and otherwise EXCLUDED by the
|
|
126
|
+
* first rule that dropped it, named.
|
|
127
|
+
*/
|
|
128
|
+
export declare function coverageOf(repo: Repo): Promise<Coverage>;
|
package/dist/walk.js
CHANGED
|
@@ -2,12 +2,29 @@
|
|
|
2
2
|
// deployable (family convention). The only place that touches the filesystem. Walks a repository, skips what is not the
|
|
3
3
|
// project's own code, reads text files under a size cap, and never opens an env file.
|
|
4
4
|
import { readdir, readFile, stat } from "node:fs/promises";
|
|
5
|
+
import { DECISION_FILES } from "./decisions.js";
|
|
5
6
|
import { join, relative, sep } from "node:path";
|
|
6
7
|
const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "out", ".next", ".nuxt", ".svelte-kit", ".vercel", ".turbo", "coverage", "vendor", "dist.bak", ".cache", "__pycache__", ".venv", "venv", "target", "test-results", "playwright-report", ".chrome-debug", ".claude-browser"]);
|
|
7
8
|
// Stylesheets are read too: `@import "tw-animate-css"` and Tailwind 4's `@plugin`
|
|
8
9
|
// are how a whole class of packages is used, and a walker that listed .css files
|
|
9
10
|
// but never read them flagged every one of those packages as unreferenced.
|
|
10
|
-
|
|
11
|
+
// SHIPPED ARTIFACTS ARE CODE (0.2, "no stone unturned"). The first real
|
|
12
|
+
// repository this met carried 137 `.tmpl` files - the adapter templates the
|
|
13
|
+
// generator writes into a customer's codebase, which is to say the product -
|
|
14
|
+
// and the walker never opened one. `.vue`, `.svelte` and `.html` were the same
|
|
15
|
+
// omission at smaller scale. A scan blind to what you ship cannot claim to have
|
|
16
|
+
// turned every stone, so a template suffix is stripped and the base extension
|
|
17
|
+
// decides: `session-route.ts.tmpl` is TypeScript.
|
|
18
|
+
const TEMPLATE_SUFFIX = /\.(tmpl|template|hbs|ejs|mustache|liquid|j2|jinja2?)$/i;
|
|
19
|
+
const TEXT_BASE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|php|java|kt|swift|sql|json|jsonc|ya?ml|toml|md|mdx|txt|prisma|graphql|gql|env\.example|sh|css|scss|sass|less|pcss|vue|svelte|astro|html|htm)$/i;
|
|
20
|
+
/** The path with any template suffix removed, so `x.ts.tmpl` reads as `x.ts`. */
|
|
21
|
+
export function baseName(rel) {
|
|
22
|
+
let out = rel;
|
|
23
|
+
while (TEMPLATE_SUFFIX.test(out))
|
|
24
|
+
out = out.replace(TEMPLATE_SUFFIX, "");
|
|
25
|
+
return out;
|
|
26
|
+
}
|
|
27
|
+
const TEXT = (rel) => TEXT_BASE.test(baseName(rel));
|
|
11
28
|
const ENV_FILE = /(^|\/)\.env(\.[a-z0-9_-]+)?$/i;
|
|
12
29
|
const MAX_BYTES = 512 * 1024;
|
|
13
30
|
const MAX_FILES = 25_000;
|
|
@@ -60,7 +77,7 @@ export async function openRepo(root) {
|
|
|
60
77
|
if (cache.has(rel))
|
|
61
78
|
return cache.get(rel);
|
|
62
79
|
let text = null;
|
|
63
|
-
if (TEXT
|
|
80
|
+
if (TEXT(rel) && !ENV_FILE.test(rel)) {
|
|
64
81
|
try {
|
|
65
82
|
const s = await stat(join(root, rel));
|
|
66
83
|
if (s.size <= MAX_BYTES)
|
|
@@ -106,8 +123,15 @@ export async function grep(repo, files, re, limit = 4000) {
|
|
|
106
123
|
}
|
|
107
124
|
return out;
|
|
108
125
|
}
|
|
109
|
-
/**
|
|
110
|
-
|
|
126
|
+
/**
|
|
127
|
+
* Code files only: what the detectors read for behaviour.
|
|
128
|
+
*
|
|
129
|
+
* `isCode` is the one to call - it sees through a template suffix, so the
|
|
130
|
+
* adapter templates the generator ships are code, which they are. `CODE` stays
|
|
131
|
+
* exported because callers match raw paths with it.
|
|
132
|
+
*/
|
|
133
|
+
export const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|php|java|kt|swift|vue|svelte|astro)$/i;
|
|
134
|
+
export const isCode = (rel) => CODE.test(baseName(rel));
|
|
111
135
|
/** Test files, which describe behaviour but do not run in production. */
|
|
112
136
|
export const TEST_FILE = /(^|\.|_|\/)(test|spec|e2e)s?(\.|\/)|(^|\/)__tests__\//i;
|
|
113
137
|
/**
|
|
@@ -119,5 +143,165 @@ export const TEST_FILE = /(^|\.|_|\/)(test|spec|e2e)s?(\.|\/)|(^|\/)__tests__\//
|
|
|
119
143
|
export const NOT_RUNTIME = /(^|\/)(docs?|fixtures?|__fixtures__|__mocks__|golden|examples?|samples?|packages\/the-prospect)\/|(^|\/)\.(?!well-known\/)[^/]+\//i;
|
|
120
144
|
/** Files the detectors read for behaviour: code, in the runtime tree, not tests. */
|
|
121
145
|
export function runtimeCode(files) {
|
|
122
|
-
return files.filter((f) =>
|
|
146
|
+
return files.filter((f) => isCode(f) && !TEST_FILE.test(f) && !NOT_RUNTIME.test(f));
|
|
147
|
+
}
|
|
148
|
+
const DROP_NOT_RUNTIME = {
|
|
149
|
+
rule: "not-runtime",
|
|
150
|
+
why: "documentation, fixtures, golden files and dotfile trees: present in the repository, not the running software",
|
|
151
|
+
test: (f) => NOT_RUNTIME.test(f),
|
|
152
|
+
};
|
|
153
|
+
/**
|
|
154
|
+
* A generated artifact: code this repository WRITES INTO somebody else's, rather
|
|
155
|
+
* than code it runs. Reading these was the point of 0.2 - 137 of them on the
|
|
156
|
+
* first real repository, the product itself, previously unopened. But a vendor
|
|
157
|
+
* named inside one is the CUSTOMER'S vendor, not this product's: an adapter
|
|
158
|
+
* directory called `next-app-clerk` exists to support Clerk, and reading it as
|
|
159
|
+
* "you pay Clerk" told an owner to drop a vendor they never had. Same mistake as
|
|
160
|
+
* the fixtures, one level further in, and found by fixing the fixtures.
|
|
161
|
+
*
|
|
162
|
+
* So: read for duplication and reachability, never for vendor presence.
|
|
163
|
+
*/
|
|
164
|
+
const DROP_GENERATED_OUT = {
|
|
165
|
+
rule: "shipped-template",
|
|
166
|
+
why: "a template this repository writes into a customer's codebase: its imports are the customer's stack, not this product's",
|
|
167
|
+
test: (f) => /\.(tmpl|template|hbs|ejs|mustache|liquid|j2|jinja2?)$/i.test(f) || /(^|\/)(adapters?|shell-templates?|generators?)\//i.test(f),
|
|
168
|
+
};
|
|
169
|
+
const DROP_TESTS = {
|
|
170
|
+
rule: "test-file",
|
|
171
|
+
why: "a test describes behaviour and often quotes somebody else's code verbatim; it is not this product doing the thing",
|
|
172
|
+
test: (f) => TEST_FILE.test(f),
|
|
173
|
+
};
|
|
174
|
+
const SCOPES = {
|
|
175
|
+
"vendor-presence": {
|
|
176
|
+
why: "only code this product runs can prove which vendors it pays for",
|
|
177
|
+
admits: (f) => isCode(f) || /\.(toml|jsonc?|ya?ml)$/i.test(baseName(f)),
|
|
178
|
+
drops: [DROP_NOT_RUNTIME, DROP_TESTS, DROP_GENERATED_OUT],
|
|
179
|
+
},
|
|
180
|
+
implementation: {
|
|
181
|
+
why: "only code this product runs can show what it built by hand",
|
|
182
|
+
admits: isCode,
|
|
183
|
+
drops: [DROP_NOT_RUNTIME, DROP_TESTS, DROP_GENERATED_OUT],
|
|
184
|
+
},
|
|
185
|
+
duplication: {
|
|
186
|
+
why: "a copied block is a copied block wherever it lives, so only generated output and vendored trees are dropped",
|
|
187
|
+
admits: isCode,
|
|
188
|
+
drops: [],
|
|
189
|
+
},
|
|
190
|
+
reachability: {
|
|
191
|
+
why: "everything the repository ships is walked; only the auditor's own package is dropped",
|
|
192
|
+
admits: isCode,
|
|
193
|
+
drops: [
|
|
194
|
+
{
|
|
195
|
+
rule: "auditor-self",
|
|
196
|
+
why: "this auditor's own source, when it is audited from inside the repository that holds it",
|
|
197
|
+
test: (f) => /(^|\/)packages\/the-prospect\//.test(f),
|
|
198
|
+
},
|
|
199
|
+
],
|
|
200
|
+
},
|
|
201
|
+
database: {
|
|
202
|
+
why: "the migrations that build THIS product's database, not a fixture of somebody else's",
|
|
203
|
+
admits: (f) => /\.sql$/i.test(baseName(f)),
|
|
204
|
+
// A fixture's migrations describe another product's database, and reporting
|
|
205
|
+
// `public.users has no row-level security` about a fixture of a customer's
|
|
206
|
+
// schema is the contamination this matrix exists to prevent - which it did,
|
|
207
|
+
// four findings out of five, on the detector's first real run.
|
|
208
|
+
drops: [DROP_GENERATED_OUT, DROP_NOT_RUNTIME],
|
|
209
|
+
},
|
|
210
|
+
decisions: {
|
|
211
|
+
why: "the files a person wrote to explain choices: DECISIONS, CLAUDE/AGENTS, ADRs, .planning, a prior audit. Read so a choice on record is not reported as drift",
|
|
212
|
+
admits: (f) => DECISION_FILES.test(f),
|
|
213
|
+
drops: [],
|
|
214
|
+
},
|
|
215
|
+
vocabulary: {
|
|
216
|
+
why: "the domain words of this product, not of a fixture describing another industry",
|
|
217
|
+
admits: isCode,
|
|
218
|
+
drops: [DROP_NOT_RUNTIME, DROP_TESTS, DROP_GENERATED_OUT],
|
|
219
|
+
},
|
|
220
|
+
};
|
|
221
|
+
/** The files one question considers. Ask by name; never re-derive a scope inline. */
|
|
222
|
+
export function scopeFor(files, q) {
|
|
223
|
+
const sc = SCOPES[q];
|
|
224
|
+
return files.filter((f) => sc.admits(f) && !sc.drops.some((d) => d.test(f)));
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* Every file walked lands in exactly one bucket, and the bucket names its rule.
|
|
228
|
+
*
|
|
229
|
+
* This is the claim "no stone unturned" actually rests on. Before it, the scan
|
|
230
|
+
* could not answer "what did you not look at" - which is the question the claim
|
|
231
|
+
* is asserting an answer to. A file is ANALYSED if any question admits it,
|
|
232
|
+
* UNREADABLE if the walker holds no text for it, and otherwise EXCLUDED by the
|
|
233
|
+
* first rule that dropped it, named.
|
|
234
|
+
*/
|
|
235
|
+
export async function coverageOf(repo) {
|
|
236
|
+
const questions = Object.keys(SCOPES);
|
|
237
|
+
const admitted = new Set();
|
|
238
|
+
const perQuestion = {};
|
|
239
|
+
for (const q of questions) {
|
|
240
|
+
const got = scopeFor(repo.files, q);
|
|
241
|
+
perQuestion[q] = got.length;
|
|
242
|
+
for (const f of got)
|
|
243
|
+
admitted.add(f);
|
|
244
|
+
}
|
|
245
|
+
const excluded = new Map();
|
|
246
|
+
const byExt = {};
|
|
247
|
+
let unreadable = 0;
|
|
248
|
+
let unaccounted = 0;
|
|
249
|
+
for (const f of repo.files) {
|
|
250
|
+
// Keyed on the extension AS WALKED, not the parsed one. The ledger answers
|
|
251
|
+
// "did you look at my 137 .tmpl files", and an owner counts them as .tmpl.
|
|
252
|
+
// `baseName` still decides HOW each is parsed; that is a separate question.
|
|
253
|
+
const ext = (f.match(/\.[^./]+$/)?.[0] ?? "(none)").toLowerCase();
|
|
254
|
+
byExt[ext] ??= { walked: 0, analysed: 0 };
|
|
255
|
+
byExt[ext].walked++;
|
|
256
|
+
if (admitted.has(f)) {
|
|
257
|
+
byExt[ext].analysed++;
|
|
258
|
+
continue;
|
|
259
|
+
}
|
|
260
|
+
if ((await repo.read(f)) === null) {
|
|
261
|
+
unreadable++;
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Readable, and no question wanted it. Name the rule that dropped it, taking
|
|
265
|
+
// the first that matches so the count and the reason always agree.
|
|
266
|
+
const all = questions.flatMap((q) => SCOPES[q].drops);
|
|
267
|
+
const hit = all.find((d) => d.test(f));
|
|
268
|
+
const rule = hit ? hit.rule : isCode(f) ? "no-question-admits" : "not-code";
|
|
269
|
+
const why = hit
|
|
270
|
+
? hit.why
|
|
271
|
+
: isCode(f)
|
|
272
|
+
? "code that every question's scope dropped; if this list is long, a scope is too narrow"
|
|
273
|
+
: "not a code file: read for references, never analysed for behaviour";
|
|
274
|
+
const row = excluded.get(rule) ?? { rule, why, files: 0, examples: [] };
|
|
275
|
+
row.files++;
|
|
276
|
+
if (row.examples.length < 3)
|
|
277
|
+
row.examples.push(f);
|
|
278
|
+
excluded.set(rule, row);
|
|
279
|
+
}
|
|
280
|
+
const bucketed = admitted.size + [...excluded.values()].reduce((t, e) => t + e.files, 0) + unreadable;
|
|
281
|
+
unaccounted = repo.files.length - bucketed;
|
|
282
|
+
// A readable class nothing analysed. Binaries are not readable, so they never
|
|
283
|
+
// reach this list; anything here is text the scan could have read and did not.
|
|
284
|
+
const unclaimed = [];
|
|
285
|
+
for (const [ext, v] of Object.entries(byExt)) {
|
|
286
|
+
if (v.analysed > 0 || v.walked < 3)
|
|
287
|
+
continue;
|
|
288
|
+
const examples = repo.files.filter((f) => f.toLowerCase().endsWith(ext)).slice(0, 3);
|
|
289
|
+
let readable = false;
|
|
290
|
+
for (const f of examples)
|
|
291
|
+
if ((await repo.read(f)) !== null)
|
|
292
|
+
readable = true;
|
|
293
|
+
if (readable)
|
|
294
|
+
unclaimed.push({ ext, files: v.walked, examples });
|
|
295
|
+
}
|
|
296
|
+
unclaimed.sort((a, b) => b.files - a.files);
|
|
297
|
+
return {
|
|
298
|
+
walked: repo.files.length,
|
|
299
|
+
analysed: admitted.size,
|
|
300
|
+
unreadable,
|
|
301
|
+
unaccounted,
|
|
302
|
+
excluded: [...excluded.values()].sort((a, b) => b.files - a.files),
|
|
303
|
+
by_extension: byExt,
|
|
304
|
+
questions: perQuestion,
|
|
305
|
+
unclaimed,
|
|
306
|
+
};
|
|
123
307
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bigsteele/the-prospect",
|
|
3
|
-
"version": "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",
|