@markuplint/file-resolver 5.0.0-rc.4 → 5.0.0-rc.6
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 +36 -0
- package/lib/config-provider.d.ts +78 -2
- package/lib/config-provider.js +162 -41
- package/lib/cosmiconfig.d.ts +9 -0
- package/lib/cosmiconfig.js +11 -0
- package/lib/general-import.js +21 -16
- package/lib/path-utils.d.ts +27 -13
- package/lib/path-utils.js +45 -13
- package/lib/resolve-pretenders.d.ts +58 -2
- package/lib/resolve-pretenders.js +117 -4
- package/package.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,42 @@
|
|
|
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.0.0-rc.6](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.5...v5.0.0-rc.6) (2026-08-30)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- resolveConfig(false) crashes with inline config (not a file path) ([#4018](https://github.com/markuplint/markuplint/issues/4018)) ([7e38b64](https://github.com/markuplint/markuplint/commit/7e38b64caa8cca69009ee765e4aada37fc48c559)), closes [#4015](https://github.com/markuplint/markuplint/issues/4015) [#4015](https://github.com/markuplint/markuplint/issues/4015)
|
|
11
|
+
|
|
12
|
+
### Performance Improvements
|
|
13
|
+
|
|
14
|
+
- share ConfigProvider across a run's files, fix latent overrides caching bug ([#4016](https://github.com/markuplint/markuplint/issues/4016)) ([fcc1875](https://github.com/markuplint/markuplint/commit/fcc1875b1a984a5ef1bb36aa04e7b3522fefc58e)), closes [#3997](https://github.com/markuplint/markuplint/issues/3997) [#3997](https://github.com/markuplint/markuplint/issues/3997)
|
|
15
|
+
|
|
16
|
+
### BREAKING CHANGES
|
|
17
|
+
|
|
18
|
+
- `ConfigProvider#resolve(targetFile, names, false)` no longer
|
|
19
|
+
clears the provider's store/cache/plugin-resolution caches by itself. Callers
|
|
20
|
+
that relied on `cache: false` alone to force a fresh re-read must now call
|
|
21
|
+
the new `ConfigProvider#invalidate()` first.
|
|
22
|
+
|
|
23
|
+
# [5.0.0-rc.5](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.4...v5.0.0-rc.5) (2026-08-28)
|
|
24
|
+
|
|
25
|
+
### Bug Fixes
|
|
26
|
+
|
|
27
|
+
- **file-resolver:** make generalImport() OS-independent for POSIX absolute paths ([23aa492](https://github.com/markuplint/markuplint/commit/23aa492202a4b305022651210d9fc413e9baef2d)), closes [#3841](https://github.com/markuplint/markuplint/issues/3841) [#3843](https://github.com/markuplint/markuplint/issues/3843) [#3840](https://github.com/markuplint/markuplint/issues/3840)
|
|
28
|
+
- **pretenders:** resolve same-named components via imports, not scan order ([#3957](https://github.com/markuplint/markuplint/issues/3957)) ([d46a514](https://github.com/markuplint/markuplint/commit/d46a5148c4d7afb156962f4ed795f40a9324e6c5)), closes [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3951](https://github.com/markuplint/markuplint/issues/3951)
|
|
29
|
+
|
|
30
|
+
### Code Refactoring
|
|
31
|
+
|
|
32
|
+
- **rules:** redesign v5 rule system — naming, splits, specConformance ([#3989](https://github.com/markuplint/markuplint/issues/3989)) ([e925565](https://github.com/markuplint/markuplint/commit/e925565ce537848d7d1573369723cbce724a841b)), closes [#4](https://github.com/markuplint/markuplint/issues/4) [#aside-conditional-role-mapping-aria-13](https://github.com/markuplint/markuplint/issues/aside-conditional-role-mapping-aria-13)
|
|
33
|
+
|
|
34
|
+
### Features
|
|
35
|
+
|
|
36
|
+
- add `pretenders.auto` for on-demand import-graph resolution ([#3962](https://github.com/markuplint/markuplint/issues/3962)) ([5870671](https://github.com/markuplint/markuplint/commit/58706711a20c12cff080d49359f3f6443345eca3)), closes [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3959](https://github.com/markuplint/markuplint/issues/3959) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3957](https://github.com/markuplint/markuplint/issues/3957) [#3959](https://github.com/markuplint/markuplint/issues/3959) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3951](https://github.com/markuplint/markuplint/issues/3951)
|
|
37
|
+
|
|
38
|
+
### BREAKING CHANGES
|
|
39
|
+
|
|
40
|
+
- **rules:** with no alias coverage.
|
|
41
|
+
|
|
6
42
|
# [5.0.0-rc.4](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.3...v5.0.0-rc.4) (2026-04-19)
|
|
7
43
|
|
|
8
44
|
**Note:** Version bump only for package @markuplint/file-resolver
|
package/lib/config-provider.d.ts
CHANGED
|
@@ -7,9 +7,52 @@ import type { Nullable } from '@markuplint/shared';
|
|
|
7
7
|
*
|
|
8
8
|
* Handles `extends` chains, plugins, presets, overrides, and circular reference detection.
|
|
9
9
|
* Configuration files are searched via cosmiconfig and cached by file path.
|
|
10
|
+
*
|
|
11
|
+
* Designed to be shared across every target file in a run (see
|
|
12
|
+
* `MLEngineOptions.configProvider` in `packages/markuplint/src/api/ml-engine.ts`):
|
|
13
|
+
* {@link resolve}'s cache is keyed by the resolved config's `names`, not by
|
|
14
|
+
* target file, so one instance reused across many files avoids redoing
|
|
15
|
+
* merge/validate/plugin-resolution once per file that shares the same
|
|
16
|
+
* config — see #3997.
|
|
10
17
|
*/
|
|
11
18
|
export declare class ConfigProvider {
|
|
12
19
|
#private;
|
|
20
|
+
/**
|
|
21
|
+
* Clears every cached and stored config entry: the base-config cache
|
|
22
|
+
* (`#cache`), the loaded/registered config store (`#store`), `set()`'s
|
|
23
|
+
* identity→key stabilization (`#autoKeys`), `resolve-plugins.ts`'s own
|
|
24
|
+
* module-level plugin-resolution cache, and the shared `cosmiconfig`
|
|
25
|
+
* explorer's own search/load caches (so {@link search}, called right
|
|
26
|
+
* after this, re-reads the current file content instead of a stale
|
|
27
|
+
* cosmiconfig-level cache).
|
|
28
|
+
*
|
|
29
|
+
* The `cosmiconfig` explorer and the `resolve-plugins.ts` cache are
|
|
30
|
+
* module-level singletons shared by every `ConfigProvider` instance in
|
|
31
|
+
* the process — clearing them here affects other instances too, not just
|
|
32
|
+
* this one. Harmless (they just re-search/re-load), but worth knowing
|
|
33
|
+
* when reasoning about a cache-busting re-resolve's blast radius.
|
|
34
|
+
*
|
|
35
|
+
* Callers doing a cache-busting re-resolve (e.g. watch mode after a file
|
|
36
|
+
* change) must call this **before** registering any inline config via
|
|
37
|
+
* {@link set}, and before {@link search}, for that same resolve —
|
|
38
|
+
* `resolve()` itself no longer clears anything, so a `set()`/`search()`
|
|
39
|
+
* call made after `invalidate()` survives through to `resolve()`. Wrap
|
|
40
|
+
* the whole `invalidate()` → `set()`/`search()` → `resolve()` sequence in
|
|
41
|
+
* {@link runExclusive} so an overlapping call on the same instance can't
|
|
42
|
+
* interleave its own `invalidate()` in the middle of it. See #4015.
|
|
43
|
+
*/
|
|
44
|
+
invalidate(): void;
|
|
45
|
+
/**
|
|
46
|
+
* Runs `fn` exclusively with respect to every other `runExclusive` call on
|
|
47
|
+
* this instance: queued calls wait for earlier ones to settle before
|
|
48
|
+
* starting, so two overlapping cache-busting re-resolves (e.g. two
|
|
49
|
+
* watch-triggered `MLEngine#resolveConfig(false)` calls close together,
|
|
50
|
+
* whether from one engine's own provider or several engines sharing one)
|
|
51
|
+
* can't interleave — one call's {@link invalidate} can no longer wipe the
|
|
52
|
+
* `set()`/`search()` entries another call registered a moment earlier but
|
|
53
|
+
* hasn't yet consumed. See #4015.
|
|
54
|
+
*/
|
|
55
|
+
runExclusive<T>(fn: () => Promise<T>): Promise<T>;
|
|
13
56
|
/**
|
|
14
57
|
* Recursively loads a configuration and all its `extends` dependencies.
|
|
15
58
|
*
|
|
@@ -27,9 +70,27 @@ export declare class ConfigProvider {
|
|
|
27
70
|
* Resolves the full configuration for a target file by merging all named configs,
|
|
28
71
|
* resolving plugins, and applying file-specific overrides.
|
|
29
72
|
*
|
|
73
|
+
* Split into a cacheable "base" phase (`#resolveBase`: merge/validate/plugin
|
|
74
|
+
* resolution/plugin-provided `extends`) and a per-call `overrides` phase, because
|
|
75
|
+
* `overrides` matching depends on `targetFile` while everything else in `names`
|
|
76
|
+
* resolution does not. Only the base result is cached (keyed on `names`, not on
|
|
77
|
+
* `targetFile`) — callers sharing one `ConfigProvider` across many target files
|
|
78
|
+
* (see #3997) get that work done once, while `overrides` are always re-evaluated
|
|
79
|
+
* per call so a `.vue`-only override never leaks into a `.html` file's result (or
|
|
80
|
+
* vice versa) just because they resolve the same `names`.
|
|
81
|
+
*
|
|
82
|
+
* Does NOT clear the provider's store/cache itself — call {@link invalidate}
|
|
83
|
+
* first if a fresh re-read is needed (e.g. a watch-triggered re-resolve).
|
|
84
|
+
* `cache` only controls whether an already-loaded `names` entry (this call's
|
|
85
|
+
* base-config cache, and — deeper still — cosmiconfig's own per-file cache in
|
|
86
|
+
* `#load`) is reused; it used to also wipe the store/cache up front, which
|
|
87
|
+
* discarded any `set()` call the caller had just made for this same resolve
|
|
88
|
+
* (e.g. `MLEngine#resolveConfig()` registering inline `config`/`defaultConfig`
|
|
89
|
+
* right before calling this) — see #4015.
|
|
90
|
+
*
|
|
30
91
|
* @param targetFile - The file being linted
|
|
31
92
|
* @param names - Config file paths or module names to merge
|
|
32
|
-
* @param cache - Whether to
|
|
93
|
+
* @param cache - Whether to reuse already-loaded/cached entries
|
|
33
94
|
* @returns The fully resolved configuration set including plugins and errors
|
|
34
95
|
*/
|
|
35
96
|
resolve(targetFile: Readonly<MLFile>, names: readonly Nullable<string>[], cache?: boolean): Promise<ConfigSet>;
|
|
@@ -45,7 +106,22 @@ export declare class ConfigProvider {
|
|
|
45
106
|
*
|
|
46
107
|
* @param config - The optimized configuration to store
|
|
47
108
|
* @param key - An optional key to store the config under; auto-generated if omitted
|
|
109
|
+
* @param identity - Object identity to auto-key on when `key` is omitted (e.g. the
|
|
110
|
+
* caller's original, pre-merge config object). Repeated calls with the same
|
|
111
|
+
* `identity` reuse the same generated key instead of minting a fresh UUID each
|
|
112
|
+
* time, so `resolve()`'s base cache can hit for inline (non-file-path) config
|
|
113
|
+
* shared across multiple target files — see #3997. Falls back to `config` itself
|
|
114
|
+
* (which is rebuilt fresh by every caller today, so this is a no-op unless a
|
|
115
|
+
* caller passes a stable `identity`).
|
|
116
|
+
*
|
|
117
|
+
* **Invariant**: an `identity` must correspond to `config` content that is
|
|
118
|
+
* effectively immutable for as long as that identity is reused — a second
|
|
119
|
+
* call with the same `identity` but *different* `config` content returns
|
|
120
|
+
* the *first* call's key/content, silently discarding the new content. Not
|
|
121
|
+
* reachable via `MLEngine`'s call sites today (they hold `options.config`/
|
|
122
|
+
* `defaultConfig` as one unchanged reference per run); a future caller
|
|
123
|
+
* passing a reused identity for genuinely different content would hit this.
|
|
48
124
|
* @returns The key under which the config was stored
|
|
49
125
|
*/
|
|
50
|
-
set(config: OptimizedConfig, key?: string): string;
|
|
126
|
+
set(config: OptimizedConfig, key?: string, identity?: object): string;
|
|
51
127
|
}
|
package/lib/config-provider.js
CHANGED
|
@@ -3,7 +3,7 @@ import { mergeConfig } 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
|
-
import { load as loadConfig, search } from './cosmiconfig.js';
|
|
6
|
+
import { load as loadConfig, search, clearExplorerCache } from './cosmiconfig.js';
|
|
7
7
|
import { log } from './debug.js';
|
|
8
8
|
import { generalImport } from './general-import.js';
|
|
9
9
|
import { getPreset } from './get-preset.js';
|
|
@@ -20,12 +20,89 @@ const KEY_SEPARATOR = '__ML_CONFIG_MERGE__';
|
|
|
20
20
|
*
|
|
21
21
|
* Handles `extends` chains, plugins, presets, overrides, and circular reference detection.
|
|
22
22
|
* Configuration files are searched via cosmiconfig and cached by file path.
|
|
23
|
+
*
|
|
24
|
+
* Designed to be shared across every target file in a run (see
|
|
25
|
+
* `MLEngineOptions.configProvider` in `packages/markuplint/src/api/ml-engine.ts`):
|
|
26
|
+
* {@link resolve}'s cache is keyed by the resolved config's `names`, not by
|
|
27
|
+
* target file, so one instance reused across many files avoids redoing
|
|
28
|
+
* merge/validate/plugin-resolution once per file that shares the same
|
|
29
|
+
* config — see #3997.
|
|
23
30
|
*/
|
|
24
31
|
export class ConfigProvider {
|
|
25
32
|
#cache = new Map();
|
|
26
33
|
#held = new Set();
|
|
27
34
|
#recursiveLoadKeyAndDepth = new Map();
|
|
28
35
|
#store = new Map();
|
|
36
|
+
/**
|
|
37
|
+
* Stabilizes {@link set}'s auto-generated key for an inline config object
|
|
38
|
+
* across repeated calls with the *same* object reference (e.g. one caller
|
|
39
|
+
* sharing one `ConfigProvider` — and one `options.config`/`defaultConfig`
|
|
40
|
+
* object — across many target files). Without this, `set()` would mint a
|
|
41
|
+
* fresh UUID per call even for identical content, so `resolve()`'s cache
|
|
42
|
+
* (keyed on that UUID) would never hit for inline (non-file-path) config.
|
|
43
|
+
* Keyed on object identity, not content, so it costs nothing to check and
|
|
44
|
+
* needs no hashing of arbitrary config shapes.
|
|
45
|
+
*/
|
|
46
|
+
#autoKeys = new WeakMap();
|
|
47
|
+
/**
|
|
48
|
+
* Serializes {@link runExclusive} calls on this instance.
|
|
49
|
+
*/
|
|
50
|
+
#queue = Promise.resolve();
|
|
51
|
+
/**
|
|
52
|
+
* Clears every cached and stored config entry: the base-config cache
|
|
53
|
+
* (`#cache`), the loaded/registered config store (`#store`), `set()`'s
|
|
54
|
+
* identity→key stabilization (`#autoKeys`), `resolve-plugins.ts`'s own
|
|
55
|
+
* module-level plugin-resolution cache, and the shared `cosmiconfig`
|
|
56
|
+
* explorer's own search/load caches (so {@link search}, called right
|
|
57
|
+
* after this, re-reads the current file content instead of a stale
|
|
58
|
+
* cosmiconfig-level cache).
|
|
59
|
+
*
|
|
60
|
+
* The `cosmiconfig` explorer and the `resolve-plugins.ts` cache are
|
|
61
|
+
* module-level singletons shared by every `ConfigProvider` instance in
|
|
62
|
+
* the process — clearing them here affects other instances too, not just
|
|
63
|
+
* this one. Harmless (they just re-search/re-load), but worth knowing
|
|
64
|
+
* when reasoning about a cache-busting re-resolve's blast radius.
|
|
65
|
+
*
|
|
66
|
+
* Callers doing a cache-busting re-resolve (e.g. watch mode after a file
|
|
67
|
+
* change) must call this **before** registering any inline config via
|
|
68
|
+
* {@link set}, and before {@link search}, for that same resolve —
|
|
69
|
+
* `resolve()` itself no longer clears anything, so a `set()`/`search()`
|
|
70
|
+
* call made after `invalidate()` survives through to `resolve()`. Wrap
|
|
71
|
+
* the whole `invalidate()` → `set()`/`search()` → `resolve()` sequence in
|
|
72
|
+
* {@link runExclusive} so an overlapping call on the same instance can't
|
|
73
|
+
* interleave its own `invalidate()` in the middle of it. See #4015.
|
|
74
|
+
*/
|
|
75
|
+
invalidate() {
|
|
76
|
+
this.#store.clear();
|
|
77
|
+
this.#cache.clear();
|
|
78
|
+
this.#autoKeys = new WeakMap();
|
|
79
|
+
cacheClear();
|
|
80
|
+
clearExplorerCache();
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Runs `fn` exclusively with respect to every other `runExclusive` call on
|
|
84
|
+
* this instance: queued calls wait for earlier ones to settle before
|
|
85
|
+
* starting, so two overlapping cache-busting re-resolves (e.g. two
|
|
86
|
+
* watch-triggered `MLEngine#resolveConfig(false)` calls close together,
|
|
87
|
+
* whether from one engine's own provider or several engines sharing one)
|
|
88
|
+
* can't interleave — one call's {@link invalidate} can no longer wipe the
|
|
89
|
+
* `set()`/`search()` entries another call registered a moment earlier but
|
|
90
|
+
* hasn't yet consumed. See #4015.
|
|
91
|
+
*/
|
|
92
|
+
async runExclusive(fn) {
|
|
93
|
+
const previous = this.#queue;
|
|
94
|
+
let release;
|
|
95
|
+
this.#queue = new Promise(resolve => {
|
|
96
|
+
release = resolve;
|
|
97
|
+
});
|
|
98
|
+
await previous;
|
|
99
|
+
try {
|
|
100
|
+
return await fn();
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
release();
|
|
104
|
+
}
|
|
105
|
+
}
|
|
29
106
|
/**
|
|
30
107
|
* Recursively loads a configuration and all its `extends` dependencies.
|
|
31
108
|
*
|
|
@@ -77,28 +154,50 @@ export class ConfigProvider {
|
|
|
77
154
|
* Resolves the full configuration for a target file by merging all named configs,
|
|
78
155
|
* resolving plugins, and applying file-specific overrides.
|
|
79
156
|
*
|
|
157
|
+
* Split into a cacheable "base" phase (`#resolveBase`: merge/validate/plugin
|
|
158
|
+
* resolution/plugin-provided `extends`) and a per-call `overrides` phase, because
|
|
159
|
+
* `overrides` matching depends on `targetFile` while everything else in `names`
|
|
160
|
+
* resolution does not. Only the base result is cached (keyed on `names`, not on
|
|
161
|
+
* `targetFile`) — callers sharing one `ConfigProvider` across many target files
|
|
162
|
+
* (see #3997) get that work done once, while `overrides` are always re-evaluated
|
|
163
|
+
* per call so a `.vue`-only override never leaks into a `.html` file's result (or
|
|
164
|
+
* vice versa) just because they resolve the same `names`.
|
|
165
|
+
*
|
|
166
|
+
* Does NOT clear the provider's store/cache itself — call {@link invalidate}
|
|
167
|
+
* first if a fresh re-read is needed (e.g. a watch-triggered re-resolve).
|
|
168
|
+
* `cache` only controls whether an already-loaded `names` entry (this call's
|
|
169
|
+
* base-config cache, and — deeper still — cosmiconfig's own per-file cache in
|
|
170
|
+
* `#load`) is reused; it used to also wipe the store/cache up front, which
|
|
171
|
+
* discarded any `set()` call the caller had just made for this same resolve
|
|
172
|
+
* (e.g. `MLEngine#resolveConfig()` registering inline `config`/`defaultConfig`
|
|
173
|
+
* right before calling this) — see #4015.
|
|
174
|
+
*
|
|
80
175
|
* @param targetFile - The file being linted
|
|
81
176
|
* @param names - Config file paths or module names to merge
|
|
82
|
-
* @param cache - Whether to
|
|
177
|
+
* @param cache - Whether to reuse already-loaded/cached entries
|
|
83
178
|
* @returns The fully resolved configuration set including plugins and errors
|
|
84
179
|
*/
|
|
85
180
|
async resolve(targetFile, names, cache = true) {
|
|
86
|
-
if (!cache) {
|
|
87
|
-
this.#store.clear();
|
|
88
|
-
this.#cache.clear();
|
|
89
|
-
cacheClear();
|
|
90
|
-
}
|
|
91
181
|
const keys = names.filter(nonNullableFilter);
|
|
92
182
|
const key = keys.join(KEY_SEPARATOR);
|
|
93
|
-
|
|
94
|
-
if (
|
|
95
|
-
|
|
183
|
+
let baseConfigSet = this.#cache.get(key);
|
|
184
|
+
if (!baseConfigSet) {
|
|
185
|
+
baseConfigSet = await this.#resolveBase(keys, cache, targetFile.path);
|
|
186
|
+
this.#cache.set(key, baseConfigSet);
|
|
96
187
|
}
|
|
97
|
-
|
|
188
|
+
return this.#applyOverrides(baseConfigSet, targetFile);
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The `names`-dependent, `targetFile`-independent part of {@link resolve}:
|
|
192
|
+
* merges all named configs, validates, resolves plugins, and expands
|
|
193
|
+
* plugin-provided `extends`. Safe to cache under a `names`-only key.
|
|
194
|
+
*/
|
|
195
|
+
async #resolveBase(keys, cache, referrer) {
|
|
196
|
+
let configSet = await this.#mergeConfigs(keys, cache, referrer);
|
|
98
197
|
const filePath = [...configSet.files].toReversed()[0];
|
|
99
198
|
if (!filePath) {
|
|
100
199
|
throw new ConfigParserError('Config file not found', {
|
|
101
|
-
filePath:
|
|
200
|
+
filePath: referrer,
|
|
102
201
|
});
|
|
103
202
|
}
|
|
104
203
|
const errors = this.#validateConfig(configSet.config, filePath);
|
|
@@ -120,36 +219,33 @@ export class ConfigProvider {
|
|
|
120
219
|
}
|
|
121
220
|
}
|
|
122
221
|
}
|
|
123
|
-
configSet = await this.#mergeConfigs([...keys, ...extendHelds], cache,
|
|
222
|
+
configSet = await this.#mergeConfigs([...keys, ...extendHelds], cache, referrer);
|
|
124
223
|
this.#held.clear();
|
|
125
224
|
}
|
|
126
|
-
|
|
127
|
-
if (configSet.config.overrides) {
|
|
128
|
-
const overrides = configSet.config.overrides;
|
|
129
|
-
const globs = Object.keys(overrides);
|
|
130
|
-
for (const glob of globs) {
|
|
131
|
-
const isMatched = targetFile.matches(glob);
|
|
132
|
-
const config = overrides[glob];
|
|
133
|
-
if (isMatched && config) {
|
|
134
|
-
switch (configSet.config.overrideMode) {
|
|
135
|
-
case 'merge': {
|
|
136
|
-
configSet.config = mergeConfig(configSet.config, config);
|
|
137
|
-
break;
|
|
138
|
-
}
|
|
139
|
-
default: /* or "reset" */ {
|
|
140
|
-
configSet.config = config;
|
|
141
|
-
break;
|
|
142
|
-
}
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
}
|
|
147
|
-
const result = {
|
|
225
|
+
return {
|
|
148
226
|
...configSet,
|
|
149
227
|
plugins,
|
|
150
228
|
};
|
|
151
|
-
|
|
152
|
-
|
|
229
|
+
}
|
|
230
|
+
/**
|
|
231
|
+
* The `targetFile`-dependent part of {@link resolve}: matches `config.overrides`
|
|
232
|
+
* globs against `targetFile` and applies whichever match, per `overrideMode`.
|
|
233
|
+
* Never mutates `baseConfigSet` — returns it unchanged (same reference) when no
|
|
234
|
+
* override matches, or a shallow copy with a freshly computed `config` otherwise,
|
|
235
|
+
* so the cached base entry stays valid for the next target file.
|
|
236
|
+
*/
|
|
237
|
+
#applyOverrides(baseConfigSet, targetFile) {
|
|
238
|
+
let config = baseConfigSet.config;
|
|
239
|
+
if (config.overrides) {
|
|
240
|
+
const overrides = config.overrides;
|
|
241
|
+
for (const glob of Object.keys(overrides)) {
|
|
242
|
+
const overrideConfig = overrides[glob];
|
|
243
|
+
if (targetFile.matches(glob) && overrideConfig) {
|
|
244
|
+
config = config.overrideMode === 'merge' ? mergeConfig(config, overrideConfig) : overrideConfig;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
return config === baseConfigSet.config ? baseConfigSet : { ...baseConfigSet, config };
|
|
153
249
|
}
|
|
154
250
|
/**
|
|
155
251
|
* Searches for a markuplint configuration file starting from the target file's directory.
|
|
@@ -180,12 +276,37 @@ export class ConfigProvider {
|
|
|
180
276
|
*
|
|
181
277
|
* @param config - The optimized configuration to store
|
|
182
278
|
* @param key - An optional key to store the config under; auto-generated if omitted
|
|
279
|
+
* @param identity - Object identity to auto-key on when `key` is omitted (e.g. the
|
|
280
|
+
* caller's original, pre-merge config object). Repeated calls with the same
|
|
281
|
+
* `identity` reuse the same generated key instead of minting a fresh UUID each
|
|
282
|
+
* time, so `resolve()`'s base cache can hit for inline (non-file-path) config
|
|
283
|
+
* shared across multiple target files — see #3997. Falls back to `config` itself
|
|
284
|
+
* (which is rebuilt fresh by every caller today, so this is a no-op unless a
|
|
285
|
+
* caller passes a stable `identity`).
|
|
286
|
+
*
|
|
287
|
+
* **Invariant**: an `identity` must correspond to `config` content that is
|
|
288
|
+
* effectively immutable for as long as that identity is reused — a second
|
|
289
|
+
* call with the same `identity` but *different* `config` content returns
|
|
290
|
+
* the *first* call's key/content, silently discarding the new content. Not
|
|
291
|
+
* reachable via `MLEngine`'s call sites today (they hold `options.config`/
|
|
292
|
+
* `defaultConfig` as one unchanged reference per run); a future caller
|
|
293
|
+
* passing a reused identity for genuinely different content would hit this.
|
|
183
294
|
* @returns The key under which the config was stored
|
|
184
295
|
*/
|
|
185
|
-
set(config, key) {
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
296
|
+
set(config, key, identity) {
|
|
297
|
+
if (key != null) {
|
|
298
|
+
this.#store.set(key, config);
|
|
299
|
+
return key;
|
|
300
|
+
}
|
|
301
|
+
const identityKey = identity ?? config;
|
|
302
|
+
const existingKey = this.#autoKeys.get(identityKey);
|
|
303
|
+
if (existingKey != null) {
|
|
304
|
+
return existingKey;
|
|
305
|
+
}
|
|
306
|
+
const newKey = uuid();
|
|
307
|
+
this.#store.set(newKey, config);
|
|
308
|
+
this.#autoKeys.set(identityKey, newKey);
|
|
309
|
+
return newKey;
|
|
189
310
|
}
|
|
190
311
|
async #load(filePath, cache, referrer) {
|
|
191
312
|
const entity = this.#store.get(filePath);
|
package/lib/cosmiconfig.d.ts
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
1
|
import type { LoaderSync } from 'cosmiconfig';
|
|
2
2
|
import { ConfigLoadError } from '@markuplint/shared';
|
|
3
3
|
type CosmiConfig = ReturnType<LoaderSync>;
|
|
4
|
+
/**
|
|
5
|
+
* Clears the shared `cosmiconfig` explorer's own internal search/load caches.
|
|
6
|
+
* Distinct from this module's `cacheClear` parameters (which clear the same
|
|
7
|
+
* caches but only as a side effect of one `search`/`load` call) — this lets a
|
|
8
|
+
* caller (see `ConfigProvider#invalidate`) clear them up front, before any
|
|
9
|
+
* `search`/`load` call, so a subsequent `search` reads the current file
|
|
10
|
+
* content instead of the explorer's stale cache. See #4015.
|
|
11
|
+
*/
|
|
12
|
+
export declare function clearExplorerCache(): void;
|
|
4
13
|
export declare function search<T = CosmiConfig>(filePath: string, cacheClear: boolean): Promise<{
|
|
5
14
|
filePath: string;
|
|
6
15
|
config: T;
|
package/lib/cosmiconfig.js
CHANGED
|
@@ -40,6 +40,17 @@ const explorer = cosmiconfig('markuplint', {
|
|
|
40
40
|
},
|
|
41
41
|
searchStrategy: 'project',
|
|
42
42
|
});
|
|
43
|
+
/**
|
|
44
|
+
* Clears the shared `cosmiconfig` explorer's own internal search/load caches.
|
|
45
|
+
* Distinct from this module's `cacheClear` parameters (which clear the same
|
|
46
|
+
* caches but only as a side effect of one `search`/`load` call) — this lets a
|
|
47
|
+
* caller (see `ConfigProvider#invalidate`) clear them up front, before any
|
|
48
|
+
* `search`/`load` call, so a subsequent `search` reads the current file
|
|
49
|
+
* content instead of the explorer's stale cache. See #4015.
|
|
50
|
+
*/
|
|
51
|
+
export function clearExplorerCache() {
|
|
52
|
+
explorer.clearCaches();
|
|
53
|
+
}
|
|
43
54
|
export async function search(filePath, cacheClear) {
|
|
44
55
|
if (cacheClear) {
|
|
45
56
|
explorer.clearCaches();
|
package/lib/general-import.js
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import fs from 'node:fs/promises';
|
|
2
2
|
import { createRequire } from 'node:module';
|
|
3
3
|
import path from 'node:path';
|
|
4
|
-
import { pathToFileURL } from 'node:url';
|
|
5
4
|
import { isFatalError } from '@markuplint/shared';
|
|
6
5
|
import { resolve } from 'import-meta-resolve';
|
|
7
6
|
import { log } from './debug.js';
|
|
8
|
-
import { fromFileURL } from './path-utils.js';
|
|
7
|
+
import { fromFileURL, toFileURL } from './path-utils.js';
|
|
9
8
|
const gLog = log.extend('general-import');
|
|
10
9
|
const gLogSuccess = gLog.extend('success');
|
|
11
10
|
const gLogError = gLog.extend('error');
|
|
@@ -27,11 +26,11 @@ export async function generalImport(name) {
|
|
|
27
26
|
return result;
|
|
28
27
|
}
|
|
29
28
|
try {
|
|
30
|
-
// Convert absolute paths to file:// URL format
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
29
|
+
// Convert absolute paths to file:// URL format. `toFileURL()` is
|
|
30
|
+
// OS-independent (see #3840 — Node's `pathToFileURL()` resolves a
|
|
31
|
+
// POSIX-style absolute path against the current Windows drive).
|
|
32
|
+
const importPath = toFileURL(name);
|
|
33
|
+
if (importPath !== name) {
|
|
35
34
|
gLog('Converted to file URL: %s', importPath);
|
|
36
35
|
}
|
|
37
36
|
const imported = await import(importPath);
|
|
@@ -41,6 +40,18 @@ export async function generalImport(name) {
|
|
|
41
40
|
return mod;
|
|
42
41
|
}
|
|
43
42
|
catch (error) {
|
|
43
|
+
// NOTE: `isFatalError()` is intentionally NOT applied at this catch
|
|
44
|
+
// boundary. `await import()` / `require()` invoke third-party module
|
|
45
|
+
// code, so any Tier-1-shaped error (TypeError / SyntaxError / etc.)
|
|
46
|
+
// at this point may originate from inside the imported module and
|
|
47
|
+
// not from markuplint's own code — we cannot distinguish the two.
|
|
48
|
+
// The Tier 1 classification (see `isFatalError()` in
|
|
49
|
+
// `@markuplint/shared`) only covers errors raised by markuplint's
|
|
50
|
+
// own code, which excludes third-party import failures (e.g. Node 22+ removing
|
|
51
|
+
// import assertion syntax, or bun's stricter ESM parser). Treating
|
|
52
|
+
// every error as a recoverable null-return is the correct policy
|
|
53
|
+
// here; callers (config / parser / plugin loaders) decide how to
|
|
54
|
+
// surface the missing module.
|
|
44
55
|
if (
|
|
45
56
|
// @ts-ignore
|
|
46
57
|
'code' in error &&
|
|
@@ -93,13 +104,10 @@ export async function generalImport(name) {
|
|
|
93
104
|
}
|
|
94
105
|
}
|
|
95
106
|
/**
|
|
96
|
-
*
|
|
97
|
-
*
|
|
98
|
-
* does not include the subpath.
|
|
99
|
-
*
|
|
100
|
-
* Returns the absolute path if resolution succeeds, or `null` if:
|
|
107
|
+
* Returns `null` (rather than throwing) in three distinct cases that callers
|
|
108
|
+
* treat identically — no bypass is needed:
|
|
101
109
|
* - The specifier is not a package subpath (absolute path, relative path, no subpath)
|
|
102
|
-
* - The package's exports map already includes the subpath
|
|
110
|
+
* - The package's exports map already includes the subpath
|
|
103
111
|
* - The package cannot be found at all
|
|
104
112
|
*/
|
|
105
113
|
function resolvePackageSubpath(name) {
|
|
@@ -138,9 +146,6 @@ function resolvePackageSubpath(name) {
|
|
|
138
146
|
return null;
|
|
139
147
|
}
|
|
140
148
|
}
|
|
141
|
-
/**
|
|
142
|
-
* Resolves the root directory of a package by name.
|
|
143
|
-
*/
|
|
144
149
|
function resolvePackageDir(packageName) {
|
|
145
150
|
// Try CJS require.resolve first — it can often resolve package.json
|
|
146
151
|
// even when the ESM exports map doesn't include it
|
package/lib/path-utils.d.ts
CHANGED
|
@@ -1,19 +1,33 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Convert OS-native separators to forward slashes.
|
|
3
|
-
* Identity function on POSIX.
|
|
4
|
-
*/
|
|
1
|
+
/** Identity function on POSIX. */
|
|
5
2
|
export declare function toSlash(filePath: string): string;
|
|
6
|
-
/**
|
|
7
|
-
* Convert a `file://` URL to a native file path using Node.js built-in.
|
|
8
|
-
* Correctly handles URL encoding, UNC paths, and all drive letters.
|
|
9
|
-
*/
|
|
10
3
|
export declare function fromFileURL(fileUrl: string): string;
|
|
11
4
|
/**
|
|
12
|
-
*
|
|
13
|
-
*
|
|
5
|
+
* Convert an absolute file path (Windows or POSIX) into a `file://` URL
|
|
6
|
+
* suitable for `import()`. Bare module specifiers and relative paths are
|
|
7
|
+
* returned unchanged.
|
|
8
|
+
*
|
|
9
|
+
* Node's `pathToFileURL()` is intentionally avoided: on Windows it
|
|
10
|
+
* resolves a POSIX-style absolute path against the *current drive* and
|
|
11
|
+
* emits `file:///D:/tmp/foo` instead of `file:///tmp/foo` (#3840); on
|
|
12
|
+
* POSIX it likewise mishandles Windows-style drive paths. Constructing
|
|
13
|
+
* the URL ourselves keeps the function OS-independent so POSIX CI
|
|
14
|
+
* exercises the Windows code path. Each segment is percent-encoded so
|
|
15
|
+
* that spaces (`Program Files`), non-ASCII characters (e.g. Japanese
|
|
16
|
+
* usernames), and URL-reserved characters like `#` / `?` do not get
|
|
17
|
+
* reinterpreted as fragment / query delimiters by Node's URL parser.
|
|
18
|
+
*
|
|
19
|
+
* Mirrors `vscode/src/server/get-module.ts`'s `toImportSpecifier()` —
|
|
20
|
+
* keep the two in sync when adjusting Windows-path handling.
|
|
21
|
+
*
|
|
22
|
+
* Known limitation: UNC paths (`\\server\share\...`) are passed through
|
|
23
|
+
* unchanged.
|
|
24
|
+
*
|
|
25
|
+
* @see https://github.com/markuplint/markuplint/issues/3840
|
|
26
|
+
* @see https://github.com/markuplint/markuplint/issues/3836
|
|
27
|
+
* @see https://nodejs.org/api/esm.html#urls
|
|
14
28
|
*/
|
|
29
|
+
export declare function toFileURL(filePath: string): string;
|
|
30
|
+
/** Normalizes for the `ignore` library, whose matching is gitignore-style. */
|
|
15
31
|
export declare function normalizeForIgnore(filePath: string, relative?: boolean): string;
|
|
16
|
-
/**
|
|
17
|
-
* Normalize a path for glob libraries (forward slashes required).
|
|
18
|
-
*/
|
|
32
|
+
/** Glob libraries require forward slashes. */
|
|
19
33
|
export declare function normalizeForGlob(filePath: string): string;
|
package/lib/path-utils.js
CHANGED
|
@@ -1,23 +1,57 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
/**
|
|
4
|
-
* Convert OS-native separators to forward slashes.
|
|
5
|
-
* Identity function on POSIX.
|
|
6
|
-
*/
|
|
3
|
+
/** Identity function on POSIX. */
|
|
7
4
|
export function toSlash(filePath) {
|
|
8
5
|
return filePath.replaceAll('\\', '/');
|
|
9
6
|
}
|
|
10
|
-
/**
|
|
11
|
-
* Convert a `file://` URL to a native file path using Node.js built-in.
|
|
12
|
-
* Correctly handles URL encoding, UNC paths, and all drive letters.
|
|
13
|
-
*/
|
|
14
7
|
export function fromFileURL(fileUrl) {
|
|
15
8
|
return fileURLToPath(fileUrl);
|
|
16
9
|
}
|
|
17
10
|
/**
|
|
18
|
-
*
|
|
19
|
-
*
|
|
11
|
+
* Convert an absolute file path (Windows or POSIX) into a `file://` URL
|
|
12
|
+
* suitable for `import()`. Bare module specifiers and relative paths are
|
|
13
|
+
* returned unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Node's `pathToFileURL()` is intentionally avoided: on Windows it
|
|
16
|
+
* resolves a POSIX-style absolute path against the *current drive* and
|
|
17
|
+
* emits `file:///D:/tmp/foo` instead of `file:///tmp/foo` (#3840); on
|
|
18
|
+
* POSIX it likewise mishandles Windows-style drive paths. Constructing
|
|
19
|
+
* the URL ourselves keeps the function OS-independent so POSIX CI
|
|
20
|
+
* exercises the Windows code path. Each segment is percent-encoded so
|
|
21
|
+
* that spaces (`Program Files`), non-ASCII characters (e.g. Japanese
|
|
22
|
+
* usernames), and URL-reserved characters like `#` / `?` do not get
|
|
23
|
+
* reinterpreted as fragment / query delimiters by Node's URL parser.
|
|
24
|
+
*
|
|
25
|
+
* Mirrors `vscode/src/server/get-module.ts`'s `toImportSpecifier()` —
|
|
26
|
+
* keep the two in sync when adjusting Windows-path handling.
|
|
27
|
+
*
|
|
28
|
+
* Known limitation: UNC paths (`\\server\share\...`) are passed through
|
|
29
|
+
* unchanged.
|
|
30
|
+
*
|
|
31
|
+
* @see https://github.com/markuplint/markuplint/issues/3840
|
|
32
|
+
* @see https://github.com/markuplint/markuplint/issues/3836
|
|
33
|
+
* @see https://nodejs.org/api/esm.html#urls
|
|
20
34
|
*/
|
|
35
|
+
export function toFileURL(filePath) {
|
|
36
|
+
const isWindowsAbsolute = /^[a-z]:[/\\]/i.test(filePath);
|
|
37
|
+
const isPosixAbsolute = filePath.startsWith('/');
|
|
38
|
+
if (!isWindowsAbsolute && !isPosixAbsolute) {
|
|
39
|
+
return filePath;
|
|
40
|
+
}
|
|
41
|
+
if (isWindowsAbsolute) {
|
|
42
|
+
// The drive-letter segment (`c:`) is kept as-is to match the
|
|
43
|
+
// `pathToFileURL` output shape on Windows (`file:///c:/...`).
|
|
44
|
+
const [drive, ...rest] = filePath.replaceAll('\\', '/').split('/');
|
|
45
|
+
const encoded = [drive, ...rest.map(segment => encodeURIComponent(segment))].join('/');
|
|
46
|
+
return `file:///${encoded}`;
|
|
47
|
+
}
|
|
48
|
+
// POSIX absolute path. Splitting `/tmp/foo` by `/` yields `['', 'tmp',
|
|
49
|
+
// 'foo']`; the leading empty element produces the `file:///` prefix
|
|
50
|
+
// after `join('/')`, so the round-trip is exactly `file:///tmp/foo`.
|
|
51
|
+
const segments = filePath.split('/').map(segment => encodeURIComponent(segment));
|
|
52
|
+
return `file://${segments.join('/')}`;
|
|
53
|
+
}
|
|
54
|
+
/** Normalizes for the `ignore` library, whose matching is gitignore-style. */
|
|
21
55
|
export function normalizeForIgnore(filePath, relative = false) {
|
|
22
56
|
const hasBang = filePath.startsWith('!');
|
|
23
57
|
if (hasBang) {
|
|
@@ -38,9 +72,7 @@ export function normalizeForIgnore(filePath, relative = false) {
|
|
|
38
72
|
}
|
|
39
73
|
return filePath;
|
|
40
74
|
}
|
|
41
|
-
/**
|
|
42
|
-
* Normalize a path for glob libraries (forward slashes required).
|
|
43
|
-
*/
|
|
75
|
+
/** Glob libraries require forward slashes. */
|
|
44
76
|
export function normalizeForGlob(filePath) {
|
|
45
77
|
return toSlash(filePath);
|
|
46
78
|
}
|
|
@@ -1,8 +1,20 @@
|
|
|
1
1
|
import type { OptimizedConfig, Pretender } from '@markuplint/ml-config';
|
|
2
2
|
type PretendersConfig = OptimizedConfig['pretenders'];
|
|
3
|
+
/**
|
|
4
|
+
* The lint target's own identity, needed only to resolve `config.auto`.
|
|
5
|
+
* Omitting this (or omitting `config.auto`) skips auto-resolution entirely,
|
|
6
|
+
* so every other resolution source works exactly as before without it.
|
|
7
|
+
*/
|
|
8
|
+
export type ResolvePretendersContext = {
|
|
9
|
+
/** Absolute path of the file being linted */
|
|
10
|
+
readonly filePath: string;
|
|
11
|
+
/** Full source text of the file being linted (may be unsaved editor content) */
|
|
12
|
+
readonly sourceCode: string;
|
|
13
|
+
};
|
|
3
14
|
/**
|
|
4
15
|
* Resolves pretender definitions from files, imported modules, inline data,
|
|
5
|
-
*
|
|
16
|
+
* dynamic component scanning, and (when `context` is given) the lint
|
|
17
|
+
* target's own import graph.
|
|
6
18
|
*
|
|
7
19
|
* Resolution order:
|
|
8
20
|
* 1. `config.files` — direct import of pretender data files
|
|
@@ -12,9 +24,53 @@ type PretendersConfig = OptimizedConfig['pretenders'];
|
|
|
12
24
|
* 3. `config.data` — inline pretender definitions
|
|
13
25
|
* 4. `config.scan` — dynamic component scanning via glob patterns
|
|
14
26
|
* (`files` accepts `string | string[]`)
|
|
27
|
+
* 5. `config.auto` — on-demand scan of `context`'s own import graph (requires
|
|
28
|
+
* `context`; a no-op without it, e.g. when the caller has no lint target yet)
|
|
15
29
|
*
|
|
16
30
|
* @param config - The pretenders configuration section from the optimized config
|
|
31
|
+
* @param context - The lint target's path/source, required only for `config.auto`
|
|
17
32
|
* @returns An array of all resolved pretender definitions
|
|
18
33
|
*/
|
|
19
|
-
export declare function resolvePretenders(config: PretendersConfig): Promise<Pretender[]>;
|
|
34
|
+
export declare function resolvePretenders(config: PretendersConfig, context?: ResolvePretendersContext): Promise<Pretender[]>;
|
|
35
|
+
/**
|
|
36
|
+
* Resolves selector collisions in `pretenders` for the specific file about
|
|
37
|
+
* to be linted, deferring to `@markuplint/pretenders`' `disambiguatePretenders`
|
|
38
|
+
* only when there's actually a same-selector, file-backed collision to
|
|
39
|
+
* resolve — this keeps the common case (no ambiguity) free of both the
|
|
40
|
+
* dynamic import and any file/AST work.
|
|
41
|
+
*
|
|
42
|
+
* @param filePath - Absolute path of the file being linted
|
|
43
|
+
* @param sourceCode - Full source text of the file being linted
|
|
44
|
+
* @param pretenders - The flat pretender list {@link resolvePretenders} produced
|
|
45
|
+
* @returns The disambiguated pretender list, or `pretenders` itself (same
|
|
46
|
+
* reference) when there was no collision to resolve
|
|
47
|
+
*/
|
|
48
|
+
export declare function disambiguatePretendersForFile(filePath: string, sourceCode: string, pretenders: readonly Pretender[]): Promise<readonly Pretender[]>;
|
|
49
|
+
/**
|
|
50
|
+
* Deliberately gates on selector+filePath duplication alone — NOT on the
|
|
51
|
+
* selector name shape `@markuplint/pretenders`' `disambiguatePretenders`
|
|
52
|
+
* actually resolves (plain identifiers only). Duplicating that name-shape
|
|
53
|
+
* check here would let the two independently maintained filters drift out
|
|
54
|
+
* of sync: if the real filter is ever loosened without updating this one,
|
|
55
|
+
* this fast-path gate would keep skipping the dynamic import for cases the
|
|
56
|
+
* real logic would now handle, silently disabling disambiguation for them.
|
|
57
|
+
* Being a strict superset costs at most an unnecessary dynamic import for
|
|
58
|
+
* selectors the real logic ends up not touching — never a missed one.
|
|
59
|
+
*
|
|
60
|
+
* @param pretenders - The flat pretender list to check
|
|
61
|
+
* @returns `true` if some `selector` is shared by two or more `filePath`-backed entries
|
|
62
|
+
*/
|
|
63
|
+
export declare function hasResolvableCollision(pretenders: readonly Pretender[]): boolean;
|
|
64
|
+
/**
|
|
65
|
+
* Clears `@markuplint/pretenders`' module-level import/export resolution
|
|
66
|
+
* caches. Call this whenever a lint host re-resolves config without cache
|
|
67
|
+
* (e.g. watch mode after a file change) — otherwise a renamed export or a
|
|
68
|
+
* newly valid tsconfig `paths` alias keeps resolving as it did before the
|
|
69
|
+
* change for the rest of the process's lifetime. A no-op (not an error) when
|
|
70
|
+
* `@markuplint/pretenders` isn't installed, since nothing has populated its
|
|
71
|
+
* caches in that case either.
|
|
72
|
+
*
|
|
73
|
+
* @returns A promise that resolves once the caches have been cleared
|
|
74
|
+
*/
|
|
75
|
+
export declare function invalidatePretenderResolutionCaches(): Promise<void>;
|
|
20
76
|
export {};
|
|
@@ -1,9 +1,22 @@
|
|
|
1
1
|
import path from 'node:path';
|
|
2
|
+
import { rebasePretenderFilePath } from '@markuplint/ml-config';
|
|
2
3
|
import { glob } from 'glob';
|
|
3
4
|
import { generalImport } from './general-import.js';
|
|
5
|
+
/**
|
|
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.
|
|
9
|
+
* Rebasing to an absolute path immediately after each source is read is
|
|
10
|
+
* what lets {@link disambiguatePretendersForFile} later compare a
|
|
11
|
+
* pretender's origin file against the lint target's resolved imports.
|
|
12
|
+
*/
|
|
13
|
+
function rebasePretenderFilePaths(pretenders, baseDir) {
|
|
14
|
+
return pretenders.map(pretender => rebasePretenderFilePath(pretender, relPath => path.resolve(baseDir, relPath)));
|
|
15
|
+
}
|
|
4
16
|
/**
|
|
5
17
|
* Resolves pretender definitions from files, imported modules, inline data,
|
|
6
|
-
*
|
|
18
|
+
* dynamic component scanning, and (when `context` is given) the lint
|
|
19
|
+
* target's own import graph.
|
|
7
20
|
*
|
|
8
21
|
* Resolution order:
|
|
9
22
|
* 1. `config.files` — direct import of pretender data files
|
|
@@ -13,11 +26,14 @@ import { generalImport } from './general-import.js';
|
|
|
13
26
|
* 3. `config.data` — inline pretender definitions
|
|
14
27
|
* 4. `config.scan` — dynamic component scanning via glob patterns
|
|
15
28
|
* (`files` accepts `string | string[]`)
|
|
29
|
+
* 5. `config.auto` — on-demand scan of `context`'s own import graph (requires
|
|
30
|
+
* `context`; a no-op without it, e.g. when the caller has no lint target yet)
|
|
16
31
|
*
|
|
17
32
|
* @param config - The pretenders configuration section from the optimized config
|
|
33
|
+
* @param context - The lint target's path/source, required only for `config.auto`
|
|
18
34
|
* @returns An array of all resolved pretender definitions
|
|
19
35
|
*/
|
|
20
|
-
export async function resolvePretenders(config) {
|
|
36
|
+
export async function resolvePretenders(config, context) {
|
|
21
37
|
if (!config) {
|
|
22
38
|
return [];
|
|
23
39
|
}
|
|
@@ -28,7 +44,9 @@ export async function resolvePretenders(config) {
|
|
|
28
44
|
if (!pretenderFile?.data) {
|
|
29
45
|
continue;
|
|
30
46
|
}
|
|
31
|
-
|
|
47
|
+
// `file` is already absolute (resolved by the config provider), so its
|
|
48
|
+
// own directory is the correct base for the entries it carries.
|
|
49
|
+
data.push(...rebasePretenderFilePaths(pretenderFile.data, path.dirname(file)));
|
|
32
50
|
}
|
|
33
51
|
}
|
|
34
52
|
if (config.imports) {
|
|
@@ -40,6 +58,10 @@ export async function resolvePretenders(config) {
|
|
|
40
58
|
if (!pretenderFile?.data) {
|
|
41
59
|
continue;
|
|
42
60
|
}
|
|
61
|
+
// The on-disk location of an npm package's pretenders data isn't
|
|
62
|
+
// recoverable from `generalImport`'s return value, so these entries'
|
|
63
|
+
// filePath is left as-is — disambiguation simply can't confirm them
|
|
64
|
+
// (see the module JSDoc for the fallback policy this implies).
|
|
43
65
|
data.push(...pretenderFile.data);
|
|
44
66
|
}
|
|
45
67
|
}
|
|
@@ -56,9 +78,100 @@ export async function resolvePretenders(config) {
|
|
|
56
78
|
const scanned = await scan(resolved, {
|
|
57
79
|
ignoreComponentNames: entry.ignoreComponentNames ? [...entry.ignoreComponentNames] : undefined,
|
|
58
80
|
});
|
|
59
|
-
|
|
81
|
+
// `scan()` (with no `cwd` option) reports filePath relative to `process.cwd()`.
|
|
82
|
+
data.push(...rebasePretenderFilePaths(scanned, process.cwd()));
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
if (config.auto && context) {
|
|
87
|
+
const { autoScan } = await import('@markuplint/pretenders');
|
|
88
|
+
const scanned = await autoScan(context.filePath, context.sourceCode);
|
|
89
|
+
// `autoScan()` reports filePath relative to `process.cwd()`, same as `scan()`.
|
|
90
|
+
const rebased = rebasePretenderFilePaths(scanned, process.cwd());
|
|
91
|
+
// `scan` and `auto` can both walk into the same file (e.g. a component
|
|
92
|
+
// `scan`'s glob already covers that `auto`'s import-graph walk also
|
|
93
|
+
// reaches); de-duping on (selector, filePath) keeps that file's entry
|
|
94
|
+
// from appearing twice while still letting a same-selector entry from a
|
|
95
|
+
// genuinely different file through for `disambiguatePretendersForFile`
|
|
96
|
+
// to resolve. `selector` is a markuplint CSS-like selector and can
|
|
97
|
+
// legitimately contain spaces (a descendant combinator), so the pair is
|
|
98
|
+
// joined via `JSON.stringify` rather than a plain-string delimiter —
|
|
99
|
+
// otherwise two distinct (selector, filePath) pairs could concatenate to
|
|
100
|
+
// the same string and be mistaken for a duplicate.
|
|
101
|
+
const dedupeKey = (p) => JSON.stringify([p.selector, p.filePath]);
|
|
102
|
+
const seen = new Set(data.filter(p => p.filePath).map(p => dedupeKey(p)));
|
|
103
|
+
for (const pretender of rebased) {
|
|
104
|
+
if (pretender.filePath) {
|
|
105
|
+
const key = dedupeKey(pretender);
|
|
106
|
+
if (seen.has(key)) {
|
|
107
|
+
continue;
|
|
108
|
+
}
|
|
109
|
+
seen.add(key);
|
|
60
110
|
}
|
|
111
|
+
data.push(pretender);
|
|
61
112
|
}
|
|
62
113
|
}
|
|
63
114
|
return data;
|
|
64
115
|
}
|
|
116
|
+
/**
|
|
117
|
+
* Resolves selector collisions in `pretenders` for the specific file about
|
|
118
|
+
* to be linted, deferring to `@markuplint/pretenders`' `disambiguatePretenders`
|
|
119
|
+
* only when there's actually a same-selector, file-backed collision to
|
|
120
|
+
* resolve — this keeps the common case (no ambiguity) free of both the
|
|
121
|
+
* dynamic import and any file/AST work.
|
|
122
|
+
*
|
|
123
|
+
* @param filePath - Absolute path of the file being linted
|
|
124
|
+
* @param sourceCode - Full source text of the file being linted
|
|
125
|
+
* @param pretenders - The flat pretender list {@link resolvePretenders} produced
|
|
126
|
+
* @returns The disambiguated pretender list, or `pretenders` itself (same
|
|
127
|
+
* reference) when there was no collision to resolve
|
|
128
|
+
*/
|
|
129
|
+
export async function disambiguatePretendersForFile(filePath, sourceCode, pretenders) {
|
|
130
|
+
if (!hasResolvableCollision(pretenders)) {
|
|
131
|
+
return pretenders;
|
|
132
|
+
}
|
|
133
|
+
const { disambiguatePretenders } = await import('@markuplint/pretenders');
|
|
134
|
+
return disambiguatePretenders(pretenders, { filePath, sourceCode });
|
|
135
|
+
}
|
|
136
|
+
/**
|
|
137
|
+
* Deliberately gates on selector+filePath duplication alone — NOT on the
|
|
138
|
+
* selector name shape `@markuplint/pretenders`' `disambiguatePretenders`
|
|
139
|
+
* actually resolves (plain identifiers only). Duplicating that name-shape
|
|
140
|
+
* check here would let the two independently maintained filters drift out
|
|
141
|
+
* of sync: if the real filter is ever loosened without updating this one,
|
|
142
|
+
* this fast-path gate would keep skipping the dynamic import for cases the
|
|
143
|
+
* real logic would now handle, silently disabling disambiguation for them.
|
|
144
|
+
* Being a strict superset costs at most an unnecessary dynamic import for
|
|
145
|
+
* selectors the real logic ends up not touching — never a missed one.
|
|
146
|
+
*
|
|
147
|
+
* @param pretenders - The flat pretender list to check
|
|
148
|
+
* @returns `true` if some `selector` is shared by two or more `filePath`-backed entries
|
|
149
|
+
*/
|
|
150
|
+
export function hasResolvableCollision(pretenders) {
|
|
151
|
+
const seen = new Set();
|
|
152
|
+
for (const pretender of pretenders) {
|
|
153
|
+
if (!pretender.filePath) {
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
if (seen.has(pretender.selector)) {
|
|
157
|
+
return true;
|
|
158
|
+
}
|
|
159
|
+
seen.add(pretender.selector);
|
|
160
|
+
}
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Clears `@markuplint/pretenders`' module-level import/export resolution
|
|
165
|
+
* caches. Call this whenever a lint host re-resolves config without cache
|
|
166
|
+
* (e.g. watch mode after a file change) — otherwise a renamed export or a
|
|
167
|
+
* newly valid tsconfig `paths` alias keeps resolving as it did before the
|
|
168
|
+
* change for the rest of the process's lifetime. A no-op (not an error) when
|
|
169
|
+
* `@markuplint/pretenders` isn't installed, since nothing has populated its
|
|
170
|
+
* caches in that case either.
|
|
171
|
+
*
|
|
172
|
+
* @returns A promise that resolves once the caches have been cleared
|
|
173
|
+
*/
|
|
174
|
+
export async function invalidatePretenderResolutionCaches() {
|
|
175
|
+
const pretendersMod = await import('@markuplint/pretenders').catch(() => null);
|
|
176
|
+
pretendersMod?.clearPretenderCaches();
|
|
177
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@markuplint/file-resolver",
|
|
3
|
-
"version": "5.0.0-rc.
|
|
3
|
+
"version": "5.0.0-rc.6",
|
|
4
4
|
"description": "The file resolver of markuplint",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"author": "Yusuke Hirao <yusukehirao@me.com>",
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=
|
|
13
|
+
"node": ">=24"
|
|
14
14
|
},
|
|
15
15
|
"type": "module",
|
|
16
16
|
"exports": {
|
|
@@ -31,18 +31,18 @@
|
|
|
31
31
|
"clean": "tsc --build --clean"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
|
-
"@types/node": "24.
|
|
34
|
+
"@types/node": "24.13.3"
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@markuplint/html-parser": "5.0.0-rc.
|
|
38
|
-
"@markuplint/ml-ast": "5.0.0-rc.
|
|
39
|
-
"@markuplint/ml-config": "5.0.0-rc.
|
|
40
|
-
"@markuplint/ml-core": "5.0.0-rc.
|
|
41
|
-
"@markuplint/ml-spec": "5.0.0-rc.
|
|
42
|
-
"@markuplint/parser-utils": "5.0.0-rc.
|
|
43
|
-
"@markuplint/pretenders": "5.0.0-rc.
|
|
44
|
-
"@markuplint/selector": "5.0.0-rc.
|
|
45
|
-
"@markuplint/shared": "5.0.0-rc.
|
|
37
|
+
"@markuplint/html-parser": "5.0.0-rc.6",
|
|
38
|
+
"@markuplint/ml-ast": "5.0.0-rc.6",
|
|
39
|
+
"@markuplint/ml-config": "5.0.0-rc.6",
|
|
40
|
+
"@markuplint/ml-core": "5.0.0-rc.6",
|
|
41
|
+
"@markuplint/ml-spec": "5.0.0-rc.6",
|
|
42
|
+
"@markuplint/parser-utils": "5.0.0-rc.6",
|
|
43
|
+
"@markuplint/pretenders": "5.0.0-rc.6",
|
|
44
|
+
"@markuplint/selector": "5.0.0-rc.6",
|
|
45
|
+
"@markuplint/shared": "5.0.0-rc.6",
|
|
46
46
|
"cosmiconfig": "9.0.1",
|
|
47
47
|
"debug": "4.4.3",
|
|
48
48
|
"glob": "13.0.6",
|
|
@@ -51,5 +51,5 @@
|
|
|
51
51
|
"jsonc": "2.0.0",
|
|
52
52
|
"minimatch": "10.2.5"
|
|
53
53
|
},
|
|
54
|
-
"gitHead": "
|
|
54
|
+
"gitHead": "c02c3a0783eac6b2fb4707be2dc00b88f6219641"
|
|
55
55
|
}
|