@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/cli.js +23 -1
- package/dist/decisions.d.ts +93 -0
- package/dist/decisions.js +143 -0
- package/dist/detect/costs.js +2 -2
- package/dist/detect/database.d.ts +28 -0
- package/dist/detect/database.js +173 -0
- package/dist/detect/deps 2.d.ts +25 -0
- package/dist/detect/deps 2.js +198 -0
- package/dist/detect/deps.js +56 -0
- package/dist/detect/duplication.js +25 -2
- package/dist/detect/handrolled.js +73 -3
- package/dist/detect/stack.d.ts +6 -0
- package/dist/detect/stack.js +10 -2
- package/dist/detect/types 2.d.ts +106 -0
- package/dist/detect/types 2.js +11 -0
- package/dist/detect/types.d.ts +43 -0
- package/dist/detect/vendors.js +13 -6
- package/dist/index.d.ts +13 -2
- package/dist/index.js +65 -2
- package/dist/northstar.js +15 -2
- package/dist/profile.d.ts +55 -0
- package/dist/profile.js +106 -0
- package/dist/report.js +158 -18
- package/dist/score.d.ts +3 -0
- package/dist/score.js +24 -7
- package/dist/walk 2.d.ts +44 -0
- package/dist/walk 2.js +123 -0
- package/dist/walk.d.ts +85 -1
- package/dist/walk.js +189 -5
- package/package.json +1 -1
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
|
-
|
|
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
|
|
43
|
-
|
|
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
|
-
|
|
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
|
}
|
package/dist/walk 2.d.ts
ADDED
|
@@ -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[];
|
package/dist/walk 2.js
ADDED
|
@@ -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
|
-
/**
|
|
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.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",
|