@1agh/maude 0.58.0 → 0.58.2

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.
@@ -17,7 +17,7 @@
17
17
  // the provider lib didn't install for some reason) prints a useful error
18
18
  // instead of crashing the dev-server boot.
19
19
 
20
- import { existsSync, readFileSync, writeFileSync } from 'node:fs';
20
+ import { existsSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs';
21
21
  import { readdir } from 'node:fs/promises';
22
22
  import path from 'node:path';
23
23
 
@@ -32,6 +32,7 @@ import { type CanvasSyncAgent, createCanvasSyncAgent } from './agent.ts';
32
32
  import { atomicWrite } from './atomic-write.ts';
33
33
  import { createAutoCommit } from './autocommit.ts';
34
34
  import { type CellPairing, resolveCellPairing, sanitizeForLog } from './cell-pairing.ts';
35
+ import { canvasPathFromDoc, stampCanvasPath } from './codec.ts';
35
36
  import {
36
37
  type ConnectionMonitor,
37
38
  createConnectionMonitor,
@@ -44,8 +45,15 @@ import { getHubToken } from './hubs-config.ts';
44
45
  import { loadJournal, type SyncJournal } from './journal.ts';
45
46
  import { isLoopbackHost } from './loopback.ts';
46
47
  import { migrateSeed } from './migrate-seed.ts';
48
+ import { ORIGINS } from './origins.ts';
47
49
  import { createDocProjection, type DocProjection } from './projection.ts';
48
- import { describeRemoteDiff, diffRemoteDocs, fetchRemoteDocs, pullTargets } from './remote-docs.ts';
50
+ import {
51
+ describeRemoteDiff,
52
+ diffRemoteDocs,
53
+ fetchRemoteDocs,
54
+ pullTargets,
55
+ resolvePulledTarget,
56
+ } from './remote-docs.ts';
49
57
  import { createSyncStatusStore, type SyncStatusStore } from './status.ts';
50
58
  import { writeUntrustedMarkers } from './untrusted.ts';
51
59
 
@@ -422,6 +430,22 @@ export function createSyncRuntime(
422
430
  if (started || stopped) return;
423
431
  started = true;
424
432
 
433
+ // A NEW PROCESS MUST NOT SERVE THE OLD ONE'S VERDICT.
434
+ //
435
+ // `_sync.json` is a file, and `/_sync-status` returns whatever is in it.
436
+ // The first honest snapshot of THIS run is not written until every provider
437
+ // has been constructed — after a scan and a 6-second listing fetch — and
438
+ // until then the endpoint, the CLI and the browser banner were all reading
439
+ // the last run's counters as if they were current. That is how a live fleet
440
+ // showed `0 synced · 73 rejected` for a process whose own log said
441
+ // `76/76 synced` against a hub that was accepting the credential: the
442
+ // rejections were real, and they were from a previous session.
443
+ //
444
+ // Per-document verdicts are NOT rehydratable — a verdict is about a
445
+ // handshake this process has not made yet. So the file is reset to the
446
+ // honest seed (`connecting`, nothing known) the moment the runtime starts.
447
+ resetPersistedStatus(ctx, linkedHub.url);
448
+
425
449
  const scan = opts.canvases ? { canvases: opts.canvases, tsxCount: 0 } : await scanCanvases(ctx);
426
450
  // DDR-064 pre-cutover A4 + A6 — two files must never share a document, and
427
451
  // the pinned set must be bounded. See `admitCanvases`.
@@ -445,25 +469,114 @@ export function createSyncRuntime(
445
469
  localCanvases.map((c) => docNameFor(c.slug)),
446
470
  remoteDocs
447
471
  );
472
+ // PROVISIONAL targets. The listing carries names and byte counts only — a
473
+ // document's own `syncMeta.path` lives INSIDE it, so every target here is
474
+ // the fallback, and each one is re-resolved in `handleSynced` once that
475
+ // document has actually synced. See `relocatePulled` below.
476
+ //
477
+ // A FRESH LINK HAS DECLARED NOTHING. A design root with no canvases of its
478
+ // own and no `config.json` is a folder somebody just pointed at a project.
479
+ // `config.json` is not synced, so such a peer has only the DEFAULT groups
480
+ // (`system`, `ui`) — and a project whose author calls their group `screens`
481
+ // would have every single incoming path refused as out-of-group and land
482
+ // flat and invisible. That is the empty-folder case, and it is the one this
483
+ // whole change exists for.
484
+ //
485
+ // So on that one boot, an undeclared group is accepted (rules 1-7 are
486
+ // untouched — a path still has to slug back to its own document), and the
487
+ // groups actually seen are then WRITTEN into a config.json, additively. The
488
+ // relaxation therefore applies once: the next boot has a config.
489
+ // EMPTINESS IS A FACT ABOUT THE FOLDER, not about the scan. `scanCanvases`
490
+ // walks only DECLARED groups and applies the syncable + sandbox gates, so a
491
+ // project with real work in it scans to zero whenever `syncTsx:false`, the
492
+ // sandbox is off, or its canvases sit in a group it never declared — and
493
+ // treating that as "a bare folder somebody just pointed at a project" would
494
+ // let a hub author a `config.json` into a project that had work in it.
495
+ let freshLink =
496
+ localCanvases.length === 0 && !existsSync(designConfigPath(ctx)) && designRootIsBare(ctx);
497
+ const learnedGroups = new Set<string>();
498
+ // True once THIS boot wrote the config. Until then an existing file is
499
+ // somebody's own declaration and is never touched; afterwards it is ours to
500
+ // extend as further groups arrive.
501
+ let ownsSeededConfig = false;
502
+ const noteLearnedGroup = (group: string): void => {
503
+ if (!freshLink || learnedGroups.has(group)) return;
504
+ learnedGroups.add(group);
505
+ if (existsSync(designConfigPath(ctx)) && !ownsSeededConfig) return;
506
+ if (seedProjectConfig(ctx, learnedGroups, ownsSeededConfig)) ownsSeededConfig = true;
507
+ // ONE GROUP, ONCE. `freshLink` was a `const`, so after the first config
508
+ // was written every FURTHER undeclared group was accepted and appended —
509
+ // the relaxation perpetuating itself instead of closing, and a hub free to
510
+ // plant an unbounded set of directories in a single session. The project
511
+ // has now declared itself; everything after this is checked against that
512
+ // declaration like any other boot.
513
+ freshLink = false;
514
+ pathOpts.allowUndeclaredGroup = false;
515
+ };
516
+ const pathOpts: {
517
+ designRel: string;
518
+ canvasGroups: Context['cfg']['canvasGroups'];
519
+ allowUndeclaredGroup: boolean;
520
+ onRefused: (slug: string, reason: string) => void;
521
+ } = {
522
+ designRel: ctx.paths.designRel,
523
+ canvasGroups: ctx.cfg.canvasGroups,
524
+ allowUndeclaredGroup: freshLink,
525
+ onRefused: (slug: string, reason: string) =>
526
+ console.warn(`[sync/${slug}] ignoring the path this document carries — ${reason}`),
527
+ };
448
528
  const pulled = pullTargets(
449
529
  remoteDiff.hubOnly,
450
530
  ctx.paths.designRoot,
451
531
  path.join,
452
532
  path.resolve,
453
- path.sep
533
+ path.sep,
534
+ { ...pathOpts, realpath: realpathOfDeepestExisting }
454
535
  );
455
536
  const pullNote = describeRemoteDiff(remoteDiff);
456
537
  if (pullNote) console.log(`[sync] ${pullNote}`);
538
+ /** Descriptor paths for one slug at one body path. The sidecar rules live
539
+ * here, once: `.meta.json`/`.css` are SIBLINGS of the body, while
540
+ * `.annotations.svg` is keyed by the flat slug at the design root — the
541
+ * asymmetry `workspace-files.mjs` documents, and which moving the body
542
+ * must not quietly change. */
543
+ const descriptorFor = (slug: string, bodyAbs: string): CanvasDescriptor => ({
544
+ slug,
545
+ html: bodyAbs,
546
+ comments: path.join(ctx.paths.commentsDir, `${slug}.json`),
547
+ annotations: path.join(ctx.paths.designRoot, `${slug}.annotations.svg`),
548
+ meta: bodyAbs.replace(/\.tsx$/i, '.meta.json'),
549
+ css: bodyAbs.replace(/\.tsx$/i, '.css'),
550
+ });
551
+ // Which slugs came DOWN this run. Only these get their body path re-decided
552
+ // after their document syncs — a canvas already on this disk has its path
553
+ // from the disk, and letting the wire move it would be exactly the
554
+ // "a peer relocates another peer's work" hazard the hub refuses too.
555
+ // A PULL MAY NEVER TARGET A FILE THAT IS ALREADY ON THIS DISK.
556
+ //
557
+ // "Hub-only" means "no local DESCRIPTOR", which is not the same as "no local
558
+ // file": `scanCanvases` omits a canvas whose `.meta.json` says
559
+ // `syncable: false` (a security opt-out a hub must not be able to flip) and
560
+ // one the sandbox gate excluded. Such a canvas is classified hub-only and
561
+ // pulled, and its target — whether the fallback or a carried path — is the
562
+ // real file. Note the fallback collides on its own: `ui-card` falls back to
563
+ // `ui/card.tsx`, which IS `ui/Card.tsx` on a case-insensitive filesystem, so
564
+ // checking only the carried path would leave the same overwrite reachable
565
+ // with no path at all.
566
+ //
567
+ // Refusing the canvas is the conservative answer and the recoverable one:
568
+ // the project keeps the file it has, and the document is still on the hub.
569
+ //
570
+ // APPLIED TO THE FALLBACK TOO, not only to a carried path. The fallback is
571
+ // derived from the slug and the slug is derived from the path, so it lands
572
+ // in the same place: `system-colors_and_type` falls back to
573
+ // `system/colors_and_type.tsx` whether or not a path arrives. Checking only
574
+ // the carried path leaves every one of these reachable with no path at all.
575
+ const admittedPulls = pulled.filter((t) => admitPullTarget(ctx, t.slug, t.bodyAbs));
576
+ const pulledSlugs = new Set(admittedPulls.map((t) => t.slug));
457
577
  const canvases = [
458
578
  ...localCanvases,
459
- ...pulled.map((t) => ({
460
- slug: t.slug,
461
- html: t.bodyAbs,
462
- comments: path.join(ctx.paths.commentsDir, `${t.slug}.json`),
463
- annotations: path.join(ctx.paths.designRoot, `${t.slug}.annotations.svg`),
464
- meta: t.bodyAbs.replace(/\.tsx$/i, '.meta.json'),
465
- css: t.bodyAbs.replace(/\.tsx$/i, '.css'),
466
- })),
579
+ ...admittedPulls.map((t) => descriptorFor(t.slug, t.bodyAbs)),
467
580
  ];
468
581
  // T4.5 (DDR-054 §3 F3) — every syncable canvas can receive hub-pushed
469
582
  // content, so the whole set is untrusted Claude-context. Mark it (writes
@@ -476,7 +589,22 @@ export function createSyncRuntime(
476
589
  // into the tenant's repository, which the hub would then commit and mirror
477
590
  // to their GitHub — a change to somebody's repo that nobody asked for. The
478
591
  // canvases are no less untrusted; the audience for the marker is absent.
479
- if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
592
+ //
593
+ // MARKED AT THE PATH THE BODY ACTUALLY LANDS AT. A pulled canvas's target is
594
+ // provisional here — the listing carries no path, so every pulled entry is
595
+ // the fallback, and `relocatePulled` moves it once that document arrives.
596
+ // The markers used to be computed ONLY from this provisional set and never
597
+ // recomputed, so for a hub-only document carrying a nested path the
598
+ // `_untrusted/INDEX.json` + `.claudeignore` block named a file that is never
599
+ // created, while the genuinely hub-pushed body sat at the real path listed
600
+ // nowhere. That is the DDR-054 §3 F3 control pointing at a phantom.
601
+ //
602
+ // So it is written twice: once now (so the markers exist before any provider
603
+ // is built) and once after the pulls settle, from the final descriptors.
604
+ const markUntrusted = (): void => {
605
+ if (!cellPairing) writeUntrustedMarkers(ctx, canvases, linkedHub.url);
606
+ };
607
+ markUntrusted();
480
608
  if (canvases.length === 0) {
481
609
  // DDR-060 / 9.1-D — the silent early-return made linked mode look healthy
482
610
  // while syncing nothing (TSX-only projects: discovery admits .html only,
@@ -672,6 +800,26 @@ export function createSyncRuntime(
672
800
  }
673
801
  };
674
802
 
803
+ /**
804
+ * A HANDSHAKE THAT COMPLETED IS NOT A REJECTED DOCUMENT.
805
+ *
806
+ * `auth-rejected` is deliberately sticky — a dropped socket must not launder
807
+ * a rotated credential into a spinner. But the verdict is about the HUB'S
808
+ * ANSWER, and the hub has just given a different one: this document
809
+ * connected. Clearing it here, at the top of the post-handshake path, means
810
+ * a re-probe that succeeds clears the record even if the reconcile below
811
+ * then fails for a reason that has nothing to do with authentication — which
812
+ * is how `0 synced · 73 rejected` survived on a link the hub was accepting.
813
+ *
814
+ * A GENUINE rejection still says so: nothing clears until a handshake for
815
+ * that document actually completes.
816
+ */
817
+ const clearRejection = (slug: string): void => {
818
+ rejectedPermanent.delete(slug);
819
+ if (!rejectedReasons.delete(slug)) return;
820
+ console.log(`[sync/${slug}] the hub accepted this document — clearing its refusal.`);
821
+ };
822
+
675
823
  /** Post-handshake reconcile — shared by first connect and re-probe. */
676
824
  const handleSynced = async (
677
825
  canvas: CanvasDescriptor,
@@ -679,6 +827,11 @@ export function createSyncRuntime(
679
827
  provider: SyncProvider
680
828
  ): Promise<void> => {
681
829
  if (stopped) return;
830
+ clearRejection(canvas.slug);
831
+ // Not `connected` yet — the reconcile below is what makes that true. But
832
+ // no longer refused, and the difference is the whole point: `pending` says
833
+ // "still settling", `auth-rejected` says "go fix your credential".
834
+ mon.noteDocState(canvas.slug, 'pending');
682
835
  const projection = projections.get(canvas.slug);
683
836
  const agent = agents.get(canvas.slug);
684
837
  if (projection) {
@@ -728,10 +881,44 @@ export function createSyncRuntime(
728
881
  }
729
882
  }
730
883
  }
884
+ // The path travels back OUT. `syncMeta.path` is stamped from where this
885
+ // canvas actually is on THIS disk — never echoed from the wire — so the
886
+ // next peer to receive this document can place it, and a value some
887
+ // receiver refused is never laundered onward by being re-sent.
888
+ try {
889
+ const rel = path.relative(ctx.paths.designRoot, canvasPaths.html).split(path.sep).join('/');
890
+ if (rel && !rel.startsWith('..'))
891
+ stampCanvasPath(provider.document, rel, ORIGINS.DISK_PROJECTION);
892
+ } catch {
893
+ /* best-effort bookkeeping — never costs the canvas its sync */
894
+ }
895
+
731
896
  // DDR-102 — honest status: the handshake + reconcile completed.
732
897
  mon.noteDocState(canvas.slug, 'connected');
733
898
  mon.noteSyncActivity(canvas.slug);
734
- rejectedReasons.delete(canvas.slug);
899
+ };
900
+
901
+ /**
902
+ * `handleSynced`, with the one guarantee its body cannot make for itself.
903
+ *
904
+ * Everything from `migrateSeed` to `agent.reconcile()` can throw, and the
905
+ * rejection was swallowed by `settleWait` — so a document whose reconcile
906
+ * failed never reached the `connected` line and sat on whatever its last
907
+ * verdict was, forever, with nothing on screen or in the log saying why.
908
+ * A reconcile failure is a real failure and is now LOUD; it leaves the
909
+ * document `pending` (set at the top of `handleSynced`), which is what it
910
+ * is: connected to the hub, not yet settled on disk.
911
+ */
912
+ const runHandleSynced = async (
913
+ canvas: CanvasDescriptor,
914
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
915
+ provider: SyncProvider
916
+ ): Promise<void> => {
917
+ try {
918
+ await handleSynced(canvas, canvasPaths, provider);
919
+ } catch (err) {
920
+ console.error(`[sync/${canvas.slug}] post-handshake reconcile failed:`, err);
921
+ }
735
922
  };
736
923
 
737
924
  /** onceSynced() with the boot-settle ceiling — never hangs the summary on
@@ -792,6 +979,84 @@ export function createSyncRuntime(
792
979
  return null;
793
980
  };
794
981
 
982
+ /**
983
+ * Re-decide where a PULLED canvas goes, now that its document has synced.
984
+ *
985
+ * The listing (`GET /api/documents`) carries names and byte counts only —
986
+ * the path lives INSIDE the document, so it cannot be known when the target
987
+ * is first computed. This runs in the gap: after the handshake, before
988
+ * anything is written. Nothing is on disk yet for a pulled canvas, so this
989
+ * is a decision rather than a move.
990
+ *
991
+ * Local canvases never reach here. Their path comes from this disk, and
992
+ * letting a remote value relocate them is the same hazard the hub refuses
993
+ * with `pathIndex` — a peer moving another peer's work.
994
+ */
995
+ const relocatePulled = (
996
+ canvas: CanvasDescriptor,
997
+ canvasPaths: import('./agent.ts').CanvasSyncPaths,
998
+ doc: Y.Doc
999
+ ): void => {
1000
+ if (!pulledSlugs.has(canvas.slug)) return;
1001
+ const resolved = resolvePulledTarget({
1002
+ slug: canvas.slug,
1003
+ path: canvasPathFromDoc(doc),
1004
+ designRoot: ctx.paths.designRoot,
1005
+ designRel: ctx.paths.designRel,
1006
+ canvasGroups: ctx.cfg.canvasGroups,
1007
+ join: path.join,
1008
+ resolve: path.resolve,
1009
+ sep: path.sep,
1010
+ realpath: realpathOfDeepestExisting,
1011
+ allowUndeclaredGroup: pathOpts.allowUndeclaredGroup,
1012
+ onRefused: (reason) => pathOpts.onRefused(canvas.slug, reason),
1013
+ });
1014
+ if (!resolved) return;
1015
+
1016
+ // NEVER ONTO A FILE THAT ALREADY EXISTS.
1017
+ //
1018
+ // `relocatePulled`'s premise is that nothing is on disk for a pulled
1019
+ // canvas — but "pulled" only means "no LOCAL DESCRIPTOR", and `scanCanvases`
1020
+ // omits a canvas whose `.meta.json` says `syncable: false` (a security
1021
+ // opt-out) or whose `.tsx` the sandbox gate excluded. Such a canvas is
1022
+ // classified hub-only and pulled, and before this feature that was benign:
1023
+ // the body landed flat at the design root, inside no canvas group, loaded
1024
+ // by nothing. Honouring a remote path would land it on the real file and
1025
+ // let a hub overwrite exactly the canvas the user opted OUT of syncing.
1026
+ // The same admission the provisional target already passed, re-asked of
1027
+ // the destination the document actually chose.
1028
+ if (resolved.fromPath && !admitPullTarget(ctx, canvas.slug, resolved.bodyAbs)) return;
1029
+ // The TOP-level component only — `canvasGroups` names a group, not every
1030
+ // folder inside it (`ui/2026/social/x.tsx` declares `ui`). A body that
1031
+ // landed at the design root has no group and teaches nothing.
1032
+ const [group, ...rest] = path
1033
+ .relative(ctx.paths.designRoot, resolved.bodyAbs)
1034
+ .split(path.sep);
1035
+ if (group && rest.length > 0) noteLearnedGroup(group);
1036
+ if (resolved.bodyAbs === canvas.html) return;
1037
+ const next = descriptorFor(canvas.slug, resolved.bodyAbs);
1038
+ // Mutated in place: the descriptor and the paths object are already held
1039
+ // by the status surfaces and by the setup closure below, and handing them
1040
+ // a second object would leave half the runtime writing to the old path.
1041
+ Object.assign(canvas, next);
1042
+ canvasPaths.html = next.html;
1043
+ canvasPaths.meta = next.meta;
1044
+ canvasPaths.css = next.css;
1045
+ console.log(
1046
+ `[sync/${canvas.slug}] pulled into ${path.relative(ctx.paths.designRoot, next.html)}`
1047
+ );
1048
+ // RE-MARK NOW, not at the end of boot. The markers were computed from the
1049
+ // provisional descriptor set and the descriptors are mutated in place
1050
+ // here, so between this line and the end of boot the `_untrusted` index
1051
+ // would name a file that does not exist while the hub-pushed body it
1052
+ // exists to flag sits somewhere unlisted. Deferring the re-mark to the
1053
+ // boot-settle handler leaves exactly that window open — and that handler
1054
+ // is fire-and-forget, so a short-lived process never reaches it at all.
1055
+ // One small write per relocation is the right price for a marker that is
1056
+ // never wrong.
1057
+ markUntrusted();
1058
+ };
1059
+
795
1060
  const connectCanvas = async (
796
1061
  canvas: CanvasDescriptor,
797
1062
  canvasPaths: import('./agent.ts').CanvasSyncPaths,
@@ -806,10 +1071,16 @@ export function createSyncRuntime(
806
1071
  });
807
1072
  providers.set(canvas.slug, provider);
808
1073
  // First-connect setup (agent/projection creation + doc-scoped wiring)
809
- // MUST run before the onceSynced chain below — handleSynced resolves the
1074
+ // MUST run before handleSynced — that function resolves the
810
1075
  // agent/projection from the maps, and a test stub's onceSynced can
811
1076
  // settle on the very next microtask.
812
- setup?.(provider);
1077
+ //
1078
+ // For a PULLED canvas it must run AFTER the handshake instead, because
1079
+ // the agent is constructed around a body path this peer cannot know until
1080
+ // the document arrives. Ordering, not skipping: the two still happen in
1081
+ // the same order relative to each other.
1082
+ const deferSetup = !!setup && pulledSlugs.has(canvas.slug);
1083
+ if (!deferSetup) setup?.(provider);
813
1084
 
814
1085
  // Task 8 — feed this provider's WS status into the offline monitor.
815
1086
  if (provider.onStatus) {
@@ -834,7 +1105,13 @@ export function createSyncRuntime(
834
1105
  awarenessDetaches.push(opts.registry.attachHubAwareness(canvas.slug, provider.awareness));
835
1106
  }
836
1107
  // Cold-start reconcile fires once the provider has hub state.
837
- const synced = provider.onceSynced().then(() => handleSynced(canvas, canvasPaths, provider));
1108
+ const synced = provider.onceSynced().then(() => {
1109
+ if (deferSetup) {
1110
+ relocatePulled(canvas, canvasPaths, provider.document);
1111
+ setup?.(provider);
1112
+ }
1113
+ return runHandleSynced(canvas, canvasPaths, provider);
1114
+ });
838
1115
  bootWaits.push(settleWait(synced));
839
1116
  return provider;
840
1117
  };
@@ -1029,7 +1306,11 @@ export function createSyncRuntime(
1029
1306
  // the pull, so it named exactly the canvases that had just arrived and
1030
1307
  // were sitting on disk. `pulled` is the same list under the name that is
1031
1308
  // true, and it is the fact the user is told to act on.
1032
- mon.notePulled(pulled.map((t) => t.slug));
1309
+ mon.notePulled(admittedPulls.map((t) => t.slug));
1310
+ // Re-mark from the FINAL descriptors — `relocatePulled` mutates them in
1311
+ // place after each handshake, and the markers are the one consumer that
1312
+ // read them before that and would otherwise never read them again.
1313
+ markUntrusted();
1033
1314
  });
1034
1315
  }
1035
1316
 
@@ -1399,6 +1680,198 @@ async function walk(
1399
1680
  }
1400
1681
  }
1401
1682
 
1683
+ /**
1684
+ * Blank the persisted status for a process that has just started.
1685
+ *
1686
+ * Deliberately NOT a rehydration. Presentation state (which hub, how many
1687
+ * canvases) is knowable up front; a per-document verdict is not — it is the
1688
+ * outcome of a handshake this process has yet to make. Carrying one over is how
1689
+ * a stale `auth-rejected` outlives the credential rotation that fixed it.
1690
+ *
1691
+ * Best-effort, like every other write to this file: a status that cannot be
1692
+ * written must not stop a project from syncing.
1693
+ */
1694
+ function resetPersistedStatus(ctx: Context, url: string): void {
1695
+ try {
1696
+ atomicWrite(
1697
+ path.join(ctx.paths.designRoot, '_sync.json'),
1698
+ `${JSON.stringify(
1699
+ {
1700
+ url,
1701
+ canvases: 0,
1702
+ conflicts: [],
1703
+ state: 'connecting',
1704
+ queuedOps: 0,
1705
+ lastSyncAt: null,
1706
+ offlineSince: null,
1707
+ flash: null,
1708
+ updatedAt: Date.now(),
1709
+ docs: { synced: 0, pending: 0, rejected: 0 },
1710
+ },
1711
+ null,
1712
+ 2
1713
+ )}\n`
1714
+ );
1715
+ } catch {
1716
+ /* best-effort — see the doc comment */
1717
+ }
1718
+ }
1719
+
1720
+ /**
1721
+ * May a pulled canvas be materialised at this path?
1722
+ *
1723
+ * Asked of the PROVISIONAL target and again of whatever the document's own
1724
+ * `syncMeta.path` resolves to, because the two can be the same place: the
1725
+ * fallback is derived from the slug and the slug from the path, so
1726
+ * `system-colors_and_type` targets `system/colors_and_type.tsx` with or without
1727
+ * a path on the wire. A guard on the carried path alone is a guard on the
1728
+ * loudest half of the problem.
1729
+ *
1730
+ * Two refusals, both about what ALREADY occupies the location — which is
1731
+ * precisely what rule 7 does not speak to. Rule 7 ties a path to its own
1732
+ * DOCUMENT; it has nothing to say about the file already sitting there.
1733
+ */
1734
+ function admitPullTarget(ctx: Context, slug: string, bodyAbs: string): boolean {
1735
+ const rel = path.relative(ctx.paths.designRoot, bodyAbs);
1736
+ // 1. A file that is already on this disk. "Hub-only" means "no local
1737
+ // DESCRIPTOR", not "no local file": `scanCanvases` omits a canvas whose
1738
+ // `.meta.json` says `syncable: false` — a security opt-out a hub must not
1739
+ // be able to flip — and one the TSX sandbox gate excluded. Such a canvas is
1740
+ // classified hub-only and pulled, and its target is the real file. Note
1741
+ // `existsSync` settles the case-insensitive collision for free: `ui/card.tsx`
1742
+ // IS `ui/Card.tsx` on macOS, and that is exactly how the fallback reaches a
1743
+ // file the project meant to keep out of the sync set.
1744
+ if (existsSync(bodyAbs)) {
1745
+ console.warn(
1746
+ `[sync/${slug}] not pulling — ${rel} already exists on this machine and is not in ` +
1747
+ "this project's sync set (a `syncable: false` sidecar, or the TSX sandbox gate). " +
1748
+ 'The local file is kept.'
1749
+ );
1750
+ return false;
1751
+ }
1752
+ // 2. A file that means something other than "a canvas". The `.css` and
1753
+ // `.meta.json` siblings are derived from the body path and `system` is a
1754
+ // DEFAULT canvas group, so `system-colors_and_type` writes its css lane
1755
+ // straight over `tokensCssRel` — the stylesheet the dev server serves.
1756
+ if (collidesWithServedPaths(ctx, bodyAbs)) {
1757
+ console.warn(`[sync/${slug}] not pulling — ${rel} would overwrite a served project file.`);
1758
+ return false;
1759
+ }
1760
+ return true;
1761
+ }
1762
+
1763
+ /**
1764
+ * True when the design root holds nothing but runtime state.
1765
+ *
1766
+ * The emptiness question the fresh-link relaxation actually needs to ask. It is
1767
+ * NOT "did the scan find canvases": the scan walks declared groups only and
1768
+ * applies the syncable + sandbox gates, so it returns zero for several projects
1769
+ * that are anything but bare.
1770
+ */
1771
+ function designRootIsBare(ctx: Context): boolean {
1772
+ try {
1773
+ return readdirSync(ctx.paths.designRoot).every(
1774
+ (name) => name.startsWith('_') || name === '.git'
1775
+ );
1776
+ } catch {
1777
+ // No design root at all is as bare as it gets.
1778
+ return true;
1779
+ }
1780
+ }
1781
+
1782
+ /**
1783
+ * True when a pulled body's sidecars would land on a file that means something
1784
+ * other than "a canvas".
1785
+ *
1786
+ * `.css` and `.meta.json` are derived from the body path, and `system` is a
1787
+ * DEFAULT canvas group — so a hub-chosen path inside it can put an attacker's
1788
+ * css lane exactly where `tokensCssRel` is served from. Rule 7 ties a path to
1789
+ * its own DOCUMENT; it says nothing about what already occupies that location.
1790
+ */
1791
+ function collidesWithServedPaths(ctx: Context, bodyAbs: string): boolean {
1792
+ const served = new Set<string>();
1793
+ const add = (rel: unknown): void => {
1794
+ if (typeof rel === 'string' && rel) served.add(path.resolve(ctx.paths.designRoot, rel));
1795
+ };
1796
+ add(ctx.cfg.tokensCssRel);
1797
+ for (const ds of ctx.cfg.designSystems ?? []) add(ds?.tokensCssRel);
1798
+ add('config.json');
1799
+ const stem = bodyAbs.replace(/\.tsx$/i, '');
1800
+ return [bodyAbs, `${stem}.css`, `${stem}.meta.json`].some((p) => served.has(path.resolve(p)));
1801
+ }
1802
+
1803
+ /**
1804
+ * `realpathSync`, but for a path that does not exist yet.
1805
+ *
1806
+ * `realpathSync` throws ENOENT on the file we are about to create, so walk up to
1807
+ * the deepest ancestor that DOES exist, resolve that, and re-attach the tail.
1808
+ * Any symlink already on the path is therefore followed, which is the whole
1809
+ * point: `path.resolve` is lexical, and `mkdirSync(recursive: true)` traverses a
1810
+ * symlinked directory without complaint.
1811
+ */
1812
+ function realpathOfDeepestExisting(p: string): string {
1813
+ let cur = p;
1814
+ for (;;) {
1815
+ try {
1816
+ return path.join(realpathSync(cur), path.relative(cur, p));
1817
+ } catch {
1818
+ const parent = path.dirname(cur);
1819
+ if (parent === cur) return p;
1820
+ cur = parent;
1821
+ }
1822
+ }
1823
+ }
1824
+
1825
+ /** `<designRoot>/config.json` — the project's own declaration of itself. */
1826
+ function designConfigPath(ctx: Context): string {
1827
+ return path.join(ctx.paths.designRoot, 'config.json');
1828
+ }
1829
+
1830
+ /**
1831
+ * Give a freshly-linked, previously-empty folder a config of its own.
1832
+ *
1833
+ * A project pulled into a bare directory has no `config.json` (it is not part
1834
+ * of the sync lane), so it runs on the DEFAULT canvas groups — and a project
1835
+ * whose author calls their group `screens` would be listed by nothing. This
1836
+ * writes what the pull actually brought down, so the next boot needs no
1837
+ * relaxation and the tree lists the project it just received.
1838
+ *
1839
+ * ADDITIVE AND ONE-SHOT. It refuses outright if a config already exists — a
1840
+ * user's own declaration is never edited by the sync runtime, and the caller's
1841
+ * `freshLink` gate means this cannot run on a project that had canvases.
1842
+ * Best-effort: a read-only design root costs the project its tidiness, never
1843
+ * its sync.
1844
+ */
1845
+ function seedProjectConfig(
1846
+ ctx: Context,
1847
+ learnedGroups: ReadonlySet<string>,
1848
+ owned: boolean
1849
+ ): boolean {
1850
+ const file = designConfigPath(ctx);
1851
+ if (existsSync(file) && !owned) return false;
1852
+ const declared = (ctx.cfg.canvasGroups ?? []).map((g) => g.path);
1853
+ const groups = [...declared, ...[...learnedGroups].filter((g) => !declared.includes(g))];
1854
+ try {
1855
+ atomicWrite(
1856
+ file,
1857
+ `${JSON.stringify(
1858
+ {
1859
+ name: ctx.cfg.name,
1860
+ designRoot: ctx.paths.designRel,
1861
+ canvasGroups: groups.map((p) => ({ label: p, path: p })),
1862
+ },
1863
+ null,
1864
+ 2
1865
+ )}\n`
1866
+ );
1867
+ console.log(`[sync] wrote ${ctx.paths.designRel}/config.json (${groups.join(', ')}).`);
1868
+ return true;
1869
+ } catch (err) {
1870
+ console.warn(`[sync] could not write ${ctx.paths.designRel}/config.json: ${String(err)}`);
1871
+ return false;
1872
+ }
1873
+ }
1874
+
1402
1875
  /**
1403
1876
  * The shape written to `<designRoot>/_sync.json` (and broadcast on the
1404
1877
  * 'sync:status' bus) when the project is linked but has zero syncable
@@ -144,7 +144,17 @@ export function createDocProjection(opts: DocProjectionOptions): DocProjection {
144
144
  if (stopped) return;
145
145
  // Skip our own file→doc import — the file is already current, re-projecting
146
146
  // would be a redundant write. (Migration seed is on disk already too.)
147
- if (origin === ORIGINS.FILE_IMPORT || origin === ORIGINS.MIGRATION) return;
147
+ // DISK_PROJECTION is the `syncMeta.path` stamp — bookkeeping ABOUT the
148
+ // file, derived from where the file already is, so it can never make the
149
+ // file stale. (Its own doc comment promised this filterability; this is the
150
+ // step that needed it.)
151
+ if (
152
+ origin === ORIGINS.FILE_IMPORT ||
153
+ origin === ORIGINS.MIGRATION ||
154
+ origin === ORIGINS.DISK_PROJECTION
155
+ ) {
156
+ return;
157
+ }
148
158
  scheduleFlush();
149
159
  }
150
160