@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,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
+ }