load-oxfmt-config 0.13.0 → 0.15.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.
package/README.md CHANGED
@@ -10,7 +10,7 @@
10
10
  ## Features
11
11
 
12
12
  - 🔍 **Auto-discovery** - Automatically searches for config files in current and parent directories
13
- - 📦 **Multiple formats** - Auto-discovers `.oxfmtrc.json`, `.oxfmtrc.jsonc`, and `oxfmt.config.ts`, and also supports explicit `.json` / `.jsonc` / `.js` / `.mjs` / `.cjs` / `.ts` / `.mts` / `.cts` config paths
13
+ - 📦 **Multiple formats** - Auto-discovers `.oxfmtrc.json`, `.oxfmtrc.jsonc`, `oxfmt.config.ts`, and `oxfmt.config.mts`, and also supports explicit `.json` / `.jsonc` / `.js` / `.mjs` / `.cjs` / `.ts` / `.mts` / `.cts` config paths
14
14
  - 🧩 **EditorConfig fallback** - Merges supported `.editorconfig` fields into the returned oxfmt config result
15
15
  - 🚫 **Ignore resolution** - Resolves ignore status with oxfmt CLI-like global + config-scoped semantics
16
16
  - ⚡ **Built-in caching** - Caches both file resolution and parsed configs for optimal performance
@@ -32,6 +32,7 @@ pnpm add load-oxfmt-config
32
32
  ```
33
33
 
34
34
  > `oxfmt` is a peer dependency and should be installed alongside this package.
35
+ > Node.js `^22.13.0 || >=24` is supported.
35
36
 
36
37
  ## Usage
37
38
 
@@ -370,6 +371,7 @@ When `configPath` is not provided, the loader automatically searches for config
370
371
  - `.oxfmtrc.json`
371
372
  - `.oxfmtrc.jsonc`
372
373
  - `oxfmt.config.ts`
374
+ - `oxfmt.config.mts`
373
375
  3. **Stops when:**
374
376
  - A valid config file is found
375
377
  - Reaches the filesystem root
@@ -407,7 +409,7 @@ JSON with comments support:
407
409
  }
408
410
  ```
409
411
 
410
- ### TypeScript (`oxfmt.config.ts`)
412
+ ### TypeScript (`oxfmt.config.ts` / `oxfmt.config.mts`)
411
413
 
