@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.
- package/apps/studio/api.ts +14 -0
- package/apps/studio/client/app.jsx +44 -9
- package/apps/studio/client/panels/SyncPanel.jsx +53 -11
- package/apps/studio/context.ts +9 -0
- package/apps/studio/dist/client.bundle.js +525 -525
- package/apps/studio/http.ts +48 -7
- package/apps/studio/server.ts +11 -0
- package/apps/studio/sync/asset-pull.ts +210 -0
- package/apps/studio/sync/asset-push.ts +118 -75
- package/apps/studio/sync/connection-state.ts +23 -0
- package/apps/studio/sync/discovery.ts +139 -0
- package/apps/studio/sync/file-membership.ts +290 -0
- package/apps/studio/sync/file-pull.ts +330 -0
- package/apps/studio/sync/index.ts +995 -166
- package/apps/studio/sync/remote-docs.ts +98 -4
- package/apps/studio/sync/status.ts +25 -0
- package/apps/studio/sync/tombstone-apply.ts +131 -0
- package/apps/studio/test/cloud-managed-save-surfaces.test.ts +90 -0
- package/apps/studio/test/exporters/jobs.test.ts +10 -4
- package/apps/studio/test/git-cloud-posture.test.ts +11 -2
- package/apps/studio/test/sync-asset-pull.test.ts +161 -0
- package/apps/studio/test/sync-asset-push.test.ts +144 -3
- package/apps/studio/test/sync-attach-incremental.test.ts +527 -0
- package/apps/studio/test/sync-file-membership.test.ts +331 -0
- package/apps/studio/test/sync-file-pull.test.ts +333 -0
- package/apps/studio/test/sync-fresh-link-parity.test.ts +267 -0
- package/apps/studio/test/sync-panel-surface.test.ts +10 -0
- package/apps/studio/test/sync-remote-docs.test.ts +118 -8
- package/apps/studio/test/sync-resync-routes.test.ts +43 -0
- package/apps/studio/test/sync-status.test.ts +18 -0
- package/apps/studio/test/sync-supervisor.test.ts +4 -0
- package/apps/studio/test/sync-tombstone-apply.test.ts +111 -0
- package/apps/studio/test/sync-two-peer-discovery.test.ts +343 -0
- package/apps/studio/whats-new.json +18 -0
- package/cli/commands/doctor.mjs +141 -6
- package/cli/lib/gitignore-drift.mjs +149 -0
- package/cli/lib/gitignore-drift.test.mjs +156 -0
- package/package.json +8 -8
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Continuous canvas discovery — the decision half.
|
|
2
|
+
//
|
|
3
|
+
// THE BUG THIS EXISTS TO END. `createSyncRuntime.start()` enumerated a project
|
|
4
|
+
// exactly once: `scanCanvases` for the local disk, `GET /api/documents` for the
|
|
5
|
+
// hub, then one provider per canvas. After that loop nothing could join the
|
|
6
|
+
// runtime. A canvas created a second later — by the person, by `/design:new`,
|
|
7
|
+
// by a peer, by `git checkout` — was invisible to sync until the whole runtime
|
|
8
|
+
// was cycled (the Resync button, or an app restart).
|
|
9
|
+
//
|
|
10
|
+
// That read as a one-way sync, and the asymmetry was an artifact of WHO
|
|
11
|
+
// restarts. A desktop app is relaunched constantly, so a canvas made there
|
|
12
|
+
// eventually got picked up; a cloud cell is a container that stays up for days,
|
|
13
|
+
// so a canvas made there never did — no provider, therefore no Hocuspocus
|
|
14
|
+
// document, therefore not even a NAME in the listing the desktop polls. The
|
|
15
|
+
// same missing mechanism, one end of it just failed more visibly.
|
|
16
|
+
//
|
|
17
|
+
// WHY A FULL RESCAN RATHER THAN A DELTA. `canvas-list-watch.ts` already emits
|
|
18
|
+
// `canvas-list-update` with a `rel` and a `slug`, and it would be easy to build
|
|
19
|
+
// a descriptor from them. That file's own comment forbids it, and it is right:
|
|
20
|
+
// those values are ATTACKER-CONTROLLED (an agent-authored or `git checkout`-
|
|
21
|
+
// authored filename), and a descriptor is a set of paths the runtime then reads
|
|
22
|
+
// and writes. So the event is treated as a NUDGE ONLY — the authoritative set is
|
|
23
|
+
// recomputed by the same `scanCanvases` boot uses, and this module just diffs
|
|
24
|
+
// the result. A rescan also gets rename, move, a flipped `syncable: false` and a
|
|
25
|
+
// newly-declared canvas group right, all of which a single-path delta gets wrong.
|
|
26
|
+
|
|
27
|
+
/** What changed between the runtime's current membership and a fresh scan. */
|
|
28
|
+
export interface CanvasSetDiff {
|
|
29
|
+
/** Slugs present in the scan that the runtime has not attached. */
|
|
30
|
+
added: string[];
|
|
31
|
+
/** Slugs the runtime holds that the scan no longer offers. */
|
|
32
|
+
removed: string[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Diff a fresh scan against what the runtime currently owns.
|
|
37
|
+
*
|
|
38
|
+
* `attached` is the runtime's live membership (agents + projections), NOT the
|
|
39
|
+
* boot set — a canvas pulled down from the hub mid-session is attached and must
|
|
40
|
+
* therefore not be re-added on the next rescan.
|
|
41
|
+
*
|
|
42
|
+
* PULLED CANVASES ARE NEVER "REMOVED" BY THIS. A canvas that arrived from the
|
|
43
|
+
* hub may legitimately be absent from a local scan for a moment (its body is
|
|
44
|
+
* written after the handshake), and dropping it would tear down the provider
|
|
45
|
+
* that is in the middle of materialising it. The caller passes those slugs in
|
|
46
|
+
* `keep` and they are excluded from `removed` — being on the hub is reason
|
|
47
|
+
* enough to stay attached.
|
|
48
|
+
*/
|
|
49
|
+
export function diffCanvasSet(
|
|
50
|
+
attached: Iterable<string>,
|
|
51
|
+
scanned: Iterable<string>,
|
|
52
|
+
keep: Iterable<string> = []
|
|
53
|
+
): CanvasSetDiff {
|
|
54
|
+
const have = new Set(attached);
|
|
55
|
+
const want = new Set(scanned);
|
|
56
|
+
const pinned = new Set(keep);
|
|
57
|
+
const added: string[] = [];
|
|
58
|
+
const removed: string[] = [];
|
|
59
|
+
for (const slug of want) if (!have.has(slug)) added.push(slug);
|
|
60
|
+
for (const slug of have) if (!want.has(slug) && !pinned.has(slug)) removed.push(slug);
|
|
61
|
+
added.sort();
|
|
62
|
+
removed.sort();
|
|
63
|
+
return { added, removed };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export interface RescanScheduler {
|
|
67
|
+
/** Ask for a rescan. Coalesces every call inside the debounce window. */
|
|
68
|
+
schedule(): void;
|
|
69
|
+
/** Run now, awaiting any rescan already in flight. Test seam. */
|
|
70
|
+
flush(): Promise<void>;
|
|
71
|
+
stop(): void;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export interface RescanSchedulerOptions {
|
|
75
|
+
debounceMs: number;
|
|
76
|
+
run: () => Promise<void>;
|
|
77
|
+
setTimer?: (cb: () => void, ms: number) => ReturnType<typeof setTimeout>;
|
|
78
|
+
clearTimer?: (h: ReturnType<typeof setTimeout>) => void;
|
|
79
|
+
/** Reported failures — never thrown, a rescan that failed must not kill sync. */
|
|
80
|
+
onError?: (err: unknown) => void;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Debounce + serialize the rescans.
|
|
85
|
+
*
|
|
86
|
+
* Both properties are load-bearing, for different reasons. DEBOUNCE: creating a
|
|
87
|
+
* canvas writes a `.tsx` and then a `.meta.json`, and a `git checkout` touches
|
|
88
|
+
* hundreds of files — one scan per quiet window, not per write. SERIALIZE: two
|
|
89
|
+
* overlapping rescans would each diff against a membership the other is
|
|
90
|
+
* changing, and both would try to adopt the same slug.
|
|
91
|
+
*
|
|
92
|
+
* Mirrors `canvas-list-watch.ts`'s own chain-and-debounce shape deliberately, so
|
|
93
|
+
* the two watchers are reviewable side by side rather than being two different
|
|
94
|
+
* answers to one question.
|
|
95
|
+
*/
|
|
96
|
+
export function createRescanScheduler(opts: RescanSchedulerOptions): RescanScheduler {
|
|
97
|
+
const setTimer = opts.setTimer ?? ((cb, ms) => setTimeout(cb, ms));
|
|
98
|
+
const clearTimer = opts.clearTimer ?? ((h) => clearTimeout(h));
|
|
99
|
+
let pending: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
let chain: Promise<void> = Promise.resolve();
|
|
101
|
+
let stopped = false;
|
|
102
|
+
|
|
103
|
+
const runOnce = async (): Promise<void> => {
|
|
104
|
+
if (stopped) return;
|
|
105
|
+
try {
|
|
106
|
+
await opts.run();
|
|
107
|
+
} catch (err) {
|
|
108
|
+
opts.onError?.(err);
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
function enqueue(): Promise<void> {
|
|
113
|
+
chain = chain.then(runOnce, runOnce);
|
|
114
|
+
return chain;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
return {
|
|
118
|
+
schedule() {
|
|
119
|
+
if (stopped) return;
|
|
120
|
+
if (pending) clearTimer(pending);
|
|
121
|
+
pending = setTimer(() => {
|
|
122
|
+
pending = null;
|
|
123
|
+
void enqueue();
|
|
124
|
+
}, opts.debounceMs);
|
|
125
|
+
},
|
|
126
|
+
flush() {
|
|
127
|
+
if (pending) {
|
|
128
|
+
clearTimer(pending);
|
|
129
|
+
pending = null;
|
|
130
|
+
}
|
|
131
|
+
return enqueue();
|
|
132
|
+
},
|
|
133
|
+
stop() {
|
|
134
|
+
stopped = true;
|
|
135
|
+
if (pending) clearTimer(pending);
|
|
136
|
+
pending = null;
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
}
|
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
// The file plane's WHOLE membership policy — one positive classifier.
|
|
2
|
+
//
|
|
3
|
+
// Sync's unit used to be a canvas, and a file travelled iff some canvas
|
|
4
|
+
// claimed it by name. A fresh link of a real project delivered 79/79 canvases
|
|
5
|
+
// and lost 103 files — the design system's assets, its token stylesheets,
|
|
6
|
+
// `_brand-css.ts` (→ `TypeError: Importing a module script failed`), both
|
|
7
|
+
// docs (RCA: issue-fresh-link-gets-canvases-but-not-the-design-system). Every
|
|
8
|
+
// fix so far added a per-file-kind lane; the growth was the bug. This module
|
|
9
|
+
// replaces the taxonomy: membership in the manifest-driven file plane
|
|
10
|
+
// (Plane B) is decided HERE, positively, and nowhere else.
|
|
11
|
+
//
|
|
12
|
+
// BREAKER's honest test, from the binding debate
|
|
13
|
+
// (kg: maude/sync-two-plane-manifest-architecture): "That manifest collapses
|
|
14
|
+
// into whole-folder with extra steps — if the classifier ends up as
|
|
15
|
+
// everything-except-the-exclusion-regex, I have paid a manifest complexity
|
|
16
|
+
// for zero safety. The honest test before building: can the team enumerate
|
|
17
|
+
// the versioned classes POSITIVELY?" This module IS that enumeration:
|
|
18
|
+
//
|
|
19
|
+
// canvas-owned → Plane A (the per-canvas Yjs CRDT docs), NEVER Plane B.
|
|
20
|
+
// A canvas body (`.tsx` inside a canvas group) and its
|
|
21
|
+
// named sidecars (`.meta.json`, the same-named sibling
|
|
22
|
+
// `.css`, `.annotations.svg`). Plane disjointness is
|
|
23
|
+
// enforced at the SOURCE — these never enter a manifest —
|
|
24
|
+
// and tested, because a second transport under a CRDT lane
|
|
25
|
+
// is how a converged edit gets clobbered by a stale copy.
|
|
26
|
+
// inert-media → images / fonts / video / audio / svg. Flows freely.
|
|
27
|
+
// companion-text → css / md. Flows freely.
|
|
28
|
+
// code-module → ts / tsx / js / mjs outside canvas bodies. Flows ONLY
|
|
29
|
+
// through the owner-hub gate: the receiver admits it when
|
|
30
|
+
// its STORED hub record says `role === 'owner'` (or the
|
|
31
|
+
// hub is the loopback cell pairing) — never on a
|
|
32
|
+
// hub-supplied claim.
|
|
33
|
+
// never → `config.json` at the design root (it names the hub URL
|
|
34
|
+
// and the canvas groups — a synced config is a hub
|
|
35
|
+
// rewriting its own trust anchors), everything the DDR-115
|
|
36
|
+
// runtime-state taxonomy matches, and EVERYTHING not
|
|
37
|
+
// positively claimed above. Default-closed: an extension
|
|
38
|
+
// not listed here does not travel.
|
|
39
|
+
//
|
|
40
|
+
// THE RECEIVER RE-VALIDATES EVERY PATH (ATTACKER's invariant, same debate):
|
|
41
|
+
// a hub-supplied `class` field is a hint for reporting, never authority —
|
|
42
|
+
// each side classifies against its OWN tree and config before a byte moves.
|
|
43
|
+
//
|
|
44
|
+
// ⚠ MIRRORED in `apps/hub/src/file-membership.mjs` (the doc-namespace
|
|
45
|
+
// precedent). The hub image installs frozen against its own bun.lock and must
|
|
46
|
+
// not reach into apps/studio, so the logic is duplicated rather than shared,
|
|
47
|
+
// and the two are pinned to each other by
|
|
48
|
+
// `test/sync-file-membership.test.ts`, which imports the hub's `.mjs` and
|
|
49
|
+
// asserts both agree over an adversarial corpus. Change one, change the other.
|
|
50
|
+
//
|
|
51
|
+
// ⚠ FOURTH COPY, WITH A TRIPWIRE. `isRuntimeStateRel` below replicates
|
|
52
|
+
// `git/service.ts` `isMaudeRuntimeState` instead of importing it — importing
|
|
53
|
+
// would drag the git surface into the hub mirror's parity story. Three copies
|
|
54
|
+
// of the DDR-115 list already exist (git/service.ts, cli/lib/
|
|
55
|
+
// gitignore-block.mjs, the repo .gitignore) and they have drifted silently
|
|
56
|
+
// before; this copy is pinned by a test that imports BOTH and asserts
|
|
57
|
+
// agreement on a fixture list, which turns the drift into a unit-testable
|
|
58
|
+
// bug. The debate accepted the 4th copy knowingly, on that condition.
|
|
59
|
+
|
|
60
|
+
/** A `canvasGroups[]` entry, as loose as the config actually is — mirrors
|
|
61
|
+
* `canvas-path.ts`'s `CanvasGroupLike` (declared locally so this module,
|
|
62
|
+
* like its hub mirror, is dependency-free). */
|
|
63
|
+
export interface CanvasGroupLike {
|
|
64
|
+
path?: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export type FileClass = 'canvas-owned' | 'inert-media' | 'companion-text' | 'code-module' | 'never';
|
|
68
|
+
|
|
69
|
+
/** The classes the file plane actually carries. `canvas-owned` is Plane A's;
|
|
70
|
+
* `never` is nobody's. */
|
|
71
|
+
export const FILE_PLANE_CLASSES = ['inert-media', 'companion-text', 'code-module'] as const;
|
|
72
|
+
export type FilePlaneClass = (typeof FILE_PLANE_CLASSES)[number];
|
|
73
|
+
|
|
74
|
+
export function isFilePlaneClass(c: FileClass): c is FilePlaneClass {
|
|
75
|
+
return c === 'inert-media' || c === 'companion-text' || c === 'code-module';
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Max relative-path length (matches the hub's checkout shape cap). */
|
|
79
|
+
export const MAX_REL_LEN = 512;
|
|
80
|
+
|
|
81
|
+
/** Max designRoot-relative depth (matches the hub's 8-segment cap). */
|
|
82
|
+
export const MAX_SEGMENTS = 8;
|
|
83
|
+
|
|
84
|
+
// The positive extension enumerations. Everything is lowercase; the lookup
|
|
85
|
+
// lowercases first — a DS legitimately ships `…P1020428.JPG`.
|
|
86
|
+
const INERT_MEDIA_EXTS = new Set([
|
|
87
|
+
'png',
|
|
88
|
+
'jpg',
|
|
89
|
+
'jpeg',
|
|
90
|
+
'webp',
|
|
91
|
+
'gif',
|
|
92
|
+
'avif',
|
|
93
|
+
'svg',
|
|
94
|
+
'mp4',
|
|
95
|
+
'webm',
|
|
96
|
+
'mov',
|
|
97
|
+
'm4v',
|
|
98
|
+
'mp3',
|
|
99
|
+
'wav',
|
|
100
|
+
'm4a',
|
|
101
|
+
'aac',
|
|
102
|
+
'ogg',
|
|
103
|
+
'woff2',
|
|
104
|
+
'woff',
|
|
105
|
+
'ttf',
|
|
106
|
+
'otf',
|
|
107
|
+
]);
|
|
108
|
+
const COMPANION_TEXT_EXTS = new Set(['css', 'md', 'srt']);
|
|
109
|
+
const CODE_MODULE_EXTS = new Set(['ts', 'tsx', 'js', 'mjs']);
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Maude's OWN sidecar vocabulary, positively enumerated by full suffix —
|
|
113
|
+
* never bare `.json` (that stays default-closed; a manifest must not be able
|
|
114
|
+
* to land arbitrary json, least of all a config). Found by the Task-12
|
|
115
|
+
* acceptance run on the real alligators tree: `assets/<sha8>.photo.json`
|
|
116
|
+
* (non-destructive photo edits) and `assets/<sha8>.audio.json` are versioned
|
|
117
|
+
* content (DDR-115 does not ignore them), and without a lane the second
|
|
118
|
+
* machine silently loses every photo edit — the exact bug class this module
|
|
119
|
+
* exists to end.
|
|
120
|
+
*/
|
|
121
|
+
const COMPANION_SIDECAR_SUFFIXES = ['.photo.json', '.audio.json'];
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* A DIRECTORY segment must start alphanumeric — the same rule as the hub's
|
|
125
|
+
* `checkoutRelShape`, and the rule that keeps every `_*` runtime DIRECTORY
|
|
126
|
+
* (`_history/`, `_untrusted/`, `_trash/`, …) out structurally, before the
|
|
127
|
+
* explicit runtime-state check even runs.
|
|
128
|
+
*/
|
|
129
|
+
const DIR_SEGMENT = /^[A-Za-z0-9][A-Za-z0-9 ._-]*$/;
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* The FINAL segment may additionally start with `_` — this is the one
|
|
133
|
+
* deliberate relaxation over `checkoutRelShape`, and the reason the paired
|
|
134
|
+
* refusal below (`isRuntimeStateRel`) lands in the same module, same commit:
|
|
135
|
+
* real versioned FILES like `_brand-css.ts` and `preview/_layout.css` start
|
|
136
|
+
* with an underscore, and refusing the underscore wholesale is exactly the
|
|
137
|
+
* DDR-115 shape accident that left them laneless. A leading dot stays
|
|
138
|
+
* refused — `.DS_Store` and dotfiles are not project content.
|
|
139
|
+
*/
|
|
140
|
+
const FILE_SEGMENT = /^[A-Za-z0-9_][A-Za-z0-9 ._-]*$/;
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Maude's own per-machine runtime state — the DDR-115 taxonomy, replicated
|
|
144
|
+
* byte-for-byte from `git/service.ts` `isMaudeRuntimeState` (see the 4th-copy
|
|
145
|
+
* tripwire note in the header; the parity test imports both and asserts
|
|
146
|
+
* agreement). These never travel in EITHER direction, no matter what their
|
|
147
|
+
* extension says.
|
|
148
|
+
*/
|
|
149
|
+
export function isRuntimeStateRel(p: string): boolean {
|
|
150
|
+
return (
|
|
151
|
+
/(^|\/)_(?:server|active|sync|preflight|locator|export-history|generate-history)(?:\.[A-Za-z0-9_-]{1,64})?\.json$/.test(
|
|
152
|
+
p
|
|
153
|
+
) ||
|
|
154
|
+
/(^|\/)_server\.(?:lock|log)$/.test(p) ||
|
|
155
|
+
/(^|\/)_(?:history|trash|draw|photo|smoke|reports|canvas-state|state|chat|comments|untrusted|export-jobs)(?:\/|$)/.test(
|
|
156
|
+
p
|
|
157
|
+
) ||
|
|
158
|
+
/(^|\/)\.kgai(?:\/|$)/.test(p)
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
export interface ClassifyOptions {
|
|
163
|
+
/** Declared canvas groups. Absent ⇒ the same `['system', 'ui']` default the
|
|
164
|
+
* canvas-path receiver uses — the two halves of sync must agree on what a
|
|
165
|
+
* group is, or a body one lane owns leaks into the other. */
|
|
166
|
+
canvasGroups?: readonly CanvasGroupLike[];
|
|
167
|
+
/**
|
|
168
|
+
* Presence probe over the SAME tree `rel` came from. Powers the ONE
|
|
169
|
+
* sibling-dependent split: a `.css` inside a canvas group is canvas-owned
|
|
170
|
+
* when `<same-name>.tsx` exists (it is that canvas's Yjs css lane), and
|
|
171
|
+
* companion-text when it does not (`brand.css`, `_layout.css` — the RCA's
|
|
172
|
+
* missing stylesheets). This cannot be decided from the path alone, and
|
|
173
|
+
* both misreadings are wrong: "all group css is canvas-owned" re-loses the
|
|
174
|
+
* RCA's five files, "all group css flows" double-transports a CRDT lane.
|
|
175
|
+
* Absent ⇒ companion-text (the flowing side) — safe because every RECEIVER
|
|
176
|
+
* passes its own disk probe and re-refuses what its tree shows is a
|
|
177
|
+
* sidecar.
|
|
178
|
+
*/
|
|
179
|
+
hasFile?: (rel: string) => boolean;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/**
|
|
183
|
+
* Classify one designRoot-relative path. Total: every input gets a class, and
|
|
184
|
+
* every malformed input gets `never` — shape refusal and policy refusal are
|
|
185
|
+
* deliberately the same answer, so no caller can tell them apart and leak an
|
|
186
|
+
* oracle.
|
|
187
|
+
*
|
|
188
|
+
* Shape gates (all refusals → `never`): relative, `/`-separated, ≤ 8
|
|
189
|
+
* segments, ≤ 512 chars, no `..`/`.`/empty segment, no backslash, no drive
|
|
190
|
+
* letter, no control characters, no trailing-space segment, no
|
|
191
|
+
* `node_modules`, directory segments start alphanumeric, the final segment
|
|
192
|
+
* may start with `_`.
|
|
193
|
+
*/
|
|
194
|
+
export function classifyProjectFile(rel: string, opts: ClassifyOptions = {}): FileClass {
|
|
195
|
+
const parts = relShape(rel);
|
|
196
|
+
if (parts === null) return 'never';
|
|
197
|
+
|
|
198
|
+
// The design root's own `config.json` names the linked hub and the canvas
|
|
199
|
+
// groups — a peer that syncs it hands naming authority to the hub.
|
|
200
|
+
if (rel === 'config.json') return 'never';
|
|
201
|
+
if (isRuntimeStateRel(rel)) return 'never';
|
|
202
|
+
|
|
203
|
+
const last = parts[parts.length - 1] ?? '';
|
|
204
|
+
const lowerLast = last.toLowerCase();
|
|
205
|
+
const lowerRel = rel.toLowerCase();
|
|
206
|
+
const inGroup = normalizedGroups(opts.canvasGroups).some((g) =>
|
|
207
|
+
lowerRel.startsWith(`${g.toLowerCase()}/`)
|
|
208
|
+
);
|
|
209
|
+
|
|
210
|
+
if (inGroup) {
|
|
211
|
+
// The canvas body and its NAMED sidecars — Plane A's, by construction.
|
|
212
|
+
if (lowerLast.endsWith('.tsx')) return 'canvas-owned';
|
|
213
|
+
if (lowerLast.endsWith('.meta.json')) return 'canvas-owned';
|
|
214
|
+
if (lowerLast.endsWith('.annotations.svg')) return 'canvas-owned';
|
|
215
|
+
if (lowerLast.endsWith('.css') && opts.hasFile) {
|
|
216
|
+
const sibling = `${rel.slice(0, -'.css'.length)}.tsx`;
|
|
217
|
+
if (opts.hasFile(sibling)) return 'canvas-owned';
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (COMPANION_SIDECAR_SUFFIXES.some((s) => lowerLast.endsWith(s))) return 'companion-text';
|
|
222
|
+
|
|
223
|
+
const dot = lowerLast.lastIndexOf('.');
|
|
224
|
+
const ext = dot < 0 ? '' : lowerLast.slice(dot + 1);
|
|
225
|
+
if (INERT_MEDIA_EXTS.has(ext)) return 'inert-media';
|
|
226
|
+
if (COMPANION_TEXT_EXTS.has(ext)) return 'companion-text';
|
|
227
|
+
if (CODE_MODULE_EXTS.has(ext)) return 'code-module';
|
|
228
|
+
|
|
229
|
+
// Default-closed. Not an error — the answer.
|
|
230
|
+
return 'never';
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* The shape gate alone — true when `rel` could name a project file at all.
|
|
235
|
+
*
|
|
236
|
+
* Exposed for surfaces that must parse BEFORE they can classify: the hub's
|
|
237
|
+
* PUT route parses the URL before it knows the checkout's canvas groups, and
|
|
238
|
+
* a parse-stage refusal there is final (the request falls through), so it may
|
|
239
|
+
* refuse only what NO configuration could ever admit — the shape.
|
|
240
|
+
*/
|
|
241
|
+
export function isProjectFileShape(rel: string): boolean {
|
|
242
|
+
return relShape(rel) !== null;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** The segment shape rules alone — split parts, or null on refusal. */
|
|
246
|
+
function relShape(rel: unknown): string[] | null {
|
|
247
|
+
if (typeof rel !== 'string' || rel.length === 0 || rel.length > MAX_REL_LEN) return null;
|
|
248
|
+
// biome-ignore lint/suspicious/noControlCharactersInRegex: refusing them is the point.
|
|
249
|
+
if (/[\u0000-\u001f\u007f]/.test(rel)) return null;
|
|
250
|
+
if (rel.startsWith('/') || rel.includes('\\') || /^[A-Za-z]:/.test(rel)) return null;
|
|
251
|
+
const parts = rel.split('/');
|
|
252
|
+
if (parts.length > MAX_SEGMENTS) return null;
|
|
253
|
+
for (let i = 0; i < parts.length; i++) {
|
|
254
|
+
const p = parts[i] ?? '';
|
|
255
|
+
if (!p || p === '.' || p === '..') return null;
|
|
256
|
+
if (p === 'node_modules') return null;
|
|
257
|
+
if (/ $/.test(p)) return null;
|
|
258
|
+
if (!(i === parts.length - 1 ? FILE_SEGMENT : DIR_SEGMENT).test(p)) return null;
|
|
259
|
+
}
|
|
260
|
+
return parts;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Declared group paths, normalised — the same semantics (and the same
|
|
265
|
+
* `['system', 'ui']` fallback) as `canvas-path.ts`'s private helper, re-typed
|
|
266
|
+
* here because this module must load dependency-free in the hub's plain-Node
|
|
267
|
+
* runtime through its `.mjs` mirror.
|
|
268
|
+
*/
|
|
269
|
+
function normalizedGroups(canvasGroups?: readonly CanvasGroupLike[]): string[] {
|
|
270
|
+
const out: string[] = [];
|
|
271
|
+
for (const g of canvasGroups ?? []) {
|
|
272
|
+
const p = normalizeGroup(g?.path);
|
|
273
|
+
if (p && !out.includes(p)) out.push(p);
|
|
274
|
+
}
|
|
275
|
+
return out.length > 0 ? out : ['system', 'ui'];
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** One group path, or null when unusable as a containment prefix (stricter is
|
|
279
|
+
* the safe direction — an escaping group is dropped, not clamped). */
|
|
280
|
+
function normalizeGroup(raw: unknown): string | null {
|
|
281
|
+
if (typeof raw !== 'string') return null;
|
|
282
|
+
const p = raw.replace(/\\/g, '/').replace(/^\/+|\/+$/g, '');
|
|
283
|
+
if (!p) return null;
|
|
284
|
+
if (/^[A-Za-z]:/.test(p)) return null;
|
|
285
|
+
for (const part of p.split('/')) {
|
|
286
|
+
if (!part || part === '.' || part === '..') return null;
|
|
287
|
+
if (!/^[A-Za-z0-9_-][A-Za-z0-9 _-]*(?![\s\S])/.test(part)) return null;
|
|
288
|
+
}
|
|
289
|
+
return p;
|
|
290
|
+
}
|