markuplint 5.0.0-dev.5 → 5.0.0-rc.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.
@@ -146,7 +146,7 @@ flowchart TD
146
146
  ExcludeCheck -->|No| ResolveParser["resolveParser()\n パーサーモジュール選択"]
147
147
  ResolveParser --> ExtCheck{"拡張子が一致?\n(--ignore-ext でなければ)"}
148
148
  ExtCheck -->|No| ReturnNull3["null を返却"]
149
- ExtCheck -->|Yes| ResolvePretenders["resolvePretenders()"]
149
+ ExtCheck -->|Yes| ResolvePretenders["resolvePretenders()\n files + imports + data + scan"]
150
150
  ResolvePretenders --> ResolveRuleset["resolveRuleset()\n convertRuleset()"]
151
151
  ResolveRuleset --> ResolveSchemas["resolveSchemas()"]
152
152
  ResolveSchemas --> ResolveRules["resolveRules()\n プラグイン + カスタムルール"]
package/ARCHITECTURE.md CHANGED
@@ -146,7 +146,7 @@ flowchart TD
146
146
  ExcludeCheck -->|No| ResolveParser["resolveParser()\n parser module selection"]
147
147
  ResolveParser --> ExtCheck{"extension matched?\n(unless --ignore-ext)"}
148
148
  ExtCheck -->|No| ReturnNull3["Return null"]
149
- ExtCheck -->|Yes| ResolvePretenders["resolvePretenders()"]
149
+ ExtCheck -->|Yes| ResolvePretenders["resolvePretenders()\n files + imports + data + scan"]
150
150
  ResolvePretenders --> ResolveRuleset["resolveRuleset()\n convertRuleset()"]
151
151
  ResolveRuleset --> ResolveSchemas["resolveSchemas()"]
152
152
  ResolveSchemas --> ResolveRules["resolveRules()\n plugins + custom rules"]
