diff --git a/scripts/bump-objectui.sh b/scripts/bump-objectui.sh index 552c3afb19..cbea159bbf 100755 --- a/scripts/bump-objectui.sh +++ b/scripts/bump-objectui.sh @@ -27,6 +27,14 @@ # failed run must leave no half-applied state: `.objectui-sha` is byte-identical # to what it was before the run (#10797). # +# It also refuses — before writing anything, same invariant — when the OLD..NEW +# range cannot be walked in the objectui checkout and a deepen does not repair +# it. The changeset's bump LEVEL comes from what objectui declared over that +# range; with the range unreadable there is nothing to declare, and the degraded +# entry this used to emit carried the default level (`patch`) into published +# CHANGELOG text as if it were one (#14178). Deepen the checkout and re-run, or +# pass `--no-changeset` to move the pin while claiming nothing about the range. +# # Env: # CONSOLE_BUMP=major|minor|patch # force the changeset bump type (default: auto — # # the HIGHEST level objectui itself declared in the @@ -34,12 +42,14 @@ # CONSOLE_CHANGES_MAX= # cap the rendered list (default 100). A cap that # # fires says so, with the real count — never silently. # OBJECTUI_NO_DEEPEN=1 # do NOT run 'git fetch --unshallow' on the objectui -# # checkout when the pin range is truncated inside it. +# # checkout when the pin range cannot be walked in it — +# # whether an endpoint is absent or the history stops +# # inside the range (#9408 / #14178, one remedy for both). # # Default is to deepen: measured on objectui the fetch # # costs ~6s and ~4MB and turns a 110-commit walk into # # the true 191 (#9408). Set this offline, or when the -# # checkout must not be touched — the bump then takes -# # the DEGRADED path and says why. +# # checkout must not be touched — the bump then REFUSES +# # and says why, rather than deriving a weaker record. # # Assumes sibling layout: # ~/work/objectui @@ -402,91 +412,159 @@ if [[ "$OLD_SHA" == "$NEW_SHA" ]]; then exit 0 fi -# FIRST MUTATION OF THE WORKING TREE. Everything read out of the objectui commit -# was read above, and an unreadable commit already refused — nothing below this -# line can fail on a read of `$NEW_SHA` that has not already been attempted -# (#10797). Keep it that way: a new `git -C "$OBJECTUI_ROOT" …` added after this -# point re-opens exactly the half-applied write this ordering exists to prevent. -echo "$NEW_SHA" > "${FRAMEWORK_ROOT}/.objectui-sha" -echo "→ objectui pin: ${OLD_SHA:0:12} → ${NEW_SHA:0:12}${REACH_TAG}" - -# --- Emit the @objectstack/console changeset for the frontend delta ---------- -CS_FILE="" -if [[ "$NO_CHANGESET" -eq 0 ]]; then - # Can we walk the OLD..NEW range in the objectui checkout? (A shallow clone or - # a first-ever pin may not have OLD reachable — degrade to the tip subject, - # and SAY SO in the artifact: a degraded list and a complete one must never - # look alike, #4731.) - # - # THE TEST IS WALK COMPLETENESS, NOT OBJECT PRESENCE (#9408). It used to be - # `git cat-file -e OLD_SHA` — "does the OLD endpoint exist" — which is a - # different question, and the gap between them is measured: on the bump that - # landed `.changeset/console-82a94170c405.md` that test PASSED against a - # history truncated at commit 110 of 191, so this guard set RANGE_OK=1, the - # degraded path below never fired, and the digest exited 0 on a record - # crediting 36 of its 119 entries to one commit that adds exactly one. A - # truncated history is worse than an absent endpoint precisely because it - # ANSWERS: git shows its oldest visible commit as parentless, diffs it against - # the empty tree, and that one commit absorbs a whole batch. - # - # The question is asked IN THE DIGEST (`--check-walkable`) so there is one - # implementation of the rule rather than a shell copy that can drift from the - # thing it guards — see `findRangeTruncation`. Exit 2 = an endpoint is missing, - # 3 = the endpoints are here but the history stops inside the range. +# --- CAN THE OLD..NEW RANGE BE WALKED? Asked BEFORE the first mutation -------- +# (#9408 for the question, #14178 for where it is asked and what a "no" costs.) +# +# THE TEST IS WALK COMPLETENESS, NOT OBJECT PRESENCE (#9408). It used to be +# `git cat-file -e OLD_SHA` — "does the OLD endpoint exist" — which is a +# different question, and the gap between them is measured: on the bump that +# landed `.changeset/console-82a94170c405.md` that test PASSED against a history +# truncated at commit 110 of 191, so the guard reported a walkable range, the +# degraded path never fired, and the digest exited 0 on a record crediting 36 of +# its 119 entries to one commit that adds exactly one. A truncated history is +# worse than an absent endpoint precisely because it ANSWERS: git shows its +# oldest visible commit as parentless, diffs it against the empty tree, and that +# one commit absorbs a whole batch. +# +# The question is asked IN THE DIGEST (`--check-walkable`) so there is one +# implementation of the rule rather than a shell copy that can drift from the +# thing it guards — see `findRangeTruncation` and `findAbsentEndpoints`. Exit +# 2 = an endpoint is missing, 3 = the endpoints are here but the history stops +# inside the range. +# +# ONE FAILURE, TWO CAUSES, ONE REMEDY SHAPE (#14178). 2 used to skip the deepen +# entirely: the digest's endpoint guard returned before `findRangeTruncation` +# ran, so a shallow clone whose boundary sits AFTER the old pin — what +# `git clone --depth N` produces — never reached the fetch that would have +# repaired it. Both causes now take the same path: deepen, then RE-ASK. +# +# AND A "no" AFTER THAT REFUSES. Measured on a shallow checkout, same range and +# same command before and after `git fetch --unshallow`: the degraded record +# carried `patch` where the range declared `minor`, 15 declared-breaking entries +# were invisible in it, the ADR-0087 disposition prompt never fired (it keys on a +# declared-breaking changeset, and the degraded one declares none), and the run +# exited 0 having written and offered to commit it. That level publishes into +# @objectstack/console's CHANGELOG and the curated release notes, where it reads +# as a declaration. No record beats a wrong one (triage ruling, 2026-09-01), so +# the unwalkable range is now a REFUSAL rather than a fourth kind of artifact. +# +# ASKED HERE, above the pin write, for the #10797 invariant: a run that refuses +# must leave `.objectui-sha` byte-identical. The deepen this block may perform is +# additive to the OBJECTUI checkout (it adds objects and drops `.git/shallow`; it +# moves no branch and touches no working tree) and writes nothing in the +# framework tree, so nothing here can leave half-applied state behind. +RANGE_OK=0 +if [[ "$NO_CHANGESET" -eq 0 && "$OLD_SHA" != "" ]]; then range_walkable() { node "${FRAMEWORK_ROOT}/scripts/objectui-changeset-digest.mjs" \ --objectui-root "$OBJECTUI_ROOT" --from "$1" --to "$2" --check-walkable } - RANGE_OK=0 - TRUNCATED=0 - if [[ "$OLD_SHA" != "" ]]; then - WALK_RC=0 - range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$? - if [[ "$WALK_RC" -eq 0 ]]; then - RANGE_OK=1 - elif [[ "$WALK_RC" -eq 3 ]]; then - TRUNCATED=1 - # REPAIR THE INPUT BEFORE LABELLING A DERIVATION OF IT. A console changeset - # becomes published CHANGELOG text, so a degraded record is permanent — - # while the correct history is one fetch away and cheap: measured on - # objectui, `fetch --unshallow` costs ~6s and ~4MB and takes the walk from - # 110 commits to the true 191. The fetch is ADDITIVE by construction (it - # adds objects and drops .git/shallow; it moves no branch and touches no - # working tree), which is what makes doing it on the operator's checkout - # defensible rather than presumptuous. Announced before and after, and - # skippable with OBJECTUI_NO_DEEPEN=1 for an offline run. - if [[ "${OBJECTUI_NO_DEEPEN:-0}" == "1" ]]; then - echo "→ objectui history is truncated inside the range; OBJECTUI_NO_DEEPEN=1, not deepening." >&2 - elif [[ "$(git -C "$OBJECTUI_ROOT" rev-parse --is-shallow-repository 2>/dev/null)" != "true" ]]; then - # Not shallow, yet the walk stops: a graft, a `git replace`, or unrelated - # histories. `--unshallow` cannot repair those and errors out on a - # complete repository, so do not pretend it might. - echo "→ objectui history is truncated inside the range but the clone is NOT shallow" >&2 - echo " (graft, git replace, or unrelated histories) — 'fetch --unshallow' cannot repair that." >&2 - else - # RE-CHECK, never trust the fetch's exit code. Measured: `git fetch - # --unshallow` in a checkout with no remote configured exits 0 and - # changes nothing at all, so a status-only test would set RANGE_OK=1 on - # a still-truncated tree — this card's failure, one layer further in. - echo "→ objectui is a shallow clone and the pin range is truncated inside it — deepening…" - DEEPEN_RC=0 - git -C "$OBJECTUI_ROOT" fetch --unshallow || DEEPEN_RC=$? - if [[ "$DEEPEN_RC" -eq 0 ]]; then - WALK_RC=0 - range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$? - if [[ "$WALK_RC" -eq 0 ]]; then - RANGE_OK=1 - TRUNCATED=0 - echo "✓ deepened — the range walks completely now." - fi + WALK_RC=0 + range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$? + DEEPEN_NOTE="" + if [[ "$WALK_RC" -ne 0 ]]; then + if [[ "$WALK_RC" -ne 2 && "$WALK_RC" -ne 3 ]]; then + # The probe did not ANSWER. 2 and 3 are its two verdicts; anything else + # means it never reached one (no node, a syntax error, a killed process). + # Deepening would be a remedy applied to a diagnosis nobody made, so it is + # not attempted — and the refusal below says which of the two this was. + DEEPEN_NOTE=" no deepen was attempted: the probe never returned a verdict, so there is nothing to repair yet." + elif [[ "${OBJECTUI_NO_DEEPEN:-0}" == "1" ]]; then + echo "→ the objectui pin range cannot be walked here; OBJECTUI_NO_DEEPEN=1, not deepening." >&2 + DEEPEN_NOTE=" OBJECTUI_NO_DEEPEN=1 was set for this run, so no deepen was attempted." + elif [[ "$(git -C "$OBJECTUI_ROOT" rev-parse --is-shallow-repository 2>/dev/null)" != "true" ]]; then + # Not shallow, yet the range does not walk: an object that was never + # fetched, a graft, a `git replace`, or unrelated histories. `--unshallow` + # cannot repair those and errors out on a complete repository, so do not + # pretend it might. + echo "→ the objectui pin range cannot be walked here, and the clone is NOT shallow" >&2 + echo " (an unfetched object, a graft, git replace, or unrelated histories) —" >&2 + echo " 'fetch --unshallow' cannot repair that." >&2 + DEEPEN_NOTE=" the clone is NOT shallow, so 'fetch --unshallow' is not the repair here." + else + # REPAIR THE INPUT BEFORE DERIVING FROM IT. A console changeset becomes + # published CHANGELOG text, so a bad record is permanent — while the + # correct history is one fetch away and cheap: measured on objectui, + # `fetch --unshallow` costs ~6s and ~4MB and takes the walk from 110 + # commits to the true 191. The fetch is ADDITIVE by construction, which is + # what makes doing it on the operator's checkout defensible rather than + # presumptuous. Announced before and after, and skippable with + # OBJECTUI_NO_DEEPEN=1 for an offline run. + # + # RE-CHECK, never trust the fetch's exit code. Measured: `git fetch + # --unshallow` in a checkout with no remote configured exits 0 and changes + # nothing at all, so a status-only test would have declared the range + # walkable on a still-broken tree — this card's failure shape, one layer + # further in. + echo "→ objectui is a shallow clone and the pin range cannot be walked in it — deepening…" + DEEPEN_RC=0 + git -C "$OBJECTUI_ROOT" fetch --unshallow || DEEPEN_RC=$? + if [[ "$DEEPEN_RC" -eq 0 ]]; then + WALK_RC=0 + range_walkable "$OLD_SHA" "$NEW_SHA" || WALK_RC=$? + if [[ "$WALK_RC" -eq 0 ]]; then + echo "✓ deepened — the range walks completely now." else - echo "✗ 'git fetch --unshallow' failed (exit ${DEEPEN_RC}) — falling back to the degraded path." >&2 + DEEPEN_NOTE=" 'git fetch --unshallow' exited 0, but the RE-CHECK still refuses — the re-check decides, never the fetch's status." fi + else + echo "✗ 'git fetch --unshallow' failed (exit ${DEEPEN_RC})." >&2 + DEEPEN_NOTE=" 'git fetch --unshallow' failed (exit ${DEEPEN_RC}), so the input was not repaired." fi fi fi + if [[ "$WALK_RC" -eq 0 ]]; then + RANGE_OK=1 + else + case "$WALK_RC" in + 2) WALK_CAUSE="an endpoint of the range is not present as a commit object in that checkout" ;; + 3) WALK_CAUSE="both endpoints are present but the history STOPS INSIDE the range (objectstack#9408)" ;; + *) WALK_CAUSE="the walkability probe itself did not answer (exit ${WALK_RC}); its verdicts are 2 and 3" ;; + esac + { + echo "✗ REFUSING to bump: the objectui range ${OLD_SHA:0:12}..${NEW_SHA:0:12} cannot be walked in" + echo " ${OBJECTUI_ROOT} — ${WALK_CAUSE}." + echo " (the digest's own diagnostic is above.)" + # An `[[ … ]] && echo` here would be a top-level AND-list whose status is + # the test's — false when there is no note — and `set -e` kills the run on + # exactly that, mid-refusal. Same trap the #12071 loop below documents. + if [[ -n "$DEEPEN_NOTE" ]]; then echo "$DEEPEN_NOTE"; fi + echo " The @objectstack/console changeset takes BOTH its list and its bump level from the" + echo " changesets objectui declared over this range. Neither can be derived here. This bump" + echo " used to emit a degraded entry carrying the default level instead — measured on a" + echo " shallow checkout: \`patch\` for a range that declared \`minor\`, every declared-breaking" + echo " entry invisible, the ADR-0087 disposition prompt silently skipped, exit 0" + echo " (objectstack#14178). That level publishes into @objectstack/console's CHANGELOG and" + echo " the release notes, where it reads as a declaration — so this refuses instead." + echo " NOTHING WAS WRITTEN — .objectui-sha is untouched and still holds the old pin." + echo " Fix it, then re-run this bump:" + echo " git -C ${OBJECTUI_ROOT} fetch --unshallow # a shallow clone (the usual case)" + echo " git -C ${OBJECTUI_ROOT} fetch origin # an object that was never fetched" + echo " Or move the pin without a release record at all: scripts/bump-objectui.sh --no-changeset" + echo " (the pin moves, nothing is derived, and nothing is claimed about the range)." + } >&2 + exit 1 + fi +fi + +# FIRST MUTATION OF THE WORKING TREE. Everything read out of the objectui commit +# was read above, and an unreadable commit already refused — nothing below this +# line can fail on a read of `$NEW_SHA` that has not already been attempted +# (#10797). Keep it that way: a new `git -C "$OBJECTUI_ROOT" …` added after this +# point re-opens exactly the half-applied write this ordering exists to prevent. +echo "$NEW_SHA" > "${FRAMEWORK_ROOT}/.objectui-sha" +echo "→ objectui pin: ${OLD_SHA:0:12} → ${NEW_SHA:0:12}${REACH_TAG}" + +# --- Emit the @objectstack/console changeset for the frontend delta ---------- +CS_FILE="" +if [[ "$NO_CHANGESET" -eq 0 ]]; then + # `RANGE_OK` was settled ABOVE, before the pin write — walkability is a + # PRECONDITION of this whole section, and an unwalkable range already refused + # (#14178). Reaching here means one of exactly two things: the range walks + # (derive), or this is the initial pin with no previous SHA to walk from + # (degrade, and say so). There is no third input state left to test. CS_FILE="${FRAMEWORK_ROOT}/.changeset/console-${SHORT}.md" DIGEST_OK=0 BUMP="" @@ -507,25 +585,27 @@ if [[ "$NO_CHANGESET" -eq 0 ]]; then fi if [[ "$DIGEST_OK" -eq 0 ]]; then - # Degraded path: no walkable range (initial pin, shallow clone, or the - # digest could not run). Emit the tip subject ONLY, labelled as degraded — - # the reader must be able to tell this list from a derived one. + # Degraded path. Emit the tip subject ONLY, labelled as degraded — the reader + # must be able to tell this list from a derived one (#4731). + # + # WHAT IS NO LONGER HERE (#14178): "the range could not be walked". That + # input refuses above rather than degrading, because its artifact carried a + # bump LEVEL nobody declared — `patch` on a range that declared `minor` — + # into published CHANGELOG text. The initial pin is a different fact and + # keeps its degraded entry: there is no previous SHA to walk from, no remedy + # to name, and nothing is being guessed about a range that does not exist. BUMP="${CONSOLE_BUMP:-patch}" - RANGE_LABEL="${OLD_SHA:0:12}...${NEW_SHA:0:12}" - WHY="the range \`${RANGE_LABEL}\` could not be walked in this objectui checkout" if [[ "$OLD_SHA" == "" ]]; then RANGE_LABEL="(initial pin) → ${NEW_SHA:0:12}" WHY="this is the initial pin, so there is no previous SHA to walk from" - elif [[ "${TRUNCATED:-0}" -eq 1 ]]; then - # A degraded list must be distinguishable from a complete one (#4731); a - # TRUNCATED range must further be distinguishable from an ABSENT endpoint, - # because the two take different remedies and only one of them is a fetch - # away. Naming the remedy here is the difference between a reader who - # re-runs the bump correctly and one who edits the table by hand. - WHY="the objectui history at \`${OBJECTUI_ROOT}\` STOPS INSIDE the range \`${RANGE_LABEL}\`, so \ -walking it would credit a whole batch of upstream releases to the single commit where the \ -history is cut off (objectstack#9408). Deepen the checkout — \`git -C ${OBJECTUI_ROOT} fetch \ ---unshallow\` — and re-run this bump to get the real list" + else + # The range WALKS (settled above) and the digest still failed — a read + # error inside the derivation, not a precondition. Named as itself: the + # remedies above would not apply, and claiming them would send the reader + # to fetch a history that is already complete. + RANGE_LABEL="${OLD_SHA:0:12}...${NEW_SHA:0:12}" + WHY="the range \`${RANGE_LABEL}\` walks in this checkout, but the digest failed while \ +deriving from it — its diagnostic is in this run's output, above" fi cat > "$CS_FILE" <, shallow: boolean }} + */ +export function findAbsentEndpoints(objectuiRoot, from, to) { + /** @type {Array<{ role: string, rev: string, diagnostic: string }>} */ + const endpoints = []; + for (const [role, rev] of [ + ['from', from], + ['to', to], + ]) { + try { + git(objectuiRoot, ['cat-file', '-e', `${rev}^{commit}`], { captureStderr: true }); + } catch (err) { + endpoints.push({ role, rev, diagnostic: gitDiagnostic(err) }); + } + } + let shallow = false; + try { + shallow = git(objectuiRoot, ['rev-parse', '--is-shallow-repository']).trim() === 'true'; + } catch { + // Enriches the diagnostic only — the verdict above never depends on it, the + // same separation `findRangeTruncation` keeps. + } + return { absent: endpoints.length > 0, endpoints, shallow }; +} + +/** + * The operator-facing account of an absent endpoint: what, where, and the fix. + * + * Deliberately shaped like `describeTruncation`: the two are one class of + * failure with two causes, and a caller that treats them alike must be able to + * read them alike. `PREREQUISITE NOT MET` is the repo's vocabulary for "the + * measurement never happened" — the one verdict that must never be mistaken for + * either a pass or a finding. + */ +export function describeAbsentEndpoints({ objectuiRoot, from, to, absence }) { + const { endpoints, shallow } = absence; + const roles = endpoints.map((e) => `${e.role}=${e.rev.slice(0, 12)}`).join(', '); + const lines = [ + `✗ objectui-changeset-digest: PREREQUISITE NOT MET — ${endpoints.length === 1 ? 'an endpoint' : 'both endpoints'} of ` + + `${from.slice(0, 12)}..${to.slice(0, 12)} ${endpoints.length === 1 ? 'is' : 'are'} not present as a commit ` + + `object in ${objectuiRoot}.`, + ` missing here${shallow ? ' (this is a shallow clone)' : ''}: ${roles}`, + ` Nothing can be derived from a range one of whose ends is absent, and a`, + ` degraded record derived anyway would carry a bump level nobody declared`, + ` (objectstack#14178). This is the SAME class as a range that stops inside`, + ` itself (objectstack#9408): repair the input, then ask again.`, + ` Fix it: git -C ${objectuiRoot} fetch --unshallow (a shallow clone)`, + ` or: git -C ${objectuiRoot} fetch origin (an object never fetched)`, + ]; + for (const e of endpoints) { + lines.push(` git on ${e.role} (${e.rev.slice(0, 12)}):`); + lines.push(e.diagnostic); + } + return lines.join('\n'); +} + /** * Collect the `.changeset/*.md` files ADDED over `from..to`, newest first. * @@ -734,6 +848,14 @@ export function classifyRange({ objectuiRoot, from, to }) { // Placing it here is what makes "never fabricate" structural rather than a // habit each caller has to remember — the same argument that put the releasing // criterion itself in this function. + // #14178: the OTHER half of the same precondition, asked in the same place and + // for the same reason. An absent endpoint reaches `rev-list` as a bare git + // `fatal:` — a failure, but an unnamed one, and the caller cannot tell it from + // a broken checkout. Named here, both consumers get the same account. + const absence = findAbsentEndpoints(objectuiRoot, from, to); + if (absence.absent) { + throw new Error(describeAbsentEndpoints({ objectuiRoot, from, to, absence })); + } const truncation = findRangeTruncation(objectuiRoot, from, to); if (truncation.truncated) { throw new Error(describeTruncation({ objectuiRoot, from, to, truncation })); @@ -1027,13 +1149,15 @@ function main(argv) { console.error(`✗ objectui-changeset-digest: no objectui checkout at ${objectuiRoot}`); return 2; } - try { - git(objectuiRoot, ['cat-file', '-e', `${from}^{commit}`]); - git(objectuiRoot, ['cat-file', '-e', `${to}^{commit}`]); - } catch { - console.error( - `✗ objectui-changeset-digest: cannot walk ${from.slice(0, 12)}..${to.slice(0, 12)} in ${objectuiRoot}`, - ); + // #14178: an absent endpoint is a PREREQUISITE the caller must repair — the + // same class as the truncated walk below, reported the same way and answered + // with the same remedy. It used to be a one-line "cannot walk" with no cause, + // no remedy and no distinction between "shallow, deepen it" and "this checkout + // has never seen that revision", which is what let the caller treat it as + // grounds to DEGRADE rather than to fetch and ask again. + const absence = findAbsentEndpoints(objectuiRoot, from, to); + if (absence.absent) { + console.error(describeAbsentEndpoints({ objectuiRoot, from, to, absence })); return 2; } @@ -1546,28 +1670,61 @@ function selfTest() { ); check('the pin file is updated', readFileSync(join(fwRun, '.objectui-sha'), 'utf8').trim() === head); - // --- degraded range: the fallback must SAY it is degraded -------------- - const fwDegraded = join(tmp, 'fw-degraded'); + // --- an unwalkable range: REFUSE, loudly (#14178) ----------------------- + // This case used to assert the degraded artifact. The artifact is gone: its + // bump level was the `patch` DEFAULT, indistinguishable in the published + // CHANGELOG from a level objectui declared. What the case still asserts is + // the half that mattered — the failure is loud, named, and takes the + // operator to a remedy — plus the #10797 invariant the refusal now owes. + const fwDegraded = join(tmp, 'fw-unwalkable'); mkdirSync(join(fwDegraded, 'scripts'), { recursive: true }); mkdirSync(join(fwDegraded, '.changeset'), { recursive: true }); writeFileSync(join(fwDegraded, '.objectui-sha'), `${'0'.repeat(40)}\n`); for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { writeFileSync(join(fwDegraded, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); } - execFileSync('bash', [join(fwDegraded, 'scripts', 'bump-objectui.sh'), '--no-commit', head], { - encoding: 'utf8', - env: { ...process.env, OBJECTUI_ROOT: ui }, - }); - const degraded = readFileSync( - join(fwDegraded, '.changeset', `console-${head.slice(0, 12)}.md`), - 'utf8', + const unwalkableRun = spawnSync( + 'bash', + [join(fwDegraded, 'scripts', 'bump-objectui.sh'), '--no-commit', head], + { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, + ); + const unwalkableCs = join(fwDegraded, '.changeset', `console-${head.slice(0, 12)}.md`); + check( + 'an unwalkable range REFUSES loudly — non-zero, no artifact, pin untouched', + unwalkableRun.status !== 0 && + !existsSync(unwalkableCs) && + readFileSync(join(fwDegraded, '.objectui-sha'), 'utf8') === `${'0'.repeat(40)}\n` && + unwalkableRun.stderr.includes('REFUSING to bump') && + unwalkableRun.stderr.includes('cannot be walked'), + `status=${unwalkableRun.status} wrote=${existsSync(unwalkableCs)}\n${unwalkableRun.stderr}`, + ); + + // --- the INITIAL pin still degrades, and says so ------------------------ + // The one degraded artifact #14178 kept, so it needs the coverage the + // removed ones had: there is no previous SHA, hence no range, hence no + // remedy to name and nothing being guessed about a walk. A refusal here + // would make the first-ever pin impossible to write. + const fwInitial = join(tmp, 'fw-initial-pin'); + mkdirSync(join(fwInitial, 'scripts'), { recursive: true }); + mkdirSync(join(fwInitial, '.changeset'), { recursive: true }); + for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { + writeFileSync(join(fwInitial, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); + } + const initialRun = spawnSync( + 'bash', + [join(fwInitial, 'scripts', 'bump-objectui.sh'), '--no-commit', head], + { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui, GIT_TERMINAL_PROMPT: '0' } }, ); + const initialCs = join(fwInitial, '.changeset', `console-${head.slice(0, 12)}.md`); + const initialBody = existsSync(initialCs) ? readFileSync(initialCs, 'utf8') : ''; check( - 'an unwalkable range degrades LOUDLY, not silently', - degraded.includes('could not be walked') && - degraded.includes('**Degraded list**') && - degraded.includes('NOT a\ncomplete account'), - degraded, + 'the INITIAL pin (no .objectui-sha at all) still writes a DEGRADED entry that says why', + initialRun.status === 0 && + initialBody.includes('**Degraded list**') && + initialBody.includes('this is the initial pin') && + initialBody.includes('NOT a\ncomplete account') && + !initialBody.includes('could not be walked'), + `status=${initialRun.status}\n${initialBody}\n${initialRun.stderr}`, ); // --- #6175: a range whose `to` endpoint is a RELEASE COMMIT ------------- @@ -2399,7 +2556,13 @@ function selfTest() { `credited=${sinkCredited} attributed=${attributedToSink}`, ); - // ---- #9408 through the shell driver: degrade, and say WHICH failure ----- + // ---- #9408 through the shell driver: REFUSE, and say WHICH failure ------ + // + // These four used to pin the DEGRADED artifact this input produced. #14178 + // removed that path: the artifact carried a bump level nobody declared + // (`patch`) into published CHANGELOG text, and no record beats a wrong one. + // What survives unchanged is everything that made the old artifact readable + // — WHICH failure this was, and the remedy — now said in a refusal. const fwTrunc = join(tmp, 'fw-truncated'); mkdirSync(join(fwTrunc, 'scripts'), { recursive: true }); mkdirSync(join(fwTrunc, '.changeset'), { recursive: true }); @@ -2407,37 +2570,37 @@ function selfTest() { for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { writeFileSync(join(fwTrunc, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); } + const truncPinBefore = readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8'); // OBJECTUI_NO_DEEPEN=1 on purpose: a self-test must never reach the network, // and this run is also the opt-out's only coverage. const truncBump = spawnSync('bash', [join(fwTrunc, 'scripts', 'bump-objectui.sh'), '--no-commit', c6to], { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui6, OBJECTUI_NO_DEEPEN: '1' }, }); - const truncCs = readFileSync(join(fwTrunc, '.changeset', `console-${c6to.slice(0, 12)}.md`), 'utf8'); + const truncCsPath = join(fwTrunc, '.changeset', `console-${c6to.slice(0, 12)}.md`); check( - '#9408 C13 a truncated range takes the DEGRADED path instead of shipping a complete-looking record', - truncBump.status === 0 && - truncCs.includes('**Degraded list**') && - truncCs.includes('NOT a\ncomplete account'), - `status=${truncBump.status}\n${truncCs}`, + '#9408/#14178 C13 a truncated range REFUSES instead of shipping any record — no changeset file at all', + truncBump.status !== 0 && !existsSync(truncCsPath), + `status=${truncBump.status} wrote=${existsSync(truncCsPath)}\n${truncBump.stderr}`, ); check( - '#9408 C14 the degraded artifact says WHICH failure it was, and names the fix', - // #4731 requires a degraded list to be distinguishable from a complete - // one. A truncated range must further be distinguishable from an absent - // endpoint: the remedies differ, and only one of them is a fetch away. - truncCs.includes('STOPS INSIDE') && - truncCs.includes('fetch') && - truncCs.includes('objectstack#9408') && - !truncCs.includes('this is the initial pin'), - truncCs, + '#9408/#14178 C14 the refusal says WHICH failure it was, and names the fix', + // A refusal must be distinguishable from every other refusal: this one is + // a truncated walk, not an absent endpoint, and only one remedy applies. + truncBump.stderr.includes('REFUSING to bump') && + truncBump.stderr.includes('STOPS INSIDE') && + truncBump.stderr.includes('fetch --unshallow') && + truncBump.stderr.includes('objectstack#9408'), + truncBump.stderr, ); check( - '#9408 C15 the degraded artifact carries ONLY the tip subject — it never lists the truncated walk', - truncCs.includes('feat(core): fifth (#4005)') && - !truncCs.includes('Upstream change number 3') && - !truncCs.includes('Upstream change number 5'), - truncCs, + '#9408/#10797/#14178 C15 the refused run left NO half-applied state — .objectui-sha is byte-identical', + // The refusal is asked ABOVE the pin write for exactly this reason. It is + // the same invariant #10797 pinned for an unreadable commit object, now + // owed by a second refusal — and the one thing a "just exit 1" fix would + // have broken silently. + readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8') === truncPinBefore, + `before=${JSON.stringify(truncPinBefore)} after=${JSON.stringify(readFileSync(join(fwTrunc, '.objectui-sha'), 'utf8'))}`, ); check( '#9408 C16 the OPT-OUT is reported, not silent', @@ -2464,18 +2627,16 @@ function selfTest() { encoding: 'utf8', env: { ...process.env, OBJECTUI_ROOT: ui6, GIT_TERMINAL_PROMPT: '0' }, }); - const noopDeepenCs = readFileSync( - join(fwTrunc2, '.changeset', `console-${c6to.slice(0, 12)}.md`), - 'utf8', - ); + const noopDeepenCsPath = join(fwTrunc2, '.changeset', `console-${c6to.slice(0, 12)}.md`); check( - '#9408 C17 a deepen that exits 0 WITHOUT repairing the range still degrades — the re-check decides, not the fetch status', - noopDeepen.status === 0 && + '#9408/#14178 C17 a deepen that exits 0 WITHOUT repairing the range still refuses — the re-check decides, not the fetch status', + noopDeepen.status !== 0 && noopDeepen.stdout.includes('deepening') && !noopDeepen.stdout.includes('the range walks completely now') && - noopDeepenCs.includes('**Degraded list**') && - noopDeepenCs.includes('STOPS INSIDE'), - `status=${noopDeepen.status}\nSTDOUT:${noopDeepen.stdout}\nSTDERR:${noopDeepen.stderr}\n${noopDeepenCs}`, + noopDeepen.stderr.includes('REFUSING to bump') && + noopDeepen.stderr.includes('STOPS INSIDE') && + !existsSync(noopDeepenCsPath), + `status=${noopDeepen.status}\nSTDOUT:${noopDeepen.stdout}\nSTDERR:${noopDeepen.stderr}\nwrote=${existsSync(noopDeepenCsPath)}`, ); check( '#9408 C18 the truncated tree is STILL truncated afterwards — the no-op fetch is pinned as a no-op', @@ -2488,6 +2649,230 @@ function selfTest() { // truncated tree by accident. rmSync(shallowFile, { force: true }); + // ---- #14178: an ABSENT endpoint is the same failure, one step earlier --- + // + // #9408's group above synthesizes its truncation by writing `.git/shallow`, + // which keeps both endpoints present as objects. That is the SECOND of the + // two ways a shallow checkout breaks a pin range, and the rarer one. The + // FIRST is what `git clone --depth N` produces every time: the boundary sits + // AFTER the old pin, so the `from` object is not in the store at all — and + // that case returned from the endpoint guard before `findRangeTruncation` + // ever ran, so the deepen the script advertises was never reached. + // + // THE FIXTURE IS A REAL SHALLOW CLONE, not a hand-written `.git/shallow`, + // because the repair is the thing under test: `fetch --unshallow` needs a + // remote to deepen FROM. It is a `file://` remote, so this stays offline by + // construction — a self-test must never reach the network. + // + // BOTH DIRECTIONS, as always: the refusal cases sit beside a run that + // deepens and derives the COMPLETE record, asserted equal to the record the + // same range yields in a full clone. A guard that refused everything would + // pass every refusal case here and fail that one. + const uiUp = join(tmp, 'objectui-upstream'); + mkdirSync(join(uiUp, '.changeset'), { recursive: true }); + const gUp = (...args) => git(uiUp, args); + gUp('init', '-q', '-b', 'main'); + gUp('config', 'user.email', 'selftest@objectstack.ai'); + gUp('config', 'user.name', 'self test'); + gUp('config', 'commit.gpgsign', 'false'); + let upN = 0; + const commitUp = (subject, level, breaking = false) => { + upN += 1; + writeFileSync(join(uiUp, `src-${upN}.ts`), `${upN}\n`); + writeFileSync( + join(uiUp, '.changeset', `entry-${upN}.md`), + `---\n"@object-ui/core": ${level}\n---\n\n${subject}\n` + + (breaking ? '\n**BREAKING** the old prop is gone.\n' : ''), + ); + gUp('add', '-A'); + gUp('commit', '-q', '-m', subject); + return gUp('rev-parse', 'HEAD').trim(); + }; + // The LEVEL is the point of this fixture: the range declares `minor`, while + // the degraded path this card removed emitted the `patch` default. A range + // that declared `patch` would make every assertion below vacuous. + const upFrom = commitUp('feat(core): the pinned starting point (#5001)', 'patch'); + commitUp('fix(core): a patch change (#5002)', 'patch'); + commitUp('feat(grid): a MINOR change nobody would call patch (#5003)', 'minor'); + commitUp('refactor(layout): drop PageNodeRenderer (#5004)', 'minor', true); + const upTo = commitUp('feat(core): the tip (#5005)', 'patch'); + + const cloneUi = (name, depth) => { + const dir = join(tmp, name); + const args = ['clone', '-q']; + if (depth) args.push('--depth', String(depth)); + args.push(pathToFileURL(uiUp).href, dir); + execFileSync('git', args, { encoding: 'utf8' }); + // A real checkout has this ref, and #10495's reachability report keys on + // it — without it these cases would bury their assertions under warnings. + git(dir, ['update-ref', 'refs/remotes/origin/main', upTo]); + return dir; + }; + // `--depth 2` leaves the tip and its parent: `to` is present, `from` is not. + const uiShallow = cloneUi('objectui-depth2', 2); + const uiFull = cloneUi('objectui-full', 0); + + const fromPresent = (root, rev) => { + try { + git(root, ['cat-file', '-e', `${rev}^{commit}`], { captureStderr: true }); + return true; + } catch { + return false; + } + }; + let truncationCouldAnswer = true; + try { + findRangeTruncation(uiShallow, upFrom, upTo); + } catch { + truncationCouldAnswer = false; + } + check( + '#14178 S1 the fixture is the OTHER shallow shape: `from` is ABSENT, and #9408’s test cannot even answer here', + // Why the absent test must run FIRST: `rev-list from..to` needs `from`. + // Ordering the two the other way round would answer this input with a git + // `fatal:` from inside the truncation probe — a failure, but an unnamed + // one, and the caller could not tell it from a broken checkout. + !fromPresent(uiShallow, upFrom) && + fromPresent(uiShallow, upTo) && + git(uiShallow, ['rev-parse', '--is-shallow-repository']).trim() === 'true' && + truncationCouldAnswer === false, + `fromPresent=${fromPresent(uiShallow, upFrom)} toPresent=${fromPresent(uiShallow, upTo)} truncationAnswered=${truncationCouldAnswer}`, + ); + const absence = findAbsentEndpoints(uiShallow, upFrom, upTo); + const absenceFull = findAbsentEndpoints(uiFull, upFrom, upTo); + check( + '#14178 S2 findAbsentEndpoints NAMES which endpoint is missing, knows the clone is shallow — and clears the full clone', + absence.absent === true && + absence.endpoints.length === 1 && + absence.endpoints[0].role === 'from' && + absence.endpoints[0].rev === upFrom && + absence.endpoints[0].diagnostic.includes('Not a valid object name') && + absence.shallow === true && + absenceFull.absent === false, + `${JSON.stringify({ absent: absence.absent, roles: absence.endpoints.map((e) => e.role), shallow: absence.shallow, full: absenceFull.absent })}`, + ); + const absentProbe = (extraArgs, root = uiShallow) => + spawnSync( + process.execPath, + [selfPath, '--objectui-root', root, '--framework-root', fwPlain, '--from', upFrom, '--to', upTo, ...extraArgs], + { encoding: 'utf8' }, + ); + const absentWalkable = absentProbe(['--check-walkable']); + check( + '#14178 S3 --check-walkable exits 2 on an absent endpoint, reports PREREQUISITE NOT MET and names the remedy, deriving nothing', + absentWalkable.status === 2 && + absentWalkable.stderr.includes('PREREQUISITE NOT MET') && + absentWalkable.stderr.includes('fetch --unshallow') && + absentWalkable.stdout === '', + `status=${absentWalkable.status} stdout=${JSON.stringify(absentWalkable.stdout)}\n${absentWalkable.stderr}`, + ); + const absentOut = join(tmp, 'cli-absent-endpoint.md'); + const absentDeriving = absentProbe(['--out', absentOut]); + check( + '#14178 S4 the DERIVING path refuses with 2 and writes NO artifact at all', + absentDeriving.status === 2 && !existsSync(absentOut), + `status=${absentDeriving.status} wrote=${existsSync(absentOut)}`, + ); + let absentClassifyThrew = ''; + try { + classifyRange({ objectuiRoot: uiShallow, from: upFrom, to: upTo }); + } catch (err) { + absentClassifyThrew = String(err?.message ?? err); + } + check( + '#14178 S5 the SHARED implementation refuses — so objectui-range.mjs cannot derive from it either', + absentClassifyThrew.includes('PREREQUISITE NOT MET') && + absentClassifyThrew.includes(upFrom.slice(0, 12)), + absentClassifyThrew, + ); + + // S6 — THE CARD'S REPRODUCTION, end to end through the shell driver. + const mkFwFor = (name, pinSha) => { + const dir = join(tmp, name); + mkdirSync(join(dir, 'scripts'), { recursive: true }); + mkdirSync(join(dir, '.changeset'), { recursive: true }); + writeFileSync(join(dir, '.objectui-sha'), `${pinSha}\n`); + for (const f of ['bump-objectui.sh', 'objectui-changeset-digest.mjs', 'invoked-as.mjs']) { + writeFileSync(join(dir, 'scripts', f), readFileSync(join(__dirname, f), 'utf8')); + } + return dir; + }; + const runShellBump = (fwDir, uiRoot, extraEnv = {}) => + spawnSync('bash', [join(fwDir, 'scripts', 'bump-objectui.sh'), '--no-commit', upTo], { + encoding: 'utf8', + env: { ...process.env, OBJECTUI_ROOT: uiRoot, GIT_TERMINAL_PROMPT: '0', ...extraEnv }, + }); + const csName = `console-${upTo.slice(0, 12)}.md`; + + const fwFull = mkFwFor('fw-absent-control-full', upFrom); + const fullRun = runShellBump(fwFull, uiFull); + const fullCs = existsSync(join(fwFull, '.changeset', csName)) + ? readFileSync(join(fwFull, '.changeset', csName), 'utf8') + : ''; + const fwDeepen = mkFwFor('fw-absent-deepen', upFrom); + const deepenRun = runShellBump(fwDeepen, uiShallow); + const deepenCs = existsSync(join(fwDeepen, '.changeset', csName)) + ? readFileSync(join(fwDeepen, '.changeset', csName), 'utf8') + : ''; + check( + '#14178 S6 an absent endpoint now DEEPENS and re-asks — the shallow checkout derives the same record as a full clone, `minor` not the `patch` default', + // The measured before/after, in one assertion. Before this card the same + // input wrote a one-line degraded entry declaring `patch`, exited 0, and + // never printed the ADR-0087 prompt (which keys on a declared-breaking + // changeset, and the degraded one declared none). + deepenRun.status === 0 && + deepenRun.stdout.includes('deepening') && + deepenRun.stdout.includes('the range walks completely now') && + deepenCs.includes('"@objectstack/console": minor') && + deepenCs.includes('**BREAKING**') && + deepenCs.includes('adr-0087: TODO') && + !deepenCs.includes('Degraded list') && + fullRun.status === 0 && + deepenCs === fullCs, + `deepenStatus=${deepenRun.status} fullStatus=${fullRun.status} identical=${deepenCs === fullCs}\nSTDOUT:${deepenRun.stdout}\nSTDERR:${deepenRun.stderr}\n${deepenCs}`, + ); + check( + '#14178 S6b the deepen really repaired the CHECKOUT — the fixture is no longer shallow and `from` is present', + // The positive guard on S6: without it S6 would also pass if the record + // had come from somewhere other than the repaired history. + git(uiShallow, ['rev-parse', '--is-shallow-repository']).trim() === 'false' && + fromPresent(uiShallow, upFrom), + ); + + // S7 — the same input with the opt-out set. Its own clone: S6 deepened the + // one above, and a fixture that quietly stopped reproducing the defect is + // how this whole group could go green over nothing. + const uiShallow2 = cloneUi('objectui-depth2-nodeepen', 2); + const fwNoDeepen = mkFwFor('fw-absent-no-deepen', upFrom); + const pinBefore = readFileSync(join(fwNoDeepen, '.objectui-sha'), 'utf8'); + const noDeepenRun = runShellBump(fwNoDeepen, uiShallow2, { OBJECTUI_NO_DEEPEN: '1' }); + check( + '#14178 S7 absent endpoint + OBJECTUI_NO_DEEPEN=1 ⇒ REFUSAL: non-zero, no changeset, and the pin file byte-identical', + noDeepenRun.status !== 0 && + !existsSync(join(fwNoDeepen, '.changeset', csName)) && + readFileSync(join(fwNoDeepen, '.objectui-sha'), 'utf8') === pinBefore && + noDeepenRun.stderr.includes('REFUSING to bump') && + noDeepenRun.stderr.includes('NOTHING WAS WRITTEN') && + noDeepenRun.stderr.includes('OBJECTUI_NO_DEEPEN=1 was set'), + `status=${noDeepenRun.status} wrote=${existsSync(join(fwNoDeepen, '.changeset', csName))}\n${noDeepenRun.stderr}`, + ); + + // S8 — a deepen that CANNOT repair the range: the old pin names a commit + // that exists nowhere, in a clone that is not shallow at all. `--unshallow` + // is not the remedy here and the refusal must not claim it is. + const bogusPin = '0'.repeat(40); + const fwBogus = mkFwFor('fw-absent-bogus-pin', bogusPin); + const bogusRun = runShellBump(fwBogus, uiFull); + check( + '#14178 S8 an absent endpoint a deepen cannot repair still refuses — and says the clone is NOT shallow rather than prescribing --unshallow as the fix', + bogusRun.status !== 0 && + !existsSync(join(fwBogus, '.changeset', csName)) && + readFileSync(join(fwBogus, '.objectui-sha'), 'utf8') === `${bogusPin}\n` && + bogusRun.stderr.includes('REFUSING to bump') && + bogusRun.stderr.includes('NOT shallow'), + `status=${bogusRun.status}\n${bogusRun.stderr}`, + ); + // ---- #10495: is the revision being PINNED actually on objectui main? ---- // `bump-objectui.sh` pins `git rev-parse HEAD` of the operator's checkout. // That answers "what is checked out", never "is it on main". Measured on a