@ksmv/ui-checks 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +5 -0
- package/LICENSE +202 -0
- package/README.md +12 -0
- package/dist/baseline.d.ts +45 -0
- package/dist/baseline.js +228 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +137 -0
- package/dist/color.d.ts +14 -0
- package/dist/color.js +192 -0
- package/dist/config.d.ts +23 -0
- package/dist/config.js +3 -0
- package/dist/contracts.d.ts +40 -0
- package/dist/contracts.js +138 -0
- package/dist/contrast-contract.json +38 -0
- package/dist/evidence.d.ts +24 -0
- package/dist/evidence.js +132 -0
- package/dist/governance.d.ts +9 -0
- package/dist/governance.js +75 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +12 -0
- package/dist/result.d.ts +65 -0
- package/dist/result.js +42 -0
- package/dist/rules/default.d.ts +10 -0
- package/dist/rules/default.js +16 -0
- package/dist/rules/noLiteralColor.d.ts +6 -0
- package/dist/rules/noLiteralColor.js +126 -0
- package/dist/rules/theme.d.ts +23 -0
- package/dist/rules/theme.js +128 -0
- package/dist/rules/tokenComplete.d.ts +11 -0
- package/dist/rules/tokenComplete.js +115 -0
- package/dist/rules/tokenContrast.d.ts +11 -0
- package/dist/rules/tokenContrast.js +170 -0
- package/dist/rules/tokenPrefix.d.ts +2 -0
- package/dist/rules/tokenPrefix.js +62 -0
- package/dist/scan.d.ts +40 -0
- package/dist/scan.js +398 -0
- package/dist/suppressions.d.ts +9 -0
- package/dist/suppressions.js +126 -0
- package/dist/theme-contract.json +82 -0
- package/package.json +33 -0
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import { compareBaseline, validateBaseline } from "./baseline.js";
|
|
4
|
+
import { validateSuppressions } from "./suppressions.js";
|
|
5
|
+
export function resolveReportExitCode(report, failOnUncovered) {
|
|
6
|
+
if (report.operationalErrors.length > 0 ||
|
|
7
|
+
report.uncovered.some(({ required }) => required === true) ||
|
|
8
|
+
(failOnUncovered && report.uncovered.length > 0)) {
|
|
9
|
+
return 2;
|
|
10
|
+
}
|
|
11
|
+
return report.violations.length > 0 ? 1 : 0;
|
|
12
|
+
}
|
|
13
|
+
export function applyConfiguredSuppressions(report, config) {
|
|
14
|
+
if (config.suppressions === undefined) {
|
|
15
|
+
return { issues: [], valid: true };
|
|
16
|
+
}
|
|
17
|
+
const validation = validateSuppressions(config.suppressions, report.violations);
|
|
18
|
+
if (validation.exitCode === 2) {
|
|
19
|
+
report.operationalErrors.push({
|
|
20
|
+
code: "config",
|
|
21
|
+
message: "Configured suppressions are invalid.",
|
|
22
|
+
});
|
|
23
|
+
return { issues: [], valid: false };
|
|
24
|
+
}
|
|
25
|
+
report.suppressions = validation.used;
|
|
26
|
+
report.violations = [...validation.remaining, ...validation.issues];
|
|
27
|
+
return { issues: validation.issues, valid: true };
|
|
28
|
+
}
|
|
29
|
+
async function readBaseline(path) {
|
|
30
|
+
const parsed = JSON.parse(await readFile(path, "utf8"));
|
|
31
|
+
const validation = validateBaseline(parsed);
|
|
32
|
+
if (!validation.baseline) {
|
|
33
|
+
throw new Error("invalid-baseline");
|
|
34
|
+
}
|
|
35
|
+
return validation.baseline;
|
|
36
|
+
}
|
|
37
|
+
export async function applyConfiguredBaseline(report, config, issues) {
|
|
38
|
+
if (!config.baseline) {
|
|
39
|
+
return;
|
|
40
|
+
}
|
|
41
|
+
try {
|
|
42
|
+
const path = resolve(config.root ?? process.cwd(), config.baseline);
|
|
43
|
+
const baseline = await readBaseline(path);
|
|
44
|
+
const issueSet = new Set(issues);
|
|
45
|
+
const currentViolations = report.violations.filter((violation) => !issueSet.has(violation));
|
|
46
|
+
const comparison = compareBaseline(currentViolations, baseline);
|
|
47
|
+
report.baselineMatches = comparison.baselineMatches;
|
|
48
|
+
report.baselineDebt = [
|
|
49
|
+
...new Set([
|
|
50
|
+
...Object.keys(comparison.debtByRule),
|
|
51
|
+
...Object.keys(comparison.reducedByRule),
|
|
52
|
+
]),
|
|
53
|
+
]
|
|
54
|
+
.sort()
|
|
55
|
+
.map((rule) => ({
|
|
56
|
+
active: comparison.debtByRule[rule] ?? 0,
|
|
57
|
+
reduced: comparison.reducedByRule[rule] ?? 0,
|
|
58
|
+
rule,
|
|
59
|
+
}));
|
|
60
|
+
report.violations = [...comparison.newViolations, ...issues];
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
report.operationalErrors.push({
|
|
64
|
+
code: "config",
|
|
65
|
+
message: "Configured baseline could not be read or validated.",
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export async function applyDebtControls(report, config) {
|
|
70
|
+
const suppressionResult = applyConfiguredSuppressions(report, config);
|
|
71
|
+
if (suppressionResult.valid) {
|
|
72
|
+
await applyConfiguredBaseline(report, config, suppressionResult.issues);
|
|
73
|
+
}
|
|
74
|
+
report.exitCode = resolveReportExitCode(report, config.failOnUncovered ?? true);
|
|
75
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { defineConfig } from "./config.js";
|
|
2
|
+
export type { ChecksConfig, ComputedEvidenceRef, ThemeConfig } from "./config.js";
|
|
3
|
+
export { parseContrastManifest, parseThemeContract, resolveContractSet, tokenNamePattern, } from "./contracts.js";
|
|
4
|
+
export type { ContractIdentity, ContractSet, ContractSource, ContrastManifest, ContrastPair, ThemeContract, } from "./contracts.js";
|
|
5
|
+
export { computeThemeSourceHash, loadComputedEvidence } from "./evidence.js";
|
|
6
|
+
export type { ThemeEvidence } from "./evidence.js";
|
|
7
|
+
export { formatHumanReport, formatJsonReport } from "./result.js";
|
|
8
|
+
export type { BaselineDebt, CheckReport, ExitCode, IgnoredFile, OperationalError, SourceLocation, Suppression, Uncovered, Violation, } from "./result.js";
|
|
9
|
+
export { approveBaselineGrowth, baselineFor, compareBaseline, fingerprintViolation, normalizeRelativePath, serializeBaseline, validateBaseline, writeBaselineFile, } from "./baseline.js";
|
|
10
|
+
export type { BaselineComparison, BaselineEntry, BaselineFile, BaselineMetadata, BaselineValidation, GrowthApproval, GrowthApprovalOptions, } from "./baseline.js";
|
|
11
|
+
export { validateSuppressions } from "./suppressions.js";
|
|
12
|
+
export type { SuppressionValidation } from "./suppressions.js";
|
|
13
|
+
export { runChecks } from "./scan.js";
|
|
14
|
+
export type { CheckRule, RuleResult, RunChecksOptions, ScannedFile, TypeScriptToken, } from "./scan.js";
|
|
15
|
+
export { createNoLiteralColorRule } from "./rules/noLiteralColor.js";
|
|
16
|
+
export type { NoLiteralColorOptions } from "./rules/noLiteralColor.js";
|
|
17
|
+
export { createDefaultRules } from "./rules/default.js";
|
|
18
|
+
export type { DefaultRulesOptions } from "./rules/default.js";
|
|
19
|
+
export { createTokenPrefixRule } from "./rules/tokenPrefix.js";
|
|
20
|
+
export { createTokenCompleteRule, loadThemeContract, } from "./rules/tokenComplete.js";
|
|
21
|
+
export type { TokenCompleteOptions, } from "./rules/tokenComplete.js";
|
|
22
|
+
export { createTokenContrastRule, loadContrastManifest, } from "./rules/tokenContrast.js";
|
|
23
|
+
export type { TokenContrastOptions, } from "./rules/tokenContrast.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export { defineConfig } from "./config.js";
|
|
2
|
+
export { parseContrastManifest, parseThemeContract, resolveContractSet, tokenNamePattern, } from "./contracts.js";
|
|
3
|
+
export { computeThemeSourceHash, loadComputedEvidence } from "./evidence.js";
|
|
4
|
+
export { formatHumanReport, formatJsonReport } from "./result.js";
|
|
5
|
+
export { approveBaselineGrowth, baselineFor, compareBaseline, fingerprintViolation, normalizeRelativePath, serializeBaseline, validateBaseline, writeBaselineFile, } from "./baseline.js";
|
|
6
|
+
export { validateSuppressions } from "./suppressions.js";
|
|
7
|
+
export { runChecks } from "./scan.js";
|
|
8
|
+
export { createNoLiteralColorRule } from "./rules/noLiteralColor.js";
|
|
9
|
+
export { createDefaultRules } from "./rules/default.js";
|
|
10
|
+
export { createTokenPrefixRule } from "./rules/tokenPrefix.js";
|
|
11
|
+
export { createTokenCompleteRule, loadThemeContract, } from "./rules/tokenComplete.js";
|
|
12
|
+
export { createTokenContrastRule, loadContrastManifest, } from "./rules/tokenContrast.js";
|
package/dist/result.d.ts
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
export type ExitCode = 0 | 1 | 2;
|
|
2
|
+
export interface SourceLocation {
|
|
3
|
+
column: number;
|
|
4
|
+
line: number;
|
|
5
|
+
}
|
|
6
|
+
export interface Violation extends SourceLocation {
|
|
7
|
+
message: string;
|
|
8
|
+
nodeKind: string;
|
|
9
|
+
path: string;
|
|
10
|
+
rule: string;
|
|
11
|
+
}
|
|
12
|
+
export interface Uncovered extends SourceLocation {
|
|
13
|
+
message: string;
|
|
14
|
+
nodeKind: string;
|
|
15
|
+
path: string;
|
|
16
|
+
rule: string;
|
|
17
|
+
required?: boolean;
|
|
18
|
+
}
|
|
19
|
+
export interface Suppression {
|
|
20
|
+
expires: string;
|
|
21
|
+
fingerprint: string;
|
|
22
|
+
ownerRole: string;
|
|
23
|
+
path: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
rule: string;
|
|
26
|
+
}
|
|
27
|
+
export interface IgnoredFile {
|
|
28
|
+
path: string;
|
|
29
|
+
reason: "excluded-by-configuration";
|
|
30
|
+
}
|
|
31
|
+
export interface OperationalError {
|
|
32
|
+
code: "config" | "internal" | "no-files" | "parse" | "read";
|
|
33
|
+
message: string;
|
|
34
|
+
path?: string;
|
|
35
|
+
}
|
|
36
|
+
export interface BaselineDebt {
|
|
37
|
+
active: number;
|
|
38
|
+
reduced: number;
|
|
39
|
+
rule: string;
|
|
40
|
+
}
|
|
41
|
+
export interface CheckReport {
|
|
42
|
+
baselineDebt: BaselineDebt[];
|
|
43
|
+
baselineMatches: Violation[];
|
|
44
|
+
declarationsChecked: number;
|
|
45
|
+
contracts?: {
|
|
46
|
+
contrast: ContractIdentity;
|
|
47
|
+
source: ContractSource;
|
|
48
|
+
theme: ContractIdentity;
|
|
49
|
+
};
|
|
50
|
+
durationMs: number;
|
|
51
|
+
exitCode: ExitCode;
|
|
52
|
+
filesDiscovered: number;
|
|
53
|
+
filesIgnored: IgnoredFile[];
|
|
54
|
+
filesRead: number;
|
|
55
|
+
nodesChecked: number;
|
|
56
|
+
operationalErrors: OperationalError[];
|
|
57
|
+
rulesExecuted: string[];
|
|
58
|
+
schemaVersion: 1;
|
|
59
|
+
suppressions: Suppression[];
|
|
60
|
+
uncovered: Uncovered[];
|
|
61
|
+
violations: Violation[];
|
|
62
|
+
}
|
|
63
|
+
export declare function formatJsonReport(report: CheckReport): string;
|
|
64
|
+
export declare function formatHumanReport(report: CheckReport): string;
|
|
65
|
+
import type { ContractIdentity, ContractSource } from "./contracts.js";
|
package/dist/result.js
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
export function formatJsonReport(report) {
|
|
2
|
+
return JSON.stringify(report, null, 2);
|
|
3
|
+
}
|
|
4
|
+
export function formatHumanReport(report) {
|
|
5
|
+
const lines = [
|
|
6
|
+
`Exit code: ${report.exitCode}`,
|
|
7
|
+
`Files discovered: ${report.filesDiscovered}`,
|
|
8
|
+
`Files read: ${report.filesRead}`,
|
|
9
|
+
`Files ignored: ${report.filesIgnored.length}`,
|
|
10
|
+
`TSX nodes checked: ${report.nodesChecked}`,
|
|
11
|
+
`CSS declarations checked: ${report.declarationsChecked}`,
|
|
12
|
+
`Duration: ${report.durationMs}ms`,
|
|
13
|
+
`Rules executed: ${report.rulesExecuted.length}`,
|
|
14
|
+
`Violations: ${report.violations.length}`,
|
|
15
|
+
`Baseline matches: ${report.baselineMatches.length}`,
|
|
16
|
+
`Uncovered: ${report.uncovered.length}`,
|
|
17
|
+
`Suppressions: ${report.suppressions.length}`,
|
|
18
|
+
`Operational errors: ${report.operationalErrors.length}`,
|
|
19
|
+
];
|
|
20
|
+
for (const debt of report.baselineDebt) {
|
|
21
|
+
lines.push(`Baseline debt ${debt.rule}: active ${debt.active}, reduced ${debt.reduced}`);
|
|
22
|
+
}
|
|
23
|
+
for (const rule of report.rulesExecuted) {
|
|
24
|
+
lines.push(`Rule executed: ${rule}`);
|
|
25
|
+
}
|
|
26
|
+
for (const ignored of report.filesIgnored) {
|
|
27
|
+
lines.push(`Ignored: ${ignored.path} ${ignored.reason}`);
|
|
28
|
+
}
|
|
29
|
+
for (const finding of report.baselineMatches) {
|
|
30
|
+
lines.push(`Baseline match: ${finding.path}:${finding.line}:${finding.column} ${finding.rule}`);
|
|
31
|
+
}
|
|
32
|
+
for (const suppression of report.suppressions) {
|
|
33
|
+
lines.push(`Suppression: ${suppression.path} ${suppression.rule} expires ${suppression.expires}`);
|
|
34
|
+
}
|
|
35
|
+
for (const finding of [...report.violations, ...report.uncovered]) {
|
|
36
|
+
lines.push(`${finding.path}:${finding.line}:${finding.column} ${finding.rule} ${finding.message}`);
|
|
37
|
+
}
|
|
38
|
+
for (const error of report.operationalErrors) {
|
|
39
|
+
lines.push(`${error.path ? `${error.path} ` : ""}${error.code} ${error.message}`);
|
|
40
|
+
}
|
|
41
|
+
return `${lines.join("\n")}\n`;
|
|
42
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ThemeConfig } from "../config.js";
|
|
2
|
+
import type { ContrastManifest, ThemeContract } from "../contracts.js";
|
|
3
|
+
import type { CheckRule } from "../scan.js";
|
|
4
|
+
export interface DefaultRulesOptions {
|
|
5
|
+
computed?: Record<string, Record<string, string>>;
|
|
6
|
+
contract?: ThemeContract;
|
|
7
|
+
manifest?: ContrastManifest;
|
|
8
|
+
themes: ThemeConfig[];
|
|
9
|
+
}
|
|
10
|
+
export declare function createDefaultRules({ computed, contract, manifest, themes, }: DefaultRulesOptions): Promise<CheckRule[]>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { createNoLiteralColorRule } from "./noLiteralColor.js";
|
|
2
|
+
import { createTokenCompleteRule, loadThemeContract } from "./tokenComplete.js";
|
|
3
|
+
import { createTokenContrastRule, loadContrastManifest, } from "./tokenContrast.js";
|
|
4
|
+
import { createTokenPrefixRule } from "./tokenPrefix.js";
|
|
5
|
+
export async function createDefaultRules({ computed, contract, manifest, themes, }) {
|
|
6
|
+
const [resolvedContract, resolvedManifest] = await Promise.all([
|
|
7
|
+
contract ?? loadThemeContract(),
|
|
8
|
+
manifest ?? loadContrastManifest(),
|
|
9
|
+
]);
|
|
10
|
+
return [
|
|
11
|
+
createNoLiteralColorRule({ themes }),
|
|
12
|
+
createTokenPrefixRule(),
|
|
13
|
+
createTokenCompleteRule({ computed, contract: resolvedContract, themes }),
|
|
14
|
+
createTokenContrastRule({ computed, manifest: resolvedManifest, themes }),
|
|
15
|
+
];
|
|
16
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
import { isComputedPropertyName, isIdentifier, isJsxAttribute, isJsxExpression, isObjectLiteralExpression, isPropertyAssignment, isStringLiteralLikeNode, } from "typescript/unstable/ast";
|
|
2
|
+
import { containsLiteralColor, isColorBearingProperty } from "../color.js";
|
|
3
|
+
import { isAllowedThemeDeclaration } from "./theme.js";
|
|
4
|
+
function emptyResult() {
|
|
5
|
+
return { uncovered: [], violations: [] };
|
|
6
|
+
}
|
|
7
|
+
function propertyName(node) {
|
|
8
|
+
if (isIdentifier(node) || isStringLiteralLikeNode(node)) {
|
|
9
|
+
return node.text;
|
|
10
|
+
}
|
|
11
|
+
return isComputedPropertyName(node) && isStringLiteralLikeNode(node.expression)
|
|
12
|
+
? node.expression.text
|
|
13
|
+
: undefined;
|
|
14
|
+
}
|
|
15
|
+
function jsxStringValue(node) {
|
|
16
|
+
if (!node) {
|
|
17
|
+
return undefined;
|
|
18
|
+
}
|
|
19
|
+
if (isStringLiteralLikeNode(node)) {
|
|
20
|
+
return node.text;
|
|
21
|
+
}
|
|
22
|
+
return isJsxExpression(node) &&
|
|
23
|
+
node.expression &&
|
|
24
|
+
isStringLiteralLikeNode(node.expression)
|
|
25
|
+
? node.expression.text
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
function classContainsLiteralColor(value) {
|
|
29
|
+
return [...value.matchAll(/\[([^\]]+)\]/g)].some((match) => {
|
|
30
|
+
const candidate = match[1] ?? "";
|
|
31
|
+
const separator = candidate.indexOf(":");
|
|
32
|
+
if (separator <= 0) {
|
|
33
|
+
return containsLiteralColor(candidate);
|
|
34
|
+
}
|
|
35
|
+
const property = candidate.slice(0, separator);
|
|
36
|
+
const propertyValue = candidate.slice(separator + 1);
|
|
37
|
+
const beforeBracket = value.slice(0, match.index ?? 0);
|
|
38
|
+
const currentClass = beforeBracket.slice(Math.max(beforeBracket.lastIndexOf(" "), beforeBracket.lastIndexOf("\n"), beforeBracket.lastIndexOf("\t")) + 1);
|
|
39
|
+
const utility = currentClass.split(":").at(-1) ?? "";
|
|
40
|
+
if (utility.endsWith("-")) {
|
|
41
|
+
if (property === "color" || property === "image") {
|
|
42
|
+
return containsLiteralColor(propertyValue);
|
|
43
|
+
}
|
|
44
|
+
return (/^(?:accent|bg|border|caret|decoration|divide|fill|from|outline|placeholder|ring|shadow|stroke|text|to|via)-$/.test(utility) && containsLiteralColor(propertyValue));
|
|
45
|
+
}
|
|
46
|
+
return (isColorBearingProperty(property) && containsLiteralColor(propertyValue));
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
export function createNoLiteralColorRule({ themes, }) {
|
|
50
|
+
return {
|
|
51
|
+
name: "css/no-literal-color",
|
|
52
|
+
check(file) {
|
|
53
|
+
const result = emptyResult();
|
|
54
|
+
file.cssRoot?.walkDecls((declaration) => {
|
|
55
|
+
if (isColorBearingProperty(declaration.prop) &&
|
|
56
|
+
containsLiteralColor(declaration.value) &&
|
|
57
|
+
!isAllowedThemeDeclaration(file, declaration, themes)) {
|
|
58
|
+
result.violations.push({
|
|
59
|
+
column: declaration.source?.start?.column ?? 1,
|
|
60
|
+
line: declaration.source?.start?.line ?? 1,
|
|
61
|
+
message: "Literal colors are allowed only in configured themes.",
|
|
62
|
+
nodeKind: "css-declaration",
|
|
63
|
+
path: file.path,
|
|
64
|
+
rule: "css/no-literal-color",
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
});
|
|
68
|
+
if (!file.sourceFile) {
|
|
69
|
+
return result;
|
|
70
|
+
}
|
|
71
|
+
const sourceFile = file.sourceFile;
|
|
72
|
+
const visit = (node) => {
|
|
73
|
+
if (isJsxAttribute(node) && isIdentifier(node.name)) {
|
|
74
|
+
const value = jsxStringValue(node.initializer);
|
|
75
|
+
const isDirectColor = isColorBearingProperty(node.name.text);
|
|
76
|
+
const isLiteralClass = node.name.text === "className" &&
|
|
77
|
+
value !== undefined &&
|
|
78
|
+
classContainsLiteralColor(value);
|
|
79
|
+
if (value !== undefined &&
|
|
80
|
+
((isDirectColor && containsLiteralColor(value)) || isLiteralClass)) {
|
|
81
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
82
|
+
result.violations.push({
|
|
83
|
+
column: position.character + 1,
|
|
84
|
+
line: position.line + 1,
|
|
85
|
+
message: "Literal colors are allowed only in configured themes.",
|
|
86
|
+
nodeKind: "tsx-color-attribute",
|
|
87
|
+
path: file.path,
|
|
88
|
+
rule: "css/no-literal-color",
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (isJsxAttribute(node) &&
|
|
93
|
+
isIdentifier(node.name) &&
|
|
94
|
+
node.name.text === "style" &&
|
|
95
|
+
node.initializer &&
|
|
96
|
+
isJsxExpression(node.initializer) &&
|
|
97
|
+
node.initializer.expression &&
|
|
98
|
+
isObjectLiteralExpression(node.initializer.expression)) {
|
|
99
|
+
for (const property of node.initializer.expression.properties) {
|
|
100
|
+
if (!isPropertyAssignment(property) ||
|
|
101
|
+
!isStringLiteralLikeNode(property.initializer)) {
|
|
102
|
+
continue;
|
|
103
|
+
}
|
|
104
|
+
const name = propertyName(property.name);
|
|
105
|
+
if (name !== undefined &&
|
|
106
|
+
isColorBearingProperty(name) &&
|
|
107
|
+
containsLiteralColor(property.initializer.text)) {
|
|
108
|
+
const position = sourceFile.getLineAndCharacterOfPosition(property.initializer.getStart(sourceFile));
|
|
109
|
+
result.violations.push({
|
|
110
|
+
column: position.character + 1,
|
|
111
|
+
line: position.line + 1,
|
|
112
|
+
message: "Literal colors are allowed only in configured themes.",
|
|
113
|
+
nodeKind: "tsx-style-property",
|
|
114
|
+
path: file.path,
|
|
115
|
+
rule: "css/no-literal-color",
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
node.forEachChild(visit);
|
|
121
|
+
};
|
|
122
|
+
sourceFile.forEachChild(visit);
|
|
123
|
+
return result;
|
|
124
|
+
},
|
|
125
|
+
};
|
|
126
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import type { Declaration } from "postcss";
|
|
2
|
+
import type { ThemeConfig } from "../config.js";
|
|
3
|
+
import type { ScannedFile } from "../scan.js";
|
|
4
|
+
export interface ThemeDeclaration {
|
|
5
|
+
column: number;
|
|
6
|
+
line: number;
|
|
7
|
+
path: string;
|
|
8
|
+
value: string;
|
|
9
|
+
}
|
|
10
|
+
export interface CollectedTheme {
|
|
11
|
+
declarations: Map<string, ThemeDeclaration>;
|
|
12
|
+
files: Set<string>;
|
|
13
|
+
name: string;
|
|
14
|
+
overlapping: boolean;
|
|
15
|
+
unresolved: boolean;
|
|
16
|
+
selector: string;
|
|
17
|
+
}
|
|
18
|
+
export declare function createThemeCollector(themeConfigs: ThemeConfig[]): {
|
|
19
|
+
begin: () => void;
|
|
20
|
+
collect: (file: ScannedFile) => void;
|
|
21
|
+
themes: CollectedTheme[];
|
|
22
|
+
};
|
|
23
|
+
export declare function isAllowedThemeDeclaration(file: ScannedFile, declaration: Declaration, themes: ThemeConfig[]): boolean;
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
function normalizePath(path) {
|
|
2
|
+
return path.replaceAll("\\", "/").replace(/^\.\//, "");
|
|
3
|
+
}
|
|
4
|
+
function exactAttributes(selector) {
|
|
5
|
+
const attributes = new Map();
|
|
6
|
+
for (const match of selector.matchAll(/\[([\w-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\]\s]+))\]/g)) {
|
|
7
|
+
const name = match[1];
|
|
8
|
+
const value = match[2] ?? match[3] ?? match[4];
|
|
9
|
+
if (name !== undefined && value !== undefined) {
|
|
10
|
+
attributes.set(name, value);
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
return attributes;
|
|
14
|
+
}
|
|
15
|
+
function isSingleCompoundSelector(selector) {
|
|
16
|
+
const withoutAttributes = selector.replace(/\[[^\]]+\]/g, "");
|
|
17
|
+
return /^(?:[a-zA-Z][\w-]*|\*|[.#][\w-]+)*$/.test(withoutAttributes);
|
|
18
|
+
}
|
|
19
|
+
function selectorsAreProvablyDisjoint(left, right) {
|
|
20
|
+
if (!isSingleCompoundSelector(left) || !isSingleCompoundSelector(right)) {
|
|
21
|
+
return false;
|
|
22
|
+
}
|
|
23
|
+
const leftAttributes = exactAttributes(left);
|
|
24
|
+
const rightAttributes = exactAttributes(right);
|
|
25
|
+
for (const [name, value] of leftAttributes) {
|
|
26
|
+
const otherValue = rightAttributes.get(name);
|
|
27
|
+
if (otherValue !== undefined && otherValue !== value) {
|
|
28
|
+
return true;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
const leftIds = [...left.matchAll(/#([\w-]+)/g)].map((match) => match[1]);
|
|
32
|
+
const rightIds = [...right.matchAll(/#([\w-]+)/g)].map((match) => match[1]);
|
|
33
|
+
return (leftIds.length === 1 &&
|
|
34
|
+
rightIds.length === 1 &&
|
|
35
|
+
leftIds[0] !== rightIds[0]);
|
|
36
|
+
}
|
|
37
|
+
export function createThemeCollector(themeConfigs) {
|
|
38
|
+
const themes = themeConfigs.map((theme) => ({
|
|
39
|
+
declarations: new Map(),
|
|
40
|
+
files: new Set(theme.files.map(normalizePath)),
|
|
41
|
+
name: theme.name,
|
|
42
|
+
overlapping: false,
|
|
43
|
+
selector: theme.selector.trim(),
|
|
44
|
+
unresolved: false,
|
|
45
|
+
}));
|
|
46
|
+
function begin() {
|
|
47
|
+
for (const theme of themes) {
|
|
48
|
+
theme.declarations.clear();
|
|
49
|
+
theme.overlapping = false;
|
|
50
|
+
theme.unresolved = false;
|
|
51
|
+
}
|
|
52
|
+
for (let index = 0; index < themes.length; index += 1) {
|
|
53
|
+
const theme = themes[index];
|
|
54
|
+
if (!theme) {
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
for (const candidate of themes.slice(index + 1)) {
|
|
58
|
+
const sharesFile = [...theme.files].some((file) => candidate.files.has(file));
|
|
59
|
+
if (sharesFile &&
|
|
60
|
+
!selectorsAreProvablyDisjoint(theme.selector, candidate.selector)) {
|
|
61
|
+
theme.overlapping = true;
|
|
62
|
+
candidate.overlapping = true;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
function collect(file) {
|
|
68
|
+
if (!file.cssRoot) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
for (const theme of themes) {
|
|
72
|
+
if (!theme.files.has(normalizePath(file.path))) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const configuredSelectors = new Set(themes
|
|
76
|
+
.filter((candidate) => candidate.files.has(normalizePath(file.path)))
|
|
77
|
+
.map(({ selector }) => selector));
|
|
78
|
+
let matchingRules = 0;
|
|
79
|
+
file.cssRoot.walkRules((rule) => {
|
|
80
|
+
const selector = rule.selector.trim();
|
|
81
|
+
const directDeclarations = (rule.nodes ?? []).filter((node) => node.type === "decl" && node.prop.startsWith("--"));
|
|
82
|
+
if (directDeclarations.length > 0 &&
|
|
83
|
+
selector !== theme.selector &&
|
|
84
|
+
!configuredSelectors.has(selector)) {
|
|
85
|
+
theme.unresolved = true;
|
|
86
|
+
}
|
|
87
|
+
if (selector !== theme.selector) {
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
matchingRules += 1;
|
|
91
|
+
if (rule.parent?.type !== "root") {
|
|
92
|
+
theme.unresolved = true;
|
|
93
|
+
}
|
|
94
|
+
const nestedCustomDeclarations = [];
|
|
95
|
+
rule.walkDecls((declaration) => {
|
|
96
|
+
if (declaration.parent !== rule && declaration.prop.startsWith("--")) {
|
|
97
|
+
nestedCustomDeclarations.push(declaration);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
if (nestedCustomDeclarations.length > 0) {
|
|
101
|
+
theme.unresolved = true;
|
|
102
|
+
}
|
|
103
|
+
for (const declaration of directDeclarations) {
|
|
104
|
+
if (theme.declarations.has(declaration.prop)) {
|
|
105
|
+
theme.overlapping = true;
|
|
106
|
+
}
|
|
107
|
+
theme.declarations.set(declaration.prop, {
|
|
108
|
+
column: declaration.source?.start?.column ?? 1,
|
|
109
|
+
line: declaration.source?.start?.line ?? 1,
|
|
110
|
+
path: file.path,
|
|
111
|
+
value: declaration.value,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
if (matchingRules > 1) {
|
|
116
|
+
theme.overlapping = true;
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return { begin, collect, themes };
|
|
121
|
+
}
|
|
122
|
+
export function isAllowedThemeDeclaration(file, declaration, themes) {
|
|
123
|
+
const path = normalizePath(file.path);
|
|
124
|
+
const parent = declaration.parent;
|
|
125
|
+
return themes.some((theme) => theme.files.map(normalizePath).includes(path) &&
|
|
126
|
+
parent?.type === "rule" &&
|
|
127
|
+
parent.selector.trim() === theme.selector.trim());
|
|
128
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { ThemeConfig } from "../config.js";
|
|
2
|
+
import { type ThemeContract } from "../contracts.js";
|
|
3
|
+
import type { CheckRule } from "../scan.js";
|
|
4
|
+
export type { ThemeContract } from "../contracts.js";
|
|
5
|
+
export interface TokenCompleteOptions {
|
|
6
|
+
computed?: Record<string, Record<string, string>>;
|
|
7
|
+
contract: ThemeContract;
|
|
8
|
+
themes: ThemeConfig[];
|
|
9
|
+
}
|
|
10
|
+
export declare function loadThemeContract(url?: URL): Promise<ThemeContract>;
|
|
11
|
+
export declare function createTokenCompleteRule({ computed, contract, themes, }: TokenCompleteOptions): CheckRule;
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { unresolvedTokenReferences } from "../color.js";
|
|
2
|
+
import { parseThemeContract, } from "../contracts.js";
|
|
3
|
+
import { createThemeCollector } from "./theme.js";
|
|
4
|
+
export async function loadThemeContract(url) {
|
|
5
|
+
const { readFile } = await import("node:fs/promises");
|
|
6
|
+
const packaged = url ?? new URL("../theme-contract.json", import.meta.url);
|
|
7
|
+
return parseThemeContract(await readFile(packaged, "utf8"));
|
|
8
|
+
}
|
|
9
|
+
function findingLocation(theme) {
|
|
10
|
+
const first = theme.declarations.values().next().value;
|
|
11
|
+
return {
|
|
12
|
+
column: first?.column ?? 1,
|
|
13
|
+
line: first?.line ?? 1,
|
|
14
|
+
path: first?.path ?? [...theme.files].sort()[0] ?? "theme.css",
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
|
|
18
|
+
const collector = createThemeCollector(themes);
|
|
19
|
+
return {
|
|
20
|
+
name: "tokens/complete",
|
|
21
|
+
begin: collector.begin,
|
|
22
|
+
check(file) {
|
|
23
|
+
collector.collect(file);
|
|
24
|
+
return { uncovered: [], violations: [] };
|
|
25
|
+
},
|
|
26
|
+
complete() {
|
|
27
|
+
const result = { uncovered: [], violations: [] };
|
|
28
|
+
for (const theme of collector.themes) {
|
|
29
|
+
const location = findingLocation(theme);
|
|
30
|
+
const computedTheme = computed[theme.name] ?? {};
|
|
31
|
+
const evidenceTokens = new Set([
|
|
32
|
+
...contract.required,
|
|
33
|
+
...Object.keys(contract.derived),
|
|
34
|
+
...Object.values(contract.derived),
|
|
35
|
+
]);
|
|
36
|
+
const hasCompleteComputedEvidence = [...evidenceTokens].every((token) => computedTheme[token] !== undefined);
|
|
37
|
+
if (theme.overlapping && !hasCompleteComputedEvidence) {
|
|
38
|
+
result.uncovered.push({
|
|
39
|
+
...location,
|
|
40
|
+
message: `Theme ${theme.name} uses overlapping selectors.`,
|
|
41
|
+
nodeKind: "theme-selector",
|
|
42
|
+
rule: "tokens/complete",
|
|
43
|
+
required: true,
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
if (theme.unresolved && !hasCompleteComputedEvidence) {
|
|
47
|
+
result.uncovered.push({
|
|
48
|
+
...location,
|
|
49
|
+
message: `Theme ${theme.name} has unresolved cascade conditions.`,
|
|
50
|
+
nodeKind: "theme-cascade",
|
|
51
|
+
rule: "tokens/complete",
|
|
52
|
+
required: true,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
let missingRequired = false;
|
|
56
|
+
for (const token of contract.required) {
|
|
57
|
+
if (!theme.declarations.has(token)) {
|
|
58
|
+
missingRequired = true;
|
|
59
|
+
result.violations.push({
|
|
60
|
+
...location,
|
|
61
|
+
message: `Theme ${theme.name} is missing required token ${token}.`,
|
|
62
|
+
nodeKind: `theme-token:${token}`,
|
|
63
|
+
rule: "tokens/complete",
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (missingRequired) {
|
|
68
|
+
result.uncovered.push({
|
|
69
|
+
...location,
|
|
70
|
+
message: `Theme ${theme.name} is incomplete.`,
|
|
71
|
+
nodeKind: "theme-required-coverage",
|
|
72
|
+
rule: "tokens/complete",
|
|
73
|
+
required: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
for (const [token, fallback] of Object.entries(contract.derived)) {
|
|
77
|
+
if (!theme.declarations.has(token) &&
|
|
78
|
+
!theme.declarations.has(fallback)) {
|
|
79
|
+
result.violations.push({
|
|
80
|
+
...location,
|
|
81
|
+
message: `Derived token ${token} has no available documented fallback.`,
|
|
82
|
+
nodeKind: `theme-token:${token}`,
|
|
83
|
+
rule: "tokens/complete",
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const tokens = new Map([...theme.declarations].map(([name, declaration]) => [
|
|
88
|
+
name,
|
|
89
|
+
declaration.value,
|
|
90
|
+
]));
|
|
91
|
+
for (const [name, value] of Object.entries(computedTheme)) {
|
|
92
|
+
tokens.set(name, value);
|
|
93
|
+
}
|
|
94
|
+
const reportedReferences = new Set();
|
|
95
|
+
for (const declaration of theme.declarations.values()) {
|
|
96
|
+
for (const reference of unresolvedTokenReferences(declaration.value, tokens)) {
|
|
97
|
+
if (!reportedReferences.has(reference)) {
|
|
98
|
+
reportedReferences.add(reference);
|
|
99
|
+
result.uncovered.push({
|
|
100
|
+
column: declaration.column,
|
|
101
|
+
line: declaration.line,
|
|
102
|
+
message: `Theme ${theme.name} contains an unresolved token reference.`,
|
|
103
|
+
nodeKind: "theme-token-value",
|
|
104
|
+
path: declaration.path,
|
|
105
|
+
rule: "tokens/complete",
|
|
106
|
+
required: true,
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return result;
|
|
113
|
+
},
|
|
114
|
+
};
|
|
115
|
+
}
|