launchprep 0.5.1 → 0.5.2

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "launchprep",
3
- "version": "0.5.1",
3
+ "version": "0.5.2",
4
4
  "description": "Launch readiness checker. Reads your code and tells you what will break before your users find out.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -17,7 +17,7 @@
17
17
  "scripts": {
18
18
  "verify": "node scripts/verify-readonly.mjs",
19
19
  "pretest": "node scripts/verify-readonly.mjs",
20
- "test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs",
20
+ "test": "node test/run.mjs && node test/gate.mjs && node test/redact.mjs && node test/git-history.mjs && node test/no-false-alarms.mjs && node test/answers.mjs && node test/standards.mjs && node test/interactive.mjs",
21
21
  "prepack": "node scripts/prepare-publish.mjs",
22
22
  "postpack": "node scripts/restore-after-publish.mjs"
23
23
  },
@@ -115,11 +115,25 @@ export const AUTH_CHECKS = [
115
115
  const out = [];
116
116
  for (const f of repo.files) {
117
117
  if (!f.text || !isCode(f.path)) continue;
118
- const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)/g;
118
+ const re = /createHash\s*\(\s*['"](md5|sha1|sha256|sha512)['"]\)\s*\.update\s*\(\s*([A-Za-z0-9_$.]+)?/g;
119
119
  let m;
120
120
  while ((m = re.exec(f.text))) {
121
- const around = f.text.slice(Math.max(0, m.index - 400), m.index + 400);
122
- if (!/password|passwd|\bpwd\b|credential/i.test(around)) continue;
121
+ // Judge what is being HASHED, not a 400-char neighbourhood. On real
122
+ // code this fired CRITICAL on `hashToken(raw){ createHash('sha256')
123
+ // .update(raw) }` because the word "password" appeared elsewhere in the
124
+ // file (a password-policy import). sha256 on a high-entropy token - a
125
+ // reset token, a session token, a CSRF value - is correct; on a user's
126
+ // password it is the vulnerability. The thing being hashed decides.
127
+ const arg = m[2] || '';
128
+ const TOKEN = /token|csrf|nonce|otp|session|reset|verif|magic|digest|\braw\b|hmac|signature|fingerprint|etag|checksum|api[_-]?key/i;
129
+ const PASS = /password|passwd|\bpwd\b|\bpass\b/i;
130
+ if (TOKEN.test(arg)) continue; // hashing a token, not a password
131
+ const ctx = f.text.slice(Math.max(0, m.index - 200), m.index + 80);
132
+ const ctxToken = /token|csrf|nonce|\botp\b|session|reset|verif|magic|fingerprint/i.test(ctx);
133
+ const ctxPass = /password|passwd|\bpwd\b/i.test(ctx);
134
+ // fire only when a password is what is hashed here: the argument names
135
+ // it, or the immediate surroundings do and are not about a token.
136
+ if (!(PASS.test(arg) || (ctxPass && !ctxToken))) continue;
123
137
  out.push(finding('AUTH-008', `Passwords hashed with ${m[1]}`, 'critical',
124
138
  f.path, lineOf(f.text, m.index),
125
139
  `${m[1]} is built to be fast, which is exactly wrong for passwords — a modern GPU tries billions of guesses per second. If your database leaks, the passwords are recoverable.`,
package/src/index.mjs CHANGED
@@ -5,11 +5,17 @@ import { detectProfile, toGateProfile } from './detect.mjs';
5
5
  import { gate, missingFacts } from './gate.mjs';
6
6
  import { runChecks, runRootChecks, CHECKS } from './checks.mjs';
7
7
  import { render } from './report.mjs';
8
+ import { askQuestions, confirm } from './interactive.mjs';
9
+ import { questionFor } from './questions.mjs';
8
10
  import { existsSync, statSync } from 'node:fs';
9
11
 
10
12
  const args = process.argv.slice(2);
11
13
  const target = args.find(a => !a.startsWith('-')) || process.cwd();
12
14
  const asJson = args.includes('--json');
15
+ // Opt out of the terminal Q&A: --yes (take defaults, ask nothing) or the
16
+ // explicit --no-interactive. It is also skipped automatically when there is no
17
+ // TTY, so a pipe or a CI job never blocks waiting on a keypress.
18
+ const noInteractive = args.includes('--yes') || args.includes('--no-interactive');
13
19
 
14
20
  // --fail-on <severity>: the CI gate. Findings at or above the threshold turn
15
21
  // the exit code to 1 so a pipeline can stop the deploy. Opt-in on purpose —
@@ -45,12 +51,10 @@ const packages = splitWorkspaces(repo);
45
51
  //
46
52
  // The scanner works most things out from the code, but some facts are simply
47
53
  // not in the code — where the users live, whether the AI is allowed to act,
48
- // how many people are expected. Those checks were skipped and the report said
49
- // "Correct it and the checks adjust", which was false: there was no way to
50
- // correct anything. This is that way.
51
- //
52
- // Reading a file the user wrote is still read-only. Nothing is written.
53
- const stated = (() => {
54
+ // how many people are expected. Those come either from a launchprep.json the
55
+ // user wrote, or from the terminal Q&A below. Reading a file the user wrote is
56
+ // still read-only, and the Q&A only reads the keyboard: nothing is written.
57
+ const fileStated = (() => {
54
58
  const raw = repo.read('launchprep.json');
55
59
  if (raw === null) return null;
56
60
  try {
@@ -67,7 +71,7 @@ const stated = (() => {
67
71
 
68
72
  // Merge one level deep so { "stack": { "auth": "clerk" } } overrides only auth
69
73
  // and leaves the detected framework and database alone.
70
- const applyStated = (profile) => {
74
+ const applyStated = (profile, stated) => {
71
75
  if (!stated) return profile;
72
76
  const out = { ...profile };
73
77
  for (const [k, v] of Object.entries(stated)) {
@@ -78,77 +82,137 @@ const applyStated = (profile) => {
78
82
  return out;
79
83
  };
80
84
 
81
- // Profile and gate every app in the repo. Libraries are profiled too - they
82
- // simply match very few rules, which is the correct outcome, not a bug.
83
- const scanned = packages.map(w => {
84
- const full = detectProfile(w.view, { root: repo });
85
- const profile = applyStated(toGateProfile(full));
86
- const g = gate(profile);
87
- const applicableIds = new Set(g.evaluated.map(r => r.id));
88
- const findings = runChecks(w.view, profile, applicableIds)
89
- .map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
90
- return { name: w.name, full, profile, gate: g, findings };
91
- });
92
-
93
- // The app most representative of this repo leads the report.
94
- const apps = scanned.filter(s => s.profile.surface !== 'library');
95
- const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
96
-
97
- // Root-scoped checks see the whole repo, once, regardless of how many
98
- // workspaces it contains. Gated by the lead app's profile.
99
- const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
100
- const rootFindings = runRootChecks(repo, lead.profile, rootIds);
101
-
102
- const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
103
- const seen = new Set();
104
- // Each workspace sorts its own findings, but concatenating several workspaces
105
- // and the root-scoped checks destroys that order - a monorepo would show a LOW
106
- // from apps/web above a CRITICAL from apps/api. Sort once, at the end.
107
85
  const RANK = { critical: 0, high: 1, medium: 2, low: 3 };
108
- const findings = allFindings
109
- .filter(f => {
110
- const k = `${f.id}:${f.file}:${f.line}`;
111
- if (seen.has(k)) return false;
112
- seen.add(k); return true;
113
- })
114
- .sort((a, b) => RANK[a.severity] - RANK[b.severity]);
115
-
116
- // Eleven tier-2 rules have a cheap static approximation that ships free. When
117
- // one of those finds nothing that is NOT a pass - a pattern cannot see an
118
- // ownership check that lives in middleware. Reporting it as clean would tell
119
- // someone they are safe when the check simply could not look. Say so instead.
120
- const firedIds = new Set(findings.map(f => f.id));
121
- const shallow = lead.gate.evaluated
122
- .filter(r => r.has_static_approximation && !firedIds.has(r.id));
123
-
124
- const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
125
-
126
- // "51 checks apply to this app" counted 17 that nothing examined — tier-2 rules
127
- // that need the deep scan. Counting them as applying is a silent pass on a
128
- // third of the headline number, which is the one thing this scanner must never
129
- // do. It also threw away the sentence that sells the paid tier: those 17 are
130
- // not a gap, they are the product.
131
86
  const implemented = new Set(CHECKS.map(c => c.id));
132
- const coverage = {
133
- ran: lead.gate.evaluated.filter(r => implemented.has(r.id)).length,
134
- needsDeep: lead.gate.evaluated.filter(r => !implemented.has(r.id)).length,
135
- };
87
+ const keyOf = (f) => `${f.id}:${f.file}:${f.line}`;
88
+
89
+ // One scan pass, given whatever the user has stated so far. Called once for the
90
+ // initial report and again after the terminal Q&A, so the answers can unlock
91
+ // more checks without asking the user to touch a file.
92
+ function analyze(stated) {
93
+ // Profile and gate every app in the repo. Libraries are profiled too - they
94
+ // simply match very few rules, which is the correct outcome, not a bug.
95
+ const scanned = packages.map(w => {
96
+ const full = detectProfile(w.view, { root: repo });
97
+ const profile = applyStated(toGateProfile(full), stated);
98
+ const g = gate(profile);
99
+ const applicableIds = new Set(g.evaluated.map(r => r.id));
100
+ const findings = runChecks(w.view, profile, applicableIds)
101
+ .map(f => ({ ...f, file: w.prefix ? `${w.prefix}/${f.file}` : f.file }));
102
+ return { name: w.name, full, profile, gate: g, findings };
103
+ });
104
+
105
+ // The app most representative of this repo leads the report.
106
+ const apps = scanned.filter(s => s.profile.surface !== 'library');
107
+ const lead = apps.sort((a, b) => b.gate.evaluated.length - a.gate.evaluated.length)[0] || scanned[0];
108
+
109
+ // Root-scoped checks see the whole repo, once, regardless of how many
110
+ // workspaces it contains. Gated by the lead app's profile.
111
+ const rootIds = new Set(lead.gate.evaluated.map(r => r.id));
112
+ const rootFindings = runRootChecks(repo, lead.profile, rootIds);
113
+
114
+ const allFindings = [...scanned.flatMap(s => s.findings), ...rootFindings];
115
+ const seen = new Set();
116
+ // Concatenating several workspaces and the root-scoped checks destroys each
117
+ // one's own order - a monorepo would show a LOW from apps/web above a
118
+ // CRITICAL from apps/api. Sort once, at the end.
119
+ const findings = allFindings
120
+ .filter(f => { const k = keyOf(f); if (seen.has(k)) return false; seen.add(k); return true; })
121
+ .sort((a, b) => RANK[a.severity] - RANK[b.severity]);
122
+
123
+ // Eleven tier-2 rules have a cheap static approximation that ships free. When
124
+ // one of those finds nothing that is NOT a pass - a pattern cannot see an
125
+ // ownership check that lives in middleware. Reporting it as clean would tell
126
+ // someone they are safe when the check simply could not look. Say so instead.
127
+ const firedIds = new Set(findings.map(f => f.id));
128
+ const shallow = lead.gate.evaluated.filter(r => r.has_static_approximation && !firedIds.has(r.id));
129
+
130
+ const questions = missingFacts(lead.profile, lead.gate.unknown).slice(0, 3);
131
+
132
+ // "51 checks apply" counted tier-2 rules that nothing examined - a silent pass
133
+ // on a third of the headline number. Split them: what actually ran, and what
134
+ // still needs the deep scan (which is the product, not a gap).
135
+ const coverage = {
136
+ ran: lead.gate.evaluated.filter(r => implemented.has(r.id)).length,
137
+ needsDeep: lead.gate.evaluated.filter(r => !implemented.has(r.id)).length,
138
+ };
139
+
140
+ return { scanned, lead, findings, shallow, questions, coverage };
141
+ }
142
+
143
+ let stated = fileStated;
144
+ let r = analyze(stated);
136
145
 
137
146
  if (asJson) {
138
147
  console.log(JSON.stringify({
139
- packages: scanned.map(s => ({
148
+ packages: r.scanned.map(s => ({
140
149
  name: s.name, profile: s.profile,
141
150
  evaluated: s.gate.evaluated.length, skipped: s.gate.skipped.length, unknown: s.gate.unknown.length,
142
151
  })),
143
- findings, questions, coverage, stated,
144
- shallow: shallow.map(r => ({ id: r.id, title: r.title, severity: r.severity })),
152
+ findings: r.findings, questions: r.questions, coverage: r.coverage, stated,
153
+ shallow: r.shallow.map(s => ({ id: s.id, title: s.title, severity: s.severity })),
145
154
  }, null, 2));
146
155
  } else {
147
- process.stdout.write(render({ repo: target, scanned, lead, findings, questions, shallow, coverage, stated }));
156
+ process.stdout.write(render({ repo: target, ...r, stated }));
157
+
158
+ // The Q&A. Only in a real terminal, only when there is something to unlock,
159
+ // and never when the caller opted out - so a pipe, --json or a CI job is
160
+ // untouched. Answers are held in memory and merged; no file is written.
161
+ if (!noInteractive && process.stdin.isTTY && process.stdout.isTTY && r.questions.length) {
162
+ const unlocks = r.questions.reduce((n, [, c]) => n + c, 0);
163
+ const yes = await confirm(
164
+ `\n \x1b[36mAnswer ${r.questions.length} quick question${r.questions.length === 1 ? '' : 's'}` +
165
+ ` to unlock up to ${unlocks} more checks?\x1b[0m \x1b[2m[Y/n]\x1b[0m `);
166
+ if (yes) {
167
+ const answers = await askQuestions(r.questions.map(([fact]) => fact));
168
+ if (Object.keys(answers).length) {
169
+ const before = new Set(r.findings.map(keyOf));
170
+ const ranBefore = r.coverage.ran;
171
+ stated = { ...(fileStated || {}), ...answers };
172
+ r = analyze(stated);
173
+ const fresh = r.findings.filter(f => !before.has(keyOf(f)));
174
+ printDelta(fresh, r.coverage.ran - ranBefore);
175
+ } else {
176
+ process.stdout.write(`\n \x1b[2mNothing answered — the report above is unchanged.\x1b[0m\n\n`);
177
+ }
178
+ }
179
+ }
148
180
  }
149
181
 
150
- // The gate, after the full report has printed: the human still sees
151
- // everything, and the pipeline sees the verdict it asked for.
152
- if (failOn && findings.some(f => RANK[f.severity] <= RANK_GATE[failOn])) {
182
+ // The gate, after everything has printed: the human sees the whole report, the
183
+ // pipeline sees the verdict it asked for. Uses the final findings, so an answer
184
+ // that unlocked a critical still fails the build.
185
+ if (failOn && r.findings.some(f => RANK[f.severity] <= RANK_GATE[failOn])) {
153
186
  process.exit(1);
154
187
  }
188
+
189
+ // The extra findings the answers unlocked, grouped the same way the main report
190
+ // groups: one line per rule with a count, so a check that fired on many files
191
+ // does not bury the rest.
192
+ function printDelta(fresh, moreRan) {
193
+ const C = { d: '\x1b[2m', b: '\x1b[1m', o: '\x1b[0m', grn: '\x1b[32m', gry: '\x1b[90m' };
194
+ const SEV = { critical: '\x1b[31m', high: '\x1b[33m', medium: '\x1b[34m', low: '\x1b[90m' };
195
+ const p = (s = '') => process.stdout.write(s + '\n');
196
+
197
+ p(`\n ${C.grn}${moreRan} more check${moreRan === 1 ? '' : 's'} ran with your answers.${C.o}`);
198
+ if (!fresh.length) {
199
+ p(` ${C.d}They all passed — nothing new to fix.${C.o}\n`);
200
+ return;
201
+ }
202
+ const groups = [];
203
+ const byId = new Map();
204
+ for (const f of fresh) {
205
+ if (byId.has(f.id)) byId.get(f.id).push(f);
206
+ else { const arr = [f]; byId.set(f.id, arr); groups.push(arr); }
207
+ }
208
+ p(` ${C.d}${fresh.length} new finding${fresh.length === 1 ? '' : 's'}:${C.o}`);
209
+ for (const g of groups) {
210
+ const f = g[0];
211
+ const more = g.length - 1;
212
+ p(`\n ${SEV[f.severity]}${C.b}${f.severity.toUpperCase()}${C.o} ${f.title}${more ? `${C.d} · ${g.length} places${C.o}` : ''}`);
213
+ p(` ${C.gry}${f.file}:${f.line}${C.o}`);
214
+ p(` ${f.detail}`);
215
+ p(` ${C.grn}Fix${C.o} ${f.fix}`);
216
+ }
217
+ p('');
218
+ }
@@ -0,0 +1,100 @@
1
+ // The optional questions, asked in the terminal instead of made into homework.
2
+ //
3
+ // The report ends with "put these in launchprep.json and run again" - which
4
+ // means writing JSON with keys like "llm_tools_enabled" before the extra checks
5
+ // unlock. For a one-off scan that is friction most people will not pay. This
6
+ // asks the same questions out loud, takes a number or a word, and re-runs with
7
+ // the answers held in memory.
8
+ //
9
+ // READ-ONLY, deliberately. It reads the keyboard and nothing else: no file is
10
+ // written, so launchprep.json is never created behind the user's back and the
11
+ // read-only promise holds. Power users who want the answers saved for CI still
12
+ // write the file themselves - that path is unchanged.
13
+
14
+ import { createInterface } from 'node:readline';
15
+ import { questionFor } from './questions.mjs';
16
+
17
+ const C = { d: '\x1b[2m', b: '\x1b[1m', o: '\x1b[0m', c: '\x1b[36m', g: '\x1b[32m' };
18
+
19
+ // Turn a QUESTION's `answer` example - the exact JSON a user would paste - into
20
+ // the shape of the question: which key it sets, whether it is a yes/no or a
21
+ // choice, and (for a choice) the options. Parsing the example rather than
22
+ // duplicating it keeps one source of truth: questions.mjs.
23
+ export function shapeOf(answerExample) {
24
+ const dash = answerExample.indexOf('—');
25
+ const json = (dash >= 0 ? answerExample.slice(0, dash) : answerExample).trim();
26
+ const rest = dash >= 0 ? answerExample.slice(dash + 1).trim() : '';
27
+
28
+ const nested = json.match(/"([\w-]+)"\s*:\s*\{\s*"([\w-]+)"/); // "stack": { "auth": ... }
29
+ const topKey = json.match(/^"([\w-]+)"/)?.[1] || null;
30
+ const isArray = /:\s*\[/.test(json);
31
+ const isBool = !nested && /:\s*(true|false)\s*$/.test(json) && !rest;
32
+
33
+ const choices = isBool
34
+ ? ['yes', 'no']
35
+ : rest.replace(/^any of:\s*/i, '').split(',').map(s => s.trim()).filter(Boolean);
36
+
37
+ // Fold the chosen value into a stated-shaped object, matching whatever the
38
+ // example demonstrated: nested (stack.auth), array (jurisdictions), boolean
39
+ // (has_rag), or a plain string (business_model).
40
+ const apply = (stated, value) => {
41
+ if (nested) stated[nested[1]] = { ...(stated[nested[1]] || {}), [nested[2]]: value };
42
+ else if (isArray) stated[topKey] = [value];
43
+ else if (isBool) stated[topKey] = (value === 'yes' || value === true);
44
+ else stated[topKey] = value;
45
+ return stated;
46
+ };
47
+
48
+ return { choices, isBool, apply, ok: Boolean(topKey && choices.length) };
49
+ }
50
+
51
+ const rlQuestion = (rl, q) => new Promise(res => rl.question(q, res));
52
+
53
+ // Ask up to `facts` in the terminal. Returns a stated-shaped object of the
54
+ // answers given; an empty object if the user answered nothing.
55
+ export async function askQuestions(facts, { input = process.stdin, output = process.stdout, readLine } = {}) {
56
+ const rl = readLine ? null : createInterface({ input, output });
57
+ const ask = readLine || ((prompt) => new Promise(res => rl.question(prompt, res)));
58
+ const stated = {};
59
+ try {
60
+ for (const fact of facts) {
61
+ const q = questionFor(fact);
62
+ if (!q) continue;
63
+ const shape = shapeOf(q.answer);
64
+ if (!shape.ok) continue;
65
+
66
+ output.write(`\n ${C.c}${q.ask}${C.o}\n`);
67
+ const numbered = shape.choices.map((c, i) => `${C.d}${i + 1})${C.o} ${c}`).join(' ');
68
+ output.write(` ${numbered}\n`);
69
+
70
+ // Accept a number, the literal value, or blank to skip. Re-ask on garbage
71
+ // rather than silently dropping it - a wrong answer the user thinks landed
72
+ // is worse than being asked again.
73
+ let value = null;
74
+ while (value === null) {
75
+ const raw = (await ask(` ${C.b}›${C.o} `)).trim().toLowerCase();
76
+ if (raw === '') break; // skip this one
77
+ const n = Number(raw);
78
+ if (Number.isInteger(n) && n >= 1 && n <= shape.choices.length) value = shape.choices[n - 1];
79
+ else if (shape.choices.includes(raw)) value = raw;
80
+ else output.write(` ${C.d}pick a number 1–${shape.choices.length}, or press Enter to skip${C.o}\n`);
81
+ }
82
+ if (value !== null) shape.apply(stated, value);
83
+ }
84
+ } finally {
85
+ rl?.close();
86
+ }
87
+ return stated;
88
+ }
89
+
90
+ // The yes/no gate before any of it. Returns true only on an explicit yes.
91
+ export async function confirm(prompt, { input = process.stdin, output = process.stdout, readLine } = {}) {
92
+ const rl = readLine ? null : createInterface({ input, output });
93
+ const ask = readLine || ((q) => new Promise(res => rl.question(q, res)));
94
+ try {
95
+ const raw = (await ask(prompt)).trim().toLowerCase();
96
+ return raw === '' || raw === 'y' || raw === 'yes'; // default yes
97
+ } finally {
98
+ rl?.close();
99
+ }
100
+ }
package/src/report.mjs CHANGED
@@ -65,14 +65,34 @@ export function render({ repo, scanned, lead, findings, questions, shallow = [],
65
65
  const summary = ['critical', 'high', 'medium', 'low']
66
66
  .filter(k => counts[k]).map(k => `${counts[k]} ${k}`).join(' · ');
67
67
  p(` ${C.dim}${summary}${C.off}`);
68
- for (const f of findings.slice(0, 25)) {
68
+
69
+ // Group by rule. One check firing on 43 tables is ONE issue in 43 places,
70
+ // not 43 issues - listing each pushed the genuinely distinct findings off
71
+ // the bottom into "and N more", the drowning this tool exists to avoid.
72
+ // Findings arrive severity-sorted, so first-seen order keeps that order.
73
+ const groups = [];
74
+ const byId = new Map();
75
+ for (const f of findings) {
76
+ if (byId.has(f.id)) byId.get(f.id).push(f);
77
+ else { const arr = [f]; byId.set(f.id, arr); groups.push(arr); }
78
+ }
79
+ if (groups.length < findings.length)
80
+ p(` ${C.dim}${groups.length} distinct, across ${findings.length} places${C.off}`);
81
+
82
+ for (const g of groups.slice(0, 25)) {
83
+ const f = g[0];
69
84
  const [col, label] = SEV[f.severity];
70
- p(`\n ${col}${C.bold}${label}${C.off} ${f.title}`);
85
+ const more = g.length - 1;
86
+ p(`\n ${col}${C.bold}${label}${C.off} ${f.title}${more ? `${C.dim} · ${g.length} places${C.off}` : ''}`);
71
87
  p(` ${C.gry}${f.file}:${f.line}${C.off}`);
88
+ if (more) {
89
+ const also = g.slice(1, 4).map(x => `${x.file}:${x.line}`).join(' ');
90
+ p(` ${C.dim}${also}${more > 3 ? ` + ${more - 3} more` : ''}${C.off}`);
91
+ }
72
92
  p(` ${f.detail}`);
73
93
  p(` ${C.grn}Fix${C.off} ${f.fix}`);
74
94
  }
75
- if (findings.length > 25) p(`\n ${C.dim}… and ${findings.length - 25} more${C.off}`);
95
+ if (groups.length > 25) p(`\n ${C.dim}… and ${groups.length - 25} more issues${C.off}`);
76
96
  }
77
97
 
78
98
  // ---------- checked, but only as far as a program can see ----------