firstlight 0.0.0-stage → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/LICENSE +21 -0
- package/README.md +386 -2
- package/dist/baseline.js +86 -0
- package/dist/cache.js +54 -0
- package/dist/checks.js +125 -0
- package/dist/cli.js +452 -0
- package/dist/config.js +327 -0
- package/dist/diagnostics.js +459 -0
- package/dist/errors.js +25 -0
- package/dist/eta.js +76 -0
- package/dist/names.js +67 -0
- package/dist/processes.js +56 -0
- package/dist/report.js +288 -0
- package/dist/store.js +360 -0
- package/dist/supervisor.js +188 -0
- package/dist/tree.js +60 -0
- package/package.json +58 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
## 0.2.0 (2026-10-13)
|
|
6
|
+
|
|
7
|
+
`0.1.0` was removed from npm on 2026-10-12, and npm does not let a removed version be published again. `0.2.0` holds everything in `0.1.0` and the changes below.
|
|
8
|
+
|
|
9
|
+
- `firstlight run --failed` runs only the checks of the latest run that did not pass (`failed`, `timed_out`, `skipped`, `cancelled`, or `unknown`). Each one resolves against the current `firstlight.json` like a name given to `run`: a configured check comes with its `after` checks, and reused passes still apply. A script or shell command from the command line runs the same command again, and a configured check removed from `firstlight.json` is left out. With no run yet, or no check that did not pass, it prints the error envelope with `firstlight run` and exits 1. It takes no check names.
|
|
10
|
+
- `firstlight follow` attaches to the latest run and prints one line for each status change, from the first one, as it happens: `test passed (exit 0, 10.4s)`, or `typecheck failed (exit 2, 3.5s)` with the same log tail that `run` prints. It ends with the summary lines of `results --wait` and exits with the same code. `--json` prints one check object per change, in the shape of `results --json`, then the summary. Each line is written on its own, so a line watcher such as Claude Code's Monitor sees it at once. `follow` never writes `events.jsonl`.
|
|
11
|
+
- A failed or timed-out check prints its diagnostics before the log tail, as `file:line:col rule message` lines, when firstlight can read the tool's output. The parsers read the text of `tsc` (plain and `--pretty`), the vitest default reporter, `node --test` (spec and TAP reporters), and `archstrict check`. A test's diagnostic names the test's full name and the first stack frame inside the repository. With diagnostics, the log tail is 10 lines instead of 40. A check shows at most 20 diagnostics, then a count of the rest. In `--json`, each diagnostic is a `{"type":"diagnostic","check","file","line","col","rule","message"}` line after its check, and the check has `more_diagnostics` when some were left out. Without a parser, or with no diagnostics, the output does not change. This applies to `run`, `results`, and the failure lines of `follow`.
|
|
12
|
+
- `firstlight results --new` reports only the failures that the previous run did not have, and counts the rest: `not new: 2 failures already in the previous run, 3 passed`. The previous run of a check is the newest earlier kept run in which the same check, with the same `run` command, passed, failed, or timed out. A diagnostic is new when that run has none of the same check with the same file, rule, and message; the line and column do not count, so moved code is not new. A failure without diagnostics is new when the check did not fail in that run. Without a previous run, every failure is new, and one line says so. It exits 1 only for a new failure, or for a check that is still running, was cancelled, or is unknown. It works with `--wait`. With `--json`, each check gains `new` and `baseline` (and `old_diagnostics` when it failed), only new diagnostics are printed, and the summary gains `new`, `new_failures`, `old_failures`, and `no_baseline`.
|
|
13
|
+
- A vitest diagnostic shows the evidence file and the rerun command that depug's reporter printed for its test, as `evidence: <path>` and `rerun: <command>` lines under the diagnostic. In `--json`, the diagnostic line gets `evidence` and `rerun` fields. firstlight matches by the test file and the full test name in depug's rerun command; when two failed tests or two depug failures have the same file and name, or depug printed nothing for a test, it attaches nothing. A log without depug lines gives the same diagnostics as before. This applies to `run`, `results`, `results --new`, and `follow`.
|
|
14
|
+
- A check in `firstlight.json` takes `parser`: `tsc`, `vitest`, `node-test`, `archstrict`, or `none`. Any other value is a config error. Without it, firstlight infers the parser from a simple `run` command (`tsc`, `vitest`, `node --test`, `archstrict`), and from the script that `npm run <script>` or `npm test` runs, one level deep.
|
|
15
|
+
|
|
16
|
+
## 0.1.0 (2026-10-12)
|
|
17
|
+
|
|
18
|
+
The first version of firstlight.
|
|
19
|
+
|
|
20
|
+
- `firstlight run` starts every check from `firstlight.json` in parallel and returns at the first check that fails or times out. It prints that check's exit code, the last 40 lines of its log, the status of the other checks, and the next command. The other checks keep running. When every check passes, it prints one line and exits 0.
|
|
21
|
+
- `firstlight run <check>...` runs only the named checks and the checks they are `after`.
|
|
22
|
+
- `firstlight run` reads like npm-run-all's `run-p`: `firstlight run lint typecheck test` runs those `package.json` scripts in parallel as `npm run <script>`, and `firstlight run "lint:*"` runs every script that matches the pattern, in `package.json` order. `*` matches inside one `:`-separated segment, and `**` matches across segments. A check of the same name in `firstlight.json` comes first, with its `after` checks. An unknown name, or a pattern that matches no script, prints the error envelope with the configured checks and the scripts.
|
|
23
|
+
- A check can be any shell command, given on the command line: an argument with whitespace, like `firstlight run "bundle exec rubocop" "bundle exec rspec"`, or any argument after `--`, like `firstlight run lint -- make`. Its name is the command itself. The checked project needs no `package.json`.
|
|
24
|
+
- A log file name replaces each character outside `[A-Za-z0-9._-]` in the check name with `_`, and adds `-2`, `-3`, and so on when two checks of a run would share one file. The check name in the output stays as written.
|
|
25
|
+
- A second `firstlight run` stops the checks still running from the previous run before it starts.
|
|
26
|
+
- `firstlight results` reports the latest run, and `--wait` blocks until every check is done. It exits 1 when any check failed, timed out, was skipped or cancelled, or is unknown. `firstlight stop` cancels the running checks, and does nothing when none are running.
|
|
27
|
+
- A check can wait for other checks with `after`. If one of them does not pass, the check is `skipped`. A check that runs longer than `timeoutSec` (600 by default) is stopped and recorded as `timed_out`.
|
|
28
|
+
- Without `firstlight.json`, the `package.json` scripts `lint`, `typecheck`, `test`, and `archstrict` become checks.
|
|
29
|
+
- A detached supervisor per run starts the checks, each in its own process group, and records every status change in `events.jsonl` under `$XDG_STATE_HOME/firstlight/`. Nothing is written into the checked repository. The newest 20 runs are kept.
|
|
30
|
+
- If the supervisor dies before a check finishes, that check is reported as `unknown` with `orphan: true`, and no exit code is invented.
|
|
31
|
+
- 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. The estimate comes from the newest pass of the same check, with the same `run` command, in the kept runs. A running check past that duration says `past its usual` instead. Without that history, no estimate is shown. In `--json`, each check has `eta_ms` and `overdue`, and the summary has `eta_ms`.
|
|
32
|
+
- `firstlight run` reuses a pass instead of running the check again when a kept run passed it on the same working tree with the same command, and its `after` checks did the same. The working tree is a git tree hash of the tracked and untracked files, without ignored files, computed through a temporary index, so the real index and refs do not change. The text says `test passed (cached from run 01K..., 10.4s originally)`, the all-passed line counts the reused checks, and in `--json` each check has `cached_from`. Only a pass is reused. Environment variables and files outside the repository are not part of the comparison; `firstlight run --no-cache` runs every check anyway and still records its keys. Outside a git repository nothing is reused.
|
|
33
|
+
- `--json` prints JSON Lines: one object per check and one summary. A failure of firstlight itself prints `{"error","do"}` on stderr and exits 1.
|
|
34
|
+
|
|
35
|
+
Development:
|
|
36
|
+
|
|
37
|
+
- Runtime code still uses Node built-ins only. Development now uses TypeScript for `npm run typecheck`, vitest for `npm test`, Hegel property tests for the state machine, `after` validation, and run ids, archstrict for module boundaries and the layer order (`npm run archstrict`), Stryker with stryker-agent-reporter for mutation testing (`npm run mutation`), and depug, which prints an evidence file and a rerun command for each failed test. Every version is pinned exactly.
|
|
38
|
+
- The tests moved from `node:test` to vitest and assert the same behavior.
|
|
39
|
+
- `firstlight.json` makes firstlight check itself: `typecheck`, `test`, `archstrict`, and a scoped `mutation` run, in parallel.
|
|
40
|
+
- CI runs `npm ci`, `npm test`, `npm run typecheck`, and `npm run archstrict` on Node 24, with the actions pinned to commits.
|
|
41
|
+
- The package is ready to publish to npm as `firstlight`: `npm i -D firstlight` installs a `firstlight` bin. `npm run build` compiles `src/` to `dist/` with tsc, rewriting the `.ts` relative imports to `.js`, because Node does not strip types from files under `node_modules`. `prepack` runs the build, and the tarball holds `dist/`, `README.md`, `CHANGELOG.md`, `LICENSE`, and `package.json`. The supervisor starts from the same compiled file as the CLI.
|
|
42
|
+
- `test/pack.test.ts` packs the package, installs the tarball into a temporary project, and runs the installed bin: `--help`, and a `firstlight run` of a passing shell command.
|
|
43
|
+
- `.github/workflows/publish.yml` publishes a `v*` tag to npm through a trusted publisher after the GitHub Environment `publish` approves it, with provenance, then creates the GitHub release from that version's changelog section. `docs/releasing.md` lists the one-time bootstrap and the steps for each version. `SECURITY.md` points to private vulnerability reporting.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 meganemura
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,387 @@
|
|
|
1
|
-
#
|
|
1
|
+
# firstlight
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
firstlight is a parallel check runner for coding agents. An agent runs lint, typecheck, and tests through it.
|
|
4
|
+
|
|
5
|
+
firstlight starts every check at the same time and returns as soon as the first one fails, so the agent can start to fix it. The other checks keep running in the background, and the agent reads their results later. When the agent runs `firstlight run` again, firstlight first stops the checks that are still running from the previous run, then starts over.
|
|
6
|
+
|
|
7
|
+
`firstlight run` reads like npm-run-all's `run-p`, the parallel runner:
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
firstlight run lint typecheck test
|
|
11
|
+
firstlight run "lint:*" test
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
A check is any shell command, so a project does not need Node or a `package.json`. Node is only needed to run firstlight itself. A Ruby project can pass its commands directly:
|
|
15
|
+
|
|
16
|
+
```sh
|
|
17
|
+
firstlight run "bundle exec rubocop" "bundle exec rspec"
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
or keep them in `firstlight.json`:
|
|
21
|
+
|
|
22
|
+
```json
|
|
23
|
+
{
|
|
24
|
+
"checks": {
|
|
25
|
+
"rubocop": { "run": "bundle exec rubocop" },
|
|
26
|
+
"rspec": { "run": "bundle exec rspec", "timeoutSec": 900 }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
```
|
|
30
|
+
|
|
31
|
+
and run `firstlight run`, or `firstlight run rspec` for one of them.
|
|
32
|
+
|
|
33
|
+
## Agent flow
|
|
34
|
+
|
|
35
|
+
1. Start the checks:
|
|
36
|
+
|
|
37
|
+
```sh
|
|
38
|
+
firstlight run lint typecheck test
|
|
39
|
+
```
|
|
40
|
+
|
|
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
|
+
|
|
43
|
+
2. Fix the failure. Meanwhile, the other checks continue.
|
|
44
|
+
|
|
45
|
+
3. Read the rest:
|
|
46
|
+
|
|
47
|
+
```sh
|
|
48
|
+
firstlight results --wait
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
Or watch each check finish as it happens, with `firstlight follow`.
|
|
52
|
+
|
|
53
|
+
4. Run again. This cancels anything left from step 1:
|
|
54
|
+
|
|
55
|
+
```sh
|
|
56
|
+
firstlight run lint typecheck test
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Or run only what did not pass the last time:
|
|
60
|
+
|
|
61
|
+
```sh
|
|
62
|
+
firstlight run --failed
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Arguments of `run`
|
|
66
|
+
|
|
67
|
+
`run` is the parallel `run-p` equivalent. Each argument is one of:
|
|
68
|
+
|
|
69
|
+
| Argument | Meaning |
|
|
70
|
+
| --- | --- |
|
|
71
|
+
| a word, like `test` | The check of that name in `firstlight.json`, together with the checks it is `after`. Without such a check, the `package.json` script of that name, run as `npm run test`. |
|
|
72
|
+
| a pattern, like `"lint:*"` | Every `package.json` script it matches, in `package.json` order. As in npm-run-all, `*` matches inside one `:`-separated segment and `**` matches across segments: `lint:*` matches `lint:js` and `lint:css` but not `lint:js:fix`, and `test:**` matches all three levels. Only `*` is special. Quote a pattern so the shell does not expand it. |
|
|
73
|
+
| an argument with whitespace, like `"bundle exec rspec"` | A shell command. Its check name is the command itself. |
|
|
74
|
+
| any argument after `--` | A shell command, one per argument, even a single word: `firstlight run lint -- make "bundle exec rspec"` runs the `lint` check or script, `make`, and `bundle exec rspec`. Put `--cwd` and `--json` before `--`. |
|
|
75
|
+
|
|
76
|
+
The checks run in argument order, and a configured check's `after` checks come just before it. An argument repeated, or a pattern that matches a name already given, runs once. Two different checks under one name are refused. A word that is neither a configured check nor a script, and a pattern that matches no script, stop firstlight with the error envelope, which lists the configured checks and the scripts.
|
|
77
|
+
|
|
78
|
+
With no arguments, `firstlight run` runs every check in `firstlight.json`, or, without that file, the `package.json` scripts `lint`, `typecheck`, `test`, and `archstrict` that exist.
|
|
79
|
+
|
|
80
|
+
firstlight has no sequential `run-s` mode and none of npm-run-all's other flags. To run a check only after another passes, give it `after` in `firstlight.json`.
|
|
81
|
+
|
|
82
|
+
## Install
|
|
83
|
+
|
|
84
|
+
Requires Node.js 24 or newer. Add it to a project as a development dependency:
|
|
85
|
+
|
|
86
|
+
```sh
|
|
87
|
+
npm i -D firstlight
|
|
88
|
+
npx firstlight --help
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
Or run it once without adding it:
|
|
92
|
+
|
|
93
|
+
```sh
|
|
94
|
+
npx firstlight run
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
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
|
+
|
|
99
|
+
```sh
|
|
100
|
+
node src/cli.ts --help
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
## Config
|
|
104
|
+
|
|
105
|
+
`firstlight.json` at the repository root:
|
|
106
|
+
|
|
107
|
+
```json
|
|
108
|
+
{
|
|
109
|
+
"checks": {
|
|
110
|
+
"build": { "run": "npm run build" },
|
|
111
|
+
"types": { "run": "tsc --noEmit", "after": ["build"] },
|
|
112
|
+
"test": { "run": "node --test", "after": ["build"], "timeoutSec": 600 }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
```
|
|
116
|
+
|
|
117
|
+
| Key | Meaning |
|
|
118
|
+
| --- | --- |
|
|
119
|
+
| `run` | A command string. firstlight runs it with `/bin/sh -c` in the repository root. stdout and stderr go to one log file. |
|
|
120
|
+
| `after` | Checks that must pass before this one starts. If one of them does not pass, this check is `skipped`. |
|
|
121
|
+
| `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). |
|
|
123
|
+
|
|
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.
|
|
125
|
+
|
|
126
|
+
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
|
+
|
|
128
|
+
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
|
+
|
|
130
|
+
## Commands
|
|
131
|
+
|
|
132
|
+
```text
|
|
133
|
+
firstlight run [name|pattern|"command"...] [-- command...] [--no-cache] [--cwd DIR] [--json]
|
|
134
|
+
firstlight run --failed [--no-cache] [--cwd DIR] [--json]
|
|
135
|
+
firstlight results [--wait] [--new] [--cwd DIR] [--json]
|
|
136
|
+
firstlight follow [--cwd DIR] [--json]
|
|
137
|
+
firstlight stop [--cwd DIR] [--json]
|
|
138
|
+
firstlight --help
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
- `run` stops the previous run if it is still going, starts the checks, and returns at the first `failed` or `timed_out` check, or when every check is done. A check that already passed on the same working tree is reused instead of run; see [Reused passes](#reused-passes). `--no-cache` runs every selected check.
|
|
142
|
+
- `run --failed` runs only the checks that did not pass in the latest run; see [Running what failed](#running-what-failed).
|
|
143
|
+
- `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
|
+
- `follow` prints the status changes of the latest run as they happen; see [Following a run](#following-a-run).
|
|
145
|
+
- `stop` cancels the checks that are still running. With nothing running, it does nothing and exits 0.
|
|
146
|
+
|
|
147
|
+
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
|
+
|
|
149
|
+
```text
|
|
150
|
+
lint failed (exit 1, 0.4s)
|
|
151
|
+
log: /.../lint.log
|
|
152
|
+
src/a.ts:3:1 unused variable x
|
|
153
|
+
test running (3.5s, about 8.0s left)
|
|
154
|
+
types pending (about 12s left)
|
|
155
|
+
run 01K...: 1 failed, 2 running
|
|
156
|
+
next: firstlight results --wait (about 12s left)
|
|
157
|
+
```
|
|
158
|
+
|
|
159
|
+
The estimate comes from the `elapsed_ms` of the newest `passed` result of a check with the same name and the same `run` command in an earlier kept run. This holds for script and command checks too: `lint:js` is the command `npm run lint:js` every time. A failed, timed-out, or cancelled result does not count, because it stopped early. A running check has that duration minus the time it has run. When it has already run longer, the line says `past its usual 9.0s` instead. A pending check has its own duration plus the longest time left of its `after` checks. The run has the longest time left of its unfinished checks. When a check, or one of its `after` checks, has no such history, firstlight shows no estimate for it, and none for the run. Times below 10 seconds keep one decimal; longer times are whole seconds.
|
|
160
|
+
|
|
161
|
+
## Running what failed
|
|
162
|
+
|
|
163
|
+
`firstlight run --failed` runs the checks of the latest run that did not pass: `failed`, `timed_out`, `skipped`, `cancelled`, and `unknown`. If the latest run is still going, it is stopped first, so a check that stop cancels runs again too. Each check resolves the way its name does in `firstlight run <name>`, against the current `firstlight.json`: a configured check comes with the checks it is `after`, and a reused pass still applies, so an `after` check that passed on the same working tree is not run again. A `package.json` script or a shell command that was given on the command line runs the same command again. A configured check that has since been removed from `firstlight.json` is left out.
|
|
164
|
+
|
|
165
|
+
When there is no run yet, or every check of the latest run passed, `run --failed` starts nothing, prints the error envelope with `"do":"firstlight run"`, and exits 1, because there is nothing it could prove. The same happens when every check that did not pass has been removed from `firstlight.json`. `--failed` takes no check names.
|
|
166
|
+
|
|
167
|
+
The latest run is the one `--failed` reads, so after `firstlight run --failed`, the next `--failed` narrows to what still fails among those checks. Run `firstlight run` to run every check again.
|
|
168
|
+
|
|
169
|
+
## Following a run
|
|
170
|
+
|
|
171
|
+
`firstlight follow` attaches to the latest run and prints one line for each status change of each check, starting from the first one, so a late caller sees the whole history. It then prints the summary lines that `results --wait` prints and exits with the code `results --wait` would give:
|
|
172
|
+
|
|
173
|
+
```text
|
|
174
|
+
lint running
|
|
175
|
+
test running
|
|
176
|
+
lint failed (exit 1, 0.4s)
|
|
177
|
+
log: /.../lint.log
|
|
178
|
+
src/a.ts:3:1 unused variable x
|
|
179
|
+
test passed (exit 0, 10.4s)
|
|
180
|
+
run 01K...: 1 failed, 1 passed
|
|
181
|
+
next: firstlight run
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
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.
|
|
185
|
+
|
|
186
|
+
Each line is written as it happens, with no buffering, so a tool that watches a command's output line by line, such as Claude Code's Monitor, sees each check finish. `follow` only reads the state; the supervisor stays the only writer of `events.jsonl`. With no run yet, it prints the error envelope with `"do":"firstlight run"`.
|
|
187
|
+
|
|
188
|
+
## Only new failures
|
|
189
|
+
|
|
190
|
+
`firstlight results --new` reports only the failures that were not there before, so a project that already has failures can still gate a change on not adding one:
|
|
191
|
+
|
|
192
|
+
```text
|
|
193
|
+
typecheck failed (exit 2, 1.4s)
|
|
194
|
+
src/a.ts:4:26 TS2304 Cannot find name 'missing'.
|
|
195
|
+
(1 diagnostic already in run 01K...)
|
|
196
|
+
log: /.../typecheck.log
|
|
197
|
+
...
|
|
198
|
+
not new: 1 failure already in the previous run, 2 passed
|
|
199
|
+
run 01K...: 1 new failure
|
|
200
|
+
next: firstlight run
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
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.
|
|
204
|
+
|
|
205
|
+
A failure is new when:
|
|
206
|
+
|
|
207
|
+
- for a check with [diagnostics](#diagnostics): no diagnostic of the same check in the previous run has the same file, rule, and message. The line and column do not count, so code that only moved is not new. The cost is that a second copy of a known error, in the same file with the same rule and message, is not new either.
|
|
208
|
+
- for a failed or timed-out check without diagnostics: the check did not fail or time out in the previous run.
|
|
209
|
+
- for a check with no previous run at all: always. The output says so in one line: `no earlier run to compare with, so every failure counts as new`, or `no earlier result for lint, so its failures count as new` when only some checks have none.
|
|
210
|
+
|
|
211
|
+
The output lists each check with a new failure, with only its new diagnostics, and each check that proved nothing yet (`pending`, `running`, `cancelled`, or `unknown`). Passed checks, skipped checks, and failures that were already there are counts on the `not new:` line. A skipped check is judged through the `after` check whose failure skipped it.
|
|
212
|
+
|
|
213
|
+
`--new` works with `--wait`, and the gate use waits for every check: `firstlight results --new --wait`. It exits 1 when there is a new failure, or when a check is still `pending` or `running`, was `cancelled`, or is `unknown`, because those checks prove nothing. Otherwise it exits 0, also when the run has failures that were already there.
|
|
214
|
+
|
|
215
|
+
With `--json`, every check object of `results --json` comes, with two more fields: `new` (`true` when the check has a new failure) and `baseline` (the id of the run it was compared with, or `null`). A failed check also has `old_diagnostics`, the number of its diagnostics that were already there. Only the new diagnostics follow a check, and `more_diagnostics` counts the new ones past 20. The summary has four more fields: `new` (the checks with a new failure), `new_failures` and `old_failures` (counts, where a failure is one diagnostic, or one failed check without diagnostics), and `no_baseline` (the checks with no previous run).
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{"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
|
+
{"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":[]}
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## Diagnostics
|
|
224
|
+
|
|
225
|
+
When a check fails or times out, firstlight reads its log with the check's parser, if it has one, and prints the problems the tool reported in one shape, before the log tail:
|
|
226
|
+
|
|
227
|
+
```text
|
|
228
|
+
typecheck failed (exit 2, 1.4s)
|
|
229
|
+
src/a.ts:1:14 TS2322 Type 'number' is not assignable to type 'string'.
|
|
230
|
+
src/a.ts:2:26 TS2304 Cannot find name 'missing'.
|
|
231
|
+
log: /.../typecheck.log
|
|
232
|
+
> app@1.0.0 typecheck
|
|
233
|
+
> tsc --noEmit
|
|
234
|
+
...
|
|
235
|
+
```
|
|
236
|
+
|
|
237
|
+
Each line is `file:line:col rule message`. A part the tool does not report is left out. With diagnostics, the log tail is 10 lines instead of 40, because the diagnostics carry the substance. A check shows at most 20 diagnostics, then `(N more diagnostics in the log)`. Without a parser, or when the parser finds nothing, the output is the log tail as before.
|
|
238
|
+
|
|
239
|
+
| Parser | Reads | `rule` |
|
|
240
|
+
| --- | --- | --- |
|
|
241
|
+
| `tsc` | `path(line,col): error TSxxxx: message`, and the `--pretty` form `path:line:col - error TSxxxx: message` | the `TSxxxx` code |
|
|
242
|
+
| `vitest` | the default reporter: each `FAIL` block, its error, and the first stack frame inside the repository | the test's full name, `suite > test`; none for a file that failed to load |
|
|
243
|
+
| `node-test` | `node --test` with the spec reporter (its `failing tests` section) or the TAP reporter; the first stack frame inside the repository | the test's full name, `suite > test` |
|
|
244
|
+
| `archstrict` | the text of `archstrict check`: `[rule] path:line:col` and the evidence line | the rule id |
|
|
245
|
+
|
|
246
|
+
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
|
+
|
|
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.
|
|
249
|
+
|
|
250
|
+
In `--json`, the diagnostics follow their check's object, one line each:
|
|
251
|
+
|
|
252
|
+
```json
|
|
253
|
+
{"type":"check","name":"typecheck","status":"failed","exit_code":2,"elapsed_ms":1412,"log":"/.../typecheck.log","eta_ms":0,"overdue":false,"cached_from":null,"tail":["> app@1.0.0 typecheck","> tsc --noEmit","..."]}
|
|
254
|
+
{"type":"diagnostic","check":"typecheck","file":"src/a.ts","line":1,"col":14,"rule":"TS2322","message":"Type 'number' is not assignable to type 'string'."}
|
|
255
|
+
{"type":"diagnostic","check":"typecheck","file":"src/a.ts","line":2,"col":26,"rule":"TS2304","message":"Cannot find name 'missing'."}
|
|
256
|
+
```
|
|
257
|
+
|
|
258
|
+
`line`, `col`, and `rule` are `null` when the tool has none. When a check has more than 20 diagnostics, its object has `more_diagnostics`, the number left out.
|
|
259
|
+
|
|
260
|
+
### depug evidence and rerun
|
|
261
|
+
|
|
262
|
+
When the vitest run uses [depug](https://www.npmjs.com/package/@meganemura/depug)'s reporter, depug prints an evidence file and a rerun command for each failed test. firstlight attaches both to that test's diagnostic, as two lines under it:
|
|
263
|
+
|
|
264
|
+
```text
|
|
265
|
+
test failed (exit 1, 3.2s)
|
|
266
|
+
test/names.test.ts:6:10 quotes a word with a space AssertionError: Expected values to be strictly equal: "'a b'" !== 'a b'
|
|
267
|
+
evidence: /.../tmp/depug/run-20261012-101024-1110/001-quotes-a-word-with-a-space.json
|
|
268
|
+
rerun: npx depug rerun -- npx vitest run "test/names.test.ts" -t "^quotes a word with a space$"
|
|
269
|
+
log: /.../test.log
|
|
270
|
+
```
|
|
271
|
+
|
|
272
|
+
In `--json`, the diagnostic line gets `evidence` (the absolute path) and `rerun` (the command) as two more fields. A diagnostic without them has neither field.
|
|
273
|
+
|
|
274
|
+
firstlight matches by the test file and the full test name in depug's rerun command, against the file and the name in vitest's `FAIL` header. vitest joins the names with ` > ` and depug with a space, so two tests can look the same (`a > b c` and `a b > c`). When two failed tests, or two depug failures, have the same file and name, or when depug printed nothing for a test, firstlight attaches nothing to it. A log without depug lines gives the same diagnostics as before. This applies everywhere diagnostics show: `run`, `results`, `results --new`, and `follow`.
|
|
275
|
+
|
|
276
|
+
## Reused passes
|
|
277
|
+
|
|
278
|
+
When a check already passed in a kept run on the same working tree, with the same command, `firstlight run` does not run it again. It reports the earlier pass:
|
|
279
|
+
|
|
280
|
+
```text
|
|
281
|
+
test passed (cached from run 01K..., 10.4s originally)
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
If every selected check is reused, `run` starts no check and exits 0 at once: `passed 4 checks in 0.2s (4 cached, run 01K...)`. A check that is `after` a reused check starts right away. This saves the slow checks when an agent runs again after changing nothing, or after a failure in a different check.
|
|
285
|
+
|
|
286
|
+
firstlight compares a cache key. The key of a check is the SHA-256 of:
|
|
287
|
+
|
|
288
|
+
- the tree key: the git tree hash of the working tree, tracked and untracked files, without ignored files. firstlight computes it with `git add -A` and `git write-tree` against a temporary index of its own, so the real index, the staged changes, and the refs do not change. The blobs of changed files go into the object store as loose objects, which `git gc` removes later like any other unreferenced object;
|
|
289
|
+
- the check's `run` command;
|
|
290
|
+
- the keys of its `after` checks.
|
|
291
|
+
|
|
292
|
+
The name of a check and its `timeoutSec` are not part of the key. `firstlight.json` is part of the tree unless it is ignored, so an edit to it runs every check again.
|
|
293
|
+
|
|
294
|
+
Only a `passed` result is reused. A `failed`, `timed_out`, `cancelled`, `skipped`, or `unknown` check runs again. firstlight finds a pass in the kept runs, newest first, so a pruned run stops being a source. A pass that was itself reused names the run that really ran the check, with that run's time, so a chain of unchanged runs keeps one origin.
|
|
295
|
+
|
|
296
|
+
The limits:
|
|
297
|
+
|
|
298
|
+
- Environment variables are not part of the key. A check whose result depends on one gives the earlier result after that variable changes.
|
|
299
|
+
- Files outside the repository, ignored files (for example `node_modules/`), the installed tools, and the state of a network service are not part of the key.
|
|
300
|
+
- The tree is hashed once, when the run starts. An edit made while a check runs is not in the key of that run.
|
|
301
|
+
- A directory that is not the root of a git repository has no tree key, and nothing is reused there. A git failure has the same effect.
|
|
302
|
+
|
|
303
|
+
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
|
+
|
|
305
|
+
## Statuses and output
|
|
306
|
+
|
|
307
|
+
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.
|
|
308
|
+
|
|
309
|
+
The default output is short plain text with no colors. `--json` prints JSON Lines: one object per check, then one summary:
|
|
310
|
+
|
|
311
|
+
```json
|
|
312
|
+
{"type":"check","name":"lint","status":"failed","exit_code":1,"elapsed_ms":205,"log":"/.../lint.log","eta_ms":0,"overdue":false,"cached_from":null,"tail":["src/a.ts:3:1 unused variable x"]}
|
|
313
|
+
{"type":"check","name":"test","status":"running","exit_code":null,"elapsed_ms":null,"log":"/.../test.log","eta_ms":8012,"overdue":false,"cached_from":null}
|
|
314
|
+
{"type":"check","name":"build","status":"passed","exit_code":0,"elapsed_ms":10412,"log":"/.../build.log","eta_ms":0,"overdue":false,"cached_from":"01K..."}
|
|
315
|
+
{"type":"summary","run":"01K...","failed":["lint"],"running":["test"],"passed":["build"],"cancelled":[],"next":"firstlight results --wait","eta_ms":8012}
|
|
316
|
+
```
|
|
317
|
+
|
|
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`.
|
|
319
|
+
|
|
320
|
+
## Exit codes
|
|
321
|
+
|
|
322
|
+
| Command | 0 | 1 |
|
|
323
|
+
| --- | --- | --- |
|
|
324
|
+
| `run` | every check passed | any check did not pass |
|
|
325
|
+
| `results`, `follow` | no check is `failed`, `timed_out`, `skipped`, `cancelled`, or `unknown` | at least one is |
|
|
326
|
+
| `results --new` | no failure is new, and no check is `pending`, `running`, `cancelled`, or `unknown` | a failure is new, or a check proved nothing yet |
|
|
327
|
+
| `stop` | the run stopped, or nothing was running | firstlight itself failed |
|
|
328
|
+
|
|
329
|
+
When firstlight itself fails (bad config, unknown command, no run yet), it exits 1 and prints one JSON object on stderr:
|
|
330
|
+
|
|
331
|
+
```json
|
|
332
|
+
{"error":"invalid firstlight.json: check \"b\" is after unknown check \"nope\"","do":"fix /repo/firstlight.json, then rerun the command"}
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
Do what `do` says.
|
|
336
|
+
|
|
337
|
+
## State
|
|
338
|
+
|
|
339
|
+
firstlight never writes into the repository it checks. State lives under:
|
|
340
|
+
|
|
341
|
+
```text
|
|
342
|
+
${XDG_STATE_HOME:-~/.local/state}/firstlight/<repo-key>/
|
|
343
|
+
current the latest run id
|
|
344
|
+
runs/<run-id>/run.json the run: root, supervisor pid, start time, checks,
|
|
345
|
+
cache keys, and the passes it reused
|
|
346
|
+
runs/<run-id>/events.jsonl every status change, appended by the supervisor
|
|
347
|
+
runs/<run-id>/<check>.log stdout and stderr of one check
|
|
348
|
+
runs/<run-id>/supervisor.log stderr of the supervisor itself
|
|
349
|
+
```
|
|
350
|
+
|
|
351
|
+
The log file name of a check is its name with each character outside `[A-Za-z0-9._-]` replaced by `_`, cut to 100 characters: `lint:js` writes `lint_js.log`, and `bundle exec rspec` writes `bundle_exec_rspec.log`. The name in the output and in `--json` stays as written. When two checks of one run would share a file name, ignoring case, or a check would take `supervisor.log`, one of them gets `-2`, `-3`, and so on; a name that is already a safe file name keeps its own file. The `log` field in `--json` always gives the real path.
|
|
352
|
+
|
|
353
|
+
`<repo-key>` is the first 16 hex characters of the SHA-256 of the repository root path. Run ids sort by start time. firstlight keeps the newest 20 runs.
|
|
354
|
+
|
|
355
|
+
Each run has one detached supervisor process. It records the reused passes, starts the other checks, waits on them, enforces timeouts, and writes `events.jsonl`. There is no long-lived daemon. Each check runs in its own process group, so a stop or a timeout reaches every process the check started.
|
|
356
|
+
|
|
357
|
+
## Non-goals for v0.1
|
|
358
|
+
|
|
359
|
+
- Diagnostics come only from the four parsers above. Any other tool shows its log tail as it is.
|
|
360
|
+
- No cache beyond reused passes. Earlier runs feed the time-left estimate, the passes a run reuses, and the comparison of `results --new`, and nothing else.
|
|
361
|
+
- No daemon, no queue, and no watch mode.
|
|
362
|
+
|
|
363
|
+
## Development
|
|
364
|
+
|
|
365
|
+
```sh
|
|
366
|
+
npm ci
|
|
367
|
+
npm test # vitest, with Hegel property tests
|
|
368
|
+
npm run typecheck # tsc --noEmit; Node runs the sources directly
|
|
369
|
+
npm run archstrict # module boundaries and the layer order
|
|
370
|
+
npm run mutation # Stryker, with the agent reporter
|
|
371
|
+
npm run build # tsc to dist/, the JavaScript the package ships
|
|
372
|
+
npm pack --dry-run # the tarball's file list; prepack runs the build
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
`test/pack.test.ts` packs the package, installs the tarball into a temporary project, and runs the installed `firstlight` bin, so `npm test` covers the published form too. Releases follow [docs/releasing.md](docs/releasing.md).
|
|
376
|
+
|
|
377
|
+
A failed test prints a depug evidence file and the command that reruns that one test.
|
|
378
|
+
|
|
379
|
+
firstlight checks itself. `firstlight.json` in this repository runs `typecheck`, `test`, `archstrict`, and a `mutation` run scoped to `src/checks.ts` and `src/config.ts`, all in parallel:
|
|
380
|
+
|
|
381
|
+
```sh
|
|
382
|
+
node src/cli.ts run
|
|
383
|
+
```
|
|
384
|
+
|
|
385
|
+
## License
|
|
386
|
+
|
|
387
|
+
MIT
|
package/dist/baseline.js
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
// Which failures of a run are new: the earlier result each check is
|
|
2
|
+
// compared with, and the comparison of the two. It only computes; reading
|
|
3
|
+
// the earlier runs is the store's, and parsing and printing their logs is
|
|
4
|
+
// the report's. No I/O.
|
|
5
|
+
//
|
|
6
|
+
// A diagnostic is identified by its file, rule, and message, without its
|
|
7
|
+
// line and column, so code that only moved does not count as a new
|
|
8
|
+
// failure. The cost is that a second copy of a known error in the same
|
|
9
|
+
// file, with the same rule and message, is not new either.
|
|
10
|
+
export function isFailure(status) {
|
|
11
|
+
return status === "failed" || status === "timed_out";
|
|
12
|
+
}
|
|
13
|
+
// The baseline of a check is the newest earlier kept run in which a check
|
|
14
|
+
// with the same name and the same `run` command passed, failed, or timed
|
|
15
|
+
// out. Per check rather than one run for all, because `run --failed` and
|
|
16
|
+
// `run <name>` leave runs that hold only some of the checks. A cancelled,
|
|
17
|
+
// skipped, or unfinished result proved nothing either way, so it is passed
|
|
18
|
+
// over; a changed command is a different check that shares the name.
|
|
19
|
+
export function findBaselines(specs, newestFirst) {
|
|
20
|
+
const baselines = new Map();
|
|
21
|
+
for (const spec of specs) {
|
|
22
|
+
for (const past of newestFirst) {
|
|
23
|
+
const pastSpec = past.checks.find((check) => check.name === spec.name);
|
|
24
|
+
if (pastSpec === undefined || pastSpec.run !== spec.run)
|
|
25
|
+
continue;
|
|
26
|
+
const status = past.states.find((check) => check.name === spec.name)?.status;
|
|
27
|
+
if (status === "passed" || status === "failed" || status === "timed_out") {
|
|
28
|
+
baselines.set(spec.name, { run: past.id, status });
|
|
29
|
+
break;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return baselines;
|
|
34
|
+
}
|
|
35
|
+
export function diagnosticKey(diagnostic) {
|
|
36
|
+
return JSON.stringify([diagnostic.file, diagnostic.rule, diagnostic.message]);
|
|
37
|
+
}
|
|
38
|
+
// One entry per failed check of the latest run, in its order. A failure
|
|
39
|
+
// with diagnostics is compared diagnostic by diagnostic; a failure without
|
|
40
|
+
// them is new when its baseline did not fail. A check without a baseline
|
|
41
|
+
// has only new failures.
|
|
42
|
+
export function compareFailures(latest, baselines) {
|
|
43
|
+
const comparisons = [];
|
|
44
|
+
for (const check of latest) {
|
|
45
|
+
if (!check.failed)
|
|
46
|
+
continue;
|
|
47
|
+
const baseline = baselines.get(check.name);
|
|
48
|
+
if (check.diagnostics.length === 0) {
|
|
49
|
+
comparisons.push({ name: check.name, isNew: baseline?.failed !== true, newDiagnostics: [], oldDiagnostics: 0 });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const known = new Set(baseline?.failed === true ? baseline.diagnostics.map(diagnosticKey) : []);
|
|
53
|
+
const newDiagnostics = check.diagnostics.filter((diagnostic) => !known.has(diagnosticKey(diagnostic)));
|
|
54
|
+
comparisons.push({
|
|
55
|
+
name: check.name,
|
|
56
|
+
isNew: newDiagnostics.length > 0,
|
|
57
|
+
newDiagnostics,
|
|
58
|
+
oldDiagnostics: check.diagnostics.length - newDiagnostics.length,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
return comparisons;
|
|
62
|
+
}
|
|
63
|
+
// A failure is one diagnostic, or one failed check that has none.
|
|
64
|
+
export function countFailures(comparisons) {
|
|
65
|
+
let fresh = 0;
|
|
66
|
+
let old = 0;
|
|
67
|
+
for (const comparison of comparisons) {
|
|
68
|
+
if (comparison.newDiagnostics.length === 0 && comparison.oldDiagnostics === 0) {
|
|
69
|
+
if (comparison.isNew)
|
|
70
|
+
fresh += 1;
|
|
71
|
+
else
|
|
72
|
+
old += 1;
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
fresh += comparison.newDiagnostics.length;
|
|
76
|
+
old += comparison.oldDiagnostics;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
return { new: fresh, old };
|
|
80
|
+
}
|
|
81
|
+
// A check that proved nothing yet: still to finish, stopped, or left
|
|
82
|
+
// without a result by its supervisor. A skipped check is not one of these:
|
|
83
|
+
// it is judged through the `after` check whose failure skipped it.
|
|
84
|
+
export function isUnproven(status) {
|
|
85
|
+
return status === "pending" || status === "running" || status === "cancelled" || status === "unknown";
|
|
86
|
+
}
|
package/dist/cache.js
ADDED
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// Cache keys for checks and the lookup of a pass that an earlier run
|
|
2
|
+
// already proved. A key names exactly what a pass depends on as far as
|
|
3
|
+
// firstlight can see: the working tree, the check's command, and the keys
|
|
4
|
+
// of its `after` checks. Computing the tree key is tree.ts's, and reading
|
|
5
|
+
// earlier runs is the store's. No I/O.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
// Bumped when the inputs of a key change meaning, so an old key can never
|
|
8
|
+
// match a new one by accident.
|
|
9
|
+
const KEY_VERSION = "firstlight-cache-1";
|
|
10
|
+
// The check's name is left out: two names for one command on one tree
|
|
11
|
+
// prove the same thing. timeoutSec is left out too: a pass that finished
|
|
12
|
+
// in time is a pass under any limit that let it finish. Environment
|
|
13
|
+
// variables are left out because firstlight cannot know which ones a
|
|
14
|
+
// command reads.
|
|
15
|
+
export function cacheKey(tree, run, depKeys) {
|
|
16
|
+
const deps = [...depKeys].sort();
|
|
17
|
+
return createHash("sha256").update(JSON.stringify([KEY_VERSION, tree, run, deps])).digest("hex");
|
|
18
|
+
}
|
|
19
|
+
// The after graph is acyclic once config.ts accepted it, so the recursion
|
|
20
|
+
// ends. A dependency outside `specs` cannot come from config.ts or
|
|
21
|
+
// resolveRun; it still gets a key of its own rather than none.
|
|
22
|
+
export function cacheKeys(specs, tree) {
|
|
23
|
+
const byName = new Map(specs.map((spec) => [spec.name, spec]));
|
|
24
|
+
const keys = new Map();
|
|
25
|
+
const keyOf = (name) => {
|
|
26
|
+
const known = keys.get(name);
|
|
27
|
+
if (known !== undefined)
|
|
28
|
+
return known;
|
|
29
|
+
const spec = byName.get(name);
|
|
30
|
+
const key = spec === undefined ? cacheKey(tree, `missing check ${name}`, []) : cacheKey(tree, spec.run, spec.after.map(keyOf));
|
|
31
|
+
keys.set(name, key);
|
|
32
|
+
return key;
|
|
33
|
+
};
|
|
34
|
+
for (const spec of specs)
|
|
35
|
+
keyOf(spec.name);
|
|
36
|
+
return new Map(specs.map((spec) => [spec.name, keys.get(spec.name)]));
|
|
37
|
+
}
|
|
38
|
+
// Only a pass is reused: a failure may be flaky, and a timed-out, cancelled,
|
|
39
|
+
// skipped, or unknown check proved nothing. A pass that was itself reused
|
|
40
|
+
// points at the run that really ran the check, so a chain of unchanged runs
|
|
41
|
+
// keeps one origin and its real duration.
|
|
42
|
+
export function findReuse(keys, newestFirst) {
|
|
43
|
+
const found = new Map();
|
|
44
|
+
for (const [name, key] of keys) {
|
|
45
|
+
for (const past of newestFirst) {
|
|
46
|
+
const state = past.states.find((candidate) => past.keys[candidate.name] === key && candidate.status === "passed");
|
|
47
|
+
if (state === undefined)
|
|
48
|
+
continue;
|
|
49
|
+
found.set(name, { run: state.cached_from ?? past.id, elapsed_ms: state.elapsed_ms ?? 0 });
|
|
50
|
+
break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return found;
|
|
54
|
+
}
|