@ultimat3/cli 22.15.0 → 23.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 +23 -8
- package/README.md +28 -5
- package/package.json +29 -28
- package/src/admin-catalog-keys.ts +16 -0
- package/src/admin-registration.ts +192 -0
- package/src/api-registration.ts +36 -11
- package/src/app-boundaries.ts +6 -20
- package/src/app-load.ts +55 -11
- package/src/app-manifest.ts +14 -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 +25 -1
- package/src/cmd-build-spec.ts +10 -3
- package/src/cmd-build.ts +56 -9
- package/src/cmd-db.ts +60 -11
- package/src/cmd-doctor.ts +35 -11
- package/src/cmd-generate.ts +118 -17
- package/src/cmd-jobs-spec.ts +19 -3
- package/src/cmd-jobs.ts +120 -3
- package/src/cmd-new.ts +15 -1
- package/src/cmd-routes.ts +15 -1
- package/src/cmd-secrets-ring.ts +76 -0
- package/src/cmd-secrets-spec.ts +10 -2
- package/src/cmd-secrets.ts +48 -12
- package/src/cmd-tasks.ts +84 -15
- package/src/cmd-verify.ts +10 -2
- 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/dev-dashboard.ts +2 -2
- package/src/dev-route-table.ts +9 -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/error-codes.ts +32 -0
- package/src/framework-schema.ts +15 -0
- package/src/generate-files.ts +76 -10
- package/src/generate-grant-findings.ts +76 -0
- package/src/generate-grants.ts +77 -3
- package/src/generate-kinds.ts +19 -0
- package/src/generate-write.ts +15 -27
- package/src/generated-imports.ts +59 -0
- package/src/guard-sources.ts +104 -0
- package/src/guards.ts +23 -7
- package/src/handle-registration.ts +217 -0
- 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 +11 -2
- package/src/island-store.ts +24 -16
- package/src/jobs-drain.ts +14 -7
- package/src/jobs-json.ts +50 -0
- package/src/jobs-report.ts +3 -0
- package/src/json-merge.ts +46 -0
- package/src/mcp-errors.ts +29 -1
- package/src/messages.ts +33 -0
- package/src/metrics-endpoint.ts +71 -24
- package/src/migration-extensions.ts +29 -0
- package/src/module-imports.ts +47 -0
- package/src/output.ts +18 -9
- package/src/permission-grants.ts +41 -9
- package/src/publish-closure.ts +101 -0
- package/src/role-load.ts +143 -0
- package/src/role-start.ts +18 -1
- package/src/role-wake.ts +45 -0
- package/src/runtime-admin.ts +141 -0
- package/src/runtime-hooks.ts +5 -2
- package/src/runtime-queue.ts +8 -1
- package/src/runtime-render.ts +6 -0
- package/src/runtime-services.ts +2 -1
- package/src/scaffold-typecheck.ts +1 -0
- package/src/schema-dump-drift.ts +64 -0
- package/src/serve-boot.ts +85 -157
- package/src/serve-prebuilt-paths.ts +19 -0
- package/src/serve-prebuilt.ts +84 -0
- package/src/serve-web.ts +164 -0
- package/src/serve.ts +6 -9
- package/src/server-barrels.ts +54 -0
- package/src/source-edit.ts +49 -0
- package/src/tasks-facts.ts +4 -1
- package/src/templates/action.ts +144 -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/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 +38 -29
- package/src/templates/scaffold-api.ts +55 -1
- package/src/templates/scaffold-app.ts +171 -51
- package/src/templates/scaffold-auth.ts +83 -19
- package/src/templates/scaffold-browser-client.ts +90 -0
- package/src/templates/scaffold-container.ts +16 -0
- 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 +142 -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-http.ts +9 -7
- package/src/templates/scaffold-repo.ts +52 -2
- package/src/templates/scaffold-roles.ts +56 -9
- package/src/templates/scaffold-shell.ts +36 -70
- 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 +18 -2
- package/src/transport-calls.ts +105 -0
- package/src/unscanned-admin.ts +54 -0
- package/src/verify-checks.ts +26 -7
- package/src/verify-coverage-run.ts +190 -0
- package/src/verify-deadline.ts +256 -0
- package/src/verify-errors.ts +15 -6
- package/src/verify-floor.ts +36 -9
- package/src/verify-merge.ts +117 -24
- 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 +93 -35
- package/src/verify-shard.ts +20 -9
- package/src/verify-step.ts +42 -1
- package/src/verify-tests.ts +78 -6
- package/src/workspace-checks.ts +50 -8
- package/src/workspace-dep-edit.ts +127 -0
- 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
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
// An app's `unit` step, run so its coverage can be read: the suite as a pool of plain `bun test`
|
|
2
|
+
// processes over fixed slices, each writing its own lcov, folded by `coverage-lcov.ts`'s rule.
|
|
3
|
+
//
|
|
4
|
+
// NOT `bun test --parallel` with `--coverage` added, which is the obvious form and was measured
|
|
5
|
+
// first (2026-10-01, Bun 1.4.0, `examples/dummy`, 53 unit files): Bun's own cross-worker merge is
|
|
6
|
+
// a union, so the SAME tree read 59.9% of lines at four workers, 65.0% at two and 65.5% in one
|
|
7
|
+
// process. A floor that moves with the core count is not a floor. Per-process lcov folded here
|
|
8
|
+
// read 65.90% at 2, 4 and 8 processes.
|
|
9
|
+
//
|
|
10
|
+
// It costs nothing measurable: four slice processes took 9.7 s against 12.4 s for `--parallel=4`
|
|
11
|
+
// over the same files, on a loaded 12-core box.
|
|
12
|
+
|
|
13
|
+
// why: Bun ships no recursive delete and no path-join primitive; the lcov parts live under `.x/`.
|
|
14
|
+
import { rm } from 'node:fs/promises';
|
|
15
|
+
// why: Bun ships no path-join primitive.
|
|
16
|
+
import { dirname, join } from 'node:path';
|
|
17
|
+
import type { CoverageMap } from './coverage-lcov';
|
|
18
|
+
import { fileCoverageOf, mergeCoverage, parseLcov } from './coverage-lcov';
|
|
19
|
+
import type { ExecResult, Runner } from './exec';
|
|
20
|
+
import { execOutput } from './exec';
|
|
21
|
+
import { msg } from './messages';
|
|
22
|
+
import { countsOf } from './test-counts';
|
|
23
|
+
import { testEnvOverrides } from './test-dotenv';
|
|
24
|
+
import { failureOf, ISOLATED_TEST_ENV } from './test-shards';
|
|
25
|
+
import { SLOT_HELD_ENV } from './test-slots';
|
|
26
|
+
import type { StepOutcome } from './verify-step';
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Test files per process. Small enough that a process is thrown away before its heap grows with
|
|
30
|
+
* the corpus (`test-batches.ts`), large enough that the ~0.3 s a process costs to start is noise.
|
|
31
|
+
*/
|
|
32
|
+
export const COVERAGE_SLICE_FILES = 16;
|
|
33
|
+
|
|
34
|
+
/** Where a run's lcov parts are written, under the app root — removed when the run has read them. */
|
|
35
|
+
export const COVERAGE_DIR = '.x/coverage';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The slices one run is spent as: a pure function of the sorted file list, never of the machine,
|
|
39
|
+
* so the measured number is the same on a laptop and on a runner. A directory's tests stay in one
|
|
40
|
+
* slice — function coverage is the best one process reached (`mergeFileCoverage`), and the tests
|
|
41
|
+
* that cover a file sit beside it — and only a directory larger than the cap is cut.
|
|
42
|
+
*/
|
|
43
|
+
export function coverageSlices(
|
|
44
|
+
files: readonly string[],
|
|
45
|
+
cap: number = COVERAGE_SLICE_FILES,
|
|
46
|
+
): readonly (readonly string[])[] {
|
|
47
|
+
const byDir = new Map<string, string[]>();
|
|
48
|
+
for (const file of [...new Set(files)].sort()) {
|
|
49
|
+
const dir = dirname(file);
|
|
50
|
+
const list = byDir.get(dir) ?? [];
|
|
51
|
+
list.push(file);
|
|
52
|
+
byDir.set(dir, list);
|
|
53
|
+
}
|
|
54
|
+
const slices: string[][] = [];
|
|
55
|
+
let open: string[] = [];
|
|
56
|
+
const close = (): void => {
|
|
57
|
+
if (open.length > 0) slices.push(open);
|
|
58
|
+
open = [];
|
|
59
|
+
};
|
|
60
|
+
for (const group of byDir.values()) {
|
|
61
|
+
if (group.length > cap) {
|
|
62
|
+
close();
|
|
63
|
+
for (let at = 0; at < group.length; at += cap) slices.push(group.slice(at, at + cap));
|
|
64
|
+
} else {
|
|
65
|
+
if (open.length + group.length > cap) close();
|
|
66
|
+
open.push(...group);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
close();
|
|
70
|
+
return slices;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export interface CoveredRunOptions {
|
|
74
|
+
readonly root: string;
|
|
75
|
+
readonly runner: Runner;
|
|
76
|
+
/** Paths relative to `root`. */
|
|
77
|
+
readonly files: readonly string[];
|
|
78
|
+
readonly workers: number;
|
|
79
|
+
readonly isolate?: boolean;
|
|
80
|
+
readonly env?: Readonly<Record<string, string | undefined>>;
|
|
81
|
+
readonly widthReason?: string;
|
|
82
|
+
readonly lease?: (want: number) => Promise<{ readonly count: number; release(): void }>;
|
|
83
|
+
/** Test files per process. Absent is `COVERAGE_SLICE_FILES`; a test cuts smaller. */
|
|
84
|
+
readonly sliceFiles?: number;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface CoveredRun extends StepOutcome {
|
|
88
|
+
/** Every file any slice loaded, by the path Bun wrote — relative to `root`. */
|
|
89
|
+
readonly coverage: CoverageMap;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** One slice's argv. An explicit file list, never a re-glob, for `testArgs`' reason. */
|
|
93
|
+
export const coveredArgs = (
|
|
94
|
+
files: readonly string[],
|
|
95
|
+
dir: string,
|
|
96
|
+
isolate: boolean,
|
|
97
|
+
): readonly string[] => [
|
|
98
|
+
'bun',
|
|
99
|
+
'test',
|
|
100
|
+
...(isolate ? ['--isolate'] : []),
|
|
101
|
+
'--coverage',
|
|
102
|
+
'--coverage-reporter=lcov',
|
|
103
|
+
`--coverage-dir=${dir}`,
|
|
104
|
+
...files,
|
|
105
|
+
];
|
|
106
|
+
|
|
107
|
+
export async function runCovered(options: CoveredRunOptions): Promise<CoveredRun> {
|
|
108
|
+
const slices = coverageSlices(options.files, options.sliceFiles);
|
|
109
|
+
const want = Math.max(1, Math.min(Math.trunc(options.workers), slices.length));
|
|
110
|
+
const lease = want > 1 ? await options.lease?.(want) : undefined;
|
|
111
|
+
const width = Math.min(want, lease?.count ?? want);
|
|
112
|
+
const base = join(options.root, COVERAGE_DIR, `unit-${String(process.pid)}`);
|
|
113
|
+
const isolate = options.isolate === true;
|
|
114
|
+
const overrides = {
|
|
115
|
+
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
116
|
+
...(isolate ? { [ISOLATED_TEST_ENV]: '1' } : {}),
|
|
117
|
+
...(lease === undefined ? {} : { [SLOT_HELD_ENV]: '1' }),
|
|
118
|
+
};
|
|
119
|
+
const results = new Array<ExecResult>(slices.length);
|
|
120
|
+
let next = 0;
|
|
121
|
+
// Each pool worker names its own numbered test database, as `--parallel`'s workers do.
|
|
122
|
+
const worker = async (slot: number): Promise<void> => {
|
|
123
|
+
while (next < slices.length) {
|
|
124
|
+
const index = next;
|
|
125
|
+
next += 1;
|
|
126
|
+
results[index] = await options.runner(
|
|
127
|
+
coveredArgs(slices[index] as readonly string[], join(base, String(index)), isolate),
|
|
128
|
+
{ cwd: options.root, env: { ...overrides, ULTIMATE_TEST_WORKER: String(slot) } },
|
|
129
|
+
);
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
let maps: CoverageMap[];
|
|
133
|
+
try {
|
|
134
|
+
await Promise.all(Array.from({ length: width }, (_, slot) => worker(slot)));
|
|
135
|
+
maps = await Promise.all(slices.map((_, index) => readPart(join(base, String(index)))));
|
|
136
|
+
} finally {
|
|
137
|
+
lease?.release();
|
|
138
|
+
await rm(base, { recursive: true, force: true });
|
|
139
|
+
}
|
|
140
|
+
const failed = results.filter((result) => !result.ok);
|
|
141
|
+
const first = failed[0];
|
|
142
|
+
const output = results
|
|
143
|
+
.map((result, index) =>
|
|
144
|
+
result.ok
|
|
145
|
+
? undefined
|
|
146
|
+
: [
|
|
147
|
+
msg('cli.test.batch', {
|
|
148
|
+
batch: index + 1,
|
|
149
|
+
batches: results.length,
|
|
150
|
+
files: slices[index]?.length ?? 0,
|
|
151
|
+
}),
|
|
152
|
+
execOutput(result),
|
|
153
|
+
].join('\n'),
|
|
154
|
+
)
|
|
155
|
+
.filter((text) => text !== undefined)
|
|
156
|
+
.join('\n');
|
|
157
|
+
return {
|
|
158
|
+
ok: first === undefined,
|
|
159
|
+
findings:
|
|
160
|
+
first === undefined
|
|
161
|
+
? []
|
|
162
|
+
: [
|
|
163
|
+
failureOf(first.code, options.files.length, {
|
|
164
|
+
workers: width,
|
|
165
|
+
type: 'unit',
|
|
166
|
+
...(isolate ? { isolate: true } : {}),
|
|
167
|
+
}),
|
|
168
|
+
],
|
|
169
|
+
workers: width,
|
|
170
|
+
...(options.widthReason === undefined ? {} : { widthReason: options.widthReason }),
|
|
171
|
+
tests: countsOf(results),
|
|
172
|
+
...(first === undefined ? {} : { output }),
|
|
173
|
+
coverage: mergeCoverage(maps),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* One slice's lcov as a map, kept to the app's own tree: a slice loads a thousand framework files
|
|
179
|
+
* for every hundred of the app's, and none of those is what the floor measures. No file is no
|
|
180
|
+
* coverage — the slice died before writing it, and its exit code already says so.
|
|
181
|
+
*/
|
|
182
|
+
async function readPart(dir: string): Promise<CoverageMap> {
|
|
183
|
+
const file = Bun.file(join(dir, 'lcov.info'));
|
|
184
|
+
if (!(await file.exists())) return {};
|
|
185
|
+
return Object.fromEntries(
|
|
186
|
+
parseLcov(await file.text())
|
|
187
|
+
.filter((record) => /^(?:apps|packages)\//.test(record.file))
|
|
188
|
+
.map((record) => [record.file, fileCoverageOf(record)]),
|
|
189
|
+
);
|
|
190
|
+
}
|
|
@@ -0,0 +1,256 @@
|
|
|
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 { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
|
+
import { TEST_TYPES } from '@ultimat3/testing';
|
|
7
|
+
import type { ExecResult, Runner } from './exec';
|
|
8
|
+
import type { Finding } from './output';
|
|
9
|
+
import type { VerifyStepName } from './verify-step';
|
|
10
|
+
|
|
11
|
+
/** The `x.verify.json` key: `{ "stepTimeoutMs": { "unit": 900000 } }`. */
|
|
12
|
+
export const STEP_TIMEOUT_FIELD = 'stepTimeoutMs';
|
|
13
|
+
|
|
14
|
+
/** A check step — the compiler, biome, an in-process scan. Five minutes. */
|
|
15
|
+
export const CHECK_STEP_TIMEOUT_MS = 300_000;
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* A test suite. Eight minutes: under the ten a CI job is commonly given, so the step names itself
|
|
19
|
+
* before the runner cancels the job — the framework's own `unit` step is ~3 on a 4-core runner.
|
|
20
|
+
*/
|
|
21
|
+
export const SUITE_STEP_TIMEOUT_MS = 480_000;
|
|
22
|
+
|
|
23
|
+
const SUITE_STEPS: readonly string[] = TEST_TYPES;
|
|
24
|
+
|
|
25
|
+
export const defaultStepTimeoutMs = (step: string): number =>
|
|
26
|
+
SUITE_STEPS.includes(step) ? SUITE_STEP_TIMEOUT_MS : CHECK_STEP_TIMEOUT_MS;
|
|
27
|
+
|
|
28
|
+
export type StepTimeouts = Readonly<Partial<Record<VerifyStepName, number>>>;
|
|
29
|
+
|
|
30
|
+
/** The caller's override, then the floor file's, then the step class's declared number. */
|
|
31
|
+
export const stepTimeoutMs = (
|
|
32
|
+
step: VerifyStepName,
|
|
33
|
+
...overrides: readonly (StepTimeouts | undefined)[]
|
|
34
|
+
): number => {
|
|
35
|
+
for (const table of overrides) {
|
|
36
|
+
const ms = table !== undefined && Object.hasOwn(table, step) ? table[step] : undefined;
|
|
37
|
+
if (ms !== undefined) return ms;
|
|
38
|
+
}
|
|
39
|
+
return defaultStepTimeoutMs(step);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* `stepTimeoutMs` out of `x.verify.json`, or the reasons it is not one. A value that is not a
|
|
44
|
+
* positive whole number is refused, never read as "no deadline": a typo that removed the deadline
|
|
45
|
+
* is the hang this file exists to end.
|
|
46
|
+
*/
|
|
47
|
+
export function readStepTimeouts(
|
|
48
|
+
payload: Record<string, unknown> | undefined,
|
|
49
|
+
declared: readonly string[],
|
|
50
|
+
): { timeouts?: StepTimeouts; problems: readonly string[] } {
|
|
51
|
+
const raw = payload?.[STEP_TIMEOUT_FIELD];
|
|
52
|
+
if (raw === undefined) return { problems: [] };
|
|
53
|
+
if (typeof raw !== 'object' || raw === null || Array.isArray(raw)) {
|
|
54
|
+
return { problems: [`"${STEP_TIMEOUT_FIELD}" is not an object of { "<step>": milliseconds }`] };
|
|
55
|
+
}
|
|
56
|
+
const problems: string[] = [];
|
|
57
|
+
const timeouts = new Map<string, number>();
|
|
58
|
+
for (const [step, ms] of Object.entries(raw)) {
|
|
59
|
+
if (!declared.includes(step)) {
|
|
60
|
+
problems.push(`"${STEP_TIMEOUT_FIELD}" names ${step}, which x verify does not run`);
|
|
61
|
+
} else if (typeof ms !== 'number' || !Number.isSafeInteger(ms) || ms <= 0) {
|
|
62
|
+
problems.push(
|
|
63
|
+
`"${STEP_TIMEOUT_FIELD}" gives ${step} ${JSON.stringify(ms)}, which is not a positive whole number of milliseconds`,
|
|
64
|
+
);
|
|
65
|
+
} else timeouts.set(step, ms);
|
|
66
|
+
}
|
|
67
|
+
return {
|
|
68
|
+
...(timeouts.size === 0 ? {} : { timeouts: Object.fromEntries(timeouts) as StepTimeouts }),
|
|
69
|
+
problems,
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** Carried by every process a step starts, and inherited by everything those start in turn. */
|
|
74
|
+
export const STEP_TAG_ENV = 'ULTIMATE_VERIFY_STEP';
|
|
75
|
+
|
|
76
|
+
/** `bun test`-style "the command was stopped" exit, as `timeout(1)` reports it. */
|
|
77
|
+
const EXPIRED_EXIT = 124;
|
|
78
|
+
|
|
79
|
+
export interface StepGuard {
|
|
80
|
+
/** `runner`, with every child tagged — and refusing to start one once the step has expired. */
|
|
81
|
+
readonly runner: Runner;
|
|
82
|
+
/** Stop the step: no further child starts, and every tagged process is killed. Returns how many. */
|
|
83
|
+
expire(): Promise<number>;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* The step's own view of the subprocess boundary. The tag is an ENVIRONMENT variable rather than a
|
|
88
|
+
* pid list or a process group because it is the one mark a grandchild keeps after its parent dies:
|
|
89
|
+
* `bun test --parallel` workers, `bunx`'s child and an e2e browser are all re-parented to init the
|
|
90
|
+
* moment the process this file started exits, and a walk down the ppid chain loses them there.
|
|
91
|
+
*
|
|
92
|
+
* A nested gate (a test that runs `x verify`) appends its tag to the one it inherited, so the
|
|
93
|
+
* outer step's kill still finds the inner step's children.
|
|
94
|
+
*/
|
|
95
|
+
export function guardStep(
|
|
96
|
+
runner: Runner,
|
|
97
|
+
tag: string,
|
|
98
|
+
env: Readonly<Record<string, string | undefined>> = Bun.env,
|
|
99
|
+
): StepGuard {
|
|
100
|
+
let expired = false;
|
|
101
|
+
const inherited = env[STEP_TAG_ENV];
|
|
102
|
+
const value = inherited === undefined || inherited === '' ? tag : `${inherited} ${tag}`;
|
|
103
|
+
return {
|
|
104
|
+
runner: async (command, options): Promise<ExecResult> => {
|
|
105
|
+
// The step's own code keeps running after its deadline — a batch loop moves on to the next
|
|
106
|
+
// batch when the killed one returns — and a child started then would outlive the gate.
|
|
107
|
+
if (expired) {
|
|
108
|
+
return {
|
|
109
|
+
command,
|
|
110
|
+
code: EXPIRED_EXIT,
|
|
111
|
+
ok: false,
|
|
112
|
+
stdout: '',
|
|
113
|
+
stderr: 'not started: the step is past its deadline',
|
|
114
|
+
durationMs: 0,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
return runner(command, { ...options, env: { ...options.env, [STEP_TAG_ENV]: value } });
|
|
118
|
+
},
|
|
119
|
+
async expire(): Promise<number> {
|
|
120
|
+
expired = true;
|
|
121
|
+
return killTagged(tag);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** One process and the environment it was started with, as the platform reports it. */
|
|
127
|
+
export interface ProcessEnviron {
|
|
128
|
+
readonly pid: number;
|
|
129
|
+
/** Whatever text holds `NAME=value` pairs — NUL-separated on Linux, space-separated from `ps`. */
|
|
130
|
+
readonly environ: string;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** The pids whose environment carries `tag` as one word of `STEP_TAG_ENV`'s value. */
|
|
134
|
+
export function taggedPids(processes: readonly ProcessEnviron[], tag: string): readonly number[] {
|
|
135
|
+
const marker = `${STEP_TAG_ENV}=`;
|
|
136
|
+
const pids: number[] = [];
|
|
137
|
+
for (const one of processes) {
|
|
138
|
+
for (const pair of one.environ.split('\0')) {
|
|
139
|
+
const at = pair.indexOf(marker);
|
|
140
|
+
if (at < 0) continue;
|
|
141
|
+
// From `ps`, the pair is one line of space-separated pairs: the value runs to the next
|
|
142
|
+
// ` NAME=`, and a tag never holds `=`.
|
|
143
|
+
const value = pair.slice(at + marker.length).split(/ (?=[A-Za-z_][A-Za-z0-9_]*=)/)[0] ?? '';
|
|
144
|
+
if (value.split(' ').includes(tag)) pids.push(one.pid);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
return pids;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Linux: every process's start-time environment is a file. Unreadable ones are not ours. */
|
|
151
|
+
async function procfsProcesses(): Promise<readonly ProcessEnviron[] | undefined> {
|
|
152
|
+
if (!(await Bun.file('/proc/self/environ').exists())) return undefined;
|
|
153
|
+
const out: ProcessEnviron[] = [];
|
|
154
|
+
for await (const entry of new Bun.Glob('[0-9]*').scan({ cwd: '/proc', onlyFiles: false })) {
|
|
155
|
+
const pid = Number(entry);
|
|
156
|
+
if (!Number.isSafeInteger(pid)) continue;
|
|
157
|
+
const environ = await Bun.file(`/proc/${entry}/environ`)
|
|
158
|
+
.text()
|
|
159
|
+
.catch(() => undefined);
|
|
160
|
+
if (environ !== undefined) out.push({ pid, environ });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Elsewhere (macOS, BSD): `ps` in its BSD spelling — `e` prints each process's environment after
|
|
167
|
+
* its command, `ww` never truncates it. The same spelling answers on Linux, which is where it is
|
|
168
|
+
* tested; procfs is preferred there because it needs no process and no parsing.
|
|
169
|
+
*/
|
|
170
|
+
export async function psProcesses(): Promise<readonly ProcessEnviron[]> {
|
|
171
|
+
try {
|
|
172
|
+
const ps = Bun.spawn(['ps', 'axeww', '-o', 'pid=,command='], {
|
|
173
|
+
stdout: 'pipe',
|
|
174
|
+
stderr: 'ignore',
|
|
175
|
+
});
|
|
176
|
+
const text = await new Response(ps.stdout).text();
|
|
177
|
+
await ps.exited;
|
|
178
|
+
return text.split('\n').flatMap((line) => {
|
|
179
|
+
const match = /^\s*(\d+)\s+(.*)$/.exec(line);
|
|
180
|
+
return match === null ? [] : [{ pid: Number(match[1]), environ: match[2] ?? '' }];
|
|
181
|
+
});
|
|
182
|
+
} catch {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
const SWEEPS = 5;
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* SIGKILL every process carrying `tag`, and sweep again until a pass finds none: a worker can fork
|
|
191
|
+
* between the listing and its own death. Never this process — a nested gate carries the outer tag.
|
|
192
|
+
*/
|
|
193
|
+
export async function killTagged(
|
|
194
|
+
tag: string,
|
|
195
|
+
/** The process table. Absent is this platform's: procfs where there is one, `ps` otherwise. */
|
|
196
|
+
list: () => Promise<readonly ProcessEnviron[]> = async () =>
|
|
197
|
+
(await procfsProcesses()) ?? (await psProcesses()),
|
|
198
|
+
): Promise<number> {
|
|
199
|
+
const killed = new Set<number>();
|
|
200
|
+
for (let sweep = 0; sweep < SWEEPS; sweep += 1) {
|
|
201
|
+
const processes = await list();
|
|
202
|
+
const found = taggedPids(processes, tag).filter((pid) => pid !== process.pid);
|
|
203
|
+
const fresh = found.filter((pid) => !killed.has(pid));
|
|
204
|
+
if (found.length === 0) break;
|
|
205
|
+
for (const pid of found) {
|
|
206
|
+
try {
|
|
207
|
+
process.kill(pid, 'SIGKILL');
|
|
208
|
+
killed.add(pid);
|
|
209
|
+
} catch {
|
|
210
|
+
// Already gone between the listing and the signal — which is the outcome wanted.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
// A killed process stays listed until it is reaped; nothing NEW on a pass means it is done.
|
|
214
|
+
if (fresh.length === 0) break;
|
|
215
|
+
await Bun.sleep(20);
|
|
216
|
+
}
|
|
217
|
+
return killed.size;
|
|
218
|
+
}
|
|
219
|
+
|
|
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
|
+
/** `work`, or `{ timedOut: true }` once `ms` passes. The abandoned promise is never unhandled. */
|
|
238
|
+
export async function raceDeadline<T>(
|
|
239
|
+
work: Promise<T>,
|
|
240
|
+
ms: number,
|
|
241
|
+
): Promise<{ readonly timedOut: false; readonly value: T } | { readonly timedOut: true }> {
|
|
242
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
243
|
+
const expired = new Promise<{ readonly timedOut: true }>((resolve) => {
|
|
244
|
+
timer = setTimeout(() => resolve({ timedOut: true }), ms);
|
|
245
|
+
});
|
|
246
|
+
try {
|
|
247
|
+
const raced = await Promise.race([
|
|
248
|
+
work.then((value) => ({ timedOut: false as const, value })),
|
|
249
|
+
expired,
|
|
250
|
+
]);
|
|
251
|
+
if (raced.timedOut) work.catch(() => undefined);
|
|
252
|
+
return raced;
|
|
253
|
+
} finally {
|
|
254
|
+
clearTimeout(timer);
|
|
255
|
+
}
|
|
256
|
+
}
|
package/src/verify-errors.ts
CHANGED
|
@@ -25,24 +25,33 @@ export class TestBudgetInvalidError extends UltimateError {
|
|
|
25
25
|
}
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
/**
|
|
29
|
+
* The gate as an app types it. A refusal raised from another entry — `bun run verify` at the
|
|
30
|
+
* framework root, where `x verify` answers `X_NOT_IN_APP` — passes its own spelling, so the
|
|
31
|
+
* `fix:` runs where it was raised.
|
|
32
|
+
*/
|
|
33
|
+
const GATE = 'x verify';
|
|
34
|
+
|
|
28
35
|
/** `x verify --shard` asked for a split the gate cannot make. */
|
|
29
36
|
export class VerifyShardInvalidError extends UltimateError {
|
|
30
|
-
constructor(input: { reason: string; fix?: string }) {
|
|
37
|
+
constructor(input: { reason: string; fix?: string; command?: string }) {
|
|
38
|
+
const command = input.command ?? GATE;
|
|
31
39
|
super({
|
|
32
40
|
code: 'X_VERIFY_SHARD_INVALID',
|
|
33
|
-
cause:
|
|
34
|
-
fix: input.fix ??
|
|
41
|
+
cause: `${command} --shard: ${input.reason}`,
|
|
42
|
+
fix: input.fix ?? `${command} --only unit --shard 1/4 --json`,
|
|
35
43
|
});
|
|
36
44
|
}
|
|
37
45
|
}
|
|
38
46
|
|
|
39
47
|
/** A part handed to `x verify merge` that is not an `x verify --json` document. */
|
|
40
48
|
export class VerifyMergeInputError extends UltimateError {
|
|
41
|
-
constructor(input: { file: string; reason: string; sourceError?: unknown }) {
|
|
49
|
+
constructor(input: { file: string; reason: string; sourceError?: unknown; command?: string }) {
|
|
50
|
+
const command = input.command ?? GATE;
|
|
42
51
|
super({
|
|
43
52
|
code: 'X_VERIFY_MERGE_INPUT',
|
|
44
|
-
cause:
|
|
45
|
-
fix:
|
|
53
|
+
cause: `${command} merge: ${input.file} ${input.reason}`,
|
|
54
|
+
fix: `${command} --only unit --json > part.json # one document per part, the last line of stdout`,
|
|
46
55
|
...(input.sourceError === undefined ? {} : { sourceError: input.sourceError }),
|
|
47
56
|
});
|
|
48
57
|
}
|
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
|
};
|