@markuplint/file-resolver 5.4.0 → 5.6.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/CHANGELOG.md CHANGED
@@ -3,6 +3,25 @@
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
+ # [5.6.0](https://github.com/markuplint/markuplint/compare/v5.5.0...v5.6.0) (2026-11-12)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **file-resolver:** validate every selector of every configuration before they are merged ([be2611c](https://github.com/markuplint/markuplint/commit/be2611ca18243bee59676c81e409a070ce9af7ab))
11
+
12
+
13
+ # [5.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
14
+
15
+ ### Bug Fixes
16
+
17
+ * **file-resolver:** do not report a config shared by two extends paths as circular ([8594a37](https://github.com/markuplint/markuplint/commit/8594a373feda1a2f869702b6b16d619d156814c4)), closes [#4552](https://github.com/markuplint/markuplint/issues/4552)
18
+ * **file-resolver:** do not swallow errors other than InvalidSelectorError in validation ([821a661](https://github.com/markuplint/markuplint/commit/821a66170e84537c57d826133bdf486c6276f524))
19
+ * **file-resolver:** load @markuplint/pretenders only when a configuration needs it ([32ee579](https://github.com/markuplint/markuplint/commit/32ee579e4b43cf18ba9787f173796c44966c3b40))
20
+ * **file-resolver:** read the report of a JSON module before the code of a missing one ([95e4869](https://github.com/markuplint/markuplint/commit/95e4869c9be3bd53f853e5a99830514bfec5a51b))
21
+ * **file-resolver:** resolve a value spelled as a path from the configuration's directory ([3fbfeca](https://github.com/markuplint/markuplint/commit/3fbfeca83b29ba852709c50667d60ca28f1dfce6)), closes [#4459](https://github.com/markuplint/markuplint/issues/4459)
22
+ * **file-resolver:** take a JSON module through require() when import() refuses it ([15303ba](https://github.com/markuplint/markuplint/commit/15303baae4e56ac26d1e3eb4522230bae1d93efe))
23
+
24
+
6
25
  # [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
7
26
 
8
27
  ### Bug Fixes
@@ -59,13 +59,27 @@ export declare class ConfigProvider {
59
59
  /**
60
60
  * Recursively loads a configuration and all its `extends` dependencies.
61
61
  *
62
+ * A `CircularReferenceError` is reported only for a config that extends itself, directly or
63
+ * indirectly. A config reached through more than one `extends` path is loaded once and is not
64
+ * an error.
65
+ *
62
66
  * @param key - The config file path or module name to load
63
67
  * @param cache - Whether to use cached results
64
68
  * @param referrer - The file path of the config that referenced this key
65
- * @param depth - Current recursion depth (for circular reference detection)
66
69
  * @returns A set of loaded config keys and any errors encountered
67
70
  */
68
- recursiveLoad(key: string, cache: boolean, referrer: string, depth?: number): Promise<{
71
+ recursiveLoad(key: string, cache: boolean, referrer: string): Promise<{
72
+ stack: Set<string>;
73
+ errs: Error[];
74
+ }>;
75
+ /**
76
+ * @deprecated Remove the `depth` argument: it is ignored, and the overload will be removed in v6.
77
+ * @param key - The config file path or module name to load
78
+ * @param cache - Whether to use cached results
79
+ * @param referrer - The file path of the config that referenced this key
80
+ * @param depth - Ignored
81
+ */
82
+ recursiveLoad(key: string, cache: boolean, referrer: string, depth: number): Promise<{
69
83
  stack: Set<string>;
70
84
  errs: Error[];
71
85
  }>;
@@ -92,7 +106,10 @@ export declare class ConfigProvider {
92
106
  * right before calling this) — see #4015.
93
107
  *
94
108
  * @param targetFile - The file being linted
95
- * @param names - Config file paths or module names to merge
109
+ * @param names - Config file paths or module names to merge. A key of a configuration already
110
+ * stored (by {@link set} or {@link search}) is taken as it is; any other name is resolved as a
111
+ * value of `extends` is, from the working directory: a module name stays, a path becomes
112
+ * absolute
96
113
  * @param cache - Whether to reuse already-loaded/cached entries
97
114
  * @returns The fully resolved configuration set including plugins and errors
98
115
  */
@@ -2,7 +2,7 @@ import path from 'node:path';
2
2
  import { mergeConfig, rebaseHandWrittenPretenderFilePath } from '@markuplint/ml-config';
3
3
  import { ConfigParserError } from '@markuplint/parser-utils';
4
4
  import { InvalidSelectorError, createSelector } from '@markuplint/selector';
5
- import { nonNullableFilter, toNoEmptyStringArrayFromStringOrArray, ConfigLoadError } from '@markuplint/shared';
5
+ import { nonNullableFilter, toNoEmptyStringArrayFromStringOrArray, ConfigLoadError, isFatalError, } from '@markuplint/shared';
6
6
  import { clearAutoLoadRulesCache } from './auto-load-rules.js';
7
7
  import { load as loadConfig, search, clearExplorerCache, configLoadErrorFiles } from './cosmiconfig.js';
8
8
  import { log } from './debug.js';
@@ -12,10 +12,14 @@ import { isPluginModuleName } from './is-plugin-module-name.js';
12
12
  import { isPresetModuleName } from './is-preset-module-name.js';
13
13
  import { moduleExists } from './module-exists.js';
14
14
  import { relPathToNameOrAbsPath } from './path-to-abs-or-name.js';
15
+ import { resolveNameOrAbsPath } from './resolve-name-or-abs-path.js';
15
16
  import { cacheClear, resolvePlugins } from './resolve-plugins.js';
16
17
  import { fileExists, uuid } from './utils.js';
17
18
  const cpLog = log.extend('config-provider');
18
19
  const KEY_SEPARATOR = '__ML_CONFIG_MERGE__';
20
+ function isPretenderList(pretenders) {
21
+ return Array.isArray(pretenders);
22
+ }
19
23
  /**
20
24
  * Manages loading, caching, and resolving markuplint configuration files.
21
25
  *
@@ -32,7 +36,6 @@ const KEY_SEPARATOR = '__ML_CONFIG_MERGE__';
32
36
  export class ConfigProvider {
33
37
  #cache = new Map();
34
38
  #held = new Set();
35
- #recursiveLoadKeyAndDepth = new Map();
36
39
  #store = new Map();
37
40
  /**
38
41
  * Stabilizes {@link set}'s auto-generated key for an inline config object
@@ -108,26 +111,34 @@ export class ConfigProvider {
108
111
  release();
109
112
  }
110
113
  }
114
+ async recursiveLoad(key, cache, referrer, depth) {
115
+ return await this.#recursiveLoad(key, cache, referrer, new Set(), new Set());
116
+ }
111
117
  /**
112
- * Recursively loads a configuration and all its `extends` dependencies.
118
+ * A config is circular only when it is its own ancestor on the current `extends` path
119
+ * (`ancestors`). Reaching the same config through two different paths (a diamond, e.g. two
120
+ * presets that extend the same sub-presets) is not a cycle, however deep each path is, so
121
+ * comparing depths cannot tell the two apart (#4552).
113
122
  *
114
- * @param key - The config file path or module name to load
115
- * @param cache - Whether to use cached results
116
- * @param referrer - The file path of the config that referenced this key
117
- * @param depth - Current recursion depth (for circular reference detection)
118
- * @returns A set of loaded config keys and any errors encountered
123
+ * `visited` holds every key already walked in this top-level call. A key met again is skipped:
124
+ * its keys are already in the result and its errors, circular ones included, were already
125
+ * reported once. Without it the walk repeats once per path, which grows exponentially with
126
+ * stacked diamonds, and reports the same error once per path. State lives in the arguments,
127
+ * not on the instance, so concurrent calls do not see each other.
119
128
  */
120
- async recursiveLoad(key, cache, referrer, depth = 1) {
129
+ async #recursiveLoad(key, cache, referrer, ancestors, visited) {
121
130
  const stack = new Set();
122
131
  const errs = [];
123
- const ancestorDepth = this.#recursiveLoadKeyAndDepth.get(key);
124
- if (ancestorDepth != null && ancestorDepth < depth) {
132
+ if (ancestors.has(key)) {
125
133
  return {
126
134
  stack,
127
135
  errs: [new CircularReferenceError(`Circular reference detected: ${key}`)],
128
136
  };
129
137
  }
130
- this.#recursiveLoadKeyAndDepth.set(key, depth);
138
+ if (visited.has(key)) {
139
+ return { stack, errs };
140
+ }
141
+ visited.add(key);
131
142
  let config = this.#store.get(key);
132
143
  if (!config) {
133
144
  config = await this.#load(key, cache, referrer);
@@ -151,12 +162,18 @@ export class ConfigProvider {
151
162
  }
152
163
  const depKeys = config.extends === null ? null : toNoEmptyStringArrayFromStringOrArray(config.extends);
153
164
  if (depKeys) {
154
- for (const depKey of depKeys) {
155
- const keys = await this.recursiveLoad(depKey, cache, key, depth + 1);
156
- for (const key of keys.stack) {
157
- stack.add(key);
165
+ ancestors.add(key);
166
+ try {
167
+ for (const depKey of depKeys) {
168
+ const keys = await this.#recursiveLoad(depKey, cache, key, ancestors, visited);
169
+ for (const loadedKey of keys.stack) {
170
+ stack.add(loadedKey);
171
+ }
172
+ errs.push(...keys.errs);
158
173
  }
159
- errs.push(...keys.errs);
174
+ }
175
+ finally {
176
+ ancestors.delete(key);
160
177
  }
161
178
  }
162
179
  stack.add(key);
@@ -185,12 +202,17 @@ export class ConfigProvider {
185
202
  * right before calling this) — see #4015.
186
203
  *
187
204
  * @param targetFile - The file being linted
188
- * @param names - Config file paths or module names to merge
205
+ * @param names - Config file paths or module names to merge. A key of a configuration already
206
+ * stored (by {@link set} or {@link search}) is taken as it is; any other name is resolved as a
207
+ * value of `extends` is, from the working directory: a module name stays, a path becomes
208
+ * absolute
189
209
  * @param cache - Whether to reuse already-loaded/cached entries
190
210
  * @returns The fully resolved configuration set including plugins and errors
191
211
  */
192
212
  async resolve(targetFile, names, cache = true) {
193
- const keys = names.filter(nonNullableFilter);
213
+ const keys = await Promise.all(names
214
+ .filter(nonNullableFilter)
215
+ .map(name => (this.#store.has(name) ? name : resolveNameOrAbsPath(process.cwd(), name))));
194
216
  const key = keys.join(KEY_SEPARATOR);
195
217
  let baseConfigSet = this.#cache.get(key);
196
218
  if (!baseConfigSet) {
@@ -212,10 +234,7 @@ export class ConfigProvider {
212
234
  filePath: referrer,
213
235
  });
214
236
  }
215
- const errors = this.#validateConfig(configSet.config, filePath);
216
- configSet.errs.push(...errors);
217
237
  const { plugins, errors: pluginErrors } = await resolvePlugins(configSet.config.plugins);
218
- configSet.errs.push(...pluginErrors);
219
238
  if (this.#held.size > 0) {
220
239
  const extendHelds = [...this.#held.values()];
221
240
  for (const held of extendHelds) {
@@ -234,6 +253,9 @@ export class ConfigProvider {
234
253
  configSet = await this.#mergeConfigs([...keys, ...extendHelds], cache, referrer);
235
254
  this.#held.clear();
236
255
  }
256
+ // After the plugins' configurations are merged in: that merge makes a new `errs`, which would lose
257
+ // these.
258
+ configSet.errs.push(...pluginErrors);
237
259
  return {
238
260
  ...configSet,
239
261
  plugins,
@@ -397,20 +419,25 @@ export class ConfigProvider {
397
419
  const resolvedKeys = new Set();
398
420
  const errs = [];
399
421
  for (const key of keys) {
400
- this.#recursiveLoadKeyAndDepth.clear();
401
422
  const keySet = await this.recursiveLoad(key, cache, referrer);
402
423
  for (const k of keySet.stack) {
403
424
  resolvedKeys.add(k);
404
425
  }
405
426
  errs.push(...keySet.errs);
406
427
  }
407
- const configs = [...resolvedKeys].map(name => this.#store.get(name)).filter(nonNullableFilter);
408
428
  let resultConfig = {};
409
- for (const config of configs) {
429
+ for (const name of resolvedKeys) {
430
+ const config = this.#store.get(name);
431
+ if (!config) {
432
+ continue;
433
+ }
410
434
  if (config instanceof ConfigLoadError) {
411
435
  errs.push(config);
412
436
  continue;
413
437
  }
438
+ // Each configuration is validated as it is, before the merge loses which one holds a selector,
439
+ // so that an error names the file (or the plugin configuration) that the user has to fix.
440
+ errs.push(...this.#validateConfig(config, name));
414
441
  resultConfig = mergeConfig(resultConfig, config);
415
442
  }
416
443
  return {
@@ -433,24 +460,56 @@ export class ConfigProvider {
433
460
  : undefined,
434
461
  };
435
462
  }
463
+ /**
464
+ * Creates every selector that one configuration holds, so that an invalid one is in
465
+ * {@link ConfigSet.errs} wherever it is written: the top level, and the value of each `overrides` entry
466
+ * (whether or not its glob matches the target file, as the result is cached for every file of `names`).
467
+ * It runs on each configuration before they are merged, because the merge loses which one holds a selector.
468
+ * The selectors are those of `nodeRules`, `childNodeRules`, and the pretenders written in the
469
+ * configuration itself (`pretenders.data`; the merge has turned an array into it), including those
470
+ * that a plugin provides. Those that a pretender scan or a file produces are not known here;
471
+ * `MLDocument` reports them when it meets them. An error names the file of the pretender
472
+ * (`Pretender#filePath`) when it has one, and `filePath`, the key of the configuration (its file, or
473
+ * `plugin:<name>/<config>`), otherwise.
474
+ */
436
475
  #validateConfig(config, filePath) {
437
476
  const errors = [];
438
- if (config.nodeRules)
439
- for (const rule of config.nodeRules) {
440
- if (rule.selector) {
441
- try {
442
- createSelector(rule.selector);
443
- }
444
- catch (error) {
445
- if (error instanceof InvalidSelectorError) {
446
- errors.push(new ConfigParserError(error.message, {
447
- filePath,
448
- raw: rule.selector,
449
- }));
450
- }
451
- }
477
+ const check = (selector, from = filePath) => {
478
+ if (!selector) {
479
+ return;
480
+ }
481
+ try {
482
+ createSelector(selector);
483
+ }
484
+ catch (error) {
485
+ // Anything but an `InvalidSelectorError` is not a fault of the selector string the user wrote
486
+ // (a bug), so it must propagate instead of being reported as a configuration error.
487
+ if (isFatalError(error) || !(error instanceof InvalidSelectorError)) {
488
+ throw error;
452
489
  }
490
+ errors.push(new ConfigParserError(error.message, {
491
+ filePath: from,
492
+ raw: selector,
493
+ }));
453
494
  }
495
+ };
496
+ const checkSelectors = (holder) => {
497
+ for (const rule of holder.nodeRules ?? []) {
498
+ check(rule.selector);
499
+ }
500
+ for (const rule of holder.childNodeRules ?? []) {
501
+ check(rule.selector);
502
+ }
503
+ const pretenders = holder.pretenders;
504
+ for (const pretender of isPretenderList(pretenders) ? pretenders : (pretenders?.data ?? [])) {
505
+ // The file that declares the component is where the user fixes the entry
506
+ check(pretender.selector, pretender.filePath ?? filePath);
507
+ }
508
+ };
509
+ checkSelectors(config);
510
+ for (const override of Object.values(config.overrides ?? {})) {
511
+ checkSelectors(override);
512
+ }
454
513
  return errors;
455
514
  }
456
515
  }
@@ -1 +1,19 @@
1
+ /**
2
+ * Imports the module that a configuration names (a configuration to `extends`, a parser, a
3
+ * plugin, the data of `pretenders`) and returns what it exports: the default export when there is
4
+ * one, the namespace otherwise. Whatever cannot be imported, for whatever reason, yields `null`,
5
+ * as the callers report a missing module in their own terms; the reason goes to the debug log.
6
+ *
7
+ * A subpath of a package that its `exports` map does not expose is turned into the absolute path
8
+ * of the file first (see {@link resolvePackageSubpath}), as `import()` refuses it. A JSON module
9
+ * is taken by {@link importModule} through `require()`, so no `.json` file is read by hand: what
10
+ * `require()` takes as JSON, a byte order mark included, is what is accepted.
11
+ *
12
+ * The result is cached by specifier for the life of the process, `null` included; `import()`
13
+ * itself keeps a module for as long, so a fresh read is not to be had either way.
14
+ *
15
+ * @template T - What the module exports
16
+ * @param name - A bare specifier, with a subpath or not, or an absolute path
17
+ * @returns What the module exports, or `null` when it cannot be imported
18
+ */
1
19
  export declare function generalImport<T>(name: string): Promise<T | null>;
@@ -1,15 +1,33 @@
1
- import fs from 'node:fs/promises';
2
1
  import { createRequire } from 'node:module';
3
2
  import path from 'node:path';
4
3
  import { isFatalError } from '@markuplint/shared';
5
4
  import { resolve } from 'import-meta-resolve';
6
5
  import { log } from './debug.js';
7
- import { fromFileURL, toFileURL } from './path-utils.js';
6
+ import { importModule } from './import-module.js';
7
+ import { fromFileURL, isRelativePath, toFileURL } from './path-utils.js';
8
8
  const gLog = log.extend('general-import');
9
9
  const gLogSuccess = gLog.extend('success');
10
10
  const gLogError = gLog.extend('error');
11
11
  const cache = new Map();
12
12
  const require = createRequire(import.meta.url);
13
+ /**
14
+ * Imports the module that a configuration names (a configuration to `extends`, a parser, a
15
+ * plugin, the data of `pretenders`) and returns what it exports: the default export when there is
16
+ * one, the namespace otherwise. Whatever cannot be imported, for whatever reason, yields `null`,
17
+ * as the callers report a missing module in their own terms; the reason goes to the debug log.
18
+ *
19
+ * A subpath of a package that its `exports` map does not expose is turned into the absolute path
20
+ * of the file first (see {@link resolvePackageSubpath}), as `import()` refuses it. A JSON module
21
+ * is taken by {@link importModule} through `require()`, so no `.json` file is read by hand: what
22
+ * `require()` takes as JSON, a byte order mark included, is what is accepted.
23
+ *
24
+ * The result is cached by specifier for the life of the process, `null` included; `import()`
25
+ * itself keeps a module for as long, so a fresh read is not to be had either way.
26
+ *
27
+ * @template T - What the module exports
28
+ * @param name - A bare specifier, with a subpath or not, or an absolute path
29
+ * @returns What the module exports, or `null` when it cannot be imported
30
+ */
13
31
  export async function generalImport(name) {
14
32
  if (cache.has(name)) {
15
33
  return cache.get(name);
@@ -33,45 +51,21 @@ export async function generalImport(name) {
33
51
  if (importPath !== name) {
34
52
  gLog('Converted to file URL: %s', importPath);
35
53
  }
36
- const imported = await import(importPath);
37
- const mod = imported?.default ?? imported ?? null;
54
+ const imported = await importModule(importPath);
55
+ // A JSON module whose value is `null` is `null`, as a file with nothing in it; `??` would
56
+ // hand the namespace over instead.
57
+ const mod = (('default' in imported ? imported.default : imported) ?? null);
38
58
  gLogSuccess('Success by import("%s"): %O', importPath, mod);
39
59
  cache.set(name, mod);
40
60
  return mod;
41
61
  }
42
62
  catch (error) {
43
- // NOTE: `isFatalError()` is intentionally NOT applied at this catch
44
- // boundary. `await import()` / `require()` invoke third-party module
45
- // code, so any Tier-1-shaped error (TypeError / SyntaxError / etc.)
46
- // at this point may originate from inside the imported module and
47
- // not from markuplint's own code — we cannot distinguish the two.
48
- // The Tier 1 classification (see `isFatalError()` in
49
- // `@markuplint/shared`) only covers errors raised by markuplint's
50
- // own code, which excludes third-party import failures (e.g. Node 22+ removing
51
- // import assertion syntax, or bun's stricter ESM parser). Treating
52
- // every error as a recoverable null-return is the correct policy
53
- // here; callers (config / parser / plugin loaders) decide how to
54
- // surface the missing module.
55
- if (
56
- // @ts-ignore
57
- 'code' in error &&
58
- // @ts-ignore
59
- error.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
60
- try {
61
- const mod = require(name) ?? null;
62
- gLogSuccess('Success by require("%s"): %O', name, mod);
63
- cache.set(name, mod);
64
- return mod;
65
- }
66
- catch (error) {
67
- if (error instanceof Error && /^parse failure/i.test(error.message)) {
68
- gLogError('Error in `createRequire(import.meta.url)()`: %O', error);
69
- cache.set(name, null);
70
- return null;
71
- }
72
- gLogError('Error in generalImport: %O', error);
73
- }
74
- }
63
+ // `isFatalError()` is intentionally NOT applied at this catch: `importModule()` runs
64
+ // `import()`, which evaluates third-party module code, so a Tier-1-shaped error here
65
+ // (TypeError / SyntaxError / etc.) may come from the imported module or from the runtime's
66
+ // loader rather than from markuplint's own code, and the two cannot be told apart. Every
67
+ // error is a recoverable `null`; the callers (config / parser / plugin loaders) decide how
68
+ // to surface the missing module.
75
69
  if (error instanceof Error) {
76
70
  const { filePath, packageName } = /Missing\s"(?<filePath>[^"]+)"\sspecifier\sin\s"(?<packageName>[^"]+)"\spackage/.exec(error.message)
77
71
  ?.groups ?? {};
@@ -86,18 +80,6 @@ export async function generalImport(name) {
86
80
  return result;
87
81
  }
88
82
  }
89
- if (path.isAbsolute(name) && name.endsWith('.json')) {
90
- try {
91
- const file = await fs.readFile(name, 'utf8');
92
- const json = JSON.parse(file);
93
- gLogSuccess('Success by readFile("%s") and JSON.parse: %O', name, json);
94
- cache.set(name, json);
95
- return json;
96
- }
97
- catch (readError) {
98
- gLogError('Error in readFile("%s"): %O', name, readError);
99
- }
100
- }
101
83
  gLogError('Error in `import()`: %O', error);
102
84
  cache.set(name, null);
103
85
  return null;
@@ -111,7 +93,7 @@ export async function generalImport(name) {
111
93
  * - The package cannot be found at all
112
94
  */
113
95
  function resolvePackageSubpath(name) {
114
- if (path.isAbsolute(name) || name.startsWith('.')) {
96
+ if (path.isAbsolute(name) || isRelativePath(name)) {
115
97
  return null;
116
98
  }
117
99
  const packageName = name.startsWith('@') ? name.split('/').slice(0, 2).join('/') : name.split('/')[0];
@@ -0,0 +1,28 @@
1
+ /**
2
+ * `import()` that takes a JSON module as one. Node.js and Deno load a JSON module through
3
+ * `import()` only with the import attribute `type: 'json'` (Bun takes it either way), and a
4
+ * module that a configuration names can be one, a configuration to `extends` or the data of
5
+ * `pretenders`, with or without the `.json` extension, as the `exports` map of a package can
6
+ * redirect a specifier. The attribute cannot be given up front, as a `.json` specifier can be
7
+ * redirected to a JavaScript module the same way, and the attribute would then be rejected; and
8
+ * Deno rejects a dynamic `import()` with the attribute as it rejects one without. So the
9
+ * specifier is imported as it is, and when the runtime reports a JSON module without the
10
+ * attribute ({@link isJsonWithoutAttribute}), or the `.json` extension says so, it is
11
+ * `require()`d instead: CommonJS takes a JSON module as it is on every runtime, a bare specifier
12
+ * and a path alike, and what it returns is handed over as the default export, the shape
13
+ * `import()` gives a JSON module. Any other failure of `import()` is thrown as it is, as that is
14
+ * what the callers read (a package that is not installed, a subpath that `exports` does not
15
+ * expose), and `require()` would only run the module's code again. The report is read before the
16
+ * code of a missing module, as Deno attaches that code to the report as well; the code decides
17
+ * only for a `.json` specifier that the runtime did not report as JSON.
18
+ *
19
+ * The `catch` is not guarded by `isFatalError()`: the runtimes report the missing attribute with
20
+ * a `TypeError`, which the guard would re-throw before the retry.
21
+ *
22
+ * @param specifier - What `import()` takes: a bare specifier, a `file:` URL, or an absolute path
23
+ * as `moduleExists()` passes it (a Windows drive path fails here and is left to its
24
+ * `require.resolve()`)
25
+ * @returns The module namespace that `import()` resolves to, or `{ default }` of what
26
+ * `require()` returns for a JSON module
27
+ */
28
+ export declare function importModule(specifier: string): Promise<Record<string, unknown>>;
@@ -0,0 +1,64 @@
1
+ import { createRequire } from 'node:module';
2
+ import { isModuleNotFoundError } from '@markuplint/shared';
3
+ import { log } from './debug.js';
4
+ import { fromFileURL } from './path-utils.js';
5
+ const require = createRequire(import.meta.url);
6
+ const iLog = log.extend('import-module');
7
+ /**
8
+ * `import()` that takes a JSON module as one. Node.js and Deno load a JSON module through
9
+ * `import()` only with the import attribute `type: 'json'` (Bun takes it either way), and a
10
+ * module that a configuration names can be one, a configuration to `extends` or the data of
11
+ * `pretenders`, with or without the `.json` extension, as the `exports` map of a package can
12
+ * redirect a specifier. The attribute cannot be given up front, as a `.json` specifier can be
13
+ * redirected to a JavaScript module the same way, and the attribute would then be rejected; and
14
+ * Deno rejects a dynamic `import()` with the attribute as it rejects one without. So the
15
+ * specifier is imported as it is, and when the runtime reports a JSON module without the
16
+ * attribute ({@link isJsonWithoutAttribute}), or the `.json` extension says so, it is
17
+ * `require()`d instead: CommonJS takes a JSON module as it is on every runtime, a bare specifier
18
+ * and a path alike, and what it returns is handed over as the default export, the shape
19
+ * `import()` gives a JSON module. Any other failure of `import()` is thrown as it is, as that is
20
+ * what the callers read (a package that is not installed, a subpath that `exports` does not
21
+ * expose), and `require()` would only run the module's code again. The report is read before the
22
+ * code of a missing module, as Deno attaches that code to the report as well; the code decides
23
+ * only for a `.json` specifier that the runtime did not report as JSON.
24
+ *
25
+ * The `catch` is not guarded by `isFatalError()`: the runtimes report the missing attribute with
26
+ * a `TypeError`, which the guard would re-throw before the retry.
27
+ *
28
+ * @param specifier - What `import()` takes: a bare specifier, a `file:` URL, or an absolute path
29
+ * as `moduleExists()` passes it (a Windows drive path fails here and is left to its
30
+ * `require.resolve()`)
31
+ * @returns The module namespace that `import()` resolves to, or `{ default }` of what
32
+ * `require()` returns for a JSON module
33
+ */
34
+ export async function importModule(specifier) {
35
+ try {
36
+ return await import(specifier);
37
+ }
38
+ catch (error) {
39
+ const isJson = isJsonWithoutAttribute(error) || (specifier.endsWith('.json') && !isModuleNotFoundError(error));
40
+ if (!isJson) {
41
+ throw error;
42
+ }
43
+ iLog('`import()` refused %s as a JSON module (%O); taking `require()`', specifier, error);
44
+ return { default: require(specifier.startsWith('file:') ? fromFileURL(specifier) : specifier) };
45
+ }
46
+ }
47
+ /**
48
+ * Whether `import()` reported that the module is JSON and the attribute was not given. The
49
+ * wording is each runtime's own, observed in `general-import.runtime.spec.ts` in a fresh process
50
+ * of the runtime that runs the specs:
51
+ *
52
+ * - Node.js: the code `ERR_IMPORT_ATTRIBUTE_MISSING`
53
+ * - Deno: `Attempted to load JSON module without specifying "type": "json" attribute in the
54
+ * import statement.`
55
+ * - Bun: never, as it takes a JSON module without the attribute
56
+ *
57
+ * @param error - What `import()` threw
58
+ * @returns `true` for that report
59
+ */
60
+ function isJsonWithoutAttribute(error) {
61
+ return (error instanceof Error &&
62
+ (('code' in error && error.code === 'ERR_IMPORT_ATTRIBUTE_MISSING') ||
63
+ error.message.startsWith('Attempted to load JSON module without specifying "type": "json" attribute')));
64
+ }
@@ -1 +1,23 @@
1
+ /**
2
+ * Whether `name` resolves to a module from where markuplint is installed: a package, or a file of
3
+ * one, as `extends`, `plugins`, `parser` and `specs` take a module.
4
+ *
5
+ * The resolution is that of this file's own location, as `import()` and `createRequire()` do it,
6
+ * never that of the configuration file that names the module, so a relative path is not a module
7
+ * here and is answered `false` without asking the runtime: resolved from this file, it would reach
8
+ * this package's own files or whatever lies above them. `resolveNameOrAbsPath` keeps a value
9
+ * spelled as a path away in the first place; `ConfigProvider` passes the names it is given as they
10
+ * are, so a relative one is answered `false` here and rejected as not absolute by its `#load`.
11
+ *
12
+ * The `catch` is not guarded by `isFatalError()`: what `import()` throws comes from the runtime's
13
+ * loader, which reports a file it cannot take as a module with a `SyntaxError` or a `TypeError`.
14
+ * Vite's module runner, which Vitest runs the specs in, reports such a file as a `Parse failure`,
15
+ * and that alone proves the file resolves; after any other failure the specifier is asked of
16
+ * `require.resolve()`, and what that throws, besides the module being absent or unexposed, is
17
+ * re-thrown as it is.
18
+ *
19
+ * @param name - A bare specifier, or an absolute path
20
+ * @returns `true` when the module resolves, including a file that the package's `exports` do not
21
+ * expose, and a file that resolves but cannot be imported; `false` for a relative path
22
+ */
1
23
  export declare function moduleExists(name: string): Promise<boolean>;
@@ -1,21 +1,39 @@
1
1
  import { createRequire } from 'node:module';
2
2
  import { log } from './debug.js';
3
+ import { importModule } from './import-module.js';
4
+ import { isRelativePath } from './path-utils.js';
3
5
  const require = createRequire(import.meta.url);
4
6
  const mLog = log.extend('module-exists');
7
+ /**
8
+ * Whether `name` resolves to a module from where markuplint is installed: a package, or a file of
9
+ * one, as `extends`, `plugins`, `parser` and `specs` take a module.
10
+ *
11
+ * The resolution is that of this file's own location, as `import()` and `createRequire()` do it,
12
+ * never that of the configuration file that names the module, so a relative path is not a module
13
+ * here and is answered `false` without asking the runtime: resolved from this file, it would reach
14
+ * this package's own files or whatever lies above them. `resolveNameOrAbsPath` keeps a value
15
+ * spelled as a path away in the first place; `ConfigProvider` passes the names it is given as they
16
+ * are, so a relative one is answered `false` here and rejected as not absolute by its `#load`.
17
+ *
18
+ * The `catch` is not guarded by `isFatalError()`: what `import()` throws comes from the runtime's
19
+ * loader, which reports a file it cannot take as a module with a `SyntaxError` or a `TypeError`.
20
+ * Vite's module runner, which Vitest runs the specs in, reports such a file as a `Parse failure`,
21
+ * and that alone proves the file resolves; after any other failure the specifier is asked of
22
+ * `require.resolve()`, and what that throws, besides the module being absent or unexposed, is
23
+ * re-thrown as it is.
24
+ *
25
+ * @param name - A bare specifier, or an absolute path
26
+ * @returns `true` when the module resolves, including a file that the package's `exports` do not
27
+ * expose, and a file that resolves but cannot be imported; `false` for a relative path
28
+ */
5
29
  export async function moduleExists(name) {
30
+ if (isRelativePath(name)) {
31
+ return false;
32
+ }
6
33
  try {
7
- await import(name);
34
+ await importModule(name);
8
35
  }
9
36
  catch (error) {
10
- if (
11
- // @ts-ignore
12
- 'code' in error &&
13
- // @ts-ignore
14
- error.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
15
- // It exists, but it is may be a JSON file.
16
- mLog('Return true, but it caught Error in `import()`: %O', error);
17
- return true;
18
- }
19
37
  if (error instanceof Error && /^parse failure/i.test(error.message)) {
20
38
  // It exists, but it failed to parse.
21
39
  mLog('Return true, but it caught Error in `import()`: %O', error);
@@ -1 +1,19 @@
1
+ /**
2
+ * Applies {@link resolveNameOrAbsPath} to a property of a configuration in whichever shape it
3
+ * takes: one value, a list of values, or a map such as `parser` (an extension to a module or a
4
+ * file) and `overrides` (a glob to a configuration).
5
+ *
6
+ * In a map, or in an entry of a list, every string value is resolved unless `resolveProps` names
7
+ * the properties that hold a path, as `name` is the one of a `plugins` entry. The keys are left
8
+ * alone unless `resolveKey` says they are paths, as the globs of `overrides` are; the values of
9
+ * `overrides` are configurations in turn, which the caller resolves one by one.
10
+ *
11
+ * @template T - The shape of the property
12
+ * @param dir - The directory of the configuration file that declares the property
13
+ * @param filePath - The property, or `undefined` when the configuration does not hold it
14
+ * @param resolveProps - The properties of a map, or of an entry of a list, whose values are paths;
15
+ * every string value is one when omitted
16
+ * @param resolveKey - Whether the keys of a map are paths
17
+ * @returns The property with its paths resolved, in the same shape, or `undefined` as given
18
+ */
1
19
  export declare function relPathToNameOrAbsPath<T extends string | readonly (string | Record<string, unknown>)[] | Readonly<Record<string, unknown>> | undefined>(dir: string, filePath?: T, resolveProps?: readonly string[], resolveKey?: boolean): Promise<T>;
@@ -1,4 +1,22 @@
1
1
  import { resolveNameOrAbsPath } from './resolve-name-or-abs-path.js';
2
+ /**
3
+ * Applies {@link resolveNameOrAbsPath} to a property of a configuration in whichever shape it
4
+ * takes: one value, a list of values, or a map such as `parser` (an extension to a module or a
5
+ * file) and `overrides` (a glob to a configuration).
6
+ *
7
+ * In a map, or in an entry of a list, every string value is resolved unless `resolveProps` names
8
+ * the properties that hold a path, as `name` is the one of a `plugins` entry. The keys are left
9
+ * alone unless `resolveKey` says they are paths, as the globs of `overrides` are; the values of
10
+ * `overrides` are configurations in turn, which the caller resolves one by one.
11
+ *
12
+ * @template T - The shape of the property
13
+ * @param dir - The directory of the configuration file that declares the property
14
+ * @param filePath - The property, or `undefined` when the configuration does not hold it
15
+ * @param resolveProps - The properties of a map, or of an entry of a list, whose values are paths;
16
+ * every string value is one when omitted
17
+ * @param resolveKey - Whether the keys of a map are paths
18
+ * @returns The property with its paths resolved, in the same shape, or `undefined` as given
19
+ */
2
20
  export async function relPathToNameOrAbsPath(dir, filePath, resolveProps, resolveKey = false) {
3
21
  if (filePath == null) {
4
22
  // @ts-ignore
@@ -31,3 +31,12 @@ export declare function toFileURL(filePath: string): string;
31
31
  export declare function normalizeForIgnore(filePath: string, relative?: boolean): string;
32
32
  /** Glob libraries require forward slashes. */
33
33
  export declare function normalizeForGlob(filePath: string): string;
34
+ /**
35
+ * Whether `filePath` is written as a relative path: a `.` or `..` segment first, followed by
36
+ * either separator (`./x`, `../x`, `.\x`, `..\x`) or by nothing (`.`, `..`). A dot-file name such
37
+ * as `.markuplintrc` is not one.
38
+ *
39
+ * @param filePath - A value of a configuration that names a file or a module
40
+ * @returns `true` for a relative path
41
+ */
42
+ export declare function isRelativePath(filePath: string): boolean;
package/lib/path-utils.js CHANGED
@@ -76,3 +76,14 @@ export function normalizeForIgnore(filePath, relative = false) {
76
76
  export function normalizeForGlob(filePath) {
77
77
  return toSlash(filePath);
78
78
  }
79
+ /**
80
+ * Whether `filePath` is written as a relative path: a `.` or `..` segment first, followed by
81
+ * either separator (`./x`, `../x`, `.\x`, `..\x`) or by nothing (`.`, `..`). A dot-file name such
82
+ * as `.markuplintrc` is not one.
83
+ *
84
+ * @param filePath - A value of a configuration that names a file or a module
85
+ * @returns `true` for a relative path
86
+ */
87
+ export function isRelativePath(filePath) {
88
+ return /^\.\.?(?:[/\\]|$)/.test(filePath);
89
+ }
@@ -1 +1,19 @@
1
+ /**
2
+ * Resolves a value of a configuration that names a module or a file: a module name stays as it
3
+ * is, and a path becomes absolute against `dir`.
4
+ *
5
+ * A value spelled as a path (`./`, `../`, `.`, `..`, with either separator, or absolute) and a
6
+ * value with the `!` prefix of an exclusion are resolved without asking `moduleExists()`, which
7
+ * resolves a relative specifier from where markuplint is installed, not from the configuration: a
8
+ * file by that name there would make the value a module, keep it relative, and have it read from
9
+ * the working directory later. Installed in a project's `node_modules`, this package's `lib/` lies
10
+ * four directories below the project root (`node_modules/@markuplint/file-resolver/lib/`), so a
11
+ * configuration four directories below the root collided with the root's own `.markuplintrc`
12
+ * through `../../../../.markuplintrc`, and `../package.json` names this package's own. Any other
13
+ * value is tried as a module first, as the documentation of `extends` and its kin describes.
14
+ *
15
+ * @param dir - The directory of the configuration file that declares the value
16
+ * @param pathOrModName - The value, possibly with the `!` prefix of an exclusion
17
+ * @returns The module name as it is, or the absolute path, with the `!` prefix kept
18
+ */
1
19
  export declare function resolveNameOrAbsPath(dir: string, pathOrModName: string): Promise<string>;
@@ -2,13 +2,32 @@ import path from 'node:path';
2
2
  import { isPluginModuleName } from './is-plugin-module-name.js';
3
3
  import { isPresetModuleName } from './is-preset-module-name.js';
4
4
  import { moduleExists } from './module-exists.js';
5
+ import { isRelativePath } from './path-utils.js';
6
+ /**
7
+ * Resolves a value of a configuration that names a module or a file: a module name stays as it
8
+ * is, and a path becomes absolute against `dir`.
9
+ *
10
+ * A value spelled as a path (`./`, `../`, `.`, `..`, with either separator, or absolute) and a
11
+ * value with the `!` prefix of an exclusion are resolved without asking `moduleExists()`, which
12
+ * resolves a relative specifier from where markuplint is installed, not from the configuration: a
13
+ * file by that name there would make the value a module, keep it relative, and have it read from
14
+ * the working directory later. Installed in a project's `node_modules`, this package's `lib/` lies
15
+ * four directories below the project root (`node_modules/@markuplint/file-resolver/lib/`), so a
16
+ * configuration four directories below the root collided with the root's own `.markuplintrc`
17
+ * through `../../../../.markuplintrc`, and `../package.json` names this package's own. Any other
18
+ * value is tried as a module first, as the documentation of `extends` and its kin describes.
19
+ *
20
+ * @param dir - The directory of the configuration file that declares the value
21
+ * @param pathOrModName - The value, possibly with the `!` prefix of an exclusion
22
+ * @returns The module name as it is, or the absolute path, with the `!` prefix kept
23
+ */
5
24
  export async function resolveNameOrAbsPath(dir, pathOrModName) {
6
- if ((await moduleExists(pathOrModName)) || isPresetModuleName(pathOrModName) || isPluginModuleName(pathOrModName)) {
7
- return pathOrModName;
8
- }
9
25
  const bangAndPath = /^(!)(.*)/.exec(pathOrModName) ?? [];
10
26
  const bang = bangAndPath[1] ?? '';
11
27
  const pathname = bangAndPath[2] ?? pathOrModName;
12
- const absPath = path.resolve(dir, pathname);
13
- return bang + absPath;
28
+ const isPath = bang !== '' || path.isAbsolute(pathname) || isRelativePath(pathname);
29
+ if (!isPath && (isPresetModuleName(pathname) || isPluginModuleName(pathname) || (await moduleExists(pathname)))) {
30
+ return pathOrModName;
31
+ }
32
+ return bang + path.resolve(dir, pathname);
14
33
  }
@@ -60,6 +60,8 @@ export type PretenderResolver = {
60
60
  * only on the call that read them.
61
61
  * @returns All resolved pretender definitions — a new array on every call,
62
62
  * so the retained target-independent entries are never exposed to mutation
63
+ * @throws An `Error` that says how to install `@markuplint/pretenders` when `config.scan`
64
+ * or `config.auto` needs it and it is not installed, with the error of the import as its `cause`
63
65
  */
64
66
  resolve(context?: ResolvePretendersContext, options?: ResolvePretendersOptions): Promise<Pretender[]>;
65
67
  };
@@ -70,7 +72,8 @@ export type PretenderResolver = {
70
72
  * kept as well — the host's next config resolution creates a new resolver).
71
73
  *
72
74
  * @param config - The pretenders configuration section from the optimized config
73
- * @returns The resolver; its `resolve()` yields `[]` when `config` is absent
75
+ * @returns The resolver; its `resolve()` yields `[]` when `config` is absent, and rejects as
76
+ * {@link resolvePretenders} does when `@markuplint/pretenders` is needed and not installed
74
77
  */
75
78
  export declare function createPretenderResolver(config: PretendersConfig): PretenderResolver;
76
79
  /**
@@ -98,6 +101,10 @@ export declare function createPretenderResolver(config: PretendersConfig): Prete
98
101
  * @param context - The lint target's path/source, required only for `config.auto`
99
102
  * @param options - Where to report the files the result depends on
100
103
  * @returns An array of all resolved pretender definitions
104
+ * @throws An `Error` that says how to install `@markuplint/pretenders` when `config.scan` or
105
+ * `config.auto` needs it and it is not installed (an optional peer of this package), with the
106
+ * error of the import as its `cause`. A module that an installed `@markuplint/pretenders`
107
+ * needs being missing is thrown as it is.
101
108
  */
102
109
  export declare function resolvePretenders(config: PretendersConfig, context?: ResolvePretendersContext, options?: ResolvePretendersOptions): Promise<Pretender[]>;
103
110
  /**
@@ -116,6 +123,8 @@ export declare function resolvePretenders(config: PretendersConfig, context?: Re
116
123
  * @param options - Where to report the files the result depends on
117
124
  * @returns The disambiguated pretender list, or `pretenders` itself (same
118
125
  * reference) when there was nothing to resolve
126
+ * @throws An `Error` that says how to install `@markuplint/pretenders` when an entry has a
127
+ * `filePath` and it is not installed, with the error of the import as its `cause`
119
128
  */
120
129
  export declare function disambiguatePretendersForFile(filePath: string, sourceCode: string, pretenders: readonly Pretender[], options?: ResolvePretendersOptions): Promise<readonly Pretender[]>;
121
130
  /**
@@ -136,7 +145,8 @@ export declare function hasResolvableCollision(pretenders: readonly Pretender[])
136
145
  * newly valid tsconfig `paths` alias keeps resolving as it did before the
137
146
  * change for the rest of the process's lifetime. A no-op (not an error) when
138
147
  * `@markuplint/pretenders` isn't installed, since nothing has populated its
139
- * caches in that case either.
148
+ * caches in that case either, and when it is installed but fails to load, which
149
+ * is logged under the `ml-fr` debug namespace: what needs it fails on its own, with the reason.
140
150
  *
141
151
  * @returns A promise that resolves once the caches have been cleared
142
152
  */
@@ -1,8 +1,38 @@
1
1
  import path from 'node:path';
2
2
  import { rebaseHandWrittenPretenderFilePath, rebasePretenderFilePath } from '@markuplint/ml-config';
3
+ import { isFatalError, missingPackageOf } from '@markuplint/shared';
3
4
  import { glob } from 'glob';
5
+ import { log } from './debug.js';
4
6
  import { generalImport } from './general-import.js';
5
7
  import { normalizeForGlob } from './path-utils.js';
8
+ const PRETENDERS_PACKAGE = '@markuplint/pretenders';
9
+ /**
10
+ * `@markuplint/pretenders` is an optional peer of this package (`peerDependenciesMeta`): installing markuplint
11
+ * does not install it, nor the parsers and the frameworks behind them, so it is loaded only for a
12
+ * configuration that needs it, and one that does is told how to install it rather than given the bare
13
+ * `ERR_MODULE_NOT_FOUND` of Node.
14
+ *
15
+ * Only the package itself missing gets that message. A module that an installed `@markuplint/pretenders`
16
+ * needs being missing is a fault of the installation, and is thrown as it is (see `missingPackageOf` in
17
+ * `@markuplint/shared`).
18
+ *
19
+ * @param name - The export to load
20
+ * @param needs - What needs it, as the start of the sentence of the error: an option of the configuration
21
+ * @returns The export
22
+ * @throws An `Error` that says how to install the package, with the error of the import as its `cause`
23
+ */
24
+ async function importFromPretenders(name, needs) {
25
+ try {
26
+ const mod = await import('@markuplint/pretenders');
27
+ return mod[name];
28
+ }
29
+ catch (error) {
30
+ if (isFatalError(error) || missingPackageOf(error) !== PRETENDERS_PACKAGE) {
31
+ throw error;
32
+ }
33
+ throw new Error(`${needs} needs ${PRETENDERS_PACKAGE}, which is not installed. Add it to your project as a development dependency (for example, npm install --save-dev ${PRETENDERS_PACKAGE}), together with the parser package of each language you scan: @markuplint/vue-parser for .vue, @markuplint/svelte-parser for .svelte, @markuplint/astro-parser for .astro (and @markuplint/jsx-parser, @markuplint/mdx-parser for JSX and MDX).`, { cause: error });
34
+ }
35
+ }
6
36
  /**
7
37
  * `Pretender.filePath` is written by scanners (and by hand, in `data`) relative to
8
38
  * their own base directory, which is meaningless once entries from
@@ -22,7 +52,8 @@ function rebasePretenderFilePaths(pretenders, baseDir) {
22
52
  * kept as well — the host's next config resolution creates a new resolver).
23
53
  *
24
54
  * @param config - The pretenders configuration section from the optimized config
25
- * @returns The resolver; its `resolve()` yields `[]` when `config` is absent
55
+ * @returns The resolver; its `resolve()` yields `[]` when `config` is absent, and rejects as
56
+ * {@link resolvePretenders} does when `@markuplint/pretenders` is needed and not installed
26
57
  */
27
58
  export function createPretenderResolver(config) {
28
59
  // The promise is retained, not its result, so that overlapping first calls
@@ -71,6 +102,10 @@ export function createPretenderResolver(config) {
71
102
  * @param context - The lint target's path/source, required only for `config.auto`
72
103
  * @param options - Where to report the files the result depends on
73
104
  * @returns An array of all resolved pretender definitions
105
+ * @throws An `Error` that says how to install `@markuplint/pretenders` when `config.scan` or
106
+ * `config.auto` needs it and it is not installed (an optional peer of this package), with the
107
+ * error of the import as its `cause`. A module that an installed `@markuplint/pretenders`
108
+ * needs being missing is thrown as it is.
74
109
  */
75
110
  export function resolvePretenders(config, context, options) {
76
111
  return createPretenderResolver(config).resolve(context, options);
@@ -118,7 +153,7 @@ async function resolveTargetIndependentPretenders(config) {
118
153
  data.push(...config.data.map(pretender => rebaseHandWrittenPretenderFilePath(pretender, p => path.resolve(cwd, p))));
119
154
  }
120
155
  if (config.scan) {
121
- const { scan } = await import('@markuplint/pretenders');
156
+ const scan = await importFromPretenders('scan', 'The "pretenders.scan" option');
122
157
  for (const entry of config.scan) {
123
158
  const patterns = typeof entry.files === 'string' ? [entry.files] : [...entry.files];
124
159
  // The patterns are absolute paths of the platform (`#pathResolve`), which Windows writes
@@ -143,7 +178,7 @@ async function resolveTargetIndependentPretenders(config) {
143
178
  * `context`'s import graph to `data`, the target-independent entries.
144
179
  */
145
180
  async function appendAutoPretenders(data, context, auto, dependencies) {
146
- const { autoScan } = await import('@markuplint/pretenders');
181
+ const autoScan = await importFromPretenders('autoScan', 'The "pretenders.auto" option');
147
182
  const scanned = await autoScan(context.filePath, context.sourceCode, {
148
183
  depth: auto === true ? undefined : auto.depth,
149
184
  dependencies,
@@ -189,12 +224,14 @@ async function appendAutoPretenders(data, context, auto, dependencies) {
189
224
  * @param options - Where to report the files the result depends on
190
225
  * @returns The disambiguated pretender list, or `pretenders` itself (same
191
226
  * reference) when there was nothing to resolve
227
+ * @throws An `Error` that says how to install `@markuplint/pretenders` when an entry has a
228
+ * `filePath` and it is not installed, with the error of the import as its `cause`
192
229
  */
193
230
  export async function disambiguatePretendersForFile(filePath, sourceCode, pretenders, options) {
194
231
  if (!pretenders.some(pretender => pretender.filePath)) {
195
232
  return pretenders;
196
233
  }
197
- const { disambiguatePretenders } = await import('@markuplint/pretenders');
234
+ const disambiguatePretenders = await importFromPretenders('disambiguatePretenders', 'A pretender that has a `filePath`');
198
235
  // The same base the scanned pretenders' paths were made absolute from (see `rebasePretenderFilePaths`).
199
236
  return disambiguatePretenders(pretenders, {
200
237
  filePath,
@@ -233,11 +270,24 @@ export function hasResolvableCollision(pretenders) {
233
270
  * newly valid tsconfig `paths` alias keeps resolving as it did before the
234
271
  * change for the rest of the process's lifetime. A no-op (not an error) when
235
272
  * `@markuplint/pretenders` isn't installed, since nothing has populated its
236
- * caches in that case either.
273
+ * caches in that case either, and when it is installed but fails to load, which
274
+ * is logged under the `ml-fr` debug namespace: what needs it fails on its own, with the reason.
237
275
  *
238
276
  * @returns A promise that resolves once the caches have been cleared
239
277
  */
240
278
  export async function invalidatePretenderResolutionCaches() {
241
- const pretendersMod = await import('@markuplint/pretenders').catch(() => null);
242
- pretendersMod?.clearPretenderCaches();
279
+ let clearPretenderCaches;
280
+ try {
281
+ ({ clearPretenderCaches } = await import('@markuplint/pretenders'));
282
+ }
283
+ catch (error) {
284
+ // Clearing is best-effort, and what needs the package fails on its own with the reason when it is
285
+ // loaded, so a package that fails to load is logged and left to that. Tier-1-shaped errors are not
286
+ // told apart here either: a module the package needs runs as it loads, as in `generalImport`.
287
+ if (missingPackageOf(error) !== PRETENDERS_PACKAGE) {
288
+ log('Failed to load %s to clear its caches: %O', PRETENDERS_PACKAGE, error);
289
+ }
290
+ return;
291
+ }
292
+ clearPretenderCaches();
243
293
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/file-resolver",
3
- "version": "5.4.0",
3
+ "version": "5.6.0",
4
4
  "description": "The file resolver of markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -22,34 +22,39 @@
22
22
  "publishConfig": {
23
23
  "access": "public"
24
24
  },
25
- "typedoc": {
26
- "entryPoint": "./src/index.ts"
27
- },
28
25
  "scripts": {
29
26
  "build": "tsc --project tsconfig.build.json",
30
27
  "dev": "tsc --watch --project tsconfig.build.json",
31
28
  "clean": "tsc --build --clean"
32
29
  },
33
30
  "devDependencies": {
31
+ "@markuplint/pretenders": "5.6.0",
34
32
  "@types/node": "24.19.2"
35
33
  },
34
+ "peerDependencies": {
35
+ "@markuplint/pretenders": "^5.4.0"
36
+ },
37
+ "peerDependenciesMeta": {
38
+ "@markuplint/pretenders": {
39
+ "optional": true
40
+ }
41
+ },
36
42
  "dependencies": {
37
- "@markuplint/html-parser": "5.4.0",
38
- "@markuplint/ml-ast": "5.3.0",
39
- "@markuplint/ml-config": "5.4.0",
40
- "@markuplint/ml-core": "5.4.0",
41
- "@markuplint/ml-spec": "5.4.0",
42
- "@markuplint/parser-utils": "5.4.0",
43
- "@markuplint/pretenders": "5.4.0",
44
- "@markuplint/selector": "5.4.0",
45
- "@markuplint/shared": "5.4.0",
43
+ "@markuplint/html-parser": "5.6.0",
44
+ "@markuplint/ml-ast": "5.6.0",
45
+ "@markuplint/ml-config": "5.6.0",
46
+ "@markuplint/ml-core": "5.6.0",
47
+ "@markuplint/ml-spec": "5.6.0",
48
+ "@markuplint/parser-utils": "5.6.0",
49
+ "@markuplint/selector": "5.6.0",
50
+ "@markuplint/shared": "5.6.0",
46
51
  "cosmiconfig": "^10.0.1",
47
52
  "debug": "^4.4.3",
48
- "glob": "^13.0.6",
49
- "ignore": "^7.0.12",
53
+ "glob": "^14.0.3",
54
+ "ignore": "^7.0.14",
50
55
  "import-meta-resolve": "^4.2.0",
51
56
  "jsonc": "^3.0.0",
52
- "minimatch": "^10.2.6"
57
+ "minimatch": "^11.0.4"
53
58
  },
54
- "gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
59
+ "gitHead": "eade3d7202682e567f3c1b6985478f1d4b5c5481"
55
60
  }