@ultimat3/cli 27.13.2 → 27.14.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.13.2",
3
+ "version": "27.14.1",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -39,33 +39,33 @@
39
39
  },
40
40
  "dependencies": {
41
41
  "@babel/core": "^8.0.1",
42
- "@ultimat3/action": "27.13.2",
43
- "@ultimat3/admin": "27.13.2",
44
- "@ultimat3/ai": "27.13.2",
45
- "@ultimat3/auth": "27.13.2",
46
- "@ultimat3/cache": "27.13.2",
47
- "@ultimat3/core": "27.13.2",
48
- "@ultimat3/db": "27.13.2",
49
- "@ultimat3/entity": "27.13.2",
50
- "@ultimat3/flags": "27.13.2",
51
- "@ultimat3/http": "27.13.2",
52
- "@ultimat3/i18n": "27.13.2",
53
- "@ultimat3/jobs": "27.13.2",
54
- "@ultimat3/mail": "27.13.2",
55
- "@ultimat3/manifest": "27.13.2",
56
- "@ultimat3/mcp": "27.13.2",
57
- "@ultimat3/money": "27.13.2",
58
- "@ultimat3/notify": "27.13.2",
59
- "@ultimat3/policy": "27.13.2",
60
- "@ultimat3/pwa": "27.13.2",
61
- "@ultimat3/query": "27.13.2",
62
- "@ultimat3/realtime": "27.13.2",
63
- "@ultimat3/render": "27.13.2",
64
- "@ultimat3/schema": "27.13.2",
65
- "@ultimat3/seo": "27.13.2",
66
- "@ultimat3/storage": "27.13.2",
67
- "@ultimat3/testing": "27.13.2",
68
- "@ultimat3/time": "27.13.2",
42
+ "@ultimat3/action": "27.14.1",
43
+ "@ultimat3/admin": "27.14.1",
44
+ "@ultimat3/ai": "27.14.1",
45
+ "@ultimat3/auth": "27.14.1",
46
+ "@ultimat3/cache": "27.14.1",
47
+ "@ultimat3/core": "27.14.1",
48
+ "@ultimat3/db": "27.14.1",
49
+ "@ultimat3/entity": "27.14.1",
50
+ "@ultimat3/flags": "27.14.1",
51
+ "@ultimat3/http": "27.14.1",
52
+ "@ultimat3/i18n": "27.14.1",
53
+ "@ultimat3/jobs": "27.14.1",
54
+ "@ultimat3/mail": "27.14.1",
55
+ "@ultimat3/manifest": "27.14.1",
56
+ "@ultimat3/mcp": "27.14.1",
57
+ "@ultimat3/money": "27.14.1",
58
+ "@ultimat3/notify": "27.14.1",
59
+ "@ultimat3/policy": "27.14.1",
60
+ "@ultimat3/pwa": "27.14.1",
61
+ "@ultimat3/query": "27.14.1",
62
+ "@ultimat3/realtime": "27.14.1",
63
+ "@ultimat3/render": "27.14.1",
64
+ "@ultimat3/schema": "27.14.1",
65
+ "@ultimat3/seo": "27.14.1",
66
+ "@ultimat3/storage": "27.14.1",
67
+ "@ultimat3/testing": "27.14.1",
68
+ "@ultimat3/time": "27.14.1",
69
69
  "babel-preset-solid": "^1.9.15"
70
70
  }
71
71
  }
@@ -39,13 +39,26 @@ const ACTOR_REFUSALS: ReadonlySet<string> = new Set([
39
39
  'X_TENANCY_ACTOR_ORG_REQUIRED',
40
40
  ]);
41
41
 
