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/dist/config.js ADDED
@@ -0,0 +1,405 @@
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, isParserChoice, PARSER_CHOICES, PARSERS } 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
+ // Script names that look like a check by their first ":" segment. The
17
+ // fallback does not run them: a name like test:mutation or check can take
18
+ // hours or chain the others, so a guess would duplicate or stall work. They
19
+ // are only named, so the reader can pick.
20
+ const CHECK_LIKE = new Set(["lint", "typecheck", "test", "check", "archstrict"]);
21
+ // A configured name is one plain word, never whitespace or "*", so an
22
+ // argument of `firstlight run` that names it cannot also read as a shell
23
+ // command or a script glob. ":" is allowed so a check can keep the name of
24
+ // the script it runs, like check:architecture.
25
+ const NAME_RE = /^[A-Za-z0-9][A-Za-z0-9._:-]*$/;
26
+ const CHECK_KEYS = new Set(["run", "after", "timeoutSec", "parser"]);
27
+ export function loadConfig(root) {
28
+ const configPath = path.join(root, CONFIG_FILE);
29
+ const text = readOptional(configPath);
30
+ if (text !== null) {
31
+ return { source: "firstlight.json", checks: parseConfig(text, configPath), notRun: [] };
32
+ }
33
+ const scripts = readScripts(path.join(root, "package.json")) ?? [];
34
+ const checks = FALLBACK_SCRIPTS.filter((name) => scripts.includes(name)).map(scriptCheck);
35
+ const notRun = checkLikeScripts(scripts);
36
+ if (checks.length > 0)
37
+ return { source: "package.json", checks, notRun };
38
+ const named = notRun.length === 0 ? "" : ` (scripts that look like checks: ${notRun.join(", ")})`;
39
+ throw new AgentError(`no checks found in ${root}: there is no ${CONFIG_FILE} and package.json has no ${FALLBACK_SCRIPTS.join(", ")} script${named}`, notRun.length === 0
40
+ ? `create ${configPath} like {"checks": {"test": {"run": "npm test"}}}, then run \`firstlight run\``
41
+ : `run \`firstlight run ${notRun.map(shellQuote).join(" ")}\`, or write ${configPath} with \`firstlight init ${notRun.map(shellQuote).join(" ")} --write\``);
42
+ }
43
+ export function checkLikeScripts(scripts) {
44
+ const fallback = FALLBACK_SCRIPTS;
45
+ return scripts.filter((name) => !fallback.includes(name) && CHECK_LIKE.has(name.split(":")[0]));
46
+ }
47
+ export function parseConfig(text, file) {
48
+ let raw;
49
+ try {
50
+ raw = JSON.parse(text);
51
+ }
52
+ catch (err) {
53
+ throw invalid(file, `it is not valid JSON (${messageOf(err)})`);
54
+ }
55
+ if (!isRecord(raw))
56
+ throw invalid(file, "the top level must be an object");
57
+ for (const key of Object.keys(raw)) {
58
+ if (key !== "checks")
59
+ throw invalid(file, `unknown top-level key "${key}"; only "checks" is allowed`);
60
+ }
61
+ if (!isRecord(raw.checks) || Object.keys(raw.checks).length === 0) {
62
+ throw invalid(file, `"checks" must be an object with at least one check`);
63
+ }
64
+ const checks = [];
65
+ for (const [name, value] of Object.entries(raw.checks)) {
66
+ checks.push(parseCheck(name, value, file));
67
+ }
68
+ validateGraph(checks, file);
69
+ return checks;
70
+ }
71
+ function parseCheck(name, value, file) {
72
+ if (!NAME_RE.test(name)) {
73
+ throw invalid(file, `check name "${name}" must match ${NAME_RE.source}`);
74
+ }
75
+ if (!isRecord(value))
76
+ throw invalid(file, `check "${name}" must be an object`);
77
+ for (const key of Object.keys(value)) {
78
+ if (!CHECK_KEYS.has(key)) {
79
+ throw invalid(file, `check "${name}" has unknown key "${key}"; allowed keys are run, after, timeoutSec, parser`);
80
+ }
81
+ }
82
+ if (typeof value.run !== "string" || value.run.trim().length === 0) {
83
+ throw invalid(file, `check "${name}" needs "run", a non-empty shell command string`);
84
+ }
85
+ let after = [];
86
+ if (value.after !== undefined) {
87
+ if (!Array.isArray(value.after) || !value.after.every((dep) => typeof dep === "string")) {
88
+ throw invalid(file, `check "${name}" has "after" that is not an array of check names`);
89
+ }
90
+ after = [...new Set(value.after)];
91
+ }
92
+ let timeoutSec = DEFAULT_TIMEOUT_SEC;
93
+ if (value.timeoutSec !== undefined) {
94
+ if (typeof value.timeoutSec !== "number" || !Number.isFinite(value.timeoutSec) || value.timeoutSec <= 0) {
95
+ throw invalid(file, `check "${name}" has "timeoutSec" that is not a positive number`);
96
+ }
97
+ timeoutSec = value.timeoutSec;
98
+ }
99
+ if (value.parser === undefined)
100
+ return { name, run: value.run, after, timeoutSec };
101
+ if (!(value.parser === "none" || isParserChoice(value.parser))) {
102
+ throw invalid(file, `check "${name}" has "parser" ${JSON.stringify(value.parser)}; allowed values are ${PARSER_CHOICES.join(", ")}, or a list of ${PARSERS.join(", ")}`);
103
+ }
104
+ return { name, run: value.run, after, timeoutSec, parser: value.parser };
105
+ }
106
+ function validateGraph(checks, file) {
107
+ const byName = new Map(checks.map((check) => [check.name, check]));
108
+ for (const check of checks) {
109
+ for (const dep of check.after) {
110
+ if (!byName.has(dep)) {
111
+ throw invalid(file, `check "${check.name}" is after unknown check "${dep}"`);
112
+ }
113
+ }
114
+ }
115
+ const cycle = findCycle(checks, byName);
116
+ if (cycle !== null) {
117
+ throw invalid(file, `"after" has a cycle: ${cycle.join(" -> ")}`);
118
+ }
119
+ }
120
+ function findCycle(checks, byName) {
121
+ const done = new Set();
122
+ const stack = [];
123
+ const onStack = new Set();
124
+ const visit = (name) => {
125
+ if (onStack.has(name))
126
+ return [...stack.slice(stack.indexOf(name)), name];
127
+ if (done.has(name))
128
+ return null;
129
+ stack.push(name);
130
+ onStack.add(name);
131
+ for (const dep of byName.get(name).after) {
132
+ const cycle = visit(dep);
133
+ if (cycle !== null)
134
+ return cycle;
135
+ }
136
+ stack.pop();
137
+ onStack.delete(name);
138
+ done.add(name);
139
+ return null;
140
+ };
141
+ for (const check of checks) {
142
+ const cycle = visit(check.name);
143
+ if (cycle !== null)
144
+ return cycle;
145
+ }
146
+ return null;
147
+ }
148
+ // Keeps config order, so output order follows the file the user wrote.
149
+ export function selectChecks(checks, requested) {
150
+ if (requested.length === 0)
151
+ return checks;
152
+ const byName = new Map(checks.map((check) => [check.name, check]));
153
+ const unknown = requested.filter((name) => !byName.has(name));
154
+ if (unknown.length > 0) {
155
+ throw new AgentError(`unknown check: ${unknown.join(", ")}`, `run \`firstlight run\` with no names, or pick from: ${checks.map((check) => check.name).join(", ")}`);
156
+ }
157
+ const wanted = new Set();
158
+ const add = (name) => {
159
+ if (wanted.has(name))
160
+ return;
161
+ wanted.add(name);
162
+ for (const dep of byName.get(name).after)
163
+ add(dep);
164
+ };
165
+ for (const name of requested)
166
+ add(name);
167
+ return checks.filter((check) => wanted.has(check.name));
168
+ }
169
+ // A word of `firstlight run` is a configured check first, then a
170
+ // package.json script or glob. An argument with whitespace, or any argument
171
+ // after `--`, is a shell command named by itself. Arguments keep their
172
+ // order, and a configured check's `after` dependencies come just before it.
173
+ export function resolveRun(root, args, commands) {
174
+ const configured = configuredChecks(root);
175
+ const byName = new Map(configured.map((check) => [check.name, check]));
176
+ let scripts;
177
+ const scriptNames = () => {
178
+ if (scripts === undefined)
179
+ scripts = readScripts(path.join(root, "package.json"));
180
+ return scripts;
181
+ };
182
+ const picked = [];
183
+ const add = (spec) => {
184
+ const same = picked.find((check) => check.name === spec.name);
185
+ if (same === undefined) {
186
+ picked.push(spec);
187
+ }
188
+ else if (same.run !== spec.run) {
189
+ 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");
190
+ }
191
+ };
192
+ const addCommand = (command) => {
193
+ if (command.trim().length === 0) {
194
+ throw new AgentError("an empty command was given to `firstlight run`", "drop the empty argument");
195
+ }
196
+ add(commandCheck(command));
197
+ };
198
+ const unknown = [];
199
+ for (const arg of args) {
200
+ if (/\s/u.test(arg)) {
201
+ addCommand(arg);
202
+ }
203
+ else if (byName.has(arg)) {
204
+ for (const check of selectChecks(configured, [arg]))
205
+ add(check);
206
+ }
207
+ else if (isPattern(arg)) {
208
+ const available = scriptNames();
209
+ const matched = expandPattern(arg, available ?? []);
210
+ if (matched.length === 0) {
211
+ const why = available === null ? "there is no package.json" : `the scripts are: ${available.join(", ") || "(none)"}`;
212
+ throw new AgentError(`no package.json script matches "${arg}"; ${why}`, available === null || available.length === 0
213
+ ? `quote a shell command instead, like \`firstlight run "make test"\``
214
+ : `pick a pattern that matches one of: ${available.join(", ")}`);
215
+ }
216
+ for (const name of matched)
217
+ add(byName.get(name) ?? scriptCheck(name));
218
+ }
219
+ else if (scriptNames()?.includes(arg)) {
220
+ add(scriptCheck(arg));
221
+ }
222
+ else {
223
+ unknown.push(arg);
224
+ }
225
+ }
226
+ for (const command of commands)
227
+ addCommand(command);
228
+ if (unknown.length > 0) {
229
+ const available = scriptNames();
230
+ const choices = [
231
+ `configured checks: ${configured.length === 0 ? "(none)" : configured.map((check) => check.name).join(", ")}`,
232
+ available === null ? "no package.json" : `package.json scripts: ${available.length === 0 ? "(none)" : available.join(", ")}`,
233
+ ];
234
+ 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])}\``);
235
+ }
236
+ return picked;
237
+ }
238
+ // `firstlight run --failed`: every check of the latest run that did not
239
+ // pass, resolved against the current firstlight.json the way `run <name>`
240
+ // resolves a word. A configured check comes with its `after` checks, which
241
+ // the cache then reuses where it can. A script or shell command given on
242
+ // the command line has no entry in firstlight.json, so it runs the command
243
+ // it ran before. A configured check that has since left firstlight.json is
244
+ // dropped rather than run from a stale record.
245
+ export function selectFailed(configured, previous) {
246
+ const byName = new Map(configured.map((check) => [check.name, check]));
247
+ const picked = [];
248
+ const dropped = [];
249
+ const add = (spec) => {
250
+ if (!picked.some((check) => check.name === spec.name))
251
+ picked.push(spec);
252
+ };
253
+ for (const { spec, status } of previous) {
254
+ if (status === "passed")
255
+ continue;
256
+ if (byName.has(spec.name)) {
257
+ for (const check of selectChecks(configured, [spec.name]))
258
+ add(check);
259
+ }
260
+ else if (spec.after.length === 0 && (spec.run === scriptCheck(spec.name).run || spec.run === spec.name)) {
261
+ add(spec);
262
+ }
263
+ else {
264
+ dropped.push(spec.name);
265
+ }
266
+ }
267
+ return { checks: picked, dropped };
268
+ }
269
+ // What `firstlight init` would write: each picked script as the check
270
+ // `run` would make of it, so a later run shares its cache keys and
271
+ // durations with the runs before init, plus the parser inferred from the
272
+ // script. With no names, the picked scripts are the package.json fallback.
273
+ export function proposeChecks(root, args) {
274
+ const file = path.join(root, "package.json");
275
+ const commands = readScriptCommands(file);
276
+ if (commands === null) {
277
+ throw new AgentError(`there is no package.json in ${root}, and firstlight init proposes checks from its scripts`, `write ${path.join(root, CONFIG_FILE)} by hand, like {"checks": {"test": {"run": "make test"}}}`);
278
+ }
279
+ const scripts = Object.keys(commands);
280
+ const checkLike = checkLikeScripts(scripts);
281
+ let picked;
282
+ if (args.length === 0) {
283
+ picked = FALLBACK_SCRIPTS.filter((name) => scripts.includes(name));
284
+ if (picked.length === 0) {
285
+ throw new AgentError(`package.json has no ${FALLBACK_SCRIPTS.join(", ")} script, so firstlight init has nothing to propose`, checkLike.length === 0
286
+ ? `name the scripts to check, like \`firstlight init ${scripts[0] ?? "test"}\``
287
+ : `name the scripts to check, like \`firstlight init ${checkLike.map(shellQuote).join(" ")}\``);
288
+ }
289
+ }
290
+ else {
291
+ picked = [];
292
+ for (const arg of args) {
293
+ if (/\s/u.test(arg)) {
294
+ throw new AgentError(`firstlight init picks package.json scripts, and "${arg}" is a shell command`, `add it to ${CONFIG_FILE} by hand, like {"checks": {"name": {"run": ${JSON.stringify(arg)}}}}`);
295
+ }
296
+ const matched = isPattern(arg) ? expandPattern(arg, scripts) : scripts.includes(arg) ? [arg] : [];
297
+ if (matched.length === 0) {
298
+ throw new AgentError(`no package.json script ${isPattern(arg) ? "matches" : "is named"} "${arg}"; the scripts are: ${scripts.join(", ") || "(none)"}`, `pick from: ${scripts.join(", ") || "(none)"}`);
299
+ }
300
+ for (const name of matched)
301
+ if (!picked.includes(name))
302
+ picked.push(name);
303
+ }
304
+ }
305
+ const scriptOf = (name) => commands[name] ?? null;
306
+ const checks = picked.map((name) => {
307
+ const check = scriptCheck(name);
308
+ const parser = inferParser(check.run, scriptOf);
309
+ return parser === null ? check : { ...check, parser };
310
+ });
311
+ return { checks, leftOut: checkLike.filter((name) => !picked.includes(name)) };
312
+ }
313
+ // A timeout that leaves room for a check that once passed in `ms`: three
314
+ // times that, in whole minutes, or null when the default already does.
315
+ export function suggestTimeoutSec(ms) {
316
+ const seconds = Math.ceil((ms * 3) / 60_000) * 60;
317
+ return seconds > DEFAULT_TIMEOUT_SEC ? seconds : null;
318
+ }
319
+ // The firstlight.json text for `checks`, with only the keys that differ
320
+ // from the defaults, so the file reads like one written by hand.
321
+ export function configText(checks) {
322
+ const body = {};
323
+ for (const check of checks) {
324
+ const entry = { run: check.run };
325
+ if (check.after.length > 0)
326
+ entry.after = check.after;
327
+ if (check.timeoutSec !== DEFAULT_TIMEOUT_SEC)
328
+ entry.timeoutSec = check.timeoutSec;
329
+ if (check.parser !== undefined && check.parser !== null)
330
+ entry.parser = check.parser;
331
+ body[check.name] = entry;
332
+ }
333
+ return `${JSON.stringify({ checks: body }, null, 2)}\n`;
334
+ }
335
+ export function configuredChecks(root) {
336
+ const configPath = path.join(root, CONFIG_FILE);
337
+ const configText = readOptional(configPath);
338
+ return configText === null ? [] : parseConfig(configText, configPath);
339
+ }
340
+ export function scriptCheck(script) {
341
+ return { name: script, run: `npm run ${shellQuote(script)}`, after: [], timeoutSec: DEFAULT_TIMEOUT_SEC };
342
+ }
343
+ export function commandCheck(command) {
344
+ return { name: command, run: command, after: [], timeoutSec: DEFAULT_TIMEOUT_SEC };
345
+ }
346
+ // The parser each check's log is read with, decided once when the run
347
+ // starts so `results` and `follow` read the log the same way later. A
348
+ // "parser" in firstlight.json wins, and "none" turns inference off.
349
+ export function withParsers(root, checks) {
350
+ let scripts;
351
+ const scriptOf = (name) => {
352
+ if (scripts === undefined)
353
+ scripts = readScriptCommands(path.join(root, "package.json"));
354
+ return scripts?.[name] ?? null;
355
+ };
356
+ return checks.map((check) => {
357
+ if (check.parser === "none")
358
+ return { ...check, parser: null };
359
+ // A check `run --failed` carries over from the previous run keeps the
360
+ // parser that run decided, null included.
361
+ if (check.parser !== undefined)
362
+ return check;
363
+ return { ...check, parser: inferParser(check.run, scriptOf) };
364
+ });
365
+ }
366
+ // Script names in package.json order, or null when there is no package.json.
367
+ function readScripts(file) {
368
+ const scripts = readScriptCommands(file);
369
+ return scripts === null ? null : Object.keys(scripts);
370
+ }
371
+ function readScriptCommands(file) {
372
+ const text = readOptional(file);
373
+ if (text === null)
374
+ return null;
375
+ let raw;
376
+ try {
377
+ raw = JSON.parse(text);
378
+ }
379
+ catch (err) {
380
+ throw new AgentError(`${file} is not valid JSON (${messageOf(err)})`, `fix ${file}, then rerun the command`);
381
+ }
382
+ const scripts = isRecord(raw) && isRecord(raw.scripts) ? raw.scripts : {};
383
+ const commands = {};
384
+ for (const [name, command] of Object.entries(scripts)) {
385
+ if (typeof command === "string")
386
+ commands[name] = command;
387
+ }
388
+ return commands;
389
+ }
390
+ function invalid(file, reason) {
391
+ return new AgentError(`invalid ${path.basename(file)}: ${reason}`, `fix ${file}, then rerun the command`);
392
+ }
393
+ function readOptional(file) {
394
+ try {
395
+ return readFileSync(file, "utf8");
396
+ }
397
+ catch (err) {
398
+ if (err.code === "ENOENT")
399
+ return null;
400
+ throw new AgentError(`could not read ${file}: ${messageOf(err)}`, `check permissions on ${file}`);
401
+ }
402
+ }
403
+ function isRecord(value) {
404
+ return typeof value === "object" && value !== null && !Array.isArray(value);
405
+ }