@markuplint/ml-core 5.0.0-rc.2 → 5.0.0-rc.5

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.
Files changed (67) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +0 -5
  3. package/lib/cursor-offset.js +0 -3
  4. package/lib/fix-applier.js +3 -9
  5. package/lib/ml-core.d.ts +18 -2
  6. package/lib/ml-core.js +223 -61
  7. package/lib/ml-dom/helper/accname.d.ts +0 -8
  8. package/lib/ml-dom/helper/accname.js +7 -10
  9. package/lib/ml-dom/node/attr.js +3 -1
  10. package/lib/ml-dom/node/block.d.ts +6 -0
  11. package/lib/ml-dom/node/block.js +6 -0
  12. package/lib/ml-dom/node/child-node.d.ts +0 -9
  13. package/lib/ml-dom/node/child-node.js +0 -9
  14. package/lib/ml-dom/node/document.d.ts +20 -1
  15. package/lib/ml-dom/node/document.js +24 -13
  16. package/lib/ml-dom/node/element-close-tag.d.ts +12 -0
  17. package/lib/ml-dom/node/element-close-tag.js +12 -0
  18. package/lib/ml-dom/node/element.d.ts +22 -0
  19. package/lib/ml-dom/node/element.js +37 -11
  20. package/lib/ml-dom/node/node-store.d.ts +0 -3
  21. package/lib/ml-dom/node/node-store.js +0 -3
  22. package/lib/ml-dom/node/node.d.ts +34 -1
  23. package/lib/ml-dom/node/node.js +34 -16
  24. package/lib/ml-dom/node/parent-node.js +0 -6
  25. package/lib/ml-dom/node/rule-mapper.d.ts +8 -0
  26. package/lib/ml-dom/node/rule-mapper.js +8 -0
  27. package/lib/ml-rule/ml-rule.d.ts +19 -0
  28. package/lib/ml-rule/ml-rule.js +38 -7
  29. package/lib/ml-rule/types.d.ts +110 -1
  30. package/lib/ml-rule/types.js +28 -1
  31. package/lib/ruleset/index.d.ts +2 -1
  32. package/lib/ruleset/index.js +2 -1
  33. package/lib/test/index.js +1 -1
  34. package/lib/virtual-rule.d.ts +10 -0
  35. package/lib/virtual-rule.js +1 -24
  36. package/package.json +14 -14
  37. package/ARCHITECTURE.ja.md +0 -676
  38. package/ARCHITECTURE.md +0 -726
  39. package/SKILL.md +0 -61
  40. package/docs/linting-pipeline.ja.md +0 -307
  41. package/docs/linting-pipeline.md +0 -307
  42. package/docs/maintenance.ja.md +0 -210
  43. package/docs/maintenance.md +0 -210
  44. package/docs/ml-dom/attr.ja.md +0 -103
  45. package/docs/ml-dom/attr.md +0 -103
  46. package/docs/ml-dom/block.ja.md +0 -272
  47. package/docs/ml-dom/block.md +0 -272
  48. package/docs/ml-dom/document.ja.md +0 -134
  49. package/docs/ml-dom/document.md +0 -134
  50. package/docs/ml-dom/element.ja.md +0 -161
  51. package/docs/ml-dom/element.md +0 -161
  52. package/docs/ml-dom/helpers.ja.md +0 -203
  53. package/docs/ml-dom/helpers.md +0 -203
  54. package/docs/ml-dom/node.ja.md +0 -199
  55. package/docs/ml-dom/node.md +0 -199
  56. package/docs/ml-dom/others.ja.md +0 -120
  57. package/docs/ml-dom/others.md +0 -120
  58. package/docs/ml-dom/overview.ja.md +0 -102
  59. package/docs/ml-dom/overview.md +0 -102
  60. package/docs/ml-dom/pretender.ja.md +0 -269
  61. package/docs/ml-dom/pretender.md +0 -269
  62. package/docs/ml-dom/rule-mapping.ja.md +0 -371
  63. package/docs/ml-dom/rule-mapping.md +0 -371
  64. package/docs/ml-dom.ja.md +0 -18
  65. package/docs/ml-dom.md +0 -18
  66. package/docs/rule-system.ja.md +0 -287
  67. package/docs/rule-system.md +0 -287
