@ultimat3/cli 1.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.
Files changed (101) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +100 -0
  3. package/package.json +60 -0
  4. package/src/app-agents-md.ts +27 -0
  5. package/src/app-boundaries.ts +206 -0
  6. package/src/app-evals.ts +74 -0
  7. package/src/app-load.ts +136 -0
  8. package/src/app-manifest.ts +137 -0
  9. package/src/app-openapi.ts +12 -0
  10. package/src/app-root.ts +57 -0
  11. package/src/bin.ts +17 -0
  12. package/src/boundary-cuts.ts +219 -0
  13. package/src/budgets.ts +92 -0
  14. package/src/cmd-build.ts +109 -0
  15. package/src/cmd-db.ts +187 -0
  16. package/src/cmd-deploy.ts +124 -0
  17. package/src/cmd-dev.ts +286 -0
  18. package/src/cmd-doctor.ts +178 -0
  19. package/src/cmd-errors.ts +99 -0
  20. package/src/cmd-fix.ts +126 -0
  21. package/src/cmd-generate.ts +434 -0
  22. package/src/cmd-help.ts +94 -0
  23. package/src/cmd-i18n.ts +212 -0
  24. package/src/cmd-jobs.ts +237 -0
  25. package/src/cmd-manifest.ts +97 -0
  26. package/src/cmd-mcp.ts +176 -0
  27. package/src/cmd-new.ts +133 -0
  28. package/src/cmd-planned.ts +119 -0
  29. package/src/cmd-policy.ts +136 -0
  30. package/src/cmd-registries.ts +195 -0
  31. package/src/cmd-routes.ts +73 -0
  32. package/src/cmd-tasks.ts +151 -0
  33. package/src/cmd-test.ts +109 -0
  34. package/src/cmd-verify.ts +265 -0
  35. package/src/command.ts +33 -0
  36. package/src/dev-assets.ts +177 -0
  37. package/src/dev-dashboard.ts +242 -0
  38. package/src/dev-hooks.ts +51 -0
  39. package/src/dev-policy.ts +82 -0
  40. package/src/dev-queue.ts +109 -0
  41. package/src/dev-render.ts +129 -0
  42. package/src/dev-replicator.ts +92 -0
  43. package/src/dev-roles.ts +246 -0
  44. package/src/dev-runtime.ts +203 -0
  45. package/src/dev-services.ts +75 -0
  46. package/src/dev-traces.ts +141 -0
  47. package/src/dispatch.ts +98 -0
  48. package/src/drift.ts +86 -0
  49. package/src/error-catalog.ts +156 -0
  50. package/src/error-contract.ts +212 -0
  51. package/src/errors.ts +367 -0
  52. package/src/exec.ts +70 -0
  53. package/src/hold.ts +48 -0
  54. package/src/i18n-audit.ts +183 -0
  55. package/src/index.ts +179 -0
  56. package/src/jobs-drain.ts +151 -0
  57. package/src/jobs-json.ts +134 -0
  58. package/src/jobs-report.ts +132 -0
  59. package/src/jobs-table.ts +34 -0
  60. package/src/json-merge.ts +40 -0
  61. package/src/mcp-db-target.ts +50 -0
  62. package/src/mcp-errors.ts +99 -0
  63. package/src/mcp-host.ts +282 -0
  64. package/src/mcp-test-output.ts +57 -0
  65. package/src/messages.ts +119 -0
  66. package/src/output.ts +174 -0
  67. package/src/parse.ts +243 -0
  68. package/src/policy-facts.ts +196 -0
  69. package/src/policy-fixture.ts +71 -0
  70. package/src/registry.ts +73 -0
  71. package/src/scaffold-fixture.ts +69 -0
  72. package/src/scaffold-typecheck.ts +240 -0
  73. package/src/source-files.ts +38 -0
  74. package/src/table.ts +19 -0
  75. package/src/tasks-facts.ts +113 -0
  76. package/src/templates/action.ts +193 -0
  77. package/src/templates/admin.ts +46 -0
  78. package/src/templates/catalog-json.ts +17 -0
  79. package/src/templates/entity.ts +157 -0
  80. package/src/templates/index.ts +23 -0
  81. package/src/templates/job.ts +148 -0
  82. package/src/templates/locales.ts +93 -0
  83. package/src/templates/naming.ts +97 -0
  84. package/src/templates/policy.ts +120 -0
  85. package/src/templates/query.ts +116 -0
  86. package/src/templates/resource.ts +199 -0
  87. package/src/templates/route.ts +138 -0
  88. package/src/templates/scaffold-app.ts +320 -0
  89. package/src/templates/scaffold-docs.ts +156 -0
  90. package/src/templates/scaffold-i18n.ts +149 -0
  91. package/src/templates/scaffold-icon.ts +54 -0
  92. package/src/templates/scaffold-package-shape.ts +49 -0
  93. package/src/templates/scaffold-repo.ts +427 -0
  94. package/src/test-select.ts +130 -0
  95. package/src/test-shards.ts +188 -0
  96. package/src/thrown-by.ts +24 -0
  97. package/src/ts-scan.ts +217 -0
  98. package/src/verify-step.ts +83 -0
  99. package/src/verify-tests.ts +166 -0
  100. package/src/version-loader.ts +16 -0
  101. package/src/workspace-checks.ts +288 -0
