@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.
@@ -27,6 +27,16 @@ export interface DepFact {
27
27
  required_by?: string;
28
28
  /** True when no import, config mention, script, or package requiring it was found. */
29
29
  no_reference_found: boolean;
30
+ /** A recorded decision that explains it, when one does. On record is not a deduction. */
31
+ on_record?: OnRecord;
32
+ /** Which workflow this touches, by path. A heuristic, named as one. */
33
+ touches?: string;
34
+ }
35
+ /** Where the decision record explains a finding. */
36
+ export interface OnRecord {
37
+ file: string;
38
+ line: number;
39
+ excerpt: string;
30
40
  }
31
41
  export type VendorCategory = "ai" | "email" | "sms" | "payments" | "database" | "auth" | "storage" | "crm" | "analytics" | "monitoring" | "render" | "search" | "queue" | "maps" | "calendar" | "hosting" | "ci" | "other";
32
42
  /** One external service the code talks to, with the evidence. */
@@ -46,6 +56,7 @@ export interface OverlapFact {
46
56
  category: VendorCategory;
47
57
  services: string[];
48
58
  call_sites: number;
59
+ on_record?: OnRecord;
49
60
  }
50
61
  export type RailCategory = "pdf" | "rate-limiting" | "email-templating" | "auth-session" | "queue-scheduler" | "search" | "payments-logic" | "webhook-plumbing" | "parsing-ocr";
51
62
  /** A subsystem built by hand where the market sells a rail. */
@@ -60,6 +71,8 @@ export interface HandrolledFact {
60
71
  file: string;
61
72
  line: string;
62
73
  };
74
+ on_record?: OnRecord;
75
+ touches?: string;
63
76
  }
64
77
  /** A cluster of near-identical code living in more than one file. */
