@ultimat3/cli 22.15.0 → 24.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 +36 -13
- package/README.md +31 -8
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/affected.ts +27 -35
- package/src/api-registration.ts +125 -11
- package/src/app-artifacts.ts +1 -1
- package/src/app-auth.ts +4 -27
- package/src/app-boundaries.ts +6 -20
- package/src/app-config-load.ts +51 -0
- package/src/app-env.ts +5 -11
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +16 -8
- package/src/app-mcp.ts +10 -23
- package/src/app-permissions-borrowed.ts +129 -0
- package/src/app-permissions-site.ts +24 -0
- package/src/app-permissions.ts +0 -0
- package/src/app-reload-graph.ts +7 -30
- package/src/app-transport.ts +219 -0
- package/src/boundary-findings.ts +16 -4
- package/src/browser-transport.ts +160 -0
- package/src/budgets.ts +27 -34
- package/src/cdp-shot-driver.ts +3 -1
- package/src/cmd-auth-spec.ts +16 -0
- package/src/cmd-auth.ts +65 -0
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +107 -11
- package/src/cmd-db-backfill.ts +25 -12
- package/src/cmd-db.ts +66 -16
- package/src/cmd-deploy-helm.ts +7 -6
- package/src/cmd-deploy.ts +4 -4
- package/src/cmd-dev.ts +9 -8
- package/src/cmd-doctor-spec.ts +2 -9
- package/src/cmd-doctor.ts +75 -24
- package/src/cmd-env.ts +26 -2
- package/src/cmd-generate.ts +195 -47
- package/src/cmd-help.ts +19 -6
- package/src/cmd-i18n.ts +10 -9
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +136 -7
- package/src/cmd-manifest-spec.ts +1 -1
- package/src/cmd-manifest.ts +6 -25
- package/src/cmd-mcp.ts +46 -15
- package/src/cmd-new.ts +15 -1
- package/src/cmd-planned.ts +15 -8
- package/src/cmd-routes.ts +27 -3
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +95 -18
- package/src/cmd-shot-matrix.ts +1 -3
- package/src/cmd-shot.ts +12 -3
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-test-spec.ts +1 -7
- package/src/cmd-test.ts +13 -29
- package/src/cmd-verify.ts +13 -5
- package/src/comment-safe.ts +16 -0
- package/src/coverage-floor.ts +334 -0
- package/src/coverage-lcov.ts +193 -0
- package/src/coverage-source.ts +136 -0
- package/src/db-object-drift.ts +30 -0
- package/src/db-replay-engine.ts +115 -0
- package/src/db-schema-dump.ts +150 -0
- package/src/db-schema-refresh.ts +122 -0
- package/src/db-subscribes.ts +12 -4
- package/src/dev-dashboard-guard.ts +76 -0
- package/src/dev-dashboard.ts +51 -29
- package/src/dev-lock.ts +4 -7
- package/src/dev-route-table.ts +9 -0
- package/src/dispatch.ts +3 -6
- package/src/doctor-app-url.ts +39 -0
- package/src/doctor-auth.ts +95 -0
- package/src/doctor-coverage.ts +39 -0
- package/src/doctor-guards.ts +54 -0
- package/src/doctor-sealed.ts +132 -0
- package/src/document-graph.ts +82 -0
- package/src/drift-replica-identity.ts +42 -0
- package/src/error-catalog.ts +6 -9
- package/src/error-codes.ts +55 -3
- package/src/error-unthrown.ts +29 -11
- package/src/errors.ts +54 -83
- package/src/framework-schema-apply.ts +78 -0
- package/src/framework-schema-stamp.ts +96 -0
- package/src/framework-schema.ts +28 -2
- package/src/generate-files.ts +79 -12
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +52 -1
- package/src/generate-shadow.ts +46 -0
- package/src/generate-write.ts +62 -40
- package/src/generated-imports.ts +59 -0
- package/src/gh-target.ts +12 -7
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- package/src/i18n-audit.ts +4 -1
- package/src/i18n-registration.ts +36 -0
- package/src/image-prepare.ts +51 -8
- package/src/import-closure.ts +165 -0
- package/src/index.ts +12 -5
- package/src/island-bundle.ts +3 -15
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +95 -21
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +7 -4
- package/src/json-merge.ts +46 -0
- package/src/loopback-host.ts +13 -0
- package/src/manifest-staleness.ts +62 -0
- package/src/mcp-errors.ts +41 -4
- package/src/mcp-host.ts +16 -9
- package/src/mcp-test-run.ts +38 -0
- package/src/messages.ts +35 -0
- package/src/metrics-endpoint.ts +95 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +24 -9
- package/src/page-navigation.ts +7 -23
- package/src/page-speculation.ts +5 -20
- package/src/permission-grants.ts +41 -9
- package/src/prerender-out.ts +34 -7
- package/src/prerender.ts +8 -5
- package/src/publish-closure.ts +101 -0
- package/src/pwa-artifacts.ts +26 -94
- package/src/registry.ts +2 -0
- package/src/revalidated-response.ts +4 -3
- package/src/role-load.ts +143 -0
- package/src/role-replicator.ts +16 -0
- package/src/role-start.ts +27 -5
- package/src/role-sync.ts +55 -20
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-assets.ts +11 -7
- package/src/runtime-bindings.ts +4 -4
- package/src/runtime-cache.ts +38 -47
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-idempotency-scope.ts +23 -0
- package/src/runtime-isr.ts +36 -0
- package/src/runtime-jobs.ts +8 -33
- package/src/runtime-mfa-warning.ts +29 -0
- package/src/runtime-notify-retention.ts +10 -46
- package/src/runtime-overrides.ts +1 -1
- package/src/runtime-purge.ts +38 -16
- package/src/runtime-queue.ts +39 -11
- package/src/runtime-realtime.ts +9 -45
- package/src/runtime-render.ts +68 -35
- package/src/runtime-services.ts +22 -18
- package/src/runtime-storage.ts +10 -5
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-diff.ts +16 -0
- package/src/schema-drift.ts +45 -15
- package/src/schema-dump-drift.ts +64 -0
- package/src/seo-routes.ts +80 -9
- package/src/serve-boot.ts +114 -158
- package/src/serve-drain.ts +9 -33
- package/src/serve-env.ts +9 -6
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +169 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/shot-locale.ts +5 -23
- package/src/site-assets.ts +3 -5
- package/src/site-config.ts +9 -48
- package/src/site-seo.ts +1 -1
- package/src/sitemap-lastmod.ts +5 -0
- package/src/solid-loader.ts +12 -3
- package/src/source-edit.ts +49 -0
- package/src/source-files.ts +6 -0
- package/src/stored-object-headers.ts +43 -0
- package/src/sync-url.ts +2 -2
- package/src/tasks-facts.ts +3 -26
- package/src/templates/action.ts +147 -11
- package/src/templates/admin-catalog.ts +58 -0
- package/src/templates/admin-page.ts +36 -8
- package/src/templates/admin.ts +57 -17
- package/src/templates/backfill.ts +121 -87
- package/src/templates/entity.ts +140 -44
- package/src/templates/guard-animated-layout-property.ts +4 -11
- package/src/templates/guard-bare-error.ts +9 -13
- package/src/templates/guard-focus-visible.ts +4 -11
- package/src/templates/guard-image-dimensions.ts +6 -6
- package/src/templates/guard-island-without-states.ts +2 -4
- package/src/templates/guard-raw-breakpoint.ts +210 -0
- package/src/templates/guard-raw-colour.ts +4 -11
- package/src/templates/guard-raw-length.ts +375 -0
- package/src/templates/guard-raw-motion.ts +213 -0
- package/src/templates/guard-raw-shadow.ts +187 -0
- package/src/templates/guard-raw-z-index.ts +166 -0
- package/src/templates/guard-repo-raw-sql.ts +345 -0
- package/src/templates/guard-semantic-interactive.ts +6 -6
- package/src/templates/guard-undeclared-custom-property.ts +217 -0
- package/src/templates/guard-undefined-style-class.ts +215 -0
- package/src/templates/guard-untranslated-string.ts +6 -6
- package/src/templates/guard-unzoned-date.ts +9 -13
- package/src/templates/index.ts +3 -2
- package/src/templates/island.ts +34 -51
- package/src/templates/job-body-test.ts +121 -0
- package/src/templates/job.ts +23 -8
- package/src/templates/naming.ts +28 -0
- package/src/templates/policy.ts +16 -9
- package/src/templates/query.ts +242 -26
- package/src/templates/resource-create.ts +62 -18
- package/src/templates/resource-form-island.ts +58 -80
- package/src/templates/resource-page.ts +269 -0
- package/src/templates/resource-service.ts +96 -0
- package/src/templates/resource.ts +86 -61
- package/src/templates/route.ts +41 -35
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +177 -53
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container-compose.ts +10 -1
- package/src/templates/scaffold-container.ts +20 -2
- package/src/templates/scaffold-dashboard-bare.ts +64 -2
- package/src/templates/scaffold-dashboard-example.ts +124 -31
- package/src/templates/scaffold-dashboard-shared.ts +1 -20
- package/src/templates/scaffold-dashboard.ts +1 -2
- package/src/templates/scaffold-db-client.ts +144 -0
- package/src/templates/scaffold-db-package.ts +90 -8
- package/src/templates/scaffold-docs.ts +50 -5
- package/src/templates/scaffold-guards.ts +54 -11
- package/src/templates/scaffold-helm-fences.ts +76 -0
- package/src/templates/scaffold-helm-templates.ts +32 -7
- package/src/templates/scaffold-helm.ts +31 -1
- package/src/templates/scaffold-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +61 -3
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +74 -105
- package/src/templates/scaffold-site.ts +45 -19
- package/src/templates/scaffold-ui-package.ts +18 -0
- package/src/templates/slice-foundation.ts +46 -8
- package/src/test-counts.ts +14 -2
- package/src/test-ignores.ts +25 -0
- package/src/test-passes.ts +0 -15
- package/src/test-select.ts +5 -14
- package/src/test-shards.ts +41 -93
- package/src/test-workers.ts +26 -4
- package/src/theme-boot.ts +6 -31
- package/src/transport-calls.ts +105 -0
- package/src/trusted-hops-error.ts +17 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/unthrown-codes.ts +26 -0
- package/src/verify-checks.ts +43 -53
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +353 -0
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +171 -45
- package/src/verify-part-step.ts +90 -0
- package/src/verify-progress.ts +25 -0
- package/src/verify-role-load-probe.ts +54 -0
- package/src/verify-role-load.ts +148 -0
- package/src/verify-run.ts +116 -37
- package/src/verify-shard.ts +33 -11
- package/src/verify-stalled.ts +152 -0
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +83 -8
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- package/src/build-errors.ts +0 -54
- package/src/cmd-dev-fixture.ts +0 -204
- package/src/dev-live-fixture.ts +0 -122
- package/src/policy-fixture.ts +0 -78
- package/src/realtime-browser-probe-fixture.ts +0 -9
- package/src/role-start-fixture.ts +0 -78
- package/src/scaffold-fixture.ts +0 -160
- package/src/templates/island-fixture.ts +0 -76
- package/src/verify-errors.ts +0 -49
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
// A gate step's deadline: the declared number per step class, the `x.verify.json` override, the
|
|
2
|
+
// tag every process a step starts carries, and the kill that leaves none of them behind. A hung
|
|
3
|
+
// step used to eat the whole CI job timeout and report nothing (#589); now it fails by name.
|
|
4
|
+
|
|
5
|
+
import { TEST_TYPES } from '@ultimat3/testing';
|
|
6
|
+
import type { ExecResult, Runner } from './exec';
|
|
7
|
+
import { execOutput } from './exec';
|
|
8
|
+
import type { VerifyStepName } from './verify-step';
|
|
9
|
+
|
|
10
|
+
/** The `x.verify.json` key: `{ "stepTimeoutMs": { "unit": 900000 } }`. */
|
|
11
|
+
export const STEP_TIMEOUT_FIELD = 'stepTimeoutMs';
|
|
12
|
+
|
|
13
|
+
/** A check step — the compiler, biome, an in-process scan. Five minutes. */
|
|
14
|
+
export const CHECK_STEP_TIMEOUT_MS = 300_000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A test suite. Eight minutes: under the ten a CI job is commonly given, so the step names itself
|
|
18
|
+
* before the runner cancels the job — the framework's own `unit` step is ~3 on a 4-core runner.
|
|
19
|
+
*/
|
|
20
|
+
export const SUITE_STEP_TIMEOUT_MS = 480_000;
|
|
21
|
+
|
|
22
|
+
const SUITE_STEPS: readonly string[] = TEST_TYPES;
|
|
23
|
+
|
|
24
|
+
export const defaultStepTimeoutMs = (step: string): number =>
|
|
25
|
+
SUITE_STEPS.includes(step) ? SUITE_STEP_TIMEOUT_MS : CHECK_STEP_TIMEOUT_MS;
|
|
26
|
+
|
|
27
|
+
export type StepTimeouts = Readonly<Partial<Record<VerifyStepName, number>>>;
|
|
28
|
+
|
|
29
|
+
/** The caller's override, then the floor file's, then the step class's declared number. */
|
|
30
|
+
export const stepTimeoutMs = (
|
|
31
|
+
step: VerifyStepName,
|
|
32
|
+
...overrides: readonly (StepTimeouts | undefined)[]
|
|
33
|
+
): number => {
|
|
34
|
+
for (const table of overrides) {
|
|
35
|
+
const ms = table !== undefined && Object.hasOwn(table, step) ? table[step] : undefined;
|
|
36
|
+
if (ms !== undefined) return ms;
|
|
37
|
+
}
|
|
38
|
+
return defaultStepTimeoutMs(step);
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* `stepTimeoutMs` out of `x.verify.json`, or the reasons it is not one. A value that is not a
|
|
43
|
+
* positive whole number is refused, never read as "no deadline": a typo that removed the deadline
|
|
44
|
+
* is the hang this file exists to end.
|
|
45
|
+
*/
|
|
46
|
+
export function readStepTimeouts(
|
|
47
|
+
payload: Record<string, unknown> | undefined,
|
|
48
|
+
declared: readonly string[],
|
|
49
|
+
): { timeouts?: StepTimeouts; problems: readonly string[] } {
|
|
50
|
+
const raw = payload?.[STEP_TIMEOUT_FIELD];
|
|
51
|
+
if (raw === undefined) return { problems: [] };
|
|
52
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
53
|
+
return { problems: [`"${STEP_TIMEOUT_FIELD}" is not an object of { "<step>": milliseconds }`] };
|
|
54
|
+
}
|
|
55
|
+
const problems: string[] = [];
|
|
56
|
+
const timeouts = new Map<string, number>();
|
|
57
|
+
for (const [step, ms] of Object.entries(raw)) {
|
|
58
|
+
if (!declared.includes(step)) {
|
|
59
|
+
problems.push(`"${STEP_TIMEOUT_FIELD}" names ${step}, which x verify does not run`);
|
|
60
|
+
} else if (typeof ms !== 'number' || !Number.isSafeInteger(ms) || ms <= 0) {
|
|
61
|
+
problems.push(
|
|
62
|
+
`"${STEP_TIMEOUT_FIELD}" gives ${step} ${JSON.stringify(ms)}, which is not a positive whole number of milliseconds`,
|
|
63
|
+
);
|
|
64
|
+
} else timeouts.set(step, ms);
|
|
65
|
+
}
|
|
66
|
+
return {
|
|
67
|
+
...(timeouts.size === 0 ? {} : { timeouts: Object.fromEntries(timeouts) as StepTimeouts }),
|
|
68
|
+
problems,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Carried by every process a step starts, and inherited by everything those start in turn. */
|
|
73
|
+
export const STEP_TAG_ENV = 'ULTIMATE_VERIFY_STEP';
|
|
74
|
+
|
|
75
|
+
/** `bun test`-style "the command was stopped" exit, as `timeout(1)` reports it. */
|
|
76
|
+
const EXPIRED_EXIT = 124;
|
|
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
|
+
|
|
114
|
+
export interface StepGuard {
|
|
115
|
+
/** `runner`, with every child tagged — and refusing to start one once the step has expired. */
|
|
116
|
+
readonly runner: Runner;
|
|
117
|
+
/** Stop the step: no further child starts, every tagged process is killed, and what was in flight is told. */
|
|
118
|
+
expire(): Promise<StepExpiry>;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* The step's own view of the subprocess boundary. The tag is an ENVIRONMENT variable rather than a
|
|
123
|
+
* pid list or a process group because it is the one mark a grandchild keeps after its parent dies:
|
|
124
|
+
* `bun test --parallel` workers, `bunx`'s child and an e2e browser are all re-parented to init the
|
|
125
|
+
* moment the process this file started exits, and a walk down the ppid chain loses them there.
|
|
126
|
+
*
|
|
127
|
+
* A nested gate (a test that runs `x verify`) appends its tag to the one it inherited, so the
|
|
128
|
+
* outer step's kill still finds the inner step's children.
|
|
129
|
+
*/
|
|
130
|
+
export function guardStep(
|
|
131
|
+
runner: Runner,
|
|
132
|
+
tag: string,
|
|
133
|
+
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
134
|
+
): StepGuard {
|
|
135
|
+
let expired = false;
|
|
136
|
+
// Keyed by the promise itself: two batches of one step may run the very same argv.
|
|
137
|
+
const inFlight = new Map<Promise<ExecResult>, readonly string[]>();
|
|
138
|
+
const inherited = env[STEP_TAG_ENV];
|
|
139
|
+
const value = inherited === undefined || inherited === '' ? tag : `${inherited} ${tag}`;
|
|
140
|
+
return {
|
|
141
|
+
runner: async (command, options): Promise<ExecResult> => {
|
|
142
|
+
// The step's own code keeps running after its deadline — a batch loop moves on to the next
|
|
143
|
+
// batch when the killed one returns — and a child started then would outlive the gate.
|
|
144
|
+
if (expired) {
|
|
145
|
+
return {
|
|
146
|
+
command,
|
|
147
|
+
code: EXPIRED_EXIT,
|
|
148
|
+
ok: false,
|
|
149
|
+
stdout: '',
|
|
150
|
+
stderr: 'not started: the step is past its deadline',
|
|
151
|
+
durationMs: 0,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const run = runner(command, { ...options, env: { ...options.env, [STEP_TAG_ENV]: value } });
|
|
155
|
+
inFlight.set(run, command);
|
|
156
|
+
try {
|
|
157
|
+
return await run;
|
|
158
|
+
} finally {
|
|
159
|
+
inFlight.delete(run);
|
|
160
|
+
}
|
|
161
|
+
},
|
|
162
|
+
async expire(): Promise<StepExpiry> {
|
|
163
|
+
expired = true;
|
|
164
|
+
// Before the kill: a killed child settles its runner call, which takes it off the map.
|
|
165
|
+
const waiting = [...inFlight];
|
|
166
|
+
const workers = await killTagged(tag, undefined, isTestWorker);
|
|
167
|
+
if (workers.length > 0) {
|
|
168
|
+
await raceDeadline(Promise.allSettled(waiting.map(([run]) => run)), CRASH_REPORT_MS);
|
|
169
|
+
}
|
|
170
|
+
const rest = await killTagged(tag);
|
|
171
|
+
const killed = [...workers, ...rest.filter((one) => workers.every((w) => w.pid !== one.pid))];
|
|
172
|
+
// After it: the kill closed the child's pipes, so its call now resolves with everything it
|
|
173
|
+
// had printed — for `bun test`, the crash line of each file a worker was holding.
|
|
174
|
+
const settled = await Promise.all(
|
|
175
|
+
waiting.map(async ([run, command]): Promise<InFlightRun> => {
|
|
176
|
+
const raced = await raceDeadline(run.then(execOutput), SETTLE_MS).catch(() => undefined);
|
|
177
|
+
return raced === undefined || raced.timedOut
|
|
178
|
+
? { command }
|
|
179
|
+
: { command, output: raced.value };
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
182
|
+
return { killed, inFlight: settled };
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** One process and the environment it was started with, as the platform reports it. */
|
|
188
|
+
export interface ProcessEnviron {
|
|
189
|
+
readonly pid: number;
|
|
190
|
+
/** Whatever text holds `NAME=value` pairs — NUL-separated on Linux, space-separated from `ps`. */
|
|
191
|
+
readonly environ: string;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The pids whose environment carries `tag` as one word of `STEP_TAG_ENV`'s value. */
|
|
195
|
+
export function taggedPids(processes: readonly ProcessEnviron[], tag: string): readonly number[] {
|
|
196
|
+
const marker = `${STEP_TAG_ENV}=`;
|
|
197
|
+
const pids: number[] = [];
|
|
198
|
+
for (const one of processes) {
|
|
199
|
+
for (const pair of one.environ.split('\0')) {
|
|
200
|
+
const at = pair.indexOf(marker);
|
|
201
|
+
if (at < 0) continue;
|
|
202
|
+
// From `ps`, the pair is one line of space-separated pairs: the value runs to the next
|
|
203
|
+
// ` NAME=`, and a tag never holds `=`.
|
|
204
|
+
const value = pair.slice(at + marker.length).split(/ (?=[A-Za-z_][A-Za-z0-9_]*=)/)[0] ?? '';
|
|
205
|
+
if (value.split(' ').includes(tag)) pids.push(one.pid);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return pids;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** Linux: every process's start-time environment is a file. Unreadable ones are not ours. */
|
|
212
|
+
async function procfsProcesses(): Promise<readonly ProcessEnviron[] | undefined> {
|
|
213
|
+
if (!(await Bun.file('/proc/self/environ').exists())) return undefined;
|
|
214
|
+
const out: ProcessEnviron[] = [];
|
|
215
|
+
for await (const entry of new Bun.Glob('[0-9]*').scan({ cwd: '/proc', onlyFiles: false })) {
|
|
216
|
+
const pid = Number(entry);
|
|
217
|
+
if (!Number.isSafeInteger(pid)) continue;
|
|
218
|
+
const environ = await Bun.file(`/proc/${entry}/environ`)
|
|
219
|
+
.text()
|
|
220
|
+
.catch(() => undefined);
|
|
221
|
+
if (environ !== undefined) out.push({ pid, environ });
|
|
222
|
+
}
|
|
223
|
+
return out;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/** One `ps` listing; past this the sweep answers what it has rather than outliving the step. */
|
|
227
|
+
const PS_TIMEOUT_MS = 10_000;
|
|
228
|
+
|
|
229
|
+
const PS_LISTING: readonly string[] = ['ps', 'axeww', '-o', 'pid=,command='];
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Elsewhere (macOS, BSD): `ps` in its BSD spelling — `e` prints each process's environment after
|
|
233
|
+
* its command, `ww` never truncates it. The same spelling answers on Linux, which is where it is
|
|
234
|
+
* tested; procfs is preferred there because it needs no process and no parsing.
|
|
235
|
+
*/
|
|
236
|
+
export async function psProcesses(
|
|
237
|
+
argv: readonly string[] = PS_LISTING,
|
|
238
|
+
timeoutMs: number = PS_TIMEOUT_MS,
|
|
239
|
+
): Promise<readonly ProcessEnviron[]> {
|
|
240
|
+
try {
|
|
241
|
+
const ps = Bun.spawn([...argv], {
|
|
242
|
+
stdout: 'pipe',
|
|
243
|
+
stderr: 'ignore',
|
|
244
|
+
// The deadline's own sweep: a `ps` that hangs would hang the step it exists to end. SIGKILL,
|
|
245
|
+
// never the default SIGTERM, which a wedged child may ignore and so outlive the bound.
|
|
246
|
+
timeout: timeoutMs,
|
|
247
|
+
killSignal: 'SIGKILL',
|
|
248
|
+
});
|
|
249
|
+
const [text, code] = await Promise.all([new Response(ps.stdout).text(), ps.exited]);
|
|
250
|
+
// A listing cut short by the timeout is NOT a smaller table: read as one, a tagged worker past
|
|
251
|
+
// the cut is "nothing left to kill". Unreadable, exactly like a `ps` that never ran.
|
|
252
|
+
if (code !== 0) return [];
|
|
253
|
+
return text.split('\n').flatMap((line) => {
|
|
254
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
255
|
+
return match === null ? [] : [{ pid: Number(match[1]), environ: match[2] ?? '' }];
|
|
256
|
+
});
|
|
257
|
+
} catch {
|
|
258
|
+
return [];
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const SWEEPS = 5;
|
|
263
|
+
|
|
264
|
+
const COMMAND_CHARS = 240;
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* A process's argv, read BEFORE it is killed — afterwards there is nothing to read. procfs where
|
|
268
|
+
* there is one, `ps` otherwise; a process that is already gone is named by its pid alone.
|
|
269
|
+
*/
|
|
270
|
+
async function commandOf(pid: number): Promise<string> {
|
|
271
|
+
let text = await Bun.file(`/proc/${String(pid)}/cmdline`)
|
|
272
|
+
.text()
|
|
273
|
+
.catch(() => '');
|
|
274
|
+
if (text === '') {
|
|
275
|
+
try {
|
|
276
|
+
const ps = Bun.spawn(['ps', '-ww', '-o', 'command=', '-p', String(pid)], {
|
|
277
|
+
stdout: 'pipe',
|
|
278
|
+
stderr: 'ignore',
|
|
279
|
+
timeout: PS_TIMEOUT_MS,
|
|
280
|
+
killSignal: 'SIGKILL',
|
|
281
|
+
});
|
|
282
|
+
text = await new Response(ps.stdout).text();
|
|
283
|
+
await ps.exited;
|
|
284
|
+
} catch {
|
|
285
|
+
text = '';
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
const line = text.split('\0').join(' ').trim();
|
|
289
|
+
return line.length > COMMAND_CHARS ? `${line.slice(0, COMMAND_CHARS)}…` : line;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* SIGKILL every process carrying `tag`, and sweep again until a pass finds none: a worker can fork
|
|
294
|
+
* between the listing and its own death. Never this process — a nested gate carries the outer tag.
|
|
295
|
+
* Returns the processes it killed, each with the command line it was running.
|
|
296
|
+
*/
|
|
297
|
+
export async function killTagged(
|
|
298
|
+
tag: string,
|
|
299
|
+
/** The process table. Absent is this platform's: procfs where there is one, `ps` otherwise. */
|
|
300
|
+
list: () => Promise<readonly ProcessEnviron[]> = async () =>
|
|
301
|
+
(await procfsProcesses()) ?? (await psProcesses()),
|
|
302
|
+
/** Kill only the processes whose command line this accepts. Absent kills every tagged one. */
|
|
303
|
+
only?: (command: string) => boolean,
|
|
304
|
+
): Promise<readonly KilledProcess[]> {
|
|
305
|
+
const killed = new Map<number, KilledProcess>();
|
|
306
|
+
// Read once per pid and BEFORE the first signal: a worker exits on its own the moment its
|
|
307
|
+
// coordinator dies, and one read between two kills would find it already gone.
|
|
308
|
+
const commands = new Map<number, string>();
|
|
309
|
+
for (let sweep = 0; sweep < SWEEPS; sweep += 1) {
|
|
310
|
+
const processes = await list();
|
|
311
|
+
const tagged = taggedPids(processes, tag).filter((pid) => pid !== process.pid);
|
|
312
|
+
for (const pid of tagged) {
|
|
313
|
+
if (!commands.has(pid)) commands.set(pid, await commandOf(pid));
|
|
314
|
+
}
|
|
315
|
+
const found =
|
|
316
|
+
only === undefined ? tagged : tagged.filter((pid) => only(commands.get(pid) ?? ''));
|
|
317
|
+
const fresh = found.filter((pid) => !killed.has(pid));
|
|
318
|
+
if (found.length === 0) break;
|
|
319
|
+
for (const pid of found) {
|
|
320
|
+
try {
|
|
321
|
+
process.kill(pid, 'SIGKILL');
|
|
322
|
+
if (!killed.has(pid)) killed.set(pid, { pid, command: commands.get(pid) ?? '' });
|
|
323
|
+
} catch {
|
|
324
|
+
// Already gone between the listing and the signal — which is the outcome wanted.
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
// A killed process stays listed until it is reaped; nothing NEW on a pass means it is done.
|
|
328
|
+
if (fresh.length === 0) break;
|
|
329
|
+
await Bun.sleep(20);
|
|
330
|
+
}
|
|
331
|
+
return [...killed.values()];
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/** `work`, or `{ timedOut: true }` once `ms` passes. The abandoned promise is never unhandled. */
|
|
335
|
+
export async function raceDeadline<T>(
|
|
336
|
+
work: Promise<T>,
|
|
337
|
+
ms: number,
|
|
338
|
+
): Promise<{ readonly timedOut: false; readonly value: T } | { readonly timedOut: true }> {
|
|
339
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
340
|
+
const expired = new Promise<{ readonly timedOut: true }>((resolve) => {
|
|
341
|
+
timer = setTimeout(() => resolve({ timedOut: true }), ms);
|
|
342
|
+
});
|
|
343
|
+
try {
|
|
344
|
+
const raced = await Promise.race([
|
|
345
|
+
work.then((value) => ({ timedOut: false as const, value })),
|
|
346
|
+
expired,
|
|
347
|
+
]);
|
|
348
|
+
if (raced.timedOut) work.catch(() => undefined);
|
|
349
|
+
return raced;
|
|
350
|
+
} finally {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
}
|
|
353
|
+
}
|
package/src/verify-floor.ts
CHANGED
|
@@ -9,8 +9,12 @@ import { existsSync } from 'node:fs';
|
|
|
9
9
|
import { join } from 'node:path';
|
|
10
10
|
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
11
11
|
import { AGENTS_MD_MAX_BYTES } from '@ultimat3/manifest';
|
|
12
|
+
import type { CoverageFloor } from './coverage-floor';
|
|
13
|
+
import { COVERAGE_FIELD, readCoverageFloor } from './coverage-floor';
|
|
12
14
|
import type { Finding } from './output';
|
|
13
|
-
import {
|
|
15
|
+
import type { StepTimeouts } from './verify-deadline';
|
|
16
|
+
import { readStepTimeouts, STEP_TIMEOUT_FIELD } from './verify-deadline';
|
|
17
|
+
import { GATE_COMMAND, VERIFY_STEP_NAMES } from './verify-step';
|
|
14
18
|
|
|
15
19
|
/** Hand-written and committed, beside the generated `x.manifest.json` the same gate reads. */
|
|
16
20
|
export const VERIFY_FLOOR_FILE = 'x.verify.json';
|
|
@@ -48,6 +52,16 @@ export interface VerifyFloor {
|
|
|
48
52
|
* says `true` here once, in the file that configures the gate, and `x test`/`x verify` honour it.
|
|
49
53
|
*/
|
|
50
54
|
readonly isolate?: boolean;
|
|
55
|
+
/**
|
|
56
|
+
* A step's own deadline in milliseconds, beating its class's declared number
|
|
57
|
+
* (`verify-deadline.ts`). A step past it is `X_VERIFY_STEP_TIMEOUT`, by name.
|
|
58
|
+
*/
|
|
59
|
+
readonly stepTimeoutMs?: StepTimeouts;
|
|
60
|
+
/**
|
|
61
|
+
* The coverage the `unit` step must hold, of the app's whole source tree (`coverage-floor.ts`).
|
|
62
|
+
* Absent in an app is `X_COVERAGE_FLOOR_UNSTATED`: no app is held to a number it did not write.
|
|
63
|
+
*/
|
|
64
|
+
readonly coverage?: CoverageFloor;
|
|
51
65
|
/** Why part of the file is not a floor. The `manifest` step reports these; nothing swallows them. */
|
|
52
66
|
readonly problems: readonly string[];
|
|
53
67
|
}
|
|
@@ -147,18 +161,27 @@ export function parseVerifyFloor(
|
|
|
147
161
|
const budget = readBudget(record);
|
|
148
162
|
const typecheckBin = readTypecheckBin(record);
|
|
149
163
|
const isolate = readIsolate(record);
|
|
164
|
+
const timeouts = readStepTimeouts(record, declared);
|
|
165
|
+
const coverage = readCoverageFloor(record);
|
|
166
|
+
const rest = {
|
|
167
|
+
...(budget.budget === undefined ? {} : { agentsMdMaxBytes: budget.budget }),
|
|
168
|
+
...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
|
|
169
|
+
...(isolate.isolate === undefined ? {} : { isolate: isolate.isolate }),
|
|
170
|
+
...(timeouts.timeouts === undefined ? {} : { stepTimeoutMs: timeouts.timeouts }),
|
|
171
|
+
...(coverage.coverage === undefined ? {} : { coverage: coverage.coverage }),
|
|
172
|
+
};
|
|
150
173
|
const steps = record?.['steps'];
|
|
151
174
|
if (!Array.isArray(steps)) {
|
|
152
175
|
return {
|
|
153
176
|
steps: [],
|
|
154
|
-
...
|
|
155
|
-
...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
|
|
156
|
-
...(isolate.isolate === undefined ? {} : { isolate: isolate.isolate }),
|
|
177
|
+
...rest,
|
|
157
178
|
problems: [
|
|
158
179
|
'it has no "steps" array of step names',
|
|
159
180
|
...budget.problems,
|
|
160
181
|
...typecheckBin.problems,
|
|
161
182
|
...isolate.problems,
|
|
183
|
+
...timeouts.problems,
|
|
184
|
+
...coverage.problems,
|
|
162
185
|
],
|
|
163
186
|
};
|
|
164
187
|
}
|
|
@@ -166,9 +189,7 @@ export function parseVerifyFloor(
|
|
|
166
189
|
const unknown = named.filter((step) => !declared.includes(step));
|
|
167
190
|
return {
|
|
168
191
|
steps: named.filter((step) => declared.includes(step)),
|
|
169
|
-
...
|
|
170
|
-
...(typecheckBin.bin === undefined ? {} : { typecheckBin: typecheckBin.bin }),
|
|
171
|
-
...(isolate.isolate === undefined ? {} : { isolate: isolate.isolate }),
|
|
192
|
+
...rest,
|
|
172
193
|
problems: [
|
|
173
194
|
...isolate.problems,
|
|
174
195
|
...(named.length === steps.length ? [] : ['"steps" holds an entry that is not a string']),
|
|
@@ -177,6 +198,8 @@ export function parseVerifyFloor(
|
|
|
177
198
|
? []
|
|
178
199
|
: [`"steps" names ${unknown.join(', ')}, which x verify does not run`]),
|
|
179
200
|
...budget.problems,
|
|
201
|
+
...timeouts.problems,
|
|
202
|
+
...coverage.problems,
|
|
180
203
|
],
|
|
181
204
|
};
|
|
182
205
|
}
|
|
@@ -206,10 +229,10 @@ export const floorRequires = (floor: VerifyFloor | undefined, step: string): boo
|
|
|
206
229
|
* command that rewrites the floor is the gate editing its own ratchet, which is the false green the
|
|
207
230
|
* floor exists to close — so the run that proves the step is back is what this offers to repeat.
|
|
208
231
|
*/
|
|
209
|
-
export const vanishedSuiteFinding = (step: string): Finding => ({
|
|
232
|
+
export const vanishedSuiteFinding = (step: string, command: string = GATE_COMMAND): Finding => ({
|
|
210
233
|
code: 'X_VERIFY_SUITE_VANISHED',
|
|
211
234
|
cause: `${VERIFY_FLOOR_FILE} requires the ${step} step and this run found nothing for it to check`,
|
|
212
|
-
fix:
|
|
235
|
+
fix: `${command} --json # restore the ${step} suite, or drop "${step}" from ${VERIFY_FLOOR_FILE} in the commit that says why`,
|
|
213
236
|
docs: ERROR_DOCS_URL,
|
|
214
237
|
at: VERIFY_FLOOR_FILE,
|
|
215
238
|
});
|
|
@@ -262,5 +285,9 @@ const fixFor = (problem: string): string => {
|
|
|
262
285
|
return `x verify --json # then set "${BUDGET_FIELD}" in ${VERIFY_FLOOR_FILE} to a positive whole number of bytes, or drop the key for the ${AGENTS_MD_MAX_BYTES}B default`;
|
|
263
286
|
if (problem.includes(`"${TYPECHECK_BIN_FIELD}"`))
|
|
264
287
|
return `x verify --json # then set "${TYPECHECK_BIN_FIELD}" in ${VERIFY_FLOOR_FILE} to a non-empty binary name, or drop the key to run tsc`;
|
|
288
|
+
if (problem.includes(`"${STEP_TIMEOUT_FIELD}"`))
|
|
289
|
+
return `x verify --json # then write "${STEP_TIMEOUT_FIELD}" in ${VERIFY_FLOOR_FILE} as { "unit": 900000 } — a step it runs, a whole number of milliseconds`;
|
|
290
|
+
if (problem.includes(`"${COVERAGE_FIELD}`))
|
|
291
|
+
return `x verify --only unit --json # then write "${COVERAGE_FIELD}" in ${VERIFY_FLOOR_FILE} as { "lines": 95, "funcs": 95 }, every "exclude" entry as { "glob": "…", "why": "…" }`;
|
|
265
292
|
return `x verify --json # then write ${VERIFY_FLOOR_FILE} as {"steps":["unit","contract"]}, naming only steps it ran`;
|
|
266
293
|
};
|