@1agh/maude 0.58.1 → 0.58.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (119) hide show
  1. package/apps/studio/.ai/cache/_stats.json +1 -1
  2. package/apps/studio/acp/bridge.ts +165 -15
  3. package/apps/studio/annotations-bindings.ts +83 -4
  4. package/apps/studio/api.ts +6 -1
  5. package/apps/studio/bin/_fetch-asset.mjs +169 -5
  6. package/apps/studio/bin/_import-asset.mjs +72 -0
  7. package/apps/studio/bin/_import-figma.mjs +1121 -0
  8. package/apps/studio/bin/_video-playwright.mjs +86 -3
  9. package/apps/studio/bin/import-figma.sh +38 -0
  10. package/apps/studio/bin/read-annotations.mjs +11 -1
  11. package/apps/studio/bun.lock +16 -22
  12. package/apps/studio/canvas-edit.ts +29 -5
  13. package/apps/studio/client/app.jsx +129 -23
  14. package/apps/studio/client/export-center.jsx +42 -4
  15. package/apps/studio/client/panels/ChatPanel.jsx +25 -2
  16. package/apps/studio/client/panels/CloudBar.jsx +92 -1
  17. package/apps/studio/client/panels/FigmaImportPanel.jsx +264 -0
  18. package/apps/studio/client/panels/GitPanel.jsx +26 -6
  19. package/apps/studio/client/panels/SettingsPanel.jsx +181 -0
  20. package/apps/studio/client/panels/SetupChecklist.jsx +26 -2
  21. package/apps/studio/client/panels/TimelinePanel.jsx +2 -2
  22. package/apps/studio/client/panels/timeline-parse.js +3 -3
  23. package/apps/studio/client/styles/3-shell-maude.css +7 -0
  24. package/apps/studio/client/styles/4-components.css +134 -0
  25. package/apps/studio/client/styles/6-acp-chat.css +12 -0
  26. package/apps/studio/clip-ops.ts +93 -17
  27. package/apps/studio/cloud/endpoints.ts +78 -10
  28. package/apps/studio/cloud/renew.ts +183 -0
  29. package/apps/studio/context.ts +2 -1
  30. package/apps/studio/dist/client.bundle.js +1491 -1491
  31. package/apps/studio/dist/runtime/@remotion_media.js +56 -136
  32. package/apps/studio/dist/runtime/@remotion_player.js +18 -18
  33. package/apps/studio/dist/runtime/@remotion_transitions.js +9 -9
  34. package/apps/studio/dist/runtime/@remotion_transitions_clock-wipe.js +1 -1
  35. package/apps/studio/dist/runtime/remotion.js +12 -12
  36. package/apps/studio/dist/styles.css +1 -1
  37. package/apps/studio/exporters/_browser-bundles.ts +20 -6
  38. package/apps/studio/exporters/_runtime.ts +19 -0
  39. package/apps/studio/exporters/degraded.ts +92 -0
  40. package/apps/studio/exporters/index.ts +5 -0
  41. package/apps/studio/exporters/jobs.ts +19 -0
  42. package/apps/studio/exporters/unsupported-media.ts +170 -0
  43. package/apps/studio/exporters/video-encode-lib.ts +27 -1
  44. package/apps/studio/exporters/video-render-lib.ts +6 -0
  45. package/apps/studio/exporters/video.ts +62 -1
  46. package/apps/studio/figma/assets.test.ts +372 -0
  47. package/apps/studio/figma/assets.ts +398 -0
  48. package/apps/studio/figma/client.test.ts +395 -0
  49. package/apps/studio/figma/client.ts +513 -0
  50. package/apps/studio/figma/comments-to-strokes.test.ts +194 -0
  51. package/apps/studio/figma/comments-to-strokes.ts +173 -0
  52. package/apps/studio/figma/endpoints.ts +200 -0
  53. package/apps/studio/figma/sanitize.test.ts +256 -0
  54. package/apps/studio/figma/sanitize.ts +315 -0
  55. package/apps/studio/figma/style-map.ts +352 -0
  56. package/apps/studio/figma/to-artboard.test.ts +808 -0
  57. package/apps/studio/figma/to-artboard.ts +701 -0
  58. package/apps/studio/figma/to-render.test.ts +180 -0
  59. package/apps/studio/figma/to-render.ts +306 -0
  60. package/apps/studio/figma/to-strokes-roundtrip.test.ts +152 -0
  61. package/apps/studio/figma/to-strokes.test.ts +705 -0
  62. package/apps/studio/figma/to-strokes.ts +749 -0
  63. package/apps/studio/figma/to-tokens.test.ts +321 -0
  64. package/apps/studio/figma/to-tokens.ts +305 -0
  65. package/apps/studio/figma/types.ts +539 -0
  66. package/apps/studio/figma/url.test.ts +167 -0
  67. package/apps/studio/figma/url.ts +160 -0
  68. package/apps/studio/http.ts +129 -0
  69. package/apps/studio/sync/asset-push.ts +124 -0
  70. package/apps/studio/sync/canvas-path.ts +329 -0
  71. package/apps/studio/sync/codec.ts +42 -0
  72. package/apps/studio/sync/connection-state.ts +11 -0
  73. package/apps/studio/sync/hub-link.ts +63 -7
  74. package/apps/studio/sync/hubs-config.ts +31 -3
  75. package/apps/studio/sync/index.ts +755 -32
  76. package/apps/studio/sync/migrate-flat-fallback.ts +121 -0
  77. package/apps/studio/sync/presentation.ts +45 -1
  78. package/apps/studio/sync/projection.ts +11 -1
  79. package/apps/studio/sync/remote-docs.ts +122 -15
  80. package/apps/studio/sync/supervisor.ts +5 -1
  81. package/apps/studio/sync/workspace-signin.ts +7 -3
  82. package/apps/studio/test/acp-bridge-lifetime.test.ts +106 -0
  83. package/apps/studio/test/annotations-bindings.test.ts +150 -12
  84. package/apps/studio/test/canvas-create-api.test.ts +4 -1
  85. package/apps/studio/test/canvas-origin-gate.test.ts +13 -0
  86. package/apps/studio/test/capture-determinism-shape.test.ts +135 -0
  87. package/apps/studio/test/clip-addressing.test.ts +6 -1
  88. package/apps/studio/test/clip-ops.test.ts +5 -1
  89. package/apps/studio/test/cloud-endpoints.test.ts +96 -0
  90. package/apps/studio/test/cloud-renew.test.ts +205 -0
  91. package/apps/studio/test/cloud-shell-surfaces.test.ts +11 -2
  92. package/apps/studio/test/comment-relay-origin-gate.test.ts +117 -0
  93. package/apps/studio/test/comments-fs-rebroadcast.test.ts +155 -0
  94. package/apps/studio/test/exporters/degraded-propagation.test.ts +123 -0
  95. package/apps/studio/test/exporters/unsupported-media.test.ts +123 -0
  96. package/apps/studio/test/fetch-asset-gate.test.ts +189 -0
  97. package/apps/studio/test/figma-provenance.test.ts +108 -0
  98. package/apps/studio/test/figma-routes.test.ts +294 -0
  99. package/apps/studio/test/fixtures/mock-acp-agent-wedged.mjs +37 -0
  100. package/apps/studio/test/git-cloud-posture.test.ts +50 -0
  101. package/apps/studio/test/hub-link.test.ts +11 -0
  102. package/apps/studio/test/import-figma.test.ts +479 -0
  103. package/apps/studio/test/sync-asset-push.test.ts +124 -0
  104. package/apps/studio/test/sync-canvas-path.test.ts +200 -0
  105. package/apps/studio/test/sync-connection-state.test.ts +13 -0
  106. package/apps/studio/test/sync-hubs-config.test.ts +5 -0
  107. package/apps/studio/test/sync-migrate-flat-fallback.test.ts +98 -0
  108. package/apps/studio/test/sync-path-pull.test.ts +465 -0
  109. package/apps/studio/test/sync-presentation.test.ts +77 -0
  110. package/apps/studio/test/sync-remote-docs.test.ts +55 -3
  111. package/apps/studio/test/sync-runtime.test.ts +434 -1
  112. package/apps/studio/test/video-comp.test.ts +23 -1
  113. package/apps/studio/test/workspace-containment.test.ts +1 -0
  114. package/apps/studio/video-comp.tsx +70 -6
  115. package/apps/studio/whats-new.json +36 -0
  116. package/apps/studio/workspace-mode.ts +4 -0
  117. package/cli/commands/design.mjs +8 -0
  118. package/package.json +8 -8
  119. package/plugins/flow/.claude-plugin/config.schema.json +3 -3
