@1agh/maude 0.60.2 → 0.60.4

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 (38) hide show
  1. package/apps/studio/api.ts +14 -0
  2. package/apps/studio/client/app.jsx +44 -9
  3. package/apps/studio/client/panels/SyncPanel.jsx +53 -11
  4. package/apps/studio/context.ts +9 -0
  5. package/apps/studio/dist/client.bundle.js +525 -525
  6. package/apps/studio/http.ts +48 -7
  7. package/apps/studio/server.ts +11 -0
  8. package/apps/studio/sync/asset-pull.ts +210 -0
  9. package/apps/studio/sync/asset-push.ts +118 -75
  10. package/apps/studio/sync/connection-state.ts +23 -0
  11. package/apps/studio/sync/discovery.ts +139 -0
  12. package/apps/studio/sync/file-membership.ts +290 -0
  13. package/apps/studio/sync/file-pull.ts +330 -0
  14. package/apps/studio/sync/index.ts +995 -166
  15. package/apps/studio/sync/remote-docs.ts +98 -4
  16. package/apps/studio/sync/status.ts +25 -0
  17. package/apps/studio/sync/tombstone-apply.ts +131 -0
  18. package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
  19. package/apps/studio/test/exporters/jobs.test.ts +10 -4
  20. package/apps/studio/test/git-cloud-posture.test.ts +11 -2
  21. package/apps/studio/test/sync-asset-pull.test.ts +161 -0
  22. package/apps/studio/test/sync-asset-push.test.ts +144 -3
  23. package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
  24. package/apps/studio/test/sync-file-membership.test.ts +331 -0
  25. package/apps/studio/test/sync-file-pull.test.ts +333 -0
  26. package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
  27. package/apps/studio/test/sync-panel-surface.test.ts +10 -0
  28. package/apps/studio/test/sync-remote-docs.test.ts +118 -8
  29. package/apps/studio/test/sync-resync-routes.test.ts +43 -0
  30. package/apps/studio/test/sync-status.test.ts +18 -0
  31. package/apps/studio/test/sync-supervisor.test.ts +4 -0
  32. package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
  33. package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
  34. package/apps/studio/whats-new.json +18 -0
  35. package/cli/commands/doctor.mjs +141 -6
  36. package/cli/lib/gitignore-drift.mjs +149 -0
  37. package/cli/lib/gitignore-drift.test.mjs +156 -0
  38. package/package.json +8 -8
