@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 +28 -28
- package/src/budgets-unmeasured.ts +23 -5
- package/src/budgets.ts +24 -10
- package/src/ci-log.ts +62 -14
- package/src/ci-runs.ts +5 -1
- package/src/cmd-ci.ts +12 -4
- package/src/output.ts +18 -2
- package/src/static-report.ts +5 -1
- package/src/verify-merge.ts +24 -10
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "27.
|
|
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.
|
|
43
|
-
"@ultimat3/admin": "27.
|
|
44
|
-
"@ultimat3/ai": "27.
|
|
45
|
-
"@ultimat3/auth": "27.
|
|
46
|
-
"@ultimat3/cache": "27.
|
|
47
|
-
"@ultimat3/core": "27.
|
|
48
|
-
"@ultimat3/db": "27.
|
|
49
|
-
"@ultimat3/entity": "27.
|
|
50
|
-
"@ultimat3/flags": "27.
|
|
51
|
-
"@ultimat3/http": "27.
|
|
52
|
-
"@ultimat3/i18n": "27.
|
|
53
|
-
"@ultimat3/jobs": "27.
|
|
54
|
-
"@ultimat3/mail": "27.
|
|
55
|
-
"@ultimat3/manifest": "27.
|
|
56
|
-
"@ultimat3/mcp": "27.
|
|
57
|
-
"@ultimat3/money": "27.
|
|
58
|
-
"@ultimat3/notify": "27.
|
|
59
|
-
"@ultimat3/policy": "27.
|
|
60
|
-
"@ultimat3/pwa": "27.
|
|
61
|
-
"@ultimat3/query": "27.
|
|
62
|
-
"@ultimat3/realtime": "27.
|
|
63
|
-
"@ultimat3/render": "27.
|
|
64
|
-
"@ultimat3/schema": "27.
|
|
65
|
-
"@ultimat3/seo": "27.
|
|
66
|
-
"@ultimat3/storage": "27.
|
|
67
|
-
"@ultimat3/testing": "27.
|
|
68
|
-
"@ultimat3/time": "27.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
165
|
-
*
|
|
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
|
-
|
|
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,
|
|
209
|
+
fix: exceededJsFix(route.url, file, measured.jsBytes),
|
|
196
210
|
docs: ERROR_DOCS_URL,
|
|
197
|
-
at:
|
|
198
|
-
...(
|
|
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
|
-
|
|
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
|
-
|
|
98
|
-
//
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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
|
-
|
|
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)}`,
|
package/src/static-report.ts
CHANGED
|
@@ -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
|
-
/**
|
|
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
|
/**
|
package/src/verify-merge.ts
CHANGED
|
@@ -128,8 +128,13 @@ export function parsePart(file: string, text: string, command: string = GATE_COM
|
|
|
128
128
|
};
|
|
129
129
|
}
|
|
130
130
|
|
|
131
|
-
/**
|
|
132
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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
|
-
|
|
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) =>
|