@bigsteele/the-prospect 0.1.1 → 0.2.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/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,45 @@ 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
54
  // 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)` : "");
55
+ const dead = input.dead.filter((d) => !d.on_record);
56
+ const deadShare = input.runtime_files > 0 ? dead.length / input.runtime_files : 0;
57
+ ding("files no entrypoint reaches", Math.min(20, Math.round(deadShare * 100)), dead.length ? `${dead.length} files (${Math.round(deadShare * 100)} in 100)` : "");
44
58
  // Accidental duplicate clusters: up to 20. Deliberate copies cost nothing.
45
- const accidental = input.duplicates.filter((d) => !d.deliberate);
59
+ const accidental = input.duplicates.filter((d) => !d.deliberate && !d.parallel && !d.on_record);
46
60
  const dupLines = accidental.reduce((t, d) => t + d.lines * (d.files.length - 1), 0);
47
61
  ding("duplicated blocks not marked deliberate", Math.min(20, Math.round(dupLines / 40)), accidental.length ? `${accidental.length} clusters, ${dupLines} repeated lines` : "");
48
62
  // 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("; ") : "");
63
+ const overlaps = input.overlaps.filter((o) => !o.on_record);
64
+ 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
65
  // Hand-rolled where a rail exists: up to 12, high-confidence only.
51
- const high = input.handrolled.filter((h) => h.confidence === "high");
66
+ const high = input.handrolled.filter((h) => h.confidence === "high" && !h.on_record);
52
67
  ding("hand-rolled subsystems with rails available", Math.min(12, high.length * 4), high.length ? high.map((h) => h.rail).join(", ") : "");
53
68
  // The stack cut: up to 12. A platform another platform already covers is
54
69
  // a bill and a failure surface, and nobody decided to have both.
55
- const consolidations = input.consolidations ?? [];
70
+ const consolidations = (input.consolidations ?? []).filter((c) => !c.on_record);
56
71
  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
72
  // Multiplying cost shapes: up to 8; per-row is the loud one.
58
73
  const perRow = input.costs.filter((c) => c.shape === "per-row").length;
@@ -89,5 +104,7 @@ export function scoreProspect(input) {
89
104
  level: LEVELS[n],
90
105
  deductions: deductions.sort((a, b) => b.points - a.points),
91
106
  floors,
107
+ /** Questions with no ground in this repository, so no points either way. */
108
+ not_asked: input.not_asked ?? [],
92
109
  };
93
110
  }
