load-oxfmt-config 0.14.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
  /**
package/dist/index.mjs CHANGED
@@ -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,6 +76,46 @@ const EDITORCONFIG_FILE = ".editorconfig";
75
76
  */
76
77
  const EDITORCONFIG_GLOBAL_SECTION_NAMES = ["*"];
77
78
  //#endregion
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
78
119
  //#region src/config.ts
79
120
  /**
80
121
  * CommonJS require scoped to this ESM module for loading `.cjs` config files.
@@ -93,6 +134,15 @@ 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.
@@ -200,7 +250,9 @@ function deleteRequireCacheTree(requirePath, seen = /* @__PURE__ */ new Set()) {
200
250
  if (seen.has(requirePath)) return;
201
251
  seen.add(requirePath);
202
252
  const cachedModule = require.cache[requirePath];
203
- 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
+ }
204
256
  Reflect.deleteProperty(require.cache, requirePath);
205
257
  }
206
258
  /**
@@ -235,14 +287,37 @@ function requireFreshCommonJSConfig(resolvedPath) {
235
287
  * @returns Imported module namespace with a default config export.
236
288
  */
237
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
+ }
238
301
  try {
239
302
  const configModule = requireFreshModule(resolvedPath);
240
- 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
+ }
241
311
  return { default: readConfigModuleExports(configModule) };