@@ -0,0 +1,343 @@
1
+ // Two peers, one hub — the reported bug, end to end, in both directions.
2
+ //
3
+ // The unit tests in `sync-attach-incremental.test.ts` pin each half against a
4
+ // stub. This one puts TWO REAL RUNTIMES on one shared hub and asks the question
5
+ // the user actually asked: if somebody makes a canvas over there, does it show
6
+ // up over here, live, without anyone restarting anything?
7
+ //
8
+ // Both directions are the same code — the cell runs the same runtime the
9
+ // desktop does — so a test that only checked one would prove half of nothing.
10
+ // The reported asymmetry (desktop→cloud "worked", cloud→desktop never did) was
11
+ // never about direction: it was about which end happens to restart.
12
+ //
13
+ // WHAT IS ASSERTED. Not `size()`, which a runtime can inflate by opening a
14
+ // provider it never connects. The peer's CONTENT has to arrive, its later edits
15
+ // have to keep flowing, and the awareness bridge — the thing behind cursors —
16
+ // has to be attached for the canvas that was discovered, not merely for the
17
+ // ones that existed at boot.
18
+
19
+ import { afterEach, beforeEach, describe, expect, test } from 'bun:test';
20
+ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs';
21
+ import { tmpdir } from 'node:os';
22
+ import { join } from 'node:path';
23
+ import { Awareness } from 'y-protocols/awareness';
24
+ import * as Y from 'yjs';
25
+ import type { Context, DevServerConfig } from '../context.ts';
26
+ import { createBus } from '../context.ts';
27
+ import type { AwarenessRegistry } from '../sync/index.ts';
28
+ import { createSyncRuntime, type SyncProvider, type SyncRuntime } from '../sync/index.ts';
29
+
30
+ const HUB = 'https://hub.example.com';
31
+
32
+ let root: string;
33
+ let cfgPathEnv: string | undefined;
34
+ let realFetch: typeof fetch;
35
+
36
+ beforeEach(() => {
37
+ root = mkdtempSync(join(tmpdir(), 'two-peer-'));
38
+ cfgPathEnv = process.env.HUBS_CONFIG_PATH;
39
+ process.env.HUBS_CONFIG_PATH = join(root, 'hubs.json');
40
+ writeFileSync(
41
+ process.env.HUBS_CONFIG_PATH,
42
+ JSON.stringify({ hubs: { [HUB]: { token: 'mau_test', linkedAt: 1 } } })
43
+ );
44
+ chmodSync(process.env.HUBS_CONFIG_PATH, 0o600);
45
+ realFetch = globalThis.fetch;
46
+ });
47
+
48
+ afterEach(() => {
49
+ globalThis.fetch = realFetch;
50
+ if (cfgPathEnv === undefined) delete process.env.HUBS_CONFIG_PATH;
51
+ else process.env.HUBS_CONFIG_PATH = cfgPathEnv;
52
+ rmSync(root, { recursive: true, force: true });
53
+ });
54
+
55
+ /**
56
+ * One hub both peers connect to.
57
+ *
58
+ * Holds a Y.Doc per documentName and relays between every connection attached
59
+ * to it. Each connection carries its OWN origin symbol, so an update that
60
+ * arrives from peer A reaches peer B and is not echoed back to A — the property
61
+ * a single shared symbol would silently break, leaving a test that passes
62
+ * because nothing ever moved.
63
+ *
64
+ * It also answers `GET /api/documents` from the same map, which is the whole
65
+ * point: the listing is what a peer can learn about a canvas it has never seen,
66
+ * and it can only ever name documents somebody actually opened.
67
+ */
68
+ function makeHub() {
69
+ const docs = new Map<string, Y.Doc>();
70
+
71
+ function docFor(name: string): Y.Doc {
72
+ let d = docs.get(name);
73
+ if (!d) {
74
+ d = new Y.Doc();
75
+ docs.set(name, d);
76
+ }
77
+ return d;
78
+ }
79
+
80
+ function factory() {
81
+ return (args: { documentName: string; document?: Y.Doc }): SyncProvider => {
82
+ const hubDoc = docFor(args.documentName);
83
+ const local = args.document ?? new Y.Doc();
84
+ const conn = Symbol('conn');
85
+ // Initial state transfer, both ways — a real handshake converges the two
86
+ // sides before `onceSynced` resolves, and the cold-start reconcile the
87
+ // runtime runs afterwards depends on that having happened.
88
+ Y.applyUpdate(local, Y.encodeStateAsUpdate(hubDoc), conn);
89
+ Y.applyUpdate(hubDoc, Y.encodeStateAsUpdate(local), conn);
90
+ const onLocal = (u: Uint8Array, origin: unknown) => {
91
+ if (origin === conn) return;
92
+ Y.applyUpdate(hubDoc, u, conn);
93
+ };
94
+ const onHub = (u: Uint8Array, origin: unknown) => {
95
+ if (origin === conn) return;
96
+ Y.applyUpdate(local, u, conn);
97
+ };
98
+ local.on('update', onLocal);
99
+ hubDoc.on('update', onHub);
100
+ return {
101
+ document: local,
102
+ awareness: new Awareness(local),
103
+ async onceSynced() {},
104
+ destroy() {
105
+ local.off('update', onLocal);
106
+ hubDoc.off('update', onHub);
107
+ },
108
+ };
109
+ };
110
+ }
111
+
112
+ /** `GET /api/documents` — names only, exactly like the real route. */
113
+ function serveListing(): void {
114
+ globalThis.fetch = (async (input: RequestInfo | URL) => {
115
+ if (String(input).endsWith('/api/documents')) {
116
+ return new Response(
117
+ JSON.stringify({ documents: [...docs.keys()].map((name) => ({ name, bytes: 1 })) }),
118
+ { status: 200, headers: { 'content-type': 'application/json' } }
119
+ );
120
+ }
121
+ return new Response('nope', { status: 404 });
122
+ }) as typeof fetch;
123
+ }
124
+
125
+ return {
126
+ factory,
127
+ serveListing,
128
+ docs,
129
+ bodyOf: (n: string) => docFor(n).getText('html').toString(),
130
+ };
131
+ }
132
+
133
+ /** A peer: its own disk, its own context, its own runtime. */
134
+ function makePeer(name: string) {
135
+ const repoRoot = join(root, name);
136
+ const designRoot = join(repoRoot, 'design');
137
+ mkdirSync(join(designRoot, 'ui'), { recursive: true });
138
+ mkdirSync(join(designRoot, '_comments'), { recursive: true });
139
+ const linkedHub: DevServerConfig['linkedHub'] = { url: HUB, linkedAt: 1 };
140
+ const ctx = {
141
+ // The sandbox split is active — the gate `.tsx` sync is coupled to
142
+ // (9.1-B). TSX is the real canvas format, and it is also where the pulled
143
+ // body lands: `fallbackCanvasPath` always resolves to `.tsx`, so a test
144
+ // written against `.html` would assert a file that correctly never exists.
145
+ canvasOrigin: 'http://canvas.localhost:9',
146
+ cfg: {
147
+ name,
148
+ projectLabel: null,
149
+ designRoot: 'design',
150
+ canvasGroups: [{ label: 'Canvases', path: 'ui' }],
151
+ rootClass: 'app',
152
+ themeDefault: 'dark',
153
+ tokensCssRel: 'system/colors.css',
154
+ teamAccentDefault: null,
155
+ handoffTargets: [],
156
+ newCanvasDir: 'ui',
157
+ newComponentDir: 'ui/components',
158
+ linkedHub,
159
+ _source: 'defaults',
160
+ },
161
+ projectLabel: name,
162
+ paths: {
163
+ repoRoot,
164
+ designRel: 'design',
165
+ designRoot,
166
+ serverInfoFile: join(designRoot, '_server.json'),
167
+ activeFile: join(designRoot, '_active.json'),
168
+ commentsDir: join(designRoot, '_comments'),
169
+ canvasStateDir: join(designRoot, '_canvas-state'),
170
+ historyDir: join(designRoot, '_history'),
171
+ tokensUrlRel: 'design/system/colors.css',
172
+ systemDirRel: 'system',
173
+ },
174
+ bus: createBus(),
175
+ } as Context;
176
+
177
+ /** Which slugs got a hub-awareness bridge — the cursor lane, per canvas. */
178
+ const bridged: string[] = [];
179
+ const registry: AwarenessRegistry = {
180
+ attachHubAwareness(slug) {
181
+ bridged.push(slug);
182
+ return () => {};
183
+ },
184
+ };
185
+
186
+ return {
187
+ ctx,
188
+ bridged,
189
+ registry,
190
+ /** A canvas the way a real one exists: a `.tsx` body plus the sidecar that
191
+ * opts it into sync (Lock 1 — a hub must never be able to flip it). */
192
+ write(canvas: string, body: string) {
193
+ writeFileSync(join(designRoot, 'ui', `${canvas}.tsx`), body);
194
+ writeFileSync(
195
+ join(designRoot, 'ui', `${canvas}.meta.json`),
196
+ JSON.stringify({ syncable: true })
197
+ );
198
+ },
199
+ read(canvas: string) {
200
+ return readFileSync(join(designRoot, 'ui', `${canvas}.tsx`), 'utf8');
201
+ },
202
+ };
203
+ }
204
+
205
+ /** Settle both the fs reader's quiet window and the doc round-trips. */
206
+ const settle = (ms = 450) => new Promise((res) => setTimeout(res, ms));
207
+
208
+ describe('two peers on one hub', () => {
209
+ let runtimes: (SyncRuntime | null)[] = [];
210
+ afterEach(async () => {
211
+ for (const r of runtimes) await r?.stop();
212
+ runtimes = [];
213
+ });
214
+
215
+ test('a canvas created on peer A reaches peer B — no restart on either side', async () => {
216
+ const hub = makeHub();
217
+ const a = makePeer('a');
218
+ const b = makePeer('b');
219
+ // Both start with the same one canvas, so neither is in the special
220
+ // empty-project path and the only variable is the NEW one.
221
+ a.write('home', '<main>home</main>');
222
+ b.write('home', '<main>home</main>');
223
+ hub.serveListing();
224
+
225
+ const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
226
+ const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
227
+ runtimes = [ra, rb];
228
+ await ra?.start();
229
+ await rb?.start();
230
+ await settle(50);
231
+
232
+ expect(ra?.size()).toBe(1);
233
+ expect(rb?.size()).toBe(1);
234
+
235
+ // ── A makes a canvas. Nobody restarts anything. ──────────────────────
236
+ a.write('newidea', '<section>made on A</section>');
237
+ a.ctx.bus.emit('canvas-list-update', { action: 'added', rel: 'ui/newidea.tsx' });
238
+ await ra?.rescanNow();
239
+ await settle();
240
+
241
+ // It is on the hub — which on a cell is the step that used to be missing
242
+ // entirely, and is why the other side could not even learn the name.
243
+ expect(hub.bodyOf('ui-newidea')).toBe('<section>made on A</section>');
244
+
245
+ // ── B has never heard of it. It polls, and pulls it down. ────────────
246
+ await rb?.pullRemoteNow();
247
+ await settle();
248
+
249
+ expect(rb?.size()).toBe(2);
250
+ expect(b.read('newidea')).toBe('<section>made on A</section>');
251
+ // The cursor lane is attached for the canvas that ARRIVED, not just the
252
+ // ones that existed at boot — "no cursor in the new canvas" was half the
253
+ // report.
254
+ expect(b.bridged).toContain('ui-newidea');
255
+ });
256
+
257
+ test('the discovered canvas keeps syncing — it is live, not a one-shot copy', async () => {
258
+ const hub = makeHub();
259
+ const a = makePeer('a');
260
+ const b = makePeer('b');
261
+ a.write('home', '<main>home</main>');
262
+ b.write('home', '<main>home</main>');
263
+ hub.serveListing();
264
+
265
+ const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
266
+ const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
267
+ runtimes = [ra, rb];
268
+ await ra?.start();
269
+ await rb?.start();
270
+
271
+ a.write('shared', '<p>v1</p>');
272
+ await ra?.rescanNow();
273
+ await settle();
274
+ await rb?.pullRemoteNow();
275
+ await settle();
276
+ expect(b.read('shared')).toBe('<p>v1</p>');
277
+
278
+ // A edits it again, well after the discovery. This is the difference
279
+ // between "it appeared" and "it syncs".
280
+ a.write('shared', '<p>v2 — edited after discovery</p>');
281
+ a.ctx.bus.emit('fs:any', 'ui/shared.tsx');
282
+ // Long enough for the whole chain: A's fs quiet window, the doc round-trip,
283
+ // and B's agent debouncing its doc→file write. Three debounces, not one.
284
+ await settle(1500);
285
+
286
+ expect(hub.bodyOf('ui-shared')).toBe('<p>v2 — edited after discovery</p>');
287
+ expect(b.read('shared')).toBe('<p>v2 — edited after discovery</p>');
288
+ });
289
+
290
+ test('the other direction is the same code — B creates, A discovers', async () => {
291
+ const hub = makeHub();
292
+ const a = makePeer('a');
293
+ const b = makePeer('b');
294
+ a.write('home', '<main>home</main>');
295
+ b.write('home', '<main>home</main>');
296
+ hub.serveListing();
297
+
298
+ const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
299
+ const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
300
+ runtimes = [ra, rb];
301
+ await ra?.start();
302
+ await rb?.start();
303
+
304
+ b.write('fromb', '<article>made on B</article>');
305
+ await rb?.rescanNow();
306
+ await settle();
307
+ await ra?.pullRemoteNow();
308
+ await settle();
309
+
310
+ expect(a.read('fromb')).toBe('<article>made on B</article>');
311
+ expect(ra?.size()).toBe(2);
312
+ expect(a.bridged).toContain('ui-fromb');
313
+ });
314
+
315
+ test('a canvas each, made at the same time, and both sides end up with both', async () => {
316
+ // The case a one-directional fix passes and a real one has to survive:
317
+ // neither peer is "the server", and each has something the other lacks.
318
+ const hub = makeHub();
319
+ const a = makePeer('a');
320
+ const b = makePeer('b');
321
+ a.write('home', '<main>home</main>');
322
+ b.write('home', '<main>home</main>');
323
+ hub.serveListing();
324
+
325
+ const ra = createSyncRuntime(a.ctx, { providerFactory: hub.factory(), registry: a.registry });
326
+ const rb = createSyncRuntime(b.ctx, { providerFactory: hub.factory(), registry: b.registry });
327
+ runtimes = [ra, rb];
328
+ await ra?.start();
329
+ await rb?.start();
330
+
331
+ a.write('alpha', '<p>alpha</p>');
332
+ b.write('beta', '<p>beta</p>');
333
+ await Promise.all([ra?.rescanNow(), rb?.rescanNow()]);
334
+ await settle();
335
+ await Promise.all([ra?.pullRemoteNow(), rb?.pullRemoteNow()]);
336
+ await settle();
337
+
338
+ expect(a.read('beta')).toBe('<p>beta</p>');
339
+ expect(b.read('alpha')).toBe('<p>alpha</p>');
340
+ expect(ra?.size()).toBe(3);
341
+ expect(rb?.size()).toBe(3);
342
+ });
343
+ });
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "$schema": "./whats-new.schema.json",
3
3
  "entries": [
4
+ {
5
+ "id": "continuous-sync-discovery",
6
+ "version": "0.60.3",
7
+ "date": "2026-08-13",
8
+ "kind": "fix",
9
+ "title": "A new canvas shows up everywhere, on its own",
10
+ "summary": "Make a canvas in the cloud and it arrives on your desktop; make one on your desktop and it is live in the cloud, with cursors, straight away. Until now each side only looked for new canvases when it started up, so whichever end you had not restarted lately simply never saw them.",
11
+ "surface": "design-ui"
12
+ },
13
+ {
14
+ "id": "cloud-keeps-its-images",
15
+ "version": "0.60.3",
16
+ "date": "2026-08-13",
17
+ "kind": "fix",
18
+ "title": "Your cloud project keeps its images",
19
+ "summary": "After the cloud moved your project between machines, photographs could come back as grey boxes and the only fix was re-uploading everything from your laptop. The cloud now restores those files itself, from the copy it already had.",
20
+ "surface": "design-ui"
21
+ },
4
22
  {
5
23
  "id": "sync-resync-button",
6
24
  "version": "0.60.0",
@@ -25,6 +25,11 @@ import { stdin, stdout } from 'node:process';
25
25
  import { createInterface } from 'node:readline/promises';
26
26
  import { parseArgs } from '../lib/argv.mjs';
27
27
  import { lintConfig } from '../lib/config-lint.mjs';
28
+ import {
29
+ describeGitignoreDrift,
30
+ findGitignoreDrift,
31
+ removeGitignoreDrift,
32
+ } from '../lib/gitignore-drift.mjs';
28
33
  import { getHub } from '../lib/hubs-config.mjs';
29
34
  import { checkAll } from '../lib/preflight.mjs';
30
35
  import { detectQualityGates, detectStack } from '../lib/stack-detect.mjs';
@@ -172,6 +177,25 @@ export async function run({ args, pkgRoot }) {
172
177
  }
173
178
  }
174
179
 
180
+ // ── Gitignore drift: is git dropping VERSIONED design content? ───────────
181
+ // The one failure mode nothing else can see. A rule OUTSIDE the
182
+ // `# maude:begin`/`# maude:end` markers is invisible to the block writer, so
183
+ // a stale `.design/*.annotations.svg` — hand-copied years ago from a planning
184
+ // doc that predates DDR-115 — silently keeps every draw layer out of git
185
+ // while both UIs render it happily. See `lib/gitignore-drift.mjs`.
186
+ const gitignoreFsPath = resolve(repoRoot, '.gitignore');
187
+ const designRel = (() => {
188
+ try {
189
+ const dcfg = JSON.parse(readFileSync(designFsPath, 'utf8'));
190
+ return typeof dcfg.designRoot === 'string' && dcfg.designRoot ? dcfg.designRoot : '.design';
191
+ } catch {
192
+ return '.design';
193
+ }
194
+ })();
195
+ const gitignoreDrift = existsSync(gitignoreFsPath)
196
+ ? findGitignoreDrift(readFileSync(gitignoreFsPath, 'utf8'), designRel)
197
+ : [];
198
+
175
199
  // ── Summary ─────────────────────────────────────────────────────────────
176
200
  const hardDepsMissing = Object.values(depsByPlugin)
177
201
  .filter((r) => r.summary)
@@ -184,21 +208,44 @@ export async function run({ args, pkgRoot }) {
184
208
  schemaErrors,
185
209
  driftCount,
186
210
  qualityAdditions: additionsCount,
187
- healthy: hardDepsMissing === 0 && schemaErrors === 0,
211
+ gitignoreDrift: gitignoreDrift.length,
212
+ // A HARD failure, not a warning. Every other doctor finding is about
213
+ // configuration that is merely suboptimal; this one means work the person
214
+ // can see on screen is not in their repository and will not survive a fresh
215
+ // clone. Silence about that is how it went unnoticed for a whole project.
216
+ healthy: hardDepsMissing === 0 && schemaErrors === 0 && gitignoreDrift.length === 0,
188
217
  };
189
218
 
190
219
  if (jsonMode) {
191
220
  process.stdout.write(
192
- `${JSON.stringify({ deps: depsByPlugin, config: configReport, design: designReport, summary }, null, 2)}\n`
221
+ `${JSON.stringify(
222
+ {
223
+ deps: depsByPlugin,
224
+ config: configReport,
225
+ design: designReport,
226
+ gitignore: { path: gitignoreFsPath, designRel, drift: gitignoreDrift },
227
+ summary,
228
+ },
229
+ null,
230
+ 2
231
+ )}\n`
193
232
  );
194
233
  process.exit(summary.healthy ? 0 : 1);
195
234
  }
196
235
 
197
- printReport({ depsByPlugin, configReport, designReport, summary });
236
+ printReport({ depsByPlugin, configReport, designReport, gitignoreDrift, summary });
198
237
 
199
238
  // ── Fix path ────────────────────────────────────────────────────────────
200
239
  if (fix) {
201
- await applyFixes({ repoRoot, configFsPath, depsByPlugin, configReport });
240
+ await applyFixes({
241
+ repoRoot,
242
+ configFsPath,
243
+ depsByPlugin,
244
+ configReport,
245
+ gitignoreFsPath,
246
+ gitignoreDrift,
247
+ designRel,
248
+ });
202
249
  } else if (!summary.healthy) {
203
250
  process.stdout.write(
204
251
  '\nRun with --fix to: install missing deps (prompt per item), drop unknown keys, apply detected drift, add missing quality gates. Existing user values are NEVER overwritten.\n'
@@ -249,7 +296,7 @@ function usage() {
249
296
  `;
250
297
  }
251
298
 
252
- function printReport({ depsByPlugin, configReport, designReport, summary }) {
299
+ function printReport({ depsByPlugin, configReport, designReport, gitignoreDrift, summary }) {
253
300
  process.stdout.write('maude doctor\n\n');
254
301
  for (const [name, env] of Object.entries(depsByPlugin)) {
255
302
  process.stdout.write(` Dependencies (plugins/${name}):\n`);
@@ -340,6 +387,20 @@ function printReport({ depsByPlugin, configReport, designReport, summary }) {
340
387
  process.stdout.write('\n');
341
388
  }
342
389
 
390
+ // Gitignore drift — git is dropping content DDR-115 calls versioned.
391
+ if (gitignoreDrift?.length) {
392
+ process.stdout.write(' Gitignore (.gitignore):\n');
393
+ for (const line of describeGitignoreDrift(gitignoreDrift)) {
394
+ process.stdout.write(` ✗ ${line}\n`);
395
+ }
396
+ process.stdout.write(
397
+ ' These rules sit OUTSIDE the `# maude:begin`/`# maude:end` block, so\n' +
398
+ ' re-running the writer cannot correct them. Work you can see in the UI\n' +
399
+ ' is not in your repository and will not survive a fresh clone.\n' +
400
+ ' `maude doctor --fix` removes exactly these lines, and nothing else.\n\n'
401
+ );
402
+ }
403
+
343
404
  const parts = [];
344
405
  if (summary.hardDepsMissing) parts.push(`${summary.hardDepsMissing} hard dep missing`);
345
406
  if (summary.schemaErrors)
@@ -350,11 +411,23 @@ function printReport({ depsByPlugin, configReport, designReport, summary }) {
350
411
  parts.push(
351
412
  `${summary.qualityAdditions} quality addition${summary.qualityAdditions === 1 ? '' : 's'}`
352
413
  );
414
+ if (summary.gitignoreDrift)
415
+ parts.push(
416
+ `${summary.gitignoreDrift} gitignore rule${summary.gitignoreDrift === 1 ? '' : 's'} dropping versioned content`
417
+ );
353
418
  if (parts.length === 0) parts.push('all clear');
354
419
  process.stdout.write(` Summary: ${parts.join(', ')}.\n`);
355
420
  }
356
421
 
357
- async function applyFixes({ repoRoot, configFsPath, depsByPlugin, configReport }) {
422
+ async function applyFixes({
423
+ repoRoot,
424
+ configFsPath,
425
+ depsByPlugin,
426
+ configReport,
427
+ gitignoreFsPath,
428
+ gitignoreDrift,
429
+ designRel,
430
+ }) {
358
431
  let configChanged = false;
359
432
 
360
433
  // ── Config edits ───────────────────────────────────────────────────────
@@ -461,6 +534,68 @@ async function applyFixes({ repoRoot, configFsPath, depsByPlugin, configReport }
461
534
  }
462
535
  }
463
536
 
537
+ // ── Gitignore drift ─────────────────────────────────────────────────────
538
+ //
539
+ // The ONE fix in this command that edits a file the user hand-wrote, so it is
540
+ // the one that asks. Everything else `--fix` does is additive or confined to
541
+ // generated regions; this deletes lines from somebody's `.gitignore`. The
542
+ // prompt names each line, and a non-TTY (CI, a scripted spawn) is a decline —
543
+ // never an assumed yes.
544
+ if (gitignoreDrift?.length && gitignoreFsPath && existsSync(gitignoreFsPath)) {
545
+ process.stdout.write('\n--- Gitignore rules dropping VERSIONED design content ---\n');
546
+ for (const line of describeGitignoreDrift(gitignoreDrift)) {
547
+ process.stdout.write(` ${line}\n`);
548
+ }
549
+ if (!stdin.isTTY) {
550
+ process.stdout.write(
551
+ ' skipped — non-interactive stdin. Re-run in a terminal, or delete the lines above by hand.\n'
552
+ );
553
+ } else {
554
+ const rl = createInterface({ input: stdin, output: stdout });
555
+ const ans = await rl.question(
556
+ ` Remove ${gitignoreDrift.length === 1 ? 'this line' : `these ${gitignoreDrift.length} lines`} from .gitignore? [y/N] `
557
+ );
558
+ rl.close();
559
+ if (/^y(es)?$/i.test((ans || '').trim())) {
560
+ // DELETE ONLY WHAT WAS SHOWN, FROM THE TEXT IT WAS SHOWN FROM.
561
+ //
562
+ // `removeGitignoreDrift` deletes by LINE NUMBER, and its contract says
563
+ // those numbers must come from a scan of the same text — otherwise the
564
+ // numbers mean something else. The scan happens near the top of this
565
+ // command; the write happens here, after the report, after every
566
+ // per-dependency install (which shells out and can run for minutes) and
567
+ // after this prompt. In a Syncthing tree with concurrent sessions —
568
+ // which is exactly where this repo lives — that gap is long enough for
569
+ // the file to change, and the failure mode is silently deleting a line
570
+ // the person never saw and never approved. A `.gitignore` line can be
571
+ // the one keeping `.env` out of a commit.
572
+ //
573
+ // So: re-read, re-derive, and proceed only if the approved lines are
574
+ // still exactly where they were. Anything else is a re-run, not a guess.
575
+ const now = readFileSync(gitignoreFsPath, 'utf8');
576
+ const stillThere = findGitignoreDrift(now, designRel);
577
+ const approved = new Map(gitignoreDrift.map((d) => [d.line, d.text]));
578
+ const unchanged =
579
+ stillThere.length === approved.size &&
580
+ stillThere.every((d) => approved.get(d.line) === d.text);
581
+ if (!unchanged) {
582
+ process.stdout.write(
583
+ ' ✗ .gitignore changed while this was waiting for an answer — nothing removed.\n' +
584
+ ' Re-run `maude doctor --fix` to see the current state.\n'
585
+ );
586
+ } else {
587
+ writeFileSync(gitignoreFsPath, removeGitignoreDrift(now, stillThere), 'utf8');
588
+ process.stdout.write(
589
+ ' ✓ removed. The files are still untracked until you `git add` them —\n' +
590
+ ' `git status` will now show them for the first time.\n'
591
+ );
592
+ }
593
+ } else {
594
+ process.stdout.write(' skipped.\n');
595
+ }
596
+ }
597
+ }
598
+
464
599
  // Use the actually-changed state to decide on re-run hint.
465
600
  void repoRoot;
466
601
  }