reslop 0.2.5 → 0.2.6

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.
Files changed (105) hide show
  1. package/CHANGELOG.md +12 -1
  2. package/LICENSE +1 -1
  3. package/README.md +11 -11
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +32 -19
  6. package/lib/capabilities.js +8 -8
  7. package/lib/cli.js +40 -74
  8. package/lib/clipboard.js +39 -47
  9. package/lib/dashboard/git.js +128 -0
  10. package/lib/dashboard/model.js +332 -0
  11. package/lib/dashboard/npm.js +193 -0
  12. package/lib/dashboard/tree.js +278 -0
  13. package/lib/deps-diff.js +197 -402
  14. package/lib/deps.js +113 -240
  15. package/lib/detect.js +8 -18
  16. package/lib/diff/align.js +41 -113
  17. package/lib/diff/diff.js +12 -43
  18. package/lib/diff/display.js +25 -36
  19. package/lib/diff/edit.js +6 -20
  20. package/lib/diff/inline.js +76 -144
  21. package/lib/diff/items.js +33 -47
  22. package/lib/diff/parse.js +28 -56
  23. package/lib/diff/patch.js +12 -33
  24. package/lib/diff/unit.js +79 -178
  25. package/lib/editor.js +52 -69
  26. package/lib/files.js +71 -89
  27. package/lib/git-branches.js +254 -416
  28. package/lib/git-deps.js +147 -272
  29. package/lib/git-reword-todo.js +5 -13
  30. package/lib/git-worktree.js +141 -212
  31. package/lib/git.js +21 -49
  32. package/lib/github.js +67 -181
  33. package/lib/gitlab.js +40 -90
  34. package/lib/highlight/bash.js +55 -177
  35. package/lib/highlight/core.js +155 -141
  36. package/lib/highlight/css.js +25 -58
  37. package/lib/highlight/csv.js +19 -37
  38. package/lib/highlight/dart.js +72 -213
  39. package/lib/highlight/dot.js +14 -14
  40. package/lib/highlight/html.js +42 -63
  41. package/lib/highlight/js.js +177 -506
  42. package/lib/highlight/json.js +17 -37
  43. package/lib/highlight/log.js +19 -25
  44. package/lib/highlight/txt.js +7 -10
  45. package/lib/highlight.js +17 -46
  46. package/lib/keys.js +49 -82
  47. package/lib/manifest.js +67 -126
  48. package/lib/npm-commands.js +120 -169
  49. package/lib/remote.js +140 -190
  50. package/lib/render/branches.js +63 -61
  51. package/lib/render/chrome.js +220 -333
  52. package/lib/render/commits.js +160 -361
  53. package/lib/render/dash-activity.js +212 -0
  54. package/lib/render/dash-blocks.js +245 -0
  55. package/lib/render/dash-table.js +290 -0
  56. package/lib/render/dashboard.js +62 -0
  57. package/lib/render/diff.js +123 -258
  58. package/lib/render/files.js +40 -85
  59. package/lib/render/frame.js +78 -117
  60. package/lib/render/notes.js +139 -210
  61. package/lib/render/npm.js +213 -177
  62. package/lib/render/present.js +4 -11
  63. package/lib/render/primitives.js +72 -174
  64. package/lib/render/render.js +5 -14
  65. package/lib/render/report.js +43 -0
  66. package/lib/render/tiles.js +260 -0
  67. package/lib/render/unit.js +15 -31
  68. package/lib/report-model.js +110 -33
  69. package/lib/report-parse.js +56 -37
  70. package/lib/report-render.js +102 -59
  71. package/lib/report-run.js +87 -77
  72. package/lib/report-test.js +221 -99
  73. package/lib/report-tools.js +96 -154
  74. package/lib/review-merge.js +33 -41
  75. package/lib/review.js +68 -129
  76. package/lib/runs.js +225 -0
  77. package/lib/select.js +53 -91
  78. package/lib/session/accessors.js +14 -21
  79. package/lib/session/actions.js +61 -43
  80. package/lib/session/branches.js +166 -222
  81. package/lib/session/change-actions.js +49 -119
  82. package/lib/session/commit.js +274 -355
  83. package/lib/session/compose.js +556 -714
  84. package/lib/session/dashboard.js +385 -0
  85. package/lib/session/dispatch.js +162 -170
  86. package/lib/session/files-pane.js +76 -44
  87. package/lib/session/items.js +6 -17
  88. package/lib/session/lifecycle.js +393 -466
  89. package/lib/session/load.js +1 -14
  90. package/lib/session/navigation.js +2 -2
  91. package/lib/session/npm.js +403 -420
  92. package/lib/session/ops.js +5 -13
  93. package/lib/session/pointer.js +16 -14
  94. package/lib/session/progress.js +39 -30
  95. package/lib/session/review.js +1 -3
  96. package/lib/session/tasks.js +276 -235
  97. package/lib/session/templates.js +51 -59
  98. package/lib/session/terminal.js +85 -132
  99. package/lib/session/view.js +47 -81
  100. package/lib/session/watch.js +196 -212
  101. package/lib/session.js +81 -161
  102. package/lib/update.js +44 -111
  103. package/lib/utilities.js +46 -88
  104. package/lib/wrap.js +29 -55
  105. package/package.json +1 -1
