markuplint 5.0.1 → 5.1.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 +19 -0
- package/lib/api/ml-engine.d.ts +36 -4
- package/lib/api/ml-engine.js +288 -25
- package/lib/api/shared-watcher.d.ts +82 -0
- package/lib/api/shared-watcher.js +237 -0
- package/lib/cli/command.js +44 -23
- package/package.json +13 -13
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,25 @@
|
|
|
3
3
|
All notable changes to this project will be documented in this file.
|
|
4
4
|
See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
|
|
5
5
|
|
|
6
|
+
# [5.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
- **markuplint:** poll for file changes under Deno ([ccfeea1](https://github.com/markuplint/markuplint/commit/ccfeea1e1726fda526deff979cafac5093f179bb))
|
|
11
|
+
- **markuplint:** re-resolve the pretenders that depend on the source on setCode ([525404e](https://github.com/markuplint/markuplint/commit/525404e296b1936ef86e48d03f79479071c7e7d0)), closes [#4064](https://github.com/markuplint/markuplint/issues/4064)
|
|
12
|
+
- **ml-spec:** read option, SVG title and control text through the accname resolver ([4c66d1f](https://github.com/markuplint/markuplint/commit/4c66d1fb960e746dc9f3c6cec0c8b3c9284a657e))
|
|
13
|
+
- **ml-spec:** read the SVG title and select options through the accname resolver ([0a7c162](https://github.com/markuplint/markuplint/commit/0a7c162a83a553d7872388184ea0020735def6fb)), closes [#4067](https://github.com/markuplint/markuplint/issues/4067)
|
|
14
|
+
- **pretenders:** compose slots and contents through a chain of components ([ac6147f](https://github.com/markuplint/markuplint/commit/ac6147f4caee1a61d40c7925321fb0cdb64a88b8)), closes [#4057](https://github.com/markuplint/markuplint/issues/4057)
|
|
15
|
+
|
|
16
|
+
### Features
|
|
17
|
+
|
|
18
|
+
- **language-server:** add a language server that runs on any LSP client ([ab03edc](https://github.com/markuplint/markuplint/commit/ab03edc35457b6502a9d55e176a8adca5427f796))
|
|
19
|
+
- **markuplint:** watch the files that pretenders.scan and auto read ([b4c63ef](https://github.com/markuplint/markuplint/commit/b4c63ef246cb42ecd1b943de46dca1f58738a701)), closes [#4065](https://github.com/markuplint/markuplint/issues/4065)
|
|
20
|
+
|
|
21
|
+
### Performance Improvements
|
|
22
|
+
|
|
23
|
+
- **markuplint:** retain parsed documents only when suppressions scope needs them ([1f5326a](https://github.com/markuplint/markuplint/commit/1f5326a14d8efbf1da441452b192bcfcd3892384))
|
|
24
|
+
|
|
6
25
|
## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
|
|
7
26
|
|
|
8
27
|
### Bug Fixes
|
package/lib/api/ml-engine.d.ts
CHANGED
|
@@ -88,7 +88,8 @@ export declare class MLEngine extends Emitter<MLEngineEventMap> {
|
|
|
88
88
|
*/
|
|
89
89
|
get document(): Document<RuleConfigValue, PlainData> | null;
|
|
90
90
|
/**
|
|
91
|
-
* Closes the engine, removing all event listeners and
|
|
91
|
+
* Closes the engine, removing all event listeners and leaving the file
|
|
92
|
+
* watcher (the files no other engine watches are released with it).
|
|
92
93
|
*/
|
|
93
94
|
close(): Promise<void>;
|
|
94
95
|
/**
|
|
@@ -96,18 +97,49 @@ export declare class MLEngine extends Emitter<MLEngineEventMap> {
|
|
|
96
97
|
*
|
|
97
98
|
* Sets up the engine on first call, then verifies the document against all rules.
|
|
98
99
|
*
|
|
100
|
+
* Runs after whatever the engine is already doing — an earlier `exec()`, a
|
|
101
|
+
* {@link setCode} call, a re-resolution a watched file triggered — and
|
|
102
|
+
* before whatever is asked for later. That also means that awaiting it from
|
|
103
|
+
* inside one of the engine's own event listeners waits on itself.
|
|
104
|
+
*
|
|
99
105
|
* @returns The lint result including violations and fixed code, or `null` if setup was skipped
|
|
100
106
|
*/
|
|
101
107
|
exec(): Promise<MLResultInfo | null>;
|
|
102
108
|
/**
|
|
103
|
-
* Updates the source code and re-parses the document without re-resolving
|
|
109
|
+
* Updates the source code and re-parses the document without re-resolving
|
|
110
|
+
* configuration.
|
|
111
|
+
*
|
|
112
|
+
* The pretenders that depend on the source — `pretenders.auto`, which walks
|
|
113
|
+
* the file's own imports, and the disambiguation of same-selector entries,
|
|
114
|
+
* which reads them too — are re-resolved from the new code; the ones that
|
|
115
|
+
* depend on the config and the filesystem only are kept from the latest
|
|
116
|
+
* config resolution (see #4064 and {@link PretenderResolver}).
|
|
117
|
+
*
|
|
118
|
+
* When calls overlap, only the latest one takes effect, and every call
|
|
119
|
+
* settles only once that latest one has — so a host that runs
|
|
120
|
+
* {@link exec} right after awaiting any of them (the VS Code extension
|
|
121
|
+
* does) lints the document the file now holds, never the previous
|
|
122
|
+
* document against the newer file. For the same reason a superseded call
|
|
123
|
+
* rejects when the latest one fails: resolving would tell its caller the
|
|
124
|
+
* latest code is in place when it is not.
|
|
104
125
|
*
|
|
105
126
|
* @param code - The new markup source code
|
|
106
127
|
*/
|
|
107
128
|
setCode(code: string): Promise<void>;
|
|
108
129
|
/**
|
|
109
|
-
* Enables or disables watch mode. When enabled, the engine watches
|
|
110
|
-
*
|
|
130
|
+
* Enables or disables watch mode. When enabled, the engine watches the
|
|
131
|
+
* files its result depends on — config files, and the component files
|
|
132
|
+
* `pretenders.scan` and `pretenders.auto` read — and re-lints automatically
|
|
133
|
+
* when one changes, is created, or is removed. Changes that arrive together
|
|
134
|
+
* are one re-lint, and the lint target itself is not watched: it may be
|
|
135
|
+
* watched and managed by a language server or text editor, which tells the
|
|
136
|
+
* engine through {@link setCode}.
|
|
137
|
+
*
|
|
138
|
+
* Not watched, so a change to them is picked up only by the next
|
|
139
|
+
* config-triggered re-lint: files that do not exist yet (a component that
|
|
140
|
+
* has not been created, a `scan` glob's new match), `node_modules`,
|
|
141
|
+
* `pretenders.files` and `pretenders.imports`. See
|
|
142
|
+
* `PretenderScanOptions#dependencies` in `@markuplint/ml-config`.
|
|
111
143
|
*
|
|
112
144
|
* @param enable - Whether to enable watch mode
|
|
113
145
|
*/
|
package/lib/api/ml-engine.js
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
import { ConfigProvider, disambiguatePretendersForFile, invalidatePretenderResolutionCaches, resolveFiles, resolveParser,
|
|
1
|
+
import { ConfigProvider, createPretenderResolver, disambiguatePretendersForFile, invalidatePretenderResolutionCaches, resolveFiles, resolveParser, resolveRules, resolveSpecs, } from '@markuplint/file-resolver';
|
|
2
|
+
import path from 'node:path';
|
|
2
3
|
import { applyRuleAliasesToConfig, mergeConfig } from '@markuplint/ml-config';
|
|
3
4
|
import { MLCore, convertRuleset } from '@markuplint/ml-core';
|
|
4
5
|
import { ruleAliasTable } from '@markuplint/rules';
|
|
5
6
|
import { isFatalError } from '@markuplint/shared';
|
|
6
|
-
import { FSWatcher } from 'chokidar';
|
|
7
7
|
import { Emitter } from 'strict-event-emitter';
|
|
8
8
|
import { log as coreLog, verbosely } from '../debug.js';
|
|
9
9
|
import { i18n } from '../i18n.js';
|
|
10
|
+
import { subscribe as subscribeToWatcher, toWatchKey } from './shared-watcher.js';
|
|
10
11
|
const log = coreLog.extend('ml-engine');
|
|
11
12
|
const fileLog = log.extend('file');
|
|
12
13
|
const configLog = log.extend('config');
|
|
@@ -19,6 +20,15 @@ const configLog = log.extend('config');
|
|
|
19
20
|
* way a stable `options.config`/`defaultConfig` reference does. See #3997.
|
|
20
21
|
*/
|
|
21
22
|
const RECOMMENDED_CONFIG = { extends: ['markuplint:recommended'] };
|
|
23
|
+
/**
|
|
24
|
+
* How long watch events are gathered before the engine re-resolves, so that
|
|
25
|
+
* a change of many files at once (a `git checkout`, a formatter run) is one
|
|
26
|
+
* re-resolution. Rollup's `watch.buildDelay` is 25ms; tsc waits 250ms and
|
|
27
|
+
* webpack 20ms. A re-resolution is not interrupted by events that arrive
|
|
28
|
+
* during it (see `#watchRerun`), so the delay only has to be long enough to
|
|
29
|
+
* gather the burst, not to outlast the work.
|
|
30
|
+
*/
|
|
31
|
+
const WATCH_DEBOUNCE_MS = 25;
|
|
22
32
|
/**
|
|
23
33
|
* The main markuplint engine that orchestrates file resolution, configuration loading,
|
|
24
34
|
* parsing, and linting. Supports both single-file and watch-mode operation.
|
|
@@ -64,7 +74,53 @@ export class MLEngine extends Emitter {
|
|
|
64
74
|
#core = null;
|
|
65
75
|
#file;
|
|
66
76
|
#options;
|
|
67
|
-
|
|
77
|
+
/**
|
|
78
|
+
* The resolver of the latest config resolution, kept so `setCode()` can
|
|
79
|
+
* re-resolve the pretenders that depend on the source without re-reading
|
|
80
|
+
* the ones that depend on the config and the filesystem only. See #4064.
|
|
81
|
+
*/
|
|
82
|
+
#pretenderResolver = null;
|
|
83
|
+
/**
|
|
84
|
+
* Counts `setCode()` calls so that, when they overlap, only the latest one
|
|
85
|
+
* reaches the file and the core — `setCode()` awaits the pretender
|
|
86
|
+
* re-resolution, and an earlier call finishing later must not put its
|
|
87
|
+
* older code back.
|
|
88
|
+
*/
|
|
89
|
+
#setCodeSeq = 0;
|
|
90
|
+
/**
|
|
91
|
+
* The latest `setCode()` call's application, so that a superseded call can
|
|
92
|
+
* resolve only after it has taken effect.
|
|
93
|
+
*/
|
|
94
|
+
#latestSetCode = Promise.resolve();
|
|
95
|
+
/**
|
|
96
|
+
* What is running, so that the next piece of work starts when it is over:
|
|
97
|
+
* {@link exec}, the application of {@link setCode}, and the re-resolution a
|
|
98
|
+
* watched file triggers share one queue. They all read and replace the same
|
|
99
|
+
* state — the pretender resolver, the core's document — and `verify()` with
|
|
100
|
+
* `fix` rewrites the document while it runs; run side by side, a lint could
|
|
101
|
+
* see another's half-done state, and the one that finished last would put
|
|
102
|
+
* its older pretenders over a newer resolution.
|
|
103
|
+
*/
|
|
104
|
+
#queue = Promise.resolve();
|
|
105
|
+
/**
|
|
106
|
+
* This engine's view of the process-wide watcher (see `./shared-watcher.ts`);
|
|
107
|
+
* `null` unless watching.
|
|
108
|
+
*/
|
|
109
|
+
#watch = null;
|
|
110
|
+
/** The config files of the latest resolution, as {@link ConfigSet.files} names them. */
|
|
111
|
+
#configFiles = new Set();
|
|
112
|
+
/**
|
|
113
|
+
* The files the latest pretenders resolution read (see
|
|
114
|
+
* `ResolvePretendersOptions#dependencies`). `setCode()` replaces them, as
|
|
115
|
+
* the `auto` ones follow the source's imports.
|
|
116
|
+
*/
|
|
117
|
+
#pretenderDependencies = new Set();
|
|
118
|
+
/** The pending gathering of watch events into one re-resolution. */
|
|
119
|
+
#watchDebounce;
|
|
120
|
+
/** Whether the loop of re-resolutions is running, one at a time. */
|
|
121
|
+
#rerunning = false;
|
|
122
|
+
/** Set by an event that arrives while a re-resolution is running: it ran on state older than the event. */
|
|
123
|
+
#rerunAgain = false;
|
|
68
124
|
constructor(file, options) {
|
|
69
125
|
super();
|
|
70
126
|
if (this.#options?.debug) {
|
|
@@ -86,20 +142,29 @@ export class MLEngine extends Emitter {
|
|
|
86
142
|
return this.#core?.document ?? null;
|
|
87
143
|
}
|
|
88
144
|
/**
|
|
89
|
-
* Closes the engine, removing all event listeners and
|
|
145
|
+
* Closes the engine, removing all event listeners and leaving the file
|
|
146
|
+
* watcher (the files no other engine watches are released with it).
|
|
90
147
|
*/
|
|
91
148
|
async close() {
|
|
92
149
|
this.removeAllListeners();
|
|
93
|
-
await this.#
|
|
150
|
+
await this.#stopWatching();
|
|
94
151
|
}
|
|
95
152
|
/**
|
|
96
153
|
* Executes linting on the target file and returns the results.
|
|
97
154
|
*
|
|
98
155
|
* Sets up the engine on first call, then verifies the document against all rules.
|
|
99
156
|
*
|
|
157
|
+
* Runs after whatever the engine is already doing — an earlier `exec()`, a
|
|
158
|
+
* {@link setCode} call, a re-resolution a watched file triggered — and
|
|
159
|
+
* before whatever is asked for later. That also means that awaiting it from
|
|
160
|
+
* inside one of the engine's own event listeners waits on itself.
|
|
161
|
+
*
|
|
100
162
|
* @returns The lint result including violations and fixed code, or `null` if setup was skipped
|
|
101
163
|
*/
|
|
102
|
-
|
|
164
|
+
exec() {
|
|
165
|
+
return this.#enqueue(() => this.#exec());
|
|
166
|
+
}
|
|
167
|
+
async #exec() {
|
|
103
168
|
log('exec: start');
|
|
104
169
|
const core = await this.#setup();
|
|
105
170
|
if (!core) {
|
|
@@ -160,21 +225,71 @@ export class MLEngine extends Emitter {
|
|
|
160
225
|
};
|
|
161
226
|
}
|
|
162
227
|
/**
|
|
163
|
-
* Updates the source code and re-parses the document without re-resolving
|
|
228
|
+
* Updates the source code and re-parses the document without re-resolving
|
|
229
|
+
* configuration.
|
|
230
|
+
*
|
|
231
|
+
* The pretenders that depend on the source — `pretenders.auto`, which walks
|
|
232
|
+
* the file's own imports, and the disambiguation of same-selector entries,
|
|
233
|
+
* which reads them too — are re-resolved from the new code; the ones that
|
|
234
|
+
* depend on the config and the filesystem only are kept from the latest
|
|
235
|
+
* config resolution (see #4064 and {@link PretenderResolver}).
|
|
236
|
+
*
|
|
237
|
+
* When calls overlap, only the latest one takes effect, and every call
|
|
238
|
+
* settles only once that latest one has — so a host that runs
|
|
239
|
+
* {@link exec} right after awaiting any of them (the VS Code extension
|
|
240
|
+
* does) lints the document the file now holds, never the previous
|
|
241
|
+
* document against the newer file. For the same reason a superseded call
|
|
242
|
+
* rejects when the latest one fails: resolving would tell its caller the
|
|
243
|
+
* latest code is in place when it is not.
|
|
164
244
|
*
|
|
165
245
|
* @param code - The new markup source code
|
|
166
246
|
*/
|
|
167
247
|
async setCode(code) {
|
|
248
|
+
// Numbered when called, not when its turn in the queue comes: a call that
|
|
249
|
+
// a later one has overtaken by then steps aside without resolving anything.
|
|
250
|
+
const seq = ++this.#setCodeSeq;
|
|
251
|
+
const applied = this.#enqueue(() => this.#applyCode(code, seq));
|
|
252
|
+
this.#latestSetCode = applied;
|
|
253
|
+
await applied;
|
|
254
|
+
let awaited = applied;
|
|
255
|
+
while (this.#latestSetCode !== awaited) {
|
|
256
|
+
awaited = this.#latestSetCode;
|
|
257
|
+
await awaited;
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* The body of one {@link setCode} call. Two checks against the sequence
|
|
262
|
+
* number: the one after setup spares a superseded call its resolution;
|
|
263
|
+
* the one after resolution keeps a superseded call that was already
|
|
264
|
+
* resolving from overwriting the newer result.
|
|
265
|
+
*/
|
|
266
|
+
async #applyCode(code, seq) {
|
|
168
267
|
const core = await this.#setup();
|
|
169
|
-
if (!core) {
|
|
268
|
+
if (!core || seq !== this.#setCodeSeq) {
|
|
170
269
|
return;
|
|
171
270
|
}
|
|
172
271
|
this.#file.setCode(code);
|
|
173
|
-
|
|
272
|
+
const { pretenders, dependencies } = await this.#resolvePretendersForCurrentCode();
|
|
273
|
+
if (seq !== this.#setCodeSeq) {
|
|
274
|
+
return;
|
|
275
|
+
}
|
|
276
|
+
core.setCode(code, { pretenders });
|
|
277
|
+
await this.#watchPretenderDependencies(dependencies);
|
|
174
278
|
}
|
|
175
279
|
/**
|
|
176
|
-
* Enables or disables watch mode. When enabled, the engine watches
|
|
177
|
-
*
|
|
280
|
+
* Enables or disables watch mode. When enabled, the engine watches the
|
|
281
|
+
* files its result depends on — config files, and the component files
|
|
282
|
+
* `pretenders.scan` and `pretenders.auto` read — and re-lints automatically
|
|
283
|
+
* when one changes, is created, or is removed. Changes that arrive together
|
|
284
|
+
* are one re-lint, and the lint target itself is not watched: it may be
|
|
285
|
+
* watched and managed by a language server or text editor, which tells the
|
|
286
|
+
* engine through {@link setCode}.
|
|
287
|
+
*
|
|
288
|
+
* Not watched, so a change to them is picked up only by the next
|
|
289
|
+
* config-triggered re-lint: files that do not exist yet (a component that
|
|
290
|
+
* has not been created, a `scan` glob's new match), `node_modules`,
|
|
291
|
+
* `pretenders.files` and `pretenders.imports`. See
|
|
292
|
+
* `PretenderScanOptions#dependencies` in `@markuplint/ml-config`.
|
|
178
293
|
*
|
|
179
294
|
* @param enable - Whether to enable watch mode
|
|
180
295
|
*/
|
|
@@ -184,12 +299,24 @@ export class MLEngine extends Emitter {
|
|
|
184
299
|
watch: enable,
|
|
185
300
|
};
|
|
186
301
|
if (enable) {
|
|
187
|
-
|
|
302
|
+
if (!this.#watch) {
|
|
303
|
+
this.#watch = subscribeToWatcher(filePath => this.#watchOnChange(filePath), error => this.#emitWatchError(error));
|
|
304
|
+
// Whatever the engine already depends on; it resolves the rest as it goes.
|
|
305
|
+
void this.#syncWatch();
|
|
306
|
+
}
|
|
188
307
|
}
|
|
189
308
|
else {
|
|
190
|
-
this.#
|
|
309
|
+
this.#stopWatching().catch((error) => {
|
|
310
|
+
if (isFatalError(error)) {
|
|
311
|
+
throw error;
|
|
312
|
+
}
|
|
313
|
+
this.#emitWatchError(error);
|
|
314
|
+
});
|
|
191
315
|
}
|
|
192
316
|
}
|
|
317
|
+
#emitWatchError(error) {
|
|
318
|
+
this.emit('log', 'watch:error', error instanceof Error ? error.message : String(error));
|
|
319
|
+
}
|
|
193
320
|
async #createCore(fabric) {
|
|
194
321
|
fileLog('Get source code');
|
|
195
322
|
const sourceCode = await this.#file.getCode();
|
|
@@ -206,13 +333,68 @@ export class MLEngine extends Emitter {
|
|
|
206
333
|
this.#core = core;
|
|
207
334
|
return core;
|
|
208
335
|
}
|
|
209
|
-
|
|
336
|
+
/**
|
|
337
|
+
* A watched file changed, was created, or was removed. Gathers the events
|
|
338
|
+
* of a burst, then asks for a re-resolution.
|
|
339
|
+
*/
|
|
340
|
+
#watchOnChange(filePath) {
|
|
210
341
|
if (!this.#options?.watch) {
|
|
211
342
|
return;
|
|
212
343
|
}
|
|
213
344
|
this.emit('log', 'watch:onChange', filePath);
|
|
345
|
+
clearTimeout(this.#watchDebounce);
|
|
346
|
+
this.#watchDebounce = setTimeout(() => {
|
|
347
|
+
this.#watchDebounce = undefined;
|
|
348
|
+
void this.#watchRerun();
|
|
349
|
+
}, WATCH_DEBOUNCE_MS);
|
|
350
|
+
}
|
|
351
|
+
/**
|
|
352
|
+
* Re-resolves and lints, one at a time. An event that arrives while one is
|
|
353
|
+
* running means it ran on state older than the event, so it is followed by
|
|
354
|
+
* exactly one more — however many events came — rather than by one each,
|
|
355
|
+
* and never alongside it (the same way Rollup's watcher reruns, and webpack
|
|
356
|
+
* discards a compilation that a change has outdated).
|
|
357
|
+
*/
|
|
358
|
+
async #watchRerun() {
|
|
359
|
+
if (this.#rerunning) {
|
|
360
|
+
this.#rerunAgain = true;
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
this.#rerunning = true;
|
|
364
|
+
try {
|
|
365
|
+
do {
|
|
366
|
+
this.#rerunAgain = false;
|
|
367
|
+
try {
|
|
368
|
+
await this.#enqueue(() => this.#resolveAgain());
|
|
369
|
+
}
|
|
370
|
+
catch (error) {
|
|
371
|
+
if (isFatalError(error)) {
|
|
372
|
+
throw error;
|
|
373
|
+
}
|
|
374
|
+
// Nobody awaits a re-resolution a file triggered; a failure of one (a
|
|
375
|
+
// parser that cannot be found, say) must not become an unhandled
|
|
376
|
+
// rejection, nor spare the event that arrived while it ran its rerun.
|
|
377
|
+
this.#emitWatchError(error);
|
|
378
|
+
}
|
|
379
|
+
} while (this.#rerunAgain && this.#options?.watch);
|
|
380
|
+
}
|
|
381
|
+
finally {
|
|
382
|
+
this.#rerunning = false;
|
|
383
|
+
if (!this.#watchDebounce) {
|
|
384
|
+
this.emit('log', 'watch:idle', this.#file.path);
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
/**
|
|
389
|
+
* A re-resolution is not only the config: it re-reads the files the pretenders
|
|
390
|
+
* depend on, which is what a change to one of them is for.
|
|
391
|
+
*/
|
|
392
|
+
async #resolveAgain() {
|
|
393
|
+
if (!this.#options?.watch) {
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
214
396
|
const fabric = await this.#provide(false);
|
|
215
|
-
if (!fabric) {
|
|
397
|
+
if (!fabric || !this.#options?.watch) {
|
|
216
398
|
return;
|
|
217
399
|
}
|
|
218
400
|
if (fabric.configErrors) {
|
|
@@ -220,7 +402,67 @@ export class MLEngine extends Emitter {
|
|
|
220
402
|
}
|
|
221
403
|
this.emit('log', 'update:core', this.#file.path);
|
|
222
404
|
this.#core?.update(fabric);
|
|
223
|
-
await this
|
|
405
|
+
await this.#exec();
|
|
406
|
+
}
|
|
407
|
+
#enqueue(task) {
|
|
408
|
+
const result = this.#queue.then(task, task);
|
|
409
|
+
// The queue goes on whether this task succeeded or not; its failure is the
|
|
410
|
+
// caller's, through `result`.
|
|
411
|
+
this.#queue = result.then(() => { }, () => { });
|
|
412
|
+
return result;
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Leaves the process-wide watcher. A later `watchMode(true)` subscribes anew.
|
|
416
|
+
*/
|
|
417
|
+
async #stopWatching() {
|
|
418
|
+
clearTimeout(this.#watchDebounce);
|
|
419
|
+
this.#watchDebounce = undefined;
|
|
420
|
+
this.#options = { ...this.#options, watch: false };
|
|
421
|
+
const watch = this.#watch;
|
|
422
|
+
this.#watch = null;
|
|
423
|
+
await watch?.close();
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* Points the process-wide watcher at what this engine depends on now: its
|
|
427
|
+
* config files and the files its pretenders were resolved from. Resolves
|
|
428
|
+
* once they are being watched, so that a change made after an `exec()` or
|
|
429
|
+
* `setCode()` has resolved is not missed.
|
|
430
|
+
*
|
|
431
|
+
* Left out: relative names (`markuplint:recommended`, the keys of inline
|
|
432
|
+
* configs, `extends` of an npm module) that {@link ConfigSet.files} also
|
|
433
|
+
* lists, and the lint target itself.
|
|
434
|
+
*
|
|
435
|
+
* Never rejects: a watcher that cannot be updated costs the engine its
|
|
436
|
+
* re-linting, not the `exec()` / `setCode()` whose result is already in hand.
|
|
437
|
+
*/
|
|
438
|
+
async #syncWatch() {
|
|
439
|
+
try {
|
|
440
|
+
await this.#updateWatch();
|
|
441
|
+
}
|
|
442
|
+
catch (error) {
|
|
443
|
+
if (isFatalError(error)) {
|
|
444
|
+
throw error;
|
|
445
|
+
}
|
|
446
|
+
this.#emitWatchError(error);
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
async #updateWatch() {
|
|
450
|
+
const watch = this.#watch;
|
|
451
|
+
if (!watch) {
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const targetKey = toWatchKey(this.#file.path);
|
|
455
|
+
const filePaths = new Set();
|
|
456
|
+
for (const filePath of [...this.#configFiles, ...this.#pretenderDependencies]) {
|
|
457
|
+
if (path.isAbsolute(filePath) && toWatchKey(filePath) !== targetKey) {
|
|
458
|
+
filePaths.add(filePath);
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
await watch.update(filePaths);
|
|
462
|
+
}
|
|
463
|
+
async #watchPretenderDependencies(dependencies) {
|
|
464
|
+
this.#pretenderDependencies = dependencies;
|
|
465
|
+
await this.#syncWatch();
|
|
224
466
|
}
|
|
225
467
|
async #provide(cache = true) {
|
|
226
468
|
let configSet;
|
|
@@ -380,10 +622,10 @@ export class MLEngine extends Emitter {
|
|
|
380
622
|
? { ...resolvedConfigSet, ruleDeprecations: [] }
|
|
381
623
|
: { ...resolvedConfigSet, config: aliasedConfig, ruleDeprecations: ruleAliasWarnings };
|
|
382
624
|
this.emit('config', this.#file.path, configSet);
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
625
|
+
// The main file is not watched (see `#syncWatch`): it may be watched and managed
|
|
626
|
+
// by a language server or text editor or more.
|
|
627
|
+
this.#configFiles = configSet.files;
|
|
628
|
+
await this.#syncWatch();
|
|
387
629
|
return configSet;
|
|
388
630
|
}
|
|
389
631
|
async #resolveParser(
|
|
@@ -404,14 +646,35 @@ export class MLEngine extends Emitter {
|
|
|
404
646
|
// resolving as it did before the change for the rest of the process's lifetime.
|
|
405
647
|
await invalidatePretenderResolutionCaches();
|
|
406
648
|
}
|
|
649
|
+
// One resolver per config resolution: this is the only place the
|
|
650
|
+
// pretenders that depend on the config and the filesystem (files /
|
|
651
|
+
// imports / data / scan) are read. `setCode()` reuses it.
|
|
652
|
+
this.#pretenderResolver = createPretenderResolver(configSet.config.pretenders);
|
|
653
|
+
const { pretenders, dependencies } = await this.#resolvePretendersForCurrentCode();
|
|
654
|
+
await this.#watchPretenderDependencies(dependencies);
|
|
655
|
+
return pretenders;
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* Disambiguation runs here, on every call, and not only at config
|
|
659
|
+
* resolution: it reads the target's import statements, so it is as
|
|
660
|
+
* source-dependent as `auto` is.
|
|
661
|
+
*
|
|
662
|
+
* @returns The pretenders, and the files they were resolved from, which the
|
|
663
|
+
* caller watches once it has adopted this resolution (a superseded one is dropped).
|
|
664
|
+
*/
|
|
665
|
+
async #resolvePretendersForCurrentCode() {
|
|
666
|
+
const resolver = this.#pretenderResolver;
|
|
667
|
+
const dependencies = new Set();
|
|
668
|
+
if (!resolver) {
|
|
669
|
+
return { pretenders: [], dependencies };
|
|
670
|
+
}
|
|
407
671
|
const sourceCode = await this.#file.getCode();
|
|
408
|
-
const pretenders = await
|
|
409
|
-
|
|
410
|
-
|
|
672
|
+
const pretenders = await resolver.resolve({ filePath: this.#file.path, sourceCode }, { dependencies });
|
|
673
|
+
const disambiguated = await disambiguatePretendersForFile(this.#file.path, sourceCode, pretenders, {
|
|
674
|
+
dependencies,
|
|
411
675
|
});
|
|
412
|
-
const disambiguated = await disambiguatePretendersForFile(this.#file.path, sourceCode, pretenders);
|
|
413
676
|
fileLog('Resolved pretenders: %O', disambiguated);
|
|
414
|
-
return disambiguated;
|
|
677
|
+
return { pretenders: disambiguated, dependencies };
|
|
415
678
|
}
|
|
416
679
|
async #resolveRules(plugins, ruleset) {
|
|
417
680
|
const rules = await resolveRules(plugins, ruleset, this.#options?.importPresetRules ?? true);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module shared-watcher
|
|
3
|
+
*
|
|
4
|
+
* One file watcher for the whole process, which every watching `MLEngine`
|
|
5
|
+
* subscribes to for the files it depends on: its config files and, since #4065,
|
|
6
|
+
* the files `pretenders.scan` / `pretenders.auto` read.
|
|
7
|
+
*
|
|
8
|
+
* Why not one `FSWatcher` per engine: chokidar keeps the `fs.watch` handle of a
|
|
9
|
+
* path in a module-level map, so every watcher of a process that watches the
|
|
10
|
+
* same file shares it — and when an editor saves through a rename (the usual
|
|
11
|
+
* way), each watcher re-attaches to the new inode by closing its own closer and
|
|
12
|
+
* opening a new one, which finds the old shared handle still held by the other
|
|
13
|
+
* watchers and attaches to that. After the second such save nobody is told of
|
|
14
|
+
* anything any more. The VS Code server runs one engine per open document, and
|
|
15
|
+
* a component is shared by many documents, so that is the normal case here.
|
|
16
|
+
* With a single watcher per process the handle has a single owner.
|
|
17
|
+
*
|
|
18
|
+
* What follows from how chokidar behaves, and shapes the code below:
|
|
19
|
+
*
|
|
20
|
+
* - A watcher that has been `close()`d is not revived: its listeners are gone,
|
|
21
|
+
* its `ready` is spent, and its ignore list stays. A subscriber arriving
|
|
22
|
+
* after all others have left gets a new watcher (a "generation"), and events
|
|
23
|
+
* of an old one are dropped.
|
|
24
|
+
* - Only file paths are `unwatch()`ed. Unwatching a directory ignores
|
|
25
|
+
* everything under it until that directory is added again, and directories
|
|
26
|
+
* are watched on this module's behalf (for a file that does not exist yet).
|
|
27
|
+
* They are released with the generation.
|
|
28
|
+
* - `add()` returns before the path is being watched, and nothing signals
|
|
29
|
+
* completion. `update()` waits for it, by watching `getWatched()`, so that a
|
|
30
|
+
* caller who has awaited it can rely on the next change being seen.
|
|
31
|
+
* - After `unlink`, chokidar re-attaches by itself only while it watches a
|
|
32
|
+
* single path. Here it watches many, so the path is added again.
|
|
33
|
+
* - A removal is reported as `unlink` only after chokidar's 100ms atomic-write
|
|
34
|
+
* window has passed without the file coming back; a save through a temporary
|
|
35
|
+
* file never shows as one.
|
|
36
|
+
* - `unwatch()` adds the path to chokidar's ignore list until the next
|
|
37
|
+
* `add()` of it, and the list is only cleared with the generation.
|
|
38
|
+
*/
|
|
39
|
+
export type WatchEventName = 'add' | 'change' | 'unlink';
|
|
40
|
+
export type WatchListener = (filePath: string, event: WatchEventName) => void;
|
|
41
|
+
/**
|
|
42
|
+
* A subscriber's view of the shared watcher.
|
|
43
|
+
*/
|
|
44
|
+
export type WatchSubscription = {
|
|
45
|
+
/**
|
|
46
|
+
* Sets the files the subscriber is told about. Resolves once chokidar is
|
|
47
|
+
* watching all of them (or gives up waiting), so a change made after it
|
|
48
|
+
* resolves is seen. Does nothing once the subscription is closed.
|
|
49
|
+
*
|
|
50
|
+
* @param filePaths - Absolute paths; a file that does not exist yet is
|
|
51
|
+
* watched for its creation. Relative paths resolve against the cwd.
|
|
52
|
+
*/
|
|
53
|
+
update(filePaths: Iterable<string>): Promise<void>;
|
|
54
|
+
/**
|
|
55
|
+
* Leaves the watcher; the last one to leave closes it, and resolves once its
|
|
56
|
+
* handles are released.
|
|
57
|
+
*/
|
|
58
|
+
close(): Promise<void>;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* The key two spellings of one file share: absolute, and without case on
|
|
62
|
+
* Windows (VS Code reports `c:\…`, TypeScript's real path says `C:\…`).
|
|
63
|
+
* Only for telling files apart — chokidar is given the path as it came.
|
|
64
|
+
*
|
|
65
|
+
* @param filePath - A path
|
|
66
|
+
* @returns The path in its comparable form
|
|
67
|
+
*/
|
|
68
|
+
export declare function toWatchKey(filePath: string): string;
|
|
69
|
+
/**
|
|
70
|
+
* @param listener - Called with the path as given to {@link WatchSubscription.update}
|
|
71
|
+
* @param onError - Called with the errors chokidar reports (a path it cannot
|
|
72
|
+
* watch, such as when the OS watch limit is reached); fatal ones are thrown
|
|
73
|
+
* @returns The subscription, which watches nothing until `update()`
|
|
74
|
+
*/
|
|
75
|
+
export declare function subscribe(listener: WatchListener, onError?: (error: unknown) => void): WatchSubscription;
|
|
76
|
+
/**
|
|
77
|
+
* Resolves once the shared watcher is watching `filePath` again — after an
|
|
78
|
+
* `unlink`, when it has been re-attached asynchronously.
|
|
79
|
+
*
|
|
80
|
+
* @param filePath - A path some subscriber gave to `update()`
|
|
81
|
+
*/
|
|
82
|
+
export declare function whenWatching(filePath: string): Promise<void>;
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @module shared-watcher
|
|
3
|
+
*
|
|
4
|
+
* One file watcher for the whole process, which every watching `MLEngine`
|
|
5
|
+
* subscribes to for the files it depends on: its config files and, since #4065,
|
|
6
|
+
* the files `pretenders.scan` / `pretenders.auto` read.
|
|
7
|
+
*
|
|
8
|
+
* Why not one `FSWatcher` per engine: chokidar keeps the `fs.watch` handle of a
|
|
9
|
+
* path in a module-level map, so every watcher of a process that watches the
|
|
10
|
+
* same file shares it — and when an editor saves through a rename (the usual
|
|
11
|
+
* way), each watcher re-attaches to the new inode by closing its own closer and
|
|
12
|
+
* opening a new one, which finds the old shared handle still held by the other
|
|
13
|
+
* watchers and attaches to that. After the second such save nobody is told of
|
|
14
|
+
* anything any more. The VS Code server runs one engine per open document, and
|
|
15
|
+
* a component is shared by many documents, so that is the normal case here.
|
|
16
|
+
* With a single watcher per process the handle has a single owner.
|
|
17
|
+
*
|
|
18
|
+
* What follows from how chokidar behaves, and shapes the code below:
|
|
19
|
+
*
|
|
20
|
+
* - A watcher that has been `close()`d is not revived: its listeners are gone,
|
|
21
|
+
* its `ready` is spent, and its ignore list stays. A subscriber arriving
|
|
22
|
+
* after all others have left gets a new watcher (a "generation"), and events
|
|
23
|
+
* of an old one are dropped.
|
|
24
|
+
* - Only file paths are `unwatch()`ed. Unwatching a directory ignores
|
|
25
|
+
* everything under it until that directory is added again, and directories
|
|
26
|
+
* are watched on this module's behalf (for a file that does not exist yet).
|
|
27
|
+
* They are released with the generation.
|
|
28
|
+
* - `add()` returns before the path is being watched, and nothing signals
|
|
29
|
+
* completion. `update()` waits for it, by watching `getWatched()`, so that a
|
|
30
|
+
* caller who has awaited it can rely on the next change being seen.
|
|
31
|
+
* - After `unlink`, chokidar re-attaches by itself only while it watches a
|
|
32
|
+
* single path. Here it watches many, so the path is added again.
|
|
33
|
+
* - A removal is reported as `unlink` only after chokidar's 100ms atomic-write
|
|
34
|
+
* window has passed without the file coming back; a save through a temporary
|
|
35
|
+
* file never shows as one.
|
|
36
|
+
* - `unwatch()` adds the path to chokidar's ignore list until the next
|
|
37
|
+
* `add()` of it, and the list is only cleared with the generation.
|
|
38
|
+
*/
|
|
39
|
+
import path from 'node:path';
|
|
40
|
+
import { isFatalError } from '@markuplint/shared';
|
|
41
|
+
import { FSWatcher } from 'chokidar';
|
|
42
|
+
/**
|
|
43
|
+
* The longest `update()` waits for chokidar to start watching. A path that is
|
|
44
|
+
* not being watched by then (a failure chokidar reports as an `error`) is not
|
|
45
|
+
* waited for any more; the update still resolves.
|
|
46
|
+
*/
|
|
47
|
+
const ARM_TIMEOUT_MS = 2000;
|
|
48
|
+
/**
|
|
49
|
+
* Deno implements `fs.watch` of a file with an inotify watch on its inode, and
|
|
50
|
+
* counts the watches of a path: when chokidar re-attaches to a file that an
|
|
51
|
+
* atomic save has replaced, by closing its watch and opening a new one on the
|
|
52
|
+
* same path at once, the new watch is believed to exist already and stays on
|
|
53
|
+
* the old inode, deaf to every save after the second (Deno documents the
|
|
54
|
+
* atomic save dropping its own watcher on Linux, at
|
|
55
|
+
* https://docs.deno.com/runtime/run/watch_mode/). Polling does not go through
|
|
56
|
+
* that watch. Unconfirmed against Deno's source; it is what the CI of the
|
|
57
|
+
* Deno job shows.
|
|
58
|
+
*/
|
|
59
|
+
const IS_DENO = 'deno' in process.versions;
|
|
60
|
+
const DENO_POLL_INTERVAL_MS = 50;
|
|
61
|
+
let current = null;
|
|
62
|
+
/**
|
|
63
|
+
* The key two spellings of one file share: absolute, and without case on
|
|
64
|
+
* Windows (VS Code reports `c:\…`, TypeScript's real path says `C:\…`).
|
|
65
|
+
* Only for telling files apart — chokidar is given the path as it came.
|
|
66
|
+
*
|
|
67
|
+
* @param filePath - A path
|
|
68
|
+
* @returns The path in its comparable form
|
|
69
|
+
*/
|
|
70
|
+
export function toWatchKey(filePath) {
|
|
71
|
+
const resolved = path.resolve(filePath);
|
|
72
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved;
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* @param listener - Called with the path as given to {@link WatchSubscription.update}
|
|
76
|
+
* @param onError - Called with the errors chokidar reports (a path it cannot
|
|
77
|
+
* watch, such as when the OS watch limit is reached); fatal ones are thrown
|
|
78
|
+
* @returns The subscription, which watches nothing until `update()`
|
|
79
|
+
*/
|
|
80
|
+
export function subscribe(listener, onError) {
|
|
81
|
+
current ??= createGeneration();
|
|
82
|
+
const generation = current;
|
|
83
|
+
const subscriber = { generation, listener, onError, keys: new Set(), closed: false };
|
|
84
|
+
generation.subscribers.add(subscriber);
|
|
85
|
+
return {
|
|
86
|
+
update: filePaths => update(subscriber, filePaths),
|
|
87
|
+
close: () => close(subscriber),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Resolves once the shared watcher is watching `filePath` again — after an
|
|
92
|
+
* `unlink`, when it has been re-attached asynchronously.
|
|
93
|
+
*
|
|
94
|
+
* @param filePath - A path some subscriber gave to `update()`
|
|
95
|
+
*/
|
|
96
|
+
export async function whenWatching(filePath) {
|
|
97
|
+
const generation = current;
|
|
98
|
+
const entry = generation?.entries.get(toWatchKey(filePath));
|
|
99
|
+
if (generation && entry) {
|
|
100
|
+
await untilWatched(generation, [entry]);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
function createGeneration() {
|
|
104
|
+
const watcher = new FSWatcher({
|
|
105
|
+
ignoreInitial: true,
|
|
106
|
+
...(IS_DENO ? { usePolling: true, interval: DENO_POLL_INTERVAL_MS } : {}),
|
|
107
|
+
});
|
|
108
|
+
const generation = { watcher, entries: new Map(), subscribers: new Set() };
|
|
109
|
+
watcher.on('add', filePath => dispatch(generation, 'add', filePath));
|
|
110
|
+
watcher.on('change', filePath => dispatch(generation, 'change', filePath));
|
|
111
|
+
watcher.on('unlink', filePath => dispatch(generation, 'unlink', filePath));
|
|
112
|
+
watcher.on('error', error => {
|
|
113
|
+
if (isFatalError(error)) {
|
|
114
|
+
throw error;
|
|
115
|
+
}
|
|
116
|
+
for (const subscriber of generation.subscribers) {
|
|
117
|
+
subscriber.onError?.(error);
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
return generation;
|
|
121
|
+
}
|
|
122
|
+
function dispatch(generation, event, filePath) {
|
|
123
|
+
if (current !== generation) {
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
const entry = generation.entries.get(toWatchKey(filePath));
|
|
127
|
+
if (!entry) {
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
if (event === 'unlink') {
|
|
131
|
+
// chokidar let go of the path; add it again so that its coming back is seen.
|
|
132
|
+
entry.watched = false;
|
|
133
|
+
entry.gaveUp = false;
|
|
134
|
+
generation.watcher.add(entry.path);
|
|
135
|
+
}
|
|
136
|
+
for (const subscriber of entry.subscribers) {
|
|
137
|
+
subscriber.listener(entry.path, event);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
async function update(subscriber, filePaths) {
|
|
141
|
+
if (subscriber.closed) {
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
const { generation } = subscriber;
|
|
145
|
+
const next = new Map();
|
|
146
|
+
for (const filePath of filePaths) {
|
|
147
|
+
next.set(toWatchKey(filePath), filePath);
|
|
148
|
+
}
|
|
149
|
+
for (const key of subscriber.keys) {
|
|
150
|
+
if (!next.has(key)) {
|
|
151
|
+
release(generation, subscriber, key);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const joined = [];
|
|
155
|
+
for (const [key, filePath] of next) {
|
|
156
|
+
let entry = generation.entries.get(key);
|
|
157
|
+
if (!entry) {
|
|
158
|
+
entry = { path: filePath, subscribers: new Set(), watched: false, gaveUp: false };
|
|
159
|
+
generation.entries.set(key, entry);
|
|
160
|
+
generation.watcher.add(filePath);
|
|
161
|
+
}
|
|
162
|
+
entry.subscribers.add(subscriber);
|
|
163
|
+
joined.push(entry);
|
|
164
|
+
}
|
|
165
|
+
subscriber.keys = new Set(next.keys());
|
|
166
|
+
await untilWatched(generation, joined);
|
|
167
|
+
}
|
|
168
|
+
function release(generation, subscriber, key) {
|
|
169
|
+
const entry = generation.entries.get(key);
|
|
170
|
+
if (!entry) {
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
entry.subscribers.delete(subscriber);
|
|
174
|
+
if (entry.subscribers.size === 0) {
|
|
175
|
+
generation.entries.delete(key);
|
|
176
|
+
// A file path only: see the module JSDoc.
|
|
177
|
+
generation.watcher.unwatch(entry.path);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
async function close(subscriber) {
|
|
181
|
+
if (subscriber.closed) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
subscriber.closed = true;
|
|
185
|
+
const { generation } = subscriber;
|
|
186
|
+
for (const key of subscriber.keys) {
|
|
187
|
+
release(generation, subscriber, key);
|
|
188
|
+
}
|
|
189
|
+
subscriber.keys = new Set();
|
|
190
|
+
generation.subscribers.delete(subscriber);
|
|
191
|
+
if (generation.subscribers.size === 0) {
|
|
192
|
+
if (current === generation) {
|
|
193
|
+
current = null;
|
|
194
|
+
}
|
|
195
|
+
await generation.watcher.close();
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
async function untilWatched(generation, entries) {
|
|
199
|
+
let pending = entries.filter(entry => !entry.watched && !entry.gaveUp);
|
|
200
|
+
const deadline = Date.now() + ARM_TIMEOUT_MS;
|
|
201
|
+
while (pending.length > 0 && current === generation) {
|
|
202
|
+
const watched = generation.watcher.getWatched();
|
|
203
|
+
pending = pending.filter(entry => {
|
|
204
|
+
entry.watched = isWatched(watched, entry.path);
|
|
205
|
+
return !entry.watched;
|
|
206
|
+
});
|
|
207
|
+
if (pending.length === 0) {
|
|
208
|
+
break;
|
|
209
|
+
}
|
|
210
|
+
if (Date.now() >= deadline) {
|
|
211
|
+
for (const entry of pending) {
|
|
212
|
+
entry.gaveUp = true;
|
|
213
|
+
}
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
await new Promise(resolve => setTimeout(resolve, 1));
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* Whether chokidar has set up watching for `filePath`: it lists the file under
|
|
221
|
+
* its directory once it holds a handle on it; and for a file that does not
|
|
222
|
+
* exist yet it watches the nearest existing ancestor directory (listing it
|
|
223
|
+
* under *its* parent) for the file to appear.
|
|
224
|
+
*/
|
|
225
|
+
function isWatched(watched, filePath) {
|
|
226
|
+
let target = filePath;
|
|
227
|
+
while (true) {
|
|
228
|
+
const directory = path.dirname(target);
|
|
229
|
+
if (watched[directory]?.includes(path.basename(target))) {
|
|
230
|
+
return true;
|
|
231
|
+
}
|
|
232
|
+
if (directory === target) {
|
|
233
|
+
return false;
|
|
234
|
+
}
|
|
235
|
+
target = directory;
|
|
236
|
+
}
|
|
237
|
+
}
|
package/lib/cli/command.js
CHANGED
|
@@ -68,7 +68,34 @@ export async function command(files, options, apiOptions) {
|
|
|
68
68
|
// across the whole run (not per file).
|
|
69
69
|
const seenConfigMessages = new Set();
|
|
70
70
|
const filesContent = new Map();
|
|
71
|
-
|
|
71
|
+
// Suppressions scope computation is the only consumer of the parsed
|
|
72
|
+
// documents, so a document is retained past its file's own iteration only
|
|
73
|
+
// when that computation will run (--suppress, or a non-empty suppressions
|
|
74
|
+
// file in a normal run). Retaining every file's document otherwise keeps N
|
|
75
|
+
// DOM trees reachable until the command ends.
|
|
76
|
+
//
|
|
77
|
+
// A normal run reads the suppressions file exactly once, here, and applies
|
|
78
|
+
// that same snapshot at the end: deciding retention from one read and
|
|
79
|
+
// applying from a later one could disagree if the file changed mid-run,
|
|
80
|
+
// leaving `applySuppressions` without the node lists it needs.
|
|
81
|
+
const suppressionsFilePath = resolveSuppressionsPath(options.suppressionsLocation);
|
|
82
|
+
let normalRunSuppressions = {};
|
|
83
|
+
let suppressionsReadError;
|
|
84
|
+
if (!isSuppressMode && !isPruneMode) {
|
|
85
|
+
try {
|
|
86
|
+
normalRunSuppressions = await readSuppressionsFile(suppressionsFilePath);
|
|
87
|
+
}
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (isFatalError(error)) {
|
|
90
|
+
throw error;
|
|
91
|
+
}
|
|
92
|
+
// Proceed as if there were no suppressions; only the progressive
|
|
93
|
+
// output check below treats an unreadable file as an error.
|
|
94
|
+
suppressionsReadError = error;
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const needsScope = isSuppressMode || Object.keys(normalRunSuppressions).length > 0;
|
|
98
|
+
const nodeLists = new Map();
|
|
72
99
|
// Shared across every file in this run so its config cache — keyed by
|
|
73
100
|
// resolved config `names`, not by target file — actually helps: config
|
|
74
101
|
// loading/merging/plugin-resolution is done once per distinct config,
|
|
@@ -99,9 +126,10 @@ export async function command(files, options, apiOptions) {
|
|
|
99
126
|
progressiveOutput = false;
|
|
100
127
|
}
|
|
101
128
|
if (progressiveOutput && !isSuppressMode && !isPruneMode) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
129
|
+
if (suppressionsReadError != null) {
|
|
130
|
+
throw suppressionsReadError;
|
|
131
|
+
}
|
|
132
|
+
if (needsScope) {
|
|
105
133
|
progressiveOutput = false;
|
|
106
134
|
}
|
|
107
135
|
}
|
|
@@ -157,8 +185,16 @@ export async function command(files, options, apiOptions) {
|
|
|
157
185
|
sourceCode: result.sourceCode,
|
|
158
186
|
fixedCode: result.fixedCode,
|
|
159
187
|
});
|
|
160
|
-
|
|
161
|
-
|
|
188
|
+
if (needsScope) {
|
|
189
|
+
const doc = engine.document;
|
|
190
|
+
if (doc) {
|
|
191
|
+
// MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
|
|
192
|
+
// id, classList, parentElement, children are all present). The double cast is
|
|
193
|
+
// needed because TypeScript can't verify structural compatibility between the
|
|
194
|
+
// generic MLNode<T,O> and the plain PositionedNode interface at compile time.
|
|
195
|
+
nodeLists.set(result.filePath, doc.nodeList);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
162
198
|
// In fix mode, report the violations remaining in the FIXED code
|
|
163
199
|
// (re-verified by ml-core) instead of the pre-fix violations, so that
|
|
164
200
|
// the exit code and suppressions reflect the written output.
|
|
@@ -197,20 +233,7 @@ export async function command(files, options, apiOptions) {
|
|
|
197
233
|
}
|
|
198
234
|
}
|
|
199
235
|
// --- Suppressions handling ---
|
|
200
|
-
const suppressionsFilePath = resolveSuppressionsPath(options.suppressionsLocation);
|
|
201
236
|
const collectedViolationsByFile = collector.groupByFile();
|
|
202
|
-
// Build nodeLists map from engines for scope computation
|
|
203
|
-
const nodeLists = new Map();
|
|
204
|
-
for (const [filePath, engine] of engines) {
|
|
205
|
-
const doc = engine.document;
|
|
206
|
-
if (doc) {
|
|
207
|
-
// MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
|
|
208
|
-
// id, classList, parentElement, children are all present). The double cast is
|
|
209
|
-
// needed because TypeScript can't verify structural compatibility between the
|
|
210
|
-
// generic MLNode<T,O> and the plain PositionedNode interface at compile time.
|
|
211
|
-
nodeLists.set(filePath, doc.nodeList);
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
237
|
if (isSuppressMode) {
|
|
215
238
|
// Suppress mode: generate/update suppressions file
|
|
216
239
|
const existing = await readSuppressionsFile(suppressionsFilePath);
|
|
@@ -250,10 +273,8 @@ export async function command(files, options, apiOptions) {
|
|
|
250
273
|
let outputViolationsByFile = collectedViolationsByFile;
|
|
251
274
|
let suppressionsApplied = false;
|
|
252
275
|
try {
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
if (Object.keys(suppressionsData).length > 0) {
|
|
256
|
-
const { filtered, unusedEntries } = applySuppressions(collectedViolationsByFile, suppressionsData, suppressionsFilePath, { nodeLists });
|
|
276
|
+
if (needsScope) {
|
|
277
|
+
const { filtered, unusedEntries } = applySuppressions(collectedViolationsByFile, normalRunSuppressions, suppressionsFilePath, { nodeLists });
|
|
257
278
|
outputViolationsByFile = filtered;
|
|
258
279
|
suppressionsApplied = true;
|
|
259
280
|
if (unusedEntries.length > 0) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "markuplint",
|
|
3
|
-
"version": "5.0
|
|
3
|
+
"version": "5.1.0",
|
|
4
4
|
"description": "An HTML linter for all markup developers",
|
|
5
5
|
"author": "Yusuke Hirao",
|
|
6
6
|
"license": "MIT",
|
|
@@ -35,17 +35,17 @@
|
|
|
35
35
|
"clean": "tsc --build --clean tsconfig.build.json"
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
|
-
"@markuplint/cli-utils": "5.0
|
|
39
|
-
"@markuplint/file-resolver": "5.0
|
|
40
|
-
"@markuplint/html-parser": "5.0
|
|
41
|
-
"@markuplint/html-spec": "5.0
|
|
42
|
-
"@markuplint/i18n": "5.0
|
|
43
|
-
"@markuplint/ml-ast": "5.0
|
|
44
|
-
"@markuplint/ml-config": "5.0
|
|
45
|
-
"@markuplint/ml-core": "5.0
|
|
46
|
-
"@markuplint/ml-spec": "5.0
|
|
47
|
-
"@markuplint/rules": "5.0
|
|
48
|
-
"@markuplint/shared": "5.0
|
|
38
|
+
"@markuplint/cli-utils": "5.1.0",
|
|
39
|
+
"@markuplint/file-resolver": "5.1.0",
|
|
40
|
+
"@markuplint/html-parser": "5.1.0",
|
|
41
|
+
"@markuplint/html-spec": "5.1.0",
|
|
42
|
+
"@markuplint/i18n": "5.1.0",
|
|
43
|
+
"@markuplint/ml-ast": "5.1.0",
|
|
44
|
+
"@markuplint/ml-config": "5.1.0",
|
|
45
|
+
"@markuplint/ml-core": "5.1.0",
|
|
46
|
+
"@markuplint/ml-spec": "5.1.0",
|
|
47
|
+
"@markuplint/rules": "5.1.0",
|
|
48
|
+
"@markuplint/shared": "5.1.0",
|
|
49
49
|
"@types/debug": "4.1.13",
|
|
50
50
|
"chokidar": "5.0.0",
|
|
51
51
|
"debug": "4.4.3",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"strip-ansi": "7.2.0",
|
|
56
56
|
"type-fest": "5.6.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
|
|
59
59
|
}
|