reslop 0.2.4 → 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 (109) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/LICENSE +1 -1
  3. package/README.md +12 -13
  4. package/bin/reslop.js +3 -8
  5. package/lib/ansi.js +32 -19
  6. package/lib/capabilities.js +8 -12
  7. package/lib/cli.js +40 -74
  8. package/lib/clipboard.js +48 -18
  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 +196 -404
  14. package/lib/deps.js +240 -399
  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 +138 -141
  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 +90 -206
  25. package/lib/editor.js +98 -55
  26. package/lib/files.js +86 -103
  27. package/lib/git-branches.js +254 -416
  28. package/lib/git-deps.js +182 -344
  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 +157 -132
  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 +16 -26
  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 +22 -35
  44. package/lib/highlight/txt.js +9 -22
  45. package/lib/highlight.js +17 -46
  46. package/lib/keys.js +62 -85
  47. package/lib/manifest.js +67 -130
  48. package/lib/npm-commands.js +119 -189
  49. package/lib/remote.js +141 -197
  50. package/lib/render/branches.js +66 -73
  51. package/lib/render/chrome.js +264 -323
  52. package/lib/render/commits.js +187 -500
  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 -228
  58. package/lib/render/files.js +45 -99
  59. package/lib/render/frame.js +129 -183
  60. package/lib/render/notes.js +144 -160
  61. package/lib/render/npm.js +234 -112
  62. package/lib/render/present.js +4 -11
  63. package/lib/render/primitives.js +131 -170
  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 -28
  68. package/lib/report-model.js +110 -33
  69. package/lib/report-parse.js +56 -38
  70. package/lib/report-render.js +102 -59
  71. package/lib/report-run.js +87 -78
  72. package/lib/report-test.js +256 -141
  73. package/lib/report-tools.js +96 -161
  74. package/lib/review-merge.js +114 -152
  75. package/lib/review.js +115 -189
  76. package/lib/runs.js +225 -0
  77. package/lib/select.js +89 -75
  78. package/lib/session/accessors.js +14 -21
  79. package/lib/session/actions.js +82 -63
  80. package/lib/session/branches.js +193 -247
  81. package/lib/session/change-actions.js +171 -214
  82. package/lib/session/commit.js +280 -342
  83. package/lib/session/compose.js +559 -674
  84. package/lib/session/dashboard.js +385 -0
  85. package/lib/session/dispatch.js +164 -157
  86. package/lib/session/edit-click.js +57 -0
  87. package/lib/session/files-pane.js +85 -53
  88. package/lib/session/items.js +73 -104
  89. package/lib/session/lifecycle.js +403 -541
  90. package/lib/session/load.js +48 -68
  91. package/lib/session/navigation.js +96 -87
  92. package/lib/session/npm.js +409 -345
  93. package/lib/session/ops.js +196 -63
  94. package/lib/session/pointer.js +23 -15
  95. package/lib/session/progress.js +33 -21
  96. package/lib/session/review.js +33 -54
  97. package/lib/session/tasks.js +333 -0
  98. package/lib/session/templates.js +51 -59
  99. package/lib/session/terminal.js +85 -136
  100. package/lib/session/unit.js +3 -4
  101. package/lib/session/update.js +76 -97
  102. package/lib/session/view.js +52 -83
  103. package/lib/session/watch.js +208 -204
  104. package/lib/session.js +112 -237
  105. package/lib/update.js +45 -116
  106. package/lib/utilities.js +67 -89
  107. package/lib/wrap.js +33 -44
  108. package/package.json +1 -1
  109. package/lib/session/todos.js +0 -293
package/lib/review.js CHANGED
@@ -4,29 +4,31 @@ const fs = require('node:fs');
4
4
  const path = require('node:path');
5
5
 
6
6
  const files = require('./files.js');
