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 +16 -1
- package/README.md +81 -12
- package/dist/cli.js +85 -18
- package/dist/config.js +88 -10
- package/dist/diagnostics.js +29 -6
- package/dist/errors.js +10 -0
- package/dist/report.js +21 -10
- package/dist/store.js +10 -1
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
##
|
|
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
|
|
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
|
|
58
|
+
firstlight run --failed
|
|
57
59
|
```
|
|
58
60
|
|
|
59
|
-
Or run
|
|
61
|
+
Or run every check again:
|
|
60
62
|
|
|
61
63
|
```sh
|
|
62
|
-
firstlight run
|
|
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
|
|
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._
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
//
|
|
256
|
-
//
|
|
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);
|
package/dist/diagnostics.js
CHANGED
|
@@ -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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
48
|
-
|
|
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 {
|
|
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 (!
|
|
23
|
+
if (!isParserChoice(parser))
|
|
23
24
|
return [];
|
|
24
|
-
|
|
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
|
-
|
|
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 ||
|
|
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.
|
|
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"
|