Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion packages/cli/src/budgets-unmeasured.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,8 +24,13 @@ const refused = (code: string, cause: string): UnmeasuredRoute => ({
actor: ACTOR,
});

/** The file `/console` is declared by in this fixture — the locator an agent opens. */
const CONSOLE_FILE = 'apps/web/app/console/page.tsx';

const only = (entry: UnmeasuredRoute) => {
const findings = checkBudgets(manifest, { routes: [] }, [entry]);
const findings = checkBudgets(manifest, { routes: [] }, [entry], (url) =>
url === '/console' ? CONSOLE_FILE : undefined,
);
expect(findings).toHaveLength(1);
return findings[0];
};
Expand All @@ -42,6 +47,9 @@ describe('unit · a route the measurement actor could not render', () => {
expect(finding?.fix).toContain('defineMeasurementActor(() => userActor({');
expect(finding?.fix).toContain("orgId: '<");
expect(finding?.fix).toContain("roles: ['<");
// `at` is the file to open, never the route URL — and the route is not lost for that.
expect(finding?.at).toBe(CONSOLE_FILE);
expect(finding?.meta?.['route']).toBe('/console');
});
}

Expand Down
28 changes: 23 additions & 5 deletions packages/cli/src/budgets-unmeasured.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,13 +39,26 @@ const ACTOR_REFUSALS: ReadonlySet<string> = new Set([
'X_TENANCY_ACTOR_ORG_REQUIRED',
]);

