@solana-epic/cli 0.1.0-beta.3 → 0.2.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/ui.js CHANGED
@@ -19,30 +19,70 @@ export const colors = {
19
19
  critical: (text) => isColorsEnabled() ? `\x1b[31m${text}\x1b[0m` : text,
20
20
  info: (text) => isColorsEnabled() ? `\x1b[34m${text}\x1b[0m` : text,
21
21
  violet: (text) => isColorsEnabled() ? `\x1b[35m${text}\x1b[0m` : text,
22
+ green: (text) => isColorsEnabled() ? `\x1b[32m${text}\x1b[0m` : text,
23
+ // Champagne Gold — EPIC's single brand accent (256-color, soft warm gold).
24
+ gold: (text) => isColorsEnabled() ? `\x1b[38;5;222m${text}\x1b[0m` : text,
22
25
  };
26
+ // Inverted "chip" badge — premium severity pills.
27
+ const chip = (text, fg, bg) => isColorsEnabled() ? `\x1b[${fg};${bg}m ${text} \x1b[0m` : `[ ${text} ]`;
23
28
  export const DIVIDER = "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━";
29
+ const THIN = "──────────────────────────────────────────────────";
24
30
  let bannerPrinted = false;
31
+ // The EPIC wordmark — a bold, geometric block font. Rendered as the single
32
+ // brand accent (Champagne Gold) so it reads as a premium product wordmark.
33
+ const EPIC_WORDMARK = [
34
+ "███████╗ ██████╗ ██╗ ██████╗",
35
+ "██╔════╝ ██╔══██╗ ██║ ██╔════╝",
36
+ "█████╗ ██████╔╝ ██║ ██║ ",
37
+ "██╔══╝ ██╔═══╝ ██║ ██║ ",
38
+ "███████╗ ██║ ██║ ╚██████╗",
39
+ "╚══════╝ ╚═╝ ╚═╝ ╚═════╝",
40
+ ];
41
+ const EPIC_SUBTITLE = "Upgrade Intelligence for Solana";
42
+ // Single source of truth for when the startup experience is suppressed.
43
+ // Identical conditions to the legacy banner: explicit flag, env var, non-TTY.
44
+ const bannerSuppressed = (noBannerFlag) => {
45
+ if (noBannerFlag || process.env.EPIC_NO_BANNER === "1")
46
+ return true;
47
+ if (!process.stdout.isTTY)
48
+ return true;
49
+ return false;
50
+ };
51
+ export const getBannerString = (noBannerFlag = false) => {
52
+ if (bannerSuppressed(noBannerFlag))
53
+ return "";
54
+ const lines = [""];
55
+ for (const row of EPIC_WORDMARK) {
56
+ lines.push(" " + colors.gold(row));
57
+ }
58
+ lines.push("");
59
+ lines.push(" " + colors.white(EPIC_SUBTITLE));
60
+ lines.push(" " + colors.gray(`v${CLI_VERSION}`));
61
+ lines.push("");
62
+ return lines.join("\n");
63
+ };
25
64
  export const printBanner = (noBannerFlag = false) => {
26
65
  if (bannerPrinted)
27
66
  return;
28
- if (noBannerFlag || process.env.EPIC_NO_BANNER === "1") {
67
+ if (bannerSuppressed(noBannerFlag)) {
29
68
  bannerPrinted = true;
30
69
  return;
31
70
  }
32
- if (!process.stdout.isTTY) {
33
- bannerPrinted = true;
34
- return;
35
- }
36
- console.log(colors.gray(DIVIDER));
37
- console.log("");
38
- console.log(colors.white("EPIC"));
39
- console.log(colors.dim("Security-first upgrade intelligence for Solana"));
40
- console.log("");
41
- console.log(colors.cyan(`v${CLI_VERSION}`));
42
- console.log("");
43
- console.log(colors.gray(DIVIDER));
71
+ console.log(getBannerString());
44
72
  bannerPrinted = true;
45
73
  };
74
+ // The single reusable startup component: brand banner + contextual mode label.
75
+ // Every interactive command consumes this. Returns true when the experience was
76
+ // actually rendered (TTY, not suppressed) so callers can drop a now-redundant
77
+ // in-body title while leaving non-TTY output byte-for-byte unchanged.
78
+ export const printStartup = (mode, noBannerFlag = false) => {
79
+ printBanner(noBannerFlag);
80
+ if (bannerSuppressed(noBannerFlag))
81
+ return false;
82
+ console.log(" " + colors.gold("▌") + " " + colors.white(colors.bold(mode)));
83
+ console.log("");
84
+ return true;
85
+ };
46
86
  export const printFinalSignature = () => {
47
87
  // Replaced by end summary
48
88
  };
@@ -80,6 +120,73 @@ export const formatSeverity = (sev) => {
80
120
  return colors.success(s);
81
121
  return s;
82
122
  };
123
+ export const bandForScore = (score) => {
124
+ if (score >= 80)
125
+ return "CRITICAL";
126
+ if (score >= 60)
127
+ return "HIGH";
128
+ if (score >= 40)
129
+ return "MAJOR";
130
+ if (score >= 20)
131
+ return "WARNING";
132
+ return "SAFE";
133
+ };
134
+ // Colored severity pill, e.g. CRITICAL .
135
+ export const severityBadge = (band) => {
136
+ const b = band.toUpperCase();
137
+ if (b === "CRITICAL")
138
+ return chip("CRITICAL", "97", "41"); // white on red
139
+ if (b === "HIGH")
140
+ return chip("HIGH", "30", "43"); // black on yellow
141
+ if (b === "MAJOR" || b === "MEDIUM")
142
+ return chip("MAJOR", "30", "43");
143
+ if (b === "WARNING")
144
+ return chip("WARNING", "30", "43");
145
+ if (b === "SAFE" || b === "MINOR")
146
+ return chip("SAFE", "30", "42"); // black on green
147
+ return chip(b, "30", "47");
148
+ };
149
+ // A 20-cell score meter colored by band. Higher score = more risk.
150
+ export const scoreBar = (score, width = 20) => {
151
+ const clamped = Math.max(0, Math.min(100, Math.round(score)));
152
+ const filled = Math.round((clamped / 100) * width);
153
+ const empty = width - filled;
154
+ const band = bandForScore(clamped);
155
+ const paint = band === "CRITICAL" ? colors.critical :
156
+ band === "HIGH" || band === "MAJOR" || band === "WARNING" ? colors.warning :
157
+ colors.success;
158
+ return `${paint("█".repeat(filled))}${colors.gray("░".repeat(empty))}`;
159
+ };
160
+ // Wrap prose to a soft width so paragraphs read like a report, not a log line.
161
+ const wrap = (text, width = 74) => {
162
+ const words = text.split(/\s+/);
163
+ const lines = [];
164
+ let line = "";
165
+ for (const word of words) {
166
+ if ((line + " " + word).trim().length > width) {
167
+ if (line)
168
+ lines.push(line.trim());
169
+ line = word;
170
+ }
171
+ else {
172
+ line = `${line} ${word}`;
173
+ }
174
+ }
175
+ if (line.trim())
176
+ lines.push(line.trim());
177
+ return lines.length ? lines : [""];
178
+ };
179
+ // A labelled prose block: heading in white caps, wrapped body indented.
180
+ const block = (heading, body, paint = colors.dim) => {
181
+ console.log(colors.bold(colors.white(heading)));
182
+ for (const line of wrap(body))
183
+ console.log(paint(line));
184
+ console.log("");
185
+ };
186
+ // ───────────────────────────────────────────────────────────────────────────
187
+ // Security rule knowledge (audit)
188
+ // Each rule answers: what happened, why it's dangerous, what breaks, how to fix.
189
+ // ───────────────────────────────────────────────────────────────────────────
83
190
  const ruleNames = {
84
191
  "EPIC-SEC-001": "Owner Validation",
85
192
  "EPIC-SEC-002": "Missing Signer Validation",
@@ -90,71 +197,368 @@ const ruleNames = {
90
197
  export const ruleKnowledge = {
91
198
  "EPIC-SEC-001": {
92
199
  desc: "Missing Owner Validation",
93
- fix: "Use `#[account(owner = program_id)]` or `Account<'info, T>` which inherently checks ownership.",
94
- why: "Without owner validation, a malicious user can pass a forged account owned by a different program, bypassing logic checks and potentially draining funds.",
95
- historical: "Multiple yield aggregators have been drained due to forged state accounts passing checks without owner validation."
200
+ fix: "Use `#[account(owner = program_id)]`, or type the account as `Account<'info, T>` which enforces the program-owner check automatically.",
201
+ why: "Without an owner check, the runtime will happily accept an account owned by a different program. An attacker crafts a look-alike account, the program trusts its bytes, and privileged logic executes on attacker-controlled state.",
202
+ impact: "Forged accounts pass validation. Funds can be drained or protocol state corrupted, because every downstream check trusts data the attacker fully controls.",
203
+ historical: "Multiple yield aggregators have been drained when forged state accounts passed checks that omitted owner validation.",
204
+ score: 90
96
205
  },
97
206
  "EPIC-SEC-002": {
98
207
  desc: "Missing Signer Validation",
99
- fix: "Use `Signer<'info>` or `#[account(signer)]`.",
100
- why: "Without a signer check, an attacker can pass someone else's public key and execute operations on their behalf without their authorization.",
101
- historical: "A lack of signer validation allows unauthorized withdrawals or parameter tampering."
208
+ fix: "Type the authority account as `Signer<'info>`, or add `#[account(signer)]` so the runtime requires a valid signature.",
209
+ why: "A privileged instruction mutates state on behalf of an 'authority', but never verifies that the authority actually signed the transaction. Anyone can pass another user's public key in that slot.",
210
+ impact: "Attackers can act as any user withdrawing funds, changing parameters, or transferring ownership — without that user's authorization.",
211
+ historical: "Missing signer checks are a top cause of unauthorized withdrawals and admin-takeover bugs on Solana.",
212
+ score: 92
102
213
  },
103
214
  "EPIC-SEC-003": {
104
215
  desc: "Missing Post-CPI Account Reload",
105
- fix: "Call:\n\naccount.reload()?\n\nbefore accessing mutated state.",
106
- why: "After a CPI, Anchors in-memory account state can become stale. Reading stale data may produce incorrect logic or security vulnerabilities.",
107
- historical: "Protocols have shipped stale-account bugs caused by missing reloads after CPIs, leading to double-spends and logic bypasses."
216
+ fix: "Call `account.reload()?` after the CPI and before reading the account's fields again.",
217
+ why: "After a cross-program invocation mutates an account, Anchor's in-memory copy is stale. Reading it returns pre-CPI values while the on-chain state has already changed.",
218
+ impact: "Logic runs on stale balances or flags enabling double-spends, bypassed checks, and incorrect accounting that only appears under real CPI flows.",
219
+ historical: "Protocols have shipped stale-account bugs from missing reloads after CPIs, leading to double-spends and logic bypasses.",
220
+ score: 78
108
221
  },
109
222
  "EPIC-SEC-004": {
110
223
  desc: "PDA Cryptographic Seed Collision Risk",
111
- fix: "Insert a fixed-length seed or literal delimiter between variable-length seeds.",
112
- why: "Adjacent variable-length seeds can merge ambiguously, allowing an attacker to craft a PDA collision and spoof accounts.",
113
- historical: "Improper PDA derivation has allowed attackers to front-run legitimate users by crafting colliding seeds."
224
+ fix: "Insert a fixed-length seed or a literal delimiter between adjacent variable-length seeds so concatenations are unambiguous.",
225
+ why: "Two adjacent variable-length seeds can be re-sliced into a different but equally valid pair. ['ab','c'] and ['a','bc'] hash to the same PDA, so an attacker can derive a colliding address.",
226
+ impact: "An attacker can craft a PDA that collides with a legitimate user's account, spoofing identity or front-running account creation.",
227
+ historical: "Improper PDA derivation has let attackers front-run legitimate users by crafting colliding seeds.",
228
+ score: 70
114
229
  },
115
230
  "EPIC-SEC-005": {
116
231
  desc: "Arbitrary CPI Target Program Spoofing",
117
- fix: "Replace:\n\nAccountInfo<'info>\n\nWith:\n\nProgram<'info, Token>\n\nOR\n\nrequire_keys_eq!(\n token_program.key(),\n spl_token::ID\n);",
118
- why: "This guarantees the CPI target cannot be spoofed, preventing malicious code execution from a spoofed program.",
119
- historical: "Raydium and other major DEXs suffered exploits when attacker-controlled programs were passed into CPIs instead of legitimate ones."
232
+ fix: "Replace `AccountInfo<'info>` with `Program<'info, Token>`, or assert `require_keys_eq!(token_program.key(), spl_token::ID)` before the invoke.",
233
+ why: "The program to call is read from an unchecked account, so the caller decides which code runs. An attacker substitutes a malicious program that mimics the expected interface.",
234
+ impact: "The CPI executes attacker-controlled code with your program's authority token transfers, mints, or burns can be redirected or faked.",
235
+ historical: "Major DEXs have suffered exploits when attacker-controlled programs were passed into CPIs in place of the legitimate token program.",
236
+ score: 88
120
237
  }
121
238
  };
239
+ // Derive a numeric risk score from a finding's declared severity, falling back
240
+ // to the rule's knowledge-base score when available.
241
+ export const scoreForFinding = (finding) => {
242
+ const kb = ruleKnowledge[finding.rule_id];
243
+ if (kb)
244
+ return kb.score;
245
+ const s = String(finding.severity || "").toUpperCase();
246
+ if (s === "CRITICAL")
247
+ return 88;
248
+ if (s === "HIGH")
249
+ return 72;
250
+ if (s === "MAJOR" || s === "MEDIUM")
251
+ return 50;
252
+ if (s === "WARNING")
253
+ return 30;
254
+ return 10;
255
+ };
256
+ // ───────────────────────────────────────────────────────────────────────────
257
+ // Audit finding card — the "intelligent" rendering of a single finding.
258
+ // ───────────────────────────────────────────────────────────────────────────
122
259
  export const printRuleFinding = (finding) => {
123
- const sevVal = finding.severity.toUpperCase();
124
- const icon = sevVal === "CRITICAL" ? "🔴" : sevVal === "HIGH" ? "🟠" : "🟡";
125
- const sevStr = formatSeverity(finding.severity);
126
- const ruleId = colors.white(finding.rule_id);
127
- const ruleName = colors.dim(finding.rule_name || ruleNames[finding.rule_id] || finding.rule_id);
260
+ const ruleName = finding.rule_name || ruleNames[finding.rule_id] || finding.rule_id;
128
261
  const knowledge = ruleKnowledge[finding.rule_id];
129
- console.log(colors.gray(DIVIDER));
262
+ const score = scoreForFinding(finding);
263
+ const band = bandForScore(score);
264
+ console.log(colors.gray(THIN));
130
265
  console.log("");
131
- console.log(`${icon} ${sevStr}`);
132
- console.log(ruleId);
133
- console.log(ruleName);
266
+ console.log(`${severityBadge(band)} ${colors.white(finding.rule_id)} ${colors.gray("·")} ${colors.white(ruleName)}`);
134
267
  console.log("");
135
- console.log(colors.gray("──────────────────────────────"));
268
+ console.log(`${colors.dim("Risk Score")} ${scoreBar(score)} ${colors.white(`${score} / 100`)}`);
269
+ console.log(`${colors.dim("Location")} ${colors.cyan(`${finding.location.file}:${finding.location.line}`)}`);
136
270
  console.log("");
137
- console.log(colors.bold(colors.white("Location")));
138
- console.log(colors.cyan(`${finding.location.file}:${finding.location.line}`));
271
+ // What happened — prefer the engine's concrete message.
272
+ block("WHAT HAPPENED", finding.message || (knowledge ? knowledge.desc : "Security rule triggered."));
273
+ if (knowledge) {
274
+ block("WHY IT'S DANGEROUS", knowledge.why);
275
+ block("WHAT BREAKS", knowledge.impact, colors.warning);
276
+ block("HOW TO FIX", knowledge.fix, colors.green);
277
+ }
278
+ else {
279
+ block("RECOMMENDATION", finding.recommendation || "Review and validate.", colors.green);
280
+ }
281
+ };
282
+ export const upgradeRiskKnowledge = {
283
+ "Serialization Break": {
284
+ score: 90,
285
+ why: "The on-disk byte layout of this account changed. Every account already stored on-chain was serialized with the previous layout, so the offset of every field after the change no longer lines up with what the new program expects.",
286
+ impact: "Existing accounts will fail to deserialize after deployment, or silently decode into the wrong fields. Users may be unable to access funds or state created before the upgrade."
287
+ },
288
+ "Account Shrink": {
289
+ score: 88,
290
+ why: "The account's serialized size decreased. Anchor allocates a fixed buffer per account; shrinking the layout means trailing bytes from the old layout are now reinterpreted or truncated.",
291
+ impact: "Accounts created before the upgrade are larger than the new layout. Deserialization can truncate data or fail outright, and realloc cannot recover bytes that were already written."
292
+ },
293
+ "Account Expansion": {
294
+ score: 42,
295
+ why: "A new field was appended, increasing the account's serialized size. Appending is the safe direction for layout evolution, but only if existing accounts are grown to match.",
296
+ impact: "Accounts created before this upgrade are smaller than the new layout. Without an explicit realloc and rent top-up, reads of the new field run past the allocated buffer."
297
+ },
298
+ "Field Reorder": {
299
+ score: 86,
300
+ why: "Persisted fields were reordered. In Borsh serialization, field order defines byte offsets — moving a field changes where every later field lives on disk.",
301
+ impact: "Every existing account decodes into the wrong fields. Values are silently swapped rather than erroring, which is harder to detect and can corrupt accounting."
302
+ },
303
+ "Dynamic Type Introduction": {
304
+ score: 72,
305
+ why: "A dynamically-sized type (Vec, String, HashMap, …) was introduced into a persisted account. The account no longer has a fixed, predictable byte size.",
306
+ impact: "Fixed-size accounts created before the upgrade cannot represent the new layout without migration, and unbounded growth can exceed the rent-exempt allocation."
307
+ },
308
+ "Enum Expansion": {
309
+ score: 48,
310
+ why: "An enum used in a persisted account gained or changed a variant. Old clients and indexers were compiled against the previous variant set.",
311
+ impact: "Clients and indexers that don't know the new variant can panic or mis-decode. IDLs and SDKs must be regenerated before the new variant is written."
312
+ },
313
+ "Discriminator Mismatch": {
314
+ score: 95,
315
+ why: "An account struct or instruction was renamed, which changes its 8-byte Anchor discriminator. The discriminator is how the runtime identifies which type or instruction it is looking at.",
316
+ impact: "Accounts stored under the old discriminator are no longer recognized, and clients calling the old instruction name fail. This breaks both existing state and existing callers."
317
+ }
318
+ };
319
+ const KIND_TITLES = {
320
+ FIELD_ADDED: "Field Added",
321
+ FIELD_REMOVED: "Field Removed",
322
+ FIELD_REORDERED: "Field Reordered",
323
+ TYPE_CHANGED: "Type Changed",
324
+ SIZE_REDUCED: "Account Size Reduced",
325
+ DISCRIMINATOR_CHANGED: "Discriminator Changed"
326
+ };
327
+ // Concrete, human "what happened" sentence built from the structured finding.
328
+ const upgradeWhatHappened = (finding) => {
329
+ const f = finding.field || {};
330
+ switch (finding.kind) {
331
+ case "FIELD_ADDED":
332
+ return `A new field \`${f.name}: ${f.newType ?? "?"}\` was added to account \`${finding.account}\`. Serialized size grows from ${finding.oldSize} to ${finding.newSize} bytes.`;
333
+ case "FIELD_REMOVED":
334
+ return `Field \`${f.name}: ${f.oldType ?? "?"}\` was removed from account \`${finding.account}\`. Serialized size changes from ${finding.oldSize} to ${finding.newSize} bytes.`;
335
+ case "FIELD_REORDERED":
336
+ return `The field order of account \`${finding.account}\` changed. Persisted byte offsets shift even though the set of fields is unchanged.`;
337
+ case "TYPE_CHANGED":
338
+ return `Field \`${f.name}\` on account \`${finding.account}\` changed type from \`${f.oldType ?? "?"}\` to \`${f.newType ?? "?"}\`.`;
339
+ case "SIZE_REDUCED":
340
+ return `Account \`${finding.account}\` shrank from ${finding.oldSize} to ${finding.newSize} bytes.`;
341
+ case "DISCRIMINATOR_CHANGED":
342
+ return `\`${f?.name ?? finding.account}\` was renamed, changing its 8-byte discriminator${f?.oldType ? ` (was ${f.oldType})` : ""}.`;
343
+ default:
344
+ return `Layout change detected on account \`${finding.account}\`.`;
345
+ }
346
+ };
347
+ // item: UpgradeIntelligenceItem from diff-engine (riskCategory, affectedSurface, recommendation)
348
+ export const printUpgradeFinding = (finding, item) => {
349
+ const knowledge = upgradeRiskKnowledge[item.riskCategory];
350
+ const score = knowledge ? knowledge.score : scoreForFinding({ severity: finding.severity });
351
+ const band = bandForScore(score);
352
+ const title = KIND_TITLES[finding.kind] || "Layout Change";
353
+ console.log(colors.gray(THIN));
354
+ console.log("");
355
+ console.log(`${severityBadge(band)} ${colors.white(finding.account)} ${colors.gray("·")} ${colors.white(title)} ${colors.gray("·")} ${colors.violet(item.riskCategory)}`);
139
356
  console.log("");
357
+ console.log(`${colors.dim("Risk Score")} ${scoreBar(score)} ${colors.white(`${score} / 100`)}`);
358
+ console.log("");
359
+ block("WHAT HAPPENED", upgradeWhatHappened(finding));
140
360
  if (knowledge) {
141
- console.log(colors.bold(colors.white("Why it matters")));
142
- console.log(colors.dim(knowledge.why));
361
+ block("WHY IT'S DANGEROUS", knowledge.why);
362
+ block("WHAT BREAKS", knowledge.impact, colors.warning);
363
+ }
364
+ // Affected surface as a concrete bullet list.
365
+ if (item.affectedSurface && item.affectedSurface.length) {
366
+ console.log(colors.bold(colors.white("AFFECTED SURFACE")));
367
+ for (const surface of item.affectedSurface) {
368
+ console.log(colors.warning(` • ${surface}`));
369
+ }
143
370
  console.log("");
144
- console.log(colors.bold(colors.white("Suggested Fix")));
145
- console.log(colors.dim(knowledge.fix));
146
371
  }
147
- else {
148
- console.log(colors.bold(colors.white("Details")));
149
- console.log(colors.dim(finding.message));
372
+ block("HOW TO FIX", item.recommendation, colors.green);
373
+ };
374
+ // Full upgrade report header + verdict for `epic check`.
375
+ export const printUpgradeReport = (report, intelligence, meta, opts = {}) => {
376
+ // The startup mode label ("Upgrade Intelligence") already announced the mode
377
+ // in TTY mode; skip the redundant title there. In non-TTY the label is
378
+ // suppressed, so the title still prints — keeping piped output unchanged.
379
+ if (!opts.skipTitle) {
380
+ console.log(colors.gray(DIVIDER));
381
+ console.log(colors.white(colors.bold("EPIC UPGRADE INTELLIGENCE")));
382
+ console.log(colors.gray(DIVIDER));
383
+ console.log("");
384
+ }
385
+ if (!report.findings.length) {
386
+ console.log(`${severityBadge("SAFE")} ${colors.white(meta.program)}`);
150
387
  console.log("");
151
- console.log(colors.bold(colors.white("Recommendation")));
152
- console.log(colors.dim(finding.recommendation || "Review and validate."));
388
+ block("WHAT HAPPENED", `No structural account layout changes were detected between the two versions of \`${meta.program}\`. Field layouts, sizes, and discriminators are unchanged.`);
389
+ block("WHY IT'S SAFE", "Existing on-chain accounts will continue to deserialize correctly against the new program. This upgrade does not alter persisted state layout.", colors.green);
390
+ return 0;
153
391
  }
392
+ // Overall risk = the worst single finding; one break dooms the migration.
393
+ const scores = report.findings.map((f, i) => {
394
+ const item = intelligence.items[i];
395
+ const kb = item ? upgradeRiskKnowledge[item.riskCategory] : undefined;
396
+ return kb ? kb.score : scoreForFinding({ severity: f.severity });
397
+ });
398
+ const overall = Math.max(...scores);
399
+ const band = bandForScore(overall);
400
+ console.log(`${colors.dim("Program")} ${colors.white(meta.program)}`);
401
+ console.log(`${colors.dim("Findings")} ${colors.white(String(report.findings.length))}`);
402
+ console.log(`${colors.dim("Upgrade Risk")} ${scoreBar(overall)} ${colors.white(`${overall} / 100`)} ${severityBadge(band)}`);
154
403
  console.log("");
155
- console.log(colors.gray(DIVIDER));
404
+ report.findings.forEach((finding, i) => {
405
+ printUpgradeFinding(finding, intelligence.items[i]);
406
+ });
407
+ return overall;
408
+ };
409
+ // ───────────────────────────────────────────────────────────────────────────
410
+ // Account Compatibility (check) — EPIC's signature output.
411
+ // Answers: what changed, why it matters, what breaks, can I deploy, what next.
412
+ // ───────────────────────────────────────────────────────────────────────────
413
+ // Verdict pill keyed by compatibility status.
414
+ const compatBadge = (status) => {
415
+ switch (status) {
416
+ case "Blocked":
417
+ return chip("BLOCKED", "97", "41"); // white on red
418
+ case "Migration-Required":
419
+ return chip("MIGRATION", "30", "43"); // black on yellow
420
+ case "Compatible":
421
+ default:
422
+ return chip("SAFE", "30", "42"); // black on green
423
+ }
424
+ };
425
+ const compatPaint = (status) => status === "Blocked" ? colors.critical : status === "Migration-Required" ? colors.warning : colors.success;
426
+ const STATUS_LABEL = {
427
+ Blocked: "Existing accounts would be corrupted",
428
+ "Migration-Required": "Existing accounts must be migrated first",
429
+ Compatible: "Existing accounts remain valid"
430
+ };
431
+ // A single fixed-width layout cell: "name start–end".
432
+ const layoutCell = (f, width = 22) => {
433
+ if (!f)
434
+ return "".padEnd(width);
435
+ const span = f.offset + f.byteSize - 1;
436
+ const range = f.byteSize <= 1 ? `${f.offset}` : `${f.offset}–${span}`;
437
+ const approx = f.offsetApproximate || f.dynamic ? "~" : "";
438
+ const text = `${f.name} ${approx}${range}`;
439
+ return text.length >= width ? text.slice(0, width) : text.padEnd(width);
440
+ };
441
+ const rowsDiverge = (a, b) => {
442
+ if (!a || !b)
443
+ return true;
444
+ return a.name !== b.name || a.type !== b.type || a.offset !== b.offset;
445
+ };
446
+ // account: AccountCompatibility from diff-engine.
447
+ export const printAccountCompatibility = (account) => {
448
+ const paint = compatPaint(account.status);
449
+ console.log(colors.gray(THIN));
450
+ console.log("");
451
+ console.log(`${compatBadge(account.status)} ${colors.white(account.account)} ${colors.gray("·")} ${paint(STATUS_LABEL[account.status] || account.status)}`);
452
+ console.log("");
453
+ // Size line — concrete, not editorial.
454
+ const sizeText = account.oldSize != null && account.newSize != null
455
+ ? `${account.oldSize} → ${account.newSize} bytes${account.sizeDelta ? ` (${account.sizeDelta > 0 ? "+" : ""}${account.sizeDelta})` : ""}`
456
+ : account.newSize != null
457
+ ? `${account.newSize} bytes (new)`
458
+ : `${account.oldSize} bytes (removed)`;
459
+ console.log(`${colors.dim("Size")} ${colors.white(sizeText)}`);
460
+ console.log(`${colors.dim("Certainty")} ${colors.white(account.certainty)}`);
461
+ if (typeof account.rentDeltaLamports === "number") {
462
+ const sol = (account.rentDeltaLamports / 1e9).toFixed(6);
463
+ console.log(`${colors.dim("Rent")} ${colors.white(`+${account.rentDeltaLamports.toLocaleString()} lamports`)} ${colors.gray(`(~${sol} SOL per account)`)}`);
464
+ }
465
+ console.log("");
466
+ // WHY — the reasons this verdict was reached.
467
+ if (account.reasons?.length) {
468
+ console.log(colors.bold(colors.white("WHY")));
469
+ for (const reason of account.reasons) {
470
+ for (const line of wrap(`• ${reason}`))
471
+ console.log(colors.dim(line));
472
+ }
473
+ console.log("");
474
+ }
475
+ // Byte-level reasoning — the "I've never seen a tool do this" moment.
476
+ const br = account.byteReasoning;
477
+ if (br && (br.oldLayout?.length || br.newLayout?.length)) {
478
+ const rows = Math.max(br.oldLayout.length, br.newLayout.length);
479
+ console.log(`${colors.bold(colors.white("Old Layout"))} ${colors.bold(colors.white("New Layout"))}`);
480
+ for (let i = 0; i < rows; i++) {
481
+ const o = br.oldLayout[i];
482
+ const n = br.newLayout[i];
483
+ const diverged = rowsDiverge(o, n);
484
+ const left = diverged && o ? colors.gray(layoutCell(o)) : colors.dim(layoutCell(o));
485
+ const sep = diverged ? colors.gold(" → ") : colors.gray(" │ ");
486
+ const right = diverged && n ? paint(layoutCell(n)) : colors.dim(layoutCell(n));
487
+ console.log(` ${left}${sep}${right}`);
488
+ }
489
+ console.log("");
490
+ if (br.explanations?.length) {
491
+ console.log(colors.bold(colors.white("WHAT BREAKS")));
492
+ for (const ex of br.explanations) {
493
+ for (const line of wrap(ex))
494
+ console.log(colors.warning(line));
495
+ }
496
+ console.log("");
497
+ }
498
+ }
499
+ // WHAT TO DO NEXT — the diff-conditioned plan.
500
+ if (account.upgradePlan?.length) {
501
+ console.log(colors.bold(colors.white("RECOMMENDED UPGRADE PLAN")));
502
+ account.upgradePlan.forEach((step, i) => {
503
+ const num = colors.gold(`${i + 1}.`);
504
+ const lines = wrap(step, 70);
505
+ console.log(` ${num} ${paint(lines[0])}`);
506
+ for (const cont of lines.slice(1))
507
+ console.log(` ${paint(cont)}`);
508
+ });
509
+ console.log("");
510
+ }
511
+ // Honesty: never bury the caveats.
512
+ if (account.caveats?.length) {
513
+ console.log(colors.bold(colors.gray("CAVEATS")));
514
+ for (const c of account.caveats) {
515
+ for (const line of wrap(`• ${c}`))
516
+ console.log(colors.dim(line));
517
+ }
518
+ console.log("");
519
+ }
520
+ };
521
+ // Full compatibility report header + verdict for `epic check`.
522
+ // report: CompatibilityReport from diff-engine.
523
+ export const printCompatibilityReport = (report, meta, opts = {}) => {
524
+ if (!opts.skipTitle) {
525
+ console.log(colors.gray(DIVIDER));
526
+ console.log(colors.white(colors.bold("EPIC ACCOUNT COMPATIBILITY")));
527
+ console.log(colors.gray(DIVIDER));
528
+ console.log("");
529
+ }
530
+ const counts = { Blocked: 0, "Migration-Required": 0, Compatible: 0 };
531
+ for (const a of report.accounts)
532
+ counts[a.status] = (counts[a.status] || 0) + 1;
533
+ console.log(`${colors.dim("Program")} ${colors.white(meta.program)}`);
534
+ console.log(`${colors.dim("Accounts")} ${colors.white(String(report.accounts.length))}`);
535
+ console.log(`${colors.dim("Verdict")} ${compatBadge(report.overall)} ${compatPaint(report.overall)(STATUS_LABEL[report.overall] || report.overall)}`);
536
+ if (report.accounts.length) {
537
+ console.log(`${colors.dim("Breakdown")} ${colors.critical(`${counts.Blocked} blocked`)} ${colors.gray("·")} ${colors.warning(`${counts["Migration-Required"]} migration`)} ${colors.gray("·")} ${colors.success(`${counts.Compatible} safe`)}`);
538
+ }
156
539
  console.log("");
540
+ if (!report.accounts.length) {
541
+ block("WHAT HAPPENED", `No state accounts were found to compare for \`${meta.program}\`. There is nothing to migrate.`, colors.dim);
542
+ }
543
+ else {
544
+ for (const account of report.accounts)
545
+ printAccountCompatibility(account);
546
+ }
547
+ // Standing assumptions — rendered once, never per account.
548
+ if (report.assumptions?.length) {
549
+ console.log(colors.gray(THIN));
550
+ console.log("");
551
+ console.log(colors.bold(colors.gray("ANALYSIS ASSUMPTIONS")));
552
+ for (const a of report.assumptions) {
553
+ for (const line of wrap(`• ${a}`))
554
+ console.log(colors.dim(line));
555
+ }
556
+ console.log("");
557
+ }
157
558
  };
559
+ // ───────────────────────────────────────────────────────────────────────────
560
+ // End summary (audit)
561
+ // ───────────────────────────────────────────────────────────────────────────
158
562
  export const printEndSummary = (projectName, rulesExec, critical, high, timeMs, nextSteps = []) => {
159
563
  console.log(colors.gray(DIVIDER));
160
564
  console.log("");
@@ -189,7 +593,7 @@ export const printEndSummary = (projectName, rulesExec, critical, high, timeMs,
189
593
  printLine("Repository", projectName);
190
594
  console.log("");
191
595
  printLine("Score", `${score} / 100`);
192
- printLine("Status", statusColor(status));
596
+ console.log(`${colors.dim("Health")} ${scoreBar(100 - score)} ${statusColor(status)}`);
193
597
  console.log("");
194
598
  printLine("Critical", critical);
195
599
  printLine("High", high);
@@ -204,7 +608,8 @@ export const printEndSummary = (projectName, rulesExec, critical, high, timeMs,
204
608
  // Tips
205
609
  const tips = [
206
610
  "Run: epic explain EPIC-SEC-003 to understand this vulnerability.",
207
- "Use: epic audit . --markdown to generate a GitHub-ready report.",
611
+ "Use: epic audit . --format markdown to generate a GitHub-ready report.",
612
+ "Use: epic audit . --format sarif to upload findings to GitHub code scanning.",
208
613
  "Run: epic doctor to check your environment.",
209
614
  "Run: epic audit . --include-tests to analyze test directories.",
210
615
  ];