@@ -0,0 +1,200 @@
1
+ // The traversal corpus, and the rule that makes the whole thing safe.
2
+ //
3
+ // `syncMeta.path` is hub-supplied input that chooses a DIRECTORY. Every case
4
+ // below is a way a path could try to be something other than "a canvas in this
5
+ // project", plus the one rule that closes the class rather than the instance:
6
+ // the path must slug back to the document carrying it.
7
+
8
+ import { describe, expect, it } from 'bun:test';
9
+
10
+ import { canvasSlugFromRel } from '../canvas-slug.ts';
11
+ import {
12
+ fallbackCanvasPath,
13
+ MAX_CANVAS_PATH_LEN,
14
+ resolveCanvasBodyRel,
15
+ validateCanvasPath,
16
+ } from '../sync/canvas-path.ts';
17
+
18
+ const GROUPS = [{ path: 'system' }, { path: 'ui' }];
19
+
20
+ /** Validate a path AGAINST THE SLUG IT WOULD REALLY CARRY, so a rejection is
21
+ * attributable to the rule under test and not to rule 7 firing first. */
22
+ function check(path: unknown, slug: string) {
23
+ return validateCanvasPath({ path, slug, designRel: '.design', canvasGroups: GROUPS });
24
+ }
25
+
26
+ describe('validateCanvasPath — what it accepts', () => {
27
+ it('accepts a genuinely nested canvas', () => {
28
+ const v = check('ui/2026/social/summer-camp.tsx', 'ui-2026-social-summer-camp');
29
+ expect(v).toEqual({ ok: true, rel: 'ui/2026/social/summer-camp.tsx' });
30
+ });
31
+
32
+ it('accepts a canvas directly in a group', () => {
33
+ expect(check('ui/Card.tsx', 'ui-card').ok).toBe(true);
34
+ });
35
+
36
+ it('accepts the design-system group', () => {
37
+ expect(check('system/preview/logo.tsx', 'system-preview-logo').ok).toBe(true);
38
+ });
39
+
40
+ it('accepts a filename with a space — they exist and they sync today', () => {
41
+ // The slug transform maps whitespace to `_`, so `Kanban App.tsx` is a real
42
+ // canvas with a real document. Refusing it here would silently relocate a
43
+ // file that works today.
44
+ expect(check('ui/Kanban App.tsx', 'ui-kanban_app').ok).toBe(true);
45
+ });
46
+
47
+ it('is case-insensitive about the slug, like the slug transform', () => {
48
+ expect(check('ui/Card.tsx', 'UI-CARD').ok).toBe(true);
49
+ });
50
+ });
51
+
52
+ describe('validateCanvasPath — the traversal corpus', () => {
53
+ const corpus: Array<[string, unknown, string]> = [
54
+ ['parent traversal', '../../etc/passwd.tsx', 'anything'],
55
+ ['traversal inside a group', 'ui/../../../tmp/x.tsx', 'anything'],
56
+ ['a bare dot component', 'ui/./Card.tsx', 'ui-card'],
57
+ ['absolute posix', '/etc/cron.d/x.tsx', 'anything'],
58
+ ['windows drive', 'C:\\Windows\\x.tsx', 'anything'],
59
+ ['UNC share', '\\\\host\\share\\x.tsx', 'anything'],
60
+ ['a backslash smuggling a separator', 'ui\\..\\..\\x.tsx', 'anything'],
61
+ ['an empty component', 'ui//Card.tsx', 'ui-card'],
62
+ ['a trailing slash', 'ui/Card.tsx/', 'ui-card'],
63
+ ['a NUL byte', 'ui/Card.tsx\u0000.png', 'ui-card'],
64
+ ['a newline', 'ui/Ca\nrd.tsx', 'ui-card'],
65
+ ['percent-encoded traversal', 'ui/%2e%2e/%2e%2e/x.tsx', 'anything'],
66
+ ['a unicode dot lookalike as a component', 'ui/\u2024\u2024/x.tsx', 'anything'],
67
+ ['a bidi override in a component', 'ui/Ca\u202erd.tsx', 'ui-card'],
68
+ ['not a .tsx', 'ui/Card.html', 'ui-card'],
69
+ ['no extension at all', 'ui/Card', 'ui-card'],
70
+ ['an extra extension', 'ui/Card.tsx.png', 'ui-card'],
71
+ ['a dotfile', 'ui/.env.tsx', 'anything'],
72
+ ['at the design root, in no group', 'ui-card.tsx', 'ui-card'],
73
+ ['outside every declared group', 'assets/Card.tsx', 'assets-card'],
74
+ ['not a string', 42, 'anything'],
75
+ ['an object', { toString: () => 'ui/Card.tsx' }, 'ui-card'],
76
+ ['the empty string', '', 'anything'],
77
+ ];
78
+
79
+ for (const [label, path, slug] of corpus) {
80
+ it(`refuses ${label}`, () => {
81
+ const v = validateCanvasPath({ path, slug, designRel: '.design', canvasGroups: GROUPS });
82
+ expect(v.ok).toBe(false);
83
+ });
84
+ }
85
+
86
+ it('refuses a path longer than the cap', () => {
87
+ const long = `ui/${'a'.repeat(MAX_CANVAS_PATH_LEN)}.tsx`;
88
+ expect(validateCanvasPath({ path: long, slug: 'x', canvasGroups: GROUPS }).ok).toBe(false);
89
+ });
90
+ });
91
+
92
+ describe('rule 7 — a path that points elsewhere no longer addresses this document', () => {
93
+ it('refuses a well-formed path belonging to a DIFFERENT canvas', () => {
94
+ // The one that matters. Every rule above refuses a MALFORMED path; this
95
+ // refuses a perfectly legal one that simply is not this document's. Without
96
+ // it, any peer could overwrite any other canvas in the project by pushing a
97
+ // body under its own document name and someone else's path.
98
+ const v = check('ui/secrets/credentials.tsx', 'ui-card');
99
+ expect(v.ok).toBe(false);
100
+ if (!v.ok) expect(v.reason).toContain('slugs to');
101
+ });
102
+
103
+ it('refuses a path that only differs in a folder boundary', () => {
104
+ // `ui/a-b.tsx` and `ui/a/b.tsx` slug identically (`ui-a-b`) — so this pair
105
+ // is ACCEPTED by rule 7 on purpose: the collision is the slug's, it predates
106
+ // this feature, and `admitCanvases` already refuses to sync both files.
107
+ expect(check('ui/a/b.tsx', 'ui-a-b').ok).toBe(true);
108
+ expect(check('ui/a-b.tsx', 'ui-a-b').ok).toBe(true);
109
+ // What is NOT accepted is a path whose slug simply differs.
110
+ expect(check('ui/a/c.tsx', 'ui-a-b').ok).toBe(false);
111
+ });
112
+ });
113
+
114
+ describe('fallbackCanvasPath — visible, and still the same document', () => {
115
+ it('places an un-pathed canvas inside the group its slug came from', () => {
116
+ expect(fallbackCanvasPath('ui-legacy', GROUPS)).toBe('ui/legacy.tsx');
117
+ });
118
+
119
+ it('the result slugs BACK to the document — a fallback must not fork it', () => {
120
+ // The property, not the string. `ui/ui-legacy.tsx` would be visible and
121
+ // would slug to `ui-ui-legacy`: a second document syncing the same bytes,
122
+ // with the original orphaned on the hub.
123
+ for (const slug of ['ui-legacy', 'ui-2026-social-summer-camp', 'system-preview-logo']) {
124
+ expect(canvasSlugFromRel(fallbackCanvasPath(slug, GROUPS), '.design')).toBe(slug);
125
+ }
126
+ });
127
+
128
+ it('picks the group the slug actually came from, not the first one', () => {
129
+ const groups = [{ path: 'ui' }, { path: 'screens' }];
130
+ expect(fallbackCanvasPath('screens-x', groups)).toBe('screens/x.tsx');
131
+ });
132
+
133
+ it('prefers the LONGEST matching group over declaration order', () => {
134
+ const groups = [{ path: 'ui' }, { path: 'ui/social' }];
135
+ expect(fallbackCanvasPath('ui-social-x', groups)).toBe('ui/social/x.tsx');
136
+ });
137
+
138
+ it('keeps the design root when no declared group matches the slug', () => {
139
+ // Today's behaviour, deliberately: it is invisible, but it is the SAME
140
+ // document. A canvas that was outside every group at its author cannot be
141
+ // placed inside one without renaming it — which is what a path is for.
142
+ expect(fallbackCanvasPath('welcome', GROUPS)).toBe('welcome.tsx');
143
+ expect(fallbackCanvasPath('x', [])).toBe('x.tsx');
144
+ });
145
+
146
+ it('drops a group path that escapes the design root', () => {
147
+ expect(fallbackCanvasPath('etc-x', [{ path: '../../etc' }])).toBe('etc-x.tsx');
148
+ });
149
+
150
+ it('never produces a separator, a dotfile or a `..` from the slug', () => {
151
+ // The slug is charset-constrained upstream (`slugFromDocName`), so this is
152
+ // the second line rather than the first — but it is the line that stands
153
+ // between a bad slug and a directory of the sender's choosing.
154
+ for (const hostile of ['../../evil', '/etc/passwd', '..', '.', 'a\\b', '']) {
155
+ const rel = fallbackCanvasPath(hostile, GROUPS);
156
+ expect(rel).not.toContain('/');
157
+ expect(rel).not.toContain('\\');
158
+ expect(rel).not.toContain('..');
159
+ expect(rel.startsWith('.')).toBe(false);
160
+ expect(rel.endsWith('.tsx')).toBe(true);
161
+ }
162
+ });
163
+ });
164
+
165
+ describe('resolveCanvasBodyRel — one answer for "what does a refused path do"', () => {
166
+ it('uses the path when it validates', () => {
167
+ expect(
168
+ resolveCanvasBodyRel({
169
+ path: 'ui/2026/social/summer-camp.tsx',
170
+ slug: 'ui-2026-social-summer-camp',
171
+ designRel: '.design',
172
+ canvasGroups: GROUPS,
173
+ })
174
+ ).toEqual({ rel: 'ui/2026/social/summer-camp.tsx', fromPath: true });
175
+ });
176
+
177
+ it('falls back — never throws the canvas away — and says why', () => {
178
+ const reasons: string[] = [];
179
+ const out = resolveCanvasBodyRel({
180
+ path: '../../etc/passwd.tsx',
181
+ slug: 'ui-card',
182
+ canvasGroups: GROUPS,
183
+ onRefused: (r) => reasons.push(r),
184
+ });
185
+ expect(out).toEqual({ rel: 'ui/card.tsx', fromPath: false });
186
+ expect(reasons.length).toBe(1);
187
+ });
188
+
189
+ it('an ABSENT path is not a refusal — an older peer omits it', () => {
190
+ const reasons: string[] = [];
191
+ const out = resolveCanvasBodyRel({
192
+ path: undefined,
193
+ slug: 'ui-card',
194
+ canvasGroups: GROUPS,
195
+ onRefused: (r) => reasons.push(r),
196
+ });
197
+ expect(out).toEqual({ rel: 'ui/card.tsx', fromPath: false });
198
+ expect(reasons).toEqual([]);
199
+ });
200
+ });
@@ -61,6 +61,19 @@ describe('connection monitor', () => {
61
61
  expect(monitor.snapshot().state).toBe('online');
62
62
  });
