@ultimat3/cli 22.2.1 → 22.3.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.
@@ -65,9 +65,7 @@ const prerender =
65
65
  // landed on disk — which is what \`x build --target static --json\` reads back.
66
66
 
67
67
  import { join } from 'node:path';
68
- import { DEFAULT_ORIGIN, type PrerenderReport, prerenderSite } from '@ultimat3/cli';
69
- import { routeEntries } from '@ultimat3/render';
70
- import { buildRobots, buildSitemap, type RouteRecord } from '@ultimat3/seo';
68
+ import { loadSiteSettings, type PrerenderReport, prerenderSite, siteSeo } from '@ultimat3/cli';
71
69
 
72
70
  const root = join(import.meta.dir, '..', '..');
73
71
  const flag = Bun.argv.indexOf('--out');
@@ -79,47 +77,35 @@ const out = (flag === -1 ? undefined : Bun.argv[flag + 1]) ?? join(root, '.x', '
79
77
  const origin = Bun.env.SITE_ORIGIN;
80
78
 
81
79
  /**
82
- * The route table as \`@ultimat3/seo\` reads it. \`RouteRecord\` is a static row and \`defineRoute\`
83
- * is a live declaration, so one has to be projected onto the other — and \`prerenderSite\` has
84
- * already loaded the app by the time this runs, which is what fills \`routeEntries()\`.
80
+ * \`sitemap.xml\` and \`robots.txt\`, into the same directory the HTML went: a static export is
81
+ * served with no process behind it, so the CDN needs the files. \`siteSeo\` is the same answer a
82
+ * running web role serves at \`/sitemap.xml\` and \`/robots.txt\` — the public \`site/\` routes, a
83
+ * page whose \`meta\` says \`robots: { index: false }\` left out — so a crawler reads one sitemap
84
+ * from the CDN and from the container.
85
85
  *
86
- * A DYNAMIC route contributes exactly the URLs this build enumerated for it, read back off the
87
- * report rather than by calling \`prerender()\` a second time: the sitemap then cannot name a page
88
- * the artifact does not contain, which is the only failure mode a sitemap really has.
89
- */
90
- const siteRoutes = (report: PrerenderReport): readonly RouteRecord[] =>
91
- routeEntries()
92
- .filter((entry) => entry.surface === 'site')
93
- .map((entry) => ({
94
- path: entry.path,
95
- file: entry.file,
96
- surface: 'site' as const,
97
- render: entry.config.render,
98
- prerender: () =>
99
- report.pages.filter((page) => page.route === entry.path).map((page) => page.path),
100
- }));
101
-
102
- /**
103
- * \`sitemap.xml\` and \`robots.txt\`, into the same directory the HTML went. Both belong to the
104
- * ARTIFACT rather than to a request: a static export is served with no process behind it, so a
105
- * route that answered them at run time would be a file the CDN never has.
86
+ * A DYNAMIC route contributes exactly the URLs this build emitted for it, read back off the report
87
+ * rather than by calling \`prerender()\` a second time: the sitemap then cannot name a page the
88
+ * artifact does not contain.
106
89
  *
107
- * \`buildRobots\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
90
+ * \`robots.txt\` fails closed — anything that is not \`ULTIMATE_ENV=production\` emits
108
91
  * \`Disallow: /\` and advertises no sitemap — so a preview build cannot outrank the real site.
109
92
  */
110
- async function writeSeoFiles(report: PrerenderReport, baseUrl: string): Promise<readonly string[]> {
111
- const sitemap = await buildSitemap(siteRoutes(report), { baseUrl });
112
- // Past 50,000 URLs \`files\` are \`/sitemap-N.xml\` and the index is \`/sitemap.xml\`; below it,
113
- // \`files\` is that one file and there is no index. Writing both lists covers each case once.
114
- const written = sitemap.index === undefined ? sitemap.files : [sitemap.index, ...sitemap.files];
115
- for (const file of written) await Bun.write(join(out, file.path), file.xml);
116
- await Bun.write(join(out, 'robots.txt'), buildRobots({ baseUrl, sitemaps: ['/sitemap.xml'] }));
117
- return [...written.map((file) => file.path), '/robots.txt'];
93
+ async function writeSeoFiles(report: PrerenderReport): Promise<readonly string[]> {
94
+ const seo = await siteSeo({
95
+ // The origin the pages were built against, so the sitemap and every canonical agree.
96
+ baseUrl: report.origin,
97
+ disallow: (await loadSiteSettings(root)).disallow,
98
+ pagesFor: (route) =>
99
+ report.pages.filter((page) => page.route === route).map((page) => page.path),
100
+ });
101
+ for (const file of seo.sitemaps) await Bun.write(join(out, file.path), file.xml);
102
+ await Bun.write(join(out, 'robots.txt'), seo.robots);
103
+ return [...seo.sitemaps.map((file) => file.path), '/robots.txt'];
118
104
  }
119
105
 
120
106
  if (import.meta.main) {
121
107
  const report = await prerenderSite({ root, out, ...(origin === undefined ? {} : { origin }) });
122
- const seo = await writeSeoFiles(report, origin ?? DEFAULT_ORIGIN);
108
+ const seo = await writeSeoFiles(report);
123
109
  await Bun.stdout.write(
124
110
  \`\${JSON.stringify({ ok: true, out: report.out, emitted: report.pages, skipped: report.skipped, unmeasured: report.unmeasured, report: report.report, seo })}\\n\`,
125
111
  );
@@ -5,7 +5,7 @@
5
5
  import { join } from 'node:path';
6
6
  // Bun ships no equivalent: `join` builds the host-separator path from the scan root to a hit.
7
7
  // Sizing is Bun's own (`Bun.file().size`), so nothing here reaches for `node:fs`.
8
- import { nearestName } from '@ultimat3/core';
8
+ import { nearestName, renderFixShellArg } from '@ultimat3/core';
9
9
  import { BadFlagError } from './errors';
10
10
  import type { ParsedArgs } from './parse';
11
11
  import { flagString } from './parse';
@@ -45,17 +45,21 @@ const IGNORED = ['/dist/', '/build/', '/node_modules/', '/examples/', '/dummy/']
45
45
  * File size stands in for duration: cheap to read, and it correlates far better than file count.
46
46
  * `type`, when given, narrows to exactly the files verify-tests.ts would run for that suite — one
47
47
  * owner per path, decided by `ownerOf` there and never a second time here.
48
+ *
49
+ * `filter` is one substring or several: a path is kept when it contains ANY of them, so an app's
50
+ * scoped runner hands `x test` every affected slice at once instead of one process per slice.
48
51
  */
49
52
  export async function discoverTests(
50
53
  root: string,
51
- filter?: string,
54
+ filter?: string | readonly string[],
52
55
  type?: TestType,
53
56
  ): Promise<readonly TestFile[]> {
57
+ const filters = filter === undefined ? undefined : typeof filter === 'string' ? [filter] : filter;
54
58
  const files: TestFile[] = [];
55
59
  for await (const found of new Bun.Glob(TEST_GLOB).scan({ cwd: root, absolute: false })) {
56
60
  const path = found.split('\\').join('/');
57
61
  if (IGNORED.some((part) => `/${path}`.includes(part))) continue;
58
- if (filter !== undefined && !path.includes(filter)) continue;
62
+ if (filters !== undefined && !filters.some((part) => path.includes(part))) continue;
59
63
  if (type !== undefined && !belongsToType(path, type)) continue;
60
64
  files.push({ path, bytes: Bun.file(join(root, path)).size });
61
65
  }
@@ -134,6 +138,29 @@ export function readSample(args: ParsedArgs): number | undefined {
134
138
  return value;
135
139
  }
136
140
 
141
+ /**
142
+ * `--filter`'s value as the list `discoverTests` matches: comma-separated, each item trimmed. An
143
+ * empty item is refused rather than dropped — `''` is a substring of every path, so `a,,b` would
144
+ * quietly widen a narrowed run back to the whole suite.
145
+ */
146
+ export function readFilters(raw: string | undefined): readonly string[] | undefined {
147
+ if (raw === undefined) return undefined;
148
+ const items = raw.split(',').map((item) => item.trim());
149
+ if (items.some((item) => item === '')) {
150
+ const kept = items.filter((item) => item !== '');
151
+ throw new BadFlagError({
152
+ flag: 'filter',
153
+ command: 'test',
154
+ reason: `"${raw}" holds an empty path, and an empty substring matches every test file`,
155
+ fix:
156
+ kept.length === 0
157
+ ? 'x test --json'
158
+ : `x test --filter ${renderFixShellArg(kept.join(','), '<path,path>')}`,
159
+ });
160
+ }
161
+ return items;
162
+ }
163
+
137
164
  /** The selection, as `NoTestFilesError` wants it: only the parts the caller actually asked for. */
138
165
  export function missingSelection(
139
166
  type: TestType | undefined,
@@ -3,8 +3,9 @@
3
3
  // suite two different ways, and the `--worker N` reproduction a shard failure prints would then
4
4
  // name a shard the gate never ran.
5
5
 
6
- // Bun ships no CPU-count primitive; `cpus()` is the fallback when navigator cannot answer.
7
- import { cpus } from 'node:os';
6
+ // Bun ships no CPU-count or free-memory primitive: `cpus()` is the fallback when navigator cannot
7
+ // answer, and `freemem()` is the only reader of available memory.
8
+ import { cpus, freemem } from 'node:os';
8
9
  import type { TestType } from '@ultimat3/testing';
9
10
 
10
11
  /** navigator first: it is the runtime's own answer, and it respects a container's CPU limit. */
@@ -14,7 +15,7 @@ export function availableCpus(): number {
14
15
  }
15
16
 
16
17
  /**
17
- * Deliberately MORE workers than cores, with a ceiling.
18
+ * Deliberately MORE workers than cores, bounded by memory rather than by a fixed count.
18
19
  *
19
20
  * `cpus - 1` is the intuitive default and it was measured to be worthless exactly where it has to
20
21
  * pay off. On a 4-core `ubuntu-latest` — the runner this repo commits to — the `unit` step:
@@ -32,23 +33,62 @@ export function availableCpus(): number {
32
33
  * resolution, on `--isolate` rebuilding a registry per file, and on waiting for its database.
33
34
  * Oversubscribing fills those stalls.
34
35
  *
35
- * The ceiling is memory, not cores. A worker is a whole Bun process with the framework's module
36
- * graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite, so
37
- * width costs hundreds of MB per step. It binds on a developer's 12- or 32-core machine, which is
38
- * exactly where an unbounded count would swap.
36
+ * The bound is memory, not cores. A worker is a whole Bun process with the framework's module
37
+ * graph loaded and — in the typed suites — its own cloned Postgres or an in-process PGlite. Until
38
+ * 22.3 that was a FIXED ceiling of 8, which held a 12-core box to 8 workers with 30 GB free: the
39
+ * notificado.co `unit` step (381 files) sat at 51s on 8 workers against a 90s gate budget. The
40
+ * ceiling is now what the machine can actually hold — `os.freemem()` (MemAvailable on Linux, so
41
+ * reclaimable page cache counts as free) divided by `WORKER_BYTES` — which still binds on a small
42
+ * CI runner and on a loaded laptop, the two places an unbounded count would swap.
39
43
  *
40
44
  * The floor of 2 keeps a 1-core box sharding rather than silently reverting to serial.
41
45
  */
42
- export const WORKER_CEILING = 8;
43
-
44
- /** Oversubscription factor. See the table above — it is measured, not chosen for roundness. */
45
46
  export const WORKER_OVERSUBSCRIBE = 1.5;
46
47
 
47
48
  /** The floor the paragraph above names: a 1-core box shards rather than reverting to serial. */
48
49
  export const WORKER_FLOOR = 2;
49
50
 
50
- export const defaultWorkers = (available: number = availableCpus()): number =>
51
- Math.max(WORKER_FLOOR, Math.min(WORKER_CEILING, Math.round(available * WORKER_OVERSUBSCRIBE)));
51
+ /**
52
+ * What one test worker is budgeted at, for the memory bound above. MEASURED — peak RSS of the whole
53
+ * `x test unit` process tree on the notificado.co corpus (381 files, PGlite per worker), 12-core
54
+ * box, `As of 2026-09-25`:
55
+ *
56
+ * | workers | peak tree RSS |
57
+ * |---------|---------------|
58
+ * | 8 | 20.7 GB |
59
+ * | 12 | 22.5 GB |
60
+ * | 16 | 24.3 GB |
61
+ *
62
+ * The MARGINAL worker costs ~0.45 GB (the slope); the ~17 GB intercept is the corpus itself —
63
+ * every file's module graph and database, retained per worker — and does not shrink with fewer
64
+ * workers, so it is not this bound's to budget. The constant is the slope doubled and rounded to a
65
+ * power of two, because free memory is read once, before a single worker has started.
66
+ */
67
+ export const WORKER_BYTES = 1024 * 1024 * 1024;
68
+
69
+ /**
70
+ * The most `--workers` accepts, on either command. Not a default and not a memory rule — a sanity
71
+ * bound: without one `--workers 5000` parsed, the run clamped only to the file count, and it
72
+ * started one Bun process per test FILE. An explicit width below it is the caller's call.
73
+ */
74
+ export const WORKER_CEILING = 64;
75
+
76
+ /** Bun ships no memory primitive; `freemem()` is libuv's MemAvailable on Linux. */
77
+ export const availableMemory = (): number => freemem();
78
+
79
+ /**
80
+ * `ceil(cpus x 1.5)`, held to what free memory can carry and never below the floor. The file-count
81
+ * clamp is `test-passes.ts`'s (every pass is clamped to its own file list), because only the
82
+ * caller knows the selection.
83
+ */
84
+ export const defaultWorkers = (
85
+ available: number = availableCpus(),
86
+ freeBytes: number = availableMemory(),
87
+ ): number => {
88
+ const byCpu = Math.ceil(available * WORKER_OVERSUBSCRIBE);
89
+ const byMemory = Math.floor(freeBytes / WORKER_BYTES);
90
+ return Math.max(WORKER_FLOOR, Math.min(byCpu, byMemory, WORKER_CEILING));
91
+ };
52
92
 
53
93
  /**
54
94
  * Which types run across worker processes, and why the other two cannot.
package/src/verify-run.ts CHANGED
@@ -17,16 +17,18 @@ import type { StepOutcome, VerifyContext, VerifyStep } from './verify-step';
17
17
  * Run every step, never bailing early: an agent fixing three things at once needs all
18
18
  * three findings from one run, not one per round-trip.
19
19
  *
20
- * `ctx.only` narrows the list to one step. The narrowing lives HERE rather than in `cmd-verify.ts`
21
- * so that every caller of the runner — the command, `x build`, the MCP host — gets the banner and
22
- * the `--json` flag with it, instead of one of them filtering a list quietly.
20
+ * `ctx.only` narrows the list to the steps it names, kept in declared order. The narrowing lives
21
+ * HERE rather than in `cmd-verify.ts` so that every caller of the runner — the command, `x build`,
22
+ * the MCP host — gets the banner and the `--json` flag with it, instead of one of them filtering a
23
+ * list quietly.
23
24
  */
24
25
  export async function runVerify(
25
26
  steps: readonly VerifyStep[],
26
27
  ctx: VerifyContext,
27
28
  ): Promise<CommandResult> {
28
29
  const floor = await readVerifyFloor(ctx.root);
29
- const selected = ctx.only === undefined ? steps : steps.filter((step) => step.name === ctx.only);
30
+ const only = onlyList(ctx.only);
31
+ const selected = only === undefined ? steps : steps.filter((step) => only.includes(step.name));
30
32
  const byName = new Map<string, StepResult>();
31
33
  const began = performance.now();
32
34
  // The static steps wait for the serial suites and then run BESIDE them — only when `live` is in
@@ -75,7 +77,7 @@ export async function runVerify(
75
77
  // Rendered through the catalog like every other summary this file emits; the machine marker
76
78
  // is `data.notAGateRun` below. It was a bare `NOT A GATE RUN` constant, which put one
77
79
  // user-facing string outside `messages.ts` for a fact `--json` was already carrying twice.
78
- summary: ctx.only === undefined ? summary : msg('cli.verify.notAGateRun', { summary }),
80
+ summary: only === undefined ? summary : msg('cli.verify.notAGateRun', { summary }),
79
81
  steps: results,
80
82
  // `skipped` is a list beside `failed` and not a count, because the two answer the same kind of
81
83
  // question — *which* steps, not how many — and a caller ratcheting on coverage needs the names.
@@ -85,13 +87,18 @@ export async function runVerify(
85
87
  durationMs: totalMs,
86
88
  // A BOOLEAN beside the banner, so a reader of `--json` never has to substring-match a
87
89
  // summary line to learn that this run checked one thing.
88
- ...(ctx.only === undefined ? {} : { notAGateRun: true, only: ctx.only }),
90
+ // `only` is always the LIST, in declared order — one name is a list of one.
91
+ ...(only === undefined ? {} : { notAGateRun: true, only: [...only] }),
89
92
  },
90
93
  // The step's own status: one step, so `failedSteps` is that step and nothing else.
91
94
  exitCode: failedSteps.length === 0 ? 0 : 1,
92
95
  };
93
96
  }
94
97
 
98
+ /** One name or several, as one list — the context accepts both, every reader here wants a list. */
99
+ const onlyList = (only: VerifyContext['only']): readonly string[] | undefined =>
100
+ only === undefined ? undefined : typeof only === 'string' ? [only] : only;
101
+
95
102
  /**
96
103
  * What the counts are allowed to claim. A step that does not apply is recorded green so the run
97
104
  * continues, and the summary counted it among the "all 17 steps passed" — so a repo whose `job`
@@ -79,15 +79,18 @@ export interface VerifyContext {
79
79
  */
80
80
  readonly workers?: number;
81
81
  /**
82
- * ONE step, by name — an iteration loop, and the one thing here that IS a narrowing. The
82
+ * Some steps, by name — an iteration loop, and the one thing here that IS a narrowing. The
83
83
  * whole gate costs minutes (3m19s at the framework root on 12 cores, `As of 2026-09-23`), which
84
- * is the cost of asking a question about one step. It does not weaken axiom 5, and the two rules that keep it honest are
85
- * mechanical rather than remembered: a run with this set prints `NOT A GATE RUN` in the summary
86
- * AND carries `notAGateRun` in `--json` (`verify-run.ts`), so no reader of either can mistake it
87
- * for the gate; and nothing writes `x.verify.json`, so the suite floor cannot be lowered by a
88
- * run that never executed the suites. Green still means the no-flag run, unchanged.
84
+ * is the cost of asking a question about one step. A list (`x verify --only typecheck,lint`) runs
85
+ * in ONE process, so an app's scoped runner stops paying a CLI boot and an app load per step.
86
+ * It does not weaken axiom 5, and the two rules that keep it honest are mechanical rather than
87
+ * remembered: a run with this set prints `NOT A GATE RUN` in the summary AND carries `notAGateRun`
88
+ * in `--json` (`verify-run.ts`), so no reader of either can mistake it for the gate; and nothing
89
+ * writes `x.verify.json`, so the suite floor cannot be lowered by a run that never executed the
90
+ * suites — even a list naming every step. Green still means the no-flag run, unchanged. A single
91
+ * name is accepted for the callers that narrow to one (`scripts/verify.ts`).
89
92
  */
90
- readonly only?: VerifyStepName;
93
+ readonly only?: VerifyStepName | readonly VerifyStepName[];
91
94
  }
92
95
 
93
96
  export interface StepOutcome {