@markuplint/file-resolver 5.3.0 → 5.4.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,18 @@
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.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **file-resolver:** do not run a throwing config file again through require() ([21b2e87](https://github.com/markuplint/markuplint/commit/21b2e87d836baa3e0747dee623845ddfc077c091)), closes [#4413](https://github.com/markuplint/markuplint/issues/4413)
11
+ * **file-resolver:** find a Windows path written with forward slashes in the stack ([ea7336c](https://github.com/markuplint/markuplint/commit/ea7336c839365d67a3b0d6427d8d1c2b864769f1)), closes [#4413](https://github.com/markuplint/markuplint/issues/4413)
12
+ * **file-resolver:** find the configuration above a nested package.json ([de7c887](https://github.com/markuplint/markuplint/commit/de7c887aebc5ce59bfdad08802ae23554abbda27)), closes [#4405](https://github.com/markuplint/markuplint/issues/4405)
13
+ * **file-resolver:** report a config file that fails to load by its own path ([0fafeca](https://github.com/markuplint/markuplint/commit/0fafeca63d28164bcbbd7fecdaeefcb593a2ae7f)), closes [#4413](https://github.com/markuplint/markuplint/issues/4413)
14
+ * **file-resolver:** resolve the paths of a configuration from the file that declares it ([b819519](https://github.com/markuplint/markuplint/commit/b819519236e3640b65965376ba2e78ea336e9593)), closes [#4406](https://github.com/markuplint/markuplint/issues/4406)
15
+ * **file-resolver:** search the ancestors of a target whose directory does not exist ([75bdf1c](https://github.com/markuplint/markuplint/commit/75bdf1c7affce5eab88c091ca6a031c89ae29840)), closes [#4412](https://github.com/markuplint/markuplint/issues/4412)
16
+
17
+
6
18
  # [5.3.0](https://github.com/markuplint/markuplint/compare/v5.2.0...v5.3.0) (2026-10-21)
7
19
 
8
20
  ### Bug Fixes
@@ -98,7 +98,12 @@ export declare class ConfigProvider {
98
98
  */
99
99
  resolve(targetFile: Readonly<MLFile>, names: readonly Nullable<string>[], cache?: boolean): Promise<ConfigSet>;
100
100
  /**
101
- * Searches for a markuplint configuration file starting from the target file's directory.
101
+ * Searches for a markuplint configuration file starting from the target file's directory
102
+ * and going up through its ancestors; the nearest one wins. A `package.json` without a
103
+ * `markuplint` field is not a configuration file, so the search passes over it. A target
104
+ * directory that does not exist yet, such as that of an unsaved editor buffer in a new folder,
105
+ * does not end the search either: the search starts from that directory and goes on through
106
+ * its ancestors, the missing ones yielding nothing.
102
107
  *
103
108
  * @param targetFile - The file whose directory to search from
104
109
  * @returns The file path of the found config, or `null` if none was found
@@ -1,10 +1,10 @@
1
1
  import path from 'node:path';
2
- import { mergeConfig } from '@markuplint/ml-config';
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
5
  import { nonNullableFilter, toNoEmptyStringArrayFromStringOrArray, ConfigLoadError } from '@markuplint/shared';
6
6
  import { clearAutoLoadRulesCache } from './auto-load-rules.js';
7
- import { load as loadConfig, search, clearExplorerCache } from './cosmiconfig.js';
7
+ import { load as loadConfig, search, clearExplorerCache, configLoadErrorFiles } from './cosmiconfig.js';
8
8
  import { log } from './debug.js';
9
9
  import { generalImport } from './general-import.js';
10
10
  import { getPreset } from './get-preset.js';
@@ -136,7 +136,14 @@ export class ConfigProvider {
136
136
  return { stack, errs: [] };
137
137
  }
138
138
  if (config instanceof ConfigLoadError) {
139
- stack.add(config.filePath);
139
+ // The files whose change can fix the error are recorded as well as `key`, as they can be
140
+ // ones that `key` imports through `$import`, or a target of it that does not exist yet.
141
+ // `key` is added last, so that it stays the file `#resolveBase` validates against, as when
142
+ // the load succeeds.
143
+ for (const filePath of configLoadErrorFiles(config)) {
144
+ stack.add(filePath);
145
+ }
146
+ stack.add(key);
140
147
  return {
141
148
  stack,
142
149
  errs: [config],
@@ -190,7 +197,7 @@ export class ConfigProvider {
190
197
  baseConfigSet = await this.#resolveBase(keys, cache, targetFile.path);
191
198
  this.#cache.set(key, baseConfigSet);
192
199
  }
193
- return this.#applyOverrides(baseConfigSet, targetFile);
200
+ return await this.#applyOverrides(baseConfigSet, targetFile);
194
201
  }
195
202
  /**
196
203
  * The `names`-dependent, `targetFile`-independent part of {@link resolve}:
@@ -245,8 +252,13 @@ export class ConfigProvider {
245
252
  * replaces every earlier match, not just the base config. `appliedOverrides`
246
253
  * records that match order for `--show-config=details` to surface, since this
247
254
  * "last match wins outright" behavior is easy to mistake for a bug (see #4023).
255
+ *
256
+ * The plugins an override declares are loaded here, as `#resolveBase` cannot know which
257
+ * overrides apply. The result keeps every plugin of the base, replaced where the override
258
+ * declares the same one: a `reset` override that declares none still reaches the rules of
259
+ * the plugins the base loaded, as it did before the override's own were loaded at all.
248
260
  */
249
- #applyOverrides(baseConfigSet, targetFile) {
261
+ async #applyOverrides(baseConfigSet, targetFile) {
250
262
  let config = baseConfigSet.config;
251
263
  const appliedOverrides = [];
252
264
  if (config.overrides) {
@@ -258,24 +270,53 @@ export class ConfigProvider {
258
270
  appliedOverrides.push(glob);
259
271
  }
260
272
  }
261
- return config === baseConfigSet.config ? baseConfigSet : { ...baseConfigSet, config, appliedOverrides };
273
+ if (config === baseConfigSet.config) {
274
+ return baseConfigSet;
275
+ }
276
+ return {
277
+ ...(await this.#resolveOverridePlugins(baseConfigSet, config)),
278
+ config,
279
+ appliedOverrides,
280
+ };
262
281
  }
263
282
  return baseConfigSet;
264
283
  }
265
284
  /**
266
- * Searches for a markuplint configuration file starting from the target file's directory.
285
+ * @param baseConfigSet - The base configuration set, which is never mutated
286
+ * @param config - The configuration after the overrides are applied
287
+ * @returns `baseConfigSet` with the plugins of `config` loaded in, or itself (same
288
+ * reference) when `config` declares none
289
+ */
290
+ async #resolveOverridePlugins(baseConfigSet, config) {
291
+ if (!config.plugins || config.plugins === baseConfigSet.config.plugins) {
292
+ return baseConfigSet;
293
+ }
294
+ const { plugins: declared, errors } = await resolvePlugins(config.plugins);
295
+ const baseNames = new Set(baseConfigSet.plugins.map(plugin => plugin.name));
296
+ const plugins = [
297
+ ...baseConfigSet.plugins.map(plugin => declared.find(d => d.name === plugin.name) ?? plugin),
298
+ ...declared.filter(plugin => !baseNames.has(plugin.name)),
299
+ ];
300
+ // `merge` mode declares the base's plugins again, and a plugin that is not found is cached
301
+ // as the error, so what the base already reported is not reported twice.
302
+ const reported = new Set(baseConfigSet.errs.map(error => error.message));
303
+ const errs = [...baseConfigSet.errs, ...errors.filter(error => !reported.has(error.message))];
304
+ return { ...baseConfigSet, plugins, errs };
305
+ }
306
+ /**
307
+ * Searches for a markuplint configuration file starting from the target file's directory
308
+ * and going up through its ancestors; the nearest one wins. A `package.json` without a
309
+ * `markuplint` field is not a configuration file, so the search passes over it. A target
310
+ * directory that does not exist yet, such as that of an unsaved editor buffer in a new folder,
311
+ * does not end the search either: the search starts from that directory and goes on through
312
+ * its ancestors, the missing ones yielding nothing.
267
313
  *
268
314
  * @param targetFile - The file whose directory to search from
269
315
  * @returns The file path of the found config, or `null` if none was found
270
316
  */
271
317
  async search(targetFile) {
272
- const isExists = await targetFile.dirExists();
273
318
  if (cpLog.enabled) {
274
319
  cpLog('search: %s', targetFile.path);
275
- cpLog('isExists: %s', isExists);
276
- }
277
- if (!isExists) {
278
- return null;
279
320
  }
280
321
  const res = await search(targetFile.path, false);
281
322
  cpLog('searched config: %O', res);
@@ -381,26 +422,15 @@ export class ConfigProvider {
381
422
  async #pathResolve(config, filePath) {
382
423
  const optimizedConfig = mergeConfig(config);
383
424
  const dir = path.dirname(filePath);
425
+ // Only the keys of `overrides` (the globs) are resolved here; each value is a
426
+ // configuration of its own, whose paths are relative to the same directory.
427
+ const overrides = await relPathToNameOrAbsPath(dir, optimizedConfig.overrides, undefined, true);
384
428
  return {
385
- ...optimizedConfig,
429
+ ...(await resolveConfigPaths(dir, optimizedConfig)),
386
430
  extends: await relPathToNameOrAbsPath(dir, optimizedConfig.extends),
387
- plugins: await relPathToNameOrAbsPath(dir, optimizedConfig.plugins, ['name']),
388
- parser: await relPathToNameOrAbsPath(dir, optimizedConfig.parser),
389
- specs: await relPathToNameOrAbsPath(dir, optimizedConfig.specs),
390
- excludeFiles: await relPathToNameOrAbsPath(dir, optimizedConfig.excludeFiles),
391
- pretenders: optimizedConfig.pretenders
392
- ? {
393
- ...optimizedConfig.pretenders,
394
- files: await relPathToNameOrAbsPath(dir, optimizedConfig.pretenders?.files),
395
- scan: optimizedConfig.pretenders?.scan?.map(entry => ({
396
- ...entry,
397
- files: typeof entry.files === 'string'
398
- ? path.resolve(dir, entry.files)
399
- : entry.files.map(f => path.resolve(dir, f)),
400
- })),
401
- }
431
+ overrides: overrides
432
+ ? Object.fromEntries(await Promise.all(Object.entries(overrides).map(async ([glob, value]) => [glob, await resolveConfigPaths(dir, value)])))
402
433
  : undefined,
403
- overrides: await relPathToNameOrAbsPath(dir, optimizedConfig.overrides, undefined, true),
404
434
  };
405
435
  }
406
436
  #validateConfig(config, filePath) {
@@ -424,6 +454,55 @@ export class ConfigProvider {
424
454
  return errors;
425
455
  }
426
456
  }
457
+ /**
458
+ * Resolves the paths a configuration holds against `dir`, the directory of the file that
459
+ * declares it. Once configurations from different directories are merged (`extends`), the
460
+ * directory a relative path belongs to is no longer known, so this runs for the top level and
461
+ * for the value of every `overrides` entry while it still is.
462
+ *
463
+ * Every property of a configuration that holds a path has to be resolved here: one missed is
464
+ * resolved at the top level but left relative inside `overrides` (or the reverse), and what is
465
+ * left relative is read from the working directory later.
466
+ *
467
+ * The result has no `undefined` property for what the configuration does not hold. An
468
+ * `overrides` value is the configuration itself under `reset` mode, so an `undefined` key
469
+ * would show up in it as a property it never had; under `merge` mode it would overwrite the
470
+ * value the base configuration holds. `mergeConfig` leaves none in an `overrides` value for
471
+ * the same reason.
472
+ *
473
+ * @template T - The configuration type: the top level, or the value of an `overrides` entry
474
+ * @param dir - The directory of the configuration file that declares `config`
475
+ * @param config - The configuration whose paths are resolved
476
+ * @returns The configuration with the paths resolved
477
+ */
478
+ async function resolveConfigPaths(dir, config) {
479
+ return {
480
+ ...config,
481
+ ...(config.plugins && { plugins: await relPathToNameOrAbsPath(dir, config.plugins, ['name']) }),
482
+ ...(config.parser && { parser: await relPathToNameOrAbsPath(dir, config.parser) }),
483
+ ...(config.specs && { specs: await relPathToNameOrAbsPath(dir, config.specs) }),
484
+ ...(config.excludeFiles && { excludeFiles: await relPathToNameOrAbsPath(dir, config.excludeFiles) }),
485
+ ...(config.pretenders && {
486
+ pretenders: {
487
+ ...config.pretenders,
488
+ ...(config.pretenders.files && {
489
+ files: await relPathToNameOrAbsPath(dir, config.pretenders.files),
490
+ }),
491
+ ...(config.pretenders.scan && {
492
+ scan: config.pretenders.scan.map(entry => ({
493
+ ...entry,
494
+ files: typeof entry.files === 'string'
495
+ ? path.resolve(dir, entry.files)
496
+ : entry.files.map(f => path.resolve(dir, f)),
497
+ })),
498
+ }),
499
+ ...(config.pretenders.data && {
500
+ data: config.pretenders.data.map(pretender => rebaseHandWrittenPretenderFilePath(pretender, relPath => path.resolve(dir, relPath))),
501
+ }),
502
+ },
503
+ }),
504
+ };
505
+ }
427
506
  async function load(filePath, cache, referrer) {
428
507
  if (!fileExists(filePath) && (await moduleExists(filePath))) {
429
508
  const config = (await generalImport(filePath)) ?? new ConfigLoadError('Module is not found', filePath, referrer);
@@ -10,6 +10,30 @@ type CosmiConfig = ReturnType<LoaderSync>;
10
10
  * content instead of the explorer's stale cache. See #4015.
11
11
  */
12
12
  export declare function clearExplorerCache(): void;
13
+ /**
14
+ * Searches for a configuration file in the directory of `filePath` and in each ancestor
15
+ * directory up to the file system root, nearest first.
16
+ *
17
+ * The walk is done here and not by cosmiconfig's `searchStrategy: 'project'`: that strategy
18
+ * stops at the first directory that has a `package.json`, which hides a configuration file at a
19
+ * monorepo root from every file inside a package, while the documented behavior is that the
20
+ * search goes upward from the target file's directory (https://markuplint.dev/docs/configuration).
21
+ * `searchStrategy: 'global'` is no substitute: it also searches the OS-level configuration
22
+ * directory, and that cannot be turned off.
23
+ *
24
+ * A directory that has a `package.json` or `package.yaml` does not end the walk: a `package.json`
25
+ * without a `markuplint` field is not a configuration file, and a `package.yaml` never is, as it
26
+ * is not among the search places. A directory that does not exist, the target's own included,
27
+ * is passed over (see {@link isMissingDir}). An empty configuration is treated like no result
28
+ * and the walk continues. A configuration file that cannot be loaded, at any level, makes the
29
+ * search throw instead of being skipped. There is no upper bound below the file system root, so a
30
+ * configuration file in a directory outside the project applies when nothing nearer exists.
31
+ *
32
+ * @template T - The type of the configuration object
33
+ * @param filePath - The path of the target file; the search starts from its directory
34
+ * @param cacheClear - Whether to clear the explorer's caches before searching
35
+ * @returns The path and content of the nearest configuration file, or `null` if none was found
36
+ */
13
37
  export declare function search<T = CosmiConfig>(filePath: string, cacheClear: boolean): Promise<{
14
38
  filePath: string;
15
39
  config: T;
@@ -18,4 +42,29 @@ export declare function load<T = CosmiConfig>(filePath: string, cacheClear: bool
18
42
  filePath: string;
19
43
  config: T;
20
44
  }>;
45
+ /**
46
+ * The files whose change can fix `error`, for a caller to watch or to list among the files a
47
+ * configuration depends on: the file it names and, when that file's `$import` target could not
48
+ * be read, the target, which may not exist yet. Nothing when it names a directory that a search
49
+ * could not examine. For a `ConfigLoadError` raised elsewhere (a preset, a module), what it names.
50
+ *
51
+ * @param error - A `ConfigLoadError`
52
+ * @returns The absolute paths of the files, the `$import` target first; or what `error` names
53
+ */
54
+ export declare function configLoadErrorFiles(error: ConfigLoadError): readonly string[];
55
+ /**
56
+ * Finds the location nearest the top of `stack` that names one of `filePaths`, by its path (a
57
+ * CommonJS module) or by its `file:` URL (an ES module): a frame, `<path>:<line>:<column>`, or
58
+ * the header that Node.js puts above a `SyntaxError` in a CommonJS module, `<path>:<line>`. A
59
+ * `SyntaxError` in an ES module has neither. A Windows path is also looked for with forward
60
+ * slashes (`C:/p/…`), as a module runner such as Vitest names the module it evaluates.
61
+ *
62
+ * A path only matches as a whole, at the start of a line or after whitespace or `(`, so that
63
+ * another file whose path ends with the same characters does not.
64
+ *
65
+ * @param stack - The `stack` of an error
66
+ * @param filePaths - Absolute paths of the same file
67
+ * @returns `:<line>:<column>`, `:<line>` for a header, or an empty string when there is neither
68
+ */
69
+ export declare function stackLocation(stack: string, filePaths: readonly string[]): string;
21
70
  export {};
@@ -1,10 +1,27 @@
1
+ import fs from 'node:fs/promises';
1
2
  import path from 'node:path';
2
- import { ConfigParserError } from '@markuplint/parser-utils';
3
- import { cosmiconfig, defaultLoaders } from 'cosmiconfig';
3
+ import { pathToFileURL } from 'node:url';
4
+ import { inspect } from 'node:util';
5
+ import { cosmiconfig, defaultLoaders, defaultLoadersSync } from 'cosmiconfig';
4
6
  import { jsonc } from 'jsonc';
5
- import { ConfigLoadError } from '@markuplint/shared';
7
+ import { ConfigLoadError, isFatalError } from '@markuplint/shared';
6
8
  import { log } from './debug.js';
7
9
  const searchLog = log.extend('search');
10
+ /**
11
+ * Fatal errors (see {@link isFatalError}) raised by markuplint's own code inside a loader. cosmiconfig
12
+ * gives every error a loader throws the path of the file, so {@link cacheConfigError} looks here to
13
+ * re-throw these as they are instead of reporting them as the configuration's fault.
14
+ */
15
+ const ownErrors = new WeakSet();
16
+ /**
17
+ * The `$import` target that {@link withImportCheck} could not read, by the error it threw.
18
+ */
19
+ const unreadableImports = new WeakMap();
20
+ /**
21
+ * The files whose change can fix a `ConfigLoadError` raised here, by the error; see
22
+ * {@link configLoadErrorFiles}.
23
+ */
24
+ const filesOfErrors = new WeakMap();
8
25
  const jsoncLoader = (path, content) => {
9
26
  try {
10
27
  return jsonc.parse(content);
@@ -13,9 +30,179 @@ const jsoncLoader = (path, content) => {
13
30
  if (error instanceof Error && error.name === 'JSONError') {
14
31
  return defaultLoaders['noExt'](path, content);
15
32
  }
33
+ if (isFatalError(error) && error instanceof Error) {
34
+ ownErrors.add(error);
35
+ }
16
36
  throw error;
17
37
  }
18
38
  };
39
+ /**
40
+ * Loads a JavaScript or TypeScript configuration file as cosmiconfig's `loadJs` does: `import()`
41
+ * of its real path, then `require()` when that fails, and the error of `import()` when `require()`
42
+ * fails only because the file is an ES module. Spelled out because `loadJs` reads `code` of what
43
+ * `require()` throws, which itself throws a `TypeError` when the configuration code throws `null`
44
+ * or `undefined`, and the value is lost.
45
+ *
46
+ * Unlike `loadJs`, what the configuration code itself threw while `import()` evaluated it is
47
+ * thrown as it is, without `require()`: see {@link isLoadingFailure}. Node.js's `require()` of
48
+ * such a module throws the same value again, but Deno's throws a new `Error` whose message is the
49
+ * stack of the original, which loses the value, its message and the location of the throw.
50
+ *
51
+ * A value the configuration code throws that is not an `Error` is thrown as one, with the value as
52
+ * its `cause`, so that it reaches {@link cacheConfigError} with the path of the file. cosmiconfig
53
+ * assigns `filepath` to what a loader throws, and that assignment itself throws a `TypeError` on a
54
+ * primitive, which loses both the value and the path.
55
+ *
56
+ * When cosmiconfig is upgraded, compare this with its `loadJs` (`dist/loaders.js`): how it
57
+ * imports the file, when it falls back to `require()` (`ERR_REQUIRE_ESM`), and whether it has
58
+ * started transpiling TypeScript itself.
59
+ *
60
+ * Neither `catch` is guarded by {@link isFatalError}: what is caught there was thrown by the
61
+ * user's code, or by the runtime loading it.
62
+ *
63
+ * @param filepath - The configuration file
64
+ * @param content - Its content, which `require()` does not use
65
+ * @returns The default export of the module, or `module.exports` of a CommonJS module
66
+ */
67
+ const jsLoader = async (filepath, content) => {
68
+ try {
69
+ try {
70
+ const { href } = pathToFileURL(await fs.realpath(filepath));
71
+ const module = await import(href);
72
+ return module && typeof module === 'object' && 'default' in module ? module.default : undefined;
73
+ }
74
+ catch (importError) {
75
+ if (!isLoadingFailure(importError)) {
76
+ throw importError;
77
+ }
78
+ try {
79
+ return defaultLoadersSync['.js'](filepath, content);
80
+ }
81
+ catch (requireError) {
82
+ throw isRequireOfEsModule(requireError) ? importError : requireError;
83
+ }
84
+ }
85
+ }
86
+ catch (error) {
87
+ if (error instanceof Error) {
88
+ throw error;
89
+ }
90
+ const message = typeof error === 'string' ? error : inspect(error, { breakLength: Infinity });
91
+ throw new Error(message, { cause: error });
92
+ }
93
+ };
94
+ /**
95
+ * Whether `import()` failed to load the module rather than the module's own code throwing: a
96
+ * `SyntaxError` (the file cannot be parsed as an ES module, but `require()` may take it as
97
+ * CommonJS, and reports the line of a syntax error that `import()` does not), or an error with a
98
+ * Node.js error code (`ERR_*`: a file extension or format that `import()` does not take, or a
99
+ * module that cannot be resolved). Anything else, an `Error` without such a code or a value that
100
+ * is not an `Error`, was thrown by the configuration code, and `require()` would only run it
101
+ * again.
102
+ *
103
+ * @param importError - What `import()` threw
104
+ * @returns `true` when `require()` is worth trying
105
+ */
106
+ function isLoadingFailure(importError) {
107
+ return (importError instanceof SyntaxError ||
108
+ (importError instanceof Error &&
109
+ 'code' in importError &&
110
+ typeof importError.code === 'string' &&
111
+ importError.code.startsWith('ERR_')));
112
+ }
113
+ /**
114
+ * Whether `require()` failed only because the file is an ES module, the same test as
115
+ * cosmiconfig's `loadJs`.
116
+ *
117
+ * @param requireError - What `require()` threw
118
+ * @returns `true` when the error of `import()` is the one to report
119
+ */
120
+ function isRequireOfEsModule(requireError) {
121
+ return ((requireError instanceof Error && 'code' in requireError && requireError.code === 'ERR_REQUIRE_ESM') ||
122
+ (requireError instanceof SyntaxError &&
123
+ requireError.toString().includes('Cannot use import statement outside a module')));
124
+ }
125
+ /**
126
+ * Wraps `loader` so that a `$import` target that cannot be read fails while the file that
127
+ * declares it is being loaded. cosmiconfig reads the targets itself after the loader has
128
+ * returned, and fails without `filepath` there; worse, its search takes a target that does not
129
+ * exist (or ENOENT's kin: EACCES, EISDIR, ENOTDIR) for a search place without a file, and passes
130
+ * the declaring file over in silence, so the target would be linted with another configuration.
131
+ * Thrown from inside the loader, the failure carries the path of the declaring file (see
132
+ * {@link cacheConfigError}).
133
+ *
134
+ * A target that is not a string, the file itself, or a cycle is left to cosmiconfig, which
135
+ * reports it with the path in its message (see {@link importErrorPath}).
136
+ *
137
+ * Each target is opened and one byte of it read, which fails as cosmiconfig's `readFile` of it
138
+ * would (a directory fails at the read, not at the open), without reading it in full twice. A
139
+ * fatal error of this check is markuplint's own, and is recorded in {@link ownErrors}.
140
+ *
141
+ * @param loader - The loader of one format
142
+ * @returns The loader, checking the `$import` of what it loaded
143
+ */
144
+ function withImportCheck(loader) {
145
+ return async (filepath, content) => {
146
+ const loaded = await loader(filepath, content);
147
+ try {
148
+ const imports = importsOf(filepath, loaded);
149
+ await Promise.all(imports
150
+ .filter(importPath => typeof importPath === 'string')
151
+ .map(importPath => probeImport(path.resolve(path.dirname(filepath), importPath), importPath)));
152
+ }
153
+ catch (error) {
154
+ if (isFatalError(error) && error instanceof Error) {
155
+ ownErrors.add(error);
156
+ }
157
+ throw error;
158
+ }
159
+ return loaded;
160
+ };
161
+ }
162
+ /**
163
+ * The `$import` entries of what a loader loaded, taken from where cosmiconfig takes them: the
164
+ * `markuplint` field of a package file (`package.json`, `package.yaml` and the like, whatever the
165
+ * extension), or the whole of any other file.
166
+ *
167
+ * @param filepath - The file the loader was given
168
+ * @param loaded - What the loader returned
169
+ * @returns The entries, of whatever type; empty when there is no `$import`
170
+ */
171
+ function importsOf(filepath, loaded) {
172
+ const own = path.basename(filepath, path.extname(filepath)) === 'package'
173
+ ? loaded && typeof loaded === 'object' && 'markuplint' in loaded
174
+ ? loaded.markuplint
175
+ : undefined
176
+ : loaded;
177
+ if (!own || typeof own !== 'object' || !('$import' in own)) {
178
+ return [];
179
+ }
180
+ return Array.isArray(own.$import) ? own.$import : [own.$import];
181
+ }
182
+ /**
183
+ * Fails when `target` cannot be read; see {@link withImportCheck}.
184
+ *
185
+ * @param target - The absolute path of a `$import` target
186
+ * @param importPath - The entry as written, for the message
187
+ */
188
+ async function probeImport(target, importPath) {
189
+ let handle;
190
+ try {
191
+ handle = await fs.open(target, 'r');
192
+ await handle.read(Buffer.alloc(1), 0, 1, 0);
193
+ }
194
+ catch (error) {
195
+ if (isFatalError(error)) {
196
+ throw error;
197
+ }
198
+ const cannotImport = new Error(`Cannot import ${importPath}: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
199
+ unreadableImports.set(cannotImport, target);
200
+ throw cannotImport;
201
+ }
202
+ finally {
203
+ await handle?.close();
204
+ }
205
+ }
19
206
  const explorer = cosmiconfig('markuplint', {
20
207
  searchPlaces: [
21
208
  'package.json',
@@ -35,10 +222,15 @@ const explorer = cosmiconfig('markuplint', {
35
222
  'markuplint.config.jsonc',
36
223
  ],
37
224
  loaders: {
38
- noExt: jsoncLoader,
39
- '.jsonc': jsoncLoader,
225
+ noExt: withImportCheck(jsoncLoader),
226
+ '.jsonc': withImportCheck(jsoncLoader),
227
+ '.json': withImportCheck(defaultLoaders['.json']),
228
+ '.yaml': withImportCheck(defaultLoaders['.yaml']),
229
+ '.yml': withImportCheck(defaultLoaders['.yml']),
230
+ ...Object.fromEntries(['.js', '.mjs', '.cjs', '.ts', '.mts', '.cts'].map(ext => [ext, withImportCheck(jsLoader)])),
40
231
  },
41
- searchStrategy: 'project',
232
+ // Not 'project', which stops at the first `package.json`; `search()` walks the ancestors itself.
233
+ searchStrategy: 'none',
42
234
  });
43
235
  /**
44
236
  * Clears the shared `cosmiconfig` explorer's own internal search/load caches.
@@ -51,24 +243,99 @@ const explorer = cosmiconfig('markuplint', {
51
243
  export function clearExplorerCache() {
52
244
  explorer.clearCaches();
53
245
  }
246
+ /**
247
+ * Spelled out because `search()` needs the ancestors one directory at a time; see there for why
248
+ * none of cosmiconfig's own search strategies fits.
249
+ *
250
+ * @param dir - The directory to start from, which is yielded first
251
+ */
252
+ function* ancestorDirs(dir) {
253
+ let current = path.resolve(dir);
254
+ while (true) {
255
+ yield current;
256
+ const parent = path.dirname(current);
257
+ if (parent === current) {
258
+ return;
259
+ }
260
+ current = parent;
261
+ }
262
+ }
263
+ /**
264
+ * Whether `dir` does not exist: ENOENT, or ENOTDIR when one of its ancestors is a regular file
265
+ * (POSIX; Windows reports that as ENOENT). Checked before cosmiconfig is asked, as its own
266
+ * directory check takes only ENOENT for missing and throws on ENOTDIR, and as a directory it
267
+ * never sees leaves no entry in its search cache: a configuration file created later together
268
+ * with the directory is found without `clearExplorerCache()`.
269
+ *
270
+ * Any other `stat` failure (EACCES, for one) is reported like a configuration file that cannot be
271
+ * loaded, as the walk cannot tell whether that directory holds the nearest configuration.
272
+ *
273
+ * @param dir - The directory to examine
274
+ * @param filePath - The target file, named in the error report
275
+ */
276
+ async function isMissingDir(dir, filePath) {
277
+ try {
278
+ await fs.stat(dir);
279
+ return false;
280
+ }
281
+ catch (error) {
282
+ if (isFatalError(error)) {
283
+ throw error;
284
+ }
285
+ if (error instanceof Error && 'code' in error && (error.code === 'ENOENT' || error.code === 'ENOTDIR')) {
286
+ return true;
287
+ }
288
+ return cacheConfigError(dir, filePath, true)(error);
289
+ }
290
+ }
291
+ /**
292
+ * Searches for a configuration file in the directory of `filePath` and in each ancestor
293
+ * directory up to the file system root, nearest first.
294
+ *
295
+ * The walk is done here and not by cosmiconfig's `searchStrategy: 'project'`: that strategy
296
+ * stops at the first directory that has a `package.json`, which hides a configuration file at a
297
+ * monorepo root from every file inside a package, while the documented behavior is that the
298
+ * search goes upward from the target file's directory (https://markuplint.dev/docs/configuration).
299
+ * `searchStrategy: 'global'` is no substitute: it also searches the OS-level configuration
300
+ * directory, and that cannot be turned off.
301
+ *
302
+ * A directory that has a `package.json` or `package.yaml` does not end the walk: a `package.json`
303
+ * without a `markuplint` field is not a configuration file, and a `package.yaml` never is, as it
304
+ * is not among the search places. A directory that does not exist, the target's own included,
305
+ * is passed over (see {@link isMissingDir}). An empty configuration is treated like no result
306
+ * and the walk continues. A configuration file that cannot be loaded, at any level, makes the
307
+ * search throw instead of being skipped. There is no upper bound below the file system root, so a
308
+ * configuration file in a directory outside the project applies when nothing nearer exists.
309
+ *
310
+ * @template T - The type of the configuration object
311
+ * @param filePath - The path of the target file; the search starts from its directory
312
+ * @param cacheClear - Whether to clear the explorer's caches before searching
313
+ * @returns The path and content of the nearest configuration file, or `null` if none was found
314
+ */
54
315
  export async function search(filePath, cacheClear) {
55
316
  if (cacheClear) {
56
317
  explorer.clearCaches();
57
318
  }
58
- const dir = path.dirname(filePath);
59
- searchLog('Search dir: %s', dir);
60
- const result = await explorer.search(dir).catch(cacheConfigError(dir, filePath));
61
- searchLog('Search result: %O', result);
62
- if (!result || result.isEmpty) {
63
- return null;
319
+ for (const dir of ancestorDirs(path.dirname(filePath))) {
320
+ searchLog('Search dir: %s', dir);
321
+ if (await isMissingDir(dir, filePath)) {
322
+ searchLog('Missing: %s', dir);
323
+ continue;
324
+ }
325
+ const result = await explorer.search(dir).catch(cacheConfigError(dir, filePath, true));
326
+ searchLog('Search result: %O', result);
327
+ if (!result || result.isEmpty) {
328
+ continue;
329
+ }
330
+ const config = result.config;
331
+ return {
332
+ filePath: result.filepath,
333
+ config: config && typeof config === 'object' && 'default' in config && typeof config.default === 'object'
334
+ ? config.default
335
+ : config,
336
+ };
64
337
  }
65
- const config = result.config;
66
- return {
67
- filePath: result.filepath,
68
- config: config && typeof config === 'object' && 'default' in config && typeof config.default === 'object'
69
- ? config.default
70
- : config,
71
- };
338
+ return null;
72
339
  }
73
340
  export async function load(filePath, cacheClear, referrer) {
74
341
  if (cacheClear) {
@@ -86,19 +353,175 @@ export async function load(filePath, cacheClear, referrer) {
86
353
  : config,
87
354
  };
88
355
  }
89
- function cacheConfigError(fileOrDirPath, referrer) {
90
- return (reason) => {
91
- if (reason instanceof Error) {
92
- switch (reason.name) {
93
- case 'YAMLException': {
94
- throw new ConfigParserError(reason.message, {
95
- // @ts-ignore
96
- filePath: reason.filepath ?? fileOrDirPath,
97
- });
98
- }
99
- }
100
- throw new ConfigLoadError(reason.message, fileOrDirPath, referrer);
356
+ /**
357
+ * Converts a failure of cosmiconfig, or of the `stat` in {@link isMissingDir}, into the error
358
+ * markuplint reports: a `ConfigLoadError`, whatever the format of the file. A file that cannot
359
+ * be parsed is one, YAML included, as `MLEngine` fails the target for it like for any other
360
+ * configuration that cannot be loaded.
361
+ *
362
+ * cosmiconfig sets `filepath` on what a loader throws (a file that cannot be parsed or imported,
363
+ * or whose `$import` target cannot be read, see {@link withImportCheck}; a value the
364
+ * configuration code throws that is not an `Error` is one by then, see {@link jsLoader}). That
365
+ * path, not `fileOrDirPath`, is the one reported: a search walks up through the ancestors of the
366
+ * target, so the directory being searched can be far from the target, and only the path tells
367
+ * the user which configuration file to fix. The message names it as well, as `MLEngine` shows
368
+ * the message alone, unless cosmiconfig's JSON or YAML loader already began it with the path.
369
+ *
370
+ * An error without `filepath` is one cosmiconfig raises outside a loader, or one from a
371
+ * directory that cannot be examined. Of the former, an invalid `$import` (not a string, the file
372
+ * itself, a cycle) names the file that declares it in the message, and is reported with that path
373
+ * (see {@link importErrorPath}); any other is reported with `fileOrDirPath`. Either keeps its own
374
+ * message.
375
+ *
376
+ * When the configuration code throws, the path is followed by the location in that file nearest
377
+ * the throw (see {@link stackLocation}), so that the user can open it at the failing statement.
378
+ * The original error is kept as the `cause`.
379
+ *
380
+ * Only a fatal error that markuplint's own code raised inside a loader ({@link ownErrors}) is
381
+ * re-thrown as it is. Every other error a loader throws comes from the user's configuration code,
382
+ * or from parsing the user's file with `JSON.parse` or js-yaml, so a `SyntaxError` or a
383
+ * `TypeError` there is the configuration's fault, not a broken invariant of markuplint. An error
384
+ * without `filepath` is not guarded either: it comes from cosmiconfig or from the file system,
385
+ * not from markuplint, and fails the target as a configuration that cannot be loaded.
386
+ *
387
+ * What {@link configLoadErrorFiles} returns for the error is recorded here: the file reported,
388
+ * and the `$import` target that could not be read; nothing when a directory is reported.
389
+ *
390
+ * @param fileOrDirPath - The configuration file given to `load()`, or the directory being searched
391
+ * @param referrer - The file whose configuration is being loaded
392
+ * @param isDir - Whether `fileOrDirPath` is a directory
393
+ * @returns A handler for the rejection, which always throws
394
+ */
395
+ function cacheConfigError(fileOrDirPath, referrer, isDir = false) {
396
+ return async (reason) => {
397
+ if (!(reason instanceof Error) || ownErrors.has(reason)) {
398
+ throw reason;
101
399
  }
102
- throw reason;
400
+ const filepath = 'filepath' in reason && typeof reason.filepath === 'string' ? reason.filepath : null;
401
+ if (!filepath) {
402
+ const declaring = importErrorPath(reason.message);
403
+ const error = new ConfigLoadError(reason.message, declaring ?? fileOrDirPath, referrer, { cause: reason });
404
+ filesOfErrors.set(error, declaring == null && isDir ? [] : [error.filePath]);
405
+ throw error;
406
+ }
407
+ // The parser's report on the lines after the path ends with a code frame for YAML; a newline
408
+ // keeps the ` in <referrer>` that `ConfigLoadError` appends off its last line.
409
+ const message = isPrefixedByLoader(reason.message, filepath)
410
+ ? `${reason.message}\n`
411
+ : `Failed to load ${filepath}${await locate(reason, filepath)}: ${reason.message}`;
412
+ const error = new ConfigLoadError(message, filepath, referrer, { cause: reason });
413
+ const unreadable = unreadableImports.get(reason);
414
+ filesOfErrors.set(error, unreadable == null ? [filepath] : [unreadable, filepath]);
415
+ throw error;
103
416
  };
104
417
  }
418
+ /**
419
+ * The files whose change can fix `error`, for a caller to watch or to list among the files a
420
+ * configuration depends on: the file it names and, when that file's `$import` target could not
421
+ * be read, the target, which may not exist yet. Nothing when it names a directory that a search
422
+ * could not examine. For a `ConfigLoadError` raised elsewhere (a preset, a module), what it names.
423
+ *
424
+ * @param error - A `ConfigLoadError`
425
+ * @returns The absolute paths of the files, the `$import` target first; or what `error` names
426
+ */
427
+ export function configLoadErrorFiles(error) {
428
+ return filesOfErrors.get(error) ?? [error.filePath];
429
+ }
430
+ /**
431
+ * Whether `message` begins as cosmiconfig's JSON and YAML loaders begin the message of the error
432
+ * they re-throw (`JSON Error in <path>:` / `YAML Error in <path>:`). Checked as a prefix rather
433
+ * than as any occurrence of the path, which an error that the configuration code throws can
434
+ * contain too.
435
+ *
436
+ * @param message - The message of the error a loader threw
437
+ * @param filepath - The configuration file the loader was given
438
+ * @returns `true` when the message already names the file
439
+ */
440
+ function isPrefixedByLoader(message, filepath) {
441
+ return message.startsWith(`JSON Error in ${filepath}:`) || message.startsWith(`YAML Error in ${filepath}:`);
442
+ }
443
+ /**
444
+ * The file that declares an invalid `$import`, read from the message of the error that
445
+ * cosmiconfig's `validateImports` throws without `filepath`: `Self-import detected in <path>`,
446
+ * `<path>: Key $import must contain a string or a list of strings`, or `Circular import
447
+ * detected:` followed by the numbered files of the cycle, the last of which repeats an earlier
448
+ * one, so the one before the last declares the `$import` that closes it. When cosmiconfig is
449
+ * upgraded, compare these with its `ExplorerBase.validateImports`; a message that matches none
450
+ * leaves the error reported with the directory or file it was loaded for. The specs of these
451
+ * cases in `config-provider.spec.ts` run against the installed cosmiconfig, so a rewording fails
452
+ * them.
453
+ *
454
+ * @param message - The message of an error without `filepath`
455
+ * @returns The path, or `null` when the message is none of these
456
+ */
457
+ function importErrorPath(message) {
458
+ const self = /^Self-import detected in (.+)$/.exec(message);
459
+ if (self) {
460
+ return self[1];
461
+ }
462
+ const notString = /^(.+): Key \$import must contain a string or a list of strings$/.exec(message);
463
+ if (notString) {
464
+ return notString[1];
465
+ }
466
+ if (message.startsWith('Circular import detected:\n')) {
467
+ const entries = message.split('\n').slice(1);
468
+ return /^\d+\. (.+)$/.exec(entries.at(-2) ?? '')?.[1] ?? null;
469
+ }
470
+ return null;
471
+ }
472
+ /**
473
+ * The location in `filepath` at which `error` was thrown, for {@link cacheConfigError}.
474
+ *
475
+ * cosmiconfig imports the real path of a JavaScript configuration file, so its frames name the
476
+ * real path when `filepath` goes through a symbolic link; both are looked for.
477
+ *
478
+ * Reading `stack` can throw in Deno when source map resolution meets an invalid mapping (see
479
+ * `MLEngine`'s `#exec`), and `realpath` can fail if the file has gone since it was loaded. Either
480
+ * failure leaves the error named by its path alone; the error being reported is the one that
481
+ * matters, so neither is re-thrown and neither is guarded by {@link isFatalError}. Only those two
482
+ * are caught: a failure of {@link stackLocation} is markuplint's own.
483
+ *
484
+ * @param error - The error a loader threw
485
+ * @param filepath - The configuration file the loader was given
486
+ * @returns What {@link stackLocation} returns
487
+ */
488
+ async function locate(error, filepath) {
489
+ let stack;
490
+ try {
491
+ stack = error.stack ?? '';
492
+ }
493
+ catch (stackError) {
494
+ searchLog('Cannot read the stack of the error in %s: %O', filepath, stackError);
495
+ return '';
496
+ }
497
+ // The message heads the stack, and can name the file with a line of its own.
498
+ const header = `${error.name}: ${error.message}`;
499
+ const frames = stack.startsWith(header) ? stack.slice(header.length) : stack;
500
+ const realpath = await fs.realpath(filepath).catch(() => filepath);
501
+ return stackLocation(frames, [filepath, realpath]);
502
+ }
503
+ /**
504
+ * Finds the location nearest the top of `stack` that names one of `filePaths`, by its path (a
505
+ * CommonJS module) or by its `file:` URL (an ES module): a frame, `<path>:<line>:<column>`, or
506
+ * the header that Node.js puts above a `SyntaxError` in a CommonJS module, `<path>:<line>`. A
507
+ * `SyntaxError` in an ES module has neither. A Windows path is also looked for with forward
508
+ * slashes (`C:/p/…`), as a module runner such as Vitest names the module it evaluates.
509
+ *
510
+ * A path only matches as a whole, at the start of a line or after whitespace or `(`, so that
511
+ * another file whose path ends with the same characters does not.
512
+ *
513
+ * @param stack - The `stack` of an error
514
+ * @param filePaths - Absolute paths of the same file
515
+ * @returns `:<line>:<column>`, `:<line>` for a header, or an empty string when there is neither
516
+ */
517
+ export function stackLocation(stack, filePaths) {
518
+ const forms = [
519
+ ...new Set(filePaths.flatMap(filePath => [filePath, filePath.replaceAll('\\', '/'), pathToFileURL(filePath).href])),
520
+ ];
521
+ const location = new RegExp(String.raw `(?:^|[\s(])(?:${forms.map(form => RegExp.escape(form)).join('|')}):(\d+)(?::(\d+))?`, 'm');
522
+ const match = location.exec(stack);
523
+ if (!match) {
524
+ return '';
525
+ }
526
+ return match[2] == null ? `:${match[1]}` : `:${match[1]}:${match[2]}`;
527
+ }
package/lib/index.d.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  export type { MLFile } from './ml-file/index.js';
2
2
  export * from './config-provider.js';
3
+ export { configLoadErrorFiles } from './cosmiconfig.js';
3
4
  export * from './resolve-files.js';
4
5
  export * from './resolve-parser.js';
5
6
  export * from './resolve-pretenders.js';
package/lib/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './config-provider.js';
2
+ export { configLoadErrorFiles } from './cosmiconfig.js';
2
3
  export * from './resolve-files.js';
3
4
  export * from './resolve-parser.js';
4
5
  export * from './resolve-pretenders.js';
@@ -12,6 +12,10 @@ export declare class MLFile {
12
12
  */
13
13
  get nPath(): string;
14
14
  get path(): string;
15
+ /**
16
+ * Whether the file's directory exists. Only ENOENT counts as missing; any other `stat` failure
17
+ * throws. Kept as part of the public `MLFile` type, though nothing in this repository calls it.
18
+ */
15
19
  dirExists(): Promise<boolean>;
16
20
  getCode(): Promise<string>;
17
21
  ignored(globPath: string | readonly string[]): boolean;
@@ -51,6 +51,10 @@ export class MLFile {
51
51
  get path() {
52
52
  return path.resolve(this.#dirname, this.#basename);
53
53
  }
54
+ /**
55
+ * Whether the file's directory exists. Only ENOENT counts as missing; any other `stat` failure
56
+ * throws. Kept as part of the public `MLFile` type, though nothing in this repository calls it.
57
+ */
54
58
  async dirExists() {
55
59
  return !!(await stat(this.#dirname));
56
60
  }
@@ -1,11 +1,13 @@
1
1
  import path from 'node:path';
2
- import { rebasePretenderFilePath } from '@markuplint/ml-config';
2
+ import { rebaseHandWrittenPretenderFilePath, rebasePretenderFilePath } from '@markuplint/ml-config';
3
3
  import { glob } from 'glob';
4
4
  import { generalImport } from './general-import.js';
5
+ import { normalizeForGlob } from './path-utils.js';
5
6
  /**
6
- * `Pretender.filePath` is written by scanners relative to their own base
7
- * directory, which is meaningless once entries from files/scan results
8
- * scattered across different directories are merged into one flat list.
7
+ * `Pretender.filePath` is written by scanners (and by hand, in `data`) relative to
8
+ * their own base directory, which is meaningless once entries from
9
+ * files/scan results/configurations scattered across different directories are
10
+ * merged into one flat list.
9
11
  * Rebasing to an absolute path immediately after each source is read is
10
12
  * what lets {@link disambiguatePretendersForFile} later compare a
11
13
  * pretender's origin file against the lint target's resolved imports.
@@ -108,13 +110,21 @@ async function resolveTargetIndependentPretenders(config) {
108
110
  }
109
111
  }
110
112
  if (config.data) {
111
- data.push(...config.data);
113
+ // A configuration file's `data` is already absolute (resolved by the config provider
114
+ // from the file's own directory). What is relative here comes from a configuration with
115
+ // no file behind it (`MLEngine`'s `config` / `defaultConfig` option), which has no
116
+ // directory of its own, so it is read from `process.cwd()` — as its `scan` globs are.
117
+ const cwd = process.cwd();
118
+ data.push(...config.data.map(pretender => rebaseHandWrittenPretenderFilePath(pretender, p => path.resolve(cwd, p))));
112
119
  }
113
120
  if (config.scan) {
114
121
  const { scan } = await import('@markuplint/pretenders');
115
122
  for (const entry of config.scan) {
116
123
  const patterns = typeof entry.files === 'string' ? [entry.files] : [...entry.files];
117
- const globResults = await Promise.all(patterns.map(p => glob(p)));
124
+ // The patterns are absolute paths of the platform (`#pathResolve`), which Windows writes
125
+ // with backslashes, and a glob reads one as an escape unless it is a slash. Only there:
126
+ // elsewhere a backslash is the escape a pattern may mean (`\[id\]`, a directory of brackets).
127
+ const globResults = await Promise.all(patterns.map(p => glob(process.platform === 'win32' ? normalizeForGlob(p) : p)));
118
128
  const resolved = globResults.flat().map(f => path.resolve(f));
119
129
  if (resolved.length > 0) {
120
130
  const scanned = await scan(resolved, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@markuplint/file-resolver",
3
- "version": "5.3.0",
3
+ "version": "5.4.0",
4
4
  "description": "The file resolver of markuplint",
5
5
  "repository": {
6
6
  "type": "git",
@@ -34,15 +34,15 @@
34
34
  "@types/node": "24.19.2"
35
35
  },
36
36
  "dependencies": {
37
- "@markuplint/html-parser": "5.3.0",
37
+ "@markuplint/html-parser": "5.4.0",
38
38
  "@markuplint/ml-ast": "5.3.0",
39
- "@markuplint/ml-config": "5.3.0",
40
- "@markuplint/ml-core": "5.3.0",
41
- "@markuplint/ml-spec": "5.3.0",
42
- "@markuplint/parser-utils": "5.3.0",
43
- "@markuplint/pretenders": "5.3.0",
44
- "@markuplint/selector": "5.3.0",
45
- "@markuplint/shared": "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",
46
46
  "cosmiconfig": "^10.0.1",
47
47
  "debug": "^4.4.3",
48
48
  "glob": "^13.0.6",
@@ -51,5 +51,5 @@
51
51
  "jsonc": "^3.0.0",
52
52
  "minimatch": "^10.2.6"
53
53
  },
54
- "gitHead": "f587cf0206c8426e57369a48d6d5fb22306eafde"
54
+ "gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
55
55
  }