@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.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +12 -0
- package/docs/contributing/ci-and-release.md +80 -43
- package/package.json +2 -1
- package/plugins/kxm/.claude-plugin/plugin.json +1 -1
- package/plugins/kxm/dist/claude-hook.js +1 -1
- package/plugins/kxm/dist/cli.js +1 -1
- package/plugins/kxm/dist/extension.js +1 -1
- package/plugins/kxm/dist/mcp-server.js +1 -1
- package/plugins/kxm/package.json +1 -1
- package/plugins/kxm/src/mcp-server.ts +1 -1
- package/plugins/kxm/src/session-work.ts +3 -1
- package/scripts/ci-classify.mjs +69 -0
- package/scripts/ci-unit-shard.mjs +230 -0
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
|
-
|
|
4
|
-
|
|
5
|
-
nightly, every merge to `main`
|
|
6
|
-
verified before it reaches npm.
|
|
7
|
-
page explains which checks run where, how a merge becomes a published
|
|
8
|
-
and which smoke tests stay manual. It is for contributors and
|
|
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] -->|"
|
|
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 -->|"
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
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`;
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
`npm
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
|
115
|
-
>
|
|
116
|
-
>
|
|
117
|
-
>
|
|
118
|
-
> `test/core/worker.test.ts
|
|
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
|
|
124
|
-
and PR templates, or `dependabot.yml`.
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
`ci-contract.test.ts`
|
|
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.
|
|
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.
|
|
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 });
|
package/plugins/kxm/dist/cli.js
CHANGED
|
@@ -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.
|
|
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();
|
package/plugins/kxm/package.json
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
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
|
+
}
|