@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.
@@ -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
+ }
@@ -1,4 +1,4 @@
1
- import { runtimeCode } from "../walk.js";
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 = runtimeCode(repo.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
- for (const m of text.matchAll(/\bfrom\s+["']([^"'\n]+)["']|\brequire\(\s*["']([^"'\n]+)["']\s*\)/g)) {
75
- const spec = (m[1] ?? m[2]);
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 (const m of text.matchAll(/process\.env\.([A-Z][A-Z0-9_]{2,})|Deno\.env\.get\(\s*["']([A-Z][A-Z0-9_]{2,})["']\s*\)/g)) {
97
- const name = (m[1] ?? m[2]);
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
- overlaps.push({
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.1.1";
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 const VERSION = "0.1.1";
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
- body.push(l.trim());
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;
@@ -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
+ }