firstlight 0.2.0 → 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 CHANGED
@@ -1,6 +1,21 @@
1
1
  # Changelog
2
2
 
3
- ## Unreleased
3
+ ## 0.3.0 (2026-10-16)
4
+
5
+ - A version bump that lands on `main` now publishes the package: `publish.yml` runs on a push to `main` that changes `package.json`, releases nothing when the `v<version>` tag already exists, and after `npm publish` creates that tag and the GitHub release on the published commit. Nobody pushes a release tag by hand.
6
+
7
+ - A check whose command, or whose `npm run` script, joins simple commands with `&&` gets the parser of each command that has one, in order, so `tsc --noEmit && vitest run` shows tsc's or vitest's diagnostics instead of a 40-line log tail. `parser` in `firstlight.json` and the parser recorded in `run.json` can be a list, like `["tsc", "vitest"]`, and `firstlight init` writes one for such a script. A list joined by `||` or `;`, or a pipe, still gets none.
8
+
9
+ - The README has a CPU contention section: measurements of checks run alone and together in three repositories, why firstlight has no concurrency limit, and how `after` trades a longer run for an earlier result of one heavy check.
10
+
11
+ - `firstlight init [script|pattern...]` proposes a `firstlight.json` from the `package.json` scripts and prints it; `--write` writes it, and refuses to replace an existing one without `--force`. With no names, it holds the scripts `lint`, `typecheck`, `test`, and `archstrict` that exist; names and patterns pick other scripts. Each check runs `npm run <script>`, gets the parser inferred from the script, and gets a `timeoutSec` of three times its newest pass, in whole minutes, when that is more than the 600-second default. It names the check-like scripts it left out, with the command that adds them. `--json` prints one `{"type":"init",...}` object. A shell command, an unknown script, a missing `package.json`, or a package with none of the four scripts and no names gives the error envelope.
12
+ - A check name in `firstlight.json` may contain `:`, so a check can keep its script's name, like `check:architecture`.
13
+
14
+ - Without `firstlight.json`, `firstlight run` names the `package.json` scripts it did not run whose name starts with `lint`, `typecheck`, `test`, `check`, or `archstrict` (for example `check:architecture`, `test:mutation`, or `check`), on a `not run:` line after its result, with a hint to name one or write `firstlight.json` with `firstlight init`. With `--json`, the summary gains `not_run`, only when that list is not empty. It still runs only `lint`, `typecheck`, `test`, and `archstrict`, because such a script can chain the others or take hours. When none of those four exists, the error envelope names the check-like scripts and its `do` runs them.
15
+
16
+ - Once every check of a run is done and one did not pass, the next command that `run`, `results`, `results --new`, and `follow` print is `firstlight run --failed` instead of `firstlight run`, in the text `next:` line and the `next` field of `--json`. While a check is still running, it stays `firstlight results --wait`. The `do` of an error envelope does not change, and neither does the next command after `stop`.
17
+
18
+ - On a Node older than 24, every command stops before it reads its arguments or starts a run, and prints the error envelope `{"error":"firstlight needs Node 24 or newer (found v20.19.0)","do":"install Node 24 or newer, then rerun the same command"}` with exit code 1. Before, an older Node could run firstlight on a version it was never tested on.
4
19
 
5
20
  ## 0.2.0 (2026-10-13)
6
21
 
package/README.md CHANGED
@@ -40,6 +40,8 @@ and run `firstlight run`, or `firstlight run rspec` for one of them.
40
40
 
41
41
  It returns at the first failed or timed-out check, with the last 40 lines of that check's log, the status of every other check, and the next command. When firstlight can read that tool's output, the check's [diagnostics](#diagnostics) come first and the log tail is 10 lines. If every check passes, it prints one line and exits 0.
42
42
 
43
+ While other checks are still running, the next command is `firstlight results --wait`.
44
+
43
45
  2. Fix the failure. Meanwhile, the other checks continue.
44
46
 
45
47
  3. Read the rest:
@@ -50,16 +52,16 @@ and run `firstlight run`, or `firstlight run rspec` for one of them.
50
52
 
51
53
  Or watch each check finish as it happens, with `firstlight follow`.
52
54
 
53
- 4. Run again. This cancels anything left from step 1:
55
+ 4. Run only what did not pass. Once every check is done and one failed, this is the next command that `results` and `follow` print. It cancels anything left from step 1:
54
56
 
55
57
  ```sh
56
- firstlight run lint typecheck test
58
+ firstlight run --failed
57
59
  ```
58
60
 
59
- Or run only what did not pass the last time:
61
+ Or run every check again:
60
62
 
61
63
  ```sh
62
- firstlight run --failed
64
+ firstlight run lint typecheck test
63
65
  ```
64
66
 
65
67
  ## Arguments of `run`
@@ -81,7 +83,7 @@ firstlight has no sequential `run-s` mode and none of npm-run-all's other flags.
81
83
 
82
84
  ## Install
83
85
 
84
- Requires Node.js 24 or newer. Add it to a project as a development dependency:
86
+ Requires Node.js 24 or newer. On an older Node, every command stops before it does anything and prints `{"error":"firstlight needs Node 24 or newer (found v20.19.0)","do":"install Node 24 or newer, then rerun the same command"}` with exit code 1. Add it to a project as a development dependency:
85
87
 
86
88
  ```sh
87
89
  npm i -D firstlight
@@ -94,6 +96,15 @@ Or run it once without adding it:
94
96
  npx firstlight run
95
97
  ```
96
98
 
