ソースを参照

merge: factory-closure envelope premise measured and rejected (CG-27)

CG-27 proposed adding function/method to ENVELOPE_KINDS so a factory closure
spanning most of its file stops merging every inner symbol into one cluster.
The issue required the ranking claim be measured before any fix. It was, on a
hermetic fixture built to make the pattern maximally visible, and it does not
hold — nothing shipped to src/.

The literal change is a large regression: dropping the enclosing range SPLITS
the file into a trivial cluster (a type alias plus a helper, span 7) and the
answer-bearing one (every closure, span 359). Cluster ranking breaks the equal
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
and is dropped whole. Rank #1 fell from 7,539 delivered chars to 397, and from
7 of 11 inner closures to 0. The enclosing range was holding the file together
as one cluster, inside which shrinkCluster already did the per-symbol ranking
the issue asked for.

A better mechanism reaching the same intent — deferring the envelope member
inside shrinkCluster, leaving clustering untouched — is noise: 69 vs 68 inner
definitions across nine query shapes, one better, one worse, seven unchanged.

The one configuration where the envelope IS selected (the factory as sole
top-tier member) is already absorbed by CG-30, which windows it on whole lines:
a contiguous readable head carrying 6 of 9 closures, bounded and never empty.

Kept: the fixture, the deterministic probe, and the measurement record.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Colby McHenry 1 ヶ月 前
コミット
463f6e7844

+ 157 - 0
__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<number>;
+  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);
+    });
+  });
+});

+ 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}`;
+}

+ 148 - 0
__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<string | null> | 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<boolean> {
+    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<void> {
+    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<string | null> {
+    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<string | null> {
+    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,
+  };
+}

+ 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 };
+}

+ 133 - 0
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
+```

+ 147 - 0
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 `<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})`);
+  }
+}