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.
- package/CHANGELOG.md +22 -1
- package/LICENSE +1 -1
- package/README.md +11 -11
- package/bin/reslop.js +3 -8
- package/lib/ansi.js +72 -19
- package/lib/api.js +1 -1
- package/lib/capabilities.js +8 -8
- package/lib/cli.js +61 -75
- package/lib/clipboard.js +39 -47
- package/lib/dashboard/git.js +128 -0
- package/lib/dashboard/model.js +357 -0
- package/lib/dashboard/npm.js +290 -0
- package/lib/dashboard/tree.js +278 -0
- package/lib/deps-diff.js +197 -402
- package/lib/deps.js +113 -240
- package/lib/detect.js +8 -18
- package/lib/diff/align.js +41 -113
- package/lib/diff/diff.js +12 -43
- package/lib/diff/display.js +25 -36
- package/lib/diff/edit.js +6 -20
- package/lib/diff/inline.js +76 -144
- package/lib/diff/items.js +33 -47
- package/lib/diff/parse.js +28 -56
- package/lib/diff/patch.js +12 -33
- package/lib/diff/unit.js +79 -178
- package/lib/editor.js +52 -69
- package/lib/files.js +79 -91
- package/lib/find.js +61 -0
- package/lib/git-branches.js +254 -416
- package/lib/git-deps.js +148 -272
- package/lib/git-reword-todo.js +5 -13
- package/lib/git-worktree.js +161 -214
- package/lib/git.js +21 -49
- package/lib/github.js +67 -181
- package/lib/gitlab.js +40 -90
- package/lib/highlight/bash.js +55 -177
- package/lib/highlight/core.js +155 -141
- package/lib/highlight/css.js +25 -58
- package/lib/highlight/csv.js +19 -37
- package/lib/highlight/dart.js +72 -213
- package/lib/highlight/dot.js +14 -14
- package/lib/highlight/html.js +42 -63
- package/lib/highlight/js.js +177 -506
- package/lib/highlight/json.js +17 -37
- package/lib/highlight/log.js +19 -25
- package/lib/highlight/txt.js +7 -10
- package/lib/highlight.js +17 -46
- package/lib/keys.js +75 -83
- package/lib/manifest.js +67 -126
- package/lib/npm-commands.js +120 -169
- package/lib/remote.js +140 -190
- package/lib/render/branches.js +63 -61
- package/lib/render/chrome.js +412 -347
- package/lib/render/commits.js +160 -361
- package/lib/render/dash-activity.js +212 -0
- package/lib/render/dash-blocks.js +496 -0
- package/lib/render/dash-table.js +295 -0
- package/lib/render/dashboard.js +62 -0
- package/lib/render/diff.js +123 -258
- package/lib/render/files.js +60 -86
- package/lib/render/frame.js +82 -117
- package/lib/render/notes.js +150 -210
- package/lib/render/npm.js +213 -177
- package/lib/render/packages.js +93 -0
- package/lib/render/present.js +4 -11
- package/lib/render/primitives.js +77 -172
- package/lib/render/render.js +5 -14
- package/lib/render/report.js +43 -0
- package/lib/render/repos.js +226 -0
- package/lib/render/search.js +25 -0
- package/lib/render/tiles.js +355 -0
- package/lib/render/unit.js +15 -31
- package/lib/report-model.js +110 -33
- package/lib/report-parse.js +56 -37
- package/lib/report-render.js +102 -59
- package/lib/report-run.js +87 -77
- package/lib/report-test.js +221 -99
- package/lib/report-tools.js +96 -154
- package/lib/review-merge.js +33 -41
- package/lib/review.js +69 -129
- package/lib/runs.js +225 -0
- package/lib/select.js +53 -91
- package/lib/session/accessors.js +14 -21
- package/lib/session/actions.js +189 -65
- package/lib/session/branches.js +164 -222
- package/lib/session/change-actions.js +49 -119
- package/lib/session/commit.js +298 -352
- package/lib/session/compose.js +572 -715
- package/lib/session/dashboard.js +530 -0
- package/lib/session/dispatch.js +197 -171
- package/lib/session/files-pane.js +92 -45
- package/lib/session/find.js +94 -0
- package/lib/session/items.js +6 -17
- package/lib/session/lifecycle.js +420 -466
- package/lib/session/load.js +1 -14
- package/lib/session/navigation.js +4 -2
- package/lib/session/npm.js +403 -420
- package/lib/session/ops.js +5 -13
- package/lib/session/packages.js +143 -0
- package/lib/session/pointer.js +30 -14
- package/lib/session/progress.js +39 -30
- package/lib/session/review.js +1 -3
- package/lib/session/tasks.js +277 -236
- package/lib/session/templates.js +51 -59
- package/lib/session/terminal.js +85 -132
- package/lib/session/view.js +59 -80
- package/lib/session/watch.js +201 -212
- package/lib/session/workspace.js +455 -0
- package/lib/session.js +95 -161
- package/lib/update.js +44 -111
- package/lib/utilities.js +46 -88
- package/lib/workspace.js +166 -0
- package/lib/wrap.js +29 -55
- 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
|
-
|
|
54
|
-
|
|
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:
|
|
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
|
|
88
|
-
|
|
89
|
-
|
|
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
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
277
|
-
const next =
|
|
278
|
-
|
|
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
|
|
351
|
-
|
|
352
|
-
|
|
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) =>
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
};
|