@@ -0,0 +1,44 @@
1
+ export interface Repo {
2
+ root: string;
3
+ name: string;
4
+ /** Every file considered, repository-relative with forward slashes. */
5
+ files: string[];
6
+ /** Text of a file, or null when it is not text, too large, an env file, or unreadable. */
7
+ read(rel: string): Promise<string | null>;
8
+ /** Files whose path matches. */
9
+ matching(re: RegExp): string[];
10
+ /** Directories skipped because they are a repository of their own or a copy of this one. */
11
+ skipped: string[];
12
+ /**
13
+ * The manifest of an INSTALLED package, looked up the way Node resolves it: from
14
+ * the directory of the manifest that declares it, up to the root. Null when it is
15
+ * not installed. node_modules is never walked; this reads one file on request.
16
+ */
17
+ installed(fromManifest: string, pkg: string): Promise<InstalledManifest | null>;
18
+ }
19
+ export interface InstalledManifest {
20
+ bin?: string | Record<string, string>;
21
+ peerDependencies?: Record<string, string>;
22
+ peerDependenciesMeta?: Record<string, {
23
+ optional?: boolean;
24
+ }>;
25
+ }
26
+ export declare function openRepo(root: string): Promise<Repo>;
27
+ /** Files whose text matches; each hit carries the count of matches. Reads at most `limit` files. */
28
+ export declare function grep(repo: Repo, files: string[], re: RegExp, limit?: number): Promise<Array<{
29
+ file: string;
30
+ count: number;
31
+ }>>;
32
+ /** Code files only: what the detectors read for behaviour. */
33
+ export declare const CODE: RegExp;
34
+ /** Test files, which describe behaviour but do not run in production. */
35
+ export declare const TEST_FILE: RegExp;
36
+ /**
37
+ * Not the running software: documentation trees, fixtures, golden files, and this
38
+ * auditor's own package when it is audited from inside the repository that holds it.
39
+ * The first run counted the auditor's detector source as an MCP server, a Bedrock
40
+ * integration and a human gate in the host repository.
41
+ */
42
+ export declare const NOT_RUNTIME: RegExp;
43
+ /** Files the detectors read for behaviour: code, in the runtime tree, not tests. */
44
+ export declare function runtimeCode(files: string[]): string[];
@@ -0,0 +1,123 @@
1
+ // Copied from ai-audit/src/walk.ts, never imported, so the package stays independently
2
+ // deployable (family convention). The only place that touches the filesystem. Walks a repository, skips what is not the
3
+ // project's own code, reads text files under a size cap, and never opens an env file.
4
+ import { readdir, readFile, stat } from "node:fs/promises";
5
+ import { join, relative, sep } from "node:path";
6
+ 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
+ // Stylesheets are read too: `@import "tw-animate-css"` and Tailwind 4's `@plugin`
8
+ // are how a whole class of packages is used, and a walker that listed .css files
9
+ // 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
+ const ENV_FILE = /(^|\/)\.env(\.[a-z0-9_-]+)?$/i;
12
+ const MAX_BYTES = 512 * 1024;
13
+ const MAX_FILES = 25_000;
14
+ export async function openRepo(root) {
15
+ const files = [];
16
+ const skipped = [];
17
+ const rootName = root.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? "";
18
+ const walk = async (dir, depth) => {
19
+ let entries;
20
+ try {
21
+ entries = await readdir(dir, { withFileTypes: true });
22
+ }
23
+ catch {
24
+ return;
25
+ }
26
+ const names = new Set(entries.map((e) => e.name));
27
+ // A directory below the root that is a repository of its own (its own .git), or a
28
+ // copy of this one (named like the root, with its own manifest), is not this
29
+ // software: Brokrr's audit counted a nested Brokrr/ twice and cited both.
30
+ if (depth > 0 && (names.has(".git") || (dir.split(/[\\/]/).pop() === rootName && names.has("package.json")))) {
31
+ skipped.push(relative(root, dir).split(sep).join("/"));
32
+ return;
33
+ }
34
+ for (const e of entries.sort((a, b) => a.name.localeCompare(b.name))) {
35
+ if (files.length >= MAX_FILES)
36
+ return;
37
+ const abs = join(dir, e.name);
38
+ if (e.isSymbolicLink())
39
+ continue;
40
+ if (e.isDirectory()) {
41
+ // dist-demo, build_old, out-web: build output under any suffix.
42
+ if (SKIP_DIRS.has(e.name) || /^(dist|build|out)([-_.][a-z0-9-]*)?$/i.test(e.name))
43
+ continue;
44
+ await walk(abs, depth + 1);
45
+ }
46
+ else if (e.isFile()) {
47
+ files.push(relative(root, abs).split(sep).join("/"));
48
+ }
49
+ }
50
+ };
51
+ await walk(root, 0);
52
+ const cache = new Map();
53
+ const name = root.replace(/[\\/]+$/, "").split(/[\\/]/).pop() ?? root;
54
+ return {
55
+ root,
56
+ name,
57
+ files,
58
+ skipped,
59
+ async read(rel) {
60
+ if (cache.has(rel))
61
+ return cache.get(rel);
62
+ let text = null;
63
+ if (TEXT.test(rel) && !ENV_FILE.test(rel)) {
64
+ try {
65
+ const s = await stat(join(root, rel));
66
+ if (s.size <= MAX_BYTES)
67
+ text = await readFile(join(root, rel), "utf8");
68
+ }
69
+ catch {
70
+ text = null;
71
+ }
72
+ }
73
+ cache.set(rel, text);
74
+ return text;
75
+ },
76
+ matching(re) {
77
+ return files.filter((f) => re.test(f));
78
+ },
79
+ async installed(fromManifest, pkg) {
80
+ const parts = fromManifest.split("/").slice(0, -1);
81
+ for (let i = parts.length; i >= 0; i--) {
82
+ const abs = join(root, ...parts.slice(0, i), "node_modules", ...pkg.split("/"), "package.json");
83
+ try {
84
+ return JSON.parse(await readFile(abs, "utf8"));
85
+ }
86
+ catch {
87
+ // not installed at this level
88
+ }
89
+ }
90
+ return null;
91
+ },
92
+ };
93
+ }
94
+ /** Files whose text matches; each hit carries the count of matches. Reads at most `limit` files. */
95
+ export async function grep(repo, files, re, limit = 4000) {
96
+ const out = [];
97
+ const flags = re.flags.includes("g") ? re.flags : re.flags + "g";
98
+ const global = new RegExp(re.source, flags);
99
+ for (const f of files.slice(0, limit)) {
100
+ const text = await repo.read(f);
101
+ if (!text)
102
+ continue;
103
+ const count = (text.match(global) ?? []).length;
104
+ if (count > 0)
105
+ out.push({ file: f, count });
106
+ }
107
+ return out;
108
+ }
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;
111
+ /** Test files, which describe behaviour but do not run in production. */
112
+ export const TEST_FILE = /(^|\.|_|\/)(test|spec|e2e)s?(\.|\/)|(^|\/)__tests__\//i;
113
+ /**
114
+ * Not the running software: documentation trees, fixtures, golden files, and this
115
+ * auditor's own package when it is audited from inside the repository that holds it.
116
+ * The first run counted the auditor's detector source as an MCP server, a Bedrock
117
+ * integration and a human gate in the host repository.
118
+ */
119
+ export const NOT_RUNTIME = /(^|\/)(docs?|fixtures?|__fixtures__|__mocks__|golden|examples?|samples?|packages\/the-prospect)\/|(^|\/)\.(?!well-known\/)[^/]+\//i;
120
+ /** Files the detectors read for behaviour: code, in the runtime tree, not tests. */
121
+ export function runtimeCode(files) {
122
+ return files.filter((f) => CODE.test(f) && !TEST_FILE.test(f) && !NOT_RUNTIME.test(f));
123
+ }
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.2.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",