@markuplint/file-resolver 5.3.0 → 5.5.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 +24 -0
- package/lib/config-provider.d.ts +26 -4
- package/lib/config-provider.js +155 -54
- package/lib/cosmiconfig.d.ts +49 -0
- package/lib/cosmiconfig.js +455 -32
- package/lib/general-import.d.ts +18 -0
- package/lib/general-import.js +31 -49
- package/lib/import-module.d.ts +28 -0
- package/lib/import-module.js +64 -0
- 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/module-exists.d.ts +22 -0
- package/lib/module-exists.js +28 -10
- package/lib/path-to-abs-or-name.d.ts +18 -0
- package/lib/path-to-abs-or-name.js +18 -0
- package/lib/path-utils.d.ts +9 -0
- package/lib/path-utils.js +11 -0
- package/lib/resolve-name-or-abs-path.d.ts +18 -0
- package/lib/resolve-name-or-abs-path.js +24 -5
- package/lib/resolve-pretenders.d.ts +12 -2
- package/lib/resolve-pretenders.js +73 -13
- package/package.json +22 -14
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/general-import.d.ts
CHANGED
|
@@ -1 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Imports the module that a configuration names (a configuration to `extends`, a parser, a
|
|
3
|
+
* plugin, the data of `pretenders`) and returns what it exports: the default export when there is
|
|
4
|
+
* one, the namespace otherwise. Whatever cannot be imported, for whatever reason, yields `null`,
|
|
5
|
+
* as the callers report a missing module in their own terms; the reason goes to the debug log.
|
|
6
|
+
*
|
|
7
|
+
* A subpath of a package that its `exports` map does not expose is turned into the absolute path
|
|
8
|
+
* of the file first (see {@link resolvePackageSubpath}), as `import()` refuses it. A JSON module
|
|
9
|
+
* is taken by {@link importModule} through `require()`, so no `.json` file is read by hand: what
|
|
10
|
+
* `require()` takes as JSON, a byte order mark included, is what is accepted.
|
|
11
|
+
*
|
|
12
|
+
* The result is cached by specifier for the life of the process, `null` included; `import()`
|
|
13
|
+
* itself keeps a module for as long, so a fresh read is not to be had either way.
|
|
14
|
+
*
|
|
15
|
+
* @template T - What the module exports
|
|
16
|
+
* @param name - A bare specifier, with a subpath or not, or an absolute path
|
|
17
|
+
* @returns What the module exports, or `null` when it cannot be imported
|
|
18
|
+
*/
|
|
1
19
|
export declare function generalImport<T>(name: string): Promise<T | null>;
|
package/lib/general-import.js
CHANGED
|
@@ -1,15 +1,33 @@
|
|
|
1
|
-
import fs from 'node:fs/promises';
|
|
2
1
|
import { createRequire } from 'node:module';
|
|
3
2
|
import path from 'node:path';
|
|
4
3
|
import { isFatalError } from '@markuplint/shared';
|
|
5
4
|
import { resolve } from 'import-meta-resolve';
|
|
6
5
|
import { log } from './debug.js';
|
|
7
|
-
import {
|
|
6
|
+
import { importModule } from './import-module.js';
|
|
7
|
+
import { fromFileURL, isRelativePath, toFileURL } from './path-utils.js';
|
|
8
8
|
const gLog = log.extend('general-import');
|
|
9
9
|
const gLogSuccess = gLog.extend('success');
|
|
10
10
|
const gLogError = gLog.extend('error');
|
|
11
11
|
const cache = new Map();
|
|
12
12
|
const require = createRequire(import.meta.url);
|
|
13
|
+
/**
|
|
14
|
+
* Imports the module that a configuration names (a configuration to `extends`, a parser, a
|
|
15
|
+
* plugin, the data of `pretenders`) and returns what it exports: the default export when there is
|
|
16
|
+
* one, the namespace otherwise. Whatever cannot be imported, for whatever reason, yields `null`,
|
|
17
|
+
* as the callers report a missing module in their own terms; the reason goes to the debug log.
|
|
18
|
+
*
|
|
19
|
+
* A subpath of a package that its `exports` map does not expose is turned into the absolute path
|
|
20
|
+
* of the file first (see {@link resolvePackageSubpath}), as `import()` refuses it. A JSON module
|
|
21
|
+
* is taken by {@link importModule} through `require()`, so no `.json` file is read by hand: what
|
|
22
|
+
* `require()` takes as JSON, a byte order mark included, is what is accepted.
|
|
23
|
+
*
|
|
24
|
+
* The result is cached by specifier for the life of the process, `null` included; `import()`
|
|
25
|
+
* itself keeps a module for as long, so a fresh read is not to be had either way.
|
|
26
|
+
*
|
|
27
|
+
* @template T - What the module exports
|
|
28
|
+
* @param name - A bare specifier, with a subpath or not, or an absolute path
|
|
29
|
+
* @returns What the module exports, or `null` when it cannot be imported
|
|
30
|
+
*/
|
|
13
31
|
export async function generalImport(name) {
|
|
14
32
|
if (cache.has(name)) {
|
|
15
33
|
return cache.get(name);
|
|
@@ -33,45 +51,21 @@ export async function generalImport(name) {
|
|
|
33
51
|
if (importPath !== name) {
|
|
34
52
|
gLog('Converted to file URL: %s', importPath);
|
|
35
53
|
}
|
|
36
|
-
const imported = await
|
|
37
|
-
|
|
54
|
+
const imported = await importModule(importPath);
|
|
55
|
+
// A JSON module whose value is `null` is `null`, as a file with nothing in it; `??` would
|
|
56
|
+
// hand the namespace over instead.
|
|
57
|
+
const mod = (('default' in imported ? imported.default : imported) ?? null);
|
|
38
58
|
gLogSuccess('Success by import("%s"): %O', importPath, mod);
|
|
39
59
|
cache.set(name, mod);
|
|
40
60
|
return mod;
|
|
41
61
|
}
|
|
42
62
|
catch (error) {
|
|
43
|
-
//
|
|
44
|
-
//
|
|
45
|
-
//
|
|
46
|
-
//
|
|
47
|
-
//
|
|
48
|
-
//
|
|
49
|
-
// `@markuplint/shared`) only covers errors raised by markuplint's
|
|
50
|
-
// own code, which excludes third-party import failures (e.g. Node 22+ removing
|
|
51
|
-
// import assertion syntax, or bun's stricter ESM parser). Treating
|
|
52
|
-
// every error as a recoverable null-return is the correct policy
|
|
53
|
-
// here; callers (config / parser / plugin loaders) decide how to
|
|
54
|
-
// surface the missing module.
|
|
55
|
-
if (
|
|
56
|
-
// @ts-ignore
|
|
57
|
-
'code' in error &&
|
|
58
|
-
// @ts-ignore
|
|
59
|
-
error.code === 'ERR_IMPORT_ASSERTION_TYPE_MISSING') {
|
|
60
|
-
try {
|
|
61
|
-
const mod = require(name) ?? null;
|
|
62
|
-
gLogSuccess('Success by require("%s"): %O', name, mod);
|
|
63
|
-
cache.set(name, mod);
|
|
64
|
-
return mod;
|
|
65
|
-
}
|
|
66
|
-
catch (error) {
|
|
67
|
-
if (error instanceof Error && /^parse failure/i.test(error.message)) {
|
|
68
|
-
gLogError('Error in `createRequire(import.meta.url)()`: %O', error);
|
|
69
|
-
cache.set(name, null);
|
|
70
|
-
return null;
|
|
71
|
-
}
|
|
72
|
-
gLogError('Error in generalImport: %O', error);
|
|
73
|
-
}
|
|
74
|
-
}
|
|
63
|
+
// `isFatalError()` is intentionally NOT applied at this catch: `importModule()` runs
|
|
64
|
+
// `import()`, which evaluates third-party module code, so a Tier-1-shaped error here
|
|
65
|
+
// (TypeError / SyntaxError / etc.) may come from the imported module or from the runtime's
|
|
66
|
+
// loader rather than from markuplint's own code, and the two cannot be told apart. Every
|
|
67
|
+
// error is a recoverable `null`; the callers (config / parser / plugin loaders) decide how
|
|
68
|
+
// to surface the missing module.
|
|
75
69
|
if (error instanceof Error) {
|
|
76
70
|
const { filePath, packageName } = /Missing\s"(?<filePath>[^"]+)"\sspecifier\sin\s"(?<packageName>[^"]+)"\spackage/.exec(error.message)
|
|
77
71
|
?.groups ?? {};
|
|
@@ -86,18 +80,6 @@ export async function generalImport(name) {
|
|
|
86
80
|
return result;
|
|
87
81
|
}
|
|
88
82
|
}
|
|
89
|
-
if (path.isAbsolute(name) && name.endsWith('.json')) {
|
|
90
|
-
try {
|
|
91
|
-
const file = await fs.readFile(name, 'utf8');
|
|
92
|
-
const json = JSON.parse(file);
|
|
93
|
-
gLogSuccess('Success by readFile("%s") and JSON.parse: %O', name, json);
|
|
94
|
-
cache.set(name, json);
|
|
95
|
-
return json;
|
|
96
|
-
}
|
|
97
|
-
catch (readError) {
|
|
98
|
-
gLogError('Error in readFile("%s"): %O', name, readError);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
83
|
gLogError('Error in `import()`: %O', error);
|
|
102
84
|
cache.set(name, null);
|
|
103
85
|
return null;
|
|
@@ -111,7 +93,7 @@ export async function generalImport(name) {
|
|
|
111
93
|
* - The package cannot be found at all
|
|
112
94
|
*/
|
|
113
95
|
function resolvePackageSubpath(name) {
|
|
114
|
-
if (path.isAbsolute(name) || name
|
|
96
|
+
if (path.isAbsolute(name) || isRelativePath(name)) {
|
|
115
97
|
return null;
|
|
116
98
|
}
|
|
117
99
|
const packageName = name.startsWith('@') ? name.split('/').slice(0, 2).join('/') : name.split('/')[0];
|