load-oxfmt-config 0.18.0 → 0.19.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 (3) hide show
  1. package/README.md +2 -1
  2. package/dist/index.mjs +103 -35
  3. package/package.json +7 -7
package/README.md CHANGED
@@ -516,8 +516,9 @@ The caching system maintains two separate caches:
516
516
 
517
517
  - Failed operations automatically clear their cache entries
518
518
  - Use `useCache: false` to bypass cache for specific calls
519
+ - Each process-level cache retains at most 1,000 least-recently used entries
519
520
  - For native ESM config files (`.mjs` or `.js` under `"type": "module"`), `useCache: false` cache-busts the entry config file. Imported ESM helper modules still follow Node.js module cache behavior for the current process.
520
- - Cache persists for the lifetime of the Node.js process
521
+ - Cache entries persist until least-recently used eviction or process exit
521
522
 
522
523
  ## Related
523
524
 
package/dist/index.mjs CHANGED
@@ -1,8 +1,9 @@
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 { hasOwn, interopDefault, isBoolean, isNumber, isObject, isRecord, isString, isUndefined } from "@ntnyq/utils";
5
- import { readFile, stat } from "node:fs/promises";
4
+ import { hasOwn, interopDefault, isBoolean, isNumber, isObject, isString, isUndefined } from "@ntnyq/utils";
5
+ import { createHash } from "node:crypto";
6
+ import { readFile, realpath, stat } from "node:fs/promises";
6
7
  import { pathToFileURL } from "node:url";
7
8
  import { parse, printParseErrorCode } from "jsonc-parser";
8
9
  import { parseBuffer } from "editorconfig";
@@ -78,6 +79,34 @@ const EDITORCONFIG_GLOBAL_SECTION_NAMES = ["*"];
78
79
  //#endregion
79
80
  //#region src/utils.ts
80
81
  /**
82
+ * Read a cache value and move it to the most-recent position.
83
+ *
84
+ * @param cache - Cache map to read.
85
+ * @param key - Cache key.
86
+ * @returns Cached value, or undefined when missing.
87
+ */
88
+ function getCacheValue(cache, key) {
89
+ const value = cache.get(key);
90
+ if (value === void 0) return;
91
+ cache.delete(key);
92
+ cache.set(key, value);
93
+ return value;
94
+ }
95
+ /**
96
+ * Store a cache value and evict the least-recent entry when over capacity.
97
+ *
98
+ * @param cache - Cache map to update.
99
+ * @param key - Cache key.
100
+ * @param value - Value to cache.
101
+ */
102
+ function setCacheValue(cache, key, value) {
103
+ cache.delete(key);
104
+ cache.set(key, value);
105
+ if (cache.size <= 1e3) return;
106
+ const oldestKey = cache.keys().next();
107
+ if (!oldestKey.done) cache.delete(oldestKey.value);
108
+ }
109
+ /**
81
110
  * Return a cached promise by key, creating and storing it on miss.
82
111
  *
83
112
  * If the promise rejects, the cache entry is removed so future calls can retry.
@@ -88,13 +117,13 @@ const EDITORCONFIG_GLOBAL_SECTION_NAMES = ["*"];
88
117
  * @returns Cached or newly created promise.
89
118
  */
90
119
  function cachePromise(cache, key, factory) {
91
- const cached = cache.get(key);
120
+ const cached = getCacheValue(cache, key);
92
121
  if (cached) return cached;
93
122
  const task = factory().catch((error) => {
94
- cache.delete(key);
123
+ if (cache.get(key) === task) cache.delete(key);
95
124
  throw error;
96
125
  });
97
- cache.set(key, task);
126
+ setCacheValue(cache, key, task);
98
127
  return task;
99
128
  }
100
129
  /**
@@ -115,6 +144,19 @@ function toPosixPath(path) {
115
144
  function splitPathSegments(path) {
116
145
  return path.split(/[\\/]+/u).filter(Boolean);
117
146
  }
147
+ /**
148
+ * Check whether a relative path points outside its intended base directory.
149
+ *
150
+ * This also recognizes Windows drive-qualified paths when running on another
151
+ * platform so the behavior can be tested consistently.
152
+ *
153
+ * @param path - Relative path candidate.
154
+ * @returns True when the path escapes or is absolute.
155
+ */
156
+ function isPathOutsideBase(path) {
157
+ const normalizedPath = toPosixPath(path);
158
+ return normalizedPath === ".." || normalizedPath.startsWith("../") || normalizedPath.startsWith("/") || /^[A-Za-z]:\//u.test(normalizedPath);
159
+ }
118
160
  //#endregion
