Просмотр исходного кода

test(explore): add the factory-closure fixture and its selection probe (CG-27)

A file whose top-level symbol spans almost all of it — createFoo() returning
an object of closures — is how Svelte 5 rune stores, React custom-hook modules,
IIFE module-pattern JS and Zustand's create((set,get)=>({…})) are all written.
probe-factory-closure.mjs measures what such a file DELIVERS from within: which
inner symbols' definitions reach the agent, not how many bytes did.
Colby McHenry 1 месяц назад
Родитель
Сommit
d49265043c

+ 5 - 0
__tests__/fixtures/factory-closure-ts/package.json

@@ -0,0 +1,5 @@
+{
+  "name": "factory-closure-ts",
+  "version": "0.0.0",
+  "private": true
+}

+ 23 - 0
__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<typeof createDashboardStore>, text: string) {
+  return store.applyFilter(parseFilterText(text));
+}
+
+export { refreshMetricCache };

+ 25 - 0
__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, string | number | undefined>): 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;
+}

+ 37 - 0
__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<number, MetricSample[]> {
+  const buckets = new Map<number, MetricSample[]>();
+  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;
+}

+ 62 - 0
__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<string, FilterSpec['op']> = {
+  ':': '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 `field<op>value` 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(' ');
+}

+ 101 - 0
__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<string, MetricSample[]>,
+  incoming: readonly MetricSample[],
+  now: number,
+): string[] {
+  const touched = new Set<string>();
+  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<string, MetricSample[]>();
+  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<string, number>();
+  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();
+}

+ 140 - 0
__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<string>();
+  let lastRefreshedAt = 0;
+
+  /** Pull the current alert set and merge acknowledgements the user made locally. */
+  async function refreshAlerts(dashboardId: string): Promise<Alert[]> {
+    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<Alert['severity'], number> {
+    const counts: Record<Alert['severity'], number> = { 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<typeof createAlertsStore>;

+ 384 - 0
__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<typeof snapshot>) => 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<Widget[]> {
+    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<MetricSample[]> {
+    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<number, number>();
+    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<string, MetricSample[]>();
+    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<string, MetricSample[]>();
+    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<typeof snapshot>) => 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<typeof createDashboardStore>;
+
+/** 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}`;
+}

+ 28 - 0
__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<unknown>;
+  now: () => number;
+  log: (message: string) => void;
+}

+ 43 - 0
__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<void> {
+    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 };
+}

+ 144 - 0
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 `<n>\t<text>`; 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})`);
+  }
+}