From 2b67d0d1967cc538161f596f36c041fb387ba967 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 21 Aug 2026 06:30:36 +0000 Subject: [PATCH] fix(pm): render a ci-failure `fix` as the one remedy it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `fix` arrives from `classifyTransportProbe` as the wrapped lines of a single sentence, but both printers in `scripts/pm/ci-failure.mjs` prefixed every element with `fix:`, so one remedy rendered as three and a reader counting "how many things do I have to do" counted wrong. Prefix the first line only and pad the continuations under it — the idiom `check-half-states.mjs`, the file that PRODUCES these verdicts, already prints with. Both call sites now go through one helper so they cannot drift apart again. The lines are padded rather than joined and re-wrapped: the `bad-credential-anon-reachable` remedy is a copy-pasteable command followed by an annotation that carries its own indentation and an arrow pointing back at the command, and re-flowing would swallow the command into the prose. Presentation only — no exit code, no verdict, no classification changes. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01DdCnBGcHeufjrq7drTD3wt --- scripts/pm/ci-failure.mjs | 83 +++++++++++++++++++++++++++++++++++++-- 1 file changed, 80 insertions(+), 3 deletions(-) diff --git a/scripts/pm/ci-failure.mjs b/scripts/pm/ci-failure.mjs index f70ceb93d0..d4014e8186 100644 --- a/scripts/pm/ci-failure.mjs +++ b/scripts/pm/ci-failure.mjs @@ -2264,6 +2264,51 @@ async function selfTest() { ['repo-scope-refused', ['rate:anon', 'repo']], ); + // -- renderFixLines: one remedy is one `fix:` marker (#10362) ------------- + // Driven through the REAL producer, not a hand-written array: what needs + // pinning is that this file renders what `classifyTransportProbe` actually + // emits. A fixture would only restate the printer's own assumption about the + // shape, which is the assumption that was wrong. + const refusedFix = classifyTransportProbe({ + token: 'ghp_x', + authed: { status: 200, rateLimitRemaining: 4999 }, + repo: { status: 403, rateLimitRemaining: 4999 }, + }).fix; + t('the producer still wraps this remedy across several lines', refusedFix.length > 1, true); + t( + 'a multi-line remedy renders with exactly ONE fix: marker', + renderFixLines(refusedFix).filter((l) => l.startsWith(' fix: ')).length, + 1, + ); + t( + 'its continuations are padded under the marker rather than re-marked', + renderFixLines(refusedFix).slice(1).every((l) => l.startsWith(' ') && !l.includes('fix: ')), + true, + ); + t('rendering neither adds a line nor drops one', renderFixLines(refusedFix).length, refusedFix.length); + + // The branch that makes re-wrapping unsafe: a copy-pasteable command whose + // annotation carries its own indentation and an arrow pointing back at it. + const anonFix = classifyTransportProbe({ + token: 'ghp_x', + authed: { status: 401 }, + anon: { status: 200, rateLimitRemaining: 59 }, + }).fix; + t( + 'the copy-pasteable command keeps the marked line to itself', + renderFixLines(anonFix)[0], + ' fix: GITHUB_TOKEN= GH_TOKEN= node scripts/pm/check-half-states.mjs', + ); + t( + 'the annotation keeps its own indent, so its arrow still points at that command', + renderFixLines(anonFix)[1].startsWith(' ↑'), + true, + ); + + t('an empty fix renders nothing at all', renderFixLines([]), []); + t('an absent fix renders nothing at all', renderFixLines(undefined), []); + t('a one-line remedy is just the marked line', renderFixLines(['do the thing']), [' fix: do the thing']); + if (failures.length > 0) { console.error(`✗ ci-failure --self-test (${failures.length} failure(s)):\n`); for (const f of failures) console.error(` • ${f}`); @@ -2295,10 +2340,42 @@ async function selfTest() { ' annotations carried none, a log that anchored nothing is still a shortfall rather than a\n' + ' manufactured answer, a 410 is named as expired retention rather than as an absence of\n' + ' evidence, a CONNECT refusal lands as transport with the blocked host to report, and a\n' + - ' tail with no `##[error]` in it is labelled a window rather than an anchor.', + ' tail with no `##[error]` in it is labelled a window rather than an anchor. And a `fix`\n' + + ' renders as the ONE remedy it is: a single `fix:` marker with its continuations padded\n' + + ' under it, keeping a copy-pasteable command on a line of its own.', ); } +/** + * Render a verdict's `fix` as the ONE remedy it is: the marker goes on the first + * line, and every continuation is padded to sit under it (#10362). + * + * `fix` arrives as the wrapped lines of a single sentence — never as a list of + * independent remedies (measured across every branch of + * `classifyTransportProbe`). Marking each element `fix:` rendered one remedy as + * three, and a reader counting "how many things do I have to do" counted wrong. + * + * The lines are padded rather than re-flowed because the producer's breaks are + * load-bearing in at least one branch: `bad-credential-anon-reachable` is a + * copy-pasteable command followed by an annotation carrying its own + * indentation, whose `↑` points at the command above it. Joining and + * re-wrapping would swallow the command into the prose and leave the arrow + * pointing at nothing, so continuations keep their own leading whitespace. + * + * This is the idiom `check-half-states.mjs` — the file that PRODUCES these + * verdicts — already prints with. Both of this file's printers go through here + * so the two cannot drift apart again, which is how the second one came to + * exist (#10155 matched the entry point's spelling rather than fixing it in an + * unrelated PR). + * + * Presentation only: no exit code, no verdict, no claim about the tree. + */ +function renderFixLines(fix) { + const lines = fix ?? []; + if (lines.length === 0) return []; + return [` fix: ${lines[0]}`, ...lines.slice(1).map((l) => (l ? ` ${l}` : ''))]; +} + /** * The mid-walk net's printer (#10155). The DECISION is `midWalkVerdict`, pure * and self-tested; what lives here is the one thing it cannot be handed — the @@ -2320,7 +2397,7 @@ async function reportMidWalkFailure(error, stage) { const decision = midWalkVerdict({ probe, error, read: error?.read ?? null, stage }); console.error(`\nci-failure: ${decision.verdict} — ${decision.headline}\n`); for (const line of decision.detail) console.error(line ? ` ${line}` : ''); - for (const line of decision.fix ?? []) console.error(` fix: ${line}`); + for (const line of renderFixLines(decision.fix)) console.error(line); console.error(" Piping reports the PIPE's status, so `... | tail` reads green either way. Use `echo \"EXIT=$?\"`."); process.exit(decision.exit); } @@ -2377,7 +2454,7 @@ if (!invokedDirectly) { if (probe.kind !== 'reachable') { console.error(`ci-failure: PREREQUISITE NOT MET — ${probe.headline}`); for (const line of probe.detail ?? []) console.error(` ${line}`); - for (const line of probe.fix ?? []) console.error(` fix: ${line}`); + for (const line of renderFixLines(probe.fix)) console.error(line); console.error(` (Exit ${EXIT_PREREQUISITE_NOT_MET}. This classifies the ENVIRONMENT, not the tree.)`); process.exit(EXIT_PREREQUISITE_NOT_MET); }