@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
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
// One step of an `x verify --json` part, read against the shape `mergeStep` relies on. It was a
|
|
2
|
+
// cast past `name` and `ok`: `findings: [null]` merged green, and a step with no `durationMs`
|
|
3
|
+
// reached `Math.max` as `undefined` — a part a CI job hand-edited, or truncated, was believed.
|
|
4
|
+
|
|
5
|
+
import type { Finding } from './output';
|
|
6
|
+
import type { TestCounts } from './test-counts';
|
|
7
|
+
|
|
8
|
+
export interface PartStep {
|
|
9
|
+
readonly name: string;
|
|
10
|
+
readonly ok: boolean;
|
|
11
|
+
readonly durationMs: number;
|
|
12
|
+
readonly skipped: boolean;
|
|
13
|
+
readonly findings: readonly Finding[];
|
|
14
|
+
readonly workers?: number;
|
|
15
|
+
readonly tests?: TestCounts;
|
|
16
|
+
readonly output?: string;
|
|
17
|
+
readonly warnings?: readonly string[];
|
|
18
|
+
readonly shard?: {
|
|
19
|
+
readonly index: number;
|
|
20
|
+
readonly total: number;
|
|
21
|
+
readonly corpusHash: string;
|
|
22
|
+
readonly files: readonly string[];
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
27
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
28
|
+
|
|
29
|
+
const isCount = (value: unknown): value is number =>
|
|
30
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
31
|
+
|
|
32
|
+
/** What every finding is: code, cause and fix as strings. Optional fields ride along as written. */
|
|
33
|
+
export const isFinding = (value: unknown): value is Finding =>
|
|
34
|
+
isRecord(value) &&
|
|
35
|
+
typeof value['code'] === 'string' &&
|
|
36
|
+
typeof value['cause'] === 'string' &&
|
|
37
|
+
typeof value['fix'] === 'string';
|
|
38
|
+
|
|
39
|
+
const isTests = (value: unknown): value is TestCounts =>
|
|
40
|
+
isRecord(value) &&
|
|
41
|
+
isCount(value['ran']) &&
|
|
42
|
+
isCount(value['skipped']) &&
|
|
43
|
+
(value['errors'] === undefined || isCount(value['errors']));
|
|
44
|
+
|
|
45
|
+
const isTextList = (value: unknown): value is readonly string[] =>
|
|
46
|
+
Array.isArray(value) && value.every((line) => typeof line === 'string');
|
|
47
|
+
|
|
48
|
+
const isShard = (value: unknown): value is NonNullable<PartStep['shard']> =>
|
|
49
|
+
isRecord(value) &&
|
|
50
|
+
isCount(value['index']) &&
|
|
51
|
+
isCount(value['total']) &&
|
|
52
|
+
typeof value['corpusHash'] === 'string' &&
|
|
53
|
+
Array.isArray(value['files']) &&
|
|
54
|
+
value['files'].every((file) => typeof file === 'string');
|
|
55
|
+
|
|
56
|
+
/** The step, or the reason it is not one — phrased to follow "holds a step …". */
|
|
57
|
+
export function readPartStep(raw: unknown): PartStep | string {
|
|
58
|
+
if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['ok'] !== 'boolean') {
|
|
59
|
+
return 'holds a step without a name and ok';
|
|
60
|
+
}
|
|
61
|
+
const name = raw['name'];
|
|
62
|
+
const durationMs = raw['durationMs'];
|
|
63
|
+
if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) {
|
|
64
|
+
return `holds step "${name}" without a durationMs in milliseconds`;
|
|
65
|
+
}
|
|
66
|
+
const findings = raw['findings'];
|
|
67
|
+
if (!Array.isArray(findings) || !findings.every(isFinding)) {
|
|
68
|
+
return `holds step "${name}" whose findings are not each a code, a cause and a fix`;
|
|
69
|
+
}
|
|
70
|
+
const { tests, shard, output, workers, skipped, warnings } = raw;
|
|
71
|
+
if (tests !== undefined && !isTests(tests)) {
|
|
72
|
+
return `holds step "${name}" whose tests are not counts`;
|
|
73
|
+
}
|
|
74
|
+
if (shard !== undefined && !isShard(shard)) {
|
|
75
|
+
return `holds step "${name}" whose shard is not an index, a total, a corpusHash and its files`;
|
|
76
|
+
}
|
|
77
|
+
if (output !== undefined && typeof output !== 'string') {
|
|
78
|
+
return `holds step "${name}" whose output is not text`;
|
|
79
|
+
}
|
|
80
|
+
if (warnings !== undefined && !isTextList(warnings)) {
|
|
81
|
+
return `holds step "${name}" whose warnings are not a list of text`;
|
|
82
|
+
}
|
|
83
|
+
if (workers !== undefined && !isCount(workers)) {
|
|
84
|
+
return `holds step "${name}" whose workers is not a count`;
|
|
85
|
+
}
|
|
86
|
+
return {
|
|
87
|
+
name,
|
|
88
|
+
ok: raw['ok'],
|
|
89
|
+
durationMs,
|
|
90
|
+
skipped: skipped === true,
|
|
91
|
+
findings,
|
|
92
|
+
...(workers === undefined ? {} : { workers }),
|
|
93
|
+
...(tests === undefined ? {} : { tests }),
|
|
94
|
+
...(output === undefined ? {} : { output }),
|
|
95
|
+
...(warnings === undefined ? {} : { warnings }),
|
|
96
|
+
...(shard === undefined ? {} : { shard }),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
@@ -3,11 +3,22 @@
|
|
|
3
3
|
// those registered that the worker's process did not hold. Run by hand, it is the same answer.
|
|
4
4
|
|
|
5
5
|
import { scanAppModules } from './app-load';
|
|
6
|
+
import type { RelativeHost } from './posix-path';
|
|
7
|
+
import { posixRelative } from './posix-path';
|
|
6
8
|
import { scanBackground } from './role-load';
|
|
7
9
|
import type { MissingRegistration } from './verify-role-load';
|
|
8
10
|
import { PROBE_MARKER, registeredSince, registrations } from './verify-role-load';
|
|
9
11
|
import { writeLine } from './write-line';
|
|
10
12
|
|
|
13
|
+
/**
|
|
14
|
+
* The app-root-relative POSIX key a scanned module is held under — the form `scanBackground`'s
|
|
15
|
+
* `files` already are. A `${root}/` prefix strip matched nothing on Windows (`D:\…\app\apps\…`),
|
|
16
|
+
* so every module the worker DID import was blamed as missing. `host` is for a test only.
|
|
17
|
+
*/
|
|
18
|
+
export function moduleKey(root: string, absolute: string, host?: RelativeHost): string {
|
|
19
|
+
return posixRelative(root, absolute, host);
|
|
20
|
+
}
|
|
21
|
+
|
|
11
22
|
/**
|
|
12
23
|
* What importing each module the worker load left out registered, by module — `[]` when the whole
|
|
13
24
|
* scan reported a module that would not import. Exported so a test calls it in its own process;
|
|
@@ -31,12 +42,11 @@ export async function probeRoleLoad(root: string): Promise<readonly MissingRegis
|
|
|
31
42
|
|
|
32
43
|
// The whole app, in the scan's own order. `include` is asked before each import, so the look it
|
|
33
44
|
// takes is at what the PREVIOUS module added; a module the worker already imported adds nothing.
|
|
34
|
-
const prefix = root.endsWith('/') ? root : `${root}/`;
|
|
35
45
|
const whole = await scanAppModules(root, {
|
|
36
46
|
track: false,
|
|
37
47
|
include: (absolute) => {
|
|
38
48
|
attribute();
|
|
39
|
-
const file =
|
|
49
|
+
const file = moduleKey(root, absolute);
|
|
40
50
|
current = imported.has(file) ? undefined : file;
|
|
41
51
|
return true;
|
|
42
52
|
},
|
package/src/verify-role-load.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
// manifest; this is the build error for everything else a job reads by name.
|
|
6
6
|
|
|
7
7
|
// why: Bun ships no path API; the fix names the import relative to the API index.
|
|
8
|
-
import { dirname, join
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
9
|
import { ERROR_DOCS_URL, registeredServiceNames, singleLine } from '@ultimat3/core';
|
|
10
10
|
import { entityNames } from '@ultimat3/entity';
|
|
11
11
|
import { catalogDeclarationCount } from '@ultimat3/i18n';
|
|
@@ -16,6 +16,8 @@ import { definedStorage } from '@ultimat3/storage';
|
|
|
16
16
|
import { API_INDEX } from './app-root';
|
|
17
17
|
import type { Runner } from './exec';
|
|
18
18
|
import type { Finding } from './output';
|
|
19
|
+
import type { RelativeHost } from './posix-path';
|
|
20
|
+
import { posixRelative } from './posix-path';
|
|
19
21
|
import { quoteArg } from './shell-quote';
|
|
20
22
|
|
|
21
23
|
/** One thing a process registered, as a job would ask for it: by kind and by name. */
|
|
@@ -79,9 +81,12 @@ export function registeredSince(
|
|
|
79
81
|
return after.filter((one) => !had.has(keyOf(one)));
|
|
80
82
|
}
|
|
81
83
|
|
|
82
|
-
/**
|
|
83
|
-
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* The import the API index is missing, as it would be written there — POSIX on every host: a `\`
|
|
86
|
+
* pasted from a Windows run is an import no Linux teammate resolves. `host` is for a test only.
|
|
87
|
+
*/
|
|
88
|
+
export function specifierFor(module: string, host?: RelativeHost): string {
|
|
89
|
+
const from = posixRelative(dirname(API_INDEX), module, host).replace(/\.[jt]sx?$/, '');
|
|
85
90
|
return from.startsWith('.') ? from : `./${from}`;
|
|
86
91
|
}
|
|
87
92
|
|
package/src/verify-run.ts
CHANGED
|
@@ -7,14 +7,22 @@ import type { CoverageMap } from './coverage-lcov';
|
|
|
7
7
|
import { encodeCoverage } from './coverage-lcov';
|
|
8
8
|
import { msg } from './messages';
|
|
9
9
|
import type { CommandResult, Finding, JsonValue, StepResult } from './output';
|
|
10
|
-
import {
|
|
10
|
+
import type { StepGuard } from './verify-deadline';
|
|
11
|
+
import { guardStep, raceDeadline, stepTimeoutMs } from './verify-deadline';
|
|
11
12
|
import {
|
|
12
13
|
floorRequires,
|
|
13
14
|
readVerifyFloor,
|
|
14
15
|
skippedSuiteFinding,
|
|
15
16
|
vanishedSuiteFinding,
|
|
16
17
|
} from './verify-floor';
|
|
17
|
-
import
|
|
18
|
+
import { stalledOutput, stepTimeoutFinding } from './verify-stalled';
|
|
19
|
+
import type {
|
|
20
|
+
CoverageNumbers,
|
|
21
|
+
StepOutcome,
|
|
22
|
+
VerifyContext,
|
|
23
|
+
VerifyStep,
|
|
24
|
+
VerifyStepName,
|
|
25
|
+
} from './verify-step';
|
|
18
26
|
import { GATE_COMMAND } from './verify-step';
|
|
19
27
|
|
|
20
28
|
/**
|
|
@@ -172,7 +180,7 @@ export function verifySummary(input: {
|
|
|
172
180
|
* `manifest` joined them 2026-09-23: it compares committed files against the code and writes
|
|
173
181
|
* nothing, and at the repo root its host checks are ~20 whole-tree reads that sat alone at the end.
|
|
174
182
|
*/
|
|
175
|
-
export const BESIDE_SERIAL_SUITES: ReadonlySet<
|
|
183
|
+
export const BESIDE_SERIAL_SUITES: ReadonlySet<VerifyStepName> = new Set<VerifyStepName>([
|
|
176
184
|
'lint',
|
|
177
185
|
'boundaries',
|
|
178
186
|
'filesize',
|
|
@@ -182,7 +190,7 @@ export const BESIDE_SERIAL_SUITES: ReadonlySet<string> = new Set([
|
|
|
182
190
|
]);
|
|
183
191
|
|
|
184
192
|
/** The consecutive run of steps the static group overlaps, in the order `VERIFY_STEP_NAMES` holds. */
|
|
185
|
-
export const SERIAL_SUITES: readonly
|
|
193
|
+
export const SERIAL_SUITES: readonly VerifyStepName[] = ['live', 'job', 'e2e', 'eval'];
|
|
186
194
|
|
|
187
195
|
async function runStep(
|
|
188
196
|
step: VerifyStep,
|
|
@@ -243,10 +251,7 @@ async function runStep(
|
|
|
243
251
|
limit,
|
|
244
252
|
);
|
|
245
253
|
const outcome: StepOutcome = raced.timedOut
|
|
246
|
-
?
|
|
247
|
-
ok: false,
|
|
248
|
-
findings: [stepTimeoutFinding(step.name, limit, await guard.expire(), command)],
|
|
249
|
-
}
|
|
254
|
+
? await expired(step.name, limit, guard, command)
|
|
250
255
|
: raced.value;
|
|
251
256
|
// A suite that executed nothing did not run, whatever its exit code says: `bun test` exits 0
|
|
252
257
|
// over an all-skipped file, so the counts are the only channel that can tell the two apart.
|
|
@@ -272,6 +277,9 @@ async function runStep(
|
|
|
272
277
|
skipped: nothingRan && !required,
|
|
273
278
|
findings: [...outcome.findings, ...vanished],
|
|
274
279
|
...(outcome.output === undefined ? {} : { output: outcome.output }),
|
|
280
|
+
...(outcome.warnings === undefined || outcome.warnings.length === 0
|
|
281
|
+
? {}
|
|
282
|
+
: { warnings: outcome.warnings }),
|
|
275
283
|
...(outcome.workers === undefined ? {} : { workers: outcome.workers }),
|
|
276
284
|
...(outcome.widthReason === undefined ? {} : { widthReason: outcome.widthReason }),
|
|
277
285
|
...(outcome.shard === undefined ? {} : { shard: outcome.shard }),
|
|
@@ -282,6 +290,22 @@ async function runStep(
|
|
|
282
290
|
};
|
|
283
291
|
}
|
|
284
292
|
|
|
293
|
+
/** The timed-out step's outcome: the finding naming what was in flight, over what it last printed. */
|
|
294
|
+
async function expired(
|
|
295
|
+
step: VerifyStepName,
|
|
296
|
+
limit: number,
|
|
297
|
+
guard: StepGuard,
|
|
298
|
+
command: string,
|
|
299
|
+
): Promise<StepOutcome> {
|
|
300
|
+
const expiry = await guard.expire();
|
|
301
|
+
const output = stalledOutput(expiry);
|
|
302
|
+
return {
|
|
303
|
+
ok: false,
|
|
304
|
+
findings: [stepTimeoutFinding(step, limit, expiry, command)],
|
|
305
|
+
...(output === undefined ? {} : { output }),
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
|
|
285
309
|
function findingOf(error: unknown, step: string, command: string): Finding {
|
|
286
310
|
// A step may throw anything, including an Error that fights being read: `instanceof` runs a
|
|
287
311
|
// Proxy's `getPrototypeOf` trap and `.message` runs a getter, so a hostile throw would take the
|
package/src/verify-shard.ts
CHANGED
|
@@ -5,13 +5,24 @@
|
|
|
5
5
|
// of the machine, the clock or the order a glob yielded — so job 3 of 8 is the same files on every
|
|
6
6
|
// runner, and a failing shard reproduces locally with the same flags.
|
|
7
7
|
|
|
8
|
-
import { renderFixShellArg } from '@ultimat3/core';
|
|
8
|
+
import { renderFixShellArg, UltimateError } from '@ultimat3/core';
|
|
9
9
|
import { CryptoHasher } from 'bun';
|
|
10
10
|
import type { ShardFacts } from './output';
|
|
11
|
-
import { VerifyShardInvalidError } from './verify-errors';
|
|
12
11
|
import type { VerifyStepName } from './verify-step';
|
|
13
12
|
import { GATE_COMMAND } from './verify-step';
|
|
14
13
|
|
|
14
|
+
/** `x verify --shard` asked for a split the gate cannot make. */
|
|
15
|
+
export class VerifyShardInvalidError extends UltimateError {
|
|
16
|
+
constructor(input: { reason: string; fix?: string; command?: string }) {
|
|
17
|
+
const command = input.command ?? GATE_COMMAND;
|
|
18
|
+
super({
|
|
19
|
+
code: 'X_VERIFY_SHARD_INVALID',
|
|
20
|
+
cause: `${command} --shard: ${input.reason}`,
|
|
21
|
+
fix: input.fix ?? `${command} --only unit --shard 1/4 --json`,
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
15
26
|
/** The steps a shard may split: the parallel suites. `live` and `e2e` are serial by design. */
|
|
16
27
|
export const SHARDABLE_STEPS: readonly VerifyStepName[] = ['unit', 'contract', 'job'];
|
|
17
28
|
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
// What a step was doing when its deadline passed, as the `X_VERIFY_STEP_TIMEOUT` finding. A hung
|
|
2
|
+
// test file used to cost eight minutes and report "2 process(es) were killed" — nothing a reader
|
|
3
|
+
// could run. The finding now names the file bun says a killed worker held, and its `fix:` runs it.
|
|
4
|
+
|
|
5
|
+
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
6
|
+
import type { Finding } from './output';
|
|
7
|
+
import { quoteArg } from './shell-quote';
|
|
8
|
+
import type { InFlightRun, StepExpiry } from './verify-deadline';
|
|
9
|
+
import { STEP_TIMEOUT_FIELD } from './verify-deadline';
|
|
10
|
+
import type { VerifyStepName } from './verify-step';
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* One `bun test` the step was waiting on, read against what it printed as it was stopped. A `type`,
|
|
14
|
+
* like the meta that carries it: an interface is not assignable to `Finding.meta`'s JSON record.
|
|
15
|
+
*/
|
|
16
|
+
export type StalledRun = {
|
|
17
|
+
/** The argv without its file list: `bun test --parallel=3`. */
|
|
18
|
+
readonly command: string;
|
|
19
|
+
/** Files the run was handed; 0 for a serial run, which selects by filter. */
|
|
20
|
+
readonly files: number;
|
|
21
|
+
/** `--parallel`'s width, and 1 for a serial run. */
|
|
22
|
+
readonly workers: number;
|
|
23
|
+
/** The files that were still running, as bun named them. Empty when it named none. */
|
|
24
|
+
readonly stuck: readonly string[];
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** `X_VERIFY_STEP_TIMEOUT`'s `meta`. A `type`: an interface is not assignable to `Finding.meta`. */
|
|
28
|
+
export type StepTimeoutMeta = {
|
|
29
|
+
readonly step: string;
|
|
30
|
+
readonly deadlineMs: number;
|
|
31
|
+
readonly killed: readonly { readonly pid: number; readonly command: string }[];
|
|
32
|
+
readonly inFlight: readonly StalledRun[];
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* What a `--parallel` coordinator prints for the file a killed worker was holding. The guard kills
|
|
37
|
+
* the workers first for exactly this line (`verify-deadline.ts`); it is bun's own statement of
|
|
38
|
+
* what was in flight, and it is printed in every reporter mode.
|
|
39
|
+
*/
|
|
40
|
+
const CRASHED = /^\s*✗ (.+) \(worker crashed: SIG[A-Z0-9]+\)$/;
|
|
41
|
+
|
|
42
|
+
/** Bun's per-file header — `::group::`-prefixed on GitHub Actions. A serial run prints it at START. */
|
|
43
|
+
const FILE_LINE = /^(?:::group::)?(\S.*\.test\.[cm]?[jt]sx?):$/;
|
|
44
|
+
|
|
45
|
+
const PARALLEL = '--parallel=';
|
|
46
|
+
|
|
47
|
+
const bare = (path: string): string => (path.startsWith('./') ? path.slice(2) : path);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A parallel run names its stuck files itself, one crash line per killed worker. A serial run has
|
|
51
|
+
* no worker to kill: it prints a file's header when the file STARTS, so the stuck file is the last
|
|
52
|
+
* one printed — unless a `(fail)` follows it, which is also all a failures-only reporter ever
|
|
53
|
+
* prints, and then nothing is claimed.
|
|
54
|
+
*/
|
|
55
|
+
export function stalledRun(run: InFlightRun): StalledRun | undefined {
|
|
56
|
+
const [program, verb, ...rest] = run.command;
|
|
57
|
+
if (program !== 'bun' || verb !== 'test' || run.output === undefined) return undefined;
|
|
58
|
+
const lines = run.output.split('\n').map((line) => line.trimEnd());
|
|
59
|
+
const width = rest.find((arg) => arg.startsWith(PARALLEL));
|
|
60
|
+
const flags = rest.filter((arg) => arg.startsWith('--') && !arg.startsWith('--timings'));
|
|
61
|
+
const command = ['bun', 'test', ...flags].join(' ');
|
|
62
|
+
if (width === undefined) {
|
|
63
|
+
const last = lines.findLastIndex((line) => FILE_LINE.test(line));
|
|
64
|
+
const started = last < 0 ? undefined : FILE_LINE.exec(lines[last] ?? '')?.[1];
|
|
65
|
+
const failed = lines.slice(last + 1).some((line) => line.startsWith('(fail)'));
|
|
66
|
+
return {
|
|
67
|
+
command,
|
|
68
|
+
files: 0,
|
|
69
|
+
workers: 1,
|
|
70
|
+
stuck: started === undefined || failed ? [] : [started],
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
const files = rest.filter((arg) => !arg.startsWith('--'));
|
|
74
|
+
const crashed = lines.flatMap((line) => {
|
|
75
|
+
const path = CRASHED.exec(line)?.[1];
|
|
76
|
+
// As the caller handed it in, so the `fix:` reruns the very argument the run was given.
|
|
77
|
+
return path === undefined ? [] : [files.find((file) => bare(file) === bare(path)) ?? path];
|
|
78
|
+
});
|
|
79
|
+
return {
|
|
80
|
+
command,
|
|
81
|
+
files: files.length,
|
|
82
|
+
workers: Math.max(1, Number(width.slice(PARALLEL.length)) || 1),
|
|
83
|
+
stuck: [...new Set(crashed)],
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** How many paths a `cause:` spells out — the rest are in `meta`. */
|
|
88
|
+
const NAMED_IN_CAUSE = 5;
|
|
89
|
+
|
|
90
|
+
const OUTPUT_TAIL_CHARS = 4_000;
|
|
91
|
+
|
|
92
|
+
/** The last thing each killed run printed: what a reader of a red step looks at first. */
|
|
93
|
+
export function stalledOutput(expiry: StepExpiry): string | undefined {
|
|
94
|
+
const text = expiry.inFlight
|
|
95
|
+
.flatMap((run) => (run.output === undefined || run.output === '' ? [] : [run.output]))
|
|
96
|
+
.join('\n');
|
|
97
|
+
if (text === '') return undefined;
|
|
98
|
+
return text.length > OUTPUT_TAIL_CHARS ? `…${text.slice(-OUTPUT_TAIL_CHARS)}` : text;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const inFlightClause = (runs: readonly StalledRun[], expiry: StepExpiry): string => {
|
|
102
|
+
const files = runs.flatMap((run) => run.stuck);
|
|
103
|
+
const named = runs.find((run) => run.stuck.length > 0);
|
|
104
|
+
if (named !== undefined) {
|
|
105
|
+
const shown = files.slice(0, NAMED_IN_CAUSE).join(', ');
|
|
106
|
+
const more = files.length > NAMED_IN_CAUSE ? `, +${String(files.length - NAMED_IN_CAUSE)}` : '';
|
|
107
|
+
return `; still running when it was stopped: ${shown}${more} (${named.command})`;
|
|
108
|
+
}
|
|
109
|
+
const waiting = expiry.inFlight[0];
|
|
110
|
+
if (waiting === undefined) return '';
|
|
111
|
+
return runs[0] === undefined
|
|
112
|
+
? `; it was waiting on: ${waiting.command.slice(0, 4).join(' ')}`
|
|
113
|
+
: `; its running "${runs[0].command}" named no file still in flight, so the run itself did not exit`;
|
|
114
|
+
};
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* A step that ran past its deadline, on that step's own line. `command` is the gate as it was
|
|
118
|
+
* invoked here (`x verify`, or `bun run verify` at the framework root), so the fix runs where the
|
|
119
|
+
* finding was raised. With a stuck file named, the fix runs THAT file; the step's own rerun and
|
|
120
|
+
* the `stepTimeoutMs` edit ride behind it.
|
|
121
|
+
*/
|
|
122
|
+
export const stepTimeoutFinding = (
|
|
123
|
+
step: VerifyStepName,
|
|
124
|
+
ms: number,
|
|
125
|
+
expiry: StepExpiry,
|
|
126
|
+
command: string,
|
|
127
|
+
): Finding => {
|
|
128
|
+
const runs = expiry.inFlight.flatMap((run) => stalledRun(run) ?? []);
|
|
129
|
+
const stuck = runs.flatMap((run) => run.stuck);
|
|
130
|
+
const rerun = `${command} --only ${step} --json`;
|
|
131
|
+
const longer = `if it legitimately needs longer, edit x.verify.json — add "${STEP_TIMEOUT_FIELD}": { "${step}": <milliseconds> }`;
|
|
132
|
+
// Each path through `quoteArg`, one argv word apiece: a test file's name is data from the disk,
|
|
133
|
+
// and this line is pasted into a shell.
|
|
134
|
+
const alone = ['bun', 'test', ...stuck.map(quoteArg)].join(' ');
|
|
135
|
+
const meta: StepTimeoutMeta = {
|
|
136
|
+
step,
|
|
137
|
+
deadlineMs: ms,
|
|
138
|
+
killed: expiry.killed.map((one) => ({ pid: one.pid, command: one.command })),
|
|
139
|
+
inFlight: runs,
|
|
140
|
+
};
|
|
141
|
+
return {
|
|
142
|
+
code: 'X_VERIFY_STEP_TIMEOUT',
|
|
143
|
+
cause: `step "${step}" did not finish within its ${String(ms)} ms deadline, so it was stopped and ${String(expiry.killed.length)} process(es) it had started were killed${inFlightClause(runs, expiry)}`,
|
|
144
|
+
fix:
|
|
145
|
+
stuck.length > 0
|
|
146
|
+
? `${alone} # what had not finished, alone; the whole step: ${rerun}; ${longer}`
|
|
147
|
+
: `${rerun} # reproduce it alone; ${longer}`,
|
|
148
|
+
docs: ERROR_DOCS_URL,
|
|
149
|
+
...(stuck[0] === undefined ? {} : { at: stuck[0] }),
|
|
150
|
+
meta,
|
|
151
|
+
};
|
|
152
|
+
};
|
package/src/verify-step.ts
CHANGED
|
@@ -135,6 +135,8 @@ export interface StepOutcome {
|
|
|
135
135
|
readonly ok: boolean;
|
|
136
136
|
readonly findings: readonly Finding[];
|
|
137
137
|
readonly output?: string;
|
|
138
|
+
/** Advice that does not fail the step; carried on a pass too (`StepResult.warnings`). */
|
|
139
|
+
readonly warnings?: readonly string[];
|
|
138
140
|
/**
|
|
139
141
|
* Processes this step actually used. `1` is a step that ran serially, and a reader has to be
|
|
140
142
|
* able to tell "serial because nothing could isolate it" from "parallel and fast" without
|
package/src/verify-test-run.ts
CHANGED
|
@@ -4,13 +4,14 @@
|
|
|
4
4
|
|
|
5
5
|
// why: Bun ships no path-join primitive; the timings cache lives under the run's root.
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
+
import { ISOLATED_ENV } from '@ultimat3/testing';
|
|
7
8
|
import type { Runner } from './exec';
|
|
8
9
|
import type { Finding } from './output';
|
|
9
10
|
import { runBatches, testBatches } from './test-batches';
|
|
10
11
|
import { countsOf } from './test-counts';
|
|
11
12
|
import { testEnvOverrides } from './test-dotenv';
|
|
12
13
|
import type { TestFile } from './test-select';
|
|
13
|
-
import { failureOf,
|
|
14
|
+
import { failureOf, TEST_TIMINGS_FILE, testArgs } from './test-shards';
|
|
14
15
|
import { BATCH_FILES_PER_WORKER, SHARED_BATCH_FILES_PER_WORKER } from './test-workers';
|
|
15
16
|
import type { StepOutcome } from './verify-step';
|
|
16
17
|
// Type-only, so nothing here evaluates verify-tests.ts and the two files cannot form a cycle.
|
|
@@ -53,7 +54,7 @@ export async function runParallel(options: ParallelRunOptions): Promise<StepOutc
|
|
|
53
54
|
const workers = Math.max(1, Math.min(Math.trunc(options.workers), files.length || 1));
|
|
54
55
|
const envOverrides = {
|
|
55
56
|
...testEnvOverrides(options.root, options.env ?? Bun.env),
|
|
56
|
-
...(options.isolate === true ? { [
|
|
57
|
+
...(options.isolate === true ? { [ISOLATED_ENV]: '1' } : {}),
|
|
57
58
|
};
|
|
58
59
|
// Batched exactly as `x test` batches the same selection at the same width, so the gate's
|
|
59
60
|
// `fix:` line — `x test <type> --workers N` — reruns the very processes that failed.
|
package/src/verify-tests.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { judgeCoverage } from './coverage-floor';
|
|
|
18
18
|
import { msg } from './messages';
|
|
19
19
|
import { countsOf } from './test-counts';
|
|
20
20
|
import { testEnvOverrides } from './test-dotenv';
|
|
21
|
+
import { IGNORED_TEST_PATTERNS } from './test-ignores';
|
|
21
22
|
import type { TestFile } from './test-select';
|
|
22
23
|
import { discoverTests } from './test-select';
|
|
23
24
|
import { machineLease } from './test-slots';
|
|
@@ -39,7 +40,6 @@ import { runParallel } from './verify-test-run';
|
|
|
39
40
|
* and `@ultimat3/testing` is already a runtime dependency of this package.
|
|
40
41
|
*/
|
|
41
42
|
export type { TestType } from '@ultimat3/testing';
|
|
42
|
-
export { TEST_TYPES } from '@ultimat3/testing';
|
|
43
43
|
|
|
44
44
|
type TypedTest = Exclude<TestType, 'unit'>;
|
|
45
45
|
|
|
@@ -124,7 +124,9 @@ const isSerial = (type: TestType): boolean => SERIAL_TYPES.includes(type);
|
|
|
124
124
|
* Build output, and nested projects that carry their own `x verify`. `examples/**` and
|
|
125
125
|
* `dummy/**` are the second kind: each holds a whole app gated by its own run of this same step
|
|
126
126
|
* list, so collecting them here would report one app failure on two different gates. The patterns
|
|
127
|
-
* are relative to the run's root, so this excludes nothing when the app itself is the root.
|
|
127
|
+
* are relative to the run's root, so this excludes nothing when the app itself is the root. The
|
|
128
|
+
* policy — which directories, and which are anchored at the root — is `test-ignores.ts`'s, the same
|
|
129
|
+
* one `discoverTests` filters the parallel steps by; this is its glob projection.
|
|
128
130
|
*
|
|
129
131
|
* `dummy/**` was added after the framework gate started running a demo app's tests and disagreeing
|
|
130
132
|
* with `bun run test` — which already excluded it — for no reason a reader could see.
|
|
@@ -133,7 +135,7 @@ const isSerial = (type: TestType): boolean => SERIAL_TYPES.includes(type);
|
|
|
133
135
|
* those apps and blocks on a per-app ratchet. Until 2026-08 that was only true of `examples/**`,
|
|
134
136
|
* and `dummy/social-media-clone` was excluded by this list and gated by nothing.
|
|
135
137
|
*/
|
|
136
|
-
const NEVER_A_TEST =
|
|
138
|
+
const NEVER_A_TEST = IGNORED_TEST_PATTERNS;
|
|
137
139
|
|
|
138
140
|
const ignoreFlags = (patterns: readonly string[]): readonly string[] =>
|
|
139
141
|
patterns.map((pattern) => `--path-ignore-patterns=${pattern}`);
|
package/src/worker-bundle.ts
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// why: Bun ships no path API; the entry is resolved to a file and named in the cause.
|
|
8
8
|
import { dirname, join, relative } from 'node:path';
|
|
9
9
|
import type { Route, UltimateRequest } from '@ultimat3/http';
|
|
10
|
-
import { applyCacheHeaders,
|
|
10
|
+
import { applyCacheHeaders, jsonResponse } from '@ultimat3/http';
|
|
11
11
|
import { FrameworkScriptBuildFailedError, type FrameworkScriptKind } from './errors';
|
|
12
12
|
import { describeBuildError, graphHash, stripDebugId } from './island-identity';
|
|
13
13
|
|
|
@@ -198,7 +198,7 @@ function scriptRoute(
|
|
|
198
198
|
handler: (request: UltimateRequest): Response => {
|
|
199
199
|
const script = source();
|
|
200
200
|
if (script === undefined || script.url !== request.pathname) {
|
|
201
|
-
return
|
|
201
|
+
return jsonResponse(
|
|
202
202
|
{
|
|
203
203
|
ok: false,
|
|
204
204
|
error: {
|
package/src/workspace-checks.ts
CHANGED
|
@@ -14,6 +14,8 @@ import { existsSync, readFileSync } from 'node:fs';
|
|
|
14
14
|
import { join } from 'node:path';
|
|
15
15
|
import { ERROR_DOCS_URL, renderCauseValue } from '@ultimat3/core';
|
|
16
16
|
import type { Finding } from './output';
|
|
17
|
+
import { pathSegments } from './path-segments';
|
|
18
|
+
import { toPosix } from './posix-path';
|
|
17
19
|
import {
|
|
18
20
|
entryFilesOf,
|
|
19
21
|
FIXTURE_SUFFIX,
|
|
@@ -23,6 +25,8 @@ import {
|
|
|
23
25
|
} from './publish-closure';
|
|
24
26
|
import { isReExportManifest } from './reexport-manifest';
|
|
25
27
|
import { eachSourceFile, isGenerated } from './source-files';
|
|
28
|
+
import type { ShippedPackage } from './test-only-shipped';
|
|
29
|
+
import { checkTestOnlyShipped, entryPatternsOf } from './test-only-shipped';
|
|
26
30
|
import { checkRootReferences } from './tsconfig-references';
|
|
27
31
|
|
|
28
32
|
export const LINE_CEILING = 500;
|
|
@@ -53,15 +57,41 @@ export const countLines = (text: string): number =>
|
|
|
53
57
|
* statement of logic in it is not, and only reading tells them apart.
|
|
54
58
|
*/
|
|
55
59
|
export async function checkFileSizes(root: string): Promise<readonly Finding[]> {
|
|
60
|
+
return (await fileSizeReport(root)).findings;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Where a split gets planned rather than forced. Seven files once sat at exactly 500 lines,
|
|
65
|
+
* trimmed to the ceiling instead of split: a band that lists a file at 450 makes the split a
|
|
66
|
+
* decision taken with room to spare. Never a finding — a warning that fails the step is a ceiling.
|
|
67
|
+
*/
|
|
68
|
+
export const WARN_BAND = 450;
|
|
69
|
+
|
|
70
|
+
/** A file in the band: at or over `WARN_BAND`, at or under the ceiling. */
|
|
71
|
+
export interface FileSizeWarning {
|
|
72
|
+
readonly path: string;
|
|
73
|
+
readonly lines: number;
|
|
74
|
+
readonly ceiling: number;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface FileSizeReport {
|
|
78
|
+
readonly findings: readonly Finding[];
|
|
79
|
+
readonly warnings: readonly FileSizeWarning[];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The ceiling's findings and the band's warnings, from one read of every file. */
|
|
83
|
+
export async function fileSizeReport(root: string): Promise<FileSizeReport> {
|
|
56
84
|
const findings: Finding[] = [];
|
|
85
|
+
const warnings: FileSizeWarning[] = [];
|
|
57
86
|
for await (const path of eachSourceFile(root)) {
|
|
58
87
|
if (isGenerated(path)) continue;
|
|
59
88
|
const source = await Bun.file(join(root, path)).text();
|
|
60
89
|
const lines = countLines(source);
|
|
61
|
-
if (lines
|
|
62
|
-
findings.push(tooLongFinding(path, lines));
|
|
90
|
+
if (lines < WARN_BAND || isReExportManifest(source)) continue;
|
|
91
|
+
if (lines > LINE_CEILING) findings.push(tooLongFinding(path, lines));
|
|
92
|
+
else warnings.push({ path, lines, ceiling: LINE_CEILING });
|
|
63
93
|
}
|
|
64
|
-
return findings;
|
|
94
|
+
return { findings, warnings };
|
|
65
95
|
}
|
|
66
96
|
|
|
67
97
|
export const missingFileFinding = (dir: string, file: string, scaffolder: boolean): Finding => ({
|
|
@@ -307,13 +337,20 @@ export function frameworkDepsOf(manifest: unknown): ManifestFacts['frameworkDeps
|
|
|
307
337
|
});
|
|
308
338
|
}
|
|
309
339
|
|
|
340
|
+
/**
|
|
341
|
+
* `packages/<dir>/package.json` → `<dir>`, from a glob answer in the host's separator. Split on `/`
|
|
342
|
+
* alone, Windows' `packages\db\package.json` named no directory, so an app's `package-shape` found
|
|
343
|
+
* no workspace and its floor reported `X_VERIFY_SUITE_VANISHED` on a scaffold fresh from `x new`.
|
|
344
|
+
*/
|
|
345
|
+
export const workspaceDirOf = (globbed: string): string | undefined => pathSegments(globbed)[1];
|
|
346
|
+
|
|
310
347
|
export async function workspacePackages(root: string): Promise<readonly string[]> {
|
|
311
348
|
const dirs: string[] = [];
|
|
312
349
|
for await (const path of new Bun.Glob('packages/*/package.json').scan({
|
|
313
350
|
cwd: root,
|
|
314
351
|
absolute: false,
|
|
315
352
|
})) {
|
|
316
|
-
const dir = path
|
|
353
|
+
const dir = workspaceDirOf(path);
|
|
317
354
|
if (dir !== undefined) dirs.push(dir);
|
|
318
355
|
}
|
|
319
356
|
return dirs.sort();
|
|
@@ -345,7 +382,10 @@ async function fixtureReach(root: string, dir: string, manifest: unknown): Promi
|
|
|
345
382
|
// consumer's, so no `*.test.ts` is read.
|
|
346
383
|
const sources = new Map<string, string>();
|
|
347
384
|
for await (const path of new Bun.Glob('src/**/*.{ts,tsx}').scan({ cwd: base, absolute: false })) {
|
|
348
|
-
|
|
385
|
+
// Keyed POSIX: `entryFilesOf` and every import the walk resolves are, on every host.
|
|
386
|
+
if (!/\.test\.tsx?$/.test(path)) {
|
|
387
|
+
sources.set(toPosix(path), readFileSync(join(base, path), 'utf8'));
|
|
388
|
+
}
|
|
349
389
|
}
|
|
350
390
|
if (!mayReachFixture(sources, entries)) return [];
|
|
351
391
|
return fixturesReachedFrom({ read: (path) => sources.get(path) }, entries).map((fixture) =>
|
|
@@ -361,6 +401,7 @@ export async function checkPackageShape(
|
|
|
361
401
|
const scaffolder = existsSync(join(root, 'scripts', 'new-package.ts'));
|
|
362
402
|
const findings: Finding[] = [];
|
|
363
403
|
const facts: ManifestFacts[] = [];
|
|
404
|
+
const shipped: ShippedPackage[] = [];
|
|
364
405
|
const allowlisted = new Set(ARTIFACT_ALLOWLIST);
|
|
365
406
|
for (const dir of await workspacePackages(root)) {
|
|
366
407
|
for (const file of PACKAGE_FILES) {
|
|
@@ -373,7 +414,7 @@ export async function checkPackageShape(
|
|
|
373
414
|
cwd: join(root, 'packages', dir),
|
|
374
415
|
absolute: false,
|
|
375
416
|
})) {
|
|
376
|
-
if (!allowlisted.has(join('packages', dir, path))) artifacts.push(path);
|
|
417
|
+
if (!allowlisted.has(toPosix(join('packages', dir, path)))) artifacts.push(path);
|
|
377
418
|
}
|
|
378
419
|
if (artifacts.length > 0) {
|
|
379
420
|
findings.push(buildArtifactsFinding(dir, artifacts.length));
|
|
@@ -389,7 +430,10 @@ export async function checkPackageShape(
|
|
|
389
430
|
findings.push(badVersionFinding(dir, version));
|
|
390
431
|
continue;
|
|
391
432
|
}
|
|
392
|
-
if (record.private !== true)
|
|
433
|
+
if (record.private !== true) {
|
|
434
|
+
findings.push(...(await fixtureReach(root, dir, manifest)));
|
|
435
|
+
shipped.push({ dir, entries: entryPatternsOf(manifest) });
|
|
436
|
+
}
|
|
393
437
|
facts.push({
|
|
394
438
|
dir,
|
|
395
439
|
name: typeof record.name === 'string' ? record.name : `@ultimat3/${dir}`,
|
|
@@ -403,6 +447,8 @@ export async function checkPackageShape(
|
|
|
403
447
|
...findings,
|
|
404
448
|
...checkLockstep(facts, options.release),
|
|
405
449
|
...checkPublishShape(root, facts),
|
|
450
|
+
// The other way test code ships: a module only tests import, under a name `files` keeps.
|
|
451
|
+
...(await checkTestOnlyShipped(root, shipped)),
|
|
406
452
|
// Nothing enforced that a workspace joins the root build graph, and `scripts/new-package.ts`
|
|
407
453
|
// never added one — so a package could ship, be imported, and be typechecked by nothing. It
|
|
408
454
|
// rides on this step because it is this step's own question: what does a workspace owe the
|