@ksmv/ui-checks 0.3.2 → 0.4.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 +15 -0
- package/README.md +12 -3
- package/dist/contracts.d.ts +7 -0
- package/dist/contracts.js +16 -13
- package/dist/contrast-contract.json +4 -2
- package/dist/density.js +3 -0
- package/dist/evidence.js +157 -64
- package/dist/rules/default.js +6 -1
- package/dist/rules/tokenComplete.js +12 -3
- package/dist/rules/tokenContrast.d.ts +2 -1
- package/dist/rules/tokenContrast.js +9 -1
- package/dist/scan.js +8 -3
- package/dist/theme-contract.json +7 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,20 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.0 - 2026-10-03
|
|
4
|
+
|
|
5
|
+
- **Contratos de tema 2.2.0 e de contraste 2.0.0** empacotados: dois derivados e dois pares do controle marcado (ver o CHANGELOG do `@ksmv/ui-react`). Um par novo pode reprovar um tema que hoje passa.
|
|
6
|
+
- **`tokens/contrast` resolve derivados não declarados** pela cadeia do contrato até um token declarado. Na 0.3.3 e anteriores, um par com um derivado que o tema não declarava ficava como cobertura pendente quando não havia evidência calculada.
|
|
7
|
+
- **A evidência pede a origem de um derivado usado num par de contraste.** Na 0.3.3 e anteriores, toda ponta de par era exigida com valor, e um derivado não declarado, que o navegador informa vazio, fazia a evidência ser recusada.
|
|
8
|
+
- **A identidade dos contratos muda**, então evidência capturada antes da atualização é recusada com código 2: quem usa `computedEvidence` precisa capturar de novo.
|
|
9
|
+
|
|
10
|
+
## 0.3.3 - 2026-10-02
|
|
11
|
+
|
|
12
|
+
- **O erro de inicialização das regras diz a causa.** Na 0.3.2 e anteriores, evidência recusada ou contrato não resolvido saíam com código 2 e só com `Unable to initialize configured rules.`. O erro operacional agora diz qual foi o motivo e, quando há, o remédio: arquivo de evidência ausente, ilegível ou fora do formato; identidade do contrato diferente da instalada, com as duas versões; hash das fontes do tema diferente; seletores ou tema diferentes dos configurados; `capturedAt` inválido ou no futuro; tokens sem valor ou com `var(...)` restante, com os nomes dos tokens. A mensagem não repete valores do produto, e a saída continua sendo código 2.
|
|
13
|
+
- Quem compara o texto exato `Invalid computed theme evidence.` lançado por `loadComputedEvidence` precisa ajustar: as mensagens novas começam com esse texto e seguem com a causa.
|
|
14
|
+
- **Contrato de tema 2.1.0** empacotado, com 3 derivados opcionais novos: `--ui-button-font-weight-sm`, `--ui-button-font-weight-md` e `--ui-button-font-weight-lg`, que herdam de `--ui-button-font-weight`. Nenhuma declaração nova é exigida do tema.
|
|
15
|
+
- **A identidade do contrato muda**, então evidência capturada antes da atualização é recusada com código 2: quem usa `computedEvidence` precisa capturar de novo.
|
|
16
|
+
- `tokens/complete` valida os três como peso (inteiro de 1 a 1000, `normal` ou `bold`) e passa a seguir a cadeia de derivados até um token declarado. Na 0.3.2 e anteriores só o primeiro nível era conferido, e um derivado de outro derivado ficava sem fallback quando o tema não declarava o intermediário.
|
|
17
|
+
|
|
3
18
|
## 0.3.2 - 2026-10-01
|
|
4
19
|
|
|
5
20
|
- **O hash das fontes do tema deixa de depender do fim de linha do checkout.** Num repositório sem regra de fim de linha, o mesmo commit sai com CRLF no Windows e com LF no Linux. Na 0.3.1 e anteriores o hash cobria os bytes do arquivo, então a evidência capturada num sistema era recusada no outro, com código 2: verde na máquina de quem capturou e vermelho no CI. `CRLF` passa a contar como `LF`; qualquer outra diferença continua mudando o hash.
|
package/README.md
CHANGED
|
@@ -80,13 +80,22 @@ Seletores disjuntos, como `[data-theme="light"]` e `[data-theme="dark"]`, são v
|
|
|
80
80
|
|
|
81
81
|
A evidência não é uma exceção manual. O arquivo é recusado, com código `2`, quando:
|
|
82
82
|
|
|
83
|
-
-
|
|
84
|
-
-
|
|
83
|
+
- o arquivo não existe, não pode ser lido, não é JSON válido ou não tem a estrutura da receita abaixo;
|
|
84
|
+
- `capturedAt` não é um horário UTC ou está no futuro;
|
|
85
|
+
- os seletores diferem dos configurados, ou o tema nomeado em `computedEvidence.theme` não está no arquivo ou foi capturado para outro seletor;
|
|
85
86
|
- o hash das fontes diverge, ou seja, o tema mudou depois da captura (o fim de linha não conta: o mesmo arquivo em CRLF e em LF tem o mesmo hash);
|
|
86
87
|
- a identidade de um dos contratos diverge da instalada, ou seja, o pacote React mudou depois da captura;
|
|
87
88
|
- falta algum token exigido, ou algum valor ainda contém `var(...)`.
|
|
88
89
|
|
|
89
|
-
|
|
90
|
+
O erro operacional diz qual desses motivos ocorreu e, quando há, o remédio. Por exemplo, depois de alterar um arquivo de tema sem capturar de novo:
|
|
91
|
+
|
|
92
|
+
```text
|
|
93
|
+
config Invalid computed theme evidence in artifacts/theme-evidence.json: the theme source hash differs, so a theme file changed after the capture. Capture the evidence again.
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Depois de atualizar o pacote React, a mensagem traz a versão do contrato da captura e a do contrato instalado; quando faltam tokens ou resta `var(...)`, traz os nomes dos tokens, em ordem, até dez, e a contagem dos demais. A mensagem não repete valores do produto: aparecem só o caminho da evidência e o nome do tema como estão na configuração, nomes de tokens e versões de contrato. Falha ao resolver os contratos também é relatada com a própria causa (`Unable to resolve the configured contract source.`, `Unsupported theme contract.` ou `Unsupported contrast manifest.`).
|
|
97
|
+
|
|
98
|
+
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. Quando a origem também é um derivado, como no peso do botão por tamanho (`--ui-button-font-weight-sm`, `-md` e `-lg` derivam de `--ui-button-font-weight`), a cadeia é seguida até um token declarado. Um derivado que é ponta de um par de contraste, como `--ui-control-checked-bg`, também pode ficar vazio; a captura precisa então do token em que a cadeia dele termina. Sem evidência, `tokens/contrast` segue a mesma cadeia: um derivado que o tema não declara é avaliado pelo valor da origem.
|
|
90
99
|
|
|
91
100
|
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
101
|
|
package/dist/contracts.d.ts
CHANGED
|
@@ -32,6 +32,13 @@ export interface ContractSet {
|
|
|
32
32
|
source: ContractSource;
|
|
33
33
|
theme: ThemeContract;
|
|
34
34
|
}
|
|
35
|
+
/**
|
|
36
|
+
* A failure whose message this package wrote, so a report may repeat it. The
|
|
37
|
+
* message of any other error is never reported: it could carry product content.
|
|
38
|
+
*/
|
|
39
|
+
export declare class CheckSetupError extends Error {
|
|
40
|
+
}
|
|
41
|
+
export declare const semverPattern: RegExp;
|
|
35
42
|
export declare function parseThemeContract(content: string): ThemeContract;
|
|
36
43
|
export declare function parseContrastManifest(content: string): ContrastManifest;
|
|
37
44
|
export declare function resolveContractSet({ root, source, }: {
|
package/dist/contracts.js
CHANGED
|
@@ -3,7 +3,13 @@ import { readFile } from "node:fs/promises";
|
|
|
3
3
|
import { createRequire } from "node:module";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
export const tokenNamePattern = /^--ui-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
6
|
-
|
|
6
|
+
/**
|
|
7
|
+
* A failure whose message this package wrote, so a report may repeat it. The
|
|
8
|
+
* message of any other error is never reported: it could carry product content.
|
|
9
|
+
*/
|
|
10
|
+
export class CheckSetupError extends Error {
|
|
11
|
+
}
|
|
12
|
+
export const semverPattern = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
|
|
7
13
|
function exactKeys(value, keys) {
|
|
8
14
|
return Object.keys(value).sort().join(",") === [...keys].sort().join(",");
|
|
9
15
|
}
|
|
@@ -19,7 +25,7 @@ export function parseThemeContract(content) {
|
|
|
19
25
|
value = JSON.parse(content);
|
|
20
26
|
}
|
|
21
27
|
catch {
|
|
22
|
-
throw new
|
|
28
|
+
throw new CheckSetupError("Unsupported theme contract.");
|
|
23
29
|
}
|
|
24
30
|
if (!isRecord(value) ||
|
|
25
31
|
!exactKeys(value, [
|
|
@@ -42,7 +48,7 @@ export function parseThemeContract(content) {
|
|
|
42
48
|
Object.entries(value.derived).some(([token, fallback]) => !tokenNamePattern.test(token) ||
|
|
43
49
|
typeof fallback !== "string" ||
|
|
44
50
|
!tokenNamePattern.test(fallback))) {
|
|
45
|
-
throw new
|
|
51
|
+
throw new CheckSetupError("Unsupported theme contract.");
|
|
46
52
|
}
|
|
47
53
|
return value;
|
|
48
54
|
}
|
|
@@ -52,7 +58,7 @@ export function parseContrastManifest(content) {
|
|
|
52
58
|
value = JSON.parse(content);
|
|
53
59
|
}
|
|
54
60
|
catch {
|
|
55
|
-
throw new
|
|
61
|
+
throw new CheckSetupError("Unsupported contrast manifest.");
|
|
56
62
|
}
|
|
57
63
|
if (!isRecord(value) ||
|
|
58
64
|
!exactKeys(value, ["contractVersion", "pairs", "schemaVersion"]) ||
|
|
@@ -60,7 +66,7 @@ export function parseContrastManifest(content) {
|
|
|
60
66
|
typeof value.contractVersion !== "string" ||
|
|
61
67
|
!semverPattern.test(value.contractVersion) ||
|
|
62
68
|
!Array.isArray(value.pairs)) {
|
|
63
|
-
throw new
|
|
69
|
+
throw new CheckSetupError("Unsupported contrast manifest.");
|
|
64
70
|
}
|
|
65
71
|
const identities = [];
|
|
66
72
|
for (const pair of value.pairs) {
|
|
@@ -75,12 +81,12 @@ export function parseContrastManifest(content) {
|
|
|
75
81
|
typeof pair.minimum !== "number" ||
|
|
76
82
|
!Number.isFinite(pair.minimum) ||
|
|
77
83
|
pair.minimum <= 0) {
|
|
78
|
-
throw new
|
|
84
|
+
throw new CheckSetupError("Unsupported contrast manifest.");
|
|
79
85
|
}
|
|
80
86
|
identities.push(`${pair.context}\u0000${pair.foreground}\u0000${pair.background}`);
|
|
81
87
|
}
|
|
82
88
|
if (new Set(identities).size !== identities.length) {
|
|
83
|
-
throw new
|
|
89
|
+
throw new CheckSetupError("Unsupported contrast manifest.");
|
|
84
90
|
}
|
|
85
91
|
return value;
|
|
86
92
|
}
|
|
@@ -99,7 +105,7 @@ async function reactContractPaths(root) {
|
|
|
99
105
|
};
|
|
100
106
|
}
|
|
101
107
|
catch {
|
|
102
|
-
throw new
|
|
108
|
+
throw new CheckSetupError("Unable to resolve the configured contract source.");
|
|
103
109
|
}
|
|
104
110
|
}
|
|
105
111
|
export async function resolveContractSet({ root, source, }) {
|
|
@@ -127,12 +133,9 @@ export async function resolveContractSet({ root, source, }) {
|
|
|
127
133
|
};
|
|
128
134
|
}
|
|
129
135
|
catch (error) {
|
|
130
|
-
if (error instanceof
|
|
131
|
-
(error.message === "Unsupported theme contract." ||
|
|
132
|
-
error.message === "Unsupported contrast manifest." ||
|
|
133
|
-
error.message === "Unable to resolve the configured contract source.")) {
|
|
136
|
+
if (error instanceof CheckSetupError) {
|
|
134
137
|
throw error;
|
|
135
138
|
}
|
|
136
|
-
throw new
|
|
139
|
+
throw new CheckSetupError("Unable to resolve the configured contract source.");
|
|
137
140
|
}
|
|
138
141
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion": "
|
|
2
|
+
"contractVersion": "2.0.0",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"pairs": [
|
|
5
5
|
{ "foreground": "--ui-action-primary-text", "background": "--ui-action-primary-bg", "minimum": 4.5, "context": "normal-text" },
|
|
@@ -33,6 +33,8 @@
|
|
|
33
33
|
{ "foreground": "--ui-text-inverse", "background": "--ui-surface-inverse", "minimum": 4.5, "context": "inverse-text" },
|
|
34
34
|
{ "foreground": "--ui-border-control", "background": "--ui-surface", "minimum": 3, "context": "control-boundary" },
|
|
35
35
|
{ "foreground": "--ui-focus-ring", "background": "--ui-surface", "minimum": 3, "context": "focus-indicator" },
|
|
36
|
-
{ "foreground": "--ui-focus-ring", "background": "--ui-focus-ring-offset", "minimum": 3, "context": "focus-indicator" }
|
|
36
|
+
{ "foreground": "--ui-focus-ring", "background": "--ui-focus-ring-offset", "minimum": 3, "context": "focus-indicator" },
|
|
37
|
+
{ "foreground": "--ui-control-checked-bg", "background": "--ui-surface", "minimum": 3, "context": "checked-control-boundary" },
|
|
38
|
+
{ "foreground": "--ui-control-checked-fg", "background": "--ui-control-checked-bg", "minimum": 3, "context": "checked-indicator" }
|
|
37
39
|
]
|
|
38
40
|
}
|
package/dist/density.js
CHANGED
|
@@ -28,6 +28,9 @@ export const densityTokenKinds = Object.freeze({
|
|
|
28
28
|
"--ui-field-height": "control-height",
|
|
29
29
|
"--ui-dialog-title-font-size": "font-size",
|
|
30
30
|
"--ui-button-font-weight": "font-weight",
|
|
31
|
+
"--ui-button-font-weight-sm": "font-weight",
|
|
32
|
+
"--ui-button-font-weight-md": "font-weight",
|
|
33
|
+
"--ui-button-font-weight-lg": "font-weight",
|
|
31
34
|
});
|
|
32
35
|
const lengthPattern = /^(?:\d+(?:\.\d+)?|\.\d+)(?:px|rem|em)$/iu;
|
|
33
36
|
const math = /^(?:calc|clamp|min|max)\(.+\)$/iu;
|
package/dist/evidence.js
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { readFile } from "node:fs/promises";
|
|
3
3
|
import { isAbsolute, relative, resolve } from "node:path";
|
|
4
|
-
import { tokenNamePattern, } from "./contracts.js";
|
|
4
|
+
import { CheckSetupError, semverPattern, tokenNamePattern, } from "./contracts.js";
|
|
5
|
+
const hashPattern = /^sha256:[a-f0-9]{64}$/;
|
|
6
|
+
const tokenListLimit = 10;
|
|
5
7
|
function normalizedFiles(root, files) {
|
|
6
8
|
return [...new Set(files.map((file) => file.replaceAll("\\", "/")))]
|
|
7
9
|
.sort()
|
|
@@ -11,7 +13,7 @@ function normalizedFiles(root, files) {
|
|
|
11
13
|
if (isAbsolute(file) ||
|
|
12
14
|
fromRoot === ".." ||
|
|
13
15
|
fromRoot.startsWith("../")) {
|
|
14
|
-
throw new
|
|
16
|
+
throw new CheckSetupError("Theme files must be relative paths inside the configured root.");
|
|
15
17
|
}
|
|
16
18
|
return { absolute, path: fromRoot };
|
|
17
19
|
});
|
|
@@ -43,65 +45,75 @@ function exactKeys(value, keys) {
|
|
|
43
45
|
function isRecord(value) {
|
|
44
46
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
45
47
|
}
|
|
46
|
-
function
|
|
47
|
-
return isRecord(
|
|
48
|
-
exactKeys(
|
|
49
|
-
|
|
50
|
-
|
|
48
|
+
function isIdentity(value) {
|
|
49
|
+
return isRecord(value) &&
|
|
50
|
+
exactKeys(value, ["contractVersion", "sha256"]) &&
|
|
51
|
+
typeof value.contractVersion === "string" &&
|
|
52
|
+
semverPattern.test(value.contractVersion) &&
|
|
53
|
+
typeof value.sha256 === "string" &&
|
|
54
|
+
hashPattern.test(value.sha256);
|
|
51
55
|
}
|
|
52
56
|
// Derived tokens are not required: a theme may leave them to their documented
|
|
53
|
-
// default, the source token, and the browser then reports them as empty.
|
|
57
|
+
// default, the source token, and the browser then reports them as empty. A
|
|
58
|
+
// contrast endpoint that is such a token is required through its source.
|
|
54
59
|
function requiredEvidenceTokens(contracts) {
|
|
55
60
|
const derived = contracts.theme.derived;
|
|
61
|
+
const required = new Set(contracts.theme.required);
|
|
62
|
+
const origin = (token, seen = new Set()) => {
|
|
63
|
+
const source = derived[token];
|
|
64
|
+
if (required.has(token) || source === undefined || seen.has(token))
|
|
65
|
+
return token;
|
|
66
|
+
return origin(source, new Set([...seen, token]));
|
|
67
|
+
};
|
|
56
68
|
return new Set([
|
|
57
|
-
...
|
|
69
|
+
...required,
|
|
58
70
|
// A source that is itself derived is optional too; its chain is followed.
|
|
59
71
|
...Object.values(derived).filter((source) => !(source in derived)),
|
|
60
72
|
...contracts.contrast.pairs.flatMap(({ background, foreground }) => [
|
|
61
|
-
background,
|
|
62
|
-
foreground,
|
|
73
|
+
origin(background),
|
|
74
|
+
origin(foreground),
|
|
63
75
|
]),
|
|
64
76
|
]);
|
|
65
77
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
78
|
+
// Structure only: whether the content agrees with the contracts, the
|
|
79
|
+
// configuration and the theme files is checked afterwards, cause by cause.
|
|
80
|
+
function hasEvidenceStructure(value) {
|
|
81
|
+
return isRecord(value) &&
|
|
82
|
+
exactKeys(value, [
|
|
69
83
|
"capturedAt",
|
|
70
84
|
"contracts",
|
|
71
85
|
"schemaVersion",
|
|
72
86
|
"selectors",
|
|
73
87
|
"sourceHash",
|
|
74
88
|
"themes",
|
|
75
|
-
])
|
|
76
|
-
value.schemaVersion
|
|
77
|
-
typeof value.capturedAt
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
value.
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
(tokenValue.trim().length > 0 || optional.has(token)) &&
|
|
104
|
-
!/var\s*\(/i.test(tokenValue)));
|
|
89
|
+
]) &&
|
|
90
|
+
value.schemaVersion === 1 &&
|
|
91
|
+
typeof value.capturedAt === "string" &&
|
|
92
|
+
typeof value.sourceHash === "string" &&
|
|
93
|
+
hashPattern.test(value.sourceHash) &&
|
|
94
|
+
Array.isArray(value.selectors) &&
|
|
95
|
+
value.selectors.every((selector) => typeof selector === "string") &&
|
|
96
|
+
isRecord(value.contracts) &&
|
|
97
|
+
exactKeys(value.contracts, ["contrast", "theme"]) &&
|
|
98
|
+
isIdentity(value.contracts.contrast) &&
|
|
99
|
+
isIdentity(value.contracts.theme) &&
|
|
100
|
+
isRecord(value.themes) &&
|
|
101
|
+
Object.values(value.themes).every((theme) => isRecord(theme) &&
|
|
102
|
+
exactKeys(theme, ["selector", "tokens"]) &&
|
|
103
|
+
typeof theme.selector === "string" &&
|
|
104
|
+
isRecord(theme.tokens) &&
|
|
105
|
+
Object.entries(theme.tokens).every(([token, tokenValue]) => tokenNamePattern.test(token) && typeof tokenValue === "string"));
|
|
106
|
+
}
|
|
107
|
+
function counted(tokens, noun) {
|
|
108
|
+
return `${tokens.length} ${noun}${tokens.length === 1 ? "" : "s"}`;
|
|
109
|
+
}
|
|
110
|
+
// Token names only, never their values, and a long list is cut short.
|
|
111
|
+
function named(tokens) {
|
|
112
|
+
const sorted = [...tokens].sort();
|
|
113
|
+
const shown = sorted.slice(0, tokenListLimit).join(", ");
|
|
114
|
+
return sorted.length > tokenListLimit
|
|
115
|
+
? `${shown} and ${sorted.length - tokenListLimit} more`
|
|
116
|
+
: shown;
|
|
105
117
|
}
|
|
106
118
|
export async function loadComputedEvidence({ contracts, evidenceByPath, now = new Date(), root, themes, }) {
|
|
107
119
|
try {
|
|
@@ -113,35 +125,100 @@ export async function loadComputedEvidence({ contracts, evidenceByPath, now = ne
|
|
|
113
125
|
}
|
|
114
126
|
const result = {};
|
|
115
127
|
const requiredTokens = requiredEvidenceTokens(contracts);
|
|
128
|
+
// A token the evidence must carry is never optional, even when the contract
|
|
129
|
+
// also lists it as derived or as a brand token.
|
|
130
|
+
const optionalTokens = new Set([...Object.keys(contracts.theme.derived), ...contracts.theme.brandComposition]
|
|
131
|
+
.filter((token) => !requiredTokens.has(token)));
|
|
116
132
|
for (const [path, group] of grouped) {
|
|
117
|
-
const
|
|
118
|
-
|
|
119
|
-
if (
|
|
120
|
-
|
|
133
|
+
const refused = (cause) => new CheckSetupError(`Invalid computed theme evidence in ${path}: ${cause}`);
|
|
134
|
+
let evidence = evidenceByPath?.get(path);
|
|
135
|
+
if (evidence === undefined) {
|
|
136
|
+
let content;
|
|
137
|
+
try {
|
|
138
|
+
content = await readFile(resolve(root, path), "utf8");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw refused("the file is missing or cannot be read. Capture the evidence, or correct computedEvidence.file.");
|
|
142
|
+
}
|
|
143
|
+
try {
|
|
144
|
+
evidence = JSON.parse(content);
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
throw refused("the file is not valid JSON. Capture the evidence again.");
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
if (!hasEvidenceStructure(evidence)) {
|
|
151
|
+
throw refused("the file does not have the evidence structure. Capture the evidence again with the documented recipe.");
|
|
152
|
+
}
|
|
153
|
+
// The contracts come first: after a package upgrade the capture is stale
|
|
154
|
+
// in other ways too, and capturing again is what resolves them.
|
|
155
|
+
for (const contract of ["theme", "contrast"]) {
|
|
156
|
+
const captured = evidence.contracts[contract];
|
|
157
|
+
const installed = contracts.identities[contract];
|
|
158
|
+
if (captured.contractVersion !== installed.contractVersion) {
|
|
159
|
+
throw refused(`it was captured for ${contract} contract ${captured.contractVersion}, and the installed ${contract} contract is ${installed.contractVersion}. Bring the theme up to the installed contract and capture the evidence again.`);
|
|
160
|
+
}
|
|
161
|
+
if (captured.sha256 !== installed.sha256) {
|
|
162
|
+
throw refused(`it was captured for another build of ${contract} contract ${installed.contractVersion} than the installed one. Capture the evidence again.`);
|
|
163
|
+
}
|
|
121
164
|
}
|
|
122
165
|
const capturedAt = new Date(evidence.capturedAt);
|
|
123
|
-
if (
|
|
124
|
-
|
|
166
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(evidence.capturedAt) ||
|
|
167
|
+
!Number.isFinite(capturedAt.valueOf())) {
|
|
168
|
+
throw refused("capturedAt is not a UTC timestamp such as 2026-01-31T12:00:00.000Z. Capture the evidence again.");
|
|
169
|
+
}
|
|
170
|
+
if (capturedAt > now) {
|
|
171
|
+
throw refused("capturedAt is in the future. Correct the clock of the capturing machine and capture the evidence again.");
|
|
125
172
|
}
|
|
126
173
|
const selectors = [...new Set(group.map(({ selector }) => selector))].sort();
|
|
127
174
|
if ([...new Set(evidence.selectors)].sort().join("\0") !== selectors.join("\0")) {
|
|
128
|
-
throw
|
|
175
|
+
throw refused("its selectors differ from the selectors configured for the themes that use this file. Capture the evidence again.");
|
|
129
176
|
}
|
|
130
177
|
const files = [...new Set(group.flatMap(({ files }) => files))];
|
|
131
|
-
|
|
132
|
-
|
|
178
|
+
let sourceHash;
|
|
179
|
+
try {
|
|
180
|
+
sourceHash = await computeThemeSourceHash(root, files);
|
|
181
|
+
}
|
|
182
|
+
catch (error) {
|
|
183
|
+
if (error instanceof CheckSetupError)
|
|
184
|
+
throw error;
|
|
185
|
+
throw new CheckSetupError(`Unable to read the theme files that use the computed evidence in ${path}.`);
|
|
133
186
|
}
|
|
187
|
+
if (evidence.sourceHash !== sourceHash) {
|
|
188
|
+
throw refused("the theme source hash differs, so a theme file changed after the capture. Capture the evidence again.");
|
|
189
|
+
}
|
|
190
|
+
// A referenced theme has to carry every required token; any other theme
|
|
191
|
+
// in the file only has to be sound in what it does carry.
|
|
192
|
+
const checkValues = (subject, tokens, referenced) => {
|
|
193
|
+
const hasNoValue = (token) => tokens[token] === undefined ? referenced : tokens[token].trim().length === 0;
|
|
194
|
+
const missing = [...requiredTokens].filter(hasNoValue);
|
|
195
|
+
if (missing.length > 0) {
|
|
196
|
+
throw refused(`${subject} has no value for ${counted(missing, "required token")}: ${named(missing)}. Declare ${missing.length === 1 ? "it" : "them"} in the theme and capture the evidence again.`);
|
|
197
|
+
}
|
|
198
|
+
// Empty means undeclared, which only an optional token may be: a derived
|
|
199
|
+
// token left to its default, or a brand token the theme does not use.
|
|
200
|
+
const empty = Object.keys(tokens).filter((token) => hasNoValue(token) && !optionalTokens.has(token));
|
|
201
|
+
if (empty.length > 0) {
|
|
202
|
+
throw refused(`${subject} has an empty value for ${counted(empty, "token")} the contract does not make optional: ${named(empty)}. Capture the evidence again with the documented recipe.`);
|
|
203
|
+
}
|
|
204
|
+
const unresolved = Object.keys(tokens).filter((token) => /var\s*\(/i.test(tokens[token]));
|
|
205
|
+
if (unresolved.length > 0) {
|
|
206
|
+
throw refused(`${subject} still has var(...) in the value of ${counted(unresolved, "token")}: ${named(unresolved)}. Capture the values computed by a browser, where every var() is resolved.`);
|
|
207
|
+
}
|
|
208
|
+
};
|
|
134
209
|
for (const theme of group) {
|
|
135
210
|
const name = theme.computedEvidence.theme;
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
211
|
+
const subject = `its theme ${JSON.stringify(name)}`;
|
|
212
|
+
const computedTheme = Object.hasOwn(evidence.themes, name)
|
|
213
|
+
? evidence.themes[name]
|
|
214
|
+
: undefined;
|
|
215
|
+
if (!computedTheme) {
|
|
216
|
+
throw refused(`it has no theme named ${JSON.stringify(name)}, the name in computedEvidence.theme. Correct the name or capture the evidence again.`);
|
|
139
217
|
}
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
throw new Error("tokens");
|
|
143
|
-
}
|
|
218
|
+
if (computedTheme.selector !== theme.selector) {
|
|
219
|
+
throw refused(`${subject} was captured for another selector than the configured one. Capture the evidence again.`);
|
|
144
220
|
}
|
|
221
|
+
checkValues(subject, computedTheme.tokens, true);
|
|
145
222
|
const tokens = Object.fromEntries(Object.entries(computedTheme.tokens).filter(([, tokenValue]) => tokenValue.trim().length > 0));
|
|
146
223
|
// A derived token left to its default takes its source's value,
|
|
147
224
|
// following sources that are derived themselves. A chain that does not
|
|
@@ -152,18 +229,34 @@ export async function loadComputedEvidence({ contracts, evidenceByPath, now = ne
|
|
|
152
229
|
return value;
|
|
153
230
|
const source = contracts.theme.derived[token];
|
|
154
231
|
if (source === undefined || seen.has(token))
|
|
155
|
-
|
|
232
|
+
return undefined;
|
|
156
233
|
return derivedValue(source, new Set([...seen, token]));
|
|
157
234
|
};
|
|
235
|
+
const underivable = [];
|
|
158
236
|
for (const token of Object.keys(contracts.theme.derived)) {
|
|
159
|
-
|
|
237
|
+
const value = derivedValue(token, new Set());
|
|
238
|
+
if (value === undefined)
|
|
239
|
+
underivable.push(token);
|
|
240
|
+
else
|
|
241
|
+
tokens[token] = value;
|
|
242
|
+
}
|
|
243
|
+
if (underivable.length > 0) {
|
|
244
|
+
throw refused(`${subject} has no value from which to derive ${counted(underivable, "token")}: ${named(underivable)}.`);
|
|
160
245
|
}
|
|
161
246
|
result[theme.name] = tokens;
|
|
162
247
|
}
|
|
248
|
+
const referenced = new Set(group.map((theme) => theme.computedEvidence.theme));
|
|
249
|
+
for (const [name, theme] of Object.entries(evidence.themes)) {
|
|
250
|
+
if (!referenced.has(name)) {
|
|
251
|
+
checkValues("a theme the configuration does not reference", theme.tokens, false);
|
|
252
|
+
}
|
|
253
|
+
}
|
|
163
254
|
}
|
|
164
255
|
return result;
|
|
165
256
|
}
|
|
166
|
-
catch {
|
|
167
|
-
|
|
257
|
+
catch (error) {
|
|
258
|
+
if (error instanceof CheckSetupError)
|
|
259
|
+
throw error;
|
|
260
|
+
throw new CheckSetupError("Invalid computed theme evidence.");
|
|
168
261
|
}
|
|
169
262
|
}
|
package/dist/rules/default.js
CHANGED
|
@@ -12,7 +12,12 @@ export async function createDefaultRules({ components, computed, contract, manif
|
|
|
12
12
|
createNoLiteralColorRule({ themes }),
|
|
13
13
|
createTokenPrefixRule(),
|
|
14
14
|
createTokenCompleteRule({ computed, contract: resolvedContract, themes }),
|
|
15
|
-
createTokenContrastRule({
|
|
15
|
+
createTokenContrastRule({
|
|
16
|
+
computed,
|
|
17
|
+
derived: resolvedContract.derived,
|
|
18
|
+
manifest: resolvedManifest,
|
|
19
|
+
themes,
|
|
20
|
+
}),
|
|
16
21
|
...(components
|
|
17
22
|
? [createComponentOverrideRule({ classHelpers: components.classHelpers, sources: components.sources })]
|
|
18
23
|
: []),
|
|
@@ -75,9 +75,18 @@ export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
|
|
|
75
75
|
required: true,
|
|
76
76
|
});
|
|
77
77
|
}
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
78
|
+
// A derived token falls back to its source, and to the source's own
|
|
79
|
+
// source when that one is derived too, until a declared token.
|
|
80
|
+
const hasFallback = (token, seen) => {
|
|
81
|
+
if (theme.declarations.has(token))
|
|
82
|
+
return true;
|
|
83
|
+
const source = contract.derived[token];
|
|
84
|
+
if (source === undefined || seen.has(token))
|
|
85
|
+
return false;
|
|
86
|
+
return hasFallback(source, new Set([...seen, token]));
|
|
87
|
+
};
|
|
88
|
+
for (const token of Object.keys(contract.derived)) {
|
|
89
|
+
if (!hasFallback(token, new Set())) {
|
|
81
90
|
result.violations.push({
|
|
82
91
|
...location,
|
|
83
92
|
anchor: theme.name,
|
|
@@ -4,8 +4,9 @@ import type { CheckRule } from "../scan.js";
|
|
|
4
4
|
export type { ContrastManifest, ContrastPair } from "../contracts.js";
|
|
5
5
|
export interface TokenContrastOptions {
|
|
6
6
|
computed?: Record<string, Record<string, string>>;
|
|
7
|
+
derived?: Record<string, string>;
|
|
7
8
|
manifest: ContrastManifest;
|
|
8
9
|
themes: ThemeConfig[];
|
|
9
10
|
}
|
|
10
11
|
export declare function loadContrastManifest(url?: URL): Promise<ContrastManifest>;
|
|
11
|
-
export declare function createTokenContrastRule({ computed, manifest, themes, }: TokenContrastOptions): CheckRule;
|
|
12
|
+
export declare function createTokenContrastRule({ computed, derived, manifest, themes, }: TokenContrastOptions): CheckRule;
|
|
@@ -84,7 +84,7 @@ function dynamicStyleResult(file) {
|
|
|
84
84
|
sourceFile.forEachChild(visit);
|
|
85
85
|
return result;
|
|
86
86
|
}
|
|
87
|
-
export function createTokenContrastRule({ computed = {}, manifest, themes, }) {
|
|
87
|
+
export function createTokenContrastRule({ computed = {}, derived = {}, manifest, themes, }) {
|
|
88
88
|
const collector = createThemeCollector(themes);
|
|
89
89
|
return {
|
|
90
90
|
name: "tokens/contrast",
|
|
@@ -130,6 +130,14 @@ export function createTokenContrastRule({ computed = {}, manifest, themes, }) {
|
|
|
130
130
|
for (const [name, value] of Object.entries(computedTheme)) {
|
|
131
131
|
tokens.set(name, value);
|
|
132
132
|
}
|
|
133
|
+
// A derived token the theme leaves undeclared takes its documented
|
|
134
|
+
// default, the source token. resolveColor follows the chain and stops
|
|
135
|
+
// on a cycle.
|
|
136
|
+
for (const [token, source] of Object.entries(derived)) {
|
|
137
|
+
if (!tokens.has(token)) {
|
|
138
|
+
tokens.set(token, `var(${source})`);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
133
141
|
for (const pair of manifest.pairs) {
|
|
134
142
|
const background = resolveColor(tokens.get(pair.background), tokens);
|
|
135
143
|
const foreground = resolveColor(tokens.get(pair.foreground), tokens);
|
package/dist/scan.js
CHANGED
|
@@ -3,7 +3,7 @@ import { extname, relative, resolve } from "node:path";
|
|
|
3
3
|
import postcss from "postcss";
|
|
4
4
|
import { computeLineStarts, createScanner, LanguageVariant, SyntaxKind, } from "typescript/unstable/ast";
|
|
5
5
|
import { API } from "typescript/unstable/sync";
|
|
6
|
-
import { resolveContractSet } from "./contracts.js";
|
|
6
|
+
import { CheckSetupError, resolveContractSet, } from "./contracts.js";
|
|
7
7
|
import { loadComputedEvidence } from "./evidence.js";
|
|
8
8
|
import { assignFingerprints } from "./baseline.js";
|
|
9
9
|
import { applyDebtControls } from "./governance.js";
|
|
@@ -234,7 +234,7 @@ export async function runChecks(config, options = {}) {
|
|
|
234
234
|
}
|
|
235
235
|
rules = [...(await configuredRules)].sort((left, right) => left.name.localeCompare(right.name));
|
|
236
236
|
}
|
|
237
|
-
catch {
|
|
237
|
+
catch (error) {
|
|
238
238
|
const report = emptyReport(["coverage/dynamic-expression"]);
|
|
239
239
|
if (contracts) {
|
|
240
240
|
report.contracts = {
|
|
@@ -243,9 +243,14 @@ export async function runChecks(config, options = {}) {
|
|
|
243
243
|
theme: contracts.identities.theme,
|
|
244
244
|
};
|
|
245
245
|
}
|
|
246
|
+
// Only the default rules say why: their causes are written by this package
|
|
247
|
+
// and repeat nothing of the product. Rules supplied by the caller, and any
|
|
248
|
+
// failure nobody anticipated, stay behind the generic message.
|
|
246
249
|
report.operationalErrors.push({
|
|
247
250
|
code: usesDefaultRules ? "config" : "internal",
|
|
248
|
-
message:
|
|
251
|
+
message: usesDefaultRules && error instanceof CheckSetupError
|
|
252
|
+
? error.message
|
|
253
|
+
: "Unable to initialize configured rules.",
|
|
249
254
|
});
|
|
250
255
|
report.exitCode = 2;
|
|
251
256
|
report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
|
package/dist/theme-contract.json
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
{
|
|
2
|
-
"contractVersion": "2.
|
|
2
|
+
"contractVersion": "2.2.0",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"prefix": "--ui-",
|
|
5
5
|
"required": [
|
|
@@ -94,7 +94,12 @@
|
|
|
94
94
|
"--ui-status-info-border": "--ui-status-info",
|
|
95
95
|
"--ui-field-height": "--ui-control-height-md",
|
|
96
96
|
"--ui-dialog-title-font-size": "--ui-font-size-xl",
|
|
97
|
-
"--ui-button-font-weight": "--ui-font-weight-strong"
|
|
97
|
+
"--ui-button-font-weight": "--ui-font-weight-strong",
|
|
98
|
+
"--ui-button-font-weight-sm": "--ui-button-font-weight",
|
|
99
|
+
"--ui-button-font-weight-md": "--ui-button-font-weight",
|
|
100
|
+
"--ui-button-font-weight-lg": "--ui-button-font-weight",
|
|
101
|
+
"--ui-control-checked-bg": "--ui-action-primary-bg",
|
|
102
|
+
"--ui-control-checked-fg": "--ui-action-primary-text"
|
|
98
103
|
},
|
|
99
104
|
"brandComposition": [
|
|
100
105
|
"--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.4.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",
|