@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 +12 -0
- package/lib/config-provider.d.ts +6 -1
- package/lib/config-provider.js +108 -29
- package/lib/cosmiconfig.d.ts +49 -0
- package/lib/cosmiconfig.js +455 -32
- package/lib/index.d.ts +1 -0
- package/lib/index.js +1 -0
- package/lib/ml-file/ml-file.d.ts +4 -0
- package/lib/ml-file/ml-file.js +4 -0
- package/lib/resolve-pretenders.js +16 -6
- package/package.json +10 -10
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
|
package/lib/config-provider.d.ts
CHANGED
|
@@ -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
|
package/lib/config-provider.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
388
|
-
|
|
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);
|
package/lib/cosmiconfig.d.ts
CHANGED
|
@@ -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 {};
|
package/lib/cosmiconfig.js
CHANGED
|
@@ -1,10 +1,27 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
1
2
|
import path from 'node:path';
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
-
|
|
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
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
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
package/lib/index.js
CHANGED
package/lib/ml-file/ml-file.d.ts
CHANGED
|
@@ -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;
|
package/lib/ml-file/ml-file.js
CHANGED
|
@@ -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
|
|
7
|
-
* directory, which is meaningless once entries from
|
|
8
|
-
* scattered across different directories are
|
|
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
|
|
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
|
-
|
|
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
|
+
"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.
|
|
37
|
+
"@markuplint/html-parser": "5.4.0",
|
|
38
38
|
"@markuplint/ml-ast": "5.3.0",
|
|
39
|
-
"@markuplint/ml-config": "5.
|
|
40
|
-
"@markuplint/ml-core": "5.
|
|
41
|
-
"@markuplint/ml-spec": "5.
|
|
42
|
-
"@markuplint/parser-utils": "5.
|
|
43
|
-
"@markuplint/pretenders": "5.
|
|
44
|
-
"@markuplint/selector": "5.
|
|
45
|
-
"@markuplint/shared": "5.
|
|
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": "
|
|
54
|
+
"gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
|
|
55
55
|
}
|