@isentinel/rojo-utils 0.1.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.
Files changed (115) hide show
  1. package/CLAUDE.md +40 -0
  2. package/coverage/base.css +224 -0
  3. package/coverage/block-navigation.js +87 -0
  4. package/coverage/clover.xml +388 -0
  5. package/coverage/coverage-final.json +7 -0
  6. package/coverage/favicon.png +0 -0
  7. package/coverage/index.html +191 -0
  8. package/coverage/loader.ts.html +220 -0
  9. package/coverage/mount-collector.ts.html +301 -0
  10. package/coverage/path-mapper.ts.html +220 -0
  11. package/coverage/prettify.css +1 -0
  12. package/coverage/prettify.js +2 -0
  13. package/coverage/rojo-resolver.ts.html +1900 -0
  14. package/coverage/rojo-tree.ts.html +559 -0
  15. package/coverage/sort-arrow-sprite.png +0 -0
  16. package/coverage/sorter.js +210 -0
  17. package/coverage/tree-mapper.ts.html +247 -0
  18. package/dist/index.d.mts +175 -0
  19. package/dist/index.d.mts.map +1 -0
  20. package/dist/index.mjs +551 -0
  21. package/dist/index.mjs.map +1 -0
  22. package/eslint.config.ts +23 -0
  23. package/out-tsc/index.d.ts +10 -0
  24. package/out-tsc/index.d.ts.map +1 -0
  25. package/out-tsc/index.js +6 -0
  26. package/out-tsc/loader.d.ts +3 -0
  27. package/out-tsc/loader.d.ts.map +1 -0
  28. package/out-tsc/loader.js +37 -0
  29. package/out-tsc/mount-collector.d.ts +10 -0
  30. package/out-tsc/mount-collector.d.ts.map +1 -0
  31. package/out-tsc/mount-collector.js +37 -0
  32. package/out-tsc/path-mapper.d.ts +4 -0
  33. package/out-tsc/path-mapper.d.ts.map +1 -0
  34. package/out-tsc/path-mapper.js +34 -0
  35. package/out-tsc/rojo-resolver.d.ts +110 -0
  36. package/out-tsc/rojo-resolver.d.ts.map +1 -0
  37. package/out-tsc/rojo-resolver.js +448 -0
  38. package/out-tsc/rojo-tree.d.ts +15 -0
  39. package/out-tsc/rojo-tree.d.ts.map +1 -0
  40. package/out-tsc/rojo-tree.js +106 -0
  41. package/out-tsc/tree-mapper.d.ts +4 -0
  42. package/out-tsc/tree-mapper.d.ts.map +1 -0
  43. package/out-tsc/tree-mapper.js +34 -0
  44. package/out-tsc/tsconfig.tsbuildinfo +1 -0
  45. package/out-tsc/types.d.ts +26 -0
  46. package/out-tsc/types.d.ts.map +1 -0
  47. package/out-tsc/types.js +1 -0
  48. package/out-tsc/vitest/eslint.config.d.ts +3 -0
  49. package/out-tsc/vitest/eslint.config.d.ts.map +1 -0
  50. package/out-tsc/vitest/eslint.shared.d.ts +13 -0
  51. package/out-tsc/vitest/eslint.shared.d.ts.map +1 -0
  52. package/out-tsc/vitest/libs/rojo-utils/eslint.config.d.ts +3 -0
  53. package/out-tsc/vitest/libs/rojo-utils/eslint.config.d.ts.map +1 -0
  54. package/out-tsc/vitest/libs/rojo-utils/src/loader.spec.d.ts +2 -0
  55. package/out-tsc/vitest/libs/rojo-utils/src/loader.spec.d.ts.map +1 -0
  56. package/out-tsc/vitest/libs/rojo-utils/src/mount-collector.spec.d.ts +2 -0
  57. package/out-tsc/vitest/libs/rojo-utils/src/mount-collector.spec.d.ts.map +1 -0
  58. package/out-tsc/vitest/libs/rojo-utils/src/path-mapper.spec.d.ts +2 -0
  59. package/out-tsc/vitest/libs/rojo-utils/src/path-mapper.spec.d.ts.map +1 -0
  60. package/out-tsc/vitest/libs/rojo-utils/src/rojo-resolver.spec.d.ts +2 -0
  61. package/out-tsc/vitest/libs/rojo-utils/src/rojo-resolver.spec.d.ts.map +1 -0
  62. package/out-tsc/vitest/libs/rojo-utils/src/rojo-tree.spec.d.ts +2 -0
  63. package/out-tsc/vitest/libs/rojo-utils/src/rojo-tree.spec.d.ts.map +1 -0
  64. package/out-tsc/vitest/libs/rojo-utils/src/tree-mapper.spec.d.ts +2 -0
  65. package/out-tsc/vitest/libs/rojo-utils/src/tree-mapper.spec.d.ts.map +1 -0
  66. package/out-tsc/vitest/libs/rojo-utils/test/setup/jest-extended.d.ts +2 -0
  67. package/out-tsc/vitest/libs/rojo-utils/test/setup/jest-extended.d.ts.map +1 -0
  68. package/out-tsc/vitest/libs/rojo-utils/tsconfig.spec.tsbuildinfo +1 -0
  69. package/out-tsc/vitest/libs/rojo-utils/tsdown.config.d.ts +3 -0
  70. package/out-tsc/vitest/libs/rojo-utils/tsdown.config.d.ts.map +1 -0
  71. package/out-tsc/vitest/libs/rojo-utils/vitest.config.d.ts +3 -0
  72. package/out-tsc/vitest/libs/rojo-utils/vitest.config.d.ts.map +1 -0
  73. package/out-tsc/vitest/src/loader.spec.d.ts +2 -0
  74. package/out-tsc/vitest/src/loader.spec.d.ts.map +1 -0
  75. package/out-tsc/vitest/src/mount-collector.spec.d.ts +2 -0
  76. package/out-tsc/vitest/src/mount-collector.spec.d.ts.map +1 -0
  77. package/out-tsc/vitest/src/path-mapper.spec.d.ts +2 -0
  78. package/out-tsc/vitest/src/path-mapper.spec.d.ts.map +1 -0
  79. package/out-tsc/vitest/src/rojo-resolver.spec.d.ts +2 -0
  80. package/out-tsc/vitest/src/rojo-resolver.spec.d.ts.map +1 -0
  81. package/out-tsc/vitest/src/rojo-tree.spec.d.ts +2 -0
  82. package/out-tsc/vitest/src/rojo-tree.spec.d.ts.map +1 -0
  83. package/out-tsc/vitest/src/tree-mapper.spec.d.ts +2 -0
  84. package/out-tsc/vitest/src/tree-mapper.spec.d.ts.map +1 -0
  85. package/out-tsc/vitest/test/setup/jest-extended.d.ts +2 -0
  86. package/out-tsc/vitest/test/setup/jest-extended.d.ts.map +1 -0
  87. package/out-tsc/vitest/tsconfig.spec.tsbuildinfo +1 -0
  88. package/out-tsc/vitest/tsdown.config.d.ts +3 -0
  89. package/out-tsc/vitest/tsdown.config.d.ts.map +1 -0
  90. package/out-tsc/vitest/vitest.config.d.ts +3 -0
  91. package/out-tsc/vitest/vitest.config.d.ts.map +1 -0
  92. package/package.json +48 -0
  93. package/project.json +25 -0
  94. package/src/index.ts +25 -0
  95. package/src/loader.spec.ts +240 -0
  96. package/src/loader.ts +45 -0
  97. package/src/mount-collector.spec.ts +272 -0
  98. package/src/mount-collector.ts +72 -0
  99. package/src/path-mapper.spec.ts +201 -0
  100. package/src/path-mapper.ts +45 -0
  101. package/src/rojo-resolver.spec.ts +956 -0
  102. package/src/rojo-resolver.ts +605 -0
  103. package/src/rojo-tree.spec.ts +622 -0
  104. package/src/rojo-tree.ts +162 -0
  105. package/src/tree-mapper.spec.ts +147 -0
  106. package/src/tree-mapper.ts +54 -0
  107. package/src/types.ts +29 -0
  108. package/test/setup/jest-extended.ts +4 -0
  109. package/test/setup/vitest-jest-extended.d.ts +1 -0
  110. package/tsconfig.json +13 -0
  111. package/tsconfig.lib.json +17 -0
  112. package/tsconfig.lib.tsbuildinfo +1 -0
  113. package/tsconfig.spec.json +26 -0
  114. package/tsdown.config.ts +15 -0
  115. package/vitest.config.ts +20 -0
