markuplint 5.0.0-rc.5 → 5.0.0-rc.7

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,52 @@
3
3
  All notable changes to this project will be documented in this file.
4
4
  See [Conventional Commits](https://conventionalcommits.org) for commit guidelines.
5
5
 
6
+ # [5.0.0-rc.7](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.6...v5.0.0-rc.7) (2026-08-31)
7
+
8
+ ### Features
9
+
10
+ - **markuplint:** surface appliedOverrides in --show-config=details ([10ed615](https://github.com/markuplint/markuplint/commit/10ed6157cf99786971ae90551ea0411cecdc8433))
11
+
12
+ # [5.0.0-rc.6](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.5...v5.0.0-rc.6) (2026-08-30)
13
+
14
+ ### Bug Fixes
15
+
16
+ - add no-aria-hidden-on-hidden-until-found rule (closes [#3999](https://github.com/markuplint/markuplint/issues/3999)) ([#4017](https://github.com/markuplint/markuplint/issues/4017)) ([3dfb300](https://github.com/markuplint/markuplint/commit/3dfb3001dc66452918d5cebbb071244b68779b30))
17
+ - resolveConfig(false) crashes with inline config (not a file path) ([#4018](https://github.com/markuplint/markuplint/issues/4018)) ([7e38b64](https://github.com/markuplint/markuplint/commit/7e38b64caa8cca69009ee765e4aada37fc48c559)), closes [#4015](https://github.com/markuplint/markuplint/issues/4015) [#4015](https://github.com/markuplint/markuplint/issues/4015)
18
+
19
+ ### Features
20
+
21
+ - split rule-deprecation notices out of config-error ([#4013](https://github.com/markuplint/markuplint/issues/4013)) ([812e6f3](https://github.com/markuplint/markuplint/commit/812e6f356839af8f257cfd91e6b16cfdfdd7cf33))
22
+
23
+ ### Performance Improvements
24
+
25
+ - share ConfigProvider across a run's files, fix latent overrides caching bug ([#4016](https://github.com/markuplint/markuplint/issues/4016)) ([fcc1875](https://github.com/markuplint/markuplint/commit/fcc1875b1a984a5ef1bb36aa04e7b3522fefc58e)), closes [#3997](https://github.com/markuplint/markuplint/issues/3997) [#3997](https://github.com/markuplint/markuplint/issues/3997)
26
+
27
+ ### BREAKING CHANGES
28
+
29
+ - `ConfigProvider#resolve(targetFile, names, false)` no longer
30
+ clears the provider's store/cache/plugin-resolution caches by itself. Callers
31
+ that relied on `cache: false` alone to force a fresh re-read must now call
32
+ the new `ConfigProvider#invalidate()` first.
33
+ - violations for deprecated rule names now have
34
+ `ruleId: 'rule-deprecation'` instead of `ruleId: 'config-error'`. Any
35
+ consumer filtering `MLCore.verify()` output (or the markuplint CLI/API) by
36
+ `ruleId === 'config-error'` to catch deprecation messages must also check
37
+ for `rule-deprecation`.
38
+
39
+ - feat(markuplint): add --severity-deprecation CLI flag
40
+
41
+ Wires the new severity.deprecation config option (@markuplint/ml-config)
42
+ and the rule-deprecation ruleId (@markuplint/ml-core) through the CLI:
43
+
44
+ - --severity-deprecation flag, mirroring --severity-parse-error
45
+ - --show-config details now also surfaces ruleDeprecations
46
+ - per-run dedupe and failed-file counting generalized to cover both
47
+ config-level ruleIds (config-error and rule-deprecation), not just
48
+ config-error
49
+
50
+ * docs(website): document severity.deprecation (EN + JA)
51
+
6
52
  # [5.0.0-rc.5](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.4...v5.0.0-rc.5) (2026-08-28)
7
53
 
8
54
  ### Bug Fixes
package/README.md CHANGED
@@ -87,6 +87,7 @@ Options
87
87
  --verbose Output with detailed information.
88
88
  --include-node-modules Include files in node_modules directory. Default: false.
89
89
  --severity-parse-error Specifies the severity level of parse errors. Supports "error", "warning", and "off". Default: "error".
90
+ --severity-deprecation Specifies the severity level of deprecated rule name notices. Supports "error", "warning", and "off". Default: "warning".
90
91
  --max-count Limit the number of violations shown. Default: 0 (no limit).
91
92
  --max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).
92
93
  --progressive-output Output results immediately after processing each file. Default: false.
package/lib/api/lint.d.ts CHANGED
@@ -4,6 +4,11 @@ import type { Target } from '@markuplint/file-resolver';
4
4
  /**
5
5
  * Lints multiple targets (files or inline sources) and returns results for each.
6
6
  *
7
+ * Config-level violations (broken config, deprecated rule names) are deduped
8
+ * per call the same way the CLI dedupes them per run: a message identical
9
+ * across every file sharing one config is kept only in the first file's
10
+ * result, not repeated in every file's `violations` array. See #3997.
11
+ *
7
12
  * @param targetList - An array of file paths/globs or inline source code targets
8
13
  * @param options - API options for configuration, locale, rules, and behavior
9
14
  * @returns An array of lint results, one per processed file
package/lib/api/lint.js CHANGED
@@ -1,8 +1,14 @@
1
- import { resolveFiles } from '@markuplint/file-resolver';
1
+ import { ConfigProvider, resolveFiles } from '@markuplint/file-resolver';
2
+ import { dedupeConfigLevelViolations } from '../dedupe-config-violations.js';
2
3
  import { MLEngine } from './ml-engine.js';
3
4
  /**
4
5
  * Lints multiple targets (files or inline sources) and returns results for each.
5
6
  *
7
+ * Config-level violations (broken config, deprecated rule names) are deduped
8
+ * per call the same way the CLI dedupes them per run: a message identical
9
+ * across every file sharing one config is kept only in the first file's
10
+ * result, not repeated in every file's `violations` array. See #3997.
11
+ *
6
12
  * @param targetList - An array of file paths/globs or inline source code targets
7
13
  * @param options - API options for configuration, locale, rules, and behavior
8
14
  * @returns An array of lint results, one per processed file
@@ -10,13 +16,44 @@ import { MLEngine } from './ml-engine.js';
10
16
  export async function lint(targetList, options) {
11
17
  const res = [];
12
18
  const files = await resolveFiles(targetList);
19
+ // Shared across every file so `MLEngine`'s config cache — keyed by
20
+ // resolved config `names`, not by target file — actually helps: see
21
+ // `ConfigProvider.resolve`'s doc comment and #3997.
22
+ const configProvider = new ConfigProvider();
23
+ const seenConfigMessages = new Set();
13
24
  for (const file of files) {
14
- const engine = new MLEngine(file, options);
25
+ const engine = new MLEngine(file, { ...options, configProvider });
15
26
  const result = await engine.exec();
16
27
  if (!result) {
17
28
  continue;
18
29
  }
19
- res.push(result);
30
+ // Dedupe both views a caller might read: the first-pass `violations`,
31
+ // and — when fix mode found fixes — `fixSummary.finalPassViolations`
32
+ // (the post-fix re-verification `command.ts` prefers when reporting
33
+ // fixed results). Both arrays describe the SAME file, so they always
34
+ // carry the same config-level messages — that's not a repeat worth
35
+ // suppressing. Only a *later file* repeating a message already kept
36
+ // (in either array) is. So both arrays are deduped against the same
37
+ // pre-file snapshot of `seenConfigMessages` (not against each other),
38
+ // and only after both are done are their combined discoveries folded
39
+ // back into `seenConfigMessages` for the next file. The snapshot is
40
+ // only taken when there's a second array to protect against — most
41
+ // files (no fix mode, or fix mode with nothing to fix) have none.
42
+ let finalPassViolations = result.fixSummary?.finalPassViolations;
43
+ const seenBeforeThisFile = finalPassViolations ? new Set(seenConfigMessages) : undefined;
44
+ const violations = dedupeConfigLevelViolations(result.violations, seenConfigMessages);
45
+ if (finalPassViolations && seenBeforeThisFile) {
46
+ const seenForFinalPass = new Set(seenBeforeThisFile);
47
+ finalPassViolations = dedupeConfigLevelViolations(finalPassViolations, seenForFinalPass);
48
+ for (const key of seenForFinalPass) {
49
+ seenConfigMessages.add(key);
50
+ }
51
+ }
52
+ res.push({
53
+ ...result,
54
+ violations,
55
+ fixSummary: result.fixSummary && { ...result.fixSummary, finalPassViolations },
56
+ });
20
57
  }
21
58
  return res;
22
59
  }
@@ -1,12 +1,44 @@
1
1
  import type { APIOptions, MLEngineEventMap } from './types.js';
2
2
  import type { MLResultInfo } from '../types.js';
3
3
  import type { ConfigSet, MLFile, Target } from '@markuplint/file-resolver';
4
- import type { PlainData } from '@markuplint/ml-config';
4
+ import type { PlainData, RuleAliasWarning } from '@markuplint/ml-config';
5
5
  import type { Document, RuleConfigValue } from '@markuplint/ml-core';
6
+ import { ConfigProvider } from '@markuplint/file-resolver';
6
7
  import { Emitter } from 'strict-event-emitter';
7
8
  type MLEngineOptions = {
8
9
  readonly debug?: boolean;
9
10
  readonly watch?: boolean;
11
+ /**
12
+ * A pre-built {@link ConfigProvider} to resolve config through, instead of
13
+ * the one this instance would otherwise create for itself.
14
+ *
15
+ * `ConfigProvider` caches by the resolved config's `names` (file paths),
16
+ * not by target file — so a caller looping over many files (the CLI, or
17
+ * the `lint()` API) that constructs one `MLEngine` per file should share
18
+ * a single `ConfigProvider` across that loop; every file whose config
19
+ * resolves to the same `names` then reuses the same cached base config
20
+ * (merge/validate/plugin-resolution) instead of redoing that work once
21
+ * per file. See #3997.
22
+ *
23
+ * Optional and additive: omitted, each `MLEngine` still creates its own
24
+ * provider exactly as before.
25
+ *
26
+ * **Caveat — `watch: true`**: `resolveConfig()`'s cache-busting
27
+ * (`cache: false`, used internally on every watch-triggered re-resolve)
28
+ * calls `ConfigProvider#invalidate()`, which clears the *entire* shared
29
+ * provider, not anything scoped to one engine or file. `resolveConfig()`
30
+ * runs through `ConfigProvider#runExclusive()`, so an overlapping call
31
+ * from another engine can no longer interleave with — and corrupt — this
32
+ * one's in-flight resolve (see #4015); it can only run *before* or
33
+ * *after* it. Sharing one `configProvider` across multiple engines that
34
+ * also have `watch: true` still risks one engine's re-resolve evicting
35
+ * another's already-cached config, forcing an avoidable re-resolve on
36
+ * that engine's next lookup. Neither the CLI (which doesn't support
37
+ * `--watch`) nor `lint()` (which never sets `watch`) create this
38
+ * combination — it only arises if a direct API consumer builds it
39
+ * deliberately.
40
+ */
41
+ readonly configProvider?: ConfigProvider;
10
42
  };
11
43
  /**
12
44
  * Options for creating an {@link MLEngine} from inline source code.
@@ -17,6 +49,16 @@ export type FromCodeOptions = APIOptions & MLEngineOptions & {
17
49
  /** Optional working directory for config resolution */
18
50
  readonly dirname?: string;
19
51
  };
52
+ /**
53
+ * A {@link ConfigSet} widened with deprecated-rule-name notices found while
54
+ * applying {@link applyRuleAliasesToConfig}. Kept structured (not folded
55
+ * into `errs` as generic `Error`s) so `MLCore` can report them under their
56
+ * own `rule-deprecation` ruleId instead of `config-error` — see
57
+ * `packages/@markuplint/ml-core/src/ml-core.ts`'s `verify()`.
58
+ */
59
+ type ResolvedConfigSet = ConfigSet & {
60
+ readonly ruleDeprecations: readonly RuleAliasWarning[];
61
+ };
20
62
  /**
21
63
  * The main markuplint engine that orchestrates file resolution, configuration loading,
22
64
  * parsing, and linting. Supports both single-file and watch-mode operation.
@@ -86,6 +128,6 @@ export declare class MLEngine extends Emitter<MLEngineEventMap> {
86
128
  * @param cache - Whether to reuse previously loaded config files
87
129
  * @returns The resolved configuration set
88
130
  */
89
- resolveConfig(cache: boolean): Promise<ConfigSet>;
131
+ resolveConfig(cache: boolean): Promise<ResolvedConfigSet>;
90
132
  }
91
133
  export {};
@@ -10,6 +10,15 @@ import { i18n } from '../i18n.js';
10
10
  const log = coreLog.extend('ml-engine');
11
11
  const fileLog = log.extend('file');
12
12
  const configLog = log.extend('config');
13
+ /**
14
+ * The config passed to {@link ConfigProvider.set} when no config was
15
+ * discovered, requested, or provided by the caller. A module-level constant
16
+ * (not rebuilt per call) so its object identity is stable — letting
17
+ * `ConfigProvider.set`'s auto-key cache (keyed by identity) recognize repeat
18
+ * calls across every file in a run sharing one `ConfigProvider`, the same
19
+ * way a stable `options.config`/`defaultConfig` reference does. See #3997.
20
+ */
21
+ const RECOMMENDED_CONFIG = { extends: ['markuplint:recommended'] };
13
22
  /**
14
23
  * The main markuplint engine that orchestrates file resolution, configuration loading,
15
24
  * parsing, and linting. Supports both single-file and watch-mode operation.
@@ -63,7 +72,7 @@ export class MLEngine extends Emitter {
63
72
  }
64
73
  this.#file = file;
65
74
  this.#options = options;
66
- this.#configProvider = new ConfigProvider();
75
+ this.#configProvider = options?.configProvider ?? new ConfigProvider();
67
76
  this.watchMode(!!this.#options?.watch);
68
77
  log('[MLEngine] Initialized: %s', this.#file.path);
69
78
  }
@@ -225,6 +234,7 @@ export class MLEngine extends Emitter {
225
234
  plugins: [],
226
235
  files: new Set(),
227
236
  errs: [error],
237
+ ruleDeprecations: [],
228
238
  };
229
239
  }
230
240
  else {
@@ -292,6 +302,7 @@ export class MLEngine extends Emitter {
292
302
  locale,
293
303
  ruleCommonSettings,
294
304
  configErrors: configSet.errs,
305
+ ruleDeprecations: configSet.ruleDeprecations,
295
306
  };
296
307
  }
297
308
  /**
@@ -313,28 +324,46 @@ export class MLEngine extends Emitter {
313
324
  async resolveConfig(cache) {
314
325
  this.emit('log', 'resolveConfig', JSON.stringify(this.#configProvider, null, 2));
315
326
  configLog('configProvider: %s', this.#configProvider);
316
- const defaultConfigKey = this.#options?.defaultConfig && this.#configProvider.set(mergeConfig(this.#options?.defaultConfig));
317
- configLog('defaultConfigKey: %s', defaultConfigKey ?? 'N/A');
318
- this.emit('log', 'defaultConfigKey', defaultConfigKey ?? 'N/A');
319
- const targetConfig = await this.#configProvider.search(this.#file);
320
- this.emit('log', 'targetConfig', targetConfig ?? 'N/A');
321
- const configFilePathsFromTarget = this.#options?.noSearchConfig || this.#options?.configFile
322
- ? (defaultConfigKey ?? null)
323
- : (targetConfig ?? defaultConfigKey);
324
- configLog('configFilePathsFromTarget: %s', configFilePathsFromTarget ?? 'N/A');
325
- this.emit('log', 'configFilePathsFromTarget', configFilePathsFromTarget ?? 'N/A');
326
- const configKey = this.#options?.config && this.#configProvider.set(mergeConfig(this.#options.config));
327
- configLog('option.config: %s', configKey ?? 'N/A');
328
- this.emit('log', 'option.config', configFilePathsFromTarget ?? 'N/A');
329
- let defaultRecommended = null;
330
- if (!defaultConfigKey && !configFilePathsFromTarget && !configKey && !this.#options?.configFile) {
331
- // No configured
332
- // Default: set recommended
333
- defaultRecommended = this.#configProvider.set({ extends: ['markuplint:recommended'] });
334
- }
335
- configLog('defaultRecommended: %s', defaultRecommended ?? 'N/A');
336
- this.emit('log', 'defaultRecommended', defaultRecommended ?? 'N/A');
337
- const resolvedConfigSet = await this.#configProvider.resolve(this.#file, [configFilePathsFromTarget, this.#options?.configFile, configKey, defaultRecommended], cache);
327
+ // Runs the whole invalidate → set → search → resolve sequence as one
328
+ // exclusive unit on the provider — an overlapping call on the same
329
+ // (possibly shared, e.g. two watch-triggered re-resolves close
330
+ // together) `ConfigProvider` must not interleave its own `invalidate()`
331
+ // in the middle of this one's `set()`/`search()` calls, which would
332
+ // wipe the keys just registered below before `resolve()` gets to use
333
+ // them. See #4015.
334
+ const resolvedConfigSet = await this.#configProvider.runExclusive(async () => {
335
+ if (!cache) {
336
+ // Must run before any `set()` call below — `ConfigProvider#resolve()`
337
+ // no longer clears its own store on `cache: false`, so invalidating
338
+ // after registering this call's inline config would discard it
339
+ // again immediately. See #4015.
340
+ this.#configProvider.invalidate();
341
+ }
342
+ const defaultConfigKey = this.#options?.defaultConfig &&
343
+ this.#configProvider.set(mergeConfig(this.#options.defaultConfig), undefined, this.#options.defaultConfig);
344
+ configLog('defaultConfigKey: %s', defaultConfigKey ?? 'N/A');
345
+ this.emit('log', 'defaultConfigKey', defaultConfigKey ?? 'N/A');
346
+ const targetConfig = await this.#configProvider.search(this.#file);
347
+ this.emit('log', 'targetConfig', targetConfig ?? 'N/A');
348
+ const configFilePathsFromTarget = this.#options?.noSearchConfig || this.#options?.configFile
349
+ ? (defaultConfigKey ?? null)
350
+ : (targetConfig ?? defaultConfigKey);
351
+ configLog('configFilePathsFromTarget: %s', configFilePathsFromTarget ?? 'N/A');
352
+ this.emit('log', 'configFilePathsFromTarget', configFilePathsFromTarget ?? 'N/A');
353
+ const configKey = this.#options?.config &&
354
+ this.#configProvider.set(mergeConfig(this.#options.config), undefined, this.#options.config);
355
+ configLog('option.config: %s', configKey ?? 'N/A');
356
+ this.emit('log', 'option.config', configFilePathsFromTarget ?? 'N/A');
357
+ let defaultRecommended = null;
358
+ if (!defaultConfigKey && !configFilePathsFromTarget && !configKey && !this.#options?.configFile) {
359
+ // No configured
360
+ // Default: set recommended
361
+ defaultRecommended = this.#configProvider.set(RECOMMENDED_CONFIG);
362
+ }
363
+ configLog('defaultRecommended: %s', defaultRecommended ?? 'N/A');
364
+ this.emit('log', 'defaultRecommended', defaultRecommended ?? 'N/A');
365
+ return this.#configProvider.resolve(this.#file, [configFilePathsFromTarget, this.#options?.configFile, configKey, defaultRecommended], cache);
366
+ });
338
367
  // Rewrite deprecated rule names (v5 rule-system redesign, #3989) to
339
368
  // their current replacement(s) so old configurations keep working.
340
369
  // Applied once, here, after `extends` is fully merged — everything
@@ -343,16 +372,13 @@ export class MLEngine extends Emitter {
343
372
  // the top-level `rules` map, and each `nodeRules`/`childNodeRules`
344
373
  // entry's own `rules`.
345
374
  const { config: aliasedConfig, warnings: ruleAliasWarnings } = applyRuleAliasesToConfig(resolvedConfigSet.config, ruleAliasTable);
375
+ // Kept structured (not folded into `errs` as generic `Error`s) so
376
+ // `MLCore` can report these under their own `rule-deprecation` ruleId,
377
+ // separate from genuine config-validation failures — see
378
+ // `ResolvedConfigSet`'s doc comment.
346
379
  const configSet = ruleAliasWarnings.length === 0
347
- ? resolvedConfigSet
348
- : {
349
- ...resolvedConfigSet,
350
- config: aliasedConfig,
351
- errs: [
352
- ...resolvedConfigSet.errs,
353
- ...ruleAliasWarnings.map(({ deprecatedName, replacedBy }) => new Error(`Rule "${deprecatedName}" is deprecated and will be removed in v6. Use ${replacedBy.join(', ')} instead.`)),
354
- ],
355
- };
380
+ ? { ...resolvedConfigSet, ruleDeprecations: [] }
381
+ : { ...resolvedConfigSet, config: aliasedConfig, ruleDeprecations: ruleAliasWarnings };
356
382
  this.emit('config', this.#file.path, configSet);
357
383
  if (this.#options?.watch) {
358
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.
@@ -1,5 +1,5 @@
1
1
  import type { ReadonlyDeep } from 'type-fest';
2
- export declare const help = "\nUsage\n\t$ markuplint <HTML file paths (glob format)>\n\t$ <stdout> | markuplint\n\nOptions\n\t--config, -c FILE_PATH A configuration file path.\n\t--fix, Fix HTML.\n\t--fix-dry-run Show what --fix would change without writing files.\n\t--format, -f FORMAT Output format. Support \"JSON\", \"Simple\", \"GitHub\" and \"Standard\". Default: \"Standard\".\n\t--no-search-config No search a configure file automatically.\n\t--ignore-ext Evaluate files that are received even though the type of extension.\n\t--no-import-preset-rules No import preset rules.\n\t--locale Locale of the message of violation. Default is an OS setting.\n\t--no-color, Output no color.\n\t--problem-only, -p Output only problems, without passeds.\n\t--no-allow-warnings Return status code 1 even if there are warnings.\n\t--allow-empty-input Return status code 1 even if there are no input files.\n\t--show-config Output computed configuration of the target file. Supports \"details\" and empty. Default: empty.\n\t--verbose Output with detailed information.\n\t--include-node-modules Include files in node_modules directory. Default: false.\n\t--severity-parse-error Severity for the built-in parse-error channel. Supports \"error\", \"warning\", and \"off\". Unset by default: fatal ParserErrors emit at \"error\" and non-fatal parse5 events are off (opt-in per code via config).\n\t--max-count Limit the number of violations shown. Default: 0 (no limit).\n\t--max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).\n\t--no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).\n\n\t--suppress [Experimental] Generate/update suppressions file for all current errors.\n\t--suppress-rule RULE_ID [Experimental] Suppress only the specified rule.\n\t--prune-suppressions [Experimental] Remove stale entries from the suppressions file.\n\t--suppressions-location PATH [Experimental] Custom path for the suppressions file. Default: \"markuplint-suppressions.json\".\n\n\t--init Initialize settings interactively.\n\t--search Search lines of codes that include the target element by selectors.\n\n\t--help, -h Show help.\n\t--version, -v Show version.\n\nExamples\n\t$ markuplint verifyee.html --config path/to/.markuplintrc\n\t$ cat verifyee.html | markuplint\n";
2
+ export declare const help = "\nUsage\n\t$ markuplint <HTML file paths (glob format)>\n\t$ <stdout> | markuplint\n\nOptions\n\t--config, -c FILE_PATH A configuration file path.\n\t--fix, Fix HTML.\n\t--fix-dry-run Show what --fix would change without writing files.\n\t--format, -f FORMAT Output format. Support \"JSON\", \"Simple\", \"GitHub\" and \"Standard\". Default: \"Standard\".\n\t--no-search-config No search a configure file automatically.\n\t--ignore-ext Evaluate files that are received even though the type of extension.\n\t--no-import-preset-rules No import preset rules.\n\t--locale Locale of the message of violation. Default is an OS setting.\n\t--no-color, Output no color.\n\t--problem-only, -p Output only problems, without passeds.\n\t--no-allow-warnings Return status code 1 even if there are warnings.\n\t--allow-empty-input Return status code 1 even if there are no input files.\n\t--show-config Output computed configuration of the target file. Supports \"details\" and empty. Default: empty.\n\t--verbose Output with detailed information.\n\t--include-node-modules Include files in node_modules directory. Default: false.\n\t--severity-parse-error Severity for the built-in parse-error channel. Supports \"error\", \"warning\", and \"off\". Unset by default: fatal ParserErrors emit at \"error\" and non-fatal parse5 events are off (opt-in per code via config).\n\t--severity-deprecation Severity for the built-in rule-deprecation channel (deprecated rule names). Supports \"error\", \"warning\", and \"off\". Default: \"warning\".\n\t--max-count Limit the number of violations shown. Default: 0 (no limit).\n\t--max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).\n\t--no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).\n\n\t--suppress [Experimental] Generate/update suppressions file for all current errors.\n\t--suppress-rule RULE_ID [Experimental] Suppress only the specified rule.\n\t--prune-suppressions [Experimental] Remove stale entries from the suppressions file.\n\t--suppressions-location PATH [Experimental] Custom path for the suppressions file. Default: \"markuplint-suppressions.json\".\n\n\t--init Initialize settings interactively.\n\t--search Search lines of codes that include the target element by selectors.\n\n\t--help, -h Show help.\n\t--version, -v Show version.\n\nExamples\n\t$ markuplint verifyee.html --config path/to/.markuplintrc\n\t$ cat verifyee.html | markuplint\n";
3
3
  export declare const cli: import("meow").Result<{
4
4
  config: {
5
5
  type: "string";
@@ -74,6 +74,9 @@ export declare const cli: import("meow").Result<{
74
74
  severityParseError: {
75
75
  type: "string";
76
76
  };
77
+ severityDeprecation: {
78
+ type: "string";
79
+ };
77
80
  maxCount: {
78
81
  type: "number";
79
82
  default: number;
@@ -21,6 +21,7 @@ Options
21
21
  --verbose Output with detailed information.
22
22
  --include-node-modules Include files in node_modules directory. Default: false.
23
23
  --severity-parse-error Severity for the built-in parse-error channel. Supports "error", "warning", and "off". Unset by default: fatal ParserErrors emit at "error" and non-fatal parse5 events are off (opt-in per code via config).
24
+ --severity-deprecation Severity for the built-in rule-deprecation channel (deprecated rule names). Supports "error", "warning", and "off". Default: "warning".
24
25
  --max-count Limit the number of violations shown. Default: 0 (no limit).
25
26
  --max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).
26
27
  --no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).
@@ -116,6 +117,9 @@ export const cli = meow(help, {
116
117
  severityParseError: {
117
118
  type: 'string',
118
119
  },
120
+ severityDeprecation: {
121
+ type: 'string',
122
+ },
119
123
  maxCount: {
120
124
  type: 'number',
121
125
  default: 0,
@@ -1,13 +1,30 @@
1
1
  import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { resolveFiles } from '@markuplint/file-resolver';
3
+ import { ConfigProvider, resolveFiles } from '@markuplint/file-resolver';
4
4
  import { ViolationCollector } from '@markuplint/ml-core';
5
5
  import { isFatalError } from '@markuplint/shared';
6
6
  import { MLEngine } from '../api/index.js';
7
7
  import { log } from '../debug.js';
8
8
  import { applySuppressions, generateSuppressions, mergeSuppressions, pruneSuppressions, readSuppressionsFile, resolveSuppressionsPath, writeSuppressionsFile, } from '../suppressions/index.js';
9
+ import { CONFIG_LEVEL_RULE_IDS, dedupeConfigLevelViolations } from '../dedupe-config-violations.js';
9
10
  import { outputDryRunDiff } from './dry-run-output.js';
10
11
  import { output, outputSummary } from './output.js';
12
+ /**
13
+ * Validates a `--severity-*` flag's raw string value against the uniform
14
+ * single-value form both `--severity-parse-error` and `--severity-deprecation`
15
+ * accept, shared so adding another such flag doesn't mean copy-pasting this
16
+ * check again.
17
+ *
18
+ * @returns the validated value, or `undefined` if unset or not one of
19
+ * `"error"`, `"warning"`, `"off"`.
20
+ */
21
+ function parseSeverityFlag(value) {
22
+ const normalized = value?.toLowerCase();
23
+ if (normalized != null && ['error', 'warning', 'off'].includes(normalized)) {
24
+ return normalized;
25
+ }
26
+ return undefined;
27
+ }
11
28
  export async function command(files, options, apiOptions) {
12
29
  const fixDryRun = options.fixDryRun;
13
30
  if (options.fix && fixDryRun) {
@@ -47,18 +64,22 @@ export async function command(files, options, apiOptions) {
47
64
  const collector = new ViolationCollector(options.maxCount);
48
65
  const processedFiles = [];
49
66
  const skippedFiles = [];
50
- // `config-error` violations (deprecated rule names, unresolved rule
51
- // references, ...) come from resolving this run's config, so the same
52
- // message is identical across every file. Track messages already
53
- // reported once so a run over many files doesn't repeat the same lines
54
- // per file — the config is one thing, not N things.
67
+ // See `dedupeConfigLevelViolations` for why this Set needs to persist
68
+ // across the whole run (not per file).
55
69
  const seenConfigMessages = new Set();
56
70
  const filesContent = new Map();
57
71
  const engines = new Map();
58
- const severityParseError = options.severityParseError?.toLowerCase();
59
- const severity = severityParseError != null && ['error', 'warning', 'off'].includes(severityParseError)
60
- ? { parseError: severityParseError }
61
- : {};
72
+ // Shared across every file in this run so its config cache — keyed by
73
+ // resolved config `names`, not by target file — actually helps: config
74
+ // loading/merging/plugin-resolution is done once per distinct config,
75
+ // not once per file. See #3997.
76
+ const configProvider = new ConfigProvider();
77
+ const parsedSeverityParseError = parseSeverityFlag(options.severityParseError);
78
+ const parsedSeverityDeprecation = parseSeverityFlag(options.severityDeprecation);
79
+ const severity = {
80
+ ...(parsedSeverityParseError != null && { parseError: parsedSeverityParseError }),
81
+ ...(parsedSeverityDeprecation != null && { deprecation: parsedSeverityDeprecation }),
82
+ };
62
83
  // Progressive output prints each file's own violations as soon as that
63
84
  // file is processed, ahead of the two whole-run passes that batch output
64
85
  // waits for: suppressions (applied once at the end, below, via
@@ -101,6 +122,7 @@ export async function command(files, options, apiOptions) {
101
122
  debug: verbose,
102
123
  severity,
103
124
  ...apiOptions,
125
+ configProvider,
104
126
  });
105
127
  if (options.showConfig != null) {
106
128
  const isDetails = options.showConfig === 'details';
@@ -116,6 +138,8 @@ export async function command(files, options, apiOptions) {
116
138
  dependencies,
117
139
  plugins: configSet.plugins,
118
140
  errors: configSet.errs,
141
+ ruleDeprecations: configSet.ruleDeprecations,
142
+ appliedOverrides: configSet.appliedOverrides ?? [],
119
143
  };
120
144
  }
121
145
  else {
@@ -143,20 +167,7 @@ export async function command(files, options, apiOptions) {
143
167
  const violationsBeforeDedupe = fixDryRun
144
168
  ? result.violations
145
169
  : (result.fixSummary?.finalPassViolations ?? result.violations);
146
- // `config-error` is regenerated fresh per file (config resolution is
147
- // not shared across a run), so an identical message would otherwise
148
- // repeat once per file. Keep only the first occurrence across this run.
149
- const reportedViolations = violationsBeforeDedupe.filter(violation => {
150
- if (violation.ruleId !== 'config-error') {
151
- return true;
152
- }
153
- const key = `${violation.ruleId} ${violation.message}`;
154
- if (seenConfigMessages.has(key)) {
155
- return false;
156
- }
157
- seenConfigMessages.add(key);
158
- return true;
159
- });
170
+ const reportedViolations = dedupeConfigLevelViolations(violationsBeforeDedupe, seenConfigMessages);
160
171
  // Progressive出力が有効でJSON形式でない場合
161
172
  if (progressiveOutput && format !== 'json') {
162
173
  // 即座に出力
@@ -326,10 +337,11 @@ export async function command(files, options, apiOptions) {
326
337
  let failedFileCount = 0;
327
338
  for (const filePath of processedFiles) {
328
339
  const violations = outputViolationsByFile.get(filePath) || [];
329
- // A file whose only violations are `config-error` didn't fail on its
330
- // own content — the config issue is reported once for the whole run
331
- // (see the dedupe above), not attributable to this particular file.
332
- if (violations.some(violation => violation.ruleId !== 'config-error')) {
340
+ // A file whose only violations are config-level (`CONFIG_LEVEL_RULE_IDS`)
341
+ // didn't fail on its own content — the config issue is reported once
342
+ // for the whole run (see the dedupe above), not attributable to this
343
+ // particular file.
344
+ if (violations.some(violation => !CONFIG_LEVEL_RULE_IDS.has(violation.ruleId))) {
333
345
  failedFileCount++;
334
346
  }
335
347
  for (const violation of violations) {
@@ -0,0 +1,29 @@
1
+ import type { Violation } from '@markuplint/ml-config';
2
+ /**
3
+ * Synthetic ruleIds produced from resolving a run's config rather than from
4
+ * linting a file's content (`config-error`: broken config; `rule-deprecation`:
5
+ * deprecated-but-working rule names). Both regenerate identically for every
6
+ * file that shares the same config, so both need the same per-run dedupe —
7
+ * see {@link dedupeConfigLevelViolations} — and the same exclusion from
8
+ * per-file failure counting (`packages/markuplint/src/cli/command.ts`).
9
+ */
10
+ export declare const CONFIG_LEVEL_RULE_IDS: Set<string>;
11
+ /**
12
+ * Filters `violations` down to the first occurrence of each distinct
13
+ * config-level ({@link CONFIG_LEVEL_RULE_IDS}) message, tracked in `seen`
14
+ * across calls. Non-config-level violations always pass through unchanged.
15
+ *
16
+ * Even with config resolution now shared across a run (see
17
+ * `ConfigProvider`, #3997), each file's `MLCore.verify()` still builds its
18
+ * own `violations` array independently from that shared config data — so a
19
+ * config-level message would otherwise still repeat once per file that
20
+ * shares the config. The config is one thing, not N things. `seen` is
21
+ * caller-owned (a plain `Set<string>`) so both the CLI (per run) and the
22
+ * `lint()` API (per call) can each keep their own dedupe scope without
23
+ * sharing state between unrelated runs.
24
+ *
25
+ * @param violations - Violations to filter, in file-processing order
26
+ * @param seen - Accumulates `${ruleId} ${message}` keys already kept; mutated in place
27
+ * @returns `violations` with repeat config-level entries removed
28
+ */
29
+ export declare function dedupeConfigLevelViolations(violations: readonly Violation[], seen: Set<string>): readonly Violation[];
@@ -0,0 +1,41 @@
1
+ import { CONFIG_ERROR_RULE_ID, RULE_DEPRECATION_RULE_ID } from '@markuplint/ml-core';
2
+ /**
3
+ * Synthetic ruleIds produced from resolving a run's config rather than from
4
+ * linting a file's content (`config-error`: broken config; `rule-deprecation`:
5
+ * deprecated-but-working rule names). Both regenerate identically for every
6
+ * file that shares the same config, so both need the same per-run dedupe —
7
+ * see {@link dedupeConfigLevelViolations} — and the same exclusion from
8
+ * per-file failure counting (`packages/markuplint/src/cli/command.ts`).
9
+ */
10
+ export const CONFIG_LEVEL_RULE_IDS = new Set([CONFIG_ERROR_RULE_ID, RULE_DEPRECATION_RULE_ID]);
11
+ /**
12
+ * Filters `violations` down to the first occurrence of each distinct
13
+ * config-level ({@link CONFIG_LEVEL_RULE_IDS}) message, tracked in `seen`
14
+ * across calls. Non-config-level violations always pass through unchanged.
15
+ *
16
+ * Even with config resolution now shared across a run (see
17
+ * `ConfigProvider`, #3997), each file's `MLCore.verify()` still builds its
18
+ * own `violations` array independently from that shared config data — so a
19
+ * config-level message would otherwise still repeat once per file that
20
+ * shares the config. The config is one thing, not N things. `seen` is
21
+ * caller-owned (a plain `Set<string>`) so both the CLI (per run) and the
22
+ * `lint()` API (per call) can each keep their own dedupe scope without
23
+ * sharing state between unrelated runs.
24
+ *
25
+ * @param violations - Violations to filter, in file-processing order
26
+ * @param seen - Accumulates `${ruleId} ${message}` keys already kept; mutated in place
27
+ * @returns `violations` with repeat config-level entries removed
28
+ */
29
+ export function dedupeConfigLevelViolations(violations, seen) {
30
+ return violations.filter(violation => {
31
+ if (!CONFIG_LEVEL_RULE_IDS.has(violation.ruleId)) {
32
+ return true;
33
+ }
34
+ const key = `${violation.ruleId} ${violation.message}`;
35
+ if (seen.has(key)) {
36
+ return false;
37
+ }
38
+ seen.add(key);
39
+ return true;
40
+ });
41
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markuplint",
3
- "version": "5.0.0-rc.5",
3
+ "version": "5.0.0-rc.7",
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.0-rc.5",
39
- "@markuplint/file-resolver": "5.0.0-rc.5",
40
- "@markuplint/html-parser": "5.0.0-rc.5",
41
- "@markuplint/html-spec": "5.0.0-rc.5",
42
- "@markuplint/i18n": "5.0.0-rc.5",
43
- "@markuplint/ml-ast": "5.0.0-rc.5",
44
- "@markuplint/ml-config": "5.0.0-rc.5",
45
- "@markuplint/ml-core": "5.0.0-rc.5",
46
- "@markuplint/ml-spec": "5.0.0-rc.5",
47
- "@markuplint/rules": "5.0.0-rc.5",
48
- "@markuplint/shared": "5.0.0-rc.5",
38
+ "@markuplint/cli-utils": "5.0.0-rc.7",
39
+ "@markuplint/file-resolver": "5.0.0-rc.7",
40
+ "@markuplint/html-parser": "5.0.0-rc.7",
41
+ "@markuplint/html-spec": "5.0.0-rc.7",
42
+ "@markuplint/i18n": "5.0.0-rc.7",
43
+ "@markuplint/ml-ast": "5.0.0-rc.7",
44
+ "@markuplint/ml-config": "5.0.0-rc.7",
45
+ "@markuplint/ml-core": "5.0.0-rc.7",
46
+ "@markuplint/ml-spec": "5.0.0-rc.7",
47
+ "@markuplint/rules": "5.0.0-rc.7",
48
+ "@markuplint/shared": "5.0.0-rc.7",
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": "8d87463af2ff3f1b83fb28da20f1819362cf3555"
58
+ "gitHead": "8d7a3b801e0f5f88438c003e7eee2bd45e6aedd0"
59
59
  }