@ultimat3/cli 23.0.0 → 25.0.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/CLAUDE.md +28 -13
- package/README.md +32 -7
- package/package.json +29 -29
- package/src/admin-catalog-keys.ts +2 -2
- package/src/affected.ts +30 -36
- package/src/api-registration.ts +89 -0
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +17 -4
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +26 -25
- package/src/app-load.ts +38 -14
- package/src/app-manifest.ts +4 -10
- package/src/app-mcp.ts +31 -46
- package/src/app-name.ts +52 -0
- package/src/app-permissions-borrowed.ts +123 -0
- package/src/app-permissions-site.ts +48 -0
- package/src/app-permissions.ts +7 -17
- package/src/app-root.ts +9 -5
- package/src/bin.ts +3 -7
- package/src/browser-launcher.ts +4 -2
- package/src/budgets-charged.ts +60 -0
- package/src/budgets-scan.ts +76 -0
- package/src/budgets-unmeasured.ts +91 -0
- package/src/budgets.ts +117 -154
- package/src/cdp-shot-driver.ts +20 -1
- package/src/cdp-shot-watch.ts +35 -31
- package/src/ci-log.ts +0 -0
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +7 -1
- package/src/cmd-build.ts +151 -22
- package/src/cmd-db-backfill.ts +26 -13
- package/src/cmd-db-branch.ts +2 -2
- package/src/cmd-db.ts +10 -6
- package/src/cmd-deploy-compose.ts +94 -0
- package/src/cmd-deploy-drain.ts +48 -0
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy-start-first.ts +178 -0
- package/src/cmd-deploy.ts +158 -36
- package/src/cmd-dev.ts +18 -356
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +57 -80
- package/src/cmd-env.ts +55 -16
- package/src/cmd-generate.ts +89 -32
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +21 -17
- package/src/cmd-jobs-spec.ts +7 -22
- package/src/cmd-jobs.ts +33 -140
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +71 -22
- package/src/cmd-new.ts +36 -8
- package/src/cmd-planned.ts +24 -8
- package/src/cmd-routes.ts +15 -5
- package/src/cmd-secrets.ts +47 -6
- package/src/cmd-shot-matrix.ts +22 -7
- package/src/cmd-shot-spec.ts +10 -2
- package/src/cmd-shot.ts +46 -41
- package/src/cmd-tasks.ts +2 -2
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +14 -30
- package/src/cmd-verify.ts +4 -4
- package/src/coverage-lcov.ts +4 -2
- package/src/db-backfill.ts +2 -2
- package/src/db-replay-engine.ts +4 -10
- package/src/db-subscribes.ts +27 -16
- package/src/dev-boot.ts +290 -0
- package/src/dev-child-watch.ts +63 -12
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +56 -30
- package/src/dev-lock.ts +100 -31
- package/src/dev-rebuild.ts +80 -0
- package/src/dev-restart-retire.ts +46 -0
- package/src/dev-route-table.ts +30 -8
- package/src/dev-supervisor.ts +114 -22
- package/src/dev-traces.ts +53 -20
- package/src/dispatch.ts +17 -13
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-offline.ts +6 -5
- package/src/document-options.ts +7 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/drift.ts +29 -6
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +29 -3
- package/src/error-page-csp.ts +14 -15
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/exec.ts +8 -0
- package/src/favicon.ts +2 -2
- package/src/fix-command.ts +9 -1
- package/src/framework-env.ts +148 -0
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +25 -3
- package/src/generate-catalog-locales.ts +79 -0
- package/src/generate-files.ts +4 -3
- package/src/generate-kinds.ts +18 -0
- package/src/generate-plural.ts +50 -0
- package/src/generate-shadow.ts +95 -0
- package/src/generate-write.ts +47 -13
- package/src/gh-target.ts +12 -7
- package/src/hold.ts +56 -1
- package/src/i18n-audit.ts +25 -9
- package/src/i18n-index.ts +5 -6
- package/src/i18n-registration.ts +7 -9
- package/src/import-scan.ts +28 -3
- package/src/index.ts +2 -6
- package/src/island-bundle.ts +57 -79
- package/src/island-capture.ts +1 -1
- package/src/island-duplicate-refusal.ts +57 -0
- package/src/island-duplicates.ts +221 -0
- package/src/island-entry-missing.ts +64 -0
- package/src/island-harness-route.ts +20 -4
- package/src/island-harness-script.ts +23 -2
- package/src/island-identity.ts +17 -14
- package/src/island-package-dedupe.ts +150 -0
- package/src/island-realtime.ts +36 -16
- package/src/island-routes.ts +2 -2
- package/src/island-runtime.ts +241 -0
- package/src/island-sources.ts +105 -0
- package/src/island-states-load.ts +6 -0
- package/src/island-store.ts +90 -6
- package/src/jobs-json.ts +0 -26
- package/src/jobs-report.ts +36 -20
- package/src/local-cli-handoff.ts +90 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +20 -4
- package/src/mcp-host.ts +22 -14
- package/src/mcp-test-run.ts +38 -0
- package/src/mcp-ui-inspect.ts +3 -2
- package/src/mcp-ui-interact.ts +3 -2
- package/src/mcp-ui.ts +8 -7
- package/src/measure-scope.ts +15 -10
- package/src/messages.ts +29 -16
- package/src/metrics-endpoint.ts +28 -3
- package/src/migration-extensions.ts +1 -1
- package/src/otlp-export.ts +2 -2
- package/src/output.ts +24 -0
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +19 -31
- package/src/parse.ts +0 -8
- package/src/permission-grants.ts +3 -3
- package/src/posix-path.ts +30 -0
- package/src/prerender-locales.ts +6 -4
- package/src/prerender-out.ts +34 -7
- package/src/prerender-report.ts +58 -0
- package/src/prerender-served.ts +52 -0
- package/src/prerender.ts +71 -103
- package/src/pwa-artifacts.ts +33 -94
- package/src/pwa-manifest-locales.ts +9 -9
- package/src/registry.ts +2 -0
- package/src/retry-memo.ts +1 -1
- package/src/revalidated-response.ts +4 -3
- package/src/role-realtime.ts +7 -1
- package/src/role-replicator.ts +124 -29
- package/src/role-start.ts +69 -85
- package/src/role-sync.ts +119 -25
- package/src/root-env.ts +157 -28
- package/src/route-islands.ts +69 -0
- package/src/runtime-admin.ts +4 -1
- package/src/runtime-assets.ts +26 -9
- package/src/runtime-bindings.ts +27 -7
- package/src/runtime-cache.ts +46 -55
- package/src/runtime-hooks.ts +2 -1
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr-outcome.ts +100 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +76 -41
- package/src/runtime-live-feed.ts +1 -1
- package/src/runtime-mail.ts +26 -0
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +4 -4
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +43 -22
- package/src/runtime-realtime.ts +11 -45
- package/src/runtime-render.ts +136 -114
- package/src/runtime-replica.ts +3 -8
- package/src/runtime-services.ts +49 -26
- package/src/runtime-storage-upload.ts +2 -2
- package/src/runtime-storage.ts +13 -8
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/secrets-rotation.ts +8 -9
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +51 -12
- package/src/serve-drain.ts +11 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-retire.ts +95 -0
- package/src/serve-web.ts +31 -15
- package/src/serve.ts +27 -10
- package/src/server-barrels.ts +6 -3
- package/src/shot-browser.ts +1 -1
- package/src/shot-locale.ts +6 -33
- package/src/shot-settle.ts +3 -3
- package/src/shot-theme.ts +1 -1
- package/src/shot-viewport.ts +152 -0
- package/src/signal-shred.ts +26 -12
- package/src/site-asset-routes.ts +2 -2
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +7 -5
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-files.ts +19 -3
- package/src/static-report.ts +5 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/style-routes.ts +2 -2
- package/src/sw-artifacts.ts +8 -8
- package/src/sw-precache-plan.ts +34 -4
- package/src/sync-authenticator.ts +3 -3
- package/src/sync-url.ts +3 -3
- package/src/tasks-facts.ts +3 -29
- package/src/templates/action.ts +7 -4
- package/src/templates/backfill.ts +4 -4
- package/src/templates/entity.ts +2 -2
- package/src/templates/github/ci.yml.ts +28 -18
- package/src/templates/github/image.yml.ts +92 -0
- package/src/templates/github/runner.ts +18 -0
- package/src/templates/guard-bare-error.ts +16 -2
- package/src/templates/guard-unzoned-date.ts +56 -6
- package/src/templates/index.ts +1 -1
- package/src/templates/job-body-test.ts +2 -2
- package/src/templates/job.ts +6 -6
- package/src/templates/naming.ts +28 -0
- package/src/templates/query.ts +9 -8
- package/src/templates/resource-page.ts +9 -7
- package/src/templates/resource-service.ts +2 -2
- package/src/templates/route.ts +26 -14
- package/src/templates/scaffold-api.ts +2 -3
- package/src/templates/scaffold-app.ts +8 -3
- package/src/templates/scaffold-auth.ts +205 -70
- package/src/templates/scaffold-bin.ts +122 -0
- package/src/templates/scaffold-claude-commands.ts +11 -11
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +16 -8
- package/src/templates/scaffold-dashboard-bare.ts +8 -4
- package/src/templates/scaffold-dashboard-example-tests.ts +208 -0
- package/src/templates/scaffold-dashboard-example.ts +112 -172
- package/src/templates/scaffold-dashboard-shared.ts +62 -1
- package/src/templates/scaffold-dashboard.ts +9 -5
- package/src/templates/scaffold-db-client.ts +4 -2
- package/src/templates/scaffold-db-package.ts +2 -2
- package/src/templates/scaffold-docs.ts +20 -77
- package/src/templates/scaffold-domain-package.ts +0 -4
- package/src/templates/scaffold-entries.ts +4 -3
- package/src/templates/scaffold-env.ts +6 -3
- package/src/templates/scaffold-gitattributes.ts +59 -0
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +100 -19
- package/src/templates/scaffold-helm.ts +62 -5
- package/src/templates/scaffold-http.ts +1 -1
- package/src/templates/scaffold-i18n.ts +15 -2
- package/src/templates/scaffold-icon.ts +2 -2
- package/src/templates/scaffold-repo.ts +27 -16
- package/src/templates/scaffold-shell.ts +55 -37
- package/src/test-counts.ts +2 -6
- package/src/test-ignores.ts +25 -0
- package/src/test-only-shipped.ts +193 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +7 -15
- package/src/test-shards.ts +40 -95
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +8 -33
- package/src/theme-brand.ts +66 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/trusted-hops.ts +49 -0
- package/src/unscanned-admin.ts +2 -2
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +29 -48
- package/src/verify-coverage-run.ts +3 -2
- package/src/verify-deadline.ts +207 -33
- package/src/verify-merge.ts +108 -43
- package/src/verify-part-step.ts +98 -0
- package/src/verify-role-load-probe.ts +12 -2
- package/src/verify-role-load.ts +9 -4
- package/src/verify-run.ts +32 -8
- package/src/verify-shard.ts +13 -2
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +2 -0
- package/src/verify-test-run.ts +3 -2
- package/src/verify-tests.ts +5 -3
- package/src/worker-bundle.ts +2 -2
- package/src/workspace-checks.ts +53 -7
- package/src/workspace-graph.ts +3 -2
- package/src/write-line.ts +82 -7
- package/src/browser-launcher-fake-html.ts +0 -80
- package/src/browser-launcher-fake.ts +0 -168
- package/src/build-errors.ts +0 -54
- package/src/jobs-drain.ts +0 -163
- package/src/scaffold-typecheck.ts +0 -227
- package/src/thrown-by.ts +0 -24
- package/src/verify-errors.ts +0 -58
package/src/verify-deadline.ts
CHANGED
|
@@ -2,10 +2,9 @@
|
|
|
2
2
|
// tag every process a step starts carries, and the kill that leaves none of them behind. A hung
|
|
3
3
|
// step used to eat the whole CI job timeout and report nothing (#589); now it fails by name.
|
|
4
4
|
|
|
5
|
-
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
5
|
import { TEST_TYPES } from '@ultimat3/testing';
|
|
7
6
|
import type { ExecResult, Runner } from './exec';
|
|
8
|
-
import
|
|
7
|
+
import { execOutput } from './exec';
|
|
9
8
|
import type { VerifyStepName } from './verify-step';
|
|
10
9
|
|
|
11
10
|
/** The `x.verify.json` key: `{ "stepTimeoutMs": { "unit": 900000 } }`. */
|
|
@@ -76,11 +75,47 @@ export const STEP_TAG_ENV = 'ULTIMATE_VERIFY_STEP';
|
|
|
76
75
|
/** `bun test`-style "the command was stopped" exit, as `timeout(1)` reports it. */
|
|
77
76
|
const EXPIRED_EXIT = 124;
|
|
78
77
|
|
|
78
|
+
/** A process the kill found, as it was the moment before it died. */
|
|
79
|
+
export interface KilledProcess {
|
|
80
|
+
readonly pid: number;
|
|
81
|
+
/** Its argv, clipped — a `bun test` coordinator's names every file of its batch. */
|
|
82
|
+
readonly command: string;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** A child the step was still waiting on when it expired. */
|
|
86
|
+
export interface InFlightRun {
|
|
87
|
+
readonly command: readonly string[];
|
|
88
|
+
/** Everything it had printed before the kill; absent when it did not settle after it. */
|
|
89
|
+
readonly output?: string;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** What a step was doing when its deadline passed — `verify-stalled.ts` turns it into the finding. */
|
|
93
|
+
export interface StepExpiry {
|
|
94
|
+
readonly killed: readonly KilledProcess[];
|
|
95
|
+
readonly inFlight: readonly InFlightRun[];
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** A killed child's pipes close with it; this is only the bound on a runner that is not `exec`. */
|
|
99
|
+
const SETTLE_MS = 2_000;
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* How a `bun test --parallel` worker is told from its coordinator: the flag bun starts each with
|
|
103
|
+
* (probed on 1.4.0). Killing the WORKERS first is what names the stuck file — the coordinator
|
|
104
|
+
* outlives them just long enough to print `✗ <file> (worker crashed: SIGKILL)` for whatever each
|
|
105
|
+
* was holding, in every reporter mode: plain, GitHub Actions' `::group::`, and the failures-only
|
|
106
|
+
* one an agent's shell switches on. A file's own header line cannot do it — bun prints that as
|
|
107
|
+
* results stream, so a file stuck in its third test has one.
|
|
108
|
+
*/
|
|
109
|
+
const isTestWorker = (command: string): boolean => command.includes(' --test-worker');
|
|
110
|
+
|
|
111
|
+
/** How long a coordinator gets to say what its workers were holding before it is killed too. */
|
|
112
|
+
const CRASH_REPORT_MS = 1_500;
|
|
113
|
+
|
|
79
114
|
export interface StepGuard {
|
|
80
115
|
/** `runner`, with every child tagged — and refusing to start one once the step has expired. */
|
|
81
116
|
readonly runner: Runner;
|
|
82
|
-
/** Stop the step: no further child starts,
|
|
83
|
-
expire(): Promise<
|
|
117
|
+
/** Stop the step: no further child starts, every tagged process is killed, and what was in flight is told. */
|
|
118
|
+
expire(): Promise<StepExpiry>;
|
|
84
119
|
}
|
|
85
120
|
|
|
86
121
|
/**
|
|
@@ -91,13 +126,23 @@ export interface StepGuard {
|
|
|
91
126
|
*
|
|
92
127
|
* A nested gate (a test that runs `x verify`) appends its tag to the one it inherited, so the
|
|
93
128
|
* outer step's kill still finds the inner step's children.
|
|
129
|
+
*
|
|
130
|
+
* Windows reads no process's environment, so there the tag finds nothing: every child's pid is
|
|
131
|
+
* recorded at spawn (`ExecOptions.onSpawn`) and `expire` tree-kills each one (`taskkillTree`).
|
|
94
132
|
*/
|
|
95
133
|
export function guardStep(
|
|
96
134
|
runner: Runner,
|
|
97
135
|
tag: string,
|
|
98
136
|
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
137
|
+
platform: PlatformKill = {},
|
|
99
138
|
): StepGuard {
|
|
100
139
|
let expired = false;
|
|
140
|
+
// Keyed by the promise itself: two batches of one step may run the very same argv.
|
|
141
|
+
const inFlight = new Map<Promise<ExecResult>, readonly string[]>();
|
|
142
|
+
// Every child's pid, recorded the moment it exists and dropped when its run settles (a settled
|
|
143
|
+
// pid may be reused). Windows reads no process's environment, so this is the step's only handle.
|
|
144
|
+
const spawned = new Map<number, readonly string[]>();
|
|
145
|
+
const windows = (platform.platform ?? process.platform) === 'win32';
|
|
101
146
|
const inherited = env[STEP_TAG_ENV];
|
|
102
147
|
const value = inherited === undefined || inherited === '' ? tag : `${inherited} ${tag}`;
|
|
103
148
|
return {
|
|
@@ -114,15 +159,108 @@ export function guardStep(
|
|
|
114
159
|
durationMs: 0,
|
|
115
160
|
};
|
|
116
161
|
}
|
|
117
|
-
|
|
162
|
+
let pid: number | undefined;
|
|
163
|
+
const run = runner(command, {
|
|
164
|
+
...options,
|
|
165
|
+
env: { ...options.env, [STEP_TAG_ENV]: value },
|
|
166
|
+
onSpawn: (spawnedPid) => {
|
|
167
|
+
pid = spawnedPid;
|
|
168
|
+
spawned.set(spawnedPid, command);
|
|
169
|
+
options.onSpawn?.(spawnedPid);
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
inFlight.set(run, command);
|
|
173
|
+
try {
|
|
174
|
+
return await run;
|
|
175
|
+
} finally {
|
|
176
|
+
inFlight.delete(run);
|
|
177
|
+
if (pid !== undefined) spawned.delete(pid);
|
|
178
|
+
}
|
|
118
179
|
},
|
|
119
|
-
async expire(): Promise<
|
|
180
|
+
async expire(): Promise<StepExpiry> {
|
|
120
181
|
expired = true;
|
|
121
|
-
|
|
182
|
+
// Before the kill: a killed child settles its runner call, which takes it off the map.
|
|
183
|
+
const waiting = [...inFlight];
|
|
184
|
+
if (windows) {
|
|
185
|
+
const killed = await killTrees(spawned, platform.killTree ?? taskkillTree);
|
|
186
|
+
return { killed, inFlight: await settleAll(waiting) };
|
|
187
|
+
}
|
|
188
|
+
const workers = await killTagged(tag, undefined, isTestWorker);
|
|
189
|
+
if (workers.length > 0) {
|
|
190
|
+
await raceDeadline(Promise.allSettled(waiting.map(([run]) => run)), CRASH_REPORT_MS);
|
|
191
|
+
}
|
|
192
|
+
const rest = await killTagged(tag);
|
|
193
|
+
const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
|
|
194
|
+
// After it: the kill closed the child's pipes, so its call now resolves with everything it
|
|
195
|
+
// had printed — for `bun test`, the crash line of each file a worker was holding.
|
|
196
|
+
return { killed, inFlight: await settleAll(waiting) };
|
|
122
197
|
},
|
|
123
198
|
};
|
|
124
199
|
}
|
|
125
200
|
|
|
201
|
+
/** What each child had printed, once the kill closed its pipes — or just its argv past `SETTLE_MS`. */
|
|
202
|
+
function settleAll(
|
|
203
|
+
waiting: readonly (readonly [Promise<ExecResult>, readonly string[]])[],
|
|
204
|
+
): Promise<readonly InFlightRun[]> {
|
|
205
|
+
return Promise.all(
|
|
206
|
+
waiting.map(async ([run, command]): Promise<InFlightRun> => {
|
|
207
|
+
const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
|
|
208
|
+
return raced === undefined || raced.timedOut ? { command } : { command, output: raced.value };
|
|
209
|
+
}),
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Kills `pid` and every process under it; answers whether it did. */
|
|
214
|
+
export type KillTree = (pid: number) => Promise<boolean>;
|
|
215
|
+
|
|
216
|
+
/** The platform a guard kills on, injectable so the Windows path runs on any host's tests. */
|
|
217
|
+
export interface PlatformKill {
|
|
218
|
+
/** `process.platform` unless a test names one. */
|
|
219
|
+
readonly platform?: string;
|
|
220
|
+
/** Windows' tree kill. Absent is `taskkill /T /F /PID`. */
|
|
221
|
+
readonly killTree?: KillTree;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Windows: `taskkill /T /F` — the tree under the pid, forced. POSIX's tag sweep cannot run there
|
|
226
|
+
* (no process exposes its environment) and `process.kill` ends one process, leaving a
|
|
227
|
+
* `bun test --parallel` coordinator's workers behind. Bounded like `ps`, for the same reason.
|
|
228
|
+
*/
|
|
229
|
+
export async function taskkillTree(pid: number): Promise<boolean> {
|
|
230
|
+
try {
|
|
231
|
+
const proc = Bun.spawn(['taskkill', '/T', '/F', '/PID', String(pid)], {
|
|
232
|
+
stdout: 'ignore',
|
|
233
|
+
stderr: 'ignore',
|
|
234
|
+
timeout: PS_TIMEOUT_MS,
|
|
235
|
+
killSignal: 'SIGKILL',
|
|
236
|
+
});
|
|
237
|
+
return (await proc.exited) === 0;
|
|
238
|
+
} catch {
|
|
239
|
+
return false;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* Every recorded child, each with its tree, named by the argv it was started with. A child that
|
|
245
|
+
* already exited is not recorded — its run settled — so what is left is what was still running.
|
|
246
|
+
* A grandchild whose parent already exited is out of reach here: Windows keeps no tag to find it.
|
|
247
|
+
*/
|
|
248
|
+
export async function killTrees(
|
|
249
|
+
spawned: ReadonlyMap<number, readonly string[]>,
|
|
250
|
+
killTree: KillTree,
|
|
251
|
+
): Promise<readonly KilledProcess[]> {
|
|
252
|
+
const killed: KilledProcess[] = [];
|
|
253
|
+
for (const [pid, command] of spawned) {
|
|
254
|
+
if (pid === process.pid || !(await killTree(pid))) continue;
|
|
255
|
+
const line = command.join(' ');
|
|
256
|
+
killed.push({
|
|
257
|
+
pid,
|
|
258
|
+
command: line.length > COMMAND_CHARS ? `${line.slice(0, COMMAND_CHARS)}…` : line,
|
|
259
|
+
});
|
|
260
|
+
}
|
|
261
|
+
return killed;
|
|
262
|
+
}
|
|
263
|
+
|
|
126
264
|
/** One process and the environment it was started with, as the platform reports it. */
|
|
127
265
|
export interface ProcessEnviron {
|
|
128
266
|
readonly pid: number;
|
|
@@ -162,19 +300,33 @@ async function procfsProcesses(): Promise<readonly ProcessEnviron[] | undefined>
|
|
|
162
300
|
return out;
|
|
163
301
|
}
|
|
164
302
|
|
|
303
|
+
/** One `ps` listing; past this the sweep answers what it has rather than outliving the step. */
|
|
304
|
+
const PS_TIMEOUT_MS = 10_000;
|
|
305
|
+
|
|
306
|
+
const PS_LISTING: readonly string[] = ['ps', 'axeww', '-o', 'pid=,command='];
|
|
307
|
+
|
|
165
308
|
/**
|
|
166
309
|
* Elsewhere (macOS, BSD): `ps` in its BSD spelling — `e` prints each process's environment after
|
|
167
310
|
* its command, `ww` never truncates it. The same spelling answers on Linux, which is where it is
|
|
168
311
|
* tested; procfs is preferred there because it needs no process and no parsing.
|
|
169
312
|
*/
|
|
170
|
-
export async function psProcesses(
|
|
313
|
+
export async function psProcesses(
|
|
314
|
+
argv: readonly string[] = PS_LISTING,
|
|
315
|
+
timeoutMs: number = PS_TIMEOUT_MS,
|
|
316
|
+
): Promise<readonly ProcessEnviron[]> {
|
|
171
317
|
try {
|
|
172
|
-
const ps = Bun.spawn([
|
|
318
|
+
const ps = Bun.spawn([...argv], {
|
|
173
319
|
stdout: 'pipe',
|
|
174
320
|
stderr: 'ignore',
|
|
321
|
+
// The deadline's own sweep: a `ps` that hangs would hang the step it exists to end. SIGKILL,
|
|
322
|
+
// never the default SIGTERM, which a wedged child may ignore and so outlive the bound.
|
|
323
|
+
timeout: timeoutMs,
|
|
324
|
+
killSignal: 'SIGKILL',
|
|
175
325
|
});
|
|
176
|
-
const text = await new Response(ps.stdout).text();
|
|
177
|
-
|
|
326
|
+
const [text, code] = await Promise.all([new Response(ps.stdout).text(), ps.exited]);
|
|
327
|
+
// A listing cut short by the timeout is NOT a smaller table: read as one, a tagged worker past
|
|
328
|
+
// the cut is "nothing left to kill". Unreadable, exactly like a `ps` that never ran.
|
|
329
|
+
if (code !== 0) return [];
|
|
178
330
|
return text.split('\n').flatMap((line) => {
|
|
179
331
|
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
180
332
|
return match === null ? [] : [{ pid: Number(match[1]), environ: match[2] ?? '' }];
|
|
@@ -186,26 +338,65 @@ export async function psProcesses(): Promise<readonly ProcessEnviron[]> {
|
|
|
186
338
|
|
|
187
339
|
const SWEEPS = 5;
|
|
188
340
|
|
|
341
|
+
const COMMAND_CHARS = 240;
|
|
342
|
+
|
|
343
|
+
/**
|
|
344
|
+
* A process's argv, read BEFORE it is killed — afterwards there is nothing to read. procfs where
|
|
345
|
+
* there is one, `ps` otherwise; a process that is already gone is named by its pid alone.
|
|
346
|
+
*/
|
|
347
|
+
async function commandOf(pid: number): Promise<string> {
|
|
348
|
+
let text = await Bun.file(`/proc/${String(pid)}/cmdline`)
|
|
349
|
+
.text()
|
|
350
|
+
.catch(() => '');
|
|
351
|
+
if (text === '') {
|
|
352
|
+
try {
|
|
353
|
+
const ps = Bun.spawn(['ps', '-ww', '-o', 'command=', '-p', String(pid)], {
|
|
354
|
+
stdout: 'pipe',
|
|
355
|
+
stderr: 'ignore',
|
|
356
|
+
timeout: PS_TIMEOUT_MS,
|
|
357
|
+
killSignal: 'SIGKILL',
|
|
358
|
+
});
|
|
359
|
+
text = await new Response(ps.stdout).text();
|
|
360
|
+
await ps.exited;
|
|
361
|
+
} catch {
|
|
362
|
+
text = '';
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
const line = text.split('\0').join(' ').trim();
|
|
366
|
+
return line.length > COMMAND_CHARS ? `${line.slice(0, COMMAND_CHARS)}…` : line;
|
|
367
|
+
}
|
|
368
|
+
|
|
189
369
|
/**
|
|
190
370
|
* SIGKILL every process carrying `tag`, and sweep again until a pass finds none: a worker can fork
|
|
191
371
|
* between the listing and its own death. Never this process — a nested gate carries the outer tag.
|
|
372
|
+
* Returns the processes it killed, each with the command line it was running.
|
|
192
373
|
*/
|
|
193
374
|
export async function killTagged(
|
|
194
375
|
tag: string,
|
|
195
376
|
/** The process table. Absent is this platform's: procfs where there is one, `ps` otherwise. */
|
|
196
377
|
list: () => Promise<readonly ProcessEnviron[]> = async () =>
|
|
197
378
|
(await procfsProcesses()) ?? (await psProcesses()),
|
|
198
|
-
|
|
199
|
-
|
|
379
|
+
/** Kill only the processes whose command line this accepts. Absent kills every tagged one. */
|
|
380
|
+
only?: (command: string) => boolean,
|
|
381
|
+
): Promise<readonly KilledProcess[]> {
|
|
382
|
+
const killed = new Map<number, KilledProcess>();
|
|
383
|
+
// Read once per pid and BEFORE the first signal: a worker exits on its own the moment its
|
|
384
|
+
// coordinator dies, and one read between two kills would find it already gone.
|
|
385
|
+
const commands = new Map<number, string>();
|
|
200
386
|
for (let sweep = 0; sweep < SWEEPS; sweep += 1) {
|
|
201
387
|
const processes = await list();
|
|
202
|
-
const
|
|
388
|
+
const tagged = taggedPids(processes, tag).filter((pid) => pid !== process.pid);
|
|
389
|
+
for (const pid of tagged) {
|
|
390
|
+
if (!commands.has(pid)) commands.set(pid, await commandOf(pid));
|
|
391
|
+
}
|
|
392
|
+
const found =
|
|
393
|
+
only === undefined ? tagged : tagged.filter((pid) => only(commands.get(pid) ?? ''));
|
|
203
394
|
const fresh = found.filter((pid) => !killed.has(pid));
|
|
204
395
|
if (found.length === 0) break;
|
|
205
396
|
for (const pid of found) {
|
|
206
397
|
try {
|
|
207
398
|
process.kill(pid, 'SIGKILL');
|
|
208
|
-
killed.
|
|
399
|
+
if (!killed.has(pid)) killed.set(pid, { pid, command: commands.get(pid) ?? '' });
|
|
209
400
|
} catch {
|
|
210
401
|
// Already gone between the listing and the signal — which is the outcome wanted.
|
|
211
402
|
}
|
|
@@ -214,26 +405,9 @@ export async function killTagged(
|
|
|
214
405
|
if (fresh.length === 0) break;
|
|
215
406
|
await Bun.sleep(20);
|
|
216
407
|
}
|
|
217
|
-
return killed.
|
|
408
|
+
return [...killed.values()];
|
|
218
409
|
}
|
|
219
410
|
|
|
220
|
-
/**
|
|
221
|
-
* A step that ran past its deadline, on that step's own line. `command` is the gate as it was
|
|
222
|
-
* invoked here (`x verify`, or `bun run verify` at the framework root), so the fix runs where the
|
|
223
|
-
* finding was raised.
|
|
224
|
-
*/
|
|
225
|
-
export const stepTimeoutFinding = (
|
|
226
|
-
step: VerifyStepName,
|
|
227
|
-
ms: number,
|
|
228
|
-
killed: number,
|
|
229
|
-
command: string,
|
|
230
|
-
): Finding => ({
|
|
231
|
-
code: 'X_VERIFY_STEP_TIMEOUT',
|
|
232
|
-
cause: `step "${step}" did not finish within its ${String(ms)} ms deadline, so it was stopped and ${String(killed)} process(es) it had started were killed`,
|
|
233
|
-
fix: `${command} --only ${step} --json # reproduce it alone; if it legitimately needs longer, edit x.verify.json — add "${STEP_TIMEOUT_FIELD}": { "${step}": <milliseconds> }`,
|
|
234
|
-
docs: ERROR_DOCS_URL,
|
|
235
|
-
});
|
|
236
|
-
|
|
237
411
|
/** `work`, or `{ timedOut: true }` once `ms` passes. The abandoned promise is never unhandled. */
|
|
238
412
|
export async function raceDeadline<T>(
|
|
239
413
|
work: Promise<T>,
|
package/src/verify-merge.ts
CHANGED
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
import { existsSync } from 'node:fs';
|
|
17
17
|
// why: Bun ships no path-join primitive.
|
|
18
18
|
import { join } from 'node:path';
|
|
19
|
-
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
19
|
+
import { ERROR_DOCS_URL, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
20
20
|
import { APP_CONFIG_FILE } from './app-root';
|
|
21
21
|
import { judgeCoverage } from './coverage-floor';
|
|
22
22
|
import type { CoverageMap } from './coverage-lcov';
|
|
@@ -24,27 +24,29 @@ import { decodeCoverage, mergeCoverage } from './coverage-lcov';
|
|
|
24
24
|
import { msg } from './messages';
|
|
25
25
|
import type { CommandResult, Finding, StepResult } from './output';
|
|
26
26
|
import type { TestCounts } from './test-counts';
|
|
27
|
-
import { VerifyMergeInputError } from './verify-errors';
|
|
28
27
|
import type { VerifyFloor } from './verify-floor';
|
|
29
28
|
import { floorRequires, skippedSuiteFinding } from './verify-floor';
|
|
29
|
+
import type { PartStep } from './verify-part-step';
|
|
30
|
+
import { readPartStep } from './verify-part-step';
|
|
30
31
|
import { verifySummary } from './verify-run';
|
|
32
|
+
import { corpusHash } from './verify-shard';
|
|
31
33
|
import { GATE_COMMAND, VERIFY_STEP_NAMES } from './verify-step';
|
|
32
34
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
}
|
|
35
|
+
/**
|
|
36
|
+
* A part handed to `x verify merge` that is not an `x verify --json` document. `command` is the
|
|
37
|
+
* entry that raised it — `bun run verify` at the framework root, where `x verify` answers
|
|
38
|
+
* `X_NOT_IN_APP` — so the `fix:` runs where it was raised.
|
|
39
|
+
*/
|
|
40
|
+
export class VerifyMergeInputError extends UltimateError {
|
|
41
|
+
constructor(input: { file: string; reason: string; sourceError?: unknown; command?: string }) {
|
|
42
|
+
const command = input.command ?? GATE_COMMAND;
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_VERIFY_MERGE_INPUT',
|
|
45
|
+
cause: `${command} merge: ${input.file} ${input.reason}`,
|
|
46
|
+
fix: `${command} --only unit --json > part.json # one document per part, the last line of stdout`,
|
|
47
|
+
...(input.sourceError === undefined ? {} : { sourceError: input.sourceError }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
48
50
|
}
|
|
49
51
|
|
|
50
52
|
export interface VerifyPart {
|
|
@@ -70,8 +72,9 @@ const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
|
70
72
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
71
73
|
|
|
72
74
|
/**
|
|
73
|
-
* The LAST non-empty line
|
|
74
|
-
* the gate's
|
|
75
|
+
* The LAST non-empty line, and only it: `bin/check --json` prints the build's document and then
|
|
76
|
+
* the gate's. Never "the last line that parses" — a part whose tail is not the gate's document is
|
|
77
|
+
* a truncated or appended upload, and falling back to an earlier line would believe it.
|
|
75
78
|
*/
|
|
76
79
|
export function parsePart(file: string, text: string, command: string = GATE_COMMAND): VerifyPart {
|
|
77
80
|
const lines = text
|
|
@@ -100,14 +103,9 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
|
|
|
100
103
|
const data = isRecord(doc['data']) ? doc['data'] : {};
|
|
101
104
|
const steps: PartStep[] = [];
|
|
102
105
|
for (const raw of doc['steps'] as unknown[]) {
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
reason: 'holds a step without a name and ok',
|
|
107
|
-
command,
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
steps.push(raw as unknown as PartStep);
|
|
106
|
+
const step = readPartStep(raw);
|
|
107
|
+
if (typeof step === 'string') throw new VerifyMergeInputError({ file, reason: step, command });
|
|
108
|
+
steps.push(step);
|
|
111
109
|
}
|
|
112
110
|
const coverage = new Map<string, CoverageMap>();
|
|
113
111
|
for (const [step, entry] of Object.entries(isRecord(data['coverage']) ? data['coverage'] : {})) {
|
|
@@ -134,15 +132,41 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
|
|
|
134
132
|
/** A gap finding, with a fix spelled for the entry that raised it. */
|
|
135
133
|
type Gap = (cause: string, at?: string) => Finding;
|
|
136
134
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
135
|
+
/**
|
|
136
|
+
* The gap kinds a merge can find. `present` is a document that arrived and disagrees with the
|
|
137
|
+
* others; `absent` is one that never arrived at all — GitHub failing to acquire a hosted runner
|
|
138
|
+
* leaves the job `cancelled` with no steps and no upload, and "missing shard 2/3" alone read as a
|
|
139
|
+
* shard that ran and died. The two are fixed differently: the first is debugged, the second re-run.
|
|
140
|
+
*/
|
|
141
|
+
interface Gaps {
|
|
142
|
+
readonly present: Gap;
|
|
143
|
+
readonly absent: Gap;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** Said of every absent gap, so it can never be mistaken for a failure the part reported. */
|
|
147
|
+
const NEVER_ARRIVED =
|
|
148
|
+
'its result never arrived — its CI job never ran or never uploaded (cancelled before a step, or no runner acquired); this is not a failure it reported';
|
|
149
|
+
|
|
150
|
+
const gapsFor = (command: string): Gaps => {
|
|
151
|
+
const finding =
|
|
152
|
+
(fix: string): Gap =>
|
|
153
|
+
(cause, at) => ({
|
|
154
|
+
code: 'X_VERIFY_MERGE_INCOMPLETE',
|
|
155
|
+
cause,
|
|
156
|
+
fix,
|
|
157
|
+
docs: ERROR_DOCS_URL,
|
|
158
|
+
...(at === undefined ? {} : { at }),
|
|
159
|
+
});
|
|
160
|
+
return {
|
|
161
|
+
present: finding(
|
|
162
|
+
`${command} merge parts/*.json --json # after every CI job uploaded its part`,
|
|
163
|
+
),
|
|
164
|
+
// The merge is still what proves the gate whole; what it waits on is a re-run, not a fix.
|
|
165
|
+
absent: finding(
|
|
166
|
+
`${command} merge parts/*.json --json # after re-running the job that never reported: gh run rerun <run-id> --failed`,
|
|
167
|
+
),
|
|
168
|
+
};
|
|
169
|
+
};
|
|
146
170
|
|
|
147
171
|
/** The step every part's coverage facts belong to. */
|
|
148
172
|
const COVERED_STEP = 'unit';
|
|
@@ -184,20 +208,54 @@ const sumCounts = (steps: readonly PartStep[]): TestCounts | undefined => {
|
|
|
184
208
|
};
|
|
185
209
|
};
|
|
186
210
|
|
|
211
|
+
/**
|
|
212
|
+
* The shards' `files` must PARTITION the corpus they all hashed: each file in exactly one shard,
|
|
213
|
+
* and the union hashing to `corpusHash`. Agreeing on the hash is not agreeing on the split — shard
|
|
214
|
+
* 1/2 round-robin beside 2/2 with `--timings` ran one file twice and another nowhere, green.
|
|
215
|
+
*/
|
|
216
|
+
function partitionGaps(
|
|
217
|
+
name: string,
|
|
218
|
+
sharded: readonly { readonly step: PartStep }[],
|
|
219
|
+
gap: Gap,
|
|
220
|
+
): readonly Finding[] {
|
|
221
|
+
const counts = new Map<string, number>();
|
|
222
|
+
for (const entry of sharded) {
|
|
223
|
+
for (const file of entry.step.shard?.files ?? []) counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
224
|
+
}
|
|
225
|
+
const twice = [...counts].filter(([, count]) => count > 1).map(([file]) => file);
|
|
226
|
+
const found: Finding[] = [];
|
|
227
|
+
if (twice.length > 0) {
|
|
228
|
+
found.push(gap(`step "${name}" ran ${twice.join(', ')} in more than one shard`, name));
|
|
229
|
+
}
|
|
230
|
+
// A list's fingerprint, not a credential: what the shards ran against what they all counted.
|
|
231
|
+
const ranCorpus = corpusHash([...counts.keys()]);
|
|
232
|
+
const countedCorpus = sharded[0]?.step.shard?.corpusHash;
|
|
233
|
+
if (ranCorpus !== countedCorpus) {
|
|
234
|
+
found.push(
|
|
235
|
+
gap(
|
|
236
|
+
`step "${name}"'s shards ran ${String(counts.size)} distinct file(s), which is not the corpus they hashed — some file ran in no shard; every shard must split one list the same way (all with --timings, or none)`,
|
|
237
|
+
name,
|
|
238
|
+
),
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
return found;
|
|
242
|
+
}
|
|
243
|
+
|
|
187
244
|
/** One step's entries from every part, folded into one step result and its gaps. */
|
|
188
245
|
function mergeStep(
|
|
189
246
|
name: string,
|
|
190
247
|
entries: readonly { readonly part: string; readonly step: PartStep }[],
|
|
191
248
|
floor: VerifyFloor | undefined,
|
|
192
|
-
|
|
249
|
+
kinds: Gaps,
|
|
193
250
|
rider: MergeRiders[string] | undefined,
|
|
194
251
|
): StepResult {
|
|
252
|
+
const gap = kinds.present;
|
|
195
253
|
if (entries.length === 0) {
|
|
196
254
|
return {
|
|
197
255
|
name,
|
|
198
256
|
ok: false,
|
|
199
257
|
durationMs: 0,
|
|
200
|
-
findings: [
|
|
258
|
+
findings: [kinds.absent(`step "${name}" is in no part: ${NEVER_ARRIVED}`, name)],
|
|
201
259
|
};
|
|
202
260
|
}
|
|
203
261
|
const sharded = entries.filter((entry) => entry.step.shard !== undefined);
|
|
@@ -237,8 +295,8 @@ function mergeStep(
|
|
|
237
295
|
const doubled = [...seen].filter(([, count]) => count > 1).map(([index]) => index);
|
|
238
296
|
if (missing.length > 0) {
|
|
239
297
|
gaps.push(
|
|
240
|
-
|
|
241
|
-
`step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}`,
|
|
298
|
+
kinds.absent(
|
|
299
|
+
`step "${name}" is missing shard(s) ${missing.map((i) => `${String(i)}/${String(total)}`).join(', ')}: ${NEVER_ARRIVED}`,
|
|
242
300
|
name,
|
|
243
301
|
),
|
|
244
302
|
);
|
|
@@ -246,6 +304,8 @@ function mergeStep(
|
|
|
246
304
|
if (doubled.length > 0) {
|
|
247
305
|
gaps.push(gap(`step "${name}" has shard(s) ${doubled.join(', ')} more than once`, name));
|
|
248
306
|
}
|
|
307
|
+
// Only over a complete, single-corpus set — any gap above already explains a partial union.
|
|
308
|
+
if (gaps.length === 0) gaps.push(...partitionGaps(name, sharded, gap));
|
|
249
309
|
}
|
|
250
310
|
const steps = entries.map((entry) => entry.step);
|
|
251
311
|
const tests = sumCounts(steps);
|
|
@@ -262,6 +322,8 @@ function mergeStep(
|
|
|
262
322
|
.map((step) => step.output as string),
|
|
263
323
|
...(rider?.output === undefined ? [] : [rider.output]),
|
|
264
324
|
].join('\n');
|
|
325
|
+
// Every part's advice, once each and in the order the parts reported it.
|
|
326
|
+
const warnings = [...new Set(steps.flatMap((step) => step.warnings ?? []))];
|
|
265
327
|
const skipped =
|
|
266
328
|
sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
|
|
267
329
|
return {
|
|
@@ -276,6 +338,7 @@ function mergeStep(
|
|
|
276
338
|
...(skipped ? { skipped: true } : {}),
|
|
277
339
|
findings,
|
|
278
340
|
...(output.length === 0 ? {} : { output }),
|
|
341
|
+
...(warnings.length === 0 ? {} : { warnings }),
|
|
279
342
|
...(tests === undefined ? {} : { tests }),
|
|
280
343
|
};
|
|
281
344
|
}
|
|
@@ -287,11 +350,13 @@ export function mergeParts(
|
|
|
287
350
|
declared: readonly string[] = VERIFY_STEP_NAMES,
|
|
288
351
|
options: MergeOptions = {},
|
|
289
352
|
): CommandResult {
|
|
290
|
-
const
|
|
353
|
+
const kinds = gapsFor(options.command ?? GATE_COMMAND);
|
|
291
354
|
const unknown = parts.flatMap((part) =>
|
|
292
355
|
part.steps
|
|
293
356
|
.filter((step) => !declared.includes(step.name))
|
|
294
|
-
.map((step) =>
|
|
357
|
+
.map((step) =>
|
|
358
|
+
kinds.present(`${part.file} reports "${step.name}", which x verify does not run`),
|
|
359
|
+
),
|
|
295
360
|
);
|
|
296
361
|
const steps = declared.map((name) =>
|
|
297
362
|
mergeStep(
|
|
@@ -300,7 +365,7 @@ export function mergeParts(
|
|
|
300
365
|
part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
|
|
301
366
|
),
|
|
302
367
|
floor,
|
|
303
|
-
|
|
368
|
+
kinds,
|
|
304
369
|
options.riders !== undefined && Object.hasOwn(options.riders, name)
|
|
305
370
|
? options.riders[name]
|
|
306
371
|
: undefined,
|