Skip to content
Merged
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
314 changes: 312 additions & 2 deletions scripts/objectui-changeset-digest.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -146,6 +146,24 @@
// `packages/*/src/**` with no changeset fails). Until it lands, this is the only
// place the class is visible at all; after it lands, this list goes quiet on its
// own, because there will be nothing to name.
//
// WHY THE ARTIFACT CARRIES AN UNANSWERED ADR-0087 MARKER (#6494)
// --------------------------------------------------------------
// #6099 made this script DECLARE breaking changes (`**BREAKING**`), and #6148's
// gate requires every declared-breaking changeset to state an ADR-0087
// disposition. The first pin bump after #6289 hit exactly that (PR #6476), was
// patched by hand — and a hand-patched marker in a GENERATED file survives only
// until the next bump rewrites it. The recurrence condition is objectui's
// standing practice, not an edge case: its release window bans `major`, so
// breaking changes arrive as `minor` plus a body annotation, which is precisely
// what this script now recognises.
//
// So the artifact carries the QUESTION. `<!-- adr-0087: TODO … -->` is a marker
// the gate parses, refuses, and reports as unanswered — the operator answers it
// at bump time, in writing, every time. The generator never picks a disposition:
// #6148's whole design is that the gate does not answer for you, and a generator
// that answers for you is the same hole with a different author. The argument in
// full, including the two rejected alternatives, is at `ADR_0087_SCAFFOLD`.

import { execFileSync, spawnSync } from 'node:child_process';
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
Expand Down Expand Up @@ -336,6 +354,84 @@ export function markBreaking(text) {
return `${BREAKING_MARKER} — ${text}`;
}

/**
* The ADR-0087 disposition SCAFFOLD — a question, never an answer (#6494).
*
* `scripts/check-adr-0087-registration.mjs` (#6148) requires every changeset
* that DECLARES a breaking change to carry exactly one `adr-0087:` disposition
* marker. From #6099 onward this script declares one as soon as an upstream
* entry is annotated — and that is objectui's ORDINARY shape, not an exotic
* one: its release window bans `major` (`scripts/check-changeset-no-major.mjs`),
* so a breaking change ships as `minor` plus the author's own body annotation,
* which is exactly what `hasBreakingAnnotation` above finds. Measured on the
* range PR #6476 bumped: 24 changesets, 2 of them annotated-breaking. So the
* gate's red is the COMMON case on any substantive pin bump, and a marker
* patched in by hand lives exactly until the next bump regenerates the file.
*
* Hence a placeholder that the gate STILL JUDGES:
*
* * `TODO` parses as neither `registered ...` nor `not-required (...) ...`,
* so the gate reports `unparseable disposition: "TODO …"` and stays RED.
* Present-but-unanswered reads differently from absent, and it lands the
* gate's own fix-it block listing the four accepted forms.
* * It is ONE marker. Two would produce a different red ("N markers -- exactly
* one disposition is expected") that names the tooling instead of the
* question, so nothing else in the emitted body may spell `adr-0087:`.
* * It says NOTHING about which disposition applies. Writing `not-required
* (...)` here — even the category that is usually right — would make this
* script perform the judgement #6148 exists to extract from a human, and it
* would then be written unread on every bump forever. That gate's whole
* design is that it never answers for you; a generator that answers for you
* is the same hole with a different author.
*
* An HTML comment for the reason the gate's own header gives: a changeset body
* is copied VERBATIM into CHANGELOG.md, so the marker must render as nothing for
* end users while staying fully visible in the diff, in `git grep` and in the
* gate's log.
*/
export const ADR_0087_SCAFFOLD =
'<!-- adr-0087: TODO — the pin bump cannot answer this; a human must (objectstack#6494) -->';