63
63
 
64
+ test('the snapshot carries a stable startedAt — the clock a stall is measured against', () => {
65
+ const { clock, monitor } = makeMonitor();
66
+ const born = monitor.snapshot().startedAt;
67
+ expect(typeof born).toBe('number');
68
+ // It must NOT move with later emits — `updatedAt` does that job, and a
69
+ // rejection storm refreshing `updatedAt` forever is exactly why a second,
70
+ // fixed stamp exists.
71
+ clock.advance(60_000);
72
+ monitor.noteProviderStatus('p1', 'connecting');
73
+ expect(monitor.snapshot().startedAt).toBe(born);
74
+ expect(monitor.snapshot().updatedAt).toBeGreaterThan(born as number);
75
+ });
76
+
64
77
  test('a link nobody answers still escalates to offline', () => {
65
78
  // The seed change must not create a state that waits forever: a provider
66
79
  // that only ever reports 'connecting' has to reach the offline banner on
@@ -44,6 +44,11 @@ describe('normalizeUrl', () => {
44
44
  test('preserves non-root path case', () => {
45
45
  expect(normalizeUrl('https://Hub.example.com/Path')).toBe('https://hub.example.com/Path');
46
46
  });
47
+
48
+ test('rejects embedded credentials (claim-a hardening — user:pass@ survives toString)', () => {
49
+ expect(() => normalizeUrl('https://evil@proj.cloud.maude.sh')).toThrow(/embedded credentials/);
50
+ expect(() => normalizeUrl('https://u:p@proj.cloud.maude.sh')).toThrow(/embedded credentials/);
51
+ });
47
52
  });
48
53
 
49
54
  // Write the test hubs.json with mode 0600 so the DDR-054 §2h mode-warning
@@ -0,0 +1,98 @@
1
+ // Fix 4 of the 2026-08-10 sync RCA — the one-shot quarantine of pre-fix-5
2
+ // flat fallback twins. The rules under test: a flat body moves ONLY when a
3
+ // grouped twin of the same slug exists; siblings ride along; annotations stay
4
+ // (they serve the surviving twin); a lone flat canvas is somebody's work and
5
+ // never moves; a second run is a no-op; nothing ever throws into boot.
6
+
7
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
8
+ import { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs';
9
+ import { tmpdir } from 'node:os';
10
+ import { join } from 'node:path';
11
+
12
+ import { migrateFlatFallback } from '../sync/migrate-flat-fallback.ts';
13
+
14
+ let designRoot: string;
15
+
16
+ beforeEach(() => {
17
+ designRoot = mkdtempSync(join(tmpdir(), 'migrate-flat-'));
18
+ });
19
+
20
+ afterEach(() => {
21
+ rmSync(designRoot, { recursive: true, force: true });
22
+ });
23
+
24
+ const run = () => migrateFlatFallback({ designRoot, designRel: '.design', log: () => {} });
25
+
26
+ describe('migrateFlatFallback — quarantine the redundant flat copy, never work', () => {
27
+ test('a collision pair: the flat copy (+ siblings) moves to _trash, the grouped twin stays', () => {
28
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
29
+ writeFileSync(join(designRoot, 'ui/welcome.tsx'), 'grouped\n');
30
+ writeFileSync(join(designRoot, 'ui-welcome.tsx'), 'flat stub\n');
31
+ writeFileSync(join(designRoot, 'ui-welcome.meta.json'), '{}\n');
32
+ writeFileSync(join(designRoot, 'ui-welcome.css'), '.x{}\n');
33
+ // Annotations are keyed by the flat slug and serve the SURVIVING twin.
34
+ writeFileSync(join(designRoot, 'ui-welcome.annotations.svg'), '<svg/>\n');
35
+
36
+ const moves = run();
37
+
38
+ expect(moves).toHaveLength(1);
39
+ expect(moves[0].slug).toBe('ui-welcome');
40
+ expect(moves[0].from).toBe('ui-welcome.tsx');
41
+ expect(moves[0].keptTwin).toBe('ui/welcome.tsx');
42
+
43
+ expect(existsSync(join(designRoot, 'ui/welcome.tsx'))).toBe(true);
44
+ expect(existsSync(join(designRoot, 'ui-welcome.tsx'))).toBe(false);
45
+ expect(existsSync(join(designRoot, 'ui-welcome.meta.json'))).toBe(false);
46
+ expect(existsSync(join(designRoot, 'ui-welcome.css'))).toBe(false);
47
+ expect(existsSync(join(designRoot, 'ui-welcome.annotations.svg'))).toBe(true);
48
+
49
+ const trashed = readdirSync(join(designRoot, moves[0].trashedTo)).sort();
50
+ expect(trashed).toEqual(['ui-welcome.css', 'ui-welcome.meta.json', 'ui-welcome.tsx']);
51
+ });
52
+
53
+ test('a lone flat canvas has no twin — it is somebody’s work and stays put', () => {
54
+ writeFileSync(join(designRoot, 'scratch-pad.tsx'), 'mine\n');
55
+ expect(run()).toEqual([]);
56
+ expect(existsSync(join(designRoot, 'scratch-pad.tsx'))).toBe(true);
57
+ });
58
+
59
+ test('a grouped file with no flat twin is untouched', () => {
60
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
61
+ writeFileSync(join(designRoot, 'ui/card.tsx'), 'x\n');
62
+ expect(run()).toEqual([]);
63
+ expect(existsSync(join(designRoot, 'ui/card.tsx'))).toBe(true);
64
+ });
65
+
66
+ test('the second run is a no-op — the quarantine dir is runtime state the scan skips', () => {
67
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
68
+ writeFileSync(join(designRoot, 'ui/welcome.tsx'), 'grouped\n');
69
+ writeFileSync(join(designRoot, 'ui-welcome.tsx'), 'flat stub\n');
70
+
71
+ expect(run()).toHaveLength(1);
72
+ expect(run()).toEqual([]);
73
+ // The quarantined copy is still there — moved, never deleted.
74
+ const trash = readdirSync(join(designRoot, '_trash'));
75
+ expect(trash).toHaveLength(1);
76
+ });
77
+
78
+ test('nested twins at any depth count', () => {
79
+ mkdirSync(join(designRoot, 'ui/2026/social'), { recursive: true });
80
+ writeFileSync(join(designRoot, 'ui/2026/social/summer-camp.tsx'), 'deep\n');
81
+ writeFileSync(join(designRoot, 'ui-2026-social-summer-camp.tsx'), 'flat stub\n');
82
+
83
+ const moves = run();
84
+ expect(moves).toHaveLength(1);
85
+ expect(moves[0].keptTwin).toBe('ui/2026/social/summer-camp.tsx');
86
+ expect(existsSync(join(designRoot, 'ui/2026/social/summer-camp.tsx'))).toBe(true);
87
+ });
88
+
89
+ test('a missing design root is a quiet no-op, not a throw', () => {
90
+ expect(
91
+ migrateFlatFallback({
92
+ designRoot: join(designRoot, 'does-not-exist'),
93
+ designRel: '.design',
94
+ log: () => {},
95
+ })
96
+ ).toEqual([]);
97
+ });
98
+ });