@ultimat3/cli 5.0.1 → 7.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 (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. package/types/babel-modules.d.ts +31 -0
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
@@ -7,12 +7,20 @@ import { frameworkVersion, VERSION_DEFINE } from '@ultimat3/core';
7
7
  import { requireAppRoot } from './app-root';
8
8
  import { runVerify } from './cmd-verify';
9
9
  import type { CliCommand, CommandContext } from './command';
10
+ import { externalArgs } from './compile-externals';
10
11
  import { BuildEntryMissingError, UnknownCommandError } from './errors';
11
12
  import type { ExecResult } from './exec';
12
13
  import { execOutput } from './exec';
13
14
  import { msg } from './messages';
14
15
  import type { CommandResult } from './output';
15
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';
16
24
 
17
25
  export const BUILD_TARGETS = ['docker', 'binary', 'static'] as const;
18
26
 
@@ -58,6 +66,11 @@ export function dockerArgs(root: string, tag: string): readonly string[] {
58
66
  * `frameworkVersion()` has nothing to read and throws — which is exactly how this target came to
59
67
  * compile an artifact that could never boot. The value is this CLI's own `@ultimat3/core`, which is
60
68
  * the app's too: the packages release in lockstep and `x new` pins them together.
69
+ *
70
+ * `externalArgs()` is not optional either, and for the opposite reason: it names the one specifier
71
+ * this graph must NOT resolve. `apps/web/server.ts` reaches `serve.ts`, which reaches the island
72
+ * builder, which reaches `@babel/core` — whose `.cts`-config loader requires a package we
73
+ * deliberately do not install. Bun 1.3 fails the compile on it. See `compile-externals.ts`.
61
74
  */
62
75
  export function binaryArgs(root: string, out: string): readonly string[] {
63
76
  return [
@@ -67,6 +80,7 @@ export function binaryArgs(root: string, out: string): readonly string[] {
67
80
  '--minify',
68
81
  '--define',
69
82
  `${VERSION_DEFINE}=${JSON.stringify(frameworkVersion())}`,
83
+ ...externalArgs(),
70
84
  join(root, BUILD_ENTRY.binary),
71
85
  '--outfile',
72
86
  out,
@@ -104,14 +118,19 @@ export function preflightResult(verify: CommandResult): CommandResult {
104
118
  * `✗ built docker`; and the builder's own logs went only into `lines`, which is declared human-only
105
119
  * and which `renderJson` drops — so CI, which runs `--json`, got the exit code and nothing to act
106
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).
107
125
  */
108
126
  export function buildResult(input: {
109
127
  readonly target: BuildTarget;
110
128
  readonly artifact: string;
111
129
  readonly command: readonly string[];
112
130
  readonly result: ExecResult;
131
+ readonly report?: StaticReport;
113
132
  }): CommandResult {
114
- const { result, target } = input;
133
+ const { report, result, target } = input;
115
134
  const output = result.ok ? '' : execOutput(result);
116
135
  return {
117
136
  ok: result.ok,
@@ -132,8 +151,13 @@ export function buildResult(input: {
132
151
  artifact: input.artifact,
133
152
  durationMs: result.durationMs,
134
153
  ...(result.ok ? {} : { output }),
154
+ ...staticReportData(report),
135
155
  },
136
- lines: result.ok ? [] : output.split('\n'),
156
+ lines: result.ok
157
+ ? report === undefined
158
+ ? []
159
+ : renderStaticReport(report)
160
+ : output.split('\n'),
137
161
  };
138
162
  }
139
163
 
@@ -171,11 +195,20 @@ export const buildCommand: CliCommand = {
171
195
  flagString(ctx.args, 'out') ?? join(root, '.x', target === 'static' ? 'static' : 'app');
172
196
  const tag = flagString(ctx.args, 'tag') ?? 'ultimate-app:dev';
173
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;
174
206
  return buildResult({
175
207
  target,
176
208
  artifact: target === 'docker' ? tag : out,
177
209
  command,
178
- result: await ctx.runner(command, { cwd: root }),
210
+ result,
211
+ ...(report === undefined ? {} : { report }),
179
212
  });
180
213
  },
181
214
  };
package/src/cmd-ci.ts ADDED
@@ -0,0 +1,273 @@
1
+ // `x ci` — one command instead of three. `gh run view` prints a tree of ticks and one cross, and
2
+ // the error is inside a per-job log that is mostly setup noise; for a `verify` job that log tail IS
3
+ // the findings block, with its `X_*` codes and executable `fix:` lines already in it. So this
4
+ // command fetches the runs, opens only the failed steps' log, and hands back the findings the gate
5
+ // already wrote — in the same three-line shape every other Ultimate error is printed in.
6
+
7
+ import { UltimateError } from '@ultimat3/core';
8
+ import type { CiLogLine } from './ci-log';
9
+ import { findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
10
+ import type { CiJob, CiRun } from './ci-runs';
11
+ import { failedLog, isFailed, isRunning, latestPerWorkflow, listRuns, viewRun } from './ci-runs';
12
+ import type { CliCommand, CommandContext } from './command';
13
+ import { parseIntFlag } from './flag-number';
14
+ import type { GhRepo } from './gh-target';
15
+ import { currentBranch, resolveRepo } from './gh-target';
16
+ import { msg } from './messages';
17
+ import type { CommandResult, Finding, JsonValue } from './output';
18
+ import { flagBool, flagString } from './parse';
19
+
20
+ /**
21
+ * Every catalog key this command renders, declared — `msg()` answers `⟦key⟧` for a key nobody
22
+ * added, which is loud in a terminal and SILENT to a build. `cmd-ci.test.ts` holds this list
23
+ * against the catalog, so a missing string is a failing test rather than a rendered artefact.
24
+ */
25
+ export const CI_MESSAGE_KEYS = [
26
+ 'cli.ci.failed',
27
+ 'cli.ci.green',
28
+ 'cli.ci.running',
29
+ 'cli.ci.run',
30
+ 'cli.ci.job',
31
+ 'cli.ci.jobs.other',
32
+ 'cli.ci.tail',
33
+ 'cli.ci.pending',
34
+ 'cli.ci.logs.empty',
35
+ ] as const;
36
+
37
+ /** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
38
+ export const TAIL_LINES = 40;
39
+
40
+ /** How far back a branch's run history is read before "the latest run of each workflow". */
41
+ export const RUN_LOOKBACK = 20;
42
+
43
+ /** No run to triage. The remedy is a branch that has one, or the id of the run in question. */
44
+ export class CiRunNotFoundError extends UltimateError {
45
+ constructor(input: { branch: string; repo: string }) {
46
+ super({
47
+ code: 'X_CI_RUN_NOT_FOUND',
48
+ cause: `no workflow run on ${input.repo} for branch "${input.branch}"`,
49
+ fix: `x ci --branch main --repo ${input.repo} --json`,
50
+ });
51
+ }
52
+ }
53
+
54
+ const RUN_FLAG = { name: 'run', command: 'ci', min: 1, example: 'x ci --run 32484583944 --json' };
55
+ const TAIL_FLAG = { name: 'tail', command: 'ci', min: 1, example: 'x ci --tail 80 --json' };
56
+
57
+ export const ciCommand: CliCommand = {
58
+ spec: {
59
+ name: 'ci',
60
+ summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
61
+ usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
62
+ flags: [
63
+ { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
64
+ { name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
65
+ { name: 'run', type: 'string', summary: 'one run id, instead of this branch latest' },
66
+ {
67
+ name: 'tail',
68
+ type: 'string',
69
+ summary: `log lines kept per failed job (default ${TAIL_LINES})`,
70
+ },
71
+ { name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
72
+ ],
73
+ },
74
+ async run(ctx: CommandContext): Promise<CommandResult> {
75
+ const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
76
+ const rawRun = flagString(ctx.args, 'run');
77
+ const rawTail = flagString(ctx.args, 'tail');
78
+ const limit = flagBool(ctx.args, 'full')
79
+ ? Number.POSITIVE_INFINITY
80
+ : rawTail === undefined
81
+ ? TAIL_LINES
82
+ : parseIntFlag(rawTail, TAIL_FLAG);
83
+ if (rawRun !== undefined) {
84
+ const viewed = await viewRun(ctx, repo, parseIntFlag(rawRun, RUN_FLAG));
85
+ return report(repo, viewed.run.branch, [await inspect(ctx, repo, viewed, limit)]);
86
+ }
87
+ const branch = flagString(ctx.args, 'branch') ?? (await currentBranch(ctx));
88
+ const runs = latestPerWorkflow(await listRuns(ctx, repo, branch, RUN_LOOKBACK));
89
+ if (runs.length === 0) throw new CiRunNotFoundError({ branch, repo: repo.slug });
90
+ const inspected: RunReport[] = [];
91
+ for (const run of runs) {
92
+ // Only a failed run is opened. A green run's jobs are 35 rows saying `success`, and fetching
93
+ // them would turn the fast answer ("CI is green") into the slow one.
94
+ inspected.push(
95
+ isFailed(run) ? await inspect(ctx, repo, await viewRun(ctx, repo, run.id), limit) : { run },
96
+ );
97
+ }
98
+ return report(repo, branch, inspected);
99
+ },
100
+ };
101
+
102
+ interface JobFailure {
103
+ readonly job: string;
104
+ readonly conclusion: string;
105
+ readonly url: string;
106
+ readonly failedSteps: readonly string[];
107
+ readonly tail: readonly string[];
108
+ }
109
+
110
+ interface RunReport {
111
+ readonly run: CiRun;
112
+ readonly jobs?: readonly CiJob[];
113
+ readonly failures?: readonly JobFailure[];
114
+ readonly findings?: readonly Finding[];
115
+ }
116
+
117
+ const stepFailed = (conclusion: string | null): boolean =>
118
+ conclusion !== null && conclusion !== 'success' && conclusion !== 'skipped';
119
+
120
+ /**
121
+ * One failed run, opened. The findings are attributed to the job whose lines produced them, and
122
+ * a job the log attributes nothing to falls back to the whole log — a format change on GitHub's
123
+ * side must cost the attribution, never the finding.
124
+ */
125
+ async function inspect(
126
+ ctx: CommandContext,
127
+ repo: GhRepo,
128
+ viewed: { readonly run: CiRun; readonly jobs: readonly CiJob[] },
129
+ limit: number,
130
+ ): Promise<RunReport> {
131
+ const { run, jobs } = viewed;
132
+ if (!isFailed(run)) return { run, jobs };
133
+ const lines = parseLogLines(await failedLog(ctx, repo, run.id));
134
+ const failed = jobs.filter((job) => stepFailed(job.conclusion));
135
+ // A run can fail with no failed JOB — a startup failure, a cancelled matrix — and the log is
136
+ // then the only thing that knows which name to file it under.
137
+ const names = failed.length > 0 ? failed.map((job) => job.name) : jobsInLog(lines);
138
+ const failures: JobFailure[] = [];
139
+ const findings: Finding[] = [];
140
+ for (const name of names) {
141
+ const own = lines.filter((line) => line.job === name);
142
+ const pool: readonly CiLogLine[] = own.length > 0 ? own : lines;
143
+ const job = failed.find((candidate) => candidate.name === name);
144
+ for (const finding of findingsFrom(pool)) {
145
+ findings.push(finding.at === undefined ? { ...finding, at: name } : finding);
146
+ }
147
+ failures.push({
148
+ job: name,
149
+ conclusion: job?.conclusion ?? conclusionOf(run),
150
+ url: job?.url ?? run.url,
151
+ failedSteps: (job?.steps ?? [])
152
+ .filter((step) => stepFailed(step.conclusion))
153
+ .map((step) => step.name),
154
+ tail: tailOf(lines, name, limit),
155
+ });
156
+ }
157
+ return { run, jobs, failures, findings: dedupe(findings) };
158
+ }
159
+
160
+ /**
161
+ * The same block can be reached twice — once per job when the log attributes nothing, once per
162
+ * attempt when a run was re-run — and two copies of one finding read as two problems.
163
+ */
164
+ function dedupe(findings: readonly Finding[]): readonly Finding[] {
165
+ const seen = new Set<string>();
166
+ return findings.filter((finding) => {
167
+ const key = `${finding.code} ${finding.cause}`;
168
+ if (seen.has(key)) return false;
169
+ seen.add(key);
170
+ return true;
171
+ });
172
+ }
173
+
174
+ const conclusionOf = (run: CiRun): string => run.conclusion ?? msg('cli.ci.pending');
175
+
176
+ function report(repo: GhRepo, branch: string, reports: readonly RunReport[]): CommandResult {
177
+ const failed = reports.filter((entry) => isFailed(entry.run));
178
+ const running = reports.filter((entry) => isRunning(entry.run));
179
+ const findings = dedupe(reports.flatMap((entry) => entry.findings ?? []));
180
+ return {
181
+ ok: failed.length === 0,
182
+ command: 'ci',
183
+ summary:
184
+ failed.length > 0
185
+ ? msg('cli.ci.failed', {
186
+ failed: failed.length,
187
+ runs: reports.length,
188
+ branch,
189
+ findings: findings.length,
190
+ })
191
+ : running.length > 0
192
+ ? msg('cli.ci.running', { running: running.length, runs: reports.length, branch })
193
+ : msg('cli.ci.green', { runs: reports.length, branch }),
194
+ findings,
195
+ lines: reports.flatMap(runLines),
196
+ data: {
197
+ repo: repo.slug,
198
+ branch,
199
+ counts: {
200
+ runs: reports.length,
201
+ failed: failed.length,
202
+ running: running.length,
203
+ findings: findings.length,
204
+ },
205
+ runs: reports.map(runJson),
206
+ },
207
+ };
208
+ }
209
+
210
+ function runLines(entry: RunReport): readonly string[] {
211
+ const out = [
212
+ msg('cli.ci.run', {
213
+ conclusion: conclusionOf(entry.run),
214
+ workflow: entry.run.workflow,
215
+ url: entry.run.url,
216
+ }),
217
+ ];
218
+ const failures = entry.failures ?? [];
219
+ for (const failure of failures) {
220
+ out.push(
221
+ msg('cli.ci.job', {
222
+ conclusion: failure.conclusion,
223
+ job: failure.job,
224
+ steps: failure.failedSteps.join(', '),
225
+ }),
226
+ );
227
+ if (failure.tail.length === 0) {
228
+ out.push(msg('cli.ci.logs.empty', { url: failure.url }));
229
+ continue;
230
+ }
231
+ out.push(msg('cli.ci.tail', { job: failure.job }));
232
+ for (const line of failure.tail) out.push(` | ${line}`);
233
+ }
234
+ const jobs = entry.jobs;
235
+ if (jobs !== undefined && jobs.length > failures.length) {
236
+ out.push(msg('cli.ci.jobs.other', { count: jobs.length - failures.length }));
237
+ }
238
+ return out;
239
+ }
240
+
241
+ function runJson(entry: RunReport): JsonValue {
242
+ return {
243
+ id: entry.run.id,
244
+ workflow: entry.run.workflow,
245
+ status: entry.run.status,
246
+ conclusion: entry.run.conclusion,
247
+ title: entry.run.title,
248
+ url: entry.run.url,
249
+ createdAt: entry.run.createdAt,
250
+ // Absent rather than empty on a run that was never opened: `jobs: []` on a green run would
251
+ // claim the run has no jobs, which is a different statement from "nobody asked".
252
+ ...(entry.jobs === undefined
253
+ ? {}
254
+ : {
255
+ jobs: entry.jobs.map((job) => ({
256
+ name: job.name,
257
+ status: job.status,
258
+ conclusion: job.conclusion,
259
+ url: job.url,
260
+ })),
261
+ }),
262
+ ...(entry.failures === undefined
263
+ ? {}
264
+ : {
265
+ failures: entry.failures.map((failure) => ({
266
+ job: failure.job,
267
+ url: failure.url,
268
+ failedSteps: [...failure.failedSteps],
269
+ tail: [...failure.tail],
270
+ })),
271
+ }),
272
+ };
273
+ }