@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/detect/types.d.ts
CHANGED
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
*/
|
|
11
11
|
/** One declared dependency and every place it was actually seen. */
|
|
12
12
|
export interface DepFact {
|
|
13
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
14
|
+
id?: string;
|
|
13
15
|
name: string;
|
|
14
16
|
version: string;
|
|
15
17
|
/** Which manifest declared it, repository-relative. */
|
|
@@ -27,6 +29,16 @@ export interface DepFact {
|
|
|
27
29
|
required_by?: string;
|
|
28
30
|
/** True when no import, config mention, script, or package requiring it was found. */
|
|
29
31
|
no_reference_found: boolean;
|
|
32
|
+
/** A recorded decision that explains it, when one does. On record is not a deduction. */
|
|
33
|
+
on_record?: OnRecord;
|
|
34
|
+
/** Which workflow this touches, by path. A heuristic, named as one. */
|
|
35
|
+
touches?: string;
|
|
36
|
+
}
|
|
37
|
+
/** Where the decision record explains a finding. */
|
|
38
|
+
export interface OnRecord {
|
|
39
|
+
file: string;
|
|
40
|
+
line: number;
|
|
41
|
+
excerpt: string;
|
|
30
42
|
}
|
|
31
43
|
export type VendorCategory = "ai" | "email" | "sms" | "payments" | "database" | "auth" | "storage" | "crm" | "analytics" | "monitoring" | "render" | "search" | "queue" | "maps" | "calendar" | "hosting" | "ci" | "other";
|
|
32
44
|
/** One external service the code talks to, with the evidence. */
|
|
@@ -43,13 +55,27 @@ export interface VendorFact {
|
|
|
43
55
|
}
|
|
44
56
|
/** Two or more services doing the same category of work. */
|
|
45
57
|
export interface OverlapFact {
|
|
58
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
59
|
+
id?: string;
|
|
46
60
|
category: VendorCategory;
|
|
47
61
|
services: string[];
|
|
48
62
|
call_sites: number;
|
|
63
|
+
/** What each service was seen doing inside the category, when the code says. */
|
|
64
|
+
roles?: Record<string, string[]>;
|
|
65
|
+
/**
|
|
66
|
+
* Set when the code shows the services doing DIFFERENT jobs inside one
|
|
67
|
+
* category - Stripe processing on a merchant's own account beside Square
|
|
68
|
+
* billing the platform, Gemini writing text beside Replicate making images.
|
|
69
|
+
* A category is not a job. Listed, never deducted; the text is the reason.
|
|
70
|
+
*/
|
|
71
|
+
distinct?: string;
|
|
72
|
+
on_record?: OnRecord;
|
|
49
73
|
}
|
|
50
74
|
export type RailCategory = "pdf" | "rate-limiting" | "email-templating" | "auth-session" | "queue-scheduler" | "search" | "payments-logic" | "webhook-plumbing" | "parsing-ocr";
|
|
51
75
|
/** A subsystem built by hand where the market sells a rail. */
|
|
52
76
|
export interface HandrolledFact {
|
|
77
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
78
|
+
id?: string;
|
|
53
79
|
rail: RailCategory;
|
|
54
80
|
files: string[];
|
|
55
81
|
loc: number;
|
|
@@ -60,9 +86,13 @@ export interface HandrolledFact {
|
|
|
60
86
|
file: string;
|
|
61
87
|
line: string;
|
|
62
88
|
};
|
|
89
|
+
on_record?: OnRecord;
|
|
90
|
+
touches?: string;
|
|
63
91
|
}
|
|
64
92
|
/** A cluster of near-identical code living in more than one file. */
|
|
65
93
|
export interface DuplicateFact {
|
|
94
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
95
|
+
id?: string;
|
|
66
96
|
files: string[];
|
|
67
97
|
/** Lines in the repeated block, after normalization. */
|
|
68
98
|
lines: number;
|
|
@@ -70,16 +100,37 @@ export interface DuplicateFact {
|
|
|
70
100
|
deliberate: boolean;
|
|
71
101
|
/** First normalized line of the block, so a reader can find it. */
|
|
72
102
|
opens_with: string;
|
|
103
|
+
/**
|
|
104
|
+
* The files sit at the same relative path under sibling directories of an
|
|
105
|
+
* adapters/templates tree: parallel implementations for different stacks,
|
|
106
|
+
* which duplicate by design because a generated file cannot import from the
|
|
107
|
+
* generator. Reported, never deducted.
|
|
108
|
+
*/
|
|
109
|
+
parallel?: boolean;
|
|
110
|
+
on_record?: OnRecord;
|
|
111
|
+
touches?: string;
|
|
73
112
|
}
|
|
74
113
|
/** A runtime file no entrypoint reaches. */
|
|
75
114
|
export interface DeadFact {
|
|
115
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
116
|
+
id?: string;
|
|
76
117
|
file: string;
|
|
77
118
|
loc: number;
|
|
78
119
|
/** Why the detector believes nothing reaches it. */
|
|
79
120
|
note: string;
|
|
121
|
+
/**
|
|
122
|
+
* A UI-kit scaffold component (shadcn's `components.json` beside it) that no
|
|
123
|
+
* file imports. Never bundled, so it does not ship; still read and searched.
|
|
124
|
+
* Listed at a quarter of the weight.
|
|
125
|
+
*/
|
|
126
|
+
scaffold?: boolean;
|
|
127
|
+
on_record?: OnRecord;
|
|
128
|
+
touches?: string;
|
|
80
129
|
}
|
|
81
130
|
/** A call whose cost multiplies: per request, per row, or on a clock. */
|
|
82
131
|
export interface CostFact {
|
|
132
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
133
|
+
id?: string;
|
|
83
134
|
file: string;
|
|
84
135
|
shape: "per-request" | "per-row" | "per-schedule";
|
|
85
136
|
/** What is being called - a vendor host or an SDK call name. */
|
|
@@ -104,3 +155,28 @@ export interface NorthStar {
|
|
|
104
155
|
confidence: "high" | "low" | "unknown";
|
|
105
156
|
note: string;
|
|
106
157
|
}
|
|
158
|
+
/** A shape in the migrations worth a human minute. Never a verdict. */
|
|
159
|
+
export interface DatabaseFinding {
|
|
160
|
+
/** Stable id, so a verdict can name this finding: see verdicts.ts. */
|
|
161
|
+
id?: string;
|
|
162
|
+
kind: "table_without_rls" | "rls_not_forced" | "definer_without_check" | "policy_reaches_anon";
|
|
163
|
+
/** The table, function or policy, schema-qualified where it has one. */
|
|
164
|
+
subject: string;
|
|
165
|
+
file: string;
|
|
166
|
+
note: string;
|
|
167
|
+
}
|
|
168
|
+
/**
|
|
169
|
+
* What the migrations hold. The counts ride with the findings on purpose: an
|
|
170
|
+
* empty finding list and a scan that never opened a file must not read alike.
|
|
171
|
+
*/
|
|
172
|
+
export interface DatabaseReading {
|
|
173
|
+
files: number;
|
|
174
|
+
tables: number;
|
|
175
|
+
policies: number;
|
|
176
|
+
definer_functions: number;
|
|
177
|
+
/** Definer functions whose EXECUTE the migrations revoke from public, anon or authenticated. */
|
|
178
|
+
definer_execute_revoked: number;
|
|
179
|
+
/** A guard function the repository carries to check definer grants at runtime, when one is named. */
|
|
180
|
+
guard?: string;
|
|
181
|
+
findings: DatabaseFinding[];
|
|
182
|
+
}
|
package/dist/detect/vendors.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { scopeFor, isCode } from "../walk.js";
|
|
2
2
|
/** The map earns a row when a vendor is common enough that a founder would
|
|
3
3
|
* recognise the name. Everything else lands in "unrecognised outbound". */
|
|
4
4
|
const KNOWN = [
|
|
@@ -53,10 +53,47 @@ const KNOWN = [
|
|
|
53
53
|
{ service: "Cal.com", category: "calendar", hosts: /api\.cal\.com$/, env: /^CAL_/ },
|
|
54
54
|
{ service: "Calendly", category: "calendar", hosts: /api\.calendly\.com$/, env: /^CALENDLY_/ },
|
|
55
55
|
];
|
|
56
|
+
const ROLES = {
|
|
57
|
+
payments: [
|
|
58
|
+
{
|
|
59
|
+
role: "processes on a merchant's own account (Connect or OAuth markers)",
|
|
60
|
+
merchant: true,
|
|
61
|
+
re: /\/v1\/accounts\b|stripe[-_]account|on_behalf_of|transfer_data|application_fee|oauth2\/(token|authorize|revoke)|refresh_token|merchant_id|connected_account/i,
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
role: "bills the platform's own account (subscriptions, its own checkout)",
|
|
65
|
+
re: /\bsubscriptions?\b|\/v1\/checkout\/sessions|\/v2\/(checkout|subscriptions|invoices)|\binvoices?\b|price_id|\bplan_id\b/i,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
ai: [
|
|
69
|
+
{ role: "writes text", re: /generateContent|chat\/completions|\/v1\/messages\b|messages\.create|\/completions\b|\bembeddings\b|responses\.create/i },
|
|
70
|
+
{ role: "makes images", re: /\/predictions\b|images\.(generate|edit)|\/images\/generations|\bimagen\b|nano-banana|stable-diffusion|\bsdxl\b|\bflux[-/]|dall-e/i },
|
|
71
|
+
{ role: "makes speech or audio", re: /text-to-speech|\/v1\/audio\/|speech\.create|audio\.transcriptions/i },
|
|
72
|
+
],
|
|
73
|
+
};
|
|
74
|
+
/**
|
|
75
|
+
* A role belongs to a vendor only when it sits ON that vendor's call: the line
|
|
76
|
+
* that names the vendor, or the few lines under it that finish the same call.
|
|
77
|
+
* "Nearest vendor within forty lines" credited Gemini with making images because
|
|
78
|
+
* a `predictions:` type field sat thirty lines from its URL.
|
|
79
|
+
*/
|
|
80
|
+
const ROLE_REACH = 30; // lines under the vendor's line; the role regexes are endpoint-shaped, so this reach stays safe
|
|
81
|
+
/** Every KNOWN service a single line names, by host, env var or import. */
|
|
82
|
+
function vendorsOnLine(line, only) {
|
|
83
|
+
const out = [];
|
|
84
|
+
const host = /https?:\/\/([a-z0-9][a-z0-9.-]+\.[a-z]{2,})/i.exec(line)?.[1]?.toLowerCase();
|
|
85
|
+
const env = /([A-Z][A-Z0-9_]{2,})/.exec(line)?.[1];
|
|
86
|
+
const spec = /["']((?:npm:)?@?[a-z0-9][a-z0-9._/-]*)["']/i.exec(line)?.[1]?.replace(/^npm:/, "");
|
|
87
|
+
for (const k of only) {
|
|
88
|
+
if ((host && k.hosts?.test(host)) || (env && k.env?.test(env)) || (spec && k.sdks?.test(spec)))
|
|
89
|
+
out.push(k);
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
56
93
|
/** Hosts that are content, not services: fonts, CDNs of the app's own assets, social links. */
|
|
57
94
|
const NOISE_HOST = /(^|\.)(esm\.sh|cdn\.jsdelivr\.net|unpkg\.com|fonts\.(googleapis|gstatic)\.com|githubusercontent\.com|github\.com|linkedin\.com|x\.com|twitter\.com|instagram\.com|facebook\.com|youtube\.com|tiktok\.com|schema\.org|w3\.org|localhost)$/i;
|
|
58
95
|
export async function detectVendors(repo) {
|
|
59
|
-
const files =
|
|
96
|
+
const files = scopeFor(repo.files, "vendor-presence").filter(isCode);
|
|
60
97
|
const byService = new Map();
|
|
61
98
|
const touch = (k, kind, what, file) => {
|
|
62
99
|
const v = byService.get(k.service) ?? { service: k.service, category: k.category, evidence: [], call_sites: 0 };
|
|
@@ -71,8 +108,13 @@ export async function detectVendors(repo) {
|
|
|
71
108
|
const text = await repo.read(f);
|
|
72
109
|
if (!text)
|
|
73
110
|
continue;
|
|
74
|
-
|
|
75
|
-
|
|
111
|
+
// LANGUAGE-NEUTRAL IMPORTS (0.2). The vendor table is already neutral - an
|
|
112
|
+
// SDK name, a host, an env var - and only the extraction was JavaScript, so
|
|
113
|
+
// a Python service importing `stripe` read as using no payment vendor at
|
|
114
|
+
// all. Python `import x` / `from x import y`, Go's quoted import paths and
|
|
115
|
+
// Ruby's `require` all name the package the same way npm does.
|
|
116
|
+
for (const m of text.matchAll(/\bfrom\s+["']([^"'\n]+)["']|\brequire\(\s*["']([^"'\n]+)["']\s*\)|^\s*import\s+([a-z0-9_.]+)|^\s*from\s+([a-z0-9_.]+)\s+import\b|^\s*require\s+["']([^"'\n]+)["']/gim)) {
|
|
117
|
+
const spec = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5]);
|
|
76
118
|
if (spec.startsWith("."))
|
|
77
119
|
continue;
|
|
78
120
|
const pkg = spec.replace(/^npm:/, "").split("/").slice(0, spec.startsWith("@") || spec.startsWith("npm:@") ? 2 : 1).join("/");
|
|
@@ -93,8 +135,10 @@ export async function detectVendors(repo) {
|
|
|
93
135
|
unknownHosts.set(host, set);
|
|
94
136
|
}
|
|
95
137
|
}
|
|
96
|
-
for
|
|
97
|
-
|
|
138
|
+
// Same reasoning for environment variables: `os.environ["STRIPE_SECRET_KEY"]`
|
|
139
|
+
// and `os.getenv(...)` name a vendor exactly as `process.env.` does.
|
|
140
|
+
for (const m of text.matchAll(/process\.env\.([A-Z][A-Z0-9_]{2,})|Deno\.env\.get\(\s*["']([A-Z][A-Z0-9_]{2,})["']\s*\)|os\.(?:environ(?:\.get)?\(?\[?|getenv\()\s*["']([A-Z][A-Z0-9_]{2,})["']|ENV\[["']([A-Z][A-Z0-9_]{2,})["']\]|os\.Getenv\(\s*["']([A-Z][A-Z0-9_]{2,})["']/g)) {
|
|
141
|
+
const name = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5]);
|
|
98
142
|
for (const k of KNOWN)
|
|
99
143
|
if (k.env?.test(name))
|
|
100
144
|
touch(k, "env", name, f);
|
|
@@ -111,11 +155,67 @@ export async function detectVendors(repo) {
|
|
|
111
155
|
for (const [category, list] of byCategory) {
|
|
112
156
|
if (category === "other" || list.length < 2)
|
|
113
157
|
continue;
|
|
114
|
-
|
|
158
|
+
const o = {
|
|
115
159
|
category,
|
|
116
160
|
services: list.map((v) => v.service),
|
|
117
161
|
call_sites: list.reduce((t, v) => t + v.call_sites, 0),
|
|
118
|
-
}
|
|
162
|
+
};
|
|
163
|
+
const roleTable = ROLES[category];
|
|
164
|
+
if (roleTable) {
|
|
165
|
+
// Roles, read from the lines near each vendor's own markers.
|
|
166
|
+
const known = list.map((v) => KNOWN.find((k) => k.service === v.service));
|
|
167
|
+
const roles = new Map(); // service -> role -> where
|
|
168
|
+
const evidenceFiles = new Set(list.flatMap((v) => v.evidence.map((e) => e.file)));
|
|
169
|
+
for (const f of evidenceFiles) {
|
|
170
|
+
const text = await repo.read(f);
|
|
171
|
+
if (!text)
|
|
172
|
+
continue;
|
|
173
|
+
const lines = text.split("\n");
|
|
174
|
+
const marks = [];
|
|
175
|
+
lines.forEach((line, i) => {
|
|
176
|
+
for (const k of vendorsOnLine(line, known))
|
|
177
|
+
marks.push({ at: i, k });
|
|
178
|
+
});
|
|
179
|
+
if (!marks.length)
|
|
180
|
+
continue;
|
|
181
|
+
lines.forEach((line, i) => {
|
|
182
|
+
for (const r of roleTable) {
|
|
183
|
+
if (!r.re.test(line))
|
|
184
|
+
continue;
|
|
185
|
+
let best = null;
|
|
186
|
+
for (const m of marks) {
|
|
187
|
+
const d = i - m.at; // the vendor line itself, or lines under it
|
|
188
|
+
if (d >= 0 && d <= ROLE_REACH && (!best || d < best.d))
|
|
189
|
+
best = { d, k: m.k };
|
|
190
|
+
}
|
|
191
|
+
if (!best)
|
|
192
|
+
continue;
|
|
193
|
+
const mine = roles.get(best.k.service) ?? new Map();
|
|
194
|
+
if (!mine.has(r.role))
|
|
195
|
+
mine.set(r.role, `${f}:${i + 1}`);
|
|
196
|
+
roles.set(best.k.service, mine);
|
|
197
|
+
}
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
if (roles.size)
|
|
201
|
+
o.roles = Object.fromEntries([...roles].map(([s, m]) => [s, [...m.keys()]]));
|
|
202
|
+
const merchantRole = roleTable.find((r) => r.merchant);
|
|
203
|
+
const merchants = merchantRole ? o.services.filter((s) => roles.get(s)?.has(merchantRole.role)) : [];
|
|
204
|
+
if (merchants.length) {
|
|
205
|
+
const where = merchants.map((s) => `${s} (${roles.get(s).get(merchantRole.role)})`).join(" and ");
|
|
206
|
+
o.distinct = `${where} ${merchants.length > 1 ? "carry" : "carries"} Connect or OAuth markers: charging on a merchant's own account. A second processor beside that is a merchant's choice of where their sales land, not the platform paying twice.`;
|
|
207
|
+
}
|
|
208
|
+
else if (o.services.every((s) => roles.get(s)?.size)) {
|
|
209
|
+
const sets = o.services.map((s) => new Set(roles.get(s).keys()));
|
|
210
|
+
const disjoint = sets.every((a, i) => sets.every((b, j) => i === j || ![...a].some((r) => b.has(r))));
|
|
211
|
+
if (disjoint) {
|
|
212
|
+
o.distinct =
|
|
213
|
+
o.services.map((s) => `${s} ${[...roles.get(s).entries()].map(([r, w]) => `${r} (${w})`).join(" and ")}`).join("; ") +
|
|
214
|
+
". One category, two jobs.";
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
overlaps.push(o);
|
|
119
219
|
}
|
|
120
220
|
const unrecognised = [...unknownHosts.entries()]
|
|
121
221
|
.map(([host, set]) => ({ host, files: [...set].slice(0, 5) }))
|
package/dist/index.d.ts
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
|
+
import { type Coverage } from "./walk.js";
|
|
1
2
|
import { type StackReading } from "./detect/stack.js";
|
|
3
|
+
import { type RepoProfile } from "./profile.js";
|
|
4
|
+
import { type DecisionRecord } from "./decisions.js";
|
|
2
5
|
import { type ProspectScore } from "./score.js";
|
|
3
|
-
import type { CostFact, DeadFact, DepFact, DuplicateFact, Fingerprint, HandrolledFact, NorthStar, OverlapFact, VendorFact } from "./detect/types.js";
|
|
6
|
+
import type { CostFact, DeadFact, DepFact, DatabaseReading, DuplicateFact, Fingerprint, HandrolledFact, NorthStar, OverlapFact, VendorFact } from "./detect/types.js";
|
|
4
7
|
export { toMarkdown, secretShaped } from "./report.js";
|
|
5
8
|
export { checkReport } from "./check.js";
|
|
9
|
+
export { checkVerdicts, rescore, showMath, findingIds } from "./verdicts.js";
|
|
10
|
+
export type { Verdicts, VerdictRecord, EvidenceEntry, Verdict } from "./verdicts.js";
|
|
6
11
|
export type { ProspectScore } from "./score.js";
|
|
7
12
|
export interface Prospect {
|
|
8
13
|
format: "bigsteele-prospect/1";
|
|
@@ -24,6 +29,14 @@ export interface Prospect {
|
|
|
24
29
|
dead: DeadFact[];
|
|
25
30
|
cost_surfaces: CostFact[];
|
|
26
31
|
fingerprint: Fingerprint;
|
|
32
|
+
/** What the migrations hold, and the shapes in them worth a minute. */
|
|
33
|
+
database: DatabaseReading;
|
|
34
|
+
/** What kind of repository this is, and which questions it could answer. */
|
|
35
|
+
profile: RepoProfile;
|
|
36
|
+
/** The decision record that was read, so "on record" can be checked. */
|
|
37
|
+
decisions: DecisionRecord;
|
|
38
|
+
/** Every file walked, in exactly one bucket, each with the rule that put it there. */
|
|
39
|
+
coverage: Coverage;
|
|
27
40
|
score: ProspectScore;
|
|
28
41
|
totals: {
|
|
29
42
|
files: number;
|
|
@@ -32,5 +45,5 @@ export interface Prospect {
|
|
|
32
45
|
entrypoints: number;
|
|
33
46
|
};
|
|
34
47
|
}
|
|
35
|
-
export declare const VERSION = "0.
|
|
48
|
+
export declare const VERSION = "0.3.0";
|
|
36
49
|
export declare function runProspect(root: string): Promise<Prospect>;
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@
|
|
|
20
20
|
* run, or required.
|
|
21
21
|
*/
|
|
22
22
|
import { openRepo } from "./walk.js";
|
|
23
|
-
import { runtimeCode } from "./walk.js";
|
|
23
|
+
import { runtimeCode, coverageOf } from "./walk.js";
|
|
24
24
|
import { detectDeps } from "./detect/deps.js";
|
|
25
25
|
import { detectVendors } from "./detect/vendors.js";
|
|
26
26
|
import { detectHandrolled } from "./detect/handrolled.js";
|
|
@@ -29,11 +29,15 @@ import { detectDeadweight } from "./detect/deadweight.js";
|
|
|
29
29
|
import { detectCosts } from "./detect/costs.js";
|
|
30
30
|
import { detectFingerprint } from "./detect/fingerprint.js";
|
|
31
31
|
import { detectStack } from "./detect/stack.js";
|
|
32
|
+
import { detectDatabase } from "./detect/database.js";
|
|
32
33
|
import { readNorthStar } from "./northstar.js";
|
|
34
|
+
import { profileRepo } from "./profile.js";
|
|
35
|
+
import { Decisions, touches } from "./decisions.js";
|
|
33
36
|
import { scoreProspect } from "./score.js";
|
|
34
37
|
export { toMarkdown, secretShaped } from "./report.js";
|
|
35
38
|
export { checkReport } from "./check.js";
|
|
36
|
-
export
|
|
39
|
+
export { checkVerdicts, rescore, showMath, findingIds } from "./verdicts.js";
|
|
40
|
+
export const VERSION = "0.3.0";
|
|
37
41
|
export async function runProspect(root) {
|
|
38
42
|
const repo = await openRepo(root);
|
|
39
43
|
const runtime = runtimeCode(repo.files);
|
|
@@ -48,13 +52,91 @@ export async function runProspect(root) {
|
|
|
48
52
|
readNorthStar(repo),
|
|
49
53
|
detectStack(repo),
|
|
50
54
|
]);
|
|
55
|
+
const database = await detectDatabase(repo);
|
|
56
|
+
// READ WHAT WAS DECIDED BEFORE REPORTING WHAT WAS BUILT (0.2). Every Lane 1
|
|
57
|
+
// finding is checked against the record; one the record explains is marked
|
|
58
|
+
// on record with the citation, listed so the reader sees the tool looked, and
|
|
59
|
+
// not deducted. A dependency loaded by name, a copy inlined on purpose, a
|
|
60
|
+
// second vendor kept on purpose: each was reported as drift before this.
|
|
61
|
+
const record = await Decisions.read(repo);
|
|
62
|
+
const few = record.critical_few;
|
|
63
|
+
// A file is named to the record by its path fragment, never its bare stem:
|
|
64
|
+
// `session` matches half the record, `shell-templates/react/src/session` does not.
|
|
65
|
+
const frag = (f) => {
|
|
66
|
+
const parts = f.replace(/\.tmpl$/, "").split("/");
|
|
67
|
+
const stem = parts.pop().replace(/\.[^.]+$/, "");
|
|
68
|
+
const dir = parts.pop();
|
|
69
|
+
return dir ? [`${dir}/${stem}`, f] : [stem, f];
|
|
70
|
+
};
|
|
71
|
+
for (const d of deps) {
|
|
72
|
+
if (!d.no_reference_found)
|
|
73
|
+
continue;
|
|
74
|
+
d.on_record = record.explains([d.name]) ?? undefined;
|
|
75
|
+
d.touches = touches(d.manifest, few);
|
|
76
|
+
}
|
|
77
|
+
// A pair is decided by an entry naming BOTH sides: "Vercel's nameservers"
|
|
78
|
+
// alone is not a decision to run Vercel beside Cloudflare.
|
|
79
|
+
for (const o of vendorsReading.overlaps)
|
|
80
|
+
o.on_record = record.explains(o.services, { all: true }) ?? undefined;
|
|
81
|
+
for (const o of stack.overlaps)
|
|
82
|
+
o.on_record = record.explains(o.services, { all: true }) ?? undefined;
|
|
83
|
+
for (const c of stack.consolidations)
|
|
84
|
+
c.on_record = record.explains([c.candidate, c.keep], { all: true }) ?? undefined;
|
|
85
|
+
for (const h of handrolled) {
|
|
86
|
+
h.on_record = record.explains(h.files.flatMap(frag)) ?? undefined;
|
|
87
|
+
h.touches = touches(h.files[0] ?? "", few);
|
|
88
|
+
}
|
|
89
|
+
for (const d of duplicates) {
|
|
90
|
+
// Parallel adapters are explained structurally; the record is not consulted.
|
|
91
|
+
d.on_record = d.parallel ? undefined : record.explains(d.files.flatMap(frag)) ?? undefined;
|
|
92
|
+
d.touches = touches(d.files[0] ?? "", few);
|
|
93
|
+
}
|
|
94
|
+
for (const d of deadReading.dead) {
|
|
95
|
+
d.on_record = record.explains(frag(d.file)) ?? undefined;
|
|
96
|
+
d.touches = touches(d.file, few);
|
|
97
|
+
}
|
|
98
|
+
// EVERY FINDING GETS A NAME (0.3). The protocol has to rule on each one -
|
|
99
|
+
// confirmed, refuted, on record, unknown - and a verdict needs something to
|
|
100
|
+
// point at. Ids are stable across runs of the same repository.
|
|
101
|
+
for (const d of deps)
|
|
102
|
+
if (d.no_reference_found)
|
|
103
|
+
d.id = `dep:${d.name}`;
|
|
104
|
+
for (const o of vendorsReading.overlaps)
|
|
105
|
+
o.id = `overlap:${o.category}`;
|
|
106
|
+
for (const o of stack.overlaps)
|
|
107
|
+
o.id = `overlap:${o.category}`;
|
|
108
|
+
for (const h of handrolled)
|
|
109
|
+
h.id = `hand:${h.rail}`;
|
|
110
|
+
for (const c of stack.consolidations)
|
|
111
|
+
c.id = `cut:${c.candidate}`;
|
|
112
|
+
duplicates.forEach((d, i) => { if (!d.deliberate && !d.parallel)
|
|
113
|
+
d.id = `dup:${i + 1}:${d.files[0] ?? ""}`; });
|
|
114
|
+
for (const d of deadReading.dead)
|
|
115
|
+
d.id = `dead:${d.file}`;
|
|
116
|
+
for (const c of costs)
|
|
117
|
+
c.id = `cost:${c.file}:${c.line.split(":")[0]}`;
|
|
118
|
+
for (const f of database.findings)
|
|
119
|
+
f.id = `db:${f.kind}:${f.subject}`;
|
|
120
|
+
// Last, and after every detector, so the ledger describes the run that just
|
|
121
|
+
// happened rather than a plan for one.
|
|
122
|
+
const coverage = await coverageOf(repo);
|
|
51
123
|
// A vendor SDK that is DECLARED but never referenced is a bill with no
|
|
52
124
|
// work behind it - the score's hardest floor.
|
|
53
125
|
const vendorSdkNames = new Set(vendorsReading.vendors.flatMap((v) => v.evidence.filter((e) => e.kind === "sdk").map((e) => e.what)));
|
|
54
126
|
const unusedPaid = deps.find((d) => !d.dev && d.no_reference_found && /^(openai|stripe|twilio|resend|@sendgrid\/|@anthropic-ai\/|@clerk\/|algoliasearch|cloudinary)/.test(d.name))?.name ?? null;
|
|
55
127
|
void vendorSdkNames;
|
|
128
|
+
// The profile reads the repository's SHAPE, and it runs after the detectors
|
|
129
|
+
// because it needs to know what they found ground for: a question with no
|
|
130
|
+
// ground is recorded as not applicable rather than answered "nothing found",
|
|
131
|
+
// which is how a six-file scraper scored 96 out of 100.
|
|
132
|
+
const profile = profileRepo(repo, {
|
|
133
|
+
sqlFiles: database.files,
|
|
134
|
+
manifestDeps: deps.length,
|
|
135
|
+
analysed: coverage.analysed,
|
|
136
|
+
});
|
|
56
137
|
const allOverlaps = [...vendorsReading.overlaps, ...stack.overlaps];
|
|
57
138
|
const score = scoreProspect({
|
|
139
|
+
not_asked: profile.not_applicable.map((n) => n.question),
|
|
58
140
|
deps,
|
|
59
141
|
overlaps: allOverlaps,
|
|
60
142
|
consolidations: stack.consolidations,
|
|
@@ -82,6 +164,10 @@ export async function runProspect(root) {
|
|
|
82
164
|
dead: deadReading.dead,
|
|
83
165
|
cost_surfaces: costs,
|
|
84
166
|
fingerprint,
|
|
167
|
+
database,
|
|
168
|
+
profile,
|
|
169
|
+
decisions: record.toJSON(),
|
|
170
|
+
coverage,
|
|
85
171
|
score,
|
|
86
172
|
totals: {
|
|
87
173
|
files: repo.files.length,
|
package/dist/northstar.js
CHANGED
|
@@ -3,17 +3,30 @@
|
|
|
3
3
|
// this package's own lean fixture as the host's North Star, which is the
|
|
4
4
|
// exact self-contamination bug the family review caught in wiremap.
|
|
5
5
|
const UNKNOWN_NOTE = "No North Star found. The protocol derives one with the operator before any suggestion is written; without it, neither lane can tell overbuilt from essential.";
|
|
6
|
+
/**
|
|
7
|
+
* The FIRST PARAGRAPH under the heading, not the whole section. The section is
|
|
8
|
+
* the sentence plus everything written to explain it, and joining the lot
|
|
9
|
+
* printed "Read it from the README's fir" - the sentence, the provenance note,
|
|
10
|
+
* and a truncation mid-word. The sentence is the first paragraph; the rest is
|
|
11
|
+
* for a person.
|
|
12
|
+
*/
|
|
6
13
|
function section(md, heading) {
|
|
7
14
|
const lines = md.split("\n");
|
|
8
15
|
const at = lines.findIndex((l) => /^#{1,4}\s/.test(l) && heading.test(l));
|
|
9
16
|
if (at < 0)
|
|
10
17
|
return null;
|
|
11
18
|
const body = [];
|
|
19
|
+
let started = false;
|
|
12
20
|
for (const l of lines.slice(at + 1)) {
|
|
13
21
|
if (/^#{1,4}\s/.test(l))
|
|
14
22
|
break;
|
|
15
|
-
if (l.trim())
|
|
16
|
-
|
|
23
|
+
if (!l.trim()) {
|
|
24
|
+
if (started)
|
|
25
|
+
break;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
started = true;
|
|
29
|
+
body.push(l.trim());
|
|
17
30
|
}
|
|
18
31
|
return body.length ? body.join(" ") : null;
|
|
19
32
|
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What kind of repository is this, and which questions can honestly be asked of it.
|
|
3
|
+
*
|
|
4
|
+
* THE DEFECT THIS EXISTS FOR. The detectors encoded one repository's
|
|
5
|
+
* assumptions - npm manifests, Postgres migrations, a `caller-check:` marker
|
|
6
|
+
* convention - and then applied them everywhere. Run across five repositories of
|
|
7
|
+
* different shapes, coverage swung from 38% to 79% and a six-file scraper scored
|
|
8
|
+
* **96 out of 100**, because a question that could not be asked returned nothing
|
|
9
|
+
* found, and nothing found scored as healthy.
|
|
10
|
+
*
|
|
11
|
+
* That is the same failure the rest of this version is about, one level up. A
|
|
12
|
+
* check that reports the same whether it works or not is worthless; a SCAN that
|
|
13
|
+
* reports the same whether it looked or not is worse, because it carries a
|
|
14
|
+
* number. "No dependencies with no reference found" means one thing after
|
|
15
|
+
* reading a 68-package manifest and something else entirely when no manifest was
|
|
16
|
+
* found at all, and the report said it identically both ways.
|
|
17
|
+
*
|
|
18
|
+
* SO: profile first, then ask only what the ground supports, and say out loud
|
|
19
|
+
* which questions were not asked and why. A question skipped honestly costs
|
|
20
|
+
* nothing. A question skipped silently becomes a score.
|
|
21
|
+
*/
|
|
22
|
+
import type { Repo } from "./walk.js";
|
|
23
|
+
export interface NotApplicable {
|
|
24
|
+
question: string;
|
|
25
|
+
why: string;
|
|
26
|
+
}
|
|
27
|
+
export interface RepoProfile {
|
|
28
|
+
/** Languages by weight of code files, most first. */
|
|
29
|
+
languages: string[];
|
|
30
|
+
/** Dependency manifests found, by filename. */
|
|
31
|
+
manifests: string[];
|
|
32
|
+
/** Shapes recognised: monorepo, migrations, containers, workflows, and so on. */
|
|
33
|
+
traits: string[];
|
|
34
|
+
/** Questions the repository can actually answer. */
|
|
35
|
+
questions_asked: string[];
|
|
36
|
+
/** Questions with no ground to stand on, each with the reason. */
|
|
37
|
+
not_applicable: NotApplicable[];
|
|
38
|
+
/**
|
|
39
|
+
* How much of a reading this is. A six-file scraper examined completely and a
|
|
40
|
+
* 4,000-file monorepo examined completely are both "complete" and are not the
|
|
41
|
+
* same evidence, so the report says which it is holding.
|
|
42
|
+
*/
|
|
43
|
+
depth: {
|
|
44
|
+
code_files: number;
|
|
45
|
+
analysed_share: number;
|
|
46
|
+
/** thin | partial | substantial - the weight a reader should give the result. */
|
|
47
|
+
reading: "thin" | "partial" | "substantial";
|
|
48
|
+
why: string;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
export declare function profileRepo(repo: Repo, seen: {
|
|
52
|
+
sqlFiles: number;
|
|
53
|
+
manifestDeps: number;
|
|
54
|
+
analysed: number;
|
|
55
|
+
}): RepoProfile;
|
package/dist/profile.js
ADDED
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { baseName, isCode } from "./walk.js";
|
|
2
|
+
const MANIFESTS = [
|
|
3
|
+
{ file: /(^|\/)package\.json$/, lang: "javascript", name: "package.json" },
|
|
4
|
+
{ file: /(^|\/)requirements\.txt$/, lang: "python", name: "requirements.txt" },
|
|
5
|
+
{ file: /(^|\/)pyproject\.toml$/, lang: "python", name: "pyproject.toml" },
|
|
6
|
+
{ file: /(^|\/)Pipfile$/, lang: "python", name: "Pipfile" },
|
|
7
|
+
{ file: /(^|\/)go\.mod$/, lang: "go", name: "go.mod" },
|
|
8
|
+
{ file: /(^|\/)Cargo\.toml$/, lang: "rust", name: "Cargo.toml" },
|
|
9
|
+
{ file: /(^|\/)Gemfile$/, lang: "ruby", name: "Gemfile" },
|
|
10
|
+
{ file: /(^|\/)composer\.json$/, lang: "php", name: "composer.json" },
|
|
11
|
+
{ file: /(^|\/)pubspec\.yaml$/, lang: "dart", name: "pubspec.yaml" },
|
|
12
|
+
{ file: /(^|\/)(pom\.xml|build\.gradle(\.kts)?)$/, lang: "java", name: "pom.xml / build.gradle" },
|
|
13
|
+
];
|
|
14
|
+
const EXT_LANG = [
|
|
15
|
+
[/\.(ts|tsx|js|jsx|mjs|cjs)$/i, "javascript"],
|
|
16
|
+
[/\.py$/i, "python"],
|
|
17
|
+
[/\.go$/i, "go"],
|
|
18
|
+
[/\.rs$/i, "rust"],
|
|
19
|
+
[/\.rb$/i, "ruby"],
|
|
20
|
+
[/\.php$/i, "php"],
|
|
21
|
+
[/\.(java|kt)$/i, "java"],
|
|
22
|
+
[/\.swift$/i, "swift"],
|
|
23
|
+
[/\.(vue|svelte|astro)$/i, "javascript"],
|
|
24
|
+
];
|
|
25
|
+
export function profileRepo(repo, seen) {
|
|
26
|
+
const code = repo.files.filter(isCode);
|
|
27
|
+
const weight = new Map();
|
|
28
|
+
for (const f of code) {
|
|
29
|
+
for (const [re, lang] of EXT_LANG) {
|
|
30
|
+
if (re.test(baseName(f))) {
|
|
31
|
+
weight.set(lang, (weight.get(lang) ?? 0) + 1);
|
|
32
|
+
break;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
const languages = [...weight.entries()].sort((a, b) => b[1] - a[1]).map(([l]) => l);
|
|
37
|
+
const manifests = [];
|
|
38
|
+
for (const m of MANIFESTS) {
|
|
39
|
+
if (repo.files.some((f) => m.file.test(f) && !/node_modules/.test(f)))
|
|
40
|
+
manifests.push(m.name);
|
|
41
|
+
}
|
|
42
|
+
const traits = [];
|
|
43
|
+
if (repo.files.some((f) => /(^|\/)(packages|apps)\//.test(f)))
|
|
44
|
+
traits.push("monorepo");
|
|
45
|
+
if (seen.sqlFiles > 0)
|
|
46
|
+
traits.push("sql-migrations");
|
|
47
|
+
if (repo.files.some((f) => /(^|\/)Dockerfile$/.test(f)))
|
|
48
|
+
traits.push("containers");
|
|
49
|
+
if (repo.files.some((f) => /^\.github\/workflows\//.test(f)))
|
|
50
|
+
traits.push("ci-workflows");
|
|
51
|
+
if (repo.files.some((f) => /(^|\/)(terraform|\.tf)$|\.tf$/.test(f)))
|
|
52
|
+
traits.push("infra-as-code");
|
|
53
|
+
// A question is asked when the repository holds the thing it asks about.
|
|
54
|
+
// Anything else is recorded as not applicable, with the reason, and does not
|
|
55
|
+
// reach the score.
|
|
56
|
+
const asked = [];
|
|
57
|
+
const na = [];
|
|
58
|
+
const hasManifest = manifests.length > 0;
|
|
59
|
+
if (hasManifest && seen.manifestDeps > 0)
|
|
60
|
+
asked.push("dependencies");
|
|
61
|
+
else {
|
|
62
|
+
na.push({
|
|
63
|
+
question: "dependencies",
|
|
64
|
+
why: hasManifest
|
|
65
|
+
? "a manifest was found and declares no runtime dependencies, so there is nothing to find unreferenced"
|
|
66
|
+
: "no dependency manifest of any recognised kind, so 'a package nothing references' has no ground to stand on",
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
if (seen.sqlFiles > 0)
|
|
70
|
+
asked.push("database");
|
|
71
|
+
else {
|
|
72
|
+
na.push({
|
|
73
|
+
question: "database",
|
|
74
|
+
why: "no SQL migrations, so row-level security and definer functions cannot be read from this repository. The database may still exist and be managed elsewhere.",
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
if (code.length >= 12)
|
|
78
|
+
asked.push("duplication", "reachability");
|
|
79
|
+
else {
|
|
80
|
+
na.push({
|
|
81
|
+
question: "duplication and reachability",
|
|
82
|
+
why: `only ${code.length} code file(s): too few for a duplicate block or an unreached module to mean anything`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
if (code.length > 0)
|
|
86
|
+
asked.push("vendors", "implementation");
|
|
87
|
+
const share = repo.files.length ? seen.analysed / repo.files.length : 0;
|
|
88
|
+
const reading = code.length < 15 ? "thin" : code.length < 150 || share < 0.4 ? "partial" : "substantial";
|
|
89
|
+
return {
|
|
90
|
+
languages,
|
|
91
|
+
manifests,
|
|
92
|
+
traits,
|
|
93
|
+
questions_asked: asked,
|
|
94
|
+
not_applicable: na,
|
|
95
|
+
depth: {
|
|
96
|
+
code_files: code.length,
|
|
97
|
+
analysed_share: Math.round(share * 100) / 100,
|
|
98
|
+
reading,
|
|
99
|
+
why: reading === "thin"
|
|
100
|
+
? `${code.length} code files. A clean result here means there was little to examine, not that a large system was examined and found clean.`
|
|
101
|
+
: reading === "partial"
|
|
102
|
+
? `${code.length} code files, ${Math.round(share * 100)}% of the repository analysed. Enough to be worth reading, not enough to be exhaustive.`
|
|
103
|
+
: `${code.length} code files, ${Math.round(share * 100)}% of the repository analysed.`,
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|