@savvy-web/silk-effects 5.3.1 → 5.5.0

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.
@@ -1,9 +1,25 @@
1
+ import { RepoNote } from "./manifest.js";
1
2
  import { Schema } from "effect";
2
3
 
3
4
  //#region src/repos/schemas/reports.ts
4
5
  /**
5
6
  * Status of one vendored repo: gitlink presence and dirtiness, plus notes
6
7
  * that no longer match the pinned ref.
8
+ *
9
+ * @remarks
10
+ * `commit` is an alias of `stagedCommit`, retained for one release so an
11
+ * existing consumer reading `entry.commit` keeps working while it migrates to
12
+ * the index-aware triple. It carries no independent release tag beyond
13
+ * `@public` (this schema has no narrower audience to gate it behind).
14
+ * Removal is scheduled by a tracked issue, not gated on a renderer migration
15
+ * precondition here.
16
+ *
17
+ * Not fully behavior-preserving: `commit: stagedCommit ?? null` reads `null`
18
+ * for a gitlink committed at `HEAD` but staged for REMOVAL, where the prior
19
+ * single-`commit` field showed the committed oid. `stagedCommit` is `None`
20
+ * in exactly that case (nothing is staged), so the alias reports "nothing
21
+ * staged" rather than "here is what HEAD still has" — a real, if narrow,
22
+ * difference from the pre-triple `commit` field's behavior.
7
23
  * @public
8
24
  */