/**
* Will `check-adr-0087-registration.mjs` judge THIS ARTIFACT as declaring a
* breaking change? Read the artifact we actually rendered, not our belief about
* it — the scaffold has to appear on exactly the files that gate will judge.
*
* That gate's `breakingDeclaration()` unions three signals, and this mirrors the
* two a generated console changeset can ever raise:
*
* 1. a `major` bump in the frontmatter — reachable here in RC pre-mode and via
* `CONSOLE_BUMP=major`, and it arms the gate ON ITS OWN, with no marker
* anywhere in the body.
* 2. `**BREAKING` (or a `BREAKING CHANGE:` line) in the body.
* 3. a conventional-commit `!` on the summary line — UNREACHABLE, deliberately
* not mirrored: the first line of the emitted file is this script's own
* fixed sentence (`Console (objectui) refreshed to …`) and the gate's
* pattern `^[a-z]+(\([^)]*\))?!:` is case-sensitive, so nothing this
* generator writes can start it.
*
* Why the RENDERED body rather than the `breaking` count: the two nearly agree,
* and the gap runs one way. `breaking > 0` always renders `**BREAKING**` (the ⚠️
* note restates the marker by construction), so the count implies the signal;
* the converse can fail. An entry whose own summary carries an emphasis run too
* long for `BREAKING_EMPHASIS_LABEL`'s 48-character tail is NOT counted breaking
* here, yet its text reaches the rendered line where the gate's looser
* `/\*\*BREAKING/` matches it. Reading the artifact closes that by construction
* instead of by agreement between two predicates.
*
* Why the gate's own function is not imported: that module's CLI dispatch runs
* at TOP LEVEL (it has no `import.meta.url === argv[1]` guard), so importing it
* would execute the gate; and it would put the release-critical bump path one
* rename away from failing. The agreement is pinned in the self-test instead,
* which runs the real gate as a child process over a real temp repository.
*
* @param {{ bump: string, body: string }} artifact
*/
export function artifactDeclaresBreaking({ bump, body }) {
if (bump === 'major') return true;
return /\*\*BREAKING/i.test(body) || /^\s*BREAKING[ -]CHANGE/mi.test(body);
}