@@ -0,0 +1,195 @@
1
+ // `x actions`, `x queries`, `x entities` — the declaration registries as a table or as JSON.
2
+ // Replaces grepping a source tree, which is what an agent does when the framework has no answer
3
+ // to "what actions/queries/entities exist". One table, one generic command body: the three
4
+ // commands differ only in which registry they read, a row's columns, and (for a query only) the
5
+ // one extra field `describe` surfaces that its descriptor does not already carry.
6
+
7
+ import type { ActionDescriptor, AnyAction } from '@ultimat3/action';
8
+ import { describeActions, getAction, jsonSchemaOf } from '@ultimat3/action';
9
+ import type { EntityDescription, RegistryEntry } from '@ultimat3/entity';
10
+ import { describeEntities, getEntity } from '@ultimat3/entity';
11
+ import type { AnyQuery, QueryDescriptor } from '@ultimat3/query';
12
+ import { describeQueries, getQuery } from '@ultimat3/query';
13
+ import { loadApp } from './app-load';
14
+ import { requireAppRoot } from './app-root';
15
+ import type { CliCommand, CommandContext } from './command';
16
+ import { BadFlagError, DeclarationUnknownError } from './errors';
17
+ import { msg } from './messages';
18
+ import type { CommandResult, Finding, JsonValue } from './output';
19
+ import type { CommandSpec } from './parse';
20
+ import { nearest } from './parse';
21
+ import { renderTable } from './table';
22
+
23
+ /**
24
+ * A descriptor is plain JSON by construction; only its `unknown`-typed schema fields need this
25
+ * cast to satisfy `JsonValue` — same idiom as `@ultimat3/manifest`'s `asJson`.
26
+ */
27
+ const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
28
+
29
+ const formatValue = (value: JsonValue): string =>
30
+ typeof value === 'string' ? value : JSON.stringify(value);
31
+
32
+ /** One `key: value` line per top-level field — generic across all three descriptor shapes. */
33
+ const detailLines = (payload: Readonly<Record<string, JsonValue>>): readonly string[] =>
34
+ Object.entries(payload).map(([key, value]) => ` ${key}: ${formatValue(value)}`);
35
+
36
+ /**
37
+ * One config per registry: how `list` renders a row, and what `describe` adds beyond the
38
+ * descriptor itself — only a query's input schema does (`QUERIES.extra`); `ActionDescriptor`
39
+ * already carries `input`/`output`, and an entity has no invocation shape to surface.
40
+ */
41
+ interface RegistryKind<D extends { readonly name: string }, Raw extends { describe(): D }> {
42
+ readonly kind: 'actions' | 'queries' | 'entities';
43
+ readonly singular: string;
44
+ readonly spec: CommandSpec;
45
+ readonly header: readonly string[];
46
+ list(): readonly D[];
47
+ find(name: string): Raw | undefined;
48
+ row(item: D): readonly string[];
49
+ extra(raw: Raw): Readonly<Record<string, JsonValue>>;
50
+ }
51
+
52
+ const ACTIONS: RegistryKind<ActionDescriptor, AnyAction> = {
53
+ kind: 'actions',
54
+ singular: 'action',
55
+ spec: {
56
+ name: 'actions',
57
+ summary: 'the action registry: input/output schema, policy, tags, MCP exposure',
58
+ usage: 'x actions [list|describe <name>] [--json]',
59
+ subcommands: ['list', 'describe'],
60
+ requiresApp: true,
61
+ },
62
+ header: ['name', 'verb', 'resource', 'path', 'capability', 'mcp'],
63
+ list: describeActions,
64
+ find: getAction,
65
+ row: (a) => [a.name, a.verb, a.resource, a.path, a.capability, a.mcp.expose ? 'yes' : 'no'],
66
+ extra: () => ({}),
67
+ };
68
+
69
+ const QUERIES: RegistryKind<QueryDescriptor, AnyQuery> = {
70
+ kind: 'queries',
71
+ singular: 'query',
72
+ spec: {
73
+ name: 'queries',
74
+ summary: 'the query registry: schema, policy, live, cache tags',
75
+ usage: 'x queries [list|describe <name>] [--json]',
76
+ subcommands: ['list', 'describe'],
77
+ requiresApp: true,
78
+ },
79
+ header: ['name', 'live', 'capability', 'tags', 'ttlMs'],
80
+ list: describeQueries,
81
+ find: getQuery,
82
+ row: (q) => [
83
+ q.name,
84
+ q.live ? 'yes' : 'no',
85
+ q.capability,
86
+ q.tags.length === 0 ? '-' : q.tags.join(','),
87
+ q.ttlMs === null ? '-' : String(q.ttlMs),
88
+ ],
89
+ // The descriptor carries no input shape at all; the JSON-schema view of it is cheap and is
90
+ // exactly what an agent needs before it can call `x dev`'s query endpoint correctly.
91
+ extra: (raw) => ({ input: asJson(jsonSchemaOf(raw.input)) }),
92
+ };
93
+
94
+ const ENTITIES: RegistryKind<EntityDescription, RegistryEntry> = {
95
+ kind: 'entities',
96
+ singular: 'entity',
97
+ spec: {
98
+ name: 'entities',
99
+ summary: 'the entity registry: columns, invariants, indexes, tenancy',
100
+ usage: 'x entities [list|describe <name>] [--json]',
101
+ subcommands: ['list', 'describe'],
102
+ requiresApp: true,
103
+ },
104
+ header: ['name', 'table', 'columns', 'invariants', 'indexes', 'orgScoped'],
105
+ list: describeEntities,
106
+ find: getEntity,
107
+ row: (e) => [
108
+ e.name,
109
+ e.table,
110
+ String(e.columns.length),
111
+ String(e.invariants.length),
112
+ String(e.indexes.length),
113
+ e.orgScoped ? 'yes' : 'no',
114
+ ],
115
+ extra: () => ({}),
116
+ };
117
+
118
+ function listResult<D extends { readonly name: string }, Raw extends { describe(): D }>(
119
+ kind: RegistryKind<D, Raw>,
120
+ findings: readonly Finding[],
121
+ ): CommandResult {
122
+ const items = kind.list();
123
+ return {
124
+ ok: findings.length === 0,
125
+ command: kind.kind,
126
+ summary: msg('cli.registry.count', { count: items.length, kind: kind.kind }),
127
+ lines:
128
+ items.length === 0
129
+ ? []
130
+ : renderTable(kind.header, items.map(kind.row)).map((line) => ` ${line}`),
131
+ findings,
132
+ data: items.map((item) => asJson(item)),
133
+ };
134
+ }
135
+
136
+ function describeResult<D extends { readonly name: string }, Raw extends { describe(): D }>(
137
+ kind: RegistryKind<D, Raw>,
138
+ ctx: CommandContext,
139
+ findings: readonly Finding[],
140
+ ): CommandResult {
141
+ const name = ctx.args.positionals[0];
142
+ if (name === undefined) {
143
+ throw new BadFlagError({
144
+ flag: 'name',
145
+ command: kind.kind,
146
+ reason: `x ${kind.kind} describe <name> needs a name`,
147
+ fix: `x ${kind.kind} list --json`,
148
+ });
149
+ }
150
+ const raw = kind.find(name);
151
+ if (raw === undefined) {
152
+ const known = kind.list().map((item) => item.name);
153
+ const suggestion = nearest(name, known);
154
+ throw new DeclarationUnknownError(
155
+ suggestion === undefined
156
+ ? { kind: kind.kind, singular: kind.singular, name, known }
157
+ : { kind: kind.kind, singular: kind.singular, name, known, suggestion },
158
+ );
159
+ }
160
+ const payload: Record<string, JsonValue> = { ...asJson(raw.describe()), ...kind.extra(raw) };
161
+ return {
162
+ ok: findings.length === 0,
163
+ command: kind.kind,
164
+ summary: msg('cli.registry.described', { kind: kind.singular, name }),
165
+ lines: detailLines(payload),
166
+ findings,
167
+ data: payload,
168
+ };
169
+ }
170
+
171
+ async function runRegistryCommand<
172
+ D extends { readonly name: string },
173
+ Raw extends { describe(): D },
174
+ >(kind: RegistryKind<D, Raw>, ctx: CommandContext): Promise<CommandResult> {
175
+ const root = requireAppRoot(kind.kind, ctx.cwd).dir;
176
+ const { findings } = await loadApp(root);
177
+ return ctx.args.subcommand === 'describe'
178
+ ? describeResult(kind, ctx, findings)
179
+ : listResult(kind, findings);
180
+ }
181
+
182
+ export const actionsCommand: CliCommand = {
183
+ spec: ACTIONS.spec,
184
+ run: (ctx) => runRegistryCommand(ACTIONS, ctx),
185
+ };
186
+
187
+ export const queriesCommand: CliCommand = {
188
+ spec: QUERIES.spec,
189
+ run: (ctx) => runRegistryCommand(QUERIES, ctx),
190
+ };
191
+
192
+ export const entitiesCommand: CliCommand = {
193
+ spec: ENTITIES.spec,
194
+ run: (ctx) => runRegistryCommand(ENTITIES, ctx),
195
+ };
@@ -0,0 +1,73 @@
1
+ // `x routes` — the route table as a table, or as JSON. Replaces grepping a router directory, which
2
+ // is what an agent does when the framework has no answer to "what URLs exist".
3
+ //
4
+ // The rows are `@ultimat3/render`'s own `describeRoutes()`: the CLI prints the route table, it
5
+ // does not keep a second one.
6
+
7
+ import type { RouteDescriptor } from '@ultimat3/render';
8
+ import { describeRoutes } from '@ultimat3/render';
9
+ import { loadApp } from './app-load';
10
+ import { requireAppRoot } from './app-root';
11
+ import type { CliCommand, CommandContext } from './command';
12
+ import { msg } from './messages';
13
+ import type { CommandResult, JsonValue } from './output';
14
+ import { flagString } from './parse';
15
+
16
+ /** Fixed-width columns so the output diffs cleanly between runs and between machines. */
17
+ export function renderRouteTable(routes: readonly RouteDescriptor[]): readonly string[] {
18
+ const rows = routes.map((route) => [
19
+ route.path,
20
+ route.surface,
21
+ route.mode,
22
+ route.hydrate,
23
+ route.offline,
24
+ route.file,
25
+ ]);
26
+ const header = ['path', 'surface', 'render', 'hydrate', 'offline', 'file'];
27
+ const widths = header.map((title, index) =>
28
+ Math.max(title.length, ...rows.map((row) => (row[index] ?? '').length)),
29
+ );
30
+ const line = (cells: readonly string[]): string =>
31
+ cells.map((value, index) => value.padEnd(widths[index] ?? 0)).join(' ');
32
+ return [line(header), ...rows.map(line)];
33
+ }
34
+
35
+ const routeJson = (routes: readonly RouteDescriptor[]): JsonValue =>
36
+ routes.map((route) => ({
37
+ path: route.path,
38
+ surface: route.surface,
39
+ file: route.file,
40
+ render: route.mode,
41
+ hydrate: route.hydrate,
42
+ offline: route.offline,
43
+ budget: { js: route.budgetJs, lcp: route.budgetLcp },
44
+ }));
45
+
46
+ export const routesCommand: CliCommand = {
47
+ spec: {
48
+ name: 'routes',
49
+ summary: 'the route table: path, surface, render mode, hydrate, offline',
50
+ usage: 'x routes [--surface site|app] [--json]',
51
+ requiresApp: true,
52
+ flags: [{ name: 'surface', type: 'string', summary: 'filter by surface' }],
53
+ },
54
+ async run(ctx: CommandContext): Promise<CommandResult> {
55
+ const root = requireAppRoot('routes', ctx.cwd).dir;
56
+ const { findings } = await loadApp(root);
57
+ const surface = flagString(ctx.args, 'surface');
58
+ const routes = describeRoutes().filter(
59
+ (route) => surface === undefined || route.surface === surface,
60
+ );
61
+ return {
62
+ ok: findings.length === 0,
63
+ command: 'routes',
64
+ summary:
65
+ routes.length === 0
66
+ ? msg('cli.routes.empty')
67
+ : msg('cli.routes.count', { count: routes.length }),
68
+ lines: routes.length === 0 ? [] : renderRouteTable(routes).map((line) => ` ${line}`),
69
+ findings,
70
+ data: { routes: routeJson(routes) },
71
+ };
72
+ },
73
+ };
@@ -0,0 +1,151 @@
1
+ // `x tasks [list|show <name>]` — introspect scheduled cron tasks: cadence, timezone, the jobs
2
+ // each enqueues, and real next-occurrence instants from `@ultimat3/time`'s cron math instead of
3
+ // an agent reading `0 3 * * *` and guessing. CLI wiring only; the pure computation lives in
4
+ // `tasks-facts.ts` — the same split `cmd-jobs.ts` makes against `jobs-report.ts`.
5
+
6
+ import { systemClock } from '@ultimat3/core';
7
+ import type { TaskHandle } from '@ultimat3/jobs';
8
+ import type { CronPhrases } from '@ultimat3/time';
9
+ import { loadApp } from './app-load';
10
+ import { requireAppRoot } from './app-root';
11
+ import type { CliCommand, CommandContext } from './command';
12
+ import { BadFlagError, DeclarationUnknownError } from './errors';
13
+ import { msg } from './messages';
14
+ import type { CommandResult, Finding, JsonValue } from './output';
15
+ import { flagString, nearest } from './parse';
16
+ import { renderTable } from './table';
17
+ import {
18
+ findTaskHandle,
19
+ knownTaskNames,
20
+ listTaskFacts,
21
+ parseCountFlag,
22
+ type TaskFact,
23
+ taskShowFacts,
24
+ } from './tasks-facts';
25
+
26
+ const HEADER = ['name', 'cron', 'tz', 'catchUp', 'jobs', 'next'] as const;
27
+
28
+ /**
29
+ * The vocabulary `describeCron` interpolates. The cron *math* stays in `tasks-facts.ts` — it is
30
+ * locale-neutral — but these are words `x tasks show` prints, so they come from the catalog like
31
+ * every other rendered string. `msg()` leaves an un-supplied `{n}`/`{time}`/`{days}`/`{months}`
32
+ * intact, which is what makes each value arrive as the template `describeCron` fills in.
33
+ */
34
+ const cronPhrases = (): CronPhrases => ({
35
+ everyMinute: msg('cli.cron.everyMinute'),
36
+ everyNMinutes: msg('cli.cron.everyNMinutes'),
37
+ everyHour: msg('cli.cron.everyHour'),
38
+ everyNHours: msg('cli.cron.everyNHours'),
39
+ at: msg('cli.cron.at'),
40
+ andMore: msg('cli.cron.andMore'),
41
+ onDaysOfMonth: msg('cli.cron.onDaysOfMonth'),
42
+ onWeekdays: msg('cli.cron.onWeekdays'),
43
+ inMonths: msg('cli.cron.inMonths'),
44
+ everyDay: msg('cli.cron.everyDay'),
45
+ });
46
+
47
+ /** A descriptor/fact is plain JSON by construction — same idiom as `cmd-registries.ts`'s `asJson`. */
48
+ const asJson = (value: object): Record<string, JsonValue> => value as Record<string, JsonValue>;
49
+
50
+ const formatValue = (value: JsonValue): string =>
51
+ typeof value === 'string' ? value : JSON.stringify(value);
52
+
53
+ /** One `key: value` line per top-level field — same idiom as `cmd-registries.ts`'s `detailLines`. */
54
+ const detailLines = (payload: Readonly<Record<string, JsonValue>>): readonly string[] =>
55
+ Object.entries(payload).map(([key, value]) => ` ${key}: ${formatValue(value)}`);
56
+
57
+ const jobsCell = (jobs: readonly string[]): string => (jobs.length === 0 ? '-' : jobs.join(','));
58
+
59
+ const row = (fact: TaskFact): readonly string[] => [
60
+ fact.name,
61
+ fact.cron,
62
+ fact.tz,
63
+ fact.catchUp,
64
+ jobsCell(fact.jobs),
65
+ fact.next,
66
+ ];
67
+
68
+ function runList(nowMs: number, findings: readonly Finding[]): CommandResult {
69
+ const facts = listTaskFacts(nowMs);
70
+ return {
71
+ ok: findings.length === 0,
72
+ command: 'tasks',
73
+ summary: msg('cli.tasks.count', { count: facts.length }),
74
+ lines: facts.length === 0 ? [] : renderTable(HEADER, facts.map(row)).map((line) => ` ${line}`),
75
+ findings,
76
+ data: facts.map((fact) => asJson(fact)),
77
+ };
78
+ }
79
+
80
+ /** Resolves the `show <name>` positional to a handle, or throws — the two failure paths named
81
+ * in the brief: no positional at all, and a positional that names no registered task. */
82
+ function requireHandle(ctx: CommandContext): TaskHandle {
83
+ const name = ctx.args.positionals[0];
84
+ if (name === undefined) {
85
+ throw new BadFlagError({
86
+ flag: 'name',
87
+ command: 'tasks',
88
+ reason: 'x tasks show <name> needs a task name',
89
+ fix: 'x tasks list --json',
90
+ });
91
+ }
92
+ const handle = findTaskHandle(name);
93
+ if (handle !== undefined) return handle;
94
+ const known = knownTaskNames();
95
+ const suggestion = nearest(name, known);
96
+ throw new DeclarationUnknownError(
97
+ suggestion === undefined
98
+ ? { kind: 'tasks', singular: 'task', name, known, verb: 'show' }
99
+ : { kind: 'tasks', singular: 'task', name, known, suggestion, verb: 'show' },
100
+ );
101
+ }
102
+
103
+ function runShow(ctx: CommandContext, nowMs: number, findings: readonly Finding[]): CommandResult {
104
+ const handle = requireHandle(ctx);
105
+ const count = parseCountFlag(flagString(ctx.args, 'count'));
106
+ const { descriptor, describe, upcoming } = taskShowFacts(handle, nowMs, count, cronPhrases());
107
+ const first = upcoming[0];
108
+ const lines = [
109
+ ...detailLines(asJson(descriptor)),
110
+ ` ${describe}`,
111
+ ...upcoming.map((occurrence) => ` ${occurrence.at}`),
112
+ ];
113
+ return {
114
+ ok: findings.length === 0,
115
+ command: 'tasks',
116
+ summary: msg('cli.tasks.shown', {
117
+ name: descriptor.name,
118
+ cron: descriptor.cron,
119
+ tz: descriptor.tz,
120
+ next: first === undefined ? '' : first.at,
121
+ }),
122
+ lines,
123
+ findings,
124
+ data: {
125
+ ...asJson(descriptor),
126
+ describe,
127
+ upcoming: upcoming.map((occurrence) => asJson(occurrence)),
128
+ },
129
+ };
130
+ }
131
+
132
+ export const tasksCommand: CliCommand = {
133
+ spec: {
134
+ name: 'tasks',
135
+ summary: 'cron tasks, their timezone and their next run',
136
+ usage: 'x tasks [list|show <name>] [--count n] [--json]',
137
+ requiresApp: true,
138
+ subcommands: ['list', 'show'],
139
+ flags: [
140
+ { name: 'count', type: 'string', summary: 'show: how many upcoming occurrences to list' },
141
+ ],
142
+ },
143
+ async run(ctx: CommandContext): Promise<CommandResult> {
144
+ const root = requireAppRoot('tasks', ctx.cwd).dir;
145
+ const { findings } = await loadApp(root);
146
+ const nowMs = systemClock.now().getTime();
147
+ return ctx.args.subcommand === 'show'
148
+ ? runShow(ctx, nowMs, findings)
149
+ : runList(nowMs, findings);
150
+ },
151
+ };
@@ -0,0 +1,109 @@
1
+ // `x test`'s command surface: the flags and the one positional it accepts, and the refusals that
2
+ // happen before a single process starts. Which files run is test-select.ts, how they are split and
3
+ // spawned is test-shards.ts — this file only turns argv into their inputs, so a parsing bug can
4
+ // never be read as a sharding one.
5
+
6
+ // Bun ships no CPU-count primitive; `cpus()` is the fallback when navigator cannot answer.
7
+ import { cpus } from 'node:os';
8
+ import type { CliCommand, CommandContext } from './command';
9
+ import { BadFlagError, NoTestFilesError } from './errors';
10
+ import type { CommandResult } from './output';
11
+ import type { ParsedArgs } from './parse';
12
+ import { flagString } from './parse';
13
+ import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
14
+ import { quoteArg, runShards } from './test-shards';
15
+ import type { TestType } from './verify-tests';
16
+ import { TEST_TYPES } from './verify-tests';
17
+
18
+ /** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
19
+ export function availableCpus(): number {
20
+ const hinted = typeof navigator === 'undefined' ? Number.NaN : navigator.hardwareConcurrency;
21
+ return Math.max(1, Number.isFinite(hinted) && hinted > 0 ? Math.trunc(hinted) : cpus().length);
22
+ }
23
+
24
+ function readIndex(args: ParsedArgs, name: string, min: number): number | undefined {
25
+ const raw = flagString(args, name);
26
+ if (raw === undefined) return undefined;
27
+ const value = Number.parseInt(raw, 10);
28
+ if (!Number.isInteger(value) || value < min) {
29
+ throw new BadFlagError({
30
+ flag: name,
31
+ command: 'test',
32
+ reason: `expects an integer >= ${min}, got "${raw}"`,
33
+ });
34
+ }
35
+ return value;
36
+ }
37
+
38
+ /**
39
+ * One positional, and it is the type. `x test contract live` used to run `contract` and drop
40
+ * `live` on the floor, so a caller reading "contract passed" believed two suites had run. A path
41
+ * substring is what `--filter` is for, which is what the fix hands back.
42
+ */
43
+ function readOnlyType(positionals: readonly string[]): TestType | undefined {
44
+ const [first, second] = positionals;
45
+ if (second === undefined) return readType(first);
46
+ const known: readonly string[] = TEST_TYPES;
47
+ const type = first !== undefined && known.includes(first) ? first : TEST_TYPES[0];
48
+ throw new BadFlagError({
49
+ flag: 'type',
50
+ command: 'test',
51
+ reason: `takes at most one test type, got ${positionals.length}: ${positionals.join(' ')}`,
52
+ fix: `x test ${type} --filter ${quoteArg(second)}`,
53
+ });
54
+ }
55
+
56
+ export const testCommand: CliCommand = {
57
+ spec: {
58
+ name: 'test',
59
+ summary:
60
+ 'run one test type — or the whole suite — across N processes, one isolated database per worker',
61
+ usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
62
+ flags: [
63
+ { name: 'workers', type: 'string', summary: 'process count (default: available CPUs)' },
64
+ {
65
+ name: 'worker',
66
+ type: 'string',
67
+ summary: 'rerun only shard I of the same split — reproduces a CI worker failure locally',
68
+ },
69
+ { name: 'filter', type: 'string', summary: 'only files whose path contains this substring' },
70
+ {
71
+ name: 'sample',
72
+ type: 'string',
73
+ summary:
74
+ 'run at most N files of the selected type — a fast signal for the eval loop, never a gate',
75
+ },
76
+ ],
77
+ },
78
+ async run(ctx: CommandContext): Promise<CommandResult> {
79
+ const type = readOnlyType(ctx.args.positionals);
80
+ const filter = flagString(ctx.args, 'filter');
81
+ const sample = readSample(ctx.args);
82
+ const discovered = await discoverTests(ctx.cwd, filter, type);
83
+ if (discovered.length === 0) {
84
+ throw new NoTestFilesError({ root: ctx.cwd, ...missingSelection(type, filter) });
85
+ }
86
+ const files = sample === undefined ? discovered : sampleFiles(discovered, sample);
87
+ const requested = readIndex(ctx.args, 'workers', 1) ?? availableCpus();
88
+ const workers = Math.max(1, Math.min(requested, files.length));
89
+ const only = readIndex(ctx.args, 'worker', 0);
90
+ if (only !== undefined && only >= workers) {
91
+ throw new BadFlagError({
92
+ flag: 'worker',
93
+ command: 'test',
94
+ reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
95
+ });
96
+ }
97
+ return runShards({
98
+ root: ctx.cwd,
99
+ runner: ctx.runner,
100
+ files,
101
+ workers,
102
+ ...(only === undefined ? {} : { only }),
103
+ ...(filter === undefined ? {} : { filter }),
104
+ ...(type === undefined ? {} : { type }),
105
+ // `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
106
+ ...(sample === undefined ? {} : { sample: { kept: files.length, total: discovered.length } }),
107
+ });
108
+ },
109
+ };