@ksmv/ui-checks 0.1.5 → 0.3.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 +13 -0
- package/README.md +31 -0
- package/dist/color.js +2 -0
- package/dist/config.d.ts +7 -0
- package/dist/density.d.ts +22 -0
- package/dist/density.js +100 -0
- package/dist/index.d.ts +4 -2
- package/dist/index.js +1 -0
- package/dist/result.d.ts +7 -0
- package/dist/result.js +4 -0
- package/dist/rules/componentOverride.d.ts +6 -0
- package/dist/rules/componentOverride.js +354 -0
- package/dist/rules/default.d.ts +3 -2
- package/dist/rules/default.js +5 -1
- package/dist/rules/overrideCatalog.d.ts +5 -0
- package/dist/rules/overrideCatalog.js +85 -0
- package/dist/rules/tokenComplete.js +33 -0
- package/dist/scan.d.ts +4 -1
- package/dist/scan.js +51 -5
- package/dist/theme-contract.json +26 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.0 - 2026-09-30
|
|
4
|
+
|
|
5
|
+
- **Regra `components/override`**, opt-in pelo bloco `components` da configuração. Num componente da biblioteca, ajuste de encaixe por `className` ou `style` (margem, largura, item de flex/grid, posição, visibilidade, alinhamento do texto) é livre; ajuste de aparência ou de arranjo interno exige `data-ui-override="motivo"` no mesmo elemento. Classes fora do catálogo contam como aparência. `className` ou `style` que o checker não consegue ler e props repassadas depois de `className`/`style` também exigem a marca.
|
|
6
|
+
- Os achados são violações comuns (saída 1) e podem entrar em baseline durante a migração; os elementos marcados aparecem na lista nova `overrides` do relatório, com o motivo.
|
|
7
|
+
- O bloco `components` tem arquivos próprios (`include`, `exclude`), fontes (`sources`, padrão `@ksmv/ui-react`) e helpers de classe (`classHelpers`, que somam aos padrões). Arquivos só desse bloco recebem só esta regra: nem a cobertura dinâmica nem as regras de cor passam a valer neles.
|
|
8
|
+
- Furos conhecidos, documentados: componente do produto que repassa props para um componente da biblioteca, CSS do produto que mira as classes internas `.ui-*`, props repassadas sozinhas ou antes de `className`/`style`, componente usado como valor e arquivo que não compila (erro operacional de parse, fora do alcance da regra). Um `components.include` que não casa nenhum arquivo é erro operacional (saída 2).
|
|
9
|
+
|
|
10
|
+
## 0.2.0 - 2026-09-29
|
|
11
|
+
|
|
12
|
+
- **Contrato de tema 2.0.0** empacotado, com os 20 tokens de densidade obrigatórios e os 3 derivados. Pontes da 0.1.x ficam incompletas em `tokens/complete`, e evidência capturada antes da mudança deixa de conferir com a identidade do contrato.
|
|
13
|
+
- **Validação de valor dos tokens de densidade** em `tokens/complete`, pela lista fechada de tokens (nunca por padrão de nome): fonte e altura aceitam comprimento maior que zero em `px`, `rem` ou `em`; respiro e espaçamento aceitam também `0`; entrelinha, número sem unidade maior que zero; nos três, também `calc()`, `clamp()`, `min()` ou `max()`; peso, inteiro de 1 a 1000, `normal` ou `bold`. A conferência usa o valor da evidência calculada quando existe; sem evidência, um `var()` é conferido pelo token do mesmo tema ou pelo fallback que ele alcança. Valor inválido é cobertura obrigatória: sai com código 2 e não entra em baseline, e a mensagem diz o formato esperado, sem repetir o valor.
|
|
14
|
+
- `css/no-literal-color` deixa de ler como cor uma palavra só com dígitos hexadecimais e sem `#`, como o peso `600` numa custom property fora do seletor de tema.
|
|
15
|
+
|
|
3
16
|
## 0.1.5 - 2026-09-28
|
|
4
17
|
|
|
5
18
|
- **Fingerprints de baseline estáveis.** O fingerprint passa a seguir o conteúdo do achado e a ordem entre achados idênticos no mesmo arquivo, e não mais a linha e a coluna. Na 0.1.4, uma linha inserida acima de dívida conhecida fazia toda a dívida seguinte reaparecer como achado novo; numa aplicação consumidora real, um comentário no topo do CSS gerou 115 falsos achados novos. O conteúdo entra no fingerprint só como hash, e nenhum trecho é gravado ou relatado.
|
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ A evidência não é uma exceção manual. O arquivo é recusado, com código `2
|
|
|
88
88
|
|
|
89
89
|
Tokens opcionais, os derivados e os de marca, podem ficar vazios na captura: é assim que o navegador informa um token que o tema não declarou. Um derivado vazio assume o valor do token de origem, que é o padrão documentado no contrato.
|
|
90
90
|
|
|
91
|
+
Os tokens de densidade têm o valor conferido por `tokens/complete`: comprimento maior que zero em `px`, `rem` ou `em` para fonte e altura; também `0` para respiro e espaçamento; número sem unidade maior que zero para a entrelinha; em todos esses, também `calc()`, `clamp()`, `min()` ou `max()`; inteiro de 1 a 1000, `normal` ou `bold` para o peso. Com evidência, vale o valor capturado no navegador. Sem evidência, um valor que chega por `var()` é conferido pelo que ele alcança: o token declarado no mesmo tema ou, se não houver, o fallback do próprio `var()`; uma referência que não chega a valor nenhum fica como cobertura pendente. Valor inválido é cobertura obrigatória e não entra em baseline.
|
|
92
|
+
|
|
91
93
|
A captura usa a API pública deste pacote e um navegador à sua escolha. Com Playwright, e a aplicação servida localmente:
|
|
92
94
|
|
|
93
95
|
```js
|
|
@@ -145,6 +147,35 @@ await writeFile(target, `${JSON.stringify({
|
|
|
145
147
|
|
|
146
148
|
Capture de novo sempre que um arquivo de tema mudar ou o pacote React for atualizado; a verificação recusa a evidência antiga, que é exatamente o objetivo.
|
|
147
149
|
|
|
150
|
+
## Ajustes em componentes
|
|
151
|
+
|
|
152
|
+
A regra `components/override` confere como o produto ajusta os componentes da biblioteca. Ela só roda quando a configuração tem o bloco `components`:
|
|
153
|
+
|
|
154
|
+
```js
|
|
155
|
+
components: {
|
|
156
|
+
include: ["src/**/*.tsx"],
|
|
157
|
+
exclude: ["src/**/*.test.tsx"],
|
|
158
|
+
sources: ["@ksmv/ui-react", "@/ui"],
|
|
159
|
+
classHelpers: ["tw"],
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
- **Livre:** o que encaixa o componente no pai — margem, largura, item de flex/grid (`flex-1`, `self-*`, `col-span-*`…), posição, `z-*`, visibilidade (`hidden`, `sr-only`) e alinhamento do texto. No `style`, as propriedades equivalentes.
|
|
164
|
+
- **Exige `data-ui-override="motivo"` no mesmo elemento:** aparência (cor, fundo, borda, raio, sombra, fonte, altura, padding, `gap-*`, `overflow-*`), arranjo interno (`flex`, `grid-cols-*`, `justify-*`, `items-*`, `block`) e qualquer classe fora do catálogo, inclusive classes próprias do produto. Também exigem a marca `className` ou `style` que a regra não consegue ler e props repassadas depois de `className` ou `style`.
|
|
165
|
+
- **Relatório:** sem marca, é uma violação (saída 1), que pode entrar em baseline durante a migração. Com marca, o elemento aparece em `overrides`, com o motivo.
|
|
166
|
+
- **Arquivos:** os do `components.include` recebem só esta regra. Um arquivo que também está no `include` principal recebe as duas coisas, e aí a cobertura dinâmica volta a valer nele.
|
|
167
|
+
- **O que a regra não vê:** um componente do produto que repassa props para um componente da biblioteca, e CSS do produto que mira as classes internas `.ui-*`; props repassadas sozinhas ou antes de `className`/`style` (`<Button {...props} />`); componente usado como valor (`const X = Button; <X />`); arquivos que não compilam — aparecem como erro operacional de parse (saída 2) e não são analisados pela regra.
|
|
168
|
+
|
|
169
|
+
### Detalhes
|
|
170
|
+
|
|
171
|
+
- `sources` substitui o padrão: mantenha `@ksmv/ui-react` na lista ao acrescentar um barril; a comparação é pelo especificador exato do import (subcaminhos e imports relativos precisam de entrada própria).
|
|
172
|
+
- `classHelpers` soma aos padrões (`cn`, `clsx`, `cx`, `classnames`, `classNames`, `twMerge`, `twJoin`, `cva`) e entende chamadas no estilo clsx; de `cva(...)` só os textos literais são lidos. Template marcado (`tw\`...\``) e funções de variante geradas por `cva` contam como ilegíveis.
|
|
173
|
+
- `undefined`, `null`, `true`, `false` e `0` dentro de uma expressão de classe não contribuem classe.
|
|
174
|
+
- `overrides[]` tem `path`, `line`, `column`, `component`, `reason` e `classes`; `classes` lista as classes visuais autorizadas, as propriedades de `style` como `style:<propriedade>` e os tipos de achado (`dynamic-class`, `dynamic-style`, `spread-after`).
|
|
175
|
+
- componente do produto com `forwardRef` que faz `<Button className={cn("mt-2", className)} {...props} />`: gera `dynamic-class` e `spread-after`; passe o spread antes do `className` e marque o elemento com o motivo ("repassa className do chamador"), ou use a baseline durante a migração.
|
|
176
|
+
- o padrão responsivo `hidden md:inline-flex` exige marca (`inline-flex` é arranjo interno).
|
|
177
|
+
- um `components.include` que não casa nenhum arquivo é erro operacional (saída 2).
|
|
178
|
+
|
|
148
179
|
## API programática
|
|
149
180
|
|
|
150
181
|
O ponto de entrada `@ksmv/ui-checks` exporta `runChecks`, as regras, `resolveContractSet`, `computeThemeSourceHash`, `loadComputedEvidence`, as funções de baseline e os formatadores de relatório, com os tipos TypeScript correspondentes. Os contratos embutidos estão em `@ksmv/ui-checks/theme-contract.json` e `@ksmv/ui-checks/contrast-contract.json`.
|
package/dist/color.js
CHANGED
|
@@ -100,6 +100,8 @@ export function containsLiteralColor(value) {
|
|
|
100
100
|
if (node.type === "word" &&
|
|
101
101
|
node.value.toLowerCase() !== "currentcolor" &&
|
|
102
102
|
node.value.toLowerCase() !== "transparent" &&
|
|
103
|
+
// culori parses bare hex digits ("600", "fed") as colors; CSS never does.
|
|
104
|
+
!/^[0-9a-f]{3,8}$/iu.test(node.value) &&
|
|
103
105
|
parsedColor(node.value)) {
|
|
104
106
|
literal = true;
|
|
105
107
|
return false;
|
package/dist/config.d.ts
CHANGED
|
@@ -10,8 +10,15 @@ export interface ThemeConfig {
|
|
|
10
10
|
name: string;
|
|
11
11
|
selector: string;
|
|
12
12
|
}
|
|
13
|
+
export interface ComponentsConfig {
|
|
14
|
+
classHelpers?: string[];
|
|
15
|
+
exclude?: string[];
|
|
16
|
+
include: string[];
|
|
17
|
+
sources?: string[];
|
|
18
|
+
}
|
|
13
19
|
export interface ChecksConfig {
|
|
14
20
|
baseline?: string;
|
|
21
|
+
components?: ComponentsConfig;
|
|
15
22
|
contractSource?: ContractSource;
|
|
16
23
|
exclude?: string[];
|
|
17
24
|
failOnUncovered?: boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
export type DensityKind = "control-height" | "font-size" | "font-weight" | "line-height" | "spacing";
|
|
2
|
+
/**
|
|
3
|
+
* The closed list of density tokens whose values the checker validates. It is
|
|
4
|
+
* matched by exact name, never by pattern: `--ui-font-body` or
|
|
5
|
+
* `--ui-shadow-sm` must not be read as lengths.
|
|
6
|
+
*/
|
|
7
|
+
export declare const densityTokenKinds: Readonly<Record<string, DensityKind>>;
|
|
8
|
+
export declare function isValidDensityValue(kind: DensityKind, raw: string): boolean;
|
|
9
|
+
/**
|
|
10
|
+
* Human-readable description of what a valid value looks like for the given
|
|
11
|
+
* density kind, used in checker diagnostics. Never echoes the offending
|
|
12
|
+
* value itself.
|
|
13
|
+
*/
|
|
14
|
+
export declare function densityExpectation(kind: DensityKind): string;
|
|
15
|
+
/**
|
|
16
|
+
* Resolves a whole-value `var()` reference to the literal it ends in, using
|
|
17
|
+
* the theme's own declarations (and the evidence) first and the reference's
|
|
18
|
+
* fallback second. Returns undefined when the chain does not end in a literal
|
|
19
|
+
* (the reference check reports that) or when it passes through another
|
|
20
|
+
* density token, which is validated on its own.
|
|
21
|
+
*/
|
|
22
|
+
export declare function resolveDensityReference(value: string, tokens: ReadonlyMap<string, string>, seen?: ReadonlySet<string>): string | undefined;
|
package/dist/density.js
ADDED
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import valueParser from "postcss-value-parser";
|
|
2
|
+
/**
|
|
3
|
+
* The closed list of density tokens whose values the checker validates. It is
|
|
4
|
+
* matched by exact name, never by pattern: `--ui-font-body` or
|
|
5
|
+
* `--ui-shadow-sm` must not be read as lengths.
|
|
6
|
+
*/
|
|
7
|
+
export const densityTokenKinds = Object.freeze({
|
|
8
|
+
"--ui-font-size-xs": "font-size",
|
|
9
|
+
"--ui-font-size-sm": "font-size",
|
|
10
|
+
"--ui-font-size-md": "font-size",
|
|
11
|
+
"--ui-font-size-lg": "font-size",
|
|
12
|
+
"--ui-font-size-xl": "font-size",
|
|
13
|
+
"--ui-font-weight-strong": "font-weight",
|
|
14
|
+
"--ui-line-height-control": "line-height",
|
|
15
|
+
"--ui-control-height-sm": "control-height",
|
|
16
|
+
"--ui-control-height-md": "control-height",
|
|
17
|
+
"--ui-control-height-lg": "control-height",
|
|
18
|
+
"--ui-control-padding-inline-sm": "spacing",
|
|
19
|
+
"--ui-control-padding-inline-md": "spacing",
|
|
20
|
+
"--ui-control-padding-inline-lg": "spacing",
|
|
21
|
+
"--ui-space-1": "spacing",
|
|
22
|
+
"--ui-space-1-5": "spacing",
|
|
23
|
+
"--ui-space-2": "spacing",
|
|
24
|
+
"--ui-space-3": "spacing",
|
|
25
|
+
"--ui-space-4": "spacing",
|
|
26
|
+
"--ui-space-6": "spacing",
|
|
27
|
+
"--ui-space-8": "spacing",
|
|
28
|
+
"--ui-field-height": "control-height",
|
|
29
|
+
"--ui-dialog-title-font-size": "font-size",
|
|
30
|
+
"--ui-button-font-weight": "font-weight",
|
|
31
|
+
});
|
|
32
|
+
const lengthPattern = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/iu;
|
|
33
|
+
const math = /^(?:calc|clamp|min|max)\(.+\)$/iu;
|
|
34
|
+
const unitless = /^(?:\d+(?:\.\d+)?|\.\d+)$/u;
|
|
35
|
+
export function isValidDensityValue(kind, raw) {
|
|
36
|
+
const value = raw.trim();
|
|
37
|
+
switch (kind) {
|
|
38
|
+
case "font-weight": {
|
|
39
|
+
if (/^(?:normal|bold)$/iu.test(value))
|
|
40
|
+
return true;
|
|
41
|
+
if (!/^\d+$/u.test(value))
|
|
42
|
+
return false;
|
|
43
|
+
const weight = Number(value);
|
|
44
|
+
return weight >= 1 && weight <= 1000;
|
|
45
|
+
}
|
|
46
|
+
case "line-height":
|
|
47
|
+
return math.test(value) || (unitless.test(value) && Number(value) > 0);
|
|
48
|
+
case "spacing":
|
|
49
|
+
return value === "0" || math.test(value) || lengthPattern.test(value);
|
|
50
|
+
case "control-height":
|
|
51
|
+
case "font-size":
|
|
52
|
+
return math.test(value) || (lengthPattern.test(value) && Number.parseFloat(value) > 0);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Human-readable description of what a valid value looks like for the given
|
|
57
|
+
* density kind, used in checker diagnostics. Never echoes the offending
|
|
58
|
+
* value itself.
|
|
59
|
+
*/
|
|
60
|
+
export function densityExpectation(kind) {
|
|
61
|
+
switch (kind) {
|
|
62
|
+
case "control-height":
|
|
63
|
+
case "font-size":
|
|
64
|
+
return "a length above zero in px, rem or em, or calc()/clamp()/min()/max()";
|
|
65
|
+
case "spacing":
|
|
66
|
+
return "zero or a length in px, rem or em, or calc()/clamp()/min()/max()";
|
|
67
|
+
case "line-height":
|
|
68
|
+
return "a unitless number above zero, or calc()/clamp()/min()/max()";
|
|
69
|
+
case "font-weight":
|
|
70
|
+
return "an integer from 1 to 1000, normal or bold";
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/**
|
|
74
|
+
* Resolves a whole-value `var()` reference to the literal it ends in, using
|
|
75
|
+
* the theme's own declarations (and the evidence) first and the reference's
|
|
76
|
+
* fallback second. Returns undefined when the chain does not end in a literal
|
|
77
|
+
* (the reference check reports that) or when it passes through another
|
|
78
|
+
* density token, which is validated on its own.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveDensityReference(value, tokens, seen = new Set()) {
|
|
81
|
+
const trimmed = value.trim();
|
|
82
|
+
const nodes = valueParser(trimmed).nodes;
|
|
83
|
+
if (nodes.length !== 1)
|
|
84
|
+
return undefined;
|
|
85
|
+
const [node] = nodes;
|
|
86
|
+
if (node.type !== "function" || node.value.toLowerCase() !== "var")
|
|
87
|
+
return trimmed;
|
|
88
|
+
const comma = node.nodes.findIndex((child) => child.type === "div" && child.value === ",");
|
|
89
|
+
const name = valueParser.stringify(comma === -1 ? node.nodes : node.nodes.slice(0, comma)).trim();
|
|
90
|
+
const fallback = comma === -1 ? undefined : valueParser.stringify(node.nodes.slice(comma + 1)).trim();
|
|
91
|
+
if (name in densityTokenKinds || seen.has(name))
|
|
92
|
+
return undefined;
|
|
93
|
+
const next = new Set([...seen, name]);
|
|
94
|
+
const declared = tokens.get(name);
|
|
95
|
+
if (declared !== undefined)
|
|
96
|
+
return resolveDensityReference(declared, tokens, next);
|
|
97
|
+
if (fallback)
|
|
98
|
+
return resolveDensityReference(fallback, tokens, next);
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
export { defineConfig } from "./config.js";
|
|
2
|
-
export type { ChecksConfig, ComputedEvidenceRef, ThemeConfig } from "./config.js";
|
|
2
|
+
export type { ChecksConfig, ComponentsConfig, ComputedEvidenceRef, ThemeConfig, } from "./config.js";
|
|
3
3
|
export { parseContrastManifest, parseThemeContract, resolveContractSet, tokenNamePattern, } from "./contracts.js";
|
|
4
4
|
export type { ContractIdentity, ContractSet, ContractSource, ContrastManifest, ContrastPair, ThemeContract, } from "./contracts.js";
|
|
5
5
|
export { computeThemeSourceHash, loadComputedEvidence } from "./evidence.js";
|
|
6
6
|
export type { ThemeEvidence } from "./evidence.js";
|
|
7
7
|
export { formatHumanReport, formatJsonReport } from "./result.js";
|
|
8
|
-
export type { BaselineDebt, CheckReport, ExitCode, IgnoredFile, OperationalError, SourceLocation, Suppression, Uncovered, Violation, } from "./result.js";
|
|
8
|
+
export type { BaselineDebt, CheckReport, ComponentOverride, ExitCode, IgnoredFile, OperationalError, SourceLocation, Suppression, Uncovered, Violation, } from "./result.js";
|
|
9
9
|
export { approveBaselineGrowth, baselineFor, compareBaseline, fingerprintViolation, normalizeRelativePath, serializeBaseline, validateBaseline, writeBaselineFile, } from "./baseline.js";
|
|
10
10
|
export type { BaselineComparison, BaselineEntry, BaselineFile, BaselineMetadata, BaselineValidation, GrowthApproval, GrowthApprovalOptions, } from "./baseline.js";
|
|
11
11
|
export { validateSuppressions } from "./suppressions.js";
|
|
@@ -17,6 +17,8 @@ export type { NoLiteralColorOptions } from "./rules/noLiteralColor.js";
|
|
|
17
17
|
export { createDefaultRules } from "./rules/default.js";
|
|
18
18
|
export type { DefaultRulesOptions } from "./rules/default.js";
|
|
19
19
|
export { createTokenPrefixRule } from "./rules/tokenPrefix.js";
|
|
20
|
+
export { createComponentOverrideRule } from "./rules/componentOverride.js";
|
|
21
|
+
export type { ComponentOverrideOptions } from "./rules/componentOverride.js";
|
|
20
22
|
export { createTokenCompleteRule, loadThemeContract, } from "./rules/tokenComplete.js";
|
|
21
23
|
export type { TokenCompleteOptions, } from "./rules/tokenComplete.js";
|
|
22
24
|
export { createTokenContrastRule, loadContrastManifest, } from "./rules/tokenContrast.js";
|
package/dist/index.js
CHANGED
|
@@ -8,5 +8,6 @@ export { runChecks } from "./scan.js";
|
|
|
8
8
|
export { createNoLiteralColorRule } from "./rules/noLiteralColor.js";
|
|
9
9
|
export { createDefaultRules } from "./rules/default.js";
|
|
10
10
|
export { createTokenPrefixRule } from "./rules/tokenPrefix.js";
|
|
11
|
+
export { createComponentOverrideRule } from "./rules/componentOverride.js";
|
|
11
12
|
export { createTokenCompleteRule, loadThemeContract, } from "./rules/tokenComplete.js";
|
|
12
13
|
export { createTokenContrastRule, loadContrastManifest, } from "./rules/tokenContrast.js";
|
package/dist/result.d.ts
CHANGED
|
@@ -24,6 +24,12 @@ export interface Uncovered extends SourceLocation {
|
|
|
24
24
|
rule: string;
|
|
25
25
|
required?: boolean;
|
|
26
26
|
}
|
|
27
|
+
export interface ComponentOverride extends SourceLocation {
|
|
28
|
+
classes: string[];
|
|
29
|
+
component: string;
|
|
30
|
+
path: string;
|
|
31
|
+
reason: string;
|
|
32
|
+
}
|
|
27
33
|
export interface Suppression {
|
|
28
34
|
expires: string;
|
|
29
35
|
fingerprint: string;
|
|
@@ -62,6 +68,7 @@ export interface CheckReport {
|
|
|
62
68
|
filesRead: number;
|
|
63
69
|
nodesChecked: number;
|
|
64
70
|
operationalErrors: OperationalError[];
|
|
71
|
+
overrides: ComponentOverride[];
|
|
65
72
|
rulesExecuted: string[];
|
|
66
73
|
schemaVersion: 1;
|
|
67
74
|
suppressions: Suppression[];
|
package/dist/result.js
CHANGED
|
@@ -15,6 +15,7 @@ export function formatHumanReport(report) {
|
|
|
15
15
|
`Baseline matches: ${report.baselineMatches.length}`,
|
|
16
16
|
`Uncovered: ${report.uncovered.length}`,
|
|
17
17
|
`Suppressions: ${report.suppressions.length}`,
|
|
18
|
+
`Overrides: ${report.overrides.length}`,
|
|
18
19
|
`Operational errors: ${report.operationalErrors.length}`,
|
|
19
20
|
];
|
|
20
21
|
for (const debt of report.baselineDebt) {
|
|
@@ -32,6 +33,9 @@ export function formatHumanReport(report) {
|
|
|
32
33
|
for (const suppression of report.suppressions) {
|
|
33
34
|
lines.push(`Suppression: ${suppression.path} ${suppression.rule} expires ${suppression.expires}`);
|
|
34
35
|
}
|
|
36
|
+
for (const override of report.overrides) {
|
|
37
|
+
lines.push(`Override: ${override.path}:${override.line}:${override.column} ${override.component} "${override.reason}" ${override.classes.join(" ")}`.trimEnd());
|
|
38
|
+
}
|
|
35
39
|
for (const finding of [...report.violations, ...report.uncovered]) {
|
|
36
40
|
lines.push(`${finding.path}:${finding.line}:${finding.column} ${finding.rule} ${finding.message}`);
|
|
37
41
|
}
|
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import { isArrayLiteralExpression, isAsExpression, isBinaryExpression, isCallExpression, isComputedPropertyName, isConditionalExpression, isFalseLiteral, isIdentifier, isImportDeclaration, isJsxAttribute, isJsxExpression, isJsxOpeningElement, isJsxSelfClosingElement, isJsxSpreadAttribute, isNamedImports, isNamespaceImport, isNonNullExpression, isNullLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isShorthandPropertyAssignment, isStringLiteral, isStringLiteralLikeNode, isTrueLiteral, SyntaxKind, } from "typescript/unstable/ast";
|
|
2
|
+
import { classifyClass, classifyStyleProperty } from "./overrideCatalog.js";
|
|
3
|
+
const defaultHelpers = ["cn", "clsx", "cx", "cva", "twMerge", "twJoin", "classnames", "classNames"];
|
|
4
|
+
const kindMessages = {
|
|
5
|
+
"dynamic-class": "a class expression the checker cannot read",
|
|
6
|
+
"dynamic-style": "a style the checker cannot read",
|
|
7
|
+
"spread-after": "props spread after className or style",
|
|
8
|
+
};
|
|
9
|
+
function collectImports(sourceFile, sources) {
|
|
10
|
+
const bindings = new Map();
|
|
11
|
+
for (const statement of sourceFile.statements) {
|
|
12
|
+
if (!isImportDeclaration(statement) || !isStringLiteral(statement.moduleSpecifier))
|
|
13
|
+
continue;
|
|
14
|
+
if (!sources.has(statement.moduleSpecifier.text))
|
|
15
|
+
continue;
|
|
16
|
+
const clause = statement.importClause;
|
|
17
|
+
if (!clause || clause.phaseModifier === SyntaxKind.TypeKeyword)
|
|
18
|
+
continue;
|
|
19
|
+
const named = clause.namedBindings;
|
|
20
|
+
if (named && isNamespaceImport(named))
|
|
21
|
+
bindings.set(named.name.text, { namespace: true });
|
|
22
|
+
if (named && isNamedImports(named)) {
|
|
23
|
+
for (const element of named.elements) {
|
|
24
|
+
if (element.isTypeOnly)
|
|
25
|
+
continue;
|
|
26
|
+
bindings.set(element.name.text, {
|
|
27
|
+
imported: (element.propertyName ?? element.name).text,
|
|
28
|
+
namespace: false,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return bindings;
|
|
34
|
+
}
|
|
35
|
+
function bindingNames(name, into) {
|
|
36
|
+
if (!name)
|
|
37
|
+
return;
|
|
38
|
+
if (isIdentifier(name)) {
|
|
39
|
+
into.add(name.text);
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
name.forEachChild((child) => {
|
|
43
|
+
if (child.kind === SyntaxKind.BindingElement) {
|
|
44
|
+
bindingNames(child.name, into);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
/** Names declared directly in a scope-creating node (parameters or block statements). */
|
|
49
|
+
function scopeNames(node) {
|
|
50
|
+
const record = node;
|
|
51
|
+
const isBlock = node.kind === SyntaxKind.Block;
|
|
52
|
+
if (!record.parameters && !isBlock)
|
|
53
|
+
return undefined;
|
|
54
|
+
const names = new Set();
|
|
55
|
+
for (const parameter of record.parameters ?? []) {
|
|
56
|
+
bindingNames(parameter.name, names);
|
|
57
|
+
}
|
|
58
|
+
if (isBlock) {
|
|
59
|
+
for (const statement of record.statements ?? []) {
|
|
60
|
+
if (statement.kind === SyntaxKind.VariableStatement) {
|
|
61
|
+
const list = statement.declarationList;
|
|
62
|
+
for (const declaration of list.declarations) {
|
|
63
|
+
bindingNames(declaration.name, names);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (statement.kind === SyntaxKind.FunctionDeclaration || statement.kind === SyntaxKind.ClassDeclaration) {
|
|
67
|
+
bindingNames(statement.name, names);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return names;
|
|
72
|
+
}
|
|
73
|
+
function tagParts(tagName) {
|
|
74
|
+
const path = [];
|
|
75
|
+
let current = tagName;
|
|
76
|
+
while (isPropertyAccessExpression(current)) {
|
|
77
|
+
path.unshift(current.name.text);
|
|
78
|
+
current = current.expression;
|
|
79
|
+
}
|
|
80
|
+
return isIdentifier(current) ? { path, root: current.text } : undefined;
|
|
81
|
+
}
|
|
82
|
+
function propertyKey(node) {
|
|
83
|
+
if (isIdentifier(node) || isStringLiteralLikeNode(node))
|
|
84
|
+
return node.text;
|
|
85
|
+
return isComputedPropertyName(node) && isStringLiteralLikeNode(node.expression)
|
|
86
|
+
? node.expression.text
|
|
87
|
+
: undefined;
|
|
88
|
+
}
|
|
89
|
+
/** Unwraps type assertions, `satisfies` and `!` before reading an expression. */
|
|
90
|
+
function unwrapTypeWrappers(node) {
|
|
91
|
+
let current = node;
|
|
92
|
+
while (isParenthesizedExpression(current) ||
|
|
93
|
+
isAsExpression(current) ||
|
|
94
|
+
isSatisfiesExpression(current) ||
|
|
95
|
+
isNonNullExpression(current)) {
|
|
96
|
+
current = current.expression;
|
|
97
|
+
}
|
|
98
|
+
return current;
|
|
99
|
+
}
|
|
100
|
+
/** `undefined`, `null`, `true`, `false` and `0` contribute no classes and are not unreadable. */
|
|
101
|
+
function isInertLiteral(expression) {
|
|
102
|
+
return ((isIdentifier(expression) && expression.text === "undefined") ||
|
|
103
|
+
isNullLiteral(expression) ||
|
|
104
|
+
isTrueLiteral(expression) ||
|
|
105
|
+
isFalseLiteral(expression) ||
|
|
106
|
+
(isNumericLiteral(expression) && expression.text === "0"));
|
|
107
|
+
}
|
|
108
|
+
function readClassExpression(node, helpers) {
|
|
109
|
+
const reading = { literals: [] };
|
|
110
|
+
const markUnreadable = (at) => {
|
|
111
|
+
reading.unreadableAt ??= at;
|
|
112
|
+
};
|
|
113
|
+
const visit = (rawExpression) => {
|
|
114
|
+
const expression = unwrapTypeWrappers(rawExpression);
|
|
115
|
+
if (isStringLiteralLikeNode(expression)) {
|
|
116
|
+
reading.literals.push(...expression.text.split(/\s+/u).filter(Boolean));
|
|
117
|
+
}
|
|
118
|
+
else if (isInertLiteral(expression)) {
|
|
119
|
+
// no contribution
|
|
120
|
+
}
|
|
121
|
+
else if (isJsxExpression(expression)) {
|
|
122
|
+
if (expression.expression)
|
|
123
|
+
visit(expression.expression);
|
|
124
|
+
else
|
|
125
|
+
markUnreadable(expression);
|
|
126
|
+
}
|
|
127
|
+
else if (isCallExpression(expression) && isIdentifier(expression.expression) && helpers.has(expression.expression.text)) {
|
|
128
|
+
const isCva = expression.expression.text === "cva";
|
|
129
|
+
for (const argument of expression.arguments) {
|
|
130
|
+
if (isCva && isObjectLiteralExpression(unwrapTypeWrappers(argument)))
|
|
131
|
+
continue;
|
|
132
|
+
visit(argument);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
else if (isArrayLiteralExpression(expression)) {
|
|
136
|
+
for (const element of expression.elements)
|
|
137
|
+
visit(element);
|
|
138
|
+
}
|
|
139
|
+
else if (isConditionalExpression(expression)) {
|
|
140
|
+
visit(expression.whenTrue);
|
|
141
|
+
visit(expression.whenFalse);
|
|
142
|
+
}
|
|
143
|
+
else if (isBinaryExpression(expression) && expression.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken) {
|
|
144
|
+
visit(expression.right);
|
|
145
|
+
}
|
|
146
|
+
else if (isBinaryExpression(expression) &&
|
|
147
|
+
(expression.operatorToken.kind === SyntaxKind.BarBarToken ||
|
|
148
|
+
expression.operatorToken.kind === SyntaxKind.QuestionQuestionToken)) {
|
|
149
|
+
visit(expression.left);
|
|
150
|
+
visit(expression.right);
|
|
151
|
+
}
|
|
152
|
+
else if (isObjectLiteralExpression(expression)) {
|
|
153
|
+
for (const property of expression.properties) {
|
|
154
|
+
const key = isPropertyAssignment(property) || isShorthandPropertyAssignment(property)
|
|
155
|
+
? propertyKey(property.name)
|
|
156
|
+
: undefined;
|
|
157
|
+
if (key === undefined)
|
|
158
|
+
markUnreadable(property);
|
|
159
|
+
else
|
|
160
|
+
reading.literals.push(...key.split(/\s+/u).filter(Boolean));
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
markUnreadable(expression);
|
|
165
|
+
}
|
|
166
|
+
};
|
|
167
|
+
visit(node);
|
|
168
|
+
return reading;
|
|
169
|
+
}
|
|
170
|
+
function markerReason(initializer) {
|
|
171
|
+
const text = initializer && isStringLiteralLikeNode(initializer)
|
|
172
|
+
? initializer.text
|
|
173
|
+
: initializer && isJsxExpression(initializer) && initializer.expression && isStringLiteralLikeNode(initializer.expression)
|
|
174
|
+
? initializer.expression.text
|
|
175
|
+
: undefined;
|
|
176
|
+
const reason = text?.trim().replace(/\s+/gu, " ");
|
|
177
|
+
return reason ? reason : undefined;
|
|
178
|
+
}
|
|
179
|
+
export function createComponentOverrideRule({ classHelpers = [], sources = ["@ksmv/ui-react"], } = {}) {
|
|
180
|
+
const sourceSet = new Set(sources);
|
|
181
|
+
const helpers = new Set([...defaultHelpers, ...classHelpers]);
|
|
182
|
+
return {
|
|
183
|
+
name: "components/override",
|
|
184
|
+
scope: "components",
|
|
185
|
+
check(file) {
|
|
186
|
+
const result = {
|
|
187
|
+
overrides: [],
|
|
188
|
+
uncovered: [],
|
|
189
|
+
violations: [],
|
|
190
|
+
};
|
|
191
|
+
const sourceFile = file.sourceFile;
|
|
192
|
+
if (!sourceFile)
|
|
193
|
+
return result;
|
|
194
|
+
const imports = collectImports(sourceFile, sourceSet);
|
|
195
|
+
if (imports.size === 0)
|
|
196
|
+
return result;
|
|
197
|
+
const scopes = [];
|
|
198
|
+
const analyze = (element) => {
|
|
199
|
+
const opening = element;
|
|
200
|
+
const parts = tagParts(opening.tagName);
|
|
201
|
+
if (!parts)
|
|
202
|
+
return;
|
|
203
|
+
const binding = imports.get(parts.root);
|
|
204
|
+
if (!binding || scopes.some((names) => names.has(parts.root)))
|
|
205
|
+
return;
|
|
206
|
+
if (binding.namespace && parts.path.length === 0)
|
|
207
|
+
return;
|
|
208
|
+
const component = binding.namespace ? parts.path.join(".") : [binding.imported, ...parts.path].join(".");
|
|
209
|
+
const visual = new Set();
|
|
210
|
+
const styleVisual = new Set();
|
|
211
|
+
const kinds = new Set();
|
|
212
|
+
// Where each kind of unreadable finding originates, so two findings on
|
|
213
|
+
// the same element sort by position instead of falling back to message text.
|
|
214
|
+
const kindNodes = new Map();
|
|
215
|
+
let reason;
|
|
216
|
+
let sawClassOrStyle = false;
|
|
217
|
+
const readClass = (initializer) => {
|
|
218
|
+
sawClassOrStyle = true;
|
|
219
|
+
if (!initializer)
|
|
220
|
+
return;
|
|
221
|
+
const reading = readClassExpression(initializer, helpers);
|
|
222
|
+
for (const token of reading.literals) {
|
|
223
|
+
if (classifyClass(token) === "visual")
|
|
224
|
+
visual.add(token);
|
|
225
|
+
}
|
|
226
|
+
if (reading.unreadableAt) {
|
|
227
|
+
kinds.add("dynamic-class");
|
|
228
|
+
kindNodes.set("dynamic-class", reading.unreadableAt);
|
|
229
|
+
}
|
|
230
|
+
};
|
|
231
|
+
const readStyle = (initializer) => {
|
|
232
|
+
sawClassOrStyle = true;
|
|
233
|
+
const unwrappedInitializer = initializer && isJsxExpression(initializer) ? initializer.expression : initializer;
|
|
234
|
+
const expression = unwrappedInitializer && unwrapTypeWrappers(unwrappedInitializer);
|
|
235
|
+
if (!expression || !isObjectLiteralExpression(expression)) {
|
|
236
|
+
kinds.add("dynamic-style");
|
|
237
|
+
kindNodes.set("dynamic-style", expression ?? initializer ?? element);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
for (const property of expression.properties) {
|
|
241
|
+
const key = isPropertyAssignment(property) || isShorthandPropertyAssignment(property)
|
|
242
|
+
? propertyKey(property.name)
|
|
243
|
+
: undefined;
|
|
244
|
+
if (key === undefined) {
|
|
245
|
+
kinds.add("dynamic-style");
|
|
246
|
+
kindNodes.set("dynamic-style", kindNodes.get("dynamic-style") ?? property);
|
|
247
|
+
}
|
|
248
|
+
else if (classifyStyleProperty(key) === "visual")
|
|
249
|
+
styleVisual.add(key);
|
|
250
|
+
}
|
|
251
|
+
};
|
|
252
|
+
for (const attribute of opening.attributes.properties) {
|
|
253
|
+
if (isJsxSpreadAttribute(attribute)) {
|
|
254
|
+
const markSpread = (node) => {
|
|
255
|
+
if (!sawClassOrStyle)
|
|
256
|
+
return;
|
|
257
|
+
kinds.add("spread-after");
|
|
258
|
+
kindNodes.set("spread-after", kindNodes.get("spread-after") ?? node);
|
|
259
|
+
};
|
|
260
|
+
const spread = unwrapTypeWrappers(attribute.expression);
|
|
261
|
+
if (!isObjectLiteralExpression(spread)) {
|
|
262
|
+
markSpread(attribute);
|
|
263
|
+
continue;
|
|
264
|
+
}
|
|
265
|
+
for (const property of spread.properties) {
|
|
266
|
+
// A nested spread or a key the checker cannot name may carry
|
|
267
|
+
// className or style, so it counts as a spread at that point.
|
|
268
|
+
const value = isPropertyAssignment(property)
|
|
269
|
+
? property.initializer
|
|
270
|
+
: isShorthandPropertyAssignment(property)
|
|
271
|
+
? property.name
|
|
272
|
+
: undefined;
|
|
273
|
+
const key = isPropertyAssignment(property) || isShorthandPropertyAssignment(property)
|
|
274
|
+
? propertyKey(property.name)
|
|
275
|
+
: undefined;
|
|
276
|
+
if (value === undefined || key === undefined)
|
|
277
|
+
markSpread(property);
|
|
278
|
+
else if (key === "className")
|
|
279
|
+
readClass(value);
|
|
280
|
+
else if (key === "style")
|
|
281
|
+
readStyle(value);
|
|
282
|
+
}
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (!isJsxAttribute(attribute))
|
|
286
|
+
continue;
|
|
287
|
+
const name = isIdentifier(attribute.name) ? attribute.name.text : undefined;
|
|
288
|
+
if (name === "data-ui-override")
|
|
289
|
+
reason = markerReason(attribute.initializer);
|
|
290
|
+
if (name === "className")
|
|
291
|
+
readClass(attribute.initializer);
|
|
292
|
+
if (name === "style")
|
|
293
|
+
readStyle(attribute.initializer);
|
|
294
|
+
}
|
|
295
|
+
const locationOf = (node) => {
|
|
296
|
+
const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
|
|
297
|
+
return { column: position.character + 1, line: position.line + 1, path: file.path };
|
|
298
|
+
};
|
|
299
|
+
const location = locationOf(element);
|
|
300
|
+
const classes = [...visual].sort();
|
|
301
|
+
const styles = [...styleVisual].sort();
|
|
302
|
+
const findingKinds = [...kinds].sort();
|
|
303
|
+
if (reason !== undefined) {
|
|
304
|
+
result.overrides.push({
|
|
305
|
+
...location,
|
|
306
|
+
classes: [...classes, ...styles.map((key) => `style:${key}`), ...findingKinds],
|
|
307
|
+
component,
|
|
308
|
+
reason,
|
|
309
|
+
});
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
312
|
+
if (classes.length > 0) {
|
|
313
|
+
result.violations.push({
|
|
314
|
+
...location,
|
|
315
|
+
anchor: `${component}|classes|${classes.join(" ")}`,
|
|
316
|
+
message: `${component} has visual classes without data-ui-override: ${classes.join(", ")}.`,
|
|
317
|
+
nodeKind: "component-override:classes",
|
|
318
|
+
rule: "components/override",
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
if (styles.length > 0) {
|
|
322
|
+
result.violations.push({
|
|
323
|
+
...location,
|
|
324
|
+
anchor: `${component}|style|${styles.join(" ")}`,
|
|
325
|
+
message: `${component} has visual style properties without data-ui-override: ${styles.join(", ")}.`,
|
|
326
|
+
nodeKind: "component-override:style",
|
|
327
|
+
rule: "components/override",
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
for (const kind of findingKinds) {
|
|
331
|
+
result.violations.push({
|
|
332
|
+
...locationOf(kindNodes.get(kind) ?? element),
|
|
333
|
+
anchor: `${component}|${kind}`,
|
|
334
|
+
message: `${component} has ${kindMessages[kind]} without data-ui-override.`,
|
|
335
|
+
nodeKind: `component-override:${kind}`,
|
|
336
|
+
rule: "components/override",
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
const visit = (node) => {
|
|
341
|
+
const names = scopeNames(node);
|
|
342
|
+
if (names)
|
|
343
|
+
scopes.push(names);
|
|
344
|
+
if (isJsxOpeningElement(node) || isJsxSelfClosingElement(node))
|
|
345
|
+
analyze(node);
|
|
346
|
+
node.forEachChild(visit);
|
|
347
|
+
if (names)
|
|
348
|
+
scopes.pop();
|
|
349
|
+
};
|
|
350
|
+
sourceFile.forEachChild(visit);
|
|
351
|
+
return result;
|
|
352
|
+
},
|
|
353
|
+
};
|
|
354
|
+
}
|
package/dist/rules/default.d.ts
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import type { ThemeConfig } from "../config.js";
|
|
1
|
+
import type { ComponentsConfig, ThemeConfig } from "../config.js";
|
|
2
2
|
import type { ContrastManifest, ThemeContract } from "../contracts.js";
|
|
3
3
|
import type { CheckRule } from "../scan.js";
|
|
4
4
|
export interface DefaultRulesOptions {
|
|
5
|
+
components?: ComponentsConfig;
|
|
5
6
|
computed?: Record<string, Record<string, string>>;
|
|
6
7
|
contract?: ThemeContract;
|
|
7
8
|
manifest?: ContrastManifest;
|
|
8
9
|
themes: ThemeConfig[];
|
|
9
10
|
}
|
|
10
|
-
export declare function createDefaultRules({ computed, contract, manifest, themes, }: DefaultRulesOptions): Promise<CheckRule[]>;
|
|
11
|
+
export declare function createDefaultRules({ components, computed, contract, manifest, themes, }: DefaultRulesOptions): Promise<CheckRule[]>;
|
package/dist/rules/default.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
+
import { createComponentOverrideRule } from "./componentOverride.js";
|
|
1
2
|
import { createNoLiteralColorRule } from "./noLiteralColor.js";
|
|
2
3
|
import { createTokenCompleteRule, loadThemeContract } from "./tokenComplete.js";
|
|
3
4
|
import { createTokenContrastRule, loadContrastManifest, } from "./tokenContrast.js";
|
|
4
5
|
import { createTokenPrefixRule } from "./tokenPrefix.js";
|
|
5
|
-
export async function createDefaultRules({ computed, contract, manifest, themes, }) {
|
|
6
|
+
export async function createDefaultRules({ components, computed, contract, manifest, themes, }) {
|
|
6
7
|
const [resolvedContract, resolvedManifest] = await Promise.all([
|
|
7
8
|
contract ?? loadThemeContract(),
|
|
8
9
|
manifest ?? loadContrastManifest(),
|
|
@@ -12,5 +13,8 @@ export async function createDefaultRules({ computed, contract, manifest, themes,
|
|
|
12
13
|
createTokenPrefixRule(),
|
|
13
14
|
createTokenCompleteRule({ computed, contract: resolvedContract, themes }),
|
|
14
15
|
createTokenContrastRule({ computed, manifest: resolvedManifest, themes }),
|
|
16
|
+
...(components
|
|
17
|
+
? [createComponentOverrideRule({ classHelpers: components.classHelpers, sources: components.sources })]
|
|
18
|
+
: []),
|
|
15
19
|
];
|
|
16
20
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export type OverrideClass = "free" | "visual";
|
|
2
|
+
/** The utility a class applies, without variants, importance or negative sign. */
|
|
3
|
+
export declare function utilityOf(token: string): string;
|
|
4
|
+
export declare function classifyClass(token: string): OverrideClass;
|
|
5
|
+
export declare function classifyStyleProperty(name: string): OverrideClass;
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Splits a class at its variant separators: the colons outside square
|
|
3
|
+
* brackets, so `[&:hover]:bg-red-500` and `bg-[color:var(--x)]` stay intact.
|
|
4
|
+
*/
|
|
5
|
+
function splitVariants(token) {
|
|
6
|
+
const parts = [];
|
|
7
|
+
let depth = 0;
|
|
8
|
+
let current = "";
|
|
9
|
+
for (const char of token) {
|
|
10
|
+
if (char === "[")
|
|
11
|
+
depth += 1;
|
|
12
|
+
if (char === "]")
|
|
13
|
+
depth = Math.max(0, depth - 1);
|
|
14
|
+
if (char === ":" && depth === 0) {
|
|
15
|
+
parts.push(current);
|
|
16
|
+
current = "";
|
|
17
|
+
continue;
|
|
18
|
+
}
|
|
19
|
+
current += char;
|
|
20
|
+
}
|
|
21
|
+
parts.push(current);
|
|
22
|
+
return parts;
|
|
23
|
+
}
|
|
24
|
+
/** The utility a class applies, without variants, importance or negative sign. */
|
|
25
|
+
export function utilityOf(token) {
|
|
26
|
+
let utility = splitVariants(token).at(-1) ?? "";
|
|
27
|
+
if (utility.startsWith("!"))
|
|
28
|
+
utility = utility.slice(1);
|
|
29
|
+
if (utility.endsWith("!"))
|
|
30
|
+
utility = utility.slice(0, -1);
|
|
31
|
+
if (utility.startsWith("-"))
|
|
32
|
+
utility = utility.slice(1);
|
|
33
|
+
return utility;
|
|
34
|
+
}
|
|
35
|
+
// Spec §4.1: fitting into the parent is free; everything else, including any
|
|
36
|
+
// class outside this list, changes the component and needs a marker. A class
|
|
37
|
+
// is free only when its utility AND its value match the family's grammar —
|
|
38
|
+
// matching only the prefix would also free look-alike product classes such as
|
|
39
|
+
// `top-bar` or `order-summary`.
|
|
40
|
+
const num = String.raw `\d+(?:\.\d+)?`;
|
|
41
|
+
const frac = String.raw `\d+\/\d+`;
|
|
42
|
+
// An arbitrary value may nest brackets or parentheses (`mt-[theme(spacing[2])]`);
|
|
43
|
+
// the family prefix already fixes the property, so any bracketed value is fine.
|
|
44
|
+
const arb = String.raw `\[\S+\]|\(\S+\)`;
|
|
45
|
+
const size = String.raw `3xs|2xs|xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|prose`;
|
|
46
|
+
const vw = String.raw `screen|svw|lvw|dvw`;
|
|
47
|
+
const freeUtilities = [
|
|
48
|
+
new RegExp(`^m[trblxyse]?-(?:${num}|px|auto|${arb})$`, "u"),
|
|
49
|
+
new RegExp(`^(?:min-|max-)?w-(?:${num}|${frac}|px|auto|full|${vw}|min|max|fit|${size}|${arb})$`, "u"),
|
|
50
|
+
/^max-w-none$/u,
|
|
51
|
+
/^flex-(?:1|none|auto|initial)$/u,
|
|
52
|
+
/^grow$/u,
|
|
53
|
+
new RegExp(`^grow-(?:${num}|${arb})$`, "u"),
|
|
54
|
+
/^shrink$/u,
|
|
55
|
+
new RegExp(`^shrink-(?:${num}|${arb})$`, "u"),
|
|
56
|
+
new RegExp(`^basis-(?:${num}|${frac}|px|auto|full|${size}|${arb})$`, "u"),
|
|
57
|
+
new RegExp(`^order-(?:${num}|first|last|none|${arb})$`, "u"),
|
|
58
|
+
/^(?:self|justify-self|place-self)-(?:auto|start|end|center|stretch|baseline)$/u,
|
|
59
|
+
new RegExp(`^(?:col|row)-span-(?:${num}|full|${arb})$`, "u"),
|
|
60
|
+
new RegExp(`^(?:col|row)-(?:start|end)-(?:${num}|auto|${arb})$`, "u"),
|
|
61
|
+
/^(?:static|relative|absolute|fixed|sticky)$/u,
|
|
62
|
+
new RegExp(`^inset(?:-[xy])?-(?:${num}|${frac}|px|auto|full|${arb})$`, "u"),
|
|
63
|
+
new RegExp(`^(?:top|right|bottom|left|start|end)-(?:${num}|${frac}|px|auto|full|${arb})$`, "u"),
|
|
64
|
+
new RegExp(`^z-(?:${num}|auto|${arb})$`, "u"),
|
|
65
|
+
/^(?:hidden|sr-only|not-sr-only|invisible|visible)$/u,
|
|
66
|
+
/^text-(?:left|center|right|justify|start|end)$/u,
|
|
67
|
+
];
|
|
68
|
+
export function classifyClass(token) {
|
|
69
|
+
const utility = utilityOf(token);
|
|
70
|
+
return freeUtilities.some((pattern) => pattern.test(utility)) ? "free" : "visual";
|
|
71
|
+
}
|
|
72
|
+
// Spec §4.2, by property name; kebab-case keys are read as camelCase.
|
|
73
|
+
const freeStyleProperties = new Set([
|
|
74
|
+
"margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginBlock",
|
|
75
|
+
"marginBlockStart", "marginBlockEnd", "marginInline", "marginInlineStart", "marginInlineEnd",
|
|
76
|
+
"width", "minWidth", "maxWidth", "flex", "flexGrow", "flexShrink", "flexBasis", "order",
|
|
77
|
+
"alignSelf", "justifySelf", "placeSelf", "gridColumn", "gridColumnStart", "gridColumnEnd",
|
|
78
|
+
"gridRow", "gridRowStart", "gridRowEnd", "gridArea", "position", "inset", "insetBlock",
|
|
79
|
+
"insetBlockStart", "insetBlockEnd", "insetInline", "insetInlineStart", "insetInlineEnd",
|
|
80
|
+
"top", "right", "bottom", "left", "zIndex", "textAlign", "visibility",
|
|
81
|
+
]);
|
|
82
|
+
export function classifyStyleProperty(name) {
|
|
83
|
+
const camel = name.replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase());
|
|
84
|
+
return freeStyleProperties.has(camel) ? "free" : "visual";
|
|
85
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { unresolvedTokenReferences } from "../color.js";
|
|
2
2
|
import { parseThemeContract, } from "../contracts.js";
|
|
3
|
+
import { densityExpectation, densityTokenKinds, isValidDensityValue, resolveDensityReference, } from "../density.js";
|
|
3
4
|
import { createThemeCollector } from "./theme.js";
|
|
4
5
|
export async function loadThemeContract(url) {
|
|
5
6
|
const { readFile } = await import("node:fs/promises");
|
|
@@ -116,6 +117,38 @@ export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
|
|
|
116
117
|
}
|
|
117
118
|
}
|
|
118
119
|
}
|
|
120
|
+
// Density values are checked on the value the browser resolved when the
|
|
121
|
+
// evidence carries it; without evidence, a var() is checked through
|
|
122
|
+
// what it resolves to in this theme or its own fallback, and one that
|
|
123
|
+
// resolves to nothing stays with the reference check above. An invalid
|
|
124
|
+
// value is mandatory coverage, so no baseline can absorb it.
|
|
125
|
+
for (const [token, kind] of Object.entries(densityTokenKinds)) {
|
|
126
|
+
if (!evidenceTokens.has(token))
|
|
127
|
+
continue;
|
|
128
|
+
const declaration = theme.declarations.get(token);
|
|
129
|
+
// A derived token that is not declared here is validated through
|
|
130
|
+
// its own source (the fallback token it derives from); reporting
|
|
131
|
+
// it again from this theme would just duplicate that diagnostic.
|
|
132
|
+
if (token in contract.derived && !declaration)
|
|
133
|
+
continue;
|
|
134
|
+
const raw = computedTheme[token] ?? declaration?.value;
|
|
135
|
+
if (raw === undefined)
|
|
136
|
+
continue;
|
|
137
|
+
const value = /var\s*\(/iu.test(raw) ? resolveDensityReference(raw, tokens) : raw;
|
|
138
|
+
if (value === undefined)
|
|
139
|
+
continue;
|
|
140
|
+
if (!isValidDensityValue(kind, value)) {
|
|
141
|
+
result.uncovered.push({
|
|
142
|
+
...(declaration
|
|
143
|
+
? { column: declaration.column, line: declaration.line, path: declaration.path }
|
|
144
|
+
: location),
|
|
145
|
+
message: `Theme ${theme.name} has an invalid value for ${token}; expected ${densityExpectation(kind)}.`,
|
|
146
|
+
nodeKind: "theme-density-value",
|
|
147
|
+
rule: "tokens/complete",
|
|
148
|
+
required: true,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
119
152
|
}
|
|
120
153
|
return result;
|
|
121
154
|
},
|
package/dist/scan.d.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { type Root } from "postcss";
|
|
2
2
|
import { SyntaxKind, type SourceFile } from "typescript/unstable/ast";
|
|
3
3
|
import type { ChecksConfig } from "./config.js";
|
|
4
|
-
import type { CheckReport, Uncovered, Violation } from "./result.js";
|
|
4
|
+
import type { CheckReport, ComponentOverride, Uncovered, Violation } from "./result.js";
|
|
5
5
|
export interface ScannedFile {
|
|
6
6
|
content: string;
|
|
7
7
|
cssRoot?: Root;
|
|
@@ -16,6 +16,7 @@ export interface TypeScriptToken {
|
|
|
16
16
|
text: string;
|
|
17
17
|
}
|
|
18
18
|
export interface RuleResult {
|
|
19
|
+
overrides?: ComponentOverride[];
|
|
19
20
|
uncovered: Uncovered[];
|
|
20
21
|
violations: Violation[];
|
|
21
22
|
}
|
|
@@ -24,6 +25,8 @@ export interface CheckRule {
|
|
|
24
25
|
check(file: ScannedFile): Promise<RuleResult> | RuleResult;
|
|
25
26
|
complete?(): Promise<RuleResult> | RuleResult;
|
|
26
27
|
name: string;
|
|
28
|
+
/** Which file set the rule runs on; "main" (the default) is `include`. */
|
|
29
|
+
scope?: "main" | "components";
|
|
27
30
|
}
|
|
28
31
|
export interface RunChecksOptions {
|
|
29
32
|
applyGovernance?: boolean;
|
package/dist/scan.js
CHANGED
|
@@ -88,6 +88,7 @@ function emptyReport(rulesExecuted) {
|
|
|
88
88
|
filesRead: 0,
|
|
89
89
|
nodesChecked: 0,
|
|
90
90
|
operationalErrors: [],
|
|
91
|
+
overrides: [],
|
|
91
92
|
rulesExecuted,
|
|
92
93
|
schemaVersion: 1,
|
|
93
94
|
suppressions: [],
|
|
@@ -150,6 +151,25 @@ function validateRuntimeConfig(value) {
|
|
|
150
151
|
theme.computedEvidence.theme.length === 0))))) {
|
|
151
152
|
return "Themes must be an array of named selectors with files.";
|
|
152
153
|
}
|
|
154
|
+
if (candidate.components !== undefined) {
|
|
155
|
+
const components = candidate.components;
|
|
156
|
+
const isList = (value, nonEmpty) => Array.isArray(value) &&
|
|
157
|
+
(!nonEmpty || value.length > 0) &&
|
|
158
|
+
value.every((item) => typeof item === "string" && item.length > 0);
|
|
159
|
+
if (!components ||
|
|
160
|
+
typeof components !== "object" ||
|
|
161
|
+
Array.isArray(components) ||
|
|
162
|
+
Object.keys(components).some((key) => !["classHelpers", "exclude", "include", "sources"].includes(key)) ||
|
|
163
|
+
!isList(components.include, true) ||
|
|
164
|
+
(components.exclude !== undefined &&
|
|
165
|
+
!isList(components.exclude, false)) ||
|
|
166
|
+
(components.sources !== undefined &&
|
|
167
|
+
!isList(components.sources, true)) ||
|
|
168
|
+
(components.classHelpers !== undefined &&
|
|
169
|
+
!isList(components.classHelpers, false))) {
|
|
170
|
+
return "components needs include patterns and optional exclude, sources and classHelpers string lists.";
|
|
171
|
+
}
|
|
172
|
+
}
|
|
153
173
|
return undefined;
|
|
154
174
|
}
|
|
155
175
|
export async function runChecks(config, options = {}) {
|
|
@@ -192,6 +212,7 @@ export async function runChecks(config, options = {}) {
|
|
|
192
212
|
themes: config.themes ?? [],
|
|
193
213
|
});
|
|
194
214
|
configuredRules = await (await import("./rules/default.js")).createDefaultRules({
|
|
215
|
+
components: config.components,
|
|
195
216
|
computed,
|
|
196
217
|
contract: contracts.theme,
|
|
197
218
|
manifest: contracts.contrast,
|
|
@@ -251,7 +272,19 @@ export async function runChecks(config, options = {}) {
|
|
|
251
272
|
}))
|
|
252
273
|
.sort((left, right) => left.path.localeCompare(right.path));
|
|
253
274
|
report.filesDiscovered = includedBeforeExcludes.length;
|
|
254
|
-
|
|
275
|
+
const componentPaths = config.components
|
|
276
|
+
? (await discoverFiles(root, config.components.include, config.components.exclude)).filter((path) => extname(path).toLowerCase() !== ".css")
|
|
277
|
+
: [];
|
|
278
|
+
if (config.components && componentPaths.length === 0) {
|
|
279
|
+
report.operationalErrors.push({
|
|
280
|
+
code: "no-files",
|
|
281
|
+
message: "No files matched components.include.",
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
const mainPaths = new Set(absolutePaths);
|
|
285
|
+
const componentSet = new Set(componentPaths);
|
|
286
|
+
const allPaths = [...new Set([...absolutePaths, ...componentPaths])].sort((left, right) => left.localeCompare(right));
|
|
287
|
+
if (allPaths.length === 0) {
|
|
255
288
|
report.operationalErrors.push({
|
|
256
289
|
code: "no-files",
|
|
257
290
|
message: "No files matched the configured include patterns.",
|
|
@@ -267,7 +300,7 @@ export async function runChecks(config, options = {}) {
|
|
|
267
300
|
// in one report, so type analysis is refused rather than silently mixed.
|
|
268
301
|
const typeScriptPaths = options.readFile
|
|
269
302
|
? []
|
|
270
|
-
:
|
|
303
|
+
: allPaths.filter((path) => extname(path).toLowerCase() !== ".css");
|
|
271
304
|
if (options.readFile) {
|
|
272
305
|
report.operationalErrors.push({
|
|
273
306
|
code: "internal",
|
|
@@ -292,8 +325,10 @@ export async function runChecks(config, options = {}) {
|
|
|
292
325
|
});
|
|
293
326
|
}
|
|
294
327
|
}
|
|
295
|
-
for (const absolutePath of
|
|
328
|
+
for (const absolutePath of allPaths) {
|
|
296
329
|
const path = toPosixPath(relative(root, absolutePath));
|
|
330
|
+
const inMain = mainPaths.has(absolutePath);
|
|
331
|
+
const inComponents = componentSet.has(absolutePath);
|
|
297
332
|
let content;
|
|
298
333
|
try {
|
|
299
334
|
content = await readSource(absolutePath);
|
|
@@ -307,7 +342,8 @@ export async function runChecks(config, options = {}) {
|
|
|
307
342
|
});
|
|
308
343
|
continue;
|
|
309
344
|
}
|
|
310
|
-
if (
|
|
345
|
+
if (inMain &&
|
|
346
|
+
usesDefaultRules &&
|
|
311
347
|
config.contractSource === "bundled" &&
|
|
312
348
|
/(?:from\s*|import\s*(?:\(\s*)?)["']@ksmv\/ui-react(?:\/[^"']*)?["']/.test(content) &&
|
|
313
349
|
!report.operationalErrors.some(({ code, message }) => code === "config" &&
|
|
@@ -341,7 +377,8 @@ export async function runChecks(config, options = {}) {
|
|
|
341
377
|
file.sourceFile = project.program.getSourceFile(absolutePath);
|
|
342
378
|
}
|
|
343
379
|
report.nodesChecked += coverage.nodesChecked;
|
|
344
|
-
|
|
380
|
+
if (inMain)
|
|
381
|
+
report.uncovered.push(...coverage.uncovered);
|
|
345
382
|
}
|
|
346
383
|
}
|
|
347
384
|
catch {
|
|
@@ -353,10 +390,14 @@ export async function runChecks(config, options = {}) {
|
|
|
353
390
|
continue;
|
|
354
391
|
}
|
|
355
392
|
for (const rule of rules) {
|
|
393
|
+
const runsHere = (rule.scope ?? "main") === "components" ? inComponents : inMain;
|
|
394
|
+
if (!runsHere)
|
|
395
|
+
continue;
|
|
356
396
|
try {
|
|
357
397
|
const result = await rule.check(file);
|
|
358
398
|
report.violations.push(...result.violations);
|
|
359
399
|
report.uncovered.push(...result.uncovered);
|
|
400
|
+
report.overrides.push(...(result.overrides ?? []));
|
|
360
401
|
}
|
|
361
402
|
catch {
|
|
362
403
|
report.operationalErrors.push({
|
|
@@ -375,6 +416,7 @@ export async function runChecks(config, options = {}) {
|
|
|
375
416
|
const result = await rule.complete();
|
|
376
417
|
report.violations.push(...result.violations);
|
|
377
418
|
report.uncovered.push(...result.uncovered);
|
|
419
|
+
report.overrides.push(...(result.overrides ?? []));
|
|
378
420
|
}
|
|
379
421
|
catch {
|
|
380
422
|
report.operationalErrors.push({
|
|
@@ -394,6 +436,10 @@ export async function runChecks(config, options = {}) {
|
|
|
394
436
|
report.uncovered.sort(compareFindings);
|
|
395
437
|
report.operationalErrors.sort((left, right) => (left.path ?? "").localeCompare(right.path ?? "") ||
|
|
396
438
|
left.code.localeCompare(right.code));
|
|
439
|
+
report.overrides.sort((left, right) => left.path.localeCompare(right.path) ||
|
|
440
|
+
left.line - right.line ||
|
|
441
|
+
left.column - right.column ||
|
|
442
|
+
left.component.localeCompare(right.component));
|
|
397
443
|
report.exitCode = resolveExitCode(report, config.failOnUncovered ?? true);
|
|
398
444
|
report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
|
|
399
445
|
return report;
|
package/dist/theme-contract.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion": "
|
|
2
|
+
"contractVersion": "2.0.0",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"prefix": "--ui-",
|
|
5
5
|
"required": [
|
|
@@ -62,7 +62,27 @@
|
|
|
62
62
|
"--ui-radius-pill",
|
|
63
63
|
"--ui-shadow-sm",
|
|
64
64
|
"--ui-shadow-md",
|
|
65
|
-
"--ui-shadow-lg"
|
|
65
|
+
"--ui-shadow-lg",
|
|
66
|
+
"--ui-font-size-xs",
|
|
67
|
+
"--ui-font-size-sm",
|
|
68
|
+
"--ui-font-size-md",
|
|
69
|
+
"--ui-font-size-lg",
|
|
70
|
+
"--ui-font-size-xl",
|
|
71
|
+
"--ui-font-weight-strong",
|
|
72
|
+
"--ui-line-height-control",
|
|
73
|
+
"--ui-control-height-sm",
|
|
74
|
+
"--ui-control-height-md",
|
|
75
|
+
"--ui-control-height-lg",
|
|
76
|
+
"--ui-control-padding-inline-sm",
|
|
77
|
+
"--ui-control-padding-inline-md",
|
|
78
|
+
"--ui-control-padding-inline-lg",
|
|
79
|
+
"--ui-space-1",
|
|
80
|
+
"--ui-space-1-5",
|
|
81
|
+
"--ui-space-2",
|
|
82
|
+
"--ui-space-3",
|
|
83
|
+
"--ui-space-4",
|
|
84
|
+
"--ui-space-6",
|
|
85
|
+
"--ui-space-8"
|
|
66
86
|
],
|
|
67
87
|
"derived": {
|
|
68
88
|
"--ui-action-primary-border": "--ui-action-primary-bg",
|
|
@@ -71,7 +91,10 @@
|
|
|
71
91
|
"--ui-status-success-border": "--ui-status-success",
|
|
72
92
|
"--ui-status-warning-border": "--ui-status-warning",
|
|
73
93
|
"--ui-status-danger-border": "--ui-status-danger",
|
|
74
|
-
"--ui-status-info-border": "--ui-status-info"
|
|
94
|
+
"--ui-status-info-border": "--ui-status-info",
|
|
95
|
+
"--ui-field-height": "--ui-control-height-md",
|
|
96
|
+
"--ui-dialog-title-font-size": "--ui-font-size-xl",
|
|
97
|
+
"--ui-button-font-weight": "--ui-font-weight-strong"
|
|
75
98
|
},
|
|
76
99
|
"brandComposition": [
|
|
77
100
|
"--ui-brand-primary",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksmv/ui-checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.3.0",
|
|
4
4
|
"description": "Static and computed quality checks for semantic UI token contracts.",
|
|
5
5
|
"keywords": ["accessibility", "design-tokens", "quality", "static-analysis"],
|
|
6
6
|
"license": "Apache-2.0",
|