markuplint 5.0.1 → 5.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -3,6 +3,31 @@
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.1](https://github.com/markuplint/markuplint/compare/v5.1.0...v5.1.1) (2026-10-09)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **markuplint:** fail a file whose config cannot be loaded instead of passing it ([4579d89](https://github.com/markuplint/markuplint/commit/4579d890e4b3e18f34fbe91f8b8d7f59ca886e92))
11
+
12
+ # [5.1.0](https://github.com/markuplint/markuplint/compare/v5.0.1...v5.1.0) (2026-10-08)
13
+
14
+ ### Bug Fixes
15
+
16
+ - **markuplint:** poll for file changes under Deno ([ccfeea1](https://github.com/markuplint/markuplint/commit/ccfeea1e1726fda526deff979cafac5093f179bb))
17
+ - **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)
18
+ - **ml-spec:** read option, SVG title and control text through the accname resolver ([4c66d1f](https://github.com/markuplint/markuplint/commit/4c66d1fb960e746dc9f3c6cec0c8b3c9284a657e))
19
+ - **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)
20
+ - **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)
21
+
22
+ ### Features
23
+
24
+ - **language-server:** add a language server that runs on any LSP client ([ab03edc](https://github.com/markuplint/markuplint/commit/ab03edc35457b6502a9d55e176a8adca5427f796))
25
+ - **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)
26
+
27
+ ### Performance Improvements
28
+
29
+ - **markuplint:** retain parsed documents only when suppressions scope needs them ([1f5326a](https://github.com/markuplint/markuplint/commit/1f5326a14d8efbf1da441452b192bcfcd3892384))
30
+
6
31
  ## [5.0.1](https://github.com/markuplint/markuplint/compare/v5.0.0...v5.0.1) (2026-09-22)
7
32
 
8
33
  ### Bug Fixes
@@ -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 stopping the file watcher.
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 configuration.
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 config files
110
- * for changes and re-lints automatically.
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
  */
@@ -1,12 +1,13 @@
1
- import { ConfigProvider, disambiguatePretendersForFile, invalidatePretenderResolutionCaches, resolveFiles, resolveParser, resolvePretenders, resolveRules, resolveSpecs, } from '@markuplint/file-resolver';
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
- import { MLCore, convertRuleset } from '@markuplint/ml-core';
4
+ import { CONFIG_ERROR_RULE_ID, MLCore, convertRuleset } from '@markuplint/ml-core';
4
5
  import { ruleAliasTable } from '@markuplint/rules';
5
- import { isFatalError } from '@markuplint/shared';
6
- import { FSWatcher } from 'chokidar';
6
+ import { ConfigLoadError, isFatalError } from '@markuplint/shared';
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.
@@ -61,10 +71,65 @@ export class MLEngine extends Emitter {
61
71
  return files[0];
62
72
  }
63
73
  #configProvider;
74
+ /**
75
+ * Why the latest config resolution left nothing to lint with: a config that
76
+ * cannot be loaded (Tier 2). `null` while the latest resolution succeeded.
77
+ *
78
+ * Set by `#provide` and reported by `#reportConfigFailure` as one
79
+ * error-severity `config-error` for the file. It is not folded into an empty
80
+ * config plus a warning: a file that no rule has looked at must not pass.
81
+ */
82
+ #configFailure = null;
64
83
  #core = null;
65
84
  #file;
66
85
  #options;
67
- #watcher = new FSWatcher();
86
+ /**
87
+ * The resolver of the latest config resolution, kept so `setCode()` can
88
+ * re-resolve the pretenders that depend on the source without re-reading
89
+ * the ones that depend on the config and the filesystem only. See #4064.
90
+ */
91
+ #pretenderResolver = null;
92
+ /**
93
+ * Counts `setCode()` calls so that, when they overlap, only the latest one
94
+ * reaches the file and the core — `setCode()` awaits the pretender
95
+ * re-resolution, and an earlier call finishing later must not put its
96
+ * older code back.
97
+ */
98
+ #setCodeSeq = 0;
99
+ /**
100
+ * The latest `setCode()` call's application, so that a superseded call can
101
+ * resolve only after it has taken effect.
102
+ */
103
+ #latestSetCode = Promise.resolve();
104
+ /**
105
+ * What is running, so that the next piece of work starts when it is over:
106
+ * {@link exec}, the application of {@link setCode}, and the re-resolution a
107
+ * watched file triggers share one queue. They all read and replace the same
108
+ * state — the pretender resolver, the core's document — and `verify()` with
109
+ * `fix` rewrites the document while it runs; run side by side, a lint could
110
+ * see another's half-done state, and the one that finished last would put
111
+ * its older pretenders over a newer resolution.
112
+ */
113
+ #queue = Promise.resolve();
114
+ /**
115
+ * This engine's view of the process-wide watcher (see `./shared-watcher.ts`);
116
+ * `null` unless watching.
117
+ */
118
+ #watch = null;
119
+ /** The config files of the latest resolution, as {@link ConfigSet.files} names them. */
120
+ #configFiles = new Set();
121
+ /**
122
+ * The files the latest pretenders resolution read (see
123
+ * `ResolvePretendersOptions#dependencies`). `setCode()` replaces them, as
124
+ * the `auto` ones follow the source's imports.
125
+ */
126
+ #pretenderDependencies = new Set();
127
+ /** The pending gathering of watch events into one re-resolution. */
128
+ #watchDebounce;
129
+ /** Whether the loop of re-resolutions is running, one at a time. */
130
+ #rerunning = false;
131
+ /** Set by an event that arrives while a re-resolution is running: it ran on state older than the event. */
132
+ #rerunAgain = false;
68
133
  constructor(file, options) {
69
134
  super();
70
135
  if (this.#options?.debug) {
@@ -86,23 +151,36 @@ export class MLEngine extends Emitter {
86
151
  return this.#core?.document ?? null;
87
152
  }
88
153
  /**
89
- * Closes the engine, removing all event listeners and stopping the file watcher.
154
+ * Closes the engine, removing all event listeners and leaving the file
155
+ * watcher (the files no other engine watches are released with it).
90
156
  */
91
157
  async close() {
92
158
  this.removeAllListeners();
93
- await this.#watcher.close();
159
+ await this.#stopWatching();
94
160
  }
95
161
  /**
96
162
  * Executes linting on the target file and returns the results.
97
163
  *
98
164
  * Sets up the engine on first call, then verifies the document against all rules.
99
165
  *
166
+ * Runs after whatever the engine is already doing — an earlier `exec()`, a
167
+ * {@link setCode} call, a re-resolution a watched file triggered — and
168
+ * before whatever is asked for later. That also means that awaiting it from
169
+ * inside one of the engine's own event listeners waits on itself.
170
+ *
100
171
  * @returns The lint result including violations and fixed code, or `null` if setup was skipped
101
172
  */
102
- async exec() {
173
+ exec() {
174
+ return this.#enqueue(() => this.#exec());
175
+ }
176
+ async #exec() {
103
177
  log('exec: start');
104
178
  const core = await this.#setup();
105
179
  if (!core) {
180
+ if (this.#configFailure) {
181
+ log('exec: config cannot be loaded');
182
+ return this.#reportConfigFailure(this.#configFailure);
183
+ }
106
184
  log('exec: cancel (unsetuped yet)');
107
185
  return null;
108
186
  }
@@ -160,21 +238,71 @@ export class MLEngine extends Emitter {
160
238
  };
161
239
  }
162
240
  /**
163
- * Updates the source code and re-parses the document without re-resolving configuration.
241
+ * Updates the source code and re-parses the document without re-resolving
242
+ * configuration.
243
+ *
244
+ * The pretenders that depend on the source — `pretenders.auto`, which walks
245
+ * the file's own imports, and the disambiguation of same-selector entries,
246
+ * which reads them too — are re-resolved from the new code; the ones that
247
+ * depend on the config and the filesystem only are kept from the latest
248
+ * config resolution (see #4064 and {@link PretenderResolver}).
249
+ *
250
+ * When calls overlap, only the latest one takes effect, and every call
251
+ * settles only once that latest one has — so a host that runs
252
+ * {@link exec} right after awaiting any of them (the VS Code extension
253
+ * does) lints the document the file now holds, never the previous
254
+ * document against the newer file. For the same reason a superseded call
255
+ * rejects when the latest one fails: resolving would tell its caller the
256
+ * latest code is in place when it is not.
164
257
  *
165
258
  * @param code - The new markup source code
166
259
  */
167
260
  async setCode(code) {
261
+ // Numbered when called, not when its turn in the queue comes: a call that
262
+ // a later one has overtaken by then steps aside without resolving anything.
263
+ const seq = ++this.#setCodeSeq;
264
+ const applied = this.#enqueue(() => this.#applyCode(code, seq));
265
+ this.#latestSetCode = applied;
266
+ await applied;
267
+ let awaited = applied;
268
+ while (this.#latestSetCode !== awaited) {
269
+ awaited = this.#latestSetCode;
270
+ await awaited;
271
+ }
272
+ }
273
+ /**
274
+ * The body of one {@link setCode} call. Two checks against the sequence
275
+ * number: the one after setup spares a superseded call its resolution;
276
+ * the one after resolution keeps a superseded call that was already
277
+ * resolving from overwriting the newer result.
278
+ */
279
+ async #applyCode(code, seq) {
168
280
  const core = await this.#setup();
169
- if (!core) {
281
+ if (!core || seq !== this.#setCodeSeq) {
170
282
  return;
171
283
  }
172
284
  this.#file.setCode(code);
173
- core.setCode(code);
285
+ const { pretenders, dependencies } = await this.#resolvePretendersForCurrentCode();
286
+ if (seq !== this.#setCodeSeq) {
287
+ return;
288
+ }
289
+ core.setCode(code, { pretenders });
290
+ await this.#watchPretenderDependencies(dependencies);
174
291
  }
175
292
  /**
176
- * Enables or disables watch mode. When enabled, the engine watches config files
177
- * for changes and re-lints automatically.
293
+ * Enables or disables watch mode. When enabled, the engine watches the
294
+ * files its result depends on — config files, and the component files
295
+ * `pretenders.scan` and `pretenders.auto` read — and re-lints automatically
296
+ * when one changes, is created, or is removed. Changes that arrive together
297
+ * are one re-lint, and the lint target itself is not watched: it may be
298
+ * watched and managed by a language server or text editor, which tells the
299
+ * engine through {@link setCode}.
300
+ *
301
+ * Not watched, so a change to them is picked up only by the next
302
+ * config-triggered re-lint: files that do not exist yet (a component that
303
+ * has not been created, a `scan` glob's new match), `node_modules`,
304
+ * `pretenders.files` and `pretenders.imports`. See
305
+ * `PretenderScanOptions#dependencies` in `@markuplint/ml-config`.
178
306
  *
179
307
  * @param enable - Whether to enable watch mode
180
308
  */
@@ -184,12 +312,24 @@ export class MLEngine extends Emitter {
184
312
  watch: enable,
185
313
  };
186
314
  if (enable) {
187
- this.#watcher.on('change', this.#onChange.bind(this));
315
+ if (!this.#watch) {
316
+ this.#watch = subscribeToWatcher(filePath => this.#watchOnChange(filePath), error => this.#emitWatchError(error));
317
+ // Whatever the engine already depends on; it resolves the rest as it goes.
318
+ void this.#syncWatch();
319
+ }
188
320
  }
189
321
  else {
190
- this.#watcher.removeAllListeners();
322
+ this.#stopWatching().catch((error) => {
323
+ if (isFatalError(error)) {
324
+ throw error;
325
+ }
326
+ this.#emitWatchError(error);
327
+ });
191
328
  }
192
329
  }
330
+ #emitWatchError(error) {
331
+ this.emit('log', 'watch:error', error instanceof Error ? error.message : String(error));
332
+ }
193
333
  async #createCore(fabric) {
194
334
  fileLog('Get source code');
195
335
  const sourceCode = await this.#file.getCode();
@@ -206,48 +346,191 @@ export class MLEngine extends Emitter {
206
346
  this.#core = core;
207
347
  return core;
208
348
  }
209
- async #onChange(filePath) {
349
+ /**
350
+ * A watched file changed, was created, or was removed. Gathers the events
351
+ * of a burst, then asks for a re-resolution.
352
+ */
353
+ #watchOnChange(filePath) {
210
354
  if (!this.#options?.watch) {
211
355
  return;
212
356
  }
213
357
  this.emit('log', 'watch:onChange', filePath);
358
+ clearTimeout(this.#watchDebounce);
359
+ this.#watchDebounce = setTimeout(() => {
360
+ this.#watchDebounce = undefined;
361
+ void this.#watchRerun();
362
+ }, WATCH_DEBOUNCE_MS);
363
+ }
364
+ /**
365
+ * Re-resolves and lints, one at a time. An event that arrives while one is
366
+ * running means it ran on state older than the event, so it is followed by
367
+ * exactly one more — however many events came — rather than by one each,
368
+ * and never alongside it (the same way Rollup's watcher reruns, and webpack
369
+ * discards a compilation that a change has outdated).
370
+ */
371
+ async #watchRerun() {
372
+ if (this.#rerunning) {
373
+ this.#rerunAgain = true;
374
+ return;
375
+ }
376
+ this.#rerunning = true;
377
+ try {
378
+ do {
379
+ this.#rerunAgain = false;
380
+ try {
381
+ await this.#enqueue(() => this.#resolveAgain());
382
+ }
383
+ catch (error) {
384
+ if (isFatalError(error)) {
385
+ throw error;
386
+ }
387
+ // Nobody awaits a re-resolution a file triggered; a failure of one (a
388
+ // parser that cannot be found, say) must not become an unhandled
389
+ // rejection, nor spare the event that arrived while it ran its rerun.
390
+ this.#emitWatchError(error);
391
+ }
392
+ } while (this.#rerunAgain && this.#options?.watch);
393
+ }
394
+ finally {
395
+ this.#rerunning = false;
396
+ if (!this.#watchDebounce) {
397
+ this.emit('log', 'watch:idle', this.#file.path);
398
+ }
399
+ }
400
+ }
401
+ /**
402
+ * A re-resolution is not only the config: it re-reads the files the pretenders
403
+ * depend on, which is what a change to one of them is for.
404
+ */
405
+ async #resolveAgain() {
406
+ if (!this.#options?.watch) {
407
+ return;
408
+ }
214
409
  const fabric = await this.#provide(false);
215
410
  if (!fabric) {
411
+ if (this.#configFailure) {
412
+ // The core holds the config that no longer loads. Dropping it makes the
413
+ // next `exec()` / `setCode()` resolve again instead of linting on it.
414
+ this.#core = null;
415
+ if (this.#options?.watch) {
416
+ await this.#reportConfigFailure(this.#configFailure);
417
+ }
418
+ }
419
+ return;
420
+ }
421
+ if (!this.#options?.watch) {
216
422
  return;
217
423
  }
218
424
  if (fabric.configErrors) {
219
425
  this.emit('config-errors', this.#file.path, fabric.configErrors);
220
426
  }
221
427
  this.emit('log', 'update:core', this.#file.path);
222
- this.#core?.update(fabric);
223
- await this.exec();
428
+ if (this.#core) {
429
+ this.#core.update(fabric);
430
+ }
431
+ else {
432
+ // No core yet — the config did not load until now. `#exec()` would
433
+ // resolve once more through `#setup()`, for the fabric just resolved.
434
+ await this.#createCore(fabric);
435
+ }
436
+ await this.#exec();
437
+ }
438
+ #enqueue(task) {
439
+ const result = this.#queue.then(task, task);
440
+ // The queue goes on whether this task succeeded or not; its failure is the
441
+ // caller's, through `result`.
442
+ this.#queue = result.then(() => { }, () => { });
443
+ return result;
444
+ }
445
+ /**
446
+ * Leaves the process-wide watcher. A later `watchMode(true)` subscribes anew.
447
+ */
448
+ async #stopWatching() {
449
+ clearTimeout(this.#watchDebounce);
450
+ this.#watchDebounce = undefined;
451
+ this.#options = { ...this.#options, watch: false };
452
+ const watch = this.#watch;
453
+ this.#watch = null;
454
+ await watch?.close();
224
455
  }
456
+ /**
457
+ * Points the process-wide watcher at what this engine depends on now: its
458
+ * config files and the files its pretenders were resolved from. Resolves
459
+ * once they are being watched, so that a change made after an `exec()` or
460
+ * `setCode()` has resolved is not missed.
461
+ *
462
+ * Left out: relative names (`markuplint:recommended`, the keys of inline
463
+ * configs, `extends` of an npm module) that {@link ConfigSet.files} also
464
+ * lists, and the lint target itself.
465
+ *
466
+ * Never rejects: a watcher that cannot be updated costs the engine its
467
+ * re-linting, not the `exec()` / `setCode()` whose result is already in hand.
468
+ */
469
+ async #syncWatch() {
470
+ try {
471
+ await this.#updateWatch();
472
+ }
473
+ catch (error) {
474
+ if (isFatalError(error)) {
475
+ throw error;
476
+ }
477
+ this.#emitWatchError(error);
478
+ }
479
+ }
480
+ async #updateWatch() {
481
+ const watch = this.#watch;
482
+ if (!watch) {
483
+ return;
484
+ }
485
+ const targetKey = toWatchKey(this.#file.path);
486
+ const filePaths = new Set();
487
+ for (const filePath of [...this.#configFiles, ...this.#pretenderDependencies]) {
488
+ if (path.isAbsolute(filePath) && toWatchKey(filePath) !== targetKey) {
489
+ filePaths.add(filePath);
490
+ }
491
+ }
492
+ await watch.update(filePaths);
493
+ }
494
+ async #watchPretenderDependencies(dependencies) {
495
+ this.#pretenderDependencies = dependencies;
496
+ await this.#syncWatch();
497
+ }
498
+ /**
499
+ * Resolves everything a lint of the file needs and returns it, or `null`
500
+ * when there is nothing to lint with: the file is missing, excluded, or
501
+ * unmatched by the extension — or its config cannot be loaded, which
502
+ * `#configFailure` records.
503
+ *
504
+ * A config that cannot be loaded is a Tier 2 failure (see the module JSDoc
505
+ * of `@markuplint/shared`'s `errors/index.ts`): any non-fatal error thrown by
506
+ * the resolution, or a `ConfigLoadError` collected into the config set's
507
+ * `errs`, fails the file. Tier 1 errors — markuplint's own bugs
508
+ * (`isFatalError()`) — are not caught here. The other `errs` (a rule or a
509
+ * plugin that is not found, an invalid selector, a circular `extends`)
510
+ * leave the rest of the config usable, so they stay `config-error` warnings
511
+ * that `MLCore.verify()` reports.
512
+ */
225
513
  async #provide(cache = true) {
514
+ this.#configFailure = null;
226
515
  let configSet;
227
516
  try {
228
517
  configSet = await this.resolveConfig(cache);
229
518
  }
230
519
  catch (error) {
231
- if (error instanceof Error) {
232
- configSet = {
233
- config: {},
234
- plugins: [],
235
- files: new Set(),
236
- errs: [error],
237
- ruleDeprecations: [],
238
- };
239
- }
240
- else {
520
+ if (isFatalError(error) || !(error instanceof Error)) {
241
521
  throw error;
242
522
  }
523
+ return this.#failConfig(error);
243
524
  }
244
525
  fileLog('Fetched Config files: %O', configSet.files);
245
526
  fileLog('Resolved Config: %O', configSet.config);
246
527
  fileLog('Resolved Plugins: %O', configSet.plugins);
247
528
  fileLog('Resolve Errors: %O', configSet.errs);
248
- if (!(await this.#file.isFile())) {
249
- this.emit('log', 'file-no-exists', `The file doesn't exist or it is not a file: ${this.#file.path}`);
250
- fileLog("The file doesn't exist or it is not a file: %s", this.#file.path);
529
+ const loadError = configSet.errs.find(error => error instanceof ConfigLoadError);
530
+ if (loadError) {
531
+ return this.#failConfig(loadError);
532
+ }
533
+ if (!(await this.#isTargetFile())) {
251
534
  return null;
252
535
  }
253
536
  // Exclude
@@ -380,10 +663,10 @@ export class MLEngine extends Emitter {
380
663
  ? { ...resolvedConfigSet, ruleDeprecations: [] }
381
664
  : { ...resolvedConfigSet, config: aliasedConfig, ruleDeprecations: ruleAliasWarnings };
382
665
  this.emit('config', this.#file.path, configSet);
383
- if (this.#options?.watch) {
384
- // It doesn't watch the main HTML file because it may is watched and managed by a language server or text editor or more.
385
- this.#watcher.add([...configSet.files]);
386
- }
666
+ // The main file is not watched (see `#syncWatch`): it may be watched and managed
667
+ // by a language server or text editor or more.
668
+ this.#configFiles = configSet.files;
669
+ await this.#syncWatch();
387
670
  return configSet;
388
671
  }
389
672
  async #resolveParser(
@@ -404,14 +687,35 @@ export class MLEngine extends Emitter {
404
687
  // resolving as it did before the change for the rest of the process's lifetime.
405
688
  await invalidatePretenderResolutionCaches();
406
689
  }
690
+ // One resolver per config resolution: this is the only place the
691
+ // pretenders that depend on the config and the filesystem (files /
692
+ // imports / data / scan) are read. `setCode()` reuses it.
693
+ this.#pretenderResolver = createPretenderResolver(configSet.config.pretenders);
694
+ const { pretenders, dependencies } = await this.#resolvePretendersForCurrentCode();
695
+ await this.#watchPretenderDependencies(dependencies);
696
+ return pretenders;
697
+ }
698
+ /**
699
+ * Disambiguation runs here, on every call, and not only at config
700
+ * resolution: it reads the target's import statements, so it is as
701
+ * source-dependent as `auto` is.
702
+ *
703
+ * @returns The pretenders, and the files they were resolved from, which the
704
+ * caller watches once it has adopted this resolution (a superseded one is dropped).
705
+ */
706
+ async #resolvePretendersForCurrentCode() {
707
+ const resolver = this.#pretenderResolver;
708
+ const dependencies = new Set();
709
+ if (!resolver) {
710
+ return { pretenders: [], dependencies };
711
+ }
407
712
  const sourceCode = await this.#file.getCode();
408
- const pretenders = await resolvePretenders(configSet.config.pretenders, {
409
- filePath: this.#file.path,
410
- sourceCode,
713
+ const pretenders = await resolver.resolve({ filePath: this.#file.path, sourceCode }, { dependencies });
714
+ const disambiguated = await disambiguatePretendersForFile(this.#file.path, sourceCode, pretenders, {
715
+ dependencies,
411
716
  });
412
- const disambiguated = await disambiguatePretendersForFile(this.#file.path, sourceCode, pretenders);
413
717
  fileLog('Resolved pretenders: %O', disambiguated);
414
- return disambiguated;
718
+ return { pretenders: disambiguated, dependencies };
415
719
  }
416
720
  async #resolveRules(plugins, ruleset) {
417
721
  const rules = await resolveRules(plugins, ruleset, this.#options?.importPresetRules ?? true);
@@ -435,6 +739,58 @@ export class MLEngine extends Emitter {
435
739
  this.emit('schemas', this.#file.path, schemas);
436
740
  return schemas;
437
741
  }
742
+ async #isTargetFile() {
743
+ if (await this.#file.isFile()) {
744
+ return true;
745
+ }
746
+ this.emit('log', 'file-no-exists', `The file doesn't exist or it is not a file: ${this.#file.path}`);
747
+ fileLog("The file doesn't exist or it is not a file: %s", this.#file.path);
748
+ return false;
749
+ }
750
+ /**
751
+ * Records that the config cannot be loaded, unless the file is missing too,
752
+ * which is the more basic reason to skip it. Always resolves to `null`: the
753
+ * "nothing to lint with" answer of `#provide`.
754
+ */
755
+ async #failConfig(error) {
756
+ fileLog('Config cannot be loaded: %O', error);
757
+ if (await this.#isTargetFile()) {
758
+ this.#configFailure = error;
759
+ }
760
+ return null;
761
+ }
762
+ /**
763
+ * Reports a config that cannot be loaded as the file's only violation, at
764
+ * error severity, so the run fails instead of passing a file no rule has
765
+ * checked.
766
+ *
767
+ * Emits `lint` as well as `lint-error`: an editor shows its diagnostics from
768
+ * `lint`, and a config that stops loading while it is being edited must show
769
+ * up there. See `#exec` for the `lint-error` conversion of a failed
770
+ * `verify()`, which has no violations to publish.
771
+ */
772
+ async #reportConfigFailure(error) {
773
+ const sourceCode = await this.#file.getCode();
774
+ const violations = [
775
+ {
776
+ ruleId: CONFIG_ERROR_RULE_ID,
777
+ severity: 'error',
778
+ message: error.message,
779
+ line: 1,
780
+ col: 1,
781
+ raw: '',
782
+ },
783
+ ];
784
+ this.emit('lint-error', this.#file.path, sourceCode, error);
785
+ this.emit('lint', this.#file.path, sourceCode, violations, sourceCode, null, null);
786
+ return {
787
+ violations,
788
+ filePath: this.#file.path,
789
+ sourceCode,
790
+ fixedCode: sourceCode,
791
+ status: 'processed',
792
+ };
793
+ }
438
794
  async #setup() {
439
795
  if (this.#core) {
440
796
  return this.#core;
@@ -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
+ }
@@ -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
- const engines = new Map();
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
- const suppressionsFilePathForCheck = resolveSuppressionsPath(options.suppressionsLocation);
103
- const existingSuppressions = await readSuppressionsFile(suppressionsFilePathForCheck);
104
- if (Object.keys(existingSuppressions).length > 0) {
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
- // Store engine for scope computation in suppressions
161
- engines.set(result.filePath, engine);
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
- await fs.access(suppressionsFilePath);
254
- const suppressionsData = await readSuppressionsFile(suppressionsFilePath);
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.1",
3
+ "version": "5.1.1",
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.1",
39
- "@markuplint/file-resolver": "5.0.1",
40
- "@markuplint/html-parser": "5.0.1",
41
- "@markuplint/html-spec": "5.0.1",
42
- "@markuplint/i18n": "5.0.1",
43
- "@markuplint/ml-ast": "5.0.1",
44
- "@markuplint/ml-config": "5.0.1",
45
- "@markuplint/ml-core": "5.0.1",
46
- "@markuplint/ml-spec": "5.0.1",
47
- "@markuplint/rules": "5.0.1",
48
- "@markuplint/shared": "5.0.1",
38
+ "@markuplint/cli-utils": "5.1.1",
39
+ "@markuplint/file-resolver": "5.1.1",
40
+ "@markuplint/html-parser": "5.1.1",
41
+ "@markuplint/html-spec": "5.1.1",
42
+ "@markuplint/i18n": "5.1.1",
43
+ "@markuplint/ml-ast": "5.1.1",
44
+ "@markuplint/ml-config": "5.1.1",
45
+ "@markuplint/ml-core": "5.1.1",
46
+ "@markuplint/ml-spec": "5.1.1",
47
+ "@markuplint/rules": "5.1.1",
48
+ "@markuplint/shared": "5.1.1",
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": "edbaeedca5588fb291ea9d4e21a4ab2bf41fc9cb"
58
+ "gitHead": "a940c8526e971d590d97395ce924910221208820"
59
59
  }