@kontextmind/kxm 0.7.147 → 0.7.149

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.
@@ -11,7 +11,7 @@
11
11
  "name": "kxm",
12
12
  "source": "./plugins/kxm",
13
13
  "description": "Durable workflows, peer agents, and kxm tui",
14
- "version": "0.7.147",
14
+ "version": "0.7.149",
15
15
  "category": "development",
16
16
  "tags": ["kxm", "multi-agent", "workflows", "mcp"]
17
17
  }
package/CHANGELOG.md CHANGED
@@ -142,6 +142,15 @@ All notable user-facing changes are documented here. The project follows [Semant
142
142
 
143
143
  ### Changed
144
144
 
145
+ - **Pull-request CI runs the unit suite on Linux Node 24, and `CI / required` is the aggregate check.**
146
+ Docs and plan markdown skip the code jobs. `engine.test.ts` is split by
147
+ test name. `permission.test.ts` and `runtime.test.ts` run one file at a
148
+ time, and every other unit file runs in one light lane. Pushes to `main`
149
+ still run `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24.
150
+ A pull request that touches path, process, shell, spawn, package, lockfile,
151
+ or workflow files also runs the unit lanes on Windows. Playwright stays on
152
+ Obscura. See [CI and release](docs/contributing/ci-and-release.md).
153
+
145
154
  - **Docs match the 2026-09-27 Steel and machine-account infrastructure.**
146
155
  Steel (`steel.kontextmind.com`, alias `steel.theneuro.me`) is reached only
147
156
  through Caddy on `kxmd-proxy` (VM 230) and Authentik forward auth. Direct
@@ -180,6 +189,7 @@ All notable user-facing changes are documented here. The project follows [Semant
180
189
  `opus` because the justfile recipe review-arch hardcoded that model while
181
190
  `reviewer-arch` listed only `fable-claude`. That recipe is gone; a request
182
191
  for `opus` fails closed.
192
+
183
193
  - **Dispatch reads role and model files, and agents bind a role.**
184
194
  `scripts/roster-policy.mjs` builds the developer policy from
185
195
  `.kxm/models/*.yaml` and `.kxm/roles/*.yaml` at `refs/remotes/origin/main`.
@@ -518,6 +528,8 @@ All notable user-facing changes are documented here. The project follows [Semant
518
528
 
519
529
  ### Fixed
520
530
 
531
+ - **Dry-run ship status no longer rewrites `.git/index`.** `git status` refreshes the index under an optional lock. The ship-status read passes `--no-optional-locks`, so a dry run leaves the checkout untouched.
532
+
521
533
  - **A committed checkout counts as authored work, and a one-shot outcome must be a standalone JSON object.** The authoring witness includes `HEAD` with porcelain status and both diffs, so a write that commits its edits is `changed` and can stay `passed`. A `rev-parse` failure keeps that empty head term only when the repository has no commits; any other git failure is `unwitnessed`. A one-shot outcome is accepted when the whole reply is one JSON object, or when that object stands alone on the final line. A closing code fence around the final object is allowed. An object followed by prose, a truncated reply, and an ambiguous tail settle `failed`.
522
534
 
523
535
  - **The test suite no longer passes `--test-timeout`.** Under `node --test` that flag bounds each file, so coverage on CI timed out `test/core/engine.test.ts` at three minutes. The wall clock in `scripts/run-bounded.mjs` still bounds each script.
@@ -1,11 +1,12 @@
1
1
  # CI and release
2
2
 
3
- Every pull request and every push to `main` that changes code runs a
4
- three-minute merge-safety gate, the complete suite with coverage floors runs
5
- nightly, every merge to `main` cuts a patch release, and every release is
6
- verified before it reaches npm. This
7
- page explains which checks run where, how a merge becomes a published version,
8
- and which smoke tests stay manual. It is for contributors and maintainers.
3
+ Pull requests run lint, typecheck, and the unit suite on Linux Node 24.
4
+ Pushes to `main` also run that suite and the full OS × Node matrix.
5
+ The complete suite with coverage floors runs nightly, every merge to `main`
6
+ cuts a patch release, and every release is verified before it reaches npm.
7
+ This page explains which checks run where, how a merge becomes a published
8
+ version, and which smoke tests stay manual. It is for contributors and
9
+ maintainers.
9
10
 
10
11
  ## The pipeline at a glance
11
12
 
@@ -14,9 +15,10 @@ release and an npm publish; the nightly job adds the slower suites.
14
15
 
15
16
  ```mermaid
16
17
  flowchart LR
17
- PR[Pull request] -->|"validate:pr, docs lint, plugin validation"| MERGE{Merged?}
18
+ PR[Pull request] -->|"unit shards, docs lint, plugin validation"| REQ["CI / required"]
19
+ REQ --> MERGE{Merged?}
18
20
  MERGE -->|yes| MAIN[main]
19
- MAIN -->|"same three-minute validate:pr"| PUSH[Push CI]
21
+ MAIN -->|"unit shards plus validate:pr matrix"| PUSH[Push CI]
20
22
  MAIN -->|"Auto-Release: next patch tag"| TAG[Tag vX.Y.Z]
21
23
  TAG -->|dispatch| REL[Release workflow]
22
24
  REL -->|"verify, stamp version, pack"| GH[GitHub release<br/>kxm-X.Y.Z.tgz]
@@ -35,46 +37,75 @@ together.
35
37
  | Trigger | Workflow (job) | What it runs |
36
38
  |---|---|---|
37
39
  | Before you push | Local | `npm run verify` |
38
- | Pull request and push to `main` | `ci.yml` (Validate, two Node legs) | `validate:pr`, the three-minute gate; skipped for documentation-only changes |
40
+ | Pull request, push to `main`, or manual | `ci.yml` (`required`) | Aggregates the lanes below into one pass/fail check named `CI / required` |
39
41
  | Pull request and push | `ci.yml` (Docs lint) | `lint:docs` and `check:versions`, always |
40
- | Pull request and push | `ci.yml` (Plugin validation) | `claude plugin validate --strict` on the marketplace and the plugin; skipped for documentation-only changes |
42
+ | Code pull request and code push | `ci.yml` (Unit engine and Unit, Linux Node 24) | `engine.test.ts` split by test name; `permission.test.ts` and `runtime.test.ts` one file at a time; every other unit file together. Typecheck and `check-generated` run on the light lane |
43
+ | Platform-sensitive pull request | `ci.yml` (Unit, Windows Node 24) | The same four unit lanes on `windows-latest` |
44
+ | Push to `main`, or manual | `ci.yml` (Validate matrix) | `validate:pr` on Linux and Windows for Node 22.19.0 and Node 24; not on a pull request |
45
+ | Code pull request and code push | `ci.yml` (Plugin validation) | `claude plugin validate --strict` on the marketplace and the plugin |
41
46
  | Daily at 04:00 UTC, or manual | `nightly.yml` | `test:coverage:complete`, `check`, `check:generated`, `npm pack --dry-run` |
42
47
  | Merged pull request | `auto-release.yml` | Tags the merge commit and dispatches `release.yml` |
43
48
  | Tag push or dispatch | `release.yml` | Verifies, packs and publishes (see [Release flow](#release-flow)) |
44
49
  | Manual only | `smoke.yml` | Real Pi smoke, currently disabled (see [Smoke tests](#smoke-tests)) |
45
- | Pull request, or manual | `e2e.yml` | `npm run e2e` on `ubuntu-latest`: Obscura v0.2.3 plus the Playwright smoke test. This workflow does not enable CI, Nightly, or Real Pi smoke |
50
+ | Pull request, or manual | `e2e.yml` | `npm run e2e` on `ubuntu-latest`: Obscura v0.2.3 plus the Playwright smoke test. Separate from `ci.yml` |
46
51
 
47
52
  The npm scripts behind those rows:
48
53
 
49
54
  | Script | Composition |
50
55
  |---|---|
51
56
  | `verify` | `npm test` (core and package unit tests), `check`, `check:generated` |
57
+ | `test:ci-shard` | One unit lane: build, then `engine <index> <total>`, `serial`, or `light` over `test/core/*.test.ts` and `packages/core/*/tests/unit/*.test.ts` |
52
58
  | `validate:pr` | `build`, `typecheck`, a compact contract and smoke set of nine `test/core` files, `check:versions`, and the generated-`dist` check |
53
- | `validate:ci` | `test:coverage` (core and package tests, 91/80/92 floors), `check`, `npm pack --dry-run`; not run by CI today, available locally |
59
+ | `validate:ci` | `test:coverage` (core and package tests, 91/80/92 floors), `check`, `npm pack --dry-run`; the Release workflow runs it, and it stays available locally |
54
60
  | `test:coverage:complete` | Core, simulation and package tests with 93/80/93 floors |
55
61
  | `check` | `typecheck`, `lint:docs`, `check:versions` |
56
62
 
57
- Three differences matter when a check fails on one side only:
58
-
59
- - CI runs a compact contract and smoke set, not the core suite. The full core
60
- suite and the package unit tests under `packages/core/*/tests` run in your
61
- local `npm run verify`; run it before every push.
62
- - Coverage and `test/simulations` run only in the nightly complete suite, never
63
- on a pull request or a push to `main`.
64
- - A regression the compact set misses can reach `main` and show up in the
65
- nightly run, so treat a nightly failure as a release blocker.
66
-
67
- ### Validate matrix and required checks
68
-
69
- The Validate job runs on Node 22.19.0 and Node 24, on Linux and Windows. Linux
70
- uses the ARC scale set `kontextmind-doks` with a three-minute job timeout.
71
- Windows uses GitHub-hosted `windows-latest` with a fifteen-minute timeout so
72
- `npm ci` can finish. The branch ruleset requires the job names
73
- `Validate (linux, Node 22.19.0)` and `Validate (linux, Node 24)` only; the
74
- Windows names are reported but not required, so a Windows-only failure does not
75
- block merge. Renaming a required Linux job or the matrix means updating the
76
- ruleset in the same change. A newer push cancels an older pull request run;
77
- runs on `main` are never cancelled.
63
+ What moved off the pull-request lane, and what did not:
64
+
65
+ - The four `validate:pr` cells — Linux and Windows, Node 22.19.0 and Node 24 —
66
+ run on a push to `main` and on `workflow_dispatch`. They do not run on a
67
+ pull request. The nine files inside `validate:pr` still run on a code pull
68
+ request, because they are part of the Linux Node 24 unit suite.
69
+ - Node 22.19.0 does not run the unit suite on a pull request. It runs
70
+ `validate:pr` on `main`.
71
+ - Windows runs the unit suite on a pull request only when the classifier marks
72
+ the change platform-sensitive. Every push to `main` that changes code still
73
+ runs `validate:pr` on Windows.
74
+ - Coverage, `test/simulations`, and `npm pack --dry-run` stay in the nightly
75
+ complete suite. They were not part of pull-request CI before this split.
76
+ - Plugin validation still runs on code pull requests and code pushes.
77
+
78
+ Run `npm run verify` locally before every push. A nightly failure is a release
79
+ blocker: it is the only place the simulation suite and coverage floors run.
80
+
81
+ ### Lanes and the required check
82
+
83
+ Code pull requests run Docs lint, two Linux Node 24 engine shards, a serial
84
+ lane (`permission.test.ts` then `runtime.test.ts`), a light lane for every
85
+ other unit file, and Plugin validation. The light lane typechecks and checks
86
+ generated bundles. `engine.test.ts` is split by test name because that file
87
+ alone was 174 seconds; the serial lane keeps the next two longest files off
88
+ the light pool, which was 268 seconds when every non-engine file shared one
89
+ job. Linux jobs use the npm cache from
90
+ `actions/setup-node`. Restoring a `node_modules` tarball was slower than
91
+ `npm ci` on the Linux runners (about 24s versus 17s on 2026-09-24), so that
92
+ cache stays on the Windows jobs, where `npm ci` is the slow step.
93
+
94
+ The job `required` always runs. Its check name is `CI / required`. It fails
95
+ when a lane fails or is cancelled, and it passes when a lane was skipped
96
+ because the change did not need it. Add `CI / required` as a required status
97
+ check in the `protect-main` ruleset. Skipped matrix legs are not required
98
+ names, so they do not block auto-merge. This repository change does not edit
99
+ that ruleset.
100
+
101
+ A newer push cancels an older pull request run. Runs on `main` are never
102
+ cancelled. Unit and Validate matrices use `fail-fast`.
103
+
104
+ The Validate matrix still runs on Node 22.19.0 and Node 24, on Linux and
105
+ Windows, for pushes to `main` and for `workflow_dispatch`. Linux uses the ARC
106
+ scale set `kontextmind-doks` with a three-minute job timeout. Windows uses
107
+ GitHub-hosted `windows-latest` with a fifteen-minute timeout so `npm ci` can
108
+ finish. Those four job names are not the required check anymore.
78
109
 
79
110
  ### CI jobs stay queued while a runner is online
80
111
 
@@ -111,20 +142,26 @@ autoscaler. Do not relabel `km-gh-rn01` or push an empty commit as a routing
111
142
  workaround.
112
143
 
113
144
  > [!NOTE]
114
- > Windows Validate uses GitHub-hosted `windows-latest`, not a self-hosted
115
- > homelab runner and not the ARC scale set. Do not add a `kontextmind-doks`
116
- > label to a Windows runner. Nightly complete coverage and release stay on
117
- > Linux. Windows-specific fixtures (for example the `pi.cmd` worker launch in
118
- > `test/core/worker.test.ts`) also run in the hosted Windows Validate legs.
145
+ > Windows jobs use GitHub-hosted `windows-latest`, not a self-hosted homelab
146
+ > runner and not the ARC scale set. Do not add a `kontextmind-doks` label to a
147
+ > Windows runner. Nightly complete coverage and release stay on Linux.
148
+ > Platform-sensitive pull requests run the unit suite on Windows, which
149
+ > includes `test/core/worker.test.ts`. The main Validate legs run the compact
150
+ > `validate:pr` gate.
119
151
 
120
152
  ### The docs-only classifier
121
153
 
122
154
  The first job, Classify changes, lists the changed paths and sets `code=false`
123
- when every path matches `*.md`, `docs/*`, `.kxm/assets/*`, `LICENSE`, the issue
124
- and PR templates, or `dependabot.yml`. Validate and Plugin validation read it:
125
- for a documentation-only change they report success without checking out the
126
- code, so the required job names still pass. Docs lint always runs.
127
- `ci-contract.test.ts` pins this behavior.
155
+ when every path matches `*.md` (including `plans/**/*.md`), `docs/**`,
156
+ `.kxm/assets/**`, `LICENSE`, the issue and PR templates, or `dependabot.yml`.
157
+ A non-markdown file under `plans/` is code. Unit lanes, Plugin validation,
158
+ and the Validate matrix are skipped. Docs lint runs, and `CI / required`
159
+ passes. `scripts/ci-classify.mjs` and `ci-contract.test.ts` pin this behavior.
160
+
161
+ `platform=true` when a path is a package manifest, the lockfile, a file under
162
+ `scripts/` or `.github/workflows/`, or a filename that names path, process,
163
+ shell, spawn, worker, supervisor, repo-root, or ssh-remote behavior. A
164
+ platform-sensitive pull request also runs the Windows Node 24 unit lanes.
128
165
 
129
166
  > [!WARNING]
130
167
  > Several tests and code paths read documentation files by path. A pull request
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kontextmind/kxm",
3
- "version": "0.7.147",
3
+ "version": "0.7.149",
4
4
  "description": "KXM local-first multi-agent orchestration and operator dashboard",
5
5
  "type": "module",
6
6
  "author": "KontextMind",
@@ -38,6 +38,7 @@
38
38
  "test:simulations": "npm run build && node scripts/run-bounded.mjs 1200000 node --disable-warning=ExperimentalWarning --experimental-strip-types --test --test-force-exit --test-concurrency=4 test/simulations/*.test.ts",
39
39
  "test:complete": "npm run build && node scripts/run-bounded.mjs 2400000 node --disable-warning=ExperimentalWarning --experimental-strip-types --test --test-force-exit --test-concurrency=4 test/core/*.test.ts test/simulations/*.test.ts",
40
40
  "test": "npm run build && node scripts/run-bounded.mjs 1200000 node --disable-warning=ExperimentalWarning --experimental-strip-types --test --test-force-exit --test-concurrency=4 test/core/*.test.ts packages/core/*/tests/unit/*.test.ts",
41
+ "test:ci-shard": "node scripts/ci-unit-shard.mjs",
41
42
  "e2e": "node scripts/obscura.mjs --ensure && playwright test",
42
43
  "test:coverage:core": "npm run build && node scripts/run-bounded.mjs 2400000 node --disable-warning=ExperimentalWarning --experimental-strip-types --test --test-force-exit --test-concurrency=4 --experimental-test-coverage --test-coverage-lines=91 --test-coverage-branches=80 --test-coverage-functions=92 --test-coverage-include=plugins/kxm/src/**/*.ts --test-coverage-exclude=plugins/kxm/src/server.ts --test-coverage-exclude=plugins/kxm/src/mcp-server.ts --test-coverage-exclude=plugins/kxm/src/runtime-supervisor.ts --test-coverage-include=packages/core/*/src/**/*.ts test/core/*.test.ts packages/core/*/tests/unit/*.test.ts",
43
44
  "test:coverage:complete": "npm run build && node scripts/run-bounded.mjs 2400000 node --disable-warning=ExperimentalWarning --experimental-strip-types --test --test-force-exit --test-concurrency=4 --experimental-test-coverage --test-coverage-lines=93 --test-coverage-branches=80 --test-coverage-functions=93 --test-coverage-include=plugins/kxm/src/**/*.ts --test-coverage-exclude=plugins/kxm/src/server.ts --test-coverage-exclude=plugins/kxm/src/mcp-server.ts --test-coverage-exclude=plugins/kxm/src/runtime-supervisor.ts --test-coverage-include=packages/core/*/src/**/*.ts test/core/*.test.ts test/simulations/*.test.ts packages/core/*/tests/unit/*.test.ts",
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json.schemastore.org/claude-code-plugin-manifest.json",
3
3
  "name": "kxm",
4
4
  "displayName": "KXM",
5
- "version": "0.7.147",
5
+ "version": "0.7.149",
6
6
  "description": "Headless multi-agent orchestration, durable workflows, and a live operator dashboard for Pi and Claude Code",
7
7
  "author": {
8
8
  "name": "KontextMind",
@@ -724,7 +724,7 @@ function formatShipLine(ship) {
724
724
  }
725
725
  function readGitShip(cwd) {
726
726
  try {
727
- const dirty = spawnSync("git", ["-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
727
+ const dirty = spawnSync("git", ["--no-optional-locks", "-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
728
728
  if (dirty.status !== 0) return void 0;
729
729
  const isDirty = dirty.stdout.trim().length > 0;
730
730
  const upstream = spawnSync("git", ["-C", cwd, "rev-list", "--count", "@{u}..HEAD"], { encoding: "utf8", windowsHide: true });
@@ -50095,7 +50095,7 @@ function formatShipLine(ship) {
50095
50095
  }
50096
50096
  function readGitShip(cwd) {
50097
50097
  try {
50098
- const dirty = spawnSync10("git", ["-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
50098
+ const dirty = spawnSync10("git", ["--no-optional-locks", "-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
50099
50099
  if (dirty.status !== 0) return void 0;
50100
50100
  const isDirty = dirty.stdout.trim().length > 0;
50101
50101
  const upstream = spawnSync10("git", ["-C", cwd, "rev-list", "--count", "@{u}..HEAD"], { encoding: "utf8", windowsHide: true });
@@ -37099,7 +37099,7 @@ function formatShipLine(ship) {
37099
37099
  }
37100
37100
  function readGitShip(cwd) {
37101
37101
  try {
37102
- const dirty = spawnSync("git", ["-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
37102
+ const dirty = spawnSync("git", ["--no-optional-locks", "-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
37103
37103
  if (dirty.status !== 0) return void 0;
37104
37104
  const isDirty2 = dirty.stdout.trim().length > 0;
37105
37105
  const upstream = spawnSync("git", ["-C", cwd, "rev-list", "--count", "@{u}..HEAD"], { encoding: "utf8", windowsHide: true });
@@ -17313,7 +17313,7 @@ function sessionTokenFixHint(policy) {
17313
17313
  }
17314
17314
 
17315
17315
  // plugins/kxm/src/mcp-server.ts
17316
- var VERSION = "0.7.147";
17316
+ var VERSION = "0.7.149";
17317
17317
  var CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
17318
17318
  var inbox = /* @__PURE__ */ new Map();
17319
17319
  var notifiedInbox = /* @__PURE__ */ new Set();
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claude-plugin",
3
- "version": "0.7.147",
3
+ "version": "0.7.149",
4
4
  "private": true,
5
5
  "type": "module",
6
6
  "engines": {
@@ -11,7 +11,7 @@ import { deliverInboxNotification } from "./inbox.ts";
11
11
  import type { HubEvent, MessageRecord } from "./protocol.ts";
12
12
  import { sessionTokenFixHint } from "./session-token-hint.ts";
13
13
 
14
- const VERSION = "0.7.147";
14
+ const VERSION = "0.7.149";
15
15
  const CONFIGURE_PLUGIN = "/plugin configure kxm@kxm";
16
16
  const inbox = new Map<string, MessageRecord>();
17
17
  const notifiedInbox = new Set<string>();
@@ -127,7 +127,9 @@ export function formatShipLine(ship?: SessionShipStatus): string {
127
127
 
128
128
  export function readGitShip(cwd: string): SessionShipStatus | undefined {
129
129
  try {
130
- const dirty = spawnSync("git", ["-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
130
+ // --no-optional-locks keeps a ship-status read from rewriting .git/index.
131
+ // git status refreshes the index under an optional lock; a dry run must not.
132
+ const dirty = spawnSync("git", ["--no-optional-locks", "-C", cwd, "status", "--porcelain"], { encoding: "utf8", windowsHide: true });
131
133
  if (dirty.status !== 0) return undefined;
132
134
  const isDirty = dirty.stdout.trim().length > 0;
133
135
 
@@ -0,0 +1,69 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Classify a pull request or push into the CI lanes.
4
+ //
5
+ // Docs and plan markdown skip the code jobs. The aggregate `required` job
6
+ // still runs, so a skipped matrix does not leave the status check blank.
7
+ // Platform-sensitive paths add the Windows unit shards on pull requests.
8
+ // Pushes to main and workflow_dispatch keep the full OS x Node validate matrix.
9
+
10
+ import { readFileSync } from "node:fs";
11
+
12
+ const DOCS_RULES = [
13
+ (file) => file.endsWith(".md"),
14
+ (file) => file.startsWith("docs/"),
15
+ (file) => file.startsWith(".kxm/assets/"),
16
+ (file) => file === "LICENSE",
17
+ (file) => file.startsWith(".github/ISSUE_TEMPLATE/"),
18
+ (file) => file === ".github/pull_request_template.md",
19
+ (file) => file === ".github/dependabot.yml",
20
+ ];
21
+
22
+ // Filename tokens for path, process, shell, and spawn behavior, plus the
23
+ // dependency and workflow files that change how those paths run.
24
+ const PLATFORM_NAME = /(path|paths|process|shell|spawn|worker|supervisor|repo-root|ssh-remote)/i;
25
+
26
+ export function isDocsPath(file) {
27
+ return DOCS_RULES.some((rule) => rule(file));
28
+ }
29
+
30
+ export function isPlatformPath(file) {
31
+ if (/(^|\/)package\.json$/.test(file)) return true;
32
+ if (/(^|\/)package-lock\.json$/.test(file)) return true;
33
+ if (file.startsWith(".github/workflows/")) return true;
34
+ if (file.startsWith("scripts/")) return true;
35
+ const base = file.split("/").pop() ?? file;
36
+ return PLATFORM_NAME.test(base);
37
+ }
38
+
39
+ export function unboundedClassification() {
40
+ return { code: true, platform: false, runValidate: true };
41
+ }
42
+
43
+ export function classifyPaths(paths, event) {
44
+ const files = paths.map((file) => file.trim()).filter(Boolean);
45
+ const code = files.some((file) => !isDocsPath(file));
46
+ const platform = files.some((file) => isPlatformPath(file));
47
+ const runValidate = event !== "pull_request" && code;
48
+ return { code, platform, runValidate };
49
+ }
50
+
51
+ function emit(result) {
52
+ process.stdout.write(`code=${result.code}\n`);
53
+ process.stdout.write(`platform=${result.platform}\n`);
54
+ process.stdout.write(`run_validate=${result.runValidate}\n`);
55
+ }
56
+
57
+ if (process.argv[1] && process.argv[1].endsWith("ci-classify.mjs")) {
58
+ const event = process.argv[2] ?? "";
59
+ if (event !== "pull_request" && event !== "push" && event !== "workflow_dispatch" && event !== "unbounded") {
60
+ process.stderr.write("usage: ci-classify.mjs <pull_request|push|workflow_dispatch|unbounded>\n");
61
+ process.exit(2);
62
+ }
63
+ if (event === "unbounded") {
64
+ emit(unboundedClassification());
65
+ } else {
66
+ const stdin = readFileSync(0, "utf8");
67
+ emit(classifyPaths(stdin.split(/\r?\n/), event));
68
+ }
69
+ }
@@ -0,0 +1,230 @@
1
+ #!/usr/bin/env node
2
+
3
+ // Unit lanes for CI.
4
+ //
5
+ // Measured on Node 24 (4 cores), one file at a time: engine.test.ts 174s,
6
+ // permission.test.ts 74s, runtime.test.ts 53s. Those files run their tests
7
+ // sequentially, and running them in one process pool stretched the rest of
8
+ // the suite to 268s. engine.test.ts is split by test name across two jobs.
9
+ // permission and runtime run one file at a time in a serial job so they do
10
+ // not steal cores from each other. Every other unit file runs in a light
11
+ // job at concurrency 4.
12
+ //
13
+ // Dynamic `test(\`...\${...}\`)` names stay together as one pattern so a
14
+ // loop is not dropped. Run via `npm run test:ci-shard` so npm_execpath is
15
+ // set for the packed-install test: `engine <index> <total>`, `serial`, or
16
+ // `light`.
17
+
18
+ import { spawn, spawnSync } from "node:child_process";
19
+ import { globSync, readFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+
22
+ export const SHARD_TOTAL = 2;
23
+ export const ENGINE_FILE = "test/core/engine.test.ts";
24
+ // Solo Node 24 timings: permission 74s, runtime 53s. One file at a time.
25
+ export const SERIAL_FILES = [
26
+ "test/core/permission.test.ts",
27
+ "test/core/runtime.test.ts",
28
+ ];
29
+ const LIGHT_CONCURRENCY = 4;
30
+
31
+ function escapeRegExp(value) {
32
+ return value.replace(/[|\\{}()[\]^$+*?.]/g, "\\$&");
33
+ }
34
+
35
+ export function listUnitFiles(root) {
36
+ return [
37
+ ...globSync("test/core/*.test.ts", { cwd: root }),
38
+ ...globSync("packages/core/*/tests/unit/*.test.ts", { cwd: root }),
39
+ ].sort();
40
+ }
41
+
42
+ export function extractTestPatterns(source) {
43
+ const patterns = [];
44
+ const seen = new Set();
45
+ const re = /^[ \t]*test\(\s*(["'`])([\s\S]*?)\1/gm;
46
+ for (const match of source.matchAll(re)) {
47
+ const raw = match[2];
48
+ let sourcePattern;
49
+ let dynamic = false;
50
+ let name;
51
+ if (raw.includes("${")) {
52
+ dynamic = true;
53
+ const marked = raw.replace(/\$\{[^}]*\}/g, "\0");
54
+ sourcePattern = `^${escapeRegExp(marked).replaceAll("\0", ".*?")}$`;
55
+ } else {
56
+ name = raw.replace(/\\(["'`\\])/g, "$1");
57
+ sourcePattern = `^${escapeRegExp(name)}$`;
58
+ }
59
+ if (seen.has(sourcePattern)) continue;
60
+ seen.add(sourcePattern);
61
+ patterns.push({ dynamic, name, source: sourcePattern });
62
+ }
63
+ return patterns;
64
+ }
65
+
66
+ function conflicts(patterns) {
67
+ const exact = patterns.filter((pattern) => !pattern.dynamic);
68
+ const dynamic = patterns.filter((pattern) => pattern.dynamic);
69
+ const hits = [];
70
+ for (const pattern of dynamic) {
71
+ const regex = new RegExp(pattern.source);
72
+ for (const item of exact) {
73
+ if (regex.test(item.name)) hits.push(`${item.name} matches ${pattern.source}`);
74
+ }
75
+ }
76
+ return hits;
77
+ }
78
+
79
+ function enginePatterns(root) {
80
+ const patterns = extractTestPatterns(readFileSync(join(root, ENGINE_FILE), "utf8"));
81
+ if (patterns.length === 0) throw new Error(`${ENGINE_FILE} has no recognizable test() names`);
82
+ const overlapped = conflicts(patterns);
83
+ if (overlapped.length > 0) {
84
+ throw new Error(`${ENGINE_FILE} dynamic test names also match exact tests:\n${overlapped.join("\n")}`);
85
+ }
86
+ return patterns;
87
+ }
88
+
89
+ export function planEngineShard(root, index, total = SHARD_TOTAL) {
90
+ if (!Number.isInteger(index) || !Number.isInteger(total) || index < 1 || index > total) {
91
+ throw new Error(`shard ${index}/${total} is outside 1..${total}`);
92
+ }
93
+ const patterns = enginePatterns(root).filter((_, patternIndex) => patternIndex % total === index - 1);
94
+ if (patterns.length === 0) throw new Error(`engine shard ${index}/${total} assigned no tests`);
95
+ return { file: ENGINE_FILE, patterns };
96
+ }
97
+
98
+ export function planSerial(root) {
99
+ const files = listUnitFiles(root);
100
+ if (!files.includes(ENGINE_FILE)) throw new Error(`${ENGINE_FILE} is missing`);
101
+ for (const file of SERIAL_FILES) {
102
+ if (!files.includes(file)) throw new Error(`${file} is missing`);
103
+ }
104
+ return [...SERIAL_FILES];
105
+ }
106
+
107
+ export function planLight(root) {
108
+ const serial = new Set(SERIAL_FILES);
109
+ const files = listUnitFiles(root).filter((file) => file !== ENGINE_FILE && !serial.has(file));
110
+ if (files.length === 0) throw new Error("light lane has no unit files");
111
+ return files;
112
+ }
113
+
114
+ export function coverageOfShards(root, total = SHARD_TOTAL) {
115
+ const heavy = new Map();
116
+ for (let index = 1; index <= total; index += 1) {
117
+ const plan = planEngineShard(root, index, total);
118
+ for (const pattern of plan.patterns) heavy.set(pattern.source, (heavy.get(pattern.source) ?? 0) + 1);
119
+ }
120
+ return { files: listUnitFiles(root), serial: planSerial(root), light: planLight(root), heavy };
121
+ }
122
+
123
+ function runNode(args, label, children) {
124
+ return new Promise((resolve) => {
125
+ const child = spawn(process.execPath, args, { env: process.env });
126
+ children.add(child);
127
+ let stdout = "";
128
+ let stderr = "";
129
+ const flush = (buffer, stream, chunk) => {
130
+ buffer += chunk.toString();
131
+ const lines = buffer.split("\n");
132
+ const rest = lines.pop() ?? "";
133
+ for (const line of lines) stream.write(`[${label}] ${line}\n`);
134
+ return rest;
135
+ };
136
+ child.stdout.on("data", (chunk) => { stdout = flush(stdout, process.stdout, chunk); });
137
+ child.stderr.on("data", (chunk) => { stderr = flush(stderr, process.stderr, chunk); });
138
+ child.on("close", (code) => {
139
+ children.delete(child);
140
+ if (stdout) process.stdout.write(`[${label}] ${stdout}\n`);
141
+ if (stderr) process.stderr.write(`[${label}] ${stderr}\n`);
142
+ resolve(code ?? 1);
143
+ });
144
+ child.on("error", (error) => {
145
+ children.delete(child);
146
+ process.stderr.write(`[${label}] ${error.message}\n`);
147
+ resolve(1);
148
+ });
149
+ });
150
+ }
151
+
152
+ function testArgs(concurrency, files, patterns) {
153
+ const args = [
154
+ "--disable-warning=ExperimentalWarning",
155
+ "--experimental-strip-types",
156
+ "--test",
157
+ "--test-force-exit",
158
+ `--test-concurrency=${concurrency}`,
159
+ ];
160
+ for (const pattern of patterns ?? []) args.push("--test-name-pattern", pattern.source);
161
+ args.push(...files);
162
+ return args;
163
+ }
164
+
165
+ async function runPool(tasks, limit) {
166
+ const pending = [...tasks];
167
+ const children = new Set();
168
+ let failed = false;
169
+ const workers = Array.from({ length: Math.min(limit, pending.length) }, async () => {
170
+ while (pending.length > 0 && !failed) {
171
+ const task = pending.shift();
172
+ const code = await task(children);
173
+ if (code !== 0) {
174
+ failed = true;
175
+ for (const child of children) child.kill("SIGTERM");
176
+ }
177
+ }
178
+ });
179
+ await Promise.all(workers);
180
+ return failed ? 1 : 0;
181
+ }
182
+
183
+ function npmBuild() {
184
+ const npmCli = process.env.npm_execpath;
185
+ if (!npmCli) {
186
+ process.stderr.write("npm_execpath is required; run via npm run test:ci-shard\n");
187
+ return 1;
188
+ }
189
+ const result = spawnSync(process.execPath, [npmCli, "run", "build"], {
190
+ stdio: "inherit",
191
+ env: process.env,
192
+ });
193
+ return result.status ?? 1;
194
+ }
195
+
196
+ async function main() {
197
+ const mode = process.argv[2];
198
+ const root = process.cwd();
199
+ const built = npmBuild();
200
+ if (built !== 0) process.exit(built);
201
+ if (mode === "serial") {
202
+ const files = planSerial(root);
203
+ process.exit(await runPool([
204
+ (children) => runNode(testArgs(1, files), "serial", children),
205
+ ], 1));
206
+ }
207
+ if (mode === "light") {
208
+ const files = planLight(root);
209
+ process.exit(await runPool([
210
+ (children) => runNode(testArgs(LIGHT_CONCURRENCY, files), "light", children),
211
+ ], 1));
212
+ }
213
+ if (mode === "engine") {
214
+ const index = Number(process.argv[3]);
215
+ const total = Number(process.argv[4] ?? SHARD_TOTAL);
216
+ const plan = planEngineShard(root, index, total);
217
+ process.exit(await runPool([
218
+ (children) => runNode(testArgs(1, [plan.file], plan.patterns), "engine", children),
219
+ ], 1));
220
+ }
221
+ process.stderr.write("usage: ci-unit-shard.mjs <serial|light|engine> [index total]\n");
222
+ process.exit(2);
223
+ }
224
+
225
+ if (process.argv[1] && process.argv[1].endsWith("ci-unit-shard.mjs")) {
226
+ main().catch((error) => {
227
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
228
+ process.exit(1);
229
+ });
230
+ }