diff --git a/cspell.json b/cspell.json index 68861cd..37aa55c 100644 --- a/cspell.json +++ b/cspell.json @@ -17,6 +17,9 @@ "btcbb", "burndown", "bxapp", + "canonify", + "capslock", + "cavecrew", "chartjs", "chatmode", "chatmodes", @@ -49,20 +52,33 @@ "gantt", "ghaw", "glpat", + "goldmark", "harness", "harnesses", "interestingness", + "jukka", "knip", + "kurkela", "leitner", + "linecap", + "linejoin", "lookback", + "markdownify", "metas", "metachars", "metaprogramming", + "monokai", + "multiset", + "nathachai", + "nodata", + "nullglob", "onedrive", "opencode", "optim", + "osascript", "outonly", "pousr", + "precomputation", "prereqs", "prodreview", "promptfile", @@ -82,14 +98,18 @@ "sidechain", "sparkline", "tamas", + "thongniran", "todoread", "tokenless", "toolcall", "toolsmith", + "topbar", "treemap", "tseslint", "undercount", "unparseable", + "unreviewed", + "unsandboxed", "upskilling", "visualbasic", "vitest", diff --git a/docs/content/measure/output.md b/docs/content/measure/output.md index d8e1946..a6a494f 100644 --- a/docs/content/measure/output.md +++ b/docs/content/measure/output.md @@ -17,7 +17,10 @@ The Output page shows your **Code Output** -- how much code your AI assistants h The Code Output tab measures how much code your AI assistants have generated: - **AI-Generated LoC** -- Total estimated lines of code across all sessions +- **Net AI LoC** -- Lines the AI added minus lines the AI removed, across all sessions (can be negative) -The **Daily Production** chart shows lines of code per day as a bar chart. Below it, breakdowns show production split **by language** (TypeScript, CSS, Python, etc.), **by workspace**, **by model**, and **by harness**. +The **Daily AI Code Output** chart shows the net new lines of code the AI assistant wrote per day as a bar chart (it aggregates by week or month over longer ranges). Each edit is compared against the previous version of the file, so only added lines are counted -- re-saving an unchanged file or rewriting the same lines is not double-counted. Below it, breakdowns show output split **by language** (TypeScript, CSS, Python, etc.), **by workspace**, **by model**, and **by harness**. + +The **Net Code Output** charts complement gross output by accounting for deletions. The default chart diverges added lines (above zero) against removed lines (below zero) with a net line overlaid, so a day where the assistant deleted or rewrote more than it added dips below zero. **Net by Model**, **Net by Workspace**, and **Net by Harness** tabs break the same net figure (added minus removed) down by dimension. This reflects the lasting footprint of AI edits on your files rather than just gross volume. Time range selectors let you view the last 7 days, 4 weeks, 3 months, 6 months, or all time. diff --git a/scripts/benchmark-reload-stability.ts b/scripts/benchmark-reload-stability.ts index fd81b7f..2840239 100644 --- a/scripts/benchmark-reload-stability.ts +++ b/scripts/benchmark-reload-stability.ts @@ -40,7 +40,7 @@ type WorkerPayload = { result: { workspaces: Array<[string, { id: string; name: string; path: string }]>; sessions: import('../src/core/types').Session[]; - editLocIndex: Array<[string, Array<[string, number]>]>; + editLocIndex: Array<[string, Array<[string, { added: number; removed: number }]>]>; }; }; diff --git a/src/core/analyzer-base.ts b/src/core/analyzer-base.ts index 5369bb7..66bae2b 100644 --- a/src/core/analyzer-base.ts +++ b/src/core/analyzer-base.ts @@ -6,14 +6,15 @@ /* Base class for analyzer modules -- provides shared filtering logic */ import { Session, SessionRequest, DateFilter } from './types'; +import { EditLocIndex } from './edit-loc-diff'; import { toDateStr } from './helpers'; export class AnalyzerBase { protected readonly sessions: Session[]; - protected readonly editLocIndex: Map>; + protected readonly editLocIndex: EditLocIndex; protected readonly requestSessionMap: Map; - constructor(sessions: Session[], editLocIndex: Map>, sharedMap?: Map) { + constructor(sessions: Session[], editLocIndex: EditLocIndex, sharedMap?: Map) { this.sessions = sessions; this.editLocIndex = editLocIndex; if (sharedMap) { @@ -57,7 +58,7 @@ export class AnalyzerBase { protected requestLoc(r: SessionRequest): number { let loc = r.aiCode.reduce((s, b) => s + b.loc, 0); const eMap = this.editLocIndex.get(r.requestId); - if (eMap) for (const v of eMap.values()) loc += v; + if (eMap) for (const v of eMap.values()) loc += v.added; return loc; } diff --git a/src/core/analyzer-config.ts b/src/core/analyzer-config.ts index 1d5690d..acf6e37 100644 --- a/src/core/analyzer-config.ts +++ b/src/core/analyzer-config.ts @@ -8,6 +8,7 @@ import * as fs from 'fs'; import * as path from 'path'; +import { EditLocIndex } from './edit-loc-diff'; import { Session, DateFilter, Workspace, AntiPattern, OccurrenceDetail, ConfigHealthData, WorkspaceConfigHealth, @@ -32,7 +33,7 @@ import { export class ConfigAnalyzer extends AnalyzerBase { private workspaces: Map; - constructor(sessions: Session[], editLocIndex: Map>, workspaces: Map, sharedMap?: Map) { + constructor(sessions: Session[], editLocIndex: EditLocIndex, workspaces: Map, sharedMap?: Map) { super(sessions, editLocIndex, sharedMap); this.workspaces = workspaces; } diff --git a/src/core/analyzer-patterns.ts b/src/core/analyzer-patterns.ts index 70a8b88..3da9745 100644 --- a/src/core/analyzer-patterns.ts +++ b/src/core/analyzer-patterns.ts @@ -472,7 +472,7 @@ export class PatternsAnalyzer extends AnalyzerBase { estimatedLoc += request.aiCode.reduce((sum, block) => sum + block.loc, 0); const editLocs = this.editLocIndex.get(request.requestId); if (editLocs) { - for (const loc of editLocs.values()) estimatedLoc += loc; + for (const loc of editLocs.values()) estimatedLoc += loc.added; } } return estimatedLoc; diff --git a/src/core/analyzer-production.ts b/src/core/analyzer-production.ts index 5357ed7..482044c 100644 --- a/src/core/analyzer-production.ts +++ b/src/core/analyzer-production.ts @@ -15,13 +15,19 @@ export class ProductionAnalyzer extends AnalyzerBase { getCodeProduction(f?: DateFilter): CodeProductionData { const reqs = this.filter(f); let totalAiLoc = 0; + let totalRemovedAiLoc = 0; let aiBlocks = 0; const langAi = new Map(); const dailyAi = new Map(); + const dailyRemovedAi = new Map(); const wsAi = new Map(); + const wsRemoved = new Map(); const dailyWsAi = new Map>(); + const dailyWsRemoved = new Map>(); const dailyModelAi = new Map>(); + const dailyModelRemoved = new Map>(); const dailyHarnessAi = new Map>(); + const dailyHarnessRemoved = new Map>(); for (const request of reqs) { const day = toDateStr(request.timestamp!); @@ -49,13 +55,20 @@ export class ProductionAnalyzer extends AnalyzerBase { const model = normalizeModel(request.modelId || 'unknown'); const harness = session?.harness || 'unknown'; for (const [file, loc] of editLocs) { - totalAiLoc += loc; - this.addProductionLoc(langAi, file.split('.').pop()?.toLowerCase() || 'unknown', loc); - if (day) this.addProductionLoc(dailyAi, day, loc); - this.addWorkspaceProductionLoc(wsAi, dailyWsAi, workspaceName, day, loc); + totalAiLoc += loc.added; + totalRemovedAiLoc += loc.removed; + this.addProductionLoc(langAi, file.split('.').pop()?.toLowerCase() || 'unknown', loc.added); if (day) { - this.addDailyGroupLoc(dailyModelAi, model, day, loc); - this.addDailyGroupLoc(dailyHarnessAi, harness, day, loc); + this.addProductionLoc(dailyAi, day, loc.added); + this.addProductionLoc(dailyRemovedAi, day, loc.removed); + } + this.addWorkspaceProductionLoc(wsAi, dailyWsAi, workspaceName, day, loc.added); + this.addWorkspaceProductionLoc(wsRemoved, dailyWsRemoved, workspaceName, day, loc.removed); + if (day) { + this.addDailyGroupLoc(dailyModelAi, model, day, loc.added); + this.addDailyGroupLoc(dailyHarnessAi, harness, day, loc.added); + this.addDailyGroupLoc(dailyModelRemoved, model, day, loc.removed); + this.addDailyGroupLoc(dailyHarnessRemoved, harness, day, loc.removed); } } } @@ -80,6 +93,7 @@ export class ProductionAnalyzer extends AnalyzerBase { return { summary: { totalAiLoc, totalUserLoc: 0, totalLoc: totalAiLoc, + totalRemovedAiLoc, totalNetAiLoc: totalAiLoc - totalRemovedAiLoc, aiBlocks, userBlocks: 0, aiRatio: 1, locCost2010, costPerLoc: totalAiLoc > 0 ? locCost2010 / totalAiLoc : 0, @@ -92,6 +106,7 @@ export class ProductionAnalyzer extends AnalyzerBase { dailyTimeline: { labels: dayArr, aiLoc: dayArr.map(d => dailyAi.get(d) || 0), + removedLoc: dayArr.map(d => dailyRemovedAi.get(d) || 0), userLoc: dayArr.map(() => 0), }, byWorkspace: { @@ -99,24 +114,26 @@ export class ProductionAnalyzer extends AnalyzerBase { aiLoc: wsArr.map(w => wsAi.get(w) || 0), userLoc: wsArr.map(() => 0), }, - dailyByWorkspace: Object.fromEntries( - Array.from(dailyWsAi.entries()).map(([ws, dm]) => [ - ws, dayArr.map(d => dm.get(d) || 0), - ]) - ), - dailyByModel: Object.fromEntries( - Array.from(dailyModelAi.entries()).map(([m, dm]) => [ - m, dayArr.map(d => dm.get(d) || 0), - ]) - ), - dailyByHarness: Object.fromEntries( - Array.from(dailyHarnessAi.entries()).map(([h, dm]) => [ - h, dayArr.map(d => dm.get(d) || 0), - ]) - ), + dailyByWorkspace: this.toDailyRecord(dailyWsAi, dayArr), + dailyRemovedByWorkspace: this.toDailyRecord(dailyWsRemoved, dayArr), + dailyByModel: this.toDailyRecord(dailyModelAi, dayArr), + dailyRemovedByModel: this.toDailyRecord(dailyModelRemoved, dayArr), + dailyByHarness: this.toDailyRecord(dailyHarnessAi, dayArr), + dailyRemovedByHarness: this.toDailyRecord(dailyHarnessRemoved, dayArr), }; } + private toDailyRecord( + groupMap: Map>, + dayArr: string[], + ): Record { + return Object.fromEntries( + Array.from(groupMap.entries()).map(([key, dm]) => [ + key, dayArr.map(d => dm.get(d) || 0), + ]) + ); + } + private addProductionLoc(target: Map, key: string, loc: number): void { target.set(key, (target.get(key) || 0) + loc); } diff --git a/src/core/analyzer.test.ts b/src/core/analyzer.test.ts index 7cb033f..f5e87f9 100644 --- a/src/core/analyzer.test.ts +++ b/src/core/analyzer.test.ts @@ -6,6 +6,7 @@ import { describe, it, expect } from 'vitest'; import { Analyzer } from './analyzer'; import { Session, SessionRequest, DateFilter } from './types'; +import { accumulateEditLoc, EditTimelineLike } from './edit-loc-diff'; /** * Simulates panel.ts validateDateFilter -- this is the exact logic @@ -631,3 +632,77 @@ describe('Analyzer', () => { }); }); }); + +describe('getCodeProduction with deduplicated edit LoC', () => { + it('combines aiCode blocks with incrementally-counted edit LoC instead of summing whole-file snapshots', () => { + const URI = 'file:///proj/app.ts'; + const v1 = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n'); + const v2 = v1 + '\nline10'; + const v3 = v2 + '\nline11'; + const wholeFile = (text: string) => [{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1_000_000, endColumn: 1 }, text }]; + const timeline: EditTimelineLike = { + fileBaselines: [[`${URI}::r1`, { uri: { external: URI }, requestId: 'r1', content: v1 }]], + operations: [ + { type: 'textEdit', requestId: 'r1', uri: { external: URI }, epoch: 1, edits: wholeFile(v1) }, + { type: 'textEdit', requestId: 'r1', uri: { external: URI }, epoch: 2, edits: wholeFile(v2) }, + { type: 'textEdit', requestId: 'r1', uri: { external: URI }, epoch: 3, edits: wholeFile(v3) }, + ], + }; + const editLocIndex = new Map>(); + accumulateEditLoc(timeline, editLocIndex); + // The three whole-file snapshots (10 + 11 + 12 lines) collapse to 2 newly-produced lines. + expect(editLocIndex.get('r1')?.get(URI)?.added).toBe(2); + + const ts = new Date(2024, 5, 15, 10, 0, 0).getTime(); + const sessions = [ + makeSession({ + sessionId: 's1', harness: 'Codex', creationDate: ts, workspaceName: 'proj', + requests: [makeRequest({ requestId: 'r1', timestamp: ts, aiCode: [{ language: 'typescript', loc: 5 }] })], + }), + ]; + const a = new Analyzer(sessions, editLocIndex); + const prod = a.getCodeProduction(); + // 5 code-block LoC + 2 deduplicated edit LoC = 7 (not 5 + 33 from naive snapshot summing). + expect(prod.summary.totalAiLoc).toBe(7); + }); + + it('reports net = gross added minus removed and surfaces removed in the daily timeline', () => { + const URI = 'file:///proj/app.ts'; + const wholeFile = (text: string) => [{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1_000_000, endColumn: 1 }, text }]; + const v1 = 'a\nb\nc\nd\ne'; + const v2 = 'a\nb'; // removes c, d, e (3 removed, 0 added) + const timeline: EditTimelineLike = { + fileBaselines: [[`${URI}::r1`, { uri: { external: URI }, requestId: 'r1', content: v1 }]], + operations: [ + { type: 'textEdit', requestId: 'r1', uri: { external: URI }, epoch: 1, edits: wholeFile(v1) }, // equals baseline -> 0/0 + { type: 'textEdit', requestId: 'r1', uri: { external: URI }, epoch: 2, edits: wholeFile(v2) }, // 0 added, 3 removed + ], + }; + const editLocIndex = new Map>(); + accumulateEditLoc(timeline, editLocIndex); + + const ts = new Date(2024, 5, 15, 10, 0, 0).getTime(); + const day = '2024-06-15'; + const sessions = [ + makeSession({ + sessionId: 's1', harness: 'Codex', creationDate: ts, workspaceName: 'proj', + requests: [makeRequest({ requestId: 'r1', timestamp: ts, modelId: 'gpt-5', aiCode: [{ language: 'typescript', loc: 5 }] })], + }), + ]; + const prod = new Analyzer(sessions, editLocIndex).getCodeProduction(); + // Gross = 5 code-block LoC + 0 edit-added; removed = 3; net = 5 - 3 = 2. + expect(prod.summary.totalAiLoc).toBe(5); + expect(prod.summary.totalRemovedAiLoc).toBe(3); + expect(prod.summary.totalNetAiLoc).toBe(2); + + const dayIdx = prod.dailyTimeline.labels.indexOf(day); + expect(dayIdx).toBeGreaterThanOrEqual(0); + expect(prod.dailyTimeline.aiLoc[dayIdx]).toBe(5); + expect(prod.dailyTimeline.removedLoc[dayIdx]).toBe(3); + // Removed is broken down by dimension and aligns with the same day index. + expect(prod.dailyRemovedByModel['gpt-5'][dayIdx]).toBe(3); + expect(prod.dailyRemovedByWorkspace['proj'][dayIdx]).toBe(3); + expect(prod.dailyRemovedByHarness['Codex'][dayIdx]).toBe(3); + }); +}); + diff --git a/src/core/analyzer.ts b/src/core/analyzer.ts index 0cc158b..4320957 100644 --- a/src/core/analyzer.ts +++ b/src/core/analyzer.ts @@ -27,6 +27,7 @@ import { ContextAnalyzer } from './analyzer-context'; import { InsightsAnalyzer } from './analyzer-insights'; import { ImageAnalyzer, ImageGalleryData } from './analyzer-images'; import { AnalyzerBase } from './analyzer-base'; +import { EditLoc, EditLocIndex } from './edit-loc-diff'; import { errorCore, infoCore, warnCore } from './log'; export class Analyzer { @@ -42,12 +43,12 @@ export class Analyzer { private readonly context: ContextAnalyzer; private readonly images: ImageAnalyzer; private readonly sessions: Session[]; - private readonly editLocIndex: Map>; + private readonly editLocIndex: EditLocIndex; private readonly workspaces: Map; private cache = new Map(); - constructor(sessions: Session[], editLocIndex?: Map>, workspaces?: Map) { - const elIdx = editLocIndex ?? new Map>(); + constructor(sessions: Session[], editLocIndex?: EditLocIndex, workspaces?: Map) { + const elIdx = editLocIndex ?? new Map>(); this.sessions = sessions; this.editLocIndex = elIdx; this.workspaces = workspaces ?? new Map(); diff --git a/src/core/cache.ts b/src/core/cache.ts index 8e9b378..a112af8 100644 --- a/src/core/cache.ts +++ b/src/core/cache.ts @@ -14,6 +14,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Session, Workspace } from './types'; +import { EditLoc, EditLocIndex } from './edit-loc-diff'; import { warnCore } from './log'; import { parseSessionFile } from './parser-vscode'; import { parseCLIEventsFile } from './parser-vscode-cli'; @@ -21,7 +22,7 @@ import { parseCLIEventsFile } from './parser-vscode-cli'; export interface ParseResult { workspaces: Map; sessions: Session[]; - editLocIndex: Map>; + editLocIndex: EditLocIndex; sessionSourceIndex: Map; } @@ -92,7 +93,7 @@ const CACHE_DIR = path.join(process.env.HOME || process.env.USERPROFILE || '', ' const CACHE_FILE = path.join(CACHE_DIR, 'parsed.json'); const CACHE_META = path.join(CACHE_DIR, 'meta.json'); -const CACHE_VERSION = 9; +const CACHE_VERSION = 11; /** Refuse to JSON.parse cache files beyond these sizes: a corrupted (or * tampered) cache must degrade to a full re-parse, not OOM the host. */ @@ -107,7 +108,7 @@ interface CacheMetaPayload { interface SerializedCachePayload { workspaces: Array<[string, Workspace]>; sessions: Session[]; - editLocIndex: Array<[string, Array<[string, number]>]>; + editLocIndex: Array<[string, Array<[string, EditLoc]>]>; sessionSourceIndex: Array<[string, SessionSource]>; } @@ -131,7 +132,7 @@ function readSerializedCachePayload(value: unknown): SerializedCachePayload | nu return { workspaces: value.workspaces as Array<[string, Workspace]>, sessions: value.sessions as Session[], - editLocIndex: value.editLocIndex as Array<[string, Array<[string, number]>]>, + editLocIndex: value.editLocIndex as Array<[string, Array<[string, EditLoc]>]>, sessionSourceIndex: Array.isArray(value.sessionSourceIndex) ? value.sessionSourceIndex as Array<[string, SessionSource]> : [], @@ -285,7 +286,7 @@ export async function loadCacheData(): Promise { // Yield after parse to let the event loop breathe await new Promise(r => setTimeout(r, 0)); const workspaces = new Map(raw.workspaces); - const editLocIndex = new Map>(); + const editLocIndex: EditLocIndex = new Map(); for (const [k, v] of raw.editLocIndex) { editLocIndex.set(k, new Map(v)); } diff --git a/src/core/edit-loc-diff.test.ts b/src/core/edit-loc-diff.test.ts new file mode 100644 index 0000000..5e83687 --- /dev/null +++ b/src/core/edit-loc-diff.test.ts @@ -0,0 +1,524 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { describe, it, expect } from 'vitest'; +import { + applyTextEdits, + countLines, + countAddedLines, + countAddedRemoved, + accumulateEditLoc, + EditLoc, + EditLocIndex, + EditOpLike, + EditTimelineLike, + FileBaselineLike, +} from './edit-loc-diff'; + +function uriOp(uri: string, reqId: string, epoch: number, edits: EditOpLike['edits']): EditOpLike { + return { type: 'textEdit', requestId: reqId, uri: { external: uri }, epoch, edits }; +} + +function wholeFile(text: string): EditOpLike['edits'] { + // A whole-file replacement spanning from the start to a far-past-EOF position (apply_patch style). + return [{ range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1_000_000, endColumn: 1 }, text }]; +} + +function baseline(uri: string, reqId: string, content: string): [string, FileBaselineLike] { + return [`${uri}::${reqId}`, { uri: { external: uri }, requestId: reqId, content }]; +} + +function newIndex(): EditLocIndex { + return new Map>(); +} + +function addedFor(index: EditLocIndex, reqId: string, uri: string): number | undefined { + return index.get(reqId)?.get(uri)?.added; +} + +function removedFor(index: EditLocIndex, reqId: string, uri: string): number | undefined { + return index.get(reqId)?.get(uri)?.removed; +} + +function totalFor(index: EditLocIndex, uri: string): number { + let sum = 0; + for (const fileMap of index.values()) { + sum += fileMap.get(uri)?.added ?? 0; + } + return sum; +} + +function totalRemovedFor(index: EditLocIndex, uri: string): number { + let sum = 0; + for (const fileMap of index.values()) { + sum += fileMap.get(uri)?.removed ?? 0; + } + return sum; +} + +describe('countLines', () => { + it('returns 0 for empty string', () => { + expect(countLines('')).toBe(0); + }); + + it('counts a single line without a trailing newline', () => { + expect(countLines('a')).toBe(1); + }); + + it('counts newline-delimited lines', () => { + expect(countLines('a\nb\nc')).toBe(3); + }); + + it('does not double-count a trailing newline', () => { + expect(countLines('a\nb\nc\n')).toBe(3); + }); +}); + +describe('applyTextEdits', () => { + it('returns content unchanged when there are no edits', () => { + expect(applyTextEdits('hello', [])).toBe('hello'); + expect(applyTextEdits('hello', undefined)).toBe('hello'); + }); + + it('replaces text within a single line', () => { + const out = applyTextEdits('line1\nline2\nline3', [ + { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 6 }, text: 'LINE1' }, + ]); + expect(out).toBe('LINE1\nline2\nline3'); + }); + + it('replaces a multi-line span', () => { + const out = applyTextEdits('a\nb\nc\nd', [ + { range: { startLineNumber: 2, startColumn: 1, endLineNumber: 3, endColumn: 2 }, text: 'X' }, + ]); + expect(out).toBe('a\nX\nd'); + }); + + it('inserts at a zero-width range', () => { + const out = applyTextEdits('ac', [ + { range: { startLineNumber: 1, startColumn: 2, endLineNumber: 1, endColumn: 2 }, text: 'b' }, + ]); + expect(out).toBe('abc'); + }); + + it('replaces the whole file with an over-extended range', () => { + const out = applyTextEdits('old1\nold2', wholeFile('new1\nnew2\nnew3')); + expect(out).toBe('new1\nnew2\nnew3'); + }); + + it('applies multiple edits in one op independent of their order', () => { + const edits = [ + { range: { startLineNumber: 3, startColumn: 1, endLineNumber: 3, endColumn: 2 }, text: 'C' }, + { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 2 }, text: 'A' }, + ]; + expect(applyTextEdits('a\nb\nc', edits)).toBe('A\nb\nC'); + }); + + it('appends edits that have no range', () => { + const out = applyTextEdits('abc', [{ text: 'def' }]); + expect(out).toBe('abcdef'); + }); + + it('clamps out-of-bounds ranges to the content length', () => { + const out = applyTextEdits('abc', [ + { range: { startLineNumber: 5, startColumn: 99, endLineNumber: 9, endColumn: 99 }, text: 'X' }, + ]); + expect(out).toBe('abcX'); + }); + + it('deletes a line when the replacement text is empty (apply_patch removal)', () => { + const out = applyTextEdits('a\nb\nc\nd', [ + { range: { startLineNumber: 2, startColumn: 1, endLineNumber: 3, endColumn: 1 }, text: '' }, + ]); + expect(out).toBe('a\nc\nd'); + }); + + it('applies a mix of ranged and range-less edits in one op', () => { + const out = applyTextEdits('a\nb\nc', [ + { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1, endColumn: 2 }, text: 'A' }, + { text: '\nappended' }, + ]); + expect(out).toBe('A\nb\nc\nappended'); + }); +}); + +describe('countAddedLines', () => { + it('returns 0 for identical content', () => { + expect(countAddedLines('a\nb\nc', 'a\nb\nc')).toBe(0); + }); + + it('counts every line when the previous content is empty', () => { + expect(countAddedLines('', 'a\nb\nc')).toBe(3); + }); + + it('counts appended lines', () => { + expect(countAddedLines('a\nb', 'a\nb\nc\nd')).toBe(2); + }); + + it('counts only the changed lines of a whole-file rewrite', () => { + const prev = 'a\nb\nc\nd\ne'; + const next = 'a\nb\nCHANGED\nd\ne'; + expect(countAddedLines(prev, next)).toBe(1); + }); + + it('treats lines as a multiset when there are duplicates', () => { + expect(countAddedLines('a\na', 'a\na\na')).toBe(1); + }); + + it('returns 0 for reordered but otherwise identical lines', () => { + expect(countAddedLines('a\nb\nc', 'c\nb\na')).toBe(0); + }); + + it('counts a replaced line once', () => { + expect(countAddedLines('old', 'new')).toBe(1); + }); + + it('does not credit deleted lines — a remove-and-add nets the single new line', () => { + // 'c' removed, 'f' appended: only 'f' is newly produced. + expect(countAddedLines('a\nb\nc\nd\ne', 'a\nb\nd\ne\nf')).toBe(1); + }); + + it('credits no new lines when a file is fully emptied', () => { + // Emptying a file produces no new content, and '' has zero logical lines. + expect(countAddedLines('a\nb\nc', '')).toBe(0); + }); +}); + +describe('countAddedRemoved', () => { + it('reports zero added and zero removed for identical content', () => { + expect(countAddedRemoved('a\nb\nc', 'a\nb\nc')).toEqual({ added: 0, removed: 0 }); + }); + + it('reports added lines and zero removed for a pure append', () => { + expect(countAddedRemoved('a\nb', 'a\nb\nc\nd')).toEqual({ added: 2, removed: 0 }); + }); + + it('reports removed lines and zero added for a pure deletion', () => { + expect(countAddedRemoved('a\nb\nc\nd', 'a\nb')).toEqual({ added: 0, removed: 2 }); + }); + + it('reports both added and removed for a replacement', () => { + // 'c' removed, 'f' added. + expect(countAddedRemoved('a\nb\nc\nd\ne', 'a\nb\nd\ne\nf')).toEqual({ added: 1, removed: 1 }); + }); + + it('reports a whole-file rewrite as one changed line each way', () => { + const prev = 'a\nb\nc\nd\ne'; + const next = 'a\nb\nCHANGED\nd\ne'; + expect(countAddedRemoved(prev, next)).toEqual({ added: 1, removed: 1 }); + }); + + it('reports nothing for reordered but otherwise identical lines', () => { + expect(countAddedRemoved('a\nb\nc', 'c\nb\na')).toEqual({ added: 0, removed: 0 }); + }); + + it('treats lines as a multiset with duplicates', () => { + expect(countAddedRemoved('a\na', 'a\na\na')).toEqual({ added: 1, removed: 0 }); + }); + + it('removes a single duplicate when the count drops', () => { + expect(countAddedRemoved('a\na\na', 'a\na')).toEqual({ added: 0, removed: 1 }); + }); + + it('reports zero removed for an empty prev (empty text has no logical lines)', () => { + // '' has zero logical lines, so a brand-new file adds its lines and removes nothing. + expect(countAddedRemoved('', 'a\nb\nc')).toEqual({ added: 3, removed: 0 }); + }); + + it('reports nothing when both sides are empty', () => { + expect(countAddedRemoved('', '')).toEqual({ added: 0, removed: 0 }); + }); + + it('reports zero added and N removed when a file is fully emptied', () => { + // '' has no logical lines, so nothing is added and all of prev's lines are removed. + expect(countAddedRemoved('a\nb\nc', '')).toEqual({ added: 0, removed: 3 }); + }); + + it('keeps added − removed equal to the change in line count for every example', () => { + const cases: [string, string][] = [ + ['a\nb\nc', 'a\nb\nc'], + ['a\nb', 'a\nb\nc\nd'], + ['a\nb\nc\nd', 'a\nb'], + ['a\nb\nc\nd\ne', 'a\nb\nd\ne\nf'], + ['', 'x\ny'], + ['a\na\na', 'a'], + ]; + for (const [prev, next] of cases) { + const { added, removed } = countAddedRemoved(prev, next); + // Net change tracks logical line count (matching countLines / forEachLineHash). + expect(added - removed).toBe(countLines(next) - countLines(prev)); + } + }); +}); + +describe('accumulateEditLoc', () => { + const URI = 'file:///project/src/app.ts'; + + it('counts repeated whole-file snapshots incrementally rather than summing them', () => { + // One request rewrites the same 10-line file three times, each time adding one line. + const v1 = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n'); + const v2 = v1 + '\nline10'; + const v3 = v2 + '\nline11'; + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', v1)], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), + uriOp(URI, 'r1', 2, wholeFile(v2)), + uriOp(URI, 'r1', 3, wholeFile(v3)), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + // Baseline already equals v1, so v1 adds 0; v2 adds 1; v3 adds 1. Total 2 — not 30. + expect(totalFor(index, URI)).toBe(2); + }); + + it('does not count a pre-existing file in full on the first whole-file snapshot', () => { + const existing = 'a\nb\nc\nd\ne'; + const edited = 'a\nb\nCHANGED\nd\ne'; + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', existing)], + operations: [uriOp(URI, 'r1', 1, wholeFile(edited))], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(totalFor(index, URI)).toBe(1); + }); + + it('counts a brand-new file (no baseline) in full', () => { + const created = 'a\nb\nc'; + const timeline: EditTimelineLike = { + operations: [uriOp(URI, 'r1', 1, wholeFile(created))], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(totalFor(index, URI)).toBe(3); + }); + + it('seeds from the resolved initial content when the per-request baseline is missing', () => { + const existing = 'a\nb\nc\nd\ne'; + const edited = 'a\nb\nCHANGED\nd\ne'; + const timeline: EditTimelineLike = { + operations: [uriOp(URI, 'r1', 1, wholeFile(edited))], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index, uri => (uri === URI ? existing : undefined)); + expect(totalFor(index, URI)).toBe(1); + }); + + it('carries reconstructed state across requests when a later request has no baseline', () => { + const v1 = 'a\nb\nc'; + const v2 = 'a\nb\nc\nd'; + const timeline: EditTimelineLike = { + // Only r1 has a baseline; r2 must carry over the state reconstructed after r1. + fileBaselines: [baseline(URI, 'r1', '')], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), + uriOp(URI, 'r2', 2, wholeFile(v2)), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(addedFor(index, 'r1', URI)).toBe(3); // new file, full count + expect(addedFor(index, 'r2', URI)).toBe(1); // only the added line + }); + + it('attributes lines to the correct request when one file is edited by multiple requests', () => { + const v1 = 'a\nb'; + const v2 = 'a\nb\nc'; + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', ''), baseline(URI, 'r2', v1)], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), + uriOp(URI, 'r2', 2, wholeFile(v2)), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(addedFor(index, 'r1', URI)).toBe(2); + expect(addedFor(index, 'r2', URI)).toBe(1); + }); + + it('records each file separately when one request edits several files', () => { + const uriA = 'file:///a.ts'; + const uriB = 'file:///b.ts'; + const timeline: EditTimelineLike = { + operations: [ + uriOp(uriA, 'r1', 1, wholeFile('a\nb')), + uriOp(uriB, 'r1', 2, wholeFile('x\ny\nz')), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(addedFor(index, 'r1', uriA)).toBe(2); + expect(addedFor(index, 'r1', uriB)).toBe(3); + }); + + it('processes ops in epoch order even when the input is out of order', () => { + const v1 = 'a\nb\nc'; + const v2 = 'a\nb\nc\nd'; + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', '')], + operations: [ + uriOp(URI, 'r1', 2, wholeFile(v2)), // later epoch listed first + uriOp(URI, 'r1', 1, wholeFile(v1)), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(totalFor(index, URI)).toBe(4); // 3 for v1 + 1 added for v2 + }); + + it('ignores non-textEdit operations', () => { + const timeline: EditTimelineLike = { + operations: [ + { type: 'create', requestId: 'r1', uri: { external: URI }, epoch: 1 }, + { type: 'delete', requestId: 'r1', uri: { external: URI }, epoch: 2 }, + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(index.size).toBe(0); + }); + + it('counts small ranged edits (Anthropic-style) as the number of touched lines', () => { + const prev = 'a\nb\nc'; + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', prev)], + operations: [ + uriOp(URI, 'r1', 1, [ + { range: { startLineNumber: 2, startColumn: 1, endLineNumber: 2, endColumn: 2 }, text: 'B\nB2' }, + ]), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + // 'b' becomes 'B\nB2': one line changed plus one inserted -> 2 new lines. + expect(totalFor(index, URI)).toBe(2); + }); + + it('credits only newly added lines when a later snapshot also removes lines', () => { + const v1 = 'a\nb\nc\nd\ne'; + const v2 = 'a\nb\nd\ne\nf'; // removed 'c', appended 'f' -> 1 newly produced line + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', v1)], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), // equals baseline -> 0 + uriOp(URI, 'r1', 2, wholeFile(v2)), // +1 added, +1 removed + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(totalFor(index, URI)).toBe(1); + expect(totalRemovedFor(index, URI)).toBe(1); + }); + + it('records removed lines so net output can go negative on a cleanup edit', () => { + const v1 = 'a\nb\nc\nd\ne'; + const v2 = 'a\nb'; // three lines removed, nothing added + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', v1)], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), // equals baseline -> 0/0 + uriOp(URI, 'r1', 2, wholeFile(v2)), // 0 added, 3 removed + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(totalFor(index, URI)).toBe(0); + expect(totalRemovedFor(index, URI)).toBe(3); + // Net = added - removed = -3. + expect(totalFor(index, URI) - totalRemovedFor(index, URI)).toBe(-3); + }); + + it('records zero removed for a brand-new file', () => { + const timeline: EditTimelineLike = { + operations: [uriOp(URI, 'r1', 1, wholeFile('a\nb\nc'))], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(removedFor(index, 'r1', URI)).toBe(0); + }); + + it('attributes removed lines to the request that deleted them, not the one that added them', () => { + const v1 = 'a\nb\nc\nd'; + const v2 = 'a\nb'; // r2 deletes 'c' and 'd' + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', ''), baseline(URI, 'r2', v1)], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), // r1: +4 added, 0 removed + uriOp(URI, 'r2', 2, wholeFile(v2)), // r2: 0 added, 2 removed + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(addedFor(index, 'r1', URI)).toBe(4); + expect(removedFor(index, 'r1', URI)).toBe(0); + expect(addedFor(index, 'r2', URI)).toBe(0); + expect(removedFor(index, 'r2', URI)).toBe(2); + }); + + it('records removed lines for a ranged (Anthropic-style) deletion', () => { + const prev = 'a\nb\nc\nd'; + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', prev)], + operations: [ + // Delete lines 2-3 ('b' and 'c') by replacing the span with nothing. + uriOp(URI, 'r1', 1, [ + { range: { startLineNumber: 2, startColumn: 1, endLineNumber: 4, endColumn: 1 }, text: '' }, + ]), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(totalFor(index, URI)).toBe(0); + expect(totalRemovedFor(index, URI)).toBe(2); + }); + + it('carries removed state across requests when a later request has no baseline', () => { + const v1 = 'a\nb\nc\nd'; + const v2 = 'a\nb'; // r2 (no baseline) must diff against r1's reconstructed state + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', '')], + operations: [ + uriOp(URI, 'r1', 1, wholeFile(v1)), + uriOp(URI, 'r2', 2, wholeFile(v2)), + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(removedFor(index, 'r2', URI)).toBe(2); + }); + + it('skips operations that are missing a requestId or a uri', () => { + const timeline: EditTimelineLike = { + operations: [ + { type: 'textEdit', uri: { external: URI }, epoch: 1, edits: wholeFile('a\nb') }, // no requestId + { type: 'textEdit', requestId: 'r1', epoch: 2, edits: wholeFile('x\ny') }, // no uri + ], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(index.size).toBe(0); + }); + + it('contributes nothing (and creates no entry) for an op with no edits', () => { + const timeline: EditTimelineLike = { + fileBaselines: [baseline(URI, 'r1', 'a\nb\nc')], + operations: [uriOp(URI, 'r1', 1, [])], + }; + const index = newIndex(); + accumulateEditLoc(timeline, index); + expect(index.size).toBe(0); + }); + + it('does nothing for an empty or missing timeline', () => { + const index = newIndex(); + accumulateEditLoc(undefined, index); + accumulateEditLoc({}, index); + accumulateEditLoc({ operations: [] }, index); + expect(index.size).toBe(0); + }); +}); diff --git a/src/core/edit-loc-diff.ts b/src/core/edit-loc-diff.ts new file mode 100644 index 0000000..637516c --- /dev/null +++ b/src/core/edit-loc-diff.ts @@ -0,0 +1,313 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the MIT License. See LICENSE in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/* + * Incremental, tool-agnostic line counting for VS Code chatEditingSessions. + * + * VS Code persists a timeline of edit operations per agent session. Models that use + * apply_patch (OpenAI/Codex) re-serialize the WHOLE file as a `textEdit` after every + * small change, so naively summing the lines in each payload counts the unchanged body + * of a file many times over. Models that use ranged string-replace edits (Anthropic) + * conversely under-count, since only inserted newlines were tallied. + * + * This module reconstructs each file version from its baseline and counts only the lines + * that are new compared to the previous version of that same file. The diff is a linear + * multiset comparison of line hashes, so it stays O(payload chars) — the same asymptotic + * class as the previous newline scan — while removing the per-tool bias. + */ + +/** A monaco-style range as serialized in the edit-state timeline (1-based, end-exclusive). */ +export interface RangeLike { + startLineNumber?: number; + startColumn?: number; + endLineNumber?: number; + endColumn?: number; +} + +/** A single text edit within a `textEdit` operation. */ +export interface TextEditLike { + range?: RangeLike; + text?: string; +} + +/** A file operation entry from `timeline.operations`. */ +export interface EditOpLike { + type: string; + requestId?: string; + uri?: { external?: string }; + epoch?: number; + edits?: TextEditLike[]; +} + +/** A baseline entry from `timeline.fileBaselines` (full pre-edit content for a request). */ +export interface FileBaselineLike { + uri?: { external?: string }; + requestId?: string; + content?: string; +} + +/** The `timeline` object inside a chatEditingSessions `state.json`. */ +export interface EditTimelineLike { + operations?: EditOpLike[]; + fileBaselines?: [string, FileBaselineLike][]; +} + +/** Resolves the session-initial content for a file URI (read from `contents/`). */ +export type InitialContentResolver = (uriExternal: string) => string | undefined; + +/** Lines the model added and removed for a single (request, file) cell. */ +export interface EditLoc { + added: number; + removed: number; +} + +/** Per-request, per-file produced-line tallies: requestId -> fileUri -> {added, removed}. */ +export type EditLocIndex = Map>; + +const NEWLINE = 10; + +/** djb2 (xor variant) hash of a line slice, computed without allocating a substring. */ +function hashLineSlice(text: string, start: number, end: number): number { + let h = 5381; + for (let i = start; i < end; i++) { + h = ((h * 33) ^ text.charCodeAt(i)) >>> 0; + } + return h; +} + +/** + * Invokes `cb` once per logical line, matching `countLines`: one call per newline plus a + * final call when the text does not end in a newline. Empty text yields no calls, and a + * trailing newline is not counted as an extra empty line. This keeps the multiset diff + * consistent with `countLines`, so toggling the EOF newline (`"a\n"` ⇄ `"a"`) is a no-op. + */ +function forEachLineHash(text: string, cb: (h: number) => void): void { + let segStart = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === NEWLINE) { + cb(hashLineSlice(text, segStart, i)); + segStart = i + 1; + } + } + if (segStart < text.length) cb(hashLineSlice(text, segStart, text.length)); +} + +/** Logical line count: newlines plus a final line when the text does not end in a newline. '' -> 0. */ +export function countLines(text: string): number { + if (text.length === 0) return 0; + let n = 0; + for (let i = 0; i < text.length; i++) { + if (text.charCodeAt(i) === NEWLINE) n++; + } + if (text.charCodeAt(text.length - 1) !== NEWLINE) n++; + return n; +} + +/** + * Counts how many lines of `next` are new compared to `prev`, treating lines as an + * unordered multiset. Reworked or reordered lines that already existed are not counted; + * lines that were replaced are counted as new. Linear in the size of both inputs. + */ +export function countAddedLines(prev: string, next: string): number { + const counts = new Map(); + forEachLineHash(prev, h => counts.set(h, (counts.get(h) ?? 0) + 1)); + let added = 0; + forEachLineHash(next, h => { + const c = counts.get(h); + if (c && c > 0) { + counts.set(h, c - 1); + } else { + added++; + } + }); + return added; +} + +/** + * Counts, in a single multiset pass, how many lines of `next` are new versus `prev` + * (`added`) and how many lines of `prev` are gone from `next` (`removed`). Lines are + * treated as an unordered multiset, so reordering counts as neither. Linear in both inputs. + */ +export function countAddedRemoved(prev: string, next: string): EditLoc { + const counts = new Map(); + forEachLineHash(prev, h => counts.set(h, (counts.get(h) ?? 0) + 1)); + let added = 0; + forEachLineHash(next, h => { + const c = counts.get(h); + if (c && c > 0) { + counts.set(h, c - 1); + } else { + added++; + } + }); + let removed = 0; + for (const c of counts.values()) if (c > 0) removed += c; + return { added, removed }; +} + +/** + * Reconstructs the file content after applying a `textEdit` operation's edits to `content`. + * Ranges are 1-based line/column and end-exclusive (monaco semantics). Edits are applied + * bottom-up so earlier offsets remain valid. Edits without a range are appended. + */ +export function applyTextEdits(content: string, edits: TextEditLike[] | undefined): string { + if (!edits || edits.length === 0) return content; + + const lineStart: number[] = [0]; + for (let i = 0; i < content.length; i++) { + if (content.charCodeAt(i) === NEWLINE) lineStart.push(i + 1); + } + const offsetOf = (line: number, col: number): number => { + const li = (line | 0) - 1; + if (li >= lineStart.length) return content.length; + let off = lineStart[Math.max(li, 0)] + Math.max((col | 0) - 1, 0); + if (off < 0) off = 0; + if (off > content.length) off = content.length; + return off; + }; + + const resolved: { start: number; end: number; text: string }[] = []; + const appended: string[] = []; + for (const e of edits) { + const text = e?.text ?? ''; + const r = e?.range; + if (!r || typeof r.startLineNumber !== 'number') { + appended.push(text); + continue; + } + let start = offsetOf(r.startLineNumber, r.startColumn ?? 1); + let end = offsetOf(r.endLineNumber ?? r.startLineNumber, r.endColumn ?? r.startColumn ?? 1); + if (end < start) { + const tmp = start; + start = end; + end = tmp; + } + resolved.push({ start, end, text }); + } + + resolved.sort((a, b) => b.start - a.start || b.end - a.end); + let result = content; + for (const r of resolved) { + result = result.slice(0, r.start) + r.text + result.slice(r.end); + } + if (appended.length > 0) result += appended.join(''); + return result; +} + +/** Builds a `${uri}::${requestId}` -> pre-request baseline content lookup. */ +function buildBaselineMap(timeline: EditTimelineLike): Map { + const baselineByKey = new Map(); + for (const entry of timeline.fileBaselines ?? []) { + const baseline = entry?.[1]; + const uri = baseline?.uri?.external; + const reqId = baseline?.requestId; + if (uri && reqId) baselineByKey.set(`${uri}::${reqId}`, baseline.content ?? ''); + } + return baselineByKey; +} + +/** Groups `textEdit` operations by file URI, preserving input order. */ +function groupTextEditOpsByFile(ops: EditOpLike[]): Map { + const byFile = new Map(); + for (const op of ops) { + if (op.type !== 'textEdit') continue; + const uri = op.uri?.external; + if (!uri || !op.requestId) continue; + let arr = byFile.get(uri); + if (!arr) { + arr = []; + byFile.set(uri, arr); + } + arr.push(op); + } + return byFile; +} + +/** Records `added`/`removed` lines against a (request, file) cell, summing into any existing value. */ +function addLoc(editLocIndex: EditLocIndex, reqId: string, uri: string, added: number, removed: number): void { + if (added <= 0 && removed <= 0) return; + let fileMap = editLocIndex.get(reqId); + if (!fileMap) { + fileMap = new Map(); + editLocIndex.set(reqId, fileMap); + } + const cur = fileMap.get(uri); + if (cur) { + cur.added += added; + cur.removed += removed; + } else { + fileMap.set(uri, { added, removed }); + } +} + +/** Chooses the diff seed for a request: per-request baseline, then session-initial, then carry-over. */ +function seedPrev( + prev: string | undefined, + uri: string, + reqId: string, + baselineByKey: Map, + resolveInitialContent?: InitialContentResolver, +): string { + const baseline = baselineByKey.get(`${uri}::${reqId}`); + if (baseline !== undefined) return baseline; + if (prev === undefined) return resolveInitialContent?.(uri) ?? ''; + return prev; +} + +/** Walks one file's operations in epoch order, attributing newly produced lines to each request. */ +function accumulateFileOps( + uri: string, + fileOps: EditOpLike[], + baselineByKey: Map, + editLocIndex: EditLocIndex, + resolveInitialContent?: InitialContentResolver, +): void { + fileOps.sort((a, b) => (a.epoch ?? 0) - (b.epoch ?? 0)); + let prev: string | undefined; + let lastReqId: string | undefined; + for (const op of fileOps) { + const reqId = op.requestId!; + if (reqId !== lastReqId) { + prev = seedPrev(prev, uri, reqId, baselineByKey, resolveInitialContent); + lastReqId = reqId; + } + const next = applyTextEdits(prev!, op.edits); + if (prev === '') { + addLoc(editLocIndex, reqId, uri, countLines(next), 0); + } else { + const { added, removed } = countAddedRemoved(prev!, next); + addLoc(editLocIndex, reqId, uri, added, removed); + } + prev = next; + } +} + +/** + * Walks a session's edit timeline and records, per request and file, the number of lines + * the model actually produced — reconstructing each file version and counting only the + * lines that are new versus the previous version of that file. + * + * `prev` (the seed for the diff) is chosen per request in priority order: + * 1. the per-request baseline (`fileBaselines[`${uri}::${requestId}`]`) — the file's + * content at the start of that request, including any manual edits; + * 2. otherwise, for the first request to touch the file, the session-initial content + * (resolved from `initialFileContents` via `resolveInitialContent`); + * 3. otherwise, the reconstructed state carried over from the previous request; + * 4. otherwise empty — the file is treated as genuinely new and counted in full. + */ +export function accumulateEditLoc( + timeline: EditTimelineLike | undefined, + editLocIndex: EditLocIndex, + resolveInitialContent?: InitialContentResolver, +): void { + const ops = timeline?.operations; + if (!ops || ops.length === 0) return; + + const baselineByKey = buildBaselineMap(timeline); + const byFile = groupTextEditOpsByFile(ops); + for (const [uri, fileOps] of byFile) { + accumulateFileOps(uri, fileOps, baselineByKey, editLocIndex, resolveInitialContent); + } +} diff --git a/src/core/parser-shared.ts b/src/core/parser-shared.ts index 658504b..e2e121e 100644 --- a/src/core/parser-shared.ts +++ b/src/core/parser-shared.ts @@ -10,6 +10,7 @@ import * as os from 'os'; import * as path from 'path'; import { CodeBlock, Session, SessionRequest, Workspace } from './types'; import { SessionSource } from './cache'; +import { EditLocIndex } from './edit-loc-diff'; import { classifyWorkType } from './helpers'; import { warnCore } from './log'; import { SessionSchema } from './schemas'; @@ -199,7 +200,7 @@ function textForCodeScan(text: string): string { export interface ParseContext { workspaces: Map; sessions: Session[]; - editLocIndex: Map>; + editLocIndex: EditLocIndex; sessionSourceIndex: Map; /** Running total of AI-generated lines of code (sum of all aiCode blocks). */ aiLoc: number; diff --git a/src/core/parser-vscode.test.ts b/src/core/parser-vscode.test.ts index 2ed6924..7b16fa8 100644 --- a/src/core/parser-vscode.test.ts +++ b/src/core/parser-vscode.test.ts @@ -9,7 +9,7 @@ import * as path from 'path'; import { describe, it, expect } from 'vitest'; import { reconstructFromJsonl } from './parser-vscode-files'; import { parseCLIEventsFile } from './parser-vscode-cli'; -import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs } from './parser-vscode'; +import { parseSessionFile, harnessFromPath, findVsCodeDirs, scanVsCodeDirs, parseEditState } from './parser-vscode'; function withTempFile(name: string, content: string, run: (filePath: string) => void): void { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-')); @@ -794,3 +794,71 @@ describe('findVsCodeDirs — VS Code Server', () => { } }); }); + +describe('parseEditState (AI-generated LoC)', () => { + const URI = 'file:///project/src/app.ts'; + + function wholeFileEdit(text: string): unknown { + return { range: { startLineNumber: 1, startColumn: 1, endLineNumber: 1_000_000, endColumn: 1 }, text }; + } + + function textEditOp(reqId: string, epoch: number, text: string): unknown { + return { type: 'textEdit', requestId: reqId, uri: { external: URI }, epoch, edits: [wholeFileEdit(text)] }; + } + + function totalFor(index: Map>, uri: string): number { + let sum = 0; + for (const fileMap of index.values()) sum += fileMap.get(uri)?.added ?? 0; + return sum; + } + + it('counts repeated whole-file snapshots incrementally, not by summing payloads', () => { + const v1 = Array.from({ length: 10 }, (_, i) => `line${i}`).join('\n'); + const v2 = v1 + '\nline10'; + const v3 = v2 + '\nline11'; + const raw = JSON.stringify({ + timeline: { + fileBaselines: [[`${URI}::r1`, { uri: { external: URI }, requestId: 'r1', content: v1 }]], + operations: [textEditOp('r1', 1, v1), textEditOp('r1', 2, v2), textEditOp('r1', 3, v3)], + }, + }); + const index = new Map>(); + parseEditState(raw, index, os.tmpdir()); + expect(totalFor(index, URI)).toBe(2); + }); + + it('skips payloads that contain no textEdit operations', () => { + const raw = JSON.stringify({ timeline: { operations: [{ type: 'create', requestId: 'r1', uri: { external: URI } }] } }); + const index = new Map>(); + parseEditState(raw, index, os.tmpdir()); + expect(index.size).toBe(0); + }); + + it('does not throw on corrupt JSON that contains the textEdit guard token', () => { + const index = new Map>(); + expect(() => parseEditState('{"textEdit": not-valid-json', index, os.tmpdir())).not.toThrow(); + expect(index.size).toBe(0); + }); + + it('seeds the diff from contents/ when the per-request baseline is missing', () => { + const existing = 'a\nb\nc\nd\ne'; + const edited = 'a\nb\nCHANGED\nd\ne'; + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'ai-engineer-coach-edits-')); + try { + const hash = 'deadbeef'; + fs.mkdirSync(path.join(dir, 'contents'), { recursive: true }); + fs.writeFileSync(path.join(dir, 'contents', hash), existing, 'utf-8'); + const raw = JSON.stringify({ + initialFileContents: [[URI, hash]], + timeline: { operations: [textEditOp('r1', 1, edited)] }, + }); + const index = new Map>(); + parseEditState(raw, index, dir); + // Only the one changed line is counted — the pre-existing body is not re-counted. + expect(totalFor(index, URI)).toBe(1); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); + diff --git a/src/core/parser-vscode.ts b/src/core/parser-vscode.ts index 00d0cdf..c3abf1c 100644 --- a/src/core/parser-vscode.ts +++ b/src/core/parser-vscode.ts @@ -9,6 +9,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { Session, SessionRequest, ToolConfirmation } from './types'; import { createRequest, createSession, detectDevcontainerFromRequests, extractSkillNameFromPath, ParseContext, prefetchCache } from './parser-shared'; +import { accumulateEditLoc, EditTimelineLike, InitialContentResolver } from './edit-loc-diff'; import { debugCore, warnCore } from './log'; import { canonicalizeReasoningEffort, extractReasoningEffortFromModelId } from './helpers'; import { parseCLIEventsFile } from './parser-vscode-cli'; @@ -156,30 +157,48 @@ function listEditStateFiles(esDir: string): string[] { } } -function countLinesAdded(edits: { text?: string }[] | undefined): number { - let linesAdded = 0; - for (const edit of (edits || [])) { - const text = edit.text || ''; - if (text) linesAdded += (text.match(/\n/g) || []).length; - } - return linesAdded; -} +type EditState = { + initialFileContents?: [string, string][]; + timeline?: EditTimelineLike; +}; -function processEditOperation(op: EditStateOperation, editLocIndex: ParseContext['editLocIndex']): void { - if (op.type !== 'textEdit') return; - const reqId = op.requestId || ''; - const uri = op.uri?.external || ''; - if (!reqId || !uri) return; - if (!editLocIndex.has(reqId)) editLocIndex.set(reqId, new Map()); - const fileMap = editLocIndex.get(reqId)!; - const linesAdded = countLinesAdded(op.edits); - fileMap.set(uri, (fileMap.get(uri) || 0) + linesAdded); +/** + * Builds a lazy resolver for a file's session-initial content, reading the content-addressed + * `contents/` blob referenced by `initialFileContents` only when first requested. + */ +function makeInitialContentResolver(state: EditState, stateDir: string): InitialContentResolver { + const hashByUri = new Map(); + for (const entry of state.initialFileContents ?? []) { + const uri = entry?.[0]; + const hash = entry?.[1]; + if (typeof uri === 'string' && typeof hash === 'string') hashByUri.set(uri, hash); + } + const cache = new Map(); + return (uri: string): string | undefined => { + if (cache.has(uri)) return cache.get(uri); + const hash = hashByUri.get(uri); + let content: string | undefined; + if (hash) { + try { + content = readFile(path.join(stateDir, 'contents', hash)); + } catch { + content = undefined; + } + } + cache.set(uri, content); + return content; + }; } -function processEditOperations(operations: EditStateOperation[] | undefined, editLocIndex: ParseContext['editLocIndex']): void { - for (const op of (operations || [])) { - processEditOperation(op, editLocIndex); +/** Parses an edit-state JSON payload and accumulates AI-produced LoC into `editLocIndex`. */ +export function parseEditState(raw: string, editLocIndex: ParseContext['editLocIndex'], stateDir: string): void { + if (!raw.includes('"textEdit"')) return; + let state: EditState; + try { state = JSON.parse(raw) as EditState; } catch (e) { + warnCore('parser-vscode', `Corrupt state payload in ${stateDir}`, e); + return; } + accumulateEditLoc(state.timeline, editLocIndex, makeInitialContentResolver(state, stateDir)); } function parseEditStateFile(stateFile: string, editLocIndex: ParseContext['editLocIndex']): void { @@ -191,13 +210,7 @@ function parseEditStateFile(stateFile: string, editLocIndex: ParseContext['editL } return; } - if (!raw.includes('"textEdit"')) return; - let state: { timeline?: { operations?: EditStateOperation[] } }; - try { state = JSON.parse(raw) as typeof state; } catch (e) { - warnCore('parser-vscode', `Corrupt state file ${stateFile}`, e); - return; - } - processEditOperations(state.timeline?.operations, editLocIndex); + parseEditState(raw, editLocIndex, path.dirname(stateFile)); } function chunkInterval(total: number): number { @@ -448,13 +461,6 @@ interface SessionFileData { }; } -type EditStateOperation = { - type: string; - requestId?: string; - uri?: { external?: string }; - edits?: { text?: string }[]; -}; - type TodoToolCall = { name?: string; arguments?: unknown; diff --git a/src/core/parser.ts b/src/core/parser.ts index 916d721..6cee716 100644 --- a/src/core/parser.ts +++ b/src/core/parser.ts @@ -11,6 +11,7 @@ import * as os from 'os'; import { runtimeDebug } from './runtime-debug'; import { Workspace } from './types'; import { ParseContext, prefetchCache } from './parser-shared'; +import { EditLoc, EditLocIndex } from './edit-loc-diff'; import { getMemoryCache, setMemoryCache, computeDirMetasAsync, loadCacheData, saveCacheData, findStaleDirs, clearCache, stripSessionsForMemory } from './cache'; import type { DirMetas, ParseResult, SessionSource } from './cache'; import { findVsCodeDirs, scanVsCodeDirs, processWorkspaceEntry, processWorkspaceEntryAsync, harnessFromPath } from './parser-vscode'; @@ -166,7 +167,7 @@ interface WorkerParseResponse { result: { workspaces: [string, Workspace][]; sessions: ParseResult['sessions']; - editLocIndex: [string, [string, number][]][]; + editLocIndex: [string, [string, EditLoc][]][]; sessionSourceIndex: [string, ParseResult['sessionSourceIndex'] extends Map ? V : never][]; }; dirMetas: DirMetas; @@ -464,7 +465,7 @@ async function collectExternalHarnesses( export function parseAllLogs(logsDirs: string[]): ParseResult { const workspaces = new Map(); const sessions: import('./types').Session[] = []; - const editLocIndex = new Map>(); + const editLocIndex: EditLocIndex = new Map(); const sessionSourceIndex = new Map(); const ctx: ParseContext = { workspaces, sessions, editLocIndex, sessionSourceIndex, aiLoc: 0 }; @@ -597,7 +598,7 @@ export async function parseAllLogsAsyncDetailed( report({ phase: 2, detail: 'Cold parse', pct: pct(2, 0) }); const workspaces = new Map(); const sessions: import('./types').Session[] = []; - const editLocIndex = new Map>(); + const editLocIndex: EditLocIndex = new Map(); const sessionSourceIndex = new Map(); const ctx: ParseContext = { workspaces, sessions, editLocIndex, sessionSourceIndex, aiLoc: 0 }; diff --git a/src/core/summary-export.test.ts b/src/core/summary-export.test.ts index 3c6bb4b..a450be7 100644 --- a/src/core/summary-export.test.ts +++ b/src/core/summary-export.test.ts @@ -25,6 +25,8 @@ const codeProduction: CodeProductionData = { totalAiLoc: 1200, totalUserLoc: 300, totalLoc: 1500, + totalRemovedAiLoc: 200, + totalNetAiLoc: 1000, aiBlocks: 12, userBlocks: 4, aiRatio: 0.8, @@ -36,11 +38,14 @@ const codeProduction: CodeProductionData = { aiLoc: [900, 250, 50], userLoc: [100, 150, 50], }, - dailyTimeline: { labels: [], aiLoc: [], userLoc: [] }, + dailyTimeline: { labels: [], aiLoc: [], removedLoc: [], userLoc: [] }, byWorkspace: { labels: [], aiLoc: [], userLoc: [] }, dailyByWorkspace: {}, + dailyRemovedByWorkspace: {}, dailyByModel: {}, + dailyRemovedByModel: {}, dailyByHarness: {}, + dailyRemovedByHarness: {}, }; const dailyActivity: DailyActivity = { diff --git a/src/core/types/analytics-types.ts b/src/core/types/analytics-types.ts index c05952b..821da2f 100644 --- a/src/core/types/analytics-types.ts +++ b/src/core/types/analytics-types.ts @@ -48,6 +48,8 @@ export interface CodeProductionData { totalAiLoc: number; totalUserLoc: number; totalLoc: number; + totalRemovedAiLoc: number; + totalNetAiLoc: number; aiBlocks: number; userBlocks: number; aiRatio: number; @@ -62,6 +64,7 @@ export interface CodeProductionData { dailyTimeline: { labels: string[]; aiLoc: number[]; + removedLoc: number[]; userLoc: number[]; }; byWorkspace: { @@ -70,8 +73,11 @@ export interface CodeProductionData { userLoc: number[]; }; dailyByWorkspace: Record; + dailyRemovedByWorkspace: Record; dailyByModel: Record; + dailyRemovedByModel: Record; dailyByHarness: Record; + dailyRemovedByHarness: Record; } export interface ConsumptionData { diff --git a/src/core/warm-up-worker.ts b/src/core/warm-up-worker.ts index f002f19..7587c53 100644 --- a/src/core/warm-up-worker.ts +++ b/src/core/warm-up-worker.ts @@ -7,11 +7,12 @@ import { parentPort } from 'worker_threads'; import { Analyzer } from './analyzer'; +import type { EditLocIndex } from './edit-loc-diff'; import type { Session, Workspace } from './types'; interface WarmUpWorkerRequest { sessions: Session[]; - editLocIndex?: Map>; + editLocIndex?: EditLocIndex; workspaces?: Map; } diff --git a/src/webview/page-dashboard.ts b/src/webview/page-dashboard.ts index 8d7df45..1aa5d19 100644 --- a/src/webview/page-dashboard.ts +++ b/src/webview/page-dashboard.ts @@ -185,7 +185,7 @@ function renderDashboardSkillFinder(skillCache: ReturnType export async function renderDashboard(container: HTMLElement, currentFilter: DateFilter): Promise { const emptyDaily: DailyActivity = { labels: [], values: [], sessions: [], loc: [], workspaces: [], byHarness: [] }; - const emptyCodeProd: CodeProductionData = { summary: { totalAiLoc: 0, totalUserLoc: 0, totalLoc: 0, aiBlocks: 0, userBlocks: 0, aiRatio: 0, locCost2010: 0, costPerLoc: 0 }, byLanguage: { labels: [], aiLoc: [], userLoc: [] }, dailyTimeline: { labels: [], aiLoc: [], userLoc: [] }, byWorkspace: { labels: [], aiLoc: [], userLoc: [] }, dailyByWorkspace: {}, dailyByModel: {}, dailyByHarness: {} }; + const emptyCodeProd: CodeProductionData = { summary: { totalAiLoc: 0, totalUserLoc: 0, totalLoc: 0, totalRemovedAiLoc: 0, totalNetAiLoc: 0, aiBlocks: 0, userBlocks: 0, aiRatio: 0, locCost2010: 0, costPerLoc: 0 }, byLanguage: { labels: [], aiLoc: [], userLoc: [] }, dailyTimeline: { labels: [], aiLoc: [], removedLoc: [], userLoc: [] }, byWorkspace: { labels: [], aiLoc: [], userLoc: [] }, dailyByWorkspace: {}, dailyRemovedByWorkspace: {}, dailyByModel: {}, dailyRemovedByModel: {}, dailyByHarness: {}, dailyRemovedByHarness: {} }; const [stats, daily, wsBreakdown, harnessBreakdown, antiPatterns, codeProd] = await rpcAllSettled([ rpc<{ totalSessions: number; totalWorkspaces: number; totalRequests: number }>('getStats', currentFilter as Record), rpc('getDailyActivity', currentFilter as Record), diff --git a/src/webview/page-output.ts b/src/webview/page-output.ts index dbe5fe7..3c6bbac 100644 --- a/src/webview/page-output.ts +++ b/src/webview/page-output.ts @@ -58,13 +58,16 @@ function aggregateByWorkspace( } interface ProdData { - summary: { totalAiLoc: number; locCost2010: number }; - dailyTimeline: { labels: string[]; aiLoc: number[] }; + summary: { totalAiLoc: number; totalRemovedAiLoc: number; totalNetAiLoc: number; locCost2010: number }; + dailyTimeline: { labels: string[]; aiLoc: number[]; removedLoc: number[] }; byLanguage: { labels: string[]; aiLoc: number[] }; byWorkspace: { labels: string[]; aiLoc: number[] }; dailyByWorkspace: Record; + dailyRemovedByWorkspace: Record; dailyByModel: Record; + dailyRemovedByModel: Record; dailyByHarness: Record; + dailyRemovedByHarness: Record; } interface AiCreditRpcData { @@ -252,21 +255,46 @@ export async function renderOutput(container: HTMLElement, currentFilter: DateFi const prod = await rpc('getCodeProduction', buildRangeFilter()); const s = prod.summary; const level = aggregationLevel(activeRangeDays); - const chartTitle = level === 'weekly' ? 'Weekly Production' : level === 'monthly' ? 'Monthly Production' : 'Daily Production'; + const chartTitle = level === 'weekly' ? 'Weekly AI Code Output' : level === 'monthly' ? 'Monthly AI Code Output' : 'Daily AI Code Output'; const yLabel = level === 'weekly' ? 'LoC/week' : level === 'monthly' ? 'LoC/month' : 'LoC/day'; + const cadence = level === 'weekly' ? 'week' : level === 'monthly' ? 'month' : 'day'; + const outputInfo = html`${'\u24d8'}Counts the net new lines of code the AI assistant actually wrote, aggregated per ${cadence}. Each edit is compared against the previous version of the file, so only added lines are counted ${'\u2014'} re-saving an unchanged file or rewriting the same lines is not double-counted. Taller bars mean more AI-authored code in that period; hover a bar to see the exact line count, and use the tabs below to split output by model, workspace, or harness.`; + const netInfo = html`${'\u24d8'}Net AI LoC is lines the AI added minus lines the AI removed, aggregated per ${cadence}. A ${cadence} where the assistant deleted or rewrote more than it added shows a bar below zero. This reflects the lasting footprint of AI edits on your files, not just gross output.`; + const netTitle = level === 'weekly' ? 'Weekly Net AI Code Output' : level === 'monthly' ? 'Monthly Net AI Code Output' : 'Daily Net AI Code Output'; render(html`
<${StatCard} label="AI-Generated LoC" value=${formatNum(s.totalAiLoc)} accent="var(--accent-blue)" /> + <${StatCard} label="Net AI LoC" value=${formatNum(s.totalNetAiLoc)} accent="var(--accent-green)" />
-
<${CanvasEl} id="prodModelChart" height=${300} title=${chartTitle} />
+
+
+
${chartTitle} ${outputInfo}
+ +
+
<${CanvasEl} id="prodDailyChart" height=${300} title=${chartTitle} />
<${CanvasEl} id="prodHarnessChart" height=${300} title=${chartTitle} />
+
+ + + + +
+
+
+
${netTitle} ${netInfo}
+ +
+
+
<${CanvasEl} id="netModelChart" height=${300} title=${netTitle} />
+
<${CanvasEl} id="netWorkspaceChart" height=${300} title=${netTitle} />
+
<${CanvasEl} id="netHarnessChart" height=${300} title=${netTitle} />
<${CanvasEl} id="prodLangChart" height=${300} title="By Language" /> <${CanvasEl} id="prodWsChart" height=${300} title="By Workspace" /> @@ -394,6 +422,69 @@ export async function renderOutput(container: HTMLElement, currentFilter: DateFi scales: { x: { beginAtZero: true } }, }); + // --- Net total chart (diverging added / removed) --- + const { labels: netLabels, values: addedTotals } = aggregateTimeline( + prod.dailyTimeline.labels, prod.dailyTimeline.aiLoc, level, + ); + const { values: removedTotals } = aggregateTimeline( + prod.dailyTimeline.labels, prod.dailyTimeline.removedLoc, level, + ); + const removedNeg = removedTotals.map(v => -v); + const netTotals = addedTotals.map((v, i) => v - removedTotals[i]); + createChart('netTotalChart', 'bar', { + labels: netLabels, + datasets: [ + { label: 'Added', data: addedTotals, backgroundColor: COLORS.green + '99', borderColor: COLORS.green, borderWidth: 1, stack: 'net' }, + { label: 'Removed', data: removedNeg, backgroundColor: COLORS.red + '99', borderColor: COLORS.red, borderWidth: 1, stack: 'net' }, + { type: 'line', label: 'Net', data: netTotals, borderColor: COLORS.blue, backgroundColor: COLORS.blue, borderWidth: 2, pointRadius: 2, fill: false, stack: undefined }, + ], + }, { + plugins: { legend: { position: 'top' } }, + scales: { x: { stacked: true, ticks: { maxTicksLimit: 15 } }, y: { stacked: true, beginAtZero: false, title: { display: true, text: yLabel } } }, + }); + + // --- Net stacked charts by dimension --- + const buildNetDatasets = ( + grossMap: Record, + removedMap: Record, + colorFor: (name: string, i: number) => string, + limit: number, + ): { labels: string[]; datasets: Record[] } => { + const { labels, byWs: grossBuckets } = aggregateByWorkspace(prod.dailyTimeline.labels, grossMap, level); + const { byWs: removedBuckets } = aggregateByWorkspace(prod.dailyTimeline.labels, removedMap, level); + const names = Object.keys(grossBuckets).sort((a, b) => { + const sumA = grossBuckets[a].reduce((s, v) => s + v, 0) - (removedBuckets[a]?.reduce((s, v) => s + v, 0) ?? 0); + const sumB = grossBuckets[b].reduce((s, v) => s + v, 0) - (removedBuckets[b]?.reduce((s, v) => s + v, 0) ?? 0); + return sumB - sumA; + }); + const top = names.slice(0, limit); + const other = names.slice(limit); + const netOf = (name: string) => grossBuckets[name].map((v, i) => v - (removedBuckets[name]?.[i] ?? 0)); + const datasets: Record[] = top.map((name, i) => ({ + label: name, data: netOf(name), + backgroundColor: colorFor(name, i) + '99', borderColor: colorFor(name, i), borderWidth: 1, stack: 'net', + })); + if (other.length > 0) { + const otherData = labels.map((_, i) => other.reduce((s, name) => s + (grossBuckets[name][i] - (removedBuckets[name]?.[i] ?? 0)), 0)); + datasets.push({ label: `Other (${other.length})`, data: otherData, backgroundColor: COLORS.muted + '60', borderColor: COLORS.muted, borderWidth: 1, stack: 'net' }); + } + return { labels, datasets }; + }; + + const netChartOpts = { + plugins: { legend: { position: 'top' } }, + scales: { x: { stacked: true, ticks: { maxTicksLimit: 15 } }, y: { stacked: true, beginAtZero: false, title: { display: true, text: yLabel } } }, + }; + + const netModel = buildNetDatasets(prod.dailyByModel, prod.dailyRemovedByModel, (_, i) => PALETTE[i % PALETTE.length], 8); + createChart('netModelChart', 'bar', { labels: netModel.labels, datasets: netModel.datasets }, netChartOpts); + + const netWs = buildNetDatasets(prod.dailyByWorkspace, prod.dailyRemovedByWorkspace, (_, i) => wsColor(i), 15); + createChart('netWorkspaceChart', 'bar', { labels: netWs.labels, datasets: netWs.datasets }, netChartOpts); + + const netHarness = buildNetDatasets(prod.dailyByHarness, prod.dailyRemovedByHarness, (name) => harnessColor(name), 12); + createChart('netHarnessChart', 'bar', { labels: netHarness.labels, datasets: netHarness.datasets }, netChartOpts); + // Wire production chart tab switching for (const btn of target.querySelectorAll('.chart-tab[data-prod-tab]')) { btn.addEventListener('click', () => { @@ -401,10 +492,22 @@ export async function renderOutput(container: HTMLElement, currentFilter: DateFi btn.classList.add('active'); const tab = btn.dataset.prodTab; const panelMap: Record = { model: 'prodTabModel', workspace: 'prodTabWorkspace', harness: 'prodTabHarness' }; - for (const p of target.querySelectorAll('.chart-tab-panel')) p.classList.remove('active'); + for (const p of target.querySelectorAll('#prodTabModel, #prodTabWorkspace, #prodTabHarness')) p.classList.remove('active'); document.getElementById(panelMap[tab || 'model'])!.classList.add('active'); }); } + + // Wire net chart tab switching + for (const btn of target.querySelectorAll('.chart-tab[data-net-tab]')) { + btn.addEventListener('click', () => { + for (const b of target.querySelectorAll('.chart-tab[data-net-tab]')) b.classList.remove('active'); + btn.classList.add('active'); + const tab = btn.dataset.netTab; + const panelMap: Record = { total: 'netTabTotal', model: 'netTabModel', workspace: 'netTabWorkspace', harness: 'netTabHarness' }; + for (const p of target.querySelectorAll('#netTabTotal, #netTabModel, #netTabWorkspace, #netTabHarness')) p.classList.remove('active'); + document.getElementById(panelMap[tab || 'total'])!.classList.add('active'); + }); + } } function buildCreditCoverageSummary(data: AiCreditRpcData): { missingLabel: ComponentChildren; partialLabel: ComponentChildren; pendingLabel: ComponentChildren; noDataLabel: ComponentChildren } {