65
78
  export interface DuplicateFact {
@@ -70,6 +83,15 @@ export interface DuplicateFact {
70
83
  deliberate: boolean;
71
84
  /** First normalized line of the block, so a reader can find it. */
72
85
  opens_with: string;
86
+ /**
87
+ * The files sit at the same relative path under sibling directories of an
88
+ * adapters/templates tree: parallel implementations for different stacks,
89
+ * which duplicate by design because a generated file cannot import from the
90
+ * generator. Reported, never deducted.
91
+ */
92
+ parallel?: boolean;
93
+ on_record?: OnRecord;
94
+ touches?: string;
73
95
  }
74
96
  /** A runtime file no entrypoint reaches. */
75
97
  export interface DeadFact {
@@ -77,6 +99,8 @@ export interface DeadFact {
77
99
  loc: number;
78
100
  /** Why the detector believes nothing reaches it. */
79
101
  note: string;
102
+ on_record?: OnRecord;
103
+ touches?: string;
80
104
  }
81
105
  /** A call whose cost multiplies: per request, per row, or on a clock. */
82
106
  export interface CostFact {
@@ -104,3 +128,22 @@ export interface NorthStar {
104
128
  confidence: "high" | "low" | "unknown";
105
129
  note: string;
106
130
  }
131
+ /** A shape in the migrations worth a human minute. Never a verdict. */
132
+ export interface DatabaseFinding {
133
+ kind: "table_without_rls" | "rls_not_forced" | "definer_without_check" | "policy_reaches_anon";
134
+ /** The table, function or policy, schema-qualified where it has one. */
135
+ subject: string;
136
+ file: string;
137
+ note: string;
138
+ }
139
+ /**
140
+ * What the migrations hold. The counts ride with the findings on purpose: an
141
+ * empty finding list and a scan that never opened a file must not read alike.
142
+ */
143
+ export interface DatabaseReading {
144
+ files: number;
145
+ tables: number;
146
+ policies: number;
147
+ definer_functions: number;
148
+ findings: DatabaseFinding[];
149
+ }
@@ -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 = [
@@ -56,7 +56,7 @@ const KNOWN = [
56
56
  /** Hosts that are content, not services: fonts, CDNs of the app's own assets, social links. */
57
57
  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
58
  export async function detectVendors(repo) {
59
- const files = runtimeCode(repo.files);
59
+ const files = scopeFor(repo.files, "vendor-presence").filter(isCode);
60
60
  const byService = new Map();
61
61
  const touch = (k, kind, what, file) => {
62
62
  const v = byService.get(k.service) ?? { service: k.service, category: k.category, evidence: [], call_sites: 0 };
@@ -71,8 +71,13 @@ export async function detectVendors(repo) {
71
71
  const text = await repo.read(f);
72
72
  if (!text)
73
73
  continue;
74
- for (const m of text.matchAll(/\bfrom\s+["']([^"'\n]+)["']|\brequire\(\s*["']([^"'\n]+)["']\s*\)/g)) {
75
- const spec = (m[1] ?? m[2]);
74
+ // LANGUAGE-NEUTRAL IMPORTS (0.2). The vendor table is already neutral - an
75
+ // SDK name, a host, an env var - and only the extraction was JavaScript, so
76
+ // a Python service importing `stripe` read as using no payment vendor at
77
+ // all. Python `import x` / `from x import y`, Go's quoted import paths and
78
+ // Ruby's `require` all name the package the same way npm does.
79
+ 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)) {
80
+ const spec = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5]);
76
81
  if (spec.startsWith("."))
77
82
  continue;
78
83
  const pkg = spec.replace(/^npm:/, "").split("/").slice(0, spec.startsWith("@") || spec.startsWith("npm:@") ? 2 : 1).join("/");
@@ -93,8 +98,10 @@ export async function detectVendors(repo) {
93
98
  unknownHosts.set(host, set);
94
99
  }
95
100
  }
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]);
101
+ // Same reasoning for environment variables: `os.environ["STRIPE_SECRET_KEY"]`
102
+ // and `os.getenv(...)` name a vendor exactly as `process.env.` does.
103
+ 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)) {
104
+ const name = (m[1] ?? m[2] ?? m[3] ?? m[4] ?? m[5]);
98
105
  for (const k of KNOWN)
99
106
  if (k.env?.test(name))
100
107
  touch(k, "env", name, f);
package/dist/index.d.ts CHANGED
@@ -1,6 +1,9 @@
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";
6
9
  export type { ProspectScore } from "./score.js";
@@ -24,6 +27,14 @@ export interface Prospect {
24
27
  dead: DeadFact[];
25
28
  cost_surfaces: CostFact[];
26
29
  fingerprint: Fingerprint;
30
+ /** What the migrations hold, and the shapes in them worth a minute. */
31
+ database: DatabaseReading;
32
+ /** What kind of repository this is, and which questions it could answer. */
33
+ profile: RepoProfile;
34
+ /** The decision record that was read, so "on record" can be checked. */
35
+ decisions: DecisionRecord;
36
+ /** Every file walked, in exactly one bucket, each with the rule that put it there. */
37
+ coverage: Coverage;
27
38
  score: ProspectScore;
28
39
  totals: {
29
40
  files: number;
@@ -32,5 +43,5 @@ export interface Prospect {
32
43
  entrypoints: number;
33
44
  };
34
45
  }
35
- export declare const VERSION = "0.1.1";
46
+ export declare const VERSION = "0.2.0";
36
47
  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,14 @@ 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 const VERSION = "0.2.0";
37
40
  export async function runProspect(root) {
38
41
  const repo = await openRepo(root);
39
42
  const runtime = runtimeCode(repo.files);
@@ -48,13 +51,69 @@ export async function runProspect(root) {
48
51
  readNorthStar(repo),
49
52
  detectStack(repo),
50
53
  ]);
54
+ const database = await detectDatabase(repo);
55
+ // READ WHAT WAS DECIDED BEFORE REPORTING WHAT WAS BUILT (0.2). Every Lane 1
56
+ // finding is checked against the record; one the record explains is marked
57
+ // on record with the citation, listed so the reader sees the tool looked, and
58
+ // not deducted. A dependency loaded by name, a copy inlined on purpose, a
59
+ // second vendor kept on purpose: each was reported as drift before this.
60
+ const record = await Decisions.read(repo);
61
+ const few = record.critical_few;
62
+ // A file is named to the record by its path fragment, never its bare stem:
63
+ // `session` matches half the record, `shell-templates/react/src/session` does not.
64
+ const frag = (f) => {
65
+ const parts = f.replace(/\.tmpl$/, "").split("/");
66
+ const stem = parts.pop().replace(/\.[^.]+$/, "");
67
+ const dir = parts.pop();
68
+ return dir ? [`${dir}/${stem}`, f] : [stem, f];
69
+ };
70
+ for (const d of deps) {
71
+ if (!d.no_reference_found)
72
+ continue;
73
+ d.on_record = record.explains([d.name]) ?? undefined;
74
+ d.touches = touches(d.manifest, few);
75
+ }
76
+ // A pair is decided by an entry naming BOTH sides: "Vercel's nameservers"
77
+ // alone is not a decision to run Vercel beside Cloudflare.
78
+ for (const o of vendorsReading.overlaps)
79
+ o.on_record = record.explains(o.services, { all: true }) ?? undefined;
80
+ for (const o of stack.overlaps)
81
+ o.on_record = record.explains(o.services, { all: true }) ?? undefined;
82
+ for (const c of stack.consolidations)
83
+ c.on_record = record.explains([c.candidate, c.keep], { all: true }) ?? undefined;
84
+ for (const h of handrolled) {
85
+ h.on_record = record.explains(h.files.flatMap(frag)) ?? undefined;
86
+ h.touches = touches(h.files[0] ?? "", few);
87
+ }
88
+ for (const d of duplicates) {
89
+ // Parallel adapters are explained structurally; the record is not consulted.
90
+ d.on_record = d.parallel ? undefined : record.explains(d.files.flatMap(frag)) ?? undefined;
91
+ d.touches = touches(d.files[0] ?? "", few);
92
+ }
93
+ for (const d of deadReading.dead) {
94
+ d.on_record = record.explains(frag(d.file)) ?? undefined;
95
+ d.touches = touches(d.file, few);
96
+ }
97
+ // Last, and after every detector, so the ledger describes the run that just
98
+ // happened rather than a plan for one.
99
+ const coverage = await coverageOf(repo);
51
100
  // A vendor SDK that is DECLARED but never referenced is a bill with no
52
101
  // work behind it - the score's hardest floor.
53
102
  const vendorSdkNames = new Set(vendorsReading.vendors.flatMap((v) => v.evidence.filter((e) => e.kind === "sdk").map((e) => e.what)));
54
103
  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
104
  void vendorSdkNames;
105
+ // The profile reads the repository's SHAPE, and it runs after the detectors
106
+ // because it needs to know what they found ground for: a question with no
107
+ // ground is recorded as not applicable rather than answered "nothing found",
108
+ // which is how a six-file scraper scored 96 out of 100.
109
+ const profile = profileRepo(repo, {
110
+ sqlFiles: database.files,
111
+ manifestDeps: deps.length,
112
+ analysed: coverage.analysed,
113
+ });
56
114
  const allOverlaps = [...vendorsReading.overlaps, ...stack.overlaps];
57
115
  const score = scoreProspect({
116
+ not_asked: profile.not_applicable.map((n) => n.question),
58
117
  deps,
59
118
  overlaps: allOverlaps,
60
119
  consolidations: stack.consolidations,
@@ -82,6 +141,10 @@ export async function runProspect(root) {
82
141
  dead: deadReading.dead,
83
142
  cost_surfaces: costs,
84
143
  fingerprint,
144
+ database,
145
+ profile,
146
+ decisions: record.toJSON(),
147
+ coverage,
85
148
  score,
86
149
  totals: {
87
150
  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
+ }
package/dist/report.js CHANGED
@@ -38,29 +38,113 @@ export function toMarkdown(p) {
38
38
  else {
39
39
  L.push(claims.slice(0, 2).join(", and ") + ".", "");
40
40
  }
41
- L.push(`## Today / After`, "");
42
- L.push(`| Today | After |`);
43
- L.push(`| --- | --- |`);
44
- if (noRef.length)
45
- L.push(`| ${noRef.length} packages installed, updated and audited that nothing imports | They are gone, and every install, update and security audit is smaller |`);
46
- if (p.overlaps.length) {
47
- const o = p.overlaps[0];
48
- L.push(`| ${prose(o.services)} ${o.services.length > 2 ? "all" : "both"} do ${label(o.category)} work | One vendor does it, one invoice, one integration to maintain |`);
41
+ // NEXT ACTIONS, NOT TODAY/AFTER (0.2). The Big Sean ends every finding with
42
+ // the workflow it protects, who does it, and how long; the Today/After table
43
+ // ended with "Suggestions arrive as opinions". A reader acts on the first
44
+ // shape and skims the second. Only what is NOT on record reaches this list.
45
+ const actions = [];
46
+ for (const d of noRef.filter((x) => !x.on_record).slice(0, 3)) {
47
+ actions.push({
48
+ what: `\`${d.name}\` is declared in \`${d.manifest}\` and nothing references it`,
49
+ touches: d.touches ?? "",
50
+ task: `grep the repository for its name outside \`${d.manifest}\`; if nothing loads it by name at runtime, remove it`,
51
+ retest: "install, build and run the test suite with it gone",
52
+ });
49
53
  }
50
- if (highHand.length) {
51
- const h = highHand[0];
52
- L.push(`| ${h.loc} lines of hand-rolled ${h.rail} code you maintain alone | A decision on record: keep it on purpose, or a rail carries it |`);
54
+ for (const o of p.overlaps.filter((x) => !x.on_record)) {
55
+ actions.push({
56
+ what: `${prose(o.services)} both do ${label(o.category)} work`,
57
+ touches: "one bill and one failure surface per vendor",
58
+ task: `name which one carries this job, or record in DECISIONS.md why both stay`,
59
+ retest: "the next scan lists the pair under On record",
60
+ });
53
61
  }
54
- if (cuts.length) {
55
- const c = cuts[0];
56
- L.push(`| ${c.candidate} runs beside ${c.keep}, which already covers ${c.covers.split(" (")[0]} | One platform, one bill, one place a deploy can fail |`);
62
+ for (const h of highHand.filter((x) => !x.on_record)) {
63
+ actions.push({
64
+ what: `${h.loc} lines of hand-rolled ${h.rail} in \`${h.signal.file}\``,
65
+ touches: h.touches ?? "",
66
+ task: "decide once: keep it on purpose and record why, or let a rail carry it (Lane 2 names the rails)",
67
+ retest: "the decision appears in the record, or the subsystem is gone",
68
+ });
69
+ }
70
+ const bigAccidental = accidental.filter((d) => !d.parallel && !d.on_record).slice(0, 2);
71
+ for (const d of bigAccidental) {
72
+ actions.push({
73
+ what: `${d.lines} lines repeated across ${d.files.length} files, opening \`${d.opens_with.slice(0, 60)}\``,
74
+ touches: d.touches ?? "",
75
+ task: "lift to one place, or mark the copy deliberate in its header",
76
+ retest: "the cluster is gone from the next scan",
77
+ });
78
+ }
79
+ if (actions.length) {
80
+ L.push(`## Your next actions`, "");
81
+ L.push(`Only what no recorded decision explains. Each names the workflow it touches - by path, a heuristic - and how to know it is done.`, "");
82
+ actions.forEach((a, i) => {
83
+ L.push(`### ${i + 1}. ${a.what}`);
84
+ L.push(`**Touches.** ${a.touches}`);
85
+ L.push(`**Task.** ${a.task}`);
86
+ L.push(`**Retest.** ${a.retest}`, "");
87
+ });
88
+ }
89
+ // ON RECORD. What the scan found and the record already explains. Listed so
90
+ // the reader sees the tool looked, and charged nothing.
91
+ const onRecord = [];
92
+ for (const d of noRef.filter((x) => x.on_record))
93
+ onRecord.push({ what: `\`${d.name}\` shows no reference`, where: `${d.on_record.file}:${d.on_record.line} - ${d.on_record.excerpt}` });
94
+ for (const o of p.overlaps.filter((x) => x.on_record))
95
+ onRecord.push({ what: `${prose(o.services)} both do ${label(o.category)} work`, where: `${o.on_record.file}:${o.on_record.line} - ${o.on_record.excerpt}` });
96
+ for (const c of cuts.filter((x) => x.on_record))
97
+ onRecord.push({ what: `${c.candidate} beside ${c.keep}`, where: `${c.on_record.file}:${c.on_record.line} - ${c.on_record.excerpt}` });
98
+ for (const h of highHand.filter((x) => x.on_record))
99
+ onRecord.push({ what: `hand-rolled ${h.rail}`, where: `${h.on_record.file}:${h.on_record.line} - ${h.on_record.excerpt}` });
100
+ for (const d of accidental.filter((x) => x.on_record))
101
+ onRecord.push({ what: `${d.lines} lines shared by ${d.files.map((f) => f.split("/").pop()).join(", ")}`, where: `${d.on_record.file}:${d.on_record.line} - ${d.on_record.excerpt}` });
102
+ const parallels = p.duplicates.filter((d) => d.parallel);
103
+ if (onRecord.length || parallels.length) {
104
+ L.push(`## On record`, "");
105
+ // Say what the record EXPLAINED, not what was read. An empty table under "already
106
+ // explained by CHECKPOINT-QUEUE.md" told the owner their notes covered the findings
107
+ // when they did not - and the gap between what a record holds and what the scan
108
+ // found is itself the useful fact here.
109
+ const explainedBy = [...new Set(onRecord.map((r) => r.where.split(":")[0]))];
110
+ L.push(onRecord.length
111
+ ? `Found, and already explained by ${explainedBy.slice(0, 3).map((f) => `\`${f}\``).join(", ")}. Nothing here costs points.`
112
+ : `${p.decisions.files.length} decision file(s) read, ${p.decisions.entries} lines. None explains a finding above: what the scan found is not yet on the record, which is the case for a line in DECISIONS.md rather than a fix.`, "");
113
+ if (onRecord.length) {
114
+ L.push(`| Finding | Where it is decided |`, `| --- | --- |`);
115
+ for (const r of onRecord)
116
+ L.push(`| ${r.what} | ${r.where.replace(/\|/g, "\\|")} |`);
117
+ L.push("");
118
+ }
119
+ if (parallels.length) {
120
+ const total = parallels.reduce((t, d) => t + d.lines * (d.files.length - 1), 0);
121
+ L.push(`${parallels.length} parallel adapter block(s), ${total} lines in total, at the same relative path under sibling adapter directories. A generated file cannot import from the generator, so these duplicate by design; the one question is whether the generator could render them from a single partial.`, "");
122
+ }
123
+ }
124
+ // UNKNOWN. What the scan could not settle, stated rather than scored.
125
+ const unknowns = [];
126
+ if (p.north_star.confidence !== "high")
127
+ unknowns.push(`the North Star: ${p.north_star.note}`);
128
+ if (!p.decisions.files.length)
129
+ unknowns.push("whether any of the above is on purpose: no decision record was found (DECISIONS.md, ADRs, CLAUDE.md), so nothing could be marked on record");
130
+ for (const n of p.profile.not_applicable)
131
+ unknowns.push(`${n.question}: ${n.why}`);
132
+ if (unknowns.length) {
133
+ L.push(`## Unknown`, "");
134
+ for (const u of unknowns)
135
+ L.push(`- ${u}`);
136
+ L.push("");
57
137
  }
58
- if (p.dead.length)
59
- L.push(`| ${p.dead.length} files everyone reads, searches and ships but nothing runs | Deleted, with this report as the receipt |`);
60
- L.push(`| Suggestions arrive as opinions | Every suggestion stands on a fact in your code, a sourced fact from your market, and your North Star |`);
61
- L.push("");
62
138
  L.push(`## Score`, "");
63
139
  L.push(`**${p.score.total}/100 (${p.score.grade})** - Level ${p.score.level.n}: **${p.score.level.name}**. ${p.score.level.meaning}`, "");
140
+ // THE NUMBER NEVER TRAVELS ALONE (0.2). 96/100 on a six-file scraper and
141
+ // 96/100 on a four-thousand-file monorepo are not the same claim, and the
142
+ // report used to print them identically.
143
+ L.push(`_${p.profile.depth.why}_`, "");
144
+ if (p.score.not_asked.length) {
145
+ L.push(`**Not asked of this repository:** ${p.score.not_asked.join(", ")}. ` +
146
+ `Neither credited nor penalised - see What was read.`, "");
147
+ }
64
148
  for (const f of p.score.floors)
65
149
  L.push(`- ${f}`);
66
150
  if (p.score.floors.length)
@@ -72,6 +156,62 @@ export function toMarkdown(p) {
72
156
  L.push(`| ${d.what} | ${d.points} | ${d.evidence} |`);
73
157
  L.push("");
74
158
  }
159
+ // THE LEDGER GOES BEFORE THE FINDINGS (0.2). A reader deciding how much to
160
+ // trust a list of findings needs to know what was looked at to produce it, and
161
+ // the honest answer used to be unavailable: the scan read 473 of 2,077 files
162
+ // on the first repository it met and had no way to say so.
163
+ const c = p.coverage;
164
+ L.push(`## What was read`, "");
165
+ const pr = p.profile;
166
+ L.push(`A ${pr.languages[0] ?? "mixed"} repository` +
167
+ (pr.languages.length > 1 ? ` (also ${pr.languages.slice(1, 3).join(", ")})` : "") +
168
+ (pr.manifests.length ? `, declaring dependencies in ${pr.manifests.join(" and ")}` : `, with no dependency manifest found`) +
169
+ (pr.traits.length ? `. Shapes recognised: ${pr.traits.join(", ")}.` : "."), "");
170
+ if (pr.not_applicable.length) {
171
+ L.push(`Questions with no ground to stand on here:`, "");
172
+ for (const n of pr.not_applicable)
173
+ L.push(`- **${n.question}** - ${n.why}`);
174
+ L.push("");
175
+ }
176
+ L.push(`**${c.analysed} of ${c.walked} files analysed.** ` +
177
+ `${c.unreadable} unreadable (binaries and files over the size cap), ` +
178
+ `${c.walked - c.analysed - c.unreadable} excluded by a named rule, ` +
179
+ `${c.unaccounted} unaccounted for.`, "");
180
+ if (c.excluded.length) {
181
+ L.push(`| Excluded | Files | Why |`);
182
+ L.push(`| --- | --- | --- |`);
183
+ for (const e of c.excluded)
184
+ L.push(`| \`${e.rule}\` | ${e.files} | ${e.why} |`);
185
+ L.push("");
186
+ }
187
+ if (c.unclaimed.length) {
188
+ L.push(`Readable file classes no question claims. Some of these are right to ignore; ` +
189
+ `the list is here so the choice is visible rather than assumed.`, "");
190
+ for (const u of c.unclaimed) {
191
+ L.push(`- \`${u.ext}\` - ${u.files} files, none analysed (e.g. \`${u.examples[0] ?? ""}\`)`);
192
+ }
193
+ L.push("");
194
+ }
195
+ const db = p.database;
196
+ if (db.files > 0) {
197
+ L.push(`### The database`, "");
198
+ L.push(`${db.files} migration file(s): ${db.tables} table(s), ${db.policies} policy/policies, ` +
199
+ `${db.definer_functions} function(s) running as definer. ` +
200
+ (db.findings.length === 0
201
+ ? `Nothing below stood out.`
202
+ : `${db.findings.length} shape(s) worth a minute.`), "");
203
+ if (db.findings.length) {
204
+ L.push(`| Shape | Subject | Where |`);
205
+ L.push(`| --- | --- | --- |`);
206
+ for (const x of db.findings.slice(0, 15)) {
207
+ L.push(`| ${x.kind.replace(/_/g, " ")} | \`${x.subject}\` | \`${x.file}\` |`);
208
+ }
209
+ if (db.findings.length > 15)
210
+ L.push(`| ...and ${db.findings.length - 15} more | | in the JSON |`);
211
+ L.push("");
212
+ L.push(db.findings[0].note, "");
213
+ }
214
+ }
75
215
  L.push(`## North Star`, "");
76
216
  if (p.north_star.sentence) {
77
217
  L.push(`> ${p.north_star.sentence}`, "");