@bluewombat/slots 0.10.0 → 0.11.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/README.md +76 -50
- package/builders/{e2e.mjs → feature-test.mjs} +27 -17
- package/gates/base-diff.mjs +276 -0
- package/gates/ci-green.mjs +83 -3
- package/gates/feature-tests.mjs +280 -0
- package/gates/tests-only.mjs +65 -0
- package/package.json +2 -2
- package/builders/tests.mjs +0 -139
- package/gates/e2e.mjs +0 -538
- package/gates/tests-frozen.mjs +0 -96
- package/gates/tests-red.mjs +0 -227
package/README.md
CHANGED
|
@@ -57,8 +57,7 @@ these all use it. How Host wires a slot into a run:
|
|
|
57
57
|
| ------------------------------- | --------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
58
58
|
| `agents/cursor.mjs` | Agent | Runs Cursor CLI with a filled prompt |
|
|
59
59
|
| `agents/claude.mjs` | Agent | Runs Claude Code with a filled prompt |
|
|
60
|
-
| `builders/
|
|
61
|
-
| `builders/e2e.mjs` | Builder | The Ticket that holds a Feature's e2e acceptance suite: one test per scenario of the task, named after its id, and the manifest `e2e/scenarios.json`; nothing outside the suite folder, the suite never run. One command for the first pass and a refused one |
|
|
60
|
+
| `builders/feature-test.mjs` | Builder | The Ticket that holds a Feature's tests: one test per scenario of the task, named after its id, existing tests updated when a scenario changes them, and the manifest `feature-tests/scenarios.json`; tests never run. One command for the first pass and a refused one |
|
|
62
61
|
| `builders/producer.mjs` | Builder | First pass of the unit of work: fill `{{task}}`, then spawn the agent |
|
|
63
62
|
| `builders/repair.mjs` | Builder | A Gate refused the unit: fill `{{task}}` and `{{report}}` |
|
|
64
63
|
| `ticket/fix.mjs` | Builder | A judgement refused the assembled ticket: fill `{{task}}` and `{{report}}` |
|
|
@@ -68,12 +67,12 @@ these all use it. How Host wires a slot into a run:
|
|
|
68
67
|
| `gates/workspace-changed.mjs` | Gate | The Attempt left an uncommitted change |
|
|
69
68
|
| `gates/parent-clean.mjs` | Gate | Isolator's Parent working files did not move |
|
|
70
69
|
| `gates/sensitive-path.mjs` | Gate | No path matching a glob was touched |
|
|
70
|
+
| `gates/base-diff.mjs` | Gate | What the work changed since the line (`--base`): no denied path was touched, or a command holds when a path did |
|
|
71
|
+
| `gates/tests-only.mjs` | Gate | Every path the workspace changed matches a glob: the tests, their helpers and the manifest, nothing else |
|
|
71
72
|
| `gates/workspace-unchanged.mjs` | Gate | A read-only pass changed nothing at all (a file modified, deleted or added) |
|
|
72
73
|
| `gates/gitignore-leak.mjs` | Gate | The ticket's commits add nothing its own `.gitignore` excludes |
|
|
73
74
|
| `gates/project-checks.mjs` | Gate | One command of the Project's own (typecheck, tests, lint, build) exits 0 in the workspace |
|
|
74
|
-
| `gates/tests
|
|
75
|
-
| `gates/tests-frozen.mjs` | Gate | A test file held by the "Tests: " checkpoint was not modified, deleted or renamed (`--test-glob`, `--subject-prefix`) |
|
|
76
|
-
| `gates/e2e.mjs` | Gate | The Feature's acceptance suite, run once on the assembled line: the Project's `--up` / `--ready` / `--run` / `--down`, a JUnit report compared with the manifest of scenario ids, each failing test retried once. |
|
|
75
|
+
| `gates/feature-tests.mjs` | Gate | The Feature's own tests, run once on the assembled line: the Project's `--run` filtered on the ids of the manifest, a JUnit report compared with them. |
|
|
77
76
|
| `gates/ci-green.mjs` | Gate | The work line's own checks came back green |
|
|
78
77
|
| `publishers/git.mjs` | Publisher | Pushes the ticket's branch to the work line's own remote |
|
|
79
78
|
| `refreshers/git.mjs` | Refresher | Fast-forwards the work line copy onto what the Authority holds |
|
|
@@ -407,11 +406,52 @@ the workspace — tracked paths, and new ones `.gitignore` does not exclude —
|
|
|
407
406
|
what an install left under an ignored directory (a dependency's own `.github/`)
|
|
408
407
|
is not a change to guard.
|
|
409
408
|
|
|
409
|
+
`base-diff` is `sensitive-path` against the work line instead of `HEAD`. It takes the
|
|
410
|
+
paths that differ between the base and the workspace — the commits since the workspace
|
|
411
|
+
left it (`BASE...HEAD`) and what is on disk and not committed, an ignored path excluded —
|
|
412
|
+
so it still sees the work after the commit, and answers at the unit, the ticket and the
|
|
413
|
+
recheck (`--stage assembly`). The base is the branch checked out in the **main worktree**
|
|
414
|
+
of the repository the workspace belongs to: Isolation makes every workspace a worktree of
|
|
415
|
+
the work line's copy, and Host keeps that copy on the line the work lands on, a
|
|
416
|
+
Feature's own line while a Feature is open. In a Ticket of a Feature the diff is therefore
|
|
417
|
+
the Ticket's own, not the Feature's so far. `--base REF` names another ref instead; a
|
|
418
|
+
hand-named `main` is the copy's local branch, which Host does not move (the Refresher
|
|
419
|
+
moves the Feature's line), so it can be weeks old. Two uses, alone or together:
|
|
420
|
+
|
|
421
|
+
```yaml
|
|
422
|
+
- id: no-workflow-edit
|
|
423
|
+
argv: [node, ./node_modules/@bluewombat/slots/gates/base-diff.mjs, --deny, ".github/workflows/**"]
|
|
424
|
+
- id: e2e-when-app-changes
|
|
425
|
+
timeoutMs: 600000
|
|
426
|
+
argv:
|
|
427
|
+
- node
|
|
428
|
+
- ./node_modules/@bluewombat/slots/gates/base-diff.mjs
|
|
429
|
+
- --when
|
|
430
|
+
- "src/**"
|
|
431
|
+
- --run
|
|
432
|
+
- "npm run e2e"
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
`--deny GLOB` (repeatable) fail-retries when a changed path matches. `--when GLOB`
|
|
436
|
+
(repeatable) with `--run COMMAND` hands the command to `project-checks` when a changed path
|
|
437
|
+
matches (so it is cleaned, bounded by the Gate's ceiling and classified as every command of
|
|
438
|
+
the Project is) and answers as it does; `MASON_BASE` is the base and `MASON_CHANGED_FILES`
|
|
439
|
+
the changed paths one per line. When nothing matches it passes without running it. It
|
|
440
|
+
fail-blocks where there is no base to derive: the workspace is the main worktree itself,
|
|
441
|
+
the main worktree has no branch (detached), a `--base` the workspace lacks, and
|
|
442
|
+
`--stage feature` without `--base` — the Feature's workspace is its line with the target
|
|
443
|
+
merged in, so the line would show the target's work; name the target there.
|
|
444
|
+
|
|
445
|
+
`tests-only` is its counterpart: it fail-retries if the workspace changed a path that matches
|
|
446
|
+
none of the globs (added, modified, deleted or renamed, tracked or new and not ignored). It is
|
|
447
|
+
the Gate of the feature-test Ticket, whose tests sit among the Project's code
|
|
448
|
+
(`**/*.test.ts`), where a list of denied paths cannot say "everything but tests".
|
|
449
|
+
|
|
410
450
|
`project-checks --cmd COMMAND` runs one command of the Project's own in the workspace
|
|
411
451
|
and answers on its exit code. It is the Gate that makes a machine, not the Builder,
|
|
412
452
|
say that the code typechecks and the tests pass. The command goes through the shell,
|
|
413
453
|
so it is written as the Project writes it; it knows no stack.
|
|
414
|
-
The report carries the end of the output, cleaned as it arrives: escape codes removed, a line redrawn with `\r` reduced to its last state, and spinner or progress lines (`[⣷] Pulling schema...`) dropped, so thousands of redraws cannot push the real error out of the tail. `tests
|
|
454
|
+
The report carries the end of the output, cleaned as it arrives: escape codes removed, a line redrawn with `\r` reduced to its last state, and spinner or progress lines (`[⣷] Pulling schema...`) dropped, so thousands of redraws cannot push the real error out of the tail. `feature-tests` reads its output the same way.
|
|
415
455
|
|
|
416
456
|
```yaml
|
|
417
457
|
- id: typecheck
|
|
@@ -502,70 +542,56 @@ the variable holding a token that can read the checks — the same one the
|
|
|
502
542
|
manager's `tokenEnv` names. It has no default on purpose: `GITHUB_TOKEN` is
|
|
503
543
|
what `gh` reads ahead of its own login, so an operator must not export it,
|
|
504
544
|
and a Gate that fell back to it refused only at the first assembly of a real
|
|
505
|
-
run. `--remote`, `--poll-ms` and `--api-base` (GitHub Enterprise) are there too.
|
|
545
|
+
run. `--remote`, `--poll-ms` and `--api-base` (GitHub Enterprise) are there too.
|
|
546
|
+
`--require-check NAME` (repeatable) names a check that must exist and have succeeded:
|
|
547
|
+
the check run (or status context) called exactly `NAME`. The general rule counts a
|
|
548
|
+
`skipped` or `neutral` run as green, so an e2e job skipped for want of its secrets lets the
|
|
549
|
+
line through; a required one that settled so is `fail-blocking`: the work cannot make it run, so a person reads it, not a fix agent. One not created yet is
|
|
550
|
+
waited for like `--require-checks`, and at the ceiling the CI did not answer
|
|
551
|
+
(`fail-blocking`). A name is exact: a matrix job is one check per cell (`e2e (chrome)`), and each is listed. It talks to GitHub, and knows
|
|
506
552
|
nothing about `@bluewombat/manager-github`: a Project may run either one
|
|
507
553
|
without the other.
|
|
508
554
|
|
|
509
|
-
`sensitive-path`, `workspace-changed`, `gitignore-leak` and `ci-green` need a git workspace, which
|
|
555
|
+
`sensitive-path`, `base-diff`, `workspace-changed`, `gitignore-leak` and `ci-green` need a git workspace, which
|
|
510
556
|
is what Isolator makes when WorkLineStable is a git tree. They fail-block on a
|
|
511
557
|
workspace Isolator had to copy.
|
|
512
558
|
|
|
513
|
-
### The
|
|
559
|
+
### The feature tests
|
|
514
560
|
|
|
515
|
-
`
|
|
516
|
-
(`feature.validate.gates`).
|
|
517
|
-
|
|
561
|
+
`feature-tests.mjs` runs the tests of a Feature on the assembled Feature line, from a Feature Gate
|
|
562
|
+
(`feature.validate.gates`). Only the scenarios of this Feature run, not the Project's whole suite. It
|
|
563
|
+
knows no stack and starts no application: the Project supplies one command, run through the shell
|
|
564
|
+
in the workspace, and the Gate runs it, compares and judges.
|
|
518
565
|
|
|
519
566
|
| Option | Meaning |
|
|
520
567
|
| --- | --- |
|
|
521
|
-
| `--
|
|
522
|
-
| `--
|
|
523
|
-
| `--
|
|
524
|
-
| `--
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
| `--junit PATH` | Where `--run` writes its report. Default `e2e/results/junit.xml`. Removed before every run, so an old report is never read |
|
|
528
|
-
| `--server-log PATH` | The application's log, whose tail goes in a red report. Default: what `--up` printed |
|
|
529
|
-
| `--max-scenarios N` | The most scenarios a suite may hold. Default 5 |
|
|
530
|
-
| `--max-run-ms N` | The most the whole Gate may take. Default 600000 (10 minutes), and never more than the Gate's own ceiling less 25 s |
|
|
531
|
-
| `--start-timeout-ms N` | The most `--up` and `--ready` may take together. Default 120000 |
|
|
532
|
-
| `--host HOST` | Handed to every command as `E2E_HOST`. Default `127.0.0.1`; any non-loopback address is refused |
|
|
533
|
-
| `--port N` | Handed to every command as `E2E_PORT`. Absent: the application's own choice |
|
|
534
|
-
|
|
535
|
-
Every command also gets `E2E_DATA_DIR`, an empty directory made for the run and removed after it
|
|
536
|
-
(the data is disposable), and `E2E_RESULTS_DIR`, the folder of `--junit`. A secret is not an
|
|
537
|
-
option of the Gate: the commands read it from the environment mason was started in, and it
|
|
538
|
-
belongs in no file of the repository.
|
|
539
|
-
|
|
540
|
-
A scenario is a manifest id, and every test carries its id in its name (`E2 pays by card`). The
|
|
568
|
+
| `--run CMD` | Required. Runs the tests of the Feature and writes a JUnit XML report. `{ids}` is the ids of the manifest joined with `\|` (a regular expression, `pnpm test --grep "{ids}"`), `{junit}` the path of the report |
|
|
569
|
+
| `--manifest PATH` | The scenarios of the Feature. Default `feature-tests/scenarios.json`: an array of ids, or `{ "scenarios": [ "S1" \| { "id": "S1", "title": "…" } ] }` |
|
|
570
|
+
| `--junit PATH` | Where `--run` writes its report. Default `feature-tests/results/junit.xml`. Removed before every run, so an old report is never read |
|
|
571
|
+
| `--max-run-ms N` | The most the whole Gate may take. Default 600000 (10 minutes), and never more than the Gate's own ceiling less 5 s |
|
|
572
|
+
|
|
573
|
+
A scenario is a manifest id, and every test carries its id in its name (`S2 pays by card`). The
|
|
541
574
|
comparison is exact: an id with no test, a test with no manifest id, and a test the runner
|
|
542
|
-
skipped are failures.
|
|
543
|
-
is not a refusal; failing twice is red. The verdict:
|
|
575
|
+
skipped are failures. There is no retry. The verdict:
|
|
544
576
|
|
|
545
577
|
| Verdict | When |
|
|
546
578
|
| --- | --- |
|
|
547
|
-
| `pass` | Every scenario ran and passed. The report says how many and which
|
|
548
|
-
| `fail-retryable` | Red: the report has, per scenario, its id, the test, the step (`at`)
|
|
549
|
-
| `fail-blocking` | What no change to the application fixes: a missing, empty or
|
|
579
|
+
| `pass` | Every scenario ran and passed. The report says how many and which |
|
|
580
|
+
| `fail-retryable` | Red: the report has, per scenario, its id, the test, the step (`at`) and the message. Whether the test or the application is wrong is for the caller to say: the feature-test Ticket's author reads the report to fix its tests, and the Feature's fix repairs the application with the tests frozen. Also a runner that crashed before writing a report, and tests that carry no id of the manifest |
|
|
581
|
+
| `fail-blocking` | What no change to the application fixes: a missing, empty or malformed manifest, a run that outlives the ceiling, a run that exits 0 and writes no report |
|
|
550
582
|
|
|
551
|
-
The Project's `.gitignore` excludes the results folder (`
|
|
552
|
-
|
|
583
|
+
The Project's `.gitignore` excludes the results folder (`feature-tests/results/`): a Gate that guards
|
|
584
|
+
the folder (`sensitive-path feature-tests/**` in `feature.fix.gates`) would read the report as a change.
|
|
553
585
|
|
|
554
586
|
```yaml
|
|
555
|
-
- id:
|
|
556
|
-
skipWhen: skip-test
|
|
587
|
+
- id: feature-tests
|
|
588
|
+
skipWhen: skip-feature-test # Host's: not run for a Feature that carries the label
|
|
557
589
|
timeoutMs: 720000
|
|
558
590
|
argv:
|
|
559
591
|
- node
|
|
560
|
-
- ./node_modules/@bluewombat/slots/gates/
|
|
561
|
-
- --up
|
|
562
|
-
- pnpm e2e:up
|
|
563
|
-
- --ready
|
|
564
|
-
- pnpm e2e:ready
|
|
592
|
+
- ./node_modules/@bluewombat/slots/gates/feature-tests.mjs
|
|
565
593
|
- --run
|
|
566
|
-
- pnpm
|
|
567
|
-
- --down
|
|
568
|
-
- pnpm e2e:down
|
|
594
|
+
- pnpm test --grep "{ids}" --reporter=junit --outputFile={junit}
|
|
569
595
|
```
|
|
570
596
|
|
|
571
597
|
## Publishers
|
|
@@ -650,7 +676,7 @@ type in JSDoc.
|
|
|
650
676
|
```
|
|
651
677
|
README.md this file
|
|
652
678
|
agents/ vendor CLIs: filled prompt in, serialized run out
|
|
653
|
-
builders/ first-pass and repair roles for the unit of work, and the
|
|
679
|
+
builders/ first-pass and repair roles for the unit of work, and the feature-test Ticket's
|
|
654
680
|
ticket/ fix and validate for an assembled ticket
|
|
655
681
|
feature/ fix for a whole feature
|
|
656
682
|
lib/ what the shipped templates say, shared by the roles
|
|
@@ -15,29 +15,38 @@ import {
|
|
|
15
15
|
} from "@bluewombat/slot-kit";
|
|
16
16
|
import { attemptOf } from "../lib/fill.mjs";
|
|
17
17
|
|
|
18
|
-
// The Ticket that holds a Feature's
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
//
|
|
18
|
+
// The Ticket that holds a Feature's tests. One command for its first pass and for a pass a Gate
|
|
19
|
+
// refused: the tests are written once from the scenarios of the task, and a refusal is about their
|
|
20
|
+
// form, so there is no second command to tell apart. `{{report}}` and `{{refused_by}}` are empty
|
|
21
|
+
// on a first pass; `{{refusal}}` is a ready-made paragraph about them.
|
|
22
22
|
|
|
23
|
-
const NAME = "builder
|
|
23
|
+
const NAME = "builder feature-test";
|
|
24
24
|
|
|
25
25
|
const DEFAULT_TEMPLATE = `You are a coding agent working unattended in a checkout of the project.
|
|
26
26
|
|
|
27
|
-
You write the
|
|
28
|
-
|
|
27
|
+
You write the tests of a feature. The task below lists its scenarios, agreed with the operator
|
|
28
|
+
before development, each with an id (S1, S2, ...). The feature is built: this checkout holds every
|
|
29
|
+
other Ticket of it.
|
|
29
30
|
|
|
30
31
|
Task:
|
|
31
32
|
{{task}}
|
|
32
33
|
|
|
33
|
-
- Write one test per scenario, in the
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
34
|
+
- Write one test per scenario, in the test framework this project already uses, at the level the
|
|
35
|
+
scenario describes (through the feature's public interfaces: routes, services, pages), and start
|
|
36
|
+
the name of each test with its scenario id ("S1 …"). Follow the scenario's steps and expected
|
|
37
|
+
result as written; do not add, merge, drop or weaken one. If a scenario changes a behaviour that
|
|
38
|
+
existing tests describe, update those tests to the new behaviour, and only those.
|
|
39
|
+
- List the ids of the scenarios in the manifest \`feature-tests/scenarios.json\`, a JSON array:
|
|
40
|
+
["S1", "S2"]. Only the scenarios of this feature, never the project's other tests.
|
|
41
|
+
- Write only tests, their helpers and the manifest. Touch no file of the application, and add no
|
|
38
42
|
dependency it does not already have.
|
|
39
|
-
-
|
|
40
|
-
|
|
43
|
+
- A Gate runs your tests against the built feature after every attempt and gives you its report
|
|
44
|
+
(the refusal below). Read it: a test that fails for its own reasons (a wrong selector or wait, a
|
|
45
|
+
wrong data setup, a misread expectation) is yours to fix. Check only that your tests compile
|
|
46
|
+
before you finish, with the project's own typecheck or build command.
|
|
47
|
+
- If a test stays red because the feature does not do what the scenario says, do not change the
|
|
48
|
+
application and do not weaken the test: that is a finding. Change nothing more and end with
|
|
49
|
+
MASON_BLOCKED, naming the scenario, what the feature does instead and where.
|
|
41
50
|
|
|
42
51
|
{{refusal}}
|
|
43
52
|
|
|
@@ -45,9 +54,10 @@ Task:
|
|
|
45
54
|
|
|
46
55
|
## When you cannot act
|
|
47
56
|
|
|
48
|
-
If what you were given is not enough to act on (a scenario is ambiguous,
|
|
49
|
-
the
|
|
50
|
-
|
|
57
|
+
If what you were given is not enough to act on (a scenario is ambiguous, cannot be written without
|
|
58
|
+
changing the application or without a weaker result than asked, the framework is missing, the
|
|
59
|
+
feature is wrong, the cause is outside this directory), do not invent a change and do not lower
|
|
60
|
+
the scenario. Change nothing and end your final message with exactly one line:
|
|
51
61
|
|
|
52
62
|
MASON_BLOCKED: <what is missing, in one line>
|
|
53
63
|
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { realpathSync } from "node:fs";
|
|
4
|
+
import { dirname, join } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import {
|
|
7
|
+
emitVerdict,
|
|
8
|
+
GATE_FLAGS,
|
|
9
|
+
listOption,
|
|
10
|
+
ownArgv,
|
|
11
|
+
parseOptions,
|
|
12
|
+
pathMatchesGlob,
|
|
13
|
+
stageOf,
|
|
14
|
+
take,
|
|
15
|
+
textOption,
|
|
16
|
+
} from "@bluewombat/slot-kit";
|
|
17
|
+
|
|
18
|
+
// What the work changed since the line it is offered to, judged by path.
|
|
19
|
+
//
|
|
20
|
+
// The base is the work line the workspace was cut from. Without --base it is the
|
|
21
|
+
// branch checked out in the main worktree of the repository the workspace belongs
|
|
22
|
+
// to: Isolation makes every workspace a worktree of the work line's copy, and Host
|
|
23
|
+
// keeps that copy on the line the work lands on (a Feature's own line while a
|
|
24
|
+
// Feature is open), so the answer follows the line. A branch named by hand
|
|
25
|
+
// (`--base main`) is the copy's local branch and is only as fresh as the last
|
|
26
|
+
// time something moved it; Host moves the Feature's line, not `main`.
|
|
27
|
+
//
|
|
28
|
+
// The changed set is what differs between the base and the workspace: the commits
|
|
29
|
+
// since the workspace left it (`BASE...HEAD`) and whatever is on disk and not yet
|
|
30
|
+
// committed (a fold can carry it out, so it counts; an ignored path does not).
|
|
31
|
+
// Unlike a Gate that compares the disk to the last commit, it still sees the work
|
|
32
|
+
// once it is committed, so it answers at the unit, the ticket and the recheck
|
|
33
|
+
// after the align (`--stage assembly`).
|
|
34
|
+
//
|
|
35
|
+
// At `--stage feature` there is no base to derive: the Feature's workspace is its
|
|
36
|
+
// line with the target merged in, so the line would show the target's work. Name
|
|
37
|
+
// the target with --base there.
|
|
38
|
+
//
|
|
39
|
+
// --base REF optional. The ref to compare with, instead of the main
|
|
40
|
+
// worktree's branch.
|
|
41
|
+
//
|
|
42
|
+
// Two ways to use the changed set, alone or together:
|
|
43
|
+
//
|
|
44
|
+
// --deny GLOB repeatable. fail-retryable when a changed path matches:
|
|
45
|
+
// "this must not be touched".
|
|
46
|
+
// --when GLOB repeatable, with --run. When a changed path matches, run
|
|
47
|
+
// CMD with project-checks (shell, output cleaned, the Gate's
|
|
48
|
+
// ceiling honoured) and answer as it does: "if this changed,
|
|
49
|
+
// this must hold". When nothing matches, pass without running it.
|
|
50
|
+
// --run CMD MASON_BASE holds the base; MASON_CHANGED_FILES holds the
|
|
51
|
+
// changed paths, one per line, so CMD can narrow itself to them.
|
|
52
|
+
//
|
|
53
|
+
// `*` is one path segment, `**` any depth.
|
|
54
|
+
|
|
55
|
+
const cwd = process.cwd();
|
|
56
|
+
const projectChecks = join(dirname(fileURLToPath(import.meta.url)), "project-checks.mjs");
|
|
57
|
+
|
|
58
|
+
const parsed = parseOptions(
|
|
59
|
+
ownArgv(process.argv, GATE_FLAGS),
|
|
60
|
+
{ "--base": {}, "--deny": { many: true }, "--when": { many: true }, "--run": {} },
|
|
61
|
+
"base-diff",
|
|
62
|
+
);
|
|
63
|
+
if (!parsed.ok) {
|
|
64
|
+
emitVerdict("fail-blocking", parsed.reason);
|
|
65
|
+
process.exit(0);
|
|
66
|
+
}
|
|
67
|
+
const explicitBase = textOption(parsed.values, "base");
|
|
68
|
+
const deny = listOption(parsed.values, "deny");
|
|
69
|
+
const when = listOption(parsed.values, "when");
|
|
70
|
+
const command = textOption(parsed.values, "run");
|
|
71
|
+
|
|
72
|
+
if (explicitBase?.startsWith("-")) {
|
|
73
|
+
emitVerdict("fail-blocking", `base-diff cannot take "${explicitBase}" as a base.`);
|
|
74
|
+
process.exit(0);
|
|
75
|
+
}
|
|
76
|
+
if (deny.length === 0 && when.length === 0 && command === undefined) {
|
|
77
|
+
emitVerdict(
|
|
78
|
+
"fail-blocking",
|
|
79
|
+
"base-diff needs at least one --deny GLOB, or --when GLOB with --run CMD.",
|
|
80
|
+
);
|
|
81
|
+
process.exit(0);
|
|
82
|
+
}
|
|
83
|
+
if (when.length > 0 !== (command !== undefined)) {
|
|
84
|
+
emitVerdict(
|
|
85
|
+
"fail-blocking",
|
|
86
|
+
"base-diff takes --when and --run together: a condition, and what to run on it.",
|
|
87
|
+
);
|
|
88
|
+
process.exit(0);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (git(["rev-parse", "--is-inside-work-tree"]).stdout.trim() !== "true") {
|
|
92
|
+
emitVerdict("fail-blocking", "base-diff needs a git workspace.");
|
|
93
|
+
process.exit(0);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const base = explicitBase ?? deriveBase();
|
|
97
|
+
if (base === undefined) {
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
if (git(["rev-parse", "--verify", "--quiet", `${base}^{commit}`]).status !== 0) {
|
|
101
|
+
emitVerdict("fail-blocking", `base-diff cannot find the base "${base}" in this workspace.`);
|
|
102
|
+
process.exit(0);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const changed = changedPaths();
|
|
106
|
+
if (changed === undefined) {
|
|
107
|
+
process.exit(0);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
if (deny.length > 0) {
|
|
111
|
+
const hit = changed.filter((path) => matchesAny(path, deny));
|
|
112
|
+
if (hit.length > 0) {
|
|
113
|
+
emitVerdict(
|
|
114
|
+
"fail-retryable",
|
|
115
|
+
`Paths changed since ${base} that this work may not touch (${deny.join(", ")}):\n${hit.join("\n")}`,
|
|
116
|
+
);
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (command !== undefined && changed.some((path) => matchesAny(path, when))) {
|
|
122
|
+
runCondition(command);
|
|
123
|
+
} else {
|
|
124
|
+
emitVerdict("pass");
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* The branch checked out in the main worktree: the line this workspace's work
|
|
129
|
+
* lands on. Nothing when there is none to name; the verdict is then already out.
|
|
130
|
+
* @returns {string | undefined}
|
|
131
|
+
*/
|
|
132
|
+
function deriveBase() {
|
|
133
|
+
if (stageOf(process.argv) === "feature") {
|
|
134
|
+
emitVerdict(
|
|
135
|
+
"fail-blocking",
|
|
136
|
+
"base-diff cannot derive a base at the feature stage: the Feature's workspace is its line with the target merged in, so the line would show the target's work, not the Feature's. Name the target with --base.",
|
|
137
|
+
);
|
|
138
|
+
return undefined;
|
|
139
|
+
}
|
|
140
|
+
const listed = git(["worktree", "list", "--porcelain"]);
|
|
141
|
+
if (listed.status !== 0) {
|
|
142
|
+
emitVerdict("fail-blocking", `base-diff cannot list the worktrees: ${listed.stderr.trim()}`);
|
|
143
|
+
return undefined;
|
|
144
|
+
}
|
|
145
|
+
// The first entry is the main worktree.
|
|
146
|
+
const main = listed.stdout.split("\n\n")[0] ?? "";
|
|
147
|
+
const mainPath = /^worktree (.+)$/m.exec(main)?.[1];
|
|
148
|
+
const branch = /^branch refs\/heads\/(.+)$/m.exec(main)?.[1];
|
|
149
|
+
const top = git(["rev-parse", "--show-toplevel"]).stdout.trim();
|
|
150
|
+
if (mainPath === undefined || top.length === 0) {
|
|
151
|
+
emitVerdict(
|
|
152
|
+
"fail-blocking",
|
|
153
|
+
"base-diff cannot tell which worktree is the main one. Name the base with --base.",
|
|
154
|
+
);
|
|
155
|
+
return undefined;
|
|
156
|
+
}
|
|
157
|
+
if (sameDirectory(mainPath, top)) {
|
|
158
|
+
emitVerdict(
|
|
159
|
+
"fail-blocking",
|
|
160
|
+
"base-diff has no base to derive: this workspace is the main worktree itself, not a worktree of a work line. Name the base with --base.",
|
|
161
|
+
);
|
|
162
|
+
return undefined;
|
|
163
|
+
}
|
|
164
|
+
if (branch === undefined) {
|
|
165
|
+
emitVerdict(
|
|
166
|
+
"fail-blocking",
|
|
167
|
+
`base-diff has no base to derive: the main worktree (${mainPath}) has no branch checked out. Name the base with --base.`,
|
|
168
|
+
);
|
|
169
|
+
return undefined;
|
|
170
|
+
}
|
|
171
|
+
return `refs/heads/${branch}`;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Runs the --run command through project-checks, so it is cleaned, bounded and
|
|
176
|
+
* classified the way every command of the Project is, and relays its verdict.
|
|
177
|
+
* @param {string} cmd
|
|
178
|
+
*/
|
|
179
|
+
function runCondition(cmd) {
|
|
180
|
+
const flags = ["--cmd", cmd];
|
|
181
|
+
const ceiling = take(process.argv, "--timeout-ms");
|
|
182
|
+
if (ceiling !== undefined) {
|
|
183
|
+
flags.push("--timeout-ms", ceiling);
|
|
184
|
+
}
|
|
185
|
+
const result = spawnSync(process.execPath, [projectChecks, ...flags], {
|
|
186
|
+
cwd,
|
|
187
|
+
encoding: "utf8",
|
|
188
|
+
maxBuffer: 64 * 1024 * 1024,
|
|
189
|
+
env: { ...process.env, MASON_BASE: base, MASON_CHANGED_FILES: (changed ?? []).join("\n") },
|
|
190
|
+
});
|
|
191
|
+
const line = (result.stdout ?? "").trim().split("\n").filter(Boolean).at(-1);
|
|
192
|
+
/** @type {{ verdict?: string; report?: string } | undefined} */
|
|
193
|
+
let answer;
|
|
194
|
+
try {
|
|
195
|
+
answer = line === undefined ? undefined : JSON.parse(line);
|
|
196
|
+
} catch {
|
|
197
|
+
answer = undefined;
|
|
198
|
+
}
|
|
199
|
+
if (answer?.verdict === "pass") {
|
|
200
|
+
emitVerdict("pass");
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
if (answer?.verdict === "fail-retryable" || answer?.verdict === "fail-blocking") {
|
|
204
|
+
const lead = `Paths matching ${when.join(", ")} changed since ${base}. `;
|
|
205
|
+
emitVerdict(answer.verdict, `${lead}${answer.report ?? ""}`.trim());
|
|
206
|
+
return;
|
|
207
|
+
}
|
|
208
|
+
emitVerdict(
|
|
209
|
+
"fail-blocking",
|
|
210
|
+
`base-diff could not read the answer of "${cmd}": ${(result.stderr ?? "").trim().slice(-2_000) || "no output"}`,
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Every path that differs from the base, committed or not, sorted. Nothing when
|
|
216
|
+
* git could not say: the verdict is already out.
|
|
217
|
+
* @returns {string[] | undefined}
|
|
218
|
+
*/
|
|
219
|
+
function changedPaths() {
|
|
220
|
+
// --no-renames: a move is a removal and an addition, so the old path counts too.
|
|
221
|
+
const committed = git(["diff", "--name-only", "--no-renames", "-z", `${base}...HEAD`]);
|
|
222
|
+
if (committed.status !== 0) {
|
|
223
|
+
emitVerdict(
|
|
224
|
+
"fail-blocking",
|
|
225
|
+
`base-diff cannot diff HEAD against "${base}": ${committed.stderr.trim()}`,
|
|
226
|
+
);
|
|
227
|
+
return undefined;
|
|
228
|
+
}
|
|
229
|
+
const status = git(["status", "--porcelain=v1", "-z", "--untracked-files=all"]);
|
|
230
|
+
if (status.status !== 0) {
|
|
231
|
+
emitVerdict("fail-blocking", `base-diff cannot read the workspace: ${status.stderr.trim()}`);
|
|
232
|
+
return undefined;
|
|
233
|
+
}
|
|
234
|
+
const paths = new Set(committed.stdout.split("\0").filter(Boolean));
|
|
235
|
+
const fields = status.stdout.split("\0");
|
|
236
|
+
for (let i = 0; i < fields.length; i++) {
|
|
237
|
+
const entry = fields[i] ?? "";
|
|
238
|
+
if (entry.length < 4) {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
241
|
+
paths.add(entry.slice(3));
|
|
242
|
+
// A rename or a copy carries the old path in the next field.
|
|
243
|
+
if (entry[0] === "R" || entry[0] === "C") {
|
|
244
|
+
const old = fields[++i];
|
|
245
|
+
if (old !== undefined && old.length > 0) {
|
|
246
|
+
paths.add(old);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return [...paths].map((path) => path.replaceAll("\\", "/")).sort();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/**
|
|
254
|
+
* @param {string} a
|
|
255
|
+
* @param {string} b
|
|
256
|
+
*/
|
|
257
|
+
function sameDirectory(a, b) {
|
|
258
|
+
try {
|
|
259
|
+
return realpathSync(a) === realpathSync(b);
|
|
260
|
+
} catch {
|
|
261
|
+
return a === b;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* @param {string} path
|
|
267
|
+
* @param {readonly string[]} patterns
|
|
268
|
+
*/
|
|
269
|
+
function matchesAny(path, patterns) {
|
|
270
|
+
return patterns.some((pattern) => pathMatchesGlob(path, pattern));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** @param {string[]} args */
|
|
274
|
+
function git(args) {
|
|
275
|
+
return spawnSync("git", ["-C", cwd, ...args], { encoding: "utf8" });
|
|
276
|
+
}
|