99
+ Without `firstlight.json`, `firstlight run` runs the `package.json` scripts `lint`, `typecheck`, `test`, and `archstrict`. To choose the checks, let `firstlight init` propose a `firstlight.json`, read it, then write it:
100
+
101
+ ```sh
102
+ npx firstlight init typecheck test check:architecture
103
+ npx firstlight init typecheck test check:architecture --write
104
+ ```
105
+
106
+ See [Starting a config](#starting-a-config).
107
+
97
108
  The package holds JavaScript compiled from `src/` and has no runtime dependencies. From a checkout, Node strips the TypeScript types and runs the source directly, with no build:
98
109
 
99
110
  ```sh
@@ -119,12 +130,54 @@ node src/cli.ts --help
119
130
  | `run` | A command string. firstlight runs it with `/bin/sh -c` in the repository root. stdout and stderr go to one log file. |
120
131
  | `after` | Checks that must pass before this one starts. If one of them does not pass, this check is `skipped`. |
121
132
  | `timeoutSec` | Seconds before firstlight stops the check and records `timed_out`. The default is 600. |
122
- | `parser` | How to read the check's log when it fails: `tsc`, `vitest`, `node-test`, or `archstrict`. Without it, firstlight infers the parser from `run`. `none` turns diagnostics off for this check. See [Diagnostics](#diagnostics). |
133
+ | `parser` | How to read the check's log when it fails: `tsc`, `vitest`, `node-test`, or `archstrict`, or a list of them, like `["tsc", "vitest"]`, read in order. Without it, firstlight infers the parser from `run`. `none` turns diagnostics off for this check. See [Diagnostics](#diagnostics). |
123
134
 
124
- A check name in `firstlight.json` must match `[A-Za-z0-9][A-Za-z0-9._-]*`, so that an argument that names it cannot also read as a pattern or a command. firstlight rejects unknown keys, unknown `after` names, cycles, and an unknown `parser` before it starts anything.
135
+ A check name in `firstlight.json` must match `[A-Za-z0-9][A-Za-z0-9._:-]*`, so that an argument that names it cannot also read as a pattern or a command. firstlight rejects unknown keys, unknown `after` names, cycles, and an unknown `parser` before it starts anything.
125
136
 
126
137
  Without `firstlight.json`, each of the `package.json` scripts `lint`, `typecheck`, `test`, and `archstrict` that exists becomes a check that runs `npm run <name>`, with no `after`.
127
138
 
139
+ Other scripts whose name starts with one of those words or with `check` (`check:architecture`, `test:mutation`, `check`) are not run, because such a script can chain the others or take hours. `firstlight run` names them instead, after its result:
140
+
141
+ ```text
142
+ passed 2 checks in 79.5s (run 01K...)
143
+ not run: check:architecture, test:mutation (without firstlight.json, only lint, typecheck, test, archstrict run; name a script, like firstlight run check:architecture, or write firstlight.json with firstlight init)
144
+ ```
145
+
146
+ With `--json`, the summary gains `"not_run": ["check:architecture", "test:mutation"]`. When none of the four scripts exists, the error envelope names those scripts and suggests running them.
147
+
148
+ ### Starting a config
149
+
150
+ `firstlight init` proposes a `firstlight.json` from the `package.json` scripts and prints it. It writes nothing until `--write` is given, and `--write` does not replace an existing `firstlight.json` without `--force`.
151
+
152
+ ```text
153
+ $ firstlight init typecheck test check:architecture
154
+ would write /repo/firstlight.json:
155
+ {
156
+ "checks": {
157
+ "typecheck": {
158
+ "run": "npm run typecheck",
159
+ "parser": "tsc"
160
+ },
161
+ "test": {
162
+ "run": "npm run test",
163
+ "parser": "vitest"
164
+ },
165
+ "check:architecture": {
166
+ "run": "npm run check:architecture"
167
+ }
168
+ }
169
+ }
170
+ left out: test:mutation (to add them: firstlight init typecheck test check:architecture test:mutation)
171
+ next: firstlight init typecheck test check:architecture --write
172
+ ```
173
+
174
+ - With no names, the proposal holds the scripts `lint`, `typecheck`, `test`, and `archstrict` that exist. Names and patterns (`"check:*"`, as in `run`) pick any scripts instead. A shell command is not a script, so add it to `firstlight.json` by hand.
175
+ - Each check is named after its script and runs `npm run <script>`, the same command `firstlight run <script>` runs, so the runs before `init` still count for reused passes and time estimates.
176
+ - `parser` is written when firstlight can infer it from the script (see [Diagnostics](#diagnostics)). A script that chains commands with `&&`, like `tsc --noEmit && vitest run`, gets the list of its commands' parsers.
177
+ - `timeoutSec` is written when a kept run passed the same check in more than a third of the default 600 seconds: three times that duration, rounded up to whole minutes.
178
+ - `left out:` names the scripts that look like checks (they start with `lint`, `typecheck`, `test`, `check`, or `archstrict`) and that the proposal does not hold, with the command that adds them.
179
+ - `--json` prints one object: `{"type":"init","file","written","existed","config","left_out","next"}`.
180
+
128
181
  The repository root is `git rev-parse --show-toplevel` from the current directory, or the current directory when that fails. `--cwd DIR` starts from `DIR` instead.
129
182
 
130
183
  ## Commands
@@ -135,6 +188,7 @@ firstlight run --failed [--no-cache] [--cwd DIR] [--json]
135
188
  firstlight results [--wait] [--new] [--cwd DIR] [--json]
136
189
  firstlight follow [--cwd DIR] [--json]
137
190
  firstlight stop [--cwd DIR] [--json]
191
+ firstlight init [script|pattern...] [--write [--force]] [--cwd DIR] [--json]
138
192
  firstlight --help
139
193
  ```
140
194
 
@@ -143,6 +197,7 @@ firstlight --help
143
197
  - `results` reports the latest run: status, exit code, elapsed time, and for `failed` and `timed_out` checks the diagnostics, the log path, and the last 40 log lines (10 when there are diagnostics). `--wait` blocks until every check is done. `--new` reports only the failures the previous run did not have; see [Only new failures](#only-new-failures).
144
198
  - `follow` prints the status changes of the latest run as they happen; see [Following a run](#following-a-run).
145
199
  - `stop` cancels the checks that are still running. With nothing running, it does nothing and exits 0.
200
+ - `init` proposes a `firstlight.json` from the `package.json` scripts, and writes it with `--write`; see [Starting a config](#starting-a-config).
146
201
 
147
202
  A `running` or `pending` check shows about how long it has left, and the `next: firstlight results --wait` line shows the time left for the whole run, so the agent knows when to pull the rest:
148
203
 
@@ -178,7 +233,7 @@ lint failed (exit 1, 0.4s)
178
233
  src/a.ts:3:1 unused variable x
179
234
  test passed (exit 0, 10.4s)
180
235
  run 01K...: 1 failed, 1 passed
181
- next: firstlight run
236
+ next: firstlight run --failed
182
237
  ```
183
238
 
184
239
  A `running` line shows the time left that firstlight estimated when the check started, when there is one. A failed or timed-out check has the same [diagnostics](#diagnostics) and log tail that `run` prints. A reused pass says `cached from run 01K...`, and a check whose supervisor died appears as `unknown` at the end. With `--json`, each change is one check object, in the same shape as `results --json`, followed by the diagnostic lines of a failed check, and the last line is the summary object.
@@ -197,7 +252,7 @@ typecheck failed (exit 2, 1.4s)
197
252
  ...
198
253
  not new: 1 failure already in the previous run, 2 passed
199
254
  run 01K...: 1 new failure
200
- next: firstlight run
255
+ next: firstlight run --failed
201
256
  ```
202
257
 
203
258
  The previous run is chosen per check: it is the newest kept run before the latest one in which a check with the same name and the same `run` command `passed`, `failed`, or `timed_out`. A run in which that check was `cancelled`, `skipped`, or never finished is passed over, because it proved nothing either way. This is per check, not one run for all, because `run --failed` and `run <name>` leave runs that hold only some of the checks.
@@ -217,7 +272,7 @@ With `--json`, every check object of `results --json` comes, with two more field
217
272
  ```json
218
273
  {"type":"check","name":"typecheck","status":"failed","exit_code":2,"elapsed_ms":1412,"log":"/.../typecheck.log","eta_ms":0,"overdue":false,"cached_from":null,"new":true,"baseline":"01K...","old_diagnostics":1,"tail":["..."]}
219
274
  {"type":"diagnostic","check":"typecheck","file":"src/a.ts","line":4,"col":26,"rule":"TS2304","message":"Cannot find name 'missing'."}
220
- {"type":"summary","run":"01K...","failed":["typecheck"],"running":[],"passed":[],"cancelled":[],"next":"firstlight run","eta_ms":0,"new":["typecheck"],"new_failures":1,"old_failures":1,"no_baseline":[]}
275
+ {"type":"summary","run":"01K...","failed":["typecheck"],"running":[],"passed":[],"cancelled":[],"next":"firstlight run --failed","eta_ms":0,"new":["typecheck"],"new_failures":1,"old_failures":1,"no_baseline":[]}
221
276
  ```
222
277
 
223
278
  ## Diagnostics
@@ -245,7 +300,7 @@ Each line is `file:line:col rule message`. A part the tool does not report is le
245
300
 
246
301
  The message is the first line of the error. A line that ends in `:` takes the next line too, so `Expected values to be strictly equal: 7 !== 6` stays whole. Paths inside the repository are relative to its root. Colors and CRLF line endings make no difference.
247
302
 
248
- A check's `parser` in `firstlight.json` chooses the parser. Without one, firstlight infers it from the `run` command when the command is one simple command: `tsc`, `vitest`, `node --test`, or `archstrict`, also through `npx`, `pnpm exec`, a `node_modules/.bin/` path, or a leading `NAME=value`. For `npm run <script>` and `npm test`, it reads the script's own command in `package.json`, one level deep. A command with `&&`, `||`, `;`, or a pipe gets no parser, because it can run more than one tool. firstlight decides the parser when the run starts, so `results` reads the log the same way later. A parser reads text, so a command that prints JSON, such as `archstrict check --json`, gives no diagnostics.
303
+ A check's `parser` in `firstlight.json` chooses the parser. Without one, firstlight infers it from the `run` command when the command is simple commands joined by `&&`: `tsc`, `vitest`, `node --test`, or `archstrict`, also through `npx`, `pnpm exec`, a `node_modules/.bin/` path, or a leading `NAME=value`. For `npm run <script>` and `npm test`, it reads the script's own command in `package.json`, one level deep. An `&&` list, like `tsc --noEmit && vitest run`, gets the parser of each command that has one, in order, and its diagnostics are what each parser finds; a parser skips the lines of other tools. A command with `||`, `;`, or a pipe gets no parser, because which tool's output the log ends with is not fixed. firstlight decides the parser when the run starts, so `results` reads the log the same way later. A parser reads text, so a command that prints JSON, such as `archstrict check --json`, gives no diagnostics.
249
304
 
250
305
  In `--json`, the diagnostics follow their check's object, one line each:
251
306
 
@@ -302,6 +357,20 @@ The limits:
302
357
 
303
358
  In these cases, run `firstlight run --no-cache`. It runs every selected check, and still records the keys, so a later run can reuse its passes.
304
359
 
360
+ ## CPU contention
361
+
362
+ firstlight starts every check at once and has no concurrency limit. Test runners such as vitest and `node --test` already spread one check over every core, so checks that run together share the CPU: each one takes longer than it does alone, and the whole run takes at least the checks' total CPU time divided by the number of cores. Measured on a 4-CPU machine, with Node 24.21.0, three runs each, times in seconds:
363
+
364
+ | Repository | Checks | Each alone (sum) | Together | Slowest check alone, together |
365
+ | --- | --- | --- | --- | --- |
366
+ | firstlight | typecheck, test, archstrict | 35.1 | 34.7 | test 30.9, 34.2 |
367
+ | finderscope | typecheck, test, archstrict | 62.8 | 59.4 | test 58.4, 58.9 |
368
+ | polylinedb | typecheck, test, check:architecture | 197.8 | 178.9 | test 132.0, 178.4 |
369
+
370
+ In polylinedb, test used about 2.9 cores and check:architecture about 3.8 while each ran alone, so together they need about 160 seconds of the 4 cores at best; 179 is close to that, and 20 seconds less than one after the other. A limit on how many checks run at once would not make the run shorter, because the total CPU time is the same; it would only move time from one check to another. The failure that arrives first still arrives first: typecheck finished in 3 to 6 seconds in every run, against about 2 alone. (finderscope's test had the same two failures on that machine in every run, alone and together.)
371
+
372
+ To see one heavy check's result sooner at the cost of a longer run, start another heavy check after it with `after`. In polylinedb, putting check:architecture after test would let test report at about 132 seconds instead of 178, and the run would end at about 196 instead of 179.
373
+
305
374
  ## Statuses and output
306
375
 
307
376
  A check is `pending`, `running`, `passed`, `failed`, `timed_out`, `cancelled`, or `skipped`. A check is `unknown`, with `orphan: true`, when its supervisor process is gone before the check finished. firstlight does not invent an exit code for it. A check killed by a signal has the exit code 128 plus the signal number.
@@ -315,7 +384,7 @@ The default output is short plain text with no colors. `--json` prints JSON Line
315
384
  {"type":"summary","run":"01K...","failed":["lint"],"running":["test"],"passed":["build"],"cancelled":[],"next":"firstlight results --wait","eta_ms":8012}
316
385
  ```
317
386
 
318
- `failed` in the summary holds the checks that are `failed`, `timed_out`, `skipped`, or `unknown`. `running` holds `pending` and `running` checks. `next` is the command to run next, or `null`. `eta_ms` on a check is the estimated milliseconds it has left: `0` for a finished check, and `null` when there is no estimate. `overdue` is `true` when a running check has run longer than its usual duration; its `eta_ms` is then `0`. `cached_from` is the id of the run that ran a reused check, or `null`. That run may be pruned since. For a reused check, `exit_code` and `elapsed_ms` come from that run, and its log holds one line that names it. `eta_ms` in the summary is the estimate for the whole run, `0` when nothing is left to run, or `null`.
387
+ `failed` in the summary holds the checks that are `failed`, `timed_out`, `skipped`, or `unknown`. `running` holds `pending` and `running` checks. `next` is the command to run next, or `null`: `firstlight results --wait` while a check is still running, and `firstlight run --failed` once every check is done and one did not pass. `eta_ms` on a check is the estimated milliseconds it has left: `0` for a finished check, and `null` when there is no estimate. `overdue` is `true` when a running check has run longer than its usual duration; its `eta_ms` is then `0`. `cached_from` is the id of the run that ran a reused check, or `null`. That run may be pruned since. For a reused check, `exit_code` and `elapsed_ms` come from that run, and its log holds one line that names it. `eta_ms` in the summary is the estimate for the whole run, `0` when nothing is left to run, or `null`.
319
388
 
320
389
  ## Exit codes
321
390
 
package/dist/cli.js CHANGED
@@ -5,15 +5,15 @@
5
5
  // subcommand).
6
6
  // It never appends to events.jsonl; it only reads what the supervisor wrote.
7
7
  import { spawn } from "node:child_process";
8
- import { closeSync, openSync, writeSync } from "node:fs";
8
+ import { closeSync, existsSync, openSync, writeFileSync, writeSync } from "node:fs";
9
9
  import path from "node:path";
10
10
  import { fileURLToPath } from "node:url";
11
11
  import { parseArgs } from "node:util";
12
12
  import { compareFailures, isFailure, isUnproven } from "./baseline.js";
13
13
  import { cacheKeys } from "./cache.js";
14
14
  import { bucketOf, IllegalTransition, isSettled, presentChecks, replay } from "./checks.js";
15
- import { configuredChecks, loadConfig, resolveRun, selectFailed, withParsers } from "./config.js";
16
- import { AgentError, exitWithEnvelope } from "./errors.js";
15
+ import { CONFIG_FILE, configText, configuredChecks, loadConfig, proposeChecks, resolveRun, selectFailed, suggestTimeoutSec, withParsers, } from "./config.js";
16
+ import { AgentError, exitWithEnvelope, nodeVersionError } from "./errors.js";
17
17
  import { estimate } from "./eta.js";
18
18
  import { shellQuote } from "./names.js";
19
19
  import { signalGroup, signalPid, sleep } from "./processes.js";
@@ -31,6 +31,7 @@ Usage:
31
31
  firstlight results [--wait] [--new] [--cwd DIR] [--json]
32
32
  firstlight follow [--cwd DIR] [--json]
33
33
  firstlight stop [--cwd DIR] [--json]
34
+ firstlight init [script|pattern...] [--write [--force]] [--cwd DIR] [--json]
34
35
  firstlight --help
35
36
 
36
37
  run Stop the previous run if it is still going, start the checks in
@@ -59,6 +60,15 @@ follow Print one line for each status change of the latest run, from its
59
60
  exit as results --wait would. Each line is flushed on its own, so
60
61
  a log watcher sees it at once.
61
62
  stop Cancel the checks still running. Does nothing when none are.
63
+ init Propose a firstlight.json from the package.json scripts and print
64
+ it. --write writes it; --force lets --write replace an existing
65
+ one. With no names, it holds the scripts lint, typecheck, test,
66
+ and archstrict; names and patterns pick other scripts:
67
+ firstlight init typecheck test check:architecture
68
+ Each check runs "npm run <script>", gets the parser inferred from
69
+ the script, and gets a timeoutSec when an earlier run passed it in
70
+ more than a third of the 600s default. It names the scripts that
71
+ look like checks and were left out.
62
72
 
63
73
  Arguments of run:
64
74
  word a check in firstlight.json (with its "after" checks), else a
@@ -88,7 +98,8 @@ diagnostics, at most 20, as "file:line:col rule message" lines before a
88
98
  {"type":"diagnostic",...} lines after the check. The parsers are tsc,
89
99
  vitest, node-test (node --test), and archstrict. Each check's parser comes from its "parser" in firstlight.json,
90
100
  or from its command: tsc, vitest, node --test, archstrict, or "npm run
91
- <script>" (and "npm test") whose script runs one of them. "parser":
101
+ <script>" (and "npm test") whose script runs one of them; an && list
102
+ gets each of its commands' parsers, like ["tsc", "vitest"]. "parser":
92
103
  "none" turns this off for one check. A vitest diagnostic whose test
93
104
  depug's reporter named also gets "evidence: <path>" and "rerun:
94
105
  <command>" lines under it, and "evidence" and "rerun" fields in --json.
@@ -100,7 +111,9 @@ shell command:
100
111
  "types": {"run": "make types", "parser": "tsc"}}}
101
112
  To run checks in order, give a check "after" in firstlight.json. Without
102
113
  firstlight.json, "firstlight run" runs the package.json scripts lint,
103
- typecheck, test, and archstrict.
114
+ typecheck, test, and archstrict, and names the other scripts that start
115
+ with one of those words or with "check" (check:architecture, test:e2e)
116
+ on a "not run:" line, without running them.
104
117
 
105
118
  Exit codes:
106
119
  0 every check passed (run), or no check failed or was cancelled
@@ -135,6 +148,8 @@ async function main(argv) {
135
148
  failed: { type: "boolean", default: false },
136
149
  new: { type: "boolean", default: false },
137
150
  "no-cache": { type: "boolean", default: false },
151
+ write: { type: "boolean", default: false },
152
+ force: { type: "boolean", default: false },
138
153
  help: { type: "boolean", short: "h", default: false },
139
154
  },
140
155
  });
@@ -159,10 +174,13 @@ async function main(argv) {
159
174
  if (command === undefined) {
160
175
  throw new AgentError("missing command", "run `firstlight run`, or `firstlight --help` for the usage");
161
176
  }
162
- if (!["run", "results", "follow", "stop"].includes(command)) {
177
+ if (!["run", "results", "follow", "stop", "init"].includes(command)) {
163
178
  throw new AgentError(`unknown command: ${command}`, "run `firstlight --help` for the usage");
164
179
  }
165
- if (command !== "run" && (names.length > 0 || commands.length > 0)) {
180
+ if (command === "init" && commands.length > 0) {
181
+ throw new AgentError("init picks package.json scripts and takes no commands after --", `add the commands to ${CONFIG_FILE} by hand`);
182
+ }
183
+ if (command !== "run" && command !== "init" && (names.length > 0 || commands.length > 0)) {
166
184
  throw new AgentError(`${command} takes no check names`, `run \`firstlight ${command}\` without arguments`);
167
185
  }
168
186
  if (values.wait && command !== "results") {
@@ -174,6 +192,12 @@ async function main(argv) {
174
192
  if (values["no-cache"] && command !== "run") {
175
193
  throw new AgentError("--no-cache belongs to run", "run `firstlight run --no-cache`");
176
194
  }
195
+ if (values.write && command !== "init") {
196
+ throw new AgentError("--write belongs to init", "run `firstlight init --write`");
197
+ }
198
+ if (values.force && !values.write) {
199
+ throw new AgentError("--force only lets init --write replace a firstlight.json", "run `firstlight init --write --force`");
200
+ }
177
201
  if (values.failed && command !== "run") {
178
202
  throw new AgentError("--failed belongs to run", "run `firstlight run --failed`");
179
203
  }
@@ -193,6 +217,8 @@ async function main(argv) {
193
217
  return resultsCommand(ctx, values.wait, values.new);
194
218
  case "follow":
195
219
  return followCommand(ctx);
220
+ case "init":
221
+ return initCommand(ctx, names, values.write, values.force);
196
222
  default:
197
223
  return stopCommand(ctx);
198
224
  }
@@ -200,6 +226,7 @@ async function main(argv) {
200
226
  async function runCommand(ctx, selection, useCache) {
201
227
  const previous = ctx.state.currentId();
202
228
  let checks;
229
+ let notRun = [];
203
230
  if (selection === "failed") {
204
231
  if (previous === null) {
205
232
  throw new AgentError(`no run yet for ${ctx.state.root}, so no check has failed`, cmd(ctx, "run"));
@@ -213,10 +240,14 @@ async function runCommand(ctx, selection, useCache) {
213
240
  }
214
241
  else {
215
242
  const { names, commands } = selection;
216
- checks =
217
- names.length === 0 && commands.length === 0
218
- ? loadConfig(ctx.state.root).checks
219
- : resolveRun(ctx.state.root, names, commands);
243
+ if (names.length === 0 && commands.length === 0) {
244
+ const config = loadConfig(ctx.state.root);
245
+ checks = config.checks;
246
+ notRun = config.notRun;
247
+ }
248
+ else {
249
+ checks = resolveRun(ctx.state.root, names, commands);
250
+ }
220
251
  if (previous !== null)
221
252
  await stopRun(ctx.state, previous);
222
253
  }
@@ -258,18 +289,18 @@ async function runCommand(ctx, selection, useCache) {
258
289
  const eta = etaOf(snapshot, typical);
259
290
  const first = firstFailure(snapshot.checks);
260
291
  if (first !== null) {
261
- const next = snapshot.checks.every(isSettled) ? cmd(ctx, "run") : cmd(ctx, "results --wait");
262
- out(ctx.json ? jsonLines(snapshot, next, eta) : text(snapshot, next, eta, first.name));
292
+ const next = snapshot.checks.every(isSettled) ? cmd(ctx, "run --failed") : cmd(ctx, "results --wait");
293
+ out(ctx.json ? jsonLines(snapshot, next, eta, notRun) : text(snapshot, next, eta, first.name, notRun));
263
294
  return 1;
264
295
  }
265
296
  if (snapshot.checks.every(isSettled)) {
266
297
  if (snapshot.checks.every((view) => view.status === "passed")) {
267
- out(ctx.json ? jsonLines(snapshot, null, eta) : allPassedText(snapshot));
298
+ out(ctx.json ? jsonLines(snapshot, null, eta, notRun) : allPassedText(snapshot, notRun));
268
299
  return 0;
269
300
  }
270
301
  const superseded = ctx.state.currentId() !== run.id;
271
- const next = superseded ? cmd(ctx, "results --wait") : cmd(ctx, "run");
272
- out(ctx.json ? jsonLines(snapshot, next, eta) : text(snapshot, next, eta));
302
+ const next = superseded ? cmd(ctx, "results --wait") : cmd(ctx, "run --failed");
303
+ out(ctx.json ? jsonLines(snapshot, next, eta, notRun) : text(snapshot, next, eta, null, notRun));
273
304
  return 1;
274
305
  }
275
306
  await sleep(POLL_MS);
@@ -302,7 +333,7 @@ async function resultsCommand(ctx, wait, onlyNew) {
302
333
  const failing = [...comparison.checks.values()].some((check) => check.isNew);
303
334
  const running = snapshot.checks.some((view) => bucketOf(view.status) === "running");
304
335
  const unproven = snapshot.checks.some((view) => isUnproven(view.status));
305
- const next = running ? cmd(ctx, "results --new --wait") : failing || unproven ? cmd(ctx, "run") : null;
336
+ const next = running ? cmd(ctx, "results --new --wait") : failing || unproven ? cmd(ctx, "run --failed") : null;
306
337
  out(ctx.json ? newJsonLines(snapshot, comparison, next, eta) : newText(snapshot, comparison, next, eta));
307
338
  return failing || unproven ? 1 : 0;
308
339
  }
@@ -383,6 +414,39 @@ async function followCommand(ctx) {
383
414
  await sleep(POLL_MS);
384
415
  }
385
416
  }
417
+ function initCommand(ctx, names, write, force) {
418
+ const { checks, leftOut } = proposeChecks(ctx.state.root, names);
419
+ const durations = ctx.state.passDurations(checks);
420
+ const proposed = checks.map((check) => {
421
+ const ms = durations.get(check.name);
422
+ const timeoutSec = ms === undefined ? null : suggestTimeoutSec(ms);
423
+ return timeoutSec === null ? check : { ...check, timeoutSec };
424
+ });
425
+ const file = path.join(ctx.state.root, CONFIG_FILE);
426
+ const exists = existsSync(file);
427
+ const content = configText(proposed);
428
+ if (write) {
429
+ if (exists && !force) {
430
+ throw new AgentError(`${file} already exists, and init does not replace it`, cmd(ctx, `init${argsOf(names)} --write --force`));
431
+ }
432
+ writeFileSync(file, content);
433
+ }
434
+ const next = write ? cmd(ctx, "run") : cmd(ctx, `init${argsOf(names)} --write${exists ? " --force" : ""}`);
435
+ const widen = leftOut.length === 0 ? null : cmd(ctx, `init${argsOf([...checks.map((check) => check.name), ...leftOut])}`);
436
+ if (ctx.json) {
437
+ out(`${JSON.stringify({ type: "init", file, written: write, existed: exists, config: JSON.parse(content), left_out: leftOut, next })}\n`);
438
+ return 0;
439
+ }
440
+ const lines = [write ? `wrote ${file}:` : `would write ${file}${exists ? " (it exists; --force replaces it)" : ""}:`, content.trimEnd()];
441
+ if (widen !== null)
442
+ lines.push(`left out: ${leftOut.join(", ")} (to add them: ${widen})`);
443
+ lines.push(`next: ${next}`);
444
+ out(`${lines.join("\n")}\n`);
445
+ return 0;
446
+ }
447
+ function argsOf(names) {
448
+ return names.map((name) => ` ${shellQuote(name)}`).join("");
449
+ }
386
450
  async function stopCommand(ctx) {
387
451
  const id = ctx.state.currentId();
388
452
  const stopped = id === null ? false : await stopRun(ctx.state, id);
@@ -438,7 +502,7 @@ function nextFor(ctx, snapshot) {
438
502
  if (buckets.includes("running"))
439
503
  return cmd(ctx, "results --wait");
440
504
  if (buckets.includes("failed") || buckets.includes("cancelled"))
441
- return cmd(ctx, "run");
505
+ return cmd(ctx, "run --failed");
442
506
  return null;
443
507
  }
444
508
  function cmd(ctx, rest) {
@@ -447,6 +511,9 @@ function cmd(ctx, rest) {
447
511
  function out(textValue) {
448
512
  writeSync(1, textValue);
449
513
  }
514
+ const tooOld = nodeVersionError(process.version);
515
+ if (tooOld !== null)
516
+ exitWithEnvelope(tooOld);
450
517
  main(process.argv.slice(2)).then((code) => {
451
518
  process.exitCode = code;
452
519
  }, (err) => exitWithEnvelope(err));
package/dist/config.js CHANGED
@@ -7,28 +7,42 @@
7
7
  // process.
8
8
  import { readFileSync } from "node:fs";
9
9
  import path from "node:path";
10
- import { inferParser, PARSER_CHOICES } from "./diagnostics.js";
10
+ import { inferParser, isParserChoice, PARSER_CHOICES, PARSERS } from "./diagnostics.js";
11
11
  import { AgentError, messageOf } from "./errors.js";
12
12
  import { expandPattern, isPattern, shellQuote } from "./names.js";
13
13
  export const CONFIG_FILE = "firstlight.json";
14
14
  export const DEFAULT_TIMEOUT_SEC = 600;
15
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"]);
16
21
  // A configured name is one plain word, never whitespace or "*", so an
17
22
  // 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._-]*$/;
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._:-]*$/;
20
26
  const CHECK_KEYS = new Set(["run", "after", "timeoutSec", "parser"]);
21
27
  export function loadConfig(root) {
22
28
  const configPath = path.join(root, CONFIG_FILE);
23
29
  const text = readOptional(configPath);
24
30
  if (text !== null) {
25
- return { source: "firstlight.json", checks: parseConfig(text, configPath) };
31
+ return { source: "firstlight.json", checks: parseConfig(text, configPath), notRun: [] };
26
32
  }
27
33
  const scripts = readScripts(path.join(root, "package.json")) ?? [];
28
34
  const checks = FALLBACK_SCRIPTS.filter((name) => scripts.includes(name)).map(scriptCheck);
35
+ const notRun = checkLikeScripts(scripts);
29
36
  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\``);
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]));
32
46
  }
33
47
  export function parseConfig(text, file) {
34
48
  let raw;
@@ -84,8 +98,8 @@ function parseCheck(name, value, file) {
84
98
  }
85
99
  if (value.parser === undefined)
86
100
  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(", ")}`);
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(", ")}`);
89
103
  }
90
104
  return { name, run: value.run, after, timeoutSec, parser: value.parser };
91
105
  }
@@ -252,8 +266,72 @@ export function selectFailed(configured, previous) {
252
266
  }
253
267
  return { checks: picked, dropped };
254
268
  }
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.
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
+ }
257
335
  export function configuredChecks(root) {
258
336
  const configPath = path.join(root, CONFIG_FILE);
259
337
  const configText = readOptional(configPath);
@@ -39,14 +39,37 @@ export function formatDiagnostic(diagnostic) {
39
39
  }
40
40
  return [where, diagnostic.rule, diagnostic.message].filter((part) => part !== null && part !== "").join(" ");
41
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.
42
+ export function isParserChoice(value) {
43
+ return isParserName(value) || (Array.isArray(value) && value.length > 0 && value.every(isParserName));
44
+ }
45
+ export function parserList(choice) {
46
+ return typeof choice === "string" ? [choice] : choice;
47
+ }
48
+ // The command is read only when it is simple commands joined by `&&`: a
49
+ // pipe, `;`, `||`, or a substitution could run any tool, and a wrong parser
50
+ // is worse than none. An `&&` list runs its commands in order and each
51
+ // parser skips the lines it does not recognize, so every command's parser
52
+ // reads the one log. `npm run <script>` and `npm test` look up the script's
53
+ // own command one level deep; that command is not followed further.
46
54
  export function inferParser(command, scriptOf) {
47
- const words = simpleWords(command);
48
- if (words === null)
55
+ const segments = command.split("&&");
56
+ const found = [];
57
+ for (const segment of segments) {
58
+ if (segment.trim().length === 0)
59
+ return null;
60
+ const words = simpleWords(segment);
61
+ if (words === null)
62
+ return null;
63
+ for (const parser of parserList(inferSimple(words, scriptOf) ?? [])) {
64
+ if (!found.includes(parser))
65
+ found.push(parser);
66
+ }
67
+ }
68
+ if (found.length === 0)
49
69
  return null;
70
+ return found.length === 1 ? found[0] : found;
71
+ }
72
+ function inferSimple(words, scriptOf) {
50
73
  let i = 0;
51
74
  while (i < words.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(words[i]))
52
75
  i += 1;
package/dist/errors.js CHANGED
@@ -20,6 +20,16 @@ export function exitWithEnvelope(err) {
20
20
  writeSync(2, `${JSON.stringify(body)}\n`);
21
21
  process.exit(1);
22
22
  }
23
+ // The oldest Node major firstlight is tested on; package.json "engines"
24
+ // says the same. Older Nodes are refused up front, so a missing API never
25
+ // surfaces later as an unrelated stack trace or a hang.
26
+ export const NODE_FLOOR = 24;
27
+ export function nodeVersionError(version) {
28
+ const major = Number(/^v?(\d+)\./.exec(version)?.[1]);
29
+ if (!Number.isInteger(major) || major >= NODE_FLOOR)
30
+ return null;
31
+ return new AgentError(`firstlight needs Node ${NODE_FLOOR} or newer (found ${version})`, `install Node ${NODE_FLOOR} or newer, then rerun the same command`);
32
+ }
23
33
  export function messageOf(err) {
24
34
  return err instanceof Error ? err.message : String(err);
25
35
  }
package/dist/report.js CHANGED
@@ -7,7 +7,8 @@
7
7
  // gets its own text and JSON here too, from a comparison the CLI made.
8
8
  import { countFailures, isUnproven } from "./baseline.js";
9
9
  import { bucketOf, isSettled } from "./checks.js";
10
- import { formatDiagnostic, isParserName, parseDiagnostics } from "./diagnostics.js";
10
+ import { FALLBACK_SCRIPTS } from "./config.js";
11
+ import { formatDiagnostic, isParserChoice, parseDiagnostics, parserList } from "./diagnostics.js";
11
12
  import { formatSeconds } from "./eta.js";
12
13
  import { logPaths, readLogEnd, tailLines } from "./store.js";
13
14
  export const TAIL_LINES = 40;
@@ -19,9 +20,10 @@ export const DIAGNOSTIC_LIMIT = 20;
19
20
  // that run recorded for it.
20
21
  export function checkDiagnostics(run, dir, name) {
21
22
  const parser = run.checks.find((check) => check.name === name)?.parser;
22
- if (!isParserName(parser))
23
+ if (!isParserChoice(parser))
23
24
  return [];
24
- return parseDiagnostics(parser, readLogEnd(logPaths(dir, run.checks).get(name)), run.root);
25
+ const log = readLogEnd(logPaths(dir, run.checks).get(name));
26
+ return parserList(parser).flatMap((one) => parseDiagnostics(one, log, run.root));
25
27
  }
26
28
  // `shown` narrows the diagnostics printed, as `results --new` does; the
27
29
  // tail stays short whenever the log has any.
@@ -37,7 +39,7 @@ function logReport(snapshot, view, file, shown) {
37
39
  function comparedOf(comparison, view) {
38
40
  return { baseline: comparison.baselines.get(view.name) ?? null, comparison: comparison.checks.get(view.name) };
39
41
  }
40
- export function summarize(snapshot, next, eta) {
42
+ export function summarize(snapshot, next, eta, notRun = []) {
41
43
  const summary = {
42
44
  type: "summary",
43
45
  run: snapshot.run.id,
@@ -50,14 +52,16 @@ export function summarize(snapshot, next, eta) {
50
52
  };
51
53
  for (const view of snapshot.checks)
52
54
  summary[bucketOf(view.status)].push(view.name);
55
+ if (notRun.length > 0)
56
+ summary.not_run = notRun;
53
57
  return summary;
54
58
  }
55
59
  function showsLog(view) {
56
60
  return view.status === "failed" || view.status === "timed_out";
57
61
  }
58
- export function jsonLines(snapshot, next, eta) {
62
+ export function jsonLines(snapshot, next, eta, notRun = []) {
59
63
  const lines = snapshot.checks.map((view) => checkJson(snapshot, view, eta));
60
- lines.push(JSON.stringify(summarize(snapshot, next, eta)));
64
+ lines.push(JSON.stringify(summarize(snapshot, next, eta, notRun)));
61
65
  return `${lines.join("\n")}\n`;
62
66
  }
63
67
  // One line for the check, then, for a failed check, one line per
@@ -106,7 +110,7 @@ export function checkJson(snapshot, view, eta, compared) {
106
110
  }
107
111
  // `focus` puts one check first with its log, as `run` does for the check
108
112
  // that failed first; the others get one status line each.
109
- export function text(snapshot, next, eta, focus = null) {
113
+ export function text(snapshot, next, eta, focus = null, notRun = []) {
110
114
  const out = [];
111
115
  const ordered = focus === null
112
116
  ? snapshot.checks
@@ -116,7 +120,7 @@ export function text(snapshot, next, eta, focus = null) {
116
120
  if (showsLog(view) && (focus === null || view.name === focus))
117
121
  out.push(...logBlock(snapshot, view));
118
122
  }
119
- return `${out.join("\n")}\n${summaryText(snapshot, next, eta)}`;
123
+ return `${out.join("\n")}\n${notRunText(notRun)}${summaryText(snapshot, next, eta)}`;
120
124
  }
121
125
  // The lines after the check lines: the counts, then the next command.
122
126
  export function summaryText(snapshot, next, eta) {
@@ -231,12 +235,19 @@ export function newText(snapshot, comparison, next, eta) {
231
235
  function plural(count, word) {
232
236
  return count === 1 ? word : `${word}s`;
233
237
  }
234
- export function allPassedText(snapshot) {
238
+ // Only a run without firstlight.json has these: the check-like scripts the
239
+ // package.json fallback did not run.
240
+ export function notRunText(notRun) {
241
+ if (notRun.length === 0)
242
+ return "";
243
+ return `not run: ${notRun.join(", ")} (without firstlight.json, only ${FALLBACK_SCRIPTS.join(", ")} run; name a script, like firstlight run ${notRun[0]}, or write firstlight.json with firstlight init)\n`;
244
+ }
245
+ export function allPassedText(snapshot, notRun = []) {
235
246
  const seconds = (Date.now() - Date.parse(snapshot.run.started_at)) / 1000;
236
247
  const count = snapshot.checks.length;
237
248
  const cached = cachedCount(snapshot);
238
249
  const notes = cached === 0 ? `run ${snapshot.run.id}` : `${cached} cached, run ${snapshot.run.id}`;
239
- return `passed ${count} check${count === 1 ? "" : "s"} in ${seconds.toFixed(1)}s (${notes})\n`;
250
+ return `passed ${count} check${count === 1 ? "" : "s"} in ${seconds.toFixed(1)}s (${notes})\n${notRunText(notRun)}`;
240
251
  }
241
252
  function statusLine(snapshot, view, eta) {
242
253
  const details = [];
package/dist/store.js CHANGED
@@ -145,6 +145,12 @@ export class RepoState {
145
145
  .map((files) => ({ checks: files.record.checks, states: files.states }));
146
146
  return typicalDurations(run.checks, past);
147
147
  }
148
+ // The newest pass of each check in any kept run, for checks that no run
149
+ // holds yet, as `firstlight init` proposes them.
150
+ passDurations(specs) {
151
+ const past = this.pastRuns().map((files) => ({ checks: files.record.checks, states: files.states }));
152
+ return typicalDurations(specs, past);
153
+ }
148
154
  // The run each check of `run` is compared with, keyed by check name, with
149
155
  // that run's record, whose parser and log the comparison reads.
150
156
  baselines(run) {
@@ -333,7 +339,10 @@ function isRunRecord(value) {
333
339
  typeof check.run === "string" &&
334
340
  Array.isArray(check.after) &&
335
341
  typeof check.timeoutSec === "number" &&
336
- (check.parser === undefined || check.parser === null || typeof check.parser === "string"));
342
+ (check.parser === undefined ||
343
+ check.parser === null ||
344
+ typeof check.parser === "string" ||
345
+ (Array.isArray(check.parser) && check.parser.every((name) => typeof name === "string"))));
337
346
  }
338
347
  function isEvent(value) {
339
348
  if (!isRecord(value) || typeof value.at !== "string")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "firstlight",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "Run lint, typecheck, and tests in parallel for a coding agent, and return at the first failure.",
5
5
  "keywords": [
6
6
  "parallel",
@@ -37,6 +37,7 @@
37
37
  "scripts": {
38
38
  "build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json",
39
39
  "test": "vitest run",
40
+ "test:scenarios": "nuka check && nuka run features",
40
41
  "typecheck": "tsc --noEmit",
41
42
  "archstrict": "archstrict check",
42
43
  "mutation": "stryker run",
@@ -49,6 +50,7 @@
49
50
  "@stryker-mutator/vitest-runner": "10.0.0",
50
51
  "@types/node": "26.5.1",
51
52
  "archstrict": "0.1.0",
53
+ "nukadoko": "0.14.0",
52
54
  "stryker-agent-reporter": "0.1.0",
53
55
  "typescript": "6.0.3",
54
56
  "vitest": "4.1.11"