@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
package/src/verify-merge.ts
CHANGED
|
@@ -6,55 +6,82 @@
|
|
|
6
6
|
// - the zero-tests floor applied to the SUMMED counts of a sharded step, which each shard deferred;
|
|
7
7
|
// - any gap is a named `X_VERIFY_MERGE_INCOMPLETE` finding, never a silent pass.
|
|
8
8
|
//
|
|
9
|
+
// - an app's coverage floor judged HERE for a sharded `unit` step: one shard's slice cannot hold a
|
|
10
|
+
// floor, so each hands its facts over in its document and the fold is judged once.
|
|
11
|
+
//
|
|
9
12
|
// The merged document is a gate run (no `notAGateRun`), because the question it answers — did
|
|
10
13
|
// every step run, over the whole tree, and pass — is the gate's question.
|
|
11
14
|
|
|
12
|
-
|
|
15
|
+
// why: Bun ships no synchronous existence check and no path-join primitive.
|
|
16
|
+
import { existsSync } from 'node:fs';
|
|
17
|
+
// why: Bun ships no path-join primitive.
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { ERROR_DOCS_URL, renderThrowable, UltimateError } from '@ultimat3/core';
|
|
20
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
21
|
+
import { judgeCoverage } from './coverage-floor';
|
|
22
|
+
import type { CoverageMap } from './coverage-lcov';
|
|
23
|
+
import { decodeCoverage, mergeCoverage } from './coverage-lcov';
|
|
13
24
|
import { msg } from './messages';
|
|
14
25
|
import type { CommandResult, Finding, StepResult } from './output';
|
|
15
26
|
import type { TestCounts } from './test-counts';
|
|
16
|
-
import { VerifyMergeInputError } from './verify-errors';
|
|
17
27
|
import type { VerifyFloor } from './verify-floor';
|
|
18
28
|
import { floorRequires, skippedSuiteFinding } from './verify-floor';
|
|
29
|
+
import type { PartStep } from './verify-part-step';
|
|
30
|
+
import { readPartStep } from './verify-part-step';
|
|
19
31
|
import { verifySummary } from './verify-run';
|
|
20
|
-
import {
|
|
32
|
+
import { corpusHash } from './verify-shard';
|
|
33
|
+
import { GATE_COMMAND, VERIFY_STEP_NAMES } from './verify-step';
|
|
21
34
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
}
|
|
35
|
+
/**
|
|
36
|
+
* A part handed to `x verify merge` that is not an `x verify --json` document. `command` is the
|
|
37
|
+
* entry that raised it — `bun run verify` at the framework root, where `x verify` answers
|
|
38
|
+
* `X_NOT_IN_APP` — so the `fix:` runs where it was raised.
|
|
39
|
+
*/
|
|
40
|
+
export class VerifyMergeInputError extends UltimateError {
|
|
41
|
+
constructor(input: { file: string; reason: string; sourceError?: unknown; command?: string }) {
|
|
42
|
+
const command = input.command ?? GATE_COMMAND;
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_VERIFY_MERGE_INPUT',
|
|
45
|
+
cause: `${command} merge: ${input.file} ${input.reason}`,
|
|
46
|
+
fix: `${command} --only unit --json > part.json # one document per part, the last line of stdout`,
|
|
47
|
+
...(input.sourceError === undefined ? {} : { sourceError: input.sourceError }),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
37
50
|
}
|
|
38
51
|
|
|
39
52
|
export interface VerifyPart {
|
|
40
53
|
readonly file: string;
|
|
41
54
|
readonly durationMs: number;
|
|
42
55
|
readonly steps: readonly PartStep[];
|
|
56
|
+
/** step → what this part's slice of it covered and did not judge (`data.coverage.<step>.facts`). */
|
|
57
|
+
readonly coverage?: Readonly<Record<string, CoverageMap>>;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Findings a step earns from the parts TOGETHER, which no one part could raise. */
|
|
61
|
+
export type MergeRiders = Readonly<
|
|
62
|
+
Record<string, { readonly findings: readonly Finding[]; readonly output?: string }>
|
|
63
|
+
>;
|
|
64
|
+
|
|
65
|
+
export interface MergeOptions {
|
|
66
|
+
/** The gate as invoked here, for the `fix:` of a gap. */
|
|
67
|
+
readonly command?: string;
|
|
68
|
+
readonly riders?: MergeRiders;
|
|
43
69
|
}
|
|
44
70
|
|
|
45
71
|
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
46
72
|
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
47
73
|
|
|
48
74
|
/**
|
|
49
|
-
* The LAST non-empty line
|
|
50
|
-
* the gate's
|
|
75
|
+
* The LAST non-empty line, and only it: `bin/check --json` prints the build's document and then
|
|
76
|
+
* the gate's. Never "the last line that parses" — a part whose tail is not the gate's document is
|
|
77
|
+
* a truncated or appended upload, and falling back to an earlier line would believe it.
|
|
51
78
|
*/
|
|
52
|
-
export function parsePart(file: string, text: string): VerifyPart {
|
|
79
|
+
export function parsePart(file: string, text: string, command: string = GATE_COMMAND): VerifyPart {
|
|
53
80
|
const lines = text
|
|
54
81
|
.split('\n')
|
|
55
82
|
.map((line) => line.trim())
|
|
56
83
|
.filter((line) => line.length > 0);
|
|
57
|
-
if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty' });
|
|
84
|
+
if (lines.length === 0) throw new VerifyMergeInputError({ file, reason: 'is empty', command });
|
|
58
85
|
let doc: unknown;
|
|
59
86
|
try {
|
|
60
87
|
doc = JSON.parse(lines[lines.length - 1] as string);
|
|
@@ -63,54 +90,138 @@ export function parsePart(file: string, text: string): VerifyPart {
|
|
|
63
90
|
file,
|
|
64
91
|
reason: `does not end in a JSON document (${renderThrowable(error)})`,
|
|
65
92
|
sourceError: error,
|
|
93
|
+
command,
|
|
66
94
|
});
|
|
67
95
|
}
|
|
68
96
|
if (!isRecord(doc) || doc['command'] !== 'verify' || !Array.isArray(doc['steps'])) {
|
|
69
97
|
throw new VerifyMergeInputError({
|
|
70
98
|
file,
|
|
71
99
|
reason: 'is not an x verify --json document (no "command": "verify" with "steps")',
|
|
100
|
+
command,
|
|
72
101
|
});
|
|
73
102
|
}
|
|
74
103
|
const data = isRecord(doc['data']) ? doc['data'] : {};
|
|
75
104
|
const steps: PartStep[] = [];
|
|
76
105
|
for (const raw of doc['steps'] as unknown[]) {
|
|
77
|
-
|
|
78
|
-
|
|
106
|
+
const step = readPartStep(raw);
|
|
107
|
+
if (typeof step === 'string') throw new VerifyMergeInputError({ file, reason: step, command });
|
|
108
|
+
steps.push(step);
|
|
109
|
+
}
|
|
110
|
+
const coverage = new Map<string, CoverageMap>();
|
|
111
|
+
for (const [step, entry] of Object.entries(isRecord(data['coverage']) ? data['coverage'] : {})) {
|
|
112
|
+
// A part that judged its own floor carries numbers, not facts: nothing here to fold.
|
|
113
|
+
if (!isRecord(entry) || !('facts' in entry)) continue;
|
|
114
|
+
const map = decodeCoverage(entry['facts']);
|
|
115
|
+
if (map === undefined) {
|
|
116
|
+
throw new VerifyMergeInputError({
|
|
117
|
+
file,
|
|
118
|
+
reason: `carries coverage for "${step}" that is not a coverage map`,
|
|
119
|
+
command,
|
|
120
|
+
});
|
|
79
121
|
}
|
|
80
|
-
|
|
122
|
+
coverage.set(step, map);
|
|
81
123
|
}
|
|
82
124
|
return {
|
|
83
125
|
file,
|
|
84
126
|
durationMs: typeof data['durationMs'] === 'number' ? data['durationMs'] : 0,
|
|
85
127
|
steps,
|
|
128
|
+
...(coverage.size === 0 ? {} : { coverage: Object.fromEntries(coverage) }),
|
|
86
129
|
};
|
|
87
130
|
}
|
|
88
131
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
132
|
+
/** A gap finding, with a fix spelled for the entry that raised it. */
|
|
133
|
+
type Gap = (cause: string, at?: string) => Finding;
|
|
134
|
+
|
|
135
|
+
const gapFor =
|
|
136
|
+
(command: string): Gap =>
|
|
137
|
+
(cause, at) => ({
|
|
138
|
+
code: 'X_VERIFY_MERGE_INCOMPLETE',
|
|
139
|
+
cause,
|
|
140
|
+
fix: `${command} merge parts/*.json --json # after every CI job uploaded its part`,
|
|
141
|
+
docs: ERROR_DOCS_URL,
|
|
142
|
+
...(at === undefined ? {} : { at }),
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
/** The step every part's coverage facts belong to. */
|
|
146
|
+
const COVERED_STEP = 'unit';
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* An app's coverage floor over a SHARDED `unit` step, judged once on the fold of every shard —
|
|
150
|
+
* the finding no shard could raise alone. Nothing when the step was not split (the whole run
|
|
151
|
+
* judged itself), when a shard is red (a failed slice wrote no lcov, and its own finding is the
|
|
152
|
+
* one to read), or outside an app. Shards that carry no facts at all fold to an empty map, which
|
|
153
|
+
* is 0% and a red gate: a split can never skip the floor by saying nothing.
|
|
154
|
+
*/
|
|
155
|
+
export async function coverageRiders(
|
|
156
|
+
parts: readonly VerifyPart[],
|
|
157
|
+
root: string,
|
|
158
|
+
floor: VerifyFloor | undefined,
|
|
159
|
+
): Promise<MergeRiders> {
|
|
160
|
+
if (!existsSync(join(root, APP_CONFIG_FILE))) return {};
|
|
161
|
+
const shards = parts.flatMap((part) =>
|
|
162
|
+
part.steps
|
|
163
|
+
.filter((step) => step.name === COVERED_STEP && step.shard !== undefined)
|
|
164
|
+
.map((step) => ({ step, coverage: part.coverage?.[COVERED_STEP] })),
|
|
165
|
+
);
|
|
166
|
+
if (shards.length === 0 || shards.some((shard) => !shard.step.ok)) return {};
|
|
167
|
+
const folded = mergeCoverage(
|
|
168
|
+
shards.flatMap((shard) => (shard.coverage === undefined ? [] : [shard.coverage])),
|
|
169
|
+
);
|
|
170
|
+
const verdict = await judgeCoverage(root, folded, floor?.coverage);
|
|
171
|
+
return { [COVERED_STEP]: { findings: verdict.findings, output: verdict.output } };
|
|
172
|
+
}
|
|
96
173
|
|
|
97
174
|
const sumCounts = (steps: readonly PartStep[]): TestCounts | undefined => {
|
|
98
175
|
const counted = steps.filter((step) => step.tests !== undefined);
|
|
99
176
|
if (counted.length === 0) return undefined;
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}),
|
|
105
|
-
|
|
106
|
-
);
|
|
177
|
+
const errors = counted.reduce((sum, step) => sum + (step.tests?.errors ?? 0), 0);
|
|
178
|
+
return {
|
|
179
|
+
ran: counted.reduce((sum, step) => sum + (step.tests?.ran ?? 0), 0),
|
|
180
|
+
skipped: counted.reduce((sum, step) => sum + (step.tests?.skipped ?? 0), 0),
|
|
181
|
+
...(errors === 0 ? {} : { errors }),
|
|
182
|
+
};
|
|
107
183
|
};
|
|
108
184
|
|
|
185
|
+
/**
|
|
186
|
+
* The shards' `files` must PARTITION the corpus they all hashed: each file in exactly one shard,
|
|
187
|
+
* and the union hashing to `corpusHash`. Agreeing on the hash is not agreeing on the split — shard
|
|
188
|
+
* 1/2 round-robin beside 2/2 with `--timings` ran one file twice and another nowhere, green.
|
|
189
|
+
*/
|
|
190
|
+
function partitionGaps(
|
|
191
|
+
name: string,
|
|
192
|
+
sharded: readonly { readonly step: PartStep }[],
|
|
193
|
+
gap: Gap,
|
|
194
|
+
): readonly Finding[] {
|
|
195
|
+
const counts = new Map<string, number>();
|
|
196
|
+
for (const entry of sharded) {
|
|
197
|
+
for (const file of entry.step.shard?.files ?? []) counts.set(file, (counts.get(file) ?? 0) + 1);
|
|
198
|
+
}
|
|
199
|
+
const twice = [...counts].filter(([, count]) => count > 1).map(([file]) => file);
|
|
200
|
+
const found: Finding[] = [];
|
|
201
|
+
if (twice.length > 0) {
|
|
202
|
+
found.push(gap(`step "${name}" ran ${twice.join(', ')} in more than one shard`, name));
|
|
203
|
+
}
|
|
204
|
+
// A list's fingerprint, not a credential: what the shards ran against what they all counted.
|
|
205
|
+
const ranCorpus = corpusHash([...counts.keys()]);
|
|
206
|
+
const countedCorpus = sharded[0]?.step.shard?.corpusHash;
|
|
207
|
+
if (ranCorpus !== countedCorpus) {
|
|
208
|
+
found.push(
|
|
209
|
+
gap(
|
|
210
|
+
`step "${name}"'s shards ran ${String(counts.size)} distinct file(s), which is not the corpus they hashed — some file ran in no shard; every shard must split one list the same way (all with --timings, or none)`,
|
|
211
|
+
name,
|
|
212
|
+
),
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
return found;
|
|
216
|
+
}
|
|
217
|
+
|
|
109
218
|
/** One step's entries from every part, folded into one step result and its gaps. */
|
|
110
219
|
function mergeStep(
|
|
111
220
|
name: string,
|
|
112
221
|
entries: readonly { readonly part: string; readonly step: PartStep }[],
|
|
113
222
|
floor: VerifyFloor | undefined,
|
|
223
|
+
gap: Gap,
|
|
224
|
+
rider: MergeRiders[string] | undefined,
|
|
114
225
|
): StepResult {
|
|
115
226
|
if (entries.length === 0) {
|
|
116
227
|
return {
|
|
@@ -166,6 +277,8 @@ function mergeStep(
|
|
|
166
277
|
if (doubled.length > 0) {
|
|
167
278
|
gaps.push(gap(`step "${name}" has shard(s) ${doubled.join(', ')} more than once`, name));
|
|
168
279
|
}
|
|
280
|
+
// Only over a complete, single-corpus set — any gap above already explains a partial union.
|
|
281
|
+
if (gaps.length === 0) gaps.push(...partitionGaps(name, sharded, gap));
|
|
169
282
|
}
|
|
170
283
|
const steps = entries.map((entry) => entry.step);
|
|
171
284
|
const tests = sumCounts(steps);
|
|
@@ -174,16 +287,23 @@ function mergeStep(
|
|
|
174
287
|
const nothingRan = sharded.length > 0 && tests !== undefined && tests.ran === 0;
|
|
175
288
|
const required = floorRequires(floor, name);
|
|
176
289
|
const floorGap = nothingRan && required ? [skippedSuiteFinding(name, tests.skipped)] : [];
|
|
177
|
-
const
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
290
|
+
const riding = rider?.findings ?? [];
|
|
291
|
+
const findings = [...steps.flatMap((step) => step.findings), ...gaps, ...floorGap, ...riding];
|
|
292
|
+
const output = [
|
|
293
|
+
...steps
|
|
294
|
+
.filter((step) => !step.ok && step.output !== undefined && step.output.length > 0)
|
|
295
|
+
.map((step) => step.output as string),
|
|
296
|
+
...(rider?.output === undefined ? [] : [rider.output]),
|
|
297
|
+
].join('\n');
|
|
182
298
|
const skipped =
|
|
183
299
|
sharded.length > 0 ? nothingRan && !required : steps.every((step) => step.skipped);
|
|
184
300
|
return {
|
|
185
301
|
name,
|
|
186
|
-
ok:
|
|
302
|
+
ok:
|
|
303
|
+
steps.every((step) => step.ok) &&
|
|
304
|
+
gaps.length === 0 &&
|
|
305
|
+
floorGap.length === 0 &&
|
|
306
|
+
riding.length === 0,
|
|
187
307
|
// The slowest job: the parts ran side by side, and wall time is what a CI run waits on.
|
|
188
308
|
durationMs: Math.max(...steps.map((step) => step.durationMs)),
|
|
189
309
|
...(skipped ? { skipped: true } : {}),
|
|
@@ -198,7 +318,9 @@ export function mergeParts(
|
|
|
198
318
|
parts: readonly VerifyPart[],
|
|
199
319
|
floor: VerifyFloor | undefined,
|
|
200
320
|
declared: readonly string[] = VERIFY_STEP_NAMES,
|
|
321
|
+
options: MergeOptions = {},
|
|
201
322
|
): CommandResult {
|
|
323
|
+
const gap = gapFor(options.command ?? GATE_COMMAND);
|
|
202
324
|
const unknown = parts.flatMap((part) =>
|
|
203
325
|
part.steps
|
|
204
326
|
.filter((step) => !declared.includes(step.name))
|
|
@@ -211,6 +333,10 @@ export function mergeParts(
|
|
|
211
333
|
part.steps.filter((step) => step.name === name).map((step) => ({ part: part.file, step })),
|
|
212
334
|
),
|
|
213
335
|
floor,
|
|
336
|
+
gap,
|
|
337
|
+
options.riders !== undefined && Object.hasOwn(options.riders, name)
|
|
338
|
+
? options.riders[name]
|
|
339
|
+
: undefined,
|
|
214
340
|
),
|
|
215
341
|
);
|
|
216
342
|
const failed = steps.filter((step) => !step.ok).map((step) => step.name);
|
|
@@ -0,0 +1,90 @@
|
|
|
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 shard?: {
|
|
18
|
+
readonly index: number;
|
|
19
|
+
readonly total: number;
|
|
20
|
+
readonly corpusHash: string;
|
|
21
|
+
readonly files: readonly string[];
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
26
|
+
typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
27
|
+
|
|
28
|
+
const isCount = (value: unknown): value is number =>
|
|
29
|
+
typeof value === 'number' && Number.isInteger(value) && value >= 0;
|
|
30
|
+
|
|
31
|
+
/** What every finding is: code, cause and fix as strings. Optional fields ride along as written. */
|
|
32
|
+
const isFinding = (value: unknown): value is Finding =>
|
|
33
|
+
isRecord(value) &&
|
|
34
|
+
typeof value['code'] === 'string' &&
|
|
35
|
+
typeof value['cause'] === 'string' &&
|
|
36
|
+
typeof value['fix'] === 'string';
|
|
37
|
+
|
|
38
|
+
const isTests = (value: unknown): value is TestCounts =>
|
|
39
|
+
isRecord(value) &&
|
|
40
|
+
isCount(value['ran']) &&
|
|
41
|
+
isCount(value['skipped']) &&
|
|
42
|
+
(value['errors'] === undefined || isCount(value['errors']));
|
|
43
|
+
|
|
44
|
+
const isShard = (value: unknown): value is NonNullable<PartStep['shard']> =>
|
|
45
|
+
isRecord(value) &&
|
|
46
|
+
isCount(value['index']) &&
|
|
47
|
+
isCount(value['total']) &&
|
|
48
|
+
typeof value['corpusHash'] === 'string' &&
|
|
49
|
+
Array.isArray(value['files']) &&
|
|
50
|
+
value['files'].every((file) => typeof file === 'string');
|
|
51
|
+
|
|
52
|
+
/** The step, or the reason it is not one — phrased to follow "holds a step …". */
|
|
53
|
+
export function readPartStep(raw: unknown): PartStep | string {
|
|
54
|
+
if (!isRecord(raw) || typeof raw['name'] !== 'string' || typeof raw['ok'] !== 'boolean') {
|
|
55
|
+
return 'holds a step without a name and ok';
|
|
56
|
+
}
|
|
57
|
+
const name = raw['name'];
|
|
58
|
+
const durationMs = raw['durationMs'];
|
|
59
|
+
if (typeof durationMs !== 'number' || !Number.isFinite(durationMs) || durationMs < 0) {
|
|
60
|
+
return `holds step "${name}" without a durationMs in milliseconds`;
|
|
61
|
+
}
|
|
62
|
+
const findings = raw['findings'];
|
|
63
|
+
if (!Array.isArray(findings) || !findings.every(isFinding)) {
|
|
64
|
+
return `holds step "${name}" whose findings are not each a code, a cause and a fix`;
|
|
65
|
+
}
|
|
66
|
+
const { tests, shard, output, workers, skipped } = raw;
|
|
67
|
+
if (tests !== undefined && !isTests(tests)) {
|
|
68
|
+
return `holds step "${name}" whose tests are not counts`;
|
|
69
|
+
}
|
|
70
|
+
if (shard !== undefined && !isShard(shard)) {
|
|
71
|
+
return `holds step "${name}" whose shard is not an index, a total, a corpusHash and its files`;
|
|
72
|
+
}
|
|
73
|
+
if (output !== undefined && typeof output !== 'string') {
|
|
74
|
+
return `holds step "${name}" whose output is not text`;
|
|
75
|
+
}
|
|
76
|
+
if (workers !== undefined && !isCount(workers)) {
|
|
77
|
+
return `holds step "${name}" whose workers is not a count`;
|
|
78
|
+
}
|
|
79
|
+
return {
|
|
80
|
+
name,
|
|
81
|
+
ok: raw['ok'],
|
|
82
|
+
durationMs,
|
|
83
|
+
skipped: skipped === true,
|
|
84
|
+
findings,
|
|
85
|
+
...(workers === undefined ? {} : { workers }),
|
|
86
|
+
...(tests === undefined ? {} : { tests }),
|
|
87
|
+
...(output === undefined ? {} : { output }),
|
|
88
|
+
...(shard === undefined ? {} : { shard }),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// One finished gate step as one line of JSON, for stderr. `x verify --json` prints its document
|
|
2
|
+
// when the run ENDS, so a job cancelled mid-run used to leave a log with nothing in it (#589):
|
|
3
|
+
// the last of these lines is the last step that finished, and the hung one is whichever is next.
|
|
4
|
+
|
|
5
|
+
import type { StepResult } from './output';
|
|
6
|
+
|
|
7
|
+
/** `{"step":"lint","ok":true,"ms":21987}` — plus `"skipped":true` for a step that did not apply. */
|
|
8
|
+
export const stepLine = (step: StepResult): string =>
|
|
9
|
+
JSON.stringify({
|
|
10
|
+
step: step.name,
|
|
11
|
+
ok: step.ok,
|
|
12
|
+
ms: step.durationMs,
|
|
13
|
+
...(step.skipped === true ? { skipped: true } : {}),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The `onStep` a `--json` run streams through, or nothing: the human render needs no second
|
|
18
|
+
* channel. One function for both entries (`x verify`, and `bun run verify` at the framework
|
|
19
|
+
* root), so the two cannot print different lines.
|
|
20
|
+
*/
|
|
21
|
+
export const stepStream = (
|
|
22
|
+
json: boolean,
|
|
23
|
+
write: (line: string) => void,
|
|
24
|
+
): { readonly onStep?: (step: StepResult) => void } =>
|
|
25
|
+
json ? { onStep: (step) => write(stepLine(step)) } : {};
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
// The child `verify-role-load.ts` spawns: `bun verify-role-load-probe.ts <app root>`. Imports what
|
|
2
|
+
// a worker imports, then every module that load left out — one at a time — and prints what each of
|
|
3
|
+
// those registered that the worker's process did not hold. Run by hand, it is the same answer.
|
|
4
|
+
|
|
5
|
+
import { scanAppModules } from './app-load';
|
|
6
|
+
import { scanBackground } from './role-load';
|
|
7
|
+
import type { MissingRegistration } from './verify-role-load';
|
|
8
|
+
import { PROBE_MARKER, registeredSince, registrations } from './verify-role-load';
|
|
9
|
+
import { writeLine } from './write-line';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* What importing each module the worker load left out registered, by module — `[]` when the whole
|
|
13
|
+
* scan reported a module that would not import. Exported so a test calls it in its own process;
|
|
14
|
+
* the gate runs it as a child, where the registries hold nothing the gate's own load put there.
|
|
15
|
+
*/
|
|
16
|
+
export async function probeRoleLoad(root: string): Promise<readonly MissingRegistration[]> {
|
|
17
|
+
const background = await scanBackground(root);
|
|
18
|
+
const imported = new Set(background.files);
|
|
19
|
+
let held = registrations();
|
|
20
|
+
const missing: MissingRegistration[] = [];
|
|
21
|
+
let current: string | undefined;
|
|
22
|
+
|
|
23
|
+
/** Whatever appeared since the last look was registered by importing `current`. */
|
|
24
|
+
const attribute = (): void => {
|
|
25
|
+
const now = registrations();
|
|
26
|
+
if (current !== undefined) {
|
|
27
|
+
for (const one of registeredSince(held, now)) missing.push({ ...one, module: current });
|
|
28
|
+
}
|
|
29
|
+
held = now;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
// The whole app, in the scan's own order. `include` is asked before each import, so the look it
|
|
33
|
+
// takes is at what the PREVIOUS module added; a module the worker already imported adds nothing.
|
|
34
|
+
const prefix = root.endsWith('/') ? root : `${root}/`;
|
|
35
|
+
const whole = await scanAppModules(root, {
|
|
36
|
+
track: false,
|
|
37
|
+
include: (absolute) => {
|
|
38
|
+
attribute();
|
|
39
|
+
const file = absolute.startsWith(prefix) ? absolute.slice(prefix.length) : absolute;
|
|
40
|
+
current = imported.has(file) ? undefined : file;
|
|
41
|
+
return true;
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
attribute();
|
|
45
|
+
// A module that would not import registered nothing in EITHER load, and the gate's own load
|
|
46
|
+
// already reports it: an answer drawn from a short scan would blame the wrong thing.
|
|
47
|
+
return whole.findings.length > 0 ? [] : missing;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (import.meta.main) {
|
|
51
|
+
const answer = await probeRoleLoad(process.argv[2] ?? process.cwd());
|
|
52
|
+
writeLine(`${PROBE_MARKER}${JSON.stringify(answer)}`);
|
|
53
|
+
process.exit(0);
|
|
54
|
+
}
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
// The gate's half of per-role loading (`manifest` step): a worker and a scheduler import the API
|
|
2
|
+
// index and every module that reaches no component or stylesheet (`role-load.ts`). A registration
|
|
3
|
+
// made in a module that DOES reach one, and that the index does not import, exists in the web role
|
|
4
|
+
// and in every tool — and not in a worker. At boot only jobs and tasks are checked, against the
|
|
5
|
+
// manifest; this is the build error for everything else a job reads by name.
|
|
6
|
+
|
|
7
|
+
// why: Bun ships no path API; the fix names the import relative to the API index.
|
|
8
|
+
import { dirname, join, relative } from 'node:path';
|
|
9
|
+
import { ERROR_DOCS_URL, registeredServiceNames, singleLine } from '@ultimat3/core';
|
|
10
|
+
import { entityNames } from '@ultimat3/entity';
|
|
11
|
+
import { catalogDeclarationCount } from '@ultimat3/i18n';
|
|
12
|
+
import { registeredBackfills, registeredJobs, registeredTasks } from '@ultimat3/jobs';
|
|
13
|
+
import { registeredLayouts, registeredMailIds } from '@ultimat3/mail';
|
|
14
|
+
import { registeredChannels } from '@ultimat3/realtime';
|
|
15
|
+
import { definedStorage } from '@ultimat3/storage';
|
|
16
|
+
import { API_INDEX } from './app-root';
|
|
17
|
+
import type { Runner } from './exec';
|
|
18
|
+
import type { Finding } from './output';
|
|
19
|
+
import { quoteArg } from './shell-quote';
|
|
20
|
+
|
|
21
|
+
/** One thing a process registered, as a job would ask for it: by kind and by name. */
|
|
22
|
+
export interface Registration {
|
|
23
|
+
readonly kind: string;
|
|
24
|
+
readonly name: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** A registration the whole app makes and the background load does not, and the module it took. */
|
|
28
|
+
export interface MissingRegistration extends Registration {
|
|
29
|
+
/** App-root-relative: the scanned module whose import registered it. */
|
|
30
|
+
readonly module: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Every name-keyed registry a job body can read without importing the declaration: what
|
|
35
|
+
* `ctx.<service>`, `disk('x')`, `t('key')`, a mail layout or a channel resolves through. A
|
|
36
|
+
* registry read only through a static import of its declaration is not here — the import loads it.
|
|
37
|
+
*/
|
|
38
|
+
export function registrations(): readonly Registration[] {
|
|
39
|
+
const of = (kind: string, names: readonly string[]): readonly Registration[] =>
|
|
40
|
+
names.map((name) => ({ kind, name }));
|
|
41
|
+
return [
|
|
42
|
+
...of('service', registeredServiceNames()),
|
|
43
|
+
...of('storage disk', definedStorage()?.diskNames ?? []),
|
|
44
|
+
// A catalog declaration carries no name of its own: the count is what a module adds to.
|
|
45
|
+
...of(
|
|
46
|
+
'catalog declaration',
|
|
47
|
+
Array.from({ length: catalogDeclarationCount() }, (_, at) => `#${String(at + 1)}`),
|
|
48
|
+
),
|
|
49
|
+
...of('entity', entityNames()),
|
|
50
|
+
...of(
|
|
51
|
+
'job',
|
|
52
|
+
registeredJobs().map((job) => job.name),
|
|
53
|
+
),
|
|
54
|
+
...of(
|
|
55
|
+
'task',
|
|
56
|
+
registeredTasks().map((task) => task.name),
|
|
57
|
+
),
|
|
58
|
+
...of(
|
|
59
|
+
'backfill',
|
|
60
|
+
registeredBackfills().map((backfill) => backfill.name),
|
|
61
|
+
),
|
|
62
|
+
...of('mail', registeredMailIds()),
|
|
63
|
+
...of('mail layout', registeredLayouts()),
|
|
64
|
+
...of(
|
|
65
|
+
'channel',
|
|
66
|
+
registeredChannels().map((channel) => channel.name),
|
|
67
|
+
),
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const keyOf = (one: Registration): string => `${one.kind}\u0000${one.name}`;
|
|
72
|
+
|
|
73
|
+
/** What `after` holds that `before` did not. */
|
|
74
|
+
export function registeredSince(
|
|
75
|
+
before: readonly Registration[],
|
|
76
|
+
after: readonly Registration[],
|
|
77
|
+
): readonly Registration[] {
|
|
78
|
+
const had = new Set(before.map(keyOf));
|
|
79
|
+
return after.filter((one) => !had.has(keyOf(one)));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** The import the API index is missing, as it would be written there. */
|
|
83
|
+
function specifierFor(module: string): string {
|
|
84
|
+
const from = relative(dirname(API_INDEX), module).replace(/\.[jt]sx?$/, '');
|
|
85
|
+
return from.startsWith('.') ? from : `./${from}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function roleLoadFinding(missing: MissingRegistration): Finding {
|
|
89
|
+
return {
|
|
90
|
+
code: 'X_ROLE_LOAD_INCOMPLETE',
|
|
91
|
+
cause: `${missing.kind} ${JSON.stringify(missing.name)} is registered by importing ${missing.module}, which reaches a component or a stylesheet and which ${API_INDEX} does not import — so a worker and a scheduler, which import no document, run without it`,
|
|
92
|
+
fix: `edit ${API_INDEX}: add import '${specifierFor(missing.module)}'; — or move the ${missing.kind} out of ${missing.module} into a module that imports no .tsx and no stylesheet`,
|
|
93
|
+
docs: ERROR_DOCS_URL,
|
|
94
|
+
at: missing.module,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The line the probe prints its answer on; everything else it writes is a module's own output. */
|
|
99
|
+
export const PROBE_MARKER = 'x-role-load-probe:';
|
|
100
|
+
|
|
101
|
+
const PROBE = join(import.meta.dir, 'verify-role-load-probe.ts');
|
|
102
|
+
|
|
103
|
+
const isMissing = (value: unknown): value is MissingRegistration => {
|
|
104
|
+
if (typeof value !== 'object' || value === null) return false;
|
|
105
|
+
const one = value as Record<string, unknown>;
|
|
106
|
+
return (
|
|
107
|
+
typeof one['kind'] === 'string' &&
|
|
108
|
+
typeof one['name'] === 'string' &&
|
|
109
|
+
typeof one['module'] === 'string'
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
/** The probe's answer off its stdout — `undefined` when it printed none. */
|
|
114
|
+
export function parseProbe(stdout: string): readonly MissingRegistration[] | undefined {
|
|
115
|
+
const line = stdout
|
|
116
|
+
.split('\n')
|
|
117
|
+
.reverse()
|
|
118
|
+
.find((one) => one.startsWith(PROBE_MARKER));
|
|
119
|
+
if (line === undefined) return undefined;
|
|
120
|
+
try {
|
|
121
|
+
const parsed: unknown = JSON.parse(line.slice(PROBE_MARKER.length));
|
|
122
|
+
return Array.isArray(parsed) && parsed.every(isMissing) ? parsed : undefined;
|
|
123
|
+
} catch {
|
|
124
|
+
return undefined;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* One child, because the comparison needs a process whose registries hold ONLY what the background
|
|
130
|
+
* load put there — this one has already imported the whole app. The child imports what a worker
|
|
131
|
+
* imports, then the rest module by module, and answers what each of those added.
|
|
132
|
+
*/
|
|
133
|
+
export async function roleLoadFindings(root: string, runner: Runner): Promise<readonly Finding[]> {
|
|
134
|
+
const result = await runner(['bun', PROBE, root], { cwd: root });
|
|
135
|
+
const missing = parseProbe(result.stdout);
|
|
136
|
+
if (missing !== undefined) return missing.map(roleLoadFinding);
|
|
137
|
+
// No answer is not a pass: the load a worker performs did not complete in a clean process.
|
|
138
|
+
const said = singleLine(result.stderr.trim().split('\n').at(-1) ?? '');
|
|
139
|
+
return [
|
|
140
|
+
{
|
|
141
|
+
code: 'X_ROLE_LOAD_INCOMPLETE',
|
|
142
|
+
cause: `the worker's load of this app did not complete in a process of its own (exit ${String(result.code)}${said === '' ? '' : `: ${said}`}), so what a worker registers could not be compared with the whole app`,
|
|
143
|
+
fix: `bun ${quoteArg(PROBE)} ${quoteArg(root)}`,
|
|
144
|
+
docs: ERROR_DOCS_URL,
|
|
145
|
+
at: API_INDEX,
|
|
146
|
+
},
|
|
147
|
+
];
|
|
148
|
+
}
|