119
161
  //#region src/config.ts
120
162
  /**
@@ -149,7 +191,9 @@ const freshJavaScriptModuleCache = /* @__PURE__ */ new Map();
149
191
  * @returns True when the value can be treated as an oxfmt options object.
150
192
  */
151
193
  function isConfigObject(value) {
152
- return isRecord(value);
194
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
195
+ const prototype = Object.getPrototypeOf(value);
196
+ return prototype === Object.prototype || prototype === null;
153
197
  }
154
198
  /**
155
199
  * Read a parsed JSON-like config value as an oxfmt config object.
@@ -200,14 +244,14 @@ async function importJitiConfigModule(resolvedPath, useCache) {
200
244
  }).import(resolvedPath);
201
245
  }
202
246
  /**
203
- * Build a cache-busting import key from entry file metadata.
247
+ * Build a cache-busting import key from entry file contents.
204
248
  *
205
249
  * @param resolvedPath - Absolute config file path.
206
- * @returns Stable key that changes when the entry file mtime or size changes.
250
+ * @returns Stable key that changes when the entry file contents change.
207
251
  */
208
252
  async function getFreshImportCacheKey(resolvedPath) {
209
- const stats = await stat(resolvedPath, { bigint: true });
210
- return `${stats.mtimeNs}:${stats.size}`;
253
+ const content = await readFile(resolvedPath);
254
+ return createHash("sha256").update(content).digest("hex");
211
255
  }
212
256
  /**
213
257
  * Read a Node-style `code` field from an unknown thrown value.
@@ -222,12 +266,13 @@ function getErrorCode(error) {
222
266
  * Check whether a loader error should continue the JS config fallback chain.
223
267
  *
224
268
  * @param error - Thrown value from require or native import.
269
+ * @param resolvedPaths - Entry module paths that may appear in parser errors.
225
270
  * @returns True when the error is from loader compatibility, not config execution.
226
271
  */
