fix(#1384): scan live changeset fragments in the #1777 purity gate (#1385)

The product-name-purity gate scanned CHANGELOG.md only, never the
.changeset/*.md fragments that render into it. An impure fragment passed
PR review, sat dormant, and re-introduced a forbidden parenthetical
product description at the next release — even after CHANGELOG.md had been
hand-fixed. This is the recurrence vector behind the 1.5.0 back-merge (#1379)
failure.

- Purify the two live fragments to the already-accepted forms:
  - happy-finches-travel.md: "Claude Code (background dispatch …)"
    -> "Claude Code; background dispatch …"
  - 924-claude-flat-skill-layout.md: "Claude (`~/.claude/…`)"
    -> "Claude at `~/.claude/…`"
- Extend the #1777 gate to also scan live .changeset/*.md fragments,
  reusing one shared detection helper. Archived fragments never re-render
  and are intentionally out of scope.

Test-only + changeset-prose change; no production behavior change.

Closes #1384

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Tom Boucher
2026-06-17 12:14:15 -04:00
committed by GitHub
parent 6210621494
commit afd95a15a9
3 changed files with 72 additions and 20 deletions

View File

@@ -39,7 +39,46 @@ const README_FILES = [
'docs/README.md',
].filter(f => fs.existsSync(path.join(ROOT, f)));
// Detect "ProductName (description)" parentheticals in arbitrary prose, skipping
// version references like "Claude Code (v1.32.0)" / "Claude (1.5.0)". Returns the
// matched substrings so callers can report them. Shared by the CHANGELOG and the
// changeset-fragment scans so both apply identical rules.
function findProductParentheticals(content) {
const found = [];
for (const product of PRODUCTS) {
// Match "ProductName (something)" but not "ProductName (v1.2.3)" (version refs are ok)
const pattern = new RegExp(
product.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
'\\s*\\([^)]*(?!v?\\d+\\.\\d)[^)]*\\)',
'g'
);
const matches = content.match(pattern);
if (!matches) continue;
for (const m of matches) {
// Skip version references like "Claude Code (v1.32.0)"
if (/\(v?\d+\.\d+/.test(m)) continue;
found.push(m);
}
}
return found;
}
describe('product name purity (#1777)', () => {
// Pin the shared detector's contract so neither the CHANGELOG nor the
// fragment scan can pass vacuously: a silently-broken helper that always
// returned [] would otherwise go undetected whenever the scanned files
// happen to be clean.
test('findProductParentheticals catches a real violation and allows version refs', () => {
assert.deepEqual(
findProductParentheticals('see Claude Code (the Anthropic CLI) for details'),
['Claude Code (the Anthropic CLI)'],
);
assert.deepEqual(
findProductParentheticals('upgraded to Claude Code (v1.32.0)'),
[],
);
});
test('no README install-block comments contain parenthetical descriptions', () => {
const violations = [];
@@ -84,24 +123,7 @@ describe('product name purity (#1777)', () => {
if (!fs.existsSync(changelog)) return;
const content = fs.readFileSync(changelog, 'utf-8');
const violations = [];
for (const product of PRODUCTS) {
// Match "ProductName (something)" but not "ProductName (v1.2.3)" (version refs are ok)
const pattern = new RegExp(
product.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +
'\\s*\\([^)]*(?!v?\\d+\\.\\d)[^)]*\\)',
'g'
);
const matches = content.match(pattern);
if (matches) {
for (const m of matches) {
// Skip version references like "Claude Code (v1.32.0)"
if (/\(v?\d+\.\d+/.test(m)) continue;
violations.push(m);
}
}
}
const violations = findProductParentheticals(content);
assert.strictEqual(
violations.length, 0,
@@ -112,4 +134,34 @@ describe('product name purity (#1777)', () => {
].join('\n')
);
});
test('live changeset fragments do not include parenthetical product descriptions', () => {
const changesetDir = path.join(ROOT, '.changeset');
if (!fs.existsSync(changesetDir)) return;
// Only LIVE fragments (.changeset/*.md) render into CHANGELOG.md at release
// time, so an impure fragment silently re-introduces a #1777 violation at the
// next release / back-merge even after CHANGELOG.md itself was hand-fixed.
// Archived fragments (.changeset/archived/) never re-render and are out of scope.
const fragments = fs.readdirSync(changesetDir, { withFileTypes: true })
.filter(d => d.isFile() && d.name.endsWith('.md') && d.name !== 'README.md')
.map(d => d.name);
const violations = [];
for (const frag of fragments) {
const content = fs.readFileSync(path.join(changesetDir, frag), 'utf-8');
for (const m of findProductParentheticals(content)) {
violations.push(frag + ' — ' + m);
}
}
assert.strictEqual(
violations.length, 0,
[
'Changeset fragments must not include parenthetical product descriptions',
'(fragment prose renders verbatim into CHANGELOG.md at release time):',
...violations.map(v => ' ' + v),
].join('\n')
);
});
});