/**
* Collect the `.changeset/*.md` files ADDED over `from..to`, newest first.
*
Expand Down Expand Up @@ -561,7 +657,7 @@ export function classifyRange({ objectuiRoot, from, to }) {
/**
* Build the digest for a range.
*
* @returns {{ bump: string, declaredLevel: string|null, breaking: number, breakingByLevel: number, breakingByAnnotation: number, releasing: Array<object>, releaseNothing: number, noChangeset: number, noChangesetCommits: Array<object>, changesetsAdded: number, absentAtTo: number, totalCommits: number, downgradedMajor: boolean, body: string }}
* @returns {{ bump: string, declaredLevel: string|null, breaking: number, breakingByLevel: number, breakingByAnnotation: number, releasing: Array<object>, releaseNothing: number, noChangeset: number, noChangesetCommits: Array<object>, changesetsAdded: number, absentAtTo: number, totalCommits: number, downgradedMajor: boolean, adr0087Scaffold: boolean, body: string }}
*/
export function buildDigest({
objectuiRoot,
Expand Down Expand Up @@ -715,14 +811,28 @@ export function buildDigest({
}
}

const body = [
const rendered = [
accounting,
'',
...lines,
...(notes.length ? ['', ...notes] : []),
...(undeclared.length ? ['', ...undeclared] : []),
].join('\n');

// --- #6494: the artifact carries the ADR-0087 QUESTION, never its answer ---
// A changeset that declares breaking must carry a disposition marker (#6148),
// and THIS changeset is generated: a marker written by hand survives exactly
// until the next pin bump rewrites the file, which is how PR #6476's answer
// was already scheduled to disappear. So the generator emits the question and
// leaves it deliberately unanswered — see ADR_0087_SCAFFOLD.
//
// Zero-suppressed against the gate's own subject: on an artifact the gate does
// not judge, an unanswered marker would sit there unjudged forever and teach
// the next reader that this marker is decoration. It appears exactly where it
// is enforced.
const adr0087Scaffold = artifactDeclaresBreaking({ bump, body: rendered });
const body = adr0087Scaffold ? `${rendered}\n\n${ADR_0087_SCAFFOLD}` : rendered;

return {
bump,
declaredLevel,
Expand All @@ -737,6 +847,7 @@ export function buildDigest({
absentAtTo,
totalCommits,
downgradedMajor,
adr0087Scaffold,
body,
};
}
Expand Down Expand Up @@ -830,6 +941,21 @@ function main(argv) {
);
}

// #6494: the one line the operator running the bump must act on. It goes to
// stderr beside the accounting, which is where a `BUMP="$(…)"` caller lets it
// through — the shell driver captures stdout only. Saying it here is not the
// enforcement (the gate is); it is what stops the operator discovering the red
// one push later, and it names the marker so the fix is a search away.
if (digest.adr0087Scaffold) {
console.error(
`→ this changeset DECLARES a breaking change, so it carries an UNANSWERED ADR-0087 ` +
`disposition placeholder. Replace the \`adr-0087: TODO\` marker in ` +
`${out ?? 'the changeset'} with the real disposition before you push: ` +
`\`Check Changeset\` rejects the placeholder on purpose, and the generator cannot ` +
`answer it for you (objectstack#6494).`,
);
}

if (out) {
mkdirSync(dirname(out), { recursive: true });
writeFileSync(out, file);
Expand Down Expand Up @@ -1624,6 +1750,190 @@ function selfTest() {
!allDeclared.body.includes('declared nowhere'),
allDeclared.body,
);

// --- #6494: the ADR-0087 disposition scaffold ---------------------------
// The gate's own marker pattern (check-adr-0087-registration.mjs:416),
// copied rather than imported: that module's CLI dispatch is top-level, so
// importing it would RUN the gate. The copy is belt-and-braces — the REAL
// gate binary judges a real artifact in the round trip below, and that, not
// this regex, is the authority on what the marker means.
const markersIn = (text) =>
[...text.matchAll(/<!--\s*adr-0087\s*:\s*([\s\S]*?)-->/g)].map((m) =>
m[1].replace(/\s+/g, ' ').trim(),
);

// A range with NOTHING breaking in it, forced to `major` the way
// `CONSOLE_BUMP=major` does. The frontmatter alone is a breaking declaration
// to the gate, so this is the artifact a `breaking > 0` proxy would miss.
const majorOverride = buildDigest({
objectuiRoot: ui5,
frameworkRoot: fwPlain,
from: base5,
to: head5,
bumpOverride: 'major',
});

check(
'#6494 an artifact that DECLARES breaking carries EXACTLY ONE adr-0087 marker',
annotated.adr0087Scaffold === true &&
markersIn(annotated.body).length === 1 &&
annotated.body.includes(ADR_0087_SCAFFOLD) &&
digest.adr0087Scaffold === true &&
markersIn(digest.body).length === 1,
annotated.body,
);
check(
'#6494 the marker is a PLACEHOLDER — it names no disposition at all',
// Both legal forms must MISS it, or the generator would have answered the
// question on the human's behalf — the one thing #6148 forbids.
(markersIn(annotated.body)[0] ?? '').startsWith('TODO') &&
!/^registered\b/i.test(markersIn(annotated.body)[0] ?? '') &&
!/^not-required\b/i.test(markersIn(annotated.body)[0] ?? ''),
markersIn(annotated.body)[0],
);
check(
'#6494 a `major` FRONTMATTER alone arms the gate — the scaffold follows it there',
// The gate declares breaking on the frontmatter ALONE, with no marker in
// the body, so a `breaking > 0` proxy would have missed this artifact.
majorOverride.bump === 'major' &&
majorOverride.breaking === 0 &&
!/\*\*BREAKING/i.test(majorOverride.body) &&
majorOverride.adr0087Scaffold === true &&
markersIn(majorOverride.body).length === 1,
majorOverride.body,
);
check(
'#6494 a NON-breaking artifact carries no marker at all (negative polarity)',
// Declared as negative polarity: deleting the emission also produces no
// marker, so this cannot go red under that ablation. It pins a DIFFERENT
// regression — a marker on an artifact the gate never judges, which would
// sit unanswered forever and teach the next reader to ignore it. The three
// positive guards keep it from passing for the vacuous reason (an empty or
// unbuilt body would satisfy "no marker" just as well).
allDeclared.releasing.length === 2 &&
allDeclared.breaking === 0 &&
allDeclared.bump === 'minor' &&
allDeclared.adr0087Scaffold === false &&
markersIn(allDeclared.body).length === 0,
allDeclared.body,
);
check(
'#6494 the flag, the rendered marker and the gate criterion agree on EVERY fixture',
[digest, plain, overridden, capped, annotated, named, named2, released, allDeclared, majorOverride].every(
(d) =>
d.adr0087Scaffold === (markersIn(d.body).length === 1) &&
d.adr0087Scaffold === (d.bump === 'major' || /\*\*BREAKING/i.test(d.body)),
),
[digest, plain, overridden, capped, annotated, named, named2, released, allDeclared, majorOverride]
.map((d) => `${d.bump}/${d.adr0087Scaffold}/${markersIn(d.body).length}`)
.join(' '),
);

// --- #6494 THE ROUND TRIP, through the REAL gate ------------------------
// The claim "the gate recognises this as present-but-unanswered" is about
// ANOTHER script, so nothing short of that script's own verdict settles it.
// A throwaway repo carrying the gate's required inputs (#4690: it refuses to
// report a verdict without them) plus a COPY of the gate, so its REPO_ROOT
// resolves here — the same idiom the bump-objectui.sh run above uses.
const gateRepo = join(tmp, 'fw-gate');
mkdirSync(gateRepo, { recursive: true });
const gg = (...args) => git(gateRepo, args);
const gw = (rel, text) => {
mkdirSync(dirname(join(gateRepo, rel)), { recursive: true });
writeFileSync(join(gateRepo, rel), text);
};
gg('init', '-q', '-b', 'main');
gg('config', 'user.email', 'selftest@objectstack.ai');
gg('config', 'user.name', 'self test');
gg('config', 'commit.gpgsign', 'false');
const LEDGER_ENTRY = (id) =>
`export const X = {\n semantic: [\n {\n id: '${id}',\n surface: 's',\n },\n ],\n};\n`;
gw('packages/spec/src/migrations/registry.ts', LEDGER_ENTRY('old-entry-one'));
gw('packages/spec/src/conversions/registry.ts', LEDGER_ENTRY('a-conversion'));
gw(
'packages/spec/spec-changes.json',
`${JSON.stringify({ perMajor: [{ to: 17, migrated: [{ migrationId: 'old-entry-one' }] }] }, null, 2)}\n`,
);
// One declared-breaking changeset in STOCK (committed at base, so it is not
// in the judged diff) — the gate's convention-rot assertion needs its
// breaking detector to match something.
gw('.changeset/stock-breaking.md', '---\n"@objectstack/spec": major\n---\n\nstock\n\n**BREAKING** something\n');
gw(
'scripts/check-adr-0087-registration.mjs',
readFileSync(join(__dirname, 'check-adr-0087-registration.mjs'), 'utf8'),
);
gg('add', '-A');
gg('commit', '-q', '-m', 'base');
const gateBase = gg('rev-parse', 'HEAD').trim();

// The range is the #6099 fixture: `minor` + the author's own annotation,
// objectui's ORDINARY breaking shape and the one that reopens this every bump.
const gateCs = join(gateRepo, '.changeset', `console-${head2.slice(0, 12)}.md`);
const generated = spawnSync(
process.execPath,
[selfPath, '--objectui-root', ui2, '--framework-root', gateRepo, '--from', base2, '--to', head2, '--out', gateCs],
{ encoding: 'utf8' },
);
gg('add', '-A');
gg('commit', '-q', '-m', 'chore(console): bump objectui pin');
const runGate = () =>
spawnSync(process.execPath, [join(gateRepo, 'scripts', 'check-adr-0087-registration.mjs'), '--base', gateBase], {
encoding: 'utf8',
cwd: gateRepo,
});

const gateRed = runGate();
check(
'#6494 ROUND TRIP (red): the REAL gate reads the marker and refuses it as UNANSWERED',
generated.status === 0 &&
gateRed.status !== 0 &&
// The EXACT verdict, not merely "it is red": present-but-unparseable is a
// different fact from absent, and only the first one proves the scaffold
// reached the gate at all.
/but unparseable disposition: "TODO/.test(gateRed.stderr) &&
gateRed.stderr.includes(`console-${head2.slice(0, 12)}.md`) &&
gateRed.stderr.includes('<!-- adr-0087: not-required (no-migration-prescription) why -->'),
`generator=${generated.status} gate=${gateRed.status}\n${gateRed.stderr}`,
);
check(
'#6494 the operator is told at BUMP time, not one push later',
generated.stderr.includes('UNANSWERED ADR-0087') && generated.stderr.includes('adr-0087: TODO'),
generated.stderr,
);

// The other half. Same file, same range, same generator — only the human's
// answer is added, and the gate's verdict flips. A red that no answer can
// clear would be a broken gate, not a scaffold.
//
// The replacement is CHECKED before it is committed, and the commit allows an
// empty tree. Found while reverse-verifying this group: with the emission
// ablated there is no placeholder to replace, so the write was a no-op, `git
// commit` exited 1 on a clean tree and threw — the whole self-test died with
// a raw `execFileSync` stack instead of naming a failed check. An ablation is
// exactly when the next reader needs a NAME, not a stack trace.
const answered = readFileSync(gateCs, 'utf8').replace(
ADR_0087_SCAFFOLD,
'<!-- adr-0087: not-required (already-registered old-entry-one) the pinned frontend range carries no spec surface beyond that entry -->',
);
check(
'#6494 the answer replaces the placeholder IN PLACE — one marker before, one after',
answered !== readFileSync(gateCs, 'utf8') &&
!answered.includes('adr-0087: TODO') &&
markersIn(answered).length === 1,
answered.slice(-400),
);
writeFileSync(gateCs, answered);
gg('add', '-A');
gg('commit', '-q', '--allow-empty', '-m', 'answer the ADR-0087 question');
const gateGreen = runGate();
check(
'#6494 ROUND TRIP (green): a real disposition written in that same place clears it',
gateGreen.status === 0 &&
gateGreen.stdout.includes(
'✓ check-adr-0087-registration: 1 declared-breaking changeset(s), each carrying an ADR-0087 disposition.',
),
`status=${gateGreen.status}\n${gateGreen.stdout}\n${gateGreen.stderr}`,
);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
Expand Down
Loading