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
+
+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