42
- function ownCodeFinding(url: string, entry: UnmeasuredRoute): Finding {
42
+ /**
43
+ * The locator every finding here carries: `at` is the FILE an agent opens, never the route URL —
44
+ * `output.ts`'s `Finding.at`, and `verify-checks.ts` reads it as the path to open. The URL is a
45
+ * fact of its own and rides beside it in `meta.route`, so `--json` loses nothing by the move.
46
+ *
47
+ * OMITTED, not filled with something, when no file declares the route: a URL is not a path anyone
48
+ * can open, and `at` that names nothing opens nothing. The cause and the fix both name the URL.
49
+ */
50
+ const locatorOf = (file: string | undefined, url: string): Pick<Finding, 'at' | 'meta'> => ({
51
+ ...(file === undefined ? {} : { at: file }),
52
+ meta: { route: url },
53
+ });
54
+
55
+ function ownCodeFinding(url: string, file: string | undefined, entry: UnmeasuredRoute): Finding {
43
56
  return {
44
57
  code: entry.code ?? 'X_BUDGET_UNMEASURED',
45
58
  cause: entry.cause ?? entry.reason,
46
59
  fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
47
60
  docs: ERROR_DOCS_URL,
48
- at: url,
61
+ ...locatorOf(file, url),
49
62
  };
50
63
  }
51
64
 
@@ -57,15 +70,20 @@ const refusalOf = (entry: UnmeasuredRoute): string =>
57
70
  * A build ran and has no row for `url`. `entry` is the report's account of why, when it has one:
58
71
  * an instruction under its own code, an actor edit, or — for a failure that is a fact about the
59
72
  * build's environment — the report, with the refusal already in the cause.
73
+ *
74
+ * `file` is the file that declares `url`, resolved by the caller (`budgets.ts` asks the loaded
75
+ * route table); `undefined` when no route file claims it, and the finding then carries no locator
76
+ * rather than a URL that opens nothing.
60
77
  */
61
78
  export function builtUnmeasuredFinding(
62
79
  url: string,
63
80
  declared: string,
64
81
  statsFile: string,
65
82
  entry: UnmeasuredRoute | undefined,
83
+ file: string | undefined,
66
84
  ): Finding {
67
85
  if (entry?.code !== undefined && REPORTED_BY_OWN_CODE.has(entry.code)) {
68
- return ownCodeFinding(url, entry);
86
+ return ownCodeFinding(url, file, entry);
69
87
  }
70
88
  const base = `${url} declares a ${declared} budget and ${statsFile} has no row for it, so the build ran and could not weigh it`;
71
89
  if (entry?.code !== undefined && ACTOR_REFUSALS.has(entry.code)) {
@@ -76,7 +94,7 @@ export function builtUnmeasuredFinding(
76
94
  // The paste: one declaration in the one file every build imports.
77
95
  fix: "defineMeasurementActor(() => userActor({ id: 'measure', orgId: '<an org your dev seed creates>', roles: ['<the role this page requires>'] })) # in app.config.ts",
78
96
  docs: ERROR_DOCS_URL,
79
- at: url,
97
+ ...locatorOf(file, url),
80
98
  };
81
99
  }
82
100
  return {
@@ -86,6 +104,6 @@ export function builtUnmeasuredFinding(
86
104
  // route it could not weigh, and why.
87
105
  fix: `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`,
88
106
  docs: ERROR_DOCS_URL,
89
- at: url,
107
+ ...locatorOf(file, url),
90
108
  };
91
109
  }
package/src/budgets.ts CHANGED
@@ -117,27 +117,34 @@ function exceededJsFix(url: string, file: string | undefined, bytes: number): st
117
117
  * run and could not weigh this one, which is `PrerenderReport.unmeasured`'s question and not a
118
118
  * second build's. Reporting the first as the second is what sends a reader to re-run a build that
119
119
  * already did everything it was going to do.
120
+ *
121
+ * `fileOf` names the file an agent opens to fix this route, exactly as it does for
122
+ * `X_BUDGET_EXCEEDED`: `Finding.at` is a PATH, never a URL (`output.ts`, `verify-checks.ts`),
123
+ * so the route rides beside it in `meta.route` and every cause below still spells the URL.
120
124
  */
121
125
  function unmeasuredFinding(
122
126
  url: string,
123
127
  declared: string,
124
128
  stats: BuildStats | undefined,
125
129
  unmeasured: readonly UnmeasuredRoute[],
130
+ fileOf: RouteFileOf,
126
131
  ): Finding {
132
+ const file = fileOf(url);
127
133
  if (stats?.stale === true) {
128
134
  return {
129
135
  code: 'X_BUDGET_UNMEASURED',
130
136
  cause: `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} was written by an earlier measurement rule than this gate's (v${String(BUILD_STATS_RULES)}), so its numbers are not this gate's to read`,
131
137
  fix: 'x build --target static --json && x verify --json',
132
138
  docs: ERROR_DOCS_URL,
133
- at: url,
139
+ ...(file === undefined ? {} : { at: file }),
140
+ meta: { route: url },
134
141
  };
135
142
  }
136
143
  // A build ran: the report's account of this route decides the instruction
137
144
  // (`budgets-unmeasured.ts`) — its own code, an actor edit, or the report.
138
145
  if (stats !== undefined) {
139
146
  const entry = unmeasured.find((one) => one.path === url);
140
- return builtUnmeasuredFinding(url, declared, BUILD_STATS_FILE, entry);
147
+ return builtUnmeasuredFinding(url, declared, BUILD_STATS_FILE, entry, file);
141
148
  }
142
149
  return {
143
150
  code: 'X_BUDGET_UNMEASURED',
@@ -147,7 +154,8 @@ function unmeasuredFinding(
147
154
  // the one caller of `writeBuildStats`.
148
155
  fix: 'x build --target static --json && x verify --json',
149
156
  docs: ERROR_DOCS_URL,
150
- at: url,
157
+ ...(file === undefined ? {} : { at: file }),
158
+ meta: { route: url },
151
159
  };
152
160
  }
153
161
 
@@ -161,8 +169,9 @@ function unmeasuredFinding(
161
169
  * and can be absent for the same reason; with it, a route whose measurement failed on a code in
162
170
  * `REPORTED_BY_OWN_CODE` (`budgets-unmeasured.ts`) is reported under that code, with the build's own cause and fix.
163
171
  *
164
- * `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
165
- * so render's route table already holds it. A test hands its own.
172
+ * `fileOf` names the file an agent opens for a route's finding — the locator `Finding.at` carries
173
+ * and the file `X_BUDGET_EXCEEDED`'s fix edits: the step runs after the app loaded, so render's
174
+ * route table already holds it. A test hands its own.
166
175
  */
167
176
  export function checkBudgets(
168
177
  manifest: Manifest,
@@ -183,19 +192,24 @@ export function checkBudgets(
183
192
  const js = jsBudgetOf(route);
184
193
  if (measured === undefined) {
185
194
  if (js !== null) {
186
- findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured));
195
+ findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured, fileOf));
187
196
  }
188
197
  continue;
189
198
  }
190
199
  if (js !== null && measured.jsBytes > js) {
191
- const meta = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
200
+ // `at` is the file an agent opens — the SAME resolution `exceededJsFix` edits, so the
201
+ // locator and the instruction name one place. The route URL it belongs to is in `cause`
202
+ // (`budgets-charged.test.ts` holds the per-file breakdown in `meta` only when a row carries
203
+ // one — adding a `route` key would conflict with that contract).
204
+ const file = fileOf(route.url);
205
+ const charged = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
192
206
  findings.push({
193
207
  code: 'X_BUDGET_EXCEEDED',
194
208
  cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}${chargedClause(measured.charged, measured.inlineJsBytes)}`,
195
- fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
209
+ fix: exceededJsFix(route.url, file, measured.jsBytes),
196
210
  docs: ERROR_DOCS_URL,
197
- at: route.url,
198
- ...(meta === undefined ? {} : { meta }),
211
+ ...(file === undefined ? {} : { at: file }),
212
+ ...(charged === undefined ? {} : { meta: charged }),
199
213
  });
200
214
  }
201
215
  }