412
414
  ```ts
413
415
  export default {
@@ -460,7 +462,7 @@ Notes:
460
462
  - `node_modules` can be included by passing `withNodeModules: true`.
461
463
 
462
464
  - The default lockfile list mirrors oxfmt documentation intent (`package-lock.json`, `pnpm-lock.yaml`, etc.) and common ecosystem lockfiles. It is not guaranteed to be a complete internal oxfmt list.
463
- - `ignorePatterns` use gitignore semantics and are interpreted relative to the resolved oxfmt config directory.
465
+ - `ignorePatterns` use gitignore semantics and are interpreted relative to the resolved oxfmt config directory. Parent-directory (`..`) path segments are rejected because patterns cannot match files outside that directory.
464
466
  - `includeConfigIgnorePatterns` defaults to `true` to preserve current behavior.
465
467
  - `loadConfigForIgnorePatterns` defaults to `true` to preserve current behavior.
466
468
  - Nested config behavior follows oxfmt semantics:
@@ -471,14 +473,13 @@ Notes:
471
473
 
472
474
  ## Precedence
473
475
 
474
- The merged result follows this order:
476
+ The merged result follows oxfmt's fallback semantics:
475
477
 
476
- 1. Root-level values from the nearest `.editorconfig`
477
- 2. Root-level values from `.oxfmtrc.json`, `.oxfmtrc.jsonc`, or `oxfmt.config.ts`
478
- 3. Overrides generated from `.editorconfig` sections
479
- 4. Overrides declared directly in the oxfmt config file
478
+ 1. Root and section-specific `.editorconfig` values provide defaults for unset fields
479
+ 2. Root-level values from `.oxfmtrc.json`, `.oxfmtrc.jsonc`, `oxfmt.config.ts`, or `oxfmt.config.mts` take precedence over all `.editorconfig` values
480
+ 3. Overrides declared directly in the oxfmt config file have the highest priority
480
481
 
481
- This means explicit root-level oxfmt config values win over root-level `.editorconfig` fallback values. Section-specific `.editorconfig` entries are represented as generated `overrides` in the static result, and explicit oxfmt `overrides` are appended after them.
482
+ Section-specific `.editorconfig` entries are represented as generated `overrides` in the static result. Fields already defined at the oxfmt config root are omitted from those generated overrides, and explicit oxfmt `overrides` are appended after them.
482
483
 
483
484
  ## Limitations
484
485
 
package/dist/index.d.mts CHANGED
@@ -1,5 +1,4 @@
1
1
  import { FormatConfig } from "oxfmt";
2
-
3
2
  //#region src/types.d.ts
4
3
  /**
5
4
  * Object-form `.editorconfig` option for fine-grained lookup control.
@@ -88,7 +87,8 @@ interface OxfmtOptions extends FormatConfig {
88
87
  /**
89
88
  * Ignore files matching these glob patterns.
90
89
  *
91
- * Patterns are based on the location of the oxfmt configuration file.
90
+ * Patterns use gitignore-style matching rooted at the oxfmt configuration
91
+ * directory. Parent-directory (`..`) path segments are rejected.
92
92
  */
93
93
  ignorePatterns?: string[];
94
94
  /**
@@ -187,27 +187,6 @@ interface IsOxfmtIgnoredResult {
187
187
  reason?: 'default-dir' | 'lockfile' | 'gitignore' | 'git-info-exclude' | 'prettierignore' | 'ignore-path' | 'config-ignore-patterns';
188
188
  }
189
189
  //#endregion
190
- //#region src/config-file.d.ts
191
- /**
192
- * Resolve the oxfmt config file path.
193
- *
194
- * - If `configPath` is provided, absolute paths are returned as-is;
195
- * relative paths are joined to `cwd`.
196
- * - Otherwise, walk upward from `cwd` to find known filenames.
197
- *
198
- * @param cwd - Starting directory for resolution.
199
- * @param configPath - Optional explicit path.
200
- * @returns Absolute path to config file, or undefined when not found.
201
- *
202
- * @example
203
- * ```ts
204
- * import { resolveOxfmtrcPath } from 'load-oxfmt-config'
205
- *
206
- * const path = await resolveOxfmtrcPath(process.cwd())
207
- * ```
208
- */
209
- declare function resolveOxfmtrcPath(cwd: string, configPath?: string): Promise<string | undefined>;
210
- //#endregion
211
190
  //#region src/core.d.ts
212
191
  /**
213
192
  * Resolve config + editorconfig and return merged config with metadata.
@@ -244,4 +223,52 @@ declare function loadOxfmtConfig(options?: LoadOxfmtConfigOptions): Promise<Load
244
223
  */
245
224
  declare function isOxfmtIgnored(options: IsOxfmtIgnoredOptions): Promise<IsOxfmtIgnoredResult>;
246
225
  //#endregion
247
- export { EditorconfigOption, IsOxfmtIgnoredOptions, IsOxfmtIgnoredResult, LoadOxfmtConfigOptions, LoadOxfmtConfigResult, OxfmtConfigOverride, OxfmtOptions, isOxfmtIgnored, loadOxfmtConfig, resolveOxfmtrcPath };
226
+ //#region src/config.d.ts
227
+ /**
228
+ * Build a cache key for path resolution (cwd + configPath).
229
+ *
230
+ * @param cwd - Current working directory.
231
+ * @param configPath - Optional config path.
232
+ * @returns Cache key for resolve cache.
233
+ */
234
+ declare function getResolveCacheKey(cwd: string, configPath?: string): string;
235
+ /**
236
+ * Build a cache key for config content; prefixes missing entries.
237
+ *
238
+ * @param resolvedPath - Resolved oxfmt config path.
239
+ * @param editorconfigPath - Resolved editorconfig path.
240
+ * @param resolveKey - Resolution cache key.
241
+ * @returns Cache key for config content cache.
242
+ */
243
+ declare function getConfigCacheKey(resolvedPath: string | undefined, editorconfigPath: string | undefined, resolveKey: string): string;
244
+ /**
245
+ * Resolve the oxfmt config file path.
246
+ *
247
+ * - If `configPath` is provided, absolute paths are returned as-is;
248
+ * relative paths are joined to `cwd`.
249
+ * - Otherwise, walk upward from `cwd` to find known filenames.
250
+ *
251
+ * @param cwd - Starting directory for resolution.
252
+ * @param configPath - Optional explicit path.
253
+ * @returns Absolute path to config file, or undefined when not found.
254
+ *
255
+ * @example
256
+ * ```ts
257
+ * import { resolveOxfmtrcPath } from 'load-oxfmt-config'
258
+ *
259
+ * const path = await resolveOxfmtrcPath(process.cwd())
260
+ * ```
261
+ */
262
+ declare function resolveOxfmtrcPath(cwd: string, configPath?: string): Promise<string | undefined>;
263
+ /**
264
+ * Read and parse an oxfmt config file.
265
+ *
266
+ * @param resolvedPath - Absolute path to config file.
267
+ * @param options - Config loading options.
268
+ * @returns Parsed config object.
269
+ */
270
+ declare function readConfigFromFile(resolvedPath: string, options?: {
271
+ useCache?: boolean;
272
+ }): Promise<OxfmtOptions>;
273
+ //#endregion
274
+ export { EditorconfigOption, IsOxfmtIgnoredOptions, IsOxfmtIgnoredResult, LoadOxfmtConfigOptions, LoadOxfmtConfigResult, OxfmtConfigOverride, OxfmtOptions, getConfigCacheKey, getResolveCacheKey, isOxfmtIgnored, loadOxfmtConfig, readConfigFromFile, resolveOxfmtrcPath };
package/dist/index.mjs CHANGED
@@ -1,7 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
3
3
  import process from "node:process";
4
- import { interopDefault, isBoolean, isNumber, isObject } from "@ntnyq/utils";
4
+ import { hasOwn, interopDefault, isBoolean, isNumber, isObject, isRecord, isString, isUndefined } from "@ntnyq/utils";
5
5
  import { readFile, stat } from "node:fs/promises";
6
6
  import { pathToFileURL } from "node:url";
7
7
  import { parse, printParseErrorCode } from "jsonc-parser";
@@ -15,7 +15,8 @@ import ignore from "ignore";
15
15
  const OXFMT_CONFIG_FILES = [
16
16
  ".oxfmtrc.json",
17
17
  ".oxfmtrc.jsonc",
18
- "oxfmt.config.ts"
18
+ "oxfmt.config.ts",
19
+ "oxfmt.config.mts"
19
20
  ];
20
21
  /**
21
22
  * Supported extensions for explicit config paths.
@@ -75,7 +76,47 @@ const EDITORCONFIG_FILE = ".editorconfig";
75
76
  */
76
77
  const EDITORCONFIG_GLOBAL_SECTION_NAMES = ["*"];
77
78
  //#endregion
78
- //#region src/config-file.ts
79
+ //#region src/utils.ts
80
+ /**
81
+ * Return a cached promise by key, creating and storing it on miss.
82
+ *
83
+ * If the promise rejects, the cache entry is removed so future calls can retry.
84
+ *
85
+ * @param cache - Map used to store inflight/resolved promises.
86
+ * @param key - Cache key.
87
+ * @param factory - Factory to create the promise when missing.
88
+ * @returns Cached or newly created promise.
89
+ */
90
+ function cachePromise(cache, key, factory) {
91
+ const cached = cache.get(key);
92
+ if (cached) return cached;
93
+ const task = factory().catch((error) => {
94
+ cache.delete(key);
95
+ throw error;
96
+ });
97
+ cache.set(key, task);
98
+ return task;
99
+ }
100
+ /**
101
+ * Normalize a filesystem path to POSIX-style separators.
102
+ *
103
+ * @param path - Original path.
104
+ * @returns Path using `/` as separator.
105
+ */
106
+ function toPosixPath(path) {
107
+ return path.replaceAll("\\", "/");
108
+ }
109
+ /**
110
+ * Split a path into non-empty segments.
111
+ *
112
+ * @param path - Original path.
113
+ * @returns Path segments.
114
+ */
115
+ function splitPathSegments(path) {
116
+ return path.split(/[\\/]+/u).filter(Boolean);
117
+ }
118
+ //#endregion
119
+ //#region src/config.ts
79
120
  /**
80
121
  * CommonJS require scoped to this ESM module for loading `.cjs` config files.
81
122
  */
@@ -93,13 +134,33 @@ const syntaxFallbackMessages = [
93
134
  /Unexpected token 'import'/u
94
135
  ];
95
136
  /**
137
+ * ESM namespaces loaded from `.js` config files while bypassing normal caches.
138
+ *
139
+ * Node can load synchronous ESM through `require()`, but deleting
140
+ * `require.cache` does not invalidate the ESM loader cache. Remember the entry
141
+ * file version so changed configs can switch to a cache-busted native import
142
+ * without evaluating the first load twice.
143
+ */
144
+ const freshJavaScriptModuleCache = /* @__PURE__ */ new Map();
145
+ /**
96
146
  * Check whether an unknown config export is an object accepted by oxfmt.
97
147
  *
98
148
  * @param value - Runtime value to validate.
99
149
  * @returns True when the value can be treated as an oxfmt options object.
100
150
  */
101
151
  function isConfigObject(value) {
102
- return typeof value === "object" && value !== null && !Array.isArray(value);
152
+ return isRecord(value);
153
+ }
154
+ /**
155
+ * Read a parsed JSON-like config value as an oxfmt config object.
156
+ *
157
+ * @param config - Runtime config value.
158
+ * @returns Oxfmt config object.
159
+ * @throws When the config value is not an object.
160
+ */
161
+ function readParsedConfigObject(config) {
162
+ if (!isConfigObject(config)) throw new Error("Configuration file must be an object.");
163
+ return config;
103
164
  }
104
165
  /**
105
166
  * Read the default export from a JavaScript or TypeScript config module.
@@ -109,7 +170,7 @@ function isConfigObject(value) {
109
170
  * @throws When the module has no default export or the default export is not an object.
110
171
  */
111
172
  function readConfigDefaultExport(mod) {
112
- if (!Object.hasOwn(mod, "default")) throw new Error("Configuration file has no default export.");
173
+ if (!hasOwn(mod, "default")) throw new Error("Configuration file has no default export.");
113
174
  const config = mod["default"];
114
175
  if (!isConfigObject(config)) throw new Error("Configuration file must have a default export that is an object.");
115
176
  return config;
@@ -155,7 +216,7 @@ async function getFreshImportCacheKey(resolvedPath) {
155
216
  * @returns Error code when present.
156
217
  */
157
218
  function getErrorCode(error) {
158
- return typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" ? error.code : void 0;
219
+ return isObject(error) && "code" in error && isString(error.code) ? error.code : void 0;
159
220
  }
160
221
  /**
161
222
  * Check whether a loader error should continue the JS config fallback chain.
@@ -189,7 +250,9 @@ function deleteRequireCacheTree(requirePath, seen = /* @__PURE__ */ new Set()) {
189
250
  if (seen.has(requirePath)) return;
190
251
  seen.add(requirePath);
191
252
  const cachedModule = require.cache[requirePath];
192
- if (cachedModule) for (const child of cachedModule.children) deleteRequireCacheTree(child.id, seen);
253
+ if (cachedModule) {
254
+ for (const child of cachedModule.children) if (!splitPathSegments(child.id).includes("node_modules")) deleteRequireCacheTree(child.id, seen);
255
+ }
193
256
  Reflect.deleteProperty(require.cache, requirePath);
194
257
  }
195
258
  /**
@@ -224,14 +287,37 @@ function requireFreshCommonJSConfig(resolvedPath) {
224
287
  * @returns Imported module namespace with a default config export.
225
288
  */
226
289
  async function importFreshJavaScriptConfigModule(resolvedPath) {
290
+ const cacheKey = await getFreshImportCacheKey(resolvedPath);
291
+ const cachedModule = freshJavaScriptModuleCache.get(resolvedPath);
292
+ if (cachedModule?.cacheKey === cacheKey) return cachedModule.namespace;
293
+ if (cachedModule) {
294
+ const namespace = await importNativeFreshModule(resolvedPath);
295
+ freshJavaScriptModuleCache.set(resolvedPath, {
296
+ cacheKey,
297
+ namespace
298
+ });
299
+ return namespace;
300
+ }
227
301
  try {
228
302
  const configModule = requireFreshModule(resolvedPath);
229
- if (Object.prototype.toString.call(configModule) === "[object Module]") return importNativeFreshModule(resolvedPath);
303
+ if (Object.prototype.toString.call(configModule) === "[object Module]") {
304
+ const namespace = configModule;
305
+ freshJavaScriptModuleCache.set(resolvedPath, {
306
+ cacheKey,
307
+ namespace
308
+ });
309
+ return namespace;
310
+ }
230
311
  return { default: readConfigModuleExports(configModule) };
231
312
  } catch (error) {
232
313
  if (!isJavaScriptLoaderFallbackError(error)) throw error;
233
314
  try {
234
- return await importNativeFreshModule(resolvedPath);
315
+ const namespace = await importNativeFreshModule(resolvedPath);
316
+ freshJavaScriptModuleCache.set(resolvedPath, {
317
+ cacheKey,
318
+ namespace
319
+ });
320
+ return namespace;
235
321
  } catch (importError) {
236
322
  if (!isJavaScriptLoaderFallbackError(importError)) throw importError;
237
323
  return importJitiConfigModule(resolvedPath, false);
@@ -246,7 +332,7 @@ async function importFreshJavaScriptConfigModule(resolvedPath) {
246
332
  * @returns Cache key for resolve cache.
247
333
  */
248
334
  function getResolveCacheKey(cwd, configPath) {
249
- return `${cwd}::${configPath || ""}`;
335
+ return JSON.stringify([cwd, configPath ?? null]);
250
336
  }
251
337
  /**
252
338
  * Build a cache key for config content; prefixes missing entries.
@@ -257,7 +343,11 @@ function getResolveCacheKey(cwd, configPath) {
257
343
  * @returns Cache key for config content cache.
258
344
  */
259
345
  function getConfigCacheKey(resolvedPath, editorconfigPath, resolveKey) {
260
- return `${resolvedPath || `missing-oxfmt:${resolveKey}`}::${editorconfigPath || `missing-editorconfig:${resolveKey}`}`;
346
+ return JSON.stringify([
347
+ resolvedPath ?? null,
348
+ editorconfigPath ?? null,
349
+ resolveKey
350
+ ]);
261
351
  }
262
352
  /**
263
353
  * Resolve the oxfmt config file path.
@@ -321,55 +411,15 @@ async function readConfigFromFile(resolvedPath, options = {}) {
321
411
  });
322
412
  if (parseErrors.length > 0) {
323
413
  const firstError = parseErrors[0];
324
- const errorCode = firstError === void 0 ? "Unknown" : printParseErrorCode(firstError.error);
414
+ const errorCode = isUndefined(firstError) ? "Unknown" : printParseErrorCode(firstError.error);
325
415
  throw new Error(`Invalid JSONC syntax: ${errorCode}`);
326
416
  }
327
- return parsed ?? {};
417
+ return readParsedConfigObject(isUndefined(parsed) && content.trim() === "" ? {} : parsed);
328
418
  }
329
- if (extension === ".json") return JSON.parse(content);
330
- if (!extension) return JSON.parse(content);
419
+ if (extension === ".json") return readParsedConfigObject(JSON.parse(content));
420
+ if (!extension) return readParsedConfigObject(JSON.parse(content));
331
421
  if (!OXFMT_EXPLICIT_CONFIG_EXTENSIONS.includes(extension)) throw new Error(`Unsupported oxfmt config extension "${extension}" at ${resolvedPath}`);
332
- return JSON.parse(content);
333
- }
334
- //#endregion
335
- //#region src/utils.ts
336
- /**
337
- * Return a cached promise by key, creating and storing it on miss.
338
- *
339
- * If the promise rejects, the cache entry is removed so future calls can retry.
340
- *
341
- * @param cache - Map used to store inflight/resolved promises.
342
- * @param key - Cache key.
343
- * @param factory - Factory to create the promise when missing.
344
- * @returns Cached or newly created promise.
345
- */
346
- function cachePromise(cache, key, factory) {
347
- const cached = cache.get(key);
348
- if (cached) return cached;
349
- const task = factory().catch((error) => {
350
- cache.delete(key);
351
- throw error;
352
- });
353
- cache.set(key, task);
354
- return task;
355
- }
356
- /**
357
- * Normalize a filesystem path to POSIX-style separators.
358
- *
359
- * @param path - Original path.
360
- * @returns Path using `/` as separator.
361
- */
362
- function toPosixPath(path) {
363
- return path.replaceAll("\\", "/");
364
- }
365
- /**
366
- * Split a path into non-empty segments.
367
- *
368
- * @param path - Original path.
369
- * @returns Path segments.
370
- */
371
- function splitPathSegments(path) {
372
- return path.split(/[\\/]+/u).filter(Boolean);
422
+ return readParsedConfigObject(JSON.parse(content));
373
423
  }
374
424
  //#endregion
375
425
  //#region src/editorconfig.ts
@@ -409,12 +459,18 @@ function mergeRootOptions(oxfmtConfig, editorconfigRootOptions) {
409
459
  /**
410
460
  * Merges EditorConfig-derived overrides with explicit oxfmt overrides.
411
461
  *
412
- * @param oxfmtOverrides - Overrides declared in the oxfmt config.
462
+ * @param oxfmtConfig - Explicit root options and overrides from the oxfmt config.
413
463
  * @param editorconfigOverrides - Overrides derived from .editorconfig sections.
414
464
  * @returns The merged overrides array, or undefined when no overrides exist.
415
465
  */
416
- function mergeOverrides(oxfmtOverrides, editorconfigOverrides) {
417
- const mergedOverrides = [...editorconfigOverrides, ...oxfmtOverrides || []];
466
+ function mergeOverrides(oxfmtConfig, editorconfigOverrides) {
467
+ const mergedOverrides = [...editorconfigOverrides.flatMap((override) => {
468
+ const options = Object.fromEntries(Object.entries(override.options || {}).filter(([name]) => !Object.hasOwn(oxfmtConfig, name)));
469
+ return Object.keys(options).length > 0 ? [{
470
+ ...override,
471
+ options
472
+ }] : [];
473
+ }), ...oxfmtConfig.overrides || []];
418
474
  return mergedOverrides.length > 0 ? mergedOverrides : void 0;
419
475
  }
420
476
  /**
@@ -611,7 +667,7 @@ async function loadOxfmtConfig(options = {}) {
611
667
  if (!editorconfigPath) return oxfmtConfig;
612
668
  const editorconfigData = await readEditorconfigFromFile(editorconfigPath, anchorDir);
613
669
  const mergedConfig = mergeRootOptions(oxfmtConfig, editorconfigData.rootOptions);
614
- const mergedOverrides = mergeOverrides(oxfmtConfig.overrides, editorconfigData.overrides);
670
+ const mergedOverrides = mergeOverrides(oxfmtConfig, editorconfigData.overrides);
615
671
  if (!mergedOverrides) return mergedConfig;
616
672
  return {
617
673
  ...mergedConfig,
@@ -717,16 +773,25 @@ async function matchIgnoreFile(filepath, ignoreFilePath, useCache, baseDir) {
717
773
  */
718
774
  async function matchIgnoreFileChain(filepath, ignoreFileEntries, useCache) {
719
775
  let ignored = false;
776
+ let matched = false;
720
777
  for (const entry of ignoreFileEntries) {
721
778
  const matcher = await loadIgnoreMatcher(entry.path, useCache);
722
779
  if (!matcher) continue;
723
780
  const relativeToIgnore = relativeSafe(entry.baseDir ?? dirname(entry.path), filepath);
724
781
  if (relativeToIgnore === ".." || relativeToIgnore.startsWith("../")) continue;
725
782
  const result = matcher.test(relativeToIgnore);
726
- if (result.ignored) ignored = true;
727
- else if (result.unignored) ignored = false;
783
+ if (result.ignored) {
784
+ ignored = true;
785
+ matched = true;
786
+ } else if (result.unignored) {
787
+ ignored = false;
788
+ matched = true;
789
+ }
728
790
  }
729
- return ignored;
791
+ return {
792
+ ignored,
793
+ matched
794
+ };
730
795
  }
731
796
  /**
732
797
  * Resolve an ignore file path against cwd when needed.
@@ -757,6 +822,38 @@ async function hasGitEntry(dir) {
757
822
  }
758
823
  }
759
824
  /**
825
+ * Resolve the git info exclude file from a repo root.
826
+ *
827
+ * Worktrees and submodules store `.git` as a file containing a `gitdir:` pointer.
828
+ *
829
+ * @param repoRoot - Git repository working tree root.
830
+ * @returns Absolute path to the git info exclude file, or undefined when unavailable.
831
+ */
832
+ async function resolveGitInfoExcludePath(repoRoot) {
833
+ const gitEntryPath = join(repoRoot, ".git");
834
+ try {
835
+ const stats = await stat(gitEntryPath);
836
+ if (stats.isDirectory()) return join(gitEntryPath, "info", "exclude");
837
+ if (!stats.isFile()) return;
838
+ const gitdir = (await readFile(gitEntryPath, "utf8")).split(/\r?\n/u).find((line) => line.startsWith("gitdir:"))?.slice(7).trim();
839
+ if (!gitdir) return;
840
+ const resolvedGitDir = isAbsolute(gitdir) ? gitdir : resolve(repoRoot, gitdir);
841
+ const commonDirPath = join(resolvedGitDir, "commondir");
842
+ try {
843
+ const commonDir = (await readFile(commonDirPath, "utf8")).trim();
844
+ if (commonDir) return join(isAbsolute(commonDir) ? commonDir : resolve(resolvedGitDir, commonDir), "info", "exclude");
845
+ } catch (error) {
846
+ const code = error.code;
847
+ if (code !== "ENOENT" && code !== "ENOTDIR") throw error;
848
+ }
849
+ return join(resolvedGitDir, "info", "exclude");
850
+ } catch (error) {
851
+ const code = error.code;
852
+ if (code === "ENOENT" || code === "ENOTDIR") return;
853
+ throw error;
854
+ }
855
+ }
856
+ /**
760
857
  * Find the nearest git repo root by walking up from a start directory.
761
858
  *
762
859
  * @param fromDir - Directory to start from.
@@ -795,6 +892,18 @@ async function collectGitignorePaths(filepath) {
795
892
  };
796
893
  }
797
894
  /**
895
+ * Validate config ignore patterns using oxfmt's path constraints.
896
+ *
897
+ * @param patterns - Config ignore patterns.
898
+ * @throws When a pattern contains a parent-directory path segment.
899
+ */
900
+ function validateConfigIgnorePatterns(patterns) {
901
+ for (const pattern of patterns) {
902
+ const pathPart = pattern.startsWith("!") ? pattern.slice(1) : pattern;
903
+ if ((pathPart.endsWith(String.raw`\ `) ? pathPart : pathPart.trimEnd()).split("/").includes("..")) throw new Error(`Invalid pattern \`${pattern}\` in \`ignorePatterns\`: \`..\` is not supported, patterns are resolved within the config file's directory`);
904
+ }
905
+ }
906
+ /**
798
907
  * Match `ignorePatterns` from config with support for negated patterns.
799
908
  *
800
909
  * @param filepath - Absolute file path.
@@ -804,6 +913,7 @@ async function collectGitignorePaths(filepath) {
804
913
  * @returns True when patterns mark the file as ignored.
805
914
  */