7
- const { TODO_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
+ const utilities = require('./utilities.js');
11
+ const { pad2, dateStamp } = utilities;
10
12
 
11
13
  const createStore = (reviewPath, templates = []) => ({
12
14
  reviewPath,
13
15
  dirty: false,
14
- nextTodoId: 1,
16
+ nextTaskId: 1,
15
17
  feedback: new Map(),
16
18
  code: new Map(),
17
- todos: [],
19
+ tasks: [],
18
20
  templates: [...templates],
19
21
  status: 'editing',
20
22
  baseline: null,
21
23
  });
22
24
 
23
25
  const noteCounts = (store) => {
24
- const counts = { feedback: 0, todo: 0, todoDone: 0, code: 0 };
26
+ const counts = { feedback: 0, tasks: 0, tasksDone: 0, code: 0 };
25
27
  if (!store) return counts;
26
- for (const todo of store.todos) {
27
- if (!todo.text.trim()) continue;
28
- counts.todo += 1;
29
- if (todo.done) counts.todoDone += 1;
28
+ for (const task of store.tasks) {
29
+ if (!task.text.trim()) continue;
30
+ counts.tasks += 1;
31
+ if (task.done) counts.tasksDone += 1;
30
32
  }
31
33
  for (const note of store.feedback.values()) {
32
34
  if (note.text.trim()) counts.feedback += 1;
@@ -37,98 +39,72 @@ const noteCounts = (store) => {
37
39
 
38
40
  const hasNotes = (store) => {
39
41
  const counts = noteCounts(store);
40
- return counts.feedback > 0 || counts.todo > 0 || counts.code > 0;
41
- };
42
+ return counts.feedback > 0 || counts.tasks > 0 || counts.code > 0;
43
+ };
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
+ });
42
55
 
43
- const setFeedback = (store, key, note) => {
44
- const text = note.text ?? '';
45
- const trimmed = text.trim();
46
- const prev = store.feedback.get(key);
47
- if (!trimmed) {
56
+ const putNote = (store, notes, key, note, clear) => {
57
+ const prev = notes.get(key);
58
+ if (clear) {
48
59
  if (!prev) return;
49
- store.feedback.delete(key);
60
+ notes.delete(key);
50
61
  store.dirty = true;
51
62
  return;
52
63
  }
53
- if (prev && prev.text === text) return;
54
- store.feedback.set(key, {
55
- file: note.file,
56
- oldStart: note.oldStart ?? 0,
57
- newStart: note.newStart ?? 0,
58
- blockId: note.blockId ?? 0,
59
- origin: note.origin ?? '',
60
- header: note.header ?? '',
61
- text,
62
- done: note.done ?? prev?.done ?? false,
63
- });
64
+ if (prev && prev.text === (note.text ?? '')) return;
65
+ notes.set(key, toNote(note, prev?.done));
64
66
  store.dirty = true;
65
67
  };
66
68
 
69
+ const setFeedback = (store, key, note) => {
70
+ const clear = !(note.text ?? '').trim();
71
+ putNote(store, store.feedback, key, note, clear);
72
+ };
73
+
67
74
  const setCode = (store, key, note) => {
68
- const prev = store.code.get(key);
69
- if (note.clear) {
70
- if (!prev) return;
71
- store.code.delete(key);
72
- store.dirty = true;
73
- return;
74
- }
75
- const text = note.text ?? '';
76
- if (prev && prev.text === text) return;
77
- store.code.set(key, {
78
- file: note.file,
79
- oldStart: note.oldStart ?? 0,
80
- newStart: note.newStart ?? 0,
81
- blockId: note.blockId ?? 0,
82
- origin: note.origin ?? '',
83
- header: note.header ?? '',
84
- text,
85
- done: note.done ?? prev?.done ?? false,
86
- });
87
- store.dirty = true;
75
+ putNote(store, store.code, key, note, Boolean(note.clear));
88
76
  };
89
77
 
90
- const addTodo = (store, file, text = '') => {
91
- const id = store.nextTodoId;
92
- store.nextTodoId += 1;
93
- const todo = { id, file, text, done: false };
94
- store.todos.push(todo);
78
+ const addTask = (store, file, text = '', done = false, kind = 'backlog') => {
79
+ const id = store.nextTaskId;
80
+ store.nextTaskId += 1;
81
+ const todo = { id, file, text, done, kind: taskKind({ kind }) };
82
+ store.tasks.push(todo);
95
83
  store.dirty = true;
96
84
  return todo;
97
85
  };
98
86
 
99
- const removeTodo = (store, id) => {
100
- const next = [];
101
- let found = false;
102
- for (const todo of store.todos) {
103
- if (todo.id === id) {
104
- found = true;
105
- continue;
106
- }
107
- next.push(todo);
108
- }
109
- if (!found) return false;
110
- store.todos = next;
87
+ const removeTask = (store, id) => {
88
+ const tasks = store.tasks.filter((todo) => todo.id !== id);
89
+ if (tasks.length === store.tasks.length) return false;
90
+ store.tasks = tasks;
111
91
  store.dirty = true;
112
92
  return true;
113
93
  };
114
94
 
115
- const setTodoText = (store, id, text) => {
116
- const i = store.todos.findIndex((todo) => todo.id === id);
95
+ const setTaskText = (store, id, text) => {
96
+ const i = store.tasks.findIndex((todo) => todo.id === id);
117
97
  if (i < 0) return;
118
- const prev = store.todos[i];
119
- const trimmed = text.trim();
120
- if (!trimmed) return void removeTodo(store, id);
121
- if (prev.text === text) return;
122
- store.todos[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 };
123
101
  store.dirty = true;
124
102
  };
125
103
 
126
- const setTodoDone = (store, id, done) => {
127
- const i = store.todos.findIndex((todo) => todo.id === id);
128
- if (i < 0) return;
129
- const prev = store.todos[i];
130
- if (prev.done === done) return;
131
- store.todos[i] = { id: prev.id, file: prev.file, text: prev.text, done };
104
+ const setTaskDone = (store, id, done) => {
105
+ const i = store.tasks.findIndex((todo) => todo.id === id);
106
+ if (i < 0 || store.tasks[i].done === done) return;
107
+ store.tasks[i] = { ...store.tasks[i], done };
132
108
  store.dirty = true;
133
109
  };
134
110
 
@@ -153,21 +129,11 @@ const applyImportedNotes = (store, imported) => {
153
129
  const prev = store.feedback.get(key);
154
130
  const text = prev ? `${prev.text}\n\n${note.text}` : note.text;
155
131
  const done = prev ? prev.done && !!note.done : !!note.done;
156
- setFeedback(store, key, {
157
- file: note.file,
158
- oldStart: note.oldStart ?? 0,
159
- newStart: note.newStart ?? 0,
160
- blockId: note.blockId ?? 0,
161
- origin: note.origin ?? '',
162
- header: note.header ?? '',
163
- text,
164
- done,
165
- });
166
- }
167
- const todos = imported.todos ?? [];
132
+ setFeedback(store, key, { ...note, text, done });
133
+ }
134
+ const todos = imported.tasks ?? imported.todos ?? [];
168
135
  for (const todo of todos) {
169
- const added = addTodo(store, todo.file || '', todo.text ?? '');
170
- if (todo.done) added.done = true;
136
+ addTask(store, todo.file || '', todo.text ?? '', !!todo.done);
171
137
  }
172
138
  };
173
139
 
@@ -233,6 +199,15 @@ const SECTION_LINE = {
233
199
  '### Todo': 'todo',
234
200
  '### Feedback': 'feedback',
235
201
  };
202
+ const BACKLOG_HEAD = 'Backlog';
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
+ ]);
236
211
 
237
212
  const AGENT_INSTRUCTIONS = [
238
213
  '- Execute reviews with `status` `ready, partial, editing`',
@@ -249,10 +224,8 @@ const AGENT_INSTRUCTIONS = [
249
224
  const toInt = (text) => parseInt(text, 10);
250
225
 
251
226
  const canonicalStatus = (raw) => {
252
- if (!raw) return '';
253
227
  const name = raw === 'pending' ? 'ready' : raw;
254
- if (REVIEW_STATUSES.includes(name)) return name;
255
- return '';
228
+ return REVIEW_STATUSES.includes(name) ? name : '';
256
229
  };
257
230
 
258
231
  const parseFrontmatterStatus = (text) => {
@@ -272,44 +245,15 @@ const parseFrontmatterStatus = (text) => {
272
245
 
273
246
  const isDoneMark = (mark) => mark === 'x' || mark === 'X';
274
247
 
275
- const pushTodo = (builder, file, text, done) => {
276
- const id = builder.nextTodoId;
277
- builder.nextTodoId += 1;
278
- builder.todos.push({ id, file, text, done });
279
- };
280
-
281
248
  const checkboxLine = (text, done) => {
282
249
  const flat = text.trim().replace(/\n/g, ' ');
283
250
  const mark = done ? 'x' : ' ';
284
251
  return `- [${mark}] ${flat}`;
285
252
  };
286
253
 
287
- const storeFeedback = (builder, note, key) => {
288
- const next = {
289
- file: note.file,
290
- oldStart: note.oldStart ?? 0,
291
- newStart: note.newStart ?? 0,
292
- blockId: note.blockId ?? 0,
293
- origin: note.origin ?? '',
294
- header: note.header ?? '',
295
- text: note.text,
296
- done: note.done ?? false,
297
- };
298
- builder.feedback.set(key || feedbackKey(next), next);
299
- };
300
-
301
- const storeCode = (builder, note, key) => {
302
- const next = {
303
- file: note.file,
304
- oldStart: note.oldStart ?? 0,
305
- newStart: note.newStart ?? 0,
306
- blockId: note.blockId ?? 0,
307
- origin: note.origin ?? '',
308
- header: note.header ?? '',
309
- text: note.text,
310
- done: note.done ?? false,
311
- };
312
- 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);
313
257
  };
314
258
 
315
259
  const feedbackLine = (note) => {
@@ -350,17 +294,23 @@ const parseFeedSuffix = (textItem) => {
350
294
  const fileGroup = (groups, file) => {
351
295
  let group = groups.get(file);
352
296
  if (!group) {
353
- group = { todos: [], feedback: [], code: [] };
297
+ group = { tasks: [], feedback: [], code: [] };
354
298
  groups.set(file, group);
355
299
  }
356
300
  return group;
357
301
  };
358
302
 
303
+ const TASK_SECTION = 'task:';
304
+
305
+ const taskSection = (id) => `${TASK_SECTION}${id}`;
306
+
359
307
  const groupReviewNotes = (notes) => {
360
308
  const groups = new Map();
361
- for (const todo of notes.todos) {
362
- if (!todo.text.trim()) continue;
363
- fileGroup(groups, TODO_FILE).todos.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
+ }
364
314
  }
365
315
  for (const key of notes.feedback.keys()) {
366
316
  const note = notes.feedback.get(key);
@@ -411,12 +361,22 @@ const emitCodeItem = (lines, note) => {
411
361
  lines.push(fence);
412
362
  };
413
363
 
364
+ const sectionTitle = (file) => {
365
+ if (!file.startsWith(TASK_SECTION)) return file;
366
+ return taskTitle(file.slice(TASK_SECTION.length));
367
+ };
368
+
369
+ const backlogFile = (name) => {
370
+ if (name === BACKLOG_HEAD || name === LEGACY_BACKLOG) return TASKS_FILE;
371
+ return name;
372
+ };
373
+
414
374
  const emitReviewGroups = (lines, groups) => {
415
375
  for (const file of groups.keys()) {
416
376
  const group = groups.get(file);
417
- lines.push(`## ${file}`);
377
+ lines.push(`## ${sectionTitle(file)}`);
418
378
  lines.push('');
419
- for (const todo of group.todos) {
379
+ for (const todo of group.tasks) {
420
380
  lines.push(checkboxLine(todo.text, todo.done));
421
381
  }
422
382
  for (const item of group.feedback) {
@@ -456,15 +416,18 @@ const parseFeedbackHeading = (line) => {
456
416
 
457
417
  const flushPendingFeedback = (builder, pending) => {
458
418
  if (!pending || !(pending.text ?? '').trim()) return null;
459
- storeFeedback(builder, pending, pending.key);
419
+ storeNote(builder.feedback, pending, pending.key);
460
420
  return null;
461
421
  };
462
422
 
463
423
  const headingFile = (line) => {
464
424
  const quoted = FILE_QUOTE.exec(line);
465
- if (quoted) return quoted[1];
425
+ if (quoted) return backlogFile(quoted[1]);
466
426
  if (line.startsWith('## ') && line !== '## Agent instructions') {
467
- return 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;
468
431
  }
469
432
  return null;
470
433
  };
@@ -483,33 +446,23 @@ const startFeedbackPending = (heading, file) => ({
483
446
  const applyFeedMeta = (pending, raw) => {
484
447
  const loc = parseLoc(raw);
485
448
  if (!loc) return pending;
486
- pending.origin = loc.origin;
487
- pending.file = loc.file;
488
- pending.oldStart = loc.oldStart;
489
- pending.newStart = loc.newStart;
490
- pending.blockId = loc.blockId;
491
- pending.key = feedbackKey(loc);
492
- return pending;
449
+ return Object.assign(pending, loc, { key: feedbackKey(loc) });
493
450
  };
494
451
 
495
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
+ }
496
458
  if (section === 'todo' && file) {
497
- pushTodo(builder, file, textItem, done);
459
+ addTask(builder, file, textItem, done, 'backlog');
498
460
  return pending;
499
461
  }
500
462
  const parsed = parseFeedSuffix(textItem);
501
463
  if (parsed) {
502
464
  flushPendingFeedback(builder, pending);
503
- storeFeedback(builder, {
504
- file: parsed.file || file,
505
- oldStart: parsed.oldStart,
506
- newStart: parsed.newStart,
507
- blockId: parsed.blockId,
508
- origin: '',
509
- header: '',
510
- text: parsed.text,
511
- done,
512
- });
465
+ storeNote(builder.feedback, { ...parsed, file: parsed.file || file, done });
513
466
  return null;
514
467
  }
515
468
  if (pending) {
@@ -518,7 +471,7 @@ const applyCheckItem = (builder, file, section, pending, textItem, done) => {
518
471
  return flushPendingFeedback(builder, pending);
519
472
  }
520
473
  if (file && section !== 'feedback') {
521
- pushTodo(builder, file, textItem, done);
474
+ addTask(builder, file, textItem, done, 'backlog');
522
475
  }
523
476
  return pending;
524
477
  };
@@ -557,29 +510,11 @@ const startCodePending = (line) => {
557
510
  if (!match) return null;
558
511
  const loc = parseLoc(match[2]);
559
512
  if (!loc) return null;
560
- return {
561
- done: isDoneMark(match[1]),
562
- origin: loc.origin,
563
- file: loc.file,
564
- oldStart: loc.oldStart,
565
- newStart: loc.newStart,
566
- blockId: loc.blockId,
567
- open: '',
568
- lines: [],
569
- };
513
+ return { done: isDoneMark(match[1]), ...loc, open: '', lines: [] };
570
514
  };
571
515
 
572
516
  const storeCodeNote = (builder, pending, text) => {
573
- storeCode(builder, {
574
- file: pending.file,
575
- oldStart: pending.oldStart,
576
- newStart: pending.newStart,
577
- blockId: pending.blockId,
578
- origin: pending.origin,
579
- header: '',
580
- text,
581
- done: pending.done,
582
- });
517
+ storeNote(builder.code, { ...pending, text });
583
518
  };
584
519
 
585
520
  const flushPendingCode = (builder, pending) => {
@@ -646,15 +581,6 @@ const TEMPLATES_FILE = 'templates.json';
646
581
  const AUTOSAVE_MS = 3000;
647
582
  const REVIEW_NAME = /^(\d{4}-\d{2}-\d{2})-(\d+)\.md$/;
648
583
 
649
- const pad2 = (n) => `${n}`.padStart(2, '0');
650
-
651
- const dateStamp = (date) => {
652
- const y = date.getFullYear();
653
- const month = pad2(date.getMonth() + 1);
654
- const day = pad2(date.getDate());
655
- return `${y}-${month}-${day}`;
656
- };
657
-
658
584
  const allocateReviewPath = (dir, date, existingNames) => {
659
585
  const stamp = dateStamp(date);
660
586
  let max = -1;
@@ -666,8 +592,7 @@ const allocateReviewPath = (dir, date, existingNames) => {
666
592
  if (n > max) max = n;
667
593
  }
668
594
  const next = max + 1;
669
- const file = `${stamp}-${next < 100 ? pad2(next) : String(next)}.md`;
670
- return path.join(dir, REVIEW_DIR, file);
595
+ return path.join(dir, REVIEW_DIR, `${stamp}-${pad2(next)}.md`);
671
596
  };
672
597
 
673
598
  const listReviewNames = (dir) => {
@@ -802,10 +727,11 @@ module.exports = {
802
727
  rememberTemplate,
803
728
  setFeedback,
804
729
  setCode,
805
- addTodo,
806
- removeTodo,
807
- setTodoText,
808
- setTodoDone,
730
+ addTask,
731
+ taskKind,
732
+ removeTask,
733
+ setTaskText,
734
+ setTaskDone,
809
735
  checkLabel,
810
736
  feedbackKey,
811
737
  applyImportedNotes,
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
+ };