@ultimat3/cli 6.0.0 → 8.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 (91) hide show
  1. package/CLAUDE.md +65 -5
  2. package/README.md +8 -3
  3. package/package.json +25 -24
  4. package/src/affected.ts +320 -0
  5. package/src/app-boundaries.ts +55 -5
  6. package/src/bin.ts +6 -3
  7. package/src/browser-launcher.ts +109 -0
  8. package/src/ci-log.ts +0 -0
  9. package/src/ci-runs.ts +179 -0
  10. package/src/cmd-affected.ts +109 -0
  11. package/src/cmd-build.ts +29 -3
  12. package/src/cmd-ci.ts +273 -0
  13. package/src/cmd-db-backfill.ts +240 -0
  14. package/src/cmd-db-branch.ts +3 -2
  15. package/src/cmd-db.ts +35 -156
  16. package/src/cmd-deploy.ts +37 -3
  17. package/src/cmd-dev.ts +7 -1
  18. package/src/cmd-errors.ts +2 -3
  19. package/src/cmd-fix.ts +3 -3
  20. package/src/cmd-i18n.ts +67 -5
  21. package/src/cmd-jobs.ts +27 -4
  22. package/src/cmd-mcp.ts +18 -9
  23. package/src/cmd-new.ts +91 -4
  24. package/src/cmd-policy.ts +3 -2
  25. package/src/cmd-pr.ts +359 -0
  26. package/src/cmd-registries.ts +3 -2
  27. package/src/cmd-shot.ts +382 -0
  28. package/src/cmd-tasks.ts +9 -4
  29. package/src/cmd-test.ts +96 -7
  30. package/src/cmd-verify.ts +47 -6
  31. package/src/dev-cache.ts +1 -1
  32. package/src/dev-lock.ts +124 -12
  33. package/src/dev-queue.ts +12 -7
  34. package/src/dev-replicator.ts +3 -7
  35. package/src/dev-roles-fixture.ts +1 -1
  36. package/src/dev-roles.ts +40 -8
  37. package/src/dev-runtime.ts +96 -4
  38. package/src/dev-sync.ts +9 -4
  39. package/src/dispatch.ts +35 -5
  40. package/src/drift.ts +52 -7
  41. package/src/error-codes.ts +21 -0
  42. package/src/framework-scope.ts +57 -5
  43. package/src/generate-kinds.ts +19 -1
  44. package/src/gh-target.ts +118 -0
  45. package/src/gh.ts +204 -0
  46. package/src/i18n-registration.ts +67 -4
  47. package/src/index.ts +38 -1
  48. package/src/island-bundle.ts +62 -3
  49. package/src/island-solid-production.ts +129 -0
  50. package/src/island-styles.ts +41 -0
  51. package/src/jobs-report.ts +10 -13
  52. package/src/mcp-errors.ts +12 -0
  53. package/src/messages.ts +76 -0
  54. package/src/output.ts +22 -2
  55. package/src/parse.ts +81 -37
  56. package/src/pr-threads.ts +291 -0
  57. package/src/prerender.ts +52 -10
  58. package/src/realtime-browser-probe-fixture.ts +9 -0
  59. package/src/registry.ts +8 -0
  60. package/src/runtime-overrides.ts +11 -3
  61. package/src/shot-settle.ts +57 -0
  62. package/src/shot-verdict.ts +360 -0
  63. package/src/static-report.ts +219 -0
  64. package/src/sync-authenticator.ts +86 -14
  65. package/src/templates/guard-bare-error.ts +122 -0
  66. package/src/templates/guard-raw-colour.ts +138 -0
  67. package/src/templates/guard-untranslated-string.ts +138 -0
  68. package/src/templates/guard-unzoned-date.ts +142 -0
  69. package/src/templates/index.ts +4 -0
  70. package/src/templates/island-fixture.ts +76 -0
  71. package/src/templates/island.ts +130 -18
  72. package/src/templates/resource-form-island.ts +279 -0
  73. package/src/templates/resource.ts +20 -41
  74. package/src/templates/route.ts +15 -2
  75. package/src/templates/scaffold-app.ts +13 -78
  76. package/src/templates/scaffold-container.ts +30 -4
  77. package/src/templates/scaffold-db-package.ts +46 -7
  78. package/src/templates/scaffold-docs.ts +24 -13
  79. package/src/templates/scaffold-entries.ts +131 -0
  80. package/src/templates/scaffold-guards.ts +26 -0
  81. package/src/templates/scaffold-mcp-package.ts +35 -2
  82. package/src/templates/scaffold-package-shape.ts +7 -2
  83. package/src/templates/scaffold-repo.ts +37 -6
  84. package/src/test-select.ts +4 -3
  85. package/src/test-shards.ts +19 -3
  86. package/src/verify-checks.ts +11 -1
  87. package/src/verify-run.ts +25 -3
  88. package/src/verify-step.ts +11 -2
  89. package/src/verify-tests.ts +11 -3
  90. package/src/workspace-graph.ts +241 -0
  91. package/src/write-line.ts +23 -5
