diff --git a/__tests__/explore-factory-closure.test.ts b/__tests__/explore-factory-closure.test.ts new file mode 100644 index 0000000..d0b0838 --- /dev/null +++ b/__tests__/explore-factory-closure.test.ts @@ -0,0 +1,157 @@ +/** + * Regression gate for the FACTORY-CLOSURE file shape (task CG-27). + * + * A `createFoo()` that returns an object of closures spans almost all of its + * file, so its indexed range is an ENVELOPE around every symbol the query + * actually wants. Svelte 5 rune stores, React custom-hook modules, IIFE + * module-pattern JS and Zustand's `create((set, get) => ({ … }))` are all + * written this way, so it is a shape rather than a one-repo quirk. + * + * CG-27 asked whether the >50%-of-file envelope drop — which fires for `class`, + * `struct`, `interface` and friends but not for `function`/`method` — should be + * extended to cover it. **Measured, it should not**, and the issue was closed as + * obsolete: `docs/benchmarks/explore-factory-closure-cg27.md` has the numbers. + * Two independent mechanisms already absorb the shape: + * + * - `shrinkCluster` orders members by (importance desc, SIZE ASC) and refuses + * any member that overruns the cap once something is kept, so a file-spanning + * member is only ever selected when it is the sole member of the top + * importance tier; + * - when it IS selected, CG-30 windows it on whole lines rather than emitting + * it whole, so the file still delivers bounded, readable source. + * + * Dropping the range instead SPLITS the file into several clusters, and only the + * first-chosen cluster may be shrunk — measured, a trivial 7-line cluster won the + * density tiebreak and the answer-bearing cluster was dropped whole, taking the + * rank-#1 file from 7,539 chars and 7 of 11 inner definitions to 397 and none. + * + * So this file pins the OUTCOME, not the mechanism: whatever future work does to + * clustering, a factory-closure file must keep delivering the closures inside it + * — that is what stops the agent Reading the file back. + */ +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 type { ExploreDiagnosticReport } from '../src/mcp/explore-diagnostics'; + +const FIXTURE_SRC = path.join(__dirname, 'fixtures', 'factory-closure-ts'); + +/** The factory file, and the closure factory whose body is nearly all of it. */ +const TARGET = 'src/stores/dashboard-store.ts'; +const FACTORY = 'createDashboardStore'; +/** Prose the way a newcomer asks it, naming two of the closures inside. */ +const QUERY = 'how does the dashboard store refresh its metrics and apply a filter'; + +describe('CG-27 — a factory-closure file delivers the closures inside it', () => { + let testDir: string; + let cg: CodeGraph; + let response: string; + let report: ExploreDiagnosticReport; + /** Source lines of TARGET the response actually carried. */ + let delivered: Set; + let sourceLines: string[]; + + beforeAll(async () => { + testDir = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-cg27-')); + fs.cpSync(FIXTURE_SRC, testDir, { recursive: true }); + fs.rmSync(path.join(testDir, '.codegraph'), { recursive: true, force: true }); + + cg = CodeGraph.initSync(testDir); + await cg.indexAll(); + + const sidecar = path.join(testDir, 'explore-diag.jsonl'); + const previous = process.env.CODEGRAPH_EXPLORE_DEBUG; + process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar; + try { + response = (await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY })) + .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; + + // A line counts as delivered only when the response numbers it AND the text + // matches that source line — a line number quoted in prose must not count. + sourceLines = fs.readFileSync(path.join(testDir, TARGET), 'utf-8').split('\n'); + delivered = new Set(); + for (const line of response.split('\n')) { + const m = /^(\d+)\t(.*)$/.exec(line); + if (!m) continue; + const n = Number(m[1]); + if (n >= 1 && n <= sourceLines.length && sourceLines[n - 1] === m[2]) delivered.add(n); + } + }, 120_000); + + afterAll(() => { + if (cg) cg.destroy(); + if (testDir && fs.existsSync(testDir)) fs.rmSync(testDir, { recursive: true, force: true }); + }); + + /** The closures defined inside the factory, straight from the index. */ + const innerClosures = () => { + const nodes = cg.getNodesInFile(TARGET); + const factory = nodes.find((n) => n.name === FACTORY)!; + return nodes.filter((n) => (n.kind === 'function' || n.kind === 'method') + && n.name !== FACTORY + && n.startLine > factory.startLine && n.endLine <= factory.endLine); + }; + + describe('fixture shape — if this rots, the gate below means nothing', () => { + it('holds one symbol spanning most of the file, with closures inside it', () => { + const factory = cg.getNodesInFile(TARGET).find((n) => n.name === FACTORY); + expect(factory, `${TARGET} has no ${FACTORY} node`).toBeDefined(); + // The envelope condition the >50% drop tests for — and `function`, the kind + // that drop does not cover. + expect(factory!.kind).toBe('function'); + expect(factory!.endLine - factory!.startLine + 1) + .toBeGreaterThan(sourceLines.length * 0.5); + expect(innerClosures().length).toBeGreaterThanOrEqual(8); + }); + + it('is too long to ship whole, so it renders through the cluster path', () => { + // Past WHOLE_FILE_MAX_LINES (220 for a non-central file): the whole-file + // grace and buy arms cannot claim it, so the envelope actually matters. + expect(sourceLines.length).toBeGreaterThan(220); + expect(report.files.find((f) => f.path === TARGET)?.render).toBe('clusters'); + }); + }); + + describe('the gate', () => { + it('delivers the closures the query named, not just the factory head', () => { + const inner = innerClosures(); + for (const name of ['refreshMetrics', 'applyFilter']) { + const node = inner.find((n) => n.name === name)!; + expect(node, `${name} is not an inner closure any more`).toBeDefined(); + expect(delivered.has(node.startLine), `${name} definition line not delivered`).toBe(true); + } + }); + + it('delivers most of the closures, spread across the file', () => { + const inner = innerClosures(); + const hit = inner.filter((n) => delivered.has(n.startLine)); + // Measured on the `feature/CG-24` tip: 7 of 11. The bar is half, so ordinary + // budget movement does not fail the suite, but losing the closures does. + expect(hit.length).toBeGreaterThanOrEqual(Math.ceil(inner.length / 2)); + // Not one contiguous head window off the top of the factory: the whole + // point is that selection reaches symbols deep in the body. + const last = inner[inner.length - 1]!; + const deepest = Math.max(...hit.map((n) => n.startLine)); + expect(deepest).toBeGreaterThan((last.startLine + inner[0]!.startLine) / 2); + }); + + it('never renders an empty section for the file', () => { + const rec = report.files.find((f) => f.path === TARGET)!; + expect(rec.emittedChars).toBeGreaterThan(0); + expect(delivered.size).toBeGreaterThan(20); + }); + + it('keeps the response inside the hard ceiling', () => { + expect(report.envelope.chars).toBeLessThanOrEqual(report.budget.hardCeiling); + }); + }); +}); diff --git a/__tests__/fixtures/factory-closure-ts/package.json b/__tests__/fixtures/factory-closure-ts/package.json new file mode 100644 index 0000000..f597b57 --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/package.json @@ -0,0 +1,5 @@ +{ + "name": "factory-closure-ts", + "version": "0.0.0", + "private": true +} diff --git a/__tests__/fixtures/factory-closure-ts/src/index.ts b/__tests__/fixtures/factory-closure-ts/src/index.ts new file mode 100644 index 0000000..2a873de --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/index.ts @@ -0,0 +1,23 @@ +import { createDashboardStore } from './stores/dashboard-store'; +import { createAlertsStore } from './stores/alerts-store'; +import { mountPanel } from './ui/panel'; +import { parseFilterText } from './services/filter-parser'; +import { refreshMetricCache } from './services/metric-service'; +import type { StoreDeps } from './stores/types'; + +/** Wire a dashboard: build both stores, mount the panel, boot it. */ +export async function startDashboard(deps: StoreDeps, baseUrl: string, dashboardId: string) { + const store = createDashboardStore(deps, baseUrl); + const alerts = createAlertsStore(deps, baseUrl); + const panel = mountPanel(store, dashboardId); + await panel.boot(); + await alerts.refreshAlerts(dashboardId); + return { store, alerts, panel }; +} + +/** Apply the filter bar's text to the dashboard store. */ +export function searchDashboard(store: ReturnType, text: string) { + return store.applyFilter(parseFilterText(text)); +} + +export { refreshMetricCache }; diff --git a/__tests__/fixtures/factory-closure-ts/src/lib/http.ts b/__tests__/fixtures/factory-closure-ts/src/lib/http.ts new file mode 100644 index 0000000..84d254e --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/lib/http.ts @@ -0,0 +1,25 @@ +/** Minimal fetch helpers the dashboard store depends on. */ + +export interface RequestOptions { + retries: number; + timeoutMs: number; +} + +export const defaultRequestOptions: RequestOptions = { retries: 2, timeoutMs: 5_000 }; + +/** Build a query string from a plain record, skipping empty values. */ +export function toQueryString(params: Record): string { + const parts: string[] = []; + for (const [key, value] of Object.entries(params)) { + if (value === undefined || value === '') continue; + parts.push(`${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`); + } + return parts.length > 0 ? `?${parts.join('&')}` : ''; +} + +/** Join a base path and a resource path without doubling the separator. */ +export function joinPath(base: string, resource: string): string { + if (base.endsWith('/') && resource.startsWith('/')) return base + resource.slice(1); + if (!base.endsWith('/') && !resource.startsWith('/')) return `${base}/${resource}`; + return base + resource; +} diff --git a/__tests__/fixtures/factory-closure-ts/src/lib/metrics.ts b/__tests__/fixtures/factory-closure-ts/src/lib/metrics.ts new file mode 100644 index 0000000..be39ecc --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/lib/metrics.ts @@ -0,0 +1,37 @@ +import type { MetricSample } from '../stores/types'; + +/** Statistics helpers shared by the store and the panel. */ + +export function meanOf(samples: readonly MetricSample[]): number { + if (samples.length === 0) return 0; + let total = 0; + for (const sample of samples) total += sample.value; + return total / samples.length; +} + +export function medianOf(samples: readonly MetricSample[]): number { + if (samples.length === 0) return 0; + const values = samples.map((s) => s.value).sort((a, b) => a - b); + const mid = Math.floor(values.length / 2); + return values.length % 2 === 0 ? (values[mid - 1]! + values[mid]!) / 2 : values[mid]!; +} + +export function rateOfChange(samples: readonly MetricSample[]): number { + if (samples.length < 2) return 0; + const ordered = samples.slice().sort((a, b) => a.at - b.at); + const first = ordered[0]!; + const last = ordered[ordered.length - 1]!; + const elapsed = last.at - first.at; + return elapsed > 0 ? (last.value - first.value) / elapsed : 0; +} + +export function bucketByHour(samples: readonly MetricSample[]): Map { + const buckets = new Map(); + for (const sample of samples) { + const hour = Math.floor(sample.at / 3_600_000); + const bucket = buckets.get(hour); + if (bucket) bucket.push(sample); + else buckets.set(hour, [sample]); + } + return buckets; +} diff --git a/__tests__/fixtures/factory-closure-ts/src/services/filter-parser.ts b/__tests__/fixtures/factory-closure-ts/src/services/filter-parser.ts new file mode 100644 index 0000000..293dbb0 --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/services/filter-parser.ts @@ -0,0 +1,62 @@ +import type { FilterSpec } from '../stores/types'; + +/** Parse the dashboard's filter bar text into filter specs. */ + +const OPERATORS: Record = { + ':': 'eq', + '~': 'contains', + '>': 'gt', + '<': 'lt', +}; + +/** `title~sales kind:chart column>3` → three specs. */ +export function parseFilterText(text: string): FilterSpec[] { + const specs: FilterSpec[] = []; + for (const token of tokenize(text)) { + const spec = parseToken(token); + if (spec) specs.push(spec); + } + return specs; +} + +/** Split on whitespace, honouring double-quoted values. */ +export function tokenize(text: string): string[] { + const tokens: string[] = []; + let current = ''; + let quoted = false; + for (const ch of text) { + if (ch === '"') { quoted = !quoted; continue; } + if (!quoted && /\s/.test(ch)) { + if (current.length > 0) { tokens.push(current); current = ''; } + continue; + } + current += ch; + } + if (current.length > 0) tokens.push(current); + return tokens; +} + +/** One `fieldvalue` token, or null when it does not parse. */ +export function parseToken(token: string): FilterSpec | null { + for (const [symbol, op] of Object.entries(OPERATORS)) { + const at = token.indexOf(symbol); + if (at <= 0) continue; + const field = token.slice(0, at).trim(); + const value = token.slice(at + symbol.length).trim(); + if (field.length === 0 || value.length === 0) return null; + return { field, op, value }; + } + return null; +} + +/** Render specs back to filter-bar text — the round trip the URL uses. */ +export function formatFilterText(specs: readonly FilterSpec[]): string { + const symbolFor = (op: FilterSpec['op']): string => + Object.entries(OPERATORS).find(([, candidate]) => candidate === op)?.[0] ?? ':'; + return specs + .map((spec) => { + const value = /\s/.test(spec.value) ? `"${spec.value}"` : spec.value; + return `${spec.field}${symbolFor(spec.op)}${value}`; + }) + .join(' '); +} diff --git a/__tests__/fixtures/factory-closure-ts/src/services/metric-service.ts b/__tests__/fixtures/factory-closure-ts/src/services/metric-service.ts new file mode 100644 index 0000000..59ddd1d --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/services/metric-service.ts @@ -0,0 +1,101 @@ +import type { FilterSpec, MetricSample, Widget } from '../stores/types'; +import { bucketByHour, meanOf, rateOfChange } from '../lib/metrics'; + +/** + * Stateless metric helpers — the server-shaped half of the same domain. These + * are ordinary top-level functions, not closures, so they are the control the + * factory-closure file is measured against. + */ + +const STALE_AFTER_MS = 15 * 60 * 1000; + +/** Refresh a cached metric map in place, returning the widgets that changed. */ +export function refreshMetricCache( + cache: Map, + incoming: readonly MetricSample[], + now: number, +): string[] { + const touched = new Set(); + for (const sample of incoming) { + if (typeof sample.value !== 'number' || Number.isNaN(sample.value)) continue; + const bucket = cache.get(sample.widgetId); + if (bucket) bucket.push(sample); + else cache.set(sample.widgetId, [sample]); + touched.add(sample.widgetId); + } + for (const [widgetId, bucket] of cache) { + const fresh = bucket.filter((s) => now - s.at <= STALE_AFTER_MS); + if (fresh.length !== bucket.length) { + cache.set(widgetId, fresh); + touched.add(widgetId); + } + } + return [...touched].sort(); +} + +/** Apply a filter spec set to raw samples rather than to widgets. */ +export function filterMetrics( + samples: readonly MetricSample[], + specs: readonly FilterSpec[], +): MetricSample[] { + if (specs.length === 0) return samples.slice(); + return samples.filter((sample) => specs.every((spec) => { + const field = spec.field === 'unit' + ? sample.unit + : spec.field === 'widget' + ? sample.widgetId + : String(sample.value); + switch (spec.op) { + case 'eq': return field === spec.value; + case 'contains': return field.includes(spec.value); + case 'gt': return Number(field) > Number(spec.value); + case 'lt': return Number(field) < Number(spec.value); + default: return false; + } + })); +} + +/** Per-widget rollup used by the server-rendered summary card. */ +export function rollupByWidget( + samples: readonly MetricSample[], + widgets: readonly Widget[], +): Array<{ widgetId: string; title: string; mean: number; slope: number; hours: number }> { + const titles = new Map(widgets.map((w) => [w.id, w.title])); + const grouped = new Map(); + for (const sample of samples) { + const bucket = grouped.get(sample.widgetId); + if (bucket) bucket.push(sample); + else grouped.set(sample.widgetId, [sample]); + } + + const out: Array<{ widgetId: string; title: string; mean: number; slope: number; hours: number }> = []; + for (const [widgetId, bucket] of grouped) { + out.push({ + widgetId, + title: titles.get(widgetId) ?? '(unknown)', + mean: meanOf(bucket), + slope: rateOfChange(bucket), + hours: bucketByHour(bucket).size, + }); + } + out.sort((a, b) => b.mean - a.mean); + return out; +} + +/** Which widgets have not reported inside the staleness window. */ +export function staleWidgets( + samples: readonly MetricSample[], + widgets: readonly Widget[], + now: number, +): string[] { + const newest = new Map(); + for (const sample of samples) { + const seen = newest.get(sample.widgetId) ?? 0; + if (sample.at > seen) newest.set(sample.widgetId, sample.at); + } + return widgets + .filter((w) => !w.hidden) + .filter((w) => now - (newest.get(w.id) ?? 0) > STALE_AFTER_MS) + .map((w) => w.id) + .sort(); +} diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/alerts-store.ts b/__tests__/fixtures/factory-closure-ts/src/stores/alerts-store.ts new file mode 100644 index 0000000..fc07928 --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/stores/alerts-store.ts @@ -0,0 +1,140 @@ +import type { FilterSpec, StoreDeps } from './types'; +import { joinPath, toQueryString } from '../lib/http'; + +const ALERT_ENDPOINT = '/api/dashboard/alerts'; + +export interface Alert { + id: string; + widgetId: string; + severity: 'info' | 'warn' | 'critical'; + message: string; + raisedAt: number; + acknowledgedAt: number | null; +} + +/** + * The alerts store — the dashboard's second factory closure. Same shape as the + * metric store: every operation is a closure over private state. + */ +export function createAlertsStore(deps: StoreDeps, baseUrl: string) { + let alerts: Alert[] = []; + let filters: FilterSpec[] = []; + let mutedWidgets = new Set(); + let lastRefreshedAt = 0; + + /** Pull the current alert set and merge acknowledgements the user made locally. */ + async function refreshAlerts(dashboardId: string): Promise { + const url = joinPath(baseUrl, ALERT_ENDPOINT) + toQueryString({ dashboard: dashboardId }); + let payload: unknown; + try { + payload = await deps.fetchJson(url); + } catch (error) { + deps.log(`refreshAlerts failed: ${error instanceof Error ? error.message : String(error)}`); + return alerts; + } + if (!Array.isArray(payload)) { + deps.log('refreshAlerts got a non-array payload'); + return alerts; + } + + const acknowledged = new Map( + alerts.filter((a) => a.acknowledgedAt !== null).map((a) => [a.id, a.acknowledgedAt]), + ); + const merged: Alert[] = []; + for (const raw of payload as Alert[]) { + if (typeof raw.id !== 'string' || raw.id.length === 0) continue; + merged.push({ + ...raw, + acknowledgedAt: acknowledged.get(raw.id) ?? raw.acknowledgedAt ?? null, + }); + } + merged.sort((a, b) => b.raisedAt - a.raisedAt); + alerts = merged; + lastRefreshedAt = deps.now(); + return alerts; + } + + /** Filter the alert list the same way the metric store filters widgets. */ + function applyAlertFilter(specs: readonly FilterSpec[]): Alert[] { + filters = specs.slice(); + if (filters.length === 0) return alerts; + + const fieldOf = (alert: Alert, field: string): string => { + switch (field) { + case 'severity': return alert.severity; + case 'widget': return alert.widgetId; + case 'message': return alert.message; + default: return ''; + } + }; + + return alerts.filter((alert) => filters.every((spec) => { + const value = fieldOf(alert, spec.field); + switch (spec.op) { + case 'eq': return value.toLowerCase() === spec.value.toLowerCase(); + case 'contains': return value.toLowerCase().includes(spec.value.toLowerCase()); + case 'gt': return value > spec.value; + case 'lt': return value < spec.value; + default: return false; + } + })); + } + + /** Mark an alert acknowledged locally; the next refresh preserves it. */ + function acknowledge(alertId: string): boolean { + const target = alerts.find((a) => a.id === alertId); + if (!target || target.acknowledgedAt !== null) return false; + target.acknowledgedAt = deps.now(); + deps.log(`acknowledged ${alertId}`); + return true; + } + + /** Silence a widget's alerts without dropping them from the buffer. */ + function muteWidget(widgetId: string): void { + mutedWidgets.add(widgetId); + deps.log(`muted ${widgetId} (${mutedWidgets.size} muted)`); + } + + function unmuteWidget(widgetId: string): boolean { + return mutedWidgets.delete(widgetId); + } + + /** The alerts the dashboard should actually show right now. */ + function visibleAlerts(): Alert[] { + return applyAlertFilter(filters) + .filter((a) => !mutedWidgets.has(a.widgetId)) + .filter((a) => a.acknowledgedAt === null); + } + + /** Counts per severity, for the badge on the alerts tab. */ + function countBySeverity(): Record { + const counts: Record = { info: 0, warn: 0, critical: 0 }; + for (const alert of visibleAlerts()) counts[alert.severity] += 1; + return counts; + } + + function reset(): void { + alerts = []; + filters = []; + mutedWidgets = new Set(); + lastRefreshedAt = 0; + } + + function snapshot() { + return { alerts: visibleAlerts(), counts: countBySeverity(), lastRefreshedAt }; + } + + return { + refreshAlerts, + applyAlertFilter, + acknowledge, + muteWidget, + unmuteWidget, + visibleAlerts, + countBySeverity, + reset, + snapshot, + }; +} + +export type AlertsStore = ReturnType; diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/dashboard-store.ts b/__tests__/fixtures/factory-closure-ts/src/stores/dashboard-store.ts new file mode 100644 index 0000000..36be563 --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/stores/dashboard-store.ts @@ -0,0 +1,384 @@ +import type { FilterSpec, MetricSample, StoreDeps, Widget } from './types'; +import { defaultRequestOptions, joinPath, toQueryString } from '../lib/http'; + +const WIDGET_ENDPOINT = '/api/dashboard/widgets'; +const METRIC_ENDPOINT = '/api/dashboard/metrics'; +const SAMPLE_RETENTION_MS = 6 * 60 * 60 * 1000; +const MAX_SAMPLES_PER_WIDGET = 720; +const COLUMN_COUNT = 12; + +/** + * The dashboard store: one factory closure holding every operation the + * dashboard performs. Callers get an object of closures; nothing inside is + * exported on its own. + */ +export function createDashboardStore(deps: StoreDeps, baseUrl: string) { + let widgets: Widget[] = []; + let samples: MetricSample[] = []; + let activeFilters: FilterSpec[] = []; + let lastSyncedAt = 0; + let loading = false; + let lastError: string | null = null; + const listeners = new Set<(snapshot: ReturnType) => void>(); + + function snapshot() { + return { + widgets: widgets.filter((w) => !w.hidden), + sampleCount: samples.length, + filters: activeFilters.slice(), + lastSyncedAt, + loading, + lastError, + }; + } + + /** + * Fetch the widget set for the current user and merge it into local state, + * preserving any layout the user has moved since the last sync. + */ + async function loadWidgets(dashboardId: string, includeHidden = false): Promise { + loading = true; + lastError = null; + const url = joinPath(baseUrl, WIDGET_ENDPOINT) + toQueryString({ + dashboard: dashboardId, + hidden: includeHidden ? '1' : undefined, + }); + + let attempt = 0; + let payload: unknown = null; + while (attempt <= defaultRequestOptions.retries) { + try { + payload = await deps.fetchJson(url); + break; + } catch (error) { + attempt += 1; + if (attempt > defaultRequestOptions.retries) { + lastError = error instanceof Error ? error.message : String(error); + loading = false; + deps.log(`loadWidgets failed after ${attempt} attempts: ${lastError}`); + notify(); + return widgets; + } + deps.log(`loadWidgets retry ${attempt} for ${dashboardId}`); + } + } + + const incoming = Array.isArray(payload) ? (payload as Widget[]) : []; + const byId = new Map(widgets.map((w) => [w.id, w])); + const merged: Widget[] = []; + for (const next of incoming) { + const existing = byId.get(next.id); + if (!existing) { + merged.push({ ...next }); + continue; + } + // Server owns identity and content; the client owns placement. + merged.push({ + ...next, + column: existing.column, + row: existing.row, + span: existing.span, + hidden: existing.hidden, + }); + byId.delete(next.id); + } + for (const orphan of byId.values()) { + deps.log(`widget ${orphan.id} no longer exists on the server`); + } + + widgets = merged; + lastSyncedAt = deps.now(); + loading = false; + notify(); + return widgets; + } + + /** + * Pull fresh metric samples for every visible widget, append them to the + * rolling buffer, and drop anything past the retention window. + */ + async function refreshMetrics(windowMs = SAMPLE_RETENTION_MS): Promise { + if (widgets.length === 0) { + deps.log('refreshMetrics called with no widgets loaded'); + return samples; + } + loading = true; + const visible = widgets.filter((w) => !w.hidden); + const collected: MetricSample[] = []; + + for (const widget of visible) { + const url = joinPath(baseUrl, METRIC_ENDPOINT) + toQueryString({ + widget: widget.id, + since: deps.now() - windowMs, + }); + let payload: unknown; + try { + payload = await deps.fetchJson(url); + } catch (error) { + lastError = error instanceof Error ? error.message : String(error); + deps.log(`refreshMetrics failed for ${widget.id}: ${lastError}`); + continue; + } + if (!Array.isArray(payload)) { + deps.log(`refreshMetrics got a non-array payload for ${widget.id}`); + continue; + } + for (const raw of payload as MetricSample[]) { + if (typeof raw.value !== 'number' || Number.isNaN(raw.value)) continue; + if (typeof raw.at !== 'number' || raw.at <= 0) continue; + collected.push({ + widgetId: widget.id, + at: raw.at, + value: raw.value, + unit: raw.unit ?? 'count', + }); + } + } + + const cutoff = deps.now() - windowMs; + const kept = samples.filter((s) => s.at >= cutoff); + samples = kept.concat(collected); + pruneSamples(MAX_SAMPLES_PER_WIDGET); + lastSyncedAt = deps.now(); + loading = false; + notify(); + return samples; + } + + /** + * Replace the active filter set and recompute which widgets stay visible. + * A widget survives when every filter matches one of its fields. + */ + function applyFilter(specs: readonly FilterSpec[]): Widget[] { + activeFilters = specs.slice(); + if (activeFilters.length === 0) { + widgets = widgets.map((w) => ({ ...w, hidden: false })); + notify(); + return widgets; + } + + const matches = (widget: Widget, spec: FilterSpec): boolean => { + const field = spec.field === 'title' + ? widget.title + : spec.field === 'kind' + ? widget.kind + : spec.field === 'column' + ? String(widget.column) + : ''; + switch (spec.op) { + case 'eq': + return field.toLowerCase() === spec.value.toLowerCase(); + case 'contains': + return field.toLowerCase().includes(spec.value.toLowerCase()); + case 'gt': + return Number(field) > Number(spec.value); + case 'lt': + return Number(field) < Number(spec.value); + default: + return false; + } + }; + + let hiddenCount = 0; + widgets = widgets.map((widget) => { + const visible = activeFilters.every((spec) => matches(widget, spec)); + if (!visible) hiddenCount += 1; + return { ...widget, hidden: !visible }; + }); + deps.log(`applyFilter hid ${hiddenCount} of ${widgets.length} widgets`); + notify(); + return widgets; + } + + /** + * Render the current sample buffer as CSV, one row per sample, ordered by + * widget then timestamp so a diff between two exports stays readable. + */ + function exportCsv(separator = ','): string { + const header = ['widget', 'title', 'at', 'value', 'unit'].join(separator); + if (samples.length === 0) return header; + + const titles = new Map(widgets.map((w) => [w.id, w.title])); + const ordered = samples.slice().sort((a, b) => { + if (a.widgetId !== b.widgetId) return a.widgetId < b.widgetId ? -1 : 1; + return a.at - b.at; + }); + + const escape = (value: string): string => { + if (!value.includes(separator) && !value.includes('"') && !value.includes('\n')) return value; + return `"${value.replace(/"/g, '""')}"`; + }; + + const rows = ordered.map((sample) => [ + escape(sample.widgetId), + escape(titles.get(sample.widgetId) ?? '(unknown)'), + String(sample.at), + String(sample.value), + escape(sample.unit), + ].join(separator)); + + return [header, ...rows].join('\n'); + } + + /** + * Pack widgets back into a dense grid after a move or a hide, so the layout + * never leaves a hole a user has to scroll past. + */ + function reconcileLayout(columnCount = COLUMN_COUNT): Widget[] { + const visible = widgets.filter((w) => !w.hidden); + const hidden = widgets.filter((w) => w.hidden); + + const ordered = visible.slice().sort((a, b) => { + if (a.row !== b.row) return a.row - b.row; + return a.column - b.column; + }); + + const rowWidth = new Map(); + const placed: Widget[] = []; + for (const widget of ordered) { + const span = Math.max(1, Math.min(widget.span, columnCount)); + let row = 0; + let column = 0; + for (;;) { + const used = rowWidth.get(row) ?? 0; + if (used + span <= columnCount) { + column = used; + rowWidth.set(row, used + span); + break; + } + row += 1; + } + placed.push({ ...widget, row, column, span }); + } + + let trailing = placed.length > 0 ? Math.max(...placed.map((w) => w.row)) + 1 : 0; + for (const widget of hidden) { + placed.push({ ...widget, row: trailing, column: 0 }); + trailing += 1; + } + + widgets = placed; + notify(); + return widgets; + } + + /** + * Cap the rolling buffer per widget, keeping the newest samples. Called after + * every refresh so memory stays bounded on a long-lived dashboard. + */ + function pruneSamples(perWidget = MAX_SAMPLES_PER_WIDGET): number { + if (samples.length === 0) return 0; + const grouped = new Map(); + for (const sample of samples) { + const bucket = grouped.get(sample.widgetId); + if (bucket) bucket.push(sample); + else grouped.set(sample.widgetId, [sample]); + } + + let dropped = 0; + const kept: MetricSample[] = []; + for (const [, bucket] of grouped) { + bucket.sort((a, b) => a.at - b.at); + if (bucket.length > perWidget) { + dropped += bucket.length - perWidget; + kept.push(...bucket.slice(bucket.length - perWidget)); + } else { + kept.push(...bucket); + } + } + + kept.sort((a, b) => a.at - b.at); + samples = kept; + if (dropped > 0) deps.log(`pruneSamples dropped ${dropped} samples`); + return dropped; + } + + /** + * Reduce the buffer to one aggregate per widget — the numbers the summary + * strip at the top of the dashboard renders. + */ + function summarize(): Array<{ widgetId: string; title: string; min: number; max: number; mean: number; count: number }> { + const titles = new Map(widgets.map((w) => [w.id, w.title])); + const grouped = new Map(); + for (const sample of samples) { + const bucket = grouped.get(sample.widgetId); + if (bucket) bucket.push(sample); + else grouped.set(sample.widgetId, [sample]); + } + + const out: Array<{ widgetId: string; title: string; min: number; max: number; mean: number; count: number }> = []; + for (const [widgetId, bucket] of grouped) { + let min = Number.POSITIVE_INFINITY; + let max = Number.NEGATIVE_INFINITY; + let total = 0; + for (const sample of bucket) { + if (sample.value < min) min = sample.value; + if (sample.value > max) max = sample.value; + total += sample.value; + } + out.push({ + widgetId, + title: titles.get(widgetId) ?? '(unknown)', + min: bucket.length > 0 ? min : 0, + max: bucket.length > 0 ? max : 0, + mean: bucket.length > 0 ? total / bucket.length : 0, + count: bucket.length, + }); + } + + out.sort((a, b) => b.count - a.count || (a.title < b.title ? -1 : 1)); + return out; + } + + /** Register a listener and get an unsubscribe back. */ + function subscribe(listener: (snapshot: ReturnType) => void): () => void { + listeners.add(listener); + listener(snapshot()); + return () => { + listeners.delete(listener); + }; + } + + function notify(): void { + const current = snapshot(); + for (const listener of listeners) { + try { + listener(current); + } catch (error) { + deps.log(`dashboard listener threw: ${error instanceof Error ? error.message : String(error)}`); + } + } + } + + /** Drop every sample and widget — used when the user switches dashboards. */ + function reset(): void { + widgets = []; + samples = []; + activeFilters = []; + lastSyncedAt = 0; + lastError = null; + loading = false; + notify(); + } + + return { + loadWidgets, + refreshMetrics, + applyFilter, + exportCsv, + reconcileLayout, + pruneSamples, + summarize, + subscribe, + reset, + snapshot, + }; +} + +export type DashboardStore = ReturnType; + +/** One-line description of a store's state, for the debug panel. */ +export function describeStore(store: DashboardStore): string { + const state = store.snapshot(); + return `${state.widgets.length} widgets · ${state.sampleCount} samples · synced ${state.lastSyncedAt}`; +} diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/session-store.ts b/__tests__/fixtures/factory-closure-ts/src/stores/session-store.ts new file mode 100644 index 0000000..031d2ab --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/stores/session-store.ts @@ -0,0 +1,148 @@ +import type { StoreDeps } from './types'; +import { joinPath, toQueryString } from '../lib/http'; + +/** + * The session store — a factory closure and NOTHING else at file scope. No + * companion type alias, no tail helper, no exported constants: every other + * symbol in this file lives inside the closure. That shape matters, because it + * is the one where the enclosing range is the only top-importance symbol the + * file can offer a query. + */ +export function createSessionStore(deps: StoreDeps, baseUrl: string) { + const SESSION_ENDPOINT = '/api/session'; + const REFRESH_SKEW_MS = 30_000; + + let token: string | null = null; + let expiresAt = 0; + let profile: { id: string; email: string; roles: string[] } | null = null; + let refreshing: Promise | null = null; + const auditLog: Array<{ at: number; event: string }> = []; + + function record(event: string): void { + auditLog.push({ at: deps.now(), event }); + if (auditLog.length > 200) auditLog.splice(0, auditLog.length - 200); + } + + /** Exchange credentials for a session token and cache the profile. */ + async function signIn(email: string, password: string): Promise { + const url = joinPath(baseUrl, SESSION_ENDPOINT) + toQueryString({ email }); + let payload: unknown; + try { + payload = await deps.fetchJson(url); + } catch (error) { + record(`signIn failed: ${error instanceof Error ? error.message : String(error)}`); + return false; + } + if (typeof payload !== 'object' || payload === null) { + record('signIn got a non-object payload'); + return false; + } + const body = payload as { token?: string; expiresAt?: number; profile?: typeof profile }; + if (typeof body.token !== 'string' || body.token.length === 0) { + record('signIn payload carried no token'); + return false; + } + void password; + token = body.token; + expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : deps.now() + 3_600_000; + profile = body.profile ?? null; + record(`signIn ok for ${email}`); + return true; + } + + /** Drop every trace of the session, locally and on the server. */ + async function signOut(): Promise { + if (token === null) return; + const url = joinPath(baseUrl, SESSION_ENDPOINT) + toQueryString({ action: 'revoke' }); + try { + await deps.fetchJson(url); + } catch (error) { + record(`signOut revoke failed: ${error instanceof Error ? error.message : String(error)}`); + } + token = null; + expiresAt = 0; + profile = null; + refreshing = null; + record('signOut complete'); + } + + /** + * Renew the token before it expires. Concurrent callers share one in-flight + * request so a burst of requests cannot start a refresh storm. + */ + async function refreshToken(): Promise { + if (token === null) return null; + if (refreshing !== null) return refreshing; + + refreshing = (async () => { + const url = joinPath(baseUrl, SESSION_ENDPOINT) + toQueryString({ action: 'refresh' }); + try { + const payload = await deps.fetchJson(url); + const body = payload as { token?: string; expiresAt?: number }; + if (typeof body?.token === 'string' && body.token.length > 0) { + token = body.token; + expiresAt = typeof body.expiresAt === 'number' ? body.expiresAt : deps.now() + 3_600_000; + record('refreshToken renewed the session'); + return token; + } + record('refreshToken payload carried no token'); + return null; + } catch (error) { + record(`refreshToken failed: ${error instanceof Error ? error.message : String(error)}`); + return null; + } finally { + refreshing = null; + } + })(); + + return refreshing; + } + + /** The token to send with a request, renewing it first when it is close to expiry. */ + async function authorize(): Promise { + if (token === null) return null; + if (deps.now() + REFRESH_SKEW_MS < expiresAt) return token; + return refreshToken(); + } + + /** Does the signed-in user hold every one of these roles? */ + function hasRoles(...required: string[]): boolean { + if (profile === null) return false; + const held = new Set(profile.roles); + for (const role of required) { + if (!held.has(role)) return false; + } + return true; + } + + /** Seconds left on the session, floored at zero. */ + function secondsRemaining(): number { + if (token === null) return 0; + return Math.max(0, Math.floor((expiresAt - deps.now()) / 1000)); + } + + /** The last N audit entries, newest first — what the account page renders. */ + function recentActivity(limit = 20): Array<{ at: number; event: string }> { + return auditLog.slice(-limit).reverse(); + } + + function snapshot() { + return { + signedIn: token !== null, + email: profile?.email ?? null, + roles: profile?.roles ?? [], + secondsRemaining: secondsRemaining(), + }; + } + + return { + signIn, + signOut, + refreshToken, + authorize, + hasRoles, + secondsRemaining, + recentActivity, + snapshot, + }; +} diff --git a/__tests__/fixtures/factory-closure-ts/src/stores/types.ts b/__tests__/fixtures/factory-closure-ts/src/stores/types.ts new file mode 100644 index 0000000..0659cf6 --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/stores/types.ts @@ -0,0 +1,28 @@ +export interface Widget { + id: string; + kind: 'chart' | 'table' | 'stat'; + title: string; + column: number; + row: number; + span: number; + hidden: boolean; +} + +export interface MetricSample { + widgetId: string; + at: number; + value: number; + unit: string; +} + +export interface FilterSpec { + field: string; + op: 'eq' | 'gt' | 'lt' | 'contains'; + value: string; +} + +export interface StoreDeps { + fetchJson: (url: string) => Promise; + now: () => number; + log: (message: string) => void; +} diff --git a/__tests__/fixtures/factory-closure-ts/src/ui/panel.ts b/__tests__/fixtures/factory-closure-ts/src/ui/panel.ts new file mode 100644 index 0000000..28204e8 --- /dev/null +++ b/__tests__/fixtures/factory-closure-ts/src/ui/panel.ts @@ -0,0 +1,43 @@ +import type { DashboardStore } from '../stores/dashboard-store'; +import type { FilterSpec } from '../stores/types'; +import { medianOf } from '../lib/metrics'; + +/** The dashboard panel — the only consumer of the store's closures. */ +export function mountPanel(store: DashboardStore, dashboardId: string) { + let disposed = false; + + const unsubscribe = store.subscribe((state) => { + if (disposed) return; + render(state.widgets.length, state.sampleCount, state.loading); + }); + + async function boot(): Promise { + await store.loadWidgets(dashboardId); + await store.refreshMetrics(); + store.reconcileLayout(); + } + + function search(text: string): void { + const specs: FilterSpec[] = text.trim().length === 0 + ? [] + : [{ field: 'title', op: 'contains', value: text.trim() }]; + store.applyFilter(specs); + } + + function download(): string { + return store.exportCsv(); + } + + function render(widgetCount: number, sampleCount: number, loading: boolean): void { + void widgetCount; + void sampleCount; + void loading; + } + + function dispose(): void { + disposed = true; + unsubscribe(); + } + + return { boot, search, download, dispose, median: medianOf }; +} diff --git a/docs/benchmarks/explore-factory-closure-cg27.md b/docs/benchmarks/explore-factory-closure-cg27.md new file mode 100644 index 0000000..ef39aef --- /dev/null +++ b/docs/benchmarks/explore-factory-closure-cg27.md @@ -0,0 +1,133 @@ +# Deterministic measurement — the factory-closure envelope (task CG-27) + +**Date:** 2026-08-06 · **Baseline:** `feature/CG-24` @ `dc4fd75` · +**Harness:** `scripts/agent-eval/probe-factory-closure.mjs` against a hermetic fixture +(`__tests__/fixtures/factory-closure-ts/`, copied to a temp dir and indexed per run, so two runs +on one build give identical numbers). No agent A/B: the claim under test is which SYMBOLS get +selected inside one file, and the agent runs are far too noisy to see that. + +**Verdict: the premise does not survive measurement. CG-27 is closed as obsolete, CG-30 credited.** +The literal change the issue proposes is a large REGRESSION, and a more careful mechanism reaching +the same intent is noise (69 vs 68 inner definitions delivered across nine query shapes). + +--- + +## The claim + +`ENVELOPE_KINDS` in `src/mcp/tools.ts` drops a node covering >50% of its file from the cluster +ranges, so the granular symbols inside form their own clusters instead of merging into one blob. +It lists container kinds — `class`, `struct`, `interface`, `enum`, … — and **not `function` or +`method`**. A factory closure (`createFoo()` returning an object of closures) therefore survives +as a file-spanning range. That shape is common, not a one-repo quirk: Svelte 5 `.svelte.ts` rune +stores, React custom-hook modules, IIFE/module-pattern JS, and Zustand's +`create((set, get) => ({ … }))`. + +CG-30 already bounds the BYTES such a member may spend, so what remained was a ranking claim: +a file-spanning range merges every inner symbol into one cluster, so selection cannot rank and +pick the relevant closures independently. The issue required that claim be measured before any fix. + +## The fixture + +`__tests__/fixtures/factory-closure-ts/` — a dashboard app with three stores written as factory +closures, two stateless services and a UI consumer competing for one envelope. + +| file | lines | shape | +|---|---|---| +| `src/stores/dashboard-store.ts` | 385 | `createDashboardStore` spans 15–376 (**94%**), 11 closures inside; a tail type alias + helper at file scope | +| `src/stores/alerts-store.ts` | 141 | `createAlertsStore` spans 19–138 (**85%**), 9 closures inside | +| `src/stores/session-store.ts` | 148 | a factory and NOTHING else at file scope — no companion type, no tail helper | +| `src/services/metric-service.ts`, `src/services/filter-parser.ts` | 105, 62 | ordinary top-level functions — the control | + +Both factory files are past `WHOLE_FILE_MAX_LINES` where it matters, so they render through the +cluster path and the envelope actually bites. + +## Result 1 — the envelope is almost never selected in the first place + +`shrinkCluster` orders a cluster's members by **(importance desc, size ASC)** and refuses any +member that overruns the cap once something is kept. A file-spanning member is therefore only ever +selected when it is the FIRST candidate — which requires it to be the *sole* member of the top +importance tier. In eight of the nine query shapes measured, some smaller member shared that tier +(a one-line type alias, a tail helper, another closure), so the factory sorted last and was never +kept. The envelope was inert. + +## Result 2 — the proposed change is a large regression + +Making the >50% drop kind-independent, measured on the primary query +(*"how does the dashboard store refresh its metrics and apply a filter"*): + +| | baseline | drop the range | +|---|---|---| +| `dashboard-store.ts` (rank #1) delivered | 7,539 chars | **397** | +| inner closure definitions delivered | 7 of 11 | **0 of 11** | +| its own reservation left unspent | 0 | ~5,200 of 5,601 | + +The mechanism, from the cluster dump: dropping the range **splits** the file into two clusters — +`378-384` (a one-line type alias plus a four-line helper, score 15, span 7) and `4-362` (every +closure, score 116, span 359). Cluster ranking breaks the `maxImportance` tie on **density**, so +the trivial cluster wins, is taken first, and is the only one that may be shrunk. The +answer-bearing cluster then does not fit the remainder and is **dropped whole** — later clusters +are never shrunk, by design. + +The enclosing range is what was holding the file together as one cluster, inside which +`shrinkCluster` was already doing exactly the per-symbol ranking the issue asked for. + +## Result 3 — the careful version of the same intent is noise + +Deferring the envelope MEMBER inside `shrinkCluster` (leaving clustering granularity untouched, so +Result 2's split never happens) reaches the issue's intent by a better mechanism. Nine query +shapes, same fixture, same indexes — inner closure definitions delivered: + +| query | target | baseline | deferred | +|---|---|---|---| +| how does the dashboard store refresh its metrics and apply a filter | dashboard | 7/11 | **8/11** | +| createDashboardStore | dashboard | 8/11 | 8/11 | +| how is the dashboard store created and wired up | dashboard | **9/11** | 8/11 | +| createDashboardStore exportCsv summarize | dashboard | 9/11 | 9/11 | +| where is the dashboard store constructed | dashboard | 7/11 | 7/11 | +| how are widgets loaded and the layout reconciled | dashboard | 4/11 | 4/11 | +| createSessionStore (adverse: the factory IS the sole top-tier member) | alerts | 6/9 | **7/9** | +| how are alerts refreshed and acknowledged | alerts | 9/9 | 9/9 | +| createAlertsStore | alerts | 9/9 | 9/9 | +| **total** | | **68** | **69** | + +One better, one worse, seven unchanged — on a fixture built specifically to make this pattern +maximally visible. That is not a measurable selection improvement, so nothing shipped. + +## Where the envelope DOES get selected, and why CG-30 already covers it + +The adverse row above is the one configuration the ordering cannot neutralise: `createAlertsStore` +was the sole importance-10 member, so it was kept first at 3,939 chars against a 2,468 cap and +every closure was skipped. CG-30 then **windowed it on whole lines** rather than emitting it whole +or dropping the file — the response carried lines 16–108, a contiguous, readable head of the +factory carrying 6 of its 9 closure definitions. Bounded, sufficient, never empty. That is the +symptom this issue was filed against, already absorbed. + +--- + +## Byproduct — a real defect this measurement exposed (filed separately) + +Result 2's mechanism is not confined to the hypothetical change. Instrumenting the **epic tip** +across the deterministic 6-repo suite for files that drop a cluster while leaving most of their +reservation unspent: + +| file | budget | spent | unspent | kept cluster | dropped cluster | +|---|---|---|---|---|---| +| `django/db/models/sql/query.py` | 10,135 | 1,923 | **8,212 (81%)** | 1379–1400, score 14 | 306–929, **score 290** | +| `okhttp .../RealInterceptorChain.kt` | 6,058 | 1,474 | **4,584 (76%)** | 16–44, score 44 | 113–373, score 171 | +| `okhttp .../Interceptor.kt` | 4,697 | 2,027 | 2,670 (57%) | 85–138, score 21 | 154–257, score 10 | +| `gin/routergroup.go` | 5,782 | 3,273 | 2,509 (43%) | 33–91, score 116 | 103–188, score 128 | + +A file whose top cluster by density is trivial keeps that one, drops the cluster carrying 20x the +score, and leaves most of its own reservation unspent — because only the first-chosen cluster may +be shrunk. `query.py` is the file CLAUDE.md already names as the `_fetch_all` case. + +## Reproducing + +```bash +npm run build +node scripts/agent-eval/probe-factory-closure.mjs # primary query +node scripts/agent-eval/probe-factory-closure.mjs \ + --target src/stores/alerts-store.ts --factory createAlertsStore \ + --query "createSessionStore" # the adverse configuration +npx vitest run __tests__/explore-factory-closure.test.ts # the standing gate +``` diff --git a/scripts/agent-eval/probe-factory-closure.mjs b/scripts/agent-eval/probe-factory-closure.mjs new file mode 100644 index 0000000..0b5a6e9 --- /dev/null +++ b/scripts/agent-eval/probe-factory-closure.mjs @@ -0,0 +1,147 @@ +#!/usr/bin/env node +/** + * CG-27 measurement probe — what a factory-closure file actually delivers. + * + * `probe-allocation.mjs` measures how the envelope is split BETWEEN files. This + * one measures what comes back from WITHIN one file whose top-level symbol spans + * almost all of it: a `createFoo()` factory returning an object of closures + * (Svelte 5 rune stores, React hook modules, Zustand `create((set,get)=>({…}))`, + * IIFE module-pattern JS). The claim under test is a ranking one, not a byte one + * — CG-30 already bounds the bytes — so the number that matters is WHICH inner + * symbols reach the agent, not how many chars did. + * + * Prints, for the factory file: every line range the response delivered, and for + * each inner function whether its DEFINITION LINE is inside one of them. + * + * Usage (needs a current `npm run build`): + * node scripts/agent-eval/probe-factory-closure.mjs + * node scripts/agent-eval/probe-factory-closure.mjs --json + * node scripts/agent-eval/probe-factory-closure.mjs --query "..." + */ +import { cpSync, mkdtempSync, readFileSync, rmSync, existsSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { dirname, join, resolve } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const HERE = dirname(fileURLToPath(import.meta.url)); +const REPO_ROOT = resolve(HERE, '../..'); +const FIXTURE = join(REPO_ROOT, '__tests__/fixtures/factory-closure-ts'); +const targetAt = process.argv.indexOf('--target'); +const TARGET = targetAt >= 0 ? process.argv[targetAt + 1] : 'src/stores/dashboard-store.ts'; +const factoryAt = process.argv.indexOf('--factory'); +const FACTORY = factoryAt >= 0 ? process.argv[factoryAt + 1] : 'createDashboardStore'; + +const argv = process.argv.slice(2); +const asJson = argv.includes('--json'); +const queryAt = argv.indexOf('--query'); +const QUERY = queryAt >= 0 + ? argv[queryAt + 1] + : 'how does the dashboard store refresh its metrics and apply a filter'; + +const say = (s = '') => { if (!asJson) console.log(s); }; +const num = (n) => Math.round(n).toLocaleString('en-US'); + +const load = (rel) => import(pathToFileURL(resolve(REPO_ROOT, rel)).href); +if (!existsSync(join(REPO_ROOT, 'dist/index.js'))) { + console.error('dist/ not built — run `npm run build` first.'); + process.exit(2); +} +const idxMod = await load('dist/index.js'); +const toolsMod = await load('dist/mcp/tools.js'); +const CodeGraph = idxMod.default?.default ?? idxMod.default ?? idxMod.CodeGraph; +const ToolHandler = toolsMod.ToolHandler ?? toolsMod.default?.ToolHandler; + +const dir = mkdtempSync(join(tmpdir(), 'cg-factory-')); +cpSync(FIXTURE, dir, { recursive: true }); +rmSync(join(dir, '.codegraph'), { recursive: true, force: true }); + +let out; +try { + let cg = CodeGraph.initSync(dir); + await cg.indexAll(); + + // Inner function definitions, straight from the index — the symbols the file's + // enclosing factory range would otherwise swallow. + const nodes = cg.getNodesInFile(TARGET); + const factory = nodes.find((n) => n.name === FACTORY); + const inner = nodes + .filter((n) => (n.kind === 'function' || n.kind === 'method') + && n.name !== FACTORY + && factory && n.startLine > factory.startLine && n.endLine <= factory.endLine) + .sort((a, b) => a.startLine - b.startLine); + cg.close?.(); + + const sidecar = join(dir, 'diag.jsonl'); + process.env.CODEGRAPH_EXPLORE_DEBUG = sidecar; + cg = CodeGraph.openSync(dir); + const res = await new ToolHandler(cg).execute('codegraph_explore', { query: QUERY }); + const text = res.content?.[0]?.text ?? ''; + cg.close?.(); + delete process.env.CODEGRAPH_EXPLORE_DEBUG; + const report = JSON.parse(readFileSync(sidecar, 'utf8').trim().split('\n').pop()); + + // Which source lines of the target file the response actually carries. The + // response numbers every delivered line `\t`; match them back against + // the file so a line number that merely appears in prose can't count. + const source = readFileSync(join(dir, TARGET), 'utf8').split('\n'); + const delivered = new Set(); + for (const line of text.split('\n')) { + const m = /^(\d+)\t(.*)$/.exec(line); + if (!m) continue; + const n = Number(m[1]); + if (n >= 1 && n <= source.length && source[n - 1] === m[2]) delivered.add(n); + } + // Collapse to ranges for display. + const ranges = []; + for (const n of [...delivered].sort((a, b) => a - b)) { + const last = ranges[ranges.length - 1]; + if (last && n === last.end + 1) last.end = n; + else ranges.push({ start: n, end: n }); + } + + const covered = (n) => delivered.has(n.startLine); + const rec = report.files.find((f) => f.path === TARGET) ?? null; + + out = { + query: QUERY, + target: TARGET, + fileLines: source.length, + factory: factory ? { name: factory.name, start: factory.startLine, end: factory.endLine } : null, + file: rec && { + rank: rec.rank, render: rec.render, clipped: rec.clipped, + emittedChars: rec.emittedChars, finalChars: rec.finalChars, + allowance: rec.allowance, spendable: rec.spendable, skipped: rec.skipped, + }, + deliveredRanges: ranges, + deliveredLines: delivered.size, + inner: inner.map((n) => ({ name: n.name, start: n.startLine, end: n.endLine, delivered: covered(n) })), + innerDelivered: inner.filter(covered).length, + innerTotal: inner.length, + envelope: report.envelope, + allFiles: report.files + .filter((f) => f.emittedChars > 0 || f.finalChars > 0) + .map((f) => ({ rank: f.rank, path: f.path, render: f.render, emitted: f.emittedChars, final: f.finalChars })), + }; +} finally { + rmSync(dir, { recursive: true, force: true }); +} + +if (asJson) { + console.log(JSON.stringify(out, null, 2)); +} else { + say(`query "${out.query}"`); + say(`target ${out.target} — ${out.fileLines} lines, factory ${out.factory?.name} spans ${out.factory?.start}–${out.factory?.end}`); + say(''); + say(' # render emitted final file'); + for (const f of out.allFiles) { + say(` ${String(f.rank).padStart(2)} ${(f.render ?? '-').padEnd(10)} ${num(f.emitted).padStart(7)} ${num(f.final).padStart(7)} ${f.path}`); + } + say(''); + say(`delivered lines of ${out.target}: ${out.deliveredLines}`); + say(` ranges: ${out.deliveredRanges.map((r) => `${r.start}-${r.end}`).join(', ') || '(none)'}`); + say(''); + say(`inner symbols whose definition reached the agent: ${out.innerDelivered}/${out.innerTotal}`); + for (const n of out.inner) { + say(` ${n.delivered ? '✓' : '·'} ${n.name} (${n.start}–${n.end})`); + } +}