markuplint 5.0.0-rc.0 → 5.0.0-rc.2

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,42 @@
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.2](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.1...v5.0.0-rc.2) (2026-04-15)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **markuplint:** add default export condition and re-export isFatalError ([55a990a](https://github.com/markuplint/markuplint/commit/55a990affeec62c23a96cf15b42327fcb867e809))
11
+ - **markuplint:** add missing status property to github-reporter test data ([bb7ba62](https://github.com/markuplint/markuplint/commit/bb7ba62e9350c173987f76de2741aaeb3ba0997b))
12
+
13
+ - build!: remove ESLint and replace with oxlint ([1e0a337](https://github.com/markuplint/markuplint/commit/1e0a337707f76b903b16beeeb8c4d4fc0d8fc9e4))
14
+ - feat(markuplint)!: remove deprecated autoLoad option and MLResultInfo_v1 interface ([4eb1d05](https://github.com/markuplint/markuplint/commit/4eb1d05eb2829019cd4073afa153a512b1c4c8fa))
15
+
16
+ ### Features
17
+
18
+ - **config-presets:** add document uniqueness rules to html-standard preset ([6ed848b](https://github.com/markuplint/markuplint/commit/6ed848bd800416d1220b9de95ece7a3d752d881f))
19
+ - **markuplint:** add CLI summary output ([4743ba0](https://github.com/markuplint/markuplint/commit/4743ba0be7311288ea2b28fb9345567cf97c1a23))
20
+ - **markuplint:** add suppressions subpath export and editor severity downgrade ([362adef](https://github.com/markuplint/markuplint/commit/362adef1a040c66fec36c01bd8d8fcbe9a66c453))
21
+ - **vscode:** add suppressed message prefix and blame parser tests ([cd80a80](https://github.com/markuplint/markuplint/commit/cd80a802c3bad29a8a1d510d2160a21a6f0a2682))
22
+
23
+ ### BREAKING CHANGES
24
+
25
+ - ESLint is no longer used. Use oxlint instead.
26
+ - The autoLoad option has been removed from APIOptions.
27
+ Rules are now always auto-loaded unconditionally.
28
+ The MLResultInfo_v1 interface has also been removed.
29
+
30
+ # [5.0.0-rc.1](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.0...v5.0.0-rc.1) (2026-03-27)
31
+
32
+ ### Bug Fixes
33
+
34
+ - add isFatalError guard to MLEngine.exec and fix accname Deno crash ([c4b20de](https://github.com/markuplint/markuplint/commit/c4b20de128b2cfee582b3588e5004cb90065825b))
35
+ - **markuplint:** use platform-native paths in suppressions round-trip test ([df4b6a5](https://github.com/markuplint/markuplint/commit/df4b6a5f83b0fed3f74afba72cccd7bc2dbf8606))
36
+
37
+ ### Features
38
+
39
+ - **markuplint:** add experimental bulk suppressions ([bd3ab72](https://github.com/markuplint/markuplint/commit/bd3ab7204870dd6061e8e4ccbeaacd751d069a73)), closes [#3503](https://github.com/markuplint/markuplint/issues/3503)
40
+ - **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)
41
+
6
42
  # [5.0.0-rc.0](https://github.com/markuplint/markuplint/compare/v5.0.0-alpha.3...v5.0.0-rc.0) (2026-03-12)
7
43
 
8
44
  ### Bug Fixes
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)
@@ -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
  }
@@ -337,7 +341,7 @@ export class MLEngine extends Emitter {
337
341
  return pretenders;
338
342
  }
339
343
  async #resolveRules(plugins, ruleset) {
340
- const rules = await resolveRules(plugins, ruleset, this.#options?.importPresetRules ?? true, this.#options?.autoLoad ?? true);
344
+ const rules = await resolveRules(plugins, ruleset, this.#options?.importPresetRules ?? true);
341
345
  if (this.#options?.rules) {
342
346
  rules.push(...this.#options.rules);
343
347
  }
@@ -18,10 +18,6 @@ export type APIOptions = {
18
18
  readonly rules?: readonly Readonly<AnyMLRule>[];
19
19
  readonly importPresetRules?: boolean;
20
20
  readonly severity?: SeverityOptions;
21
- /**
22
- * @deprecated
23
- */
24
- readonly autoLoad?: boolean;
25
21
  };
26
22
  /**
27
23
  * Event map for the {@link MLEngine}, defining all emitted events and their payload types.
@@ -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,10 +2,12 @@ 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
- import { output } from './output.js';
10
+ import { output, outputSummary } from './output.js';
9
11
  /**
10
12
  * Executes the markuplint linting command against the given files.
11
13
  *
@@ -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,13 @@ export async function command(files, options, apiOptions) {
101
114
  if (!result) {
102
115
  continue;
103
116
  }
117
+ processedFiles.push(result.filePath);
118
+ filesContent.set(result.filePath, {
119
+ sourceCode: result.sourceCode,
120
+ fixedCode: result.fixedCode,
121
+ });
122
+ // Store engine for scope computation in suppressions
123
+ engines.set(result.filePath, engine);
104
124
  // Progressive出力が有効でJSON形式でない場合
105
125
  if (options.progressiveOutput && format !== 'json') {
106
126
  // 即座に出力
@@ -112,14 +132,6 @@ export async function command(files, options, apiOptions) {
112
132
  status: 'processed',
113
133
  }, options);
114
134
  }
115
- else {
116
- // 従来の動作:メモリに蓄積
117
- processedFiles.push(result.filePath);
118
- filesContent.set(result.filePath, {
119
- sourceCode: result.sourceCode,
120
- fixedCode: result.fixedCode,
121
- });
122
- }
123
135
  // Add violations to collector
124
136
  collector.pushWithFile(result.filePath, ...result.violations);
125
137
  const errorCount = result.violations.filter(v => v.severity === 'error').length;
@@ -137,19 +149,111 @@ export async function command(files, options, apiOptions) {
137
149
  outputDryRunDiff(result.filePath, result.sourceCode, result.fixedCode);
138
150
  }
139
151
  }
152
+ // --- Suppressions handling ---
153
+ const suppressionsFilePath = resolveSuppressionsPath(options.suppressionsLocation);
154
+ const collectedViolationsByFile = collector.groupByFile();
155
+ // Build nodeLists map from engines for scope computation
156
+ const nodeLists = new Map();
157
+ for (const [filePath, engine] of engines) {
158
+ const doc = engine.document;
159
+ if (doc) {
160
+ // MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
161
+ // id, classList, parentElement, children are all present). The double cast is
162
+ // needed because TypeScript can't verify structural compatibility between the
163
+ // generic MLNode<T,O> and the plain PositionedNode interface at compile time.
164
+ nodeLists.set(filePath, doc.nodeList);
165
+ }
166
+ }
167
+ if (isSuppressMode) {
168
+ // Suppress mode: generate/update suppressions file
169
+ const existing = await readSuppressionsFile(suppressionsFilePath);
170
+ const generated = generateSuppressions(collectedViolationsByFile, suppressionsFilePath, {
171
+ filterRule: options.suppressRule,
172
+ nodeLists,
173
+ });
174
+ const merged = mergeSuppressions(existing, generated);
175
+ await writeSuppressionsFile(suppressionsFilePath, merged);
176
+ let totalSuppressed = 0;
177
+ let totalRules = 0;
178
+ for (const rules of Object.values(generated)) {
179
+ for (const entry of Object.values(rules)) {
180
+ totalSuppressed += entry.count;
181
+ totalRules++;
182
+ }
183
+ }
184
+ process.stderr.write(`[Experimental] ${totalSuppressed} violation(s) for ${totalRules} rule(s) suppressed in ${path.relative(process.cwd(), suppressionsFilePath)}\n`);
185
+ return false;
186
+ }
187
+ if (isPruneMode) {
188
+ // Prune mode: remove stale entries
189
+ const existing = await readSuppressionsFile(suppressionsFilePath);
190
+ if (Object.keys(existing).length === 0) {
191
+ process.stderr.write('No suppressions file found. Nothing to prune.\n');
192
+ return false;
193
+ }
194
+ const pruned = pruneSuppressions(collectedViolationsByFile, existing, suppressionsFilePath);
195
+ await writeSuppressionsFile(suppressionsFilePath, pruned);
196
+ const existingCount = Object.values(existing).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
197
+ const prunedCount = Object.values(pruned).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
198
+ const removedCount = existingCount - prunedCount;
199
+ process.stderr.write(`[Experimental] Suppressions pruned: ${removedCount} entry/entries removed, ${prunedCount} remaining.\n`);
200
+ return false;
201
+ }
202
+ // Normal lint mode: apply suppressions if file exists
203
+ let outputViolationsByFile = collectedViolationsByFile;
204
+ let suppressionsApplied = false;
205
+ try {
206
+ await fs.access(suppressionsFilePath);
207
+ const suppressionsData = await readSuppressionsFile(suppressionsFilePath);
208
+ if (Object.keys(suppressionsData).length > 0) {
209
+ const { filtered, unusedEntries } = applySuppressions(collectedViolationsByFile, suppressionsData, suppressionsFilePath, { nodeLists });
210
+ outputViolationsByFile = filtered;
211
+ suppressionsApplied = true;
212
+ if (unusedEntries.length > 0) {
213
+ process.stderr.write(`[Experimental] ${unusedEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
214
+ }
215
+ // Recalculate hasError and totalWarningCount from filtered violations
216
+ hasError = false;
217
+ totalWarningCount = 0;
218
+ for (const violations of filtered.values()) {
219
+ const errorCount = violations.filter(v => v.severity === 'error').length;
220
+ const warningCount = violations.filter(v => v.severity === 'warning').length;
221
+ totalWarningCount += warningCount;
222
+ if (errorCount > 0 || (warningCount > 0 && !options.allowWarnings)) {
223
+ hasError = true;
224
+ }
225
+ }
226
+ }
227
+ }
228
+ catch (error) {
229
+ if (isFatalError(error)) {
230
+ throw error;
231
+ }
232
+ // Suppressions file does not exist or is unreadable, proceed normally
233
+ }
140
234
  // Output results
141
235
  if (format === 'json') {
142
- const jsonOutput = collector.toArray();
143
- process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
236
+ if (suppressionsApplied) {
237
+ // Build filtered array with filePath
238
+ const jsonOutput = [];
239
+ for (const [filePath, violations] of outputViolationsByFile) {
240
+ for (const violation of violations) {
241
+ jsonOutput.push({ ...violation, filePath });
242
+ }
243
+ }
244
+ process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
245
+ }
246
+ else {
247
+ const jsonOutput = collector.toArray();
248
+ process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
249
+ }
144
250
  return false;
145
251
  }
146
252
  // Progressive出力が無効の場合のみループ後に出力
147
253
  if (!options.progressiveOutput) {
148
- // For standard/simple/github output, group violations by file
149
- const violationsByFile = collector.groupByFile();
150
254
  // Output per file - include processed files without violations
151
255
  for (const filePath of processedFiles) {
152
- const violations = violationsByFile.get(filePath) || [];
256
+ const violations = outputViolationsByFile.get(filePath) || [];
153
257
  const content = filesContent.get(filePath) || { sourceCode: '', fixedCode: '' };
154
258
  if (violations.length === 0 && !options.problemOnly) {
155
259
  log('Output reports');
@@ -180,6 +284,40 @@ export async function command(files, options, apiOptions) {
180
284
  }
181
285
  }
182
286
  }
287
+ let errorCount = 0;
288
+ let warningCount = 0;
289
+ let noticeCount = 0;
290
+ let failedFileCount = 0;
291
+ for (const filePath of processedFiles) {
292
+ const violations = outputViolationsByFile.get(filePath) || [];
293
+ if (violations.length > 0) {
294
+ failedFileCount++;
295
+ }
296
+ for (const violation of violations) {
297
+ switch (violation.severity) {
298
+ case 'error': {
299
+ errorCount++;
300
+ break;
301
+ }
302
+ case 'warning': {
303
+ warningCount++;
304
+ break;
305
+ }
306
+ case 'info': {
307
+ noticeCount++;
308
+ break;
309
+ }
310
+ }
311
+ }
312
+ }
313
+ outputSummary({
314
+ checkedFileCount: processedFiles.length,
315
+ failedFileCount,
316
+ skippedFileCount: skippedFiles.length,
317
+ errorCount,
318
+ warningCount,
319
+ noticeCount,
320
+ }, options);
183
321
  // Check if max-warnings limit is exceeded (ESLint compatible)
184
322
  if (!hasError && options.maxWarnings >= 0 && totalWarningCount > options.maxWarnings) {
185
323
  hasError = true;
@@ -1,5 +1,13 @@
1
1
  import type { CLIOptions } from './bootstrap.js';
2
2
  import type { MLResultInfo } from '../types.js';
3
+ export interface SummaryInfo {
4
+ readonly checkedFileCount: number;
5
+ readonly failedFileCount: number;
6
+ readonly skippedFileCount: number;
7
+ readonly errorCount: number;
8
+ readonly warningCount: number;
9
+ readonly noticeCount: number;
10
+ }
3
11
  /**
4
12
  * Writes lint results to stdout or stderr using the reporter selected by `--format`.
5
13
  *
@@ -12,3 +20,14 @@ import type { MLResultInfo } from '../types.js';
12
20
  * @param options - CLI options that control the output format and color.
13
21
  */
14
22
  export declare function output(results: MLResultInfo, options: CLIOptions): void;
23
+ /**
24
+ * Writes an aggregated lint summary after all file outputs have been emitted.
25
+ *
26
+ * Summary output is enabled for the standard and simple formats only.
27
+ * JSON and GitHub formats are left unchanged because they are intended for
28
+ * machine-readable output.
29
+ *
30
+ * @param summary - Aggregated counts across all linted files.
31
+ * @param options - CLI options that control the output format and color.
32
+ */
33
+ export declare function outputSummary(summary: SummaryInfo, options: CLIOptions): void;
package/lib/cli/output.js CHANGED
@@ -1,5 +1,8 @@
1
+ import { font, xterm } from '@markuplint/cli-utils';
1
2
  import stripAnsi from 'strip-ansi';
2
3
  import { simpleReporter, standardReporter, githubReporter } from '../reporter/index.js';
4
+ const loggerError = font.red;
5
+ const loggerWarning = xterm(208);
3
6
  /**
4
7
  * Writes lint results to stdout or stderr using the reporter selected by `--format`.
5
8
  *
@@ -43,3 +46,63 @@ export function output(results, options) {
43
46
  }
44
47
  process.stdout.write(msg);
45
48
  }
49
+ /**
50
+ * Writes an aggregated lint summary after all file outputs have been emitted.
51
+ *
52
+ * Summary output is enabled for the standard and simple formats only.
53
+ * JSON and GitHub formats are left unchanged because they are intended for
54
+ * machine-readable output.
55
+ *
56
+ * @param summary - Aggregated counts across all linted files.
57
+ * @param options - CLI options that control the output format and color.
58
+ */
59
+ export function outputSummary(summary, options) {
60
+ const format = options.format?.toLowerCase() ?? 'standard';
61
+ if (format === 'json' || format === 'github') {
62
+ return;
63
+ }
64
+ if (summary.checkedFileCount === 0 && summary.skippedFileCount === 0) {
65
+ return;
66
+ }
67
+ const problemCount = summary.errorCount + summary.warningCount + summary.noticeCount;
68
+ if (options.problemOnly && problemCount === 0) {
69
+ return;
70
+ }
71
+ const out = createSummaryLines(summary);
72
+ if (out.length === 0) {
73
+ return;
74
+ }
75
+ let msg = `\n${out.join('\n')}\n`;
76
+ msg = options.color ? msg : stripAnsi(msg);
77
+ if (problemCount > 0) {
78
+ process.stderr.write(msg);
79
+ return;
80
+ }
81
+ process.stdout.write(msg);
82
+ }
83
+ function createSummaryLines(summary) {
84
+ const out = [];
85
+ const problemCount = summary.errorCount + summary.warningCount + summary.noticeCount;
86
+ const passedFileCount = summary.checkedFileCount - summary.failedFileCount;
87
+ if (problemCount === 0) {
88
+ out.push(`${font.green('✔')} ${pluralize(summary.checkedFileCount, 'file')} checked, 0 problems`);
89
+ }
90
+ else {
91
+ const problemDetail = [
92
+ `${summary.errorCount} error${summary.errorCount === 1 ? '' : 's'}`,
93
+ `${summary.warningCount} warning${summary.warningCount === 1 ? '' : 's'}`,
94
+ summary.noticeCount > 0 ? `${summary.noticeCount} notice${summary.noticeCount === 1 ? '' : 's'}` : null,
95
+ ]
96
+ .filter(Boolean)
97
+ .join(', ');
98
+ const logger = summary.errorCount > 0 ? loggerError : loggerWarning;
99
+ out.push(`${logger('✖')} ${problemCount} problem${problemCount === 1 ? '' : 's'} (${problemDetail}) in ${pluralize(summary.checkedFileCount, 'file')}`, `${pluralize(summary.checkedFileCount, 'file')} checked: ${passedFileCount} passed, ${summary.failedFileCount} failed`);
100
+ }
101
+ if (summary.skippedFileCount > 0) {
102
+ out.push(`${font.yellow('⚠')} ${pluralize(summary.skippedFileCount, 'file')} skipped because --max-count was reached`);
103
+ }
104
+ return out;
105
+ }
106
+ function pluralize(count, noun) {
107
+ return `${count} ${noun}${count === 1 ? '' : 's'}`;
108
+ }
@@ -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;