@@ -0,0 +1,109 @@
1
+ // The app's own browser library, resolved from the app's own `node_modules` — never a dependency
2
+ // of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
3
+ // precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
4
+ // command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { UltimateError } from '@ultimat3/core';
8
+ import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
9
+ import { localBrowser } from '@ultimat3/scraping';
10
+ import { docsFor } from './error-codes';
11
+
12
+ /**
13
+ * The one library this works against. Playwright is not an alternative and is not a flag:
14
+ * `packages/scraping/src/cdp-port.ts` records that its `connectOverCDP` cannot perform the
15
+ * WebSocket upgrade under Bun (oven-sh/bun#9911), verified against puppeteer-core 25.8.0.
16
+ */
17
+ export const BROWSER_PACKAGE = 'puppeteer-core';
18
+
19
+ /** Where a browser binary is named when the flag does not name one. Read in this order. */
20
+ export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
21
+
22
+ /**
23
+ * A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
24
+ * shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
25
+ * same install either way, because both are answered by putting the real package in the app.
26
+ */
27
+ export class ShotBrowserMissingError extends UltimateError {
28
+ constructor(input: { root: string; detail: string }) {
29
+ super({
30
+ code: 'X_SHOT_BROWSER_MISSING',
31
+ cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
32
+ // One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
33
+ // one literal, and a fix line the gate cannot read is a fix line nothing holds to the
34
+ // contract. `browser-launcher.test.ts` pins it against the constant instead.
35
+ fix: 'bun add -d puppeteer-core',
36
+ docs: docsFor('X_SHOT_BROWSER_MISSING'),
37
+ meta: { root: input.root, package: BROWSER_PACKAGE },
38
+ });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
44
+ * transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
45
+ * question decides — is there a `launch` to call?
46
+ */
47
+ const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
48
+ if (typeof module !== 'object' || module === null) return undefined;
49
+ const candidate = module as { launch?: unknown; default?: unknown };
50
+ if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
51
+ // `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
52
+ // refused rather than followed: one unbounded recursion here is a stack overflow instead of the
53
+ // instruction this whole function exists to produce.
54
+ if (candidate.default === undefined || candidate.default === candidate) return undefined;
55
+ return launcherIn(candidate.default);
56
+ };
57
+
58
+ export interface AppBrowserOptions {
59
+ readonly root: string;
60
+ /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
61
+ readonly executablePath?: string | undefined;
62
+ /** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
63
+ readonly resolve?: (specifier: string, from: string) => string;
64
+ readonly load?: (path: string) => Promise<unknown>;
65
+ }
66
+
67
+ /** The path a run will launch, or `undefined` for "let the library find its own". */
68
+ export const executablePathFrom = (
69
+ flag: string | undefined,
70
+ env: Readonly<Record<string, string | undefined>>,
71
+ ): string | undefined => {
72
+ if (flag !== undefined && flag.length > 0) return flag;
73
+ for (const name of BROWSER_PATH_VARS) {
74
+ const value = env[name];
75
+ if (value !== undefined && value.length > 0) return value;
76
+ }
77
+ return undefined;
78
+ };
79
+
80
+ /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
81
+ export const browserBinaryExists = (path: string): boolean => existsSync(path);
82
+
83
+ /**
84
+ * The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
85
+ * this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
86
+ * no such dependency, and would answer "missing" for an app that installed it correctly.
87
+ */
88
+ export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
89
+ const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
90
+ const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
91
+ let entry: string;
92
+ try {
93
+ entry = resolve(BROWSER_PACKAGE, options.root);
94
+ } catch {
95
+ throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
96
+ }
97
+ const launcher = launcherIn(await load(entry));
98
+ if (launcher === undefined) {
99
+ throw new ShotBrowserMissingError({
100
+ root: options.root,
101
+ detail: `resolved to ${entry}, which exports no launch()`,
102
+ });
103
+ }
104
+ return localBrowser({
105
+ launcher,
106
+ headless: true,
107
+ ...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
108
+ });
109
+ }
package/src/ci-log.ts ADDED
Binary file
package/src/ci-runs.ts ADDED
@@ -0,0 +1,179 @@
1
+ // Which workflow runs `x ci` is about, and the three `gh run` calls that answer it. Selection
2
+ // lives here rather than in the command so "the latest run of each workflow on this branch" is one
3
+ // testable function over plain rows instead of a shape only a subprocess can produce.
4
+
5
+ import { t } from '@ultimat3/schema';
6
+ import type { GhHost } from './gh';
7
+ import { ghJson, runGh } from './gh';
8
+ import type { GhRepo } from './gh-target';
9
+
10
+ /** The fields both `gh run list` and `gh run view` accept, named once so the two agree. */
11
+ export const RUN_FIELDS =
12
+ 'databaseId,status,conclusion,headBranch,displayTitle,workflowName,url,createdAt';
13
+
14
+ const RUN_ROW = t.object({
15
+ databaseId: t.number,
16
+ status: t.string,
17
+ conclusion: t.nullable(t.string),
18
+ headBranch: t.string,
19
+ displayTitle: t.string,
20
+ workflowName: t.string,
21
+ url: t.string,
22
+ createdAt: t.string,
23
+ });
24
+
25
+ const STEP = t.object({ name: t.string, conclusion: t.nullable(t.string), number: t.number });
26
+
27
+ const JOB = t.object({
28
+ name: t.string,
29
+ status: t.string,
30
+ conclusion: t.nullable(t.string),
31
+ url: t.string,
32
+ steps: t.array(STEP),
33
+ });
34
+
35
+ const RUN_LIST = t.array(RUN_ROW);
36
+ const RUN_VIEW = RUN_ROW.extend({ jobs: t.array(JOB) });
37
+
38
+ export interface CiStep {
39
+ readonly name: string;
40
+ readonly conclusion: string | null;
41
+ readonly number: number;
42
+ }
43
+
44
+ export interface CiJob {
45
+ readonly name: string;
46
+ readonly status: string;
47
+ readonly conclusion: string | null;
48
+ readonly url: string;
49
+ readonly steps: readonly CiStep[];
50
+ }
51
+
52
+ export interface CiRun {
53
+ readonly id: number;
54
+ readonly status: string;
55
+ readonly conclusion: string | null;
56
+ readonly branch: string;
57
+ readonly title: string;
58
+ readonly workflow: string;
59
+ readonly url: string;
60
+ readonly createdAt: string;
61
+ }
62
+
63
+ /**
64
+ * The conclusions that mean "this run is not green". `cancelled` and `timed_out` are in it because
65
+ * a cancelled run has told the reader nothing about their change, and a command that reported it
66
+ * as passing would be a green verdict over an unanswered question. `skipped` is not: a workflow
67
+ * whose conditions did not match was never asked.
68
+ */
69
+ export const FAILED_CONCLUSIONS: readonly string[] = [
70
+ 'failure',
71
+ 'cancelled',
72
+ 'timed_out',
73
+ 'startup_failure',
74
+ 'action_required',
75
+ 'stale',
76
+ ];
77
+
78
+ export const isFailed = (run: CiRun): boolean =>
79
+ run.conclusion !== null && FAILED_CONCLUSIONS.includes(run.conclusion);
80
+
81
+ export const isRunning = (run: CiRun): boolean => run.status !== 'completed';
82
+
83
+ interface RunRow {
84
+ readonly databaseId: number;
85
+ readonly status: string;
86
+ readonly conclusion: string | null;
87
+ readonly headBranch: string;
88
+ readonly displayTitle: string;
89
+ readonly workflowName: string;
90
+ readonly url: string;
91
+ readonly createdAt: string;
92
+ }
93
+
94
+ const runOf = (row: RunRow): CiRun => ({
95
+ id: row.databaseId,
96
+ status: row.status,
97
+ conclusion: row.conclusion,
98
+ branch: row.headBranch,
99
+ title: row.displayTitle,
100
+ workflow: row.workflowName,
101
+ url: row.url,
102
+ createdAt: row.createdAt,
103
+ });
104
+
105
+ /**
106
+ * The newest run of EACH workflow, which is the honest answer to "is CI green on this branch".
107
+ * Taking the newest run overall reports whichever workflow happened to finish last — this repo
108
+ * runs `ci` and `deploy-social-demo` off the same push, so half the time that is a verdict about
109
+ * a workflow the caller was not asking about.
110
+ */
111
+ export function latestPerWorkflow(runs: readonly CiRun[]): readonly CiRun[] {
112
+ const newest = new Map<string, CiRun>();
113
+ for (const run of runs) {
114
+ const held = newest.get(run.workflow);
115
+ if (held === undefined || run.createdAt > held.createdAt) newest.set(run.workflow, run);
116
+ }
117
+ return [...newest.values()];
118
+ }
119
+
120
+ export async function listRuns(
121
+ host: GhHost,
122
+ repo: GhRepo,
123
+ branch: string,
124
+ limit: number,
125
+ ): Promise<readonly CiRun[]> {
126
+ const rows = await ghJson(
127
+ host,
128
+ [
129
+ 'run',
130
+ 'list',
131
+ '--repo',
132
+ repo.slug,
133
+ '--branch',
134
+ branch,
135
+ '--limit',
136
+ String(limit),
137
+ '--json',
138
+ RUN_FIELDS,
139
+ ],
140
+ RUN_LIST,
141
+ {
142
+ label: `gh run list --branch ${branch}`,
143
+ fix: `x ci --branch ${branch} --repo ${repo.slug} --json`,
144
+ },
145
+ );
146
+ return rows.map(runOf);
147
+ }
148
+
149
+ /** One run, with its jobs — `gh run view --json` carries both, so this is one round trip. */
150
+ export async function viewRun(
151
+ host: GhHost,
152
+ repo: GhRepo,
153
+ id: number,
154
+ ): Promise<{ readonly run: CiRun; readonly jobs: readonly CiJob[] }> {
155
+ const viewed = await ghJson(
156
+ host,
157
+ ['run', 'view', String(id), '--repo', repo.slug, '--json', `${RUN_FIELDS},jobs`],
158
+ RUN_VIEW,
159
+ { label: `gh run view ${id}`, fix: `x ci --run ${id} --repo ${repo.slug} --json` },
160
+ );
161
+ return { run: runOf(viewed), jobs: viewed.jobs };
162
+ }
163
+
164
+ /**
165
+ * The failed steps' log, and only those. `--log` is the whole run — setup, caches, every green
166
+ * step — where `--log-failed` is the part a triage starts from, which is the difference between
167
+ * one command and three.
168
+ */
169
+ export async function failedLog(host: GhHost, repo: GhRepo, id: number): Promise<string> {
170
+ const result = await runGh(
171
+ host,
172
+ ['run', 'view', String(id), '--repo', repo.slug, '--log-failed'],
173
+ {
174
+ label: `gh run view ${id} --log-failed`,
175
+ fix: `gh run view ${id} --repo ${repo.slug} --log # the whole log, when the failed steps carry none`,
176
+ },
177
+ );
178
+ return result.stdout;
179
+ }
@@ -0,0 +1,109 @@
1
+ // `x affected` — the workspaces a diff forces a re-test of, closed transitively over the workspace
2
+ // graph. `x verify` runs everything, which is right for a gate and wrong for the loop an agent
3
+ // iterates in; without this command that agent invents its own scoping, and an invented one that
4
+ // misses a transitive dependent is a green checkmark on a broken repo.
5
+ //
6
+ // CLI wiring only. What a diff touches is `affected.ts`, what the workspaces are is
7
+ // `workspace-graph.ts` — the `cmd-jobs.ts` / `jobs-report.ts` split, repeated.
8
+
9
+ import { affectedScope, DEFAULT_BASE } from './affected';
10
+ import type { CliCommand, CommandContext } from './command';
11
+ import { msg } from './messages';
12
+ import type { CommandResult, JsonValue } from './output';
13
+ import { flagBool } from './parse';
14
+ import { renderTable } from './table';
15
+
16
+ const HEADER = ['workspace', 'dir'] as const;
17
+
18
+ /**
19
+ * Every catalog row the affected surface renders — this command's four and the one `x test
20
+ * --affected` prints when nothing is affected. One list, because the two commands are one surface
21
+ * and a per-file list would leave whichever half nobody thought about unchecked.
22
+ *
23
+ * The rule it exists for: `msg()` answers `⟦key⟧` for a key the catalog lacks, which is loud in the
24
+ * terminal and completely silent to a build, so a command can ship a summary no locale renders.
25
+ * `cmd-affected.test.ts` holds `messages.ts` to this list.
26
+ */
27
+ export const AFFECTED_MESSAGE_KEYS = [
28
+ 'cli.affected.count',
29
+ 'cli.affected.none',
30
+ 'cli.affected.rootWide',
31
+ 'cli.affected.dirty',
32
+ 'cli.test.affected.none',
33
+ ] as const;
34
+
35
+ export const affectedCommand: CliCommand = {
36
+ spec: {
37
+ name: 'affected',
38
+ summary: 'the workspaces a diff touches, and every workspace that depends on one of them',
39
+ usage: 'x affected [--base <ref>] [--dirty] [--paths] [--json]',
40
+ flags: [
41
+ {
42
+ name: 'base',
43
+ type: 'string',
44
+ summary: `git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
45
+ },
46
+ {
47
+ name: 'dirty',
48
+ type: 'boolean',
49
+ summary: 'also count uncommitted work — every agent sharing this checkout, not only yours',
50
+ },
51
+ { name: 'paths', type: 'boolean', summary: 'print bare directories instead of a table' },
52
+ ],
53
+ },
54
+ async run(ctx: CommandContext): Promise<CommandResult> {
55
+ // The one resolver `x test --affected` narrows with, so this command reports exactly what that
56
+ // one runs. It reads `--base` before git is spawned (a malformed ref must not cost a
57
+ // subprocess) and takes the diff at the CHECKOUT root, which is what git prints paths against.
58
+ const { selection, root, plan } = await affectedScope({
59
+ runner: ctx.runner,
60
+ cwd: ctx.cwd,
61
+ args: ctx.args,
62
+ command: 'affected',
63
+ });
64
+ const params = { base: selection.base, changed: plan.changed.length };
65
+ const table = renderTable(
66
+ HEADER,
67
+ plan.workspaces.map((workspace) => [workspace.name, workspace.dir]),
68
+ ).map((line) => ` ${line}`);
69
+ const data: JsonValue = {
70
+ base: selection.base,
71
+ dirty: selection.dirty,
72
+ root,
73
+ changed: [...plan.changed],
74
+ ignored: [...plan.ignored],
75
+ rootWide: [...plan.rootWide],
76
+ workspaces: plan.workspaces.map((workspace) => ({
77
+ name: workspace.name,
78
+ dir: workspace.dir,
79
+ })),
80
+ paths: plan.workspaces.map((workspace) => workspace.dir),
81
+ };
82
+ return {
83
+ // An empty answer is a fact, not a failure: a `.md`-only diff genuinely re-checks nothing,
84
+ // and reporting it red would fail a build for editing a doc. The count is in the summary and
85
+ // `data.changed`/`data.ignored` say what was looked at, so "green because nothing is
86
+ // affected" is never mistaken for "green because everything passed".
87
+ ok: true,
88
+ command: 'affected',
89
+ summary:
90
+ plan.workspaces.length === 0
91
+ ? msg('cli.affected.none', params)
92
+ : msg('cli.affected.count', { ...params, count: plan.workspaces.length }),
93
+ lines: [
94
+ ...(selection.dirty ? [msg('cli.affected.dirty')] : []),
95
+ ...(plan.rootWide.length === 0
96
+ ? []
97
+ : [msg('cli.affected.rootWide', { files: plan.rootWide.join(', ') })]),
98
+ ...(plan.workspaces.length === 0
99
+ ? []
100
+ : // `--paths` changes only what the human sees; `--json` carries both projections on
101
+ // every run, so the two renderers can never state different sets.
102
+ flagBool(ctx.args, 'paths')
103
+ ? plan.workspaces.map((workspace) => workspace.dir)
104
+ : table),
105
+ ],
106
+ data,
107
+ };
108
+ },
109
+ };
package/src/cmd-build.ts CHANGED
@@ -14,6 +14,13 @@ import { execOutput } from './exec';
14
14
  import { msg } from './messages';
15
15
  import type { CommandResult } from './output';
16
16
  import { flagString } from './parse';
17
+ import type { StaticReport } from './static-report';
18
+ import {
19
+ readStaticReport,
20
+ removeStaticReport,
21
+ renderStaticReport,
22
+ staticReportData,
23
+ } from './static-report';
17
24
 
18
25
  export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
19
26
 
@@ -111,14 +118,19 @@ export function preflightResult(verify: CommandResult): CommandResult {
111
118
  * `✗ built docker`; and the builder's own logs went only into `lines`, which is declared human-only
112
119
  * and which `renderJson` drops — so CI, which runs `--json`, got the exit code and nothing to act
113
120
  * on. The output now rides in `data` and `lines` renders that same string.
121
+ *
122
+ * `report` is the static target's inventory, and BOTH renderers get it: `--json` is the house rule,
123
+ * but an agent reading terminal output is the primary developer here, so a silent human path is the
124
+ * same defect in a different costume. `lines` still carries nothing `data` does not (#242).
114
125
  */
115
126
  export function buildResult(input: {
116
127
  readonly target: BuildTarget;
117
128
  readonly artifact: string;
118
129
  readonly command: readonly string[];
119
130
  readonly result: ExecResult;
131
+ readonly report?: StaticReport;
120
132
  }): CommandResult {
121
- const { result, target } = input;
133
+ const { report, result, target } = input;
122
134
  const output = result.ok ? '' : execOutput(result);
123
135
  return {
124
136
  ok: result.ok,
@@ -139,8 +151,13 @@ export function buildResult(input: {
139
151
  artifact: input.artifact,
140
152
  durationMs: result.durationMs,
141
153
  ...(result.ok ? {} : { output }),
154
+ ...staticReportData(report),
142
155
  },
143
- lines: result.ok ? [] : output.split('\n'),
156
+ lines: result.ok
157
+ ? report === undefined
158
+ ? []
159
+ : renderStaticReport(report)
160
+ : output.split('\n'),
144
161
  };
145
162
  }
146
163
 
@@ -178,11 +195,20 @@ export const buildCommand: CliCommand = {
178
195
  flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
179
196
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
180
197
  const command = argsFor(target, { root, tag, out });
198
+ // Removed BEFORE the builder runs, so a build that writes no inventory can never be reported
199
+ // with the last one's: a stale emitted list is worse than none, because it reads as this run's.
200
+ if (target === 'static') await removeStaticReport(root);
201
+ const result = await ctx.runner(command, { cwd: root });
202
+ // `prerenderSite` writes it; an app whose `apps/web/prerender.ts` does not call that writes no
203
+ // `.x/build-stats.json` either, and `x verify`'s `budgets` step already reds that app with
204
+ // `X_BUDGET_UNMEASURED` — so the absence needs no second code here.
205
+ const report = target === 'static' && result.ok ? await readStaticReport(root) : undefined;
181
206
  return buildResult({
182
207
  target,
183
208
  artifact: target === 'docker' ? tag : out,
184
209
  command,
185
- result: await ctx.runner(command, { cwd: root }),
210
+ result,
211
+ ...(report === undefined ? {} : { report }),
186
212
  });
187
213
  },
188
214
  };