From e1c97f16357c59cb6014e096168d2b3c3e16271d Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:23:16 +0000 Subject: [PATCH 1/2] =?UTF-8?q?fix(scripts):=20objectui=20digest=20?= =?UTF-8?q?=E5=9C=A8=E5=A3=B0=E6=98=8E=E7=A0=B4=E5=9D=8F=E6=80=A7=E6=97=B6?= =?UTF-8?q?=E5=86=99=E5=85=A5=E4=B8=80=E4=BB=BD=E9=97=A8=E4=BB=8D=E5=88=A4?= =?UTF-8?q?=E7=BA=A2=E7=9A=84=20ADR-0087=20=E5=A4=84=E7=BD=AE=E8=84=9A?= =?UTF-8?q?=E6=89=8B=E6=9E=B6=20(#6494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #6099 之后 digest 会在正文打 `**BREAKING**`,而 #6148 的门要求任何声明破坏性的 changeset 必须携带 `adr-0087:` 处置标记。生成器不写这个标记,`Check Changeset` 判红;PR #6476 手工补了一行而绿,但那份 changeset 是**生成的** —— 下一次 pin bump 重新生成时手工标记消失,同一个红复现。且复发条件是常见情形:objectui 在发布窗口内 禁 `major`,把破坏性一律声明为 `minor` + 正文标注,那是它的常规做法。 生成器现在写入一份**占位**而不是答案: `TODO` 既不匹配 `registered ...` 也不匹配 `not-required (...) ...`,门因此报 `unparseable disposition: "TODO …"` 并继续判红 —— 「有标记但未作答」与「无标记」 在日志里是两个不同的事实,且会带出门自己的四种合法写法。生成器**不**替人选处置: #6148 的设计就是门从不替你作答,生成器替你作答是同一个洞换了作者。 判定读的是**刚渲染出的产物**而非 `breaking` 计数:计数蕴含标记,反向不成立 (作者自写的强调标签超过 48 字符时不计入 breaking,但文本仍进入渲染行,被门更宽的 `/\*\*BREAKING/` 命中);`major` frontmatter 也会单独触发门。第三个信号 (conventional-commit `!`)在本生成器下不可达,注释里写明原因。 自测新增 8 条,含**通过真实门二进制**的往返:占位 → 门红(引用其原文),换成真实 处置 → 门绿。门模块的 CLI 派发在顶层执行,import 它等于运行它,故以子进程 + 临时仓 驱动,不在发布路径上引入耦合。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- scripts/objectui-changeset-digest.mjs | 302 +++++++++++++++++++++++++- 1 file changed, 300 insertions(+), 2 deletions(-) diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 4b3568390c..bde43caf53 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -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. `` 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'; @@ -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 = + ''; + +/** + * 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. * @@ -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, releaseNothing: number, noChangeset: number, noChangesetCommits: Array, changesetsAdded: number, absentAtTo: number, totalCommits: number, downgradedMajor: boolean, body: string }} + * @returns {{ bump: string, declaredLevel: string|null, breaking: number, breakingByLevel: number, breakingByAnnotation: number, releasing: Array, releaseNothing: number, noChangeset: number, noChangesetCommits: Array, changesetsAdded: number, absentAtTo: number, totalCommits: number, downgradedMajor: boolean, adr0087Scaffold: boolean, body: string }} */ export function buildDigest({ objectuiRoot, @@ -715,7 +811,7 @@ export function buildDigest({ } } - const body = [ + const rendered = [ accounting, '', ...lines, @@ -723,6 +819,20 @@ export function buildDigest({ ...(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, @@ -737,6 +847,7 @@ export function buildDigest({ absentAtTo, totalCommits, downgradedMajor, + adr0087Scaffold, body, }; } @@ -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); @@ -1624,6 +1750,178 @@ 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(//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(''), + `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. + writeFileSync( + gateCs, + readFileSync(gateCs, 'utf8').replace( + ADR_0087_SCAFFOLD, + '', + ), + ); + gg('add', '-A'); + gg('commit', '-q', '-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 }); } From 1b263cba5d58c7891ec866cb8a812b7b7c9ce411 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 04:27:44 +0000 Subject: [PATCH 2/2] =?UTF-8?q?test(scripts):=20=E5=8F=8D=E5=90=91?= =?UTF-8?q?=E9=AA=8C=E8=AF=81=E5=8F=91=E7=8E=B0=E7=9A=84=E8=87=AA=E6=B5=8B?= =?UTF-8?q?=E7=A1=AC=E5=8C=96=20=E2=80=94=E2=80=94=20=E6=B6=88=E8=9E=8D?= =?UTF-8?q?=E6=97=B6=E7=BB=99=E5=87=BA=E5=85=B7=E5=90=8D=E5=A4=B1=E8=B4=A5?= =?UTF-8?q?=E8=80=8C=E9=9D=9E=20execFileSync=20=E6=A0=88=20(#6494)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 反向验证(把脚手架发射消融掉再跑自测)暴露一条本 PR 自己的毛病:没有占位可替换时 `replace()` 是空操作,随后的 `git commit` 在干净树上退出 1 并抛出,整份自测以一段 `execFileSync` 栈中止,ROUND TRIP (green) 这条根本没来得及报告。消融正是下一个读者 最需要「哪条断言红了」的时刻。 于是:替换先经 `check()` 断言(占位确实在、替换确实改动了文件、前后各恰好一个标记), 提交带 `--allow-empty`。消融后 9 条中 8 条具名判红、无异常;恢复后 9 条全绿。 Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01BDmDsu2575gDxeMCxXhDE3 --- scripts/objectui-changeset-digest.mjs | 26 +++++++++++++++++++------- 1 file changed, 19 insertions(+), 7 deletions(-) diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index bde43caf53..2ebdc81519 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -1904,15 +1904,27 @@ function selfTest() { // 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. - writeFileSync( - gateCs, - readFileSync(gateCs, 'utf8').replace( - ADR_0087_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, + '', + ); + 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', '-m', 'answer the ADR-0087 question'); + 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',