From 4024c66c887175a9daca12e801e0e2f2b3e4754c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 3 Aug 2026 15:08:06 +0000 Subject: [PATCH] fix(tooling): objectui-range reads objectui's declared changesets, not commit types (#4843) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `scripts/objectui-range.mjs` — whose output is pasted into a release page's Console section — kept the `feat|fix` subject-line guess that #4731 removed from `bump-objectui.sh`. Measured on the same real range (7d9734d5e321..785b8a5d432c, 53 non-merge objectui commits) the default output dropped 13 commits that actually released, 6 of them breaking `refactor(...)!`, and listed 5 that release nothing in objectui. The two scripts now share ONE criterion instead of each carrying a copy: the classification moved into an exported `classifyRange()` in objectui-changeset-digest.mjs, used by both the pin changeset (the platform release record) and this release-page aggregation. Because the output IS release-page body text, the accounting is unconditional — every run states how many changesets released of how many were added across how many commits, plus the excluded counts (release-nothing changesets, changeset-less commits), zeros included. `--all` now names those entries instead of switching a filter off. Headings group by declared level; grouping is presentation, never a filter. Guarded by `objectui-range.mjs --self-test`, folded into the existing `pnpm check:objectui-changeset` gate. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018iARDqtrhQgz6fVHDeDkbQ --- .../objectui-range-from-declarations.md | 34 ++ .github/workflows/lint.yml | 3 + docs/releases-maintenance.md | 21 +- package.json | 2 +- scripts/objectui-changeset-digest.mjs | 89 +++- scripts/objectui-range.mjs | 481 +++++++++++++++--- 6 files changed, 528 insertions(+), 102 deletions(-) create mode 100644 .changeset/objectui-range-from-declarations.md diff --git a/.changeset/objectui-range-from-declarations.md b/.changeset/objectui-range-from-declarations.md new file mode 100644 index 0000000000..6aef7d1c9f --- /dev/null +++ b/.changeset/objectui-range-from-declarations.md @@ -0,0 +1,34 @@ +--- +--- + +Tooling-only (#4843): `scripts/objectui-range.mjs` — the aggregation layer whose output is +pasted into a release page's **Console** section — now reports the frontend changes objectui +**declared** over the pinned range instead of guessing them from conventional-commit types. +Releases nothing; no package changes. + +#4731 fixed this failure mode on the `bump-objectui.sh` side; the release page kept the old +guess (`const KEEP = ALL_TYPES ? null : new Set(['feat', 'fix'])`, with `--all` as an +explicit opt-in). Measured on the same real range (`7d9734d5e321..785b8a5d432c`, 53 +non-merge objectui commits), the default output **dropped 13 commits that actually +released** — 6 of them breaking `refactor(...)!`, including `refactor(layout)!: delete +PageNodeRenderer` and burn-ledger batches 2/4/5/6/7, plus `chore(deps): lockstep the +@objectstack family onto 17.0.0-rc.1` — while **listing 5 commits that release nothing** in +objectui (two `fix(ci)`: one with no changeset, one whose changeset has an empty +frontmatter). Breaking changes were the single class structurally unable to appear, in the +artifact that leads with breaking changes. + +The two scripts now share **one** criterion rather than each carrying a copy: the +classification moved into an exported `classifyRange()` in +`scripts/objectui-changeset-digest.mjs`, and both `bump-objectui.sh` (the platform release +record) and `objectui-range.mjs` (the release page) go through it. A second copy would +drift, and the first thing it would drift on is the class that already went missing once. + +Because the output *is* release-page body text — a reader cannot tell a filtered list from a +complete one — the accounting is now **unconditional**: every run prints how many changesets +released of how many were added across how many commits, plus the excluded counts +(release-nothing changesets, commits carrying no changeset), including when those counts are +zero. `--all` changes meaning from "include every commit type" (the filter is gone) to "also +name the excluded entries, one per line". Headings group by the level objectui declared +(breaking / features / fixes); grouping is presentation and never a filter. Guarded by +`node scripts/objectui-range.mjs --self-test`, folded into the existing +`pnpm check:objectui-changeset` gate. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 23a328c791..dcd8419ad0 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -233,6 +233,9 @@ jobs: # throwaway git repos carrying those exact shapes, runs the real scripts # over them, and asserts the emitted changeset — including that a cap which # fires announces itself and that an unwalkable range degrades loudly. + # #4843 folded `scripts/objectui-range.mjs` — the release page's Console + # section — onto that same `classifyRange()` criterion, so both self-tests + # run here: one criterion, two consumers, no room to drift apart. - name: objectui pin-changeset digest guard run: pnpm check:objectui-changeset diff --git a/docs/releases-maintenance.md b/docs/releases-maintenance.md index d1e4724c97..23a3eedc2c 100644 --- a/docs/releases-maintenance.md +++ b/docs/releases-maintenance.md @@ -71,15 +71,32 @@ platform version maps to. Backend content comes from the spec/package changesets. Frontend content comes from objectui's history for the SHA range bundled in that release. Because `.objectui-sha` is version-controlled, `scripts/objectui-range.mjs` computes that range from any two -framework revisions and prints the feat/fix commits grouped by type + the largest -touched areas — ready to paste into a Console section: +framework revisions and prints the frontend changes **objectui declared** over it — +grouped by declared level (breaking / features / fixes), with the largest touched +areas — ready to paste into a Console section: ```bash # frontend delta bundled between two framework revisions (needs ../objectui) node scripts/objectui-range.mjs # e.g. the two release commits node scripts/objectui-range.mjs --from --to --json # explicit SHAs / tooling +node scripts/objectui-range.mjs --all # also name what ships nothing ``` +**What "a frontend change" means here (#4843).** The list comes from the +`.changeset/*.md` files objectui added over the range, via the same `classifyRange()` +in `scripts/objectui-changeset-digest.mjs` that `bump-objectui.sh` uses to write the +pin changeset (#4731) — one criterion, two consumers, so the platform release record +and this page's Console section cannot disagree. A changeset with an empty frontmatter +block is changesets' own "release-nothing" marker and is excluded; so is a commit that +added no changeset. **Both exclusions are counted out loud in the printed markdown**, +because the output *is* release-page body text and a reader cannot otherwise tell a +filtered list from a complete one. Headings group by declared level; grouping is +presentation and never a filter. + +This replaced a `feat|fix` guess on commit subjects. On the real range +`7d9734d5e321..785b8a5d432c` that guess dropped 13 commits that actually released — +6 of them breaking `refactor(...)!` — and listed 5 that release nothing. + Without an objectui checkout it still prints the SHA range to inspect. The framework changesets also embed companion frontend notes inline ("Companion objectui PR ships…", renderer notes), which are enough to write an accurate Console section on diff --git a/package.json b/package.json index bc3ba7eb03..6831dcbf82 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "check:durability-log-level": "node scripts/check-durability-degradation-log-level.mjs --self-test && node scripts/check-durability-degradation-log-level.mjs", "check:startup-registry-verdict": "node scripts/check-startup-registry-verdict.mjs --self-test && node scripts/check-startup-registry-verdict.mjs", "check:console-sha": "node scripts/check-console-sha.mjs", - "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test", + "check:objectui-changeset": "node scripts/objectui-changeset-digest.mjs --self-test && node scripts/objectui-range.mjs --self-test", "check:release-notes": "node scripts/check-release-notes.mjs", "check:node-version": "node scripts/check-node-version.mjs", "check:published-files": "node scripts/check-published-files.mjs --self-test && node scripts/check-published-files.mjs", diff --git a/scripts/objectui-changeset-digest.mjs b/scripts/objectui-changeset-digest.mjs index 1c2de6f489..5e37fbb6ce 100644 --- a/scripts/objectui-changeset-digest.mjs +++ b/scripts/objectui-changeset-digest.mjs @@ -125,12 +125,21 @@ export function clampSummary(summary, limit = 180) { * it — the released frontend change would vanish from the release record, the * very failure this whole mechanism exists to prevent. * - * @returns {{ entries: Array<{ path: string, sha: string, subject: string }>, totalCommits: number, commitsWithChangeset: number }} + * `commits` is every non-merge commit in the range (newest first) and + * `commitsWithChangesetShas` the subset that added one, so a caller can NAME + * the commits it is leaving out instead of only counting them (#4843). + * + * @returns {{ entries: Array<{ path: string, sha: string, subject: string }>, commits: Array<{ sha: string, subject: string }>, totalCommits: number, commitsWithChangeset: number, commitsWithChangesetShas: Set }} */ export function collectAddedChangesets(objectuiRoot, from, to) { - const totalCommits = git(objectuiRoot, ['log', '--no-merges', '--format=%H', `${from}..${to}`]) + const commits = git(objectuiRoot, ['log', '--no-merges', '--format=%H%x09%s', `${from}..${to}`]) .split('\n') - .filter(Boolean).length; + .filter(Boolean) + .map((line) => { + const tab = line.indexOf('\t'); + return { sha: line.slice(0, tab), subject: line.slice(tab + 1) }; + }); + const totalCommits = commits.length; const raw = git(objectuiRoot, [ 'log', @@ -164,7 +173,13 @@ export function collectAddedChangesets(objectuiRoot, from, to) { commitsWithChangeset.add(sha); entries.push({ path, sha, subject }); } - return { entries, totalCommits, commitsWithChangeset: commitsWithChangeset.size }; + return { + entries, + commits, + totalCommits, + commitsWithChangeset: commitsWithChangeset.size, + commitsWithChangesetShas: commitsWithChangeset, + }; } /** Read a changeset's content at `to`, falling back to the commit that added it. */ @@ -196,31 +211,36 @@ export function inPreMode(frameworkRoot) { } /** - * Build the digest for a range. + * THE criterion: which objectui changes over `from..to` actually ship in the + * frontend release, as objectui DECLARED it — plus a full account of what is + * being left out and why. * - * @returns {{ bump: string, declaredLevel: string|null, breaking: number, releasing: Array, releaseNothing: number, noChangeset: number, totalCommits: number, downgradedMajor: boolean, body: string }} + * This is the single shared implementation. `bump-objectui.sh` (via + * `buildDigest` below, #4731) and `scripts/objectui-range.mjs` (#4843) both go + * through it, so the platform release record and the release page's Console + * section can never disagree about what "a releasing frontend change" means. + * Two copies of this rule would drift, and the first thing they would drift on + * is the class that already went missing once: breaking `refactor(...)!`. + * + * Nothing here reads a commit type. Grouping output BY type is presentation and + * belongs to the caller; it must never become a filter again. + * + * @returns {{ releasing: Array, releaseNothingEntries: Array, noChangesetCommits: Array, releaseNothing: number, noChangeset: number, changesetsAdded: number, totalCommits: number }} */ -export function buildDigest({ - objectuiRoot, - frameworkRoot = REPO_ROOT, - from, - to, - max = DEFAULT_MAX_ENTRIES, - bumpOverride = '', -}) { - const { entries, totalCommits, commitsWithChangeset } = collectAddedChangesets( +export function classifyRange({ objectuiRoot, from, to }) { + const { entries, commits, totalCommits, commitsWithChangesetShas } = collectAddedChangesets( objectuiRoot, from, to, ); const releasing = []; - let releaseNothing = 0; + const releaseNothingEntries = []; for (const entry of entries) { const { packages, summary } = parseChangeset(readAt(objectuiRoot, to, entry.sha, entry.path)); const level = highestLevel(packages); if (!level) { - releaseNothing++; + releaseNothingEntries.push({ ...entry, summary: summary || entry.subject }); continue; } releasing.push({ @@ -236,6 +256,38 @@ export function buildDigest({ const order = { major: 0, minor: 1, patch: 2 }; releasing.sort((a, b) => order[a.level] - order[b.level]); + const noChangesetCommits = commits.filter((c) => !commitsWithChangesetShas.has(c.sha)); + + return { + releasing, + releaseNothingEntries, + noChangesetCommits, + releaseNothing: releaseNothingEntries.length, + noChangeset: noChangesetCommits.length, + changesetsAdded: entries.length, + totalCommits, + }; +} + +/** + * Build the digest for a range. + * + * @returns {{ bump: string, declaredLevel: string|null, breaking: number, releasing: Array, releaseNothing: number, noChangeset: number, totalCommits: number, downgradedMajor: boolean, body: string }} + */ +export function buildDigest({ + objectuiRoot, + frameworkRoot = REPO_ROOT, + from, + to, + max = DEFAULT_MAX_ENTRIES, + bumpOverride = '', +}) { + const { releasing, releaseNothing, noChangeset, changesetsAdded, totalCommits } = classifyRange({ + objectuiRoot, + from, + to, + }); + const declaredLevel = releasing.length ? releasing.reduce( (best, r) => (LEVEL_RANK[r.level] > LEVEL_RANK[best] ? r.level : best), @@ -273,7 +325,6 @@ export function buildDigest({ ); } - const noChangeset = Math.max(0, totalCommits - commitsWithChangeset); const omitted = []; if (releaseNothing > 0) { omitted.push( @@ -286,7 +337,7 @@ export function buildDigest({ const accounting = `Derived from the changesets objectui declared over the range — ` + - `${releasing.length} releasing of ${entries.length} changeset${entries.length === 1 ? '' : 's'} added ` + + `${releasing.length} releasing of ${changesetsAdded} changeset${changesetsAdded === 1 ? '' : 's'} added ` + `across ${totalCommits} non-merge commit${totalCommits === 1 ? '' : 's'}` + (omitted.length ? `; omitted: ${omitted.join(', ')} (they ship no package code).` : '.'); diff --git a/scripts/objectui-range.mjs b/scripts/objectui-range.mjs index 400d9b7f84..5f82635755 100644 --- a/scripts/objectui-range.mjs +++ b/scripts/objectui-range.mjs @@ -1,12 +1,14 @@ #!/usr/bin/env node +// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license. +// // objectui-range — summarize the frontend (objectui) delta bundled between two // framework revisions, ready to paste into a release page's Console section. // // The platform is one version-locked train and the Console UI is frozen into // @objectstack/console at the objectui commit pinned in .objectui-sha. To learn // "what did the frontend change between platform version A and B", you diff the -// pinned SHA at two framework revisions and read objectui's log for that range. -// This script does exactly that — the aggregation layer described in +// pinned SHA at two framework revisions and read what objectui DECLARED over +// that range. This script does exactly that — the aggregation layer described in // docs/releases-maintenance.md. // // Usage: @@ -16,18 +18,48 @@ // node scripts/objectui-range.mjs --from --to // skip the framework lookup; use explicit objectui SHAs. // … --json emit structured JSON instead of markdown -// … --all include every conventional-commit type (default: feat + fix) +// … --all also itemize what ships nothing (release-nothing changesets and +// commits carrying no changeset), not just count them +// … --self-test run the built-in assertions over throwaway git repos // // Env: // OBJECTUI_ROOT=/path/to/objectui (default: ../objectui, like bump-objectui.sh) +// +// WHAT COUNTS AS A FRONTEND CHANGE HERE (#4843) +// --------------------------------------------- +// The releasing entries come from `classifyRange()` in +// scripts/objectui-changeset-digest.mjs — the SAME function `bump-objectui.sh` +// uses to write the pin changeset (#4731). One criterion, two consumers: the +// platform release record and this release-page section can no longer disagree +// about which frontend changes shipped. +// +// This script used to guess instead, keeping only `feat` + `fix` subject lines +// unless you passed `--all`. Measured on the real range +// `7d9734d5e321..785b8a5d432c` (53 non-merge commits, objectui), that guess: +// +// * DROPPED 13 commits that actually released, 6 of them BREAKING (`!`): +// `refactor(layout)!: delete PageNodeRenderer` and burn-ledger batches +// 2/4/5/6/7 — plus `chore(deps): lockstep the @objectstack family onto +// 17.0.0-rc.1`. Breaking changes were the single class structurally unable +// to appear, in the artifact that leads with breaking changes. +// * PULLED IN 5 commits that release nothing in objectui, two of them +// `fix(ci)` (one with no changeset at all, one whose changeset has an empty +// frontmatter — changesets' own spelling of "release-nothing"). +// +// The output is a release page's body, so a reader cannot tell a filtered list +// from a complete one. Hence: the accounting footer is UNCONDITIONAL — every +// excluded item is counted out loud in the artifact itself, and `--all` names +// them. Headings group by the level objectui declared; grouping is presentation +// and must never become a filter again. Declaration over inference, per +// AGENTS.md Prime Directive #12. import { execFileSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import { existsSync, readFileSync } from 'node:fs'; +import { dirname, join, resolve } from 'node:path'; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { classifyRange, clampSummary } from './objectui-changeset-digest.mjs'; const FRAMEWORK_ROOT = dirname(dirname(fileURLToPath(import.meta.url))); -const OBJECTUI_ROOT = - process.env.OBJECTUI_ROOT || join(FRAMEWORK_ROOT, '..', 'objectui'); const argv = process.argv.slice(2); const has = (f) => argv.includes(f); @@ -40,11 +72,16 @@ const positional = argv.filter( ); const JSON_OUT = has('--json'); -const ALL_TYPES = has('--all'); +const SHOW_EXCLUDED = has('--all'); if (has('-h') || has('--help')) { - console.log(readFileSync(fileURLToPath(import.meta.url), 'utf8').split('\n') - .filter((l) => l.startsWith('//')).map((l) => l.slice(3)).join('\n')); + console.log( + readFileSync(fileURLToPath(import.meta.url), 'utf8') + .split('\n') + .filter((l) => l.startsWith('//')) + .map((l) => l.slice(3)) + .join('\n'), + ); process.exit(0); } @@ -58,99 +95,383 @@ function git(cwd, args) { } // Resolve the objectui SHA pinned at a given framework rev (or the working tree). -function pinAt(rev) { +function pinAt(root, rev) { if (rev === undefined) { - const p = join(FRAMEWORK_ROOT, '.objectui-sha'); + const p = join(root, '.objectui-sha'); if (!existsSync(p)) die('.objectui-sha not found in the working tree'); return readFileSync(p, 'utf8').trim(); } try { - return git(FRAMEWORK_ROOT, ['show', `${rev}:.objectui-sha`]).trim(); + return git(root, ['show', `${rev}:.objectui-sha`]).trim(); } catch { return die(`cannot read .objectui-sha at framework rev '${rev}'`); } } -let fromSha = val('--from'); -let toSha = val('--to'); -if (!fromSha || !toSha) { - if (positional.length < 1) { - die('need [new-rev], or --from --to . See --help/header.'); - } - fromSha = pinAt(positional[0]); - toSha = pinAt(positional[1]); // undefined → working-tree pin +const CC = /^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; + +/** Conventional-commit scope, when the subject has one. PRESENTATION ONLY. */ +function scopeOf(subject) { + const m = subject.match(CC); + return m ? m[2] || '' : ''; } -if (!existsSync(join(OBJECTUI_ROOT, '.git'))) { - die( - `no objectui checkout at ${OBJECTUI_ROOT}.\n` + - ` Clone it as a sibling, or set OBJECTUI_ROOT=/path/to/objectui.\n` + - ` Range to inspect once available: ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)}`, +/** Declared level → the release-page heading it belongs under. */ +const SECTIONS = [ + ['major', 'Breaking changes'], + ['minor', 'Features'], + ['patch', 'Fixes'], +]; + +/** + * Render the Console section for a classified range. + * + * Every count that could hide something is in the returned markdown, always — + * including the zeros. A footer that only appears when something was dropped + * still leaves the reader unable to tell "nothing dropped" from "footer not + * implemented". + */ +export function renderRange({ fromSha, toSha, classified, showExcluded = false }) { + const { releasing, releaseNothingEntries, noChangesetCommits, changesetsAdded, totalCommits } = + classified; + + const areaCounts = {}; + for (const r of releasing) { + const scope = scopeOf(r.subject); + if (scope) areaCounts[scope] = (areaCounts[scope] || 0) + 1; + } + const topAreas = Object.entries(areaCounts) + .sort((a, b) => b[1] - a[1]) + .slice(0, 10); + + const line = (r) => { + const scope = scopeOf(r.subject) || r.packages[0] || ''; + return `- ${scope ? `**${scope}** — ` : ''}${clampSummary(r.summary)} (objectui \`${r.sha.slice(0, 9)}\`)`; + }; + + const out = []; + out.push( + ``, ); -} + if (topAreas.length) { + out.push('', `_Largest areas: ${topAreas.map(([a, n]) => `${a} (${n})`).join(', ')}_`); + } -if (fromSha === toSha) { - console.log(`objectui unchanged (${fromSha.slice(0, 12)}) — no frontend delta in this range.`); - process.exit(0); -} + for (const [level, heading] of SECTIONS) { + const group = releasing.filter((r) => r.level === level); + if (group.length) out.push('', `### ${heading}`, ...group.map(line)); + } + + if (!releasing.length) { + out.push( + '', + '_No releasing changeset in this range — every objectui commit here ships nothing._', + ); + } -let rawLog; -try { - rawLog = git(OBJECTUI_ROOT, [ - 'log', '--no-merges', '--format=%H%x09%s', `${fromSha}..${toSha}`, - ]); -} catch { - die( - `cannot walk ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)} in ${OBJECTUI_ROOT}.\n` + - ` Fetch it: git -C ${OBJECTUI_ROOT} fetch --all`, + // --- the accounting. UNCONDITIONAL: a release page reader cannot otherwise + // tell a filtered list from a complete one (#4843 / #3340). --- + const excluded = [ + `${releaseNothingEntries.length} release-nothing changeset${releaseNothingEntries.length === 1 ? '' : 's'}`, + `${noChangesetCommits.length} commit${noChangesetCommits.length === 1 ? '' : 's'} carrying no changeset`, + ]; + out.push( + '', + `_Derived from the changesets objectui declared over this range — ` + + `${releasing.length} releasing of ${changesetsAdded} added across ${totalCommits} non-merge commit(s). ` + + `Excluded as shipping no package code: ${excluded.join(', ')}` + + `${showExcluded ? '' : ' (run with `--all` to list them)'}._`, ); -} -const CC = /^(feat|fix|refactor|perf|docs|test|chore|build|ci|style|revert)(?:\(([^)]+)\))?(!)?:\s*(.+)$/; -const KEEP = ALL_TYPES ? null : new Set(['feat', 'fix']); + if (showExcluded) { + const rows = [ + ...releaseNothingEntries.map( + (e) => `- _(release-nothing)_ ${e.subject} (objectui \`${e.sha.slice(0, 9)}\`)`, + ), + ...noChangesetCommits.map( + (c) => `- _(no changeset)_ ${c.subject} (objectui \`${c.sha.slice(0, 9)}\`)`, + ), + ]; + out.push('', '### Excluded — ships no package code'); + out.push(...(rows.length ? rows : ['- _(nothing excluded in this range)_'])); + } -const commits = []; -for (const line of rawLog.split('\n').filter(Boolean)) { - const tab = line.indexOf('\t'); - const sha = line.slice(0, tab); - const subject = line.slice(tab + 1); - const m = subject.match(CC); - const type = m ? m[1] : 'other'; - const scope = m ? m[2] || '' : ''; - const desc = m ? m[4] : subject; - if (KEEP && !KEEP.has(type)) continue; - commits.push({ sha, type, scope, desc, subject }); + return { markdown: out.join('\n'), topAreas }; } -// Top touched areas (by scope) across the kept commits — the "big picture" line. -const areaCounts = {}; -for (const c of commits) if (c.scope) areaCounts[c.scope] = (areaCounts[c.scope] || 0) + 1; -const topAreas = Object.entries(areaCounts).sort((a, b) => b[1] - a[1]).slice(0, 10); +function main() { + let fromSha = val('--from'); + let toSha = val('--to'); + if (!fromSha || !toSha) { + if (positional.length < 1) { + die('need [new-rev], or --from --to . See --help/header.'); + } + fromSha = pinAt(FRAMEWORK_ROOT, positional[0]); + toSha = pinAt(FRAMEWORK_ROOT, positional[1]); // undefined → working-tree pin + } -const byType = { feat: [], fix: [] }; -for (const c of commits) (byType[c.type] || (byType[c.type] = [])).push(c); + const objectuiRoot = process.env.OBJECTUI_ROOT || join(FRAMEWORK_ROOT, '..', 'objectui'); -if (JSON_OUT) { - console.log(JSON.stringify( - { from: fromSha, to: toSha, count: commits.length, topAreas, commits }, null, 2, - )); - process.exit(0); -} + if (!existsSync(join(objectuiRoot, '.git'))) { + die( + `no objectui checkout at ${objectuiRoot}.\n` + + ` Clone it as a sibling, or set OBJECTUI_ROOT=/path/to/objectui.\n` + + ` Range to inspect once available: ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)}`, + ); + } -const line = (c) => `- ${c.scope ? `**${c.scope}** — ` : ''}${c.desc}`; -const out = []; -out.push(``); -if (topAreas.length) { - out.push('', `_Largest areas: ${topAreas.map(([a, n]) => `${a} (${n})`).join(', ')}_`); -} -if (byType.feat?.length) { - out.push('', '### Features', ...byType.feat.map(line)); + if (fromSha === toSha) { + console.log( + `objectui unchanged (${fromSha.slice(0, 12)}) — no frontend delta in this range.`, + ); + return 0; + } + + let classified; + try { + classified = classifyRange({ objectuiRoot, from: fromSha, to: toSha }); + } catch { + die( + `cannot walk ${fromSha.slice(0, 12)}..${toSha.slice(0, 12)} in ${objectuiRoot}.\n` + + ` Fetch it: git -C ${objectuiRoot} fetch --all`, + ); + } + + const { markdown, topAreas } = renderRange({ + fromSha, + toSha, + classified, + showExcluded: SHOW_EXCLUDED, + }); + + if (JSON_OUT) { + console.log( + JSON.stringify( + { + from: fromSha, + to: toSha, + criterion: 'declared-changeset', + count: classified.releasing.length, + totalCommits: classified.totalCommits, + changesetsAdded: classified.changesetsAdded, + releaseNothing: classified.releaseNothing, + noChangeset: classified.noChangeset, + topAreas, + releasing: classified.releasing, + releaseNothingEntries: classified.releaseNothingEntries, + noChangesetCommits: classified.noChangesetCommits, + }, + null, + 2, + ), + ); + return 0; + } + + console.log(markdown); + return 0; } -if (byType.fix?.length) { - out.push('', '### Fixes', ...byType.fix.map(line)); + +// --------------------------------------------------------------------------- +// Self-test (#4843) — the repo idiom for a `scripts/` gate: build a throwaway +// git repo carrying the exact shapes measured on the real range, run the real +// code over it, assert the ARTIFACT (the markdown a maintainer pastes). +// --------------------------------------------------------------------------- + +function selfTest() { + const failures = []; + const check = (name, cond, detail = '') => { + if (cond) { + console.log(` ✓ ${name}`); + } else { + failures.push(`${name}${detail ? ` — ${detail}` : ''}`); + console.log(` ✗ ${name}${detail ? ` — ${detail}` : ''}`); + } + }; + + const tmp = mkdtempSync(join(tmpdir(), 'objectui-range-selftest-')); + try { + const ui = join(tmp, 'objectui'); + mkdirSync(join(ui, '.changeset'), { recursive: true }); + const g = (...args) => git(ui, args); + g('init', '-q', '-b', 'main'); + g('config', 'user.email', 'selftest@objectstack.ai'); + g('config', 'user.name', 'self test'); + g('config', 'commit.gpgsign', 'false'); + + const commit = (subject, files) => { + for (const [path, content] of Object.entries(files)) { + mkdirSync(dirname(join(ui, path)), { recursive: true }); + writeFileSync(join(ui, path), content); + } + g('add', '-A'); + g('commit', '-q', '-m', subject); + return g('rev-parse', 'HEAD').trim(); + }; + + const base = commit('chore: base', { 'README.md': 'base\n' }); + + // The five shapes measured on 7d9734d5e321..785b8a5d432c. + // 1. an ordinary releasing `feat` — the only shape the old filter got right + commit('feat(grid): aggregate single-call mode for bulk actions (#3201)', { + '.changeset/bulk-action-aggregate.md': + '---\n"@object-ui/plugin-grid": minor\n---\n\nGrid bulk actions gain an aggregate single-call mode.\n', + 'src/grid.ts': 'a\n', + }); + // 2. a BREAKING `refactor(...)!` — structurally invisible to a feat|fix filter + const breakingSha = commit( + 'refactor(layout)!: delete PageNodeRenderer, the unregistered page-node renderer (#3225)', + { + '.changeset/remove-dead-layout-page-node-renderer.md': + '---\n"@object-ui/layout": major\n---\n\nRemove `PageNodeRenderer`, the dead page-node renderer (objectui#3223).\n', + 'src/layout.ts': 'b\n', + }, + ); + // 3. a releasing `chore(deps)` — also invisible to a feat|fix filter + commit('chore(deps): lockstep the @objectstack family onto 17.0.0-rc.1 (#3189)', { + '.changeset/objectstack-family-rc1-lockstep.md': + '---\n"@object-ui/app-shell": minor\n"@object-ui/core": minor\n---\n\nBring the whole `@objectstack` family to `17.0.0-rc.1`.\n', + 'package.json': '{}\n', + }); + // 4. matches `fix` on the subject, declares release-nothing (empty frontmatter) + commit('fix(ci): hand the cross-repo token to github-script (#3186)', { + '.changeset/fix-cross-repo-closer-require.md': + '---\n---\n\nfix(ci): hand the cross-repo token to github-script\n\nRelease-nothing: touches a workflow only.\n', + '.github/workflows/x.yml': 'on: push\n', + }); + // 5. matches `fix` on the subject, carries no changeset at all + commit('fix(ci): never render a budget FAIL for a run that measured nothing (#3198)', { + '.github/workflows/y.yml': 'on: push\n', + }); + const head = g('rev-parse', 'HEAD').trim(); + + console.log('objectui-range --self-test'); + + const classified = classifyRange({ objectuiRoot: ui, from: base, to: head }); + const { markdown } = renderRange({ fromSha: base, toSha: head, classified }); + + // --- the class that went missing: it must be IN the artifact, and lead --- + check( + 'a BREAKING `refactor(...)!` commit IS in the pasteable list', + markdown.includes('PageNodeRenderer') && markdown.includes(breakingSha.slice(0, 9)), + markdown, + ); + check( + 'breaking changes get their own leading section (declared major)', + markdown.indexOf('### Breaking changes') > -1 && + markdown.indexOf('### Breaking changes') < markdown.indexOf('### Features'), + markdown, + ); + check( + 'a releasing non-feat/fix `chore(deps)` commit IS in the list', + markdown.includes('Bring the whole'), + markdown, + ); + check( + 'commit type never filters: all 3 releasing changesets are listed', + classified.releasing.length === 3, + `got ${classified.releasing.length}`, + ); + + // --- what ships nothing is excluded, and SAYS SO in the artifact --- + check( + 'a `fix(ci)` with an EMPTY frontmatter changeset is NOT listed', + !markdown.includes('cross-repo token'), + markdown, + ); + check( + 'a `fix(ci)` with no changeset at all is NOT listed', + !markdown.includes('budget FAIL'), + markdown, + ); + check( + 'the release-nothing count is spoken IN THE MARKDOWN, not just in JSON', + classified.releaseNothing === 1 && markdown.includes('1 release-nothing changeset'), + markdown, + ); + check( + 'the no-changeset commit count is spoken IN THE MARKDOWN', + classified.noChangeset === 1 && markdown.includes('1 commit carrying no changeset'), + markdown, + ); + check( + 'the accounting footer is unconditional and states the totals', + markdown.includes('3 releasing of 4 added across 5 non-merge commit(s)'), + markdown, + ); + + // --- a range that excludes NOTHING still says so (zeros are load-bearing) --- + const clean = renderRange({ + fromSha: base, + toSha: head, + classified: { + ...classified, + releaseNothingEntries: [], + noChangesetCommits: [], + releaseNothing: 0, + noChangeset: 0, + }, + }); + check( + 'a range excluding nothing STILL prints the accounting (0, not silence)', + clean.markdown.includes('0 release-nothing changesets') && + clean.markdown.includes('0 commits carrying no changeset'), + clean.markdown, + ); + + // --- `--all` names the excluded entries instead of only counting them --- + const all = renderRange({ fromSha: base, toSha: head, classified, showExcluded: true }); + check( + '`--all` itemizes the excluded commits by subject', + all.markdown.includes('### Excluded — ships no package code') && + all.markdown.includes('cross-repo token') && + all.markdown.includes('budget FAIL'), + all.markdown, + ); + + // --- end-to-end through the real CLI ------------------------------------ + const cli = fileURLToPath(import.meta.url); + const run = (args) => + execFileSync('node', [cli, ...args], { + encoding: 'utf8', + env: { ...process.env, OBJECTUI_ROOT: ui }, + }); + const cliMd = run(['--from', base, '--to', head]); + check( + 'the CLI markdown carries the breaking entry and not the ci noise', + cliMd.includes('PageNodeRenderer') && !cliMd.includes('cross-repo token'), + cliMd, + ); + const cliJson = JSON.parse(run(['--from', base, '--to', head, '--json'])); + check( + 'the CLI JSON reports the declared criterion and the excluded counts', + cliJson.criterion === 'declared-changeset' && + cliJson.count === 3 && + cliJson.releaseNothing === 1 && + cliJson.noChangeset === 1, + JSON.stringify(cliJson).slice(0, 200), + ); + check( + 'every listed entry carries the level objectui declared', + cliJson.releasing.every((r) => ['major', 'minor', 'patch'].includes(r.level)) && + cliJson.releasing.filter((r) => r.level === 'major').length === 1, + JSON.stringify(cliJson.releasing.map((r) => r.level)), + ); + } finally { + rmSync(tmp, { recursive: true, force: true }); + } + + if (failures.length) { + console.error(`\n⛔ objectui-range --self-test: ${failures.length} failure(s)`); + for (const f of failures) console.error(` - ${f}`); + return 1; + } + console.log('✓ objectui-range --self-test: all checks passed'); + return 0; } -const extra = Object.keys(byType).filter((t) => t !== 'feat' && t !== 'fix' && byType[t].length); -for (const t of extra) out.push('', `### ${t}`, ...byType[t].map(line)); -if (commits.length === 0) out.push('', '_No feat/fix commits in range (try --all)._'); -console.log(out.join('\n')); +if (resolve(process.argv[1] ?? '') === resolve(fileURLToPath(import.meta.url))) { + process.exit(has('--self-test') ? selfTest() : main()); +}