package/lib/review.js CHANGED
@@ -4,7 +4,7 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  const files = require('./files.js');
7
- const { TASKS_FILE, REVIEW_DIR } = files;
7
+ const { TASKS_FILE, REVIEW_DIR, TASK_KINDS, taskKind, taskTitle } = files;
8
8
  const reviewMerge = require('./review-merge.js');
9
9
  const { captureBaseline, mergeReview } = reviewMerge;
10
10
  const utilities = require('./utilities.js');
@@ -42,6 +42,17 @@ const hasNotes = (store) => {
42
42
  return counts.feedback > 0 || counts.tasks > 0 || counts.code > 0;
43
43
  };
44
44
 
45
+ const toNote = (note, done = false) => ({
46
+ file: note.file,
47
+ oldStart: note.oldStart ?? 0,
48
+ newStart: note.newStart ?? 0,
49
+ blockId: note.blockId ?? 0,
50
+ origin: note.origin ?? '',
51
+ header: note.header ?? '',
52
+ text: note.text ?? '',
53
+ done: note.done ?? done,
54
+ });
55
+
45
56
  const putNote = (store, notes, key, note, clear) => {
46
57
  const prev = notes.get(key);
47
58
  if (clear) {
@@ -50,18 +61,8 @@ const putNote = (store, notes, key, note, clear) => {
50
61
  store.dirty = true;
51
62
  return;
52
63
  }
53
- const text = note.text ?? '';
54
- if (prev && prev.text === text) return;
55
- notes.set(key, {
56
- file: note.file,
57
- oldStart: note.oldStart ?? 0,
58
- newStart: note.newStart ?? 0,
59
- blockId: note.blockId ?? 0,
60
- origin: note.origin ?? '',
61
- header: note.header ?? '',
62
- text,
63
- done: note.done ?? prev?.done ?? false,
64
- });
64
+ if (prev && prev.text === (note.text ?? '')) return;
65
+ notes.set(key, toNote(note, prev?.done));
65
66
  store.dirty = true;
66
67
  };
67
68
 
@@ -74,27 +75,19 @@ const setCode = (store, key, note) => {
74
75
  putNote(store, store.code, key, note, Boolean(note.clear));
75
76
  };
76
77
 
77
- const addTask = (store, file, text = '') => {
78
+ const addTask = (store, file, text = '', done = false, kind = 'backlog') => {
78
79
  const id = store.nextTaskId;
79
80
  store.nextTaskId += 1;
80
- const todo = { id, file, text, done: false };
81
+ const todo = { id, file, text, done, kind: taskKind({ kind }) };
81
82
  store.tasks.push(todo);
82
83
  store.dirty = true;
83
84
  return todo;
84
85
  };
85
86
 
86
87
  const removeTask = (store, id) => {
87
- const next = [];
88
- let found = false;
89
- for (const todo of store.tasks) {
90
- if (todo.id === id) {
91
- found = true;
92
- continue;
93
- }
94
- next.push(todo);
95
- }
96
- if (!found) return false;
97
- store.tasks = next;
88
+ const tasks = store.tasks.filter((todo) => todo.id !== id);
89
+ if (tasks.length === store.tasks.length) return false;
90
+ store.tasks = tasks;
98
91
  store.dirty = true;
99
92
  return true;
100
93
  };
@@ -102,20 +95,16 @@ const removeTask = (store, id) => {
102
95
  const setTaskText = (store, id, text) => {
103
96
  const i = store.tasks.findIndex((todo) => todo.id === id);
104
97
  if (i < 0) return;
105
- const prev = store.tasks[i];
106
- const trimmed = text.trim();
107
- if (!trimmed) return void removeTask(store, id);
108
- if (prev.text === text) return;
109
- store.tasks[i] = { id: prev.id, file: prev.file, text, done: prev.done };
98
+ if (!text.trim()) return void removeTask(store, id);
99
+ if (store.tasks[i].text === text) return;
100
+ store.tasks[i] = { ...store.tasks[i], text };
110
101
  store.dirty = true;
111
102
  };
112
103
 
113
104
  const setTaskDone = (store, id, done) => {
114
105
  const i = store.tasks.findIndex((todo) => todo.id === id);
115
- if (i < 0) return;
116
- const prev = store.tasks[i];
117
- if (prev.done === done) return;
118
- store.tasks[i] = { id: prev.id, file: prev.file, text: prev.text, done };
106
+ if (i < 0 || store.tasks[i].done === done) return;
107
+ store.tasks[i] = { ...store.tasks[i], done };
119
108
  store.dirty = true;
120
109
  };
121
110
 
@@ -140,21 +129,11 @@ const applyImportedNotes = (store, imported) => {
140
129
  const prev = store.feedback.get(key);
141
130
  const text = prev ? `${prev.text}\n\n${note.text}` : note.text;
142
131
  const done = prev ? prev.done && !!note.done : !!note.done;
143
- setFeedback(store, key, {
144
- file: note.file,
145
- oldStart: note.oldStart ?? 0,
146
- newStart: note.newStart ?? 0,
147
- blockId: note.blockId ?? 0,
148
- origin: note.origin ?? '',
149
- header: note.header ?? '',
150
- text,
151
- done,
152
- });
132
+ setFeedback(store, key, { ...note, text, done });
153
133
  }
154
134
  const todos = imported.tasks ?? imported.todos ?? [];
155
135
  for (const todo of todos) {
156
- const added = addTask(store, todo.file || '', todo.text ?? '');
157
- if (todo.done) added.done = true;
136
+ addTask(store, todo.file || '', todo.text ?? '', !!todo.done);
158
137
  }
159
138
  };
160
139
 
@@ -222,6 +201,13 @@ const SECTION_LINE = {
222
201
  };
223
202
  const BACKLOG_HEAD = 'Backlog';
224
203
  const LEGACY_BACKLOG = 'TODOs';
204
+ const TASK_TITLES = new Map([
205
+ [BACKLOG_HEAD, 'backlog'],
206
+ [LEGACY_BACKLOG, 'backlog'],
207
+ ['Issues', 'issues'],
208
+ ['Bug Reports', 'bugs'],
209
+ ['Feature Requests', 'features'],
210
+ ]);
225
211
 
226
212
  const AGENT_INSTRUCTIONS = [
227
213
  '- Execute reviews with `status` `ready, partial, editing`',
@@ -238,10 +224,8 @@ const AGENT_INSTRUCTIONS = [
238
224
  const toInt = (text) => parseInt(text, 10);
239
225
 
240
226
  const canonicalStatus = (raw) => {
241
- if (!raw) return '';
242
227
  const name = raw === 'pending' ? 'ready' : raw;
243
- if (REVIEW_STATUSES.includes(name)) return name;
244
- return '';
228
+ return REVIEW_STATUSES.includes(name) ? name : '';
245
229
  };
246
230
 
247
231
  const parseFrontmatterStatus = (text) => {
@@ -261,44 +245,15 @@ const parseFrontmatterStatus = (text) => {
261
245
 
262
246
  const isDoneMark = (mark) => mark === 'x' || mark === 'X';
263
247
 
264
- const pushTodo = (builder, file, text, done) => {
265
- const id = builder.nextTaskId;
266
- builder.nextTaskId += 1;
267
- builder.tasks.push({ id, file, text, done });
268
- };
269
-
270
248
  const checkboxLine = (text, done) => {
271
249
  const flat = text.trim().replace(/\n/g, ' ');
272
250
  const mark = done ? 'x' : ' ';
273
251
  return `- [${mark}] ${flat}`;
274
252
  };
275
253
 
276
- const storeFeedback = (builder, note, key) => {
277
- const next = {
278
- file: note.file,
279
- oldStart: note.oldStart ?? 0,
280
- newStart: note.newStart ?? 0,
281
- blockId: note.blockId ?? 0,
282
- origin: note.origin ?? '',
283
- header: note.header ?? '',
284
- text: note.text,
285
- done: note.done ?? false,
286
- };
287
- builder.feedback.set(key || feedbackKey(next), next);
288
- };
289
-
290
- const storeCode = (builder, note, key) => {
291
- const next = {
292
- file: note.file,
293
- oldStart: note.oldStart ?? 0,
294
- newStart: note.newStart ?? 0,
295
- blockId: note.blockId ?? 0,
296
- origin: note.origin ?? '',
297
- header: note.header ?? '',
298
- text: note.text,
299
- done: note.done ?? false,
300
- };
301
- builder.code.set(key || feedbackKey(next), next);
254
+ const storeNote = (notes, note, key) => {
255
+ const next = toNote(note);
256
+ notes.set(key || feedbackKey(next), next);
302
257
  };
303
258
 
304
259
  const feedbackLine = (note) => {
@@ -345,11 +300,17 @@ const fileGroup = (groups, file) => {
345
300
  return group;
346
301
  };
347
302
 
303
+ const TASK_SECTION = 'task:';
304
+
305
+ const taskSection = (id) => `${TASK_SECTION}${id}`;
306
+
348
307
  const groupReviewNotes = (notes) => {
349
308
  const groups = new Map();
350
- for (const todo of notes.tasks) {
351
- if (!todo.text.trim()) continue;
352
- fileGroup(groups, TASKS_FILE).tasks.push(todo);
309
+ for (const kind of TASK_KINDS) {
310
+ for (const todo of notes.tasks) {
311
+ if (!todo.text.trim() || taskKind(todo) !== kind.id) continue;
312
+ fileGroup(groups, taskSection(kind.id)).tasks.push(todo);
313
+ }
353
314
  }
354
315
  for (const key of notes.feedback.keys()) {
355
316
  const note = notes.feedback.get(key);
@@ -400,7 +361,10 @@ const emitCodeItem = (lines, note) => {
400
361
  lines.push(fence);
401
362
  };
402
363
 
403
- const sectionTitle = (file) => (file === TASKS_FILE ? BACKLOG_HEAD : file);
364
+ const sectionTitle = (file) => {
365
+ if (!file.startsWith(TASK_SECTION)) return file;
366
+ return taskTitle(file.slice(TASK_SECTION.length));
367
+ };
404
368
 
405
369
  const backlogFile = (name) => {
406
370
  if (name === BACKLOG_HEAD || name === LEGACY_BACKLOG) return TASKS_FILE;
@@ -452,7 +416,7 @@ const parseFeedbackHeading = (line) => {
452
416
 
453
417
  const flushPendingFeedback = (builder, pending) => {
454
418
  if (!pending || !(pending.text ?? '').trim()) return null;
455
- storeFeedback(builder, pending, pending.key);
419
+ storeNote(builder.feedback, pending, pending.key);
456
420
  return null;
457
421
  };
458
422
 
@@ -460,7 +424,10 @@ const headingFile = (line) => {
460
424
  const quoted = FILE_QUOTE.exec(line);
461
425
  if (quoted) return backlogFile(quoted[1]);
462
426
  if (line.startsWith('## ') && line !== '## Agent instructions') {
463
- return backlogFile(line.slice(3));
427
+ const name = line.slice(3);
428
+ const kind = TASK_TITLES.get(name);
429
+ if (kind) return taskSection(kind);
430
+ return name;
464
431
  }
465
432
  return null;
466
433
  };
@@ -479,33 +446,23 @@ const startFeedbackPending = (heading, file) => ({
479
446
  const applyFeedMeta = (pending, raw) => {
480
447
  const loc = parseLoc(raw);
481
448
  if (!loc) return pending;
482
- pending.origin = loc.origin;
483
- pending.file = loc.file;
484
- pending.oldStart = loc.oldStart;
485
- pending.newStart = loc.newStart;
486
- pending.blockId = loc.blockId;
487
- pending.key = feedbackKey(loc);
488
- return pending;
449
+ return Object.assign(pending, loc, { key: feedbackKey(loc) });
489
450
  };
490
451
 
491
452
  const applyCheckItem = (builder, file, section, pending, textItem, done) => {
453
+ if (file.startsWith(TASK_SECTION)) {
454
+ const kind = file.slice(TASK_SECTION.length);
455
+ addTask(builder, TASKS_FILE, textItem, done, kind);
456
+ return pending;
457
+ }
492
458
  if (section === 'todo' && file) {
493
- pushTodo(builder, file, textItem, done);
459
+ addTask(builder, file, textItem, done, 'backlog');
494
460
  return pending;
495
461
  }
496
462
  const parsed = parseFeedSuffix(textItem);
497
463
  if (parsed) {
498
464
  flushPendingFeedback(builder, pending);
499
- storeFeedback(builder, {
500
- file: parsed.file || file,
501
- oldStart: parsed.oldStart,
502
- newStart: parsed.newStart,
503
- blockId: parsed.blockId,
504
- origin: '',
505
- header: '',
506
- text: parsed.text,
507
- done,
508
- });
465
+ storeNote(builder.feedback, { ...parsed, file: parsed.file || file, done });
509
466
  return null;
510
467
  }
511
468
  if (pending) {
@@ -514,7 +471,7 @@ const applyCheckItem = (builder, file, section, pending, textItem, done) => {
514
471
  return flushPendingFeedback(builder, pending);
515
472
  }
516
473
  if (file && section !== 'feedback') {
517
- pushTodo(builder, file, textItem, done);
474
+ addTask(builder, file, textItem, done, 'backlog');
518
475
  }
519
476
  return pending;
520
477
  };
@@ -553,29 +510,11 @@ const startCodePending = (line) => {
553
510
  if (!match) return null;
554
511
  const loc = parseLoc(match[2]);
555
512
  if (!loc) return null;
556
- return {
557
- done: isDoneMark(match[1]),
558
- origin: loc.origin,
559
- file: loc.file,
560
- oldStart: loc.oldStart,
561
- newStart: loc.newStart,
562
- blockId: loc.blockId,
563
- open: '',
564
- lines: [],
565
- };
513
+ return { done: isDoneMark(match[1]), ...loc, open: '', lines: [] };
566
514
  };
567
515
 
568
516
  const storeCodeNote = (builder, pending, text) => {
569
- storeCode(builder, {
570
- file: pending.file,
571
- oldStart: pending.oldStart,
572
- newStart: pending.newStart,
573
- blockId: pending.blockId,
574
- origin: pending.origin,
575
- header: '',
576
- text,
577
- done: pending.done,
578
- });
517
+ storeNote(builder.code, { ...pending, text });
579
518
  };
580
519
 
581
520
  const flushPendingCode = (builder, pending) => {
@@ -653,8 +592,7 @@ const allocateReviewPath = (dir, date, existingNames) => {
653
592
  if (n > max) max = n;
654
593
  }
655
594
  const next = max + 1;
656
- const file = `${stamp}-${next < 100 ? pad2(next) : String(next)}.md`;
657
- return path.join(dir, REVIEW_DIR, file);
595
+ return path.join(dir, REVIEW_DIR, `${stamp}-${pad2(next)}.md`);
658
596
  };
659
597
 
660
598
  const listReviewNames = (dir) => {
@@ -790,6 +728,7 @@ module.exports = {
790
728
  setFeedback,
791
729
  setCode,
792
730
  addTask,
731
+ taskKind,
793
732
  removeTask,
794
733
  setTaskText,
795
734
  setTaskDone,
package/lib/runs.js ADDED
@@ -0,0 +1,225 @@
1
+ 'use strict';
2
+
3
+ const fs = require('node:fs');
4
+ const path = require('node:path');
5
+
6
+ const ansi = require('./ansi.js');
7
+ const { stripAnsi } = ansi;
8
+ const commands = require('./npm-commands.js');
9
+ const { isPassingTest } = commands;
10
+
11
+ const LOG_DIR = '.log';
12
+ const RUNS_REL = `${LOG_DIR}/.runs`;
13
+ const RUNS_KEEP = 24;
14
+ const WRITE_MS = 150;
15
+ const FAILING_TEST = /^\s*(?:not ok \d+|[✖✗×]) /;
16
+ const LIFECYCLE_EVENT = 'npm_lifecycle_event';
17
+
18
+ const runsDir = (root) => path.join(root, RUNS_REL);
19
+
20
+ const isRunsRel = (rel) =>
21
+ rel === LOG_DIR || rel === RUNS_REL || rel.startsWith(`${RUNS_REL}/`);
22
+
23
+ const commandLabel = (program, args) =>
24
+ [path.basename(program), ...args].join(' ').trim();
25
+
26
+ const isFailingTest = (line) => FAILING_TEST.test(stripAnsi(line));
27
+
28
+ class Tracker {
29
+ constructor() {
30
+ this.progress = { done: 0, failed: 0, lines: 0 };
31
+ this.carry = '';
32
+ }
33
+
34
+ feed(chunk) {
35
+ const parts = `${this.carry}${chunk}`.split('\n');
36
+ this.carry = parts.pop();
37
+ const progress = this.progress;
38
+ for (const line of parts) {
39
+ progress.lines += 1;
40
+ if (isPassingTest(line)) progress.done += 1;
41
+ else if (isFailingTest(line)) progress.failed += 1;
42
+ }
43
+ }
44
+ }
45
+
46
+ const pidAlive = (pid) => {
47
+ if (!Number.isInteger(pid) || pid <= 0) return false;
48
+ try {
49
+ process.kill(pid, 0);
50
+ return true;
51
+ } catch (error) {
52
+ return error.code === 'EPERM';
53
+ }
54
+ };
55
+
56
+ const readRecord = (file) => {
57
+ try {
58
+ const record = JSON.parse(fs.readFileSync(file, 'utf8'));
59
+ if (!record || typeof record !== 'object' || !record.id) return null;
60
+ return record;
61
+ } catch {
62
+ return null;
63
+ }
64
+ };
65
+
66
+ const listRunNames = (root) => {
67
+ try {
68
+ return fs.readdirSync(runsDir(root)).filter((n) => n.endsWith('.json'));
69
+ } catch {
70
+ return [];
71
+ }
72
+ };
73
+
74
+ const settleRecord = (record, alive = pidAlive) => {
75
+ if (record.status !== 'running' || alive(record.pid)) return record;
76
+ return { ...record, status: 'aborted' };
77
+ };
78
+
79
+ const readRuns = (root) => {
80
+ const dir = runsDir(root);
81
+ const records = [];
82
+ for (const name of listRunNames(root)) {
83
+ const record = readRecord(path.join(dir, name));
84
+ if (record) records.push(settleRecord(record));
85
+ }
86
+ return records.sort((left, right) => right.startedAt - left.startedAt);
87
+ };
88
+
89
+ const writeRecord = (root, record) => {
90
+ const dir = runsDir(root);
91
+ fs.mkdirSync(dir, { recursive: true });
92
+ const file = path.join(dir, `${record.id}.json`);
93
+ const temp = `${file}.tmp`;
94
+ const text = JSON.stringify(record);
95
+ try {
96
+ fs.writeFileSync(temp, text);
97
+ fs.renameSync(temp, file);
98
+ } catch {
99
+ fs.writeFileSync(file, text);
100
+ }
101
+ };
102
+
103
+ const pruneRuns = (root) => {
104
+ const dir = runsDir(root);
105
+ const names = listRunNames(root).sort();
106
+ const extra = names.length - RUNS_KEEP;
107
+ for (let i = 0; i < extra; i++) {
108
+ fs.rmSync(path.join(dir, names[i]), { force: true });
109
+ }
110
+ };
111
+
112
+ const previousTotal = (root, command) => {
113
+ for (const record of readRuns(root)) {
114
+ if (record.command !== command) continue;
115
+ if (record.status === 'running' || record.status === 'aborted') continue;
116
+ const progress = record.progress ?? {};
117
+ const total = (progress.done ?? 0) + (progress.failed ?? 0);
118
+ if (total > 0) return total;
119
+ }
120
+ return 0;
121
+ };
122
+
123
+ const sumField = (reports, key) => {
124
+ let total = null;
125
+ for (const item of reports) {
126
+ const value = item.summary[key];
127
+ if (typeof value !== 'number') continue;
128
+ total = (total ?? 0) + value;
129
+ }
130
+ return total;
131
+ };
132
+
133
+ const summarizeDocument = (doc) => {
134
+ if (!doc || !Array.isArray(doc.reports)) return null;
135
+ const reports = doc.reports;
136
+ const durations = reports.map((item) => item.summary.duration);
137
+ let problems = 0;
138
+ for (const item of reports) problems += item.problems.length;
139
+ return {
140
+ tools: reports.map((item) => item.tool),
141
+ tests: sumField(reports, 'tests'),
142
+ passed: sumField(reports, 'passed'),
143
+ failed: sumField(reports, 'failed'),
144
+ skipped: sumField(reports, 'skipped'),
145
+ errors: sumField(reports, 'errors'),
146
+ warnings: sumField(reports, 'warnings'),
147
+ duration: durations.find(Boolean) ?? '',
148
+ problems,
149
+ };
150
+ };
151
+
152
+ const safely = (fn) => {
153
+ try {
154
+ fn();
155
+ } catch {
156
+ // run records are best effort
157
+ }
158
+ };
159
+
160
+ class RunRecorder {
161
+ constructor(root, command, options = {}) {
162
+ this.root = root;
163
+ this.clock = options.now ?? Date.now;
164
+ this.tracker = new Tracker();
165
+ this.lastWrite = 0;
166
+ this.closed = false;
167
+ const startedAt = this.clock();
168
+ const env = options.env || process.env;
169
+ this.record = {
170
+ v: 1,
171
+ id: `${startedAt}-${process.pid}`,
172
+ pid: process.pid,
173
+ command,
174
+ script: `${env[LIFECYCLE_EVENT] ?? ''}`.trim(),
175
+ cwd: root,
176
+ startedAt,
177
+ endedAt: 0,
178
+ status: 'running',
179
+ exit: null,
180
+ progress: { ...this.tracker.progress, expected: 0 },
181
+ result: null,
182
+ };
183
+ safely(() => {
184
+ this.record.progress.expected = previousTotal(root, command);
185
+ pruneRuns(root);
186
+ this.write();
187
+ });
188
+ }
189
+
190
+ write() {
191
+ Object.assign(this.record.progress, this.tracker.progress);
192
+ writeRecord(this.root, this.record);
193
+ }
194
+
195
+ feed(chunk) {
196
+ if (this.closed) return;
197
+ this.tracker.feed(chunk);
198
+ const now = this.clock();
199
+ if (now - this.lastWrite < WRITE_MS) return;
200
+ this.lastWrite = now;
201
+ safely(() => this.write());
202
+ }
203
+
204
+ finish(status, result = null) {
205
+ if (this.closed) return;
206
+ this.closed = true;
207
+ const record = this.record;
208
+ record.endedAt = this.clock();
209
+ record.exit = status;
210
+ record.status = status === 0 ? 'passed' : 'failed';
211
+ record.result = result;
212
+ safely(() => this.write());
213
+ }
214
+ }
215
+
216
+ module.exports = {
217
+ runsDir,
218
+ isRunsRel,
219
+ commandLabel,
220
+ Tracker,
221
+ settleRecord,
222
+ readRuns,
223
+ summarizeDocument,
224
+ RunRecorder,
225
+ };