Skip to content

Commit e25e300

Browse files
os-zhuangclaude
andauthored
fix(scripts): shadcn-sync refuses to silently delete local edits, and compiles the package after it writes (#3035)
#3033 stopped the sync writing unresolvable import paths. It did not make `--update-all` safe to run: the other failure mode is overwriting local edits, and I was wrong about how that shows up. I expected a loud type error. It is silent. `command.tsx` is the worked example. It carries local additions upstream has never had — a `sr-only` `DialogTitle`/`DialogDescription` on `CommandDialog` (Radix requires an accessible name) and `contentProps`, which is the ADR-0054 C4 testability contract letting callers attach a stable locator to the dialog element. Syncing it deletes 38 lines, removes both, and **type-checks clean**, because upstream drops each addition together with its usages. Nothing downstream notices. So two layers, not one: **Refuse before writing.** Any line the local file has that the incoming version does not is reported, and the write is skipped. Comments count — a comment explaining why we diverged is exactly what must not evaporate. `--force` overrides. Measured across all 46 registry entries this discriminates rather than crying wolf: 29 have no local-only lines and sync cleanly, 17 are protected. `--update-all` now reports skipped separately from failed. **Compile after writing.** `--update` and `--update-all` then run `pnpm --filter @object-ui/components type-check` and say plainly whether the package still builds, with the rollback command on failure. `--no-verify` skips it. This is the backstop for whatever the first layer does not model — it would not have caught `command`, which is precisely why the first layer exists. Reporting only; neither layer rolls back for you. Auto-reverting would throw away a sync someone may want to fix by hand. Verified end to end: `--update command` refuses and lists the ADR-0054 lines; `--force` overrides it; `--update button` (0 local-only lines) syncs and leaves the tree byte-identical; the #3033 unmapped-path guard and the `resizable` reclassification both still hold. Tree restored — no component file changes here. Co-authored-by: Jack Zhuang <277994282+os-zhuang@users.noreply.github.com> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
1 parent d2b7215 commit e25e300

1 file changed

Lines changed: 137 additions & 9 deletions

File tree

scripts/shadcn-sync.js

Lines changed: 137 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,12 +16,15 @@
1616
* --diff <name> Show detailed diff for a component
1717
* --list List all components
1818
* --backup Create backup before updating
19+
* --force Overwrite even when local edits would be lost
20+
* --no-verify Skip the type-check that runs after an update
1921
*/
2022

2123
import fs from 'fs/promises';
2224
import path from 'path';
2325
import { fileURLToPath } from 'url';
2426
import https from 'https';
27+
import { spawnSync } from 'child_process';
2528

2629
const __filename = fileURLToPath(import.meta.url);
2730
const __dirname = path.dirname(__filename);
@@ -133,6 +136,85 @@ function findUnmappedAliases(content) {
133136
return [...new Set([...content.matchAll(/["'](@\/[^"']+)["']/g)].map((m) => m[1]))];
134137
}
135138

139+
/** The header `updateComponent` prepends, so it isn't counted as a local edit. */
140+
const OBJECTUI_HEADER = /^\/\*\*\n \* ObjectUI\n(?: \*.*\n)* \*\/\n+/;
141+
142+
/**
143+
* Lines the local file has that the incoming upstream version does not.
144+
*
145+
* These are what an overwrite destroys, and a type-check cannot see them:
146+
* upstream drops a local addition *and* its usages consistently, so the result
147+
* still compiles. `command.tsx` is the worked example — it carries a local
148+
* `sr-only` `DialogTitle`/`DialogDescription` on `CommandDialog` (Radix
149+
* requires an accessible name). Syncing it deletes 38 lines, removes the
150+
* accessibility fix, and type-checks clean.
151+
*
152+
* Comments count. A local comment explaining why we diverged is exactly the
153+
* kind of thing that must not evaporate silently.
154+
*
155+
* Measured across the 46 registry entries: 29 have none of these and sync
156+
* cleanly; 17 do. So this discriminates rather than crying wolf.
157+
*/
158+
function localOnlyLines(localContent, upstreamContent) {
159+
const norm = (t) =>
160+
t
161+
.replace(OBJECTUI_HEADER, '')
162+
.split('\n')
163+
.map((l) => l.trim())
164+
.filter(Boolean);
165+
const upstream = new Set(norm(upstreamContent));
166+
return norm(localContent).filter((l) => !upstream.has(l));
167+
}
168+
169+
/**
170+
* Type-check the package after writing to it, and say plainly whether the sync
171+
* left it compiling.
172+
*
173+
* The `findUnmappedAliases` guard above only catches import paths. The other
174+
* way a sync breaks things is by discarding local edits: several components
175+
* have picked up named imports upstream does not have (`command` added
176+
* `DialogTitle`/`DialogDescription`, `sonner` added `toast`), and overwriting
177+
* them wholesale drops those. That surfaces as a type error, not a bad path —
178+
* so nothing short of actually compiling will catch it.
179+
*
180+
* Reporting only. Rolling back automatically would throw away a sync the user
181+
* may want to fix by hand, so this prints the command instead.
182+
*/
183+
function verifyPackageCompiles({ backup } = {}) {
184+
logSection('Verifying the package still compiles');
185+
log('Running: pnpm --filter @object-ui/components type-check', 'dim');
186+
log('(skip with --no-verify)\n', 'dim');
187+
188+
const result = spawnSync('pnpm', ['--filter', '@object-ui/components', 'type-check'], {
189+
cwd: REPO_ROOT,
190+
stdio: 'inherit',
191+
encoding: 'utf-8',
192+
});
193+
194+
if (result.error) {
195+
log(`\n⚠ Could not run the type-check: ${result.error.message}`, 'yellow');
196+
log(' Verify by hand before committing.', 'yellow');
197+
return null;
198+
}
199+
200+
if (result.status === 0) {
201+
log('\n✓ Type-check passed — the synced components still compile.', 'green');
202+
return true;
203+
}
204+
205+
log(`\n✗ Type-check FAILED (exit ${result.status}). The sync broke the package.`, 'red');
206+
log(' Most likely a local edit was overwritten — upstream does not carry every', 'yellow');
207+
log(' named export we import (e.g. command/DialogTitle, sonner/toast).', 'yellow');
208+
log('\n To roll back everything this run wrote:', 'cyan');
209+
log(' git checkout -- packages/components/src/ui/', 'dim');
210+
if (backup) {
211+
log(` Backups from this run are also in ${path.relative(REPO_ROOT, BACKUP_DIR)}/`, 'dim');
212+
} else {
213+
log(' (Re-run with --backup next time to keep copies outside git.)', 'dim');
214+
}
215+
return false;
216+
}
217+
136218
async function checkComponent(name, manifest) {
137219
const componentInfo = manifest.components[name];
138220
if (!componentInfo) {
@@ -294,6 +376,29 @@ async function updateComponent(name, manifest, options = {}) {
294376
return false;
295377
}
296378

379+
// Don't clobber local edits silently. A type-check will not catch this —
380+
// see localOnlyLines — so refusing is the only thing that makes the loss
381+
// a decision rather than an accident.
382+
const targetPathForCheck = path.join(COMPONENTS_DIR, `${name}.tsx`);
383+
let existing = null;
384+
try {
385+
existing = await fs.readFile(targetPathForCheck, 'utf-8');
386+
} catch {
387+
/* new component — nothing to lose */
388+
}
389+
390+
if (existing && !options.force) {
391+
const lost = localOnlyLines(existing, content);
392+
if (lost.length > 0) {
393+
log(`✗ Refusing to overwrite ${name}.tsx — ${lost.length} local line(s) upstream does not have:`, 'red');
394+
lost.slice(0, 8).forEach((l) => log(` ${l.length > 96 ? l.slice(0, 96) + '…' : l}`, 'dim'));
395+
if (lost.length > 8) log(` … and ${lost.length - 8} more`, 'dim');
396+
log(` Overwriting deletes these. Port them onto the new upstream version by hand,`, 'yellow');
397+
log(` or re-run with --force if they are genuinely obsolete.`, 'yellow');
398+
return 'skipped';
399+
}
400+
}
401+
297402
// Add ObjectUI header
298403
const header = `/**
299404
* ObjectUI
@@ -344,30 +449,44 @@ async function updateAllComponents(options = {}) {
344449
}
345450

346451
let updated = 0;
452+
let skipped = 0;
347453
let failed = 0;
348-
454+
349455
for (const name of componentNames) {
350-
const success = await updateComponent(name, manifest, options);
351-
if (success) {
456+
const result = await updateComponent(name, manifest, options);
457+
if (result === true) {
352458
updated++;
459+
} else if (result === 'skipped') {
460+
skipped++;
353461
} else {
354462
failed++;
355463
}
356-
464+
357465
// Small delay to avoid rate limiting
358466
await new Promise(resolve => setTimeout(resolve, 100));
359467
}
360-
468+
361469
logSection('Update Complete');
362470
log(`✓ Updated: ${updated} components`, 'green');
471+
if (skipped > 0) {
472+
log(`⊘ Skipped: ${skipped} components (local edits would be lost — see above)`, 'yellow');
473+
log(` Port those edits by hand, or re-run with --force to take upstream as-is.`, 'dim');
474+
}
363475
if (failed > 0) {
364476
log(`✗ Failed: ${failed} components`, 'red');
365477
}
366478

479+
// Only worth compiling if something was actually written.
480+
const compiles = updated > 0 && options.verify !== false ? verifyPackageCompiles(options) : undefined;
481+
367482
log('\nNext steps:', 'cyan');
368483
log('1. Review the changes: git diff packages/components/src/ui/', 'dim');
369-
log('2. Test the components: pnpm test', 'dim');
370-
log('3. Build the package: pnpm --filter @object-ui/components build', 'dim');
484+
if (compiles === false) {
485+
log('2. Fix or roll back — the type-check above failed.', 'dim');
486+
} else {
487+
log('2. Test the components: pnpm test', 'dim');
488+
log('3. Build the package: pnpm --filter @object-ui/components build', 'dim');
489+
}
371490
}
372491

373492
async function showDiff(name) {
@@ -431,7 +550,9 @@ async function main() {
431550
await checkAllComponents();
432551
} else if (args.includes('--update-all')) {
433552
const backup = args.includes('--backup');
434-
await updateAllComponents({ backup });
553+
const verify = !args.includes('--no-verify');
554+
const force = args.includes('--force');
555+
await updateAllComponents({ backup, verify, force });
435556
} else if (args.includes('--update')) {
436557
const idx = args.indexOf('--update');
437558
const componentName = args[idx + 1];
@@ -441,7 +562,12 @@ async function main() {
441562
}
442563
const manifest = await loadManifest();
443564
const backup = args.includes('--backup');
444-
await updateComponent(componentName, manifest, { backup });
565+
const verify = !args.includes('--no-verify');
566+
const force = args.includes('--force');
567+
const written = await updateComponent(componentName, manifest, { backup, force });
568+
// A single component can break the package just as thoroughly as a bulk
569+
// run, so it gets the same compile check.
570+
if (written === true && verify) verifyPackageCompiles({ backup });
445571
} else if (args.includes('--diff')) {
446572
const idx = args.indexOf('--diff');
447573
const componentName = args[idx + 1];
@@ -462,6 +588,8 @@ async function main() {
462588
console.log(' --diff <name> Show detailed diff for a component');
463589
console.log(' --list List all components');
464590
console.log(' --backup Create backup before updating');
591+
console.log(' --force Overwrite even if local edits would be lost');
592+
console.log(' --no-verify Skip the post-update type-check');
465593
console.log(' --help, -h Show this help message\n');
466594
} else {
467595
log('Unknown option. Use --help for usage information.', 'red');

0 commit comments

Comments
 (0)