@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.
- package/CHANGELOG.md +37 -0
- package/README.md +0 -5
- package/lib/cursor-offset.js +0 -3
- package/lib/fix-applier.js +3 -9
- package/lib/ml-core.d.ts +18 -2
- package/lib/ml-core.js +223 -61
- package/lib/ml-dom/helper/accname.d.ts +0 -8
- package/lib/ml-dom/helper/accname.js +7 -10
- package/lib/ml-dom/node/attr.js +3 -1
- package/lib/ml-dom/node/block.d.ts +6 -0
- package/lib/ml-dom/node/block.js +6 -0
- package/lib/ml-dom/node/child-node.d.ts +0 -9
- package/lib/ml-dom/node/child-node.js +0 -9
- package/lib/ml-dom/node/document.d.ts +20 -1
- package/lib/ml-dom/node/document.js +24 -13
- package/lib/ml-dom/node/element-close-tag.d.ts +12 -0
- package/lib/ml-dom/node/element-close-tag.js +12 -0
- package/lib/ml-dom/node/element.d.ts +22 -0
- package/lib/ml-dom/node/element.js +37 -11
- package/lib/ml-dom/node/node-store.d.ts +0 -3
- package/lib/ml-dom/node/node-store.js +0 -3
- package/lib/ml-dom/node/node.d.ts +34 -1
- package/lib/ml-dom/node/node.js +34 -16
- package/lib/ml-dom/node/parent-node.js +0 -6
- package/lib/ml-dom/node/rule-mapper.d.ts +8 -0
- package/lib/ml-dom/node/rule-mapper.js +8 -0
- package/lib/ml-rule/ml-rule.d.ts +19 -0
- package/lib/ml-rule/ml-rule.js +38 -7
- package/lib/ml-rule/types.d.ts +110 -1
- package/lib/ml-rule/types.js +28 -1
- package/lib/ruleset/index.d.ts +2 -1
- package/lib/ruleset/index.js +2 -1
- package/lib/test/index.js +1 -1
- package/lib/virtual-rule.d.ts +10 -0
- package/lib/virtual-rule.js +1 -24
- package/package.json +14 -14
- package/ARCHITECTURE.ja.md +0 -676
- package/ARCHITECTURE.md +0 -726
- package/SKILL.md +0 -61
- package/docs/linting-pipeline.ja.md +0 -307
- package/docs/linting-pipeline.md +0 -307
- package/docs/maintenance.ja.md +0 -210
- package/docs/maintenance.md +0 -210
- package/docs/ml-dom/attr.ja.md +0 -103
- package/docs/ml-dom/attr.md +0 -103
- package/docs/ml-dom/block.ja.md +0 -272
- package/docs/ml-dom/block.md +0 -272
- package/docs/ml-dom/document.ja.md +0 -134
- package/docs/ml-dom/document.md +0 -134
- package/docs/ml-dom/element.ja.md +0 -161
- package/docs/ml-dom/element.md +0 -161
- package/docs/ml-dom/helpers.ja.md +0 -203
- package/docs/ml-dom/helpers.md +0 -203
- package/docs/ml-dom/node.ja.md +0 -199
- package/docs/ml-dom/node.md +0 -199
- package/docs/ml-dom/others.ja.md +0 -120
- package/docs/ml-dom/others.md +0 -120
- package/docs/ml-dom/overview.ja.md +0 -102
- package/docs/ml-dom/overview.md +0 -102
- package/docs/ml-dom/pretender.ja.md +0 -269
- package/docs/ml-dom/pretender.md +0 -269
- package/docs/ml-dom/rule-mapping.ja.md +0 -371
- package/docs/ml-dom/rule-mapping.md +0 -371
- package/docs/ml-dom.ja.md +0 -18
- package/docs/ml-dom.md +0 -18
- package/docs/rule-system.ja.md +0 -287
- 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
|
|
package/lib/cursor-offset.js
CHANGED
|
@@ -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
|
}
|
package/lib/fix-applier.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
//
|
|
44
|
-
//
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
264
|
-
|
|
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
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
*
|
|
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
|
|
316
|
-
// fixes are oscillating (A → B → A) and will never converge.
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
//
|
|
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
|
|
336
|
-
currentCode =
|
|
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;
|