reslop 0.2.5 → 0.2.7

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 (114) hide show
  1. package/CHANGELOG.md +22 -1
  2. package/LICENSE +1 -1
  3. package/README.md +11 -11
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +72 -19
  6. package/lib/api.js +1 -1
  7. package/lib/capabilities.js +8 -8
  8. package/lib/cli.js +61 -75
  9. package/lib/clipboard.js +39 -47
  10. package/lib/dashboard/git.js +128 -0
  11. package/lib/dashboard/model.js +357 -0
  12. package/lib/dashboard/npm.js +290 -0
  13. package/lib/dashboard/tree.js +278 -0
  14. package/lib/deps-diff.js +197 -402
  15. package/lib/deps.js +113 -240
  16. package/lib/detect.js +8 -18
  17. package/lib/diff/align.js +41 -113
  18. package/lib/diff/diff.js +12 -43
  19. package/lib/diff/display.js +25 -36
  20. package/lib/diff/edit.js +6 -20
  21. package/lib/diff/inline.js +76 -144
  22. package/lib/diff/items.js +33 -47
  23. package/lib/diff/parse.js +28 -56
  24. package/lib/diff/patch.js +12 -33
  25. package/lib/diff/unit.js +79 -178
  26. package/lib/editor.js +52 -69
  27. package/lib/files.js +79 -91
  28. package/lib/find.js +61 -0
  29. package/lib/git-branches.js +254 -416
  30. package/lib/git-deps.js +148 -272
  31. package/lib/git-reword-todo.js +5 -13
  32. package/lib/git-worktree.js +161 -214
  33. package/lib/git.js +21 -49
  34. package/lib/github.js +67 -181
  35. package/lib/gitlab.js +40 -90
  36. package/lib/highlight/bash.js +55 -177
  37. package/lib/highlight/core.js +155 -141
  38. package/lib/highlight/css.js +25 -58
  39. package/lib/highlight/csv.js +19 -37
  40. package/lib/highlight/dart.js +72 -213
  41. package/lib/highlight/dot.js +14 -14
  42. package/lib/highlight/html.js +42 -63
  43. package/lib/highlight/js.js +177 -506
  44. package/lib/highlight/json.js +17 -37
  45. package/lib/highlight/log.js +19 -25
  46. package/lib/highlight/txt.js +7 -10
  47. package/lib/highlight.js +17 -46
  48. package/lib/keys.js +75 -83
  49. package/lib/manifest.js +67 -126
  50. package/lib/npm-commands.js +120 -169
  51. package/lib/remote.js +140 -190
  52. package/lib/render/branches.js +63 -61
  53. package/lib/render/chrome.js +412 -347
  54. package/lib/render/commits.js +160 -361
  55. package/lib/render/dash-activity.js +212 -0
  56. package/lib/render/dash-blocks.js +496 -0
  57. package/lib/render/dash-table.js +295 -0
  58. package/lib/render/dashboard.js +62 -0
  59. package/lib/render/diff.js +123 -258
  60. package/lib/render/files.js +60 -86
  61. package/lib/render/frame.js +82 -117
  62. package/lib/render/notes.js +150 -210
  63. package/lib/render/npm.js +213 -177
  64. package/lib/render/packages.js +93 -0
  65. package/lib/render/present.js +4 -11
  66. package/lib/render/primitives.js +77 -172
  67. package/lib/render/render.js +5 -14
  68. package/lib/render/report.js +43 -0
  69. package/lib/render/repos.js +226 -0
  70. package/lib/render/search.js +25 -0
  71. package/lib/render/tiles.js +355 -0
  72. package/lib/render/unit.js +15 -31
  73. package/lib/report-model.js +110 -33
  74. package/lib/report-parse.js +56 -37
  75. package/lib/report-render.js +102 -59
  76. package/lib/report-run.js +87 -77
  77. package/lib/report-test.js +221 -99
  78. package/lib/report-tools.js +96 -154
  79. package/lib/review-merge.js +33 -41
  80. package/lib/review.js +69 -129
  81. package/lib/runs.js +225 -0
  82. package/lib/select.js +53 -91
  83. package/lib/session/accessors.js +14 -21
  84. package/lib/session/actions.js +189 -65
  85. package/lib/session/branches.js +164 -222
  86. package/lib/session/change-actions.js +49 -119
  87. package/lib/session/commit.js +298 -352
  88. package/lib/session/compose.js +572 -715
  89. package/lib/session/dashboard.js +530 -0
  90. package/lib/session/dispatch.js +197 -171
  91. package/lib/session/files-pane.js +92 -45
  92. package/lib/session/find.js +94 -0
  93. package/lib/session/items.js +6 -17
  94. package/lib/session/lifecycle.js +420 -466
  95. package/lib/session/load.js +1 -14
  96. package/lib/session/navigation.js +4 -2
  97. package/lib/session/npm.js +403 -420
  98. package/lib/session/ops.js +5 -13
  99. package/lib/session/packages.js +143 -0
  100. package/lib/session/pointer.js +30 -14
  101. package/lib/session/progress.js +39 -30
  102. package/lib/session/review.js +1 -3
  103. package/lib/session/tasks.js +277 -236
  104. package/lib/session/templates.js +51 -59
  105. package/lib/session/terminal.js +85 -132
  106. package/lib/session/view.js +59 -80
  107. package/lib/session/watch.js +201 -212
  108. package/lib/session/workspace.js +455 -0
  109. package/lib/session.js +95 -161
  110. package/lib/update.js +44 -111
  111. package/lib/utilities.js +46 -88
  112. package/lib/workspace.js +166 -0
  113. package/lib/wrap.js +29 -55
  114. 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 = 'features') => {
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,14 @@ 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, 'debt'],
206
+ [LEGACY_BACKLOG, 'debt'],
207
+ ['Issues', 'debt'],
208
+ ['Bug Reports', 'bugs'],
209
+ ['Feature Requests', 'features'],
210
+ ...TASK_KINDS.map((kind) => [kind.title, kind.id]),
211
+ ]);
225
212
 