@@ -0,0 +1,448 @@
1
+ import { type } from "arktype";
2
+ import * as fs from "node:fs";
3
+ import path from "node:path";
4
+ const LUA_EXT = ".lua";
5
+ const LUAU_EXT = ".luau";
6
+ const JSON_EXT = ".json";
7
+ const TOML_EXT = ".toml";
8
+ const ROJO_MODULE_EXTS = new Set([JSON_EXT, LUAU_EXT, TOML_EXT]);
9
+ const ROJO_SCRIPT_EXTS = new Set([LUAU_EXT]);
10
+ const INIT_NAME = "init";
11
+ const SERVER_SUB_EXTENSION = ".server";
12
+ const CLIENT_SUB_EXTENSION = ".client";
13
+ const MODULE_SUB_EXTENSION = "";
14
+ const ROJO_FILE_REGEX = /^.+\.project\.json$/;
15
+ const ROJO_DEFAULT_NAME = "default.project.json";
16
+ const ROJO_OLD_NAME = "roblox-project.json";
17
+ export const RbxType = {
18
+ LocalScript: 2,
19
+ ModuleScript: 0,
20
+ Script: 1,
21
+ Unknown: 3,
22
+ };
23
+ const SUB_EXT_TYPE_MAP = new Map([
24
+ [CLIENT_SUB_EXTENSION, RbxType.LocalScript],
25
+ [MODULE_SUB_EXTENSION, RbxType.ModuleScript],
26
+ [SERVER_SUB_EXTENSION, RbxType.Script],
27
+ ]);
28
+ const DEFAULT_ISOLATED_CONTAINERS = [
29
+ ["StarterPack"],
30
+ ["StarterGui"],
31
+ ["StarterPlayer", "StarterPlayerScripts"],
32
+ ["StarterPlayer", "StarterCharacterScripts"],
33
+ ["StarterPlayer", "StarterCharacter"],
34
+ ["PluginDebugService"],
35
+ ];
36
+ const CLIENT_CONTAINERS = [["StarterPack"], ["StarterGui"], ["StarterPlayer"]];
37
+ const SERVER_CONTAINERS = [["ServerStorage"], ["ServerScriptService"]];
38
+ export const FileRelation = {
39
+ InToIn: 3,
40
+ InToOut: 2,
41
+ OutToIn: 1,
42
+ OutToOut: 0,
43
+ };
44
+ export const NetworkType = {
45
+ Client: 1,
46
+ Server: 2,
47
+ Unknown: 0,
48
+ };
49
+ function stripRojoExtensions(filePath) {
50
+ let stripped = filePath;
51
+ const extension = path.extname(stripped);
52
+ if (ROJO_MODULE_EXTS.has(extension)) {
53
+ stripped = stripped.slice(0, -extension.length);
54
+ if (ROJO_SCRIPT_EXTS.has(extension)) {
55
+ const subExtension = path.extname(stripped);
56
+ if (subExtension === SERVER_SUB_EXTENSION || subExtension === CLIENT_SUB_EXTENSION) {
57
+ stripped = stripped.slice(0, -subExtension.length);
58
+ }
59
+ }
60
+ }
61
+ return stripped;
62
+ }
63
+ function arrayStartsWith(a, b) {
64
+ const minLength = Math.min(a.length, b.length);
65
+ for (let index = 0; index < minLength; index++) {
66
+ if (a[index] !== b[index]) {
67
+ return false;
68
+ }
69
+ }
70
+ return true;
71
+ }
72
+ function isPathDescendantOf(filePath, directoryPath) {
73
+ return directoryPath === filePath || !path.relative(directoryPath, filePath).startsWith("..");
74
+ }
75
+ let rojoFileSchema;
76
+ function isValidRojoConfig(value) {
77
+ // Built lazily on first use (not at module top level) so consumers can
78
+ // auto-mock this module without a hoisting TDZ on the arktype import, then
79
+ // memoized so repeated tree-walk validations don't re-allocate the schema. We
80
+ // validate only the fields the resolver reads; parseTree handles arbitrary
81
+ // tree shapes defensively.
82
+ rojoFileSchema ??= type({
83
+ "name": "string",
84
+ "servePort?": "number",
85
+ "tree": "object",
86
+ });
87
+ return !(rojoFileSchema(value) instanceof type.errors);
88
+ }
89
+ function convertToLuau(filePath) {
90
+ const extension = path.extname(filePath);
91
+ if (extension === LUA_EXT) {
92
+ return filePath.slice(0, -extension.length) + LUAU_EXT;
93
+ }
94
+ return filePath;
95
+ }
96
+ export const RbxPathParent = Symbol("Parent");
97
+ export class RojoResolver {
98
+ rbxPath = new Array();
99
+ realpathCache = new Map();
100
+ walkedConfigFilesInternal = new Set();
101
+ walkedDirectoriesInternal = new Set();
102
+ filePathToRbxPathMap = new Map();
103
+ isolatedContainers = [...DEFAULT_ISOLATED_CONTAINERS];
104
+ partitions = new Array();
105
+ warnings = new Array();
106
+ isGame = false;
107
+ static findRojoConfigFilePath(projectPath) {
108
+ const warnings = new Array();
109
+ const defaultPath = path.join(projectPath, ROJO_DEFAULT_NAME);
110
+ if (fs.existsSync(defaultPath)) {
111
+ return { path: defaultPath, warnings };
112
+ }
113
+ const candidates = new Array();
114
+ for (const fileName of fs.readdirSync(projectPath)) {
115
+ if (fileName !== ROJO_DEFAULT_NAME &&
116
+ (fileName === ROJO_OLD_NAME || ROJO_FILE_REGEX.test(fileName))) {
117
+ candidates.push(path.join(projectPath, fileName));
118
+ }
119
+ }
120
+ if (candidates.length > 1) {
121
+ warnings.push(`Multiple *.project.json files found, using ${candidates[0]}`);
122
+ }
123
+ return { path: candidates[0], warnings };
124
+ }
125
+ static fromPath(rojoConfigFilePath) {
126
+ const resolver = new RojoResolver();
127
+ resolver.parseConfig(path.resolve(rojoConfigFilePath), true);
128
+ return resolver;
129
+ }
130
+ /**
131
+ * Restore a resolver from a {@link RojoResolverState} snapshot.
132
+ * @param state - The serialized resolver state to restore from.
133
+ * @returns A resolver equivalent to the one the state was captured from.
134
+ */
135
+ static fromState(state) {
136
+ const resolver = new RojoResolver();
137
+ resolver.partitions = state.partitions.map((partition) => {
138
+ return { fsPath: partition.fsPath, rbxPath: partition.rbxPath.slice() };
139
+ });
140
+ const filePathToRbxPathMap = new Map();
141
+ for (const [filePath, rbxPath] of state.filePathToRbxPathMap) {
142
+ filePathToRbxPathMap.set(filePath, rbxPath.slice());
143
+ }
144
+ resolver.filePathToRbxPathMap = filePathToRbxPathMap;
145
+ resolver.isolatedContainers = state.isolatedContainers.map((container) => {
146
+ return container.slice();
147
+ });
148
+ resolver.isGame = state.isGame;
149
+ resolver.warnings = state.warnings.slice();
150
+ for (const directory of state.walkedDirs) {
151
+ resolver.walkedDirectoriesInternal.add(directory);
152
+ }
153
+ for (const configFile of state.walkedConfigFiles) {
154
+ resolver.walkedConfigFilesInternal.add(configFile);
155
+ }
156
+ return resolver;
157
+ }
158
+ static fromTree(basePath, tree) {
159
+ const resolver = new RojoResolver();
160
+ resolver.parseTree(basePath, "", tree, true);
161
+ return resolver;
162
+ }
163
+ getFileRelation(fileRbxPath, moduleRbxPath) {
164
+ const fileContainer = this.getContainer(this.isolatedContainers, fileRbxPath);
165
+ const moduleContainer = this.getContainer(this.isolatedContainers, moduleRbxPath);
166
+ if (fileContainer && moduleContainer) {
167
+ if (fileContainer === moduleContainer) {
168
+ return FileRelation.InToIn;
169
+ }
170
+ return FileRelation.OutToIn;
171
+ }
172
+ if (fileContainer && !moduleContainer) {
173
+ return FileRelation.InToOut;
174
+ }
175
+ if (!fileContainer && moduleContainer) {
176
+ return FileRelation.OutToIn;
177
+ }
178
+ // !fileContainer && !moduleContainer
179
+ return FileRelation.OutToOut;
180
+ }
181
+ getNetworkType(rbxPath) {
182
+ if (this.getContainer(SERVER_CONTAINERS, rbxPath)) {
183
+ return NetworkType.Server;
184
+ }
185
+ if (this.getContainer(CLIENT_CONTAINERS, rbxPath)) {
186
+ return NetworkType.Client;
187
+ }
188
+ return NetworkType.Unknown;
189
+ }
190
+ getPartitions() {
191
+ return this.partitions;
192
+ }
193
+ getRbxPathFromFilePath(filePath) {
194
+ const resolved = convertToLuau(path.resolve(filePath));
195
+ const rbxPath = this.filePathToRbxPathMap.get(resolved);
196
+ if (rbxPath) {
197
+ return rbxPath;
198
+ }
199
+ const extension = path.extname(resolved);
200
+ for (const partition of this.partitions) {
201
+ if (isPathDescendantOf(resolved, partition.fsPath)) {
202
+ const stripped = stripRojoExtensions(resolved);
203
+ const relativePath = path.relative(partition.fsPath, stripped);
204
+ const relativeParts = relativePath === "" ? [] : relativePath.split(path.sep);
205
+ if (ROJO_SCRIPT_EXTS.has(extension) && relativeParts.at(-1) === INIT_NAME) {
206
+ relativeParts.pop();
207
+ }
208
+ return [...partition.rbxPath, ...relativeParts];
209
+ }
210
+ }
211
+ return undefined;
212
+ }
213
+ getRbxTypeFromFilePath(filePath) {
214
+ const resolved = convertToLuau(filePath);
215
+ const extension = path.extname(resolved);
216
+ const subExtension = path.extname(path.basename(resolved, extension));
217
+ if (ROJO_SCRIPT_EXTS.has(extension)) {
218
+ return SUB_EXT_TYPE_MAP.get(subExtension) ?? RbxType.Unknown;
219
+ }
220
+ // non-script exts cannot use .server, .client, etc.
221
+ return RbxType.ModuleScript;
222
+ }
223
+ /**
224
+ * Serialize this resolver to a {@link RojoResolverState} snapshot.
225
+ * @returns A serializable snapshot of this resolver's state.
226
+ */
227
+ getState() {
228
+ const filePathToRbxPathMap = new Array();
229
+ for (const [filePath, rbxPath] of this.filePathToRbxPathMap) {
230
+ filePathToRbxPathMap.push([filePath, rbxPath.slice()]);
231
+ }
232
+ return {
233
+ filePathToRbxPathMap,
234
+ isGame: this.isGame,
235
+ isolatedContainers: this.isolatedContainers.map((container) => container.slice()),
236
+ partitions: this.partitions.map((partition) => {
237
+ return { fsPath: partition.fsPath, rbxPath: partition.rbxPath.slice() };
238
+ }),
239
+ walkedConfigFiles: [...this.walkedConfigFilesInternal],
240
+ walkedDirs: [...this.walkedDirectoriesInternal],
241
+ warnings: this.warnings.slice(),
242
+ };
243
+ }
244
+ getWarnings() {
245
+ return this.warnings;
246
+ }
247
+ isIsolated(rbxPath) {
248
+ return this.getContainer(this.isolatedContainers, rbxPath) !== undefined;
249
+ }
250
+ static relative(rbxFrom, rbxTo) {
251
+ const maxLength = Math.max(rbxFrom.length, rbxTo.length);
252
+ let diffIndex = maxLength;
253
+ for (let index = 0; index < maxLength; index++) {
254
+ if (rbxFrom[index] !== rbxTo[index]) {
255
+ diffIndex = index;
256
+ break;
257
+ }
258
+ }
259
+ const result = new Array();
260
+ if (diffIndex < rbxFrom.length) {
261
+ for (let index = 0; index < rbxFrom.length - diffIndex; index++) {
262
+ result.push(RbxPathParent);
263
+ }
264
+ }
265
+ for (let index = diffIndex; index < rbxTo.length; index++) {
266
+ // eslint-disable-next-line ts/no-non-null-assertion -- Loop index
267
+ result.push(rbxTo[index]);
268
+ }
269
+ return result;
270
+ }
271
+ /**
272
+ * Create a synthetic RojoResolver for ProjectType.Package. Forces all imports
273
+ * to be relative.
274
+ * @param basePath - The base filesystem path the package resolves against.
275
+ * @returns A resolver that maps every file in the package relatively.
276
+ */
277
+ static synthetic(basePath) {
278
+ const resolver = new RojoResolver();
279
+ resolver.parseTree(basePath, "", { $path: basePath }, true);
280
+ return resolver;
281
+ }
282
+ get walkedConfigFiles() {
283
+ return this.walkedConfigFilesInternal;
284
+ }
285
+ get walkedDirectories() {
286
+ return this.walkedDirectoriesInternal;
287
+ }
288
+ cachedRealpath(targetPath) {
289
+ let resolved = this.realpathCache.get(targetPath);
290
+ if (resolved === undefined) {
291
+ resolved = fs.realpathSync(targetPath);
292
+ this.realpathCache.set(targetPath, resolved);
293
+ }
294
+ return resolved;
295
+ }
296
+ getContainer(from, rbxPath) {
297
+ if (this.isGame && rbxPath) {
298
+ for (const container of from) {
299
+ if (arrayStartsWith(rbxPath, container)) {
300
+ return container;
301
+ }
302
+ }
303
+ }
304
+ return undefined;
305
+ }
306
+ parseConfig(rojoConfigFilePath, doNotPush = false) {
307
+ if (!fs.existsSync(rojoConfigFilePath)) {
308
+ this.warn(`RojoResolver: Path does not exist "${rojoConfigFilePath}"`);
309
+ return;
310
+ }
311
+ const realPath = this.cachedRealpath(rojoConfigFilePath);
312
+ this.walkedConfigFilesInternal.add(realPath);
313
+ let configJson;
314
+ try {
315
+ configJson = JSON.parse(fs.readFileSync(realPath, "utf8"));
316
+ }
317
+ catch {
318
+ // Malformed JSON: leave configJson undefined and fall through so it
319
+ // is reported as an invalid configuration rather than crashing the
320
+ // caller.
321
+ }
322
+ if (isValidRojoConfig(configJson)) {
323
+ this.parseTree(path.dirname(rojoConfigFilePath), configJson.name, configJson.tree, doNotPush);
324
+ }
325
+ else {
326
+ this.warn("RojoResolver: Invalid configuration!");
327
+ }
328
+ }
329
+ parsePath(itemPath) {
330
+ const luauPath = convertToLuau(itemPath);
331
+ // A $path pointing straight at a *.project.json file embeds that nested
332
+ // project at the current rbxPath (Rojo composes projects this way), the
333
+ // same as a directory whose default.project.json is discovered below.
334
+ // Without this, the .json extension routes the file into
335
+ // filePathToRbxPathMap as an opaque leaf and the nested tree's mounts
336
+ // (its partitions, its @rbxts mounts) are never read.
337
+ if (ROJO_FILE_REGEX.test(path.basename(luauPath))) {
338
+ this.parseConfig(luauPath, true);
339
+ return;
340
+ }
341
+ const realPath = fs.existsSync(luauPath) ? this.cachedRealpath(luauPath) : luauPath;
342
+ const extension = path.extname(luauPath);
343
+ if (ROJO_MODULE_EXTS.has(extension)) {
344
+ this.filePathToRbxPathMap.set(luauPath, [...this.rbxPath]);
345
+ }
346
+ else {
347
+ const isDirectory = fs.existsSync(realPath) && fs.statSync(realPath).isDirectory();
348
+ if (isDirectory) {
349
+ this.walkedDirectoriesInternal.add(realPath);
350
+ }
351
+ if (isDirectory && fs.readdirSync(realPath).includes(ROJO_DEFAULT_NAME)) {
352
+ this.parseConfig(path.join(luauPath, ROJO_DEFAULT_NAME), true);
353
+ }
354
+ else {
355
+ this.partitions.unshift({
356
+ fsPath: luauPath,
357
+ rbxPath: [...this.rbxPath],
358
+ });
359
+ if (isDirectory) {
360
+ this.searchDirectory(luauPath);
361
+ }
362
+ }
363
+ }
364
+ }
365
+ parseTree(basePath, name, tree, doNotPush = false) {
366
+ if (!doNotPush) {
367
+ this.rbxPath.push(name);
368
+ }
369
+ if (tree.$path !== undefined) {
370
+ this.parsePath(path.resolve(basePath, typeof tree.$path === "string" ? tree.$path : tree.$path.optional));
371
+ }
372
+ if (tree.$className === "DataModel") {
373
+ this.isGame = true;
374
+ }
375
+ const childNames = Object.keys(tree).filter((value) => !value.startsWith("$"));
376
+ for (const childName of childNames) {
377
+ // eslint-disable-next-line ts/no-non-null-assertion -- Object.keys ensures this is defined
378
+ this.parseTree(basePath, childName, tree[childName]);
379
+ }
380
+ if (!doNotPush) {
381
+ this.rbxPath.pop();
382
+ }
383
+ }
384
+ searchChildren(directory, directoryEntries) {
385
+ // Two-phase: parse every sibling *.project.json before recursing into
386
+ // sibling directories. parsePath / parseConfig insert partitions via
387
+ // unshift, and getRbxPathFromFilePath returns the first match, so
388
+ // interleaving in raw entry order would shuffle partition precedence
389
+ // for overlapping mounts.
390
+ const projectFiles = new Array();
391
+ const subDirectories = new Array();
392
+ for (const entry of directoryEntries) {
393
+ const childPath = path.join(directory, entry.name);
394
+ let isFile = entry.isFile();
395
+ let isDirectory = entry.isDirectory();
396
+ // Symlinks (both flags false), unknown-type entries on NFS/SMB/FUSE
397
+ // mounts and some Windows network drives, and NTFS junctions on
398
+ // Node versions that don't classify them: resolve and stat the
399
+ // target so we still discover nested *.project.json files and
400
+ // walkable directories. A broken symlink throws ENOENT here —
401
+ // warn and skip rather than crash the whole walk.
402
+ if (!isFile && !isDirectory) {
403
+ try {
404
+ const stat = fs.statSync(this.cachedRealpath(childPath));
405
+ isFile = stat.isFile();
406
+ isDirectory = stat.isDirectory();
407
+ }
408
+ catch (err) {
409
+ this.warn(`RojoResolver: Failed to resolve "${childPath}" (${err.message})`);
410
+ continue;
411
+ }
412
+ }
413
+ if (isFile && ROJO_FILE_REGEX.test(entry.name)) {
414
+ projectFiles.push(childPath);
415
+ }
416
+ else if (isDirectory) {
417
+ subDirectories.push({ name: entry.name, path: childPath });
418
+ }
419
+ }
420
+ for (const childPath of projectFiles) {
421
+ this.parseConfig(childPath);
422
+ }
423
+ for (const { name, path: childPath } of subDirectories) {
424
+ this.searchDirectory(childPath, name);
425
+ }
426
+ }
427
+ searchDirectory(directory, item) {
428
+ const realPath = this.cachedRealpath(directory);
429
+ this.walkedDirectoriesInternal.add(realPath);
430
+ // readdirSync transparently follows a symlink on the argument; the
431
+ // result is equivalent to readdirSync(realpathSync(directory)).
432
+ const directoryEntries = fs.readdirSync(directory, { withFileTypes: true });
433
+ if (directoryEntries.some((entry) => entry.name === ROJO_DEFAULT_NAME)) {
434
+ this.parseConfig(path.join(directory, ROJO_DEFAULT_NAME));
435
+ return;
436
+ }
437
+ if (item !== undefined) {
438
+ this.rbxPath.push(item);
439
+ }
440
+ this.searchChildren(directory, directoryEntries);
441
+ if (item !== undefined) {
442
+ this.rbxPath.pop();
443
+ }
444
+ }
445
+ warn(str) {
446
+ this.warnings.push(str);
447
+ }
448
+ }
@@ -0,0 +1,15 @@
1
+ import type { RojoTreeNode } from "./types.ts";
2
+ /**
3
+ * Like {@link resolveNestedProjects}, but also reports the absolute path of
4
+ * every nested project file inlined during resolution. Change-detection
5
+ * callers hash these so an edit to a nested `*.project.json` invalidates the
6
+ * build.
7
+ */
8
+ export declare function resolveNestedProjectSources(tree: RojoTreeNode, rootDirectory: string): {
9
+ projectFiles: Array<string>;
10
+ tree: RojoTreeNode;
11
+ };
12
+ export declare function resolveNestedProjects(tree: RojoTreeNode, rootDirectory: string): RojoTreeNode;
13
+ export declare function collectPaths(node: RojoTreeNode, result: Array<string>): void;
14
+ export declare function rebaseTreePaths(node: RojoTreeNode, fromDirectory: string, toDirectory: string): RojoTreeNode;
15
+ //# sourceMappingURL=rojo-tree.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"rojo-tree.d.ts","sourceRoot":"","sources":["../src/rojo-tree.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAa/C;;;;;GAKG;AACH,wBAAgB,2BAA2B,CAC1C,IAAI,EAAE,YAAY,EAClB,aAAa,EAAE,MAAM,GACnB;IAAE,YAAY,EAAE,KAAK,CAAC,MAAM,CAAC,CAAC;IAAC,IAAI,EAAE,YAAY,CAAA;CAAE,CAIrD;AAED,wBAAgB,qBAAqB,CAAC,IAAI,EAAE,YAAY,EAAE,aAAa,EAAE,MAAM,GAAG,YAAY,CAE7F;AAED,wBAAgB,YAAY,CAAC,IAAI,EAAE,YAAY,EAAE,MAAM,EAAE,KAAK,CAAC,MAAM,CAAC,GAAG,IAAI,CAQ5E;AAED,wBAAgB,eAAe,CAC9B,IAAI,EAAE,YAAY,EAClB,aAAa,EAAE,MAAM,EACrB,WAAW,EAAE,MAAM,GACjB,YAAY,CAmBd"}
@@ -0,0 +1,106 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { dirname, join, relative, resolve } from "node:path";
3
+ /**
4
+ * Like {@link resolveNestedProjects}, but also reports the absolute path of
5
+ * every nested project file inlined during resolution. Change-detection
6
+ * callers hash these so an edit to a nested `*.project.json` invalidates the
7
+ * build.
8
+ */
9
+ export function resolveNestedProjectSources(tree, rootDirectory) {
10
+ const context = { sources: new Set(), visited: new Set() };
11
+ const resolved = resolveTree(tree, rootDirectory, rootDirectory, context);
12
+ return { projectFiles: [...context.sources], tree: resolved };
13
+ }
14
+ export function resolveNestedProjects(tree, rootDirectory) {
15
+ return resolveNestedProjectSources(tree, rootDirectory).tree;
16
+ }
17
+ export function collectPaths(node, result) {
18
+ for (const [key, value] of Object.entries(node)) {
19
+ if (key === "$path" && typeof value === "string") {
20
+ result.push(value.replaceAll("\\", "/"));
21
+ }
22
+ else if (typeof value === "object" && !Array.isArray(value) && !key.startsWith("$")) {
23
+ collectPaths(value, result);
24
+ }
25
+ }
26
+ }
27
+ export function rebaseTreePaths(node, fromDirectory, toDirectory) {
28
+ const result = {};
29
+ for (const [key, value] of Object.entries(node)) {
30
+ if (key === "$path" && typeof value === "string") {
31
+ const absolutePath = resolve(fromDirectory, value);
32
+ result[key] = relative(toDirectory, absolutePath).replaceAll("\\", "/");
33
+ continue;
34
+ }
35
+ if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
36
+ result[key] = value;
37
+ continue;
38
+ }
39
+ result[key] = rebaseTreePaths(value, fromDirectory, toDirectory);
40
+ }
41
+ return result;
42
+ }
43
+ function nestedProjectPath(currentDirectory, value) {
44
+ // Resolve a `$path` string to the nested project file it should inline, or
45
+ // undefined when the path is a plain source mount. Rojo treats a `$path`
46
+ // pointing at a directory containing `default.project.json` as a nested
47
+ // project (e.g. `$path: ".."` into a package root), so honor that alongside
48
+ // explicit `*.project.json` references.
49
+ if (value.endsWith(".project.json")) {
50
+ return join(currentDirectory, value);
51
+ }
52
+ const directoryDefault = join(currentDirectory, value, "default.project.json");
53
+ return existsSync(directoryDefault) ? directoryDefault : undefined;
54
+ }
55
+ function inlineNestedProject(projectPath, currentDirectory, originalRoot, context) {
56
+ const chain = new Set(context.visited);
57
+ chain.add(projectPath);
58
+ context.sources.add(projectPath);
59
+ let content;
60
+ try {
61
+ content = readFileSync(projectPath, "utf-8");
62
+ }
63
+ catch (err) {
64
+ const relativePath = relative(currentDirectory, projectPath);
65
+ throw new Error(`Could not read nested Rojo project: ${relativePath}`, { cause: err });
66
+ }
67
+ let project;
68
+ try {
69
+ project = JSON.parse(content);
70
+ }
71
+ catch (err) {
72
+ const relativePath = relative(currentDirectory, projectPath);
73
+ throw new Error(`Failed to parse nested Rojo project: ${relativePath}`, { cause: err });
74
+ }
75
+ return resolveTree(project.tree, dirname(projectPath), originalRoot, {
76
+ sources: context.sources,
77
+ visited: chain,
78
+ });
79
+ }
80
+ function resolveRootRelativePath(currentDirectory, value, originalRoot) {
81
+ const absolutePath = join(currentDirectory, value);
82
+ return relative(originalRoot, absolutePath).replaceAll("\\", "/");
83
+ }
84
+ function resolveTree(node, currentDirectory, originalRoot, context) {
85
+ const resolved = {};
86
+ for (const [key, value] of Object.entries(node)) {
87
+ if (key === "$path" && typeof value === "string") {
88
+ const projectPath = nestedProjectPath(currentDirectory, value);
89
+ if (projectPath === undefined) {
90
+ resolved[key] = resolveRootRelativePath(currentDirectory, value, originalRoot);
91
+ continue;
92
+ }
93
+ if (context.visited.has(projectPath)) {
94
+ throw new Error(`Circular project reference: ${value}`);
95
+ }
96
+ Object.assign(resolved, inlineNestedProject(projectPath, currentDirectory, originalRoot, context));
97
+ continue;
98
+ }
99
+ if (key.startsWith("$") || typeof value !== "object" || Array.isArray(value)) {
100
+ resolved[key] = value;
101
+ continue;
102
+ }
103
+ resolved[key] = resolveTree(value, currentDirectory, originalRoot, context);
104
+ }
105
+ return resolved;
106
+ }
@@ -0,0 +1,4 @@
1
+ import type { RojoTreeNode } from "./types.ts";
2
+ export declare function matchNodePath(childNode: RojoTreeNode, targetPath: string, childDataModelPath: string): string | undefined;
3
+ export declare function findInTree(node: RojoTreeNode, targetPath: string, currentDataModelPath: string): string | undefined;
4
+ //# sourceMappingURL=tree-mapper.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"tree-mapper.d.ts","sourceRoot":"","sources":["../src/tree-mapper.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,YAAY,CAAC;AAI/C,wBAAgB,aAAa,CAC5B,SAAS,EAAE,YAAY,EACvB,UAAU,EAAE,MAAM,EAClB,kBAAkB,EAAE,MAAM,GACxB,MAAM,GAAG,SAAS,CAiBpB;AAED,wBAAgB,UAAU,CACzB,IAAI,EAAE,YAAY,EAClB,UAAU,EAAE,MAAM,EAClB,oBAAoB,EAAE,MAAM,GAC1B,MAAM,GAAG,SAAS,CAsBpB"}
@@ -0,0 +1,34 @@
1
+ const TRAILING_SLASH = /\/$/;
2
+ export function matchNodePath(childNode, targetPath, childDataModelPath) {
3
+ const nodePath = childNode.$path;
4
+ if (typeof nodePath !== "string") {
5
+ return undefined;
6
+ }
7
+ const normalizedNodePath = nodePath.replace(TRAILING_SLASH, "");
8
+ if (normalizedNodePath === targetPath) {
9
+ return childDataModelPath;
10
+ }
11
+ if (targetPath.startsWith(`${normalizedNodePath}/`)) {
12
+ const remainder = targetPath.slice(normalizedNodePath.length + 1);
13
+ return `${childDataModelPath}/${remainder}`;
14
+ }
15
+ return undefined;
16
+ }
17
+ export function findInTree(node, targetPath, currentDataModelPath) {
18
+ for (const [key, value] of Object.entries(node)) {
19
+ if (key.startsWith("$") || typeof value !== "object") {
20
+ continue;
21
+ }
22
+ const childNode = value;
23
+ const childDataModelPath = currentDataModelPath === "" ? key : `${currentDataModelPath}/${key}`;
24
+ const pathMatch = matchNodePath(childNode, targetPath, childDataModelPath);
25
+ if (pathMatch !== undefined) {
26
+ return pathMatch;
27
+ }
28
+ const found = findInTree(childNode, targetPath, childDataModelPath);
29
+ if (found !== undefined) {
30
+ return found;
31
+ }
32
+ }
33
+ return undefined;
34
+ }
@@ -0,0 +1 @@
1
+ {"version":"7.0.0-dev.20260607.1","fileInfos":[],"options":{"allowJs":true,"allowUnreachableCode":false,"composite":true,"declarationMap":true,"erasableSyntaxOnly":true,"module":99,"noFallthroughCasesInSwitch":true,"noImplicitReturns":true,"noPropertyAccessFromIndexSignature":true,"noUncheckedIndexedAccess":true,"noUnusedLocals":true,"noUnusedParameters":true,"noImplicitOverride":true,"outDir":"./","skipLibCheck":true,"strict":true,"strictBuiltinIteratorReturn":true,"strictNullChecks":true,"target":99,"verbatimModuleSyntax":true,"esModuleInterop":true}}
@@ -0,0 +1,26 @@
1
+ export interface RojoTreeNode {
2
+ $className?: string;
3
+ $ignoreUnknownInstances?: boolean;
4
+ $path?: string | {
5
+ optional: string;
6
+ };
7
+ $properties?: Record<string, unknown>;
8
+ [key: string]: boolean | Record<string, unknown> | RojoTreeNode | string | undefined | {
9
+ optional: string;
10
+ };
11
+ }
12
+ export interface RojoProject {
13
+ name: string;
14
+ servePort?: number;
15
+ tree: RojoTreeNode;
16
+ }
17
+ /**
18
+ * Result of `loadRojoProject` — the validated narrow shape plus the original
19
+ * parsed JSON. Callers that need top-level fields beyond name/servePort/tree
20
+ * (gameId, placeId, globIgnorePaths, etc.) read them from `raw` rather than
21
+ * re-parsing the project file.
22
+ */
23
+ export interface LoadedRojoProject extends RojoProject {
24
+ raw: Record<string, unknown>;
25
+ }
26
+ //# sourceMappingURL=types.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,MAAM,WAAW,YAAY;IAC5B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,uBAAuB,CAAC,EAAE,OAAO,CAAC;IAClC,KAAK,CAAC,EAAE,MAAM,GAAG;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;IACtC,WAAW,CAAC,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;IACtC,CAAC,GAAG,EAAE,MAAM,GACT,OAAO,GACP,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,GACvB,YAAY,GACZ,MAAM,GACN,SAAS,GACT;QAAE,QAAQ,EAAE,MAAM,CAAA;KAAE,CAAC;CACxB;AAED,MAAM,WAAW,WAAW;IAC3B,IAAI,EAAE,MAAM,CAAC;IACb,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,IAAI,EAAE,YAAY,CAAC;CACnB;AAED;;;;;GAKG;AACH,MAAM,WAAW,iBAAkB,SAAQ,WAAW;IACrD,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC;CAC7B"}
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,3 @@
1
+ declare const _default: Promise<import("@isentinel/eslint-config").FlatConfigComposer<import("@isentinel/eslint-config").TypedFlatConfigItem, import("@isentinel/eslint-config").ConfigNames>>;
2
+ export default _default;
3
+ //# sourceMappingURL=eslint.config.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"eslint.config.d.ts","sourceRoot":"","sources":["../../eslint.config.ts"],"names":[],"mappings":""}
@@ -0,0 +1,13 @@
1
+ import { type ConfigNames, type FlatConfigComposer, type NamedFlatConfigItem, type OptionsConfig, type TypedFlatConfigItem } from "@isentinel/eslint-config";
2
+ type EslintPassthrough = {
3
+ [K in keyof OptionsConfig as K extends "namedConfigs" ? never : K]?: OptionsConfig[K];
4
+ };
5
+ export interface HalcyonSharedOptions extends EslintPassthrough {
6
+ name: string;
7
+ ignores?: Array<string>;
8
+ roblox: boolean;
9
+ rules?: NonNullable<TypedFlatConfigItem["rules"]>;
10
+ }
11
+ export declare function halcyon(options: HalcyonSharedOptions, ...userConfigs: Array<NamedFlatConfigItem>): Promise<FlatConfigComposer<TypedFlatConfigItem, ConfigNames>>;
12
+ export {};
13
+ //# sourceMappingURL=eslint.shared.d.ts.map