9
25
  const RepoStatusEntry = Schema.Struct({
@@ -11,7 +27,14 @@ const RepoStatusEntry = Schema.Struct({
11
27
  ref: Schema.String,
12
28
  purpose: Schema.String,
13
29
  present: Schema.Boolean,
30
+ /** @deprecated alias of `stagedCommit`; retained for one release. */
14
31
  commit: Schema.NullOr(Schema.String),
32
+ /** The gitlink oid staged in the index (`git ls-files --stage`) — sees a pin BEFORE it is committed. */
33
+ stagedCommit: Schema.optionalKey(Schema.String),
34
+ /** The gitlink oid committed at `HEAD` (`git ls-tree HEAD`). */
35
+ committedCommit: Schema.optionalKey(Schema.String),
36
+ /** The commit actually checked out in the submodule worktree (`git rev-parse HEAD` inside it); absent when there is no checkout. */
37
+ checkedOutCommit: Schema.optionalKey(Schema.String),
15
38
  dirty: Schema.Boolean,
16
39
  staleNoteIds: Schema.Array(Schema.String)
17
40
  });
@@ -26,14 +49,17 @@ const ReposStatusReport = Schema.Struct({
26
49
  /**
27
50
  * Result of reconciling working-tree submodules with the manifest: missing
28
51
  * repos initialized, sparse-checkout patterns re-applied, already-present
29
- * repos left alone, and stale locks cleared.
52
+ * repos left alone, stale locks cleared, drifted submodule URLs reconciled,
53
+ * and orphan manifest entries (no gitlink at all) registered.
30
54
  * @public
31
55
  */
32
56
  const ReposSyncReport = Schema.Struct({
33
57
  initialized: Schema.Array(Schema.String),
34
58
  sparseApplied: Schema.Array(Schema.String),
35
59
  upToDate: Schema.Array(Schema.String),
36
- clearedLocks: Schema.Array(Schema.String)
60
+ clearedLocks: Schema.Array(Schema.String),
61
+ urlSynced: Schema.Array(Schema.String),
62
+ registered: Schema.Array(Schema.String)
37
63
  });
38
64
  /**
39
65
  * Result of re-pinning a vendored repo to a new ref.
@@ -57,6 +83,51 @@ const ReposAddResult = Schema.Struct({
57
83
  path: Schema.String
58
84
  });
59
85
  /**
86
+ * Result of removing a vendored repo from the manifest: the gitlink, module
87
+ * gitdir, and `.gitmodules` section are all gone, and the entry's notes are
88
+ * surfaced so any durable ones can be promoted elsewhere before this result
89
+ * is committed.
90
+ * @public
91
+ */
92
+ const ReposRemoveResult = Schema.Struct({
93
+ name: Schema.String,
94
+ path: Schema.String,
95
+ commitMessage: Schema.String,
96
+ removedNotes: Schema.Array(RepoNote)
97
+ });
98
+ /**
99
+ * Result of renaming a vendored repo's manifest key: the `.repos/<name>`
100
+ * worktree moved, the module gitdir's `core.worktree` values re-pointed, the
101
+ * `.gitmodules` section canonicalized to the new name, and the manifest key
102
+ * renamed.
103
+ * @public
104
+ */
105
+ const ReposRenameResult = Schema.Struct({
106
+ oldName: Schema.String,
107
+ newName: Schema.String,
108
+ path: Schema.String,
109
+ commitMessage: Schema.String
110
+ });
111
+ /**
112
+ * Result of hard-resetting one or more vendored repos to their staged (or
113
+ * committed) gitlink commit and re-applying sparse-checkout paths.
114
+ *
115
+ * @remarks
116
+ * `restored` lists every repo actually reset, paired with the commit it was
117
+ * reset to. `skippedClean` is populated ONLY by the names-omitted form of
118
+ * {@link ReposManagerShape.restore} — repos left untouched because `status`
119
+ * reported them clean; an explicit-names call never skips anything (an
120
+ * explicit ask is always honored), so it always reports an empty array.
121
+ * @public
122
+ */
123
+ const ReposRestoreResult = Schema.Struct({
124
+ restored: Schema.Array(Schema.Struct({
125
+ name: Schema.String,
126
+ commit: Schema.String
127
+ })),
128
+ skippedClean: Schema.Array(Schema.String)
129
+ });
130
+ /**
60
131
  * Result of an agent-note mutation against a vendored repo.
61
132
  * @public
62
133
  */
@@ -72,4 +143,4 @@ const ReposNoteResult = Schema.Struct({
72
143
  });
73
144
 
74
145
  //#endregion
75
- export { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposStatusReport, ReposSyncReport };
146
+ export { RepoStatusEntry, ReposAddResult, ReposNoteResult, ReposPinResult, ReposRemoveResult, ReposRenameResult, ReposRestoreResult, ReposStatusReport, ReposSyncReport };
@@ -1,7 +1,7 @@
1
1
  import { MANIFEST_PATH, REPOS_DIR } from "../constants.js";
2
2
  import { ReposConfigError } from "../errors.js";
3
3
  import { ReposManifestFile } from "../schemas/manifest.js";
4
- import { Context, Effect, FileSystem, Layer, Path, Schema } from "effect";
4
+ import { Clock, Context, Effect, FileSystem, Layer, Option, Path, Schedule, Schema } from "effect";
5
5
 
6
6
  //#region src/repos/services/config-store.ts
7
7
  /**
@@ -71,10 +71,44 @@ var ReposConfigStore = class extends Context.Service()("@savvy-web/silk-effects/
71
71
  kind: "invalid"
72
72
  })));
73
73
  });
74
+ const lockSchedule = Schedule.exponential("25 millis").pipe(Schedule.upTo({ duration: "2 seconds" }));
75
+ const LOCK_MAX_AGE_MS = 6e4;
76
+ const acquireLock = (root) => Effect.gen(function* () {
77
+ const lockPath = `${manifestPath(root)}.lock`;
78
+ const dir = path.join(root, REPOS_DIR);
79
+ yield* fs.makeDirectory(dir, { recursive: true }).pipe(Effect.mapError((cause) => new ReposConfigError({
80
+ path: lockPath,
81
+ reason: `mkdir failed: ${String(cause)}`,
82
+ kind: "invalid"
83
+ })));
84
+ yield* Effect.gen(function* () {
85
+ const info = yield* fs.stat(lockPath).pipe(Effect.option);
86
+ if (Option.isSome(info) && Option.isSome(info.value.mtime)) {
87
+ if ((yield* Clock.currentTimeMillis) - info.value.mtime.value.getTime() >= LOCK_MAX_AGE_MS) yield* fs.remove(lockPath).pipe(Effect.ignore);
88
+ }
89
+ yield* Effect.scoped(fs.open(lockPath, { flag: "wx" })).pipe(Effect.asVoid);
90
+ }).pipe(Effect.retry({
91
+ schedule: lockSchedule,
92
+ while: (error) => error.reason._tag === "AlreadyExists"
93
+ }), Effect.mapError((cause) => new ReposConfigError({
94
+ path: lockPath,
95
+ reason: `timed out acquiring lock after 2s at ${lockPath}: ${String(cause)}. If no other savvy process is running, a previous run likely crashed while holding it -- remove ${lockPath} and retry.`,
96
+ kind: "invalid"
97
+ })));
98
+ });
99
+ const releaseLock = (root) => fs.remove(`${manifestPath(root)}.lock`).pipe(Effect.ignore);
100
+ const update = (root, fn) => Effect.scoped(Effect.gen(function* () {
101
+ yield* Effect.acquireRelease(acquireLock(root), () => releaseLock(root));
102
+ const result = fn(yield* read(root).pipe(Effect.catchTag("ReposConfigError", (error) => error.kind === "missing" ? Effect.succeed({ repos: {} }) : Effect.fail(error))));
103
+ const next = Effect.isEffect(result) ? yield* result : result;
104
+ yield* write(root, next);
105
+ return next;
106
+ }));
74
107
  return {
75
108
  exists,
76
109
  read,
77
- write
110
+ write,
111
+ update
78
112
  };
79
113
  }));
80
114
  };
@@ -0,0 +1,168 @@
1
+ import { REPOS_DIR } from "../constants.js";
2
+ import { GitSubmoduleError } from "../errors.js";
3
+ import { RepoDrift, ReposDriftReport } from "../schemas/drift.js";
4
+ import { ReposConfigStore } from "./config-store.js";
5
+ import { Context, Effect, FileSystem, Layer, Option, Path, Result } from "effect";
6
+ import { Git, Gitmodules } from "@effected/git";
7
+
8
+ //#region src/repos/services/drift.ts
9
+ /**
10
+ * Reconciles the four authorities a vendored repo's state is spread across —
11
+ * the manifest, `.gitmodules`, the worktree, and `git submodule status` —
12
+ * and reports every disagreement found. Read-only: no staging, no lockdown
13
+ * interaction, so it runs unmodified against a locked (`ReposLockdown`)
14
+ * tree.
15
+ * @public
16
+ */
17
+ var ReposDrift = class extends Context.Service()("@savvy-web/silk-effects/ReposDrift") {
18
+ /**
19
+ * Production implementation of {@link ReposDrift}.
20
+ * @public
21
+ */
22
+ static layer = Layer.effect(this, Effect.gen(function* () {
23
+ const configStore = yield* ReposConfigStore;
24
+ const fs = yield* FileSystem.FileSystem;
25
+ const path = yield* Path.Path;
26
+ const git = yield* Git;
27
+ /** Map any typed `@effected/git` failure onto this module's `GitSubmoduleError`. */
28
+ const asSubmoduleError = (command, cwd) => (error) => new GitSubmoduleError({
29
+ command,
30
+ cwd,
31
+ reason: error.message
32
+ });
33
+ const isPresent = (repoPath) => fs.readDirectory(repoPath).pipe(Effect.map((files) => files.length > 0), Effect.orElseSucceed(() => false));
34
+ const check = (root) => Effect.gen(function* () {
35
+ const manifest = yield* configStore.read(root);
36
+ const manifestEntries = Object.entries(manifest.repos);
37
+ const gitmodulesPath = path.join(root, ".gitmodules");
38
+ const gitmodulesText = yield* fs.readFileString(gitmodulesPath).pipe(Effect.map(Option.some), Effect.catchTag("PlatformError", (error) => error.reason._tag === "NotFound" ? Effect.succeed(Option.none()) : Effect.fail(asSubmoduleError("read .gitmodules", gitmodulesPath)(error))));
39
+ if (Option.isNone(gitmodulesText)) {
40
+ const drifts = manifestEntries.map(([name, entry]) => RepoDrift.make({
41
+ name,
42
+ kind: "unregisteredManifestEntry",
43
+ detail: `manifest entry "${name}" has no corresponding .gitmodules section (.gitmodules is absent)`,
44
+ manifestValue: entry.url
45
+ }));
46
+ return ReposDriftReport.make({
47
+ drifts,
48
+ clean: drifts.length === 0
49
+ });
50
+ }
51
+ const parsed = Gitmodules.parseResult(gitmodulesText.value);
52
+ if (Result.isFailure(parsed)) return ReposDriftReport.make({
53
+ drifts: [RepoDrift.make({
54
+ name: ".gitmodules",
55
+ kind: "gitmodulesUnparsable",
56
+ detail: `.gitmodules failed to parse: ${parsed.failure.message}`
57
+ })],
58
+ clean: false
59
+ });
60
+ const gitmodules = parsed.success;
61
+ const statuses = yield* git.submoduleStatus(root).pipe(Effect.mapError(asSubmoduleError("git submodule status", root)));
62
+ const statusByPath = new Map(statuses.map((status) => [status.path, status]));
63
+ const drifts = [];
64
+ const matchedSectionNames = /* @__PURE__ */ new Set();
65
+ const pairedByName = /* @__PURE__ */ new Map();
66
+ for (const [name] of manifestEntries) {
67
+ const expectedPath = `${REPOS_DIR}/${name}`;
68
+ const nameMatch = gitmodules.entries.find((candidate) => candidate.name === expectedPath && !matchedSectionNames.has(candidate.name));
69
+ if (nameMatch) {
70
+ matchedSectionNames.add(nameMatch.name);
71
+ pairedByName.set(name, {
72
+ section: nameMatch,
73
+ viaPath: false
74
+ });
75
+ }
76
+ }
77
+ for (const [name] of manifestEntries) {
78
+ if (pairedByName.has(name)) continue;
79
+ const expectedPath = `${REPOS_DIR}/${name}`;
80
+ const pathMatch = gitmodules.entries.find((candidate) => candidate.path === expectedPath && !matchedSectionNames.has(candidate.name));
81
+ if (pathMatch) {
82
+ matchedSectionNames.add(pathMatch.name);
83
+ pairedByName.set(name, {
84
+ section: pathMatch,
85
+ viaPath: true
86
+ });
87
+ }
88
+ }
89
+ for (const [name, entry] of manifestEntries) {
90
+ const expectedPath = `${REPOS_DIR}/${name}`;
91
+ const paired = pairedByName.get(name);
92
+ if (!paired) {
93
+ drifts.push(RepoDrift.make({
94
+ name,
95
+ kind: "unregisteredManifestEntry",
96
+ detail: `manifest entry "${name}" has no corresponding .gitmodules section`,
97
+ manifestValue: entry.url
98
+ }));
99
+ continue;
100
+ }
101
+ const { section, viaPath } = paired;
102
+ const pathMatch = viaPath ? section : void 0;
103
+ if (pathMatch) drifts.push(RepoDrift.make({
104
+ name,
105
+ kind: "pathMismatch",
106
+ detail: `manifest entry "${name}" paired with .gitmodules section "${pathMatch.name}" by its path "${expectedPath}" -- the section name diverges from the manifest entry name`,
107
+ manifestValue: name,
108
+ observedValue: pathMatch.name
109
+ }));
110
+ else if (section.path !== expectedPath) drifts.push(RepoDrift.make({
111
+ name,
112
+ kind: "pathMismatch",
113
+ detail: `manifest entry "${name}" expects path "${expectedPath}" but .gitmodules records "${section.path}"`,
114
+ manifestValue: expectedPath,
115
+ observedValue: section.path
116
+ }));
117
+ if (section.url !== entry.url) drifts.push(RepoDrift.make({
118
+ name,
119
+ kind: "urlMismatch",
120
+ detail: `manifest entry "${name}" expects url "${entry.url}" but .gitmodules records "${section.url}"`,
121
+ manifestValue: entry.url,
122
+ observedValue: section.url
123
+ }));
124
+ if (section.shallow !== true) drifts.push(RepoDrift.make({
125
+ name,
126
+ kind: "missingShallow",
127
+ detail: `.gitmodules does not record submodule.${section.name}.shallow = true`,
128
+ observedValue: section.shallow === void 0 ? "unset" : String(section.shallow)
129
+ }));
130
+ const repoPath = path.join(root, section.path);
131
+ const present = yield* isPresent(repoPath);
132
+ const status = statusByPath.get(section.path);
133
+ if (!present || status?.state === "uninitialized") drifts.push(RepoDrift.make({
134
+ name,
135
+ kind: "missingWorktree",
136
+ detail: `manifest entry "${name}" expects a checked-out worktree at "${section.path}" but none is present`
137
+ }));
138
+ else if (status?.state === "outOfSync") drifts.push(RepoDrift.make({
139
+ name,
140
+ kind: "checkoutDiverged",
141
+ detail: `the worktree checked out at "${section.path}" no longer matches the commit recorded in the superproject index`
142
+ }));
143
+ else if (status?.state === "conflict") drifts.push(RepoDrift.make({
144
+ name,
145
+ kind: "checkoutDiverged",
146
+ detail: `submodule index entry for "${section.path}" is in an unresolved merge conflict`
147
+ }));
148
+ }
149
+ for (const section of gitmodules.entries) {
150
+ if (matchedSectionNames.has(section.name)) continue;
151
+ drifts.push(RepoDrift.make({
152
+ name: section.name,
153
+ kind: "orphanGitmodulesEntry",
154
+ detail: `.gitmodules section "${section.name}" has no corresponding manifest entry`,
155
+ observedValue: section.path
156
+ }));
157
+ }
158
+ return ReposDriftReport.make({
159
+ drifts,
160
+ clean: drifts.length === 0
161
+ });
162
+ });
163
+ return { check };
164
+ }));
165
+ };
166
+
167
+ //#endregion
168
+ export { ReposDrift };
@@ -0,0 +1,119 @@
1
+ import { REPOS_DIR } from "../constants.js";
2
+ import { ReposLockdownError } from "../errors.js";
3
+ import { Context, Effect, Exit, FileSystem, Layer, Option, Path } from "effect";
4
+
5
+ //#region src/repos/services/lockdown.ts
6
+ const FILE_LOCKED_BASE_MODE = 292;
7
+ const DIR_LOCKED_MODE = 365;
8
+ const FILE_UNLOCKED_BASE_MODE = 420;
9
+ const DIR_UNLOCKED_MODE = 493;
10
+ const EXEC_BITS = 73;
11
+ /**
12
+ * Preserves the executable bit through a lock/unlock transition: a file
13
+ * whose current mode has any of the owner/group/other execute bits set
14
+ * locks to `0o555` and unlocks to `0o755` instead of stripping execute
15
+ * permission entirely.
16
+ */
17
+ const fileModeFor = (baseMode, currentMode) => baseMode | (currentMode & EXEC_BITS ? EXEC_BITS : 0);
18
+ /**
19
+ * Derives a submodule's git metadata directory (`.git/modules/...`) from the
20
+ * checkout itself rather than assuming it is named after the manifest key.
21
+ *
22
+ * A submodule worktree's `<root>/.repos/<name>/.git` is normally a FILE
23
+ * containing a single `gitdir: <path>` line pointing at the real metadata
24
+ * directory, which git names after whatever path/name the submodule was
25
+ * REGISTERED under — not necessarily the manifest key (e.g. this repo's own
26
+ * `effect` entry has gitdir `.git/modules/.repos/effect-smol`). This helper
27
+ * reads that pointer and resolves it (relative pointers are relative to the
28
+ * directory containing the `.git` file) so callers always land on the real
29
+ * metadata directory.
30
+ *
31
+ * Falls back to the name-based path `<root>/.git/modules/<REPOS_DIR>/<name>`
32
+ * whenever the pointer can't be read (submodule not initialized, `.git`
33
+ * missing) — this never fails, it only degrades to prior behavior. If
34
+ * `<root>/.repos/<name>/.git` is itself a directory (a plain, non-submodule
35
+ * checkout), it is used directly as the metadata directory.
36
+ *
37
+ * @internal
38
+ */
39
+ const resolveModuleDir = (fs, path, root, name) => Effect.gen(function* () {
40
+ const fallback = path.join(root, ".git", "modules", REPOS_DIR, name);
41
+ const dotGit = path.join(root, REPOS_DIR, name, ".git");
42
+ const info = yield* fs.stat(dotGit).pipe(Effect.option);
43
+ if (Option.isNone(info)) return fallback;
44
+ if (info.value.type === "Directory") return dotGit;
45
+ const content = yield* fs.readFileString(dotGit).pipe(Effect.option);
46
+ if (Option.isNone(content)) return fallback;
47
+ const match = /^gitdir:\s*(.+)$/m.exec(content.value);
48
+ if (!match?.[1]) return fallback;
49
+ const pointer = match[1].trim();
50
+ const resolved = path.isAbsolute(pointer) ? pointer : path.resolve(path.dirname(dotGit), pointer);
51
+ const relative = path.relative(root, resolved);
52
+ if (relative.startsWith("..") || path.isAbsolute(relative)) return fallback;
53
+ return resolved;
54
+ });
55
+ /**
56
+ * Enforces OS-level read-only permissions on vendored repos so they cannot
57
+ * be accidentally edited outside the sync flow.
58
+ * @public
59
+ */
60
+ var ReposLockdown = class extends Context.Service()("@savvy-web/silk-effects/ReposLockdown") {
61
+ /**
62
+ * Production layer over the core FileSystem.
63
+ * @public
64
+ */
65
+ static layer = Layer.effect(this, Effect.gen(function* () {
66
+ const fs = yield* FileSystem.FileSystem;
67
+ const path = yield* Path.Path;
68
+ const chmod = (entryPath, mode) => fs.chmod(entryPath, mode).pipe(Effect.mapError((cause) => new ReposLockdownError({
69
+ path: entryPath,
70
+ reason: `chmod failed: ${String(cause)}`
71
+ })));
72
+ const isSymlink = (entryPath) => fs.readLink(entryPath).pipe(Effect.option, Effect.map(Option.isSome));
73
+ const walk = (dir, fileBaseMode, dirMode, order) => Effect.gen(function* () {
74
+ if (order === "unlock") yield* chmod(dir, dirMode);
75
+ const entries = yield* fs.readDirectory(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
76
+ path: dir,
77
+ reason: `readDirectory failed: ${String(cause)}`
78
+ })));
79
+ for (const entry of entries) {
80
+ const entryPath = path.join(dir, entry);
81
+ if (yield* isSymlink(entryPath)) continue;
82
+ const maybeInfo = yield* fs.stat(entryPath).pipe(Effect.option);
83
+ if (Option.isNone(maybeInfo)) continue;
84
+ const info = maybeInfo.value;
85
+ if (info.type === "Directory") yield* walk(entryPath, fileBaseMode, dirMode, order);
86
+ else yield* chmod(entryPath, fileModeFor(fileBaseMode, info.mode));
87
+ }
88
+ if (order === "lock") yield* chmod(dir, dirMode);
89
+ });
90
+ const walkRoot = (root, name, fileBaseMode, dirMode, order) => Effect.gen(function* () {
91
+ const moduleDir = yield* resolveModuleDir(fs, path, root, name);
92
+ for (const dir of [path.join(root, REPOS_DIR, name), moduleDir]) {
93
+ if (!(yield* fs.exists(dir).pipe(Effect.mapError((cause) => new ReposLockdownError({
94
+ path: dir,
95
+ reason: `stat failed: ${String(cause)}`
96
+ }))))) continue;
97
+ yield* walk(dir, fileBaseMode, dirMode, order);
98
+ }
99
+ });
100
+ const lock = (root, name) => walkRoot(root, name, FILE_LOCKED_BASE_MODE, DIR_LOCKED_MODE, "lock");
101
+ const unlock = (root, name) => walkRoot(root, name, FILE_UNLOCKED_BASE_MODE, DIR_UNLOCKED_MODE, "unlock");
102
+ const withUnlocked = (root, name, effect) => Effect.uninterruptibleMask((restore) => Effect.gen(function* () {
103
+ const unlockExit = yield* Effect.exit(unlock(root, name));
104
+ const resultExit = Exit.isFailure(unlockExit) ? Exit.failCause(unlockExit.cause) : yield* Effect.exit(restore(effect));
105
+ const relockExit = yield* Effect.exit(lock(root, name));
106
+ if (Exit.isFailure(resultExit)) return yield* Exit.failCause(resultExit.cause);
107
+ if (Exit.isFailure(relockExit)) return yield* Exit.failCause(relockExit.cause);
108
+ return resultExit.value;
109
+ }));
110
+ return {
111
+ lock,
112
+ unlock,
113
+ withUnlocked
114
+ };
115
+ }));
116
+ };
117
+
118
+ //#endregion
119
+ export { ReposLockdown, resolveModuleDir };