226
213
  const AGENT_INSTRUCTIONS = [
227
214
  '- Execute reviews with `status` `ready, partial, editing`',
@@ -238,10 +225,8 @@ const AGENT_INSTRUCTIONS = [
238
225
  const toInt = (text) => parseInt(text, 10);
239
226
 
240
227
  const canonicalStatus = (raw) => {
241
- if (!raw) return '';
242
228
  const name = raw === 'pending' ? 'ready' : raw;
243
- if (REVIEW_STATUSES.includes(name)) return name;
244
- return '';
229
+ return REVIEW_STATUSES.includes(name) ? name : '';
245
230
  };
246
231
 
247
232
  const parseFrontmatterStatus = (text) => {
@@ -261,44 +246,15 @@ const parseFrontmatterStatus = (text) => {
261
246
 
262
247
  const isDoneMark = (mark) => mark === 'x' || mark === 'X';
263
248
 
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
249
  const checkboxLine = (text, done) => {
271
250
  const flat = text.trim().replace(/\n/g, ' ');
272
251
  const mark = done ? 'x' : ' ';
273
252
  return `- [${mark}] ${flat}`;
274
253
  };
275
254
 
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);
255
+ const storeNote = (notes, note, key) => {
256
+ const next = toNote(note);
257
+ notes.set(key || feedbackKey(next), next);
302
258
  };
303
259
 
304
260
  const feedbackLine = (note) => {
@@ -345,11 +301,17 @@ const fileGroup = (groups, file) => {
345
301
  return group;
346
302
  };
347
303
 
304
+ const TASK_SECTION = 'task:';
305
+
306
+ const taskSection = (id) => `${TASK_SECTION}${id}`;
307
+
348
308
  const groupReviewNotes = (notes) => {
349
309
  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);
310
+ for (const kind of TASK_KINDS) {
311
+ for (const todo of notes.tasks) {
312
+ if (!todo.text.trim() || taskKind(todo) !== kind.id) continue;
313
+ fileGroup(groups, taskSection(kind.id)).tasks.push(todo);
314
+ }
353
315
  }
354
316
  for (const key of notes.feedback.keys()) {
355
317
  const note = notes.feedback.get(key);
@@ -400,7 +362,10 @@ const emitCodeItem = (lines, note) => {
400
362
  lines.push(fence);
401
363
  };
402
364
 
403
- const sectionTitle = (file) => (file === TASKS_FILE ? BACKLOG_HEAD : file);
365
+ const sectionTitle = (file) => {
366
+ if (!file.startsWith(TASK_SECTION)) return file;
367
+ return taskTitle(file.slice(TASK_SECTION.length));
368
+ };
404
369
 
405
370
  const backlogFile = (name) => {
406
371
  if (name === BACKLOG_HEAD || name === LEGACY_BACKLOG) return TASKS_FILE;
@@ -452,7 +417,7 @@ const parseFeedbackHeading = (line) => {
452
417
 
453
418
  const flushPendingFeedback = (builder, pending) => {
454
419
  if (!pending || !(pending.text ?? '').trim()) return null;
455
- storeFeedback(builder, pending, pending.key);
420
+ storeNote(builder.feedback, pending, pending.key);
456
421
  return null;
457
422
  };
458
423
 
@@ -460,7 +425,10 @@ const headingFile = (line) => {
460
425
  const quoted = FILE_QUOTE.exec(line);
461
426
  if (quoted) return backlogFile(quoted[1]);
462
427
  if (line.startsWith('## ') && line !== '## Agent instructions') {
463
- return backlogFile(line.slice(3));
428
+ const name = line.slice(3);
429
+ const kind = TASK_TITLES.get(name);
430
+ if (kind) return taskSection(kind);
431
+ return name;
464
432
  }
465
433
  return null;
466
434
  };
@@ -479,33 +447,23 @@ const startFeedbackPending = (heading, file) => ({
479
447
  const applyFeedMeta = (pending, raw) => {
480
448
  const loc = parseLoc(raw);
481
449
  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;
450
+ return Object.assign(pending, loc, { key: feedbackKey(loc) });
489
451
  };
490
452
 
491
453
  const applyCheckItem = (builder, file, section, pending, textItem, done) => {
454
+ if (file.startsWith(TASK_SECTION)) {
455
+ const kind = file.slice(TASK_SECTION.length);
456
+ addTask(builder, TASKS_FILE, textItem, done, kind);
457
+ return pending;
458
+ }
492
459
  if (section === 'todo' && file) {
493
- pushTodo(builder, file, textItem, done);
460
+ addTask(builder, file, textItem, done, 'backlog');
494
461
  return pending;
495
462
  }
496
463
  const parsed = parseFeedSuffix(textItem);
497
464
  if (parsed) {
498
465
  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
- });
466
+ storeNote(builder.feedback, { ...parsed, file: parsed.file || file, done });
509
467
  return null;
510
468
  }
511
469
  if (pending) {
@@ -514,7 +472,7 @@ const applyCheckItem = (builder, file, section, pending, textItem, done) => {
514
472
  return flushPendingFeedback(builder, pending);
515
473
  }
516
474
  if (file && section !== 'feedback') {
517
- pushTodo(builder, file, textItem, done);
475
+ addTask(builder, file, textItem, done, 'backlog');
518
476
  }
519
477
  return pending;
520
478
  };
@@ -553,29 +511,11 @@ const startCodePending = (line) => {
553
511
  if (!match) return null;
554
512
  const loc = parseLoc(match[2]);
555
513
  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
- };
514
+ return { done: isDoneMark(match[1]), ...loc, open: '', lines: [] };
566
515
  };
567
516
 
568
517
  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
- });
518
+ storeNote(builder.code, { ...pending, text });
579
519
  };
580
520
 
581
521
  const flushPendingCode = (builder, pending) => {
@@ -653,8 +593,7 @@ const allocateReviewPath = (dir, date, existingNames) => {
653
593
  if (n > max) max = n;
654
594
  }
655
595
  const next = max + 1;
656
- const file = `${stamp}-${next < 100 ? pad2(next) : String(next)}.md`;
657
- return path.join(dir, REVIEW_DIR, file);
596
+ return path.join(dir, REVIEW_DIR, `${stamp}-${pad2(next)}.md`);
658
597
  };
659
598
 
660
599
  const listReviewNames = (dir) => {
@@ -790,6 +729,7 @@ module.exports = {
790
729
  setFeedback,
791
730
  setCode,
792
731
  addTask,
732
+ taskKind,
793
733
  removeTask,
794
734
  setTaskText,
795
735
  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
+ };