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.
- package/ARCHITECTURE.ja.md +1 -1
- package/ARCHITECTURE.md +1 -1
- package/CHANGELOG.md +18 -0
- package/README.md +43 -3
- package/lib/api/ml-engine.js +13 -1
- package/lib/cli/bootstrap.d.ts +15 -1
- package/lib/cli/bootstrap.js +19 -0
- package/lib/cli/command.js +112 -5
- package/lib/suppressions/apply-suppressions.d.ts +45 -0
- package/lib/suppressions/apply-suppressions.js +221 -0
- package/lib/suppressions/compute-scope.d.ts +96 -0
- package/lib/suppressions/compute-scope.js +244 -0
- package/lib/suppressions/generate-suppressions.d.ts +32 -0
- package/lib/suppressions/generate-suppressions.js +55 -0
- package/lib/suppressions/index.d.ts +20 -0
- package/lib/suppressions/index.js +16 -0
- package/lib/suppressions/merge-suppressions.d.ts +11 -0
- package/lib/suppressions/merge-suppressions.js +31 -0
- package/lib/suppressions/prune-suppressions.d.ts +22 -0
- package/lib/suppressions/prune-suppressions.js +64 -0
- package/lib/suppressions/suppressions-file.d.ts +46 -0
- package/lib/suppressions/suppressions-file.js +108 -0
- package/lib/suppressions/types.d.ts +26 -0
- package/lib/suppressions/types.js +1 -0
- package/package.json +15 -15
- package/lib/api/v1.d.ts +0 -57
- package/lib/api/v1.js +0 -41
- package/lib/v1.d.ts +0 -6
- package/lib/v1.js +0 -6
package/ARCHITECTURE.ja.md
CHANGED
|
@@ -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
|
|
package/lib/api/ml-engine.js
CHANGED
|
@@ -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
|
-
|
|
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: [
|
package/lib/cli/bootstrap.d.ts
CHANGED
|
@@ -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.
|
package/lib/cli/bootstrap.js
CHANGED
|
@@ -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
|
});
|
package/lib/cli/command.js
CHANGED
|
@@ -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
|
-
|
|
143
|
-
|
|
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 =
|
|
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
|
+
}
|