Skip to content

Commit f814f0b

Browse files
authored
Merge pull request #160: feat: add auto-fix suggestions to validation summary
feat: add auto-fix suggestions to validation summary
2 parents 0a30bac + 00508af commit f814f0b

File tree

4 files changed

+168
-30
lines changed

4 files changed

+168
-30
lines changed

.changeset/validation-auto-fix.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'helixir': minor
3+
'@helixir/core': minor
4+
---
5+
6+
feat: add auto-fix suggestions to validation summary — agents get corrected code alongside each issue

packages/core/src/handlers/validation-summary.ts

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
*/
1010

1111
import type { ValidateComponentCodeResult } from './code-validator.js';
12+
import { suggestFix } from './suggest-fix.js';
1213

1314
// ─── Types ───────────────────────────────────────────────────────────────────
1415

@@ -20,6 +21,8 @@ export interface PrioritizedIssue {
2021
message: string;
2122
line?: number;
2223
suggestion?: string;
24+
/** Auto-generated corrected code when the issue has enough data for a fix. */
25+
fix?: string;
2326
}
2427

2528
export interface ValidationSummary {
@@ -48,12 +51,14 @@ export function summarizeValidation(result: ValidateComponentCodeResult): Valida
4851
// ERROR-level: will break at runtime
4952
if (result.shadowDom) {
5053
for (const issue of result.shadowDom.issues) {
54+
const fix = tryAutoFix('shadow-dom', mapRuleToIssue(issue.rule), issue.code);
5155
issues.push({
5256
severity: 'error',
5357
category: 'shadowDom',
5458
message: issue.message,
5559
line: issue.line,
5660
suggestion: issue.suggestion,
61+
...(fix ? { fix } : {}),
5762
});
5863
}
5964
}
@@ -117,11 +122,14 @@ export function summarizeValidation(result: ValidateComponentCodeResult): Valida
117122

118123
if (result.tokenFallbacks) {
119124
for (const issue of result.tokenFallbacks.issues) {
125+
const original = `${issue.property}: ${issue.value};`;
126+
const fix = tryAutoFix('token-fallback', issue.rule, original, issue.property);
120127
issues.push({
121128
severity: 'warning',
122129
category: 'tokenFallbacks',
123130
message: issue.message,
124131
line: issue.line,
132+
...(fix ? { fix } : {}),
125133
});
126134
}
127135
}
@@ -281,6 +289,54 @@ export function summarizeValidation(result: ValidateComponentCodeResult): Valida
281289
};
282290
}
283291

292+
// ─── Auto-Fix Helpers ───────────────────────────────────────────────────────
293+
294+
/**
295+
* Maps shadow-dom-checker rule names to suggest_fix issue types.
296+
*/
297+
function mapRuleToIssue(rule: string): string {
298+
const ruleMap: Record<string, string> = {
299+
'no-descendant-piercing': 'descendant-piercing',
300+
'no-direct-element-styling': 'direct-element-styling',
301+
'no-deprecated-deep': 'deprecated-deep',
302+
'no-part-structural': 'part-structural',
303+
'no-part-chain': 'part-chain',
304+
'no-display-contents-host': 'display-contents-host',
305+
'no-external-host': 'external-host',
306+
'no-slotted-descendant': 'slotted-descendant',
307+
'no-slotted-compound': 'slotted-compound',
308+
};
309+
return ruleMap[rule] ?? rule;
310+
}
311+
312+
/**
313+
* Attempts to generate an auto-fix for a validation issue.
314+
* Returns the corrected code string, or undefined if no fix is possible.
315+
*/
316+
function tryAutoFix(
317+
type: string,
318+
issue: string,
319+
original?: string,
320+
property?: string,
321+
): string | undefined {
322+
if (!original) return undefined;
323+
try {
324+
const result = suggestFix({
325+
type: type as 'shadow-dom' | 'token-fallback' | 'theme-compat' | 'specificity' | 'layout',
326+
issue,
327+
original,
328+
...(property ? { property } : {}),
329+
});
330+
// Only return fix if it's actually different from the original
331+
if (result.suggestion !== original) {
332+
return result.suggestion;
333+
}
334+
} catch {
335+
// suggestFix failed — no auto-fix available
336+
}
337+
return undefined;
338+
}
339+
284340
// ─── Verdict Builder ────────────────────────────────────────────────────────
285341

