@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/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
- /** Code files only: what the detectors read for behaviour. */
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
- const TEXT = /\.(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)$/i;
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.test(rel) && !ENV_FILE.test(rel)) {
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
- /** Code files only: what the detectors read for behaviour. */
110
- export const CODE = /\.(ts|tsx|js|jsx|mjs|cjs|py|go|rs|rb|php|java|kt|swift)$/i;
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) => CODE.test(f) && !TEST_FILE.test(f) && !NOT_RUNTIME.test(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.1.1",
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",