package/CHANGELOG.md CHANGED
@@ -3,6 +3,43 @@
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.0.0-rc.5](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.4...v5.0.0-rc.5) (2026-08-28)
7
+
8
+ ### Bug Fixes
9
+
10
+ - **ml-core:** look up dedupe contract under both alias and base rule names ([a5dde7e](https://github.com/markuplint/markuplint/commit/a5dde7e23a12a43d976fbfaa1cc3dec67265447e)), closes [#3844](https://github.com/markuplint/markuplint/issues/3844) [#3871](https://github.com/markuplint/markuplint/issues/3871)
11
+ - **ml-core:** re-verify fixed code and detect N-pass fix cycles ([8c96516](https://github.com/markuplint/markuplint/commit/8c96516d8057acdcf06dc279c2183390f0966e98)), closes [#3890](https://github.com/markuplint/markuplint/issues/3890) [#3891](https://github.com/markuplint/markuplint/issues/3891)
12
+ - **ml-core:** stop accumulating duplicate mapping errors across setCode ([2874d5b](https://github.com/markuplint/markuplint/commit/2874d5b60b24ccc501f7dae1f6ab25fb6e51e58b)), closes [#3900](https://github.com/markuplint/markuplint/issues/3900)
13
+ - **pretenders:** resolve same-named components via imports, not scan order ([#3957](https://github.com/markuplint/markuplint/issues/3957)) ([d46a514](https://github.com/markuplint/markuplint/commit/d46a5148c4d7afb156962f4ed795f40a9324e6c5)), closes [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3951](https://github.com/markuplint/markuplint/issues/3951) [#3951](https://github.com/markuplint/markuplint/issues/3951)
14
+ - **rules:** surface disallowed-element reason via reasonOnly (close [#3815](https://github.com/markuplint/markuplint/issues/3815)) ([#3986](https://github.com/markuplint/markuplint/issues/3986)) ([0142cec](https://github.com/markuplint/markuplint/commit/0142cec667f70fee086f2a6e06d7a26e66bda380))
15
+
16
+ ### Code Refactoring
17
+
18
+ - **rules:** redesign v5 rule system — naming, splits, specConformance ([#3989](https://github.com/markuplint/markuplint/issues/3989)) ([e925565](https://github.com/markuplint/markuplint/commit/e925565ce537848d7d1573369723cbce724a841b)), closes [#4](https://github.com/markuplint/markuplint/issues/4) [#aside-conditional-role-mapping-aria-13](https://github.com/markuplint/markuplint/issues/aside-conditional-role-mapping-aria-13)
19
+
20
+ - fix(ml-core)!: reject pretender on standard HTML elements ([0576425](https://github.com/markuplint/markuplint/commit/0576425baf9b78141523bcd0e4a102062b96bc81)), closes [#3740](https://github.com/markuplint/markuplint/issues/3740)
21
+
22
+ ### Features
23
+
24
+ - **ml-core:** hook-based dedupe for parse-error channel via mirrorsParseErrorCodes ([6a36f17](https://github.com/markuplint/markuplint/commit/6a36f17d292a2400494a3670c93cb2f02999b48b)), closes [#3844](https://github.com/markuplint/markuplint/issues/3844)
25
+ - **ml-core:** surface non-fatal parser errors via opt-in parse-error channel ([d715fbe](https://github.com/markuplint/markuplint/commit/d715fbe586c478b98472a570b3175873cf244aef)), closes [#3844](https://github.com/markuplint/markuplint/issues/3844)
26
+
27
+ ### BREAKING CHANGES
28
+
29
+ - **rules:** with no alias coverage.
30
+ - `pretenders` config entries whose selector matches a
31
+ standard HTML element are now ignored. Configurations that previously
32
+ relied on `<marquee as="div">` to suppress markuplint violations must
33
+ remove the entry; the original element is now linted on its own merits.
34
+
35
+ # [5.0.0-rc.4](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.3...v5.0.0-rc.4) (2026-04-19)
36
+
37
+ **Note:** Version bump only for package @markuplint/ml-core
38
+
39
+ # [5.0.0-rc.3](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.2...v5.0.0-rc.3) (2026-04-19)
40
+
41
+ **Note:** Version bump only for package @markuplint/ml-core
42
+
6
43
  # [5.0.0-rc.2](https://github.com/markuplint/markuplint/compare/v5.0.0-rc.1...v5.0.0-rc.2) (2026-04-15)
7
44
 
8
45
  ### Bug Fixes
package/README.md CHANGED
@@ -5,11 +5,6 @@
5
5
  ## Documentation
6
6
 
7
7
  - [API Document](https://markuplint.dev/api-docs)
8
- - [Architecture](ARCHITECTURE.md) ([日本語](ARCHITECTURE.ja.md)) — Package overview, MLDOM hierarchy, rule system, and linting pipeline
9
- - [MLDOM Reference](docs/ml-dom.md) ([日本語](docs/ml-dom.ja.md)) — Class hierarchy, node properties, tree traversal
10
- - [Rule System](docs/rule-system.md) ([日本語](docs/rule-system.ja.md)) — MLRule, RuleSeed, MLRuleContext, configuration resolution
11
- - [Linting Pipeline](docs/linting-pipeline.md) ([日本語](docs/linting-pipeline.ja.md)) — MLCore engine, verify flow, pretender, plugin system
12
- - [Maintenance Guide](docs/maintenance.md) ([日本語](docs/maintenance.ja.md)) — Commands, recipes, and troubleshooting
13
8
 
14
9
  ## Install
15
10
 
@@ -15,16 +15,13 @@ export function computeCursorOffset(appliedEdits, cursorOffset) {
15
15
  const [start, end] = edit.range;
16
16
  const delta = edit.text.length - (end - start);
17
17
  if (start > cursorOffset) {
18
- // Edit is after cursor — no effect
19
18
  break;
20
19
  }
21
20
  if (end <= cursorOffset) {
22
- // Edit is entirely before cursor — shift by delta.
23
21
  // Range is half-open [start, end), so cursor at `end` is outside the edit.
24
22
  newOffset += delta;
25
23
  }
26
24
  else {
27
- // Cursor falls inside the replaced range [start, end)
28
25
  newOffset = start + edit.text.length;
29
26
  break;
30
27
  }
@@ -19,20 +19,18 @@ export function applyFixes(sourceCode, fixes) {
19
19
  if (fixes.length === 0) {
20
20
  return { output: sourceCode, applied: [], skipped: [], appliedEdits: [] };
21
21
  }
22
- // Tag each edit with its parent FixData index
23
22
  const taggedEdits = [];
24
23
  for (const [i, fix] of fixes.entries()) {
25
24
  for (const edit of fix.edits) {
26
25
  taggedEdits.push({ edit, fixIndex: i });
27
26
  }
28
27
  }
29
- // Sort: range[0] ascending, then range[1] descending (so larger ranges come first at the same start)
28
+ // Ties broken by range[1] descending so larger ranges come first at the same start.
30
29
  taggedEdits.sort((a, b) => {
31
30
  const startDiff = a.edit.range[0] - b.edit.range[0];
32
31
  // eslint-disable-next-line @typescript-eslint/strict-boolean-expressions
33
32
  return startDiff || b.edit.range[1] - a.edit.range[1];
34
33
  });
35
- // Track which FixData indices had at least one skipped edit
36
34
  const skippedFixIndices = new Set();
37
35
  const appliedEdits = [];
38
36
  let lastAppliedEnd = -1;
@@ -40,22 +38,18 @@ export function applyFixes(sourceCode, fixes) {
40
38
  let cursor = 0;
41
39
  for (const { edit, fixIndex } of taggedEdits) {
42
40
  const [start, end] = edit.range;
43
- // Overlap check: if this edit starts before the end of the last applied edit, skip it.
44
- // Also skip if a sibling edit from the same FixData was already skipped —
45
- // edits within a single FixData are atomic (all-or-nothing).
41
+ // Edits within a single FixData are atomic (all-or-nothing): if any sibling
42
+ // was skipped, skip the rest.
46
43
  if (start < lastAppliedEnd || skippedFixIndices.has(fixIndex)) {
47
44
  skippedFixIndices.add(fixIndex);
48
45
  continue;
49
46
  }
50
- // Append the source text between the last edit and this one
51
47
  parts.push(sourceCode.slice(cursor, start), edit.text);
52
48
  appliedEdits.push(edit);
53
49
  cursor = end;
54
50
  lastAppliedEnd = end;
55
51
  }
56
- // Append remaining source text
57
52
  parts.push(sourceCode.slice(cursor));
58
- // Classify FixData as applied or skipped
59
53
  const applied = [];
60
54
  const skipped = [];
61
55
  for (const [i, fix] of fixes.entries()) {
package/lib/ml-core.d.ts CHANGED
@@ -22,6 +22,20 @@ export type FixSummary = {
22
22
  * multiple passes are executed.
23
23
  */
24
24
  readonly firstPassEdits: readonly TextEdit[];
25
+ /**
26
+ * Violations remaining in the final fixed code, re-verified after the
27
+ * last fix pass.
28
+ *
29
+ * Unlike {@link VerifyResult.violations} (which reflects the first pass
30
+ * only), this list is accurate for `fixedCode`. Callers that report
31
+ * post-fix results should prefer `fixSummary.finalPassViolations ?? violations`.
32
+ * Entries may carry `fix` data whose offsets refer to `fixedCode`.
33
+ *
34
+ * `undefined` when no fixes remain applied (none were applied, or the
35
+ * applied pass was rolled back); the first-pass violations are then
36
+ * accurate as-is.
37
+ */
38
+ readonly finalPassViolations?: readonly Violation[];
25
39
  };
26
40
  /**
27
41
  * Options for {@link MLCore.verify}.
@@ -77,7 +91,7 @@ export declare class MLCore {
77
91
  *
78
92
  * @param fabric - Partial fabric with the properties to update
79
93
  */
80
- update({ parser, ruleset, rules, locale, schemas, parserOptions, configErrors }: Partial<MLFabric>): void;
94
+ update({ parser, ruleset, rules, locale, schemas, parserOptions, pretenders, configErrors }: Partial<MLFabric>): void;
81
95
  /**
82
96
  * Runs all configured rules against the parsed document and returns violations.
83
97
  *
@@ -91,7 +105,9 @@ export declare class MLCore {
91
105
  * may be the result of multiple fix passes. This means some violations in the
92
106
  * array may already be resolved in `fixedCode`, and new violations introduced
93
107
  * during later passes are not included in the array. Callers needing an accurate
94
- * violation list for the fixed code should re-verify the output.
108
+ * violation list for the fixed code should use `fixSummary.finalPassViolations`,
109
+ * which is re-verified against the final code whenever at least one fix was
110
+ * applied.
95
111
  *
96
112
  * @param fixOrOptions - Whether to attempt auto-fixing violations, or an options object
97
113
  * @returns Violations from the initial analysis and the (possibly fixed) source code
package/lib/ml-core.js CHANGED
@@ -24,7 +24,17 @@ export class MLCore {
24
24
  #schemas;
25
25
  #ruleCommonSettings;
26
26
  #sourceCode;
27
+ /**
28
+ * Config-time errors (named-rule expansion, the `configErrors` fabric input).
29
+ * Set once per construction/`update()` and never mutated by re-parsing.
30
+ */
27
31
  #configErrors;
32
+ /**
33
+ * Rule-mapping errors for the CURRENT document. Reset (not accumulated) on
34
+ * every `#createDocument()` so repeated `setCode()` calls don't duplicate
35
+ * them. See https://github.com/markuplint/markuplint/issues/3900.
36
+ */
37
+ #mappingErrors = [];
28
38
  /**
29
39
  * Pre-expansion nodeRules preserved for hot-reload.
30
40
  * When `update()` is called without a new ruleset, these are used as the
@@ -33,10 +43,6 @@ export class MLCore {
33
43
  */
34
44
  #originalNodeRules;
35
45
  #originalChildNodeRules;
36
- /**
37
- * Pre-computed namespace prefixes from wildcard disable entries.
38
- * e.g., `rules["a11y/*"]: false` yields `"a11y/"`.
39
- */
40
46
  #disabledNamespaces;
41
47
  constructor({ parser, sourceCode, ruleset, rules, locale, schemas, ruleCommonSettings, parserOptions, severity, pretenders, filename, debug, configErrors, }) {
42
48
  if (debug) {
@@ -55,9 +61,7 @@ export class MLCore {
55
61
  // Preserve pre-expansion nodeRules for hot-reload
56
62
  this.#originalNodeRules = ruleset.nodeRules ?? [];
57
63
  this.#originalChildNodeRules = ruleset.childNodeRules ?? [];
58
- // Expand named rule groups in the rules section
59
64
  const namedRulesResult = expandNamedRules(ruleset.rules ?? {}, rules);
60
- // Expand named nodeRules into virtual rules (using expanded rules as base)
61
65
  const allRulesForExpansion = [...rules, ...namedRulesResult.virtualRules];
62
66
  const nodeRuleResult = expandNamedNodeRules(this.#originalNodeRules, allRulesForExpansion);
63
67
  const childNodeRuleResult = expandNamedNodeRules(this.#originalChildNodeRules, allRulesForExpansion);
@@ -97,22 +101,20 @@ export class MLCore {
97
101
  *
98
102
  * @param fabric - Partial fabric with the properties to update
99
103
  */
100
- update({ parser, ruleset, rules, locale, schemas, parserOptions, configErrors }) {
104
+ update({ parser, ruleset, rules, locale, schemas, parserOptions, pretenders, configErrors }) {
101
105
  this.#parser = parser ?? this.#parser;
102
106
  this.#locale = locale ?? this.#locale;
103
107
  this.#schemas = schemas ?? this.#schemas;
108
+ this.#pretenders = pretenders ? [...pretenders] : this.#pretenders;
104
109
  this.#configErrors = [...(configErrors ?? [])];
105
110
  const baseRules = rules ? [...rules] : this.#rules.filter(r => !r.baseRuleId);
106
- // Use pre-expansion originals as fallback when ruleset is not provided
107
111
  const incomingNodeRules = ruleset?.nodeRules ?? this.#originalNodeRules;
108
112
  const incomingChildNodeRules = ruleset?.childNodeRules ?? this.#originalChildNodeRules;
109
- // Expand named rule groups in the rules section
110
113
  const incomingRules = ruleset?.rules ?? this.#ruleset.rules;
111
114
  const namedRulesResult = expandNamedRules(incomingRules, baseRules);
112
115
  const allRulesForExpansion = [...baseRules, ...namedRulesResult.virtualRules];
113
116
  const nodeRuleResult = expandNamedNodeRules(incomingNodeRules, allRulesForExpansion);
114
117
  const childNodeRuleResult = expandNamedNodeRules(incomingChildNodeRules, allRulesForExpansion);
115
- // Update originals if new data was provided
116
118
  if (ruleset?.nodeRules) {
117
119
  this.#originalNodeRules = ruleset.nodeRules;
118
120
  }
@@ -151,19 +153,23 @@ export class MLCore {
151
153
  log('verify: error %o', this.#document.message);
152
154
  return { violations, fixedCode: fix ? this.#sourceCode : undefined };
153
155
  }
156
+ this.#pushNonFatalParseErrors(violations);
154
157
  const definedRuleName = new Set(this.#rules.map(rule => rule.name));
155
158
  const setRuleNames = new Set([
156
159
  ...Object.keys(this.#ruleset.rules),
157
160
  ...this.#ruleset.nodeRules.flatMap(nodeRule => Object.keys(nodeRule.rules ?? {})),
158
161
  ...this.#ruleset.childNodeRules.flatMap(childNodeRule => Object.keys(childNodeRule.rules ?? {})),
159
162
  ]);
163
+ // Config-level violations are independent of the source code, so they are
164
+ // collected separately to also be included in `finalPassViolations`.
165
+ const configViolations = [];
160
166
  for (const setRuleName of setRuleNames) {
161
167
  // Skip wildcard patterns (e.g., "a11y/*") — they are namespace disable entries, not rule references
162
168
  if (setRuleName.endsWith('/*')) {
163
169
  continue;
164
170
  }
165
171
  if (!definedRuleName.has(setRuleName)) {
166
- violations.push({
172
+ configViolations.push({
167
173
  ruleId: 'config-error',
168
174
  severity: 'warning',
169
175
  message: `Rule not found: ${setRuleName}`,
@@ -173,8 +179,8 @@ export class MLCore {
173
179
  });
174
180
  }
175
181
  }
176
- for (const error of this.#configErrors) {
177
- violations.push({
182
+ for (const error of [...this.#configErrors, ...this.#mappingErrors]) {
183
+ configViolations.push({
178
184
  ruleId: 'config-error',
179
185
  severity: 'warning',
180
186
  message: error.message,
@@ -183,6 +189,7 @@ export class MLCore {
183
189
  raw: '',
184
190
  });
185
191
  }
192
+ violations.push(...configViolations);
186
193
  const ruleViolations = await this.#runAllRules(fix);
187
194
  violations.push(...ruleViolations);
188
195
  if (resultLog.enabled) {
@@ -200,7 +207,6 @@ export class MLCore {
200
207
  resultLog('Warning: %d', w);
201
208
  resultLog('Info: %d', i);
202
209
  }
203
- // Apply fixes if enabled
204
210
  let fixedCode;
205
211
  let fixSummary;
206
212
  if (fix) {
@@ -209,16 +215,28 @@ export class MLCore {
209
215
  const originalSourceCode = this.#sourceCode;
210
216
  const originalAst = this.#ast;
211
217
  const originalDocument = this.#document;
218
+ const originalMappingErrors = this.#mappingErrors;
212
219
  try {
213
220
  const fixResult = await this.#multiPassFix(violations);
214
221
  fixedCode = fixResult.code;
215
222
  fixSummary = fixResult.summary;
223
+ if (configViolations.length > 0 && fixSummary.finalPassViolations) {
224
+ // Config-level violations persist regardless of fixing;
225
+ // keep them visible in the post-fix violation list.
226
+ fixSummary = {
227
+ ...fixSummary,
228
+ finalPassViolations: [...configViolations, ...fixSummary.finalPassViolations],
229
+ };
230
+ }
216
231
  }
217
232
  finally {
218
- // Restore original state - verify() must be non-mutating
233
+ // Restore original state - verify() must be non-mutating.
234
+ // The fix loop re-parses, and each #createDocument resets
235
+ // #mappingErrors; restore it to the pre-fix document's.
219
236
  this.#sourceCode = originalSourceCode;
220
237
  this.#ast = originalAst;
221
238
  this.#document = originalDocument;
239
+ this.#mappingErrors = originalMappingErrors;
222
240
  }
223
241
  }
224
242
  else {
@@ -236,6 +254,10 @@ export class MLCore {
236
254
  return { violations, fixedCode, fixSummary };
237
255
  }
238
256
  #createDocument() {
257
+ // Reset up front: mapping errors belong to the document being (re)built,
258
+ // so a failed parse or build must not leave a previous document's errors
259
+ // behind. Repopulated below only on a successful build. See #3900.
260
+ this.#mappingErrors = [];
239
261
  if (!this.#ast) {
240
262
  return;
241
263
  }
@@ -247,8 +269,11 @@ export class MLCore {
247
269
  tagNameCaseSensitive: this.#parser.tagNameCaseSensitive,
248
270
  pretenders: this.#pretenders,
249
271
  });
250
- // Collect errors from rule mapping (e.g., invalid wildcard usage)
251
- this.#configErrors.push(...this.#ruleset.mappingErrors);
272
+ // Collect errors from rule mapping (e.g., invalid wildcard usage).
273
+ // Reset rather than append: the Document constructor regenerates
274
+ // `mappingErrors` on every call, so accumulating them would duplicate
275
+ // the same errors on each re-parse (e.g. via setCode). See #3900.
276
+ this.#mappingErrors = [...this.#ruleset.mappingErrors];
252
277
  this.#ruleset.mappingErrors.length = 0;
253
278
  }
254
279
  catch (error) {
@@ -260,14 +285,128 @@ export class MLCore {
260
285
  }
261
286
  }
262
287
  }
263
- #createParseError(message, line, col, raw) {
264
- if (this.#severity.parseError === false || this.#severity.parseError === 'off') {
288
+ /**
289
+ * Surfaces non-fatal parser conformance errors collected by the underlying
290
+ * parser (e.g., parse5's `onParseError` events on `MLASTDocument.parseErrors`)
291
+ * via the same `parse-error` violation channel as fatal `ParserError`s.
292
+ *
293
+ * Order contract (relied on by every rule spec under
294
+ * `@markuplint/rules/src/**`): each `parseErrors` entry is pushed in the
295
+ * order the parser emitted it, *before* any rule iteration runs. Tests
296
+ * that match on `toStrictEqual([...])` depend on this position.
297
+ *
298
+ * Severity resolution:
299
+ *
300
+ * - `severity.parseError` is a `Partial<Record<MLASTParseErrorCode, …>>` →
301
+ * each entry's `code` looks up its own severity; codes absent from the
302
+ * record default to `'off'` (suppressed).
303
+ * - `severity.parseError` is a single severity string/boolean → applied
304
+ * uniformly to every entry.
305
+ * - `severity.parseError` is unset → **all non-fatal codes are off**.
306
+ * Users must opt in explicitly.
307
+ *
308
+ * @param violations The verify-time violations array to mutate.
309
+ */
310
+ #pushNonFatalParseErrors(violations) {
311
+ const parseErrors = this.#ast?.parseErrors;
312
+ if (!parseErrors) {
313
+ return;
314
+ }
315
+ // Build the Set of parse5 codes that an active ml rule has claimed
316
+ // responsibility for via `meta.mirrorsParseErrorCodes`. The user's
317
+ // ruleset decides whether each rule's declaration is in scope:
318
+ //
319
+ // - Rule **set** in the ruleset (config !== undefined) — whatever the
320
+ // value (`true`, `false`, severity, object) — the user has expressed
321
+ // intent about this check. Honour the mirror declaration:
322
+ // - active: the rule itself will report violations for those codes
323
+ // - disabled (`false`): the user explicitly opted out, so the channel
324
+ // stays silent too — no surprise re-surfacing
325
+ // - Rule **not mentioned** (config === undefined) — pure default. The
326
+ // parse-error channel remains the channel of record for those codes
327
+ // and surfaces them when the user has opted in via `severity.parseError`.
328
+ //
329
+ // This keeps the responsibility clean: rule packages declare what they
330
+ // cover (static metadata); ml-core honours the user's ruleset choice;
331
+ // no per-node logic, no hard-coded code→rule map.
332
+ const mirroredCodes = new Set();
333
+ for (const rule of this.#rules) {
334
+ if (rule.mirrorsParseErrorCodes.length === 0) {
335
+ continue;
336
+ }
337
+ // The rule is "mentioned in the ruleset" if **either** the alias
338
+ // name OR the base rule name has an entry. Two entry styles exist:
339
+ //
340
+ // - Direct user configs use base rule names: `rules.attr-duplication`
341
+ // - Preset named nodeRules use alias names: `rules['html-standard/attr-duplication']`
342
+ //
343
+ // `MLRule` for a preset-aliased entry has `rule.name = 'html-standard/...'`
344
+ // and `rule.baseRuleId = 'attr-duplication'`; for a direct entry,
345
+ // `rule.name = 'attr-duplication'` and `baseRuleId` is undefined.
346
+ // Checking both names covers both styles.
347
+ const aliasConfig = this.#ruleset.rules[rule.name];
348
+ const baseConfig = rule.baseRuleId === undefined ? undefined : this.#ruleset.rules[rule.baseRuleId];
349
+ if (aliasConfig === undefined && baseConfig === undefined) {
350
+ continue;
351
+ }
352
+ for (const code of rule.mirrorsParseErrorCodes) {
353
+ mirroredCodes.add(code);
354
+ }
355
+ }
356
+ for (const parserError of parseErrors) {
357
+ if (mirroredCodes.has(parserError.code)) {
358
+ continue;
359
+ }
360
+ const violation = this.#createParseError(`Parser conformance error: ${parserError.code}`, parserError.startLine, parserError.startCol, parserError.raw, parserError.code);
361
+ if (violation) {
362
+ violations.push(violation);
363
+ }
364
+ }
365
+ }
366
+ /**
367
+ * Builds a `ruleId: 'parse-error'` violation, honouring
368
+ * `severity.parseError`.
369
+ *
370
+ * If `code` is supplied (non-fatal `parseErrors` entry) and the option is
371
+ * a `Partial<Record<code, severity>>`, the per-code value is used (absent
372
+ * codes default to `'off'`). If the option is unset, non-fatal entries
373
+ * are suppressed (opt-in only) while fatal errors (no `code`) still
374
+ * default to `'error'`.
375
+ *
376
+ * @returns the violation, or `null` if suppressed.
377
+ */
378
+ #createParseError(message, line, col, raw, code) {
379
+ const cfg = this.#severity.parseError;
380
+ if (cfg === false || cfg === 'off') {
265
381
  return null;
266
382
  }
267
- // Default severity is 'error'
268
- const severity = this.#severity.parseError === true || this.#severity.parseError == null
269
- ? 'error'
270
- : this.#severity.parseError;
383
+ let severity;
384
+ if (typeof cfg === 'object') {
385
+ if (code == null) {
386
+ // Fatal ParserError without a code; the Record form cannot target
387
+ // it, so fall back to `'error'` (the channel is otherwise enabled).
388
+ severity = 'error';
389
+ }
390
+ else {
391
+ const perCode = cfg[code];
392
+ if (perCode == null || perCode === false || perCode === 'off') {
393
+ return null;
394
+ }
395
+ severity = perCode === true ? 'error' : perCode;
396
+ }
397
+ }
398
+ else if (cfg == null) {
399
+ // New default: non-fatal `parseErrors` entries are off; fatal
400
+ // `ParserError`s (no code) still emit at `'error'`.
401
+ if (code != null) {
402
+ return null;
403
+ }
404
+ severity = 'error';
405
+ }
406
+ else {
407
+ // Uniform string/boolean form (legacy).
408
+ severity = cfg === true ? 'error' : cfg;
409
+ }
271
410
  return {
272
411
  ruleId: 'parse-error',
273
412
  severity,
@@ -278,23 +417,29 @@ export class MLCore {
278
417
  };
279
418
  }
280
419
  /**
281
- * Iteratively applies fixes, re-parses, and re-verifies until no overlapping
282
- * fixes remain or the maximum pass count is reached (ESLint-style multi-pass loop).
420
+ * ESLint-style multi-pass fix loop (modeled after SourceCodeFixer).
283
421
  *
284
422
  * **Callers must save/restore `#sourceCode`, `#ast`, and `#document`** because
285
423
  * this method mutates them during intermediate re-parse steps.
286
- *
287
- * @param initialViolations - Violations from the first verification pass
288
- * @returns The final fixed source code and a summary of the fix process
289
424
  */
290
425
  async #multiPassFix(initialViolations) {
426
+ // Same safety cap as ESLint's SourceCodeFixer (10 passes).
291
427
  const MAX_FIX_PASSES = 10;
292
428
  let currentCode = this.#sourceCode;
293
- let previousCode;
294
429
  let fixes = extractFixes(initialViolations);
295
430
  let totalApplied = 0;
296
431
  let totalSkipped = 0;
297
432
  let firstPassEdits = [];
433
+ // The input code of each pass, keyed by code string, for N-pass cycle
434
+ // detection (A → B → A as well as longer cycles such as A → B → C → A).
435
+ const codeHistory = new Map([[currentCode, 0]]);
436
+ // Violations from the latest #runAllRules call, valid for `currentCode`.
437
+ // Reused by the final verification below to avoid a redundant re-run
438
+ // when the loop already re-verified the final code.
439
+ let latestViolations;
440
+ // The most recent code that is known to parse successfully. Used to roll
441
+ // back a pass whose output fails to parse.
442
+ let lastParsableCode = currentCode;
298
443
  let pass = 0;
299
444
  for (; pass < MAX_FIX_PASSES; pass++) {
300
445
  log('fix pass %d: %d fixes', pass, fixes.length);
@@ -312,31 +457,42 @@ export class MLCore {
312
457
  log('fix pass %d: output unchanged, stopping', pass);
313
458
  break;
314
459
  }
315
- // Cycle detection: if the output matches the code from two passes ago,
316
- // fixes are oscillating (A → B → A) and will never converge.
317
- if (previousCode !== undefined && result.output === previousCode) {
318
- log('fix pass %d: cycle detected (output matches pass %d), stopping', pass, pass - 2);
460
+ // Cycle detection: if the output matches the input of any earlier pass,
461
+ // fixes are oscillating (A → B → A, A → B → C → A, ...) and will never converge.
462
+ const cycleStart = codeHistory.get(result.output);
463
+ if (cycleStart !== undefined) {
464
+ log('fix pass %d: cycle detected (output matches the input of pass %d, cycle length %d), stopping', pass, cycleStart, pass + 1 - cycleStart);
319
465
  currentCode = result.output;
466
+ latestViolations = undefined;
320
467
  break;
321
468
  }
322
- previousCode = currentCode;
323
469
  currentCode = result.output;
324
- if (result.skipped.length === 0) {
325
- log('fix pass %d: all fixes applied, stopping', pass);
326
- break;
327
- }
328
- // --- Multi-pass path (only when overlapping fixes exist) ---
329
- log('fix pass %d: %d skipped, re-parsing for next pass', pass, result.skipped.length);
330
- const previousGoodCode = currentCode;
470
+ codeHistory.set(currentCode, pass + 1);
471
+ latestViolations = undefined;
472
+ // Parse the output immediately — for the next pass or for the final
473
+ // verification — so an unparsable output is always rolled back
474
+ // instead of being returned (and written to disk) broken.
331
475
  this.#sourceCode = currentCode;
332
476
  this.#parse();
333
477
  this.#createDocument();
334
478
  if (this.#document instanceof ParserError) {
335
- log('fix pass %d: produced unparsable code, reverting to previous state', pass);
336
- currentCode = previousGoodCode;
479
+ log('fix pass %d: produced unparsable code, reverting to the previous parsable code', pass);
480
+ currentCode = lastParsableCode;
481
+ totalApplied -= result.applied.length;
482
+ if (pass === 0) {
483
+ firstPassEdits = [];
484
+ }
485
+ break;
486
+ }
487
+ lastParsableCode = currentCode;
488
+ if (result.skipped.length === 0) {
489
+ log('fix pass %d: all fixes applied, stopping', pass);
337
490
  break;
338
491
  }
492
+ // --- Multi-pass path (only when overlapping fixes exist) ---
493
+ log('fix pass %d: %d skipped, re-running rules for next pass', pass, result.skipped.length);
339
494
  const newViolations = await this.#runAllRules(true);
495
+ latestViolations = newViolations;
340
496
  fixes = extractFixes(newViolations);
341
497
  if (fixes.length === 0) {
342
498
  log('fix pass %d: no more fixable violations, stopping', pass);
@@ -347,6 +503,29 @@ export class MLCore {
347
503
  if (reachedMaxPasses) {
348
504
  log('fix: reached maximum number of passes (%d), some fixes may not have been applied', MAX_FIX_PASSES);
349
505
  }
506
+ // Final verification (#3890): compute the violations that remain in the
507
+ // final code. When the loop already re-verified `currentCode`, reuse that
508
+ // result; otherwise re-run rules once (re-parsing first unless the parsed
509
+ // state already corresponds to `currentCode`).
510
+ let finalPassViolations;
511
+ if (totalApplied > 0) {
512
+ if (latestViolations === undefined) {
513
+ if (this.#sourceCode !== currentCode) {
514
+ this.#sourceCode = currentCode;
515
+ this.#parse();
516
+ this.#createDocument();
517
+ }
518
+ if (!(this.#document instanceof ParserError)) {
519
+ latestViolations = await this.#runAllRules(true);
520
+ }
521
+ }
522
+ if (latestViolations !== undefined) {
523
+ const collected = [];
524
+ this.#pushNonFatalParseErrors(collected);
525
+ collected.push(...latestViolations);
526
+ finalPassViolations = collected;
527
+ }
528
+ }
350
529
  return {
351
530
  code: currentCode,
352
531
  summary: {
@@ -355,16 +534,10 @@ export class MLCore {
355
534
  totalSkipped,
356
535
  reachedMaxPasses,
357
536
  firstPassEdits,
537
+ finalPassViolations,
358
538
  },
359
539
  };
360
540
  }
361
- /**
362
- * Executes all configured rules against the current document and collects violations.
363
- * Skips disabled rules and handles virtual rule disable conditions.
364
- *
365
- * @param fix - Whether to execute fix callbacks on violations
366
- * @returns All violations produced by the rule set
367
- */
368
541
  async #runAllRules(fix) {
369
542
  const violations = [];
370
543
  if (this.#document instanceof ParserError) {
@@ -424,17 +597,12 @@ export class MLCore {
424
597
  }
425
598
  }
426
599
  }
427
- /**
428
- * Extracts namespace prefixes from wildcard disable entries in rules.
429
- * e.g., `{ "a11y/*": false }` yields `["a11y/"]`.
430
- */
431
600
  function extractDisabledNamespaces(rules) {
432
601
  return Object.entries(rules)
433
602
  .filter(([key, value]) => key.endsWith('/*') && value === false)
434
603
  .map(([key]) => key.slice(0, -1)); // "a11y/*" → "a11y/"
435
604
  }
436
605
  /**
437
- * Builds a mapping from base rule names to virtual rule names.
438
606
  * Used by nodeRules/childNodeRules to propagate settings (especially `false`)
439
607
  * to virtual rules created by NamedRuleGroups.
440
608
  */
@@ -456,12 +624,6 @@ virtualRules) {
456
624
  }
457
625
  return map;
458
626
  }
459
- /**
460
- * Collects all `FixData` from violations that have a fix callback result.
461
- *
462
- * @param violations - The violations to extract fixes from
463
- * @returns An array of `FixData` objects ready for `applyFixes()`
464
- */
465
627
  function extractFixes(violations) {
466
628
  const fixes = [];
467
629
  for (const v of violations) {
@@ -1,11 +1,3 @@
1
1
  import type { MLElement } from '../node/element.js';
2
2
  import type { ARIAVersion } from '@markuplint/ml-spec';
3
- /**
4
- * Computes the accessible name for an MLElement using the HTML-AAM algorithm.
5
- * Creates an MLCore-specific resolver that bridges MLElement to the AccnameResolver interface.
6
- *
7
- * @param el - The MLElement to compute the accessible name for
8
- * @param version - The ARIA specification version to use for role resolution
9
- * @returns The computed accessible name string, or an empty string on error
10
- */
11
3
  export declare function getAccname(el: MLElement<any, any>, version: ARIAVersion): string;