From 765c06aa409cce5c07de77dae7a74aea083543f5 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 6 Aug 2026 01:44:45 -0500 Subject: [PATCH] fix(explore): bound how far an oversize cluster member may overshoot (CG-30) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit shrinkCluster keeps an oversize cluster's highest-importance member WHOLE on purpose — an empty file section sends the agent to Read, the outcome explore exists to prevent. What it lacked was a bound, and "never empty" quietly meant "never bounded": on the reporting repo one file emitted 22,376 chars against a 9,181-char reservation (2.44x), past both the per-file budget and the spine ceiling. That overshoot is what collapses `headroom` for every file below it. The same rule has a second face. When the top member is bigger than the whole response ceiling, the file does not overshoot — it is dropped entirely at the renderCeiling check, so the agent gets nothing for a file it named. renderCluster now takes a ceiling (1.5x what the file may spend — the same multiple SPINE_CEILING already draws, and never below the cap, so a cluster that fits is untouched). Past it the member is WINDOWED on whole lines rather than emitted whole or dropped: leading window plus, on a flow cluster, a window on the spine's call site. A partial window shorter than 12 lines is dropped instead — a sliver in the session record forces the next call's dedup to shred the block around it or re-send it — unless nothing else was emitted, where the never-empty floor wins. Measured on the new fixture, pre-fix vs post-fix: monthly.ts 12,391 chars on a 3,334 budget (3.7x) → 4,941 (1.48x) quarterly.ts dropped, no headroom left → 4,004 delivered Also: the diagnostic now reports `spendable` (reservation + inherited slack) alongside `reserved`. Every render bound reads the former, so reporting only the latter makes an ordinary carry-forward read as a file spending over budget — and it made the overshoot this issue is about unmeasurable. A windowed file is now flagged `clipped` too, instead of presenting a window as the whole file. Co-Authored-By: Claude Opus 5 --- __tests__/explore-oversize-member.test.ts | 179 ++++++ .../fixtures/oversize-member-ts/package.json | 6 + .../fixtures/oversize-member-ts/src/index.ts | 14 + .../oversize-member-ts/src/report/format.ts | 22 + .../oversize-member-ts/src/report/monthly.ts | 509 ++++++++++++++++++ .../src/report/quarterly.ts | 235 ++++++++ .../oversize-member-ts/src/report/store.ts | 18 + .../oversize-member-ts/src/report/types.ts | 28 + .../oversize-member-ts/src/report/weekly.ts | 372 +++++++++++++ src/mcp/explore-diagnostics.ts | 28 +- src/mcp/tools.ts | 159 +++++- 11 files changed, 1560 insertions(+), 10 deletions(-) create mode 100644 __tests__/explore-oversize-member.test.ts create mode 100644 __tests__/fixtures/oversize-member-ts/package.json create mode 100644 __tests__/fixtures/oversize-member-ts/src/index.ts create mode 100644 __tests__/fixtures/oversize-member-ts/src/report/format.ts create mode 100644 __tests__/fixtures/oversize-member-ts/src/report/monthly.ts create mode 100644 __tests__/fixtures/oversize-member-ts/src/report/quarterly.ts create mode 100644 __tests__/fixtures/oversize-member-ts/src/report/store.ts create mode 100644 __tests__/fixtures/oversize-member-ts/src/report/types.ts create mode 100644 __tests__/fixtures/oversize-member-ts/src/report/weekly.ts diff --git a/__tests__/explore-oversize-member.test.ts b/__tests__/explore-oversize-member.test.ts new file mode 100644 index 0000000..019d1ff --- /dev/null +++ b/__tests__/explore-oversize-member.test.ts @@ -0,0 +1,179 @@ +/** + * Regression fixture for CG-30 — a cluster's top member may not overshoot the + * file's budget without bound. + * + * `shrinkCluster` keeps the highest-importance member of an oversize cluster + * WHOLE, deliberately: an empty file section sends the agent to Read, which is + * the outcome explore exists to prevent. What it lacked was a bound. On the + * originating repo one file emitted 22,376 chars against a 9,181-char + * reservation — 2.44x — past both the per-file budget and the spine ceiling, + * because its top member alone was that big. The overshoot is what collapses + * `headroom` for every file ranked below it (CG-31), and it has a second face: + * a member too big for the whole response ceiling makes the file drop out + * entirely rather than render short. + * + * `__tests__/fixtures/oversize-member-ts/` reproduces both permanently. Three + * report builders compete for one envelope, each a single long function far + * bigger than any reservation it can earn beside its siblings. Measured against + * the pre-fix build, this fixture produced: + * + * monthly.ts 12,391 chars emitted on a 3,334 budget (3.7x) + * quarterly.ts dropped entirely — no headroom left (the CG-31 half) + * + * The gate below is that both are now bounded AND delivered: the bound cuts the + * overshoot, and cutting the overshoot is what buys back the starved file. + * + * Measured against `spendable`, not `reserved`: the render paths bound + * themselves by the reservation PLUS whatever slack the files above left on the + * table, so a file legitimately spending inherited slack is not an overshoot. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as path from 'path'; +import * as os from 'os'; +import CodeGraph from '../src/index'; +import { ToolHandler } from '../src/mcp/tools'; +import { attributeSourceBytes } from '../src/mcp/explore-diagnostics'; +import type { ExploreDiagnosticReport, ExploreDiagnosticFile } from '../src/mcp/explore-diagnostics'; + +const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'oversize-member-ts'); + +/** A symbol bag spanning the three builders — the sibling files compete. */ +const QUERY = 'buildMonthlyReport buildWeeklyReport buildQuarterlyReport formatReportRow persistReport'; + +/** The giant: one ~24K function, far past the whole-response ceiling. */ +const GIANT = 'src/report/monthly.ts'; +/** Mid-size: one ~11K function — the file the giant's overshoot used to starve. */ +const STARVED = 'src/report/quarterly.ts'; + +/** The bound: 1.5x, the same multiple the spine ceiling already draws. */ +const OVERSHOOT_FACTOR = 1.5; + +describe('CG-30 — an oversize cluster member is bounded, not unbounded', () => { + let testDir: string; + let cg: CodeGraph; + let response: string; + let report: ExploreDiagnosticReport; + let bytes: Map; + + const fileOf = (p: string): ExploreDiagnosticFile => { + const rec = report.files.find((f) => f.path === p); + if (!rec) throw new Error(`${p} absent from the diagnostic report`); + return rec; + }; + /** What the render paths actually bound themselves by. */ + const budgetOf = (rec: ExploreDiagnosticFile): number => rec.spendable ?? rec.allowance ?? 0; + + beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg30-')); + fs.cpSync(FIXTURE_SRC, testDir, { recursive: true }); + fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); + + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + + // The per-file budget is only observable through the diagnostic sidecar, and + // the whole gate is "emitted vs what the file was allowed to spend". + const sidecar = path.join(testDir, 'explore-diag.jsonl'); + const previous = process.env.CODEGRAPH_EXPLORE_DEBUG; + process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar; + try { + const handler = new ToolHandler(cg); + const result = await handler.execute('codegraph_explore', { query: QUERY }); + response = result.content?.[0]?.text ?? ''; + } finally { + if (previous === undefined) delete process.env.CODEGRAPH_EXPLORE_DEBUG; + else process.env.CODEGRAPH_EXPLORE_DEBUG = previous; + } + const written = fs.readFileSync(sidecar, 'utf-8').trim().split('\n').filter(Boolean); + report = JSON.parse(written[written.length - 1]!) as ExploreDiagnosticReport; + bytes = attributeSourceBytes(response); + }, 120_000); + + afterAll(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + // ── Fixture shape — if these rot, the gate below means nothing ───────────── + + describe('fixture shape', () => { + it('holds single members far bigger than any budget they can earn', () => { + for (const file of [GIANT, STARVED]) { + const source = fs.readFileSync(path.join(testDir, file), 'utf-8'); + const top = cg.getNodesInFile(file) + .filter((n) => n.kind === 'function') + .sort((a, b) => (b.endLine - b.startLine) - (a.endLine - a.startLine))[0]; + expect(top, `${file} has no function node`).toBeDefined(); + // One symbol, most of the file — the "top member alone is oversize" shape. + expect(top!.endLine - top!.startLine).toBeGreaterThan(180); + expect(source.length).toBeGreaterThan(budgetOf(fileOf(file)) * 2); + } + }); + + it('is too long to ship whole, so both render through the cluster path', () => { + for (const file of [GIANT, STARVED]) { + const lineCount = fs.readFileSync(path.join(testDir, file), 'utf-8').split('\n').length; + // Past WHOLE_FILE_MAX_LINES (220 for a non-central file), so the + // whole-file paths — grace and buy — cannot claim it. + expect(lineCount, file).toBeGreaterThan(220); + expect(fileOf(file).render, file).toBe('clusters'); + } + }); + }); + + // ── The gate ────────────────────────────────────────────────────────────── + + describe('bounded overshoot', () => { + it('CG-30 GATE: the giant no longer emits a multiple of its budget', () => { + const rec = fileOf(GIANT); + // Pre-fix this file emitted 12,391 on a 3,334 budget (3.7x). + expect(rec.emittedChars).toBeLessThanOrEqual( + Math.round(budgetOf(rec) * OVERSHOOT_FACTOR) + 1); + }); + + it('CG-30 GATE: no clustered file emits past 1.5x what it may spend', () => { + const over = report.files + .filter((f) => f.render === 'clusters' && budgetOf(f) > 0) + .filter((f) => f.emittedChars > Math.round(budgetOf(f) * OVERSHOOT_FACTOR) + 1) + .map((f) => `${f.path}: ${f.emittedChars} of ${budgetOf(f)}`); + expect(over).toEqual([]); + }); + + it('CG-31: the file the overshoot used to starve is delivered', () => { + // Pre-fix: dropped with skip reason `budget-clusters` — the giant above it + // had already spent the headroom this file needed. + expect(fileOf(STARVED).skipped).toBeNull(); + expect(bytes.get(STARVED) ?? 0).toBeGreaterThan(0); + }); + + it('never emits an empty section — the invariant the old rule protected', () => { + for (const rec of report.files) { + if (rec.render !== 'clusters') continue; + expect(rec.emittedChars, rec.path).toBeGreaterThan(0); + } + // And the windowed file still leads with the symbol the query named. + expect(response).toContain('export function buildMonthlyReport'); + }); + + it('cuts on whole lines — a body is never sliced mid-line', () => { + const source = fs.readFileSync(path.join(testDir, GIANT), 'utf-8').split('\n'); + const numbered = response + .split('\n') + .map((l) => /^(\d+)\t(.*)$/.exec(l)) + .filter((m): m is RegExpExecArray => m !== null) + .filter((m) => Number(m[1]) >= 1 && Number(m[1]) <= source.length); + const matching = numbered.filter((m) => source[Number(m[1]) - 1] === m[2]); + // Every line the response numbers for this file is that whole source line. + expect(matching.length).toBeGreaterThan(20); + }); + + it('reports the cut rather than presenting a window as the whole file', () => { + expect(fileOf(GIANT).clipped).toBe(true); + }); + + it('keeps the response inside the hard ceiling', () => { + expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling); + }); + }); +}); diff --git a/__tests__/fixtures/oversize-member-ts/package.json b/__tests__/fixtures/oversize-member-ts/package.json new file mode 100644 index 0000000..6c8f80e --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/package.json @@ -0,0 +1,6 @@ +{ + "name": "oversize-member-fixture", + "version": "1.0.0", + "private": true, + "type": "module" +} diff --git a/__tests__/fixtures/oversize-member-ts/src/index.ts b/__tests__/fixtures/oversize-member-ts/src/index.ts new file mode 100644 index 0000000..156b0bd --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/index.ts @@ -0,0 +1,14 @@ +import { buildMonthlyReport } from './report/monthly'; +import { buildWeeklyReport } from './report/weekly'; +import { buildQuarterlyReport } from './report/quarterly'; +import { formatReportRows } from './report/format'; +import type { Ledger, ReportOptions } from './report/types'; + +/** Run every report for a ledger and render them. */ +export function runReports(ledger: Ledger, options: ReportOptions): string { + return [ + formatReportRows(buildMonthlyReport(ledger, options)), + formatReportRows(buildWeeklyReport(ledger, options)), + formatReportRows(buildQuarterlyReport(ledger, options)), + ].join('\n\n'); +} diff --git a/__tests__/fixtures/oversize-member-ts/src/report/format.ts b/__tests__/fixtures/oversize-member-ts/src/report/format.ts new file mode 100644 index 0000000..e1af698 --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/report/format.ts @@ -0,0 +1,22 @@ +import type { ReportRow } from './types'; + +/** Format one category total as a report row. */ +export function formatReportRow(category: string, amountCents: number, currency: string): ReportRow { + return { + category, + amount: formatAmount(amountCents), + currency, + }; +} + +/** Render cents as a fixed-point amount. */ +export function formatAmount(amountCents: number): string { + const sign = amountCents < 0 ? '-' : ''; + const abs = Math.abs(amountCents); + return `${sign}${Math.floor(abs / 100)}.${String(abs % 100).padStart(2, '0')}`; +} + +/** Render a set of rows as plain text. */ +export function formatReportRows(rows: ReportRow[]): string { + return rows.map((row) => `${row.category}\t${row.amount} ${row.currency}`).join('\n'); +} diff --git a/__tests__/fixtures/oversize-member-ts/src/report/monthly.ts b/__tests__/fixtures/oversize-member-ts/src/report/monthly.ts new file mode 100644 index 0000000..4e90fa4 --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/report/monthly.ts @@ -0,0 +1,509 @@ +import { formatReportRow } from './format'; +import { persistReport } from './store'; +import type { Ledger, ReportOptions, ReportRow } from './types'; + +/** + * Build the monthly report for one ledger. + * + * Every expense category is accrued in its own block so the finance team can + * read the month end-to-end in one place; the shape is deliberately flat. + */ +export function buildMonthlyReport(ledger: Ledger, options: ReportOptions): ReportRow[] { + const rows: ReportRow[] = []; + const totals = new Map(); + + // 1. payroll — accrue the payroll component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'payroll'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('payroll', adjusted, options.currency)); + totals.set('payroll', adjusted); + } + } + + // 2. benefits — accrue the benefits component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'benefits'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('benefits', adjusted, options.currency)); + totals.set('benefits', adjusted); + } + } + + // 3. travel — accrue the travel component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'travel'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('travel', adjusted, options.currency)); + totals.set('travel', adjusted); + } + } + + // 4. equipment — accrue the equipment component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'equipment'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('equipment', adjusted, options.currency)); + totals.set('equipment', adjusted); + } + } + + // 5. software — accrue the software component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'software'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('software', adjusted, options.currency)); + totals.set('software', adjusted); + } + } + + // 6. contractors — accrue the contractors component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'contractors'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('contractors', adjusted, options.currency)); + totals.set('contractors', adjusted); + } + } + + // 7. marketing — accrue the marketing component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'marketing'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('marketing', adjusted, options.currency)); + totals.set('marketing', adjusted); + } + } + + // 8. training — accrue the training component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'training'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('training', adjusted, options.currency)); + totals.set('training', adjusted); + } + } + + // 9. utilities — accrue the utilities component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'utilities'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('utilities', adjusted, options.currency)); + totals.set('utilities', adjusted); + } + } + + // 10. rent — accrue the rent component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'rent'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('rent', adjusted, options.currency)); + totals.set('rent', adjusted); + } + } + + // 11. insurance — accrue the insurance component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'insurance'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('insurance', adjusted, options.currency)); + totals.set('insurance', adjusted); + } + } + + // 12. legal — accrue the legal component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'legal'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('legal', adjusted, options.currency)); + totals.set('legal', adjusted); + } + } + + // 13. shipping — accrue the shipping component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'shipping'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('shipping', adjusted, options.currency)); + totals.set('shipping', adjusted); + } + } + + // 14. hosting — accrue the hosting component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'hosting'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('hosting', adjusted, options.currency)); + totals.set('hosting', adjusted); + } + } + + // 15. support — accrue the support component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'support'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('support', adjusted, options.currency)); + totals.set('support', adjusted); + } + } + + // 16. recruiting — accrue the recruiting component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'recruiting'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('recruiting', adjusted, options.currency)); + totals.set('recruiting', adjusted); + } + } + + // 17. licenses — accrue the licenses component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'licenses'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('licenses', adjusted, options.currency)); + totals.set('licenses', adjusted); + } + } + + // 18. taxes — accrue the taxes component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'taxes'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('taxes', adjusted, options.currency)); + totals.set('taxes', adjusted); + } + } + + // 19. refunds — accrue the refunds component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'refunds'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('refunds', adjusted, options.currency)); + totals.set('refunds', adjusted); + } + } + + // 20. discounts — accrue the discounts component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'discounts'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('discounts', adjusted, options.currency)); + totals.set('discounts', adjusted); + } + } + + // 21. interest — accrue the interest component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'interest'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('interest', adjusted, options.currency)); + totals.set('interest', adjusted); + } + } + + // 22. depreciation — accrue the depreciation component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'depreciation'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('depreciation', adjusted, options.currency)); + totals.set('depreciation', adjusted); + } + } + + // 23. maintenance — accrue the maintenance component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'maintenance'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('maintenance', adjusted, options.currency)); + totals.set('maintenance', adjusted); + } + } + + // 24. subscriptions — accrue the subscriptions component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'subscriptions'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('subscriptions', adjusted, options.currency)); + totals.set('subscriptions', adjusted); + } + } + + // 25. hardware — accrue the hardware component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'hardware'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('hardware', adjusted, options.currency)); + totals.set('hardware', adjusted); + } + } + + // 26. catering — accrue the catering component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'catering'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('catering', adjusted, options.currency)); + totals.set('catering', adjusted); + } + } + + // 27. conferences — accrue the conferences component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'conferences'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('conferences', adjusted, options.currency)); + totals.set('conferences', adjusted); + } + } + + // 28. advertising — accrue the advertising component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'advertising'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('advertising', adjusted, options.currency)); + totals.set('advertising', adjusted); + } + } + + // 29. research — accrue the research component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'research'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('research', adjusted, options.currency)); + totals.set('research', adjusted); + } + } + + // 30. logistics — accrue the logistics component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'logistics'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('logistics', adjusted, options.currency)); + totals.set('logistics', adjusted); + } + } + + // 31. warranty — accrue the warranty component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'warranty'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('warranty', adjusted, options.currency)); + totals.set('warranty', adjusted); + } + } + + // 32. penalties — accrue the penalties component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'penalties'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('penalties', adjusted, options.currency)); + totals.set('penalties', adjusted); + } + } + + // 33. bonuses — accrue the bonuses component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'bonuses'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('bonuses', adjusted, options.currency)); + totals.set('bonuses', adjusted); + } + } + + // 34. commissions — accrue the commissions component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'commissions'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('commissions', adjusted, options.currency)); + totals.set('commissions', adjusted); + } + } + + // 35. relocation — accrue the relocation component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'relocation'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('relocation', adjusted, options.currency)); + totals.set('relocation', adjusted); + } + } + + // 36. tooling — accrue the tooling component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'tooling'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('tooling', adjusted, options.currency)); + totals.set('tooling', adjusted); + } + } + + // 37. audit — accrue the audit component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'audit'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('audit', adjusted, options.currency)); + totals.set('audit', adjusted); + } + } + + // 38. compliance — accrue the compliance component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'compliance'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('compliance', adjusted, options.currency)); + totals.set('compliance', adjusted); + } + } + + // 39. storage — accrue the storage component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'storage'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('storage', adjusted, options.currency)); + totals.set('storage', adjusted); + } + } + + // 40. bandwidth — accrue the bandwidth component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'bandwidth'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('bandwidth', adjusted, options.currency)); + totals.set('bandwidth', adjusted); + } + } + + const grandTotal = [...totals.values()].reduce((sum, value) => sum + value, 0); + rows.push(formatReportRow('total', grandTotal, options.currency)); + persistReport(ledger.periodId, rows); + return rows; +} + +/** Header line for a rendered monthly report. */ +export function monthlyReportHeader(ledger: Ledger, options: ReportOptions): string { + return `Monthly report ${ledger.periodId} (${options.currency})`; +} + +/** Footer line for a rendered monthly report. */ +export function monthlyReportFooter(rows: ReportRow[]): string { + return `${rows.length} categories reported`; +} diff --git a/__tests__/fixtures/oversize-member-ts/src/report/quarterly.ts b/__tests__/fixtures/oversize-member-ts/src/report/quarterly.ts new file mode 100644 index 0000000..d8fc5f0 --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/report/quarterly.ts @@ -0,0 +1,235 @@ +import { formatReportRow } from './format'; +import { persistReport } from './store'; +import type { Ledger, ReportOptions, ReportRow } from './types'; + +/** Build the quarterly report for one ledger. */ +export function buildQuarterlyReport(ledger: Ledger, options: ReportOptions): ReportRow[] { + const rows: ReportRow[] = []; + const totals = new Map(); + + // 1. insurance — accrue the insurance component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'insurance'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('insurance', adjusted, options.currency)); + totals.set('insurance', adjusted); + } + } + + // 2. legal — accrue the legal component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'legal'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('legal', adjusted, options.currency)); + totals.set('legal', adjusted); + } + } + + // 3. shipping — accrue the shipping component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'shipping'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('shipping', adjusted, options.currency)); + totals.set('shipping', adjusted); + } + } + + // 4. hosting — accrue the hosting component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'hosting'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('hosting', adjusted, options.currency)); + totals.set('hosting', adjusted); + } + } + + // 5. support — accrue the support component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'support'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('support', adjusted, options.currency)); + totals.set('support', adjusted); + } + } + + // 6. recruiting — accrue the recruiting component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'recruiting'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('recruiting', adjusted, options.currency)); + totals.set('recruiting', adjusted); + } + } + + // 7. licenses — accrue the licenses component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'licenses'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('licenses', adjusted, options.currency)); + totals.set('licenses', adjusted); + } + } + + // 8. taxes — accrue the taxes component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'taxes'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('taxes', adjusted, options.currency)); + totals.set('taxes', adjusted); + } + } + + // 9. refunds — accrue the refunds component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'refunds'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('refunds', adjusted, options.currency)); + totals.set('refunds', adjusted); + } + } + + // 10. discounts — accrue the discounts component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'discounts'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('discounts', adjusted, options.currency)); + totals.set('discounts', adjusted); + } + } + + // 11. interest — accrue the interest component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'interest'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('interest', adjusted, options.currency)); + totals.set('interest', adjusted); + } + } + + // 12. depreciation — accrue the depreciation component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'depreciation'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('depreciation', adjusted, options.currency)); + totals.set('depreciation', adjusted); + } + } + + // 13. maintenance — accrue the maintenance component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'maintenance'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('maintenance', adjusted, options.currency)); + totals.set('maintenance', adjusted); + } + } + + // 14. subscriptions — accrue the subscriptions component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'subscriptions'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('subscriptions', adjusted, options.currency)); + totals.set('subscriptions', adjusted); + } + } + + // 15. hardware — accrue the hardware component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'hardware'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('hardware', adjusted, options.currency)); + totals.set('hardware', adjusted); + } + } + + // 16. catering — accrue the catering component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'catering'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('catering', adjusted, options.currency)); + totals.set('catering', adjusted); + } + } + + // 17. conferences — accrue the conferences component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'conferences'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('conferences', adjusted, options.currency)); + totals.set('conferences', adjusted); + } + } + + // 18. advertising — accrue the advertising component of the month. + { + const bucket = ledger.entries.filter((entry) => entry.category === 'advertising'); + const gross = bucket.reduce((sum, entry) => sum + entry.amountCents, 0); + const pending = bucket.filter((entry) => entry.pending).reduce((s, e) => s + e.amountCents, 0); + const adjusted = options.includePending ? gross : gross - pending; + if (adjusted !== 0 || options.includeEmptyCategories) { + rows.push(formatReportRow('advertising', adjusted, options.currency)); + totals.set('advertising', adjusted); + } + } + + const grandTotal = [...totals.values()].reduce((sum, value) => sum + value, 0); + rows.push(formatReportRow('total', grandTotal, options.currency)); + persistReport(ledger.periodId, rows); + return rows; +} + +/** Header line for a rendered quarterly report. */ +export function buildQuarterlyReportHeader(ledger: Ledger, options: ReportOptions): string { + return `quarterly report ${ledger.periodId} (${options.currency})`; +} diff --git a/__tests__/fixtures/oversize-member-ts/src/report/store.ts b/__tests__/fixtures/oversize-member-ts/src/report/store.ts new file mode 100644 index 0000000..4d65851 --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/report/store.ts @@ -0,0 +1,18 @@ +import type { ReportRow } from './types'; + +const saved = new Map(); + +/** Persist a built report for a period. */ +export function persistReport(periodId: string, rows: ReportRow[]): void { + saved.set(periodId, rows); +} + +/** Read back a persisted report. */ +export function loadReport(periodId: string): ReportRow[] { + return saved.get(periodId) ?? []; +} + +/** Drop a persisted report. */ +export function clearReport(periodId: string): void { + saved.delete(periodId); +} diff --git a/__tests__/fixtures/oversize-member-ts/src/report/types.ts b/__tests__/fixtures/oversize-member-ts/src/report/types.ts new file mode 100644 index 0000000..4bad635 --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/report/types.ts @@ -0,0 +1,28 @@ +/** One posted ledger entry. */ +export interface LedgerEntry { + id: string; + category: string; + amountCents: number; + pending: boolean; + postedAt: string; +} + +/** A period's ledger. */ +export interface Ledger { + periodId: string; + entries: LedgerEntry[]; +} + +/** How a report should be built. */ +export interface ReportOptions { + currency: string; + includePending: boolean; + includeEmptyCategories: boolean; +} + +/** One rendered report line. */ +export interface ReportRow { + category: string; + amount: string; + currency: string; +} diff --git a/__tests__/fixtures/oversize-member-ts/src/report/weekly.ts b/__tests__/fixtures/oversize-member-ts/src/report/weekly.ts new file mode 100644 index 0000000..9a81d3a --- /dev/null +++ b/__tests__/fixtures/oversize-member-ts/src/report/weekly.ts @@ -0,0 +1,372 @@ +import { formatReportRow } from './format'; +import { persistReport } from './store'; +import type { Ledger, ReportOptions, ReportRow } from './types'; + +/** Total the posted entries in one category. */ +function sumOf(ledger: Ledger, category: string): number { + return ledger.entries + .filter((entry) => entry.category === category && !entry.pending) + .reduce((sum, entry) => sum + entry.amountCents, 0); +} + +/** Total the still-pending entries in one category. */ +function pendingOf(ledger: Ledger, category: string): number { + return ledger.entries + .filter((entry) => entry.category === category && entry.pending) + .reduce((sum, entry) => sum + entry.amountCents, 0); +} + +/** Build the weekly report for one ledger. */ +export function buildWeeklyReport(ledger: Ledger, options: ReportOptions): ReportRow[] { + const rows: ReportRow[] = []; + const totals = new Map(); + + // 1. payroll + { + const gross = sumOf(ledger, 'payroll'); + const held = pendingOf(ledger, 'payroll'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('payroll', net, options.currency)); + totals.set('payroll', net); + } + } + + // 2. benefits + { + const gross = sumOf(ledger, 'benefits'); + const held = pendingOf(ledger, 'benefits'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('benefits', net, options.currency)); + totals.set('benefits', net); + } + } + + // 3. travel + { + const gross = sumOf(ledger, 'travel'); + const held = pendingOf(ledger, 'travel'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('travel', net, options.currency)); + totals.set('travel', net); + } + } + + // 4. equipment + { + const gross = sumOf(ledger, 'equipment'); + const held = pendingOf(ledger, 'equipment'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('equipment', net, options.currency)); + totals.set('equipment', net); + } + } + + // 5. software + { + const gross = sumOf(ledger, 'software'); + const held = pendingOf(ledger, 'software'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('software', net, options.currency)); + totals.set('software', net); + } + } + + // 6. contractors + { + const gross = sumOf(ledger, 'contractors'); + const held = pendingOf(ledger, 'contractors'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('contractors', net, options.currency)); + totals.set('contractors', net); + } + } + + // 7. marketing + { + const gross = sumOf(ledger, 'marketing'); + const held = pendingOf(ledger, 'marketing'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('marketing', net, options.currency)); + totals.set('marketing', net); + } + } + + // 8. training + { + const gross = sumOf(ledger, 'training'); + const held = pendingOf(ledger, 'training'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('training', net, options.currency)); + totals.set('training', net); + } + } + + // 9. utilities + { + const gross = sumOf(ledger, 'utilities'); + const held = pendingOf(ledger, 'utilities'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('utilities', net, options.currency)); + totals.set('utilities', net); + } + } + + // 10. rent + { + const gross = sumOf(ledger, 'rent'); + const held = pendingOf(ledger, 'rent'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('rent', net, options.currency)); + totals.set('rent', net); + } + } + + // 11. insurance + { + const gross = sumOf(ledger, 'insurance'); + const held = pendingOf(ledger, 'insurance'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('insurance', net, options.currency)); + totals.set('insurance', net); + } + } + + // 12. legal + { + const gross = sumOf(ledger, 'legal'); + const held = pendingOf(ledger, 'legal'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('legal', net, options.currency)); + totals.set('legal', net); + } + } + + // 13. shipping + { + const gross = sumOf(ledger, 'shipping'); + const held = pendingOf(ledger, 'shipping'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('shipping', net, options.currency)); + totals.set('shipping', net); + } + } + + // 14. hosting + { + const gross = sumOf(ledger, 'hosting'); + const held = pendingOf(ledger, 'hosting'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('hosting', net, options.currency)); + totals.set('hosting', net); + } + } + + // 15. support + { + const gross = sumOf(ledger, 'support'); + const held = pendingOf(ledger, 'support'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('support', net, options.currency)); + totals.set('support', net); + } + } + + // 16. recruiting + { + const gross = sumOf(ledger, 'recruiting'); + const held = pendingOf(ledger, 'recruiting'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('recruiting', net, options.currency)); + totals.set('recruiting', net); + } + } + + // 17. licenses + { + const gross = sumOf(ledger, 'licenses'); + const held = pendingOf(ledger, 'licenses'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('licenses', net, options.currency)); + totals.set('licenses', net); + } + } + + // 18. taxes + { + const gross = sumOf(ledger, 'taxes'); + const held = pendingOf(ledger, 'taxes'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('taxes', net, options.currency)); + totals.set('taxes', net); + } + } + + // 19. refunds + { + const gross = sumOf(ledger, 'refunds'); + const held = pendingOf(ledger, 'refunds'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('refunds', net, options.currency)); + totals.set('refunds', net); + } + } + + // 20. discounts + { + const gross = sumOf(ledger, 'discounts'); + const held = pendingOf(ledger, 'discounts'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('discounts', net, options.currency)); + totals.set('discounts', net); + } + } + + // 21. interest + { + const gross = sumOf(ledger, 'interest'); + const held = pendingOf(ledger, 'interest'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('interest', net, options.currency)); + totals.set('interest', net); + } + } + + // 22. depreciation + { + const gross = sumOf(ledger, 'depreciation'); + const held = pendingOf(ledger, 'depreciation'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('depreciation', net, options.currency)); + totals.set('depreciation', net); + } + } + + // 23. maintenance + { + const gross = sumOf(ledger, 'maintenance'); + const held = pendingOf(ledger, 'maintenance'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('maintenance', net, options.currency)); + totals.set('maintenance', net); + } + } + + // 24. subscriptions + { + const gross = sumOf(ledger, 'subscriptions'); + const held = pendingOf(ledger, 'subscriptions'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('subscriptions', net, options.currency)); + totals.set('subscriptions', net); + } + } + + // 25. hardware + { + const gross = sumOf(ledger, 'hardware'); + const held = pendingOf(ledger, 'hardware'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('hardware', net, options.currency)); + totals.set('hardware', net); + } + } + + // 26. catering + { + const gross = sumOf(ledger, 'catering'); + const held = pendingOf(ledger, 'catering'); + const net = options.includePending + ? gross + : gross - held; + if (net !== 0) { + rows.push(formatReportRow('catering', net, options.currency)); + totals.set('catering', net); + } + } + + const grandTotal = [...totals.values()] + .reduce((sum, value) => sum + value, 0); + rows.push(formatReportRow('total', grandTotal, options.currency)); + persistReport(ledger.periodId, rows); + return rows; +} + +/** Header line for a rendered weekly report. */ +export function buildWeeklyReportHeader(ledger: Ledger, options: ReportOptions): string { + return `weekly report ${ledger.periodId} (${options.currency})`; +} diff --git a/src/mcp/explore-diagnostics.ts b/src/mcp/explore-diagnostics.ts index 941fd4c..00b4b34 100644 --- a/src/mcp/explore-diagnostics.ts +++ b/src/mcp/explore-diagnostics.ts @@ -92,6 +92,15 @@ interface FileRecord extends ExploreCandidateMeta { * means an oversize first cluster or the whole-file grace overshot. */ allowance: number | null; + /** + * What the file could actually SPEND: its reservation plus the slack the + * files above it left on the table (bounded by MAX_SHARE). Every render bound + * reads this, not `allowance`, so it — not the reservation — is what an + * overshoot is measured against. `null` until the render loop reaches the + * file. Reporting only `allowance` makes an ordinary carry-forward look like + * a file spending over its reservation. + */ + spendable: number | null; render?: ExploreRenderMode; /** * Source chars this call did NOT re-send because an earlier call in the @@ -139,6 +148,8 @@ interface BudgetShape { export interface ExploreDiagnosticFile extends ExploreCandidateMeta { path: string; allowance: number | null; + /** Reservation + inherited slack — the bound the render paths actually use. */ + spendable: number | null; render: ExploreRenderMode | null; skipped: ExploreSkipReason | null; clipped: boolean; @@ -362,7 +373,7 @@ export class ExploreDiagnostics { /** Record one ranked candidate's scoring inputs, in final sort order. */ noteCandidate(path: string, meta: ExploreCandidateMeta): void { this.files.set(path, { - path, ...meta, allowance: null, + path, ...meta, allowance: null, spendable: null, dedupSavedChars: 0, dedupCovered: [], emittedChars: 0, finalChars: 0, share: 0, allocatedShare: 0, clipped: false, }); @@ -391,6 +402,15 @@ export class ExploreDiagnostics { } } + /** + * What the render loop will let this file spend — reservation plus inherited + * slack. Called once per file, before any of its render paths run. + */ + recordSpendable(path: string, chars: number): void { + const rec = this.files.get(path); + if (rec) rec.spendable = chars; + } + /** A candidate rendered source into the response. */ recordRender(path: string, render: ExploreRenderMode, sourceChars: number, clipped: boolean): void { const rec = this.files.get(path); @@ -538,6 +558,7 @@ export class ExploreDiagnostics { penalty: round6(r.penalty), kinds: r.kinds, allowance: r.allowance, + spendable: r.spendable, render: r.render ?? null, skipped: r.skipped ?? null, clipped: r.clipped, @@ -704,6 +725,11 @@ export function renderTable(report: ExploreDiagnosticReport): string { f.path, ); out.push(' kinds: ' + (f.kinds || '-')); + // Only when it differs: a file that spent over `reserved` but inside + // `spendable` took inherited slack, not a budget bug. + if (f.spendable !== null && f.allowance !== null && f.spendable !== f.allowance) { + out.push(` spendable: ${num(f.spendable)} (reservation + inherited slack)`); + } if (f.dedupSavedChars > 0) { const spans = f.dedupCovered.slice(0, 6).map(([a, b]) => (a === b ? `${a}` : `${a}-${b}`)).join(','); const more = f.dedupCovered.length > 6 ? `,+${f.dedupCovered.length - 6}` : ''; diff --git a/src/mcp/tools.ts b/src/mcp/tools.ts index 4a6e1a5..2655827 100644 --- a/src/mcp/tools.ts +++ b/src/mcp/tools.ts @@ -4093,6 +4093,7 @@ export class ToolHandler { Math.max(reserved, Math.round(budget.maxOutputChars * EXPLORE_ALLOCATION.MAX_SHARE)), ); reservedSoFar += reserved; + diag?.recordSpendable(filePath, allowance); const absPath = validatePathWithinRoot(projectRoot, filePath); if (!absPath || !existsSync(absPath)) { diag?.recordSkip(filePath, 'unreadable'); @@ -4731,7 +4732,9 @@ export class ToolHandler { for (const r of byImportance) { const sz = sizeOf(r) + GAP_MARKER.length; // Always keep the most important range, even if it alone is oversize — - // an empty section sends the agent to Read, which costs far more. + // an empty section sends the agent to Read, which costs far more. How + // far it may overshoot is bounded by the caller's ceiling (CG-30), which + // windows a runaway member instead of dropping it. if (keep.length > 0 && kept + sz > cap) continue; keep.push(r); kept += sz; @@ -4749,6 +4752,117 @@ export class ToolHandler { return merged.flatMap((m) => buildSection(m)); }; + /** + * Bounded overshoot for one cluster's render (CG-30). + * + * `shrinkCluster` keeps the highest-importance member whole even when that + * member alone is oversize — an empty file section sends the agent to Read, + * which is exactly what explore exists to prevent. But "never empty" is not + * "any size": with nothing bounding it, one 22K member rendered against a + * 9K reservation (2.4x), which collapses the headroom every file ranked + * below it draws from. Past the ceiling the member is WINDOWED rather than + * dropped — a leading window (signature + head of the body), plus a window + * on the spine's call site when the head misses it, since on a flow cluster + * the call path IS the answer. + */ + const MIN_WINDOW_LINES = 12; + /** Rendered cost of one source line, line numbering included. */ + const lineCost = (ln: number): number => + (fileLines[ln - 1] ?? '').length + 1 + (withLineNumbers ? String(ln).length + 1 : 0); + /** + * Longest prefix of `r` that fits `room`. `minLines` is the never-empty + * floor — it may overrun `room`, so it is only ever asked for when nothing + * else has been emitted and the alternative is an empty section. + */ + const headWindowOf = ( + r: ExploreLineRange, room: number, minLines = 0, + ): ExploreLineRange | null => { + let end = r.start - 1; + let chars = 0; + for (let ln = r.start; ln <= r.end; ln++) { + const cost = lineCost(ln); + if (chars + cost > room && end - r.start + 1 >= minLines) break; + chars += cost; + end = ln; + } + return end >= r.start ? { start: r.start, end } : null; + }; + /** Widest window around `line` inside [lo, hi] that fits `room`. */ + const centeredWindowOf = ( + line: number, lo: number, hi: number, room: number, + ): ExploreLineRange | null => { + if (line < lo || line > hi) return null; + let start = line, end = line, chars = lineCost(line); + for (let grown = true; grown;) { + grown = false; + if (end + 1 <= hi && chars + lineCost(end + 1) <= room) { end += 1; chars += lineCost(end); grown = true; } + if (start - 1 >= lo && chars + lineCost(start - 1) <= room) { start -= 1; chars += lineCost(start); grown = true; } + } + return { start, end }; + }; + /** + * Reduce rendered parts to fit `ceiling`, never to nothing. Whole parts are + * kept while they fit; the first part that overruns is cut to a leading + * window on whole lines (a body is never cut mid-line), and everything past + * it is dropped. The GAP_MARKER between surviving parts — and the line-number + * jump — is what tells the agent the cut happened. + * + * A partial window shorter than MIN_WINDOW_LINES is not worth emitting, and + * emitting one is actively harmful: the session record then claims a 4-line + * sliver, and the NEXT call's dedup has to either shred a whole block around + * it or re-send it. Below that floor the part is simply dropped — unless + * nothing has been emitted at all, where the floor wins over the ceiling + * because an empty section is the one outcome worse than an oversize one. + */ + const windowToCeiling = ( + parts: ReadonlyArray, + ceiling: number, + focusLine?: number, + ): SectionPart[] => { + const emit: ExploreLineRange[] = []; + const inParts = (line: number) => + parts.some((p) => line >= p.range.start && line <= p.range.end); + const needFocus = typeof focusLine === 'number' && focusLine > 0 && inParts(focusLine); + // Hold room back for the call site so the head window can't eat all of it. + const headRoom = needFocus ? Math.floor(ceiling * 0.6) : ceiling; + let used = 0; + for (const p of parts) { + const join = emit.length > 0 ? GAP_MARKER.length : 0; + if (used + join + p.text.length <= headRoom) { + emit.push(p.range); + used += join + p.text.length; + continue; + } + const first = emit.length === 0; + const win = headWindowOf( + p.range, Math.max(0, headRoom - used - join), first ? MIN_WINDOW_LINES : 0); + if (win && (first || win.end - win.start + 1 >= MIN_WINDOW_LINES)) { + emit.push(win); + used += join + renderSpan(win).length; + } + break; + } + const last = emit[emit.length - 1]; + if (needFocus && (!last || focusLine! > last.end)) { + const host = parts.find((p) => focusLine! >= p.range.start && focusLine! <= p.range.end)!; + const lo = Math.max(host.range.start, focusLine! - SPINE_WINDOW, last ? last.end + 1 : 0); + const hi = Math.min(host.range.end, focusLine! + SPINE_WINDOW); + const win = centeredWindowOf( + focusLine!, lo, hi, Math.max(0, ceiling - used - GAP_MARKER.length)); + // Same sliver floor as the head window — a two-line peek at the call + // site teaches the next call's dedup to shred the block around it. + if (win && win.end - win.start + 1 >= MIN_WINDOW_LINES) emit.push(win); + } + // Never empty: a section with no source sends the agent to Read. + if (emit.length === 0 && parts.length > 0) { + const first = headWindowOf(parts[0]!.range, ceiling, MIN_WINDOW_LINES); + if (first) emit.push(first); + } + return emit + .sort((a, b) => a.start - b.start) + .map((r) => ({ range: r, text: renderSpan(r) })); + }; + /** * One cluster's final parts: built, shrunk if it overruns `cap`, then * passed through the session history (CG-18). @@ -4761,15 +4875,33 @@ export class ToolHandler { const renderCluster = ( c: ExploreCluster, cap: number, + /** + * Hard bound on the rendered result (CG-30). `cap` is what selection asks + * for; this is how far a single oversize member is allowed to overshoot it + * before being windowed. Always >= `cap`, so a cluster that already fits is + * never touched. + */ + ceiling: number = Infinity, ): { parts: SectionPart[]; covered: ExploreLineRange[]; shrunk: boolean } => { const base = dedupeSpans(buildSection(c)); + const bound = ( + r: { parts: SectionPart[]; covered: ExploreLineRange[]; shrunk: boolean }, + ) => { + if (!Number.isFinite(ceiling) || sectionText(r.parts).length <= ceiling) return r; + // Windows are subsets of spans dedupeSpans already cleared, so the record + // still only ever claims source that was actually sent. + const parts = windowToCeiling(r.parts, ceiling, c.spineCallLine); + return { parts, covered: r.covered, shrunk: true }; + }; if (sectionText(base.parts).length <= cap) { return { parts: base.parts, covered: base.covered, shrunk: false }; } const shrunk = shrinkCluster(c, cap); - if (shrunk === null) return { parts: base.parts, covered: base.covered, shrunk: false }; + if (shrunk === null) { + return bound({ parts: base.parts, covered: base.covered, shrunk: false }); + } const dd = dedupeSpans(shrunk); - return { parts: dd.parts, covered: dd.covered, shrunk: true }; + return bound({ parts: dd.parts, covered: dd.covered, shrunk: true }); }; // Rank clusters for inclusion under the per-file cap. Entry-point @@ -4830,7 +4962,13 @@ export class ToolHandler { // clusters are never shrunk — they either fit or wait for another call. const first = chosenIndices.size === 0; const cap = rc.c.hasSpine ? SPINE_CEILING : fileBudget; - const section = renderCluster(rc.c, first ? cap : Infinity); + // CG-30: shrinking keeps the top member whole however big it is, so bound + // how far that member may overshoot — the same 1.5x-of-reservation bound + // SPINE_CEILING already draws, never below `cap` (a cluster that fits its + // cap is never windowed). A spine cluster's cap already IS that bound, so + // this holds it to it rather than letting the member rule walk past it. + const ceiling = Math.max(cap, SPINE_CEILING); + const section = renderCluster(rc.c, first ? cap : Infinity, first ? ceiling : Infinity); const text = sectionText(section.parts); const sectionLen = text.length + (!first && text.length > 0 ? GAP_MARKER.length : 0); if (first) { @@ -4872,10 +5010,11 @@ export class ToolHandler { // A chosen cluster is a COMPLETE method-range — we never cut through a body, // and a shrunk cluster drops WHOLE members for the same reason. An oversize - // single MEMBER (one long monolithic function) still renders in full: half a - // method is useless (the agent just Reads the rest for the other half), which - // is the very fallback explore exists to prevent. A pathological file is - // bounded by the cluster SELECTION above + the total hard ceiling. + // single MEMBER (one long monolithic function) is kept whole for as long as + // it fits the bounded overshoot (half a method is useless — the agent just + // Reads the rest, the fallback explore exists to prevent); past that bound it + // is WINDOWED on whole lines rather than dropped (CG-30), so a god-method + // can neither be silently lost nor spend the response's whole envelope. if (chosenIndices.size < clusters.length || anyClusterShrunk) { anyFileTrimmed = true; } @@ -4928,7 +5067,9 @@ export class ToolHandler { covered: mergeRanges(coveredRanges), overhead: 200, mode: 'clusters', - clipped: chosenIndices.size < clusters.length, + // Windowing an oversize member elides source too — reporting it as + // unclipped would hide exactly the cut the diagnostic exists to show. + clipped: chosenIndices.size < clusters.length || anyClusterShrunk, fullBody: sectionText(fullClusterParts), fullRanges: fullClusterParts.map((p) => p.range), });