@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/metrics-endpoint.ts
CHANGED
|
@@ -8,15 +8,20 @@ import {
|
|
|
8
8
|
METRICS_PATH,
|
|
9
9
|
markListening,
|
|
10
10
|
metricsText,
|
|
11
|
+
stringField,
|
|
12
|
+
UltimateError,
|
|
11
13
|
} from '@ultimat3/core';
|
|
14
|
+
import { docsFor } from './error-codes';
|
|
15
|
+
import { neighbouringPort } from './flag-number';
|
|
12
16
|
|
|
13
17
|
/**
|
|
14
18
|
* A port of its own, and NOT the role's HTTP port, for one reason the chart makes concrete:
|
|
15
19
|
* `docker/helm/templates/ingress.yaml` routes `path: /` `Prefix` to the web Service, so a
|
|
16
20
|
* `/metrics` mounted beside `/healthz` on port 3000 is `/metrics` on the internet — route
|
|
17
|
-
* patterns, request volumes and error rates, published.
|
|
18
|
-
* `
|
|
19
|
-
*
|
|
21
|
+
* patterns, request volumes and error rates, published. `service.yaml` does publish this port
|
|
22
|
+
* so a `ServiceMonitor` has a named target, but `ingress.yaml` selects its backend port BY NAME
|
|
23
|
+
* (`http`), so the endpoint stays cluster-internal by construction rather than by an ingress
|
|
24
|
+
* exclusion somebody has to remember to write.
|
|
20
25
|
*
|
|
21
26
|
* It is also the only thing `worker`, `scheduler` and `replicator` could ever be scraped on —
|
|
22
27
|
* they open no HTTP socket at all, and `queue_depth` is exactly the signal one of them owns.
|
|
@@ -24,6 +29,37 @@ import {
|
|
|
24
29
|
*/
|
|
25
30
|
export const DEFAULT_METRICS_PORT = 9090;
|
|
26
31
|
|
|
32
|
+
/**
|
|
33
|
+
* `X_PORT_IN_USE` is the code the CLI already registers for "this dev port is taken", and the
|
|
34
|
+
* scrape port is one — a synonym here would be a second code for one condition. The fix moves the
|
|
35
|
+
* port rather than naming a process to kill, because `METRICS_PORT` is the one knob both `x dev`
|
|
36
|
+
* and the container read (`serve.ts`'s `metricsPortFromEnv`).
|
|
37
|
+
*
|
|
38
|
+
* The port it names comes from `neighbouringPort`, never `port + 1`: at the top of the range
|
|
39
|
+
* that is 65536, and an instruction that cannot run is the failure this code exists to end.
|
|
40
|
+
*/
|
|
41
|
+
export class MetricsPortInUseError extends UltimateError {
|
|
42
|
+
constructor(input: { port: number }) {
|
|
43
|
+
super({
|
|
44
|
+
code: 'X_PORT_IN_USE',
|
|
45
|
+
cause: `the metrics port ${input.port} is already bound, so no role could open its scrape listener`,
|
|
46
|
+
fix: `METRICS_PORT=${neighbouringPort(input.port)} x dev --json`,
|
|
47
|
+
docs: docsFor('X_PORT_IN_USE'),
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Bun surfaces the bind failure as an `Error` carrying the libc code; nothing else is ours. Read
|
|
54
|
+
* through `stringField`, never `error instanceof Error` plus a property access: both run on a value
|
|
55
|
+
* this process did not build, and either can throw one line before the guard that was meant to make
|
|
56
|
+
* the path safe. Exported because whether the kernel refuses a second bind is the OS's business,
|
|
57
|
+
* not this package's — the contract worth pinning is that an EADDRINUSE-shaped throw becomes a
|
|
58
|
+
* coded refusal, and that is testable without racing a socket.
|
|
59
|
+
*/
|
|
60
|
+
export const isAddressInUse = (error: unknown): boolean =>
|
|
61
|
+
stringField(error, 'code') === 'EADDRINUSE';
|
|
62
|
+
|
|
27
63
|
export interface MetricsEndpointOptions {
|
|
28
64
|
/** 0 asks the kernel for an ephemeral port, which is what a test wants. */
|
|
29
65
|
readonly port?: number;
|
|
@@ -44,20 +80,32 @@ export interface MetricsEndpoint {
|
|
|
44
80
|
* signal at the moment of load is worse than no autoscaler.
|
|
45
81
|
*/
|
|
46
82
|
export function startMetricsEndpoint(options: MetricsEndpointOptions = {}): MetricsEndpoint {
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
83
|
+
const port = options.port ?? DEFAULT_METRICS_PORT;
|
|
84
|
+
// `startRoles` opens this FIRST, before any role, so `Bun.serve`'s own bare `Error` was what a
|
|
85
|
+
// second `x dev` on one machine reported: no code, no fix, at the boot path this package owns.
|
|
86
|
+
// The return type is inferred, keeping `Bun.serve`'s own shape stated once.
|
|
87
|
+
function listen() {
|
|
88
|
+
try {
|
|
89
|
+
return Bun.serve({
|
|
90
|
+
port,
|
|
91
|
+
hostname: options.hostname ?? 'localhost',
|
|
92
|
+
fetch(request: Request): Response {
|
|
93
|
+
if (new URL(request.url).pathname !== METRICS_PATH) {
|
|
94
|
+
return new Response('not found', { status: 404 });
|
|
95
|
+
}
|
|
96
|
+
// `collectMetrics()` is cumulative and never reset by a read, so two scrapers cannot
|
|
97
|
+
// steal each other's samples — but a cache would hand the second one a stale window.
|
|
98
|
+
return new Response(metricsText(), {
|
|
99
|
+
headers: { 'content-type': METRICS_CONTENT_TYPE, 'cache-control': 'no-store' },
|
|
100
|
+
});
|
|
101
|
+
},
|
|
58
102
|
});
|
|
59
|
-
}
|
|
60
|
-
|
|
103
|
+
} catch (error) {
|
|
104
|
+
if (!isAddressInUse(error)) throw error;
|
|
105
|
+
throw new MetricsPortInUseError({ port });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
const server = listen();
|
|
61
109
|
// Same rule as every other socket the framework opens: announce it, so a request back to it is
|
|
62
110
|
// recognisably this process calling itself rather than egress the test seal must refuse.
|
|
63
111
|
const stopListening = markListening(server.url.origin);
|
package/src/migrations.ts
CHANGED
|
@@ -4,11 +4,18 @@
|
|
|
4
4
|
|
|
5
5
|
import { existsSync } from 'node:fs';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
|
-
import type { Migration } from '@ultimat3/db';
|
|
7
|
+
import type { Migration, SchemaDescription } from '@ultimat3/db';
|
|
8
|
+
import { parseSnapshot } from '@ultimat3/db';
|
|
8
9
|
|
|
9
|
-
/** Where `x
|
|
10
|
+
/** Where `x db gen` — the only writer — puts them. App-root-relative, POSIX. */
|
|
10
11
|
export const MIGRATIONS_DIR = 'packages/db/migrations';
|
|
11
12
|
|
|
13
|
+
/** The schema `<id>` leaves behind, written by `x db gen` so the next one can diff against it. */
|
|
14
|
+
export const snapshotFileName = (id: string): string => `${id}.snapshot.json`;
|
|
15
|
+
|
|
16
|
+
/** The hash of the entity source `<id>` was generated from. `drift.ts` writes and reads it. */
|
|
17
|
+
export const hashFileName = (id: string): string => `${id}.hash`;
|
|
18
|
+
|
|
12
19
|
/**
|
|
13
20
|
* `-- down` alone on a line splits a migration file. Anchored to the whole line so a comment that
|
|
14
21
|
* merely mentions the word — `-- down migrations are required` — is not mistaken for the marker.
|
|
@@ -25,21 +32,47 @@ export function parseMigrationSql(id: string, sql: string): Migration {
|
|
|
25
32
|
return { id, name: migrationName(id), up, down };
|
|
26
33
|
}
|
|
27
34
|
|
|
35
|
+
/**
|
|
36
|
+
* A snapshot that will not parse is *absent*, never a half-read one: the `up` beside it is still
|
|
37
|
+
* the migration this app applies, so the file list stays whole. What that absence then means is
|
|
38
|
+
* the caller's — `x db gen` refuses with `X_MIGRATION_SNAPSHOT_MISSING` when it is the newest
|
|
39
|
+
* migration's, because there is nothing left to diff the entities against.
|
|
40
|
+
*/
|
|
41
|
+
async function readSnapshot(dir: string, id: string): Promise<SchemaDescription | undefined> {
|
|
42
|
+
const file = Bun.file(join(dir, snapshotFileName(id)));
|
|
43
|
+
if (!(await file.exists())) return undefined;
|
|
44
|
+
// Parsed to the last nested field by `@ultimat3/db`, never asserted: `{"tables":[null]}` is
|
|
45
|
+
// valid JSON and a cast made it a `SchemaDescription` the diff then threw on.
|
|
46
|
+
return parseSnapshot(await file.json().catch(() => undefined));
|
|
47
|
+
}
|
|
48
|
+
|
|
28
49
|
/**
|
|
29
50
|
* Sorted by id, because that is the apply order and `pendingMigrations` re-sorts on the same key.
|
|
30
51
|
* A missing directory is an empty list rather than a throw: an app can legitimately declare no
|
|
31
52
|
* entity yet, and the count is reported so "nothing was applied" is never silent.
|
|
53
|
+
*
|
|
54
|
+
* `<id>.down.sql` is skipped and never read as a migration of its own. Migrations before 1.2.0
|
|
55
|
+
* were hand-written as a `<id>.sql` / `<id>.down.sql` pair — a layout no generator ever produced
|
|
56
|
+
* and this reader would have applied as a migration named `<id>.down`, dropping every table the
|
|
57
|
+
* pair exists to reverse. One migration is one file, split by the `-- down` marker.
|
|
32
58
|
*/
|
|
33
59
|
export async function readMigrations(root: string): Promise<readonly Migration[]> {
|
|
34
60
|
const dir = join(root, MIGRATIONS_DIR);
|
|
35
61
|
if (!existsSync(dir)) return [];
|
|
36
62
|
const files: string[] = [];
|
|
37
|
-
for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir }))
|
|
63
|
+
for await (const file of new Bun.Glob('*.sql').scan({ cwd: dir })) {
|
|
64
|
+
if (!file.endsWith('.down.sql')) files.push(file);
|
|
65
|
+
}
|
|
38
66
|
files.sort();
|
|
39
67
|
const migrations: Migration[] = [];
|
|
40
68
|
for (const file of files) {
|
|
69
|
+
const id = file.replace(/\.sql$/, '');
|
|
41
70
|
const text = await Bun.file(join(dir, file)).text();
|
|
42
|
-
|
|
71
|
+
const snapshot = await readSnapshot(dir, id);
|
|
72
|
+
migrations.push({
|
|
73
|
+
...parseMigrationSql(id, text),
|
|
74
|
+
...(snapshot === undefined ? {} : { snapshot }),
|
|
75
|
+
});
|
|
43
76
|
}
|
|
44
77
|
return migrations;
|
|
45
78
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// OTLP export, switched on by the variable the shipped Helm chart already sets. Until this file
|
|
2
|
+
// `OTEL_EXPORTER_OTLP_ENDPOINT` was in `docker/helm/values.yaml` and **no code read it**: a
|
|
3
|
+
// deployment configured a collector, the collector received nothing, and the only signal that
|
|
4
|
+
// anything was wrong was an empty dashboard.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
configureMetrics,
|
|
8
|
+
configureTelemetry,
|
|
9
|
+
logger,
|
|
10
|
+
onShutdown,
|
|
11
|
+
otlpMetricExporter,
|
|
12
|
+
otlpSpanExporter,
|
|
13
|
+
startMetricExport,
|
|
14
|
+
tryOtlpEndpoint,
|
|
15
|
+
} from '@ultimat3/core';
|
|
16
|
+
import type { Env } from './dev-services';
|
|
17
|
+
|
|
18
|
+
/** How often counters are pushed. Core's own default; named here because the boot chose it. */
|
|
19
|
+
export const METRIC_EXPORT_INTERVAL_MS = 60_000;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Install whichever exporters an endpoint was configured for, and return the release.
|
|
23
|
+
*
|
|
24
|
+
* `tryOtlpEndpoint` is asked FIRST, per signal, because both constructors throw
|
|
25
|
+
* `X_OTLP_ENDPOINT_INVALID` when nothing configured one — deliberately, so that a telemetry
|
|
26
|
+
* exporter can never silently send nowhere. Asking is what makes the exporter optional without
|
|
27
|
+
* making it silent.
|
|
28
|
+
*
|
|
29
|
+
* Both are registered with `onShutdown(..., { phase: 'close' })`: the last spans of a drain are
|
|
30
|
+
* the ones that explain the drain, and a process that exits with a full queue loses exactly the
|
|
31
|
+
* window an operator went looking for.
|
|
32
|
+
*/
|
|
33
|
+
export function startOtlpExport(env: Env = process.env): () => void {
|
|
34
|
+
const releases: (() => void)[] = [];
|
|
35
|
+
|
|
36
|
+
// The boot's OWN env, not `process.env`, and the resolved endpoint is then passed to the
|
|
37
|
+
// exporter explicitly: `runRole({ env })` is a real seam — a test and an in-process host both
|
|
38
|
+
// pass an env that is not the process's — and an exporter that re-read `process.env` would
|
|
39
|
+
// answer a different question from the one this function just asked.
|
|
40
|
+
const traces = tryOtlpEndpoint('traces', env);
|
|
41
|
+
if (traces !== undefined) {
|
|
42
|
+
const exporter = otlpSpanExporter({ endpoint: traces });
|
|
43
|
+
configureTelemetry({ exporter });
|
|
44
|
+
releases.push(onShutdown('otlp-traces', () => exporter.shutdown(), { phase: 'close' }));
|
|
45
|
+
logger.info('ultimate otlp traces', { endpoint: traces });
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const metrics = tryOtlpEndpoint('metrics', env);
|
|
49
|
+
if (metrics !== undefined) {
|
|
50
|
+
const exporter = otlpMetricExporter({ endpoint: metrics });
|
|
51
|
+
configureMetrics({ exporter });
|
|
52
|
+
// The push loop, and not only the exporter: `configureMetrics` names where a snapshot goes
|
|
53
|
+
// and nothing decides when one is taken, so without this the collector receives one export —
|
|
54
|
+
// the drain's — for the whole life of the process.
|
|
55
|
+
const stopTimer = startMetricExport(METRIC_EXPORT_INTERVAL_MS);
|
|
56
|
+
releases.push(stopTimer);
|
|
57
|
+
releases.push(onShutdown('otlp-metrics', () => exporter.flush(), { phase: 'close' }));
|
|
58
|
+
logger.info('ultimate otlp metrics', { endpoint: metrics });
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
return () => {
|
|
62
|
+
for (const release of releases.reverse()) release();
|
|
63
|
+
};
|
|
64
|
+
}
|
package/src/output.ts
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
// and the JSON renderer are projections of it, so `--json` can never drift from the terminal
|
|
3
3
|
// output (axiom 4). The human renderer owns the canonical 3-line error format.
|
|
4
4
|
|
|
5
|
+
import { renderThrowable, stringField } from '@ultimat3/core';
|
|
6
|
+
import { msg } from './messages';
|
|
7
|
+
|
|
5
8
|
export interface Finding {
|
|
6
9
|
readonly code: string;
|
|
7
10
|
readonly cause: string;
|
|
@@ -19,6 +22,8 @@ export interface StepResult {
|
|
|
19
22
|
readonly findings: readonly Finding[];
|
|
20
23
|
/** Captured stdout/stderr, shown on failure or with --verbose. */
|
|
21
24
|
readonly output?: string;
|
|
25
|
+
/** Worker processes the step used; `1` means it ran serially. Absent for a non-test step. */
|
|
26
|
+
readonly workers?: number;
|
|
22
27
|
}
|
|
23
28
|
|
|
24
29
|
export type JsonValue =
|
|
@@ -57,34 +62,39 @@ export interface UltimateErrorShape {
|
|
|
57
62
|
readonly message: string;
|
|
58
63
|
}
|
|
59
64
|
|
|
60
|
-
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
61
|
-
typeof value === 'object' && value !== null;
|
|
62
|
-
|
|
63
65
|
/**
|
|
64
66
|
* Structural check, deliberately not `instanceof`: an error may cross a subprocess or worker
|
|
65
67
|
* boundary and arrive as a plain object, and the renderer must still produce the fix line.
|
|
68
|
+
*
|
|
69
|
+
* Every field goes through core's `stringField`, because the value is a caught throwable and the
|
|
70
|
+
* probe itself is a property read: a getter that throws, or a `Proxy` trapping `get`, raised HERE
|
|
71
|
+
* — one line before the total renderer below, in the function whose whole job is deciding what the
|
|
72
|
+
* terminal shows.
|
|
66
73
|
*/
|
|
67
74
|
export function isUltimateErrorShape(value: unknown): value is UltimateErrorShape {
|
|
68
|
-
if (!isRecord(value)) return false;
|
|
69
75
|
return (
|
|
70
|
-
|
|
71
|
-
value
|
|
72
|
-
|
|
73
|
-
typeof value['fix'] === 'string'
|
|
76
|
+
stringField(value, 'code')?.startsWith('X_') === true &&
|
|
77
|
+
stringField(value, 'cause') !== undefined &&
|
|
78
|
+
stringField(value, 'fix') !== undefined
|
|
74
79
|
);
|
|
75
80
|
}
|
|
76
81
|
|
|
77
82
|
export function findingFrom(value: unknown): Finding {
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
+
// Read once and carry the values, rather than narrowing and reading the same properties again:
|
|
84
|
+
// a getter is a function, and nothing promises it answers the same way twice.
|
|
85
|
+
const code = stringField(value, 'code');
|
|
86
|
+
const cause = stringField(value, 'cause');
|
|
87
|
+
const fix = stringField(value, 'fix');
|
|
88
|
+
if (code?.startsWith('X_') === true && cause !== undefined && fix !== undefined) {
|
|
89
|
+
const docs = stringField(value, 'docs');
|
|
90
|
+
return docs === undefined ? { code, cause, fix } : { code, cause, fix, docs };
|
|
83
91
|
}
|
|
84
|
-
|
|
92
|
+
// This is the LAST renderer between a thrown value and the terminal: a hostile `toString`, a
|
|
93
|
+
// throwing `message` getter or a trapped `instanceof` here loses the whole report, not one line
|
|
94
|
+
// of it. `renderThrowable` is total on all three.
|
|
85
95
|
return {
|
|
86
96
|
code: 'X_CLI_UNEXPECTED',
|
|
87
|
-
cause,
|
|
97
|
+
cause: renderThrowable(value),
|
|
88
98
|
fix: 'x doctor --json',
|
|
89
99
|
docs: 'https://ultimate.dev/errors/X_CLI_UNEXPECTED',
|
|
90
100
|
};
|
|
@@ -130,10 +140,20 @@ const mark = (step: StepResult): string => {
|
|
|
130
140
|
return step.ok ? '✓' : '✗';
|
|
131
141
|
};
|
|
132
142
|
|
|
143
|
+
/**
|
|
144
|
+
* How the step was run, when that is a fact about the step rather than about the machine. A gate
|
|
145
|
+
* that silently went parallel is a gate whose failures a reader would blame on flakiness, so a
|
|
146
|
+
* serial step says so and a parallel one names its width.
|
|
147
|
+
*/
|
|
148
|
+
const width = (step: StepResult): string => {
|
|
149
|
+
if (step.workers === undefined || step.skipped === true) return '';
|
|
150
|
+
return ` ${step.workers === 1 ? msg('cli.verify.serial') : msg('cli.verify.workers', { workers: step.workers })}`;
|
|
151
|
+
};
|
|
152
|
+
|
|
133
153
|
export function renderHuman(result: CommandResult, verbose = false): string {
|
|
134
154
|
const out: string[] = [];
|
|
135
155
|
for (const step of result.steps ?? []) {
|
|
136
|
-
out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms`);
|
|
156
|
+
out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}`);
|
|
137
157
|
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
138
158
|
if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
|
|
139
159
|
for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
|
|
@@ -152,6 +172,16 @@ export function renderJson(result: CommandResult): string {
|
|
|
152
172
|
durationMs: step.durationMs,
|
|
153
173
|
skipped: step.skipped === true,
|
|
154
174
|
findings: step.findings,
|
|
175
|
+
...(step.workers === undefined ? {} : { workers: step.workers }),
|
|
176
|
+
// A FAILED step carries its captured stdout, exactly as the human renderer prints it. CI runs
|
|
177
|
+
// `--json`, and without this the log said only "one or more unit tests failed" with a generic
|
|
178
|
+
// fix line — the failing test's name and its assertion diff existed and were thrown away, so
|
|
179
|
+
// the only way to learn what broke was to re-run it somewhere else. CI output is a prompt:
|
|
180
|
+
// whoever reads it next, agent or human, must be able to act without reproducing first.
|
|
181
|
+
// Success stays quiet (`--verbose` is the human's opt-in) so a green run is not a wall of text.
|
|
182
|
+
...(step.ok || step.output === undefined || step.output.length === 0
|
|
183
|
+
? {}
|
|
184
|
+
: { output: step.output }),
|
|
155
185
|
}));
|
|
156
186
|
const payload = {
|
|
157
187
|
ok: result.ok,
|
package/src/parse.ts
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
// same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
|
|
3
3
|
// access, so the parser is unit-testable and the dispatcher owns all side effects.
|
|
4
4
|
|
|
5
|
-
import { BadFlagError, UnknownCommandError } from './errors';
|
|
5
|
+
import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './errors';
|
|
6
6
|
|
|
7
7
|
export type FlagValue = string | boolean;
|
|
8
8
|
|
|
@@ -20,6 +20,32 @@ export interface CommandSpec {
|
|
|
20
20
|
readonly usage: string;
|
|
21
21
|
readonly aliases?: readonly string[];
|
|
22
22
|
readonly subcommands?: readonly string[];
|
|
23
|
+
/**
|
|
24
|
+
* What a bare `x <command>` means, when it means anything. Declared, never inferred: the parser
|
|
25
|
+
* used to answer `subcommands[0]`, so `x db` ran `gen` — the migration GENERATOR — because it
|
|
26
|
+
* sorted first. A command with no defensible default omits this and the parser refuses instead.
|
|
27
|
+
*/
|
|
28
|
+
readonly defaultSubcommand?: string;
|
|
29
|
+
/**
|
|
30
|
+
* A closed set the FIRST positional must come from, where the command has one. Declarative only:
|
|
31
|
+
* the parser leaves positionals to the command, because `x test`'s own `readOnlyType` already
|
|
32
|
+
* refuses an unknown type with the list. What this adds is a set `fix-command.ts` can resolve a
|
|
33
|
+
* citation against. `packages/ai/src/eval-errors.ts` avoids `x test <eval-name>` by hand, with a
|
|
34
|
+
* three-line comment explaining that it is `X_CLI_BAD_FLAG` — a convention held by memory,
|
|
35
|
+
* because nothing outside `cmd-test.ts` knew what `x test` accepts. Declare it from the SAME
|
|
36
|
+
* constant the command validates against, never a second literal.
|
|
37
|
+
*/
|
|
38
|
+
readonly positionalChoices?: readonly string[];
|
|
39
|
+
/**
|
|
40
|
+
* The same closed set, one level down: the set a named SUBCOMMAND's first positional must come
|
|
41
|
+
* from. `positionalChoices` cannot express it, because `fix-command.ts` only consults that field
|
|
42
|
+
* where a command declares NO subcommands — so `x db branch ls` resolved as command +
|
|
43
|
+
* subcommand and nothing ever looked at `ls`. That is how a shipped `fix:` told an agent to run
|
|
44
|
+
* a listing while `x db branch` read `ls` as a branch name and created a database from it.
|
|
45
|
+
* Declarative only, exactly like `positionalChoices`: the command still refuses an unknown word
|
|
46
|
+
* itself, and this is declared from the SAME constant it validates against.
|
|
47
|
+
*/
|
|
48
|
+
readonly subcommandPositionals?: Readonly<Record<string, readonly string[]>>;
|
|
23
49
|
readonly flags?: readonly FlagSpec[];
|
|
24
50
|
/** Command needs an app root (`app.config.ts`) — the dispatcher enforces it. */
|
|
25
51
|
readonly requiresApp?: boolean;
|
|
@@ -44,6 +70,15 @@ export const GLOBAL_FLAGS: readonly FlagSpec[] = [
|
|
|
44
70
|
{ name: 'verbose', type: 'boolean', summary: 'include step output on success' },
|
|
45
71
|
];
|
|
46
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Whether raw argv asked for JSON, readable BEFORE the parse succeeds. The parse-failure path in
|
|
75
|
+
* `dispatch.ts` has no `ParsedArgs` to read `--json` off — and it used to test `argv.includes`
|
|
76
|
+
* for the long form only, so `x doctor -j --bogusflag` rendered its `X_CLI_BAD_FLAG` as prose to
|
|
77
|
+
* an agent that had asked for JSON and then called `JSON.parse` on it. One detection, two callers.
|
|
78
|
+
*/
|
|
79
|
+
export const wantsJson = (argv: readonly string[]): boolean =>
|
|
80
|
+
argv.some((token) => token === '--json' || token === '-j');
|
|
81
|
+
|
|
47
82
|
const HELP_ALIASES = new Set(['--help', '-h', 'help']);
|
|
48
83
|
const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
|
|
49
84
|
|
|
@@ -118,7 +153,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
118
153
|
// `help` and `version` short-circuit the flag loop below, so `--json` has to be read here or the
|
|
119
154
|
// two commands silently print prose to an agent that asked for JSON — and every `fix:` naming
|
|
120
155
|
// `x help --json` would be a command that does not do what it says.
|
|
121
|
-
const json = tokens
|
|
156
|
+
const json = wantsJson(tokens);
|
|
122
157
|
if (VERSION_ALIASES.has(first)) return blank('version', specs, json);
|
|
123
158
|
if (HELP_ALIASES.has(first)) {
|
|
124
159
|
const rest = tokens.slice(1).filter((token) => !token.startsWith('-'));
|
|
@@ -198,7 +233,10 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): stri
|
|
|
198
233
|
const allowed = spec.subcommands;
|
|
199
234
|
if (allowed === undefined || allowed.length === 0) return undefined;
|
|
200
235
|
const token = positionals[0];
|
|
201
|
-
if (token === undefined)
|
|
236
|
+
if (token === undefined) {
|
|
237
|
+
if (spec.defaultSubcommand !== undefined) return spec.defaultSubcommand;
|
|
238
|
+
throw new MissingSubcommandError({ command: spec.name, known: allowed });
|
|
239
|
+
}
|
|
202
240
|
if (allowed.includes(token)) return token;
|
|
203
241
|
const suggestion = nearest(token, allowed);
|
|
204
242
|
throw new UnknownCommandError(
|
package/src/policy-facts.ts
CHANGED
|
@@ -12,12 +12,30 @@ import { devActors } from './dev-policy';
|
|
|
12
12
|
|
|
13
13
|
const isDefined = <T>(value: T | undefined): value is T => value !== undefined;
|
|
14
14
|
|
|
15
|
-
/**
|
|
16
|
-
|
|
15
|
+
/**
|
|
16
|
+
* Descriptor names whose policy references this permission — shared by list and explain.
|
|
17
|
+
*
|
|
18
|
+
* Matched on `permissions` and never on `capability`. `capability` is the DISPLAY label, and a
|
|
19
|
+
* composite policy renders as `and(post:publish, org:administer)` — never a bare permission — so
|
|
20
|
+
* an equality test against it reported every action guarded by a composite as enforcing nothing.
|
|
21
|
+
* That is `unenforced`: "this grant does nothing", printed about every non-trivial rule in a real
|
|
22
|
+
* app, to the compliance engineer reading it before an access review.
|
|
23
|
+
*/
|
|
24
|
+
const namesEnforcing = <
|
|
25
|
+
D extends { readonly name: string; readonly permissions: readonly string[] },
|
|
26
|
+
>(
|
|
17
27
|
descriptors: readonly D[],
|
|
18
28
|
permission: string,
|
|
19
29
|
): readonly string[] =>
|
|
20
|
-
descriptors
|
|
30
|
+
descriptors
|
|
31
|
+
.filter((descriptor) => descriptor.permissions.includes(permission))
|
|
32
|
+
.map((d) => d.name);
|
|
33
|
+
|
|
34
|
+
/** The same rule as `namesEnforcing`, for the two `explain` filters that keep the descriptor. */
|
|
35
|
+
const enforces = (
|
|
36
|
+
descriptor: { readonly permissions: readonly string[] },
|
|
37
|
+
permission: string,
|
|
38
|
+
): boolean => descriptor.permissions.includes(permission);
|
|
21
39
|
|
|
22
40
|
// ── list ──────────────────────────────────────────────────────────────────
|
|
23
41
|
|
|
@@ -66,7 +84,14 @@ export type SubjectKind = 'permission' | DeclarationKind;
|
|
|
66
84
|
export interface DeclarationExplanation {
|
|
67
85
|
readonly name: string;
|
|
68
86
|
readonly kind: DeclarationKind;
|
|
87
|
+
/** The policy's DISPLAY label — `and(post:publish, org:administer)` for a composite. */
|
|
69
88
|
readonly capability: string;
|
|
89
|
+
/**
|
|
90
|
+
* Every permission the policy tree references, flattened. What a grant is MATCHED against; the
|
|
91
|
+
* label above is what a person reads. Published because `grantingRoles` is derived from it —
|
|
92
|
+
* `rolesGranting('and(a:b, c:d)')` is a lookup that can only ever answer nothing.
|
|
93
|
+
*/
|
|
94
|
+
readonly permissions: readonly string[];
|
|
70
95
|
readonly label: string;
|
|
71
96
|
/**
|
|
72
97
|
* Whether this policy can be decided at all outside a request. `false` when evaluating it
|
|
@@ -117,6 +142,7 @@ const explainAction = (action: AnyAction): DeclarationExplanation => {
|
|
|
117
142
|
name: descriptor.name,
|
|
118
143
|
kind: 'action',
|
|
119
144
|
capability: descriptor.capability,
|
|
145
|
+
permissions: descriptor.permissions,
|
|
120
146
|
label: action.policy.label,
|
|
121
147
|
...matrixFor(action.policy),
|
|
122
148
|
};
|
|
@@ -128,6 +154,7 @@ const explainQuery = (query: AnyQuery): DeclarationExplanation => {
|
|
|
128
154
|
name: descriptor.name,
|
|
129
155
|
kind: 'query',
|
|
130
156
|
capability: descriptor.capability,
|
|
157
|
+
permissions: descriptor.permissions,
|
|
131
158
|
label: query.policy.label,
|
|
132
159
|
...matrixFor(query.policy),
|
|
133
160
|
};
|
|
@@ -168,12 +195,12 @@ export function explainPolicy(name: string): SubjectExplanation | undefined {
|
|
|
168
195
|
const { permission } = resolved;
|
|
169
196
|
const declarations = [
|
|
170
197
|
...describeActions()
|
|
171
|
-
.filter((descriptor) => descriptor
|
|
198
|
+
.filter((descriptor) => enforces(descriptor, permission))
|
|
172
199
|
.map((descriptor) => getAction(descriptor.name))
|
|
173
200
|
.filter(isDefined)
|
|
174
201
|
.map(explainAction),
|
|
175
202
|
...describeQueries()
|
|
176
|
-
.filter((descriptor) => descriptor
|
|
203
|
+
.filter((descriptor) => enforces(descriptor, permission))
|
|
177
204
|
.map((descriptor) => getQuery(descriptor.name))
|
|
178
205
|
.filter(isDefined)
|
|
179
206
|
.map(explainQuery),
|
|
@@ -190,7 +217,12 @@ export function explainPolicy(name: string): SubjectExplanation | undefined {
|
|
|
190
217
|
return {
|
|
191
218
|
subject: name,
|
|
192
219
|
kind: resolved.kind,
|
|
193
|
-
|
|
220
|
+
// The union over every permission the policy references, not a lookup on the label: a
|
|
221
|
+
// composite-guarded action reported NO granting roles at all, which reads as "nobody can do
|
|
222
|
+
// this" about a declaration half the roles in the app can reach.
|
|
223
|
+
grantingRoles: [
|
|
224
|
+
...new Set(declaration.permissions.flatMap((permission) => rolesGranting(permission))),
|
|
225
|
+
].sort(),
|
|
194
226
|
declarations: [declaration],
|
|
195
227
|
};
|
|
196
228
|
}
|
package/src/policy-fixture.ts
CHANGED
|
@@ -15,19 +15,26 @@ interface PostRow {
|
|
|
15
15
|
}
|
|
16
16
|
|
|
17
17
|
/**
|
|
18
|
-
*
|
|
18
|
+
* Four permissions, three roles, two actions and two queries — each fact earning its place.
|
|
19
19
|
*
|
|
20
|
-
* `
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
20
|
+
* `archivePost` is guarded by a COMPOSITE, and that is the point of it: its display capability is
|
|
21
|
+
* the label `and(post:publish, post:read)`, which is not any permission, so a report that matched
|
|
22
|
+
* on the label counted this action as enforcing nothing and printed both of its permissions as
|
|
23
|
+
* dead grants. It enforces two, and the facts say so.
|
|
24
|
+
*
|
|
25
|
+
* `post:delete` is the control: declared, granted to `admin`, enforced by nothing at all. It is
|
|
26
|
+
* what a genuinely dead grant looks like, and it is what keeps `unenforced` a claim worth making
|
|
27
|
+
* now that a composite no longer lands in it by accident.
|
|
28
|
+
*
|
|
29
|
+
* `post:publish` is enforced by two actions AND a query, so the aggregation across declarations
|
|
30
|
+
* has something to aggregate.
|
|
24
31
|
*
|
|
25
32
|
* Registers only; the registries are process-global, so the caller clears them first.
|
|
26
33
|
*/
|
|
27
34
|
export function registerPolicyFixture(): void {
|
|
28
|
-
definePermissions(['post:publish', 'post:read', 'feed:read'] as const);
|
|
35
|
+
definePermissions(['post:publish', 'post:read', 'post:delete', 'feed:read'] as const);
|
|
29
36
|
defineRoles({
|
|
30
|
-
admin: { grants: ['post:publish', 'post:read', 'feed:read'] },
|
|
37
|
+
admin: { grants: ['post:publish', 'post:read', 'post:delete', 'feed:read'] },
|
|
31
38
|
editor: { grants: ['post:publish', 'post:read'] },
|
|
32
39
|
reader: { grants: ['post:read', 'feed:read'] },
|
|
33
40
|
});
|