firstlight 0.0.0-stage → 0.3.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.
- package/CHANGELOG.md +58 -0
- package/LICENSE +21 -0
- package/README.md +455 -2
- package/dist/baseline.js +86 -0
- package/dist/cache.js +54 -0
- package/dist/checks.js +125 -0
- package/dist/cli.js +519 -0
- package/dist/config.js +405 -0
- package/dist/diagnostics.js +482 -0
- package/dist/errors.js +35 -0
- package/dist/eta.js +76 -0
- package/dist/names.js +67 -0
- package/dist/processes.js +56 -0
- package/dist/report.js +299 -0
- package/dist/store.js +369 -0
- package/dist/supervisor.js +188 -0
- package/dist/tree.js +60 -0
- package/package.json +60 -4
package/dist/baseline.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Which failures of a run are new: the earlier result each check is
|
|
2
|
+
// compared with, and the comparison of the two. It only computes; reading
|
|
3
|
+
// the earlier runs is the store's, and parsing and printing their logs is
|
|
4
|
+
// the report's. No I/O.
|
|
5
|
+
//
|
|
6
|
+
// A diagnostic is identified by its file, rule, and message, without its
|
|
7
|
+
// line and column, so code that only moved does not count as a new
|
|
8
|
+
// failure. The cost is that a second copy of a known error in the same
|
|
9
|
+
// file, with the same rule and message, is not new either.
|
|
10
|
+
export function isFailure(status) {
|
|
11
|
+
return status === "failed" || status === "timed_out";
|
|
12
|
+
}
|
|
13
|
+
// The baseline of a check is the newest earlier kept run in which a check
|
|
14
|
+
// with the same name and the same `run` command passed, failed, or timed
|
|
15
|
+
// out. Per check rather than one run for all, because `run --failed` and
|
|
16
|
+
// `run <name>` leave runs that hold only some of the checks. A cancelled,
|
|
17
|
+
// skipped, or unfinished result proved nothing either way, so it is passed
|
|
18
|
+
// over; a changed command is a different check that shares the name.
|
|
19
|
+
export function findBaselines(specs, newestFirst) {
|
|
20
|
+
const baselines = new Map();
|
|
21
|
+
for (const spec of specs) {
|
|
22
|
+
for (const past of newestFirst) {
|
|
23
|
+
const pastSpec = past.checks.find((check) => check.name === spec.name);
|
|
24
|
+
if (pastSpec === undefined || pastSpec.run !== spec.run)
|
|
25
|
+
continue;
|
|
26
|
+
const status = past.states.find((check) => check.name === spec.name)?.status;
|
|
27
|
+
if (status === "passed" || status === "failed" || status === "timed_out") {
|
|
28
|
+
baselines.set(spec.name, { run: past.id, status });
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return baselines;
|
|
34
|
+
}
|
|
35
|
+
export function diagnosticKey(diagnostic) {
|
|
36
|
+
return JSON.stringify([diagnostic.file, diagnostic.rule, diagnostic.message]);
|
|
37
|
+
}
|
|
38
|
+
// One entry per failed check of the latest run, in its order. A failure
|
|
39
|
+
// with diagnostics is compared diagnostic by diagnostic; a failure without
|
|
40
|
+
// them is new when its baseline did not fail. A check without a baseline
|
|
41
|
+
// has only new failures.
|
|
42
|
+
export function compareFailures(latest, baselines) {
|
|
43
|
+
const comparisons = [];
|
|
44
|
+
for (const check of latest) {
|
|
45
|
+
if (!check.failed)
|
|
46
|
+
continue;
|
|
47
|
+
const baseline = baselines.get(check.name);
|
|
48
|
+
if (check.diagnostics.length === 0) {
|
|
49
|
+
comparisons.push({ name: check.name, isNew: baseline?.failed !== true, newDiagnostics: [], oldDiagnostics: 0 });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const known = new Set(baseline?.failed === true ? baseline.diagnostics.map(diagnosticKey) : []);
|
|
53
|
+
const newDiagnostics = check.diagnostics.filter((diagnostic) => !known.has(diagnosticKey(diagnostic)));
|
|
54
|
+
comparisons.push({
|
|
55
|
+
name: check.name,
|
|
56
|
+
isNew: newDiagnostics.length > 0,
|
|
57
|
+
newDiagnostics,
|
|
58
|
+
oldDiagnostics: check.diagnostics.length - newDiagnostics.length,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return comparisons;
|
|
62
|
+
}
|
|
63
|
+
// A failure is one diagnostic, or one failed check that has none.
|
|
64
|
+
export function countFailures(comparisons) {
|
|
65
|
+
let fresh = 0;
|
|
66
|
+
let old = 0;
|
|
67
|
+
for (const comparison of comparisons) {
|
|
68
|
+
if (comparison.newDiagnostics.length === 0 && comparison.oldDiagnostics === 0) {
|
|
69
|
+
if (comparison.isNew)
|
|
70
|
+
fresh += 1;
|
|
71
|
+
else
|
|
72
|
+
old += 1;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
fresh += comparison.newDiagnostics.length;
|
|
76
|
+
old += comparison.oldDiagnostics;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { new: fresh, old };
|
|
80
|
+
}
|
|
81
|
+
// A check that proved nothing yet: still to finish, stopped, or left
|
|
82
|
+
// without a result by its supervisor. A skipped check is not one of these:
|
|
83
|
+
// it is judged through the `after` check whose failure skipped it.
|
|
84
|
+
export function isUnproven(status) {
|
|
85
|
+
return status === "pending" || status === "running" || status === "cancelled" || status === "unknown";
|
|
86
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Cache keys for checks and the lookup of a pass that an earlier run
|
|
2
|
+
// already proved. A key names exactly what a pass depends on as far as
|
|
3
|
+
// firstlight can see: the working tree, the check's command, and the keys
|
|
4
|
+
// of its `after` checks. Computing the tree key is tree.ts's, and reading
|
|
5
|
+
// earlier runs is the store's. No I/O.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
// Bumped when the inputs of a key change meaning, so an old key can never
|
|
8
|
+
// match a new one by accident.
|
|
9
|
+
const KEY_VERSION = "firstlight-cache-1";
|
|
10
|
+
// The check's name is left out: two names for one command on one tree
|
|
11
|
+
// prove the same thing. timeoutSec is left out too: a pass that finished
|
|
12
|
+
// in time is a pass under any limit that let it finish. Environment
|
|
13
|
+
// variables are left out because firstlight cannot know which ones a
|
|
14
|
+
// command reads.
|
|
15
|
+
export function cacheKey(tree, run, depKeys) {
|
|
16
|
+
const deps = [...depKeys].sort();
|
|
17
|
+
return createHash("sha256").update(JSON.stringify([KEY_VERSION, tree, run, deps])).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
// The after graph is acyclic once config.ts accepted it, so the recursion
|
|
20
|
+
// ends. A dependency outside `specs` cannot come from config.ts or
|
|
21
|
+
// resolveRun; it still gets a key of its own rather than none.
|
|
22
|
+
export function cacheKeys(specs, tree) {
|
|
23
|
+
const byName = new Map(specs.map((spec) => [spec.name, spec]));
|
|
24
|
+
const keys = new Map();
|
|
25
|
+
const keyOf = (name) => {
|
|
26
|
+
const known = keys.get(name);
|
|
27
|
+
if (known !== undefined)
|
|
28
|
+
return known;
|
|
29
|
+
const spec = byName.get(name);
|
|
30
|
+
const key = spec === undefined ? cacheKey(tree, `missing check ${name}`, []) : cacheKey(tree, spec.run, spec.after.map(keyOf));
|
|
31
|
+
keys.set(name, key);
|
|
32
|
+
return key;
|
|
33
|
+
};
|
|
34
|
+
for (const spec of specs)
|
|
35
|
+
keyOf(spec.name);
|
|
36
|
+
return new Map(specs.map((spec) => [spec.name, keys.get(spec.name)]));
|
|
37
|
+
}
|
|
38
|
+
// Only a pass is reused: a failure may be flaky, and a timed-out, cancelled,
|
|
39
|
+
// skipped, or unknown check proved nothing. A pass that was itself reused
|
|
40
|
+
// points at the run that really ran the check, so a chain of unchanged runs
|
|
41
|
+
// keeps one origin and its real duration.
|
|
42
|
+
export function findReuse(keys, newestFirst) {
|
|
43
|
+
const found = new Map();
|
|
44
|
+
for (const [name, key] of keys) {
|
|
45
|
+
for (const past of newestFirst) {
|
|
46
|
+
const state = past.states.find((candidate) => past.keys[candidate.name] === key && candidate.status === "passed");
|
|
47
|
+
if (state === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
found.set(name, { run: state.cached_from ?? past.id, elapsed_ms: state.elapsed_ms ?? 0 });
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|
package/dist/checks.js
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// The check status state machine and the event vocabulary built on it.
|
|
2
|
+
// The supervisor advances a check only through `advance`, and readers fold
|
|
3
|
+
// events.jsonl through the same transition table, so the file can never
|
|
4
|
+
// describe a path the machine does not allow. This module does no I/O.
|
|
5
|
+
//
|
|
6
|
+
// A result reused from an earlier run is the one step outside the table:
|
|
7
|
+
// `passed` straight from `pending`, legal only on an event that names the
|
|
8
|
+
// run it came from. Widening the table instead would let any check pass
|
|
9
|
+
// without ever running.
|
|
10
|
+
const TRANSITIONS = {
|
|
11
|
+
pending: ["running", "skipped", "cancelled"],
|
|
12
|
+
running: ["passed", "failed", "timed_out", "cancelled"],
|
|
13
|
+
passed: [],
|
|
14
|
+
failed: [],
|
|
15
|
+
timed_out: [],
|
|
16
|
+
cancelled: [],
|
|
17
|
+
skipped: [],
|
|
18
|
+
};
|
|
19
|
+
export const CHECK_STATUSES = Object.keys(TRANSITIONS);
|
|
20
|
+
export function isTerminal(status) {
|
|
21
|
+
return TRANSITIONS[status].length === 0;
|
|
22
|
+
}
|
|
23
|
+
export function canAdvance(from, to) {
|
|
24
|
+
return TRANSITIONS[from].includes(to);
|
|
25
|
+
}
|
|
26
|
+
export function canApply(from, event) {
|
|
27
|
+
if (event.cached_from !== undefined)
|
|
28
|
+
return from === "pending" && event.status === "passed";
|
|
29
|
+
return canAdvance(from, event.status);
|
|
30
|
+
}
|
|
31
|
+
export function initialState(name) {
|
|
32
|
+
return { name, status: "pending", pid: null, exit_code: null, started_at: null, elapsed_ms: null, cached_from: null };
|
|
33
|
+
}
|
|
34
|
+
export class IllegalTransition extends Error {
|
|
35
|
+
constructor(check, from, to) {
|
|
36
|
+
super(`check ${check} cannot go from ${from} to ${to}`);
|
|
37
|
+
this.name = "IllegalTransition";
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
export function advance(state, event) {
|
|
41
|
+
if (!canApply(state.status, event)) {
|
|
42
|
+
throw new IllegalTransition(state.name, state.status, event.status);
|
|
43
|
+
}
|
|
44
|
+
return {
|
|
45
|
+
name: state.name,
|
|
46
|
+
status: event.status,
|
|
47
|
+
pid: event.pid ?? state.pid,
|
|
48
|
+
exit_code: event.exit_code !== undefined ? event.exit_code : state.exit_code,
|
|
49
|
+
started_at: event.status === "running" ? event.at : state.started_at,
|
|
50
|
+
elapsed_ms: event.elapsed_ms ?? state.elapsed_ms,
|
|
51
|
+
cached_from: event.cached_from ?? state.cached_from,
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
export function foldEvents(names, events) {
|
|
55
|
+
const states = new Map(names.map((name) => [name, initialState(name)]));
|
|
56
|
+
let outcome = null;
|
|
57
|
+
for (const event of events) {
|
|
58
|
+
if (event.type === "run") {
|
|
59
|
+
outcome = event.status;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const current = states.get(event.check);
|
|
63
|
+
if (current === undefined) {
|
|
64
|
+
throw new IllegalTransition(event.check, "pending", event.status);
|
|
65
|
+
}
|
|
66
|
+
states.set(event.check, advance(current, event));
|
|
67
|
+
}
|
|
68
|
+
return { checks: names.map((name) => states.get(name)), outcome };
|
|
69
|
+
}
|
|
70
|
+
// The run as it stood right after each check event, one step per event, so
|
|
71
|
+
// a reader that arrives late still sees every status change in order. The
|
|
72
|
+
// last step of each check agrees with foldEvents.
|
|
73
|
+
export function replay(names, events) {
|
|
74
|
+
const states = new Map(names.map((name) => [name, initialState(name)]));
|
|
75
|
+
const steps = [];
|
|
76
|
+
for (const event of events) {
|
|
77
|
+
if (event.type === "run")
|
|
78
|
+
continue;
|
|
79
|
+
const current = states.get(event.check);
|
|
80
|
+
if (current === undefined) {
|
|
81
|
+
throw new IllegalTransition(event.check, "pending", event.status);
|
|
82
|
+
}
|
|
83
|
+
states.set(event.check, advance(current, event));
|
|
84
|
+
steps.push({ event, checks: names.map((name) => states.get(name)) });
|
|
85
|
+
}
|
|
86
|
+
return steps;
|
|
87
|
+
}
|
|
88
|
+
// Supervisor liveness is passed in rather than probed here, so the rule
|
|
89
|
+
// stays a pure function: a non-terminal check of a run whose supervisor is
|
|
90
|
+
// gone and that never wrote its final event has an unknown result.
|
|
91
|
+
export function presentChecks(run, supervisorAlive) {
|
|
92
|
+
const orphaned = run.outcome === null && !supervisorAlive;
|
|
93
|
+
return run.checks.map((state) => {
|
|
94
|
+
const orphan = orphaned && !isTerminal(state.status);
|
|
95
|
+
return {
|
|
96
|
+
name: state.name,
|
|
97
|
+
status: orphan ? "unknown" : state.status,
|
|
98
|
+
exit_code: state.exit_code,
|
|
99
|
+
elapsed_ms: state.elapsed_ms,
|
|
100
|
+
started_at: state.started_at,
|
|
101
|
+
pid: state.pid,
|
|
102
|
+
orphan,
|
|
103
|
+
cached_from: state.cached_from,
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
export function bucketOf(status) {
|
|
108
|
+
switch (status) {
|
|
109
|
+
case "passed":
|
|
110
|
+
return "passed";
|
|
111
|
+
case "pending":
|
|
112
|
+
case "running":
|
|
113
|
+
return "running";
|
|
114
|
+
case "cancelled":
|
|
115
|
+
return "cancelled";
|
|
116
|
+
case "failed":
|
|
117
|
+
case "timed_out":
|
|
118
|
+
case "skipped":
|
|
119
|
+
case "unknown":
|
|
120
|
+
return "failed";
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
export function isSettled(view) {
|
|
124
|
+
return view.status === "unknown" || isTerminal(view.status);
|
|
125
|
+
}
|