286342
function buildVerdict(errors: number, warnings: number, info: number): string {

tests/__fixtures__/benchmark-results/latest-benchmark.json

Lines changed: 30 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"timestamp": "2026-03-21T06:42:44.161Z",
2+
"timestamp": "2026-03-21T06:51:21.834Z",
33
"scorecards": {
44
"material": {
55
"library": "material",
@@ -187,7 +187,7 @@
187187
"heuristic": 426,
188188
"untested": 747
189189
},
190-
"timestamp": "2026-03-21T06:42:44.159Z"
190+
"timestamp": "2026-03-21T06:51:21.831Z"
191191
},
192192
"spectrum": {
193193
"library": "spectrum",
@@ -375,7 +375,7 @@
375375
"heuristic": 404,
376376
"untested": 601
377377
},
378-
"timestamp": "2026-03-21T06:42:44.159Z"
378+
"timestamp": "2026-03-21T06:51:21.831Z"
379379
},
380380
"vaadin": {
381381
"library": "vaadin",
@@ -563,7 +563,7 @@
563563
"heuristic": 439,
564564
"untested": 717
565565
},
566-
"timestamp": "2026-03-21T06:42:44.159Z"
566+
"timestamp": "2026-03-21T06:51:21.831Z"
567567
},
568568
"fluentui": {
569569
"library": "fluentui",
@@ -751,7 +751,7 @@
751751
"heuristic": 139,
752752
"untested": 293
753753
},
754-
"timestamp": "2026-03-21T06:42:44.159Z"
754+
"timestamp": "2026-03-21T06:51:21.831Z"
755755
},
756756
"carbon": {
757757
"library": "carbon",
@@ -939,7 +939,7 @@
939939
"heuristic": 397,
940940
"untested": 793
941941
},
942-
"timestamp": "2026-03-21T06:42:44.159Z"
942+
"timestamp": "2026-03-21T06:51:21.832Z"
943943
},
944944
"ui5": {
945945
"library": "ui5",
@@ -1127,7 +1127,7 @@
11271127
"heuristic": 582,
11281128
"untested": 1304
11291129
},
1130-
"timestamp": "2026-03-21T06:42:44.160Z"
1130+
"timestamp": "2026-03-21T06:51:21.832Z"
11311131
},
11321132
"calcite": {
11331133
"library": "calcite",
@@ -1315,7 +1315,7 @@
13151315
"heuristic": 318,
13161316
"untested": 848
13171317
},
1318-
"timestamp": "2026-03-21T06:42:44.160Z"
1318+
"timestamp": "2026-03-21T06:51:21.832Z"
13191319
},
13201320
"porsche": {
13211321
"library": "porsche",
@@ -1503,7 +1503,7 @@
15031503
"heuristic": 304,
15041504
"untested": 702
15051505
},
1506-
"timestamp": "2026-03-21T06:42:44.160Z"
1506+
"timestamp": "2026-03-21T06:51:21.832Z"
15071507
},
15081508
"ionic": {
15091509
"library": "ionic",
@@ -1691,7 +1691,7 @@
16911691
"heuristic": 331,
16921692
"untested": 744
16931693
},
1694-
"timestamp": "2026-03-21T06:42:44.161Z"
1694+
"timestamp": "2026-03-21T06:51:21.833Z"
16951695
},
16961696
"wired": {
16971697
"library": "wired",
@@ -1879,7 +1879,7 @@
18791879
"heuristic": 78,
18801880
"untested": 208
18811881
},
1882-
"timestamp": "2026-03-21T06:42:44.161Z"
1882+
"timestamp": "2026-03-21T06:51:21.833Z"
18831883
},
18841884
"elix": {
18851885
"library": "elix",
@@ -2067,7 +2067,7 @@
20672067
"heuristic": 148,
20682068
"untested": 740
20692069
},
2070-
"timestamp": "2026-03-21T06:42:44.161Z"
2070+
"timestamp": "2026-03-21T06:51:21.833Z"
20712071
},
20722072
"helix": {
20732073
"library": "helix",
@@ -2255,7 +2255,7 @@
22552255
"heuristic": 440,
22562256
"untested": 555
22572257
},
2258-
"timestamp": "2026-03-21T06:42:44.161Z"
2258+
"timestamp": "2026-03-21T06:51:21.834Z"
22592259
}
22602260
},
22612261
"comparisonTable": {
@@ -3278,70 +3278,70 @@
32783278
"API Surface Quality",
32793279
"Event Architecture"
32803280
],
3281-
"timestamp": "2026-03-21T06:42:44.161Z"
3281+
"timestamp": "2026-03-21T06:51:21.834Z"
32823282
},
32833283
"performance": {
3284-
"totalMs": 2262,
3284+
"totalMs": 1447,
32853285
"phases": [
32863286
{
32873287
"name": "load-libraries",
3288-
"durationMs": 168
3288+
"durationMs": 113
32893289
},
32903290
{
32913291
"name": "score-all-libraries",
3292-
"durationMs": 2091
3292+
"durationMs": 1330
32933293
},
32943294
{
32953295
"name": "score-material",
3296-
"durationMs": 247
3296+
"durationMs": 136
32973297
},
32983298
{
32993299
"name": "score-spectrum",
3300-
"durationMs": 105
3300+
"durationMs": 114
33013301
},
33023302
{
33033303
"name": "score-vaadin",
3304-
"durationMs": 640
3304+
"durationMs": 367
33053305
},
33063306
{
33073307
"name": "score-fluentui",
3308-
"durationMs": 34
3308+
"durationMs": 35
33093309
},
33103310
{
33113311
"name": "score-carbon",
3312-
"durationMs": 139
3312+
"durationMs": 68
33133313
},
33143314
{
33153315
"name": "score-ui5",
3316-
"durationMs": 192
3316+
"durationMs": 118
33173317
},
33183318
{
33193319
"name": "score-calcite",
3320-
"durationMs": 262
3320+
"durationMs": 166
33213321
},
33223322
{
33233323
"name": "score-porsche",
3324-
"durationMs": 74
3324+
"durationMs": 59
33253325
},
33263326
{
33273327
"name": "score-ionic",
3328-
"durationMs": 68
3328+
"durationMs": 54
33293329
},
33303330
{
33313331
"name": "score-wired",
3332-
"durationMs": 20
3332+
"durationMs": 13
33333333
},
33343334
{
33353335
"name": "score-elix",
3336-
"durationMs": 169
3336+
"durationMs": 149
33373337
},
33383338
{
33393339
"name": "score-helix",
3340-
"durationMs": 141
3340+
"durationMs": 51
33413341
},
33423342
{
33433343
"name": "generate-scorecards",
3344-
"durationMs": 3
3344+
"durationMs": 4
33453345
},
33463346
{
33473347
"name": "generate-comparison",

tests/handlers/validation-summary.test.ts

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -273,6 +273,82 @@ describe('summarizeValidation', () => {
273273
expect(summary.topIssues.length).toBeLessThanOrEqual(10);
274274
});
275275

276+
it('includes fix suggestions for shadow DOM issues', () => {
277+
const result = makeResult({
278+
totalIssues: 1,
279+
shadowDom: {
280+
issues: [
281+
{
282+
rule: 'no-descendant-piercing',
283+
line: 1,
284+
message: 'Cannot pierce shadow DOM with .label selector',
285+
suggestion: 'Use ::part()',
286+
code: 'my-button .label { color: red; }',
287+
},
288+
],
289+
clean: false,
290+
},
291+
});
292+
const summary = summarizeValidation(result);
293+
expect(summary.topIssues[0]?.fix).toBeDefined();
294+
expect(summary.topIssues[0]?.fix).toContain('::part(');
295+
});
296+
297+
it('includes fix suggestions for token fallback issues', () => {
298+
const result = makeResult({
299+
totalIssues: 1,
300+
tokenFallbacks: {
301+
issues: [
302+
{
303+
rule: 'missing-fallback',
304+
property: 'color',
305+
value: 'var(--my-color)',
306+
message: 'Missing fallback for var(--my-color)',
307+
line: 1,
308+
},
309+
],
310+
clean: false,
311+
},
312+
});
313+
const summary = summarizeValidation(result);
314+
expect(summary.topIssues[0]?.fix).toBeDefined();
315+
expect(summary.topIssues[0]?.fix).toContain('var(--my-color,');
316+
});
317+
318+
it('includes fix suggestions for shadow DOM JS issues', () => {
319+
const result = makeResult({
320+
totalIssues: 1,
321+
shadowDomJs: {
322+
issues: [
323+
{
324+
rule: 'no-shadow-root-access',
325+
line: 1,
326+
message: 'Do not access shadowRoot directly',
327+
suggestion: 'Use CSS parts or the public API',
328+
code: 'el.shadowRoot.querySelector(".inner")',
329+
},
330+
],
331+
clean: false,
332+
},
333+
});
334+
const summary = summarizeValidation(result);
335+
// shadowDomJs has suggestions but auto-fix is not straightforward — fix should be undefined
336+
expect(summary.topIssues[0]?.severity).toBe('error');
337+
});
338+
339+
it('omits fix field when no code is available for the issue', () => {
340+
const result = makeResult({
341+
totalIssues: 1,
342+
a11yUsage: {
343+
issues: [{ rule: 'missing-label', message: 'Add aria-label', line: 1 }],
344+
clean: false,
345+
},
346+
});
347+
const summary = summarizeValidation(result);
348+
// a11y issues don't have code to auto-fix
349+
expect(summary.topIssues[0]?.fix).toBeUndefined();
350+
});
351+
276352
it('includes a human-readable verdict string', () => {
277353
const result = makeResult({
278354
totalIssues: 3,

0 commit comments

Comments
 (0)