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
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental
|
|
3
|
+
* Scope computation for bulk suppressions (Phase 2).
|
|
4
|
+
* Computes the Lowest Common Ancestor (LCA) of violation nodes and generates
|
|
5
|
+
* a minimal CSS selector to identify the subtree containing all violations.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/markuplint/markuplint/issues/3509
|
|
8
|
+
*/
|
|
9
|
+
import type { Violation } from '@markuplint/ml-config';
|
|
10
|
+
/**
|
|
11
|
+
* @experimental
|
|
12
|
+
* Minimal node interface for scope computation.
|
|
13
|
+
* Defined as a standalone interface (rather than importing `SelectorElement`
|
|
14
|
+
* from `@markuplint/selector`) to avoid coupling the `markuplint` package
|
|
15
|
+
* to the selector package's internal types. Both `SelectorElement` and
|
|
16
|
+
* `MLElement` satisfy this interface via structural typing.
|
|
17
|
+
*/
|
|
18
|
+
export interface ScopedNode {
|
|
19
|
+
readonly nodeType: number;
|
|
20
|
+
readonly localName: string;
|
|
21
|
+
readonly id: string;
|
|
22
|
+
readonly classList: Iterable<string> & {
|
|
23
|
+
contains(className: string): boolean;
|
|
24
|
+
};
|
|
25
|
+
readonly attributes: Iterable<{
|
|
26
|
+
readonly name: string;
|
|
27
|
+
readonly value: string;
|
|
28
|
+
}>;
|
|
29
|
+
readonly parentElement: ScopedNode | null;
|
|
30
|
+
readonly children: Iterable<ScopedNode>;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* @experimental
|
|
34
|
+
* Node with position information for reverse-lookup from violations.
|
|
35
|
+
*/
|
|
36
|
+
export interface PositionedNode extends ScopedNode {
|
|
37
|
+
readonly startLine: number;
|
|
38
|
+
readonly startCol: number;
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* @experimental
|
|
42
|
+
* Returns the ancestor chain from the node's parent up to (but not including) the root.
|
|
43
|
+
* Only includes element nodes.
|
|
44
|
+
*
|
|
45
|
+
* @param node - The starting node.
|
|
46
|
+
* @returns Array of ancestor elements, closest first.
|
|
47
|
+
*/
|
|
48
|
+
export declare function getAncestorChain(node: ScopedNode): ScopedNode[];
|
|
49
|
+
/**
|
|
50
|
+
* @experimental
|
|
51
|
+
* Computes the Lowest Common Ancestor (LCA) of a set of nodes.
|
|
52
|
+
* Returns null if the LCA is `body`, `html`, or the input is empty.
|
|
53
|
+
*
|
|
54
|
+
* @param nodes - The nodes to find the LCA for.
|
|
55
|
+
* @returns The LCA node, or null if it falls back to file-level.
|
|
56
|
+
*/
|
|
57
|
+
export declare function computeLCA(nodes: readonly ScopedNode[]): ScopedNode | null;
|
|
58
|
+
/**
|
|
59
|
+
* @experimental
|
|
60
|
+
* Generates a minimal CSS selector that uniquely identifies the given node.
|
|
61
|
+
*
|
|
62
|
+
* Strategy:
|
|
63
|
+
* 1. If the node has an `id`, use `#id` (most unique)
|
|
64
|
+
* 2. If the node has classes, use `tag.class`
|
|
65
|
+
* 3. Otherwise, build an ancestor path using `>` combinators, stopping at
|
|
66
|
+
* the first ancestor with an `id`
|
|
67
|
+
*
|
|
68
|
+
* Returns `undefined` for `body` or `html` nodes (file-level fallback).
|
|
69
|
+
*
|
|
70
|
+
* @param node - The node to generate a selector for.
|
|
71
|
+
* @returns A CSS selector string, or undefined for file-level scope.
|
|
72
|
+
*/
|
|
73
|
+
export declare function generateUniqueSelector(node: ScopedNode): string | undefined;
|
|
74
|
+
/**
|
|
75
|
+
* @experimental
|
|
76
|
+
* Finds the element node at the given line/col position in the node list.
|
|
77
|
+
* If the position matches a non-element node (e.g., attribute), walks up
|
|
78
|
+
* to the nearest parent element.
|
|
79
|
+
*
|
|
80
|
+
* @param nodeList - Flat list of all nodes in the document.
|
|
81
|
+
* @param line - 1-based line number.
|
|
82
|
+
* @param col - 1-based column number.
|
|
83
|
+
* @returns The element node at the position, or null if not found.
|
|
84
|
+
*/
|
|
85
|
+
export declare function findNodeAtPosition(nodeList: readonly PositionedNode[], line: number, col: number): ScopedNode | null;
|
|
86
|
+
/**
|
|
87
|
+
* @experimental
|
|
88
|
+
* Computes the scope selector for a set of violations within a document.
|
|
89
|
+
* Only considers error-severity violations. Returns `undefined` when
|
|
90
|
+
* the LCA falls back to file-level (body/html) or when nodes cannot be found.
|
|
91
|
+
*
|
|
92
|
+
* @param nodeList - Flat list of all nodes in the document.
|
|
93
|
+
* @param violations - The violations to compute scope for.
|
|
94
|
+
* @returns A CSS selector string, or undefined for file-level scope.
|
|
95
|
+
*/
|
|
96
|
+
export declare function computeScopeForViolations(nodeList: readonly PositionedNode[], violations: readonly Violation[]): string | undefined;
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental
|
|
3
|
+
* Scope computation for bulk suppressions (Phase 2).
|
|
4
|
+
* Computes the Lowest Common Ancestor (LCA) of violation nodes and generates
|
|
5
|
+
* a minimal CSS selector to identify the subtree containing all violations.
|
|
6
|
+
*
|
|
7
|
+
* @see https://github.com/markuplint/markuplint/issues/3509
|
|
8
|
+
*/
|
|
9
|
+
const BODY_HTML_TAGS = new Set(['body', 'html']);
|
|
10
|
+
/**
|
|
11
|
+
* @experimental
|
|
12
|
+
* Returns the ancestor chain from the node's parent up to (but not including) the root.
|
|
13
|
+
* Only includes element nodes.
|
|
14
|
+
*
|
|
15
|
+
* @param node - The starting node.
|
|
16
|
+
* @returns Array of ancestor elements, closest first.
|
|
17
|
+
*/
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
19
|
+
export function getAncestorChain(node) {
|
|
20
|
+
const chain = [];
|
|
21
|
+
let current = node.parentElement;
|
|
22
|
+
while (current) {
|
|
23
|
+
chain.push(current);
|
|
24
|
+
current = current.parentElement;
|
|
25
|
+
}
|
|
26
|
+
return chain;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* @experimental
|
|
30
|
+
* Computes the Lowest Common Ancestor (LCA) of a set of nodes.
|
|
31
|
+
* Returns null if the LCA is `body`, `html`, or the input is empty.
|
|
32
|
+
*
|
|
33
|
+
* @param nodes - The nodes to find the LCA for.
|
|
34
|
+
* @returns The LCA node, or null if it falls back to file-level.
|
|
35
|
+
*/
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
37
|
+
export function computeLCA(nodes) {
|
|
38
|
+
if (nodes.length === 0) {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
if (nodes.length === 1) {
|
|
42
|
+
const parent = nodes[0].parentElement;
|
|
43
|
+
if (!parent || BODY_HTML_TAGS.has(parent.localName)) {
|
|
44
|
+
return null;
|
|
45
|
+
}
|
|
46
|
+
return parent;
|
|
47
|
+
}
|
|
48
|
+
// Get ancestor chains for all nodes (including the node itself)
|
|
49
|
+
const chains = nodes.map(node => {
|
|
50
|
+
const chain = [node, ...getAncestorChain(node)];
|
|
51
|
+
// Reverse so root is first
|
|
52
|
+
chain.reverse();
|
|
53
|
+
return chain;
|
|
54
|
+
});
|
|
55
|
+
// Walk from root, find deepest common ancestor
|
|
56
|
+
const minLength = Math.min(...chains.map(c => c.length));
|
|
57
|
+
let lca = null;
|
|
58
|
+
for (let i = 0; i < minLength; i++) {
|
|
59
|
+
const candidate = chains[0][i];
|
|
60
|
+
if (chains.every(chain => chain[i] === candidate)) {
|
|
61
|
+
lca = candidate;
|
|
62
|
+
}
|
|
63
|
+
else {
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (!lca || BODY_HTML_TAGS.has(lca.localName)) {
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
return lca;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* @experimental
|
|
74
|
+
* Generates a minimal CSS selector that uniquely identifies the given node.
|
|
75
|
+
*
|
|
76
|
+
* Strategy:
|
|
77
|
+
* 1. If the node has an `id`, use `#id` (most unique)
|
|
78
|
+
* 2. If the node has classes, use `tag.class`
|
|
79
|
+
* 3. Otherwise, build an ancestor path using `>` combinators, stopping at
|
|
80
|
+
* the first ancestor with an `id`
|
|
81
|
+
*
|
|
82
|
+
* Returns `undefined` for `body` or `html` nodes (file-level fallback).
|
|
83
|
+
*
|
|
84
|
+
* @param node - The node to generate a selector for.
|
|
85
|
+
* @returns A CSS selector string, or undefined for file-level scope.
|
|
86
|
+
*/
|
|
87
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
88
|
+
export function generateUniqueSelector(node) {
|
|
89
|
+
if (BODY_HTML_TAGS.has(node.localName)) {
|
|
90
|
+
return undefined;
|
|
91
|
+
}
|
|
92
|
+
// If this node has an id, just use it
|
|
93
|
+
if (node.id) {
|
|
94
|
+
return `#${node.id}`;
|
|
95
|
+
}
|
|
96
|
+
// If this node has classes, use tag.classA.classB (all classes)
|
|
97
|
+
const classes = [...node.classList];
|
|
98
|
+
if (classes.length > 0) {
|
|
99
|
+
return `${node.localName}.${classes.join('.')}`;
|
|
100
|
+
}
|
|
101
|
+
// If this node has a distinguishing attribute (role, or type for input),
|
|
102
|
+
// use tag[attr="value"] — more readable than nth-of-type and more stable
|
|
103
|
+
const attrSelector = getDistinguishingAttrSelector(node);
|
|
104
|
+
if (attrSelector) {
|
|
105
|
+
return attrSelector;
|
|
106
|
+
}
|
|
107
|
+
// Build ancestor path with nth-of-type for disambiguation, stopping at
|
|
108
|
+
// an id, class, or distinguishing attribute
|
|
109
|
+
const parts = [buildSegmentWithNth(node)];
|
|
110
|
+
let current = node.parentElement;
|
|
111
|
+
while (current && !BODY_HTML_TAGS.has(current.localName)) {
|
|
112
|
+
if (current.id) {
|
|
113
|
+
parts.unshift(`#${current.id}`);
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
const currentClasses = [...current.classList];
|
|
117
|
+
if (currentClasses.length > 0) {
|
|
118
|
+
parts.unshift(`${current.localName}.${currentClasses.join('.')}`);
|
|
119
|
+
break;
|
|
120
|
+
}
|
|
121
|
+
const currentAttr = getDistinguishingAttrSelector(current);
|
|
122
|
+
if (currentAttr) {
|
|
123
|
+
parts.unshift(currentAttr);
|
|
124
|
+
break;
|
|
125
|
+
}
|
|
126
|
+
parts.unshift(buildSegmentWithNth(current));
|
|
127
|
+
current = current.parentElement;
|
|
128
|
+
}
|
|
129
|
+
return parts.join(' > ');
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Attributes that serve as distinguishing selectors.
|
|
133
|
+
* - `role`: Universally applicable — identifies landmark/widget semantics
|
|
134
|
+
* - `type`: Only for `<input>` — distinguishes text/checkbox/radio/etc.
|
|
135
|
+
*/
|
|
136
|
+
const DISTINGUISHING_ATTRS = new Map([
|
|
137
|
+
[null, ['role']], // applicable to any element
|
|
138
|
+
['input', ['type']], // only for <input>
|
|
139
|
+
]);
|
|
140
|
+
/**
|
|
141
|
+
* Returns a `tag[attr="value"]` selector if the node has a distinguishing
|
|
142
|
+
* attribute (e.g., `role` or `type` for input). Returns `undefined` otherwise.
|
|
143
|
+
*/
|
|
144
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
145
|
+
function getDistinguishingAttrSelector(node) {
|
|
146
|
+
const universalAttrs = DISTINGUISHING_ATTRS.get(null) ?? [];
|
|
147
|
+
const elementAttrs = DISTINGUISHING_ATTRS.get(node.localName) ?? [];
|
|
148
|
+
const attrsToCheck = [...universalAttrs, ...elementAttrs];
|
|
149
|
+
for (const attrName of attrsToCheck) {
|
|
150
|
+
for (const attr of node.attributes) {
|
|
151
|
+
if (attr.name === attrName && attr.value) {
|
|
152
|
+
return `${node.localName}[${attrName}="${attr.value}"]`;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* Builds a selector segment for a node, adding `:nth-of-type(n)` when the
|
|
160
|
+
* node has same-tag siblings to ensure uniqueness.
|
|
161
|
+
*/
|
|
162
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
163
|
+
function buildSegmentWithNth(node) {
|
|
164
|
+
const parent = node.parentElement;
|
|
165
|
+
if (!parent) {
|
|
166
|
+
return node.localName;
|
|
167
|
+
}
|
|
168
|
+
// Count same-tag siblings and find this node's position
|
|
169
|
+
let sameTagCount = 0;
|
|
170
|
+
let position = 0;
|
|
171
|
+
for (const sibling of parent.children) {
|
|
172
|
+
if (sibling.localName === node.localName) {
|
|
173
|
+
sameTagCount++;
|
|
174
|
+
if (sibling === node) {
|
|
175
|
+
position = sameTagCount;
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
if (sameTagCount > 1) {
|
|
180
|
+
return `${node.localName}:nth-of-type(${position})`;
|
|
181
|
+
}
|
|
182
|
+
return node.localName;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* @experimental
|
|
186
|
+
* Finds the element node at the given line/col position in the node list.
|
|
187
|
+
* If the position matches a non-element node (e.g., attribute), walks up
|
|
188
|
+
* to the nearest parent element.
|
|
189
|
+
*
|
|
190
|
+
* @param nodeList - Flat list of all nodes in the document.
|
|
191
|
+
* @param line - 1-based line number.
|
|
192
|
+
* @param col - 1-based column number.
|
|
193
|
+
* @returns The element node at the position, or null if not found.
|
|
194
|
+
*/
|
|
195
|
+
export function findNodeAtPosition(
|
|
196
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
197
|
+
nodeList, line, col) {
|
|
198
|
+
for (const node of nodeList) {
|
|
199
|
+
if (node.startLine === line && node.startCol === col) {
|
|
200
|
+
if (node.nodeType === 1) {
|
|
201
|
+
return node;
|
|
202
|
+
}
|
|
203
|
+
// Non-element (e.g., attribute) — walk up to parent element
|
|
204
|
+
let parent = node.parentElement;
|
|
205
|
+
while (parent && parent.nodeType !== 1) {
|
|
206
|
+
parent = parent.parentElement;
|
|
207
|
+
}
|
|
208
|
+
return parent;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/**
|
|
214
|
+
* @experimental
|
|
215
|
+
* Computes the scope selector for a set of violations within a document.
|
|
216
|
+
* Only considers error-severity violations. Returns `undefined` when
|
|
217
|
+
* the LCA falls back to file-level (body/html) or when nodes cannot be found.
|
|
218
|
+
*
|
|
219
|
+
* @param nodeList - Flat list of all nodes in the document.
|
|
220
|
+
* @param violations - The violations to compute scope for.
|
|
221
|
+
* @returns A CSS selector string, or undefined for file-level scope.
|
|
222
|
+
*/
|
|
223
|
+
export function computeScopeForViolations(
|
|
224
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
225
|
+
nodeList, violations) {
|
|
226
|
+
const nodes = [];
|
|
227
|
+
for (const v of violations) {
|
|
228
|
+
if (v.severity !== 'error') {
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
const node = findNodeAtPosition(nodeList, v.line, v.col);
|
|
232
|
+
if (node) {
|
|
233
|
+
nodes.push(node);
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
if (nodes.length === 0) {
|
|
237
|
+
return undefined;
|
|
238
|
+
}
|
|
239
|
+
const lca = computeLCA(nodes);
|
|
240
|
+
if (!lca) {
|
|
241
|
+
return undefined;
|
|
242
|
+
}
|
|
243
|
+
return generateUniqueSelector(lca);
|
|
244
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
* Options for suppressions generation.
|
|
7
|
+
*/
|
|
8
|
+
export type GenerateSuppressionsOptions = {
|
|
9
|
+
/** If provided, only count violations for this specific ruleId. */
|
|
10
|
+
readonly filterRule?: string;
|
|
11
|
+
/**
|
|
12
|
+
* Map of absolute file paths to their document node lists.
|
|
13
|
+
* When provided, scope selectors are computed via LCA.
|
|
14
|
+
* When absent, file-level suppressions are generated (Phase 1 compatible).
|
|
15
|
+
*/
|
|
16
|
+
readonly nodeLists?: ReadonlyMap<string, readonly PositionedNode[]>;
|
|
17
|
+
};
|
|
18
|
+
/**
|
|
19
|
+
* @experimental
|
|
20
|
+
* Generates suppressions data from collected violations.
|
|
21
|
+
* Only error-severity violations are counted.
|
|
22
|
+
*
|
|
23
|
+
* When `options.nodeLists` is provided, computes a scope selector for each
|
|
24
|
+
* rule's violations via LCA (Lowest Common Ancestor). Otherwise, generates
|
|
25
|
+
* file-level suppressions (Phase 1 compatible).
|
|
26
|
+
*
|
|
27
|
+
* @param violationsByFile - Map of absolute file paths to their violations.
|
|
28
|
+
* @param suppressionsFilePath - Absolute path to the suppressions file (used for relative path calculation).
|
|
29
|
+
* @param options - Optional generation options.
|
|
30
|
+
* @returns The generated suppressions data.
|
|
31
|
+
*/
|
|
32
|
+
export declare function generateSuppressions(violationsByFile: ReadonlyMap<string, readonly Violation[]>, suppressionsFilePath: string, options?: GenerateSuppressionsOptions): SuppressionsData;
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { computeScopeForViolations } from './compute-scope.js';
|
|
2
|
+
import { toRelativePath } from './suppressions-file.js';
|
|
3
|
+
/**
|
|
4
|
+
* @experimental
|
|
5
|
+
* Generates suppressions data from collected violations.
|
|
6
|
+
* Only error-severity violations are counted.
|
|
7
|
+
*
|
|
8
|
+
* When `options.nodeLists` is provided, computes a scope selector for each
|
|
9
|
+
* rule's violations via LCA (Lowest Common Ancestor). Otherwise, generates
|
|
10
|
+
* file-level suppressions (Phase 1 compatible).
|
|
11
|
+
*
|
|
12
|
+
* @param violationsByFile - Map of absolute file paths to their violations.
|
|
13
|
+
* @param suppressionsFilePath - Absolute path to the suppressions file (used for relative path calculation).
|
|
14
|
+
* @param options - Optional generation options.
|
|
15
|
+
* @returns The generated suppressions data.
|
|
16
|
+
*/
|
|
17
|
+
export function generateSuppressions(
|
|
18
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
19
|
+
violationsByFile, suppressionsFilePath,
|
|
20
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
21
|
+
options) {
|
|
22
|
+
const filterRule = options?.filterRule;
|
|
23
|
+
const nodeLists = options?.nodeLists;
|
|
24
|
+
const data = {};
|
|
25
|
+
for (const [absolutePath, violations] of violationsByFile) {
|
|
26
|
+
// Group error violations by ruleId
|
|
27
|
+
const byRule = new Map();
|
|
28
|
+
for (const v of violations) {
|
|
29
|
+
if (v.severity !== 'error') {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
if (filterRule && v.ruleId !== filterRule) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const list = byRule.get(v.ruleId);
|
|
36
|
+
if (list) {
|
|
37
|
+
list.push(v);
|
|
38
|
+
}
|
|
39
|
+
else {
|
|
40
|
+
byRule.set(v.ruleId, [v]);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
if (byRule.size > 0) {
|
|
44
|
+
const relPath = toRelativePath(absolutePath, suppressionsFilePath);
|
|
45
|
+
const nodeList = nodeLists?.get(absolutePath);
|
|
46
|
+
const rules = {};
|
|
47
|
+
for (const [ruleId, ruleViolations] of byRule) {
|
|
48
|
+
const scope = nodeList ? computeScopeForViolations(nodeList, ruleViolations) : undefined;
|
|
49
|
+
rules[ruleId] = scope ? { count: ruleViolations.length, scope } : { count: ruleViolations.length };
|
|
50
|
+
}
|
|
51
|
+
data[relPath] = rules;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return data;
|
|
55
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental Bulk Suppressions for markuplint.
|
|
3
|
+
*
|
|
4
|
+
* Allows recording existing violations in a JSON file and suppressing them
|
|
5
|
+
* during subsequent lint runs, so new code is strictly enforced while
|
|
6
|
+
* existing violations are addressed incrementally.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/markuplint/markuplint/issues/3503
|
|
9
|
+
* @see https://eslint.org/docs/latest/use/suppressions — Reference design
|
|
10
|
+
*/
|
|
11
|
+
export type { SuppressionsData, SuppressionEntry } from './types.js';
|
|
12
|
+
export type { ApplySuppressionsResult, ApplySuppressionsOptions } from './apply-suppressions.js';
|
|
13
|
+
export type { GenerateSuppressionsOptions } from './generate-suppressions.js';
|
|
14
|
+
export type { ScopedNode, PositionedNode } from './compute-scope.js';
|
|
15
|
+
export { computeScopeForViolations, computeLCA, generateUniqueSelector } from './compute-scope.js';
|
|
16
|
+
export { applySuppressions } from './apply-suppressions.js';
|
|
17
|
+
export { generateSuppressions } from './generate-suppressions.js';
|
|
18
|
+
export { mergeSuppressions } from './merge-suppressions.js';
|
|
19
|
+
export { pruneSuppressions } from './prune-suppressions.js';
|
|
20
|
+
export { readSuppressionsFile, writeSuppressionsFile, resolveSuppressionsPath, toRelativePath, toAbsolutePath, } from './suppressions-file.js';
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental Bulk Suppressions for markuplint.
|
|
3
|
+
*
|
|
4
|
+
* Allows recording existing violations in a JSON file and suppressing them
|
|
5
|
+
* during subsequent lint runs, so new code is strictly enforced while
|
|
6
|
+
* existing violations are addressed incrementally.
|
|
7
|
+
*
|
|
8
|
+
* @see https://github.com/markuplint/markuplint/issues/3503
|
|
9
|
+
* @see https://eslint.org/docs/latest/use/suppressions — Reference design
|
|
10
|
+
*/
|
|
11
|
+
export { computeScopeForViolations, computeLCA, generateUniqueSelector } from './compute-scope.js';
|
|
12
|
+
export { applySuppressions } from './apply-suppressions.js';
|
|
13
|
+
export { generateSuppressions } from './generate-suppressions.js';
|
|
14
|
+
export { mergeSuppressions } from './merge-suppressions.js';
|
|
15
|
+
export { pruneSuppressions } from './prune-suppressions.js';
|
|
16
|
+
export { readSuppressionsFile, writeSuppressionsFile, resolveSuppressionsPath, toRelativePath, toAbsolutePath, } from './suppressions-file.js';
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { SuppressionsData } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* @experimental
|
|
4
|
+
* Merges incoming suppressions into existing suppressions.
|
|
5
|
+
* For overlapping entries, takes the maximum count.
|
|
6
|
+
*
|
|
7
|
+
* @param existing - The current suppressions data.
|
|
8
|
+
* @param incoming - The new suppressions data to merge.
|
|
9
|
+
* @returns The merged suppressions data.
|
|
10
|
+
*/
|
|
11
|
+
export declare function mergeSuppressions(existing: SuppressionsData, incoming: SuppressionsData): SuppressionsData;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @experimental
|
|
3
|
+
* Merges incoming suppressions into existing suppressions.
|
|
4
|
+
* For overlapping entries, takes the maximum count.
|
|
5
|
+
*
|
|
6
|
+
* @param existing - The current suppressions data.
|
|
7
|
+
* @param incoming - The new suppressions data to merge.
|
|
8
|
+
* @returns The merged suppressions data.
|
|
9
|
+
*/
|
|
10
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
11
|
+
export function mergeSuppressions(existing, incoming) {
|
|
12
|
+
// Uses max(count) to ensure running --suppress multiple times is idempotent
|
|
13
|
+
// and never accidentally lowers a suppression threshold. Counts are only
|
|
14
|
+
// reduced explicitly via --prune-suppressions.
|
|
15
|
+
const merged = {};
|
|
16
|
+
// Collect all file paths
|
|
17
|
+
const allFiles = new Set([...Object.keys(existing), ...Object.keys(incoming)]);
|
|
18
|
+
for (const filePath of allFiles) {
|
|
19
|
+
const existingRules = existing[filePath] ?? {};
|
|
20
|
+
const incomingRules = incoming[filePath] ?? {};
|
|
21
|
+
const allRules = new Set([...Object.keys(existingRules), ...Object.keys(incomingRules)]);
|
|
22
|
+
const mergedRules = {};
|
|
23
|
+
for (const ruleId of allRules) {
|
|
24
|
+
const existingCount = existingRules[ruleId]?.count ?? 0;
|
|
25
|
+
const incomingCount = incomingRules[ruleId]?.count ?? 0;
|
|
26
|
+
mergedRules[ruleId] = { count: Math.max(existingCount, incomingCount) };
|
|
27
|
+
}
|
|
28
|
+
merged[filePath] = mergedRules;
|
|
29
|
+
}
|
|
30
|
+
return merged;
|
|
31
|
+
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { Violation } from '@markuplint/ml-config';
|
|
2
|
+
import type { SuppressionsData } from './types.js';
|
|
3
|
+
/**
|
|
4
|
+
* @experimental
|
|
5
|
+
* Prunes stale entries from suppressions data based on current violations.
|
|
6
|
+
*
|
|
7
|
+
* - Entries with 0 current violations are removed entirely.
|
|
8
|
+
* - Entries with current count < suppressed count are updated to the current count.
|
|
9
|
+
* - Entries with current count >= suppressed count are kept as-is.
|
|
10
|
+
* - Empty file entries (all rules removed) are removed from the top-level.
|
|
11
|
+
*
|
|
12
|
+
* **Note:** Pruning counts all file-level violations, not scope-filtered ones.
|
|
13
|
+
* This is because `pruneSuppressions` does not receive document node lists
|
|
14
|
+
* (required for scope-based violation filtering). For scope-accurate counts,
|
|
15
|
+
* re-run `--suppress` which recomputes both scope and count from the DOM tree.
|
|
16
|
+
*
|
|
17
|
+
* @param currentViolations - Map of absolute file paths to current violations.
|
|
18
|
+
* @param existing - The current suppressions data.
|
|
19
|
+
* @param suppressionsFilePath - Absolute path to the suppressions file.
|
|
20
|
+
* @returns The pruned suppressions data.
|
|
21
|
+
*/
|
|
22
|
+
export declare function pruneSuppressions(currentViolations: ReadonlyMap<string, readonly Violation[]>, existing: SuppressionsData, suppressionsFilePath: string): SuppressionsData;
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { toRelativePath } from './suppressions-file.js';
|
|
2
|
+
/**
|
|
3
|
+
* @experimental
|
|
4
|
+
* Prunes stale entries from suppressions data based on current violations.
|
|
5
|
+
*
|
|
6
|
+
* - Entries with 0 current violations are removed entirely.
|
|
7
|
+
* - Entries with current count < suppressed count are updated to the current count.
|
|
8
|
+
* - Entries with current count >= suppressed count are kept as-is.
|
|
9
|
+
* - Empty file entries (all rules removed) are removed from the top-level.
|
|
10
|
+
*
|
|
11
|
+
* **Note:** Pruning counts all file-level violations, not scope-filtered ones.
|
|
12
|
+
* This is because `pruneSuppressions` does not receive document node lists
|
|
13
|
+
* (required for scope-based violation filtering). For scope-accurate counts,
|
|
14
|
+
* re-run `--suppress` which recomputes both scope and count from the DOM tree.
|
|
15
|
+
*
|
|
16
|
+
* @param currentViolations - Map of absolute file paths to current violations.
|
|
17
|
+
* @param existing - The current suppressions data.
|
|
18
|
+
* @param suppressionsFilePath - Absolute path to the suppressions file.
|
|
19
|
+
* @returns The pruned suppressions data.
|
|
20
|
+
*/
|
|
21
|
+
export function pruneSuppressions(
|
|
22
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
23
|
+
currentViolations,
|
|
24
|
+
// eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
|
|
25
|
+
existing, suppressionsFilePath) {
|
|
26
|
+
// Build a lookup: relPath → ruleId → error count
|
|
27
|
+
const currentCounts = new Map();
|
|
28
|
+
for (const [absolutePath, violations] of currentViolations) {
|
|
29
|
+
const relPath = toRelativePath(absolutePath, suppressionsFilePath);
|
|
30
|
+
const ruleCounts = new Map();
|
|
31
|
+
for (const v of violations) {
|
|
32
|
+
if (v.severity === 'error') {
|
|
33
|
+
ruleCounts.set(v.ruleId, (ruleCounts.get(v.ruleId) ?? 0) + 1);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
currentCounts.set(relPath, ruleCounts);
|
|
37
|
+
}
|
|
38
|
+
const pruned = {};
|
|
39
|
+
for (const [filePath, rules] of Object.entries(existing)) {
|
|
40
|
+
const fileCounts = currentCounts.get(filePath);
|
|
41
|
+
const prunedRules = {};
|
|
42
|
+
for (const [ruleId, entry] of Object.entries(rules)) {
|
|
43
|
+
const currentCount = fileCounts?.get(ruleId) ?? 0;
|
|
44
|
+
if (currentCount === 0) {
|
|
45
|
+
// No more violations, remove entry
|
|
46
|
+
continue;
|
|
47
|
+
}
|
|
48
|
+
if (currentCount < entry.count) {
|
|
49
|
+
// Reduced violations, update count; preserve scope if present
|
|
50
|
+
prunedRules[ruleId] = entry.scope
|
|
51
|
+
? { count: currentCount, scope: entry.scope }
|
|
52
|
+
: { count: currentCount };
|
|
53
|
+
}
|
|
54
|
+
else {
|
|
55
|
+
// Same or more violations, keep as-is (including scope)
|
|
56
|
+
prunedRules[ruleId] = entry;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
if (Object.keys(prunedRules).length > 0) {
|
|
60
|
+
pruned[filePath] = prunedRules;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return pruned;
|
|
64
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import type { SuppressionsData } from './types.js';
|
|
2
|
+
/**
|
|
3
|
+
* @experimental
|
|
4
|
+
* Resolves the absolute path to the suppressions file.
|
|
5
|
+
*
|
|
6
|
+
* @param customPath - Optional custom path (relative or absolute). Defaults to `markuplint-suppressions.json` in CWD.
|
|
7
|
+
* @returns The absolute file path.
|
|
8
|
+
*/
|
|
9
|
+
export declare function resolveSuppressionsPath(customPath?: string): string;
|
|
10
|
+
/**
|
|
11
|
+
* @experimental
|
|
12
|
+
* Reads and parses a suppressions JSON file.
|
|
13
|
+
* Returns an empty object if the file does not exist.
|
|
14
|
+
*
|
|
15
|
+
* @param filePath - Absolute path to the suppressions file.
|
|
16
|
+
* @returns The parsed suppressions data, or an empty object if the file does not exist.
|
|
17
|
+
*/
|
|
18
|
+
export declare function readSuppressionsFile(filePath: string): Promise<SuppressionsData>;
|
|
19
|
+
/**
|
|
20
|
+
* @experimental
|
|
21
|
+
* Writes suppressions data to a JSON file with sorted keys.
|
|
22
|
+
* Deletes the file if data is empty.
|
|
23
|
+
*
|
|
24
|
+
* @param filePath - Absolute path to the suppressions file.
|
|
25
|
+
* @param data - The suppressions data to write.
|
|
26
|
+
*/
|
|
27
|
+
export declare function writeSuppressionsFile(filePath: string, data: SuppressionsData): Promise<void>;
|
|
28
|
+
/**
|
|
29
|
+
* @experimental
|
|
30
|
+
* Converts an absolute file path to a relative path based on the suppressions file location.
|
|
31
|
+
* Always uses POSIX separators for cross-platform consistency.
|
|
32
|
+
*
|
|
33
|
+
* @param absoluteFilePath - The absolute path to the linted file.
|
|
34
|
+
* @param suppressionsFilePath - The absolute path to the suppressions file.
|
|
35
|
+
* @returns The relative path using POSIX separators.
|
|
36
|
+
*/
|
|
37
|
+
export declare function toRelativePath(absoluteFilePath: string, suppressionsFilePath: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* @experimental
|
|
40
|
+
* Converts a relative path (from the suppressions file) back to an absolute path.
|
|
41
|
+
*
|
|
42
|
+
* @param relativeFilePath - The relative path stored in the suppressions file.
|
|
43
|
+
* @param suppressionsFilePath - The absolute path to the suppressions file.
|
|
44
|
+
* @returns The absolute file path.
|
|
45
|
+
*/
|
|
46
|
+
export declare function toAbsolutePath(relativeFilePath: string, suppressionsFilePath: string): string;
|