@markuplint/file-resolver 4.18.4 → 4.18.5

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/CHANGELOG.md CHANGED
@@ -3,6 +3,13 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ ## [4.18.5](https://github.com/markuplint/markuplint/compare/v4.18.4...v4.18.5) (2026-10-27)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **file-resolver:** add invalidate and runExclusive to ConfigProvider ([e8490a8](https://github.com/markuplint/markuplint/commit/e8490a8d19d37277f170be480cbca6a2a62b155c)), closes [#4415](https://github.com/markuplint/markuplint/issues/4415)
11
+ - **file-resolver:** find the configuration above a nested package.json ([dbd077b](https://github.com/markuplint/markuplint/commit/dbd077bb13c0c3ed5f7febf77105917b411c42dd)), closes [#4405](https://github.com/markuplint/markuplint/issues/4405)
12
+
6
13
  ## [4.18.4](https://github.com/markuplint/markuplint/compare/v4.18.3...v4.18.4) (2026-10-22)
7
14
 
8
15
  **Note:** Version bump only for package @markuplint/file-resolver
@@ -10,6 +10,39 @@ import type { Nullable } from '@markuplint/shared';
10
10
  */
11
11
  export declare class ConfigProvider {
12
12
  #private;
13
+ /**
14
+ * Clears every cached and stored configuration: the resolved-config cache, the store of loaded
15
+ * and registered configurations, the plugin-resolution cache and the shared explorer's caches.
16
+ *
17
+ * `resolve()` with `cache` set to `false` calls this as its first step. That is too late for a
18
+ * caller that has just registered a configuration with {@link set}: the clearing discards it.
19
+ * Such a caller that wants a fresh re-read calls this method first, then {@link set} and
20
+ * {@link search}, then `resolve()` with `cache` left `true`: what was registered after the
21
+ * clearing survives to `resolve()` (#4415).
22
+ *
23
+ * The plugin-resolution cache and the explorer are shared by every `ConfigProvider` in the
24
+ * process, so clearing them here affects other instances too. They are only caches, and a
25
+ * later call reads the files again.
26
+ */
27
+ invalidate(): void;
28
+ /**
29
+ * Runs `fn` after every earlier `runExclusive()` call on this instance has settled. The queue
30
+ * is this provider's own, held in `#queue`.
31
+ *
32
+ * Wrap a whole {@link invalidate} → {@link set} / {@link search} → `resolve()` sequence in it:
33
+ * an overlapping call on the same provider (two watch-triggered re-resolves of one engine close
34
+ * together, or callers that share one provider) can then not clear the configurations this call
35
+ * has just registered and not yet consumed (#4415).
36
+ *
37
+ * Only calls that go through this method are serialized: a direct `set()`, `search()` or
38
+ * `resolve()` call on the same provider is not queued. `fn` must not call `runExclusive()` on
39
+ * the same provider and wait for it: the inner call waits for `fn` to settle, so it never ends.
40
+ *
41
+ * @template T - The type `fn` resolves to
42
+ * @param fn - The sequence to run exclusively
43
+ * @returns What `fn` resolves to; a rejection of `fn` does not block the next call
44
+ */
45
+ runExclusive<T>(fn: () => Promise<T>): Promise<T>;
13
46
  /**
14
47
  * Recursively loads a configuration and all its `extends` dependencies.
15
48
  *
@@ -27,6 +60,11 @@ export declare class ConfigProvider {
27
60
  * Resolves the full configuration for a target file by merging all named configs,
28
61
  * resolving plugins, and applying file-specific overrides.
29
62
  *
63
+ * With `cache` set to `false` it first clears the store, which also discards any configuration
64
+ * the caller has just registered with {@link set}. A caller that registers inline
65
+ * configurations and wants a fresh re-read calls {@link invalidate} before registering them and
66
+ * passes `cache` as `true` (#4415).
67
+ *
30
68
  * @param targetFile - The file being linted
31
69
  * @param names - Config file paths or module names to merge
32
70
  * @param cache - Whether to use cached results
@@ -34,7 +72,8 @@ export declare class ConfigProvider {
34
72
  */
35
73
  resolve(targetFile: Readonly<MLFile>, names: readonly Nullable<string>[], cache?: boolean): Promise<ConfigSet>;
36
74
  /**
37
- * Searches for a markuplint configuration file starting from the target file's directory.
75
+ * Searches for a markuplint configuration file starting from the target file's directory
76
+ * and going up through its ancestors; the nearest one wins (see `search` in `cosmiconfig.ts`).
38
77
  *
39
78
  * @param targetFile - The file whose directory to search from
40
79
  * @returns The file path of the found config, or `null` if none was found
@@ -3,14 +3,20 @@ var __classPrivateFieldGet = (this && this.__classPrivateFieldGet) || function (
3
3
  if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot read private member from an object whose class did not declare it");
4
4
  return kind === "m" ? f : kind === "a" ? f.call(receiver) : f ? f.value : state.get(receiver);
5
5
  };
6
- var _ConfigProvider_cache, _ConfigProvider_held, _ConfigProvider_recursiveLoadKeyAndDepth, _ConfigProvider_store;
6
+ var __classPrivateFieldSet = (this && this.__classPrivateFieldSet) || function (receiver, state, value, kind, f) {
7
+ if (kind === "m") throw new TypeError("Private method is not writable");
8
+ if (kind === "a" && !f) throw new TypeError("Private accessor was defined without a setter");
9
+ if (typeof state === "function" ? receiver !== state || !f : !state.has(receiver)) throw new TypeError("Cannot write private member to an object whose class did not declare it");
10
+ return (kind === "a" ? f.call(receiver, value) : f ? f.value = value : state.set(receiver, value)), value;
11
+ };
12
+ var _ConfigProvider_cache, _ConfigProvider_held, _ConfigProvider_recursiveLoadKeyAndDepth, _ConfigProvider_store, _ConfigProvider_queue;
7
13
  import path from 'node:path';
8
14
  import { mergeConfig } from '@markuplint/ml-config';
9
15
  import { ConfigParserError } from '@markuplint/parser-utils';
10
16
  import { InvalidSelectorError, createSelector } from '@markuplint/selector';
11
17
  import { nonNullableFilter, toNoEmptyStringArrayFromStringOrArray } from '@markuplint/shared';
12
18
  import { ConfigLoadError } from './config-load-error.js';
13
- import { load as loadConfig, search } from './cosmiconfig.js';
19
+ import { clearExplorerCache, load as loadConfig, search } from './cosmiconfig.js';
14
20
  import { log } from './debug.js';
15
21
  import { generalImport } from './general-import.js';
16
22
  import { getPreset } from './get-preset.js';
@@ -34,6 +40,59 @@ export class ConfigProvider {
34
40
  _ConfigProvider_held.set(this, new Set());
35
41
  _ConfigProvider_recursiveLoadKeyAndDepth.set(this, new Map());
36
42
  _ConfigProvider_store.set(this, new Map());
43
+ _ConfigProvider_queue.set(this, Promise.resolve());
44
+ }
45
+ /**
46
+ * Clears every cached and stored configuration: the resolved-config cache, the store of loaded
47
+ * and registered configurations, the plugin-resolution cache and the shared explorer's caches.
48
+ *
49
+ * `resolve()` with `cache` set to `false` calls this as its first step. That is too late for a
50
+ * caller that has just registered a configuration with {@link set}: the clearing discards it.
51
+ * Such a caller that wants a fresh re-read calls this method first, then {@link set} and
52
+ * {@link search}, then `resolve()` with `cache` left `true`: what was registered after the
53
+ * clearing survives to `resolve()` (#4415).
54
+ *
55
+ * The plugin-resolution cache and the explorer are shared by every `ConfigProvider` in the
56
+ * process, so clearing them here affects other instances too. They are only caches, and a
57
+ * later call reads the files again.
58
+ */
59
+ invalidate() {
60
+ __classPrivateFieldGet(this, _ConfigProvider_store, "f").clear();
61
+ __classPrivateFieldGet(this, _ConfigProvider_cache, "f").clear();
62
+ __classPrivateFieldGet(this, _ConfigProvider_held, "f").clear();
63
+ cacheClear();
64
+ clearExplorerCache();
65
+ }
66
+ /**
67
+ * Runs `fn` after every earlier `runExclusive()` call on this instance has settled. The queue
68
+ * is this provider's own, held in `#queue`.
69
+ *
70
+ * Wrap a whole {@link invalidate} → {@link set} / {@link search} → `resolve()` sequence in it:
71
+ * an overlapping call on the same provider (two watch-triggered re-resolves of one engine close
72
+ * together, or callers that share one provider) can then not clear the configurations this call
73
+ * has just registered and not yet consumed (#4415).
74
+ *
75
+ * Only calls that go through this method are serialized: a direct `set()`, `search()` or
76
+ * `resolve()` call on the same provider is not queued. `fn` must not call `runExclusive()` on
77
+ * the same provider and wait for it: the inner call waits for `fn` to settle, so it never ends.
78
+ *
79
+ * @template T - The type `fn` resolves to
80
+ * @param fn - The sequence to run exclusively
81
+ * @returns What `fn` resolves to; a rejection of `fn` does not block the next call
82
+ */
83
+ async runExclusive(fn) {
84
+ const previous = __classPrivateFieldGet(this, _ConfigProvider_queue, "f");
85
+ let release;
86
+ __classPrivateFieldSet(this, _ConfigProvider_queue, new Promise(resolve => {
87
+ release = resolve;
88
+ }), "f");
89
+ await previous;
90
+ try {
91
+ return await fn();
92
+ }
93
+ finally {
94
+ release();
95
+ }
37
96
  }
38
97
  /**
39
98
  * Recursively loads a configuration and all its `extends` dependencies.
@@ -86,6 +145,11 @@ export class ConfigProvider {
86
145
  * Resolves the full configuration for a target file by merging all named configs,
87
146
  * resolving plugins, and applying file-specific overrides.
88
147
  *
148
+ * With `cache` set to `false` it first clears the store, which also discards any configuration
149
+ * the caller has just registered with {@link set}. A caller that registers inline
150
+ * configurations and wants a fresh re-read calls {@link invalidate} before registering them and
151
+ * passes `cache` as `true` (#4415).
152
+ *
89
153
  * @param targetFile - The file being linted
90
154
  * @param names - Config file paths or module names to merge
91
155
  * @param cache - Whether to use cached results
@@ -93,9 +157,7 @@ export class ConfigProvider {
93
157
  */
94
158
  async resolve(targetFile, names, cache = true) {
95
159
  if (!cache) {
96
- __classPrivateFieldGet(this, _ConfigProvider_store, "f").clear();
97
- __classPrivateFieldGet(this, _ConfigProvider_cache, "f").clear();
98
- cacheClear();
160
+ this.invalidate();
99
161
  }
100
162
  const keys = names.filter(nonNullableFilter);
101
163
  const key = keys.join(KEY_SEPARATOR);
@@ -161,7 +223,8 @@ export class ConfigProvider {
161
223
  return result;
162
224
  }
163
225
  /**
164
- * Searches for a markuplint configuration file starting from the target file's directory.
226
+ * Searches for a markuplint configuration file starting from the target file's directory
227
+ * and going up through its ancestors; the nearest one wins (see `search` in `cosmiconfig.ts`).
165
228
  *
166
229
  * @param targetFile - The file whose directory to search from
167
230
  * @returns The file path of the found config, or `null` if none was found
@@ -289,7 +352,7 @@ export class ConfigProvider {
289
352
  return errors;
290
353
  }
291
354
  }
292
- _ConfigProvider_cache = new WeakMap(), _ConfigProvider_held = new WeakMap(), _ConfigProvider_recursiveLoadKeyAndDepth = new WeakMap(), _ConfigProvider_store = new WeakMap();
355
+ _ConfigProvider_cache = new WeakMap(), _ConfigProvider_held = new WeakMap(), _ConfigProvider_recursiveLoadKeyAndDepth = new WeakMap(), _ConfigProvider_store = new WeakMap(), _ConfigProvider_queue = new WeakMap();
293
356
  async function load(filePath, cache, referrer) {
294
357
  if (!fileExists(filePath) && (await moduleExists(filePath))) {
295
358
  const config = (await generalImport(filePath)) ?? new ConfigLoadError('Module is not found', filePath, referrer);
@@ -1,6 +1,34 @@
1
1
  import type { LoaderSync } from 'cosmiconfig';
2
2
  import { ConfigLoadError } from './config-load-error.js';
3
3
  type CosmiConfig = ReturnType<LoaderSync>;
4
+ /**
5
+ * Clears the shared explorer's own search and load caches.
6
+ *
7
+ * `search()` and `load()` clear them only as a side effect of one call; this lets a caller
8
+ * clear them up front, before any `search()` or `load()`, so that the calls that follow read the
9
+ * current file content (see `ConfigProvider#invalidate`, #4415).
10
+ */
11
+ export declare function clearExplorerCache(): void;
12
+ /**
13
+ * Searches for a configuration file in the directory of `filePath` and then in each ancestor
14
+ * directory up to the file system root, and returns the nearest one.
15
+ *
16
+ * This walk is done here instead of by cosmiconfig's `searchStrategy: 'project'`, because that
17
+ * strategy stops at the first directory that has a `package.json`, which hides a configuration
18
+ * file at a monorepo root from every file inside a package (#4405). The documented behavior is
19
+ * that the search goes upward from the target file's directory
20
+ * (https://markuplint.dev/docs/configuration). `searchStrategy: 'global'` is not a fit either: it
21
+ * also searches the OS-level configuration directory, and that cannot be turned off.
22
+ *
23
+ * A `package.json` that has no `markuplint` key is not a configuration file, so the walk
24
+ * continues past it. There is no upper bound below the file system root, so a configuration file
25
+ * in a directory outside the project applies when nothing nearer exists.
26
+ *
27
+ * @template T - The type of the configuration object
28
+ * @param filePath - The path of the target file; the search starts from its directory
29
+ * @param cacheClear - Whether to clear the explorer's caches before searching
30
+ * @returns The path and content of the nearest configuration file, or `null` if none was found
31
+ */
4
32
  export declare function search<T = CosmiConfig>(filePath: string, cacheClear: boolean): Promise<{
5
33
  filePath: string;
6
34
  config: T;
@@ -19,26 +19,75 @@ const explorer = cosmiconfig('markuplint', {
19
19
  }
20
20
  }),
21
21
  },
22
- searchStrategy: 'project',
22
+ // Checks only the directory it is given; see `search()` for the ancestor walk.
23
+ searchStrategy: 'none',
23
24
  });
25
+ /**
26
+ * Clears the shared explorer's own search and load caches.
27
+ *
28
+ * `search()` and `load()` clear them only as a side effect of one call; this lets a caller
29
+ * clear them up front, before any `search()` or `load()`, so that the calls that follow read the
30
+ * current file content (see `ConfigProvider#invalidate`, #4415).
31
+ */
32
+ export function clearExplorerCache() {
33
+ explorer.clearCaches();
34
+ }
35
+ /**
36
+ * Yields `dir` and then each of its ancestors, ending with the file system root.
37
+ *
38
+ * @param dir - The directory to start from
39
+ */
40
+ function* ancestorDirs(dir) {
41
+ let current = path.resolve(dir);
42
+ while (true) {
43
+ yield current;
44
+ const parent = path.dirname(current);
45
+ if (parent === current) {
46
+ return;
47
+ }
48
+ current = parent;
49
+ }
50
+ }
51
+ /**
52
+ * Searches for a configuration file in the directory of `filePath` and then in each ancestor
53
+ * directory up to the file system root, and returns the nearest one.
54
+ *
55
+ * This walk is done here instead of by cosmiconfig's `searchStrategy: 'project'`, because that
56
+ * strategy stops at the first directory that has a `package.json`, which hides a configuration
57
+ * file at a monorepo root from every file inside a package (#4405). The documented behavior is
58
+ * that the search goes upward from the target file's directory
59
+ * (https://markuplint.dev/docs/configuration). `searchStrategy: 'global'` is not a fit either: it
60
+ * also searches the OS-level configuration directory, and that cannot be turned off.
61
+ *
62
+ * A `package.json` that has no `markuplint` key is not a configuration file, so the walk
63
+ * continues past it. There is no upper bound below the file system root, so a configuration file
64
+ * in a directory outside the project applies when nothing nearer exists.
65
+ *
66
+ * @template T - The type of the configuration object
67
+ * @param filePath - The path of the target file; the search starts from its directory
68
+ * @param cacheClear - Whether to clear the explorer's caches before searching
69
+ * @returns The path and content of the nearest configuration file, or `null` if none was found
70
+ */
24
71
  export async function search(filePath, cacheClear) {
25
72
  if (cacheClear) {
26
73
  explorer.clearCaches();
27
74
  }
28
- const dir = path.dirname(filePath);
29
- searchLog('Search dir: %s', dir);
30
- const result = await explorer.search(dir).catch(cacheConfigError(dir, filePath));
31
- searchLog('Search result: %O', result);
32
- if (!result || result.isEmpty) {
33
- return null;
75
+ for (const dir of ancestorDirs(path.dirname(filePath))) {
76
+ searchLog('Search dir: %s', dir);
77
+ const result = await explorer.search(dir).catch(cacheConfigError(dir, filePath));
78
+ searchLog('Search result: %O', result);
79
+ if (!result || result.isEmpty) {
80
+ continue;
81
+ }
82
+ const config = result.config;
83
+ return {
84
+ filePath: result.filepath,
85
+ config: config && typeof config === 'object' && 'default' in config && typeof config.default === 'object'
86
+ ? config.default
87
+ : config,
88
+ };
34
89
  }
35
- const config = result.config;
36
- return {
37
- filePath: result.filepath,
38
- config: config && typeof config === 'object' && 'default' in config && typeof config.default === 'object'
39
- ? config.default
40
- : config,
41
- };
90
+ return null;
42
91
  }
43
92
  export async function load(filePath, cacheClear, referrer) {
44
93
  if (cacheClear) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/file-resolver",
3
- "version": "4.18.4",
3
+ "version": "4.18.5",
4
4
  "description": "The file resolver of markuplint",
5
5
  "repository": "git@github.com:markuplint/markuplint.git",
6
6
  "author": "Yusuke Hirao <yusukehirao@me.com>",
@@ -43,5 +43,5 @@
43
43
  "jsonc": "2.0.0",
44
44
  "minimatch": "10.2.6"
45
45
  },
46
- "gitHead": "b5fbb4572f7313e3c80735da1677d33cb82e6e2f"
46
+ "gitHead": "e7307cd5bdb771ac91e723c4c8869499190f2465"
47
47
  }