launchprep 0.0.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.
@@ -0,0 +1,211 @@
1
+ // Cheap repo walk. Everything downstream reads from this one pass so we never
2
+ // hit the disk twice for the same file.
3
+ import { readdirSync, readFileSync, statSync, existsSync, lstatSync, realpathSync } from 'node:fs';
4
+ import { join, relative, extname, resolve, sep, dirname, posix } from 'node:path';
5
+
6
+ const SKIP = new Set([
7
+ 'node_modules', '.git', '.next', 'dist', 'build', 'out', 'coverage',
8
+ '.venv', 'venv', '__pycache__', '.turbo', 'vendor', '.cache', 'target',
9
+ 'site-packages', 'eggs', '.eggs', '.tox', '.bundle', 'storage', 'public',
10
+ ]);
11
+ const TEXT = new Set([
12
+ '.js','.jsx','.ts','.tsx','.mjs','.cjs','.json','.sql','.py','.rb','.go',
13
+ '.yaml','.yml','.toml','.env','.example','.prisma','.md','.swift','.kt',
14
+ '.php','.erb','.haml','.slim','.html','.htm','.vue','.svelte','.cfg','.ini',
15
+ '.rake','.gemspec','.blade','.twig','.tf','.conf',
16
+ // mobile manifests and robots.txt - MOB-004 and UX-006 cannot see them otherwise
17
+ '.plist','.xml','.txt'
18
+ ]);
19
+ // files that carry no extension but decide what a project is
20
+ const NAMED = new Set([
21
+ 'Gemfile','Rakefile','Procfile','Dockerfile','Makefile','Brewfile',
22
+ 'Gemfile.lock','requirements.txt','Pipfile','manage.py','artisan',
23
+ ]);
24
+ const MAX_BYTES = 512 * 1024;
25
+
26
+ // A symlink named like a source file will otherwise be read straight through:
27
+ // `config.ts -> /etc/passwd` lands its contents in the scan, and from there into
28
+ // a finding. Anything resolving outside the repo is refused.
29
+ function containedRealPath(root, full) {
30
+ try {
31
+ const real = realpathSync(full);
32
+ const base = realpathSync(root);
33
+ return (real === base || real.startsWith(base + sep)) ? real : null;
34
+ } catch { return null; }
35
+ }
36
+
37
+
38
+ // ---- the whole ignore chain, not just the file at the scan root ------------
39
+ // .gitignore is resolved against the GIT repository root, which is often above
40
+ // the directory being scanned. Reading only `<scanroot>/.gitignore` told us our
41
+ // own api/.env was unprotected when the rule excluding it sits one level up, in
42
+ // the repo root - a CRITICAL finding that was simply wrong. Same shape as every
43
+ // other false positive we have had: it judged the file without the context that
44
+ // decides it.
45
+ //
46
+ // Read-only, and it reads .git directly rather than shelling out to git, which
47
+ // verify-readonly.mjs forbids.
48
+ function gitRoot(from) {
49
+ let d = resolve(from);
50
+ for (let i = 0; i < 40; i++) {
51
+ if (existsSync(join(d, '.git'))) return d;
52
+ const up = dirname(d);
53
+ if (up === d) return null;
54
+ d = up;
55
+ }
56
+ return null;
57
+ }
58
+
59
+ // Enough of the gitignore syntax to answer "is this path excluded": globs,
60
+ // anchoring, directory-only rules and negation. Not the whole spec - but the
61
+ // alternative was a regex that guessed, and guessing is what produced the bug.
62
+ function toRegExp(pattern) {
63
+ let p = pattern;
64
+ const anchored = p.startsWith('/') || p.slice(0, -1).includes('/');
65
+ if (p.startsWith('/')) p = p.slice(1);
66
+ if (p.endsWith('/')) p = p.slice(0, -1);
67
+ let re = '';
68
+ for (let i = 0; i < p.length; i++) {
69
+ const c = p[i];
70
+ if (c === '*') {
71
+ if (p[i + 1] === '*') { re += '.*'; i++; if (p[i + 1] === '/') i++; }
72
+ else re += '[^/]*';
73
+ }
74
+ else if (c === '?') re += '[^/]';
75
+ else re += c.replace(/[.+^${}()|[\]\\]/g, '\\$&');
76
+ }
77
+ return new RegExp('^' + (anchored ? '' : '(?:.*/)?') + re + '(?:/.*)?$');
78
+ }
79
+
80
+ // Each rule is kept with the directory its .gitignore was written in, because
81
+ // that is what its paths are relative to. Exported so the test shim builds its
82
+ // matcher the same way rather than approximating it.
83
+ export function buildIgnore(rootAbs, sources) {
84
+ const rules = [];
85
+ for (const { dir, text } of sources) {
86
+ for (const raw of String(text ?? '').split('\n')) {
87
+ const line = raw.trim();
88
+ if (!line || line.startsWith('#')) continue;
89
+ const negate = line.startsWith('!');
90
+ const pattern = negate ? line.slice(1) : line;
91
+ if (!pattern) continue;
92
+ rules.push({ dir, re: toRegExp(pattern), negate });
93
+ }
94
+ }
95
+ // last matching rule wins, which is what git does
96
+ return (relPath) => {
97
+ const abs = resolve(rootAbs, relPath);
98
+ let ignored = false;
99
+ for (const r of rules) {
100
+ const rel = relative(r.dir, abs);
101
+ if (!rel || rel.startsWith('..')) continue;
102
+ if (r.re.test(rel.split(sep).join(posix.sep))) ignored = !r.negate;
103
+ }
104
+ return ignored;
105
+ };
106
+ }
107
+
108
+ // Every .gitignore from the git root down to the scan root, plus .git/info/exclude.
109
+ export function ignoreChain(root) {
110
+ const rootAbs = resolve(root);
111
+ const gr = gitRoot(rootAbs);
112
+ const dirs = [];
113
+ if (gr) { let d = rootAbs; while (true) { dirs.unshift(d); if (d === gr) break; const up = dirname(d); if (up === d) break; d = up; } }
114
+ else dirs.push(rootAbs);
115
+
116
+ const sources = [];
117
+ const readAt = (dir, rel) => { try { return readFileSync(join(dir, rel), 'utf8'); } catch { return null; } };
118
+ for (const d of dirs) {
119
+ const t = readAt(d, '.gitignore');
120
+ if (t !== null) sources.push({ dir: d, text: t });
121
+ }
122
+ if (gr) {
123
+ const t = readAt(gr, join('.git', 'info', 'exclude'));
124
+ if (t !== null) sources.push({ dir: gr, text: t });
125
+ }
126
+ return buildIgnore(rootAbs, sources);
127
+ }
128
+
129
+ export function scanRepo(root, { maxFiles = 6000 } = {}) {
130
+ const files = [];
131
+ const rootAbs = resolve(root);
132
+ const walk = (dir) => {
133
+ if (files.length >= maxFiles) return;
134
+ let entries;
135
+ try { entries = readdirSync(dir, { withFileTypes: true }); } catch { return; }
136
+ for (const e of entries) {
137
+ if (files.length >= maxFiles) return;
138
+ if (e.name.startsWith('.') && !e.name.startsWith('.env') && e.name !== '.github') continue;
139
+ const full = join(dir, e.name);
140
+
141
+ let isLink = false;
142
+ try { isLink = lstatSync(full).isSymbolicLink(); } catch { continue; }
143
+ if (isLink && !containedRealPath(rootAbs, full)) {
144
+ // points outside the repo - record that it exists, never read it
145
+ files.push({ path: relative(root, full), text: null, skipped: 'symlink-escapes-repo' });
146
+ continue;
147
+ }
148
+
149
+ if (e.isDirectory()) { if (!SKIP.has(e.name)) walk(full); continue; }
150
+ const ext = extname(e.name) || (e.name.startsWith('.env') ? '.env' : '');
151
+ if (!TEXT.has(ext) && !NAMED.has(e.name)) {
152
+ files.push({ path: relative(root, full), text: null }); continue;
153
+ }
154
+ let text = null;
155
+ try { if (statSync(full).size <= MAX_BYTES) text = readFileSync(full, 'utf8'); } catch {}
156
+ files.push({ path: relative(root, full), text });
157
+ }
158
+ };
159
+ walk(root);
160
+ return {
161
+ root,
162
+ files,
163
+ has: (re) => files.some(f => re.test(f.path)),
164
+ find: (re) => files.filter(f => re.test(f.path)),
165
+ grep: (re, pathRe = /\.(ts|tsx|js|jsx|mjs|cjs|py|rb|php|erb|sql|prisma|ya?ml)$|(^|\/)(Gemfile|manage\.py|artisan)$/) =>
166
+ files.filter(f => f.text && pathRe.test(f.path) && re.test(f.text)),
167
+ exists: (rel) => existsSync(join(root, rel)),
168
+ read: (rel) => { try { return readFileSync(join(root, rel), 'utf8'); } catch { return null; } },
169
+ isIgnored: ignoreChain(root),
170
+ };
171
+ }
172
+
173
+ export function readPackageJson(repo) {
174
+ const raw = repo.read('package.json');
175
+ if (!raw) return null;
176
+ try { return JSON.parse(raw); } catch { return null; }
177
+ }
178
+
179
+ // Monorepos put nothing in the root manifest. Union every workspace package.json
180
+ // or we conclude a pnpm/turbo repo has no dependencies at all.
181
+ export function allManifests(repo) {
182
+ return repo.find(/(^|\/)package\.json$/)
183
+ .map(f => { try { return JSON.parse(f.text || ''); } catch { return null; } })
184
+ .filter(Boolean);
185
+ }
186
+
187
+ export function allDeps(pkgOrRepo) {
188
+ // accept either a single manifest (legacy) or a repo (monorepo-aware)
189
+ const manifests = pkgOrRepo && pkgOrRepo.files ? allManifests(pkgOrRepo)
190
+ : pkgOrRepo ? [pkgOrRepo] : [];
191
+ const out = {};
192
+ for (const m of manifests)
193
+ Object.assign(out, m.dependencies||{}, m.devDependencies||{}, m.peerDependencies||{});
194
+ return out;
195
+ }
196
+
197
+ // Comments describe code; they are not evidence about it. Strip them before
198
+ // matching, or a comment mentioning "diagnosis" classifies a CRM as health data.
199
+ export function stripComments(text) {
200
+ return text
201
+ .replace(/\/\*[\s\S]*?\*\//g, ' ')
202
+ .replace(/(^|[^:])\/\/.*$/gm, '$1')
203
+ .replace(/^\s*#(?!\[).*$/gm, '')
204
+ .replace(/^\s*--.*$/gm, '');
205
+ }
206
+
207
+ export function isWorkspaceRoot(repo) {
208
+ return repo.exists('pnpm-workspace.yaml') || repo.exists('turbo.json') ||
209
+ repo.exists('lerna.json') || repo.exists('nx.json') ||
210
+ !!readPackageJson(repo)?.workspaces;
211
+ }
package/src/gate.mjs ADDED
@@ -0,0 +1,103 @@
1
+ // The applicability gate. A rule is evaluated only if every `requires:`
2
+ // predicate holds against the detected profile.
3
+ //
4
+ // true -> applies, run the check
5
+ // false -> skipped, shown with the reason, never counted against the score
6
+ // null -> undeterminable, becomes a question, never a failure
7
+ import { createRequire } from 'node:module';
8
+ const require = createRequire(import.meta.url);
9
+ const RULES = require('./rules.json');
10
+
11
+ export const allRules = () => RULES.rules;
12
+
13
+ function get(profile, key) {
14
+ let cur = profile;
15
+ for (const part of key.split('.')) {
16
+ if (cur == null || typeof cur !== 'object' || !(part in cur)) return undefined;
17
+ cur = cur[part];
18
+ }
19
+ return cur;
20
+ }
21
+
22
+ const coerce = (v) => (v === 'true' ? true : v === 'false' ? false : v);
23
+
24
+ export function evaluate(pred, profile) {
25
+ const p = String(pred).trim();
26
+
27
+ if (p.includes(' or ')) {
28
+ const parts = p.split(' or ').map(x => evaluate(x, profile));
29
+ if (parts.some(x => x === true)) return true;
30
+ return parts.some(x => x === null) ? null : false;
31
+ }
32
+
33
+ let m = p.match(/^([\w.]+)\s+includes any of\s+\[(.+)\]$/);
34
+ if (m) {
35
+ const cur = get(profile, m[1]);
36
+ if (cur == null) return null;
37
+ const vals = m[2].split(',').map(s => s.trim());
38
+ return vals.some(v => Array.isArray(cur) ? cur.includes(v) : cur === v);
39
+ }
40
+
41
+ m = p.match(/^([\w.]+)\s+in\s+\[(.+)\]$/);
42
+ if (m) {
43
+ const cur = get(profile, m[1]);
44
+ if (cur === undefined) return null;
45
+ return m[2].split(',').map(s => s.trim()).includes(cur);
46
+ }
47
+
48
+ m = p.match(/^([\w.]+)\s*(==|!=)\s*(.+)$/);
49
+ if (m) {
50
+ const cur = get(profile, m[1]);
51
+ if (cur === undefined) return null;
52
+ const val = coerce(m[3].trim());
53
+ return m[2] === '==' ? cur === val : cur !== val;
54
+ }
55
+
56
+ return null;
57
+ }
58
+
59
+ export function applies(rule, profile) {
60
+ const reqs = rule.requires || [];
61
+ if (!reqs.length) return true;
62
+ const res = reqs.map(r => evaluate(r, profile));
63
+ if (res.some(x => x === false)) return false;
64
+ if (res.some(x => x === null)) return null;
65
+ return true;
66
+ }
67
+
68
+ // Why a rule was skipped, in the user's terms rather than predicate syntax.
69
+ export function skipReason(rule, profile) {
70
+ for (const r of rule.requires || []) {
71
+ if (evaluate(r, profile) !== false) continue;
72
+ const key = String(r).match(/^([\w.]+)/)?.[1];
73
+ const actual = get(profile, key);
74
+ return `${key} is ${JSON.stringify(actual)}`;
75
+ }
76
+ return 'precondition not met';
77
+ }
78
+
79
+ export function gate(profile) {
80
+ const evaluated = [], skipped = [], unknown = [];
81
+ for (const rule of RULES.rules) {
82
+ const a = applies(rule, profile);
83
+ if (a === true) evaluated.push(rule);
84
+ else if (a === false) skipped.push({ rule, reason: skipReason(rule, profile) });
85
+ else unknown.push(rule);
86
+ }
87
+ return { evaluated, skipped, unknown, total: RULES.rules.length };
88
+ }
89
+
90
+ // Facts that, if answered, would unlock the most currently-unknown rules.
91
+ export function missingFacts(profile, unknown) {
92
+ const counts = new Map();
93
+ for (const rule of unknown) {
94
+ for (const req of rule.requires || []) {
95
+ for (const sub of String(req).split(' or ')) {
96
+ if (evaluate(sub, profile) !== null) continue;
97
+ const key = sub.trim().match(/^([\w.]+)/)?.[1];
98
+ if (key) counts.set(key, (counts.get(key) || 0) + 1);
99
+ }
100
+ }
101
+ }
102
+ return [...counts.entries()].sort((a, b) => b[1] - a[1]);
103
+ }
package/src/index.mjs ADDED
@@ -0,0 +1,71 @@
1
+ #!/usr/bin/env node
2
+ import { scanRepo } from './fs-scan.mjs';
3
+ import { splitWorkspaces } from './workspace.mjs';
4
+ import { detectProfile, toGateProfile } from './detect.mjs';
5
+ import { gate, missingFacts } from './gate.mjs';
6
+ import { runChecks, runRootChecks } from './checks.mjs';
7
+ import { render } from './report.mjs';
8
+
9
+ const target = process.argv[2] || process.cwd();
10
+ const asJson = process.argv.includes('--json');
11
+
12
+ const repo = scanRepo(target, { maxFiles: 12000 });
13
+ const packages = splitWorkspaces(repo);
14
+
15
+ // Profile and gate every app in the repo. Libraries are profiled too - they
16
+ // simply match very few rules, which is the correct outcome, not a bug.
17
+ const scanned = packages.map(w => {
18
+ const full = detectProfile(w.view, { root: repo });
19
+ const profile = toGateProfile(full);
20
+ const g = gate(profile);
21
+ const applicableIds = new Set(g.evaluated.map(r => r.id));
22
+ const findings = runChecks(w.view, profile, applicableIds)
23
+ .map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
24
+ return { name: w.name, full, profile, gate: g, findings };
25
+ });
26
+
27
+ // The app most representative of this repo leads the report.
28
+ const apps = scanned.filter(s => s.profile.surface !== 'library');
29
+ const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
30
+
31
+ // Root-scoped checks see the whole repo, once, regardless of how many
32
+ // workspaces it contains. Gated by the lead app's profile.
33
+ const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
34
+ const rootFindings = runRootChecks(repo, lead.profile, rootIds);
35
+
36
+ const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
37
+ const seen = new Set();
38
+ // Each workspace sorts its own findings, but concatenating several workspaces
39
+ // and the root-scoped checks destroys that order - a monorepo would show a LOW
40
+ // from apps/web above a CRITICAL from apps/api. Sort once, at the end.
41
+ const RANK = { critical: 0, high: 1, medium: 2, low: 3 };
42
+ const findings = allFindings
43
+ .filter(f => {
44
+ const k = `${f.id}:${f.file}:${f.line}`;
45
+ if (seen.has(k)) return false;
46
+ seen.add(k); return true;
47
+ })
48
+ .sort((a, b) => RANK[a.severity] - RANK[b.severity]);
49
+
50
+ // Eleven tier-2 rules have a cheap static approximation that ships free. When
51
+ // one of those finds nothing that is NOT a pass - a pattern cannot see an
52
+ // ownership check that lives in middleware. Reporting it as clean would tell
53
+ // someone they are safe when the check simply could not look. Say so instead.
54
+ const firedIds = new Set(findings.map(f => f.id));
55
+ const shallow = lead.gate.evaluated
56
+ .filter(r => r.has_static_approximation && !firedIds.has(r.id));
57
+
58
+ const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
59
+
60
+ if (asJson) {
61
+ console.log(JSON.stringify({
62
+ packages: scanned.map(s => ({
63
+ name: s.name, profile: s.profile,
64
+ evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
65
+ })),
66
+ findings, questions,
67
+ shallow: shallow.map(r => ({ id: r.id, title: r.title, severity: r.severity })),
68
+ }, null, 2));
69
+ } else {
70
+ process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow }));
71
+ }
package/src/report.mjs ADDED
@@ -0,0 +1,101 @@
1
+ import { BRAND } from './brand.mjs';
2
+
3
+ const C = { red:'\x1b[31m', yel:'\x1b[33m', blu:'\x1b[34m', gry:'\x1b[90m',
4
+ bold:'\x1b[1m', dim:'\x1b[2m', grn:'\x1b[32m', cyn:'\x1b[36m', off:'\x1b[0m' };
5
+ const SEV = { critical:[C.red,'CRITICAL'], high:[C.yel,'HIGH'], medium:[C.blu,'MEDIUM'], low:[C.gry,'LOW'] };
6
+
7
+ const QUESTION = {
8
+ business_model: 'Who is this for — consumers, businesses, a marketplace, or internal use?',
9
+ jurisdictions: 'Where are your users? (EU / UK / US / other — this decides which privacy rules apply)',
10
+ expected_scale: 'How many users do you expect — under 1k, under 100k, or more?',
11
+ serves_currency: 'Do you charge in more than one currency?',
12
+ audience_locale: 'Do you serve more than one language or region?',
13
+ };
14
+
15
+ export function render({ repo, scanned, lead, findings, questions, shallow = [] }) {
16
+ const L = [];
17
+ const p = (s = '') => L.push(s);
18
+ const pr = lead.profile;
19
+
20
+ p(`\n${C.bold}${BRAND.name.toUpperCase()}${C.off} ${C.dim}${repo}${C.off}\n`);
21
+
22
+ // ---------- what this app is ----------
23
+ p(`${C.bold}What we found${C.off}`);
24
+ const ev = (f) => lead.full[f]?.evidence?.[0];
25
+ const row = (k, v, why) =>
26
+ p(` ${C.gry}${k.padEnd(10)}${C.off}${v}${why ? ` ${C.dim}(${why})${C.off}` : ''}`);
27
+
28
+ row('Type', pr.surface, ev('surface'));
29
+ row('Stack', [pr.stack.framework, pr.stack.database, pr.stack.host].filter(Boolean).join(' · ') || '—');
30
+ row('Accounts', pr.has_accounts ? 'yes' : 'no', ev('has_accounts'));
31
+ if (pr.tenancy !== 'none') row('Tenancy', pr.tenancy, ev('tenancy'));
32
+ if (pr.calls_llm) row('AI', (pr.llm_providers || []).join(', ') || 'yes', ev('calls_llm'));
33
+ if (pr.handles_payments !== 'none') row('Payments', pr.handles_payments);
34
+ if (pr.data_sensitivity !== 'none') row('Data', pr.data_sensitivity, ev('data_sensitivity'));
35
+ row('Stage', pr.stage, ev('stage'));
36
+
37
+ if (scanned.length > 1) {
38
+ const apps = scanned.filter(s => s.profile.surface !== 'library');
39
+ const libs = scanned.length - apps.length;
40
+ p(`\n ${C.dim}Monorepo — ${apps.length} app${apps.length === 1 ? '' : 's'}` +
41
+ `${libs ? ` and ${libs} shared package${libs === 1 ? '' : 's'}` : ''}, each checked on its own:${C.off}`);
42
+ for (const s of apps.slice(0, 6))
43
+ p(` ${C.dim}${s.name.padEnd(26)} ${s.profile.surface.padEnd(11)} ${s.gate.evaluated.length} checks${C.off}`);
44
+ }
45
+
46
+ // ---------- findings ----------
47
+ p(`\n${C.bold}Findings${C.off}`);
48
+ if (!findings.length) {
49
+ p(` ${C.grn}Nothing found by the deterministic checks.${C.off}`);
50
+ p(` ${C.dim}These are the fast, free checks — deeper ones need a review pass.${C.off}`);
51
+ } else {
52
+ const counts = findings.reduce((a, f) => (a[f.severity] = (a[f.severity] || 0) + 1, a), {});
53
+ // severity order, not whatever order they happened to arrive in
54
+ const summary = ['critical', 'high', 'medium', 'low']
55
+ .filter(k => counts[k]).map(k => `${counts[k]} ${k}`).join(' · ');
56
+ p(` ${C.dim}${summary}${C.off}`);
57
+ for (const f of findings.slice(0, 25)) {
58
+ const [col, label] = SEV[f.severity];
59
+ p(`\n ${col}${C.bold}${label}${C.off} ${f.title}`);
60
+ p(` ${C.gry}${f.file}:${f.line}${C.off}`);
61
+ p(` ${f.detail}`);
62
+ p(` ${C.grn}Fix${C.off} ${f.fix}`);
63
+ }
64
+ if (findings.length > 25) p(`\n ${C.dim}… and ${findings.length - 25} more${C.off}`);
65
+ }
66
+
67
+ // ---------- checked, but only as far as a program can see ----------
68
+ if (shallow.length) {
69
+ p(`\n${C.bold}${shallow.length} check${shallow.length === 1 ? '' : 's'} went only as deep as a pattern can${C.off}`);
70
+ p(` ${C.dim}These found nothing. That is not the same as being safe — a pattern`);
71
+ p(` cannot tell whether an ownership check lives in middleware or a guard.${C.off}`);
72
+ for (const r of shallow.slice(0, 6))
73
+ p(` ${C.cyn}~${C.off} ${C.gry}${r.id.padEnd(11)}${C.off}${r.title}`);
74
+ if (shallow.length > 6) p(` ${C.dim} … and ${shallow.length - 6} more${C.off}`);
75
+ p(` ${C.dim}A deep scan reads the actual path these take through your code.${C.off}`);
76
+ }
77
+
78
+ // ---------- what we did not check, and why ----------
79
+ const g = lead.gate;
80
+ p(`\n${C.bold}Coverage${C.off}`);
81
+ p(` ${C.bold}${g.evaluated.length}${C.off} of ${g.total} checks apply to this app`);
82
+ p(` ${C.dim}${g.skipped.length} skipped — they don't fit what you built${C.off}`);
83
+
84
+ const bySkipFact = {};
85
+ for (const s of g.skipped) {
86
+ const k = s.reason.split(' is ')[0];
87
+ bySkipFact[k] = (bySkipFact[k] || 0) + 1;
88
+ }
89
+ for (const [f, n] of Object.entries(bySkipFact).sort((a, b) => b[1] - a[1]).slice(0, 4))
90
+ p(` ${C.dim}${String(n).padStart(3)} because ${f} is ${JSON.stringify(lead.profile[f] ?? lead.profile.stack?.[f.split('.').pop()])}${C.off}`);
91
+
92
+ // ---------- the three questions ----------
93
+ if (questions.length) {
94
+ p(`\n${C.bold}${g.unknown.length} more checks need ${questions.length} answer${questions.length === 1 ? '' : 's'}${C.off}`);
95
+ for (const [factName, n] of questions)
96
+ p(` ${C.cyn}?${C.off} ${QUESTION[factName] || factName} ${C.dim}(unlocks ${n})${C.off}`);
97
+ }
98
+
99
+ p(`\n ${C.dim}Anything above wrong? Correct it and the checks adjust.${C.off}\n`);
100
+ return L.join('\n');
101
+ }