markuplint 5.5.0 → 5.6.0

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,13 @@
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.6.0](https://github.com/markuplint/markuplint/compare/v5.5.0...v5.6.0) (2026-11-12)
7
+
8
+ ### Bug Fixes
9
+
10
+ * **markuplint:** keep the scope of the suppressions ([4f2409d](https://github.com/markuplint/markuplint/commit/4f2409d1d9c98cefbbe91f7c0f084f5855fafeb5)), closes [#4592](https://github.com/markuplint/markuplint/issues/4592)
11
+
12
+
6
13
  # [5.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
7
14
 
8
15
  ### Bug Fixes
package/README.md CHANGED
@@ -136,6 +136,7 @@ $ markuplint --prune-suppressions "src/**/*.html"
136
136
  - Only `error`-severity violations are suppressed; `warning` and `info` always pass through.
137
137
  - If the number of violations for a file+rule pair **exceeds** the suppressed count, **all** violations for that pair are reported.
138
138
  - `--suppress` always exits with code 0 (success).
139
+ - Running `--suppress` again keeps the entries it does not record, scope included, and gives the ones it records the newly computed scope.
139
140
  - 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.
140
141
 
141
142
  ```json
@@ -246,8 +246,9 @@ export async function command(files, options, apiOptions) {
246
246
  }
247
247
  const doc = scopeEngine.document;
248
248
  if (doc) {
249
- // MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
250
- // id, classList, parentElement, children are all present). The double cast is
249
+ // MLNode structurally satisfies PositionedNode (startLine, startCol, endLine, endCol,
250
+ // localName, id, classList, parentElement, children, and closeTag on an element,
251
+ // are all present). The double cast is
251
252
  // needed because TypeScript can't verify structural compatibility between the
252
253
  // generic MLNode<T,O> and the plain PositionedNode interface at compile time.
253
254
  nodeList = doc.nodeList;
@@ -50,6 +50,29 @@ export interface ScopedNode {
50
50
  export interface PositionedNode extends ScopedNode {
51
51
  readonly startLine: number;
52
52
  readonly startCol: number;
53
+ /**
54
+ * Where the node's source ends, excluded. For an element, the end of its start tag.
55
+ * A violation inside this range belongs to the node (see `findNodeAtPosition`);
56
+ * without it, only a violation at the node's start does.
57
+ */
58
+ readonly endLine?: number;
59
+ /** The column of `endLine`, excluded. */
60
+ readonly endCol?: number;
61
+ /**
62
+ * The end tag of an element. A document's node list has no end tags, so a
63
+ * violation reported at one (`</P>`) is found through its element.
64
+ */
65
+ readonly closeTag?: SourceRange | null;
66
+ }
67
+ /**
68
+ * @experimental
69
+ * A source range: from its start, included, to its end, excluded.
70
+ */
71
+ export interface SourceRange {
72
+ readonly startLine: number;
73
+ readonly startCol: number;
74
+ readonly endLine?: number;
75
+ readonly endCol?: number;
53
76
  }
54
77
  /**
55
78
  * @experimental
@@ -143,6 +166,15 @@ export declare function computeLCA(nodes: readonly ScopedNode[]): ScopedNode | n
143
166
  export declare function generateUniqueSelector(node: ScopedNode): string | undefined;
144
167
  /**
145
168
  * @experimental
169
+ * Finds the element a violation at `line`:`col` belongs to.
170
+ *
171
+ * The node is the one `searchNodeAtLocation` of `@markuplint/ml-core` finds,
172
+ * the rule `MLDocument.searchNodeByLocation` and the hover of the language
173
+ * server share: a violation reported at an attribute (`no-duplicate-attr`),
174
+ * inside its value or at an end tag is the element that has it, and of several
175
+ * nodes that hold the position the innermost is taken. A non-element is
176
+ * resolved to its nearest element ancestor. `nodeList` is in document order.
177
+ *
146
178
  * The O(n) linear search per violation is deliberate — acceptable for
147
179
  * typical document sizes; build a position index only if very large
148
180
  * documents make this a measured bottleneck.
@@ -152,7 +184,9 @@ export declare function findNodeAtPosition(nodeList: readonly PositionedNode[],
152
184
  * @experimental
153
185
  * Computes the scope selector for a set of violations within a document.
154
186
  * Only considers error-severity violations. Returns `undefined` when
155
- * the LCA falls back to file-level (body/html) or when nodes cannot be found.
187
+ * the LCA falls back to file-level (body/html) or when a violation is at no
188
+ * element: the entry counts every violation, and applying a scope counts only
189
+ * those found inside it, so a scope that left one out would not suppress it.
156
190
  *
157
191
  * @param nodeList - Flat list of all nodes in the document.
158
192
  * @param violations - The violations to compute scope for.
@@ -20,6 +20,7 @@
20
20
  *
21
21
  * @see https://github.com/markuplint/markuplint/issues/3509
22
22
  */
23
+ import { searchNodeAtLocation } from '@markuplint/ml-core';
23
24
  const BODY_HTML_TAGS = new Set(['body', 'html']);
24
25
  /**
25
26
  * The position that ends a segment of a scope: `:nth-of-type(n)`.
@@ -372,6 +373,15 @@ function buildSegmentWithNth(node) {
372
373
  }
373
374
  /**
374
375
  * @experimental
376
+ * Finds the element a violation at `line`:`col` belongs to.
377
+ *
378
+ * The node is the one `searchNodeAtLocation` of `@markuplint/ml-core` finds,
379
+ * the rule `MLDocument.searchNodeByLocation` and the hover of the language
380
+ * server share: a violation reported at an attribute (`no-duplicate-attr`),
381
+ * inside its value or at an end tag is the element that has it, and of several
382
+ * nodes that hold the position the innermost is taken. A non-element is
383
+ * resolved to its nearest element ancestor. `nodeList` is in document order.
384
+ *
375
385
  * The O(n) linear search per violation is deliberate — acceptable for
376
386
  * typical document sizes; build a position index only if very large
377
387
  * documents make this a measured bottleneck.
@@ -379,26 +389,26 @@ function buildSegmentWithNth(node) {
379
389
  export function findNodeAtPosition(
380
390
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
381
391
  nodeList, line, col) {
382
- for (const node of nodeList) {
383
- if (node.startLine === line && node.startCol === col) {
384
- if (node.nodeType === 1) {
385
- return node;
386
- }
387
- // Non-element (e.g., attribute) — walk up to parent element
388
- let parent = node.parentElement;
389
- while (parent && parent.nodeType !== 1) {
390
- parent = parent.parentElement;
391
- }
392
- return parent;
393
- }
392
+ const found = searchNodeAtLocation(nodeList, line, col);
393
+ if (!found) {
394
+ return null;
394
395
  }
395
- return null;
396
+ if (found.nodeType === 1) {
397
+ return found;
398
+ }
399
+ let parent = found.parentElement;
400
+ while (parent && parent.nodeType !== 1) {
401
+ parent = parent.parentElement;
402
+ }
403
+ return parent;
396
404
  }
397
405
  /**
398
406
  * @experimental
399
407
  * Computes the scope selector for a set of violations within a document.
400
408
  * Only considers error-severity violations. Returns `undefined` when
401
- * the LCA falls back to file-level (body/html) or when nodes cannot be found.
409
+ * the LCA falls back to file-level (body/html) or when a violation is at no
410
+ * element: the entry counts every violation, and applying a scope counts only
411
+ * those found inside it, so a scope that left one out would not suppress it.
402
412
  *
403
413
  * @param nodeList - Flat list of all nodes in the document.
404
414
  * @param violations - The violations to compute scope for.
@@ -413,9 +423,10 @@ nodeList, violations) {
413
423
  continue;
414
424
  }
415
425
  const node = findNodeAtPosition(nodeList, v.line, v.col);
416
- if (node) {
417
- nodes.push(node);
426
+ if (!node) {
427
+ return undefined;
418
428
  }
429
+ nodes.push(node);
419
430
  }
420
431
  if (nodes.length === 0) {
421
432
  return undefined;
@@ -18,7 +18,7 @@
18
18
  export type { SuppressionsData, SuppressionEntry } from './types.js';
19
19
  export type { ApplySuppressionsResult, ApplySuppressionsOptions } from './apply-suppressions.js';
20
20
  export type { GenerateSuppressionsOptions } from './generate-suppressions.js';
21
- export type { ScopedNode, PositionedNode } from './compute-scope.js';
21
+ export type { ScopedNode, PositionedNode, SourceRange } from './compute-scope.js';
22
22
  export { computeScopeForViolations, computeLCA, generateUniqueSelector } from './compute-scope.js';
23
23
  export { applySuppressions } from './apply-suppressions.js';
24
24
  export { generateSuppressions } from './generate-suppressions.js';
@@ -2,7 +2,18 @@ import type { SuppressionsData } from './types.js';
2
2
  /**
3
3
  * @experimental
4
4
  * Merges incoming suppressions into existing suppressions.
5
- * For overlapping entries, takes the maximum count.
5
+ * For overlapping entries with the same scope, takes the maximum count;
6
+ * for overlapping entries whose scopes differ, takes the incoming entry.
7
+ *
8
+ * The scope of an overlapping entry is always the incoming one:
9
+ * `generateSuppressions` computes it as the subtree that contains every current
10
+ * violation of the rule, so an existing scope could leave some of them outside
11
+ * and unsuppressed. An incoming entry without a scope covers the whole file, so
12
+ * it drops the existing scope. A count is the number of violations inside its
13
+ * own scope, so when the scope changes, the existing count is not carried over:
14
+ * it would let new violations inside the new scope pass up to a number that
15
+ * scope never held. An entry only on one side is kept as it is, scope included
16
+ * (a rule `--suppress-rule` did not select, a file not linted).
6
17
  *
7
18
  * @param existing - The current suppressions data.
8
19
  * @param incoming - The new suppressions data to merge.
@@ -1,7 +1,18 @@
1
1
  /**
2
2
  * @experimental
3
3
  * Merges incoming suppressions into existing suppressions.
4
- * For overlapping entries, takes the maximum count.
4
+ * For overlapping entries with the same scope, takes the maximum count;
5
+ * for overlapping entries whose scopes differ, takes the incoming entry.
6
+ *
7
+ * The scope of an overlapping entry is always the incoming one:
8
+ * `generateSuppressions` computes it as the subtree that contains every current
9
+ * violation of the rule, so an existing scope could leave some of them outside
10
+ * and unsuppressed. An incoming entry without a scope covers the whole file, so
11
+ * it drops the existing scope. A count is the number of violations inside its
12
+ * own scope, so when the scope changes, the existing count is not carried over:
13
+ * it would let new violations inside the new scope pass up to a number that
14
+ * scope never held. An entry only on one side is kept as it is, scope included
15
+ * (a rule `--suppress-rule` did not select, a file not linted).
5
16
  *
6
17
  * @param existing - The current suppressions data.
7
18
  * @param incoming - The new suppressions data to merge.
@@ -9,9 +20,9 @@
9
20
  */
10
21
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
11
22
  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.
23
+ // Under the same scope, uses max(count) to ensure running --suppress multiple
24
+ // times is idempotent and never accidentally lowers a suppression threshold.
25
+ // Counts of an unchanged scope are only reduced explicitly via --prune-suppressions.
15
26
  const merged = {};
16
27
  // Collect all file paths
17
28
  const allFiles = new Set([...Object.keys(existing), ...Object.keys(incoming)]);
@@ -21,11 +32,20 @@ export function mergeSuppressions(existing, incoming) {
21
32
  const allRules = new Set([...Object.keys(existingRules), ...Object.keys(incomingRules)]);
22
33
  const mergedRules = {};
23
34
  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) };
35
+ const existingEntry = existingRules[ruleId];
36
+ const incomingEntry = incomingRules[ruleId];
37
+ mergedRules[ruleId] = mergeEntry(existingEntry, incomingEntry);
27
38
  }
28
39
  merged[filePath] = mergedRules;
29
40
  }
30
41
  return merged;
31
42
  }
43
+ function mergeEntry(existing, incoming) {
44
+ // Both are never absent: the rule ID comes from one of the two sides.
45
+ const base = incoming ?? existing;
46
+ const count = existing && incoming && existing.scope === incoming.scope
47
+ ? Math.max(existing.count, incoming.count)
48
+ : base.count;
49
+ // Only `count` and `scope` are written: a key the file has besides them is not carried over.
50
+ return base.scope === undefined ? { count } : { count, scope: base.scope };
51
+ }
@@ -11,8 +11,9 @@ import type { SuppressionsData } from './types.js';
11
11
  *
12
12
  * **Note:** Pruning counts all file-level violations, not scope-filtered ones.
13
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.
14
+ * (required for scope-based violation filtering). `--suppress` recomputes the scope
15
+ * from the DOM tree, and takes the new count when the scope changes; under an
16
+ * unchanged scope it keeps the higher count (see `mergeSuppressions`).
16
17
  *
17
18
  * @param currentViolations - Map of absolute file paths to current violations.
18
19
  * @param existing - The current suppressions data.
@@ -10,8 +10,9 @@ import { toRelativePath } from './suppressions-file.js';
10
10
  *
11
11
  * **Note:** Pruning counts all file-level violations, not scope-filtered ones.
12
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.
13
+ * (required for scope-based violation filtering). `--suppress` recomputes the scope
14
+ * from the DOM tree, and takes the new count when the scope changes; under an
15
+ * unchanged scope it keeps the higher count (see `mergeSuppressions`).
15
16
  *
16
17
  * @param currentViolations - Map of absolute file paths to current violations.
17
18
  * @param existing - The current suppressions data.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markuplint",
3
- "version": "5.5.0",
3
+ "version": "5.6.0",
4
4
  "description": "An HTML linter for all markup developers",
5
5
  "author": "Yusuke Hirao",
6
6
  "license": "MIT",
@@ -24,9 +24,6 @@
24
24
  },
25
25
  "./package.json": "./package.json"
26
26
  },
27
- "typedoc": {
28
- "entryPoint": "./src/index.ts"
29
- },
30
27
  "bin": "./bin/markuplint.mjs",
31
28
  "scripts": {
32
29
  "build": "tsc --project tsconfig.build.json",
@@ -34,25 +31,25 @@
34
31
  "clean": "tsc --build --clean tsconfig.build.json"
35
32
  },
36
33
  "devDependencies": {
37
- "@markuplint/astro-parser": "5.5.0",
38
- "@markuplint/jsx-parser": "5.5.0",
39
- "@markuplint/mdx-parser": "5.5.0",
40
- "@markuplint/pretenders": "5.5.0",
41
- "@markuplint/svelte-parser": "5.5.0",
42
- "@markuplint/vue-parser": "5.5.0"
34
+ "@markuplint/astro-parser": "5.6.0",
35
+ "@markuplint/jsx-parser": "5.6.0",
36
+ "@markuplint/mdx-parser": "5.6.0",
37
+ "@markuplint/pretenders": "5.6.0",
38
+ "@markuplint/svelte-parser": "5.6.0",
39
+ "@markuplint/vue-parser": "5.6.0"
43
40
  },
44
41
  "dependencies": {
45
42
  "@markuplint/cli-utils": "5.3.0",
46
- "@markuplint/file-resolver": "5.5.0",
47
- "@markuplint/html-parser": "5.5.0",
48
- "@markuplint/html-spec": "5.5.0",
49
- "@markuplint/i18n": "5.5.0",
50
- "@markuplint/ml-ast": "5.5.0",
51
- "@markuplint/ml-config": "5.5.0",
52
- "@markuplint/ml-core": "5.5.0",
53
- "@markuplint/ml-spec": "5.5.0",
54
- "@markuplint/rules": "5.5.0",
55
- "@markuplint/shared": "5.5.0",
43
+ "@markuplint/file-resolver": "5.6.0",
44
+ "@markuplint/html-parser": "5.6.0",
45
+ "@markuplint/html-spec": "5.6.0",
46
+ "@markuplint/i18n": "5.6.0",
47
+ "@markuplint/ml-ast": "5.6.0",
48
+ "@markuplint/ml-config": "5.6.0",
49
+ "@markuplint/ml-core": "5.6.0",
50
+ "@markuplint/ml-spec": "5.6.0",
51
+ "@markuplint/rules": "5.6.0",
52
+ "@markuplint/shared": "5.6.0",
56
53
  "@types/debug": "^4.1.13",
57
54
  "chokidar": "^6.0.0",
58
55
  "debug": "^4.4.3",
@@ -90,5 +87,5 @@
90
87
  "optional": true
91
88
  }
92
89
  },
93
- "gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
90
+ "gitHead": "eade3d7202682e567f3c1b6985478f1d4b5c5481"
94
91
  }