firstlight 0.0.0-stage → 0.2.0

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.
@@ -0,0 +1,459 @@
1
+ // Uniform diagnostics from the log of a failed check: which parser reads a
2
+ // check's output (named in firstlight.json, or inferred from its command),
3
+ // and the parsers themselves, for tsc, vitest, node:test, and archstrict.
4
+ // Each parser reads the text the tool prints by default, because the user's
5
+ // command decides the output format and text is what the log holds. This
6
+ // module does no I/O: the caller reads the log and package.json.
7
+ //
8
+ // A parser never throws and never guesses: a line it does not recognize is
9
+ // skipped, and a log with nothing it recognizes gives no diagnostics, so the
10
+ // caller falls back to the plain log tail. A vitest diagnostic also gets the
11
+ // evidence file and rerun command depug printed for its test, when exactly
12
+ // one of each side names that test.
13
+ export const PARSERS = ["tsc", "vitest", "node-test", "archstrict"];
14
+ // "none" in firstlight.json turns inference off for one check.
15
+ export const PARSER_CHOICES = [...PARSERS, "none"];
16
+ const MESSAGE_MAX = 300;
17
+ export function isParserName(value) {
18
+ return typeof value === "string" && PARSERS.includes(value);
19
+ }
20
+ export function parseDiagnostics(parser, log, root) {
21
+ const lines = toLines(log);
22
+ switch (parser) {
23
+ case "tsc":
24
+ return parseTsc(lines);
25
+ case "vitest":
26
+ return attachDepug(parseVitest(lines, root), depugFailures(lines));
27
+ case "node-test":
28
+ return lines.some((line) => /^TAP version \d+$/.test(line)) ? parseNodeTestTap(lines, root) : parseNodeTestSpec(lines, root);
29
+ case "archstrict":
30
+ return parseArchstrict(lines, root);
31
+ }
32
+ }
33
+ export function formatDiagnostic(diagnostic) {
34
+ let where = diagnostic.file;
35
+ if (diagnostic.line !== null) {
36
+ where += `:${diagnostic.line}`;
37
+ if (diagnostic.col !== null)
38
+ where += `:${diagnostic.col}`;
39
+ }
40
+ return [where, diagnostic.rule, diagnostic.message].filter((part) => part !== null && part !== "").join(" ");
41
+ }
42
+ // The command is read only when it is one simple command: a pipe, a list,
43
+ // or a substitution could run any tool, and a wrong parser is worse than
44
+ // none. `npm run <script>` and `npm test` look up the script's own command
45
+ // one level deep; that command is not followed further.
46
+ export function inferParser(command, scriptOf) {
47
+ const words = simpleWords(command);
48
+ if (words === null)
49
+ return null;
50
+ let i = 0;
51
+ while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
52
+ i += 1;
53
+ if (words[i] === "npx") {
54
+ i += 1;
55
+ while (i < words.length && words[i].startsWith("-"))
56
+ i += 1;
57
+ }
58
+ else if (words[i] === "pnpm" && words[i + 1] === "exec") {
59
+ i += 2;
60
+ }
61
+ const program = words[i]?.split("/").at(-1);
62
+ const args = words.slice(i + 1);
63
+ switch (program) {
64
+ case "tsc":
65
+ case "vitest":
66
+ case "archstrict":
67
+ return program;
68
+ case "node":
69
+ return nodeRunsTests(args) ? "node-test" : null;
70
+ case "npm": {
71
+ const script = npmScript(args);
72
+ if (script === null)
73
+ return null;
74
+ const scriptCommand = scriptOf(script);
75
+ if (scriptCommand === null)
76
+ return null;
77
+ return inferParser(scriptCommand, () => null);
78
+ }
79
+ default:
80
+ return null;
81
+ }
82
+ }
83
+ function simpleWords(command) {
84
+ if (/[;&|`<>\n]|\$\(/.test(command))
85
+ return null;
86
+ const words = [];
87
+ for (const raw of command.trim().split(/\s+/)) {
88
+ if (raw.length === 0)
89
+ continue;
90
+ const quoted = /^'([^']*)'$/.exec(raw) ?? /^"([^"]*)"$/.exec(raw);
91
+ words.push(quoted === null ? raw : quoted[1]);
92
+ }
93
+ return words;
94
+ }
95
+ const NODE_VALUE_FLAGS = new Set(["--import", "--require", "-r", "--loader", "--experimental-loader", "--env-file"]);
96
+ // `--test` counts only before the script: after it, it is the script's own.
97
+ function nodeRunsTests(args) {
98
+ for (let i = 0; i < args.length; i += 1) {
99
+ const arg = args[i];
100
+ if (arg === "--test")
101
+ return true;
102
+ if (!arg.startsWith("-") && !NODE_VALUE_FLAGS.has(args[i - 1] ?? ""))
103
+ return false;
104
+ }
105
+ return false;
106
+ }
107
+ function npmScript(args) {
108
+ const rest = args.filter((arg) => !arg.startsWith("-") || arg === "--");
109
+ const [verb, name] = rest;
110
+ if (verb === "test" || verb === "t" || verb === "tst")
111
+ return "test";
112
+ if ((verb === "run" || verb === "run-script" || verb === "rum" || verb === "urn") && name !== undefined && name !== "--")
113
+ return name;
114
+ return null;
115
+ }
116
+ function toLines(log) {
117
+ return log.replace(/\u001b\[[0-9;?]*[A-Za-z]/g, "").split(/\r\n|\n|\r/);
118
+ }
119
+ function positive(text) {
120
+ if (text === undefined)
121
+ return null;
122
+ const value = Number(text);
123
+ return Number.isSafeInteger(value) && value > 0 ? value : null;
124
+ }
125
+ function clip(message) {
126
+ const flat = message.replace(/\s+/g, " ").trim();
127
+ return flat.length <= MESSAGE_MAX ? flat : `${flat.slice(0, MESSAGE_MAX - 3)}...`;
128
+ }
129
+ // The first line of an error, joined with the next ones while it ends in
130
+ // ":", because assert's "Expected values to be strictly equal:" says
131
+ // nothing until the "7 !== 6" that follows it.
132
+ function headline(lines) {
133
+ const kept = lines.map((line) => line.trim()).filter((line) => line.length > 0);
134
+ let message = kept[0] ?? "";
135
+ for (let i = 1; i < kept.length && message.endsWith(":") && message.length < MESSAGE_MAX; i += 1) {
136
+ message += ` ${kept[i]}`;
137
+ }
138
+ return clip(message);
139
+ }
140
+ // A stack path relative to the root, or null when it is outside the
141
+ // project: a dependency, a Node internal, or another directory.
142
+ function projectPath(raw, root) {
143
+ let file = raw;
144
+ if (file.startsWith("file://")) {
145
+ try {
146
+ file = decodeURIComponent(file.slice("file://".length));
147
+ }
148
+ catch {
149
+ return null;
150
+ }
151
+ }
152
+ if (file.startsWith("node:") || file.includes("node_modules/") || file.length === 0)
153
+ return null;
154
+ if (!file.startsWith("/"))
155
+ return file;
156
+ const prefix = root.endsWith("/") ? root : `${root}/`;
157
+ return file.startsWith(prefix) && file.length > prefix.length ? file.slice(prefix.length) : null;
158
+ }
159
+ function displayPath(raw, root) {
160
+ return projectPath(raw, root) ?? raw;
161
+ }
162
+ // tsc --pretty false: `src/a.ts(3,7): error TS2322: message`. --pretty:
163
+ // `src/a.ts:3:7 - error TS2322: message` once the colors are gone. The
164
+ // indented lines after either are elaboration and are left to the log.
165
+ function parseTsc(lines) {
166
+ const out = [];
167
+ for (const line of lines) {
168
+ const plain = /^(\S.*?)\((\d+),(\d+)\): (?:error|warning) (TS\d+): (.*)$/.exec(line);
169
+ const pretty = plain === null ? /^(\S.*?):(\d+):(\d+) - (?:error|warning) (TS\d+): (.*)$/.exec(line) : null;
170
+ const match = plain ?? pretty;
171
+ if (match === null)
172
+ continue;
173
+ out.push({ file: match[1], line: positive(match[2]), col: positive(match[3]), rule: match[4], message: clip(match[5]) });
174
+ }
175
+ return out;
176
+ }
177
+ const VITEST_FRAME = /^\s*❯ (?:(.+) )?(\S+?):(\d+):(\d+)$/;
178
+ // vitest's default reporter: a ` FAIL file > suite > test` header (or
179
+ // ` FAIL file [ file ]` for a file that failed to load), the error, then
180
+ // `❯ [fn] file:line:col` frames, each followed by a code frame.
181
+ function parseVitest(lines, root) {
182
+ const out = [];
183
+ for (let i = 0; i < lines.length; i += 1) {
184
+ const header = /^ FAIL {2}(\S+?)(?: > (.+)| \[ .+ \])$/.exec(lines[i]);
185
+ if (header === null)
186
+ continue;
187
+ const testFile = header[1];
188
+ const message = [];
189
+ let frame = null;
190
+ let j = i + 1;
191
+ let inError = true;
192
+ for (; j < lines.length; j += 1) {
193
+ const line = lines[j];
194
+ if (/^ FAIL {2}/.test(line) || /^⎯{3,}/.test(line))
195
+ break;
196
+ const found = VITEST_FRAME.exec(line);
197
+ if (found !== null) {
198
+ inError = false;
199
+ const file = frame === null ? projectPath(found[2], root) : null;
200
+ if (file !== null)
201
+ frame = { file, line: positive(found[3]), col: positive(found[4]) };
202
+ continue;
203
+ }
204
+ if (inError)
205
+ message.push(line);
206
+ }
207
+ const diagnostic = {
208
+ file: frame?.file ?? testFile,
209
+ line: frame?.line ?? null,
210
+ col: frame?.col ?? null,
211
+ rule: header[2] ?? null,
212
+ message: headline(message),
213
+ };
214
+ out.push({ diagnostic, testFile, name: header[2] ?? null });
215
+ i = j - 1;
216
+ }
217
+ return out;
218
+ }
219
+ const JSON_STRING = '"(?:[^"\\\\]|\\\\.)*"';
220
+ const DEPUG_TARGET = new RegExp(`(?:^|\\s)vitest run (${JSON_STRING}) -t (${JSON_STRING})(?:\\s|$)`);
221
+ const REGEX_METACHARACTER = /[.*+?^${}()|[\]\\]/g;
222
+ // depug's vitest reporter prints `depug evidence: <absolute path> (<note>)`
223
+ // and, on the next line, `depug rerun: <command>`. The command is the only
224
+ // place that names the test: `vitest run "<file>" -t "<pattern>"`, both
225
+ // JSON strings, where the pattern is the names joined by a space, with
226
+ // regex metacharacters escaped, between `^` and `$`. A pattern that does
227
+ // not escape back to itself is not depug's, so it names nothing.
228
+ function depugFailures(lines) {
229
+ const out = [];
230
+ for (let i = 0; i + 1 < lines.length; i += 1) {
231
+ const evidence = /^depug evidence: ((?:\/|[A-Za-z]:[\\/]).*?)(?: \([^()]*\))?$/.exec(lines[i]);
232
+ if (evidence === null)
233
+ continue;
234
+ const rerun = /^depug rerun: (\S.*)$/.exec(lines[i + 1]);
235
+ if (rerun === null)
236
+ continue;
237
+ i += 1;
238
+ const target = DEPUG_TARGET.exec(rerun[1]);
239
+ if (target === null)
240
+ continue;
241
+ let testFile;
242
+ let pattern;
243
+ try {
244
+ testFile = JSON.parse(target[1]);
245
+ pattern = JSON.parse(target[2]);
246
+ }
247
+ catch {
248
+ continue;
249
+ }
250
+ if (typeof testFile !== "string" || typeof pattern !== "string")
251
+ continue;
252
+ const anchored = /^\^(.*)\$$/s.exec(pattern);
253
+ if (anchored === null)
254
+ continue;
255
+ const name = anchored[1].replace(/\\([.*+?^${}()|[\]\\])/g, "$1");
256
+ if (name.replace(REGEX_METACHARACTER, "\\$&") !== anchored[1])
257
+ continue;
258
+ out.push({ testFile, name, evidence: evidence[1], rerun: rerun[1] });
259
+ }
260
+ return out;
261
+ }
262
+ // vitest's header joins the names with " > " and depug's pattern with a
263
+ // space, so two tests can share a key ("a > b c" and "a b > c"), and a name
264
+ // that itself holds " > " reads back as two names. The key names one test
265
+ // only when exactly one diagnostic and one depug failure carry it.
266
+ function attachDepug(failures, depug) {
267
+ const keyOf = (testFile, name) => JSON.stringify([testFile, name]);
268
+ const counts = new Map();
269
+ for (const failure of failures) {
270
+ if (failure.name === null)
271
+ continue;
272
+ const key = keyOf(failure.testFile, failure.name.split(" > ").join(" "));
273
+ counts.set(key, (counts.get(key) ?? 0) + 1);
274
+ }
275
+ const byKey = new Map();
276
+ for (const entry of depug) {
277
+ const key = keyOf(entry.testFile, entry.name);
278
+ byKey.set(key, [...(byKey.get(key) ?? []), entry]);
279
+ }
280
+ return failures.map((failure) => {
281
+ if (failure.name === null)
282
+ return failure.diagnostic;
283
+ const key = keyOf(failure.testFile, failure.name.split(" > ").join(" "));
284
+ const matches = byKey.get(key) ?? [];
285
+ if (counts.get(key) !== 1 || matches.length !== 1)
286
+ return failure.diagnostic;
287
+ return { ...failure.diagnostic, evidence: matches[0].evidence, rerun: matches[0].rerun };
288
+ });
289
+ }
290
+ const STACK_FRAME = /^\s*at (?:.*? \()?(\S+?):(\d+):(\d+)\)?$/;
291
+ function firstProjectFrame(lines, root) {
292
+ for (const line of lines) {
293
+ const found = STACK_FRAME.exec(line);
294
+ if (found === null)
295
+ continue;
296
+ const file = projectPath(found[1], root);
297
+ if (file !== null)
298
+ return { file, line: positive(found[2]), col: positive(found[3]) };
299
+ }
300
+ return null;
301
+ }
302
+ function locationOf(text, root) {
303
+ if (text === undefined)
304
+ return null;
305
+ const found = /^(.+?):(\d+):(\d+)$/.exec(text.trim());
306
+ if (found === null)
307
+ return text.trim().length === 0 ? null : { file: displayPath(text.trim(), root), line: null, col: null };
308
+ return { file: displayPath(found[1], root), line: positive(found[2]), col: positive(found[3]) };
309
+ }
310
+ // node --test with the spec reporter: after `✖ failing tests:`, each
311
+ // failure is an optional `test at file:line:col`, `✖ name (time)`, and the
312
+ // indented error with its `at` stack.
313
+ function parseNodeTestSpec(lines, root) {
314
+ const start = lines.findIndex((line) => /^✖ failing tests:$/.test(line));
315
+ if (start === -1)
316
+ return [];
317
+ const fullNames = specFailures(lines.slice(0, start));
318
+ const out = [];
319
+ let location;
320
+ for (let i = start + 1; i < lines.length; i += 1) {
321
+ const line = lines[i];
322
+ const at = /^test at (.+)$/.exec(line);
323
+ if (at !== null) {
324
+ location = at[1];
325
+ continue;
326
+ }
327
+ const failed = /^✖ (.+?)(?: \([\d.]+m?s\))?$/.exec(line);
328
+ if (failed === null)
329
+ continue;
330
+ const body = [];
331
+ let j = i + 1;
332
+ for (; j < lines.length && (lines[j].startsWith(" ") || lines[j].length === 0); j += 1)
333
+ body.push(lines[j]);
334
+ const frameStart = body.findIndex((entry) => STACK_FRAME.test(entry));
335
+ const message = headline(frameStart === -1 ? body : body.slice(0, frameStart));
336
+ const where = firstProjectFrame(body, root) ?? locationOf(location, root);
337
+ out.push({
338
+ file: where?.file ?? "",
339
+ line: where?.line ?? null,
340
+ col: where?.col ?? null,
341
+ rule: takeFullName(fullNames, failed[1]),
342
+ message: message.replace(/^'(.*)'$/, "$1"),
343
+ });
344
+ location = undefined;
345
+ i = j - 1;
346
+ }
347
+ return out.filter((diagnostic) => diagnostic.file.length > 0);
348
+ }
349
+ // The failing section names a test by its own name only, so the tree above
350
+ // it, two spaces per suite, gives the full name. A suite opens with `▶ name`
351
+ // and closes with a `✖ name` line at the same depth.
352
+ function specFailures(lines) {
353
+ const suites = [];
354
+ const failures = [];
355
+ for (const line of lines) {
356
+ const entry = /^((?: {2})*)([▶✖]) (.+?)(?: \([\d.]+m?s\))?$/.exec(line);
357
+ if (entry === null)
358
+ continue;
359
+ const depth = entry[1].length / 2;
360
+ if (entry[2] === "▶") {
361
+ suites.length = depth;
362
+ suites[depth] = entry[3];
363
+ }
364
+ else if (suites.length === depth + 1 && suites[depth] === entry[3]) {
365
+ suites.length = depth;
366
+ }
367
+ else {
368
+ failures.push([...suites.slice(0, depth), entry[3]].join(" > "));
369
+ }
370
+ }
371
+ return failures;
372
+ }
373
+ function takeFullName(fullNames, name) {
374
+ const index = fullNames.findIndex((full) => full === name || full.endsWith(` > ${name}`));
375
+ if (index === -1)
376
+ return name;
377
+ return fullNames.splice(index, 1)[0];
378
+ }
379
+ // node --test --test-reporter=tap: `# Subtest: name` lines nest by four
380
+ // spaces, and each `not ok` carries a YAML block. A suite that failed only
381
+ // because a subtest did (`subtestsFailed`) adds nothing of its own.
382
+ function parseNodeTestTap(lines, root) {
383
+ const out = [];
384
+ const names = [];
385
+ for (let i = 0; i < lines.length; i += 1) {
386
+ const line = lines[i];
387
+ const subtest = /^((?: {4})*)# Subtest: (.*)$/.exec(line);
388
+ if (subtest !== null) {
389
+ const depth = subtest[1].length / 4;
390
+ names.length = depth;
391
+ names[depth] = subtest[2];
392
+ continue;
393
+ }
394
+ const failed = /^((?: {4})*)not ok \d+ - (.*?)(?: # .*)?$/.exec(line);
395
+ if (failed === null)
396
+ continue;
397
+ const indent = failed[1];
398
+ const depth = indent.length / 4;
399
+ const fields = new Map();
400
+ let j = i + 1;
401
+ if (lines[j] === `${indent} ---`) {
402
+ let key = null;
403
+ for (j += 1; j < lines.length && lines[j] !== `${indent} ...`; j += 1) {
404
+ const entry = lines[j];
405
+ const field = entry.startsWith(`${indent} `) ? /^([A-Za-z_]+):(?: (.*))?$/.exec(entry.slice(indent.length + 2)) : null;
406
+ if (field !== null) {
407
+ key = field[1];
408
+ const value = field[2] ?? "";
409
+ fields.set(key, value === "|-" || value === "|" || value === ">-" ? [] : [unquote(value)]);
410
+ }
411
+ else if (key !== null) {
412
+ fields.get(key).push(entry.slice(indent.length + 4));
413
+ }
414
+ }
415
+ }
416
+ i = j;
417
+ if (fields.get("failureType")?.[0] === "subtestsFailed")
418
+ continue;
419
+ const name = [...names.slice(0, depth), failed[2]].join(" > ");
420
+ const kind = fields.get("name")?.[0];
421
+ const error = headline(fields.get("error") ?? []);
422
+ const where = firstProjectFrame((fields.get("stack") ?? []).map((frame) => `at ${frame}`), root) ?? locationOf(fields.get("location")?.[0], root);
423
+ if (where === null)
424
+ continue;
425
+ out.push({
426
+ file: where.file,
427
+ line: where.line,
428
+ col: where.col,
429
+ rule: name,
430
+ message: clip(kind === undefined || kind.length === 0 ? error : `${kind}: ${error}`),
431
+ });
432
+ }
433
+ return out;
434
+ }
435
+ function unquote(value) {
436
+ const quoted = /^'(.*)'$/.exec(value);
437
+ return quoted === null ? value : quoted[1].replace(/''/g, "'");
438
+ }
439
+ // archstrict check: `[rule] /abs/path:line:col`, then the indented
440
+ // evidence (after an optional `frozen: true`), `because:`, and `do:`. The
441
+ // grouped form past 20 violations prints the same block for each example.
442
+ function parseArchstrict(lines, root) {
443
+ const out = [];
444
+ for (let i = 0; i < lines.length; i += 1) {
445
+ const head = /^\[([a-z][a-z0-9-]*)\] (.+?):(\d+):(\d+)$/.exec(lines[i]);
446
+ if (head === null)
447
+ continue;
448
+ let evidence = "";
449
+ for (let j = i + 1; j < lines.length && lines[j].startsWith(" "); j += 1) {
450
+ const entry = lines[j].trim();
451
+ if (entry === "frozen: true")
452
+ continue;
453
+ evidence = entry.replace(/^evidence: /, "");
454
+ break;
455
+ }
456
+ out.push({ file: displayPath(head[2], root), line: positive(head[3]), col: positive(head[4]), rule: head[1], message: clip(evidence) });
457
+ }
458
+ return out;
459
+ }
package/dist/errors.js ADDED
@@ -0,0 +1,25 @@
1
+ // Failures of firstlight itself: the error type every module throws, and the
2
+ // one place that turns it into the {"error","do"} envelope on stderr.
3
+ // A failing check is not this; checks are reported by report.ts.
4
+ import { writeSync } from "node:fs";
5
+ export class AgentError extends Error {
6
+ next;
7
+ constructor(message, next) {
8
+ super(message);
9
+ this.name = "AgentError";
10
+ this.next = next;
11
+ }
12
+ }
13
+ export function exitWithEnvelope(err) {
14
+ const body = err instanceof AgentError
15
+ ? { error: err.message, do: err.next }
16
+ : {
17
+ error: `internal error: ${err instanceof Error ? err.message : String(err)}`,
18
+ do: "rerun the same command; if it repeats, report it with this message",
19
+ };
20
+ writeSync(2, `${JSON.stringify(body)}\n`);
21
+ process.exit(1);
22
+ }
23
+ export function messageOf(err) {
24
+ return err instanceof Error ? err.message : String(err);
25
+ }
package/dist/eta.js ADDED
@@ -0,0 +1,76 @@
1
+ // The estimated time left for checks that have not finished, from how long
2
+ // the same check took when it last passed. It only computes; reading earlier
3
+ // runs is the store's, and printing the estimate is the report's. No I/O.
4
+ import { isSettled } from "./checks.js";
5
+ // Only a pass counts: a failed, timed-out, or cancelled check stopped early,
6
+ // so its elapsed time says nothing about a full run. A changed `run` command
7
+ // is a different check that happens to share the name.
8
+ export function typicalDurations(specs, newestFirst) {
9
+ const typical = new Map();
10
+ for (const spec of specs) {
11
+ for (const past of newestFirst) {
12
+ const pastSpec = past.checks.find((check) => check.name === spec.name);
13
+ if (pastSpec === undefined || pastSpec.run !== spec.run)
14
+ continue;
15
+ const state = past.states.find((check) => check.name === spec.name);
16
+ if (state?.status === "passed" && state.elapsed_ms !== null) {
17
+ typical.set(spec.name, state.elapsed_ms);
18
+ break;
19
+ }
20
+ }
21
+ }
22
+ return typical;
23
+ }
24
+ export function estimate(views, specs, typical, now) {
25
+ const byName = new Map(views.map((view) => [view.name, view]));
26
+ const after = new Map(specs.map((spec) => [spec.name, spec.after]));
27
+ const memo = new Map();
28
+ // The after graph is acyclic once config.ts accepted it, so the recursion
29
+ // ends.
30
+ const remaining = (name) => {
31
+ const known = memo.get(name);
32
+ if (known !== undefined)
33
+ return known;
34
+ const result = remainingOf(name);
35
+ memo.set(name, result);
36
+ return result;
37
+ };
38
+ const remainingOf = (name) => {
39
+ const view = byName.get(name);
40
+ if (view === undefined)
41
+ return { eta_ms: null, overdue: false };
42
+ if (isSettled(view))
43
+ return { eta_ms: 0, overdue: false };
44
+ const usual = typical.get(name);
45
+ if (usual === undefined)
46
+ return { eta_ms: null, overdue: false };
47
+ if (view.status === "running") {
48
+ if (view.started_at === null)
49
+ return { eta_ms: null, overdue: false };
50
+ const elapsed = now - Date.parse(view.started_at);
51
+ return elapsed > usual ? { eta_ms: 0, overdue: true } : { eta_ms: usual - elapsed, overdue: false };
52
+ }
53
+ let wait = 0;
54
+ for (const dep of after.get(name) ?? []) {
55
+ const depEta = remaining(dep).eta_ms;
56
+ if (depEta === null)
57
+ return { eta_ms: null, overdue: false };
58
+ wait = Math.max(wait, depEta);
59
+ }
60
+ return { eta_ms: usual + wait, overdue: false };
61
+ };
62
+ const checks = new Map(views.map((view) => [view.name, remaining(view.name)]));
63
+ let overall = 0;
64
+ for (const view of views) {
65
+ if (isSettled(view))
66
+ continue;
67
+ const eta = checks.get(view.name).eta_ms;
68
+ overall = eta === null || overall === null ? null : Math.max(overall, eta);
69
+ }
70
+ return { checks, eta_ms: overall, typical };
71
+ }
72
+ // Whole seconds above 10 s, where a tenth is noise; one decimal below.
73
+ export function formatSeconds(ms) {
74
+ const seconds = ms / 1000;
75
+ return seconds >= 10 ? `${Math.round(seconds)}s` : `${seconds.toFixed(1)}s`;
76
+ }
package/dist/names.js ADDED
@@ -0,0 +1,67 @@
1
+ // Check names as a person types them and as files on disk: the npm-run-all
2
+ // glob over package.json script names, the log file name each check gets,
3
+ // and the shell quoting of a word. It only computes; reading package.json is
4
+ // config.ts's, and creating the files is the store's. No I/O.
5
+ // npm-run-all reads "lint:*" as a path glob with ":" for "/", so "*" stays
6
+ // inside one segment and "**" crosses them. Only "*" is special here; any
7
+ // other character is literal.
8
+ export function isPattern(word) {
9
+ return word.includes("*");
10
+ }
11
+ export function patternRegExp(pattern) {
12
+ let source = "";
13
+ for (const part of pattern.split(/(\*+)/)) {
14
+ if (part.length === 0)
15
+ continue;
16
+ if (part.startsWith("*"))
17
+ source += part.length === 1 ? "[^:]*" : ".*";
18
+ else
19
+ source += part.replace(/[\\^$.|?+()[\]{}]/g, "\\$&");
20
+ }
21
+ return new RegExp(`^${source}$`, "su");
22
+ }
23
+ export function matchesPattern(pattern, name) {
24
+ return patternRegExp(pattern).test(name);
25
+ }
26
+ // Keeps the order of `names`, which is package.json order, as npm-run-all
27
+ // does.
28
+ export function expandPattern(pattern, names) {
29
+ const re = patternRegExp(pattern);
30
+ return names.filter((name) => re.test(name));
31
+ }
32
+ // The supervisor writes its own stderr to supervisor.log beside the check
33
+ // logs.
34
+ const RESERVED_LOG_BASES = ["supervisor"];
35
+ // Leaves room for "-<n>.log" under the usual 255-byte file name limit.
36
+ const MAX_LOG_BASE = 100;
37
+ export function logBase(name) {
38
+ const base = name.replace(/[^A-Za-z0-9._-]/gu, "_").slice(0, MAX_LOG_BASE);
39
+ return base.length === 0 ? "_" : base;
40
+ }
41
+ // Two names can map to one file ("a:b" and "a_b"), and on a case-insensitive
42
+ // file system "Test" and "test" already do; a later claimant gets "-2",
43
+ // "-3", and so on. A name that is already a safe base claims first, so a
44
+ // check from firstlight.json keeps `<name>.log` whatever runs beside it.
45
+ export function logFileNames(names) {
46
+ const files = new Map();
47
+ const taken = new Set(RESERVED_LOG_BASES);
48
+ const claim = (name) => {
49
+ if (files.has(name))
50
+ return;
51
+ const base = logBase(name);
52
+ let file = base;
53
+ for (let n = 2; taken.has(file.toLowerCase()); n += 1)
54
+ file = `${base}-${n}`;
55
+ taken.add(file.toLowerCase());
56
+ files.set(name, `${file}.log`);
57
+ };
58
+ for (const name of names)
59
+ if (logBase(name) === name)
60
+ claim(name);
61
+ for (const name of names)
62
+ claim(name);
63
+ return new Map(names.map((name) => [name, files.get(name)]));
64
+ }
65
+ export function shellQuote(value) {
66
+ return /^[A-Za-z0-9_./:@%+=,-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
67
+ }
@@ -0,0 +1,56 @@
1
+ // Process liveness and process-group signalling for pids firstlight started.
2
+ // Nothing here looks processes up by name or pattern: every call takes a pid
3
+ // that a run record or an event recorded.
4
+ import { readFileSync } from "node:fs";
5
+ export function isPidAlive(pid) {
6
+ if (pid === null || !Number.isInteger(pid) || pid <= 0)
7
+ return false;
8
+ try {
9
+ process.kill(pid, 0);
10
+ }
11
+ catch (err) {
12
+ return err.code === "EPERM";
13
+ }
14
+ return !isZombie(pid);
15
+ }
16
+ // A detached supervisor is reparented when the CLI exits. If the new parent
17
+ // never reaps (a container whose pid 1 is not an init), a dead supervisor
18
+ // stays a zombie and kill(pid, 0) keeps succeeding, which would hide an
19
+ // orphan forever. Linux exposes the state in /proc; elsewhere we fall back
20
+ // to kill(pid, 0) alone.
21
+ function isZombie(pid) {
22
+ let stat;
23
+ try {
24
+ stat = readFileSync(`/proc/${pid}/stat`, "utf8");
25
+ }
26
+ catch {
27
+ return false;
28
+ }
29
+ const state = stat.slice(stat.lastIndexOf(")") + 2, stat.lastIndexOf(")") + 3);
30
+ return state === "Z" || state === "X";
31
+ }
32
+ export function signalGroup(pgid, signal) {
33
+ if (!Number.isInteger(pgid) || pgid <= 0)
34
+ return false;
35
+ try {
36
+ process.kill(-pgid, signal);
37
+ return true;
38
+ }
39
+ catch {
40
+ return false;
41
+ }
42
+ }
43
+ export function signalPid(pid, signal) {
44
+ if (!Number.isInteger(pid) || pid <= 0)
45
+ return false;
46
+ try {
47
+ process.kill(pid, signal);
48
+ return true;
49
+ }
50
+ catch {
51
+ return false;
52
+ }
53
+ }
54
+ export function sleep(ms) {
55
+ return new Promise((resolve) => setTimeout(resolve, ms));
56
+ }