diff --git a/docs/FORKING.md b/docs/FORKING.md index f101a85b..c8e6e941 100644 --- a/docs/FORKING.md +++ b/docs/FORKING.md @@ -43,10 +43,10 @@ git push The `scripts/rebrand.sh` script automates updating 200+ files: -| Category | Changes | -| --------- | ------------------------------------------------------------------------------------------------------ | -| **Code** | Replaces "ScriptHammer" with your project name in all TypeScript, JavaScript, JSON, and Markdown files | -| **Files** | Renames files containing "ScriptHammer" (e.g., `ScriptHammerLogo.tsx` → `MyProjectLogo.tsx`) | +| Category | Changes | +| --------- | ---------------------------------------------------------------------------------------------------------------- | +| **Code** | Replaces every ASCII case style of "ScriptHammer" across tracked text, while keeping prose and identifiers valid | +| **Paths** | Renames brand-bearing files and directories from one collision-checked plan; binary bytes are never rewritten | ### Your brand mark is not covered by any of that @@ -137,18 +137,40 @@ Three things worth knowing: the one file it never matched. Every fork silently lost the link back, including forks whose owners would have kept it. +An applied rebrand ends with an independent, case-insensitive scan of eligible +tracked text (excluding lockfiles, binaries, and symlink targets) plus every +transformed path. Any old-brand survivor outside a +same-line `rebrand:keep` marker is an error, and the script does not print +`REBRAND COMPLETE`. Dry runs report proposed changes but do not apply that +postcondition to the intentionally unchanged tree. + +Case style is intentional: `ScriptHammer` uses the display name, +`scripthammer` uses the technical slug, `Scripthammer` uses title case, and +`SCRIPTHAMMER` uses an uppercase identifier-safe component name. Thus a fork +named `GeoLarp` gets `GeoLarp`, `geolarp`, `Geolarp`, and `GEOLARP`; an env-style +`SCRIPTHAMMER_TEST_DOMAIN` becomes `GEOLARP_TEST_DOMAIN`. Arbitrary mixed forms +such as `ScriptHAMMER` are matched too. + +The shell workflow and its case-mapping helper remain stable template tooling; +only the four recorded identity fields in `scripts/rebrand.sh` change after the +postcondition succeeds. Before a different-target re-rebrand, stage the prior +path moves with `git add -A` (preferably commit them). Automation refuses a +source identity whose projections are ambiguous, or one that collides with the +stable tooling, rather than risking a partial rewrite. Same-identity reruns are +still explicit no-ops. + **Removing the attribution is a one-line edit and you are welcome to make it.** It is MIT. The default is "kept" so that losing it is a decision rather than an accident. ### Exit Codes -| Code | Meaning | -| ---- | ------------------------ | -| 0 | Success | -| 1 | Invalid arguments | -| 2 | User declined re-rebrand | -| 3 | Git error | +| Code | Meaning | +| ---- | ----------------------------- | +| 0 | Success | +| 1 | Validation or rebrand failure | +| 2 | User declined re-rebrand | +| 3 | Git error | ## Customizing Your Theme @@ -336,7 +358,8 @@ The `session-persistence.spec.ts` test previously created users in `beforeEach` ### Build Fails After Rebrand 1. Run `docker compose down && docker compose up --build` to rebuild -2. Check for any remaining "ScriptHammer" references: `grep -r "ScriptHammer" src/` +2. The script already fails on unmarked survivors. To inspect manually, use + `git grep -Iin scripthammer` and confirm every remaining line carries `rebrand:keep`. 3. Ensure all import paths are correct after file renames ### GitHub Pages Shows 404 diff --git a/scripts/__tests__/detect-project.test.js b/scripts/__tests__/detect-project.test.js index 8d2ebe39..d351c6ce 100644 --- a/scripts/__tests__/detect-project.test.js +++ b/scripts/__tests__/detect-project.test.js @@ -365,9 +365,9 @@ export type DetectedConfig = typeof detectedConfig; isGitHubActions: true, isGitHub: true, cnameExists: false, - projectName: 'ScriptHammer', + projectName: 'ScriptHammer', // rebrand:keep }), - '/ScriptHammer' + '/ScriptHammer' // rebrand:keep ); // DISABLE_BASE_PATH=true wins over the auto-detection so the E2E build diff --git a/scripts/__tests__/rebrand-case-preserving.test.js b/scripts/__tests__/rebrand-case-preserving.test.js new file mode 100644 index 00000000..475caf2d --- /dev/null +++ b/scripts/__tests__/rebrand-case-preserving.test.js @@ -0,0 +1,486 @@ +const assert = require('node:assert/strict'); +const { + readFileSync, + mkdtempSync, + mkdirSync, + writeFileSync, + existsSync, + rmSync, + symlinkSync, +} = require('node:fs'); +const { tmpdir } = require('node:os'); +const path = require('node:path'); +const { describe, test } = require('node:test'); +const { pathToFileURL } = require('node:url'); + +const ROOT = path.resolve(__dirname, '..', '..'); +const SCRIPT = path.join(ROOT, 'scripts', 'rebrand.sh'); +const HELPER = path.join(ROOT, 'scripts', 'rebrand-case.mjs'); + +const helper = import(pathToFileURL(HELPER).href); + +const identity = async ( + targetDisplay = 'GeoLarp', // rebrand:keep + targetSlug = 'geolarp', // rebrand:keep + targetComponent = 'GeoLarp' // rebrand:keep +) => { + const { createIdentity } = await helper; + return createIdentity({ + sourceDisplay: 'ScriptHammer', // rebrand:keep + sourceSlug: 'scripthammer', // rebrand:keep + sourceComponent: 'ScriptHammer', // rebrand:keep + sourceUpper: 'SCRIPTHAMMER', // rebrand:keep + targetDisplay, + targetSlug, + targetComponent, + }); +}; + +describe('case-preserving rebrand transform (#933)', () => { + test('maps every real style plus an arbitrary mixed spelling', async () => { + const { replaceBrandText } = await helper; + const result = replaceBrandText( + [ + 'ScriptHammer', // rebrand:keep + 'scripthammer', // rebrand:keep + 'Scripthammer', // rebrand:keep + 'SCRIPTHAMMER', // rebrand:keep + 'ScriptHAMMER', // rebrand:keep + ].join('\n'), + await identity() + ); + + assert.deepEqual(result.split('\n'), [ + 'GeoLarp', // rebrand:keep + 'geolarp', // rebrand:keep + 'Geolarp', // rebrand:keep + 'GEOLARP', // rebrand:keep + 'GeoLarp', // rebrand:keep + ]); + }); + + test('keeps identifiers valid for a display name with spaces', async () => { + const { replaceBrandText } = await helper; + const result = replaceBrandText( + [ + 'prose: ScriptHammer', // rebrand:keep + 'const ScriptHammerLogo = true;', // rebrand:keep + 'const scripthammerCaches = true;', // rebrand:keep + 'const __scripthammer_syncQueue = true;', // rebrand:keep + 'function cleanupStaleScripthammerUsers() {}', // rebrand:keep + "const SCRIPTHAMMER_TEST_DOMAIN = '@scripthammer.test';", // rebrand:keep + ].join('\n'), + await identity('geo LARP', 'geo-larp', 'GeoLARP') // rebrand:keep + ); + + assert.deepEqual(result.split('\n'), [ + 'prose: geo LARP', + 'const GeoLARPLogo = true;', // rebrand:keep + 'const geolarpCaches = true;', // rebrand:keep + 'const __geolarp_syncQueue = true;', // rebrand:keep + 'function cleanupStaleGeolarpUsers() {}', // rebrand:keep + "const GEOLARP_TEST_DOMAIN = '@geo-larp.test';", // rebrand:keep + ]); + }); + + test('keeps marked lines byte-exact and independently finds unmarked survivors', async () => { + const { replaceBrandText, findBrandSurvivors } = await helper; + const source = [ + 'SCRIPTHAMMER + ScriptHAMMER // rebrand:keep', + 'Scripthammer must move', // rebrand:keep + '', + ].join('\r\n'); + const transformed = replaceBrandText(source, await identity()); + + assert.equal( + transformed, + [ + 'SCRIPTHAMMER + ScriptHAMMER // rebrand:keep', + 'Geolarp must move', // rebrand:keep + '', + ].join('\r\n') + ); + assert.deepEqual(findBrandSurvivors(source, await identity()), [ + { line: 2, text: 'Scripthammer must move' }, // rebrand:keep + ]); + assert.deepEqual(findBrandSurvivors(transformed, await identity()), []); + }); + + test('maps every path component with a path-safe projection', async () => { + const { mapBrandPath } = await helper; + assert.equal( + mapBrandPath( + 'public/blog-images/scripthammer-intro/ScripthammerBadge-SCRIPTHAMMER.svg', // rebrand:keep + await identity('geo LARP', 'geo-larp', 'GeoLARP') // rebrand:keep + ), + 'public/blog-images/geo-larp-intro/GeolarpBadge-GEOLARP.svg' // rebrand:keep + ); + }); + + test('rewrites textual path references to the exact mapped path', async () => { + const { mapBrandPath, replaceBrandText } = await helper; + const currentIdentity = await identity('geo LARP', 'geo-larp', 'GeoLARP'); // rebrand:keep + const oldPath = 'docs/design/ScriptHammer-Guide.md'; // rebrand:keep + const expected = 'docs/design/GeoLARP-Guide.md'; // rebrand:keep + + assert.equal(mapBrandPath(oldPath, currentIdentity), expected); + assert.equal( + replaceBrandText(`Read ${oldPath}`, currentIdentity), + `Read ${expected}` + ); + }); + + test('keeps escaped generated text in sync with its source content', async () => { + const { replaceBrandText } = await helper; + const currentIdentity = await identity('geo LARP', 'geo-larp', 'GeoLARP'); // rebrand:keep + const markdown = [ + '# ScriptHammer architecture', // rebrand:keep + 'Scripthammer ships from /docs/ScriptHammer-Guide.md.', // rebrand:keep + '**Hardcoded values still showing "ScriptHammer":**', // rebrand:keep + ].join('\n'); + const transformedSource = replaceBrandText(markdown, currentIdentity); + const transformedIndex = replaceBrandText( + JSON.stringify({ content: markdown }), + currentIdentity + ); + + assert.equal(JSON.parse(transformedIndex).content, transformedSource); + assert.match(transformedSource, /# geo LARP architecture/); // rebrand:keep + assert.match(transformedSource, /Geo larp ships/); // rebrand:keep + assert.match(transformedSource, /\/docs\/GeoLARP-Guide\.md/); // rebrand:keep + assert.match(transformedSource, /showing "geo LARP"/); // rebrand:keep + }); + + test('does not collide with an existing target identifier', async () => { + const { replaceBrandText } = await helper; + const footer = readFileSync( + path.join(ROOT, 'src', 'components', 'Footer.tsx'), + 'utf8' + ); + const transformed = replaceBrandText( + footer, + await identity('geo LARP', 'geo-larp', 'GeoLARP') // rebrand:keep + ); + const declaration = transformed.match(/const \[([^\]]+)] = FOOTER_LINKS/); + assert.ok( + declaration, + 'expected the footer link destructuring declaration' + ); + const names = declaration[1].split(',').map((name) => name.trim()); + assert.equal(new Set(names).size, names.length); + }); + + test('uses exact stored title projections during re-rebrand', async () => { + const { createIdentity, replaceBrandText } = await helper; + const transition = createIdentity({ + sourceDisplay: 'Geolarp', // rebrand:keep + sourceSlug: 'geolarp', // rebrand:keep + sourceComponent: 'Geolarp', // rebrand:keep + sourceUpper: 'GEOLARP', // rebrand:keep + targetDisplay: 'Second App', + targetSlug: 'second-app', + targetComponent: 'SecondApp', + }); + assert.equal( + replaceBrandText( + '# Geolarp\nconst GeolarpLogo = true;', // rebrand:keep + transition + ), + '# Second App\nconst SecondAppLogo = true;' + ); + }); + + test('rejects source-containing targets and ambiguous re-rebrand state', async () => { + const { createIdentity, validateIdentityTransition } = await helper; + const sourceContaining = await identity( + 'ScriptHammer Pro', // rebrand:keep + 'scripthammer-pro', // rebrand:keep + 'ScriptHammerPro' // rebrand:keep + ); + assert.throws( + () => validateIdentityTransition(sourceContaining), + /target identity still contains the current brand/ + ); + + const ambiguous = createIdentity({ + sourceDisplay: 'geolarp', // rebrand:keep + sourceSlug: 'geolarp', // rebrand:keep + sourceComponent: 'Geolarp', // rebrand:keep + sourceUpper: 'GEOLARP', // rebrand:keep + targetDisplay: 'Second App', + targetSlug: 'second-app', + targetComponent: 'SecondApp', + }); + assert.throws( + () => validateIdentityTransition(ambiguous), + /automated re-rebrand is unsafe/ + ); + }); + + test('rejects source identities that collide with stable runtime tooling', async () => { + const { createIdentity, validateRuntimePaths } = await helper; + const root = mkdtempSync(path.join(tmpdir(), 'rebrand-runtime-')); + try { + mkdirSync(path.join(root, 'scripts')); + writeFileSync( + path.join(root, 'scripts', 'stable.mjs'), + "import fs from 'node:fs';\n" + ); + const nodeIdentity = createIdentity({ + sourceDisplay: 'Node', + sourceSlug: 'node', + sourceComponent: 'Node', + sourceUpper: 'NODE', + targetDisplay: 'Second App', + targetSlug: 'second-app', + targetComponent: 'SecondApp', + }); + assert.throws( + () => validateRuntimePaths(root, nodeIdentity, ['scripts/stable.mjs']), + /stable rebrand tooling/ + ); + + const rebrandIdentity = createIdentity({ + sourceDisplay: 'Rebrand', + sourceSlug: 'rebrand', + sourceComponent: 'Rebrand', + sourceUpper: 'REBRAND', + targetDisplay: 'Second App', + targetSlug: 'second-app', + targetComponent: 'SecondApp', + }); + assert.throws( + () => + validateRuntimePaths(root, rebrandIdentity, ['scripts/rebrand.sh']), + /stable rebrand tooling/ + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('preflights case-folding collisions before moving either source', async () => { + const { planPathRenames } = await helper; + const currentIdentity = await identity(); + const root = mkdtempSync(path.join(tmpdir(), 'rebrand-collision-')); + try { + mkdirSync(path.join(root, 'docs')); + const upper = path.join(root, 'docs', 'SCRIPTHAMMER.md'); // rebrand:keep + const lower = path.join(root, 'docs', 'scripthammer.md'); // rebrand:keep + writeFileSync(upper, 'upper'); + writeFileSync(lower, 'lower'); + + assert.throws( + () => planPathRenames(root, [upper, lower], currentIdentity), + /rebrand path collision/ + ); + assert.equal(readFileSync(upper, 'utf8'), 'upper'); + assert.equal(readFileSync(lower, 'utf8'), 'lower'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('rejects merging a renamed source into an existing target directory', async () => { + const { planPathRenames } = await helper; + const currentIdentity = await identity(); + const root = mkdtempSync(path.join(tmpdir(), 'rebrand-directory-')); + try { + const source = path.join(root, 'assets', 'scripthammer-intro'); // rebrand:keep + const target = path.join(root, 'assets', 'geolarp-intro'); // rebrand:keep + mkdirSync(source, { recursive: true }); + mkdirSync(target, { recursive: true }); + const sourceFile = path.join(source, 'source.bin'); + writeFileSync(sourceFile, 'source'); + writeFileSync(path.join(target, 'sentinel.bin'), 'target'); + + assert.throws( + () => planPathRenames(root, [sourceFile], currentIdentity), + /rebrand target directory already exists/ + ); + assert.equal(readFileSync(sourceFile, 'utf8'), 'source'); + assert.equal( + readFileSync(path.join(target, 'sentinel.bin'), 'utf8'), + 'target' + ); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('rejects a target that would be both a file and a directory', async () => { + const { planPathRenames } = await helper; + const currentIdentity = await identity(); + const root = mkdtempSync(path.join(tmpdir(), 'rebrand-file-directory-')); + try { + const oldFile = path.join(root, 'ScriptHammer'); // rebrand:keep + const oldDirectory = path.join(root, 'ScriptHAMMER'); // rebrand:keep + const child = path.join(oldDirectory, 'child.txt'); + mkdirSync(oldDirectory, { recursive: true }); + writeFileSync(oldFile, 'file'); + writeFileSync(child, 'child'); + + assert.throws( + () => planPathRenames(root, [oldFile, child], currentIdentity), + /rebrand file\/directory collision/ + ); + assert.equal(readFileSync(oldFile, 'utf8'), 'file'); + assert.equal(readFileSync(child, 'utf8'), 'child'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('rejects a tracked symlink target that a directory rename would break', async () => { + const { planPathRenames } = await helper; + const currentIdentity = await identity(); + const root = mkdtempSync(path.join(tmpdir(), 'rebrand-symlink-')); + try { + const target = path.join(root, 'assets', 'scripthammer', 'data.txt'); // rebrand:keep + const link = path.join(root, 'current'); + mkdirSync(path.dirname(target), { recursive: true }); + writeFileSync(target, 'data'); + symlinkSync('assets/scripthammer/data.txt', link); // rebrand:keep + + assert.throws( + () => planPathRenames(root, [target, link], currentIdentity), + /tracked symlink target contains the current brand/ + ); + assert.equal(existsSync(link), true); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + test('renames a brand directory without touching binary bytes', async () => { + const { planPathRenames, applyPathRenames } = await helper; + const root = mkdtempSync(path.join(tmpdir(), 'rebrand-binary-')); + try { + const oldDirectory = path.join(root, 'assets', 'scripthammer-intro'); // rebrand:keep + const oldFile = path.join(oldDirectory, 'plain.bin'); + const expected = Buffer.from('before\0Scripthammer\0after'); // rebrand:keep + mkdirSync(oldDirectory, { recursive: true }); + writeFileSync(oldFile, expected); + + const plan = planPathRenames(root, [oldFile], await identity()); + applyPathRenames(root, plan); + + const newFile = path.join(root, 'assets', 'geolarp-intro', 'plain.bin'); // rebrand:keep + assert.deepEqual(readFileSync(newFile), expected); + assert.equal(existsSync(oldDirectory), false); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); +}); + +const contractErrors = (source) => { + const code = source + .split('\n') + .filter((line) => !/^\s*#/.test(line)) + .join('\n'); + const errors = []; + const validate = code.lastIndexOf('\n validate_brand_target\n'); + const indexGuard = code.lastIndexOf('\n assert_index_paths_current\n'); + const preflight = code.lastIndexOf(' preflight_brand_paths'); + const replace = code.lastIndexOf(' replace_brand_in_files'); + const rename = code.lastIndexOf(' rename_brand_paths'); + const icons = code.lastIndexOf(' update_brand_icons'); + const verify = code.lastIndexOf('\n assert_no_old_brand\n'); + + if (validate === -1 || preflight === -1 || validate > preflight) { + errors.push('target validation must run before path preflight'); + } + if (indexGuard === -1 || preflight === -1 || indexGuard > preflight) { + errors.push('stale-index validation must run before path preflight'); + } + if (preflight === -1 || replace === -1 || preflight > replace) { + errors.push('path collision preflight must run before content mutation'); + } + if (replace === -1) + errors.push('case-preserving content transform is not called'); + if (rename === -1 || rename < replace) + errors.push('full tracked-path transform is not called after content'); + if (verify === -1 || verify < icons) + errors.push('postcondition is not called after all writes'); + if (!/case_helper verify-paths < "\$TRACKED_SNAPSHOT"/.test(code)) + errors.push('postcondition does not scan every tracked path'); + if (/\n\s*rename_files\s/.test(code)) + errors.push('legacy basename-only rename call remains'); + return errors; +}; + +describe('rebrand.sh wiring contract (#933)', () => { + const source = readFileSync(SCRIPT, 'utf8'); + + test('the live workflow has preflight, transform, path mapping, and postcondition in order', () => { + assert.deepEqual(contractErrors(source), []); + }); + + test('controls reject removing the residual gate', () => { + const mutant = source.replace( + ' assert_no_old_brand\n', + ' : # residual gate removed\n' + ); + assert.ok( + contractErrors(mutant).includes( + 'postcondition is not called after all writes' + ) + ); + }); + + test('controls reject disconnecting target validation from the workflow', () => { + const mutant = source.replace(' validate_brand_target\n', ''); + assert.ok( + contractErrors(mutant).includes( + 'target validation must run before path preflight' + ) + ); + }); + + test('controls reject disconnecting stale-index validation from the workflow', () => { + const mutant = source.replace(' assert_index_paths_current\n', ''); + assert.ok( + contractErrors(mutant).includes( + 'stale-index validation must run before path preflight' + ) + ); + }); + + test('controls reject moving collision detection after content writes', () => { + const mutant = source + .replace(' preflight_brand_paths\n', '') + .replace( + ' replace_brand_in_files\n', + ' replace_brand_in_files\n preflight_brand_paths\n' + ); + assert.ok( + contractErrors(mutant).includes( + 'path collision preflight must run before content mutation' + ) + ); + }); + + test('controls reject restoring the basename-only path call', () => { + const mutant = source.replace( + ' rename_brand_paths\n', + ' rename_files "$ORIGINAL_NAME" "$COMPONENT_NAME"\n' + ); + const errors = contractErrors(mutant); + assert.ok( + errors.includes('full tracked-path transform is not called after content') + ); + assert.ok(errors.includes('legacy basename-only rename call remains')); + }); +}); + +test('brand fixtures in this regression file remain stable in a fork', () => { + const unmarked = readFileSync(__filename, 'utf8') + .split(/\r?\n/) + .filter( + (line) => + /(scripthammer|geolarp)/i.test(line) && // rebrand:keep + !line.includes('rebrand:keep') + ); + assert.deepEqual(unmarked, []); +}); diff --git a/scripts/rebrand-case.mjs b/scripts/rebrand-case.mjs new file mode 100644 index 00000000..da33ec30 --- /dev/null +++ b/scripts/rebrand-case.mjs @@ -0,0 +1,756 @@ +#!/usr/bin/env node + +import fs from 'node:fs'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; + +export const KEEP_MARKER = 'rebrand:keep'; + +const asciiLower = (value) => + value.replace(/[A-Z]/g, (character) => character.toLowerCase()); + +const asciiUpper = (value) => + value.replace(/[a-z]/g, (character) => character.toUpperCase()); + +const regexEscape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const asciiCasePattern = (value) => + [...value] + .map((character) => { + if (/[A-Za-z]/.test(character)) { + const lower = asciiLower(character); + return `[${lower}${asciiUpper(lower)}]`; + } + return regexEscape(character); + }) + .join(''); + +const uniqueAsciiCaseInsensitive = (values) => { + const seen = new Set(); + return values + .filter(Boolean) + .sort((left, right) => right.length - left.length) + .filter((value) => { + const folded = asciiLower(value); + if (seen.has(folded)) return false; + seen.add(folded); + return true; + }); +}; + +export function createIdentity({ + sourceDisplay, + sourceSlug, + sourceComponent, + sourceUpper, + targetDisplay, + targetSlug, + targetComponent, +}) { + const sources = uniqueAsciiCaseInsensitive([ + sourceDisplay, + sourceSlug, + sourceComponent, + sourceUpper, + ]); + if (sources.length === 0) + throw new Error('at least one source identity is required'); + + return { + sources, + sourceDisplay, + sourceSlug, + sourceComponent, + sourceUpper, + targetDisplay, + targetSlug, + targetComponent, + // Uppercase source tokens include real identifiers such as + // SCRIPTHAMMER_TEST_DOMAIN. COMPONENT_NAME is separator-free, so its uppercase // rebrand:keep + // projection remains a valid identifier even for a display name with spaces. + targetUpper: asciiUpper(targetComponent), + }; +} + +const identityPattern = (identity, global = true) => + new RegExp( + `(?:${identity.sources.map(asciiCasePattern).join('|')})`, + global ? 'g' : '' + ); + +// Deliberately independent from the substitution regex. The postcondition is a +// second implementation of ASCII-folded fixed-string search, so a regression +// in identityPattern/asciiCasePattern cannot make replacement and verification +// miss the same spelling together. +const containsSourceIdentity = (value, identity) => { + const folded = asciiLower(value); + return identity.sources.some((source) => folded.includes(asciiLower(source))); +}; + +const caseStyle = (value) => { + const letters = value.replace(/[^A-Za-z]/g, ''); + if (letters && letters === asciiLower(letters)) return 'lower'; + if (letters && letters === asciiUpper(letters)) return 'upper'; + if ( + letters && + letters[0] === asciiUpper(letters[0]) && + letters.slice(1) === asciiLower(letters.slice(1)) + ) { + return 'title'; + } + return 'mixed'; +}; + +const titleProjection = (value) => { + const lower = asciiLower(value); + return lower.replace(/[A-Za-z]/, (character) => asciiUpper(character)); +}; + +const identifierCharacter = (character) => /[A-Za-z0-9_]/.test(character ?? ''); + +const hasPathSeparator = (token) => { + if (token.includes('/')) return true; + // JSON strings encode newlines, quotes, and other control characters with a + // backslash. Treating every such escape as a Windows path made the generated + // blog index choose component casing while its Markdown source chose display + // casing. Remove JSON/JavaScript escapes before looking for a real backslash + // separator; `C:\\Brand\\file` still retains both path separators. + const withoutEscapes = token + .replace(/\\(?:["'\\/bfnrtv0]|u[0-9A-Fa-f]{4})/g, '') + // Token scanning stops at the quote in JSON's \"...\" encoding, leaving + // the escape introducer at one edge. It is not a path separator. + .replace(/^\\+|\\+$/g, ''); + return withoutEscapes.includes('\\'); +}; + +export function replacementForMatch( + match, + identity, + { identifierAdjacent = false, pathSegment = false } = {} +) { + // On a re-rebrand, the exact stored projection is stronger evidence than + // its generic letter shape. `Geolarp` can be both display and component; the // rebrand:keep + // surrounding identifier/path context distinguishes those two uses. A + // separator-bearing component is distinct and always remains a component. + if (match === identity.sourceDisplay) { + return pathSegment || identifierAdjacent + ? identity.targetComponent + : identity.targetDisplay; + } + if (match === identity.sourceComponent && match !== identity.sourceDisplay) { + return identity.targetComponent; + } + if (match === identity.sourceSlug && match !== identity.sourceDisplay) { + return identifierAdjacent + ? asciiLower(identity.targetComponent) + : identity.targetSlug; + } + if (match === identity.sourceUpper && match !== identity.sourceDisplay) { + return identity.targetUpper; + } + + switch (caseStyle(match)) { + case 'lower': + return pathSegment || !identifierAdjacent + ? identity.targetSlug + : asciiLower(identity.targetComponent); + case 'upper': + return identity.targetUpper; + case 'title': + return titleProjection( + pathSegment || identifierAdjacent + ? identity.targetComponent + : identity.targetDisplay + ); + default: + return pathSegment || identifierAdjacent + ? identity.targetComponent + : identity.targetDisplay; + } +} + +const replaceLine = (line, identity) => { + if (line.includes(KEEP_MARKER)) return line; + const pattern = identityPattern(identity); + return line.replace(pattern, (match, offset, wholeLine) => { + const before = wholeLine[offset - 1]; + const after = wholeLine[offset + match.length]; + const beforeIsEscapeCode = + wholeLine[offset - 2] === '\\' && /["'\\/bfnrtv0]/.test(before ?? ''); + let tokenStart = offset; + let tokenEnd = offset + match.length; + while ( + tokenStart > 0 && + /[A-Za-z0-9_./\\-]/.test(wholeLine[tokenStart - 1]) + ) { + tokenStart -= 1; + } + while ( + tokenEnd < wholeLine.length && + /[A-Za-z0-9_./\\-]/.test(wholeLine[tokenEnd]) + ) { + tokenEnd += 1; + } + const token = wholeLine.slice(tokenStart, tokenEnd); + return replacementForMatch(match, identity, { + identifierAdjacent: + (!beforeIsEscapeCode && identifierCharacter(before)) || + identifierCharacter(after), + pathSegment: + hasPathSeparator(token) || /\.[A-Za-z0-9]{1,10}$/.test(token), + }); + }); +}; + +export function replaceBrandText(text, identity) { + const pieces = text.split(/(\r\n|\n|\r)/); + for (let index = 0; index < pieces.length; index += 2) { + pieces[index] = replaceLine(pieces[index], identity); + } + return pieces.join(''); +} + +export function mapBrandPath(relativePath, identity) { + return relativePath + .split('/') + .map((segment) => + segment.replace(identityPattern(identity), (match) => + replacementForMatch(match, identity, { pathSegment: true }) + ) + ) + .join('/'); +} + +export function findBrandSurvivors(text, identity) { + const survivors = []; + const lines = text.split(/\r\n|\n|\r/); + for (let index = 0; index < lines.length; index += 1) { + const line = lines[index]; + if (line.includes(KEEP_MARKER)) continue; + if (containsSourceIdentity(line, identity)) { + survivors.push({ line: index + 1, text: line }); + } + } + return survivors; +} + +const readNulPaths = () => + fs.readFileSync(0).toString('utf8').split('\0').filter(Boolean); + +const relativeFromRoot = (root, absolutePath) => { + const relative = path.relative(root, absolutePath); + if ( + relative === '' || + relative === '..' || + relative.startsWith(`..${path.sep}`) || + path.isAbsolute(relative) + ) { + throw new Error(`tracked path escapes repository root: ${absolutePath}`); + } + return relative.split(path.sep).join('/'); +}; + +const pathExists = (target) => { + try { + fs.lstatSync(target); + return true; + } catch (error) { + if (error.code === 'ENOENT') return false; + throw error; + } +}; + +export function planPathRenames(root, absolutePaths, identity) { + const entries = absolutePaths.map((oldAbsolute) => { + const metadata = fs.lstatSync(oldAbsolute); + if (metadata.isSymbolicLink()) { + const target = fs.readlinkSync(oldAbsolute); + if (containsSourceIdentity(target, identity)) { + throw new Error( + `tracked symlink target contains the current brand: ${relativeFromRoot(root, oldAbsolute)} → ${target}` + ); + } + } + const oldRelative = relativeFromRoot(root, oldAbsolute); + const newRelative = mapBrandPath(oldRelative, identity); + return { + oldAbsolute, + oldRelative, + newAbsolute: path.join(root, ...newRelative.split('/')), + newRelative, + changed: oldRelative !== newRelative, + }; + }); + + const targetKeys = new Map(); + for (const entry of entries) { + const key = asciiLower(entry.newRelative); + const prior = targetKeys.get(key); + if (prior && (prior.changed || entry.changed)) { + throw new Error( + `rebrand path collision: ${prior.oldRelative} and ${entry.oldRelative} both map to ${entry.newRelative}` + ); + } + targetKeys.set(key, entry); + } + + const sourceKeys = new Set( + entries.map((entry) => asciiLower(entry.oldAbsolute)) + ); + for (const entry of entries.filter(({ changed }) => changed)) { + if ( + pathExists(entry.newAbsolute) && + !sourceKeys.has(asciiLower(entry.newAbsolute)) + ) { + throw new Error( + `rebrand target already exists: ${entry.newRelative} (from ${entry.oldRelative})` + ); + } + + let parent = path.dirname(entry.newAbsolute); + while (parent !== root && parent.startsWith(`${root}${path.sep}`)) { + if (pathExists(parent) && !fs.lstatSync(parent).isDirectory()) { + throw new Error( + `rebrand target parent is not a directory: ${path.relative(root, parent)}` + ); + } + parent = path.dirname(parent); + } + } + + // A file-by-file collision check is not enough. Moving + // assets/scripthammer-intro/a.png into an already-existing // rebrand:keep + // assets/geolarp-intro/ directory would silently merge two directory trees // rebrand:keep + // even when their filenames differ. Model every source directory once and + // reject both case-folding collisions and pre-existing destination trees. + const directoryEntries = new Map(); + for (const entry of entries) { + let oldDirectory = path.dirname(entry.oldRelative); + while (oldDirectory !== '.') { + if (!directoryEntries.has(oldDirectory)) { + const newDirectory = mapBrandPath(oldDirectory, identity); + directoryEntries.set(oldDirectory, { + oldRelative: oldDirectory, + newRelative: newDirectory, + oldAbsolute: path.join(root, ...oldDirectory.split('/')), + newAbsolute: path.join(root, ...newDirectory.split('/')), + changed: oldDirectory !== newDirectory, + }); + } + oldDirectory = path.posix.dirname(oldDirectory); + } + } + + const directoryTargets = new Map(); + for (const directory of directoryEntries.values()) { + const key = asciiLower(directory.newRelative); + const prior = directoryTargets.get(key); + if ( + prior && + prior.oldRelative !== directory.oldRelative && + (prior.changed || directory.changed) + ) { + throw new Error( + `rebrand directory collision: ${prior.oldRelative} and ${directory.oldRelative} both map to ${directory.newRelative}` + ); + } + directoryTargets.set(key, directory); + } + + for (const [key, directory] of directoryTargets) { + const file = targetKeys.get(key); + if (file) { + throw new Error( + `rebrand file/directory collision: ${file.oldRelative} maps to the directory target ${directory.newRelative}` + ); + } + } + + const sourceDirectoryKeys = new Set( + [...directoryEntries.values()].map((directory) => + asciiLower(directory.oldAbsolute) + ) + ); + for (const directory of [...directoryEntries.values()].filter( + ({ changed }) => changed + )) { + if ( + pathExists(directory.newAbsolute) && + !sourceDirectoryKeys.has(asciiLower(directory.newAbsolute)) + ) { + throw new Error( + `rebrand target directory already exists: ${directory.newRelative} (from ${directory.oldRelative})` + ); + } + } + + return entries; +} + +const removeEmptySourceDirectories = (root, entries) => { + const directories = new Set(); + for (const entry of entries) { + let current = path.dirname(entry.oldAbsolute); + while (current !== root && current.startsWith(`${root}${path.sep}`)) { + directories.add(current); + current = path.dirname(current); + } + } + for (const directory of [...directories].sort( + (a, b) => b.length - a.length + )) { + try { + fs.rmdirSync(directory); + } catch (error) { + if (!['ENOENT', 'ENOTEMPTY', 'EEXIST'].includes(error.code)) throw error; + } + } +}; + +export function applyPathRenames(root, entries) { + const changed = entries.filter(({ changed: didChange }) => didChange); + if (changed.length === 0) return; + + const staging = fs.mkdtempSync(path.join(root, '.rebrand-paths-')); + const staged = []; + try { + for (let index = 0; index < changed.length; index += 1) { + const entry = changed[index]; + const temporary = path.join(staging, String(index)); + fs.renameSync(entry.oldAbsolute, temporary); + staged.push({ entry, temporary, placed: false }); + } + + for (const item of staged) { + fs.mkdirSync(path.dirname(item.entry.newAbsolute), { recursive: true }); + fs.renameSync(item.temporary, item.entry.newAbsolute); + item.placed = true; + } + + removeEmptySourceDirectories(root, changed); + fs.rmdirSync(staging); + } catch (error) { + for (const item of [...staged].reverse()) { + try { + fs.mkdirSync(path.dirname(item.entry.oldAbsolute), { recursive: true }); + if (item.placed && pathExists(item.entry.newAbsolute)) { + fs.renameSync(item.entry.newAbsolute, item.entry.oldAbsolute); + } else if (pathExists(item.temporary)) { + fs.renameSync(item.temporary, item.entry.oldAbsolute); + } + } catch { + // Preserve the original error. The staging directory and paths in its + // diagnostic make any exceptional manual recovery explicit. + } + } + throw error; + } +} + +const reportPath = (prefix, relativePath) => { + process.stdout.write(`${prefix}\t${JSON.stringify(relativePath)}\n`); +}; + +const replaceContentCommand = (root, paths, identity, dryRun) => { + let changed = 0; + for (const absolutePath of paths) { + const before = fs.readFileSync(absolutePath, 'utf8'); + const after = replaceBrandText(before, identity); + if (after === before) continue; + changed += 1; + reportPath( + dryRun ? 'WOULD_UPDATE' : 'UPDATED', + relativeFromRoot(root, absolutePath) + ); + if (!dryRun) fs.writeFileSync(absolutePath, after); + } + process.stdout.write(`COUNT\t${changed}\n`); +}; + +const countCommand = (paths, identity) => { + let count = 0; + for (const absolutePath of paths) { + const text = fs.readFileSync(absolutePath, 'utf8'); + for (const line of text.split(/\r\n|\n|\r/)) { + if (line.includes(KEEP_MARKER)) continue; + count += [...line.matchAll(identityPattern(identity))].length; + } + } + process.stdout.write(`${count}\n`); +}; + +const pathCommand = (root, paths, identity, mode) => { + const plan = planPathRenames(root, paths, identity); + const changed = plan.filter(({ changed: didChange }) => didChange); + if (mode === 'apply') applyPathRenames(root, plan); + if (mode !== 'check') { + for (const entry of changed) { + reportPath( + mode === 'dry' ? 'WOULD_RENAME' : 'RENAMED', + `${entry.oldRelative} → ${entry.newRelative}` + ); + } + process.stdout.write(`COUNT\t${changed.length}\n`); + } +}; + +const verifyCommand = (root, sourcePaths, identity) => { + const problems = []; + for (const oldAbsolute of sourcePaths) { + const oldRelative = relativeFromRoot(root, oldAbsolute); + const newRelative = mapBrandPath(oldRelative, identity); + const current = path.join(root, ...newRelative.split('/')); + if (!pathExists(current)) { + problems.push(`${newRelative}: missing after rebrand`); + continue; + } + if (containsSourceIdentity(newRelative, identity)) { + problems.push(`${newRelative}: old brand remains in tracked path`); + } + const text = fs.readFileSync(current, 'utf8'); + for (const survivor of findBrandSurvivors(text, identity)) { + problems.push( + `${newRelative}:${survivor.line}: ${JSON.stringify(survivor.text)}` + ); + } + } + + if (problems.length > 0) { + process.stderr.write('Old brand remains outside rebrand:keep:\n'); + for (const problem of problems) process.stderr.write(` ${problem}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + 'Verified: no old-brand text or tracked paths remain outside rebrand:keep.\n' + ); +}; + +const verifyPathsCommand = (root, sourcePaths, identity) => { + const problems = []; + for (const oldAbsolute of sourcePaths) { + const oldRelative = relativeFromRoot(root, oldAbsolute); + const newRelative = mapBrandPath(oldRelative, identity); + const current = path.join(root, ...newRelative.split('/')); + if (!pathExists(current)) { + problems.push(`${newRelative}: missing after rebrand`); + } else if (containsSourceIdentity(newRelative, identity)) { + problems.push(`${newRelative}: old brand remains in tracked path`); + } + } + + if (problems.length > 0) { + process.stderr.write('Old brand remains in tracked paths:\n'); + for (const problem of problems) process.stderr.write(` ${problem}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + 'Verified: no old-brand tracked paths remain after rebrand.\n' + ); +}; + +const verifyCurrentCommand = (root, sourcePaths, identity) => { + const problems = []; + for (const current of sourcePaths) { + if (!pathExists(current)) { + problems.push( + `${relativeFromRoot(root, current)}: missing before path rename` + ); + continue; + } + const text = fs.readFileSync(current, 'utf8'); + for (const survivor of findBrandSurvivors(text, identity)) { + problems.push( + `${relativeFromRoot(root, current)}:${survivor.line}: ${JSON.stringify(survivor.text)}` + ); + } + } + if (problems.length > 0) { + process.stderr.write('Old brand remains outside rebrand:keep:\n'); + for (const problem of problems) process.stderr.write(` ${problem}\n`); + process.exitCode = 1; + return; + } + process.stdout.write( + 'Verified: no old-brand tracked text remains before path moves.\n' + ); +}; + +export const validateIdentityTransition = (identity, values = []) => { + const ambiguousSource = + identity.sourceDisplay === identity.sourceSlug || + identity.sourceDisplay === identity.sourceUpper || + identity.sourceComponent === identity.sourceUpper; + if (ambiguousSource) { + throw new Error( + 'current display identity is indistinguishable from its slug or uppercase projection; automated re-rebrand is unsafe' + ); + } + + const invalidTargets = [ + identity.targetDisplay, + identity.targetSlug, + identity.targetComponent, + identity.targetUpper, + ].filter((value) => containsSourceIdentity(value, identity)); + if (invalidTargets.length > 0) { + throw new Error( + `target identity still contains the current brand: ${JSON.stringify(invalidTargets)}` + ); + } + const invalid = values.filter( + (value) => value && containsSourceIdentity(value, identity) + ); + if (invalid.length > 0) { + throw new Error( + `rebrand input still contains the current brand: ${JSON.stringify(invalid)}` + ); + } +}; + +const validateFileCommand = (identity, file) => { + if (!file) throw new Error('validate-file requires a path'); + const text = fs.readFileSync(path.resolve(file), 'utf8'); + if (containsSourceIdentity(text, identity)) { + throw new Error(`brand-mark SVG still contains the current brand: ${file}`); + } +}; + +export const validateRuntimePaths = (root, identity, files) => { + const conflicts = []; + for (const file of files) { + if (containsSourceIdentity(file, identity)) { + conflicts.push(`${file} (path)`); + continue; + } + const survivors = findBrandSurvivors( + fs.readFileSync(path.resolve(root, file), 'utf8'), + identity + ); + if (survivors.length > 0) conflicts.push(`${file} (implementation)`); + } + if (conflicts.length > 0) { + throw new Error( + `current identity collides with stable rebrand tooling; automated re-rebrand is unsafe: ${JSON.stringify(conflicts)}` + ); + } +}; + +const updateStateCommand = (scriptPath, identity) => { + let source = fs.readFileSync(scriptPath, 'utf8'); + const assignments = new Map([ + ['ORIGINAL_NAME', identity.targetDisplay], + ['ORIGINAL_NAME_LOWER', identity.targetSlug], + ['ORIGINAL_COMPONENT_NAME', identity.targetComponent], + ['ORIGINAL_NAME_UPPER', identity.targetUpper], + ]); + for (const [name, value] of assignments) { + const pattern = new RegExp(`^${name}=.*$`, 'gm'); + const matches = source.match(pattern) ?? []; + if (matches.length !== 1) { + throw new Error( + `expected exactly one ${name} assignment, found ${matches.length}` + ); + } + source = source.replace( + pattern, + `${name}=${JSON.stringify(value)} # rebrand:keep` + ); + } + fs.writeFileSync(scriptPath, source); +}; + +const main = () => { + const [ + command, + rootArgument, + sourceDisplay, + sourceSlug, + sourceComponent, + sourceUpper, + targetDisplay, + targetSlug, + targetComponent, + ...extras + ] = process.argv.slice(2); + if (!command || !rootArgument || !targetComponent) { + throw new Error( + 'command, root, four source names, and three target names are required' + ); + } + const root = path.resolve(rootArgument); + const identity = createIdentity({ + sourceDisplay, + sourceSlug, + sourceComponent, + sourceUpper, + targetDisplay, + targetSlug, + targetComponent, + }); + + if (command === 'update-state') { + if (!extras[0]) + throw new Error('update-state requires the rebrand.sh path'); + updateStateCommand(path.resolve(extras[0]), identity); + return; + } + if (command === 'validate-target') { + validateIdentityTransition(identity, extras); + return; + } + if (command === 'validate-file') { + validateFileCommand(identity, extras[0]); + return; + } + if (command === 'validate-runtime') { + validateRuntimePaths(root, identity, extras); + return; + } + + const paths = readNulPaths(); + switch (command) { + case 'count': + countCommand(paths, identity); + break; + case 'content-dry': + replaceContentCommand(root, paths, identity, true); + break; + case 'content-apply': + replaceContentCommand(root, paths, identity, false); + break; + case 'paths-check': + pathCommand(root, paths, identity, 'check'); + break; + case 'paths-dry': + pathCommand(root, paths, identity, 'dry'); + break; + case 'paths-apply': + pathCommand(root, paths, identity, 'apply'); + break; + case 'verify': + verifyCommand(root, paths, identity); + break; + case 'verify-paths': + verifyPathsCommand(root, paths, identity); + break; + case 'verify-current': + verifyCurrentCommand(root, paths, identity); + break; + default: + throw new Error(`unknown command: ${command}`); + } +}; + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(process.argv[1]).href +) { + try { + main(); + } catch (error) { + process.stderr.write(`ERROR: ${error.message}\n`); + process.exitCode = 1; + } +} diff --git a/scripts/rebrand.sh b/scripts/rebrand.sh index 6c2f0682..7817d144 100755 --- a/scripts/rebrand.sh +++ b/scripts/rebrand.sh @@ -1,8 +1,8 @@ #!/usr/bin/env bash # ============================================================================= -# ScriptHammer Rebrand Script +# ScriptHammer Rebrand Script # rebrand:keep # ============================================================================= -# Automates rebranding of the ScriptHammer template to a new project identity. +# Automates rebranding of the ScriptHammer template to a new project identity. # rebrand:keep # Updates 200+ files including code, config, and documentation. # # Usage: ./scripts/rebrand.sh "" (--icon | --no-icon) [OPTIONS] @@ -28,7 +28,7 @@ # # Exit Codes: # 0 Success -# 1 Invalid arguments +# 1 Validation or rebrand failure # 2 Re-rebrand declined by user # 3 Git error (not a repo, git not installed) # @@ -49,19 +49,48 @@ # label: 'ScriptHammer', // rebrand:keep # # It is LINE-scoped, not file-scoped — a marker at the top of a file protects -# nothing below it. The token is deliberately brand-neutral: `scripthammer:keep` +# nothing below it. The token is deliberately brand-neutral: `scripthammer:keep` # rebrand:keep # would itself contain the string being replaced. # # The attribution link in src/config/footer-links.ts is protected this way, # which is why --preserve-attribution is now a no-op. Removing the attribution # is a one-line edit you are welcome to make. It is MIT. +# +# Case and postcondition: +# Matching is ASCII case-insensitive and replacement preserves the matched +# style: ScriptHammer/scripthammer/Scripthammer/SCRIPTHAMMER become the # rebrand:keep +# display/slug/title/uppercase-component projections. Mixed forms are matched +# too, and identifier-adjacent occurrences stay identifier-safe. +# +# An applied run finishes by scanning the mapped tracked text and paths. Any +# old-brand survivor outside a same-line rebrand:keep marker exits non-zero. +# Uppercase tokens use the separator-free component projection, so env-shaped +# names such as SCRIPTHAMMER_TEST_DOMAIN stay valid. # rebrand:keep # ============================================================================= set -euo pipefail +# Execute from an immutable temporary copy. The workflow implementation stays +# template-owned while its four identity fields are updated after verification. +# The copy also prevents Bash from observing an explicitly updated state file +# midway through a successful run. +if [ "${REBRAND_RUNTIME_COPY:-false}" != true ]; then + REBRAND_SOURCE_PATH="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/$(basename "${BASH_SOURCE[0]}")" + REBRAND_RUNTIME_PATH=$(mktemp "${TMPDIR:-/tmp}/rebrand-runtime.XXXXXX") + cp "$REBRAND_SOURCE_PATH" "$REBRAND_RUNTIME_PATH" + chmod +x "$REBRAND_RUNTIME_PATH" + export REBRAND_RUNTIME_COPY=true REBRAND_SOURCE_PATH REBRAND_RUNTIME_PATH + exec bash "$REBRAND_RUNTIME_PATH" "$@" +fi + +# Early argument/help failures happen before the tracked-file snapshots install +# their combined cleanup trap, so arm runtime cleanup immediately. +trap 'rm -f -- "$REBRAND_RUNTIME_PATH"' EXIT + # Script info -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SCRIPT_DIR="$(cd "$(dirname "$REBRAND_SOURCE_PATH")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" +CASE_HELPER="$SCRIPT_DIR/rebrand-case.mjs" VERSION="1.0.0" # Colors for output @@ -85,10 +114,19 @@ PRESERVE_SSH=false PRESERVE_ATTRIBUTION=false # Original project name to search for -ORIGINAL_NAME="ScriptHammer" -ORIGINAL_NAME_LOWER="scripthammer" +ORIGINAL_NAME="ScriptHammer" # rebrand:keep +ORIGINAL_NAME_LOWER="scripthammer" # rebrand:keep +ORIGINAL_COMPONENT_NAME="ScriptHammer" # rebrand:keep +ORIGINAL_NAME_UPPER="SCRIPTHAMMER" # rebrand:keep ORIGINAL_OWNER="TortoiseWolfe" +# One immutable view of the repository is shared by content replacement, path +# planning, and the postcondition. Without it, the first mv makes git's cached +# path stale and a later pass silently stops seeing the renamed file. +TRACKED_SNAPSHOT="" +REWRITABLE_SNAPSHOT="" +DETECTION_SNAPSHOT="" + # ============================================================================= # Helper Functions # ============================================================================= @@ -116,7 +154,7 @@ show_help() { /^#/ { sub(/^# ?/, ""); print; next } /^[[:space:]]*$/ { print ""; next } # blank line, keep going { exit } # real code, stop - ' "$0" + ' "$REBRAND_SOURCE_PATH" exit 0 } @@ -165,12 +203,12 @@ get_display_name() { # Derive a PascalCase, identifier-safe component name (#911). # -# WHY THIS IS NOT get_display_name. `ORIGINAL_NAME` ("ScriptHammer") does two jobs in this -# tree: it is a noun in prose, and it is a code identifier — `ScriptHammerLogo`, and the +# WHY THIS IS NOT get_display_name. `ORIGINAL_NAME` ("ScriptHammer") does two jobs in this # rebrand:keep +# tree: it is a noun in prose, and it is a code identifier — `ScriptHammerLogo`, and the # rebrand:keep # filename that declares it. Both substitutions used DISPLAY_NAME, which preserves the # user's spaces, hyphens and casing verbatim. That is right for prose and fatal for code: # -# fork "geoLARP" -> JSX reads a lowercase-initial tag as an +# fork "geoLARP" -> JSX reads a lowercase-initial tag as an # rebrand:keep # INTRINSIC element, so React renders an # unknown DOM tag instead of the component # fork "My Cool App" -> a syntax error — and "My Cool App" is this @@ -215,6 +253,17 @@ check_git() { log_error "Not a git repository" exit 3 fi + + if ! command -v node &>/dev/null; then + log_error "Node.js is required for case-preserving rebranding" + log_error "Run rebrand.sh inside the project container, as documented." + exit 1 + fi + + if [ ! -f "$CASE_HELPER" ]; then + log_error "Case-preserving helper not found: $CASE_HELPER" + exit 1 + fi } # Check for uncommitted changes @@ -266,7 +315,10 @@ tracked_files() { git -C "$REPO_ROOT" ls-files -z --cached | while IFS= read -r -d '' rel; do - [ -f "$REPO_ROOT/$rel" ] || continue + # Include tracked symlinks as paths, but never follow them during + # content rewriting. Gitlinks/directories are outside this script's + # file-renaming contract. + [ -f "$REPO_ROOT/$rel" ] || [ -L "$REPO_ROOT/$rel" ] || continue printf '%s\0' "$REPO_ROOT/$rel" done } @@ -279,41 +331,113 @@ tracked_files() { # Lockfiles are excluded by name deliberately, not by oversight: their contents # are generated and integrity-checked, and a brand token inside one is not prose. is_rewritable() { + [ ! -L "$1" ] || return 1 case "${1##*/}" in pnpm-lock.yaml|package-lock.json|yarn.lock|bun.lockb) return 1 ;; esac grep -Iq . "$1" 2>/dev/null } -# Count ScriptHammer references to detect if already rebranded -count_references() { - # Counts matching LINES across the same file set the sweep will rewrite. It - # used to run its own `grep -r` with a third exclusion list, so the detector - # and the thing it gates could disagree about what the repository contains. - local count=0 - local file +cleanup_file_snapshots() { + [ -z "$TRACKED_SNAPSHOT" ] || rm -f -- "$TRACKED_SNAPSHOT" + [ -z "$REWRITABLE_SNAPSHOT" ] || rm -f -- "$REWRITABLE_SNAPSHOT" + [ -z "$DETECTION_SNAPSHOT" ] || rm -f -- "$DETECTION_SNAPSHOT" + rm -f -- "$REBRAND_RUNTIME_PATH" +} + +create_file_snapshots() { + TRACKED_SNAPSHOT=$(mktemp "${TMPDIR:-/tmp}/rebrand-tracked.XXXXXX") + REWRITABLE_SNAPSHOT=$(mktemp "${TMPDIR:-/tmp}/rebrand-text.XXXXXX") + DETECTION_SNAPSHOT=$(mktemp "${TMPDIR:-/tmp}/rebrand-detect.XXXXXX") + trap cleanup_file_snapshots EXIT + + tracked_files > "$TRACKED_SNAPSHOT" while IFS= read -r -d '' file; do is_rewritable "$file" || continue - count=$(( count + $(grep -c "$ORIGINAL_NAME" "$file" 2>/dev/null || echo 0) )) - done < <(tracked_files) - echo "$count" + # The workflow implementation is stable template tooling, not brand + # content. Its four keep-marked ORIGINAL_* fields are updated explicitly + # only after verification; rewriting arbitrary shell tokens can corrupt + # a later run when a project is named e.g. "Local" or "Rebrand". + if [ "$file" = "$REBRAND_SOURCE_PATH" ]; then + continue + fi + printf '%s\0' "$file" >> "$REWRITABLE_SNAPSHOT" + # Stable tooling is excluded from already-rebranded detection. The shell + # implementation stores the source identity, while the helper documents + # transformation examples; neither is application-brand evidence. + if [ "$file" != "$REBRAND_SOURCE_PATH" ] && [ "$file" != "$CASE_HELPER" ]; then + printf '%s\0' "$file" >> "$DETECTION_SNAPSHOT" + fi + done < "$TRACKED_SNAPSHOT" +} + +case_helper() { + local command="$1" + shift + node "$CASE_HELPER" "$command" "$REPO_ROOT" \ + "$ORIGINAL_NAME" "$ORIGINAL_NAME_LOWER" \ + "$ORIGINAL_COMPONENT_NAME" "$ORIGINAL_NAME_UPPER" \ + "$DISPLAY_NAME" "$SANITIZED_NAME" "$COMPONENT_NAME" "$@" +} + +brand_identity_is_unchanged() { + [ "$DISPLAY_NAME" = "$ORIGINAL_NAME" ] && + [ "$SANITIZED_NAME" = "$ORIGINAL_NAME_LOWER" ] && + [ "$COMPONENT_NAME" = "$ORIGINAL_COMPONENT_NAME" ] && + [ "$(printf '%s' "$COMPONENT_NAME" | tr '[:lower:]' '[:upper:]')" = "$ORIGINAL_NAME_UPPER" ] +} + +validate_brand_target() { + # A target that still contains the recorded source identity makes the final + # residual contract impossible to satisfy (for example `ScriptHammer Pro` # rebrand:keep + # or the case-only target `scripthammer`). Fail before the first write. # rebrand:keep + case_helper validate-target "$OWNER" "$DESCRIPTION" < /dev/null + case_helper validate-runtime "scripts/rebrand.sh" "scripts/rebrand-case.mjs" < /dev/null + + if [ -n "${BRAND_ICON:-}" ] && [ -f "$BRAND_ICON" ] && \ + [ "$(printf '%s' "${BRAND_ICON##*.}" | tr '[:upper:]' '[:lower:]')" = svg ]; then + case_helper validate-file "$BRAND_ICON" < /dev/null + fi +} + +assert_index_paths_current() { + # `git ls-files` is intentionally the repository boundary. After a prior + # rebrand moves tracked paths, the index still names their missing sources + # until the user stages or commits the rename. A different-target rerun must + # not silently omit those files and report a false success. + local missing + missing=$(git -C "$REPO_ROOT" ls-files --deleted | sed -n '1,5p') + if [ -n "$missing" ]; then + log_error "Tracked paths are missing from the working tree; re-rebrand cannot take a complete snapshot." + log_error "Stage the prior rename with 'git add -A' (preferably commit it), then retry." + printf '%s\n' "$missing" | sed 's/^/ missing: /' >&2 + exit 1 + fi +} + +# Count ScriptHammer references to detect if already rebranded # rebrand:keep +count_references() { + # The detector and the postcondition use the same case-insensitive matcher, + # the same immutable tracked/text snapshot, and the same line-scoped keep + # rule. A fresh minimal fork with one `Scripthammer` line must not be called # rebrand:keep + # "already rebranded", and attribution-only keep lines must not prevent the + # correct zero result. + case_helper count < "$DETECTION_SNAPSHOT" } # Detect previous rebrand # -# A fresh ScriptHammer clone contains hundreds of "ScriptHammer" references -# across .ts/.tsx/.md/.yml files. A successfully-rebranded fork contains 0–4 -# (only the Footer attribution + this script's own constants — and even those -# is protected by `rebrand:keep` markers). The threshold below -# uses < 5 as the "already rebranded" signal — well under any plausible -# fresh-clone count, well above any plausible post-rebrand residual. +# A fresh clone contains hundreds of case variants. A repository with exactly +# zero unmarked matches of its recorded source identity has already moved on. +# There is deliberately no "few enough" heuristic: one missed title/uppercase +# spelling is the bug this detector is meant to expose. detect_previous_rebrand() { local ref_count ref_count=$(count_references) - if [ "$ref_count" -eq 0 ] || [ "$ref_count" -lt 5 ]; then + if [ "$ref_count" -eq 0 ]; then log_warning "This repository appears to have been rebranded already." - echo "No \"$ORIGINAL_NAME\" references found (or very few: $ref_count)." + echo "No unmarked case-insensitive \"$ORIGINAL_NAME\" references found." echo "" # Try to detect current project name from package.json @@ -389,42 +513,80 @@ replace_in_files() { done < <(tracked_files) } -# Rename files containing original name -rename_files() { - local search="$1" - local replace="$2" +# Consume the helper's escaped, line-oriented report without making path names +# part of shell syntax. Discovery/input remains NUL-separated; JSON quoting is +# used only for the human-readable log. +consume_case_report() { + local report="$1" + local counter="$2" + local kind + local payload + local count=0 - while IFS= read -r -d '' file; do - local dir - local base - local new_base - local new_file + while IFS=$'\t' read -r kind payload; do + case "$kind" in + UPDATED) log_verbose "Updated: $payload" ;; + WOULD_UPDATE) log_verbose "[DRY-RUN] Would update: $payload" ;; + RENAMED) log_verbose "Renamed: $payload" ;; + WOULD_RENAME) log_verbose "[DRY-RUN] Would rename: $payload" ;; + COUNT) count="$payload" ;; + esac + done < "$report" - dir=$(dirname "$file") - base=$(basename "$file") - new_base=$(echo "$base" | sed "s|$search|$replace|g") + if [ "$counter" = modified ]; then + FILES_MODIFIED=$((FILES_MODIFIED + count)) + else + FILES_RENAMED=$((FILES_RENAMED + count)) + fi +} - if [ "$base" != "$new_base" ]; then - new_file="$dir/$new_base" - if [ "$DRY_RUN" = true ]; then - log_verbose "[DRY-RUN] Would rename: ${base} → ${new_base}" - else - mv "$file" "$new_file" - log_verbose "Renamed: ${base} → ${new_base}" - fi - ((FILES_RENAMED++)) || true - fi - # Same discovery as the content sweep (#922). Filtering on the basename here - # rather than in `find -name` keeps both passes agreeing about what the - # repository contains. Binaries are NOT excluded: renaming a tracked - # ScriptHammerLogo.png is precisely what this pass is for. - done < <( - while IFS= read -r -d '' f; do - case "${f##*/}" in - *"$search"*) printf '%s\0' "$f" ;; - esac - done < <(tracked_files) - ) +replace_brand_in_files() { + local mode="content-apply" + local report + [ "$DRY_RUN" = false ] || mode="content-dry" + report=$(mktemp "${TMPDIR:-/tmp}/rebrand-content-report.XXXXXX") + + if ! case_helper "$mode" < "$REWRITABLE_SNAPSHOT" > "$report"; then + rm -f -- "$report" + return 1 + fi + consume_case_report "$report" modified + rm -f -- "$report" +} + +preflight_brand_paths() { + # This runs before ANY content mutation. `ScriptHammer.md` and # rebrand:keep + # `Scripthammer.md` can otherwise collapse onto the same target and mv would # rebrand:keep + # overwrite one of them after hundreds of files had already changed. + case_helper paths-check < "$TRACKED_SNAPSHOT" > /dev/null +} + +rename_brand_paths() { + local mode="paths-apply" + local report + [ "$DRY_RUN" = false ] || mode="paths-dry" + report=$(mktemp "${TMPDIR:-/tmp}/rebrand-path-report.XXXXXX") + + if ! case_helper "$mode" < "$TRACKED_SNAPSHOT" > "$report"; then + rm -f -- "$report" + return 1 + fi + consume_case_report "$report" renamed + rm -f -- "$report" +} + +update_rebrand_identity_state() { + [ "$DRY_RUN" = true ] && return 0 + case_helper update-state "$REPO_ROOT/scripts/rebrand.sh" +} + +assert_no_old_brand() { + case_helper verify-paths < "$TRACKED_SNAPSHOT" + case_helper verify < "$REWRITABLE_SNAPSHOT" +} + +assert_no_old_brand_before_path_moves() { + case_helper verify-current < "$REWRITABLE_SNAPSHOT" } # Update docker-compose.yml service name @@ -467,16 +629,16 @@ update_package_json() { fi } -# Update CNAME file (replace scripthammer domain with new project domain) +# Update CNAME file (replace scripthammer domain with new project domain) # rebrand:keep update_cname() { local cname_file="$REPO_ROOT/public/CNAME" if [ -f "$cname_file" ]; then - # Check if it's a custom domain (not scripthammer.com) + # Check if it's a custom domain (not scripthammer.com) # rebrand:keep local domain domain=$(cat "$cname_file" 2>/dev/null || echo "") - if [[ "$domain" == *"scripthammer"* ]] || [ -z "$domain" ]; then + if [[ "$domain" == *"scripthammer"* ]] || [ -z "$domain" ]; then # rebrand:keep if [ "$KEEP_CNAME" = true ]; then log_info "Keeping CNAME file as-is (--keep-cname flag set)" else @@ -509,27 +671,27 @@ scaffold_themes() { fi # Replace theme names in @plugin "daisyui" block - if grep -q "scripthammer-dark" "$css_file" 2>/dev/null; then + if grep -q "scripthammer-dark" "$css_file" 2>/dev/null; then # rebrand:keep if [ "$DRY_RUN" = true ]; then log_verbose "[DRY-RUN] Would rename theme references in globals.css" else - sed "${SED_INPLACE[@]}" "s|scripthammer-dark|${SANITIZED_NAME}-dark|g" "$css_file" - sed "${SED_INPLACE[@]}" "s|scripthammer-light|${SANITIZED_NAME}-light|g" "$css_file" - sed "${SED_INPLACE[@]}" "s|ScriptHammer Dark Theme|${DISPLAY_NAME} Dark Theme|g" "$css_file" - sed "${SED_INPLACE[@]}" "s|ScriptHammer Light Theme|${DISPLAY_NAME} Light Theme|g" "$css_file" - log_verbose "Renamed theme blocks: scripthammer-* → ${SANITIZED_NAME}-*" + sed "${SED_INPLACE[@]}" "s|scripthammer-dark|${SANITIZED_NAME}-dark|g" "$css_file" # rebrand:keep + sed "${SED_INPLACE[@]}" "s|scripthammer-light|${SANITIZED_NAME}-light|g" "$css_file" # rebrand:keep + sed "${SED_INPLACE[@]}" "s|ScriptHammer Dark Theme|${DISPLAY_NAME} Dark Theme|g" "$css_file" # rebrand:keep + sed "${SED_INPLACE[@]}" "s|ScriptHammer Light Theme|${DISPLAY_NAME} Light Theme|g" "$css_file" # rebrand:keep + log_verbose "Renamed theme blocks: scripthammer-* → ${SANITIZED_NAME}-*" # rebrand:keep fi ((FILES_MODIFIED++)) || true fi # Update ThemeScript.tsx fallback theme names local theme_script="$REPO_ROOT/src/components/ThemeScript.tsx" - if [ -f "$theme_script" ] && grep -q "scripthammer-dark" "$theme_script" 2>/dev/null; then + if [ -f "$theme_script" ] && grep -q "scripthammer-dark" "$theme_script" 2>/dev/null; then # rebrand:keep if [ "$DRY_RUN" = true ]; then log_verbose "[DRY-RUN] Would update ThemeScript.tsx theme names" else - sed "${SED_INPLACE[@]}" "s|scripthammer-dark|${SANITIZED_NAME}-dark|g" "$theme_script" - sed "${SED_INPLACE[@]}" "s|scripthammer-light|${SANITIZED_NAME}-light|g" "$theme_script" + sed "${SED_INPLACE[@]}" "s|scripthammer-dark|${SANITIZED_NAME}-dark|g" "$theme_script" # rebrand:keep + sed "${SED_INPLACE[@]}" "s|scripthammer-light|${SANITIZED_NAME}-light|g" "$theme_script" # rebrand:keep log_verbose "Updated ThemeScript.tsx theme fallbacks" fi ((FILES_MODIFIED++)) || true @@ -537,12 +699,12 @@ scaffold_themes() { # Update Storybook preview theme names local preview_file="$REPO_ROOT/.storybook/preview.tsx" - if [ -f "$preview_file" ] && grep -q "scripthammer-dark" "$preview_file" 2>/dev/null; then + if [ -f "$preview_file" ] && grep -q "scripthammer-dark" "$preview_file" 2>/dev/null; then # rebrand:keep if [ "$DRY_RUN" = true ]; then log_verbose "[DRY-RUN] Would update .storybook/preview.tsx theme names" else - sed "${SED_INPLACE[@]}" "s|scripthammer-dark|${SANITIZED_NAME}-dark|g" "$preview_file" - sed "${SED_INPLACE[@]}" "s|scripthammer-light|${SANITIZED_NAME}-light|g" "$preview_file" + sed "${SED_INPLACE[@]}" "s|scripthammer-dark|${SANITIZED_NAME}-dark|g" "$preview_file" # rebrand:keep + sed "${SED_INPLACE[@]}" "s|scripthammer-light|${SANITIZED_NAME}-light|g" "$preview_file" # rebrand:keep log_verbose "Updated Storybook preview theme names" fi ((FILES_MODIFIED++)) || true @@ -740,7 +902,7 @@ main() { if [ ${#POSITIONAL[@]} -lt 3 ]; then log_error "Missing required arguments" echo "" - echo "Usage: $0 \"\" [OPTIONS]" + echo "Usage: $REBRAND_SOURCE_PATH \"\" [OPTIONS]" echo "" echo "Use --help for more information" exit 1 @@ -792,11 +954,17 @@ main() { # Pre-flight checks check_git check_uncommitted_changes + create_file_snapshots + + local same_brand=false + if brand_identity_is_unchanged; then + same_brand=true + fi # Header echo "" echo "=========================================" - echo " ScriptHammer Rebrand Script v${VERSION}" + echo " ScriptHammer Rebrand Script v${VERSION}" # rebrand:keep echo "=========================================" echo "" @@ -815,35 +983,28 @@ main() { echo "" fi - # Check for previous rebrand - detect_previous_rebrand || true + # Check for previous rebrand. A same-target run is an explicit no-op for the + # brand sweep; requiring "zero old brand" when old and new are identical is + # a contradiction, not verification. + if [ "$same_brand" = true ]; then + log_info "Brand identity already matches; string and path transforms are a no-op." + else + validate_brand_target + assert_index_paths_current + detect_previous_rebrand || true + preflight_brand_paths + fi # Perform rebrand operations echo "Updating file contents..." - # Replace case variations - # IDENTIFIER OCCURRENCES FIRST, THEN PROSE (#911). - # - # `ScriptHammer` glued to a word character is part of a larger identifier — - # `ScriptHammerLogo`, `SimpleScriptHammer`, `ScriptHammerLogoProps` — and must stay - # identifier-safe, so it takes COMPONENT_NAME. A standalone occurrence is a noun in - # prose and keeps DISPLAY_NAME, spaces and all. - # - # Order is load-bearing: the standalone pass would otherwise consume the identifier - # occurrences before the adjacency passes ever saw them. - replace_in_files "$ORIGINAL_NAME\([A-Za-z0-9_]\)" "$COMPONENT_NAME\1" - replace_in_files "\([A-Za-z0-9_]\)$ORIGINAL_NAME" "\1$COMPONENT_NAME" - replace_in_files "$ORIGINAL_NAME" "$DISPLAY_NAME" - replace_in_files "$ORIGINAL_NAME_LOWER" "$SANITIZED_NAME" + if [ "$same_brand" = false ]; then + # One ASCII-case-insensitive pass handles canonical, lower, title, + # uppercase, and arbitrary mixed spellings. The replacement callback + # retains #911's display/slug/component distinction from local context. + replace_brand_in_files + fi replace_in_files "$ORIGINAL_OWNER" "$OWNER" - echo "" - echo "Renaming files..." - # FILENAMES ALWAYS TAKE THE COMPONENT NAME (#911). `ScriptHammerLogo.tsx` declares an - # identifier, so its filename must be identifier-safe — and no filename in this tree - # should acquire a space because someone forked as "My Cool App". - rename_files "$ORIGINAL_NAME" "$COMPONENT_NAME" - rename_files "$ORIGINAL_NAME_LOWER" "$SANITIZED_NAME" - echo "" echo "Updating docker-compose.yml..." update_docker_compose @@ -872,6 +1033,33 @@ main() { echo "Updating brand icons..." update_brand_icons + echo "" + echo "Renaming tracked paths..." + if [ "$same_brand" = false ]; then + if [ "$DRY_RUN" = false ]; then + # Catch every content/specialized-write failure while Git's indexed + # source paths still exist. A retry can then repair the same tree. + echo "Verifying content before path moves..." + assert_no_old_brand_before_path_moves + fi + + # Transform every path component from the immutable pre-mutation plan. + # This includes brand-bearing directories and moves binaries without + # reading their bytes. + rename_brand_paths + fi + + if [ "$DRY_RUN" = false ] && [ "$same_brand" = false ]; then + echo "" + echo "Verifying rebrand postcondition..." + assert_no_old_brand + + # Commit identity state only after every write and both independent + # postconditions succeed. The assignment lines themselves are keep- + # marked so the content sweep cannot publish a target state early. + update_rebrand_identity_state + fi + # Summary END_TIME=$(date +%s) ELAPSED=$((END_TIME - START_TIME)) @@ -913,7 +1101,7 @@ main() { # they are registered with third parties, not derived from a project name. # `scripts/supabase/auth-config.json` is the DESIRED STATE a daily gate # compares your live project against, so leaving it unset means the gate - # measures your project against ScriptHammer's identity and fails on values + # measures your project against ScriptHammer's identity and fails on values # rebrand:keep # that were never yours. Say so, rather than let them conclude the gate is # broken and stop reading it. echo -e "${YELLOW} ⚠ YOUR AUTH DESIRED-STATE IS STILL ${ORIGINAL_NAME}'S.${NC}" diff --git a/specs/011-feature-038-template/contracts/rebrand-script.md b/specs/011-feature-038-template/contracts/rebrand-script.md index 9f75399c..0128dd62 100644 --- a/specs/011-feature-038-template/contracts/rebrand-script.md +++ b/specs/011-feature-038-template/contracts/rebrand-script.md @@ -21,7 +21,7 @@ | Code | Meaning | | ---- | ---------------------------------------------------------- | | 0 | Success | -| 1 | Invalid arguments | +| 1 | Validation or rebrand failure | | 2 | Re-rebrand scenario (no ScriptHammer found), user declined | | 3 | Git not installed or not a git repo | @@ -73,30 +73,68 @@ Input → Output ### Re-rebrand Detection -If grep finds 0 occurrences of "ScriptHammer": +If the tracked-text scan finds exactly 0 case-insensitive, unmarked occurrences +of the four recorded source projections (display, slug, component, uppercase): ``` WARNING: This repository appears to have been rebranded already. -No "ScriptHammer" references found. +No unmarked case-insensitive "ScriptHammer" references found. Current project name appears to be: OtherProject Do you want to rebrand from "OtherProject" to "MyApp"? [y/N] ``` -### File Patterns +### Case-Preserving Substitution -**Included**: +For a target named `GeoLarp`: ``` -*.ts *.tsx *.js *.jsx *.json *.md *.yml *.yaml *.sh *.html *.css +ScriptHammer → GeoLarp +scripthammer → geolarp +Scripthammer → Geolarp +SCRIPTHAMMER → GEOLARP +ScriptHAMMER → GeoLarp ``` -**Excluded**: +Matches adjacent to identifier characters use an identifier-safe component +projection. Uppercase tokens always use the uppercase component projection, so +`SCRIPTHAMMER_TEST_DOMAIN` remains valid for a multiword display name. Lowercase +standalone tokens use the sanitized technical slug. A same-line +`rebrand:keep` marker is the current explicit content opt-out. + +### Repository and Path Scope + +Content and path discovery use one immutable, NUL-separated `git ls-files` +snapshot. This includes tracked extensionless files and future file types +without an allowlist. + +Content rewriting excludes lockfiles and files that `grep -I` classifies as +binary. Path renaming still includes those files: it changes every brand-bearing +path component without reading or rewriting file bytes. + +Before the first write, the script computes every target path and fails on an +existing-target or case-folding collision. After an applied run, it scans the +mapped tracked-text destinations and paths case-insensitively. Any unmarked old +brand is exit 1; success is not reported. + +**Not content-rewritten**: ``` -node_modules/ .next/ out/ .git/ *.lock pnpm-lock.yaml package-lock.json +untracked/ignored files +pnpm-lock.yaml package-lock.json yarn.lock bun.lockb +binary files ``` ## Idempotency -Running the script multiple times with the same arguments produces the same result. Running with different arguments in a re-rebrand scenario will prompt for confirmation. +Running the script multiple times with the same identity produces the same +result and explicitly skips the contradictory old-equals-new postcondition. +The script persists display, slug, component, and uppercase projections in its +own identity state so a later re-rebrand can find identifiers and paths emitted +by the first run. Running with different arguments in a detected re-rebrand +scenario may prompt for confirmation unless `--force` is supplied. Prior path +moves must first be staged with `git add -A` (preferably committed). Automated +different-target re-rebrands reject ambiguous source projections and identities +that collide with stable rebrand tooling. The stable shell/helper implementation +is not rewritten as application brand content; the shell's four identity fields +are updated only after verification succeeds. diff --git a/src/components/Footer.tsx b/src/components/Footer.tsx index 1cbf6943..3c94bfb8 100644 --- a/src/components/Footer.tsx +++ b/src/components/Footer.tsx @@ -1,7 +1,7 @@ import React from 'react'; import { FOOTER_LINKS } from '@/config/footer-links'; -const [CRUDGAMES, GEOLARP, SCRIPTHAMMER] = FOOTER_LINKS; +const [CRUDGAMES, GEOLARP_SITE, SCRIPTHAMMER] = FOOTER_LINKS; export function Footer() { return ( @@ -25,12 +25,12 @@ export function Footer() { {' '} for{' '} - {GEOLARP.label} + {GEOLARP_SITE.label}