227
- function isJavaScriptLoaderFallbackError(error) {
272
+ function isJavaScriptLoaderFallbackError(error, resolvedPaths) {
228
273
  const code = getErrorCode(error);
229
274
  if (code && requireFallbackErrorCodes.has(code)) return true;
230
- return error instanceof SyntaxError && syntaxFallbackMessages.some((pattern) => pattern.test(error.message));
275
+ return error instanceof SyntaxError && resolvedPaths.some((resolvedPath) => error.stack?.startsWith(`${resolvedPath}:`) === true) && syntaxFallbackMessages.some((pattern) => pattern.test(error.message));
231
276
  }
232
277
  /**
233
278
  * Import an ESM config module with entry-file cache busting.
@@ -287,12 +332,14 @@ function requireFreshCommonJSConfig(resolvedPath) {
287
332
  * @returns Imported module namespace with a default config export.
288
333
  */
289
334
  async function importFreshJavaScriptConfigModule(resolvedPath) {
335
+ const canonicalPath = await realpath(resolvedPath);
336
+ const loaderPaths = canonicalPath === resolvedPath ? [resolvedPath] : [resolvedPath, canonicalPath];
290
337
  const cacheKey = await getFreshImportCacheKey(resolvedPath);
291
- const cachedModule = freshJavaScriptModuleCache.get(resolvedPath);
338
+ const cachedModule = getCacheValue(freshJavaScriptModuleCache, resolvedPath);
292
339
  if (cachedModule?.cacheKey === cacheKey) return cachedModule.namespace;
293
340
  if (cachedModule) {
294
341
  const namespace = await importNativeFreshModule(resolvedPath);
295
- freshJavaScriptModuleCache.set(resolvedPath, {
342
+ setCacheValue(freshJavaScriptModuleCache, resolvedPath, {
296
343
  cacheKey,
297
344
  namespace
298
345
  });
@@ -302,7 +349,7 @@ async function importFreshJavaScriptConfigModule(resolvedPath) {
302
349
  const configModule = requireFreshModule(resolvedPath);
303
350
  if (Object.prototype.toString.call(configModule) === "[object Module]") {
304
351
  const namespace = configModule;
305
- freshJavaScriptModuleCache.set(resolvedPath, {
352
+ setCacheValue(freshJavaScriptModuleCache, resolvedPath, {
306
353
  cacheKey,
307
354
  namespace
308
355
  });
@@ -310,16 +357,16 @@ async function importFreshJavaScriptConfigModule(resolvedPath) {
310
357
  }
311
358
  return { default: readConfigModuleExports(configModule) };
312
359
  } catch (error) {
313
- if (!isJavaScriptLoaderFallbackError(error)) throw error;
360
+ if (!isJavaScriptLoaderFallbackError(error, loaderPaths)) throw error;
314
361
  try {
315
362
  const namespace = await importNativeFreshModule(resolvedPath);
316
- freshJavaScriptModuleCache.set(resolvedPath, {
363
+ setCacheValue(freshJavaScriptModuleCache, resolvedPath, {
317
364
  cacheKey,
318
365
  namespace
319
366
  });
320
367
  return namespace;
321
368
  } catch (importError) {
322
- if (!isJavaScriptLoaderFallbackError(importError)) throw importError;
369
+ if (!isJavaScriptLoaderFallbackError(importError, loaderPaths)) throw importError;
323
370
  return importJitiConfigModule(resolvedPath, false);
324
371
  }
325
372
  }
@@ -762,26 +809,25 @@ async function matchIgnoreFile(filepath, ignoreFilePath, useCache, baseDir) {
762
809
  const matcher = await loadIgnoreMatcher(ignoreFilePath, useCache);
763
810
  if (!matcher) return false;
764
811
  const relativeToIgnore = relativeSafe(baseDir ?? dirname(ignoreFilePath), filepath);
765
- if (relativeToIgnore === ".." || relativeToIgnore.startsWith("../")) return false;
812
+ if (isPathOutsideBase(relativeToIgnore)) return false;
766
813
  return matcher.ignores(relativeToIgnore);
767
814
  }
768
815
  /**
769
- * Match a file path against ordered ignore files while preserving negation state.
816
+ * Match a path against loaded ignore matchers in precedence order.
770
817
  *
771
- * @param filepath - Absolute file path.
772
- * @param ignoreFileEntries - Ignore files in increasing precedence order.
773
- * @param useCache - Whether to use matcher cache.
774
- * @returns True when the ordered ignore files mark the file as ignored.
818
+ * @param filepath - Absolute file or directory path to test.
819
+ * @param matcherEntries - Loaded matchers in increasing precedence order.
820
+ * @param isDirectory - Whether the target path is a directory.
821
+ * @returns Final ignore state after all matching rules.
775
822
  */
776
- async function matchIgnoreFileChain(filepath, ignoreFileEntries, useCache) {
823
+ function matchLoadedIgnoreMatchers(filepath, matcherEntries, isDirectory = false) {
777
824
  let ignored = false;
778
825
  let matched = false;
779
- for (const entry of ignoreFileEntries) {
780
- const matcher = await loadIgnoreMatcher(entry.path, useCache);
781
- if (!matcher) continue;
782
- const relativeToIgnore = relativeSafe(entry.baseDir ?? dirname(entry.path), filepath);
783
- if (relativeToIgnore === ".." || relativeToIgnore.startsWith("../")) continue;
784
- const result = matcher.test(relativeToIgnore);
826
+ for (const entry of matcherEntries) {
827
+ let relativeToIgnore = relativeSafe(entry.baseDir, filepath);
828
+ if (isPathOutsideBase(relativeToIgnore)) continue;
829
+ if (isDirectory && relativeToIgnore && !relativeToIgnore.endsWith("/")) relativeToIgnore += "/";
830
+ const result = entry.matcher.test(relativeToIgnore);
785
831
  if (result.ignored) {
786
832
  ignored = true;
787
833
  matched = true;
@@ -796,6 +842,28 @@ async function matchIgnoreFileChain(filepath, ignoreFileEntries, useCache) {
796
842
  };
797
843
  }
798
844
  /**
845
+ * Match a file path against ordered ignore files while preserving negation state.
846
+ *
847
+ * @param filepath - Absolute file path.
848
+ * @param ignoreFileEntries - Ignore files in increasing precedence order.
849
+ * @param useCache - Whether to use matcher cache.
850
+ * @param stopAtIgnoredDirectory - Whether ignored directories prevent reading nested ignore files.
851
+ * @returns True when the ordered ignore files mark the file as ignored.
852
+ */
853
+ async function matchIgnoreFileChain(filepath, ignoreFileEntries, useCache, stopAtIgnoredDirectory = false) {
854
+ const matcherEntries = [];
855
+ for (const entry of ignoreFileEntries) {
856
+ const baseDir = entry.baseDir ?? dirname(entry.path);
857
+ if (stopAtIgnoredDirectory && matchLoadedIgnoreMatchers(baseDir, matcherEntries, true).ignored) break;
858
+ const matcher = await loadIgnoreMatcher(entry.path, useCache);
859
+ if (matcher) matcherEntries.push({
860
+ baseDir,
861
+ matcher
862
+ });
863
+ }
864
+ return matchLoadedIgnoreMatchers(filepath, matcherEntries);
865
+ }
866
+ /**
799
867
  * Resolve an ignore file path against cwd when needed.
800
868
  *
801
869
  * @param path - Absolute or relative path.
@@ -920,13 +988,13 @@ function validateConfigIgnorePatterns(patterns) {
920
988
  function matchConfigIgnorePatterns(filepath, configDir, patterns, useCache) {
921
989
  validateConfigIgnorePatterns(patterns);
922
990
  const relativeFile = relativeSafe(configDir, filepath);
923
- if (relativeFile === ".." || relativeFile.startsWith("../")) return false;
991
+ if (isPathOutsideBase(relativeFile)) return false;
924
992
  if (!useCache) return ignore().add(patterns).ignores(relativeFile);
925
993
  const cacheKey = `${configDir}::${JSON.stringify(patterns)}`;
926
- const cachedMatcher = configIgnoreMatcherCache.get(cacheKey);
994
+ const cachedMatcher = getCacheValue(configIgnoreMatcherCache, cacheKey);
927
995
  if (cachedMatcher) return cachedMatcher.ignores(relativeFile);
928
996
  const matcher = ignore().add(patterns);
929
- configIgnoreMatcherCache.set(cacheKey, matcher);
997
+ setCacheValue(configIgnoreMatcherCache, cacheKey, matcher);
930
998
  return matcher.ignores(relativeFile);
931
999
  }
932
1000
  /**
@@ -961,7 +1029,7 @@ async function isOxfmtIgnored(options) {
961
1029
  };
962
1030
  const explicitIgnorePaths = (typeof options.ignorePath === "string" ? [options.ignorePath] : options.ignorePath)?.map((path) => resolveIgnoreFilePath(path, cwd));
963
1031
  const { paths: gitignorePaths, repoRoot } = await collectGitignorePaths(filepath);
964
- const gitignoreResult = await matchIgnoreFileChain(filepath, [...gitignorePaths].reverse().map((path) => ({ path })), useCache);
1032
+ const gitignoreResult = await matchIgnoreFileChain(filepath, [...gitignorePaths].reverse().map((path) => ({ path })), useCache, true);
965
1033
  if (gitignoreResult.ignored) return {
966
1034
  ignored: true,
967
1035
  reason: "gitignore"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "load-oxfmt-config",
3
- "version": "0.18.0",
3
+ "version": "0.19.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",
@@ -42,7 +42,7 @@
42
42
  "access": "public"
43
43
  },
44
44
  "dependencies": {
45
- "@ntnyq/utils": "^0.21.0",
45
+ "@ntnyq/utils": "^0.21.1",
46
46
  "editorconfig": "^3.0.2",
47
47
  "ignore": "^7.0.6",
48
48
  "jiti": "^2.7.0",
@@ -50,21 +50,21 @@
50
50
  },
51
51
  "devDependencies": {
52
52
  "@ntnyq/tsconfig": "^3.1.0",
53
- "@types/node": "^26.1.2",
53
+ "@types/node": "^26.2.0",
54
54
  "@typescript/native-preview": "^7.0.0-dev.20260707.2",
55
- "bumpp": "^12.1.1",
55
+ "bumpp": "^12.2.0",
56
56
  "changelogithub": "14.0.0",
57
57
  "husky": "^9.1.7",
58
58
  "nano-staged": "^1.0.2",
59
59
  "npm": "11.18.0",
60
60
  "npm-run-all2": "^9.0.3",
61
- "oxfmt": "^0.62.0",
62
- "oxlint": "^1.77.0",
61
+ "oxfmt": "^0.63.0",
62
+ "oxlint": "^1.78.0",
63
63
  "tsdown": "^0.22.14",
64
64
  "vitest": "^4.1.10"
65
65
  },
66
66
  "peerDependencies": {
67
- "oxfmt": ">=0.62.0"
67
+ "oxfmt": ">=0.63.0"
68
68
  },
69
69
  "nano-staged": {
70
70
  "*.{js,ts,mjs,tsx}": "oxlint --fix",