242
312
  } catch (error) {
243
313
  if (!isJavaScriptLoaderFallbackError(error)) throw error;
244
314
  try {
245
- return await importNativeFreshModule(resolvedPath);
315
+ const namespace = await importNativeFreshModule(resolvedPath);
316
+ freshJavaScriptModuleCache.set(resolvedPath, {
317
+ cacheKey,
318
+ namespace
319
+ });
320
+ return namespace;
246
321
  } catch (importError) {
247
322
  if (!isJavaScriptLoaderFallbackError(importError)) throw importError;
248
323
  return importJitiConfigModule(resolvedPath, false);
@@ -257,7 +332,7 @@ async function importFreshJavaScriptConfigModule(resolvedPath) {
257
332
  * @returns Cache key for resolve cache.
258
333
  */
259
334
  function getResolveCacheKey(cwd, configPath) {
260
- return `${cwd}::${configPath || ""}`;
335
+ return JSON.stringify([cwd, configPath ?? null]);
261
336
  }
262
337
  /**
263
338
  * Build a cache key for config content; prefixes missing entries.
@@ -268,7 +343,11 @@ function getResolveCacheKey(cwd, configPath) {
268
343
  * @returns Cache key for config content cache.
269
344
  */
270
345
  function getConfigCacheKey(resolvedPath, editorconfigPath, resolveKey) {
271
- return `${resolvedPath || `missing-oxfmt:${resolveKey}`}::${editorconfigPath || `missing-editorconfig:${resolveKey}`}`;
346
+ return JSON.stringify([
347
+ resolvedPath ?? null,
348
+ editorconfigPath ?? null,
349
+ resolveKey
350
+ ]);
272
351
  }
273
352
  /**
274
353
  * Resolve the oxfmt config file path.
@@ -343,46 +422,6 @@ async function readConfigFromFile(resolvedPath, options = {}) {
343
422
  return readParsedConfigObject(JSON.parse(content));
344
423
  }
345
424
  //#endregion
346
- //#region src/utils.ts
347
- /**
348
- * Return a cached promise by key, creating and storing it on miss.
349
- *
350
- * If the promise rejects, the cache entry is removed so future calls can retry.
351
- *
352
- * @param cache - Map used to store inflight/resolved promises.
353
- * @param key - Cache key.
354
- * @param factory - Factory to create the promise when missing.
355
- * @returns Cached or newly created promise.
356
- */
357
- function cachePromise(cache, key, factory) {
358
- const cached = cache.get(key);
359
- if (cached) return cached;
360
- const task = factory().catch((error) => {
361
- cache.delete(key);
362
- throw error;
363
- });
364
- cache.set(key, task);
365
- return task;
366
- }
367
- /**
368
- * Normalize a filesystem path to POSIX-style separators.
369
- *
370
- * @param path - Original path.
371
- * @returns Path using `/` as separator.
372
- */
373
- function toPosixPath(path) {
374
- return path.replaceAll("\\", "/");
375
- }
376
- /**
377
- * Split a path into non-empty segments.
378
- *
379
- * @param path - Original path.
380
- * @returns Path segments.
381
- */
382
- function splitPathSegments(path) {
383
- return path.split(/[\\/]+/u).filter(Boolean);
384
- }
385
- //#endregion
386
425
  //#region src/editorconfig.ts
387
426
  /**
388
427
  * Builds the cache key used for resolved EditorConfig lookups.
@@ -420,12 +459,18 @@ function mergeRootOptions(oxfmtConfig, editorconfigRootOptions) {
420
459
  /**
421
460
  * Merges EditorConfig-derived overrides with explicit oxfmt overrides.
422
461
  *
423
- * @param oxfmtOverrides - Overrides declared in the oxfmt config.
462
+ * @param oxfmtConfig - Explicit root options and overrides from the oxfmt config.
424
463
  * @param editorconfigOverrides - Overrides derived from .editorconfig sections.
425
464
  * @returns The merged overrides array, or undefined when no overrides exist.
426
465
  */
427
- function mergeOverrides(oxfmtOverrides, editorconfigOverrides) {
428
- 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 || []];
429
474
  return mergedOverrides.length > 0 ? mergedOverrides : void 0;
430
475
  }
431
476
  /**
@@ -622,7 +667,7 @@ async function loadOxfmtConfig(options = {}) {
622
667
  if (!editorconfigPath) return oxfmtConfig;
623
668
  const editorconfigData = await readEditorconfigFromFile(editorconfigPath, anchorDir);
624
669
  const mergedConfig = mergeRootOptions(oxfmtConfig, editorconfigData.rootOptions);
625
- const mergedOverrides = mergeOverrides(oxfmtConfig.overrides, editorconfigData.overrides);
670
+ const mergedOverrides = mergeOverrides(oxfmtConfig, editorconfigData.overrides);
626
671
  if (!mergedOverrides) return mergedConfig;
627
672
  return {
628
673
  ...mergedConfig,
@@ -728,16 +773,25 @@ async function matchIgnoreFile(filepath, ignoreFilePath, useCache, baseDir) {
728
773
  */
729
774
  async function matchIgnoreFileChain(filepath, ignoreFileEntries, useCache) {
730
775
  let ignored = false;
776
+ let matched = false;
731
777
  for (const entry of ignoreFileEntries) {
732
778
  const matcher = await loadIgnoreMatcher(entry.path, useCache);
733
779
  if (!matcher) continue;
734
780
  const relativeToIgnore = relativeSafe(entry.baseDir ?? dirname(entry.path), filepath);
735
781
  if (relativeToIgnore === ".." || relativeToIgnore.startsWith("../")) continue;
736
782
  const result = matcher.test(relativeToIgnore);
737
- if (result.ignored) ignored = true;
738
- 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
+ }
739
790
  }
740
- return ignored;
791
+ return {
792
+ ignored,
793
+ matched
794
+ };
741
795
  }
742
796
  /**
743
797
  * Resolve an ignore file path against cwd when needed.
@@ -783,7 +837,16 @@ async function resolveGitInfoExcludePath(repoRoot) {
783
837
  if (!stats.isFile()) return;
784
838
  const gitdir = (await readFile(gitEntryPath, "utf8")).split(/\r?\n/u).find((line) => line.startsWith("gitdir:"))?.slice(7).trim();
785
839
  if (!gitdir) return;
786
- return join(isAbsolute(gitdir) ? gitdir : resolve(repoRoot, gitdir), "info", "exclude");
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");
787
850
  } catch (error) {
788
851
  const code = error.code;
789
852
  if (code === "ENOENT" || code === "ENOTDIR") return;
@@ -829,6 +892,18 @@ async function collectGitignorePaths(filepath) {
829
892
  };
830
893
  }
831
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
+ /**
832
907
  * Match `ignorePatterns` from config with support for negated patterns.
833
908
  *
834
909
  * @param filepath - Absolute file path.
@@ -838,6 +913,7 @@ async function collectGitignorePaths(filepath) {
838
913
  * @returns True when patterns mark the file as ignored.
839
914
  */
840
915
  function matchConfigIgnorePatterns(filepath, configDir, patterns, useCache) {
916
+ validateConfigIgnorePatterns(patterns);
841
917
  const relativeFile = relativeSafe(configDir, filepath);
842
918
  if (relativeFile === ".." || relativeFile.startsWith("../")) return false;
843
919
  if (!useCache) return ignore().add(patterns).ignores(relativeFile);
@@ -880,11 +956,12 @@ async function isOxfmtIgnored(options) {
880
956
  };
881
957
  const explicitIgnorePaths = (typeof options.ignorePath === "string" ? [options.ignorePath] : options.ignorePath)?.map((path) => resolveIgnoreFilePath(path, cwd));
882
958
  const { paths: gitignorePaths, repoRoot } = await collectGitignorePaths(filepath);
883
- 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 {
884
961
  ignored: true,
885
962
  reason: "gitignore"
886
963
  };
887
- if (repoRoot) {
964
+ if (repoRoot && !gitignoreResult.matched) {
888
965
  const infoExcludePath = await resolveGitInfoExcludePath(repoRoot);
889
966
  if (infoExcludePath && await matchIgnoreFile(filepath, infoExcludePath, useCache, repoRoot)) return {
890
967
  ignored: true,
@@ -892,7 +969,7 @@ async function isOxfmtIgnored(options) {
892
969
  };
893
970
  }
894
971
  if (explicitIgnorePaths && explicitIgnorePaths.length > 0) {
895
- if (await matchIgnoreFileChain(filepath, explicitIgnorePaths.map((path) => ({ path })), useCache)) return {
972
+ if ((await matchIgnoreFileChain(filepath, explicitIgnorePaths.map((path) => ({ path })), useCache)).ignored) return {
896
973
  ignored: true,
897
974
  reason: "ignore-path"
898
975
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "load-oxfmt-config",
3
- "version": "0.14.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.1.0",
54
- "@typescript/native-preview": "^7.0.0-dev.20260706.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.58.0",
60
- "oxlint": "^1.73.0",
61
- "tsdown": "^0.22.3",
61
+ "oxfmt": "^0.59.0",
62
+ "oxlint": "^1.74.0",
63
+ "tsdown": "^0.22.8",
62
64
  "vitest": "^4.1.10"
63
65
  },
64
66
  "peerDependencies": {
65
- "oxfmt": ">=0.58.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",