function ownCodeFinding(url: string, entry: UnmeasuredRoute): Finding {
/**
* The locator every finding here carries: `at` is the FILE an agent opens, never the route URL —
* `output.ts`'s `Finding.at`, and `verify-checks.ts` reads it as the path to open. The URL is a
* fact of its own and rides beside it in `meta.route`, so `--json` loses nothing by the move.
*
* OMITTED, not filled with something, when no file declares the route: a URL is not a path anyone
* can open, and `at` that names nothing opens nothing. The cause and the fix both name the URL.
*/
const locatorOf = (file: string | undefined, url: string): Pick<Finding, 'at' | 'meta'> => ({
...(file === undefined ? {} : { at: file }),
meta: { route: url },
});

function ownCodeFinding(url: string, file: string | undefined, entry: UnmeasuredRoute): Finding {
return {
code: entry.code ?? 'X_BUDGET_UNMEASURED',
cause: entry.cause ?? entry.reason,
fix: entry.fix ?? `x build --target static --json # its "unmeasured" list has ${url}`,
docs: ERROR_DOCS_URL,
at: url,
...locatorOf(file, url),
};
}

Expand All @@ -57,15 +70,20 @@ const refusalOf = (entry: UnmeasuredRoute): string =>
* A build ran and has no row for `url`. `entry` is the report's account of why, when it has one:
* an instruction under its own code, an actor edit, or — for a failure that is a fact about the
* build's environment — the report, with the refusal already in the cause.
*
* `file` is the file that declares `url`, resolved by the caller (`budgets.ts` asks the loaded
* route table); `undefined` when no route file claims it, and the finding then carries no locator
* rather than a URL that opens nothing.
*/
export function builtUnmeasuredFinding(
url: string,
declared: string,
statsFile: string,
entry: UnmeasuredRoute | undefined,
file: string | undefined,
): Finding {
if (entry?.code !== undefined && REPORTED_BY_OWN_CODE.has(entry.code)) {
return ownCodeFinding(url, entry);
return ownCodeFinding(url, file, entry);
}
const base = `${url} declares a ${declared} budget and ${statsFile} has no row for it, so the build ran and could not weigh it`;
if (entry?.code !== undefined && ACTOR_REFUSALS.has(entry.code)) {
Expand All @@ -76,7 +94,7 @@ export function builtUnmeasuredFinding(
// The paste: one declaration in the one file every build imports.
fix: "defineMeasurementActor(() => userActor({ id: 'measure', orgId: '<an org your dev seed creates>', roles: ['<the role this page requires>'] })) # in app.config.ts",
docs: ERROR_DOCS_URL,
at: url,
...locatorOf(file, url),
};
}
return {
Expand All @@ -86,6 +104,6 @@ export function builtUnmeasuredFinding(
// route it could not weigh, and why.
fix: `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`,
docs: ERROR_DOCS_URL,
at: url,
...locatorOf(file, url),
};
}
43 changes: 40 additions & 3 deletions packages/cli/src/budgets.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,13 +69,35 @@ const jsBytesOf = async (html: string, out: string): Promise<number> =>

describe('unit · budgets', () => {
test('a declared JS budget with no measurement is a finding, not a pass', () => {
const findings = checkBudgets(manifestOf(route('/pricing', { js: '20kb' })), undefined);
const findings = checkBudgets(
manifestOf(route('/pricing', { js: '20kb' })),
undefined,
[],
// The locator is the file an agent opens, never the URL; the route is still reachable
// under `--json` in `meta.route`, and the cause already spells it for the terminal.
(url) => (url === '/pricing' ? 'apps/web/site/pricing/page.tsx' : undefined),
);
expect(findings.map((finding) => finding.code)).toEqual(['X_BUDGET_UNMEASURED']);
expect(findings[0]?.cause).toContain('/pricing');
expect(findings[0]?.cause).toContain('JS');
expect(findings[0]?.cause).toContain(BUILD_STATS_FILE);
expect(findings[0]?.fix).toBe('x build --target static --json && x verify --json');
expect(findings[0]?.at).toBe('/pricing');
expect(findings[0]?.at).toBe('apps/web/site/pricing/page.tsx');
expect(findings[0]?.meta?.['route']).toBe('/pricing');
});

// A route no file declares has no path to open, so `at` is omitted rather than filled with the
// URL — and the route is not lost for that: `meta.route` carries it either way.
test('a budgeted route no file declares carries no locator, and still names its route in meta', () => {
const findings = checkBudgets(
manifestOf(route('/pricing', { js: '20kb' })),
undefined,
[],
() => undefined,
);
expect(findings[0]?.at).toBeUndefined();
expect(findings[0]?.meta?.['route']).toBe('/pricing');
expect(findings[0]?.cause).toContain('/pricing');
});

// Two conditions, two instructions. "No build has run" is closed by one command for every route
Expand Down Expand Up @@ -114,6 +136,18 @@ describe('unit · budgets', () => {
expect(findings[0]?.cause).toContain('/dash -> chart.ts');
});

// `at` is the same file the fix edits — one place, not two that can drift apart.
test('the locator and the fix name one file', () => {
const findings = checkBudgets(
manifestOf(route('/dash', { js: '10kb' })),
stats({ path: '/dash', jsBytes: 40_961 }),
[],
() => 'apps/web/app/dash/page.tsx',
);
expect(findings[0]?.at).toBe('apps/web/app/dash/page.tsx');
expect(findings[0]?.fix).toContain('edit apps/web/app/dash/page.tsx');
});

test('the JS fix is the paste: the route file, the next whole kb, the measured bytes and a why', () => {
const findings = checkBudgets(
manifestOf(route('/dash', { js: '10kb' })),
Expand Down Expand Up @@ -340,8 +374,11 @@ describe('unit · an ABSENT stats file is every budget unmeasured, not nothing t
route('/about'),
);
// The step hands `checkBudgets` exactly this — the reader's answer, not a stand-in for it.
// No route file declares these, so `at` is absent and the routes ride in `meta.route`: the
// one-per-budgeted-route count is still exactly what the assertion below holds.
const findings = checkBudgets(manifest, await readBuildStats(root));
expect(findings.map((finding) => finding.at)).toEqual(['/', '/dash']);
expect(findings.map((finding) => finding.meta?.['route'])).toEqual(['/', '/dash']);
expect(findings.map((finding) => finding.at)).toEqual([undefined, undefined]);
expect(new Set(findings.map((finding) => finding.code))).toEqual(
new Set(['X_BUDGET_UNMEASURED']),
);
Expand Down
34 changes: 24 additions & 10 deletions packages/cli/src/budgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,27 +117,34 @@ function exceededJsFix(url: string, file: string | undefined, bytes: number): st
* run and could not weigh this one, which is `PrerenderReport.unmeasured`'s question and not a
* second build's. Reporting the first as the second is what sends a reader to re-run a build that
* already did everything it was going to do.
*
* `fileOf` names the file an agent opens to fix this route, exactly as it does for
* `X_BUDGET_EXCEEDED`: `Finding.at` is a PATH, never a URL (`output.ts`, `verify-checks.ts`),
* so the route rides beside it in `meta.route` and every cause below still spells the URL.
*/
function unmeasuredFinding(
url: string,
declared: string,
stats: BuildStats | undefined,
unmeasured: readonly UnmeasuredRoute[],
fileOf: RouteFileOf,
): Finding {
const file = fileOf(url);
if (stats?.stale === true) {
return {
code: 'X_BUDGET_UNMEASURED',
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`,
fix: 'x build --target static --json && x verify --json',
docs: ERROR_DOCS_URL,
at: url,
...(file === undefined ? {} : { at: file }),
meta: { route: url },
};
}
// A build ran: the report's account of this route decides the instruction
// (`budgets-unmeasured.ts`) — its own code, an actor edit, or the report.
if (stats !== undefined) {
const entry = unmeasured.find((one) => one.path === url);
return builtUnmeasuredFinding(url, declared, BUILD_STATS_FILE, entry);
return builtUnmeasuredFinding(url, declared, BUILD_STATS_FILE, entry, file);
}
return {
code: 'X_BUDGET_UNMEASURED',
Expand All @@ -147,7 +154,8 @@ function unmeasuredFinding(
// the one caller of `writeBuildStats`.
fix: 'x build --target static --json && x verify --json',
docs: ERROR_DOCS_URL,
at: url,
...(file === undefined ? {} : { at: file }),
meta: { route: url },
};
}

Expand All @@ -161,8 +169,9 @@ function unmeasuredFinding(
* and can be absent for the same reason; with it, a route whose measurement failed on a code in
* `REPORTED_BY_OWN_CODE` (`budgets-unmeasured.ts`) is reported under that code, with the build's own cause and fix.
*
* `fileOf` names the file an `X_BUDGET_EXCEEDED` fix edits: the step runs after the app loaded,
* so render's route table already holds it. A test hands its own.
* `fileOf` names the file an agent opens for a route's finding — the locator `Finding.at` carries
* and the file `X_BUDGET_EXCEEDED`'s fix edits: the step runs after the app loaded, so render's
* route table already holds it. A test hands its own.
*/
export function checkBudgets(
manifest: Manifest,
Expand All @@ -183,19 +192,24 @@ export function checkBudgets(
const js = jsBudgetOf(route);
if (measured === undefined) {
if (js !== null) {
findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured));
findings.push(unmeasuredFinding(route.url, 'JS', stats, unmeasured, fileOf));
}
continue;
}
if (js !== null && measured.jsBytes > js) {
const meta = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
// `at` is the file an agent opens — the SAME resolution `exceededJsFix` edits, so the
// locator and the instruction name one place. The route URL it belongs to is in `cause`
// (`budgets-charged.test.ts` holds the per-file breakdown in `meta` only when a row carries
// one — adding a `route` key would conflict with that contract).
const file = fileOf(route.url);
const charged = chargedMeta(measured.jsBytes, measured.charged, measured.inlineJsBytes);
findings.push({
code: 'X_BUDGET_EXCEEDED',
cause: `${route.url} ships ${formatBytes(measured.jsBytes)} of JS (minified, uncompressed) over a ${formatBytes(js)} budget via ${chainOf(measured)}${chargedClause(measured.charged, measured.inlineJsBytes)}`,
fix: exceededJsFix(route.url, fileOf(route.url), measured.jsBytes),
fix: exceededJsFix(route.url, file, measured.jsBytes),
docs: ERROR_DOCS_URL,
at: route.url,
...(meta === undefined ? {} : { meta }),
...(file === undefined ? {} : { at: file }),
...(charged === undefined ? {} : { meta: charged }),
});
}
}
Expand Down
76 changes: 62 additions & 14 deletions packages/cli/src/ci-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// this file undoes the transport and reconstructs the `Finding`, rather than printing a wall of
// text and asking the reader to spot the code in it.

import type { Finding } from './output';
import type { Finding, JsonValue } from './output';

/** One log line, with the two columns Actions prefixes every line with taken off. */
export interface CiLogLine {
Expand Down Expand Up @@ -67,11 +67,52 @@ export function jobsInLog(lines: readonly CiLogLine[]): readonly string[] {
}

const CODE = /^(X_[A-Z][A-Z0-9_]*)/;
const LOCATOR = /\(([^()]+)\)$/;
// The ONE way a `Finding.at` — a path an agent can open — returns from a log. The renderer
// prints it as the parenthesised expression in the head (`renderFinding`, `output.ts`).
//
// Anchored to the head minus a trailing `[step=…]`, NOT to the end of the line:
// `X_VERIFY_STEP_TIMEOUT` sets both `at` and `meta.step`, so that head reads
// `CODE (path) [step=unit]` — an end-anchor matches only what follows the brackets, the match
// fails, and the path is dropped. With no step the optional group is absent and the
// parenthesised group is still the last thing on the line.
const LOCATOR = /\(([^()]+)\)(?=\s*(?:\[step=[^[\]]+\]\s*)?$)/;
// The second, non-path locator the renderer prints on the head — `meta.step` in the same
// brackets-keyed form (`[step=lint]`) `renderFinding` writes — so a merge gap's gate step name
// or any future non-path identifier survives the round-trip too. Its own brackets are what
// `LOCATOR`'s lookahead stands aside for, so the two cannot collide in one head.
const STEP_LOCATOR = /\[step=([^[\]]+)\]\s*$/;

const after = (text: string, label: string): string | undefined =>
text.trim().startsWith(label) ? text.trim().slice(label.length).trim() : undefined;

// The `meta.step` locator the renderer prints in the head (`output.ts:185-189`): a gate step name
// the finding carries on `meta`, never on `at`. Total on every shape `Finding.meta` may take —
// the field is optional, and a malformed `meta` (a non-object value, an array) is read as "no
// step", the same answer an absent `meta` gets.
const stepOf = (finding: Finding): string => {
if (typeof finding.meta === 'object' && finding.meta !== null && !Array.isArray(finding.meta)) {
const value = (finding.meta as { readonly [key: string]: JsonValue })['step'];
return typeof value === 'string' ? value : '';
}
return '';
};

/**
* The dedupe key a finding is told apart by. Two findings are the same only if every locator
* the renderer prints agrees on them — `code`, `cause`, `at`, and `meta.step`. Ci-log's per-log
* dedupe (`findingsFrom` below) and `x ci`'s cross-log dedupe (`cmd-ci.ts`) both build the key
* off this formula, so a finding that survives one cannot be silently merged by the other.
*
* A re-run appends a second copy of the same block; two identical findings would read as two
* problems where the job has one. A `X_COVERAGE_BELOW` routed to two different packages is the
* opposite — two findings, where a key of code and cause alone folds the second into a duplicate.
* `fix` is deliberately not in it: the same block reprints its fix line, and `x ci` fences it
* differently per job, so keying on it would report a re-run twice.
*/
export function findingKey(finding: Finding): string {
return `${finding.code}\0${finding.cause}\0${finding.at ?? ''}\0${stepOf(finding)}`;
}

/**
* `renderFinding`'s output, read backwards. A code line ALONE is not a finding — prose naming a
* code, a summary counting them and a `fix:` quoting one all start with `X_`, and reconstructing
Expand All @@ -93,19 +134,26 @@ export function findingsFrom(lines: readonly CiLogLine[]): readonly Finding[] {
if (cause === undefined || fix === undefined) continue;
const code = head[1] ?? '';
const at = LOCATOR.exec(texts[index] ?? '')?.[1];
const step = STEP_LOCATOR.exec(texts[index] ?? '')?.[1];
const docs = after(texts[index + 3] ?? '', 'docs:');
const key = `${code}\0${cause}`;
// A re-run appends a second copy of the same block; two identical findings would read as two
// problems where the job has one.
if (!seen.has(key)) {
seen.add(key);
out.push({
code,
cause,
fix,
...(at === undefined ? {} : { at }),
...(docs === undefined ? {} : { docs }),
});
// Step name and any other reconstructed structured fact ride on `meta`, so the
// reconstructed finding has a place to put a non-path locator `at` cannot carry.
const meta = step === undefined ? undefined : { step };
// Built once, then keyed: `findingKey` reads `code`, `cause`, `at` and `meta.step` off it,
// so the per-log dedupe here and the across-log dedupe in `cmd-ci.ts` agree on what one
// finding is. `X_VERIFY_MERGE_INCOMPLETE` arrives here once per missing step, and only the
// step name tells them apart.
const finding: Finding = {
code,
cause,
fix,
...(at === undefined ? {} : { at }),
...(meta === undefined ? {} : { meta }),
...(docs === undefined ? {} : { docs }),
};
if (!seen.has(findingKey(finding))) {
seen.add(findingKey(finding));
out.push(finding);
}
index += 2;
}
Expand Down
Loading
Loading