code-auditor-mcp 3.4.11 → 3.4.13
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/.claude-plugin/marketplace.json +2 -2
- package/CHANGELOG.md +119 -1
- package/dist/analyzers/__verify_self_probe.d.ts +2 -0
- package/dist/analyzers/__verify_self_probe.d.ts.map +1 -0
- package/dist/analyzers/__verify_self_probe.js +5 -0
- package/dist/analyzers/__verify_self_probe.js.map +1 -0
- package/dist/analyzers/analyzerUtils.d.ts +34 -1
- package/dist/analyzers/analyzerUtils.d.ts.map +1 -1
- package/dist/analyzers/analyzerUtils.js +61 -22
- package/dist/analyzers/analyzerUtils.js.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts +18 -23
- package/dist/analyzers/cross-language/APIContractAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/cross-language/APIContractAnalyzer.js +198 -186
- package/dist/analyzers/cross-language/APIContractAnalyzer.js.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts +78 -55
- package/dist/analyzers/cross-language/DependencyGraphBuilder.d.ts.map +1 -1
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js +244 -243
- package/dist/analyzers/cross-language/DependencyGraphBuilder.js.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.d.ts +27 -37
- package/dist/analyzers/cross-language/SchemaValidator.d.ts.map +1 -1
- package/dist/analyzers/cross-language/SchemaValidator.js +314 -275
- package/dist/analyzers/cross-language/SchemaValidator.js.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts +17 -46
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js +620 -523
- package/dist/analyzers/crossDomain/CrossDomainAnalyzer.js.map +1 -1
- package/dist/analyzers/documentationAnalyzer.d.ts +7 -2
- package/dist/analyzers/documentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/documentationAnalyzer.js +259 -198
- package/dist/analyzers/documentationAnalyzer.js.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/invariantsAnalyzer.js +59 -46
- package/dist/analyzers/invariantsAnalyzer.js.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.d.ts +10 -0
- package/dist/analyzers/orm/adapterRegistry.d.ts.map +1 -1
- package/dist/analyzers/orm/adapterRegistry.js +10 -0
- package/dist/analyzers/orm/adapterRegistry.js.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.d.ts +22 -0
- package/dist/analyzers/orm/drizzleAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/drizzleAdapter.js +42 -44
- package/dist/analyzers/orm/drizzleAdapter.js.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.d.ts +30 -0
- package/dist/analyzers/orm/prismaAdapter.d.ts.map +1 -1
- package/dist/analyzers/orm/prismaAdapter.js +30 -0
- package/dist/analyzers/orm/prismaAdapter.js.map +1 -1
- package/dist/analyzers/provenance.d.ts +72 -2
- package/dist/analyzers/provenance.d.ts.map +1 -1
- package/dist/analyzers/provenance.js +462 -338
- package/dist/analyzers/provenance.js.map +1 -1
- package/dist/analyzers/reactAnalyzer.d.ts +11 -0
- package/dist/analyzers/reactAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/reactAnalyzer.js +188 -125
- package/dist/analyzers/reactAnalyzer.js.map +1 -1
- package/dist/analyzers/ruleRegistry.d.ts +118 -1
- package/dist/analyzers/ruleRegistry.d.ts.map +1 -1
- package/dist/analyzers/ruleRegistry.js +1717 -104
- package/dist/analyzers/ruleRegistry.js.map +1 -1
- package/dist/analyzers/ruleTiming.d.ts +54 -0
- package/dist/analyzers/ruleTiming.d.ts.map +1 -0
- package/dist/analyzers/ruleTiming.js +71 -0
- package/dist/analyzers/ruleTiming.js.map +1 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts +13 -0
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js +362 -283
- package/dist/analyzers/universal/UniversalConventionsAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts +26 -68
- package/dist/analyzers/universal/UniversalDRYAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js +430 -391
- package/dist/analyzers/universal/UniversalDRYAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts +24 -186
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js +1081 -1061
- package/dist/analyzers/universal/UniversalDataAccessAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts +3 -49
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js +414 -340
- package/dist/analyzers/universal/UniversalDocumentationAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts +48 -5
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js +262 -134
- package/dist/analyzers/universal/UniversalSOLIDAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts +23 -212
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js +139 -1518
- package/dist/analyzers/universal/UniversalSchemaAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts +78 -79
- package/dist/analyzers/universal/UniversalStylesAnalyzer.d.ts.map +1 -1
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js +613 -472
- package/dist/analyzers/universal/UniversalStylesAnalyzer.js.map +1 -1
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts +307 -0
- package/dist/analyzers/universal/schema/codeAnalysis.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js +965 -0
- package/dist/analyzers/universal/schema/codeAnalysis.js.map +1 -0
- package/dist/analyzers/universal/schema/config.d.ts +31 -0
- package/dist/analyzers/universal/schema/config.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/config.js +57 -0
- package/dist/analyzers/universal/schema/config.js.map +1 -0
- package/dist/analyzers/universal/schema/discovery.d.ts +167 -0
- package/dist/analyzers/universal/schema/discovery.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/discovery.js +616 -0
- package/dist/analyzers/universal/schema/discovery.js.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts +27 -0
- package/dist/analyzers/universal/schema/jsonSchema.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/jsonSchema.js +368 -0
- package/dist/analyzers/universal/schema/jsonSchema.js.map +1 -0
- package/dist/analyzers/universal/schema/migrations.d.ts +90 -0
- package/dist/analyzers/universal/schema/migrations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/migrations.js +238 -0
- package/dist/analyzers/universal/schema/migrations.js.map +1 -0
- package/dist/analyzers/universal/schema/types.d.ts +131 -0
- package/dist/analyzers/universal/schema/types.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/types.js +10 -0
- package/dist/analyzers/universal/schema/types.js.map +1 -0
- package/dist/analyzers/universal/schema/violations.d.ts +44 -0
- package/dist/analyzers/universal/schema/violations.d.ts.map +1 -0
- package/dist/analyzers/universal/schema/violations.js +54 -0
- package/dist/analyzers/universal/schema/violations.js.map +1 -0
- package/dist/auditRunner.d.ts.map +1 -1
- package/dist/auditRunner.js +101 -39
- package/dist/auditRunner.js.map +1 -1
- package/dist/cli.js +348 -40
- package/dist/cli.js.map +1 -1
- package/dist/config/configLoader.d.ts.map +1 -1
- package/dist/config/configLoader.js +9 -7
- package/dist/config/configLoader.js.map +1 -1
- package/dist/config/defaults.d.ts +12 -3
- package/dist/config/defaults.d.ts.map +1 -1
- package/dist/config/defaults.js +15 -5
- package/dist/config/defaults.js.map +1 -1
- package/dist/config/effectiveConfig.d.ts +75 -0
- package/dist/config/effectiveConfig.d.ts.map +1 -0
- package/dist/config/effectiveConfig.js +213 -0
- package/dist/config/effectiveConfig.js.map +1 -0
- package/dist/config/pathProfiles.d.ts +7 -4
- package/dist/config/pathProfiles.d.ts.map +1 -1
- package/dist/config/pathProfiles.js +8 -10
- package/dist/config/pathProfiles.js.map +1 -1
- package/dist/config/thresholdRationales.d.ts +45 -0
- package/dist/config/thresholdRationales.d.ts.map +1 -0
- package/dist/config/thresholdRationales.js +67 -0
- package/dist/config/thresholdRationales.js.map +1 -0
- package/dist/conventions/conventionMiner.d.ts.map +1 -1
- package/dist/conventions/conventionMiner.js +6 -2
- package/dist/conventions/conventionMiner.js.map +1 -1
- package/dist/enforcement/diffGate.d.ts +55 -0
- package/dist/enforcement/diffGate.d.ts.map +1 -0
- package/dist/enforcement/diffGate.js +136 -0
- package/dist/enforcement/diffGate.js.map +1 -0
- package/dist/enforcement/gate.d.ts +36 -0
- package/dist/enforcement/gate.d.ts.map +1 -0
- package/dist/enforcement/gate.js +72 -0
- package/dist/enforcement/gate.js.map +1 -0
- package/dist/enforcement/suppressions.d.ts +41 -0
- package/dist/enforcement/suppressions.d.ts.map +1 -0
- package/dist/enforcement/suppressions.js +170 -0
- package/dist/enforcement/suppressions.js.map +1 -0
- package/dist/fingerprint.d.ts.map +1 -1
- package/dist/fingerprint.js +4 -1
- package/dist/fingerprint.js.map +1 -1
- package/dist/grammars/tree-sitter-scss.wasm +0 -0
- package/dist/invariants/ruleEngine.d.ts +3 -4
- package/dist/invariants/ruleEngine.d.ts.map +1 -1
- package/dist/invariants/ruleEngine.js +2 -2
- package/dist/invariants/ruleEngine.js.map +1 -1
- package/dist/languages/LanguageOrchestrator.d.ts +14 -0
- package/dist/languages/LanguageOrchestrator.d.ts.map +1 -1
- package/dist/languages/LanguageOrchestrator.js +93 -55
- package/dist/languages/LanguageOrchestrator.js.map +1 -1
- package/dist/languages/LanguageRegistry.d.ts +8 -0
- package/dist/languages/LanguageRegistry.d.ts.map +1 -1
- package/dist/languages/LanguageRegistry.js +8 -0
- package/dist/languages/LanguageRegistry.js.map +1 -1
- package/dist/languages/RuntimeManager.d.ts +82 -60
- package/dist/languages/RuntimeManager.d.ts.map +1 -1
- package/dist/languages/RuntimeManager.js +404 -349
- package/dist/languages/RuntimeManager.js.map +1 -1
- package/dist/languages/UniversalAnalyzer.d.ts +51 -6
- package/dist/languages/UniversalAnalyzer.d.ts.map +1 -1
- package/dist/languages/UniversalAnalyzer.js +127 -88
- package/dist/languages/UniversalAnalyzer.js.map +1 -1
- package/dist/languages/adapterBridge.d.ts +36 -0
- package/dist/languages/adapterBridge.d.ts.map +1 -1
- package/dist/languages/adapterBridge.js +89 -35
- package/dist/languages/adapterBridge.js.map +1 -1
- package/dist/languages/go/GoAdapter.d.ts +66 -23
- package/dist/languages/go/GoAdapter.d.ts.map +1 -1
- package/dist/languages/go/GoAdapter.js +553 -474
- package/dist/languages/go/GoAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts +38 -9
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js +111 -74
- package/dist/languages/tree-sitter/TreeSitterCssAdapter.js.map +1 -1
- package/dist/languages/tree-sitter/converter.d.ts +18 -0
- package/dist/languages/tree-sitter/converter.d.ts.map +1 -1
- package/dist/languages/tree-sitter/converter.js +18 -0
- package/dist/languages/tree-sitter/converter.js.map +1 -1
- package/dist/languages/tree-sitter/parser.d.ts +39 -2
- package/dist/languages/tree-sitter/parser.d.ts.map +1 -1
- package/dist/languages/tree-sitter/parser.js +95 -12
- package/dist/languages/tree-sitter/parser.js.map +1 -1
- package/dist/languages/types.d.ts +67 -2
- package/dist/languages/types.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts +253 -62
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.d.ts.map +1 -1
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js +1461 -791
- package/dist/languages/typescript/TreeSitterTypeScriptAdapter.js.map +1 -1
- package/dist/mcp-tools/whitelistTools.d.ts +1 -1
- package/dist/mcp-tools-shared.d.ts.map +1 -1
- package/dist/mcp-tools-shared.js +1 -0
- package/dist/mcp-tools-shared.js.map +1 -1
- package/dist/mcpAuditJobs.d.ts.map +1 -1
- package/dist/mcpAuditJobs.js +1 -0
- package/dist/mcpAuditJobs.js.map +1 -1
- package/dist/pipeline.d.ts +30 -1
- package/dist/pipeline.d.ts.map +1 -1
- package/dist/pipeline.js +527 -203
- package/dist/pipeline.js.map +1 -1
- package/dist/pipelineAdapters.d.ts +5 -12
- package/dist/pipelineAdapters.d.ts.map +1 -1
- package/dist/pipelineAdapters.js +175 -100
- package/dist/pipelineAdapters.js.map +1 -1
- package/dist/presets/presets.d.ts +54 -0
- package/dist/presets/presets.d.ts.map +1 -0
- package/dist/presets/presets.js +181 -0
- package/dist/presets/presets.js.map +1 -0
- package/dist/reporting/sarifReportGenerator.d.ts.map +1 -1
- package/dist/reporting/sarifReportGenerator.js +0 -2
- package/dist/reporting/sarifReportGenerator.js.map +1 -1
- package/dist/ruleAliases.d.ts +67 -0
- package/dist/ruleAliases.d.ts.map +1 -0
- package/dist/ruleAliases.js +79 -0
- package/dist/ruleAliases.js.map +1 -0
- package/dist/styles/cssAstExtractor.d.ts +20 -5
- package/dist/styles/cssAstExtractor.d.ts.map +1 -1
- package/dist/styles/cssAstExtractor.js +247 -9
- package/dist/styles/cssAstExtractor.js.map +1 -1
- package/dist/styles/styleIndexer.js +13 -4
- package/dist/styles/styleIndexer.js.map +1 -1
- package/dist/styles/tailwindConfigLoader.d.ts +8 -6
- package/dist/styles/tailwindConfigLoader.d.ts.map +1 -1
- package/dist/styles/tailwindConfigLoader.js +61 -15
- package/dist/styles/tailwindConfigLoader.js.map +1 -1
- package/dist/styles/tailwindUtilityExpander.d.ts.map +1 -1
- package/dist/styles/tailwindUtilityExpander.js +13 -1
- package/dist/styles/tailwindUtilityExpander.js.map +1 -1
- package/dist/types.d.ts +201 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +8 -0
- package/dist/types.js.map +1 -1
- package/package.json +7 -4
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/hooks/hooks.json +1 -1
- package/plugin/scripts/hook-audit.sh +25 -7
- package/plugin/skills/code-auditor/SKILL.md +5 -5
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* compute histograms, clusters, and distributions across the entire codebase.
|
|
11
11
|
*/
|
|
12
12
|
import { UniversalAnalyzer } from '../../languages/UniversalAnalyzer.js';
|
|
13
|
+
import { withRuleTiming } from '../ruleTiming.js';
|
|
13
14
|
import { makeVisitorStatus } from '../../pipeline.js';
|
|
14
15
|
import { getTailwindExpander } from '../../styles/tailwindUtilityExpander.js';
|
|
15
16
|
import { normalizeValue } from '../../styles/normalizer.js';
|
|
@@ -46,125 +47,153 @@ const TAILWIND_SPACING_PX = {
|
|
|
46
47
|
'96': 384,
|
|
47
48
|
};
|
|
48
49
|
const TAILWIND_SCALE_VALUES = Object.values(TAILWIND_SPACING_PX).sort((a, b) => a - b);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
50
|
+
/**
|
|
51
|
+
* Values so common that coincidental token-name matches are always noise.
|
|
52
|
+
* Filtered before any token comparison (Spec 22 Item 1).
|
|
53
|
+
*/
|
|
54
|
+
const TRIVIAL_VALUES = new Set([
|
|
55
|
+
'0', '0px', '0rem', '0em', '0%', 'none', 'transparent',
|
|
56
|
+
'inherit', 'initial', 'unset', 'currentcolor', 'auto',
|
|
57
|
+
'100%', '50%',
|
|
58
|
+
]);
|
|
59
|
+
/**
|
|
60
|
+
* Leaf layer: identity + stateless style helpers used by every detector.
|
|
61
|
+
* Kept free of cross-method orchestration so it stays a small, stable base.
|
|
62
|
+
*/
|
|
63
|
+
class UniversalStylesAnalyzerBase extends UniversalAnalyzer {
|
|
53
64
|
name = 'styles';
|
|
54
65
|
description = 'Detects style fragmentation, value drift, token bypass, dead classes, ' +
|
|
55
66
|
'off-scale values, mechanism mixing, declaration-set similarity, and z-index sprawl';
|
|
56
67
|
category = 'style';
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
if (
|
|
68
|
-
|
|
69
|
-
violations: [],
|
|
70
|
-
errors: [{ file: '', error: 'No index handle available — style index not open' }],
|
|
71
|
-
status: makeVisitorStatus(0),
|
|
72
|
-
executionTime: Date.now() - startTime,
|
|
73
|
-
analyzerName: this.name,
|
|
74
|
-
metrics: { filesAnalyzed: 0, totalViolations: 0, executionTime: Date.now() - startTime },
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
// Query all declarations, tokens, and class usage
|
|
78
|
-
const declarations = this.queryDeclarations(indexHandle);
|
|
79
|
-
const tokens = this.queryTokens(indexHandle);
|
|
80
|
-
const classUsage = this.queryClassUsage(indexHandle);
|
|
81
|
-
if (declarations.length === 0) {
|
|
82
|
-
return {
|
|
83
|
-
violations: [],
|
|
84
|
-
errors: [],
|
|
85
|
-
status: makeVisitorStatus(files.length),
|
|
86
|
-
executionTime: Date.now() - startTime,
|
|
87
|
-
analyzerName: this.name,
|
|
88
|
-
metrics: { filesAnalyzed: files.length, totalViolations: 0, executionTime: Date.now() - startTime },
|
|
89
|
-
};
|
|
90
|
-
}
|
|
91
|
-
// Build helpers
|
|
92
|
-
const tokenValueMap = new Map(); // normalized value → token info
|
|
93
|
-
for (const t of tokens) {
|
|
94
|
-
const normalizedTokenVal = normalizeValue(t.value, '__token__');
|
|
95
|
-
tokenValueMap.set(t.value, { name: t.name, valueType: normalizedTokenVal?.type ?? null });
|
|
96
|
-
}
|
|
97
|
-
// Declarations by property
|
|
98
|
-
const byProperty = new Map();
|
|
99
|
-
for (const d of declarations) {
|
|
100
|
-
const list = byProperty.get(d.property) || [];
|
|
101
|
-
list.push(d);
|
|
102
|
-
byProperty.set(d.property, list);
|
|
68
|
+
makeViolation(filePath, line, message, classification) {
|
|
69
|
+
const v = {
|
|
70
|
+
file: filePath,
|
|
71
|
+
line,
|
|
72
|
+
column: 1,
|
|
73
|
+
severity: classification.severity,
|
|
74
|
+
message,
|
|
75
|
+
rule: classification.rule,
|
|
76
|
+
analyzer: this.name,
|
|
77
|
+
};
|
|
78
|
+
if (classification.symbol) {
|
|
79
|
+
v.functionName = classification.symbol;
|
|
103
80
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
violations.push(...this.detectOffScaleValues(byProperty, cfg));
|
|
107
|
-
violations.push(...await this.detectUndefinedClasses(classUsage, declarations, byProperty, cfg));
|
|
108
|
-
violations.push(...this.detectTokenBypass(declarations, tokenValueMap, cfg));
|
|
109
|
-
violations.push(...this.detectMechanismFragmentation(declarations, cfg));
|
|
110
|
-
violations.push(...this.detectDeclarationSetSimilarity(declarations, cfg));
|
|
111
|
-
violations.push(...this.detectZIndexInventory(byProperty, cfg));
|
|
112
|
-
// Apply severity overrides from config
|
|
113
|
-
const severityOverrides = config.severityOverrides ?? {};
|
|
114
|
-
if (Object.keys(severityOverrides).length > 0) {
|
|
115
|
-
for (const v of violations) {
|
|
116
|
-
const override = severityOverrides[v.rule];
|
|
117
|
-
if (override) {
|
|
118
|
-
v.severity = override;
|
|
119
|
-
}
|
|
120
|
-
}
|
|
81
|
+
if (classification.resolution) {
|
|
82
|
+
v.resolution = classification.resolution;
|
|
121
83
|
}
|
|
122
|
-
|
|
123
|
-
const uniqueFiles = new Set(declarations.map(d => d.file_path));
|
|
124
|
-
return {
|
|
125
|
-
violations: filtered,
|
|
126
|
-
errors: [],
|
|
127
|
-
status: makeVisitorStatus(uniqueFiles.size),
|
|
128
|
-
executionTime: Date.now() - startTime,
|
|
129
|
-
analyzerName: this.name,
|
|
130
|
-
metrics: {
|
|
131
|
-
filesAnalyzed: uniqueFiles.size,
|
|
132
|
-
totalViolations: filtered.length,
|
|
133
|
-
executionTime: Date.now() - startTime,
|
|
134
|
-
},
|
|
135
|
-
};
|
|
136
|
-
}
|
|
137
|
-
/** Not used — we override analyze() directly. */
|
|
138
|
-
async analyzeAST(_ast, _adapter, _config, _sourceCode) {
|
|
139
|
-
return [];
|
|
84
|
+
return v;
|
|
140
85
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
// -----------------------------------------------------------------------
|
|
144
|
-
queryDeclarations(indexHandle) {
|
|
86
|
+
/** Parse a CSS color string to [R, G, B] or null. */
|
|
87
|
+
parseColorToRGB(raw) {
|
|
145
88
|
try {
|
|
146
|
-
|
|
89
|
+
let v = raw.toLowerCase().trim();
|
|
90
|
+
// Hex
|
|
91
|
+
if (v.startsWith('#')) {
|
|
92
|
+
if (v.length === 4) {
|
|
93
|
+
// #rgb → #rrggbb
|
|
94
|
+
v = '#' + v[1] + v[1] + v[2] + v[2] + v[3] + v[3];
|
|
95
|
+
}
|
|
96
|
+
if (v.length === 7) {
|
|
97
|
+
return [
|
|
98
|
+
parseInt(v.slice(1, 3), 16),
|
|
99
|
+
parseInt(v.slice(3, 5), 16),
|
|
100
|
+
parseInt(v.slice(5, 7), 16),
|
|
101
|
+
];
|
|
102
|
+
}
|
|
103
|
+
if (v.length === 9) {
|
|
104
|
+
return [
|
|
105
|
+
parseInt(v.slice(1, 3), 16),
|
|
106
|
+
parseInt(v.slice(3, 5), 16),
|
|
107
|
+
parseInt(v.slice(5, 7), 16),
|
|
108
|
+
];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
// rgb(r, g, b) or rgb(r g b)
|
|
112
|
+
const rgbMatch = v.match(/rgb\(\s*(\d+)\s*,?\s*(\d+)\s*,?\s*(\d+)\s*\)/);
|
|
113
|
+
if (rgbMatch) {
|
|
114
|
+
return [
|
|
115
|
+
parseInt(rgbMatch[1]),
|
|
116
|
+
parseInt(rgbMatch[2]),
|
|
117
|
+
parseInt(rgbMatch[3]),
|
|
118
|
+
];
|
|
119
|
+
}
|
|
120
|
+
// Named colors — minimal set for common use
|
|
121
|
+
const named = {
|
|
122
|
+
'white': [255, 255, 255], 'black': [0, 0, 0],
|
|
123
|
+
'red': [255, 0, 0], 'blue': [0, 0, 255], 'green': [0, 128, 0],
|
|
124
|
+
'transparent': [0, 0, 0],
|
|
125
|
+
};
|
|
126
|
+
if (named[v])
|
|
127
|
+
return named[v];
|
|
128
|
+
return null;
|
|
147
129
|
}
|
|
148
130
|
catch {
|
|
149
|
-
return
|
|
131
|
+
return null;
|
|
150
132
|
}
|
|
151
133
|
}
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
134
|
+
/** Compute delta-E (CIE76) between two RGB colors. */
|
|
135
|
+
deltaE(a, b) {
|
|
136
|
+
const dr = a[0] - b[0];
|
|
137
|
+
const dg = a[1] - b[1];
|
|
138
|
+
const db = a[2] - b[2];
|
|
139
|
+
return Math.sqrt(dr * dr + dg * dg + db * db);
|
|
140
|
+
}
|
|
141
|
+
/**
|
|
142
|
+
* Cluster colors by delta-E distance.
|
|
143
|
+
* Simple greedy algorithm: each item joins the first cluster it's close enough to,
|
|
144
|
+
* or starts a new cluster.
|
|
145
|
+
*/
|
|
146
|
+
clusterByDeltaE(items, threshold) {
|
|
147
|
+
const clusters = [];
|
|
148
|
+
for (const item of items) {
|
|
149
|
+
let placed = false;
|
|
150
|
+
for (const cluster of clusters) {
|
|
151
|
+
// Use the first item's RGB as cluster centroid
|
|
152
|
+
const centroid = cluster[0].rgb;
|
|
153
|
+
if (this.deltaE(item.rgb, centroid) < threshold) {
|
|
154
|
+
cluster.push(item);
|
|
155
|
+
placed = true;
|
|
156
|
+
break;
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (!placed) {
|
|
160
|
+
clusters.push([item]);
|
|
161
|
+
}
|
|
158
162
|
}
|
|
163
|
+
return clusters;
|
|
159
164
|
}
|
|
160
|
-
|
|
165
|
+
/** Parse a CSS length value to px-equivalent, or null if not parseable. */
|
|
166
|
+
parseLengthToPx(raw) {
|
|
161
167
|
try {
|
|
162
|
-
|
|
168
|
+
const v = raw.trim().toLowerCase();
|
|
169
|
+
if (v === '0' || v === '0px')
|
|
170
|
+
return 0;
|
|
171
|
+
const match = v.match(/^(-?\d+(?:\.\d+)?)\s*(px|rem|em|%|vh|vw|pt|cm|mm)?$/);
|
|
172
|
+
if (!match)
|
|
173
|
+
return null;
|
|
174
|
+
const num = parseFloat(match[1]);
|
|
175
|
+
const unit = match[2] || 'px';
|
|
176
|
+
// Approximate conversions (assuming 16px base for rem/em)
|
|
177
|
+
switch (unit) {
|
|
178
|
+
case 'px': return num;
|
|
179
|
+
case 'rem': return num * 16;
|
|
180
|
+
case 'em': return num * 16;
|
|
181
|
+
case 'pt': return num * 1.333;
|
|
182
|
+
case 'cm': return num * 37.795;
|
|
183
|
+
case 'mm': return num * 3.7795;
|
|
184
|
+
default: return null; // can't convert %/vh/vw without context
|
|
185
|
+
}
|
|
163
186
|
}
|
|
164
187
|
catch {
|
|
165
|
-
return
|
|
188
|
+
return null;
|
|
166
189
|
}
|
|
167
190
|
}
|
|
191
|
+
}
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
// Detector layer: the seven style detectors, reporting through the base
|
|
194
|
+
// layer's makeViolation helper.
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
class UniversalStylesAnalyzerDetectors extends UniversalStylesAnalyzerBase {
|
|
168
197
|
// -----------------------------------------------------------------------
|
|
169
198
|
// Detector 1: Value Drift
|
|
170
199
|
// -----------------------------------------------------------------------
|
|
@@ -251,33 +280,13 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
251
280
|
}
|
|
252
281
|
if (colors.length < cfg.minCorpus)
|
|
253
282
|
return violations;
|
|
254
|
-
// Cluster by delta-E
|
|
283
|
+
// Cluster by delta-E, then flag stragglers in non-dominant clusters.
|
|
255
284
|
const clusters = this.clusterByDeltaE(colors, cfg.colorDeltaE);
|
|
256
|
-
// Find the dominant cluster (largest)
|
|
257
285
|
clusters.sort((a, b) => b.length - a.length);
|
|
258
286
|
const dominant = clusters[0];
|
|
259
287
|
if (!dominant || dominant.length < cfg.modeMinCount)
|
|
260
288
|
return violations;
|
|
261
|
-
|
|
262
|
-
// Flag stragglers in non-dominant clusters
|
|
263
|
-
for (let i = 1; i < clusters.length; i++) {
|
|
264
|
-
const cluster = clusters[i];
|
|
265
|
-
const share = cluster.length / colors.length;
|
|
266
|
-
if (share < cfg.outlierMaxShare && dominantSize >= cfg.modeMinCount) {
|
|
267
|
-
// Report once per distinct color value in the straggler cluster
|
|
268
|
-
const seenValues = new Set();
|
|
269
|
-
for (const item of cluster) {
|
|
270
|
-
const normVal = item.decl.raw_value.toLowerCase();
|
|
271
|
-
if (seenValues.has(normVal))
|
|
272
|
-
continue;
|
|
273
|
-
seenValues.add(normVal);
|
|
274
|
-
violations.push(this.makeViolation(item.decl.file_path, item.decl.line, `Color drift in "${property}": "${item.decl.raw_value}" is a rare value ` +
|
|
275
|
-
`(used ${cluster.length} time${cluster.length === 1 ? '' : 's'}, ` +
|
|
276
|
-
`${(share * 100).toFixed(1)}% of ${colors.length} usages). ` +
|
|
277
|
-
`Dominant cluster has ${dominantSize} values. Consider using a design token.`, 'warning', 'styles/value-drift', 'color'));
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
}
|
|
289
|
+
violations.push(...flagColorDriftStragglers(clusters, property, cfg, this.makeViolation.bind(this)));
|
|
281
290
|
return violations;
|
|
282
291
|
}
|
|
283
292
|
/**
|
|
@@ -315,7 +324,7 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
315
324
|
violations.push(this.makeViolation(sample.file_path, sample.line, `Value drift in "${property}": "${sample.raw_value}" is rare ` +
|
|
316
325
|
`(${list.length} of ${total} usages, ${(share * 100).toFixed(1)}%). ` +
|
|
317
326
|
`The dominant value "${modeKey}" is used ${modeCount} times. ` +
|
|
318
|
-
`Consider using a consistent value or design token.`, 'warning', 'styles/value-drift', 'exact'));
|
|
327
|
+
`Consider using a consistent value or design token.`, { severity: 'warning', rule: 'styles/value-drift', symbol: 'exact' }));
|
|
319
328
|
}
|
|
320
329
|
}
|
|
321
330
|
return violations;
|
|
@@ -356,7 +365,7 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
356
365
|
violations.push(this.makeViolation(decl.file_path, decl.line, `Off-scale "${property}" value: "${decl.raw_value}" (${px}px) ` +
|
|
357
366
|
`does not align with the inferred ${step}px scale step. ` +
|
|
358
367
|
`Nearby scale values: ${Math.floor(px / step) * step}px or ` +
|
|
359
|
-
`${Math.ceil(px / step) * step}px.`, 'warning', 'styles/off-scale'));
|
|
368
|
+
`${Math.ceil(px / step) * step}px.`, { severity: 'warning', rule: 'styles/off-scale' }));
|
|
360
369
|
}
|
|
361
370
|
}
|
|
362
371
|
}
|
|
@@ -389,6 +398,382 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
389
398
|
return null;
|
|
390
399
|
return bestStep;
|
|
391
400
|
}
|
|
401
|
+
}
|
|
402
|
+
// ---------------------------------------------------------------------------
|
|
403
|
+
// Structure detectors: class/token/mechanism integrity checks over the raw
|
|
404
|
+
// declaration and class-usage rows. Split out of the value-drift detectors so
|
|
405
|
+
// neither class trips the aggregate-complexity ceiling. Lives off the analyzer
|
|
406
|
+
// inheritance chain — it reports through the shared factory and receives the
|
|
407
|
+
// analyzer name once at construction.
|
|
408
|
+
// ---------------------------------------------------------------------------
|
|
409
|
+
/**
|
|
410
|
+
* Filter class-usage rows down to candidate names worth batch-probing,
|
|
411
|
+
* applying the skip filters that distinguish definitions, known classes, and
|
|
412
|
+
* extraction artifacts from genuine consumptions of an unknown class.
|
|
413
|
+
*/
|
|
414
|
+
function collectUndefinedClassCandidates(classUsage, definedClasses) {
|
|
415
|
+
const seen = new Set();
|
|
416
|
+
const candidates = [];
|
|
417
|
+
const usageEntries = [];
|
|
418
|
+
for (const u of classUsage) {
|
|
419
|
+
const key = `${u.class_name}::${u.file_path}`;
|
|
420
|
+
if (seen.has(key))
|
|
421
|
+
continue;
|
|
422
|
+
seen.add(key);
|
|
423
|
+
// Skip unresolvable individual class usages
|
|
424
|
+
if (u.unresolvable)
|
|
425
|
+
continue;
|
|
426
|
+
// Skip CSS class SELECTORS (definitions, not usages).
|
|
427
|
+
// CSS/SCSS files: mechanism 'class' = class_selector node = ".some-class { }"
|
|
428
|
+
// These DEFINE a class — they are not usages of one. Only usages through
|
|
429
|
+
// other mechanisms (className, apply, class in HTML) signal consumption.
|
|
430
|
+
// Without this guard, every CSS selector without a direct declaration
|
|
431
|
+
// (SCSS @include-only blocks, nested rule_sets) is falsely flagged.
|
|
432
|
+
if (u.mechanism === 'class' && /\.(css|scss)$/i.test(u.file_path))
|
|
433
|
+
continue;
|
|
434
|
+
// Skip known classes from CSS declarations
|
|
435
|
+
if (definedClasses.has(u.class_name))
|
|
436
|
+
continue;
|
|
437
|
+
// Skip PascalCase — likely a component
|
|
438
|
+
if (/^[A-Z]/.test(u.class_name))
|
|
439
|
+
continue;
|
|
440
|
+
// Skip function-like
|
|
441
|
+
if (u.class_name.includes('('))
|
|
442
|
+
continue;
|
|
443
|
+
// Skip numeric
|
|
444
|
+
if (/^\d/.test(u.class_name))
|
|
445
|
+
continue;
|
|
446
|
+
// Skip classes that start/end with [] — not valid CSS class names
|
|
447
|
+
if (u.class_name.startsWith('[') || u.class_name.startsWith(']'))
|
|
448
|
+
continue;
|
|
449
|
+
if (u.class_name.endsWith('[') || u.class_name.endsWith(']'))
|
|
450
|
+
continue;
|
|
451
|
+
// Skip extraction artifacts
|
|
452
|
+
if (/['`"${}?;!@#%^&*+=<>|\\,~]/.test(u.class_name))
|
|
453
|
+
continue;
|
|
454
|
+
usageEntries.push(u);
|
|
455
|
+
candidates.push(u.class_name);
|
|
456
|
+
}
|
|
457
|
+
return { candidates, usageEntries };
|
|
458
|
+
}
|
|
459
|
+
function flagColorDriftStragglers(clusters, property, cfg, report) {
|
|
460
|
+
const violations = [];
|
|
461
|
+
const dominant = clusters[0];
|
|
462
|
+
const dominantSize = dominant.length;
|
|
463
|
+
const total = clusters.reduce((sum, c) => sum + c.length, 0);
|
|
464
|
+
for (let i = 1; i < clusters.length; i++) {
|
|
465
|
+
const cluster = clusters[i];
|
|
466
|
+
const share = cluster.length / total;
|
|
467
|
+
if (share >= cfg.outlierMaxShare)
|
|
468
|
+
continue;
|
|
469
|
+
// Report once per distinct color value in the straggler cluster
|
|
470
|
+
const seenValues = new Set();
|
|
471
|
+
for (const item of cluster) {
|
|
472
|
+
const normVal = item.decl.raw_value.toLowerCase();
|
|
473
|
+
if (seenValues.has(normVal))
|
|
474
|
+
continue;
|
|
475
|
+
seenValues.add(normVal);
|
|
476
|
+
violations.push(report(item.decl.file_path, item.decl.line, `Color drift in "${property}": "${item.decl.raw_value}" is a rare value ` +
|
|
477
|
+
`(used ${cluster.length} time${cluster.length === 1 ? '' : 's'}, ` +
|
|
478
|
+
`${(share * 100).toFixed(1)}% of ${total} usages). ` +
|
|
479
|
+
`Dominant cluster has ${dominantSize} values. Consider using a design token.`, { severity: 'warning', rule: 'styles/value-drift', symbol: 'color' }));
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
return violations;
|
|
483
|
+
}
|
|
484
|
+
/**
|
|
485
|
+
* Collect the set of defined class names and the stylesheet that first defines
|
|
486
|
+
* each, in a single pass over the declarations. Bundled as one catalog so the
|
|
487
|
+
* undefined-class detector (Spec 37 R1: "nearest defined class by edit
|
|
488
|
+
* distance, and which stylesheet defines it") takes a single context object
|
|
489
|
+
* rather than threading the name set and location map as two separate
|
|
490
|
+
* parameters.
|
|
491
|
+
*/
|
|
492
|
+
function collectDefinedClassCatalog(byProperty) {
|
|
493
|
+
const names = new Set();
|
|
494
|
+
const locations = new Map();
|
|
495
|
+
for (const [, decls] of byProperty) {
|
|
496
|
+
for (const d of decls) {
|
|
497
|
+
const ctx = d.context;
|
|
498
|
+
if (!ctx)
|
|
499
|
+
continue;
|
|
500
|
+
const matches = ctx.matchAll(/\.([a-zA-Z0-9_-]+)/g);
|
|
501
|
+
for (const m of matches) {
|
|
502
|
+
names.add(m[1]);
|
|
503
|
+
if (!locations.has(m[1]))
|
|
504
|
+
locations.set(m[1], d.file_path);
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { names, locations };
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Levenshtein edit distance between two class names, capping the DP width so a
|
|
512
|
+
* large stylesheet catalog stays cheap. Returns Infinity when the length gap
|
|
513
|
+
* alone exceeds the match ceiling (mirrors schema/codeAnalysis.ts).
|
|
514
|
+
*/
|
|
515
|
+
function levenshteinDistance(a, b, maxDist) {
|
|
516
|
+
if (Math.abs(a.length - b.length) > maxDist)
|
|
517
|
+
return Infinity;
|
|
518
|
+
const m = a.length;
|
|
519
|
+
const n = b.length;
|
|
520
|
+
const dp = Array.from({ length: m + 1 }, () => Array(n + 1).fill(0));
|
|
521
|
+
for (let i = 0; i <= m; i++)
|
|
522
|
+
dp[i][0] = i;
|
|
523
|
+
for (let j = 0; j <= n; j++)
|
|
524
|
+
dp[0][j] = j;
|
|
525
|
+
for (let i = 1; i <= m; i++) {
|
|
526
|
+
for (let j = 1; j <= n; j++) {
|
|
527
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
528
|
+
? dp[i - 1][j - 1]
|
|
529
|
+
: 1 + Math.min(dp[i - 1][j - 1], dp[i - 1][j], dp[i][j - 1]);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return dp[m][n];
|
|
533
|
+
}
|
|
534
|
+
/**
|
|
535
|
+
* Nearest defined class within a bounded edit distance, or null when no known
|
|
536
|
+
* class is close enough to be a credible suggestion.
|
|
537
|
+
*/
|
|
538
|
+
function nearestDefinedClass(name, definedClasses) {
|
|
539
|
+
let best = null;
|
|
540
|
+
let bestDist = Infinity;
|
|
541
|
+
for (const known of definedClasses) {
|
|
542
|
+
const dist = levenshteinDistance(name.toLowerCase(), known.toLowerCase(), 3);
|
|
543
|
+
if (dist < bestDist) {
|
|
544
|
+
bestDist = dist;
|
|
545
|
+
best = known;
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
return best;
|
|
549
|
+
}
|
|
550
|
+
async function initTailwindProbe(expander, cfg, report) {
|
|
551
|
+
await expander.init({
|
|
552
|
+
projectRoot: cfg?.projectRoot,
|
|
553
|
+
useProjectConfig: true,
|
|
554
|
+
customClasses: cfg?.tailwindClasses ? new Set(cfg.tailwindClasses) : undefined,
|
|
555
|
+
});
|
|
556
|
+
// Fail-open rule (Spec 22 R1.3): if the probe fails and Tailwind IS
|
|
557
|
+
// present, disable the undefined-class detector.
|
|
558
|
+
if (expander.configFailed && expander.hasTailwindConfig) {
|
|
559
|
+
return [report('', 0, `Tailwind probe unavailable (${expander.configFailureReason ?? 'unknown error'}) — ` +
|
|
560
|
+
`undefined-class detection skipped. Classes defined only in Tailwind ` +
|
|
561
|
+
`config will not be checked. Install tailwindcss in the project ` +
|
|
562
|
+
`for full class validation.`, { severity: 'suggestion', rule: 'styles/undefined-class-disabled' })];
|
|
563
|
+
}
|
|
564
|
+
return null;
|
|
565
|
+
}
|
|
566
|
+
function flagUnresolvedClasses(usageEntries, expander, report, catalog) {
|
|
567
|
+
return withRuleTiming('styles/undefined-class', () => {
|
|
568
|
+
const { names: definedClasses, locations: definedClassLocations } = catalog;
|
|
569
|
+
const violations = [];
|
|
570
|
+
for (const u of usageEntries) {
|
|
571
|
+
const resolved = expander.resolve(u.class_name);
|
|
572
|
+
if (resolved.valid)
|
|
573
|
+
continue;
|
|
574
|
+
const nearest = nearestDefinedClass(u.class_name, definedClasses);
|
|
575
|
+
const nearestFile = nearest ? definedClassLocations.get(nearest) : undefined;
|
|
576
|
+
violations.push(report(u.file_path, u.line, `Undefined CSS class: "${u.class_name}" has no matching definition ` +
|
|
577
|
+
`in any stylesheet, Tailwind utility set, or project config.`, {
|
|
578
|
+
severity: 'warning',
|
|
579
|
+
rule: 'styles/undefined-class',
|
|
580
|
+
resolution: {
|
|
581
|
+
action: nearest ? 'use-defined-class' : 'define-or-remove-class',
|
|
582
|
+
summary: nearest
|
|
583
|
+
? `Rename "${u.class_name}" to the defined class "${nearest}"${nearestFile ? ` (defined in ${nearestFile})` : ''}.`
|
|
584
|
+
: `Define the class "${u.class_name}" in a stylesheet, or remove the usage.`,
|
|
585
|
+
symbols: nearest ? [nearest] : [u.class_name],
|
|
586
|
+
files: nearestFile ? [u.file_path, nearestFile] : [u.file_path],
|
|
587
|
+
lines: [u.line],
|
|
588
|
+
},
|
|
589
|
+
}));
|
|
590
|
+
}
|
|
591
|
+
return violations;
|
|
592
|
+
});
|
|
593
|
+
}
|
|
594
|
+
function normalizeForTokenMatch(raw) {
|
|
595
|
+
let v = raw.toLowerCase().trim();
|
|
596
|
+
v = v.replace(/,\s+/g, ',');
|
|
597
|
+
if (/^#[0-9a-f]{3}$/.test(v)) {
|
|
598
|
+
v = '#' + v[1] + v[1] + v[2] + v[2] + v[3] + v[3];
|
|
599
|
+
}
|
|
600
|
+
return v;
|
|
601
|
+
}
|
|
602
|
+
function parseNormalizedValueType(normalizedValue) {
|
|
603
|
+
if (!normalizedValue)
|
|
604
|
+
return null;
|
|
605
|
+
try {
|
|
606
|
+
const nv = JSON.parse(normalizedValue);
|
|
607
|
+
return nv.type;
|
|
608
|
+
}
|
|
609
|
+
catch {
|
|
610
|
+
return null;
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
function matchBypassToken(d, tokenValueMap, categoricalExclusions, scaleProps) {
|
|
614
|
+
if (d.token_ref)
|
|
615
|
+
return null;
|
|
616
|
+
if (d.property.startsWith('--') || d.property.startsWith('$'))
|
|
617
|
+
return null;
|
|
618
|
+
if (categoricalExclusions.has(d.property))
|
|
619
|
+
return null;
|
|
620
|
+
if (scaleProps.has(d.property))
|
|
621
|
+
return null;
|
|
622
|
+
const normalized = normalizeForTokenMatch(d.raw_value);
|
|
623
|
+
if (TRIVIAL_VALUES.has(normalized))
|
|
624
|
+
return null;
|
|
625
|
+
const tokenInfo = tokenValueMap.get(normalized);
|
|
626
|
+
if (!tokenInfo)
|
|
627
|
+
return null;
|
|
628
|
+
if (tokenInfo.valueType !== null) {
|
|
629
|
+
const declType = parseNormalizedValueType(d.normalized_value);
|
|
630
|
+
if (declType !== null && declType !== tokenInfo.valueType)
|
|
631
|
+
return null;
|
|
632
|
+
}
|
|
633
|
+
if (tokenInfo.valueType !== 'color')
|
|
634
|
+
return null;
|
|
635
|
+
return tokenInfo;
|
|
636
|
+
}
|
|
637
|
+
function flagPropertyValueFragmentation(declarations, minMechanisms, report) {
|
|
638
|
+
const violations = [];
|
|
639
|
+
const pvMap = new Map();
|
|
640
|
+
const pvSample = new Map();
|
|
641
|
+
for (const d of declarations) {
|
|
642
|
+
const key = `${d.property}::${d.normalized_value ?? d.raw_value}`;
|
|
643
|
+
const mechs = pvMap.get(key) || new Set();
|
|
644
|
+
mechs.add(d.mechanism);
|
|
645
|
+
pvMap.set(key, mechs);
|
|
646
|
+
if (!pvSample.has(key)) {
|
|
647
|
+
pvSample.set(key, d);
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
for (const [key, mechs] of pvMap) {
|
|
651
|
+
if (mechs.size < minMechanisms)
|
|
652
|
+
continue;
|
|
653
|
+
const sample = pvSample.get(key);
|
|
654
|
+
const [prop, value] = key.split('::');
|
|
655
|
+
violations.push(report(sample.file_path, sample.line, `Mechanism fragmentation: "${prop}: ${value}" is applied via ` +
|
|
656
|
+
`${mechs.size} different mechanisms (${[...mechs].sort().join(', ')}). ` +
|
|
657
|
+
`Consolidate to a single mechanism or design token.`, { severity: 'warning', rule: 'styles/mechanism-fragmentation' }));
|
|
658
|
+
}
|
|
659
|
+
return violations;
|
|
660
|
+
}
|
|
661
|
+
function flagFileMechanismMixing(declarations, minMechanisms, report) {
|
|
662
|
+
const violations = [];
|
|
663
|
+
const fileMechs = new Map();
|
|
664
|
+
for (const d of declarations) {
|
|
665
|
+
const mechs = fileMechs.get(d.file_path) || new Set();
|
|
666
|
+
mechs.add(d.mechanism);
|
|
667
|
+
fileMechs.set(d.file_path, mechs);
|
|
668
|
+
}
|
|
669
|
+
for (const [file, mechs] of fileMechs) {
|
|
670
|
+
if (mechs.size < minMechanisms)
|
|
671
|
+
continue;
|
|
672
|
+
violations.push(report(file, 1, `Mechanism mixing: ${file} uses ${mechs.size} different style ` +
|
|
673
|
+
`mechanisms (${[...mechs].sort().join(', ')}). ` +
|
|
674
|
+
`Consolidate to fewer mechanisms for maintainability.`, { severity: 'suggestion', rule: 'styles/mechanism-mixing' }));
|
|
675
|
+
}
|
|
676
|
+
return violations;
|
|
677
|
+
}
|
|
678
|
+
function buildDeclarationBlocks(declarations, minDeclarations) {
|
|
679
|
+
const blocks = new Map();
|
|
680
|
+
for (const d of declarations) {
|
|
681
|
+
if (!d.context)
|
|
682
|
+
continue;
|
|
683
|
+
const key = `${d.file_path}::${d.context}`;
|
|
684
|
+
const list = blocks.get(key) || [];
|
|
685
|
+
list.push(d);
|
|
686
|
+
blocks.set(key, list);
|
|
687
|
+
}
|
|
688
|
+
return [...blocks.entries()]
|
|
689
|
+
.map(([key, decls]) => {
|
|
690
|
+
const valueSet = new Set(decls.map(d => `${d.property}:${d.normalized_value ?? d.raw_value}`));
|
|
691
|
+
return {
|
|
692
|
+
key,
|
|
693
|
+
filePath: decls[0].file_path,
|
|
694
|
+
context: decls[0].context,
|
|
695
|
+
line: decls[0].line,
|
|
696
|
+
declCount: decls.length,
|
|
697
|
+
valueSet,
|
|
698
|
+
};
|
|
699
|
+
})
|
|
700
|
+
.filter(b => b.declCount >= minDeclarations);
|
|
701
|
+
}
|
|
702
|
+
function flagSimilarBlockPairs(blockEntries, threshold, report) {
|
|
703
|
+
const violations = [];
|
|
704
|
+
if (blockEntries.length < 2)
|
|
705
|
+
return violations;
|
|
706
|
+
const reported = new Set();
|
|
707
|
+
for (let i = 0; i < blockEntries.length; i++) {
|
|
708
|
+
for (let j = i + 1; j < blockEntries.length; j++) {
|
|
709
|
+
const a = blockEntries[i];
|
|
710
|
+
const b = blockEntries[j];
|
|
711
|
+
if (a.key === b.key)
|
|
712
|
+
continue;
|
|
713
|
+
const pairKey = [a.key, b.key].sort().join('::');
|
|
714
|
+
if (reported.has(pairKey))
|
|
715
|
+
continue;
|
|
716
|
+
reported.add(pairKey);
|
|
717
|
+
const intersection = new Set([...a.valueSet].filter(x => b.valueSet.has(x)));
|
|
718
|
+
const union = new Set([...a.valueSet, ...b.valueSet]);
|
|
719
|
+
const similarity = intersection.size / union.size;
|
|
720
|
+
if (similarity >= threshold) {
|
|
721
|
+
violations.push(report(a.filePath, a.line, `Declaration-set similarity: "${a.context}" and "${b.context}" ` +
|
|
722
|
+
`in ${b.filePath} share ${intersection.size} of ${union.size} ` +
|
|
723
|
+
`declarations (${(similarity * 100).toFixed(0)}%). ` +
|
|
724
|
+
`Consider consolidating these rules or extracting a shared mixin.`, { severity: 'suggestion', rule: 'styles/declaration-set-similarity' }));
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
return violations;
|
|
729
|
+
}
|
|
730
|
+
function buildTokenValueMap(tokens) {
|
|
731
|
+
const tokenValueMap = new Map();
|
|
732
|
+
for (const t of tokens) {
|
|
733
|
+
const normalizedTokenVal = normalizeValue(t.value, '__token__');
|
|
734
|
+
tokenValueMap.set(t.value, { name: t.name, valueType: normalizedTokenVal?.type ?? null });
|
|
735
|
+
}
|
|
736
|
+
return tokenValueMap;
|
|
737
|
+
}
|
|
738
|
+
function groupDeclarationsByProperty(declarations) {
|
|
739
|
+
const byProperty = new Map();
|
|
740
|
+
for (const d of declarations) {
|
|
741
|
+
const list = byProperty.get(d.property) || [];
|
|
742
|
+
list.push(d);
|
|
743
|
+
byProperty.set(d.property, list);
|
|
744
|
+
}
|
|
745
|
+
return byProperty;
|
|
746
|
+
}
|
|
747
|
+
function applySeverityOverrides(violations, config) {
|
|
748
|
+
const severityOverrides = config.severityOverrides ?? {};
|
|
749
|
+
if (Object.keys(severityOverrides).length === 0)
|
|
750
|
+
return;
|
|
751
|
+
for (const v of violations) {
|
|
752
|
+
const override = severityOverrides[v.rule];
|
|
753
|
+
if (override) {
|
|
754
|
+
v.severity = override;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
function buildStylesResult(spec) {
|
|
759
|
+
return {
|
|
760
|
+
violations: spec.violations,
|
|
761
|
+
errors: spec.errors ?? [],
|
|
762
|
+
status: makeVisitorStatus(spec.fileCount),
|
|
763
|
+
executionTime: Date.now() - spec.startTime,
|
|
764
|
+
analyzerName: spec.name,
|
|
765
|
+
metrics: {
|
|
766
|
+
filesAnalyzed: spec.fileCount,
|
|
767
|
+
totalViolations: spec.violations.length,
|
|
768
|
+
executionTime: Date.now() - spec.startTime,
|
|
769
|
+
},
|
|
770
|
+
};
|
|
771
|
+
}
|
|
772
|
+
class StylesStructureDetectors {
|
|
773
|
+
makeViolation;
|
|
774
|
+
constructor(makeViolation) {
|
|
775
|
+
this.makeViolation = makeViolation;
|
|
776
|
+
}
|
|
392
777
|
// -----------------------------------------------------------------------
|
|
393
778
|
// Detector 3: Undefined Classes
|
|
394
779
|
// -----------------------------------------------------------------------
|
|
@@ -404,104 +789,24 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
404
789
|
*/
|
|
405
790
|
async detectUndefinedClasses(classUsage, _declarations, byProperty, cfg) {
|
|
406
791
|
const violations = [];
|
|
407
|
-
|
|
408
|
-
const definedClasses = new Set();
|
|
409
|
-
for (const [, decls] of byProperty) {
|
|
410
|
-
for (const d of decls) {
|
|
411
|
-
const ctx = d.context;
|
|
412
|
-
if (!ctx)
|
|
413
|
-
continue;
|
|
414
|
-
const matches = ctx.matchAll(/\.([a-zA-Z0-9_-]+)/g);
|
|
415
|
-
for (const m of matches) {
|
|
416
|
-
definedClasses.add(m[1]);
|
|
417
|
-
}
|
|
418
|
-
}
|
|
419
|
-
}
|
|
420
|
-
// ── Init compile-probe (async) ─────────────────────────────────
|
|
792
|
+
const definedClassCatalog = collectDefinedClassCatalog(byProperty);
|
|
421
793
|
const expander = getTailwindExpander();
|
|
422
|
-
await expander.init({
|
|
423
|
-
projectRoot: cfg?.projectRoot,
|
|
424
|
-
useProjectConfig: true,
|
|
425
|
-
customClasses: cfg?.tailwindClasses ? new Set(cfg.tailwindClasses) : undefined,
|
|
426
|
-
});
|
|
427
794
|
// Fail-open rule (Spec 22 R1.3): if the probe fails and Tailwind IS
|
|
428
|
-
// present, disable the undefined-class detector.
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
//
|
|
433
|
-
|
|
434
|
-
if (expander.configFailed && expander.hasTailwindConfig) {
|
|
435
|
-
violations.push(this.makeViolation('', 0, `Tailwind probe unavailable (${expander.configFailureReason ?? 'unknown error'}) — ` +
|
|
436
|
-
`undefined-class detection skipped. Classes defined only in Tailwind ` +
|
|
437
|
-
`config will not be checked. Install tailwindcss in the project ` +
|
|
438
|
-
`for full class validation.`, 'suggestion', 'styles/undefined-class-disabled'));
|
|
439
|
-
return violations;
|
|
440
|
-
}
|
|
441
|
-
// ── Collect candidate class names for batch probing ─────────────
|
|
442
|
-
// Gather all potentially-unknown class names first, then batch-probe
|
|
443
|
-
// them against the project's Tailwind compiler. This is more efficient
|
|
444
|
-
// than probing one-at-a-time and lets the probe batch in groups.
|
|
445
|
-
const seen = new Set();
|
|
446
|
-
const candidates = [];
|
|
447
|
-
const usageEntries = [];
|
|
448
|
-
for (const u of classUsage) {
|
|
449
|
-
const key = `${u.class_name}::${u.file_path}`;
|
|
450
|
-
if (seen.has(key))
|
|
451
|
-
continue;
|
|
452
|
-
seen.add(key);
|
|
453
|
-
// Skip unresolvable individual class usages
|
|
454
|
-
if (u.unresolvable)
|
|
455
|
-
continue;
|
|
456
|
-
// Skip known classes from CSS definitions
|
|
457
|
-
if (definedClasses.has(u.class_name))
|
|
458
|
-
continue;
|
|
459
|
-
// Skip PascalCase — likely a component
|
|
460
|
-
if (/^[A-Z]/.test(u.class_name))
|
|
461
|
-
continue;
|
|
462
|
-
// Skip function-like
|
|
463
|
-
if (u.class_name.includes('('))
|
|
464
|
-
continue;
|
|
465
|
-
// Skip numeric
|
|
466
|
-
if (/^\d/.test(u.class_name))
|
|
467
|
-
continue;
|
|
468
|
-
// Skip classes that start/end with [] — not valid CSS class names
|
|
469
|
-
if (u.class_name.startsWith('[') || u.class_name.startsWith(']'))
|
|
470
|
-
continue;
|
|
471
|
-
if (u.class_name.endsWith('[') || u.class_name.endsWith(']'))
|
|
472
|
-
continue;
|
|
473
|
-
// Skip extraction artifacts
|
|
474
|
-
if (/['`"${}?;!@#%^&*+=<>|\\,~]/.test(u.class_name))
|
|
475
|
-
continue;
|
|
476
|
-
usageEntries.push(u);
|
|
477
|
-
candidates.push(u.class_name);
|
|
478
|
-
}
|
|
479
|
-
// ── Batch-probe unknown classes ─────────────────────────────────
|
|
795
|
+
// present, disable the undefined-class detector.
|
|
796
|
+
const failOpen = await initTailwindProbe(expander, cfg, this.makeViolation.bind(this));
|
|
797
|
+
if (failOpen)
|
|
798
|
+
return failOpen;
|
|
799
|
+
// Gather all potentially-unknown class names first, then batch-probe.
|
|
800
|
+
const { candidates, usageEntries } = collectUndefinedClassCandidates(classUsage, definedClassCatalog.names);
|
|
480
801
|
if (candidates.length > 0 && expander.probeReady) {
|
|
481
802
|
await expander.validateBatch(candidates);
|
|
482
803
|
}
|
|
483
|
-
|
|
484
|
-
for (const u of usageEntries) {
|
|
485
|
-
const resolved = expander.resolve(u.class_name);
|
|
486
|
-
if (resolved.valid)
|
|
487
|
-
continue;
|
|
488
|
-
violations.push(this.makeViolation(u.file_path, u.line, `Undefined CSS class: "${u.class_name}" has no matching definition ` +
|
|
489
|
-
`in any stylesheet, Tailwind utility set, or project config.`, 'warning', 'styles/undefined-class'));
|
|
490
|
-
}
|
|
804
|
+
violations.push(...flagUnresolvedClasses(usageEntries, expander, this.makeViolation.bind(this), definedClassCatalog));
|
|
491
805
|
return violations;
|
|
492
806
|
}
|
|
493
807
|
// -----------------------------------------------------------------------
|
|
494
808
|
// Detector 4: Token Bypass
|
|
495
809
|
// -----------------------------------------------------------------------
|
|
496
|
-
/**
|
|
497
|
-
* Spec 22 Item 1 — Values so common that coincidental token-name matches
|
|
498
|
-
* are always noise. Filtered before any token comparison.
|
|
499
|
-
*/
|
|
500
|
-
static TRIVIAL_VALUES = new Set([
|
|
501
|
-
'0', '0px', '0rem', '0em', '0%', 'none', 'transparent',
|
|
502
|
-
'inherit', 'initial', 'unset', 'currentcolor', 'auto',
|
|
503
|
-
'100%', '50%',
|
|
504
|
-
]);
|
|
505
810
|
/**
|
|
506
811
|
* Flag raw values that match a known design token's value but don't
|
|
507
812
|
* reference the token via tokenRef.
|
|
@@ -524,74 +829,15 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
524
829
|
const categoricalExclusions = new Set(cfg.categoricalPropertyExclusions ?? []);
|
|
525
830
|
const scaleProps = new Set(cfg.scaleProperties ?? []);
|
|
526
831
|
for (const d of declarations) {
|
|
527
|
-
|
|
528
|
-
if (d.token_ref)
|
|
529
|
-
continue;
|
|
530
|
-
// Skip CSS custom-property definition sites — these define
|
|
531
|
-
// token values, so literals are expected (Spec 22 R2.1).
|
|
532
|
-
if (d.property.startsWith('--'))
|
|
533
|
-
continue;
|
|
534
|
-
// Skip SCSS variable definitions — these define token values
|
|
535
|
-
// so literals are expected (Spec 22 R2.1).
|
|
536
|
-
if (d.property.startsWith('$'))
|
|
537
|
-
continue;
|
|
538
|
-
// Spec 22 R2.2: skip categorical properties — they have a small set
|
|
539
|
-
// of valid keyword values (display, position, etc.) and coincidental
|
|
540
|
-
// token-value matches are noise.
|
|
541
|
-
if (categoricalExclusions.has(d.property))
|
|
542
|
-
continue;
|
|
543
|
-
// Spec 22 R2.3: skip scale properties — values naturally vary along
|
|
544
|
-
// a scale (font-size, margin, padding, etc.) and raw values at
|
|
545
|
-
// different scale points are expected, not token bypasses.
|
|
546
|
-
if (scaleProps.has(d.property))
|
|
547
|
-
continue;
|
|
548
|
-
// Normalize the raw value for comparison
|
|
549
|
-
const normalized = this.normalizeForTokenMatch(d.raw_value);
|
|
550
|
-
// Spec 22 Item 1: skip trivial values — they're so common that
|
|
551
|
-
// coincidental token-name matches are always noise.
|
|
552
|
-
if (UniversalStylesAnalyzer.TRIVIAL_VALUES.has(normalized))
|
|
553
|
-
continue;
|
|
554
|
-
const tokenInfo = tokenValueMap.get(normalized);
|
|
832
|
+
const tokenInfo = matchBypassToken(d, tokenValueMap, categoricalExclusions, scaleProps);
|
|
555
833
|
if (!tokenInfo)
|
|
556
834
|
continue;
|
|
557
|
-
// Spec 22 Item 1: value-type equality gate — a token only
|
|
558
|
-
// matches declarations whose normalized value type is the same.
|
|
559
|
-
// E.g., --border-radius-lg: 8px (length) should not flag
|
|
560
|
-
// max-width: 8px because the normalizer types them the same, but
|
|
561
|
-
// --color-red: #22d3ee (color) should flag color: #22d3ee.
|
|
562
|
-
if (tokenInfo.valueType !== null) {
|
|
563
|
-
let declType = null;
|
|
564
|
-
if (d.normalized_value) {
|
|
565
|
-
try {
|
|
566
|
-
const nv = JSON.parse(d.normalized_value);
|
|
567
|
-
declType = nv.type;
|
|
568
|
-
}
|
|
569
|
-
catch { /* invalid JSON — proceed neutrally */ }
|
|
570
|
-
}
|
|
571
|
-
if (declType !== null && declType !== tokenInfo.valueType)
|
|
572
|
-
continue;
|
|
573
|
-
}
|
|
574
|
-
// Only color-typed tokens are specific enough to flag;
|
|
575
|
-
// lengths collide by nature (e.g., 6px matching border-radius tokens).
|
|
576
|
-
if (tokenInfo.valueType !== 'color')
|
|
577
|
-
continue;
|
|
578
835
|
violations.push(this.makeViolation(d.file_path, d.line, `Token bypass: "${d.raw_value}" for "${d.property}" matches design ` +
|
|
579
836
|
`token "${tokenInfo.name}" but was used as a raw value. ` +
|
|
580
|
-
`Use the token reference instead to keep styles consistent.`, 'warning', 'styles/token-bypass'));
|
|
837
|
+
`Use the token reference instead to keep styles consistent.`, { severity: 'warning', rule: 'styles/token-bypass' }));
|
|
581
838
|
}
|
|
582
839
|
return violations;
|
|
583
840
|
}
|
|
584
|
-
/** Normalize a raw value for token-value comparison. */
|
|
585
|
-
normalizeForTokenMatch(raw) {
|
|
586
|
-
let v = raw.toLowerCase().trim();
|
|
587
|
-
// Remove spaces after commas in functional notation
|
|
588
|
-
v = v.replace(/,\s+/g, ',');
|
|
589
|
-
// Expand shorthand hex: #fff → #ffffff
|
|
590
|
-
if (/^#[0-9a-f]{3}$/.test(v)) {
|
|
591
|
-
v = '#' + v[1] + v[1] + v[2] + v[2] + v[3] + v[3];
|
|
592
|
-
}
|
|
593
|
-
return v;
|
|
594
|
-
}
|
|
595
841
|
// -----------------------------------------------------------------------
|
|
596
842
|
// Detector 5: Mechanism Fragmentation
|
|
597
843
|
// -----------------------------------------------------------------------
|
|
@@ -602,41 +848,10 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
602
848
|
*/
|
|
603
849
|
detectMechanismFragmentation(declarations, cfg) {
|
|
604
850
|
const violations = [];
|
|
605
|
-
|
|
606
|
-
const
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
const key = `${d.property}::${d.normalized_value ?? d.raw_value}`;
|
|
610
|
-
const mechs = pvMap.get(key) || new Set();
|
|
611
|
-
mechs.add(d.mechanism);
|
|
612
|
-
pvMap.set(key, mechs);
|
|
613
|
-
if (!pvSample.has(key)) {
|
|
614
|
-
pvSample.set(key, d);
|
|
615
|
-
}
|
|
616
|
-
}
|
|
617
|
-
for (const [key, mechs] of pvMap) {
|
|
618
|
-
if (mechs.size < cfg.mechanismFragmentationMinMechanisms)
|
|
619
|
-
continue;
|
|
620
|
-
const sample = pvSample.get(key);
|
|
621
|
-
const [prop, value] = key.split('::');
|
|
622
|
-
violations.push(this.makeViolation(sample.file_path, sample.line, `Mechanism fragmentation: "${prop}: ${value}" is applied via ` +
|
|
623
|
-
`${mechs.size} different mechanisms (${[...mechs].sort().join(', ')}). ` +
|
|
624
|
-
`Consolidate to a single mechanism or design token.`, 'warning', 'styles/mechanism-fragmentation'));
|
|
625
|
-
}
|
|
626
|
-
// (B) Per-file: a single file mixing ≥3 mechanisms
|
|
627
|
-
const fileMechs = new Map();
|
|
628
|
-
for (const d of declarations) {
|
|
629
|
-
const mechs = fileMechs.get(d.file_path) || new Set();
|
|
630
|
-
mechs.add(d.mechanism);
|
|
631
|
-
fileMechs.set(d.file_path, mechs);
|
|
632
|
-
}
|
|
633
|
-
for (const [file, mechs] of fileMechs) {
|
|
634
|
-
if (mechs.size < cfg.mechanismFragmentationMinMechanisms)
|
|
635
|
-
continue;
|
|
636
|
-
violations.push(this.makeViolation(file, 1, `Mechanism mixing: ${file} uses ${mechs.size} different style ` +
|
|
637
|
-
`mechanisms (${[...mechs].sort().join(', ')}). ` +
|
|
638
|
-
`Consolidate to fewer mechanisms for maintainability.`, 'suggestion', 'styles/mechanism-mixing'));
|
|
639
|
-
}
|
|
851
|
+
const report = this.makeViolation.bind(this);
|
|
852
|
+
const minMechanisms = cfg.mechanismFragmentationMinMechanisms;
|
|
853
|
+
violations.push(...flagPropertyValueFragmentation(declarations, minMechanisms, report));
|
|
854
|
+
violations.push(...flagFileMechanismMixing(declarations, minMechanisms, report));
|
|
640
855
|
return violations;
|
|
641
856
|
}
|
|
642
857
|
// -----------------------------------------------------------------------
|
|
@@ -648,59 +863,8 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
648
863
|
* This catches near-duplicate CSS rules that share most declarations.
|
|
649
864
|
*/
|
|
650
865
|
detectDeclarationSetSimilarity(declarations, cfg) {
|
|
651
|
-
const
|
|
652
|
-
|
|
653
|
-
const blocks = new Map();
|
|
654
|
-
for (const d of declarations) {
|
|
655
|
-
if (!d.context)
|
|
656
|
-
continue;
|
|
657
|
-
const key = `${d.file_path}::${d.context}`;
|
|
658
|
-
const list = blocks.get(key) || [];
|
|
659
|
-
list.push(d);
|
|
660
|
-
blocks.set(key, list);
|
|
661
|
-
}
|
|
662
|
-
// Convert blocks to declaration-value sets
|
|
663
|
-
const blockEntries = [...blocks.entries()]
|
|
664
|
-
.map(([key, decls]) => {
|
|
665
|
-
const valueSet = new Set(decls.map(d => `${d.property}:${d.normalized_value ?? d.raw_value}`));
|
|
666
|
-
return {
|
|
667
|
-
key,
|
|
668
|
-
filePath: decls[0].file_path,
|
|
669
|
-
context: decls[0].context,
|
|
670
|
-
line: decls[0].line,
|
|
671
|
-
declCount: decls.length,
|
|
672
|
-
valueSet,
|
|
673
|
-
};
|
|
674
|
-
})
|
|
675
|
-
.filter(b => b.declCount >= cfg.declarationSetMinDeclarations);
|
|
676
|
-
if (blockEntries.length < 2)
|
|
677
|
-
return violations;
|
|
678
|
-
// Compare pairs
|
|
679
|
-
const reported = new Set(); // "keyA::keyB"
|
|
680
|
-
for (let i = 0; i < blockEntries.length; i++) {
|
|
681
|
-
for (let j = i + 1; j < blockEntries.length; j++) {
|
|
682
|
-
const a = blockEntries[i];
|
|
683
|
-
const b = blockEntries[j];
|
|
684
|
-
// Skip same-file same-context (already the same block) or same key
|
|
685
|
-
if (a.key === b.key)
|
|
686
|
-
continue;
|
|
687
|
-
const pairKey = [a.key, b.key].sort().join('::');
|
|
688
|
-
if (reported.has(pairKey))
|
|
689
|
-
continue;
|
|
690
|
-
reported.add(pairKey);
|
|
691
|
-
// Compute Jaccard similarity
|
|
692
|
-
const intersection = new Set([...a.valueSet].filter(x => b.valueSet.has(x)));
|
|
693
|
-
const union = new Set([...a.valueSet, ...b.valueSet]);
|
|
694
|
-
const similarity = intersection.size / union.size;
|
|
695
|
-
if (similarity >= cfg.declarationSetSimilarityThreshold) {
|
|
696
|
-
violations.push(this.makeViolation(a.filePath, a.line, `Declaration-set similarity: "${a.context}" and "${b.context}" ` +
|
|
697
|
-
`in ${b.filePath} share ${intersection.size} of ${union.size} ` +
|
|
698
|
-
`declarations (${(similarity * 100).toFixed(0)}%). ` +
|
|
699
|
-
`Consider consolidating these rules or extracting a shared mixin.`, 'suggestion', 'styles/declaration-set-similarity'));
|
|
700
|
-
}
|
|
701
|
-
}
|
|
702
|
-
}
|
|
703
|
-
return violations;
|
|
866
|
+
const blockEntries = buildDeclarationBlocks(declarations, cfg.declarationSetMinDeclarations);
|
|
867
|
+
return flagSimilarBlockPairs(blockEntries, cfg.declarationSetSimilarityThreshold, this.makeViolation.bind(this));
|
|
704
868
|
}
|
|
705
869
|
// -----------------------------------------------------------------------
|
|
706
870
|
// Detector 7: Z-Index Inventory
|
|
@@ -732,139 +896,116 @@ export class UniversalStylesAnalyzer extends UniversalAnalyzer {
|
|
|
732
896
|
const sample = values.get(sortedVals[0])[0];
|
|
733
897
|
violations.push(this.makeViolation(sample.file_path, sample.line, `Z-index sprawl: ${values.size} distinct z-index values ` +
|
|
734
898
|
`(${sortedVals.join(', ')}). Consider defining a z-index scale ` +
|
|
735
|
-
`(e.g., $z-layers: (dropdown: 100, modal: 200, toast: 300)).`, 'warning', 'styles/z-index-sprawl'));
|
|
899
|
+
`(e.g., $z-layers: (dropdown: 100, modal: 200, toast: 300)).`, { severity: 'warning', rule: 'styles/z-index-sprawl' }));
|
|
736
900
|
}
|
|
737
901
|
// Flag singletons (z-index values used only once)
|
|
738
902
|
for (const [val, list] of values) {
|
|
739
903
|
if (list.length === 1 && values.size > 2) {
|
|
740
904
|
const d = list[0];
|
|
741
905
|
violations.push(this.makeViolation(d.file_path, d.line, `Singleton z-index: z-index: ${val} is used only once. ` +
|
|
742
|
-
`Consider whether this value belongs in a shared z-index scale.`, 'suggestion', 'styles/z-index-singleton'));
|
|
906
|
+
`Consider whether this value belongs in a shared z-index scale.`, { severity: 'suggestion', rule: 'styles/z-index-singleton' }));
|
|
743
907
|
}
|
|
744
908
|
}
|
|
745
909
|
return violations;
|
|
746
910
|
}
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
911
|
+
}
|
|
912
|
+
// ---------------------------------------------------------------------------
|
|
913
|
+
// Orchestration layer: queries the style index and dispatches to the
|
|
914
|
+
// detectors. This is the only exported class in the chain.
|
|
915
|
+
// ---------------------------------------------------------------------------
|
|
916
|
+
/**
|
|
917
|
+
* Universal styles analyzer.
|
|
918
|
+
*/
|
|
919
|
+
export class UniversalStylesAnalyzer extends UniversalStylesAnalyzerDetectors {
|
|
920
|
+
structure = new StylesStructureDetectors(this.makeViolation.bind(this));
|
|
921
|
+
/**
|
|
922
|
+
* Override analyze() to query the full style index in one pass instead
|
|
923
|
+
* of per-file AST processing. The base class analyze() loop is bypassed.
|
|
924
|
+
* @param config
|
|
925
|
+
* @param files
|
|
926
|
+
* @param options
|
|
927
|
+
* @returns
|
|
928
|
+
*/
|
|
929
|
+
async analyze(files, config = {}, options = {}) {
|
|
930
|
+
const startTime = Date.now();
|
|
931
|
+
const cfg = { ...DEFAULT_STYLES_CONFIG, ...config };
|
|
932
|
+
// Use the IndexHandle passed through the pipeline; fall back if absent.
|
|
933
|
+
const indexHandle = config.indexHandle;
|
|
934
|
+
if (!indexHandle) {
|
|
935
|
+
return buildStylesResult({
|
|
936
|
+
violations: [],
|
|
937
|
+
errors: [{ file: '', error: 'No index handle available — style index not open' }],
|
|
938
|
+
fileCount: 0,
|
|
939
|
+
startTime,
|
|
940
|
+
name: this.name,
|
|
941
|
+
});
|
|
762
942
|
}
|
|
763
|
-
|
|
943
|
+
const declarations = this.queryDeclarations(indexHandle);
|
|
944
|
+
if (declarations.length === 0) {
|
|
945
|
+
return buildStylesResult({
|
|
946
|
+
violations: [],
|
|
947
|
+
fileCount: files.length,
|
|
948
|
+
startTime,
|
|
949
|
+
name: this.name,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
const violations = await this.runDetectors({
|
|
953
|
+
byProperty: groupDeclarationsByProperty(declarations),
|
|
954
|
+
cfg,
|
|
955
|
+
declarations,
|
|
956
|
+
classUsage: this.queryClassUsage(indexHandle),
|
|
957
|
+
tokenValueMap: buildTokenValueMap(this.queryTokens(indexHandle)),
|
|
958
|
+
});
|
|
959
|
+
applySeverityOverrides(violations, config);
|
|
960
|
+
return buildStylesResult({
|
|
961
|
+
violations: violations.filter(v => v.severity !== 'off'),
|
|
962
|
+
fileCount: new Set(declarations.map(d => d.file_path)).size,
|
|
963
|
+
startTime,
|
|
964
|
+
name: this.name,
|
|
965
|
+
});
|
|
764
966
|
}
|
|
765
|
-
/**
|
|
766
|
-
|
|
967
|
+
/** Run all detectors and return their combined violations. */
|
|
968
|
+
async runDetectors(inputs) {
|
|
969
|
+
const { byProperty, cfg, declarations, classUsage, tokenValueMap } = inputs;
|
|
970
|
+
const violations = [];
|
|
971
|
+
violations.push(...this.detectValueDrift(byProperty, cfg, declarations));
|
|
972
|
+
violations.push(...this.detectOffScaleValues(byProperty, cfg));
|
|
973
|
+
violations.push(...await this.structure.detectUndefinedClasses(classUsage, declarations, byProperty, cfg));
|
|
974
|
+
violations.push(...this.structure.detectTokenBypass(declarations, tokenValueMap, cfg));
|
|
975
|
+
violations.push(...this.structure.detectMechanismFragmentation(declarations, cfg));
|
|
976
|
+
violations.push(...this.structure.detectDeclarationSetSimilarity(declarations, cfg));
|
|
977
|
+
violations.push(...this.structure.detectZIndexInventory(byProperty, cfg));
|
|
978
|
+
return violations;
|
|
979
|
+
}
|
|
980
|
+
/** Not used — we override analyze() directly. */
|
|
981
|
+
async analyzeAST(_ast, _adapter, _config, _sourceCode) {
|
|
982
|
+
return [];
|
|
983
|
+
}
|
|
984
|
+
// -----------------------------------------------------------------------
|
|
985
|
+
// Database queries
|
|
986
|
+
// -----------------------------------------------------------------------
|
|
987
|
+
queryDeclarations(indexHandle) {
|
|
767
988
|
try {
|
|
768
|
-
|
|
769
|
-
// Hex
|
|
770
|
-
if (v.startsWith('#')) {
|
|
771
|
-
if (v.length === 4) {
|
|
772
|
-
// #rgb → #rrggbb
|
|
773
|
-
v = '#' + v[1] + v[1] + v[2] + v[2] + v[3] + v[3];
|
|
774
|
-
}
|
|
775
|
-
if (v.length === 7) {
|
|
776
|
-
return [
|
|
777
|
-
parseInt(v.slice(1, 3), 16),
|
|
778
|
-
parseInt(v.slice(3, 5), 16),
|
|
779
|
-
parseInt(v.slice(5, 7), 16),
|
|
780
|
-
];
|
|
781
|
-
}
|
|
782
|
-
if (v.length === 9) {
|
|
783
|
-
return [
|
|
784
|
-
parseInt(v.slice(1, 3), 16),
|
|
785
|
-
parseInt(v.slice(3, 5), 16),
|
|
786
|
-
parseInt(v.slice(5, 7), 16),
|
|
787
|
-
];
|
|
788
|
-
}
|
|
789
|
-
}
|
|
790
|
-
// rgb(r, g, b) or rgb(r g b)
|
|
791
|
-
const rgbMatch = v.match(/rgb\(\s*(\d+)\s*,?\s*(\d+)\s*,?\s*(\d+)\s*\)/);
|
|
792
|
-
if (rgbMatch) {
|
|
793
|
-
return [
|
|
794
|
-
parseInt(rgbMatch[1]),
|
|
795
|
-
parseInt(rgbMatch[2]),
|
|
796
|
-
parseInt(rgbMatch[3]),
|
|
797
|
-
];
|
|
798
|
-
}
|
|
799
|
-
// Named colors — minimal set for common use
|
|
800
|
-
const named = {
|
|
801
|
-
'white': [255, 255, 255], 'black': [0, 0, 0],
|
|
802
|
-
'red': [255, 0, 0], 'blue': [0, 0, 255], 'green': [0, 128, 0],
|
|
803
|
-
'transparent': [0, 0, 0],
|
|
804
|
-
};
|
|
805
|
-
if (named[v])
|
|
806
|
-
return named[v];
|
|
807
|
-
return null;
|
|
989
|
+
return indexHandle.query('SELECT * FROM style_declarations ORDER BY property, file_path, line');
|
|
808
990
|
}
|
|
809
991
|
catch {
|
|
810
|
-
return
|
|
992
|
+
return [];
|
|
811
993
|
}
|
|
812
994
|
}
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
}
|
|
820
|
-
/**
|
|
821
|
-
* Cluster colors by delta-E distance.
|
|
822
|
-
* Simple greedy algorithm: each item joins the first cluster it's close enough to,
|
|
823
|
-
* or starts a new cluster.
|
|
824
|
-
*/
|
|
825
|
-
clusterByDeltaE(items, threshold) {
|
|
826
|
-
const clusters = [];
|
|
827
|
-
for (const item of items) {
|
|
828
|
-
let placed = false;
|
|
829
|
-
for (const cluster of clusters) {
|
|
830
|
-
// Use the first item's RGB as cluster centroid
|
|
831
|
-
const centroid = cluster[0].rgb;
|
|
832
|
-
if (this.deltaE(item.rgb, centroid) < threshold) {
|
|
833
|
-
cluster.push(item);
|
|
834
|
-
placed = true;
|
|
835
|
-
break;
|
|
836
|
-
}
|
|
837
|
-
}
|
|
838
|
-
if (!placed) {
|
|
839
|
-
clusters.push([item]);
|
|
840
|
-
}
|
|
995
|
+
queryTokens(indexHandle) {
|
|
996
|
+
try {
|
|
997
|
+
return indexHandle.query('SELECT * FROM style_tokens');
|
|
998
|
+
}
|
|
999
|
+
catch {
|
|
1000
|
+
return [];
|
|
841
1001
|
}
|
|
842
|
-
return clusters;
|
|
843
1002
|
}
|
|
844
|
-
|
|
845
|
-
parseLengthToPx(raw) {
|
|
1003
|
+
queryClassUsage(indexHandle) {
|
|
846
1004
|
try {
|
|
847
|
-
|
|
848
|
-
if (v === '0' || v === '0px')
|
|
849
|
-
return 0;
|
|
850
|
-
const match = v.match(/^(-?\d+(?:\.\d+)?)\s*(px|rem|em|%|vh|vw|pt|cm|mm)?$/);
|
|
851
|
-
if (!match)
|
|
852
|
-
return null;
|
|
853
|
-
const num = parseFloat(match[1]);
|
|
854
|
-
const unit = match[2] || 'px';
|
|
855
|
-
// Approximate conversions (assuming 16px base for rem/em)
|
|
856
|
-
switch (unit) {
|
|
857
|
-
case 'px': return num;
|
|
858
|
-
case 'rem': return num * 16;
|
|
859
|
-
case 'em': return num * 16;
|
|
860
|
-
case 'pt': return num * 1.333;
|
|
861
|
-
case 'cm': return num * 37.795;
|
|
862
|
-
case 'mm': return num * 3.7795;
|
|
863
|
-
default: return null; // can't convert %/vh/vw without context
|
|
864
|
-
}
|
|
1005
|
+
return indexHandle.query('SELECT * FROM style_class_usage');
|
|
865
1006
|
}
|
|
866
1007
|
catch {
|
|
867
|
-
return
|
|
1008
|
+
return [];
|
|
868
1009
|
}
|
|
869
1010
|
}
|
|
870
1011
|
}
|