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 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
@@ -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
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
- #watcher = new FSWatcher();
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 stopping the file watcher.
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.#watcher.close();
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
- async exec() {
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 configuration.
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
- core.setCode(code);
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 config files
177
- * for changes and re-lints automatically.
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
- this.#watcher.on('change', this.#onChange.bind(this));
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.#watcher.removeAllListeners();
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
- async #onChange(filePath) {
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.exec();
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
- 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
- }
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 resolvePretenders(configSet.config.pretenders, {
409
- filePath: this.#file.path,
410
- sourceCode,
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
+ }
@@ -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.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.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.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": "edbaeedca5588fb291ea9d4e21a4ab2bf41fc9cb"
58
+ "gitHead": "3a061784a8211034ad0c1cbd64798c0213414f65"
59
59
  }