@forwardimpact/libeval 0.1.64 → 0.1.65
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/fit-trace.js +121 -31
- package/package.json +1 -1
- package/src/commands/trace.js +245 -51
- package/src/trace-multi.js +101 -0
- package/src/trace-query.js +206 -137
- package/src/trace-render.js +211 -0
- package/src/trace-usage.js +249 -0
package/src/commands/trace.js
CHANGED
|
@@ -1,9 +1,20 @@
|
|
|
1
|
-
import { join, dirname } from "node:path";
|
|
1
|
+
import { join, dirname, basename } from "node:path";
|
|
2
2
|
import { isoTimestamp } from "@forwardimpact/libutil";
|
|
3
3
|
import { createTraceCollector, sumTraceCost } from "@forwardimpact/libeval";
|
|
4
4
|
import { createTraceQuery } from "../trace-query.js";
|
|
5
5
|
import { createTraceGitHub } from "../trace-github.js";
|
|
6
6
|
import { stripSignatures } from "../signature-filter.js";
|
|
7
|
+
import { runOver, aggregate, compareTwo } from "../trace-multi.js";
|
|
8
|
+
import {
|
|
9
|
+
renderToolCalls,
|
|
10
|
+
renderCommands,
|
|
11
|
+
renderPaths,
|
|
12
|
+
renderCompare,
|
|
13
|
+
renderStatsByTool,
|
|
14
|
+
renderStatsSummary,
|
|
15
|
+
renderSearch,
|
|
16
|
+
renderDefault,
|
|
17
|
+
} from "../trace-render.js";
|
|
7
18
|
|
|
8
19
|
// Every handler receives a libcli `InvocationContext`:
|
|
9
20
|
// ctx.options — parsed flag values (`cli.parse().values`)
|
|
@@ -12,6 +23,58 @@ import { stripSignatures } from "../signature-filter.js";
|
|
|
12
23
|
// Handlers read/write the filesystem and stdout exclusively through
|
|
13
24
|
// `ctx.deps.runtime` and return `{ ok: true }` on success.
|
|
14
25
|
|
|
26
|
+
/** Characters whose presence in a `--file` value marks it as a glob. */
|
|
27
|
+
const GLOB_CHARS = /[*?[\]{}]/;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Resolve the cross-trace `--file` option (`ctx.options.file`) into a sorted
|
|
31
|
+
* flat list of file paths. A literal path passes through; a value carrying
|
|
32
|
+
* glob metacharacters expands via `runtime.fsSync.globSync`. The literal-path
|
|
33
|
+
* fast path means the common single-file and shell-pre-expanded cases never
|
|
34
|
+
* touch `globSync`.
|
|
35
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
36
|
+
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
37
|
+
* @returns {string[]}
|
|
38
|
+
*/
|
|
39
|
+
function resolveFiles(runtime, ctx) {
|
|
40
|
+
const raw = ctx.options.file;
|
|
41
|
+
const values = raw === undefined ? [] : Array.isArray(raw) ? raw : [raw];
|
|
42
|
+
const out = [];
|
|
43
|
+
for (const value of values) {
|
|
44
|
+
if (GLOB_CHARS.test(value)) {
|
|
45
|
+
out.push(...runtime.fsSync.globSync(value));
|
|
46
|
+
} else {
|
|
47
|
+
out.push(value);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
return out.sort();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Emit a query result for a cross-trace verb: under `--format json` write the
|
|
55
|
+
* JSON payload (single-object verbs unwrap when single-file so the envelope
|
|
56
|
+
* deep-equals today's output); otherwise render text to stdout. Source
|
|
57
|
+
* attribution is the renderer's job, gated by `multi`.
|
|
58
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
59
|
+
* @param {object|object[]} result
|
|
60
|
+
* @param {Function} renderer
|
|
61
|
+
* @param {import("@forwardimpact/libcli").InvocationContext} ctx
|
|
62
|
+
* @param {boolean} multi
|
|
63
|
+
* @param {boolean} [unwrap=false] - Single-object verb wrapped in a one-element array.
|
|
64
|
+
*/
|
|
65
|
+
function emit(runtime, result, renderer, ctx, multi, unwrap = false) {
|
|
66
|
+
if (ctx.options.format === "json") {
|
|
67
|
+
const payload = unwrap && !multi ? result[0] : result;
|
|
68
|
+
writeJSON(runtime, payload, ctx.options);
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
const text = renderer(result, {
|
|
72
|
+
multi,
|
|
73
|
+
signatures: !!ctx.options.signatures,
|
|
74
|
+
});
|
|
75
|
+
runtime.proc.stdout.write(text + "\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
15
78
|
// --- GitHub commands ---
|
|
16
79
|
|
|
17
80
|
/**
|
|
@@ -94,49 +157,78 @@ export async function runDownloadCommand(ctx) {
|
|
|
94
157
|
|
|
95
158
|
// --- Query commands ---
|
|
96
159
|
|
|
160
|
+
/**
|
|
161
|
+
* Build the injected loader the orchestrator uses (wires the runtime IO seam).
|
|
162
|
+
* @param {import("@forwardimpact/libutil/runtime").Runtime} runtime
|
|
163
|
+
* @returns {(file: string) => import("../trace-query.js").TraceQuery}
|
|
164
|
+
*/
|
|
165
|
+
function loader(runtime) {
|
|
166
|
+
return (file) => loadTrace(runtime, file);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** No-files error envelope for a cross-trace verb. */
|
|
170
|
+
function noFiles(verb) {
|
|
171
|
+
return { ok: false, code: 1, error: `${verb}: no files (use --file)` };
|
|
172
|
+
}
|
|
173
|
+
|
|
97
174
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
98
175
|
export async function runOverviewCommand(ctx) {
|
|
99
176
|
const { runtime } = ctx.deps;
|
|
100
|
-
|
|
177
|
+
const files = resolveFiles(runtime, ctx);
|
|
178
|
+
if (files.length === 0) return noFiles("overview");
|
|
179
|
+
const result = runOver(files, (tq) => [tq.overview()], loader(runtime));
|
|
180
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1, true);
|
|
101
181
|
return { ok: true };
|
|
102
182
|
}
|
|
103
183
|
|
|
104
184
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
105
185
|
export async function runCountCommand(ctx) {
|
|
106
186
|
const { runtime } = ctx.deps;
|
|
107
|
-
runtime
|
|
108
|
-
|
|
187
|
+
const files = resolveFiles(runtime, ctx);
|
|
188
|
+
if (files.length === 0) return noFiles("count");
|
|
189
|
+
const multi = files.length > 1;
|
|
190
|
+
const result = runOver(
|
|
191
|
+
files,
|
|
192
|
+
(tq) => [{ count: tq.count() }],
|
|
193
|
+
loader(runtime),
|
|
109
194
|
);
|
|
195
|
+
for (const r of result) {
|
|
196
|
+
const prefix = multi && r.source ? `${r.source}:` : "";
|
|
197
|
+
runtime.proc.stdout.write(`${prefix}${r.count}\n`);
|
|
198
|
+
}
|
|
110
199
|
return { ok: true };
|
|
111
200
|
}
|
|
112
201
|
|
|
113
202
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
114
203
|
export async function runBatchCommand(ctx) {
|
|
115
204
|
const { runtime } = ctx.deps;
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
parseInt(ctx.args.from, 10),
|
|
120
|
-
parseInt(ctx.args.to, 10),
|
|
121
|
-
),
|
|
122
|
-
ctx.options,
|
|
205
|
+
const result = loadTrace(runtime, ctx.args.file).batch(
|
|
206
|
+
parseInt(ctx.args.from, 10),
|
|
207
|
+
parseInt(ctx.args.to, 10),
|
|
123
208
|
);
|
|
209
|
+
emit(runtime, result, renderDefault, ctx, false);
|
|
124
210
|
return { ok: true };
|
|
125
211
|
}
|
|
126
212
|
|
|
127
213
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
128
214
|
export async function runHeadCommand(ctx) {
|
|
129
215
|
const { runtime } = ctx.deps;
|
|
130
|
-
const
|
|
131
|
-
|
|
216
|
+
const files = resolveFiles(runtime, ctx);
|
|
217
|
+
if (files.length === 0) return noFiles("head");
|
|
218
|
+
const n = ctx.options.lines ? parseInt(ctx.options.lines, 10) : 10;
|
|
219
|
+
const result = runOver(files, (tq) => tq.head(n), loader(runtime));
|
|
220
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1);
|
|
132
221
|
return { ok: true };
|
|
133
222
|
}
|
|
134
223
|
|
|
135
224
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
136
225
|
export async function runTailCommand(ctx) {
|
|
137
226
|
const { runtime } = ctx.deps;
|
|
138
|
-
const
|
|
139
|
-
|
|
227
|
+
const files = resolveFiles(runtime, ctx);
|
|
228
|
+
if (files.length === 0) return noFiles("tail");
|
|
229
|
+
const n = ctx.options.lines ? parseInt(ctx.options.lines, 10) : 10;
|
|
230
|
+
const result = runOver(files, (tq) => tq.tail(n), loader(runtime));
|
|
231
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1);
|
|
140
232
|
return { ok: true };
|
|
141
233
|
}
|
|
142
234
|
|
|
@@ -146,72 +238,120 @@ export async function runSearchCommand(ctx) {
|
|
|
146
238
|
const limit = ctx.options.limit ? parseInt(ctx.options.limit, 10) : 50;
|
|
147
239
|
const context = ctx.options.context ? parseInt(ctx.options.context, 10) : 0;
|
|
148
240
|
const full = ctx.options.full ?? false;
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
}),
|
|
156
|
-
ctx.options,
|
|
157
|
-
);
|
|
241
|
+
const result = loadTrace(runtime, ctx.args.file).search(ctx.args.pattern, {
|
|
242
|
+
limit,
|
|
243
|
+
context,
|
|
244
|
+
full,
|
|
245
|
+
});
|
|
246
|
+
emit(runtime, result, renderSearch, ctx, false);
|
|
158
247
|
return { ok: true };
|
|
159
248
|
}
|
|
160
249
|
|
|
161
250
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
162
251
|
export async function runToolsCommand(ctx) {
|
|
163
252
|
const { runtime } = ctx.deps;
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
253
|
+
const files = resolveFiles(runtime, ctx);
|
|
254
|
+
if (files.length === 0) return noFiles("tools");
|
|
255
|
+
const result = aggregate(
|
|
256
|
+
files,
|
|
257
|
+
(tq) => tq.toolFrequency(),
|
|
258
|
+
(r) => r.tool,
|
|
259
|
+
loader(runtime),
|
|
168
260
|
);
|
|
261
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1);
|
|
169
262
|
return { ok: true };
|
|
170
263
|
}
|
|
171
264
|
|
|
172
265
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
173
266
|
export async function runToolCommand(ctx) {
|
|
174
267
|
const { runtime } = ctx.deps;
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
loadTrace(runtime, ctx.args.file).tool(ctx.args.name),
|
|
178
|
-
ctx.options,
|
|
179
|
-
);
|
|
268
|
+
const result = loadTrace(runtime, ctx.args.file).tool(ctx.args.name);
|
|
269
|
+
emit(runtime, result, renderDefault, ctx, false);
|
|
180
270
|
return { ok: true };
|
|
181
271
|
}
|
|
182
272
|
|
|
183
273
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
184
274
|
export async function runErrorsCommand(ctx) {
|
|
185
275
|
const { runtime } = ctx.deps;
|
|
186
|
-
|
|
276
|
+
const files = resolveFiles(runtime, ctx);
|
|
277
|
+
if (files.length === 0) return noFiles("errors");
|
|
278
|
+
const result = runOver(files, (tq) => tq.errors(), loader(runtime));
|
|
279
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1);
|
|
187
280
|
return { ok: true };
|
|
188
281
|
}
|
|
189
282
|
|
|
190
283
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
191
284
|
export async function runReasoningCommand(ctx) {
|
|
192
285
|
const { runtime } = ctx.deps;
|
|
286
|
+
const files = resolveFiles(runtime, ctx);
|
|
287
|
+
if (files.length === 0) return noFiles("reasoning");
|
|
193
288
|
const from = ctx.options.from ? parseInt(ctx.options.from, 10) : undefined;
|
|
194
289
|
const to = ctx.options.to ? parseInt(ctx.options.to, 10) : undefined;
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
290
|
+
const result = runOver(
|
|
291
|
+
files,
|
|
292
|
+
(tq) => tq.reasoning({ from, to }),
|
|
293
|
+
loader(runtime),
|
|
199
294
|
);
|
|
295
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1);
|
|
200
296
|
return { ok: true };
|
|
201
297
|
}
|
|
202
298
|
|
|
203
299
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
204
300
|
export async function runTimelineCommand(ctx) {
|
|
205
301
|
const { runtime } = ctx.deps;
|
|
206
|
-
const
|
|
207
|
-
|
|
302
|
+
const files = resolveFiles(runtime, ctx);
|
|
303
|
+
if (files.length === 0) return noFiles("timeline");
|
|
304
|
+
const multi = files.length > 1;
|
|
305
|
+
for (const file of files) {
|
|
306
|
+
if (multi) runtime.proc.stdout.write(`# ${basename(file)}\n`);
|
|
307
|
+
runtime.proc.stdout.write(
|
|
308
|
+
loadTrace(runtime, file).timeline().join("\n") + "\n",
|
|
309
|
+
);
|
|
310
|
+
}
|
|
208
311
|
return { ok: true };
|
|
209
312
|
}
|
|
210
313
|
|
|
314
|
+
/** Select the per-file `stats` query for the active flag combination. */
|
|
315
|
+
function statsQuery(ctx) {
|
|
316
|
+
if (ctx.options.summary) return (tq) => tq.statsSummary();
|
|
317
|
+
if (ctx.options["by-tool"]) return (tq) => tq.statsByTool();
|
|
318
|
+
return (tq) => tq.stats();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/** Select the `stats` text renderer for the active flag combination. */
|
|
322
|
+
function statsRenderer(ctx) {
|
|
323
|
+
if (ctx.options.summary) return renderStatsSummary;
|
|
324
|
+
if (ctx.options["by-tool"]) return renderStatsByTool;
|
|
325
|
+
return (result) => renderDefault(result);
|
|
326
|
+
}
|
|
327
|
+
|
|
211
328
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
212
329
|
export async function runStatsCommand(ctx) {
|
|
213
330
|
const { runtime } = ctx.deps;
|
|
214
|
-
|
|
331
|
+
const files = resolveFiles(runtime, ctx);
|
|
332
|
+
if (files.length === 0) return noFiles("stats");
|
|
333
|
+
const multi = files.length > 1;
|
|
334
|
+
const query = statsQuery(ctx);
|
|
335
|
+
// stats results are per-file objects; one block per file (no cross-file sum),
|
|
336
|
+
// tagged with source only when multi-file.
|
|
337
|
+
const results = files.map((file) => ({
|
|
338
|
+
result: query(loadTrace(runtime, file)),
|
|
339
|
+
source: multi ? basename(file) : undefined,
|
|
340
|
+
}));
|
|
341
|
+
|
|
342
|
+
if (ctx.options.format === "json") {
|
|
343
|
+
const payloads = results.map((r) =>
|
|
344
|
+
multi ? { ...r.result, source: r.source } : r.result,
|
|
345
|
+
);
|
|
346
|
+
writeJSON(runtime, multi ? payloads : payloads[0], ctx.options);
|
|
347
|
+
return { ok: true };
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const render = statsRenderer(ctx);
|
|
351
|
+
const blocks = results.map((r) =>
|
|
352
|
+
multi ? `# ${r.source}\n${render(r.result)}` : render(r.result),
|
|
353
|
+
);
|
|
354
|
+
runtime.proc.stdout.write(blocks.join("\n") + "\n");
|
|
215
355
|
return { ok: true };
|
|
216
356
|
}
|
|
217
357
|
|
|
@@ -263,33 +403,87 @@ function renderCostMarkdown(cost) {
|
|
|
263
403
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
264
404
|
export async function runInitCommand(ctx) {
|
|
265
405
|
const { runtime } = ctx.deps;
|
|
266
|
-
|
|
406
|
+
const files = resolveFiles(runtime, ctx);
|
|
407
|
+
if (files.length === 0) return noFiles("init");
|
|
408
|
+
const result = runOver(files, (tq) => [tq.init()], loader(runtime));
|
|
409
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1, true);
|
|
267
410
|
return { ok: true };
|
|
268
411
|
}
|
|
269
412
|
|
|
270
413
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
271
414
|
export async function runTurnCommand(ctx) {
|
|
272
415
|
const { runtime } = ctx.deps;
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
loadTrace(runtime, ctx.args.file).turn(parseInt(ctx.args.index, 10)),
|
|
276
|
-
ctx.options,
|
|
416
|
+
const result = loadTrace(runtime, ctx.args.file).turn(
|
|
417
|
+
parseInt(ctx.args.index, 10),
|
|
277
418
|
);
|
|
419
|
+
emit(runtime, result, renderDefault, ctx, false);
|
|
278
420
|
return { ok: true };
|
|
279
421
|
}
|
|
280
422
|
|
|
281
423
|
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
282
424
|
export async function runFilterCommand(ctx) {
|
|
283
425
|
const { runtime } = ctx.deps;
|
|
426
|
+
const files = resolveFiles(runtime, ctx);
|
|
427
|
+
if (files.length === 0) return noFiles("filter");
|
|
284
428
|
const opts = {};
|
|
285
429
|
if (ctx.options.role) opts.role = ctx.options.role;
|
|
286
430
|
if (ctx.options.tool) opts.toolName = ctx.options.tool;
|
|
287
431
|
if (ctx.options.error) opts.isError = true;
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
432
|
+
const result = runOver(files, (tq) => tq.filter(opts), loader(runtime));
|
|
433
|
+
emit(runtime, result, renderDefault, ctx, files.length > 1);
|
|
434
|
+
return { ok: true };
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
// --- Aggregator verbs (tool-calls, commands, paths, compare) ---
|
|
438
|
+
|
|
439
|
+
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
440
|
+
export async function runToolCallsCommand(ctx) {
|
|
441
|
+
const { runtime } = ctx.deps;
|
|
442
|
+
const files = resolveFiles(runtime, ctx);
|
|
443
|
+
if (files.length === 0) return noFiles("tool-calls");
|
|
444
|
+
const result = runOver(files, (tq) => tq.toolCalls(), loader(runtime));
|
|
445
|
+
emit(runtime, result, renderToolCalls, ctx, files.length > 1);
|
|
446
|
+
return { ok: true };
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
450
|
+
export async function runCommandsCommand(ctx) {
|
|
451
|
+
const { runtime } = ctx.deps;
|
|
452
|
+
const files = resolveFiles(runtime, ctx);
|
|
453
|
+
if (files.length === 0) return noFiles("commands");
|
|
454
|
+
const result = runOver(
|
|
455
|
+
files,
|
|
456
|
+
(tq) => tq.commands(ctx.options.match),
|
|
457
|
+
loader(runtime),
|
|
458
|
+
);
|
|
459
|
+
emit(runtime, result, renderCommands, ctx, files.length > 1);
|
|
460
|
+
return { ok: true };
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
464
|
+
export async function runPathsCommand(ctx) {
|
|
465
|
+
const { runtime } = ctx.deps;
|
|
466
|
+
const files = resolveFiles(runtime, ctx);
|
|
467
|
+
if (files.length === 0) return noFiles("paths");
|
|
468
|
+
const result = aggregate(
|
|
469
|
+
files,
|
|
470
|
+
(tq) => tq.paths(ctx.options.prefix),
|
|
471
|
+
(r) => r.path,
|
|
472
|
+
loader(runtime),
|
|
473
|
+
);
|
|
474
|
+
emit(runtime, result, renderPaths, ctx, files.length > 1);
|
|
475
|
+
return { ok: true };
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** @param {import("@forwardimpact/libcli").InvocationContext} ctx */
|
|
479
|
+
export async function runCompareCommand(ctx) {
|
|
480
|
+
const { runtime } = ctx.deps;
|
|
481
|
+
const result = compareTwo(
|
|
482
|
+
ctx.args["file-a"],
|
|
483
|
+
ctx.args["file-b"],
|
|
484
|
+
loader(runtime),
|
|
292
485
|
);
|
|
486
|
+
emit(runtime, result, renderCompare, ctx, false);
|
|
293
487
|
return { ok: true };
|
|
294
488
|
}
|
|
295
489
|
|
|
@@ -401,7 +595,7 @@ function computeTraceCost(content) {
|
|
|
401
595
|
* @param {string} file
|
|
402
596
|
* @returns {import("../trace-query.js").TraceQuery}
|
|
403
597
|
*/
|
|
404
|
-
function loadTrace(runtime, file) {
|
|
598
|
+
export function loadTrace(runtime, file) {
|
|
405
599
|
const content = runtime.fsSync.readFileSync(file, "utf8");
|
|
406
600
|
|
|
407
601
|
try {
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-file orchestrator for cross-trace `fit-trace` verbs.
|
|
3
|
+
*
|
|
4
|
+
* Two functions centralise the load-tag-concat (`runOver`) and
|
|
5
|
+
* aggregate-and-sort (`aggregate`) policies so every cross-trace verb shares
|
|
6
|
+
* one source-attribution rule. `compareTwo` derives per-side identity from
|
|
7
|
+
* each input's basename and threads it into `TraceQuery.compare()`.
|
|
8
|
+
*
|
|
9
|
+
* `load` is injected (the exported `loadTrace` from `commands/trace.js`) so
|
|
10
|
+
* this module stays IO-policy-free and unit-testable with a stub.
|
|
11
|
+
*/
|
|
12
|
+
import { basename } from "node:path";
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load each file → `TraceQuery`, run `query(tq)`, tag each emitted record with
|
|
16
|
+
* `source: <basename>` only when more than one file is supplied. Records are
|
|
17
|
+
* concatenated in file-then-record order.
|
|
18
|
+
* @param {string[]} files
|
|
19
|
+
* @param {(tq: object) => object[]} query
|
|
20
|
+
* @param {(file: string) => object} load
|
|
21
|
+
* @returns {object[]}
|
|
22
|
+
*/
|
|
23
|
+
export function runOver(files, query, load) {
|
|
24
|
+
const multi = files.length > 1;
|
|
25
|
+
const out = [];
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
const source = basename(file);
|
|
28
|
+
const records = query(load(file));
|
|
29
|
+
for (const record of records) {
|
|
30
|
+
out.push(multi ? { ...record, source } : record);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Merge per-file record arrays by `key(record)`, summing each record's
|
|
38
|
+
* existing `count` field (not occurrence count), and frequency-sort by
|
|
39
|
+
* `count desc`. Merged records carry `sources: string[]` only when more than
|
|
40
|
+
* one file is supplied.
|
|
41
|
+
* @param {string[]} files
|
|
42
|
+
* @param {(tq: object) => Array<{count: number}>} query
|
|
43
|
+
* @param {(record: object) => string} key
|
|
44
|
+
* @param {(file: string) => object} load
|
|
45
|
+
* @returns {object[]}
|
|
46
|
+
*/
|
|
47
|
+
export function aggregate(files, query, key, load) {
|
|
48
|
+
const multi = files.length > 1;
|
|
49
|
+
const merged = new Map();
|
|
50
|
+
for (const file of files) {
|
|
51
|
+
const source = basename(file);
|
|
52
|
+
for (const record of query(load(file))) {
|
|
53
|
+
const k = key(record);
|
|
54
|
+
if (!merged.has(k)) {
|
|
55
|
+
merged.set(k, { record: { ...record }, sources: new Set() });
|
|
56
|
+
} else {
|
|
57
|
+
merged.get(k).record.count += record.count;
|
|
58
|
+
}
|
|
59
|
+
merged.get(k).sources.add(source);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return [...merged.values()]
|
|
63
|
+
.map(({ record, sources }) =>
|
|
64
|
+
multi ? { ...record, sources: [...sources].sort() } : record,
|
|
65
|
+
)
|
|
66
|
+
.sort((a, b) => b.count - a.count);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Load two files, derive each side's `{caseName, participant}` from its
|
|
71
|
+
* basename via the `split` convention, and thread them into
|
|
72
|
+
* `a.compare(b, {aIdentity, bIdentity})`.
|
|
73
|
+
* @param {string} a
|
|
74
|
+
* @param {string} b
|
|
75
|
+
* @param {(file: string) => object} load
|
|
76
|
+
* @returns {object}
|
|
77
|
+
*/
|
|
78
|
+
export function compareTwo(a, b, load) {
|
|
79
|
+
const qa = load(a);
|
|
80
|
+
const qb = load(b);
|
|
81
|
+
return qa.compare(qb, {
|
|
82
|
+
aIdentity: parseIdentity(a),
|
|
83
|
+
bIdentity: parseIdentity(b),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Parse `trace--<case>--<participant>.<role>.ndjson` into `{caseName,
|
|
89
|
+
* participant}`. On no match, `caseName` is the basename minus its final
|
|
90
|
+
* `.ndjson` extension only and `participant` is null.
|
|
91
|
+
* @param {string} file
|
|
92
|
+
* @returns {{caseName: string, participant: string|null}}
|
|
93
|
+
*/
|
|
94
|
+
export function parseIdentity(file) {
|
|
95
|
+
const name = basename(file);
|
|
96
|
+
const match = name.match(/^trace--(.+?)--(.+?)\.[^.]+\.ndjson$/);
|
|
97
|
+
if (match) {
|
|
98
|
+
return { caseName: match[1], participant: match[2] };
|
|
99
|
+
}
|
|
100
|
+
return { caseName: name.replace(/\.ndjson$/, ""), participant: null };
|
|
101
|
+
}
|