markuplint 5.4.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.
@@ -0,0 +1,171 @@
1
+ /**
2
+ * @module cli/main
3
+ *
4
+ * The CLI run itself. `index.ts` is the entry point and owns the process boundary
5
+ * (the exit code and why `process.exit()` is not used); this module is separate so a spec
6
+ * can call `main()` without starting the process.
7
+ */
8
+ import { text } from 'node:stream/consumers';
9
+ import { isFatalError } from '@markuplint/shared';
10
+ import { verbosely } from '../debug.js';
11
+ import { cli } from './bootstrap.js';
12
+ import { command } from './command.js';
13
+ import { initialize } from './init/index.js';
14
+ import { search } from './search/index.js';
15
+ /** The operand that means standard input. */
16
+ const STDIN_OPERAND = '-';
17
+ /**
18
+ * Runs the CLI and resolves to its exit code.
19
+ *
20
+ * Every branch returns a number: a branch that forgets to leaves `undefined`,
21
+ * and the `process.exitCode` that `output.ts` set (1) would leak out.
22
+ */
23
+ export async function main() {
24
+ // Same condition as meow's automatic handling: the flag has to be the only argument.
25
+ const isSoleArgument = cli.input.length === 0 && process.argv.length === 3;
26
+ if (cli.flags.v || (isSoleArgument && cli.flags.version === true)) {
27
+ return showVersion();
28
+ }
29
+ if (cli.flags.h || (isSoleArgument && cli.flags.help === true)) {
30
+ return showHelp(0);
31
+ }
32
+ if (cli.flags.verbose) {
33
+ verbosely();
34
+ }
35
+ if (cli.flags.init) {
36
+ try {
37
+ await initialize();
38
+ }
39
+ catch (error) {
40
+ if (isFatalError(error)) {
41
+ throw error;
42
+ }
43
+ process.stderr.write(error + '\n');
44
+ return 1;
45
+ }
46
+ return 0;
47
+ }
48
+ if (cli.flags.createRule) {
49
+ process.stderr.write("Use to run 'npx @markuplint/create-rule' instead of 'markuplint --create-rule'.\n");
50
+ return 1;
51
+ }
52
+ const files = cli.input;
53
+ if (files.includes(STDIN_OPERAND)) {
54
+ const fileFlags = givenFileFlags();
55
+ if (files.length > 1 || fileFlags.length > 0) {
56
+ const conflicts = files.length > 1 ? ['file paths', ...fileFlags] : fileFlags;
57
+ process.stderr.write(`The \`${STDIN_OPERAND}\` operand (standard input) cannot be combined with ${conflicts.join(', ')}.\n`);
58
+ return 1;
59
+ }
60
+ const stdin = await readStdin();
61
+ if (stdin === null) {
62
+ return 1;
63
+ }
64
+ // An empty input is an empty source, as `markuplint empty.html` is.
65
+ return lintStdin(stdin);
66
+ }
67
+ if (files.length > 0) {
68
+ if (cli.flags.search) {
69
+ try {
70
+ await search(files, cli.flags, cli.flags.search);
71
+ }
72
+ catch (error) {
73
+ if (isFatalError(error)) {
74
+ throw error;
75
+ }
76
+ process.stderr.write(error + '\n');
77
+ return 1;
78
+ }
79
+ return 0;
80
+ }
81
+ return lint(files, cli.flags);
82
+ }
83
+ if (usePipe()) {
84
+ const fileFlags = givenFileFlags();
85
+ if (fileFlags.length > 0) {
86
+ process.stderr.write(`Standard input cannot be combined with ${fileFlags.join(', ')}.\n`);
87
+ return 1;
88
+ }
89
+ const stdin = await readStdin();
90
+ if (stdin === null) {
91
+ return 1;
92
+ }
93
+ if (!stdin) {
94
+ // result is empty
95
+ return showHelp(1);
96
+ }
97
+ return lintStdin(stdin);
98
+ }
99
+ // No arguments
100
+ return showHelp(1);
101
+ }
102
+ /**
103
+ * The options that act on a file that are given, by their names: `--fix` writes the fixed source
104
+ * to it (with nothing to write to, it created a file named `<AnonymousFile>`, #4560), `--search`
105
+ * and the suppressions options report or record by file. The standard input has none, so both
106
+ * ways of reading it (the `-` operand and the no-operand pipe) reject them, and name them in the
107
+ * message, from this one list: an option that acts on a file is added here, and nowhere else.
108
+ * `--fix-dry-run` is not one: it writes nothing and prints the diff.
109
+ */
110
+ function givenFileFlags() {
111
+ const { search, fix, suppress, suppressRule, pruneSuppressions } = cli.flags;
112
+ // Each is given by the condition of the code that consumes it, not by one shared test:
113
+ // `--search` is a search when it is not empty (the file branch below tests it so), and
114
+ // `--suppress-rule` turns the suppressions mode on when it is set at all (`command.ts`).
115
+ const given = [
116
+ ['--search', Boolean(search)],
117
+ ['--fix', Boolean(fix)],
118
+ ['--suppress', Boolean(suppress)],
119
+ ['--suppress-rule', suppressRule != null],
120
+ ['--prune-suppressions', Boolean(pruneSuppressions)],
121
+ ];
122
+ return given.filter(([, isGiven]) => isGiven).map(([name]) => name);
123
+ }
124
+ function usePipe() {
125
+ return !process.stdin.isTTY && process.stdout.isTTY;
126
+ }
127
+ /**
128
+ * Reports a read failure on stderr and returns `null` instead of throwing,
129
+ * so the caller can return the exit code.
130
+ */
131
+ async function readStdin() {
132
+ try {
133
+ return await text(process.stdin);
134
+ }
135
+ catch (error) {
136
+ if (isFatalError(error)) {
137
+ throw error;
138
+ }
139
+ process.stderr.write(error + '\n');
140
+ return null;
141
+ }
142
+ }
143
+ function lintStdin(sourceCode) {
144
+ return lint([{ sourceCode }], { ...cli.flags, ignoreExt: true });
145
+ }
146
+ /**
147
+ * Runs `command()` and resolves to its exit code. A failure that is not fatal is reported on
148
+ * stderr with exit code 1, whatever the input is (file paths or the standard input).
149
+ */
150
+ async function lint(...args) {
151
+ try {
152
+ return (await command(...args)) ? 1 : 0;
153
+ }
154
+ catch (error) {
155
+ if (isFatalError(error)) {
156
+ throw error;
157
+ }
158
+ process.stderr.write(error + '\n');
159
+ return 1;
160
+ }
161
+ }
162
+ /** Prints what meow's `showVersion()` printed (`console.log` of the version). */
163
+ function showVersion() {
164
+ process.stdout.write(cli.pkg.version + '\n');
165
+ return 0;
166
+ }
167
+ /** Prints what meow's `showHelp()` printed (`console.log` of the help, which ends with a newline). */
168
+ function showHelp(exitCode) {
169
+ process.stdout.write(cli.help + '\n');
170
+ return exitCode;
171
+ }
package/lib/cli/output.js CHANGED
@@ -28,6 +28,8 @@ export function output(results, options) {
28
28
  let msg = `${out.join('\n')}\n`;
29
29
  msg = options.color ? msg : stripAnsi(msg);
30
30
  // If it has errors, Write to `stderr` and failure and exit.
31
+ // `index.ts` overwrites this `exitCode` with the verdict `command()` returns, which is the one
32
+ // that counts (warnings are written here too, yet only `--no-allow-warnings` fails on them).
31
33
  if (results.violations.length > 0) {
32
34
  process.stderr.write(msg);
33
35
  process.exitCode = 1;
@@ -78,7 +80,7 @@ function createSummaryLines(summary) {
78
80
  out.push(`${logger('✖')} ${problemCount} problem${problemCount === 1 ? '' : 's'} (${problemDetail}) in ${pluralize(summary.checkedFileCount, 'file')}`, `${pluralize(summary.checkedFileCount, 'file')} checked: ${passedFileCount} passed, ${summary.failedFileCount} failed`);
79
81
  }
80
82
  if (summary.skippedFileCount > 0) {
81
- out.push(`${font.yellow('⚠')} ${pluralize(summary.skippedFileCount, 'file')} skipped because --max-count was reached`);
83
+ out.push(`${font.yellow('⚠')} ${pluralize(summary.skippedFileCount, 'file')} not reported because --max-count was reached (violations in ${summary.skippedFileCount === 1 ? 'it' : 'them'} still count toward the exit code)`);
82
84
  }
83
85
  return out;
84
86
  }
@@ -13,7 +13,8 @@ export async function search(files, options, selectors) {
13
13
  name,
14
14
  ...createRule({
15
15
  verify({ document }) {
16
- const nodes = document.querySelectorAll(selectors);
16
+ // Every tree is searched: what is in the content of a `template` element is found too
17
+ const nodes = document.deepQuerySelectorAll(selectors);
17
18
  locations.push(...[...nodes].map(node => {
18
19
  return {
19
20
  file: document.filename ?? '_NO_FILE_',
@@ -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
@@ -57,7 +80,7 @@ export interface PositionedNode extends ScopedNode {
57
80
  * (the one `CSS.escape()` is specified by), in which a code point that is not an ident code point of CSS
58
81
  * Syntax is escaped, so the output is always one identifier. A NUL is written as U+FFFD, as the algorithm
59
82
  * has it, so it does not read back as NUL. A scope is a CSS selector, and CSS lets any code point be part
60
- * of an identifier by escaping it, "even ones that CSS uses in its syntax". So `Tabs.Root` (a member tag of JSX, MDX or Svelte) is `Tabs\.Root` and not the element
83
+ * of an identifier by escaping it, even one that CSS uses in its syntax. So `Tabs.Root` (a member tag of JSX, MDX or Svelte) is `Tabs\.Root` and not the element
61
84
  * `Tabs` with the class `Root`, `py-1.5` is `py-1\.5`, `hover:bg-x` is `hover\:bg-x`, `2xl:flex` is
62
85
  * `\32 xl\:flex`, and a class that ends with `:nth-of-type(2)` is no position. What this writes and what
63
86
  * {@link unescapeIdentifier} reads are one form, so a scope matches the element it was made for.
@@ -69,7 +92,12 @@ export interface PositionedNode extends ScopedNode {
69
92
  * with `:nth-of-type(n)` (`div.a:nth-of-type(2)`) never matched, and is still read as a position. A `\`
70
93
  * that such a file holds is read as CSS reads it: `\ba` is the hex escape U+00BA.
71
94
  *
95
+ * CSS Syntax says of an identifier:
96
+ *
97
+ * > You can include any code point at all, even ones that CSS uses in its syntax, by escaping it.
98
+ *
72
99
  * @see https://drafts.csswg.org/cssom/#serialize-an-identifier
100
+ * @see https://www.w3.org/TR/css-syntax-3/#syntax-description
73
101
  */
74
102
  export declare function serializeIdentifier(name: string): string;
75
103
  /**
@@ -104,7 +132,7 @@ export declare function splitScopeAttr(segment: string): {
104
132
  * class `a\` at the position 2, and `a\:nth-of-type(2)` is the class `a:nth-of-type(2)`. The escapes
105
133
  * are scanned from the start, as {@link splitScopeIdents} does.
106
134
  *
107
- * @see https://www.w3.org/TR/css-syntax-3/#escaping
135
+ * @see https://www.w3.org/TR/css-syntax-3/#syntax-description
108
136
  */
109
137
  export declare function splitScopeNth(segment: string): {
110
138
  readonly base: string;
@@ -138,6 +166,15 @@ export declare function computeLCA(nodes: readonly ScopedNode[]): ScopedNode | n
138
166
  export declare function generateUniqueSelector(node: ScopedNode): string | undefined;
139
167
  /**
140
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
+ *
141
178
  * The O(n) linear search per violation is deliberate — acceptable for
142
179
  * typical document sizes; build a position index only if very large
143
180
  * documents make this a measured bottleneck.
@@ -147,7 +184,9 @@ export declare function findNodeAtPosition(nodeList: readonly PositionedNode[],
147
184
  * @experimental
148
185
  * Computes the scope selector for a set of violations within a document.
149
186
  * Only considers error-severity violations. Returns `undefined` when
150
- * 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.
151
190
  *
152
191
  * @param nodeList - Flat list of all nodes in the document.
153
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)`.
@@ -40,7 +41,7 @@ const NON_ASCII_IDENT_CODE_POINT = /^[\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u0
40
41
  * (the one `CSS.escape()` is specified by), in which a code point that is not an ident code point of CSS
41
42
  * Syntax is escaped, so the output is always one identifier. A NUL is written as U+FFFD, as the algorithm
42
43
  * has it, so it does not read back as NUL. A scope is a CSS selector, and CSS lets any code point be part
43
- * of an identifier by escaping it, "even ones that CSS uses in its syntax". So `Tabs.Root` (a member tag of JSX, MDX or Svelte) is `Tabs\.Root` and not the element
44
+ * of an identifier by escaping it, even one that CSS uses in its syntax. So `Tabs.Root` (a member tag of JSX, MDX or Svelte) is `Tabs\.Root` and not the element
44
45
  * `Tabs` with the class `Root`, `py-1.5` is `py-1\.5`, `hover:bg-x` is `hover\:bg-x`, `2xl:flex` is
45
46
  * `\32 xl\:flex`, and a class that ends with `:nth-of-type(2)` is no position. What this writes and what
46
47
  * {@link unescapeIdentifier} reads are one form, so a scope matches the element it was made for.
@@ -52,7 +53,12 @@ const NON_ASCII_IDENT_CODE_POINT = /^[\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u0
52
53
  * with `:nth-of-type(n)` (`div.a:nth-of-type(2)`) never matched, and is still read as a position. A `\`
53
54
  * that such a file holds is read as CSS reads it: `\ba` is the hex escape U+00BA.
54
55
  *
56
+ * CSS Syntax says of an identifier:
57
+ *
58
+ * > You can include any code point at all, even ones that CSS uses in its syntax, by escaping it.
59
+ *
55
60
  * @see https://drafts.csswg.org/cssom/#serialize-an-identifier
61
+ * @see https://www.w3.org/TR/css-syntax-3/#syntax-description
56
62
  */
57
63
  export function serializeIdentifier(name) {
58
64
  let result = '';
@@ -187,7 +193,7 @@ export function splitScopeAttr(segment) {
187
193
  * class `a\` at the position 2, and `a\:nth-of-type(2)` is the class `a:nth-of-type(2)`. The escapes
188
194
  * are scanned from the start, as {@link splitScopeIdents} does.
189
195
  *
190
- * @see https://www.w3.org/TR/css-syntax-3/#escaping
196
+ * @see https://www.w3.org/TR/css-syntax-3/#syntax-description
191
197
  */
192
198
  export function splitScopeNth(segment) {
193
199
  // Only an unescaped `:` is looked at: an escaped one is read with its `\` before it, as one character
@@ -367,6 +373,15 @@ function buildSegmentWithNth(node) {
367
373
  }
368
374
  /**
369
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
+ *
370
385
  * The O(n) linear search per violation is deliberate — acceptable for
371
386
  * typical document sizes; build a position index only if very large
372
387
  * documents make this a measured bottleneck.
@@ -374,26 +389,26 @@ function buildSegmentWithNth(node) {
374
389
  export function findNodeAtPosition(
375
390
  // eslint-disable-next-line @typescript-eslint/prefer-readonly-parameter-types
376
391
  nodeList, line, col) {
377
- for (const node of nodeList) {
378
- if (node.startLine === line && node.startCol === col) {
379
- if (node.nodeType === 1) {
380
- return node;
381
- }
382
- // Non-element (e.g., attribute) — walk up to parent element
383
- let parent = node.parentElement;
384
- while (parent && parent.nodeType !== 1) {
385
- parent = parent.parentElement;
386
- }
387
- return parent;
388
- }
392
+ const found = searchNodeAtLocation(nodeList, line, col);
393
+ if (!found) {
394
+ return null;
389
395
  }
390
- 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;
391
404
  }
392
405
  /**
393
406
  * @experimental
394
407
  * Computes the scope selector for a set of violations within a document.
395
408
  * Only considers error-severity violations. Returns `undefined` when
396
- * 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.
397
412
  *
398
413
  * @param nodeList - Flat list of all nodes in the document.
399
414
  * @param violations - The violations to compute scope for.
@@ -408,9 +423,10 @@ nodeList, violations) {
408
423
  continue;
409
424
  }
410
425
  const node = findNodeAtPosition(nodeList, v.line, v.col);
411
- if (node) {
412
- nodes.push(node);
426
+ if (!node) {
427
+ return undefined;
413
428
  }
429
+ nodes.push(node);
414
430
  }
415
431
  if (nodes.length === 0) {
416
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.
@@ -12,6 +12,11 @@ export declare function resolveSuppressionsPath(customPath?: string): string;
12
12
  * Reads and parses a suppressions JSON file.
13
13
  * Returns an empty object if the file does not exist.
14
14
  *
15
+ * Only the top level is checked: valid JSON that is not an object (`null`, an
16
+ * array, a string, a number) is not suppressions data and fails like
17
+ * unparsable JSON does, so that every caller gets the same readable error
18
+ * instead of a `TypeError` from the first `Object.keys` on it.
19
+ *
15
20
  * @param filePath - Absolute path to the suppressions file.
16
21
  * @returns The parsed suppressions data, or an empty object if the file does not exist.
17
22
  */
@@ -20,13 +20,22 @@ export function resolveSuppressionsPath(customPath) {
20
20
  * Reads and parses a suppressions JSON file.
21
21
  * Returns an empty object if the file does not exist.
22
22
  *
23
+ * Only the top level is checked: valid JSON that is not an object (`null`, an
24
+ * array, a string, a number) is not suppressions data and fails like
25
+ * unparsable JSON does, so that every caller gets the same readable error
26
+ * instead of a `TypeError` from the first `Object.keys` on it.
27
+ *
23
28
  * @param filePath - Absolute path to the suppressions file.
24
29
  * @returns The parsed suppressions data, or an empty object if the file does not exist.
25
30
  */
26
31
  export async function readSuppressionsFile(filePath) {
27
32
  try {
28
33
  const content = await fs.readFile(filePath, 'utf8');
29
- return JSON.parse(content);
34
+ const parsed = JSON.parse(content);
35
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) {
36
+ throw new Error(`Failed to parse suppressions file "${filePath}": the top level must be a JSON object`);
37
+ }
38
+ return parsed;
30
39
  }
31
40
  catch (error) {
32
41
  if (error instanceof Error && 'code' in error && error.code === 'ENOENT') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "markuplint",
3
- "version": "5.4.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,29 +24,34 @@
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",
33
30
  "dev": "tsc --watch --project tsconfig.build.json",
34
31
  "clean": "tsc --build --clean tsconfig.build.json"
35
32
  },
33
+ "devDependencies": {
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"
40
+ },
36
41
  "dependencies": {
37
42
  "@markuplint/cli-utils": "5.3.0",
38
- "@markuplint/file-resolver": "5.4.0",
39
- "@markuplint/html-parser": "5.4.0",
40
- "@markuplint/html-spec": "5.4.0",
41
- "@markuplint/i18n": "5.3.0",
42
- "@markuplint/ml-ast": "5.3.0",
43
- "@markuplint/ml-config": "5.4.0",
44
- "@markuplint/ml-core": "5.4.0",
45
- "@markuplint/ml-spec": "5.4.0",
46
- "@markuplint/rules": "5.4.0",
47
- "@markuplint/shared": "5.4.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",
48
53
  "@types/debug": "^4.1.13",
49
- "chokidar": "^5.0.0",
54
+ "chokidar": "^6.0.0",
50
55
  "debug": "^4.4.3",
51
56
  "meow": "^14.1.0",
52
57
  "os-locale": "^8.0.0",
@@ -54,5 +59,33 @@
54
59
  "strip-ansi": "^7.2.0",
55
60
  "type-fest": "^5.10.0"
56
61
  },
57
- "gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
62
+ "peerDependencies": {
63
+ "@markuplint/astro-parser": "^5.4.0",
64
+ "@markuplint/jsx-parser": "^5.4.0",
65
+ "@markuplint/mdx-parser": "^5.4.0",
66
+ "@markuplint/pretenders": "^5.4.0",
67
+ "@markuplint/svelte-parser": "^5.4.0",
68
+ "@markuplint/vue-parser": "^5.4.0"
69
+ },
70
+ "peerDependenciesMeta": {
71
+ "@markuplint/astro-parser": {
72
+ "optional": true
73
+ },
74
+ "@markuplint/jsx-parser": {
75
+ "optional": true
76
+ },
77
+ "@markuplint/mdx-parser": {
78
+ "optional": true
79
+ },
80
+ "@markuplint/pretenders": {
81
+ "optional": true
82
+ },
83
+ "@markuplint/svelte-parser": {
84
+ "optional": true
85
+ },
86
+ "@markuplint/vue-parser": {
87
+ "optional": true
88
+ }
89
+ },
90
+ "gitHead": "eade3d7202682e567f3c1b6985478f1d4b5c5481"
58
91
  }