@ultimat3/cli 27.13.2 → 27.14.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "27.13.2",
3
+ "version": "27.14.0",
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.0",
43
+ "@ultimat3/admin": "27.14.0",
44
+ "@ultimat3/ai": "27.14.0",
45
+ "@ultimat3/auth": "27.14.0",
46
+ "@ultimat3/cache": "27.14.0",
47
+ "@ultimat3/core": "27.14.0",
48
+ "@ultimat3/db": "27.14.0",
49
+ "@ultimat3/entity": "27.14.0",
50
+ "@ultimat3/flags": "27.14.0",
51
+ "@ultimat3/http": "27.14.0",
52
+ "@ultimat3/i18n": "27.14.0",
53
+ "@ultimat3/jobs": "27.14.0",
54
+ "@ultimat3/mail": "27.14.0",
55
+ "@ultimat3/manifest": "27.14.0",
56
+ "@ultimat3/mcp": "27.14.0",
57
+ "@ultimat3/money": "27.14.0",
58
+ "@ultimat3/notify": "27.14.0",
59
+ "@ultimat3/policy": "27.14.0",
60
+ "@ultimat3/pwa": "27.14.0",
61
+ "@ultimat3/query": "27.14.0",
62
+ "@ultimat3/realtime": "27.14.0",
63
+ "@ultimat3/render": "27.14.0",
64
+ "@ultimat3/schema": "27.14.0",
65
+ "@ultimat3/seo": "27.14.0",
66
+ "@ultimat3/storage": "27.14.0",
67
+ "@ultimat3/testing": "27.14.0",
68
+ "@ultimat3/time": "27.14.0",
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,
package/src/cmd-ci.ts CHANGED
@@ -6,7 +6,7 @@
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
12
  import { ciSpec, TAIL_LINES } from './cmd-ci-spec';
@@ -128,9 +128,11 @@ async function inspect(
128
128
  const pool: readonly CiLogLine[] = own.length > 0 ? own : lines;
129
129
  const job = failed.find((candidate) => candidate.name === name);
130
130
  for (const finding of findingsFrom(pool)) {
131
+ // Keep whatever the log carried: a path-style `at` or a non-path step (now on `meta.step`).
132
+ // Inventing a locator from the CI JOB name would put a non-path in `at` — exactly the seam
133
+ // this repair closes — so when the log had nothing, the finding reads nothing.
131
134
  findings.push({
132
135
  ...finding,
133
- at: finding.at ?? name,
134
136
  // Fenced and tagged: the log is foreign text, and a `fix:` reconstructed from it is the
135
137
  // log author's instruction, not this CLI's. `commentBlock` is `x pr review`'s fence.
136
138
  fix: commentBlock(`ci-log ${name}`, [finding.fix]).join('\n'),
@@ -152,12 +154,17 @@ async function inspect(
152
154
 
153
155
  /**
154
156
  * 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.
157
+ * attempt when a run was re-run — and two copies of one finding read as two problems. The
158
+ * inverse is a finding with the same code and cause but a different `at` or `meta.step` (a
159
+ * `X_COVERAGE_BELOW` routed to two packages, the same gate step failing twice for different
160
+ * tests): they are NOT the same finding, and the key has to take every locator the gate printed
161
+ * to tell them apart. `findingKey` is the same formula `ci-log`'s per-log dedupe uses, so a
162
+ * finding that survives one cannot be silently merged by the other.
156
163
  */
157
164
  function dedupe(findings: readonly Finding[]): readonly Finding[] {
158
165
  const seen = new Set<string>();
159
166
  return findings.filter((finding) => {
160
- const key = `${finding.code} ${finding.cause}`;
167
+ const key = findingKey(finding);
161
168
  if (seen.has(key)) return false;
162
169
  seen.add(key);
163
170
  return true;
@@ -241,6 +248,7 @@ function runJson(entry: RunReport): JsonValue {
241
248
  title: entry.run.title,
242
249
  url: entry.run.url,
243
250
  createdAt: entry.run.createdAt,
251
+ headSha: entry.run.headSha,
244
252
  // Absent rather than empty on a run that was never opened: `jobs: []` on a green run would
245
253
  // claim the run has no jobs, which is a different statement from "nobody asked".
246
254
  ...(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)}`,
@@ -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
  /**
@@ -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) =>