diff --git a/.claude/agent-memory/product-architect/MEMORY.md b/.claude/agent-memory/product-architect/MEMORY.md index 7067ff751..fa7aedd15 100644 --- a/.claude/agent-memory/product-architect/MEMORY.md +++ b/.claude/agent-memory/product-architect/MEMORY.md @@ -7,6 +7,7 @@ - [Source-report split inference](source-report-split-inference.md) — budgetLines[]/deposits[] are this-source-scoped so array-shape gates are proxies; **`splitKind` SHIPPED #1911/PR #2015** incl. the ≠S-per-arm predicate, the residual arithmetic proving `(less deposit)` in both directions, the UNION-dedup/`COUNT(*)` trap, and why `isSplit` must be retained as an independent cross-check; pdfmake `'2*'` width trap; wiki + shared type JSDoc fixed (#1914, #1917/PR #1994) - [Story reviews](story-reviews.md) — per-story and per-PR review log - [Client PDF pipeline](client-pdf-pipeline.md) — ADR-034 report PDF generation, reportContent content/layout split (#1900), `dontBreakRows` silent-drop rule, document-level deduplicated legend (#1965) — ADR-034 B4 rule + legend addendum landed in PR #1979; per-locale header character budget + "no interface `t` in header/footer" (#1937/#1938, PR #1982); pdfmake `Content` is unspreadable (TS2698) but `Object.assign` needs no cast, and per-item `wordBreak`/newline-only-run facts (#1968, PR #2002). **ADR-034 debt fully PAID 2026-08-04 (#1914)**: width rule #1 (`max(horizontalRatio) <= 1`, not `_minWidth`), module table, override keys, dontBreakRows/height-bound section, injection-only locale contract. `merge.ts` footer/header interface-`t` defect **CLOSED** (footer PR #2000/#1993, header #1938); `TFunction` removed from all of `reportPdf/*` in #2001/PR #2007, so the locale contract is now compiler-enforced there and `buildReportContent.ts` is the single legitimate `TFunction` entry point — ADR-034 lines 82/178/186-188/200/219 all state the old weaker contract and needed a wiki pass (flagged in the PR #2007 review). **ADR-034 rule #1 is WRONG A SECOND TIME (proven in the PR #2008 review, 2026-08-05): `horizontalRatio` is a cell-origin bound, not a content-extent check — it is unconditionally `<= 1` on the all-fixed-width production table and cannot detect token overflow. The check that works is per-cell `_minWidth <= _calcWidth` (the ADR's `wordBreak` false-positive rationale for banning `_minWidth` is empirically false). **Third correction PAID 2026-08-05** (wiki `da1324b`): rule #1 leads with per-cell `_minWidth`, `maxHorizontalRatio` re-scoped to table-box positioning with a vacuity warning, `_minWidth` ban split table-level vs per-cell, 3rd Deviation Log row added. Semantics verified by mutation test in the PR #2008 round-2 review; **two follow-ups still open on the page — every quoted figure (69.28/33.54/266.16pt) is stale, and the rule says "every cell" while the test covers only Usage.** **#1973/PR #2010 (2026-08-05, CHANGES REQUESTED): the 2-hardcoded-shapes era is over — 96 legal column subsets via a single-absorber `computeColumnWidths`, `reportContent/columns.ts` as the AC 2.1 single derivation; `USAGE_WIDTH_7COL` 138.28pt is the NARROWEST Usage width so the 650 chunk budget needed no re-measurement (one-sided clamp). ADR-034's "Geometry constraint that blocks a feature" (line 153) was a false prohibition, plus line 144's constant reference and line 155's "hangs" claim (it throws). **All three PAID by me 2026-08-05, wiki `eb24774`**: section replaced with "Column geometry is a computed engine, not two pinned shapes", `columns.ts` added to the module structure, Deviation Log row. Same commit documented the wizard **tier-factory convention** in Architecture.md (never documented anywhere before, born #1947 after #1943/#1946) incl. the "second `DISCARD_EDITS` opt-out must trigger a tier split" limit.** **#1912/PR #2028 (2026-08-06, APPROVED): required `reportFormatters` is ADR-034 line 230's own "remove the channel" principle, and line 248 already documented the 6-arg signature — no Deviation Log row, but invariant 1 (line 206) now under-claims and wants one sentence on the next ADR-034 pass, alongside a one-liner naming `Formatters` (PDF injection contract) vs `AppFormatters extends Formatters` (app-wide superset). Open medium: `buildReportContent.ts` still has 4 unguarded union-into-`reportT()` keys (lines 143/146/204/274) rendering into the bank PDF — see recurring-patterns "1 of N sites".** **#1940/PR #2032 (2026-08-06, APPROVED): runt-merge gate + `'… '` continuation marker; the backward-merge induction (receiver virginity is structural) verified by hand AND a 400k-case verbatim-port fuzz; ADR-034 line 152's call-site quote stale a THIRD time and line 148's "bound what a cell renders" rule now under-satisfied by the 2-char unbounded marker (safe by size only: worst case +1 line, 41->42 vs the 44-line budget). #1950 needs no reorder, but its guard pins a rendered quantity that the marker moves to 36 chars / 4 lines / 44.8pt on continuation rows.** **#1941/PR #2033 (2026-08-06, APPROVED, comment not `--approve` — own PR): editor-side input caps land as module constants in `ReportContentEditor.tsx`, `USAGE_TEXT_MAX_LENGTH = 500` against the computed 650. **ADR-034 pass gains a FIFTH item**: line 148's "bound what a cell renders" now needs an input-side counterpart sentence — "input caps sit below the render budget so a typed value never needs a continuation row on its own; the render budget remains the only correctness bound." Also routed to #1950: extend its guard to `USAGE_TEXT_MAX_LENGTH < usageChunkCharsForWidth(USAGE_WIDTH_7COL)` (needs the constant exported; keep it in the editor — an input constraint in the renderer inverts the dependency). NOT a guard on `500 + 150 <= 650`: that arithmetic is a fiction, see recurring-patterns.** +- [Release toolchain](release-toolchain.md) — ccc preset major must match the conventional-changelog-writer engine (hold ccc at v9 until release-notes-generator@15); empty release notes hid for months; a crash-fix can restore the _silent_ bug, judge on output not on the error going away (#2082) - [CI Jest shard tuning](ci-test-job-tuning.md) — CI runners are ~1.8x slower than the dev box; maxWorkers/shardTotal/coverage are non-levers; diagnose by last-PASS timestamp; where testTimeout belongs - [Diary drafts pattern](diary-drafts-pattern.md) — ADR-022 draft lifecycle via status column on parent table - [EPIC-03 refinement](epic03-refinement.md) — 40 consolidated refinement items diff --git a/.claude/agent-memory/product-architect/release-toolchain.md b/.claude/agent-memory/product-architect/release-toolchain.md new file mode 100644 index 000000000..418b6e2e4 --- /dev/null +++ b/.claude/agent-memory/product-architect/release-toolchain.md @@ -0,0 +1,73 @@ +--- +name: release-toolchain +description: semantic-release changelog toolchain constraint (conventional-changelog preset major must match the writer engine), and why a crash-fix must be judged on output not on the error going away +metadata: + type: project +--- + +# Release toolchain: preset major must match the writer engine + +`conventional-changelog-conventionalcommits` (the `preset: "conventionalcommits"` in +`.releaserc.json`) must stay on the **v9 line** while `semantic-release@25` is in use. + +**Why:** there are two incompatible rendering engines. + +- `conventional-changelog-writer@8` is **handlebars**-based and expects `mainTemplate` / + `headerPartial` / `commitPartial` / `footerPartial` as **template strings**. +- `conventional-changelog-writer@9` dropped handlebars entirely for + `@conventional-changelog/template`, whose partials are **JS functions**. + +`@semantic-release/release-notes-generator@14` (what `semantic-release@25.0.9` ships) pins +`conventional-changelog-writer@^8`. The ccc **v10** preset emits function partials for the +writer@9 engine. Feeding function partials to the handlebars writer does not throw — it renders +**header-only, empty release notes**. From ccc `10.3.0` upstream planted a deliberate poison-pill +`mainTemplate` (`@conventional-changelog/template`'s `createLegacyWriterGuard`) whose _doc comment +says_ it exists to "fail loudly instead of silently rendering an empty changelog"; under handlebars +it trips `helperMissing` and produces +`Missing helper: "conventional-changelog-conventionalcommits requires conventional-changelog-writer@9 or newer"`. + +Upstream's own remedy text is "Update the tooling **or use an older major version of the preset**." +The real upgrade path is `@semantic-release/release-notes-generator@15` (writer@9, and it _also_ +bumps `conventional-commits-parser` 6->7 and `conventional-commits-filter` 5->6 — writer alone is +not the whole migration, which is why a lone `overrides` forcing writer@9 is an untested triple). +As of 2026-09-08 rng 15 is only `15.0.0-beta.2`. A `.github/dependabot.yml` ignore rule holds ccc +at the v9 major; remove it when rng 15 is adopted. + +**How to apply:** treat the ccc major and the writer major as one coupled decision. Any bump of +`semantic-release`, `@semantic-release/release-notes-generator`, or +`conventional-changelog-conventionalcommits` must be validated by a `semantic-release --dry-run` +that **prints non-empty release notes** — not merely by the absence of an error. + +## The methodological trap this hid behind (issue #2082) + +The empty-notes bug ran undetected for **months**: every beta release body was ~115 characters +(the compare-link header and nothing else), and the stable releases only looked healthy because +the workflow's "Enrich release notes with summary" step prepends the hand-written +`RELEASE_SUMMARY.md`. The auto-generated half of `v2.14.0` was also just the header. + +Two generalizable rules came out of it: + +1. **A fix that removes a crash may only restore the _silent_ version of the same bug.** The + obvious remedy here (pin ccc back to `10.2.1`, the version that last "released successfully") + makes `generateNotes` exit 0 — and still emits an empty changelog, because 10.2.1 is also a + writer@9-era preset. Verifying "the error is gone" would have shipped the bug back. The check + that discriminates is the _artifact_: `gh api repos/.../releases --jq '.[] | "\(.tag_name) \(.body|length)"'`. + See [[recurring-patterns]] — this is the release-infra instance of "assertions that pass on nothing". +2. **A CI step that parses stdout and discards the exit code cannot distinguish a crash from a + no-op.** `OUTPUT=$(npx semantic-release 2>&1) || true` plus `grep -q "Created tag v"` made a + hard failure look identical to "no releasable commits": job green, + `new-release-published=false`, and all six downstream jobs skipped. Whenever a step's success + is inferred from log text, the exit code must still gate it, and the "nothing to do" summary + must not be reachable from the failure path. + +## Verifying release changes without a working `npm install` + +`npm install` fails with nondeterministic `ENOTDIR` in the sandbox worktrees (different path each +attempt; the host mount sits at ~98% full). A faithful substitute: install `semantic-release` plus +the preset into a throwaway `/tmp` prefix, then run that binary with **cwd = the repo worktree**. +`@semantic-release/release-notes-generator` resolves the preset via +`importFrom.silent(__dirname, ...)` first, so the harness's copy is used, while git history, +`.releaserc.json` and the remote all come from the real repo. Export `GITHUB_ACTIONS=true`, +`GITHUB_EVENT_NAME=push`, `GITHUB_REF=refs/heads/beta` and a token so `env-ci` resolves the branch +and `verifyConditions` passes — otherwise `--branches ` is rejected for a branch that does +not exist on the remote, and the run never reaches `generateNotes`. diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 90b8c282d..ecdfddde6 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -34,6 +34,16 @@ updates: # (see PR #1882). Remove once ts-jest widens its peer range. - dependency-name: 'typescript' update-types: ['version-update:semver-major'] + # Hold conventional-changelog-conventionalcommits at v9 until semantic-release + # ships a release-notes-generator built on conventional-changelog-writer@9. + # The v10 preset emits JS-function partials for the writer@9 rendering engine, + # but @semantic-release/release-notes-generator@14 pins the handlebars-based + # writer@8. That mismatch produced header-only (empty) release notes silently + # from v10.0.0, and from v10.3.0 upstream added a deliberate guard that turns + # it into a hard `generateNotes` crash (see issue #2082). Remove this rule once + # @semantic-release/release-notes-generator@15 (writer@9) is released and adopted. + - dependency-name: 'conventional-changelog-conventionalcommits' + update-types: ['version-update:semver-major'] # GitHub Actions - package-ecosystem: github-actions diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 345844603..55d47b281 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -49,8 +49,24 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - OUTPUT=$(npx semantic-release 2>&1) || true + # Capture stdout+stderr for the tag/version parsing below, but keep the + # exit code: a crash and a clean "no relevant commits" run are otherwise + # indistinguishable, because both simply lack "Created tag v" in stdout. + # Swallowing the status with `|| true` let a hard generateNotes failure + # report success with new-release-published=false, silently skipping every + # downstream job — that is how v2.15.0 was never published (issue #2082). + # Never discard this status. + set +e + OUTPUT=$(npx semantic-release 2>&1) + STATUS=$? + set -e echo "$OUTPUT" + + if [ "$STATUS" -ne 0 ]; then + echo "::error title=semantic-release failed::semantic-release exited with status ${STATUS}. No release was published; see the step log above for the failing plugin step." + exit "$STATUS" + fi + if echo "$OUTPUT" | grep -q "Created tag v"; then VERSION=$(echo "$OUTPUT" | grep -oP 'Created tag v\K[0-9]+\.[0-9]+\.[0-9]+(-beta\.[0-9]+)?' || echo "") echo "new-release-published=true" >> "$GITHUB_OUTPUT" @@ -108,12 +124,22 @@ jobs: echo "| **Type** | ${RELEASE_TYPE} |" echo "| **GitHub Release** | [v${VERSION}](https://github.com/steilerDev/cornerstone/releases/tag/v${VERSION}) |" } >> "$GITHUB_STEP_SUMMARY" - else + elif [ "$PUBLISHED" = "false" ]; then { echo "### No Release" echo "" echo "No new release — commits since last tag did not trigger a version bump." } >> "$GITHUB_STEP_SUMMARY" + else + # semantic-release did not complete, so "no version bump" is not a + # supported conclusion — the outputs were never set. Say so plainly + # rather than reporting a clean no-op (issue #2082). + { + echo "### Release Failed" + echo "" + echo "\`semantic-release\` did not complete successfully, so no release was published." + echo "This is **not** a \"no relevant commits\" run — check the \`Semantic Release\` step log for the failing plugin step." + } >> "$GITHUB_STEP_SUMMARY" fi # --------------------------------------------------------------------------- diff --git a/package-lock.json b/package-lock.json index aea5883d3..dbc0f51d2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22,7 +22,7 @@ "@testing-library/user-event": "14.6.7", "@types/jest": "30.0.0", "concurrently": "10.0.5", - "conventional-changelog-conventionalcommits": "10.4.0", + "conventional-changelog-conventionalcommits": "9.3.1", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", "identity-obj-proxy": "3.0.0", @@ -52,7 +52,7 @@ "pdfmake": "0.3.11", "react": "19.2.8", "react-dom": "19.2.8", - "react-i18next": "^17.0.13", + "react-i18next": "17.0.13", "react-konva": "19.2.5", "react-router-dom": "7.18.3" }, @@ -3324,16 +3324,6 @@ "node": ">=0.1.90" } }, - "node_modules/@conventional-changelog/template": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/@conventional-changelog/template/-/template-1.4.0.tgz", - "integrity": "sha512-aalGyl7dbB5PArRebDIX43ZvBlXrYm9uWzGJ26t+4SzJVPsOuvfILGGbw5X4yX7i50YEmJ8zvbiWnqH/AAnZqg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=22" - } - }, "node_modules/@cornerstone/client": { "resolved": "client", "link": true @@ -15357,16 +15347,16 @@ } }, "node_modules/conventional-changelog-conventionalcommits": { - "version": "10.4.0", - "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-10.4.0.tgz", - "integrity": "sha512-Rriac6ZrAlVm6cy9Bz4NSp+WMHpwNXoPIYex+HjCgduAVUSbnew29DQjQw0C4g9u3HtSYzGiGY+pdBXAZo+4aA==", + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/conventional-changelog-conventionalcommits/-/conventional-changelog-conventionalcommits-9.3.1.tgz", + "integrity": "sha512-dTYtpIacRpcZgrvBYvBfArMmK2xvIpv2TaxM0/ZI5CBtNUzvF2x0t15HsbRABWprS6UPmvj+PzHVjSx4qAVKyw==", "dev": true, "license": "ISC", "dependencies": { - "@conventional-changelog/template": "^1.4.0" + "compare-func": "^2.0.0" }, "engines": { - "node": ">=22" + "node": ">=18" } }, "node_modules/conventional-changelog-writer": { diff --git a/package.json b/package.json index 21a72e65b..5f9dac7f8 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@testing-library/user-event": "14.6.7", "@types/jest": "30.0.0", "concurrently": "10.0.5", - "conventional-changelog-conventionalcommits": "10.4.0", + "conventional-changelog-conventionalcommits": "9.3.1", "eslint": "10.10.0", "eslint-config-prettier": "10.1.8", "identity-obj-proxy": "3.0.0",