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 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 Specifies the severity level of parse errors. Supports "error", "warning", and "off". Default: "error".
90
- --severity-deprecation Specifies the severity level of deprecated rule name notices. Supports "error", "warning", and "off". Default: "warning".
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 Output results immediately after processing each file. Default: false.
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)
@@ -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. The other `errs` (a rule or a
513
- * plugin that is not found, an invalid selector, a circular `extends`)
514
- * leave the rest of the config usable, so they stay `config-error` warnings
515
- * that `MLCore.verify()` reports.
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
- const pretenders = await this.#resolvePretenders(configSet, cache);
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);
@@ -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;
@@ -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";
@@ -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',
@@ -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>;
@@ -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
- const configFile = options.config &&
43
- (path.isAbsolute(options.config) ? options.config : path.resolve(process.cwd(), options.config));
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(options.maxCount);
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, so a document is retained past its file's own iteration only
73
- // when that computation will run (--suppress, or a non-empty suppressions
74
- // file in a normal run). Retaining every file's document otherwise keeps N
75
- // DOM trees reachable until the command ends.
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 at the end: deciding retention from one read and
79
- // applying from a later one could disagree if the file changed mid-run,
80
- // leaving `applySuppressions` without the node lists it needs.
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
- try {
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, ahead of the two whole-run passes that batch output
112
- // waits for: suppressions (applied once at the end, below, via
113
- // `applySuppressions`, since scope resolution and the "unused entry"
114
- // report need the complete result set) and `--max-count` truncation
115
- // (enforced by `collector.pushWithFile`'s running total across files,
116
- // including which file the limit is hit within and which later files are
117
- // skipped entirely). Printing per file ahead of either pass would show
118
- // violations, or omit a skipped-file notice, that the run's own summary
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 && options.maxCount > 0) {
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
- // Check if collector is already locked (max-count reached)
138
- if (collector.isLocked()) {
139
- log('Skipping file due to max-count limit: %s', file.path);
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 engine = new MLEngine(file, {
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
- processedFiles.push(result.filePath);
184
- filesContent.set(result.filePath, {
185
- sourceCode: result.sourceCode,
186
- fixedCode: result.fixedCode,
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
- const doc = engine.document;
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
- nodeLists.set(result.filePath, doc.nodeList);
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
- const reportedViolations = dedupeConfigLevelViolations(violationsBeforeDedupe, seenConfigMessages);
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: reportedViolations,
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
- collector.pushWithFile(result.filePath, ...reportedViolations);
220
- const errorCount = reportedViolations.filter(v => v.severity === 'error').length;
221
- const warningCount = reportedViolations.filter(v => v.severity === 'warning').length;
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
- const pruned = pruneSuppressions(collectedViolationsByFile, existing, suppressionsFilePath);
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: apply suppressions if file exists
273
- let outputViolationsByFile = collectedViolationsByFile;
274
- let suppressionsApplied = false;
275
- try {
276
- if (needsScope) {
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
- catch (error) {
297
- if (isFatalError(error)) {
298
- throw error;
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
- if (suppressionsApplied) {
305
- // Build filtered array with filePath
306
- const jsonOutput = [];
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 = outputViolationsByFile.get(filePath) || [];
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 = outputViolationsByFile.get(filePath) || [];
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
  }
@@ -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 { text } from 'node:stream/consumers';
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
- /* 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();
@@ -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
- // eslint-disable-next-line unicorn/no-process-exit
77
- process.exit(1);
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>;
@@ -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_',
@@ -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, "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
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/#escaping
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, "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
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/#escaping
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
- 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.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.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",
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": "^5.0.0",
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
- "gitHead": "647f6fb5d5743f060678599c97e9c84b58970747"
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
  }