Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 15 additions & 5 deletions src/__tests__/dashboard-score-banner.unit.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -377,7 +377,7 @@ describe('ScoreBanner headline cascade', () => {
expect(lastFrame()).toContain('3 leaks this period');
});

it('headline tier 3 — loops > 100 with cost estimate', () => {
it('headline tier 3 — loops > 100, counts and no cost estimate', () => {
const loops = Array.from({ length: 150 }, () => ({
toolName: 'Edit',
commandPreview: '/x',
Expand All @@ -399,10 +399,20 @@ describe('ScoreBanner headline cascade', () => {
filtered={filtered}
/>
);
expect(lastFrame()).toContain('150 loops');
expect(lastFrame()).toContain('wasted');
// 150 loops * count 10 * COST_PER_LOOP_ITER_USD (0.006) = $9 → "$9.00"
expect(lastFrame()).toContain('$9.00');
// Was: `150 loops · ~$9.00 wasted`, from Σ(count) × COST_PER_LOOP_ITER_USD.
// Wrong three ways — it billed the first legitimate call, counted
// long-iteration findings as loops, and used a constant measured ~170x
// low. Two inflations and one deflation landed on a believable number
// while `node9 scan` reported five times more from the same data.
//
// The headline now states what it counted. Dollars live on the one screen
// that prices them per session.
expect(lastFrame()).toContain('150 repeated patterns');
expect(lastFrame()).not.toContain('wasted');
// Scoped to the headline: the banner has its own spend indicator, so a
// bare "no $ anywhere" assertion would fail on unrelated output.
const headline = (lastFrame() ?? '').split('📌')[1]?.split('💰')[0] ?? '';
expect(headline).not.toContain('$');
});

it('headline tier 4 — exposed blast paths (when no leaks/loops/early-secrets)', () => {
Expand Down
80 changes: 80 additions & 0 deletions src/__tests__/report-headline.unit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { describe, it, expect } from 'vitest';
import { computeHeadline } from '../tui/dashboard/views/report/index.js';
import { EMPTY_FILTERED_SCAN } from '../tui/dashboard/views/report/derive.js';
import type { LoopFinding } from '../cli/commands/scan';

// The headline is the first sentence a user reads on the Report screen. It
// used to price loops itself and got three things wrong at once, landing on a
// plausible $9.73 while `node9 scan` said $48.66 from the same data.

const loop = (over: Partial<LoopFinding> = {}): LoopFinding => ({
toolName: 'Edit',
commandPreview: '/tmp/x.ts',
count: 50,
timestamp: '2026-09-01T00:00:00Z',
project: 'p',
sessionId: 's1',
agent: 'claude',
kind: 'loop',
...over,
});

const ready = { status: 'ready' } as Parameters<typeof computeHeadline>[0];

const scan = (loops: LoopFinding[]) => ({
...EMPTY_FILTERED_SCAN,
loops,
});

describe('computeHeadline — loops', () => {
it('reports counts and never a dollar figure', () => {
const h = computeHeadline(ready, scan(Array.from({ length: 150 }, () => loop())), null);
expect(h?.text).not.toMatch(/\$/);
});

it('separates sustained work from genuinely stuck patterns', () => {
// 120 long-iteration + 30 real. The old line called all 150 "loops" and
// billed every iteration of all of them.
const loops = [
...Array.from({ length: 120 }, () => loop({ kind: 'long-iteration' })),
...Array.from({ length: 30 }, () => loop({ kind: 'loop' })),
];
const h = computeHeadline(ready, scan(loops), null);
expect(h?.text).toContain('150');
expect(h?.text).toContain('30');
});

it('says zero stuck when every finding is sustained work', () => {
const loops = Array.from({ length: 150 }, () => loop({ kind: 'long-iteration' }));
const h = computeHeadline(ready, scan(loops), null);
expect(h?.text).toMatch(/\b0\b/);
});

it('counts a finding with no kind as stuck, matching the rest of the codebase', () => {
// `kind` is optional for legacy data; excluding those would under-report,
// which is the direction this product keeps failing in.
const legacy = loop();
delete (legacy as { kind?: unknown }).kind;
const h = computeHeadline(ready, scan(Array.from({ length: 150 }, () => legacy)), null);
expect(h?.text).toContain('150');
});

it('leaves the >100 threshold alone', () => {
const under = computeHeadline(ready, scan(Array.from({ length: 100 }, () => loop())), null);
const over = computeHeadline(ready, scan(Array.from({ length: 101 }, () => loop())), null);
expect(under?.text ?? '').not.toContain('repeated patterns');
expect(over?.text).toContain('repeated patterns');
});

it('still yields to leaks, which outrank loops', () => {
const h = computeHeadline(
ready,
{
...scan(Array.from({ length: 150 }, () => loop())),
leaks: [{} as never],
},
null
);
expect(h?.text).toContain('leak');
});
});
23 changes: 19 additions & 4 deletions src/tui/dashboard/views/report/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,6 @@ import { COL } from '../../panels.js';
import { computeProtection } from '../../data.js';
import type { BlastSnapshot, ReportPeriod, ScanCache, ShieldStatus } from '../../types.js';
import type { AggregateResult } from '../../../../cli/aggregate/report-audit.js';
import { COST_PER_LOOP_ITER_USD } from '@node9/policy-engine';

import { Protection } from './panels/Protection.js';
import { Cost } from './panels/Cost.js';
Expand Down Expand Up @@ -295,7 +294,7 @@ interface Headline {

/** Priority cascade — first match wins. Returns null while there's no
* data to assess (e.g. scan still loading and audit/blast both empty). */
function computeHeadline(
export function computeHeadline(
scanCache: ScanCache,
filtered: FilteredScan,
blast: BlastSnapshot | null
Expand Down Expand Up @@ -326,9 +325,25 @@ function computeHeadline(
};
}
if (filtered.loops.length > 100) {
const wasted = filtered.loops.reduce((s, l) => s + (l.count ?? 0) * COST_PER_LOOP_ITER_USD, 0);
// Counts, no dollars. This line used to read
// `Σ(count) × COST_PER_LOOP_ITER_USD`, which was wrong three ways at once:
// it priced the first legitimate call as waste, it counted
// `long-iteration` findings (sustained work on one file) as loops, and it
// used the flat constant measured at ~170x too low. Two errors inflated
// and one deflated, landing on a plausible number — $9.73 against the
// $48.66 `node9 scan` reports from the same data. A number that looks
// reasonable and is built from three mistakes gets believed.
//
// Priced correctly needs per-session rates (computeLoopWaste), which this
// screen has no access to. Rather than thread that through three layers
// for one headline, the money lives on the one screen that computes it
// properly. A third dollar figure is a third chance to disagree with
// itself.
//
// "repeated patterns", not "loops": most of these are not loops.
const stuck = filtered.loops.filter((l) => l.kind !== 'long-iteration').length;
return {
text: `📌 ${filtered.loops.length} loops · ~${fmtCost(wasted)} wasted`,
text: `📌 ${filtered.loops.length} repeated patterns · ${stuck} stuck`,
color: 'yellow',
};
}
Expand Down
Loading