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.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +386 -2
- package/dist/baseline.js +86 -0
- package/dist/cache.js +54 -0
- package/dist/checks.js +125 -0
- package/dist/cli.js +452 -0
- package/dist/config.js +327 -0
- package/dist/diagnostics.js +459 -0
- package/dist/errors.js +25 -0
- package/dist/eta.js +76 -0
- package/dist/names.js +67 -0
- package/dist/processes.js +56 -0
- package/dist/report.js +288 -0
- package/dist/store.js +360 -0
- package/dist/supervisor.js +188 -0
- package/dist/tree.js +60 -0
- package/package.json +58 -4
package/dist/config.js
ADDED
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
// Which checks a repository has: firstlight.json, or the package.json
|
|
2
|
+
// fallback, validated into CheckSpec values before anything runs. Also owns
|
|
3
|
+
// the `after` graph (unknown names, cycles, the transitive selection) and
|
|
4
|
+
// what each argument of `firstlight run <arg>...` stands for: a configured
|
|
5
|
+
// check, a package.json script or glob, or a shell command, and which
|
|
6
|
+
// checks `run --failed` runs again. It reads files and never starts a
|
|
7
|
+
// process.
|
|
8
|
+
import { readFileSync } from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { inferParser, PARSER_CHOICES } from "./diagnostics.js";
|
|
11
|
+
import { AgentError, messageOf } from "./errors.js";
|
|
12
|
+
import { expandPattern, isPattern, shellQuote } from "./names.js";
|
|
13
|
+
export const CONFIG_FILE = "firstlight.json";
|
|
14
|
+
export const DEFAULT_TIMEOUT_SEC = 600;
|
|
15
|
+
export const FALLBACK_SCRIPTS = ["lint", "typecheck", "test", "archstrict"];
|
|
16
|
+
// A configured name is one plain word, never whitespace or "*", so an
|
|
17
|
+
// argument of `firstlight run` that names it cannot also read as a shell
|
|
18
|
+
// command or a script glob.
|
|
19
|
+
const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/;
|
|
20
|
+
const CHECK_KEYS = new Set(["run", "after", "timeoutSec", "parser"]);
|
|
21
|
+
export function loadConfig(root) {
|
|
22
|
+
const configPath = path.join(root, CONFIG_FILE);
|
|
23
|
+
const text = readOptional(configPath);
|
|
24
|
+
if (text !== null) {
|
|
25
|
+
return { source: "firstlight.json", checks: parseConfig(text, configPath) };
|
|
26
|
+
}
|
|
27
|
+
const scripts = readScripts(path.join(root, "package.json")) ?? [];
|
|
28
|
+
const checks = FALLBACK_SCRIPTS.filter((name) => scripts.includes(name)).map(scriptCheck);
|
|
29
|
+
if (checks.length > 0)
|
|
30
|
+
return { source: "package.json", checks };
|
|
31
|
+
throw new AgentError(`no checks found in ${root}: there is no ${CONFIG_FILE} and package.json has no ${FALLBACK_SCRIPTS.join(", ")} script`, `create ${configPath} like {"checks": {"test": {"run": "npm test"}}}, then run \`firstlight run\``);
|
|
32
|
+
}
|
|
33
|
+
export function parseConfig(text, file) {
|
|
34
|
+
let raw;
|
|
35
|
+
try {
|
|
36
|
+
raw = JSON.parse(text);
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
throw invalid(file, `it is not valid JSON (${messageOf(err)})`);
|
|
40
|
+
}
|
|
41
|
+
if (!isRecord(raw))
|
|
42
|
+
throw invalid(file, "the top level must be an object");
|
|
43
|
+
for (const key of Object.keys(raw)) {
|
|
44
|
+
if (key !== "checks")
|
|
45
|
+
throw invalid(file, `unknown top-level key "${key}"; only "checks" is allowed`);
|
|
46
|
+
}
|
|
47
|
+
if (!isRecord(raw.checks) || Object.keys(raw.checks).length === 0) {
|
|
48
|
+
throw invalid(file, `"checks" must be an object with at least one check`);
|
|
49
|
+
}
|
|
50
|
+
const checks = [];
|
|
51
|
+
for (const [name, value] of Object.entries(raw.checks)) {
|
|
52
|
+
checks.push(parseCheck(name, value, file));
|
|
53
|
+
}
|
|
54
|
+
validateGraph(checks, file);
|
|
55
|
+
return checks;
|
|
56
|
+
}
|
|
57
|
+
function parseCheck(name, value, file) {
|
|
58
|
+
if (!NAME_RE.test(name)) {
|
|
59
|
+
throw invalid(file, `check name "${name}" must match ${NAME_RE.source}`);
|
|
60
|
+
}
|
|
61
|
+
if (!isRecord(value))
|
|
62
|
+
throw invalid(file, `check "${name}" must be an object`);
|
|
63
|
+
for (const key of Object.keys(value)) {
|
|
64
|
+
if (!CHECK_KEYS.has(key)) {
|
|
65
|
+
throw invalid(file, `check "${name}" has unknown key "${key}"; allowed keys are run, after, timeoutSec, parser`);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (typeof value.run !== "string" || value.run.trim().length === 0) {
|
|
69
|
+
throw invalid(file, `check "${name}" needs "run", a non-empty shell command string`);
|
|
70
|
+
}
|
|
71
|
+
let after = [];
|
|
72
|
+
if (value.after !== undefined) {
|
|
73
|
+
if (!Array.isArray(value.after) || !value.after.every((dep) => typeof dep === "string")) {
|
|
74
|
+
throw invalid(file, `check "${name}" has "after" that is not an array of check names`);
|
|
75
|
+
}
|
|
76
|
+
after = [...new Set(value.after)];
|
|
77
|
+
}
|
|
78
|
+
let timeoutSec = DEFAULT_TIMEOUT_SEC;
|
|
79
|
+
if (value.timeoutSec !== undefined) {
|
|
80
|
+
if (typeof value.timeoutSec !== "number" || !Number.isFinite(value.timeoutSec) || value.timeoutSec <= 0) {
|
|
81
|
+
throw invalid(file, `check "${name}" has "timeoutSec" that is not a positive number`);
|
|
82
|
+
}
|
|
83
|
+
timeoutSec = value.timeoutSec;
|
|
84
|
+
}
|
|
85
|
+
if (value.parser === undefined)
|
|
86
|
+
return { name, run: value.run, after, timeoutSec };
|
|
87
|
+
if (typeof value.parser !== "string" || !PARSER_CHOICES.includes(value.parser)) {
|
|
88
|
+
throw invalid(file, `check "${name}" has "parser" ${JSON.stringify(value.parser)}; allowed values are ${PARSER_CHOICES.join(", ")}`);
|
|
89
|
+
}
|
|
90
|
+
return { name, run: value.run, after, timeoutSec, parser: value.parser };
|
|
91
|
+
}
|
|
92
|
+
function validateGraph(checks, file) {
|
|
93
|
+
const byName = new Map(checks.map((check) => [check.name, check]));
|
|
94
|
+
for (const check of checks) {
|
|
95
|
+
for (const dep of check.after) {
|
|
96
|
+
if (!byName.has(dep)) {
|
|
97
|
+
throw invalid(file, `check "${check.name}" is after unknown check "${dep}"`);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
const cycle = findCycle(checks, byName);
|
|
102
|
+
if (cycle !== null) {
|
|
103
|
+
throw invalid(file, `"after" has a cycle: ${cycle.join(" -> ")}`);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
function findCycle(checks, byName) {
|
|
107
|
+
const done = new Set();
|
|
108
|
+
const stack = [];
|
|
109
|
+
const onStack = new Set();
|
|
110
|
+
const visit = (name) => {
|
|
111
|
+
if (onStack.has(name))
|
|
112
|
+
return [...stack.slice(stack.indexOf(name)), name];
|
|
113
|
+
if (done.has(name))
|
|
114
|
+
return null;
|
|
115
|
+
stack.push(name);
|
|
116
|
+
onStack.add(name);
|
|
117
|
+
for (const dep of byName.get(name).after) {
|
|
118
|
+
const cycle = visit(dep);
|
|
119
|
+
if (cycle !== null)
|
|
120
|
+
return cycle;
|
|
121
|
+
}
|
|
122
|
+
stack.pop();
|
|
123
|
+
onStack.delete(name);
|
|
124
|
+
done.add(name);
|
|
125
|
+
return null;
|
|
126
|
+
};
|
|
127
|
+
for (const check of checks) {
|
|
128
|
+
const cycle = visit(check.name);
|
|
129
|
+
if (cycle !== null)
|
|
130
|
+
return cycle;
|
|
131
|
+
}
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
// Keeps config order, so output order follows the file the user wrote.
|
|
135
|
+
export function selectChecks(checks, requested) {
|
|
136
|
+
if (requested.length === 0)
|
|
137
|
+
return checks;
|
|
138
|
+
const byName = new Map(checks.map((check) => [check.name, check]));
|
|
139
|
+
const unknown = requested.filter((name) => !byName.has(name));
|
|
140
|
+
if (unknown.length > 0) {
|
|
141
|
+
throw new AgentError(`unknown check: ${unknown.join(", ")}`, `run \`firstlight run\` with no names, or pick from: ${checks.map((check) => check.name).join(", ")}`);
|
|
142
|
+
}
|
|
143
|
+
const wanted = new Set();
|
|
144
|
+
const add = (name) => {
|
|
145
|
+
if (wanted.has(name))
|
|
146
|
+
return;
|
|
147
|
+
wanted.add(name);
|
|
148
|
+
for (const dep of byName.get(name).after)
|
|
149
|
+
add(dep);
|
|
150
|
+
};
|
|
151
|
+
for (const name of requested)
|
|
152
|
+
add(name);
|
|
153
|
+
return checks.filter((check) => wanted.has(check.name));
|
|
154
|
+
}
|
|
155
|
+
// A word of `firstlight run` is a configured check first, then a
|
|
156
|
+
// package.json script or glob. An argument with whitespace, or any argument
|
|
157
|
+
// after `--`, is a shell command named by itself. Arguments keep their
|
|
158
|
+
// order, and a configured check's `after` dependencies come just before it.
|
|
159
|
+
export function resolveRun(root, args, commands) {
|
|
160
|
+
const configured = configuredChecks(root);
|
|
161
|
+
const byName = new Map(configured.map((check) => [check.name, check]));
|
|
162
|
+
let scripts;
|
|
163
|
+
const scriptNames = () => {
|
|
164
|
+
if (scripts === undefined)
|
|
165
|
+
scripts = readScripts(path.join(root, "package.json"));
|
|
166
|
+
return scripts;
|
|
167
|
+
};
|
|
168
|
+
const picked = [];
|
|
169
|
+
const add = (spec) => {
|
|
170
|
+
const same = picked.find((check) => check.name === spec.name);
|
|
171
|
+
if (same === undefined) {
|
|
172
|
+
picked.push(spec);
|
|
173
|
+
}
|
|
174
|
+
else if (same.run !== spec.run) {
|
|
175
|
+
throw new AgentError(`two checks are named "${spec.name}": one runs \`${same.run}\` and one runs \`${spec.run}\``, "drop one of the two arguments, or rename the check in firstlight.json");
|
|
176
|
+
}
|
|
177
|
+
};
|
|
178
|
+
const addCommand = (command) => {
|
|
179
|
+
if (command.trim().length === 0) {
|
|
180
|
+
throw new AgentError("an empty command was given to `firstlight run`", "drop the empty argument");
|
|
181
|
+
}
|
|
182
|
+
add(commandCheck(command));
|
|
183
|
+
};
|
|
184
|
+
const unknown = [];
|
|
185
|
+
for (const arg of args) {
|
|
186
|
+
if (/\s/u.test(arg)) {
|
|
187
|
+
addCommand(arg);
|
|
188
|
+
}
|
|
189
|
+
else if (byName.has(arg)) {
|
|
190
|
+
for (const check of selectChecks(configured, [arg]))
|
|
191
|
+
add(check);
|
|
192
|
+
}
|
|
193
|
+
else if (isPattern(arg)) {
|
|
194
|
+
const available = scriptNames();
|
|
195
|
+
const matched = expandPattern(arg, available ?? []);
|
|
196
|
+
if (matched.length === 0) {
|
|
197
|
+
const why = available === null ? "there is no package.json" : `the scripts are: ${available.join(", ") || "(none)"}`;
|
|
198
|
+
throw new AgentError(`no package.json script matches "${arg}"; ${why}`, available === null || available.length === 0
|
|
199
|
+
? `quote a shell command instead, like \`firstlight run "make test"\``
|
|
200
|
+
: `pick a pattern that matches one of: ${available.join(", ")}`);
|
|
201
|
+
}
|
|
202
|
+
for (const name of matched)
|
|
203
|
+
add(byName.get(name) ?? scriptCheck(name));
|
|
204
|
+
}
|
|
205
|
+
else if (scriptNames()?.includes(arg)) {
|
|
206
|
+
add(scriptCheck(arg));
|
|
207
|
+
}
|
|
208
|
+
else {
|
|
209
|
+
unknown.push(arg);
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
for (const command of commands)
|
|
213
|
+
addCommand(command);
|
|
214
|
+
if (unknown.length > 0) {
|
|
215
|
+
const available = scriptNames();
|
|
216
|
+
const choices = [
|
|
217
|
+
`configured checks: ${configured.length === 0 ? "(none)" : configured.map((check) => check.name).join(", ")}`,
|
|
218
|
+
available === null ? "no package.json" : `package.json scripts: ${available.length === 0 ? "(none)" : available.join(", ")}`,
|
|
219
|
+
];
|
|
220
|
+
throw new AgentError(`unknown check or script: ${unknown.join(", ")} (${choices.join("; ")})`, `pick a check or script from that list, or run a shell command by quoting it, like \`firstlight run "make test"\`, or after --, like \`firstlight run -- ${shellQuote(unknown[0])}\``);
|
|
221
|
+
}
|
|
222
|
+
return picked;
|
|
223
|
+
}
|
|
224
|
+
// `firstlight run --failed`: every check of the latest run that did not
|
|
225
|
+
// pass, resolved against the current firstlight.json the way `run <name>`
|
|
226
|
+
// resolves a word. A configured check comes with its `after` checks, which
|
|
227
|
+
// the cache then reuses where it can. A script or shell command given on
|
|
228
|
+
// the command line has no entry in firstlight.json, so it runs the command
|
|
229
|
+
// it ran before. A configured check that has since left firstlight.json is
|
|
230
|
+
// dropped rather than run from a stale record.
|
|
231
|
+
export function selectFailed(configured, previous) {
|
|
232
|
+
const byName = new Map(configured.map((check) => [check.name, check]));
|
|
233
|
+
const picked = [];
|
|
234
|
+
const dropped = [];
|
|
235
|
+
const add = (spec) => {
|
|
236
|
+
if (!picked.some((check) => check.name === spec.name))
|
|
237
|
+
picked.push(spec);
|
|
238
|
+
};
|
|
239
|
+
for (const { spec, status } of previous) {
|
|
240
|
+
if (status === "passed")
|
|
241
|
+
continue;
|
|
242
|
+
if (byName.has(spec.name)) {
|
|
243
|
+
for (const check of selectChecks(configured, [spec.name]))
|
|
244
|
+
add(check);
|
|
245
|
+
}
|
|
246
|
+
else if (spec.after.length === 0 && (spec.run === scriptCheck(spec.name).run || spec.run === spec.name)) {
|
|
247
|
+
add(spec);
|
|
248
|
+
}
|
|
249
|
+
else {
|
|
250
|
+
dropped.push(spec.name);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
return { checks: picked, dropped };
|
|
254
|
+
}
|
|
255
|
+
// The checks of firstlight.json alone, without the package.json fallback:
|
|
256
|
+
// what a word given to `run` can name as a configured check.
|
|
257
|
+
export function configuredChecks(root) {
|
|
258
|
+
const configPath = path.join(root, CONFIG_FILE);
|
|
259
|
+
const configText = readOptional(configPath);
|
|
260
|
+
return configText === null ? [] : parseConfig(configText, configPath);
|
|
261
|
+
}
|
|
262
|
+
export function scriptCheck(script) {
|
|
263
|
+
return { name: script, run: `npm run ${shellQuote(script)}`, after: [], timeoutSec: DEFAULT_TIMEOUT_SEC };
|
|
264
|
+
}
|
|
265
|
+
export function commandCheck(command) {
|
|
266
|
+
return { name: command, run: command, after: [], timeoutSec: DEFAULT_TIMEOUT_SEC };
|
|
267
|
+
}
|
|
268
|
+
// The parser each check's log is read with, decided once when the run
|
|
269
|
+
// starts so `results` and `follow` read the log the same way later. A
|
|
270
|
+
// "parser" in firstlight.json wins, and "none" turns inference off.
|
|
271
|
+
export function withParsers(root, checks) {
|
|
272
|
+
let scripts;
|
|
273
|
+
const scriptOf = (name) => {
|
|
274
|
+
if (scripts === undefined)
|
|
275
|
+
scripts = readScriptCommands(path.join(root, "package.json"));
|
|
276
|
+
return scripts?.[name] ?? null;
|
|
277
|
+
};
|
|
278
|
+
return checks.map((check) => {
|
|
279
|
+
if (check.parser === "none")
|
|
280
|
+
return { ...check, parser: null };
|
|
281
|
+
// A check `run --failed` carries over from the previous run keeps the
|
|
282
|
+
// parser that run decided, null included.
|
|
283
|
+
if (check.parser !== undefined)
|
|
284
|
+
return check;
|
|
285
|
+
return { ...check, parser: inferParser(check.run, scriptOf) };
|
|
286
|
+
});
|
|
287
|
+
}
|
|
288
|
+
// Script names in package.json order, or null when there is no package.json.
|
|
289
|
+
function readScripts(file) {
|
|
290
|
+
const scripts = readScriptCommands(file);
|
|
291
|
+
return scripts === null ? null : Object.keys(scripts);
|
|
292
|
+
}
|
|
293
|
+
function readScriptCommands(file) {
|
|
294
|
+
const text = readOptional(file);
|
|
295
|
+
if (text === null)
|
|
296
|
+
return null;
|
|
297
|
+
let raw;
|
|
298
|
+
try {
|
|
299
|
+
raw = JSON.parse(text);
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
throw new AgentError(`${file} is not valid JSON (${messageOf(err)})`, `fix ${file}, then rerun the command`);
|
|
303
|
+
}
|
|
304
|
+
const scripts = isRecord(raw) && isRecord(raw.scripts) ? raw.scripts : {};
|
|
305
|
+
const commands = {};
|
|
306
|
+
for (const [name, command] of Object.entries(scripts)) {
|
|
307
|
+
if (typeof command === "string")
|
|
308
|
+
commands[name] = command;
|
|
309
|
+
}
|
|
310
|
+
return commands;
|
|
311
|
+
}
|
|
312
|
+
function invalid(file, reason) {
|
|
313
|
+
return new AgentError(`invalid ${path.basename(file)}: ${reason}`, `fix ${file}, then rerun the command`);
|
|
314
|
+
}
|
|
315
|
+
function readOptional(file) {
|
|
316
|
+
try {
|
|
317
|
+
return readFileSync(file, "utf8");
|
|
318
|
+
}
|
|
319
|
+
catch (err) {
|
|
320
|
+
if (err.code === "ENOENT")
|
|
321
|
+
return null;
|
|
322
|
+
throw new AgentError(`could not read ${file}: ${messageOf(err)}`, `check permissions on ${file}`);
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
function isRecord(value) {
|
|
326
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
327
|
+
}
|