diff --git a/src/config/authors.ts b/src/config/authors.ts index c17e9b48..eac98c52 100644 --- a/src/config/authors.ts +++ b/src/config/authors.ts @@ -174,7 +174,7 @@ export const authors: Record = { }, }, // Legacy alias for backwards compatibility - TortoiseWolfe: { + ['TortoiseWolfe']: { id: 'tortoisewolfe', name: authorConfigData.name, role: authorConfigData.role, diff --git a/tests/rebrand/test-rebrand.sh b/tests/rebrand/test-rebrand.sh index c967ffd3..654decff 100755 --- a/tests/rebrand/test-rebrand.sh +++ b/tests/rebrand/test-rebrand.sh @@ -13,10 +13,12 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" REPO_ROOT="$(cd "$SCRIPT_DIR/../.." && pwd)" REBRAND_SCRIPT="$REPO_ROOT/scripts/rebrand.sh" +REBRAND_CASE_HELPER="$REPO_ROOT/scripts/rebrand-case.mjs" +UPSTREAM_DISPLAY='Script''Hammer' # SAFETY CHECK: Never run rebrand on the actual repo SAFETY_FILE="$REPO_ROOT/.git/config" -if [ -f "$SAFETY_FILE" ] && grep -q "ScriptHammer" "$SAFETY_FILE" 2>/dev/null; then +if [ -f "$SAFETY_FILE" ] && grep -q "$UPSTREAM_DISPLAY" "$SAFETY_FILE" 2>/dev/null; then ACTUAL_REPO=true else ACTUAL_REPO=false @@ -88,6 +90,7 @@ setup_temp_dir() { mkdir -p public echo "scripthammer.com" > public/CNAME echo "export const projectName = 'ScriptHammer';" > src/components/Logo.tsx + printf 'lockfileBrand: ScriptHammer\n' > pnpm-lock.yaml # THE BRAND TOKEN IN A FILENAME AND IN AN IDENTIFIER (#911). # @@ -141,11 +144,52 @@ FOOTER printf '#!/bin/sh\n# ScriptHammer pre-commit hook\n' > .husky/pre-commit chmod +x .husky/pre-commit - # Copy the rebrand script to temp dir - cp "$REBRAND_SCRIPT" "$TEMP_DIR/scripts/" 2>/dev/null || { - mkdir -p scripts - cp "$REBRAND_SCRIPT" "$TEMP_DIR/scripts/" - } + # EVERY REAL CASE STYLE PLUS A FUTURE MIXED STYLE (#933). These are code-shaped, + # not just prose: a replacement can remove the old word and still make an invalid + # identifier for a project name with spaces. + cat > src/config/case-variants.ts <<'VARIANTS' +export const canonical = 'ScriptHammer'; +export const lower = 'scripthammer'; +export const title = 'Scripthammer'; +export const upper = 'SCRIPTHAMMER'; +export const futureMixed = 'ScriptHAMMER'; +export const scripthammerCaches = true; +export const __scripthammer_syncQueue = true; +export const SCRIPTHAMMER_TEST_DOMAIN = '@scripthammer.test'; +export function cleanupStaleScripthammerUsers() {} +export const keep = 'SCRIPTHAMMER + ScriptHAMMER'; // rebrand:keep +VARIANTS + cat > src/config/owner-map.ts <<'OWNER_MAP' +export const authors = { + ['TortoiseWolfe']: true, +}; +OWNER_MAP + + # The reported live failure: the slug/file moved but every reader-facing field + # kept the title-case spelling. The image directory is equally load-bearing — + # rewriting this URL without moving the directory produces a broken intro post. + mkdir -p public/blog public/blog-images/scripthammer-intro + cat > public/blog/scripthammer-intro.md <<'INTRO' +--- +title: Scripthammer - Opinionated Template +ogTitle: SCRIPTHAMMER +featuredImageAlt: ScriptHAMMER introduction +--- +# Scripthammer: Introduction +![Dashboard](/blog-images/scripthammer-intro/plain.png) +INTRO + printf 'binary-before\0Scripthammer\0binary-after' > public/blog-images/scripthammer-intro/plain.png + + # Non-lower path styles prove the path pass is case-insensitive too. + mkdir -p docs + printf '# Scripthammer badge\n' > src/components/ScripthammerBadge.tsx + printf '# SCRIPTHAMMER notes\n' > docs/SCRIPTHAMMER-NOTES.md + + # Copy both halves of the rebrand implementation. The shell script owns the + # workflow; the Node helper owns portable, callback-based casing and atomic + # path planning (BSD sed cannot express either safely). + mkdir -p scripts + cp "$REBRAND_SCRIPT" "$REBRAND_CASE_HELPER" "$TEMP_DIR/scripts/" # STAGE EVERYTHING. Discovery is `git ls-files`, so an unstaged fixture is an # EMPTY fixture -- every assertion below would pass vacuously against a sweep @@ -173,6 +217,30 @@ safe_rebrand() { "$TEMP_DIR/scripts/rebrand.sh" "$@" } +# Choose a deterministic target that remains different even when this harness +# is executed from a fork whose current identity is already GeoLarp. The test +# source itself is rebranded with the repository, so fixed GeoLarp arguments +# otherwise turn the integration cases into same-target no-ops downstream. +set_case_test_identity() { + CASE_SOURCE_DISPLAY=$(sed -n 's/^ORIGINAL_NAME="\([^"]*\)".*/\1/p' "$TEMP_DIR/scripts/rebrand.sh") + CASE_SOURCE_SLUG=$(sed -n 's/^ORIGINAL_NAME_LOWER="\([^"]*\)".*/\1/p' "$TEMP_DIR/scripts/rebrand.sh") + CASE_SOURCE_COMPONENT=$(sed -n 's/^ORIGINAL_COMPONENT_NAME="\([^"]*\)".*/\1/p' "$TEMP_DIR/scripts/rebrand.sh") + CASE_SOURCE_UPPER=$(sed -n 's/^ORIGINAL_NAME_UPPER="\([^"]*\)".*/\1/p' "$TEMP_DIR/scripts/rebrand.sh") + CASE_SOURCE_TITLE=$(printf '%s' "$CASE_SOURCE_COMPONENT" | tr '[:upper:]' '[:lower:]' | \ + awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }') + + if [ "$CASE_SOURCE_DISPLAY" = "GeoLarp" ]; then # rebrand:keep + CASE_TARGET_DISPLAY="CaseProbe" # rebrand:keep + else + CASE_TARGET_DISPLAY="GeoLarp" # rebrand:keep + fi + CASE_TARGET_SLUG=$(printf '%s' "$CASE_TARGET_DISPLAY" | tr '[:upper:]' '[:lower:]') + CASE_TARGET_COMPONENT="$CASE_TARGET_DISPLAY" + CASE_TARGET_UPPER=$(printf '%s' "$CASE_TARGET_COMPONENT" | tr '[:lower:]' '[:upper:]') + CASE_TARGET_TITLE=$(printf '%s' "$CASE_TARGET_COMPONENT" | tr '[:upper:]' '[:lower:]' | \ + awk '{ print toupper(substr($0, 1, 1)) substr($0, 2) }') +} + # ============================================================================ # T005b: Test argument validation (missing args should fail with exit 1) # ============================================================================ @@ -581,23 +649,52 @@ test_rerebrand_detection() { echo "# OtherProject" > README.md echo "export const projectName = 'OtherProject';" > src/components/Logo.tsx - # Copy rebrand script - cp "$REBRAND_SCRIPT" "$REREBRAND_TEMP/scripts/" + # Commit the application fixture before copying the implementation. This + # gives the detector a real tracked set without letting its own source text + # satisfy (or contaminate) the brand count. + git add package.json README.md src/components/Logo.tsx >/dev/null 2>&1 + git -c user.name=Test -c user.email=test@example.com commit -qm fixture + cp "$REBRAND_SCRIPT" "$REBRAND_CASE_HELPER" "$REREBRAND_TEMP/scripts/" - # Run without --force, test for WARNING message in output - local output - # STAGE IT. Discovery is `git ls-files` (#922), so an unstaged fixture is an - # empty one -- count_references would return 0 and this test would report - # "already rebranded detected" no matter what the detector did. It has to - # measure a repository that actually contains files. - git add -A >/dev/null 2>&1 + local output status detector='This repository appears to have been rebranded already' + set +e + output=$("$REREBRAND_TEMP/scripts/rebrand.sh" "MyApp" "testuser" "Test desc" --dry-run --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ] && printf '%s\n' "$output" | grep -Fq "$detector"; then + log_pass "Exact-zero detector recognizes an already-rebranded tree" + else + log_fail "Re-rebrand detection" "exit 0 and exact detector warning" \ + "status=$status output=${output:0:300}" + fi - output=$("$REREBRAND_TEMP/scripts/rebrand.sh" "MyApp" "testuser" "Test desc" --dry-run --no-icon 2>&1 || true) + # One alternate-case survivor is enough to prove this is not an already- + # rebranded tree. This pins removal of the former '< 5' heuristic. + printf "export const oldBrand = 'Scripthammer';\n" > src/components/legacy.ts + git add src/components/legacy.ts >/dev/null 2>&1 + set +e + output=$("$REREBRAND_TEMP/scripts/rebrand.sh" "MyApp" "testuser" "Test desc" --dry-run --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ] && ! printf '%s\n' "$output" | grep -Fq "$detector"; then + log_pass "One unmarked title-case survivor prevents rebrand detection" + else + log_fail "Alternate-case detector" "no already-rebranded warning" \ + "status=$status output=${output:0:300}" + fi - if echo "$output" | grep -qi "already.*rebranded\|no.*scripthammer.*found\|WARNING"; then - log_pass "Re-rebrand scenario detected and warned" + # The same occurrence is intentionally invisible only when its own line is + # explicitly protected. + printf "export const oldBrand = 'Scripthammer'; // rebrand:keep\n" > src/components/legacy.ts + set +e + output=$("$REREBRAND_TEMP/scripts/rebrand.sh" "MyApp" "testuser" "Test desc" --dry-run --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ] && printf '%s\n' "$output" | grep -Fq "$detector"; then + log_pass "Keep-only source references count as zero" else - log_fail "Re-rebrand detection" "warning about already rebranded" "${output:0:200}" + log_fail "Keep-only detector" "exit 0 and exact detector warning" \ + "status=$status output=${output:0:300}" fi cd "$REPO_ROOT" @@ -641,6 +738,15 @@ test_discovery_is_git_tracked() { log_fail "Extensionless file was not reached" "GeoLARP in .husky/pre-commit" "$(cat .husky/pre-commit 2>/dev/null)" fi + # Generated lockfiles are tracked but deliberately not content-rewritten; + # changing one without regenerating it can invalidate integrity metadata. + if grep -q "ScriptHammer" pnpm-lock.yaml 2>/dev/null; then + log_pass "Tracked lockfile content is left byte-stable" + else + log_fail "Lockfile exclusion" "ScriptHammer intact in pnpm-lock.yaml" \ + "$(cat pnpm-lock.yaml 2>/dev/null)" + fi + # 3. A FLOOR, so a discovery change that silently matches NOTHING fails loudly. # Every assertion above is satisfiable by a sweep that touched no file at all -- # the gitignored ones stay intact for the wrong reason. Without this the whole @@ -657,6 +763,373 @@ test_discovery_is_git_tracked() { cd "$REPO_ROOT" } +# ============================================================================ +# #933: arbitrary casing, identifier projections, full paths, and postcondition +# ============================================================================ +test_case_preserving_rebrand() { + run_test "test_case_preserving_rebrand" + setup_temp_dir + set_case_test_identity + + local binary_before output status residuals old_paths key_before key_after keep_before + binary_before=$(git hash-object public/blog-images/scripthammer-intro/plain.png) + keep_before=$(tail -1 src/config/case-variants.ts) + + set +e + output=$(safe_rebrand "$CASE_TARGET_DISPLAY" "test-user" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ]; then + log_pass "Case-preserving rebrand exits zero" + else + log_fail "Case-preserving rebrand status" "exit 0" "exit $status: $output" + cd "$REPO_ROOT" + return + fi + + local variants="$TEMP_DIR/src/config/case-variants.ts" + local expected + for expected in \ + "export const canonical = '$CASE_TARGET_DISPLAY';" \ + "export const lower = '$CASE_TARGET_SLUG';" \ + "export const title = '$CASE_TARGET_TITLE';" \ + "export const upper = '$CASE_TARGET_UPPER';" \ + "export const futureMixed = '$CASE_TARGET_DISPLAY';" \ + "export const ${CASE_TARGET_SLUG}Caches = true;" \ + "export const __${CASE_TARGET_SLUG}_syncQueue = true;" \ + "export const ${CASE_TARGET_UPPER}_TEST_DOMAIN = '@${CASE_TARGET_SLUG}.test';" \ + "export function cleanupStale${CASE_TARGET_TITLE}Users() {}"; do + if grep -Fqx "$expected" "$variants"; then + log_pass "Exact case projection: $expected" + else + log_fail "Case projection" "$expected" "$(cat "$variants")" + fi + done + + if [ "$(tail -1 "$variants")" = "$keep_before" ]; then + log_pass "All keep-line case variants remain byte-exact" + else + log_fail "Keep-line surgery" "original mixed/upper line" "$(tail -1 "$variants")" + fi + + if grep -Fqx " ['test-user']: true," src/config/owner-map.ts; then + log_pass "Hyphenated GitHub owner remains a valid quoted object key" + else + log_fail "Owner identifier safety" "quoted test-user key" \ + "$(cat src/config/owner-map.ts)" + fi + + local intro="$TEMP_DIR/public/blog/${CASE_TARGET_SLUG}-intro.md" + if [ -f "$intro" ] && grep -Fq "title: $CASE_TARGET_TITLE - Opinionated Template" "$intro" && \ + grep -Fq "ogTitle: $CASE_TARGET_UPPER" "$intro" && grep -Fq "# $CASE_TARGET_TITLE: Introduction" "$intro"; then + log_pass "Renamed intro has no missed reader-facing casing" + else + log_fail "Intro rebrand" "renamed intro with Geolarp/GEOLARP content" "$(cat "$intro" 2>/dev/null)" + fi + + local binary_after="$TEMP_DIR/public/blog-images/${CASE_TARGET_SLUG}-intro/plain.png" + if [ -f "$binary_after" ] && [ "$(git hash-object "$binary_after")" = "$binary_before" ]; then + log_pass "Brand directory renamed without changing binary bytes" + else + log_fail "Binary/path transform" "geolarp-intro path with identical hash" "missing or changed" + fi + + if [ -f "$TEMP_DIR/src/components/${CASE_TARGET_TITLE}Badge.tsx" ] && \ + [ -f "$TEMP_DIR/docs/${CASE_TARGET_UPPER}-NOTES.md" ]; then + log_pass "Title and uppercase tracked paths use their exact projections" + else + log_fail "Case-preserving paths" "GeolarpBadge.tsx and GEOLARP-NOTES.md" \ + "$(find "$TEMP_DIR" -maxdepth 3 -type f | sort | tr '\n' ' ')" + fi + + residuals="" + old_paths="" + local source + for source in "$CASE_SOURCE_DISPLAY" "$CASE_SOURCE_SLUG" "$CASE_SOURCE_COMPONENT" "$CASE_SOURCE_UPPER"; do + residuals+=$(find . \ + \( -path './.git' -o -path './node_modules' -o -path './.pay-verify' \) -prune -o \ + -type f ! -name pnpm-lock.yaml ! -name package-lock.json \ + ! -name yarn.lock ! -name bun.lockb -print0 | \ + xargs -0 grep -IinF "$source" 2>/dev/null | \ + grep -v 'rebrand:keep' || true) + old_paths+=$(find . \ + \( -path './.git' -o -path './node_modules' -o -path './.pay-verify' \) -prune -o \ + -print | grep -iF "$source" || true) + done + if [ -z "$residuals" ] && [ -z "$old_paths" ] && \ + printf '%s\n' "$output" | grep -q 'Verified: no old-brand text or tracked paths remain'; then + log_pass "Tree and script postcondition agree: zero unmarked old-brand survivors" + else + log_fail "Residual postcondition" "no unmarked content/path survivors" \ + "content=[$residuals] paths=[$old_paths] output=[$output]" + fi + + # Same-target idempotence: the intended target is not misreported as the old + # brand merely because rebrand.sh persisted it as the current identity. + key_before=$(git hash-object "$variants") + set +e + output=$(safe_rebrand "$CASE_TARGET_DISPLAY" "test-user" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + key_after=$(git hash-object "$variants") + if [ "$status" -eq 0 ] && [ "$key_before" = "$key_after" ] && \ + printf '%s\n' "$output" | grep -q 'Brand identity already matches'; then + log_pass "Same-target rerun is a clean no-op" + else + log_fail "Same-target rerun" "exit 0, unchanged bytes, explicit no-op" \ + "status=$status before=$key_before after=$key_after output=$output" + fi + + # A different-target rerun before index refresh would silently omit every + # renamed path. It must stop with an actionable error rather than claiming + # success over an incomplete snapshot. + set +e + output=$(safe_rebrand "Second App" "seconduser" "Second desc" --force --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 1 ] && printf '%s\n' "$output" | grep -q "Stage the prior rename with 'git add -A'"; then + log_pass "Different-target rerun rejects stale index paths" + else + log_fail "Stale-index re-rebrand" "exit 1 with git add -A instruction" "status=$status output=$output" + fi + + # Commit-equivalent index refresh, then prove a later re-rebrand can finish. + git add -A >/dev/null 2>&1 + set +e + output=$(safe_rebrand "Second App" "seconduser" "Second desc" --force --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ] && grep -Fqx '# Second App' "$TEMP_DIR/README.md" && \ + [ -f "$TEMP_DIR/src/components/SecondAppLogo.tsx" ] && \ + grep -Fq 'SECONDAPP_TEST_DOMAIN' "$TEMP_DIR/src/config/case-variants.ts" && \ + [ -f "$TEMP_DIR/public/blog-images/second-app-intro/plain.png" ]; then + log_pass "Re-rebrand succeeds after renamed paths are staged" + else + log_fail "Re-rebrand projections" "Second App across prose/code/path" "status=$status output=$output" + fi + + cd "$REPO_ROOT" +} + +test_path_collision_is_atomic() { + run_test "test_path_collision_is_atomic" + setup_temp_dir + set_case_test_identity + + # The uppercase fixture already exists. Its lowercase peer maps to the same + # case-folded destination; a portable rebrand must reject that before content + # writes or mv can overwrite either source. + local lower_peer="docs/${CASE_SOURCE_SLUG}-NOTES.md" + printf '# lowercase sentinel\n' > "$lower_peer" + git add "$lower_peer" >/dev/null 2>&1 + + local output status + set +e + output=$(safe_rebrand "$CASE_TARGET_DISPLAY" "testuser" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + + if [ "$status" -eq 1 ] && printf '%s\n' "$output" | grep -q 'rebrand path collision'; then + log_pass "Path collision fails before mutation" + else + log_fail "Path collision status" "exit 1 with collision diagnostic" "status=$status output=$output" + fi + if grep -Fqx "# $CASE_SOURCE_DISPLAY" README.md && \ + grep -Fqx "# $CASE_SOURCE_UPPER notes" "docs/${CASE_SOURCE_UPPER}-NOTES.md" && \ + grep -Fqx '# lowercase sentinel' "$lower_peer"; then + log_pass "Collision leaves both sources and repository content intact" + else + log_fail "Collision atomicity" "all preflight sources byte-intact" "one or more files changed" + fi + + cd "$REPO_ROOT" +} + +test_existing_target_directory_is_atomic() { + run_test "test_existing_target_directory_is_atomic" + setup_temp_dir + set_case_test_identity + + local target_dir="public/blog-images/${CASE_TARGET_SLUG}-intro" + mkdir -p "$target_dir" + printf 'target sentinel\n' > "$target_dir/sentinel.txt" + + local output status + set +e + output=$(safe_rebrand "$CASE_TARGET_DISPLAY" "testuser" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + + if [ "$status" -eq 1 ] && printf '%s\n' "$output" | grep -q 'rebrand target directory already exists'; then + log_pass "Existing target directory fails before mutation" + else + log_fail "Target directory status" "exit 1 with target-directory diagnostic" "status=$status output=$output" + fi + if grep -Fqx "# $CASE_SOURCE_DISPLAY" README.md && \ + grep -Fqx 'target sentinel' "$target_dir/sentinel.txt" && \ + [ -f "public/blog-images/${CASE_SOURCE_SLUG}-intro/plain.png" ]; then + log_pass "Existing target directory leaves source and target byte-intact" + else + log_fail "Target directory atomicity" "source and target byte-intact" "one or more files changed" + fi + + cd "$REPO_ROOT" +} + +test_residual_gate_is_fatal() { + run_test "test_residual_gate_is_fatal" + setup_temp_dir + set_case_test_identity + + printf '%s FORCE_SURVIVOR\n' "$CASE_SOURCE_TITLE" > src/config/residual.ts + git add src/config/residual.ts >/dev/null 2>&1 + + # Break the shared substitution/path regex itself. The independent verifier + # uses a separate ASCII-folded fixed-string implementation, so it must still + # catch the survivor and make the whole run non-zero. + node - "$TEMP_DIR/scripts/rebrand-case.mjs" <<'NODE' +const fs = require('node:fs'); +const file = process.argv[2]; +const anchor = "`(?:${identity.sources.map(asciiCasePattern).join('|')})`,"; +const source = fs.readFileSync(file, 'utf8'); +if (!source.includes(anchor)) throw new Error('replacement mutation anchor missing'); +fs.writeFileSync(file, source.replace(anchor, "'(?!)',")); +NODE + + local output status + set +e + output=$(safe_rebrand "$CASE_TARGET_DISPLAY" "testuser" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + + if [ "$status" -eq 1 ] && printf '%s\n' "$output" | grep -q 'Old brand remains outside rebrand:keep' && \ + printf '%s\n' "$output" | grep -q 'residual.ts:1'; then + log_pass "Independent residual scan turns a missed variant into a failure" + else + log_fail "Residual gate" "exit 1 with path:line survivor" "status=$status output=$output" + fi + if printf '%s\n' "$output" | grep -q 'REBRAND COMPLETE'; then + log_fail "Residual success suppression" "no success banner after failed postcondition" "$output" + else + log_pass "Failed postcondition never prints REBRAND COMPLETE" + fi + + if grep -Fqx "ORIGINAL_NAME=\"$CASE_SOURCE_DISPLAY\" # rebrand:keep" scripts/rebrand.sh; then + log_pass "Failed postcondition does not publish target identity state" + else + log_fail "Identity commit ordering" "source identity retained after failure" \ + "$(grep '^ORIGINAL_' scripts/rebrand.sh)" + fi + + # Restore the deliberately broken helper and retry the same command. Because + # the first failure happened before path moves and before identity commit, + # this is a real recovery rather than a same-target false success. + cp "$REBRAND_CASE_HELPER" scripts/rebrand-case.mjs + set +e + output=$(safe_rebrand "$CASE_TARGET_DISPLAY" "testuser" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ] && ! grep -qiF "$CASE_SOURCE_TITLE" src/config/residual.ts && \ + printf '%s\n' "$output" | grep -q 'REBRAND COMPLETE'; then + log_pass "Same-command retry repairs a failed residual run" + else + log_fail "Residual retry" "exit 0 with survivor repaired" "status=$status output=$output" + fi + + cd "$REPO_ROOT" +} + +test_source_containing_target_is_atomic() { + run_test "test_source_containing_target_is_atomic" + setup_temp_dir + set_case_test_identity + + local target before after output status + target=$(printf '%s' "$CASE_SOURCE_DISPLAY" | tr '[:upper:]' '[:lower:]') + if [ "$target" = "$CASE_SOURCE_DISPLAY" ]; then + target=$(printf '%s' "$CASE_SOURCE_DISPLAY" | tr '[:lower:]' '[:upper:]') + fi + before=$(git hash-object README.md) + + set +e + output=$(safe_rebrand "$target" "testuser" "Test desc" --force --no-icon 2>&1) + status=$? + set -e + after=$(git hash-object README.md) + + if [ "$status" -eq 1 ] && \ + printf '%s\n' "$output" | grep -Eq 'target identity still contains|automated re-rebrand is unsafe'; then + log_pass "Case-equivalent target is rejected before writes" + else + log_fail "Source-containing target" "exit 1 with identity diagnostic" "status=$status output=$output" + fi + if [ "$before" = "$after" ] && grep -Fqx "# $CASE_SOURCE_DISPLAY" README.md; then + log_pass "Rejected identity leaves repository bytes unchanged" + else + log_fail "Target preflight atomicity" "README byte-intact" "before=$before after=$after" + fi + + cd "$REPO_ROOT" +} + +test_forked_harness_smoke() { + run_test "test_forked_harness_smoke" + setup_temp_dir + + local output status + set +e + output=$(safe_rebrand "HarnessProbe42" "testuser" "Fork harness probe" --force --no-icon 2>&1) # rebrand:keep + status=$? + set -e + + if [ "$status" -eq 0 ] && printf '%s\n' "$output" | grep -q 'REBRAND COMPLETE'; then + log_pass "Forked harness can exercise a distinct safe target" + elif [ "$status" -eq 1 ] && \ + printf '%s\n' "$output" | grep -q 'automated re-rebrand is unsafe'; then + log_pass "Forked harness confirms an intentionally unsupported source identity" + else + log_fail "Forked harness smoke" "clean success or explicit unsafe-identity refusal" \ + "status=$status output=$output" + fi + + cd "$REPO_ROOT" +} + +test_harness_survives_rebrand() { + run_test "test_harness_survives_rebrand" + setup_temp_dir + + mkdir -p tests/rebrand + cp "$REPO_ROOT/tests/rebrand/test-rebrand.sh" tests/rebrand/test-rebrand.sh + chmod +x tests/rebrand/test-rebrand.sh + git add -A >/dev/null 2>&1 + + local output status + set +e + output=$(safe_rebrand "GeoLarp" "testuser" "Fork harness probe" --force --no-icon 2>&1) # rebrand:keep + status=$? + set -e + if [ "$status" -ne 0 ]; then + log_fail "Harness fork setup" "initial rebrand exits 0" "status=$status output=$output" + cd "$REPO_ROOT" + return + fi + + git add -A >/dev/null 2>&1 + set +e + output=$(bash tests/rebrand/test-rebrand.sh 2>&1) + status=$? + set -e + if [ "$status" -eq 0 ] && printf '%s\n' "$output" | grep -q 'Forked harness'; then + log_pass "Rebranded shell harness remains executable and green" + else + log_fail "Harness fork stability" "exit 0 through fork smoke mode" \ + "status=$status output=$output" + fi + + cd "$REPO_ROOT" +} + run_all_tests() { echo "========================================" echo "Rebrand Script Test Suite" @@ -675,16 +1148,41 @@ run_all_tests() { chmod +x "$REBRAND_SCRIPT" fi + local recorded_source + recorded_source=$(sed -n 's/^ORIGINAL_NAME="\([^"]*\)".*/\1/p' "$REBRAND_SCRIPT") + if [ "$recorded_source" != "$UPSTREAM_DISPLAY" ]; then + # The exhaustive fixtures deliberately model the upstream source. Once + # this harness has itself been rebranded, use one state-relative smoke + # instead of replaying transformed fixed expectations as red fork CI. + test_forked_harness_smoke + + echo "" + echo "========================================" + echo "Test Summary" + echo "========================================" + echo -e "Assertions: $TESTS_RUN (across $GROUPS_RUN test groups)" + echo -e "${GREEN}Passed${NC}: $TESTS_PASSED" + echo -e "${RED}Failed${NC}: $TESTS_FAILED" + [ "$TESTS_FAILED" -eq 0 ] || exit 1 + return + fi + test_argument_validation test_help_output_is_complete test_name_sanitization test_dry_run_no_changes test_discovery_is_git_tracked + test_case_preserving_rebrand + test_path_collision_is_atomic + test_existing_target_directory_is_atomic + test_residual_gate_is_fatal + test_source_containing_target_is_atomic test_rerebrand_detection test_attribution_preserved test_brand_icons test_component_identifiers_are_valid test_auth_config_desired_state + test_harness_survives_rebrand echo "" echo "========================================" @@ -736,11 +1234,32 @@ if [ $# -eq 1 ]; then test_discovery_is_git_tracked) test_discovery_is_git_tracked ;; + test_case_preserving_rebrand) + test_case_preserving_rebrand + ;; + test_path_collision_is_atomic) + test_path_collision_is_atomic + ;; + test_existing_target_directory_is_atomic) + test_existing_target_directory_is_atomic + ;; + test_residual_gate_is_fatal) + test_residual_gate_is_fatal + ;; + test_source_containing_target_is_atomic) + test_source_containing_target_is_atomic + ;; + test_harness_survives_rebrand) + test_harness_survives_rebrand + ;; *) echo "Unknown test: $1" exit 1 ;; esac + if [ "$TESTS_FAILED" -gt 0 ]; then + exit 1 + fi else run_all_tests fi