package/src/ci-log.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // this file undoes the transport and reconstructs the `Finding`, rather than printing a wall of
5
5
  // text and asking the reader to spot the code in it.
6
6
 
7
- import type { Finding } from './output';
7
+ import type { Finding, JsonValue } from './output';
8
8
 
9
9
  /** One log line, with the two columns Actions prefixes every line with taken off. */
10
10
  export interface CiLogLine {
@@ -67,11 +67,52 @@ export function jobsInLog(lines: readonly CiLogLine[]): readonly string[] {
67
67
  }
68
68
 
69
69
  const CODE = /^(X_[A-Z][A-Z0-9_]*)/;
70
- const LOCATOR = /\(([^()]+)\)$/;
70
+ // The ONE way a `Finding.at` — a path an agent can open — returns from a log. The renderer
71
+ // prints it as the parenthesised expression in the head (`renderFinding`, `output.ts`).
72
+ //
73
+ // Anchored to the head minus a trailing `[step=…]`, NOT to the end of the line:
74
+ // `X_VERIFY_STEP_TIMEOUT` sets both `at` and `meta.step`, so that head reads
75
+ // `CODE (path) [step=unit]` — an end-anchor matches only what follows the brackets, the match
76
+ // fails, and the path is dropped. With no step the optional group is absent and the
77
+ // parenthesised group is still the last thing on the line.
78
+ const LOCATOR = /\(([^()]+)\)(?=\s*(?:\[step=[^[\]]+\]\s*)?$)/;
79
+ // The second, non-path locator the renderer prints on the head — `meta.step` in the same
80
+ // brackets-keyed form (`[step=lint]`) `renderFinding` writes — so a merge gap's gate step name
81
+ // or any future non-path identifier survives the round-trip too. Its own brackets are what
82
+ // `LOCATOR`'s lookahead stands aside for, so the two cannot collide in one head.
83
+ const STEP_LOCATOR = /\[step=([^[\]]+)\]\s*$/;
71
84
 
72
85
  const after = (text: string, label: string): string | undefined =>
73
86
  text.trim().startsWith(label) ? text.trim().slice(label.length).trim() : undefined;
74
87
 
88
+ // The `meta.step` locator the renderer prints in the head (`output.ts:185-189`): a gate step name
89
+ // the finding carries on `meta`, never on `at`. Total on every shape `Finding.meta` may take —
90
+ // the field is optional, and a malformed `meta` (a non-object value, an array) is read as "no
91
+ // step", the same answer an absent `meta` gets.
92
+ const stepOf = (finding: Finding): string => {
93
+ if (typeof finding.meta === 'object' && finding.meta !== null && !Array.isArray(finding.meta)) {
94
+ const value = (finding.meta as { readonly [key: string]: JsonValue })['step'];
95
+ return typeof value === 'string' ? value : '';
96
+ }
97
+ return '';
98
+ };
99
+
100
+ /**
101
+ * The dedupe key a finding is told apart by. Two findings are the same only if every locator
102
+ * the renderer prints agrees on them — `code`, `cause`, `at`, and `meta.step`. Ci-log's per-log
103
+ * dedupe (`findingsFrom` below) and `x ci`'s cross-log dedupe (`cmd-ci.ts`) both build the key
104
+ * off this formula, so a finding that survives one cannot be silently merged by the other.
105
+ *
106
+ * A re-run appends a second copy of the same block; two identical findings would read as two
107
+ * problems where the job has one. A `X_COVERAGE_BELOW` routed to two different packages is the
108
+ * opposite — two findings, where a key of code and cause alone folds the second into a duplicate.
109
+ * `fix` is deliberately not in it: the same block reprints its fix line, and `x ci` fences it
110
+ * differently per job, so keying on it would report a re-run twice.
111
+ */
112
+ export function findingKey(finding: Finding): string {
113
+ return `${finding.code}\0${finding.cause}\0${finding.at ?? ''}\0${stepOf(finding)}`;
114
+ }
115
+
75
116
  /**
76
117
  * `renderFinding`'s output, read backwards. A code line ALONE is not a finding — prose naming a
77
118
  * code, a summary counting them and a `fix:` quoting one all start with `X_`, and reconstructing
@@ -93,19 +134,26 @@ export function findingsFrom(lines: readonly CiLogLine[]): readonly Finding[] {
93
134
  if (cause === undefined || fix === undefined) continue;
94
135
  const code = head[1] ?? '';
95
136
  const at = LOCATOR.exec(texts[index] ?? '')?.[1];
137
+ const step = STEP_LOCATOR.exec(texts[index] ?? '')?.[1];
96
138
  const docs = after(texts[index + 3] ?? '', 'docs:');
97
- const key = `${code}\0${cause}`;
98
- // A re-run appends a second copy of the same block; two identical findings would read as two
99
- // problems where the job has one.
100
- if (!seen.has(key)) {
101
- seen.add(key);
102
- out.push({
103
- code,
104
- cause,
105
- fix,
106
- ...(at === undefined ? {} : { at }),
107
- ...(docs === undefined ? {} : { docs }),
108
- });
139
+ // Step name and any other reconstructed structured fact ride on `meta`, so the
140
+ // reconstructed finding has a place to put a non-path locator `at` cannot carry.
141
+ const meta = step === undefined ? undefined : { step };
142
+ // Built once, then keyed: `findingKey` reads `code`, `cause`, `at` and `meta.step` off it,
143
+ // so the per-log dedupe here and the across-log dedupe in `cmd-ci.ts` agree on what one
144
+ // finding is. `X_VERIFY_MERGE_INCOMPLETE` arrives here once per missing step, and only the
145
+ // step name tells them apart.
146
+ const finding: Finding = {
147
+ code,
148
+ cause,
149
+ fix,
150
+ ...(at === undefined ? {} : { at }),
151
+ ...(meta === undefined ? {} : { meta }),
152
+ ...(docs === undefined ? {} : { docs }),
153
+ };
154
+ if (!seen.has(findingKey(finding))) {
155
+ seen.add(findingKey(finding));
156
+ out.push(finding);
109
157
  }
110
158
  index += 2;
111
159
  }
package/src/ci-runs.ts CHANGED
@@ -9,13 +9,14 @@ import type { GhRepo } from './gh-target';
9
9
 
10
10
  /** The fields both `gh run list` and `gh run view` accept, named once so the two agree. */
11
11
  export const RUN_FIELDS =
12
- 'databaseId,status,conclusion,headBranch,displayTitle,workflowName,url,createdAt';
12
+ 'databaseId,status,conclusion,headBranch,displayTitle,workflowName,url,createdAt,headSha';
13
13
 
14
14
  const RUN_ROW = t.object({
15
15
  databaseId: t.number,
16
16
  status: t.string,
17
17
  conclusion: t.nullable(t.string),
18
18
  headBranch: t.string,
19
+ headSha: t.string,
19
20
  displayTitle: t.string,
20
21
  workflowName: t.string,
21
22
  url: t.string,
@@ -58,6 +59,7 @@ export interface CiRun {
58
59
  readonly workflow: string;
59
60
  readonly url: string;
60
61
  readonly createdAt: string;
62
+ readonly headSha: string;
61
63
  }
62
64
 
63
65
  /**
@@ -85,6 +87,7 @@ interface RunRow {
85
87
  readonly status: string;
86
88
  readonly conclusion: string | null;
87
89
  readonly headBranch: string;
90
+ readonly headSha: string;
88
91
  readonly displayTitle: string;
89
92
  readonly workflowName: string;
90
93
  readonly url: string;
@@ -96,6 +99,7 @@ const runOf = (row: RunRow): CiRun => ({
96
99
  status: row.status,
97
100
  conclusion: row.conclusion,
98
101
  branch: row.headBranch,
102
+ headSha: row.headSha,
99
103
  title: row.displayTitle,
100
104
  workflow: row.workflowName,
101
105
  url: row.url,
@@ -117,11 +121,17 @@ export function latestPerWorkflow(runs: readonly CiRun[]): readonly CiRun[] {
117
121
  return [...newest.values()];
118
122
  }
119
123
 
124
+ /**
125
+ * The branch's runs, newest first. `filters` narrows the ask to one workflow or one event; with
126
+ * neither given, the argv is byte-for-byte the one `x ci` has always sent — cmd-ci.test.ts pins
127
+ * it, so a filter may only ever insert its pair between `--branch` and `--limit`.
128
+ */
120
129
  export async function listRuns(
121
130
  host: GhHost,
122
131
  repo: GhRepo,
123
132
  branch: string,
124
133
  limit: number,
134
+ filters?: { readonly workflow?: string | undefined; readonly event?: string | undefined },
125
135
  ): Promise<readonly CiRun[]> {
126
136
  const rows = await ghJson(
127
137
  host,
@@ -132,6 +142,8 @@ export async function listRuns(
132
142
  repo.slug,
133
143
  '--branch',
134
144
  branch,
145
+ ...(filters?.workflow === undefined ? [] : ['--workflow', filters.workflow]),
146
+ ...(filters?.event === undefined ? [] : ['--event', filters.event]),
135
147
  '--limit',
136
148
  String(limit),
137
149
  '--json',
@@ -6,10 +6,17 @@ import type { CommandSpec } from './parse';
6
6
  /** Log lines kept per failed job. Enough to hold a findings block, short enough to read. */
7
7
  export const TAIL_LINES = 40;
8
8
 
9
+ /**
10
+ * The events `--event` accepts, closed: `cmd-ci.ts` refuses every other value against this same
11
+ * constant, so the summary, the refusal and the `gh` filter can never name different sets.
12
+ */
13
+ export const CI_EVENTS = ['push', 'pull_request', 'workflow_dispatch'] as const;
14
+
9
15
  export const ciSpec: CommandSpec = {
10
16
  name: 'ci',
11
17
  summary: 'the workflow runs for this branch, and the findings inside the failed steps log',
12
- usage: 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--json]',
18
+ usage:
19
+ 'x ci [--branch <name>] [--run <id>] [--repo owner/name] [--tail <n>] [--full] [--workflow <name>] [--event <push|pull_request|workflow_dispatch>] [--json]',
13
20
  flags: [
14
21
  { name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
15
22
  { name: 'branch', type: 'string', summary: 'branch to read runs for; this one by default' },
@@ -20,5 +27,15 @@ export const ciSpec: CommandSpec = {
20
27
  summary: `log lines kept per failed job (default ${TAIL_LINES})`,
21
28
  },
22
29
  { name: 'full', type: 'boolean', summary: 'the whole failed-step log, not the tail' },
30
+ {
31
+ name: 'workflow',
32
+ type: 'string',
33
+ summary: 'only this workflow name or filename (e.g. ci.yml)',
34
+ },
35
+ {
36
+ name: 'event',
37
+ type: 'string',
38
+ summary: 'only runs of this event (push, pull_request, workflow_dispatch)',
39
+ },
23
40
  ],
24
41
  };
package/src/cmd-ci.ts CHANGED
@@ -6,11 +6,12 @@
6
6
 
7
7
  import { UltimateError } from '@ultimat3/core';
8
8
  import type { CiLogLine } from './ci-log';
9
- import { findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
9
+ import { findingKey, findingsFrom, jobsInLog, parseLogLines, tailOf } from './ci-log';
10
10
  import type { CiJob, CiRun } from './ci-runs';
11
11
  import { failedLog, isFailed, isRunning, latestPerWorkflow, listRuns, viewRun } from './ci-runs';
12
- import { ciSpec, TAIL_LINES } from './cmd-ci-spec';
12
+ import { CI_EVENTS, ciSpec, TAIL_LINES } from './cmd-ci-spec';
13
13
  import type { CliCommand, CommandContext } from './command';
14
+ import { BadFlagError } from './errors';
14
15
  import { parseIntFlag } from './flag-number';
15
16
  import { commentBlock } from './foreign-text';
16
17
  import type { GhRepo } from './gh-target';
@@ -18,6 +19,7 @@ import { currentBranch, resolveRepo } from './gh-target';
18
19
  import { msg } from './messages';
19
20
  import type { CommandResult, Finding, JsonValue } from './output';
20
21
  import { flagBool, flagString } from './parse';
22
+ import { quoteArg } from './shell-quote';
21
23
 
22
24
  export { TAIL_LINES } from './cmd-ci-spec';
23
25
 
@@ -43,10 +45,22 @@ export const RUN_LOOKBACK = 20;
43
45
 
44
46
  /** No run to triage. The remedy is a branch that has one, or the id of the run in question. */
45
47
  export class CiRunNotFoundError extends UltimateError {
46
- constructor(input: { branch: string; repo: string }) {
48
+ constructor(input: {
49
+ branch: string;
50
+ repo: string;
51
+ workflow?: string | undefined;
52
+ event?: string | undefined;
53
+ }) {
54
+ // A filter beside --branch makes an empty list two different situations — a branch never
55
+ // pushed, or a filter that matched nothing. Naming what was asked keeps the diagnosis right.
56
+ const given = [
57
+ input.workflow !== undefined ? `--workflow ${input.workflow}` : undefined,
58
+ input.event !== undefined ? `--event ${input.event}` : undefined,
59
+ ].filter((clause) => clause !== undefined);
60
+ const matching = given.length > 0 ? ` matching ${given.join(' ')}` : '';
47
61
  super({
48
62
  code: 'X_CI_RUN_NOT_FOUND',
49
- cause: `no workflow run on ${input.repo} for branch "${input.branch}"`,
63
+ cause: `no workflow run on ${input.repo} for branch "${input.branch}"${matching}`,
50
64
  fix: `x ci --branch main --repo ${input.repo} --json`,
51
65
  });
52
66
  }
@@ -55,11 +69,39 @@ export class CiRunNotFoundError extends UltimateError {
55
69
  const RUN_FLAG = { name: 'run', command: 'ci', min: 1, example: 'x ci --run 32484583944 --json' };
56
70
  const TAIL_FLAG = { name: 'tail', command: 'ci', min: 1, example: 'x ci --tail 80 --json' };
57
71
 
72
+ /**
73
+ * `--event`, read against the spec's own closed set: a typo is a refusal naming the events that
74
+ * exist, never an empty run list a reader would take for a red branch.
75
+ */
76
+ function readEvent(raw: string | undefined): (typeof CI_EVENTS)[number] | undefined {
77
+ if (raw === undefined) return undefined;
78
+ const known: readonly string[] = CI_EVENTS;
79
+ if (known.includes(raw)) return raw as (typeof CI_EVENTS)[number];
80
+ throw new BadFlagError({
81
+ flag: 'event',
82
+ command: 'ci',
83
+ reason: `${quoteArg(raw)} is not a workflow event — one of ${CI_EVENTS.join(', ')}`,
84
+ fix: 'x ci --event push --json',
85
+ });
86
+ }
87
+
58
88
  export const ciCommand: CliCommand = {
59
89
  spec: ciSpec,
60
90
  async run(ctx: CommandContext): Promise<CommandResult> {
61
- const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
91
+ // Filters are validated, and crossed with --run, before any subprocess runs: a refusal must
92
+ // not cost a gh round trip, and a filter beside --run would otherwise be silently ignored.
62
93
  const rawRun = flagString(ctx.args, 'run');
94
+ const workflow = flagString(ctx.args, 'workflow');
95
+ const event = readEvent(flagString(ctx.args, 'event'));
96
+ if (rawRun !== undefined && (workflow !== undefined || event !== undefined)) {
97
+ throw new BadFlagError({
98
+ flag: workflow !== undefined ? 'workflow' : 'event',
99
+ command: 'ci',
100
+ reason: 'filters the run list, and --run names one run already',
101
+ fix: 'x ci --run <id> --json',
102
+ });
103
+ }
104
+ const repo = await resolveRepo(ctx, 'ci', flagString(ctx.args, 'repo'));
63
105
  const rawTail = flagString(ctx.args, 'tail');
64
106
  const limit = flagBool(ctx.args, 'full')
65
107
  ? Number.POSITIVE_INFINITY
@@ -71,8 +113,11 @@ export const ciCommand: CliCommand = {
71
113
  return report(repo, viewed.run.branch, [await inspect(ctx, repo, viewed, limit)]);
72
114
  }
73
115
  const branch = flagString(ctx.args, 'branch') ?? (await currentBranch(ctx));
74
- const runs = latestPerWorkflow(await listRuns(ctx, repo, branch, RUN_LOOKBACK));
75
- if (runs.length === 0) throw new CiRunNotFoundError({ branch, repo: repo.slug });
116
+ const runs = latestPerWorkflow(
117
+ await listRuns(ctx, repo, branch, RUN_LOOKBACK, { workflow, event }),
118
+ );
119
+ if (runs.length === 0)
120
+ throw new CiRunNotFoundError({ branch, repo: repo.slug, workflow, event });
76
121
  const inspected: RunReport[] = [];
77
122
  for (const run of runs) {
78
123
  // Only a failed run is opened. A green run's jobs are 35 rows saying `success`, and fetching
@@ -128,9 +173,11 @@ async function inspect(
128
173
  const pool: readonly CiLogLine[] = own.length > 0 ? own : lines;
129
174
  const job = failed.find((candidate) => candidate.name === name);
130
175
  for (const finding of findingsFrom(pool)) {
176
+ // Keep whatever the log carried: a path-style `at` or a non-path step (now on `meta.step`).
177
+ // Inventing a locator from the CI JOB name would put a non-path in `at` — exactly the seam
178
+ // this repair closes — so when the log had nothing, the finding reads nothing.
131
179
  findings.push({
132
180
  ...finding,
133
- at: finding.at ?? name,
134
181
  // Fenced and tagged: the log is foreign text, and a `fix:` reconstructed from it is the
135
182
  // log author's instruction, not this CLI's. `commentBlock` is `x pr review`'s fence.
136
183
  fix: commentBlock(`ci-log ${name}`, [finding.fix]).join('\n'),
@@ -152,12 +199,17 @@ async function inspect(
152
199
 
153
200
  /**
154
201
  * The same block can be reached twice — once per job when the log attributes nothing, once per
155
- * attempt when a run was re-run — and two copies of one finding read as two problems.
202
+ * attempt when a run was re-run — and two copies of one finding read as two problems. The
203
+ * inverse is a finding with the same code and cause but a different `at` or `meta.step` (a
204
+ * `X_COVERAGE_BELOW` routed to two packages, the same gate step failing twice for different
205
+ * tests): they are NOT the same finding, and the key has to take every locator the gate printed
206
+ * to tell them apart. `findingKey` is the same formula `ci-log`'s per-log dedupe uses, so a
207
+ * finding that survives one cannot be silently merged by the other.
156
208
  */
157
209
  function dedupe(findings: readonly Finding[]): readonly Finding[] {
158
210
  const seen = new Set<string>();
159
211
  return findings.filter((finding) => {
160
- const key = `${finding.code} ${finding.cause}`;
212
+ const key = findingKey(finding);
161
213
  if (seen.has(key)) return false;
162
214
  seen.add(key);
163
215
  return true;
@@ -241,6 +293,7 @@ function runJson(entry: RunReport): JsonValue {
241
293
  title: entry.run.title,
242
294
  url: entry.run.url,
243
295
  createdAt: entry.run.createdAt,
296
+ headSha: entry.run.headSha,
244
297
  // Absent rather than empty on a run that was never opened: `jobs: []` on a green run would
245
298
  // claim the run has no jobs, which is a different statement from "nobody asked".
246
299
  ...(entry.jobs === undefined
package/src/output.ts CHANGED
@@ -21,7 +21,12 @@ export interface Finding {
21
21
  readonly cause: string;
22
22
  readonly fix: string;
23
23
  readonly docs?: string;
24
- /** Optional locator: a file, route, or table the finding is about. */
24
+ /**
25
+ * Optional locator: a path an agent can OPEN — a source file (its path from the repo root) or
26
+ * a table. A gate step name, a route URL, or any other identifier that is not a file is NOT
27
+ * here: it belongs on `meta` (e.g. `meta.step`), and the renderer prints it in the head where
28
+ * `ci-log` can recover it. Set iff the finding is about a file the author can edit.
29
+ */
25
30
  readonly at?: string;
26
31
  /**
27
32
  * Where the finding's TEXT came from, when not from this process. `'ci-log'` is `x ci` reading
@@ -172,7 +177,18 @@ export function renderFinding(finding: Finding, indent = ''): string {
172
177
  // Every field through `singleLine`: this is the format's only renderer for the terminal and CI
173
178
  // logs, and a `cause` carrying a newline would print a line a reader takes for a real finding.
174
179
  const code = singleLine(finding.code);
175
- const head = finding.at === undefined ? code : `${code} (${singleLine(finding.at)})`;
180
+ // The path-style locator (`at`) keeps its parenthesised form: `CODE (path)`. A non-path locator
181
+ // — today only `meta.step`, the gate step name — is printed AFTER, bracketed and keyed
182
+ // (`[step=lint]`), so `ci-log`'s existing `LOCATOR = /\(([^()]+)\)$/` still means `at` and a
183
+ // matching step regex can recover it without colliding. Either side may be absent; when BOTH
184
+ // are absent the head is the bare `CODE` it always was.
185
+ const stepValue =
186
+ typeof finding.meta === 'object' && finding.meta !== null && !Array.isArray(finding.meta)
187
+ ? (finding.meta as { readonly [key: string]: JsonValue })['step']
188
+ : undefined;
189
+ const stepLabel = typeof stepValue === 'string' ? ` [step=${singleLine(stepValue)}]` : '';
190
+ const pathLabel = finding.at === undefined ? '' : ` (${singleLine(finding.at)})`;
191
+ const head = `${code}${pathLabel}${stepLabel}`;
176
192
  const lines = [
177
193
  `${indent}${head}`,
178
194
  `${indent} cause: ${singleLine(finding.cause)}`,
@@ -37,8 +37,10 @@ const NOTHING: WorkerRetire = Object.freeze({ adopt: () => undefined, disarm: ()
37
37
 
38
38
  /**
39
39
  * Listen for `RETIRE_SIGNAL` NOW, before the boot: Bun's default for an unhandled SIGUSR2 ends the
40
- * process, so a `preStop` that lands while the pod still boots must meet this listener, which waits
41
- * for the worker and then retires it. One retire per process; a second signal joins it.
40
+ * process — except as PID 1 (a container's ENTRYPOINT), where the kernel DROPS a signal with no
41
+ * listener, so a `preStop` that lands before this line runs is lost. The chart's preStop therefore
42
+ * re-sends it every 5 s until PID 1 is gone; this listener waits for the worker and then retires it.
43
+ * One retire per process; a second signal joins it.
42
44
  *
43
45
  * Exits rather than holds, decided 2026-10-07: the `preStop` has no channel back but PID 1 itself,
44
46
  * so "retired" has to be observable as "PID 1 is gone" — and a pod that has stopped claiming serves
@@ -57,7 +57,11 @@ export type SkippedRoute = RouteFacts & {
57
57
  * re-exports the name for its own callers.
58
58
  */
59
59
  export type UnmeasuredRoute = {
60
- /** The DECLARED path, as `X_BUDGET_UNMEASURED`'s `at:` spells it, so the two rows join. */
60
+ /**
61
+ * The DECLARED path, the way the finding about it spells the route: `X_BUDGET_UNMEASURED`'s
62
+ * `meta.route` and its `cause`, so the report row and the gate's finding join. NOT its `at:` —
63
+ * `Finding.at` is the file an agent opens, and the declaring page is what that holds.
64
+ */
61
65
  readonly path: string;
62
66
  readonly reason: string;
63
67
  /**
@@ -172,7 +172,9 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
172
172
  The worker's retire, opt-in: roles.worker.retireSeconds > 0. The preStop sends PID 1 SIGUSR2 —
173
173
  the framework's retire: stop claiming, finish every held job however long it takes, abort
174
174
  nothing, then drain and exit 0 — and waits until PID 1 is gone, so the SIGTERM that follows finds
175
- nothing left to cut off. bun -e, never kill/sleep: bun is the image's ENTRYPOINT (exec form, so it
175
+ nothing left to cut off. It re-sends the signal every 5 s while it waits: PID 1 has no default
176
+ signal action, so a preStop that lands before the boot arms the listener is dropped by the kernel
177
+ (the pod then idles to its grace); a repeat joins the retire in flight. bun -e, never kill/sleep: bun is the image's ENTRYPOINT (exec form, so it
176
178
  IS PID 1). The kubelet counts the preStop against the grace period, so retireSeconds is added to
177
179
  it and is the longest a retire may take.
178
180
  */}}
@@ -183,7 +185,7 @@ nobody bound. Derived here rather than stated twice in values.yaml, where the tw
183
185
  command:
184
186
  - bun
185
187
  - -e
186
- - "process.kill(1, 'SIGUSR2'); for (;;) { try { process.kill(1, 0); } catch { break; } await Bun.sleep(1000); }"
188
+ - "process.kill(1, 'SIGUSR2'); for (let n = 1; ; n++) { try { process.kill(1, n % 5 === 0 ? 'SIGUSR2' : 0); } catch { break; } await Bun.sleep(1000); }"
187
189
  {{- end }}
188
190
  {{- end }}
189
191
  resources: {{- toYaml $cfg.resources | nindent 4 }}
@@ -128,8 +128,13 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
128
128
  };
129
129
  }
130
130
 
131
- /** A gap finding, with a fix spelled for the entry that raised it. */
132
- type Gap = (cause: string, at?: string) => Finding;
131
+ /**
132
+ * A gap finding, with a fix spelled for the entry that raised it. The second positional is the
133
+ * gate `step` name the merge is naming — matching `X_VERIFY_STEP_TIMEOUT`'s precedent at
134
+ * `verify-stalled.ts:180-197`, where a timeout finding carries the step name in `meta.step`,
135
+ * never in `at` (which is, repo-wide, a path an agent can open — `output.ts:24-25`).
136
+ */
137
+ type Gap = (cause: string, step: string, at?: string) => Finding;
133
138
 
134
139
  /**
135
140
  * The gap kinds a merge can find. `present` is a document that arrived and disagrees with the
@@ -149,13 +154,17 @@ const NEVER_ARRIVED =
149
154
  const gapsFor = (command: string): Gaps => {
150
155
  const finding =
151
156
  (fix: string): Gap =>
152
- (cause, at) => ({
153
- code: 'X_VERIFY_MERGE_INCOMPLETE',
154
- cause,
155
- fix,
156
- docs: ERROR_DOCS_URL,
157
- ...(at === undefined ? {} : { at }),
158
- });
157
+ (cause, step, at) => {
158
+ const meta: { readonly step: string } = { step };
159
+ return {
160
+ code: 'X_VERIFY_MERGE_INCOMPLETE',
161
+ cause,
162
+ fix,
163
+ docs: ERROR_DOCS_URL,
164
+ ...(at === undefined ? {} : { at }),
165
+ meta,
166
+ };
167
+ };
159
168
  return {
160
169
  present: finding(
161
170
  `${command} merge parts/*.json --json # after every CI job uploaded its part`,
@@ -354,7 +363,12 @@ export function mergeParts(
354
363
  part.steps
355
364
  .filter((step) => !declared.includes(step.name))
356
365
  .map((step) =>
357
- kinds.present(`${part.file} reports "${step.name}", which x verify does not run`),
366
+ // The unknown step's own NAME rides on `meta.step` — it is the only locator here,
367
+ // never a path. `part.file` is already in the cause.
368
+ kinds.present(
369
+ `${part.file} reports "${step.name}", which x verify does not run`,
370
+ step.name,
371
+ ),
358
372
  ),
359
373
  );
360
374
  const steps = declared.map((name) =>