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/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/scripts/agent-eval/probe-factory-closure.mjs b/scripts/agent-eval/probe-factory-closure.mjs new file mode 100644 index 0000000..cf6301f --- /dev/null +++ b/scripts/agent-eval/probe-factory-closure.mjs @@ -0,0 +1,144 @@ +#!/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 TARGET = 'src/stores/dashboard-store.ts'; + +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 === 'createDashboardStore'); + const inner = nodes + .filter((n) => (n.kind === 'function' || n.kind === 'method') + && n.name !== 'createDashboardStore' + && 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})`); + } +}