markuplint 5.4.0 → 5.5.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 +17 -0
- package/README.md +8 -6
- package/lib/api/ml-engine.js +25 -5
- package/lib/api/types.d.ts +5 -0
- package/lib/cli/bootstrap.d.ts +1 -1
- package/lib/cli/bootstrap.js +8 -3
- package/lib/cli/command.d.ts +18 -0
- package/lib/cli/command.js +173 -115
- package/lib/cli/index.d.ts +36 -0
- package/lib/cli/index.js +38 -66
- package/lib/cli/init/index.js +3 -3
- package/lib/cli/main.d.ts +14 -0
- package/lib/cli/main.js +171 -0
- package/lib/cli/output.js +3 -1
- package/lib/cli/search/index.js +2 -1
- package/lib/suppressions/compute-scope.d.ts +7 -2
- package/lib/suppressions/compute-scope.js +7 -2
- package/lib/suppressions/suppressions-file.d.ts +5 -0
- package/lib/suppressions/suppressions-file.js +10 -1
- package/package.json +49 -13
package/CHANGELOG.md
CHANGED
|
@@ -3,6 +3,23 @@
|
|
|
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.5.0](https://github.com/markuplint/markuplint/compare/v5.4.0...v5.5.0) (2026-11-10)
|
|
7
|
+
|
|
8
|
+
### Bug Fixes
|
|
9
|
+
|
|
10
|
+
* **markuplint:** exit with the lint verdict under --format json ([3c02102](https://github.com/markuplint/markuplint/commit/3c02102e939565e628447b7c25490e4346db0f20)), closes [#4551](https://github.com/markuplint/markuplint/issues/4551)
|
|
11
|
+
* **markuplint:** keep the whole report on a pipe by not calling process.exit() ([6d98027](https://github.com/markuplint/markuplint/commit/6d98027684cdcd0a3f30d45bebac81813a5a9fc7)), closes [#4554](https://github.com/markuplint/markuplint/issues/4554)
|
|
12
|
+
* **markuplint:** reject the options that act on a file when the source is stdin ([f980686](https://github.com/markuplint/markuplint/commit/f9806863a1e12c3a4d78ef30b739a65ce0318ee6)), closes [#4560](https://github.com/markuplint/markuplint/issues/4560)
|
|
13
|
+
* **markuplint:** report a failure to resolve pretenders as a config-error of the file ([6178627](https://github.com/markuplint/markuplint/commit/61786271a4be989a91e8757c455dbeec9ffe4e9e))
|
|
14
|
+
* **markuplint:** search every tree with --search ([a2e8955](https://github.com/markuplint/markuplint/commit/a2e89552b430e3473c6e7c00c273d05de9b9cdec))
|
|
15
|
+
* **markuplint:** stop --max-count from changing the exit decision ([8dd1b8a](https://github.com/markuplint/markuplint/commit/8dd1b8a86e86c3b0e1410fc22d708baa0282e853)), closes [#4556](https://github.com/markuplint/markuplint/issues/4556)
|
|
16
|
+
* **markuplint:** take configFile as a value of extends is ([789a487](https://github.com/markuplint/markuplint/commit/789a487d3efd257e9e13969674aed9f002b7ee93))
|
|
17
|
+
|
|
18
|
+
### Features
|
|
19
|
+
|
|
20
|
+
* **markuplint:** describe the parse errors of framework parsers in the parseError option ([095b21a](https://github.com/markuplint/markuplint/commit/095b21af53ec0cc03a7e87a9dd9a26669978b74d))
|
|
21
|
+
|
|
22
|
+
|
|
6
23
|
# [5.4.0](https://github.com/markuplint/markuplint/compare/v5.3.0...v5.4.0) (2026-10-29)
|
|
7
24
|
|
|
8
25
|
### Bug Fixes
|
package/README.md
CHANGED
|
@@ -69,11 +69,13 @@ $ npx markuplint --help
|
|
|
69
69
|
|
|
70
70
|
Usage
|
|
71
71
|
$ markuplint <HTML file paths (glob format)>
|
|
72
|
-
$ <stdout> | markuplint
|
|
72
|
+
$ <stdout> | markuplint Read the source from the standard input when the output is a terminal. Not with --search, --fix or the suppressions options.
|
|
73
|
+
$ <stdout> | markuplint - Read the source from the standard input. Not with file paths, --search, --fix or the suppressions options.
|
|
73
74
|
|
|
74
75
|
Options
|
|
75
76
|
--config, -c FILE_PATH A configuration file path.
|
|
76
77
|
--fix, Fix HTML.
|
|
78
|
+
--fix-dry-run Show what --fix would change without writing files.
|
|
77
79
|
--format, -f FORMAT Output format. Support "JSON", "Simple", "GitHub" and "Standard". Default: "Standard".
|
|
78
80
|
--no-search-config No search a configure file automatically.
|
|
79
81
|
--ignore-ext Evaluate files that are received even though the type of extension.
|
|
@@ -86,11 +88,11 @@ Options
|
|
|
86
88
|
--show-config Output computed configuration of the target file. Supports "details" and empty. Default: empty.
|
|
87
89
|
--verbose Output with detailed information.
|
|
88
90
|
--include-node-modules Include files in node_modules directory. Default: false.
|
|
89
|
-
--severity-parse-error
|
|
90
|
-
--severity-deprecation
|
|
91
|
-
--max-count Limit the number of violations shown. Default: 0 (no limit).
|
|
91
|
+
--severity-parse-error Severity for the built-in parse-error channel. Supports "error", "warning", and "off". Unset by default: fatal ParserErrors emit at "error" and non-fatal parse5 and framework parser events are off (opt-in per code via config).
|
|
92
|
+
--severity-deprecation Severity for the built-in rule-deprecation channel (deprecated rule names). Supports "error", "warning", and "off". Default: "warning".
|
|
93
|
+
--max-count Limit the number of violations shown, not the exit code. Default: 0 (no limit).
|
|
92
94
|
--max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).
|
|
93
|
-
--progressive-output
|
|
95
|
+
--no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).
|
|
94
96
|
|
|
95
97
|
--suppress [Experimental] Generate/update suppressions file for all current errors.
|
|
96
98
|
--suppress-rule RULE_ID [Experimental] Suppress only the specified rule.
|
|
@@ -105,8 +107,8 @@ Options
|
|
|
105
107
|
|
|
106
108
|
Examples
|
|
107
109
|
$ markuplint verifyee.html --config path/to/.markuplintrc
|
|
108
|
-
$ markuplint "**/*.html" --max-count=10
|
|
109
110
|
$ cat verifyee.html | markuplint
|
|
111
|
+
$ cat verifyee.html | markuplint - --format JSON | jq
|
|
110
112
|
```
|
|
111
113
|
|
|
112
114
|
### Bulk Suppressions (Experimental)
|
package/lib/api/ml-engine.js
CHANGED
|
@@ -509,10 +509,18 @@ export class MLEngine extends Emitter {
|
|
|
509
509
|
* of `@markuplint/shared`'s `errors/index.ts`): any non-fatal error thrown by
|
|
510
510
|
* the resolution, or a `ConfigLoadError` collected into the config set's
|
|
511
511
|
* `errs`, fails the file. Tier 1 errors — markuplint's own bugs
|
|
512
|
-
* (`isFatalError()`) — are not caught here.
|
|
513
|
-
*
|
|
514
|
-
*
|
|
515
|
-
*
|
|
512
|
+
* (`isFatalError()`) — are not caught here.
|
|
513
|
+
*
|
|
514
|
+
* The pretenders that the config asks for are resolved here as well, and
|
|
515
|
+
* fail the file in the same way. `@markuplint/pretenders` is an optional
|
|
516
|
+
* peer, so a config that needs it fails with how to install it when it is
|
|
517
|
+
* missing; any other non-fatal failure of the resolution, a component that
|
|
518
|
+
* cannot be read for one, is reported as a `config-error` too, instead of
|
|
519
|
+
* rejecting {@link exec} and ending a run that has other files to lint.
|
|
520
|
+
*
|
|
521
|
+
* The other `errs` (a rule or a plugin that is not found, an invalid
|
|
522
|
+
* selector, a circular `extends`) leave the rest of the config usable, so
|
|
523
|
+
* they stay `config-error` warnings that `MLCore.verify()` reports.
|
|
516
524
|
*/
|
|
517
525
|
async #provide(cache = true) {
|
|
518
526
|
this.#configFailure = null;
|
|
@@ -556,7 +564,19 @@ export class MLEngine extends Emitter {
|
|
|
556
564
|
...configSet.config.severity,
|
|
557
565
|
...this.#options?.severity,
|
|
558
566
|
};
|
|
559
|
-
|
|
567
|
+
let pretenders;
|
|
568
|
+
try {
|
|
569
|
+
pretenders = await this.#resolvePretenders(configSet, cache);
|
|
570
|
+
}
|
|
571
|
+
catch (error) {
|
|
572
|
+
// What the configuration asks of pretenders cannot be resolved: `@markuplint/pretenders`, an optional
|
|
573
|
+
// peer, is not installed, or a component it reads is unreadable. As with the config itself, that fails
|
|
574
|
+
// this file, not the run.
|
|
575
|
+
if (isFatalError(error) || !(error instanceof Error)) {
|
|
576
|
+
throw error;
|
|
577
|
+
}
|
|
578
|
+
return this.#failConfig(error);
|
|
579
|
+
}
|
|
560
580
|
fileLog('Resolved pretenders: %O', pretenders);
|
|
561
581
|
const ruleset = this.#resolveRuleset(configSet);
|
|
562
582
|
fileLog('Resolved ruleset: %O', ruleset);
|
package/lib/api/types.d.ts
CHANGED
|
@@ -6,6 +6,11 @@ import type { AnyMLRule, FixSummary, MLSchema, Ruleset } from '@markuplint/ml-co
|
|
|
6
6
|
* Options for the markuplint API, controlling configuration, locale, rules, and behavior.
|
|
7
7
|
*/
|
|
8
8
|
export type APIOptions = {
|
|
9
|
+
/**
|
|
10
|
+
* The configuration file to use; the search for one is skipped. Resolved as a value of
|
|
11
|
+
* `extends` is: a module name is loaded as such, and a relative path is based on the working
|
|
12
|
+
* directory, as a relative path in `config` and `defaultConfig` is.
|
|
13
|
+
*/
|
|
9
14
|
readonly configFile?: string;
|
|
10
15
|
readonly config?: Config;
|
|
11
16
|
readonly defaultConfig?: Config;
|
package/lib/cli/bootstrap.d.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { ReadonlyDeep } from 'type-fest';
|
|
2
|
-
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 Severity for the built-in parse-error channel. Supports \"error\", \"warning\", and \"off\". Unset by default: fatal ParserErrors emit at \"error\" and non-fatal parse5 events are off (opt-in per code via config).\n\t--severity-deprecation Severity for the built-in rule-deprecation channel (deprecated rule names). Supports \"error\", \"warning\", and \"off\". Default: \"warning\".\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--no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).\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";
|
|
2
|
+
export declare const help = "\nUsage\n\t$ markuplint <HTML file paths (glob format)>\n\t$ <stdout> | markuplint Read the source from the standard input when the output is a terminal. Not with --search, --fix or the suppressions options.\n\t$ <stdout> | markuplint - Read the source from the standard input. Not with file paths, --search, --fix or the suppressions options.\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 Severity for the built-in parse-error channel. Supports \"error\", \"warning\", and \"off\". Unset by default: fatal ParserErrors emit at \"error\" and non-fatal parse5 and framework parser events are off (opt-in per code via config).\n\t--severity-deprecation Severity for the built-in rule-deprecation channel (deprecated rule names). Supports \"error\", \"warning\", and \"off\". Default: \"warning\".\n\t--max-count Limit the number of violations shown, not the exit code. Default: 0 (no limit).\n\t--max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).\n\t--no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).\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\t$ cat verifyee.html | markuplint - --format JSON | jq\n";
|
|
3
3
|
export declare const cli: import("meow").Result<{
|
|
4
4
|
config: {
|
|
5
5
|
type: "string";
|
package/lib/cli/bootstrap.js
CHANGED
|
@@ -2,7 +2,8 @@ import meow from 'meow';
|
|
|
2
2
|
export const help = `
|
|
3
3
|
Usage
|
|
4
4
|
$ markuplint <HTML file paths (glob format)>
|
|
5
|
-
$ <stdout> | markuplint
|
|
5
|
+
$ <stdout> | markuplint Read the source from the standard input when the output is a terminal. Not with --search, --fix or the suppressions options.
|
|
6
|
+
$ <stdout> | markuplint - Read the source from the standard input. Not with file paths, --search, --fix or the suppressions options.
|
|
6
7
|
|
|
7
8
|
Options
|
|
8
9
|
--config, -c FILE_PATH A configuration file path.
|
|
@@ -20,9 +21,9 @@ Options
|
|
|
20
21
|
--show-config Output computed configuration of the target file. Supports "details" and empty. Default: empty.
|
|
21
22
|
--verbose Output with detailed information.
|
|
22
23
|
--include-node-modules Include files in node_modules directory. Default: false.
|
|
23
|
-
--severity-parse-error Severity for the built-in parse-error channel. Supports "error", "warning", and "off". Unset by default: fatal ParserErrors emit at "error" and non-fatal parse5 events are off (opt-in per code via config).
|
|
24
|
+
--severity-parse-error Severity for the built-in parse-error channel. Supports "error", "warning", and "off". Unset by default: fatal ParserErrors emit at "error" and non-fatal parse5 and framework parser events are off (opt-in per code via config).
|
|
24
25
|
--severity-deprecation Severity for the built-in rule-deprecation channel (deprecated rule names). Supports "error", "warning", and "off". Default: "warning".
|
|
25
|
-
--max-count Limit the number of violations shown. Default: 0 (no limit).
|
|
26
|
+
--max-count Limit the number of violations shown, not the exit code. Default: 0 (no limit).
|
|
26
27
|
--max-warnings Number of warnings to trigger nonzero exit code. Default: -1 (no limit).
|
|
27
28
|
--no-progressive-output Wait until every file is processed before outputting results. Default: false (output progressively).
|
|
28
29
|
|
|
@@ -40,9 +41,13 @@ Options
|
|
|
40
41
|
Examples
|
|
41
42
|
$ markuplint verifyee.html --config path/to/.markuplintrc
|
|
42
43
|
$ cat verifyee.html | markuplint
|
|
44
|
+
$ cat verifyee.html | markuplint - --format JSON | jq
|
|
43
45
|
`;
|
|
44
46
|
export const cli = meow(help, {
|
|
45
47
|
importMeta: import.meta,
|
|
48
|
+
// `index.ts` prints them: meow ends the process with `process.exit()`, which drops a pending write.
|
|
49
|
+
autoHelp: false,
|
|
50
|
+
autoVersion: false,
|
|
46
51
|
flags: {
|
|
47
52
|
config: {
|
|
48
53
|
type: 'string',
|
package/lib/cli/command.d.ts
CHANGED
|
@@ -1,4 +1,22 @@
|
|
|
1
1
|
import type { CLIOptions } from './bootstrap.js';
|
|
2
2
|
import type { APIOptions } from '../api/types.js';
|
|
3
3
|
import type { Target } from '@markuplint/file-resolver';
|
|
4
|
+
/**
|
|
5
|
+
* Runs the CLI over the given targets and writes the report in the format
|
|
6
|
+
* `--format` selects.
|
|
7
|
+
*
|
|
8
|
+
* @param files Files or source code to lint.
|
|
9
|
+
* @param options The parsed CLI flags.
|
|
10
|
+
* @param apiOptions Options passed through to every `MLEngine`.
|
|
11
|
+
* @returns `true` when the process must exit with `1`: an error, a warning
|
|
12
|
+
* with `--no-allow-warnings`, more warnings than `--max-warnings`, or a
|
|
13
|
+
* usage error such as no target files. The decision does not depend on
|
|
14
|
+
* `--format`: CI reads the exit code whatever format the report is in. Nor
|
|
15
|
+
* does it depend on `--max-count`, which only decides what the report shows:
|
|
16
|
+
* a violation the limit hides, and every violation of a file after the
|
|
17
|
+
* limit, still counts. (`--fix`, `--suppress` and `--prune-suppressions`
|
|
18
|
+
* ignore the limit altogether; see `maxCount` below.)
|
|
19
|
+
* `--show-config`, `--suppress` and `--prune-suppressions` do not lint for
|
|
20
|
+
* a verdict, so they return `false` whatever violations exist.
|
|
21
|
+
*/
|
|
4
22
|
export declare function command(files: readonly Readonly<Target>[], options: CLIOptions, apiOptions?: APIOptions): Promise<boolean>;
|
package/lib/cli/command.js
CHANGED
|
@@ -2,10 +2,9 @@ import { promises as fs } from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { ConfigProvider, resolveFiles } from '@markuplint/file-resolver';
|
|
4
4
|
import { ViolationCollector } from '@markuplint/ml-core';
|
|
5
|
-
import { isFatalError } from '@markuplint/shared';
|
|
6
5
|
import { MLEngine } from '../api/index.js';
|
|
7
6
|
import { log } from '../debug.js';
|
|
8
|
-
import { applySuppressions, generateSuppressions, mergeSuppressions, pruneSuppressions, readSuppressionsFile, resolveSuppressionsPath, writeSuppressionsFile, } from '../suppressions/index.js';
|
|
7
|
+
import { applySuppressions, generateSuppressions, mergeSuppressions, pruneSuppressions, readSuppressionsFile, resolveSuppressionsPath, toAbsolutePath, toRelativePath, writeSuppressionsFile, } from '../suppressions/index.js';
|
|
9
8
|
import { CONFIG_LEVEL_RULE_IDS, dedupeConfigLevelViolations } from '../dedupe-config-violations.js';
|
|
10
9
|
import { outputDryRunDiff } from './dry-run-output.js';
|
|
11
10
|
import { output, outputSummary } from './output.js';
|
|
@@ -25,6 +24,53 @@ function parseSeverityFlag(value) {
|
|
|
25
24
|
}
|
|
26
25
|
return undefined;
|
|
27
26
|
}
|
|
27
|
+
/**
|
|
28
|
+
* Applies the suppressions of one file to that file's violations.
|
|
29
|
+
*
|
|
30
|
+
* Suppression is count-based per (file, rule), so it has to see every
|
|
31
|
+
* violation the file has, never a list `--max-count` already cut short:
|
|
32
|
+
* cut short, an entry would cover violations it does not cover in full, and
|
|
33
|
+
* would be called unused when what it covers was only left out of the
|
|
34
|
+
* report.
|
|
35
|
+
*
|
|
36
|
+
* @param filePath Absolute path of the linted file.
|
|
37
|
+
* @param violations Every violation of the file.
|
|
38
|
+
* @param suppressions The suppressions data read at the start of the run.
|
|
39
|
+
* @param suppressionsFilePath Absolute path of the suppressions file.
|
|
40
|
+
* @param nodeList The file's document node list, for scope-aware entries.
|
|
41
|
+
* @returns The violations the entries of the file do not suppress, and the
|
|
42
|
+
* entries of the file that suppressed nothing (`"<path>:<ruleId>"`).
|
|
43
|
+
*/
|
|
44
|
+
function applyFileSuppressions(filePath, violations, suppressions, suppressionsFilePath, nodeList) {
|
|
45
|
+
const relPath = toRelativePath(filePath, suppressionsFilePath);
|
|
46
|
+
const fileSuppressions = suppressions[relPath];
|
|
47
|
+
if (!fileSuppressions) {
|
|
48
|
+
return { kept: violations, unusedEntries: [] };
|
|
49
|
+
}
|
|
50
|
+
// An entry that cannot be applied fails the run: treating it as "suppresses
|
|
51
|
+
// nothing" would fail the build on violations the file is meant to
|
|
52
|
+
// suppress, with no word on why.
|
|
53
|
+
const { filtered, unusedEntries } = applySuppressions(new Map([[filePath, violations]]), { [relPath]: fileSuppressions }, suppressionsFilePath, { nodeLists: nodeList ? new Map([[filePath, nodeList]]) : undefined });
|
|
54
|
+
return { kept: filtered.get(filePath) ?? [], unusedEntries };
|
|
55
|
+
}
|
|
56
|
+
/**
|
|
57
|
+
* Runs the CLI over the given targets and writes the report in the format
|
|
58
|
+
* `--format` selects.
|
|
59
|
+
*
|
|
60
|
+
* @param files Files or source code to lint.
|
|
61
|
+
* @param options The parsed CLI flags.
|
|
62
|
+
* @param apiOptions Options passed through to every `MLEngine`.
|
|
63
|
+
* @returns `true` when the process must exit with `1`: an error, a warning
|
|
64
|
+
* with `--no-allow-warnings`, more warnings than `--max-warnings`, or a
|
|
65
|
+
* usage error such as no target files. The decision does not depend on
|
|
66
|
+
* `--format`: CI reads the exit code whatever format the report is in. Nor
|
|
67
|
+
* does it depend on `--max-count`, which only decides what the report shows:
|
|
68
|
+
* a violation the limit hides, and every violation of a file after the
|
|
69
|
+
* limit, still counts. (`--fix`, `--suppress` and `--prune-suppressions`
|
|
70
|
+
* ignore the limit altogether; see `maxCount` below.)
|
|
71
|
+
* `--show-config`, `--suppress` and `--prune-suppressions` do not lint for
|
|
72
|
+
* a verdict, so they return `false` whatever violations exist.
|
|
73
|
+
*/
|
|
28
74
|
export async function command(files, options, apiOptions) {
|
|
29
75
|
const fixDryRun = options.fixDryRun;
|
|
30
76
|
if (options.fix && fixDryRun) {
|
|
@@ -39,8 +85,12 @@ export async function command(files, options, apiOptions) {
|
|
|
39
85
|
process.stderr.write('Error: --suppress/--suppress-rule and --prune-suppressions cannot be used together.\n');
|
|
40
86
|
return true;
|
|
41
87
|
}
|
|
42
|
-
|
|
43
|
-
|
|
88
|
+
// `--max-count` limits what a lint report shows. `--fix` has to reach every
|
|
89
|
+
// file, and `--suppress` / `--prune-suppressions` write what the whole run
|
|
90
|
+
// found: a cut-short run would record too few violations, and would prune
|
|
91
|
+
// the entries of the files it never reached.
|
|
92
|
+
const maxCount = isSuppressMode || isPruneMode || fix ? 0 : options.maxCount;
|
|
93
|
+
const configFile = options.config;
|
|
44
94
|
const locale = options.locale;
|
|
45
95
|
const searchConfig = options.searchConfig;
|
|
46
96
|
const ignoreExt = options.ignoreExt;
|
|
@@ -61,41 +111,44 @@ export async function command(files, options, apiOptions) {
|
|
|
61
111
|
const format = options.format?.toLowerCase().trim();
|
|
62
112
|
let hasError = false;
|
|
63
113
|
let totalWarningCount = 0;
|
|
64
|
-
const collector = new ViolationCollector(
|
|
114
|
+
const collector = new ViolationCollector(maxCount);
|
|
65
115
|
const processedFiles = [];
|
|
66
116
|
const skippedFiles = [];
|
|
117
|
+
// Targets the engine returned no result for (excluded by the config).
|
|
118
|
+
// They have no violations because they are not linted, which is what
|
|
119
|
+
// `--prune-suppressions` needs to see for their entries.
|
|
120
|
+
const ignoredFiles = [];
|
|
67
121
|
// See `dedupeConfigLevelViolations` for why this Set needs to persist
|
|
68
122
|
// across the whole run (not per file).
|
|
69
123
|
const seenConfigMessages = new Set();
|
|
124
|
+
// The same for the files after the `--max-count` limit, which are not
|
|
125
|
+
// reported: they start from what the reported files already showed, so a
|
|
126
|
+
// config-level message counts once for the run's verdict, not once per
|
|
127
|
+
// skipped file, and the report's own Set is left as it is.
|
|
128
|
+
let seenConfigMessagesOfSkipped;
|
|
70
129
|
const filesContent = new Map();
|
|
71
130
|
// Suppressions scope computation is the only consumer of the parsed
|
|
72
|
-
// documents
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
131
|
+
// documents. A normal run applies a file's suppressions while that file is
|
|
132
|
+
// processed, so its document is not kept past the iteration; only
|
|
133
|
+
// --suppress, which generates scopes for the whole run once it is over,
|
|
134
|
+
// retains them. Retaining every file's document otherwise keeps N DOM
|
|
135
|
+
// trees reachable until the command ends.
|
|
76
136
|
//
|
|
77
137
|
// A normal run reads the suppressions file exactly once, here, and applies
|
|
78
|
-
// that same snapshot
|
|
79
|
-
//
|
|
80
|
-
//
|
|
138
|
+
// that same snapshot to every file. A file that cannot be read or parsed
|
|
139
|
+
// fails the run in every output mode: which mode prints the report must
|
|
140
|
+
// not decide whether a broken suppressions file is noticed.
|
|
81
141
|
const suppressionsFilePath = resolveSuppressionsPath(options.suppressionsLocation);
|
|
82
142
|
let normalRunSuppressions = {};
|
|
83
|
-
let suppressionsReadError;
|
|
84
143
|
if (!isSuppressMode && !isPruneMode) {
|
|
85
|
-
|
|
86
|
-
normalRunSuppressions = await readSuppressionsFile(suppressionsFilePath);
|
|
87
|
-
}
|
|
88
|
-
catch (error) {
|
|
89
|
-
if (isFatalError(error)) {
|
|
90
|
-
throw error;
|
|
91
|
-
}
|
|
92
|
-
// Proceed as if there were no suppressions; only the progressive
|
|
93
|
-
// output check below treats an unreadable file as an error.
|
|
94
|
-
suppressionsReadError = error;
|
|
95
|
-
}
|
|
144
|
+
normalRunSuppressions = await readSuppressionsFile(suppressionsFilePath);
|
|
96
145
|
}
|
|
97
146
|
const needsScope = isSuppressMode || Object.keys(normalRunSuppressions).length > 0;
|
|
98
147
|
const nodeLists = new Map();
|
|
148
|
+
// Entries of the suppressions file that suppressed nothing in a file this
|
|
149
|
+
// run linted. A file the run did not lint (outside the targets) cannot be
|
|
150
|
+
// judged, so its entries are never listed.
|
|
151
|
+
const unusedSuppressionEntries = [];
|
|
99
152
|
// Shared across every file in this run so its config cache — keyed by
|
|
100
153
|
// resolved config `names`, not by target file — actually helps: config
|
|
101
154
|
// loading/merging/plugin-resolution is done once per distinct config,
|
|
@@ -108,39 +161,28 @@ export async function command(files, options, apiOptions) {
|
|
|
108
161
|
...(parsedSeverityDeprecation != null && { deprecation: parsedSeverityDeprecation }),
|
|
109
162
|
};
|
|
110
163
|
// Progressive output prints each file's own violations as soon as that
|
|
111
|
-
// file is processed
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
// skipped
|
|
118
|
-
//
|
|
119
|
-
// and exit code then contradict. Rather than duplicate that truncation
|
|
120
|
-
// and suppression logic in the progressive branch, fall back to batch
|
|
121
|
-
// output for the whole run whenever either is in play; --suppress and
|
|
122
|
-
// --prune-suppressions manage the suppressions file directly and are
|
|
123
|
-
// unaffected.
|
|
164
|
+
// file is processed. A file's suppressions are applied right there, so
|
|
165
|
+
// what is printed is what the run keeps. `--max-count` truncation is the
|
|
166
|
+
// one whole-run pass it cannot join: it is enforced by
|
|
167
|
+
// `collector.pushWithFile`'s running total across files, including which
|
|
168
|
+
// file the limit is hit within and which later files are skipped
|
|
169
|
+
// entirely. Printing per file ahead of it would show violations, or omit
|
|
170
|
+
// a skipped-file notice, that the run's own summary then contradicts, so
|
|
171
|
+
// the whole run falls back to batch output whenever it is in play.
|
|
124
172
|
let progressiveOutput = options.progressiveOutput;
|
|
125
|
-
if (progressiveOutput &&
|
|
173
|
+
if (progressiveOutput && maxCount > 0) {
|
|
126
174
|
progressiveOutput = false;
|
|
127
175
|
}
|
|
128
|
-
if (progressiveOutput && !isSuppressMode && !isPruneMode) {
|
|
129
|
-
if (suppressionsReadError != null) {
|
|
130
|
-
throw suppressionsReadError;
|
|
131
|
-
}
|
|
132
|
-
if (needsScope) {
|
|
133
|
-
progressiveOutput = false;
|
|
134
|
-
}
|
|
135
|
-
}
|
|
136
176
|
for (const file of fileList) {
|
|
137
|
-
//
|
|
138
|
-
|
|
139
|
-
|
|
177
|
+
// A file after the `--max-count` limit is not reported, but it is still
|
|
178
|
+
// linted: the limit only decides what the report shows, and a skipped
|
|
179
|
+
// file's errors must reach the exit decision like any other file's.
|
|
180
|
+
const skipped = collector.isLocked();
|
|
181
|
+
if (skipped) {
|
|
182
|
+
log('Not reporting file due to max-count limit: %s', file.path);
|
|
140
183
|
skippedFiles.push(file.path);
|
|
141
|
-
continue;
|
|
142
184
|
}
|
|
143
|
-
const
|
|
185
|
+
const engineOptions = {
|
|
144
186
|
configFile,
|
|
145
187
|
fix,
|
|
146
188
|
locale,
|
|
@@ -151,7 +193,8 @@ export async function command(files, options, apiOptions) {
|
|
|
151
193
|
severity,
|
|
152
194
|
...apiOptions,
|
|
153
195
|
configProvider,
|
|
154
|
-
}
|
|
196
|
+
};
|
|
197
|
+
const engine = new MLEngine(file, engineOptions);
|
|
155
198
|
if (options.showConfig != null) {
|
|
156
199
|
const isDetails = options.showConfig === 'details';
|
|
157
200
|
const configSet = await engine.resolveConfig(false);
|
|
@@ -178,21 +221,39 @@ export async function command(files, options, apiOptions) {
|
|
|
178
221
|
}
|
|
179
222
|
const result = await engine.exec();
|
|
180
223
|
if (!result) {
|
|
224
|
+
ignoredFiles.push(file.path);
|
|
181
225
|
continue;
|
|
182
226
|
}
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
227
|
+
if (!skipped) {
|
|
228
|
+
processedFiles.push(result.filePath);
|
|
229
|
+
filesContent.set(result.filePath, {
|
|
230
|
+
sourceCode: result.sourceCode,
|
|
231
|
+
fixedCode: result.fixedCode,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
let nodeList;
|
|
188
235
|
if (needsScope) {
|
|
189
|
-
|
|
236
|
+
// `exec()` leaves the engine on the pre-fix document, but with --fix the
|
|
237
|
+
// violations below are those of the fixed code (see `violationsBeforeDedupe`),
|
|
238
|
+
// and a scope is found by their line and column. The node list has to be
|
|
239
|
+
// that of the fixed code, which a file-based engine cannot be moved onto
|
|
240
|
+
// (its file is read-only), so the fixed code is parsed by an engine of its own.
|
|
241
|
+
let scopeEngine = engine;
|
|
242
|
+
if (!fixDryRun && result.fixSummary?.finalPassViolations) {
|
|
243
|
+
const [fixedFile] = await resolveFiles([{ sourceCode: result.fixedCode, name: result.filePath }]);
|
|
244
|
+
scopeEngine = new MLEngine(fixedFile, { ...engineOptions, fix: false });
|
|
245
|
+
await scopeEngine.exec();
|
|
246
|
+
}
|
|
247
|
+
const doc = scopeEngine.document;
|
|
190
248
|
if (doc) {
|
|
191
249
|
// MLNode structurally satisfies PositionedNode (startLine, startCol, localName,
|
|
192
250
|
// id, classList, parentElement, children are all present). The double cast is
|
|
193
251
|
// needed because TypeScript can't verify structural compatibility between the
|
|
194
252
|
// generic MLNode<T,O> and the plain PositionedNode interface at compile time.
|
|
195
|
-
|
|
253
|
+
nodeList = doc.nodeList;
|
|
254
|
+
if (isSuppressMode) {
|
|
255
|
+
nodeLists.set(result.filePath, nodeList);
|
|
256
|
+
}
|
|
196
257
|
}
|
|
197
258
|
}
|
|
198
259
|
// In fix mode, report the violations remaining in the FIXED code
|
|
@@ -203,12 +264,24 @@ export async function command(files, options, apiOptions) {
|
|
|
203
264
|
const violationsBeforeDedupe = fixDryRun
|
|
204
265
|
? result.violations
|
|
205
266
|
: (result.fixSummary?.finalPassViolations ?? result.violations);
|
|
206
|
-
|
|
267
|
+
// Once the limit is hit every later file is skipped, so the seed below
|
|
268
|
+
// is complete: nothing adds to `seenConfigMessages` after this point.
|
|
269
|
+
const seen = skipped ? (seenConfigMessagesOfSkipped ??= new Set(seenConfigMessages)) : seenConfigMessages;
|
|
270
|
+
const reportedViolations = dedupeConfigLevelViolations(violationsBeforeDedupe, seen);
|
|
271
|
+
// Suppressions apply to the file's whole violation list, before the
|
|
272
|
+
// collector sees it. Count-based matching needs every violation of
|
|
273
|
+
// the file; the `--max-count` budget must be spent on violations
|
|
274
|
+
// that will be reported; and the exit decision counts `keptViolations`
|
|
275
|
+
// of every linted file, so the report's truncation never decides it.
|
|
276
|
+
const { kept: keptViolations, unusedEntries } = isSuppressMode || isPruneMode
|
|
277
|
+
? { kept: reportedViolations, unusedEntries: [] }
|
|
278
|
+
: applyFileSuppressions(result.filePath, reportedViolations, normalRunSuppressions, suppressionsFilePath, nodeList);
|
|
279
|
+
unusedSuppressionEntries.push(...unusedEntries);
|
|
207
280
|
// Progressive出力が有効でJSON形式でない場合
|
|
208
281
|
if (progressiveOutput && format !== 'json') {
|
|
209
282
|
// 即座に出力
|
|
210
283
|
output({
|
|
211
|
-
violations:
|
|
284
|
+
violations: [...keptViolations],
|
|
212
285
|
filePath: result.filePath,
|
|
213
286
|
sourceCode: result.sourceCode,
|
|
214
287
|
fixedCode: result.fixedCode,
|
|
@@ -216,9 +289,11 @@ export async function command(files, options, apiOptions) {
|
|
|
216
289
|
}, options);
|
|
217
290
|
}
|
|
218
291
|
// Add violations to collector
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
292
|
+
if (!skipped) {
|
|
293
|
+
collector.pushWithFile(result.filePath, ...keptViolations);
|
|
294
|
+
}
|
|
295
|
+
const errorCount = keptViolations.filter(v => v.severity === 'error').length;
|
|
296
|
+
const warningCount = keptViolations.filter(v => v.severity === 'warning').length;
|
|
222
297
|
// Track total warning count across all files
|
|
223
298
|
totalWarningCount += warningCount;
|
|
224
299
|
if (!hasError && (errorCount > 0 || (warningCount > 0 && !options.allowWarnings))) {
|
|
@@ -261,7 +336,25 @@ export async function command(files, options, apiOptions) {
|
|
|
261
336
|
process.stderr.write('No suppressions file found. Nothing to prune.\n');
|
|
262
337
|
return false;
|
|
263
338
|
}
|
|
264
|
-
|
|
339
|
+
// The collected violations are the whole truth only for the files this
|
|
340
|
+
// run had as targets; a file outside the targets has no violations
|
|
341
|
+
// here because it was never checked, not because it is clean. Only the
|
|
342
|
+
// entries of target files (linted, or excluded by the config, which
|
|
343
|
+
// leaves them without violations) are judged, and so are those of a
|
|
344
|
+
// file that no longer exists, which has none either; the others are
|
|
345
|
+
// kept as they are.
|
|
346
|
+
const lintedPaths = new Set([...processedFiles, ...ignoredFiles].map(filePath => toRelativePath(filePath, suppressionsFilePath)));
|
|
347
|
+
const lintedEntries = {};
|
|
348
|
+
const unlintedEntries = {};
|
|
349
|
+
for (const [relPath, rules] of Object.entries(existing)) {
|
|
350
|
+
const judged = lintedPaths.has(relPath) ||
|
|
351
|
+
!(await fs.access(toAbsolutePath(relPath, suppressionsFilePath)).then(() => true, () => false));
|
|
352
|
+
(judged ? lintedEntries : unlintedEntries)[relPath] = rules;
|
|
353
|
+
}
|
|
354
|
+
const pruned = {
|
|
355
|
+
...unlintedEntries,
|
|
356
|
+
...pruneSuppressions(collectedViolationsByFile, lintedEntries, suppressionsFilePath),
|
|
357
|
+
};
|
|
265
358
|
await writeSuppressionsFile(suppressionsFilePath, pruned);
|
|
266
359
|
const existingCount = Object.values(existing).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
|
|
267
360
|
const prunedCount = Object.values(pruned).reduce((sum, rules) => sum + Object.keys(rules).length, 0);
|
|
@@ -269,59 +362,28 @@ export async function command(files, options, apiOptions) {
|
|
|
269
362
|
process.stderr.write(`[Experimental] Suppressions pruned: ${removedCount} entry/entries removed, ${prunedCount} remaining.\n`);
|
|
270
363
|
return false;
|
|
271
364
|
}
|
|
272
|
-
// Normal lint mode:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
const { filtered, unusedEntries } = applySuppressions(collectedViolationsByFile, normalRunSuppressions, suppressionsFilePath, { nodeLists });
|
|
278
|
-
outputViolationsByFile = filtered;
|
|
279
|
-
suppressionsApplied = true;
|
|
280
|
-
if (unusedEntries.length > 0) {
|
|
281
|
-
process.stderr.write(`[Experimental] ${unusedEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
|
|
282
|
-
}
|
|
283
|
-
// Recalculate hasError and totalWarningCount from filtered violations
|
|
284
|
-
hasError = false;
|
|
285
|
-
totalWarningCount = 0;
|
|
286
|
-
for (const violations of filtered.values()) {
|
|
287
|
-
const errorCount = violations.filter(v => v.severity === 'error').length;
|
|
288
|
-
const warningCount = violations.filter(v => v.severity === 'warning').length;
|
|
289
|
-
totalWarningCount += warningCount;
|
|
290
|
-
if (errorCount > 0 || (warningCount > 0 && !options.allowWarnings)) {
|
|
291
|
-
hasError = true;
|
|
292
|
-
}
|
|
293
|
-
}
|
|
294
|
-
}
|
|
365
|
+
// Normal lint mode: the collector already holds the violations the
|
|
366
|
+
// suppressions leave, cut to `--max-count`; `hasError` and
|
|
367
|
+
// `totalWarningCount` were counted before that cut.
|
|
368
|
+
if (unusedSuppressionEntries.length > 0) {
|
|
369
|
+
process.stderr.write(`[Experimental] ${unusedSuppressionEntries.length} unused suppression entry/entries found. Run with --prune-suppressions to clean up.\n`);
|
|
295
370
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
}
|
|
300
|
-
// Suppressions file does not exist or is unreadable, proceed normally
|
|
371
|
+
// Check if max-warnings limit is exceeded (ESLint compatible)
|
|
372
|
+
if (!hasError && options.maxWarnings >= 0 && totalWarningCount > options.maxWarnings) {
|
|
373
|
+
hasError = true;
|
|
301
374
|
}
|
|
302
|
-
// Output results
|
|
375
|
+
// Output results. The JSON branch returns early but with the same
|
|
376
|
+
// `hasError` as the other formats; see the `@returns` of this function.
|
|
303
377
|
if (format === 'json') {
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
for (const [filePath, violations] of outputViolationsByFile) {
|
|
308
|
-
for (const violation of violations) {
|
|
309
|
-
jsonOutput.push({ ...violation, filePath });
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
313
|
-
}
|
|
314
|
-
else {
|
|
315
|
-
const jsonOutput = collector.toArray();
|
|
316
|
-
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
317
|
-
}
|
|
318
|
-
return false;
|
|
378
|
+
const jsonOutput = collector.toArray();
|
|
379
|
+
process.stdout.write(JSON.stringify(jsonOutput, null, 2) + '\n');
|
|
380
|
+
return hasError;
|
|
319
381
|
}
|
|
320
382
|
// Progressive出力が無効の場合のみループ後に出力
|
|
321
383
|
if (!progressiveOutput) {
|
|
322
384
|
// Output per file - include processed files without violations
|
|
323
385
|
for (const filePath of processedFiles) {
|
|
324
|
-
const violations =
|
|
386
|
+
const violations = collectedViolationsByFile.get(filePath) || [];
|
|
325
387
|
const content = filesContent.get(filePath) || { sourceCode: '', fixedCode: '' };
|
|
326
388
|
if (violations.length === 0 && !options.problemOnly) {
|
|
327
389
|
log('Output reports');
|
|
@@ -357,7 +419,7 @@ export async function command(files, options, apiOptions) {
|
|
|
357
419
|
let noticeCount = 0;
|
|
358
420
|
let failedFileCount = 0;
|
|
359
421
|
for (const filePath of processedFiles) {
|
|
360
|
-
const violations =
|
|
422
|
+
const violations = collectedViolationsByFile.get(filePath) || [];
|
|
361
423
|
// A file whose only violations are config-level (`CONFIG_LEVEL_RULE_IDS`)
|
|
362
424
|
// didn't fail on its own content — the config issue is reported once
|
|
363
425
|
// for the whole run (see the dedupe above), not attributable to this
|
|
@@ -390,9 +452,5 @@ export async function command(files, options, apiOptions) {
|
|
|
390
452
|
warningCount,
|
|
391
453
|
noticeCount,
|
|
392
454
|
}, options);
|
|
393
|
-
// Check if max-warnings limit is exceeded (ESLint compatible)
|
|
394
|
-
if (!hasError && options.maxWarnings >= 0 && totalWarningCount > options.maxWarnings) {
|
|
395
|
-
hasError = true;
|
|
396
|
-
}
|
|
397
455
|
return hasError;
|
|
398
456
|
}
|
package/lib/cli/index.d.ts
CHANGED
|
@@ -5,5 +5,41 @@
|
|
|
5
5
|
* `process.stderr.write()` — never `console.warn`/`console.error`/
|
|
6
6
|
* `console.log` — so stdout stays reserved for lint results
|
|
7
7
|
* (e.g. `--format JSON` output that may be piped to other tools).
|
|
8
|
+
*
|
|
9
|
+
* The process ends by setting `process.exitCode` and returning from `main()`,
|
|
10
|
+
* never by `process.exit()`. On POSIX a pipe is written asynchronously, and
|
|
11
|
+
* `process.exit()` drops what is still queued: a report larger than the pipe
|
|
12
|
+
* buffer reached `| jq` cut off at its first 128 KiB (#4554). Node.js says
|
|
13
|
+
* `process.exit()` forces the exit "even if there are still asynchronous
|
|
14
|
+
* operations pending that have not yet completed fully, including I/O
|
|
15
|
+
* operations to `process.stdout`", and asks to "set the `process.exitCode`
|
|
16
|
+
* and allow the process to exit naturally" instead
|
|
17
|
+
* (https://nodejs.org/docs/latest-v24.x/api/process.html#processexitcode).
|
|
18
|
+
* meow's own `--help` / `--version` handling calls `process.exit()` as well,
|
|
19
|
+
* so it is turned off (`autoHelp` / `autoVersion` in `bootstrap.ts`) and done
|
|
20
|
+
* here. Keep it that way: a new `process.exit()` in this layer brings the
|
|
21
|
+
* truncation back for whatever it follows.
|
|
22
|
+
*
|
|
23
|
+
* Standard input is read in two ways. The `-` operand reads it whatever
|
|
24
|
+
* stdout is (POSIX Utility Syntax Guidelines, Guideline 13: "the '-' operand
|
|
25
|
+
* should be used to mean only standard input" for operands that represent
|
|
26
|
+
* files to be opened for reading), which is what lets
|
|
27
|
+
* `cat a.html | markuplint - --format json | jq` work with both ends piped.
|
|
28
|
+
* With no operand it is read only when stdout is a TTY: without that
|
|
29
|
+
* condition a parent that starts the CLI with no arguments and does not close
|
|
30
|
+
* stdin (a child process spawned with an inherited, never-closed stdin) would
|
|
31
|
+
* wait for it forever. An empty stdin is answered with the help and exit code
|
|
32
|
+
* 1 in that case, and linted as an empty source with the `-` operand.
|
|
33
|
+
* Neither way takes the options that act on a file (`--fix`, `--search` and the
|
|
34
|
+
* suppressions options): there is no file to write or record for, and `--fix`
|
|
35
|
+
* once created one named `<AnonymousFile>` (#4560). Both reject them before the
|
|
36
|
+
* standard input is read, so an empty input gets that error, not the help.
|
|
37
|
+
* `--fix-dry-run` is not among them: it writes nothing and only prints the diff.
|
|
38
|
+
*
|
|
39
|
+
* The flags that end the run early (`--help`, `--version`, `--init`,
|
|
40
|
+
* `--create-rule`) are evaluated before the operands, so `-` next to them is
|
|
41
|
+
* ignored. A failure that is not fatal (`isFatalError()`) is reported on stderr
|
|
42
|
+
* with exit code 1; a fatal one is rethrown, and the top-level `await` ends the
|
|
43
|
+
* process with exit code 1 and the stack.
|
|
8
44
|
*/
|
|
9
45
|
export {};
|
package/lib/cli/index.js
CHANGED
|
@@ -5,70 +5,42 @@
|
|
|
5
5
|
* `process.stderr.write()` — never `console.warn`/`console.error`/
|
|
6
6
|
* `console.log` — so stdout stays reserved for lint results
|
|
7
7
|
* (e.g. `--format JSON` output that may be piped to other tools).
|
|
8
|
+
*
|
|
9
|
+
* The process ends by setting `process.exitCode` and returning from `main()`,
|
|
10
|
+
* never by `process.exit()`. On POSIX a pipe is written asynchronously, and
|
|
11
|
+
* `process.exit()` drops what is still queued: a report larger than the pipe
|
|
12
|
+
* buffer reached `| jq` cut off at its first 128 KiB (#4554). Node.js says
|
|
13
|
+
* `process.exit()` forces the exit "even if there are still asynchronous
|
|
14
|
+
* operations pending that have not yet completed fully, including I/O
|
|
15
|
+
* operations to `process.stdout`", and asks to "set the `process.exitCode`
|
|
16
|
+
* and allow the process to exit naturally" instead
|
|
17
|
+
* (https://nodejs.org/docs/latest-v24.x/api/process.html#processexitcode).
|
|
18
|
+
* meow's own `--help` / `--version` handling calls `process.exit()` as well,
|
|
19
|
+
* so it is turned off (`autoHelp` / `autoVersion` in `bootstrap.ts`) and done
|
|
20
|
+
* here. Keep it that way: a new `process.exit()` in this layer brings the
|
|
21
|
+
* truncation back for whatever it follows.
|
|
22
|
+
*
|
|
23
|
+
* Standard input is read in two ways. The `-` operand reads it whatever
|
|
24
|
+
* stdout is (POSIX Utility Syntax Guidelines, Guideline 13: "the '-' operand
|
|
25
|
+
* should be used to mean only standard input" for operands that represent
|
|
26
|
+
* files to be opened for reading), which is what lets
|
|
27
|
+
* `cat a.html | markuplint - --format json | jq` work with both ends piped.
|
|
28
|
+
* With no operand it is read only when stdout is a TTY: without that
|
|
29
|
+
* condition a parent that starts the CLI with no arguments and does not close
|
|
30
|
+
* stdin (a child process spawned with an inherited, never-closed stdin) would
|
|
31
|
+
* wait for it forever. An empty stdin is answered with the help and exit code
|
|
32
|
+
* 1 in that case, and linted as an empty source with the `-` operand.
|
|
33
|
+
* Neither way takes the options that act on a file (`--fix`, `--search` and the
|
|
34
|
+
* suppressions options): there is no file to write or record for, and `--fix`
|
|
35
|
+
* once created one named `<AnonymousFile>` (#4560). Both reject them before the
|
|
36
|
+
* standard input is read, so an empty input gets that error, not the help.
|
|
37
|
+
* `--fix-dry-run` is not among them: it writes nothing and only prints the diff.
|
|
38
|
+
*
|
|
39
|
+
* The flags that end the run early (`--help`, `--version`, `--init`,
|
|
40
|
+
* `--create-rule`) are evaluated before the operands, so `-` next to them is
|
|
41
|
+
* ignored. A failure that is not fatal (`isFatalError()`) is reported on stderr
|
|
42
|
+
* with exit code 1; a fatal one is rethrown, and the top-level `await` ends the
|
|
43
|
+
* process with exit code 1 and the stack.
|
|
8
44
|
*/
|
|
9
|
-
import {
|
|
10
|
-
|
|
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
|
-
/* eslint-disable unicorn/no-process-exit */
|
|
16
|
-
if (cli.flags.v) {
|
|
17
|
-
cli.showVersion(); // And exit successfully.
|
|
18
|
-
}
|
|
19
|
-
if (cli.flags.h) {
|
|
20
|
-
cli.showHelp(0); // And exit successfully.
|
|
21
|
-
}
|
|
22
|
-
if (cli.flags.verbose) {
|
|
23
|
-
verbosely();
|
|
24
|
-
}
|
|
25
|
-
if (cli.flags.init) {
|
|
26
|
-
await initialize().catch(error => {
|
|
27
|
-
process.stderr.write(error + '\n');
|
|
28
|
-
process.exit(1);
|
|
29
|
-
});
|
|
30
|
-
process.exit(0);
|
|
31
|
-
}
|
|
32
|
-
if (cli.flags.createRule) {
|
|
33
|
-
process.stderr.write("Use to run 'npx @markuplint/create-rule' instead of 'markuplint --create-rule'.\n");
|
|
34
|
-
process.exit(1);
|
|
35
|
-
}
|
|
36
|
-
const files = cli.input;
|
|
37
|
-
if (files.length > 0) {
|
|
38
|
-
if (cli.flags.search) {
|
|
39
|
-
await search(files, cli.flags, cli.flags.search).catch(error => {
|
|
40
|
-
process.stderr.write(error + '\n');
|
|
41
|
-
process.exit(1);
|
|
42
|
-
});
|
|
43
|
-
process.exit(0);
|
|
44
|
-
}
|
|
45
|
-
const hasError = await command(files, cli.flags).catch(error => {
|
|
46
|
-
throw error;
|
|
47
|
-
});
|
|
48
|
-
process.exit(hasError ? 1 : 0);
|
|
49
|
-
}
|
|
50
|
-
if (usePipe()) {
|
|
51
|
-
const stdin = await text(process.stdin).catch(error => {
|
|
52
|
-
// eslint-disable-next-line no-console
|
|
53
|
-
console.warn(error);
|
|
54
|
-
process.exit(1);
|
|
55
|
-
});
|
|
56
|
-
if (!stdin) {
|
|
57
|
-
// result is empty
|
|
58
|
-
cli.showHelp(1);
|
|
59
|
-
}
|
|
60
|
-
const hasError = await command([{ sourceCode: stdin }], {
|
|
61
|
-
...cli.flags,
|
|
62
|
-
ignoreExt: true,
|
|
63
|
-
}).catch(error => {
|
|
64
|
-
process.stderr.write(error + '\n');
|
|
65
|
-
process.exit(1);
|
|
66
|
-
});
|
|
67
|
-
process.exit(hasError ? 1 : 0);
|
|
68
|
-
}
|
|
69
|
-
// No arguments
|
|
70
|
-
cli.showHelp(1);
|
|
71
|
-
/* eslint-enable unicorn/no-process-exit */
|
|
72
|
-
function usePipe() {
|
|
73
|
-
return !process.stdin.isTTY && process.stdout.isTTY;
|
|
74
|
-
}
|
|
45
|
+
import { main } from './main.js';
|
|
46
|
+
process.exitCode = await main();
|
package/lib/cli/init/index.js
CHANGED
|
@@ -71,10 +71,10 @@ export async function initialize() {
|
|
|
71
71
|
if (autoInstall) {
|
|
72
72
|
process.stdout.write('Install the dependencies automatically\n');
|
|
73
73
|
const modules = selectModules(selectedLangs);
|
|
74
|
-
const result = await installModule(modules, true).catch(error_ => new Error(error_));
|
|
74
|
+
const result = await installModule(modules, true).catch(error_ => error_ instanceof Error ? error_ : new Error(String(error_)));
|
|
75
75
|
if (result instanceof Error) {
|
|
76
|
-
//
|
|
77
|
-
|
|
76
|
+
// `index.ts` reports it on stderr and returns exit code 1; see its module JSDoc.
|
|
77
|
+
throw result;
|
|
78
78
|
}
|
|
79
79
|
if (result.alreadyExists) {
|
|
80
80
|
process.stdout.write('Modules are installed already.\n');
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
+
/**
|
|
9
|
+
* Runs the CLI and resolves to its exit code.
|
|
10
|
+
*
|
|
11
|
+
* Every branch returns a number: a branch that forgets to leaves `undefined`,
|
|
12
|
+
* and the `process.exitCode` that `output.ts` set (1) would leak out.
|
|
13
|
+
*/
|
|
14
|
+
export declare function main(): Promise<number>;
|
package/lib/cli/main.js
ADDED
|
@@ -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')}
|
|
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
|
}
|
package/lib/cli/search/index.js
CHANGED
|
@@ -13,7 +13,8 @@ export async function search(files, options, selectors) {
|
|
|
13
13
|
name,
|
|
14
14
|
...createRule({
|
|
15
15
|
verify({ document }) {
|
|
16
|
-
|
|
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_',
|
|
@@ -57,7 +57,7 @@ export interface PositionedNode extends ScopedNode {
|
|
|
57
57
|
* (the one `CSS.escape()` is specified by), in which a code point that is not an ident code point of CSS
|
|
58
58
|
* Syntax is escaped, so the output is always one identifier. A NUL is written as U+FFFD, as the algorithm
|
|
59
59
|
* 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,
|
|
60
|
+
* 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
61
|
* `Tabs` with the class `Root`, `py-1.5` is `py-1\.5`, `hover:bg-x` is `hover\:bg-x`, `2xl:flex` is
|
|
62
62
|
* `\32 xl\:flex`, and a class that ends with `:nth-of-type(2)` is no position. What this writes and what
|
|
63
63
|
* {@link unescapeIdentifier} reads are one form, so a scope matches the element it was made for.
|
|
@@ -69,7 +69,12 @@ export interface PositionedNode extends ScopedNode {
|
|
|
69
69
|
* with `:nth-of-type(n)` (`div.a:nth-of-type(2)`) never matched, and is still read as a position. A `\`
|
|
70
70
|
* that such a file holds is read as CSS reads it: `\ba` is the hex escape U+00BA.
|
|
71
71
|
*
|
|
72
|
+
* CSS Syntax says of an identifier:
|
|
73
|
+
*
|
|
74
|
+
* > You can include any code point at all, even ones that CSS uses in its syntax, by escaping it.
|
|
75
|
+
*
|
|
72
76
|
* @see https://drafts.csswg.org/cssom/#serialize-an-identifier
|
|
77
|
+
* @see https://www.w3.org/TR/css-syntax-3/#syntax-description
|
|
73
78
|
*/
|
|
74
79
|
export declare function serializeIdentifier(name: string): string;
|
|
75
80
|
/**
|
|
@@ -104,7 +109,7 @@ export declare function splitScopeAttr(segment: string): {
|
|
|
104
109
|
* class `a\` at the position 2, and `a\:nth-of-type(2)` is the class `a:nth-of-type(2)`. The escapes
|
|
105
110
|
* are scanned from the start, as {@link splitScopeIdents} does.
|
|
106
111
|
*
|
|
107
|
-
* @see https://www.w3.org/TR/css-syntax-3/#
|
|
112
|
+
* @see https://www.w3.org/TR/css-syntax-3/#syntax-description
|
|
108
113
|
*/
|
|
109
114
|
export declare function splitScopeNth(segment: string): {
|
|
110
115
|
readonly base: string;
|
|
@@ -40,7 +40,7 @@ const NON_ASCII_IDENT_CODE_POINT = /^[\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u0
|
|
|
40
40
|
* (the one `CSS.escape()` is specified by), in which a code point that is not an ident code point of CSS
|
|
41
41
|
* Syntax is escaped, so the output is always one identifier. A NUL is written as U+FFFD, as the algorithm
|
|
42
42
|
* 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,
|
|
43
|
+
* 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
44
|
* `Tabs` with the class `Root`, `py-1.5` is `py-1\.5`, `hover:bg-x` is `hover\:bg-x`, `2xl:flex` is
|
|
45
45
|
* `\32 xl\:flex`, and a class that ends with `:nth-of-type(2)` is no position. What this writes and what
|
|
46
46
|
* {@link unescapeIdentifier} reads are one form, so a scope matches the element it was made for.
|
|
@@ -52,7 +52,12 @@ const NON_ASCII_IDENT_CODE_POINT = /^[\u00B7\u00C0-\u00D6\u00D8-\u00F6\u00F8-\u0
|
|
|
52
52
|
* with `:nth-of-type(n)` (`div.a:nth-of-type(2)`) never matched, and is still read as a position. A `\`
|
|
53
53
|
* that such a file holds is read as CSS reads it: `\ba` is the hex escape U+00BA.
|
|
54
54
|
*
|
|
55
|
+
* CSS Syntax says of an identifier:
|
|
56
|
+
*
|
|
57
|
+
* > You can include any code point at all, even ones that CSS uses in its syntax, by escaping it.
|
|
58
|
+
*
|
|
55
59
|
* @see https://drafts.csswg.org/cssom/#serialize-an-identifier
|
|
60
|
+
* @see https://www.w3.org/TR/css-syntax-3/#syntax-description
|
|
56
61
|
*/
|
|
57
62
|
export function serializeIdentifier(name) {
|
|
58
63
|
let result = '';
|
|
@@ -187,7 +192,7 @@ export function splitScopeAttr(segment) {
|
|
|
187
192
|
* class `a\` at the position 2, and `a\:nth-of-type(2)` is the class `a:nth-of-type(2)`. The escapes
|
|
188
193
|
* are scanned from the start, as {@link splitScopeIdents} does.
|
|
189
194
|
*
|
|
190
|
-
* @see https://www.w3.org/TR/css-syntax-3/#
|
|
195
|
+
* @see https://www.w3.org/TR/css-syntax-3/#syntax-description
|
|
191
196
|
*/
|
|
192
197
|
export function splitScopeNth(segment) {
|
|
193
198
|
// Only an unescaped `:` is looked at: an escaped one is read with its `\` before it, as one character
|
|
@@ -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
|
-
|
|
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.
|
|
3
|
+
"version": "5.5.0",
|
|
4
4
|
"description": "An HTML linter for all markup developers",
|
|
5
5
|
"author": "Yusuke Hirao",
|
|
6
6
|
"license": "MIT",
|
|
@@ -33,20 +33,28 @@
|
|
|
33
33
|
"dev": "tsc --watch --project tsconfig.build.json",
|
|
34
34
|
"clean": "tsc --build --clean tsconfig.build.json"
|
|
35
35
|
},
|
|
36
|
+
"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"
|
|
43
|
+
},
|
|
36
44
|
"dependencies": {
|
|
37
45
|
"@markuplint/cli-utils": "5.3.0",
|
|
38
|
-
"@markuplint/file-resolver": "5.
|
|
39
|
-
"@markuplint/html-parser": "5.
|
|
40
|
-
"@markuplint/html-spec": "5.
|
|
41
|
-
"@markuplint/i18n": "5.
|
|
42
|
-
"@markuplint/ml-ast": "5.
|
|
43
|
-
"@markuplint/ml-config": "5.
|
|
44
|
-
"@markuplint/ml-core": "5.
|
|
45
|
-
"@markuplint/ml-spec": "5.
|
|
46
|
-
"@markuplint/rules": "5.
|
|
47
|
-
"@markuplint/shared": "5.
|
|
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",
|
|
48
56
|
"@types/debug": "^4.1.13",
|
|
49
|
-
"chokidar": "^
|
|
57
|
+
"chokidar": "^6.0.0",
|
|
50
58
|
"debug": "^4.4.3",
|
|
51
59
|
"meow": "^14.1.0",
|
|
52
60
|
"os-locale": "^8.0.0",
|
|
@@ -54,5 +62,33 @@
|
|
|
54
62
|
"strip-ansi": "^7.2.0",
|
|
55
63
|
"type-fest": "^5.10.0"
|
|
56
64
|
},
|
|
57
|
-
"
|
|
65
|
+
"peerDependencies": {
|
|
66
|
+
"@markuplint/astro-parser": "^5.4.0",
|
|
67
|
+
"@markuplint/jsx-parser": "^5.4.0",
|
|
68
|
+
"@markuplint/mdx-parser": "^5.4.0",
|
|
69
|
+
"@markuplint/pretenders": "^5.4.0",
|
|
70
|
+
"@markuplint/svelte-parser": "^5.4.0",
|
|
71
|
+
"@markuplint/vue-parser": "^5.4.0"
|
|
72
|
+
},
|
|
73
|
+
"peerDependenciesMeta": {
|
|
74
|
+
"@markuplint/astro-parser": {
|
|
75
|
+
"optional": true
|
|
76
|
+
},
|
|
77
|
+
"@markuplint/jsx-parser": {
|
|
78
|
+
"optional": true
|
|
79
|
+
},
|
|
80
|
+
"@markuplint/mdx-parser": {
|
|
81
|
+
"optional": true
|
|
82
|
+
},
|
|
83
|
+
"@markuplint/pretenders": {
|
|
84
|
+
"optional": true
|
|
85
|
+
},
|
|
86
|
+
"@markuplint/svelte-parser": {
|
|
87
|
+
"optional": true
|
|
88
|
+
},
|
|
89
|
+
"@markuplint/vue-parser": {
|
|
90
|
+
"optional": true
|
|
91
|
+
}
|
|
92
|
+
},
|
|
93
|
+
"gitHead": "fedebcfd56eaec4642e68643b2f9e10750d810e1"
|
|
58
94
|
}
|