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.
This commit is contained in:
@@ -0,0 +1,5 @@
|
|||||||
|
{
|
||||||
|
"name": "factory-closure-ts",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(' ');
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -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})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user