package/CHANGELOG.md CHANGED
@@ -3,6 +3,24 @@
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.1](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.0...v5.0.0-rc.1) (2026-03-27)
7
+
8
+ ### Bug Fixes
9
+
10
+ - add isFatalError guard to MLEngine.exec and fix accname Deno crash ([c4b20de](https://github.com/markuplint/markuplint/commit/c4b20de128b2cfee582b3588e5004cb90065825b))
11
+ - **markuplint:** use platform-native paths in suppressions round-trip test ([df4b6a5](https://github.com/markuplint/markuplint/commit/df4b6a5f83b0fed3f74afba72cccd7bc2dbf8606))
12
+
13
+ ### Features
14
+
15
+ - **markuplint:** add experimental bulk suppressions ([bd3ab72](https://github.com/markuplint/markuplint/commit/bd3ab7204870dd6061e8e4ccbeaacd751d069a73)), closes [#3503](https://github.com/markuplint/markuplint/issues/3503)
16
+ - **markuplint:** add selector scope (LCA) to bulk suppressions ([84cf73d](https://github.com/markuplint/markuplint/commit/84cf73dd92db49f1f93ff6a5c9b71c7807ce31e9)), closes [#3509](https://github.com/markuplint/markuplint/issues/3509)
17
+
18
+ # [5.0.0-rc.0](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.3...v5.0.0-rc.0) (2026-03-12)
19
+
20
+ ### Bug Fixes
21
+
22
+ - **markuplint:** guard Error.stack access for Deno source map compat ([40508a3](https://github.com/markuplint/markuplint/commit/40508a3e25a9ed84f84f63adc95cc524628b9468))
23
+
6
24
  # [5.0.0-alpha.3](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.2...v5.0.0-alpha.3) (2026-02-26)
7
25
 
8
26
  ### Features
package/README.md CHANGED
@@ -89,6 +89,12 @@ Options
89
89
  --severity-parse-error Specifies the severity level of parse errors. Supports "error", "warning", and "off". Default: "error".
90
90
  --max-count Limit the number of violations shown. Default: 0 (no limit).
91
91
  --max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).
92
+ --progressive-output Output results immediately after processing each file. Default: false.
93
+
94
+ --suppress [Experimental] Generate/update suppressions file for all current errors.
95
+ --suppress-rule RULE_ID [Experimental] Suppress only the specified rule.
96
+ --prune-suppressions [Experimental] Remove stale entries from the suppressions file.
97
+ --suppressions-location PATH [Experimental] Custom path for the suppressions file. Default: "markuplint-suppressions.json".
92
98
 
93
99
  --init Initialize settings interactively.
94
100
  --search Search lines of codes that include the target element by selectors.
@@ -102,6 +108,43 @@ Examples
102
108
  $ cat verifyee.html | markuplint
103
109
  ```
104
110
 
111
+ ### Bulk Suppressions (Experimental)
112
+
113
+ > **This feature is experimental and may change in future releases.**
114
+
115
+ When introducing new rules to an existing project, you can suppress current violations and enforce rules only on new code.
116
+
117
+ **Typical workflow:**
118
+
119
+ ```bash
120
+ # 1. Enable new rules in your config, then suppress all current errors
121
+ $ markuplint --suppress "src/**/*.html"
122
+
123
+ # 2. Commit the generated suppressions file to your repository
124
+ $ git add markuplint-suppressions.json
125
+
126
+ # 3. From now on, only new violations are reported
127
+ $ markuplint "src/**/*.html"
128
+
129
+ # 4. As you fix existing violations, clean up stale entries
130
+ $ markuplint --prune-suppressions "src/**/*.html"
131
+ ```
132
+
133
+ - Only `error`-severity violations are suppressed; `warning` and `info` always pass through.
134
+ - If the number of violations for a file+rule pair **exceeds** the suppressed count, **all** violations for that pair are reported.
135
+ - `--suppress` always exits with code 0 (success).
136
+ - When a DOM tree is available, each suppression entry includes a **scope selector** (computed via LCA) to narrow suppression to a specific subtree. Entries without `scope` apply to the entire file.
137
+
138
+ ```json
139
+ {
140
+ "src/index.html": {
141
+ "attr-duplication": { "count": 3, "scope": "#main-nav > ul" }
142
+ }
143
+ }
144
+ ```
145
+
146
+ See [ESLint's Bulk Suppressions](https://eslint.org/docs/latest/use/suppressions) for the reference design. Tracking issues: [#3503](https://github.com/markuplint/markuplint/issues/3503), [#3509](https://github.com/markuplint/markuplint/issues/3509).
147
+
105
148
  ## Documentation
106
149
 
107
150
  - [Getting Started](https://markuplint.dev/getting-started)
@@ -130,11 +173,8 @@ Examples
130
173
  ### Personal Supporters
131
174
 
132
175
  [<img width="36" src="https://avatars.githubusercontent.com/u/91733847" alt="Tokitake" />](https://github.com/Tokitake)
133
- [<img width="36" src="https://avatars.githubusercontent.com/u/1996642" alt="Okuto Oyama" />](https://github.com/yamanoku)
134
- [<img width="36" src="https://avatars.githubusercontent.com/u/6581173" alt="miita" />](https://github.com/mikimhk)
135
176
  [<img width="36" src="https://avatars.githubusercontent.com/u/111797" alt="Yasuo Fukuda" />](https://github.com/sigwyg)
136
177
  [<img width="36" src="https://avatars.githubusercontent.com/u/91047157" alt="shamokit" />](https://github.com/shamokit)
137
- [<img width="36" src="https://avatars.githubusercontent.com/u/18516475" alt="takanorip" />](https://github.com/takanorip)
138
178
 
139
179
  Need [Sponsors❤️‍🔥](https://github.com/sponsors/markuplint)
140
180
 
@@ -1,6 +1,7 @@
1
1
  import { ConfigProvider, resolveFiles, resolveParser, resolvePretenders, resolveRules, resolveSpecs, } from '@markuplint/file-resolver';
2
2
  import { mergeConfig } from '@markuplint/ml-config';
3
3
  import { MLCore, convertRuleset } from '@markuplint/ml-core';
4
+ import { isFatalError } from '@markuplint/shared';
4
5
  import { FSWatcher } from 'chokidar';
5
6
  import { Emitter } from 'strict-event-emitter';
6
7
  import { log as coreLog, verbosely } from '../debug.js';
@@ -96,6 +97,9 @@ export class MLEngine extends Emitter {
96
97
  return null;
97
98
  }
98
99
  const verifyResult = await core.verify({ fix: this.#options?.fix ?? false }).catch(error => {
100
+ if (isFatalError(error)) {
101
+ throw error;
102
+ }
99
103
  if (error instanceof Error) {
100
104
  return error;
101
105
  }
@@ -104,7 +108,15 @@ export class MLEngine extends Emitter {
104
108
  const sourceCode = await this.#file.getCode();
105
109
  if (verifyResult instanceof Error) {
106
110
  this.emit('lint-error', this.#file.path, sourceCode, verifyResult);
107
- const errMessage = verifyResult.stack ?? verifyResult.message;
111
+ // Accessing `.stack` can throw in Deno when source map resolution
112
+ // encounters invalid mappings (e.g., negative column values).
113
+ let errMessage;
114
+ try {
115
+ errMessage = verifyResult.stack ?? verifyResult.message;
116
+ }
117
+ catch {
118
+ errMessage = verifyResult.message;
119
+ }
108
120
  log('exec: error %O', errMessage);
109
121
  return {
110
122
  violations: [
@@ -3,7 +3,7 @@ import type { ReadonlyDeep } from 'type-fest';
3
3
  * Help text displayed when the CLI is invoked with `--help` or without arguments.
4
4
  * Documents all available options, flags, and usage examples.
5
5
  */
6
- 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 Specifies the severity level of parse errors. Supports \"error\", \"warning\", and \"off\". Default: \"error\".\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--progressive-output Output results immediately after processing each file. Default: false.\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";
6
+ 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 Specifies the severity level of parse errors. Supports \"error\", \"warning\", and \"off\". Default: \"error\".\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--progressive-output Output results immediately after processing each file. Default: false.\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";
7
7
  /**
8
8
  * The parsed CLI instance created by `meow`, providing access to
9
9
  * positional arguments (`cli.input`) and parsed flags (`cli.flags`).
@@ -95,6 +95,20 @@ export declare const cli: import("meow").Result<{
95
95
  type: "boolean";
96
96
  default: false;
97
97
  };
98
+ suppress: {
99
+ type: "boolean";
100
+ default: false;
101
+ };
102
+ suppressRule: {
103
+ type: "string";
104
+ };
105
+ pruneSuppressions: {
106
+ type: "boolean";
107
+ default: false;
108
+ };
109
+ suppressionsLocation: {
110
+ type: "string";
111
+ };
98
112
  }>;
99
113
  /**
100
114
  * Deeply read-only type representing the parsed CLI flags.
@@ -29,6 +29,11 @@ Options
29
29
  --max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).
30
30
  --progressive-output Output results immediately after processing each file. Default: false.
31
31
 
32
+ --suppress [Experimental] Generate/update suppressions file for all current errors.
33
+ --suppress-rule RULE_ID [Experimental] Suppress only the specified rule.
34
+ --prune-suppressions [Experimental] Remove stale entries from the suppressions file.
35
+ --suppressions-location PATH [Experimental] Custom path for the suppressions file. Default: "markuplint-suppressions.json".
36
+
32
37
  --init Initialize settings interactively.
33
38
  --search Search lines of codes that include the target element by selectors.
34
39
 
@@ -133,5 +138,19 @@ export const cli = meow(help, {
133
138
  // TODO: It will be changed to `true` in the next major version.
134
139
  default: false,
135
140
  },
141
+ suppress: {
142
+ type: 'boolean',
143
+ default: false,
144
+ },
145
+ suppressRule: {
146
+ type: 'string',
147
+ },
148
+ pruneSuppressions: {
149
+ type: 'boolean',
150
+ default: false,
151
+ },
152
+ suppressionsLocation: {
153
+ type: 'string',
154
+ },
136
155
  },
137
156
  });
@@ -2,8 +2,10 @@ import { promises as fs } from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { resolveFiles } from '@markuplint/file-resolver';
4
4
  import { ViolationCollector } from '@markuplint/ml-core';
5
+ import { isFatalError } from '@markuplint/shared';
5
6
  import { MLEngine } from '../api/index.js';
6
7
  import { log } from '../debug.js';
8
+ import { applySuppressions, generateSuppressions, mergeSuppressions, pruneSuppressions, readSuppressionsFile, resolveSuppressionsPath, writeSuppressionsFile, } from '../suppressions/index.js';
7
9
  import { outputDryRunDiff } from './dry-run-output.js';
8
10
  import { output } from './output.js';
9
11
  /**
@@ -25,6 +27,16 @@ export async function command(files, options, apiOptions) {
25
27
  process.stderr.write('Warning: --fix-dry-run takes precedence over --fix. Files will not be modified.\n');
26
28
  }
27
29
  const fix = options.fix || fixDryRun;
30
+ // Mutual exclusion checks for suppressions flags
31
+ const isSuppressMode = options.suppress || options.suppressRule != null;
32
+ const isPruneMode = options.pruneSuppressions;
33
+ if (isSuppressMode && fix) {
34
+ process.stderr.write('Warning: --suppress counts violations from the original code, not from the fixed result. Consider running --fix first, then --suppress.\n');
35
+ }
36
+ if (isSuppressMode && isPruneMode) {
37
+ process.stderr.write('Error: --suppress/--suppress-rule and --prune-suppressions cannot be used together.\n');
38
+ return true;
39
+ }
28
40
  const configFile = options.config &&
29
41
  (path.isAbsolute(options.config) ? options.config : path.resolve(process.cwd(), options.config));
30
42
  const locale = options.locale;
@@ -51,6 +63,7 @@ export async function command(files, options, apiOptions) {
51
63
  const processedFiles = [];
52
64
  const skippedFiles = [];
53
65
  const filesContent = new Map();
66
+ const engines = new Map();
54
67
  const severityParseError = options.severityParseError.toLowerCase();
55
68
  const severity = {
56
69
  parseError: ['error', 'warning', 'off'].includes(severityParseError)
@@ -101,6 +114,8 @@ export async function command(files, options, apiOptions) {
101
114
  if (!result) {
102
115
  continue;
103
116
  }
117
+ // Store engine for scope computation in suppressions
118
+ engines.set(result.filePath, engine);
104
119
  // Progressive出力が有効でJSON形式でない場合
105
120
  if (options.progressiveOutput && format !== 'json') {
106
121
  // 即座に出力
@@ -137,19 +152,111 @@ export async function command(files, options, apiOptions) {
137
152
  outputDryRunDiff(result.filePath, result.sourceCode, result.fixedCode);
138
153
  }
139
154
  }
155
+ // --- Suppressions handling ---
156
+ const suppressionsFilePath = resolveSuppressionsPath(options.suppressionsLocation);
157
+ const collectedViolationsByFile = collector.groupByFile();
158
+ // Build nodeLists map from engines for scope computation
159
+ const nodeLists = new Map();
160
+ for (const [filePath, engine] of engines) {
161
+ const doc = engine.document;
162
+ if (doc) {
163
+ // MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
164
+ // id, classList, parentElement, children are all present). The double cast is
165
+ // needed because TypeScript can't verify structural compatibility between the
166
+ // generic MLNode<T,O> and the plain PositionedNode interface at compile time.
167
+ nodeLists.set(filePath, doc.nodeList);
168
+ }
169
+ }
170
+ if (isSuppressMode) {
171
+ // Suppress mode: generate/update suppressions file
172
+ const existing = await readSuppressionsFile(suppressionsFilePath);
173
+ const generated = generateSuppressions(collectedViolationsByFile, suppressionsFilePath, {
174
+ filterRule: options.suppressRule,
175
+ nodeLists,
176
+ });
177
+ const merged = mergeSuppressions(existing, generated);
178
+ await writeSuppressionsFile(suppressionsFilePath, merged);
179
+ let totalSuppressed = 0;
180
+ let totalRules = 0;
181
+ for (const rules of Object.values(generated)) {
182
+ for (const entry of Object.values(rules)) {
183
+ totalSuppressed += entry.count;
184
+ totalRules++;
185
+ }
186
+ }
187
+ process.stderr.write(`[Experimental] ${totalSuppressed} violation(s) for ${totalRules} rule(s) suppressed in ${path.relative(process.cwd(), suppressionsFilePath)}\n`);
188
+ return false;
189
+ }
190
+ if (isPruneMode) {
191
+ // Prune mode: remove stale entries
192
+ const existing = await readSuppressionsFile(suppressionsFilePath);
193
+ if (Object.keys(existing).length === 0) {
194
+ process.stderr.write('No suppressions file found. Nothing to prune.\n');
195
+ return false;
196
+ }
197
+ const pruned = pruneSuppressions(collectedViolationsByFile, existing, suppressionsFilePath);
198
+ await writeSuppressionsFile(suppressionsFilePath, pruned);
199
+ const existingCount = Object.values(existing).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
200
+ const prunedCount = Object.values(pruned).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
201
+ const removedCount = existingCount - prunedCount;
202
+ process.stderr.write(`[Experimental] Suppressions pruned: ${removedCount} entry/entries removed, ${prunedCount} remaining.\n`);
203
+ return false;
204
+ }
205
+ // Normal lint mode: apply suppressions if file exists
206
+ let outputViolationsByFile = collectedViolationsByFile;
207
+ let suppressionsApplied = false;
208
+ try {
209
+ await fs.access(suppressionsFilePath);
210
+ const suppressionsData = await readSuppressionsFile(suppressionsFilePath);
211
+ if (Object.keys(suppressionsData).length > 0) {
212
+ const { filtered, unusedEntries } = applySuppressions(collectedViolationsByFile, suppressionsData, suppressionsFilePath, { nodeLists });
213
+ outputViolationsByFile = filtered;
214
+ suppressionsApplied = true;
215
+ if (unusedEntries.length > 0) {
216
+ process.stderr.write(`[Experimental] ${unusedEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
217
+ }
218
+ // Recalculate hasError and totalWarningCount from filtered violations
219
+ hasError = false;
220
+ totalWarningCount = 0;
221
+ for (const violations of filtered.values()) {
222
+ const errorCount = violations.filter(v => v.severity === 'error').length;
223
+ const warningCount = violations.filter(v => v.severity === 'warning').length;
224
+ totalWarningCount += warningCount;
225
+ if (errorCount > 0 || (warningCount > 0 && !options.allowWarnings)) {
226
+ hasError = true;
227
+ }
228
+ }
229
+ }
230
+ }
231
+ catch (error) {
232
+ if (isFatalError(error)) {
233
+ throw error;
234
+ }
235
+ // Suppressions file does not exist or is unreadable, proceed normally
236
+ }
140
237
  // Output results
141
238
  if (format === 'json') {
142
- const jsonOutput = collector.toArray();
143
- process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
239
+ if (suppressionsApplied) {
240
+ // Build filtered array with filePath
241
+ const jsonOutput = [];
242
+ for (const [filePath, violations] of outputViolationsByFile) {
243
+ for (const violation of violations) {
244
+ jsonOutput.push({ ...violation, filePath });
245
+ }
246
+ }
247
+ process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
248
+ }
249
+ else {
250
+ const jsonOutput = collector.toArray();
251
+ process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
252
+ }
144
253
  return false;
145
254
  }
146
255
  // Progressive出力が無効の場合のみループ後に出力
147
256
  if (!options.progressiveOutput) {
148
- // For standard/simple/github output, group violations by file
149
- const violationsByFile = collector.groupByFile();
150
257
  // Output per file - include processed files without violations
151
258
  for (const filePath of processedFiles) {
152
- const violations = violationsByFile.get(filePath) || [];
259
+ const violations = outputViolationsByFile.get(filePath) || [];
153
260
  const content = filesContent.get(filePath) || { sourceCode: '', fixedCode: '' };
154
261
  if (violations.length === 0 && !options.problemOnly) {
155
262
  log('Output reports');
@@ -0,0 +1,45 @@
1
+ import type { Violation } from '@markuplint/ml-config';
2
+ import type { PositionedNode } from './compute-scope.js';
3
+ import type { SuppressionsData } from './types.js';
4
+ /**
5
+ * @experimental
6
+ * Result of applying suppressions to violations.
7
+ */
8
+ export type ApplySuppressionsResult = {
9
+ /** Violations after filtering out suppressed ones. */
10
+ readonly filtered: Map<string, Violation[]>;
11
+ /** List of unused suppression entries (as "filePath:ruleId" strings). */
12
+ readonly unusedEntries: readonly string[];
13
+ };
14
+ /**
15
+ * @experimental
16
+ * Options for applying suppressions.
17
+ */
18
+ export type ApplySuppressionsOptions = {
19
+ /**
20
+ * Map of absolute file paths to their document node lists.
21
+ * When provided, scope-aware filtering is used.
22
+ * When absent, scope is ignored (Phase 1 compatible).
23
+ */
24
+ readonly nodeLists?: ReadonlyMap<string, readonly PositionedNode[]>;
25
+ };
26
+ /**
27
+ * @experimental
28
+ * Applies suppressions to collected violations.
29
+ *
30
+ * For each file+ruleId pair:
31
+ * - If the entry has a `scope`, only violations within that scope subtree are
32
+ * counted AND filtered. Violations outside the scope are always passed through.
33
+ * - If current scoped error count <= suppressed count: scoped error violations are removed.
34
+ * - If current scoped error count > suppressed count: ALL scoped violations are kept.
35
+ *
36
+ * Warning and info violations always pass through unmodified.
37
+ * When `options.nodeLists` is not provided, scope is ignored (Phase 1 compatible).
38
+ *
39
+ * @param violationsByFile - Map of absolute file paths to violations.
40
+ * @param suppressions - The loaded suppressions data.
41
+ * @param suppressionsFilePath - Absolute path to the suppressions file.
42
+ * @param options - Optional options including document node lists for scope-aware filtering.
43
+ * @returns Filtered violations and a list of unused suppression entries.
44
+ */
45
+ export declare function applySuppressions(violationsByFile: ReadonlyMap<string, readonly Violation[]>, suppressions: SuppressionsData, suppressionsFilePath: string, options?: ApplySuppressionsOptions): ApplySuppressionsResult;
@@ -0,0 +1,221 @@
1
+ import { findNodeAtPosition } from './compute-scope.js';
2
+ import { toRelativePath } from './suppressions-file.js';
3
+ /**
4
+ * Checks whether a violation at (line, col) is within the subtree of a node
5
+ * that matches the scope selector. Walks the violation node's ancestors
6
+ * to find a match.
7
+ */
8
+ function isViolationInScope(
9
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
10
+ nodeList, violation, scope) {
11
+ const violationNode = findNodeAtPosition(nodeList, violation.line, violation.col);
12
+ if (!violationNode) {
13
+ return false;
14
+ }
15
+ // Check the node itself and walk up ancestors
16
+ if (matchesScopeSelector(violationNode, scope)) {
17
+ return true;
18
+ }
19
+ let current = violationNode.parentElement;
20
+ while (current) {
21
+ if (matchesScopeSelector(current, scope)) {
22
+ return true;
23
+ }
24
+ current = current.parentElement;
25
+ }
26
+ return false;
27
+ }
28
+ /**
29
+ * Simple scope selector matching. Checks if a node matches a scope selector
30
+ * by matching segments right-to-left against the node and its ancestors.
31
+ *
32
+ * Supports: `#id`, `tag.class`, `tag`, and ancestor paths like `#id > tag`.
33
+ */
34
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
35
+ function matchesScopeSelector(node, scope) {
36
+ const segments = scope.split(' > ');
37
+ let current = node;
38
+ // Match segments from right (most specific) to left (ancestors)
39
+ for (let i = segments.length - 1; i >= 0; i--) {
40
+ if (!current) {
41
+ return false;
42
+ }
43
+ if (!matchesSegment(current, segments[i])) {
44
+ return false;
45
+ }
46
+ current = current.parentElement;
47
+ }
48
+ return true;
49
+ }
50
+ /**
51
+ * Matches a single selector segment against a node.
52
+ * Supports: `#id`, `tag[attr="value"]`, `tag.classA.classB`,
53
+ * `tag:nth-of-type(n)`, `tag`
54
+ */
55
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
56
+ function matchesSegment(node, segment) {
57
+ if (segment.length === 0) {
58
+ return false;
59
+ }
60
+ // #id
61
+ if (segment.startsWith('#')) {
62
+ const id = segment.slice(1);
63
+ return id.length > 0 && node.id === id;
64
+ }
65
+ // tag[attr="value"]
66
+ const attrMatch = /^([a-z-]+)\[([a-z-]+)="([^"]+)"\]$/.exec(segment);
67
+ if (attrMatch) {
68
+ const [, tag, attrName, attrValue] = attrMatch;
69
+ if (node.localName !== tag) {
70
+ return false;
71
+ }
72
+ for (const attr of node.attributes) {
73
+ if (attr.name === attrName && attr.value === attrValue) {
74
+ return true;
75
+ }
76
+ }
77
+ return false;
78
+ }
79
+ // Extract :nth-of-type(n) if present
80
+ const nthMatch = /:nth-of-type\((\d+)\)$/.exec(segment);
81
+ const baseSegment = nthMatch ? segment.slice(0, nthMatch.index) : segment;
82
+ // tag.classA.classB
83
+ const dotIndex = baseSegment.indexOf('.');
84
+ let tagMatches;
85
+ if (dotIndex === -1) {
86
+ // tag only
87
+ tagMatches = node.localName === baseSegment;
88
+ }
89
+ else {
90
+ const tag = baseSegment.slice(0, dotIndex);
91
+ const classes = baseSegment.slice(dotIndex + 1).split('.');
92
+ tagMatches = node.localName === tag && classes.every(cls => node.classList.contains(cls));
93
+ }
94
+ if (!tagMatches) {
95
+ return false;
96
+ }
97
+ // Check nth-of-type position
98
+ if (nthMatch) {
99
+ const expectedPosition = Number.parseInt(nthMatch[1], 10);
100
+ const parent = node.parentElement;
101
+ if (!parent) {
102
+ return false;
103
+ }
104
+ let position = 0;
105
+ for (const sibling of parent.children) {
106
+ if (sibling.localName === node.localName) {
107
+ position++;
108
+ if (sibling === node) {
109
+ return position === expectedPosition;
110
+ }
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+ return true;
116
+ }
117
+ /**
118
+ * @experimental
119
+ * Applies suppressions to collected violations.
120
+ *
121
+ * For each file+ruleId pair:
122
+ * - If the entry has a `scope`, only violations within that scope subtree are
123
+ * counted AND filtered. Violations outside the scope are always passed through.
124
+ * - If current scoped error count <= suppressed count: scoped error violations are removed.
125
+ * - If current scoped error count > suppressed count: ALL scoped violations are kept.
126
+ *
127
+ * Warning and info violations always pass through unmodified.
128
+ * When `options.nodeLists` is not provided, scope is ignored (Phase 1 compatible).
129
+ *
130
+ * @param violationsByFile - Map of absolute file paths to violations.
131
+ * @param suppressions - The loaded suppressions data.
132
+ * @param suppressionsFilePath - Absolute path to the suppressions file.
133
+ * @param options - Optional options including document node lists for scope-aware filtering.
134
+ * @returns Filtered violations and a list of unused suppression entries.
135
+ */
136
+ export function applySuppressions(
137
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
138
+ violationsByFile,
139
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
140
+ suppressions, suppressionsFilePath,
141
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
142
+ options) {
143
+ const filtered = new Map();
144
+ const usedEntries = new Set();
145
+ const nodeLists = options?.nodeLists;
146
+ for (const [absolutePath, violations] of violationsByFile) {
147
+ const relPath = toRelativePath(absolutePath, suppressionsFilePath);
148
+ const fileSuppressions = suppressions[relPath];
149
+ if (!fileSuppressions) {
150
+ filtered.set(absolutePath, [...violations]);
151
+ continue;
152
+ }
153
+ const nodeList = nodeLists?.get(absolutePath);
154
+ // Build a set of suppressed rules and their scoped violation indices.
155
+ // For scope-aware entries, we track WHICH specific violations are in scope
156
+ // so we can suppress only those, not all violations of the same ruleId.
157
+ const suppressedViolationIndices = new Set();
158
+ for (const [ruleId, entry] of Object.entries(fileSuppressions)) {
159
+ const { count: scopedCount, indices } = getScopedErrorInfo(violations, ruleId, entry, nodeList);
160
+ if (scopedCount > 0 && scopedCount <= entry.count) {
161
+ // Scoped count within threshold — suppress these specific violations
162
+ for (const idx of indices) {
163
+ suppressedViolationIndices.add(idx);
164
+ }
165
+ }
166
+ if (scopedCount > 0) {
167
+ usedEntries.add(`${relPath}:${ruleId}`);
168
+ }
169
+ }
170
+ // Filter violations — only remove specifically identified violations
171
+ const fileFiltered = [];
172
+ for (const [i, violation] of violations.entries()) {
173
+ if (suppressedViolationIndices.has(i)) {
174
+ continue;
175
+ }
176
+ fileFiltered.push(violation);
177
+ }
178
+ filtered.set(absolutePath, fileFiltered);
179
+ }
180
+ // Find unused entries
181
+ const unusedEntries = [];
182
+ for (const [relPath, rules] of Object.entries(suppressions)) {
183
+ for (const ruleId of Object.keys(rules)) {
184
+ const key = `${relPath}:${ruleId}`;
185
+ if (!usedEntries.has(key)) {
186
+ unusedEntries.push(key);
187
+ }
188
+ }
189
+ }
190
+ return { filtered, unusedEntries };
191
+ }
192
+ /**
193
+ * Gets the scoped error count and indices for a rule.
194
+ * When scope is present and nodeList is available, only counts/tracks violations
195
+ * within the scope subtree. Otherwise counts all error violations for the rule.
196
+ *
197
+ * @returns The count and array indices of matching violations.
198
+ */
199
+ function getScopedErrorInfo(violations, ruleId, entry,
200
+ // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
201
+ nodeList) {
202
+ let count = 0;
203
+ const indices = [];
204
+ for (const [i, violation] of violations.entries()) {
205
+ const v = violation;
206
+ if (v.severity !== 'error' || v.ruleId !== ruleId) {
207
+ continue;
208
+ }
209
+ if (entry.scope && nodeList) {
210
+ if (isViolationInScope(nodeList, v, entry.scope)) {
211
+ count++;
212
+ indices.push(i);
213
+ }
214
+ }
215
+ else {
216
+ count++;
217
+ indices.push(i);
218
+ }
219
+ }
220
+ return { count, indices };
221
+ }