@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 +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 +13 -1
- package/src/cmd-ci-spec.ts +18 -1
- package/src/cmd-ci.ts +63 -10
- package/src/output.ts +18 -2
- package/src/serve-retire.ts +4 -2
- package/src/static-report.ts +5 -1
- package/src/templates/scaffold-helm-templates.ts +4 -2
- 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.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.
|
|
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.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
|
-
|
|
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,
|
|
@@ -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',
|
package/src/cmd-ci-spec.ts
CHANGED
|
@@ -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:
|
|
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: {
|
|
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
|
-
|
|
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(
|
|
75
|
-
|
|
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 =
|
|
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
|
-
/**
|
|
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/serve-retire.ts
CHANGED
|
@@ -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
|
|
41
|
-
*
|
|
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
|
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
|
/**
|
|
@@ -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.
|
|
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 (
|
|
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 }}
|
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) =>
|