806
915
  function matchConfigIgnorePatterns(filepath, configDir, patterns, useCache) {
916
+ validateConfigIgnorePatterns(patterns);
807
917
  const relativeFile = relativeSafe(configDir, filepath);
808
918
  if (relativeFile === ".." || relativeFile.startsWith("../")) return false;
809
919
  if (!useCache) return ignore().add(patterns).ignores(relativeFile);
@@ -846,18 +956,20 @@ async function isOxfmtIgnored(options) {
846
956
  };
847
957
  const explicitIgnorePaths = (typeof options.ignorePath === "string" ? [options.ignorePath] : options.ignorePath)?.map((path) => resolveIgnoreFilePath(path, cwd));
848
958
  const { paths: gitignorePaths, repoRoot } = await collectGitignorePaths(filepath);
849
- if (await matchIgnoreFileChain(filepath, [...gitignorePaths].reverse().map((path) => ({ path })), useCache)) return {
959
+ const gitignoreResult = await matchIgnoreFileChain(filepath, [...gitignorePaths].reverse().map((path) => ({ path })), useCache);
960
+ if (gitignoreResult.ignored) return {
850
961
  ignored: true,
851
962
  reason: "gitignore"
852
963
  };
853
- if (repoRoot) {
854
- if (await matchIgnoreFile(filepath, join(repoRoot, ".git", "info", "exclude"), useCache, repoRoot)) return {
964
+ if (repoRoot && !gitignoreResult.matched) {
965
+ const infoExcludePath = await resolveGitInfoExcludePath(repoRoot);
966
+ if (infoExcludePath && await matchIgnoreFile(filepath, infoExcludePath, useCache, repoRoot)) return {
855
967
  ignored: true,
856
968
  reason: "git-info-exclude"
857
969
  };
858
970
  }
859
971
  if (explicitIgnorePaths && explicitIgnorePaths.length > 0) {
860
- if (await matchIgnoreFileChain(filepath, explicitIgnorePaths.map((path) => ({ path })), useCache)) return {
972
+ if ((await matchIgnoreFileChain(filepath, explicitIgnorePaths.map((path) => ({ path })), useCache)).ignored) return {
861
973
  ignored: true,
862
974
  reason: "ignore-path"
863
975
  };
@@ -881,4 +993,4 @@ async function isOxfmtIgnored(options) {
881
993
  return { ignored: false };
882
994
  }
883
995
  //#endregion
884
- export { isOxfmtIgnored, loadOxfmtConfig, resolveOxfmtrcPath };
996
+ export { getConfigCacheKey, getResolveCacheKey, isOxfmtIgnored, loadOxfmtConfig, readConfigFromFile, resolveOxfmtrcPath };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "load-oxfmt-config",
3
- "version": "0.13.0",
3
+ "version": "0.15.0",
4
4
  "description": "Load and resolve oxfmt configuration files, including explicit JS/TS config paths, and merge supported `.editorconfig` settings for Oxfmt.",
5
5
  "keywords": [
6
6
  "editorconfig",
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "repository": {
24
24
  "type": "git",
25
- "url": "ntnyq/load-oxfmt-config"
25
+ "url": "git+https://github.com/ntnyq/load-oxfmt-config.git"
26
26
  },
27
27
  "files": [
28
28
  "dist"
@@ -42,32 +42,37 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@ntnyq/utils": "^0.16.0",
45
+ "@ntnyq/utils": "^0.16.1",
46
46
  "editorconfig": "^3.0.2",
47
- "ignore": "^7.0.5",
47
+ "ignore": "^7.0.6",
48
48
  "jiti": "^2.7.0",
49
49
  "jsonc-parser": "^3.3.1"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@ntnyq/tsconfig": "^3.1.0",
53
- "@types/node": "^26.0.1",
54
- "@typescript/native-preview": "^7.0.0-dev.20260629.1",
53
+ "@types/node": "^26.1.1",
54
+ "@typescript/native-preview": "^7.0.0-dev.20260707.2",
55
55
  "bumpp": "^11.1.0",
56
+ "changelogithub": "14.0.0",
56
57
  "husky": "^9.1.7",
57
58
  "nano-staged": "^1.0.2",
59
+ "npm": "11.18.0",
58
60
  "npm-run-all2": "^9.0.2",
59
- "oxfmt": "^0.57.0",
60
- "oxlint": "^1.72.0",
61
- "tsdown": "^0.22.3",
62
- "vitest": "^4.1.9"
61
+ "oxfmt": "^0.59.0",
62
+ "oxlint": "^1.74.0",
63
+ "tsdown": "^0.22.8",
64
+ "vitest": "^4.1.10"
63
65
  },
64
66
  "peerDependencies": {
65
- "oxfmt": ">=0.57.0"
67
+ "oxfmt": ">=0.59.0"
66
68
  },
67
69
  "nano-staged": {
68
70
  "*.{js,ts,mjs,tsx}": "oxlint --fix",
69
71
  "*": "oxfmt --no-error-on-unmatched-pattern"
70
72
  },
73
+ "engines": {
74
+ "node": "^22.13.0 || >=24"
75
+ },
71
76
  "scripts": {
72
77
  "build": "tsdown",
73
78
  "dev": "tsdown --watch",