@ksmv/ui-checks 0.3.1 → 0.3.3
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 +13 -4
- package/dist/contracts.d.ts +7 -0
- package/dist/contracts.js +16 -13
- package/dist/density.js +3 -0
- package/dist/evidence.js +157 -61
- package/dist/rules/tokenComplete.js +12 -3
- package/dist/scan.js +8 -3
- package/dist/theme-contract.json +5 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.3 - 2026-10-02
|
|
4
|
+
|
|
5
|
+
- **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.
|
|
6
|
+
- 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.
|
|
7
|
+
- **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.
|
|
8
|
+
- **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.
|
|
9
|
+
- `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.
|
|
10
|
+
|
|
11
|
+
## 0.3.2 - 2026-10-01
|
|
12
|
+
|
|
13
|
+
- **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.
|
|
14
|
+
- **Quem tem fontes de tema em CRLF precisa capturar a evidência de novo uma vez**, porque o hash delas mudou. Evidência de fontes em LF continua válida.
|
|
15
|
+
|
|
3
16
|
## 0.3.1 - 2026-09-30
|
|
4
17
|
|
|
5
18
|
- **O CLI volta a rodar quando é chamado por um link simbólico.** Na 0.3.0 e anteriores, pelo `npx`, por um script npm ou pelo `node_modules/.bin` no Linux e no macOS, e em qualquer sistema quando `node_modules` é um link ou uma junção, o comando não executava nada e saía com código `0`: uma verificação verde que não tinha acontecido. A detecção do ponto de entrada passa a comparar os caminhos reais. **Quem roda o verificador num CI Linux deve atualizar e conferir que o passo de fato imprime o relatório.**
|
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
|
-
-
|
|
85
|
-
-
|
|
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;
|
|
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.
|
|
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
|
}
|
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,17 +13,28 @@ 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
|
});
|
|
18
20
|
}
|
|
21
|
+
/**
|
|
22
|
+
* The same commit is checked out with CRLF on one machine and LF on another
|
|
23
|
+
* when the repository has no end-of-line rule. The hash says whether the theme
|
|
24
|
+
* changed since the capture, and a line ending is not a change: CRLF counts as
|
|
25
|
+
* LF. A lone CR is left alone, and so is everything else.
|
|
26
|
+
*/
|
|
27
|
+
function withLineFeeds(content) {
|
|
28
|
+
return content.includes("\r\n")
|
|
29
|
+
? Buffer.from(content.toString("latin1").replaceAll("\r\n", "\n"), "latin1")
|
|
30
|
+
: content;
|
|
31
|
+
}
|
|
19
32
|
export async function computeThemeSourceHash(root, files) {
|
|
20
33
|
const hash = createHash("sha256");
|
|
21
34
|
for (const file of normalizedFiles(root, files)) {
|
|
22
35
|
hash.update(file.path);
|
|
23
36
|
hash.update("\0");
|
|
24
|
-
hash.update(await readFile(file.absolute));
|
|
37
|
+
hash.update(withLineFeeds(await readFile(file.absolute)));
|
|
25
38
|
hash.update("\0");
|
|
26
39
|
}
|
|
27
40
|
return `sha256:${hash.digest("hex")}`;
|
|
@@ -32,11 +45,13 @@ function exactKeys(value, keys) {
|
|
|
32
45
|
function isRecord(value) {
|
|
33
46
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
34
47
|
}
|
|
35
|
-
function
|
|
36
|
-
return isRecord(
|
|
37
|
-
exactKeys(
|
|
38
|
-
|
|
39
|
-
|
|
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);
|
|
40
55
|
}
|
|
41
56
|
// Derived tokens are not required: a theme may leave them to their documented
|
|
42
57
|
// default, the source token, and the browser then reports them as empty.
|
|
@@ -52,45 +67,45 @@ function requiredEvidenceTokens(contracts) {
|
|
|
52
67
|
]),
|
|
53
68
|
]);
|
|
54
69
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
70
|
+
// Structure only: whether the content agrees with the contracts, the
|
|
71
|
+
// configuration and the theme files is checked afterwards, cause by cause.
|
|
72
|
+
function hasEvidenceStructure(value) {
|
|
73
|
+
return isRecord(value) &&
|
|
74
|
+
exactKeys(value, [
|
|
58
75
|
"capturedAt",
|
|
59
76
|
"contracts",
|
|
60
77
|
"schemaVersion",
|
|
61
78
|
"selectors",
|
|
62
79
|
"sourceHash",
|
|
63
80
|
"themes",
|
|
64
|
-
])
|
|
65
|
-
value.schemaVersion
|
|
66
|
-
typeof value.capturedAt
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
value.
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
(tokenValue.trim().length > 0 || optional.has(token)) &&
|
|
93
|
-
!/var\s*\(/i.test(tokenValue)));
|
|
81
|
+
]) &&
|
|
82
|
+
value.schemaVersion === 1 &&
|
|
83
|
+
typeof value.capturedAt === "string" &&
|
|
84
|
+
typeof value.sourceHash === "string" &&
|
|
85
|
+
hashPattern.test(value.sourceHash) &&
|
|
86
|
+
Array.isArray(value.selectors) &&
|
|
87
|
+
value.selectors.every((selector) => typeof selector === "string") &&
|
|
88
|
+
isRecord(value.contracts) &&
|
|
89
|
+
exactKeys(value.contracts, ["contrast", "theme"]) &&
|
|
90
|
+
isIdentity(value.contracts.contrast) &&
|
|
91
|
+
isIdentity(value.contracts.theme) &&
|
|
92
|
+
isRecord(value.themes) &&
|
|
93
|
+
Object.values(value.themes).every((theme) => isRecord(theme) &&
|
|
94
|
+
exactKeys(theme, ["selector", "tokens"]) &&
|
|
95
|
+
typeof theme.selector === "string" &&
|
|
96
|
+
isRecord(theme.tokens) &&
|
|
97
|
+
Object.entries(theme.tokens).every(([token, tokenValue]) => tokenNamePattern.test(token) && typeof tokenValue === "string"));
|
|
98
|
+
}
|
|
99
|
+
function counted(tokens, noun) {
|
|
100
|
+
return `${tokens.length} ${noun}${tokens.length === 1 ? "" : "s"}`;
|
|
101
|
+
}
|
|
102
|
+
// Token names only, never their values, and a long list is cut short.
|
|
103
|
+
function named(tokens) {
|
|
104
|
+
const sorted = [...tokens].sort();
|
|
105
|
+
const shown = sorted.slice(0, tokenListLimit).join(", ");
|
|
106
|
+
return sorted.length > tokenListLimit
|
|
107
|
+
? `${shown} and ${sorted.length - tokenListLimit} more`
|
|
108
|
+
: shown;
|
|
94
109
|
}
|
|
95
110
|
export async function loadComputedEvidence({ contracts, evidenceByPath, now = new Date(), root, themes, }) {
|
|
96
111
|
try {
|
|
@@ -102,35 +117,100 @@ export async function loadComputedEvidence({ contracts, evidenceByPath, now = ne
|
|
|
102
117
|
}
|
|
103
118
|
const result = {};
|
|
104
119
|
const requiredTokens = requiredEvidenceTokens(contracts);
|
|
120
|
+
// A token the evidence must carry is never optional, even when the contract
|
|
121
|
+
// also lists it as derived or as a brand token.
|
|
122
|
+
const optionalTokens = new Set([...Object.keys(contracts.theme.derived), ...contracts.theme.brandComposition]
|
|
123
|
+
.filter((token) => !requiredTokens.has(token)));
|
|
105
124
|
for (const [path, group] of grouped) {
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
if (
|
|
109
|
-
|
|
125
|
+
const refused = (cause) => new CheckSetupError(`Invalid computed theme evidence in ${path}: ${cause}`);
|
|
126
|
+
let evidence = evidenceByPath?.get(path);
|
|
127
|
+
if (evidence === undefined) {
|
|
128
|
+
let content;
|
|
129
|
+
try {
|
|
130
|
+
content = await readFile(resolve(root, path), "utf8");
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
throw refused("the file is missing or cannot be read. Capture the evidence, or correct computedEvidence.file.");
|
|
134
|
+
}
|
|
135
|
+
try {
|
|
136
|
+
evidence = JSON.parse(content);
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
throw refused("the file is not valid JSON. Capture the evidence again.");
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
if (!hasEvidenceStructure(evidence)) {
|
|
143
|
+
throw refused("the file does not have the evidence structure. Capture the evidence again with the documented recipe.");
|
|
144
|
+
}
|
|
145
|
+
// The contracts come first: after a package upgrade the capture is stale
|
|
146
|
+
// in other ways too, and capturing again is what resolves them.
|
|
147
|
+
for (const contract of ["theme", "contrast"]) {
|
|
148
|
+
const captured = evidence.contracts[contract];
|
|
149
|
+
const installed = contracts.identities[contract];
|
|
150
|
+
if (captured.contractVersion !== installed.contractVersion) {
|
|
151
|
+
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.`);
|
|
152
|
+
}
|
|
153
|
+
if (captured.sha256 !== installed.sha256) {
|
|
154
|
+
throw refused(`it was captured for another build of ${contract} contract ${installed.contractVersion} than the installed one. Capture the evidence again.`);
|
|
155
|
+
}
|
|
110
156
|
}
|
|
111
157
|
const capturedAt = new Date(evidence.capturedAt);
|
|
112
|
-
if (
|
|
113
|
-
|
|
158
|
+
if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(evidence.capturedAt) ||
|
|
159
|
+
!Number.isFinite(capturedAt.valueOf())) {
|
|
160
|
+
throw refused("capturedAt is not a UTC timestamp such as 2026-01-31T12:00:00.000Z. Capture the evidence again.");
|
|
161
|
+
}
|
|
162
|
+
if (capturedAt > now) {
|
|
163
|
+
throw refused("capturedAt is in the future. Correct the clock of the capturing machine and capture the evidence again.");
|
|
114
164
|
}
|
|
115
165
|
const selectors = [...new Set(group.map(({ selector }) => selector))].sort();
|
|
116
166
|
if ([...new Set(evidence.selectors)].sort().join("\0") !== selectors.join("\0")) {
|
|
117
|
-
throw
|
|
167
|
+
throw refused("its selectors differ from the selectors configured for the themes that use this file. Capture the evidence again.");
|
|
118
168
|
}
|
|
119
169
|
const files = [...new Set(group.flatMap(({ files }) => files))];
|
|
120
|
-
|
|
121
|
-
|
|
170
|
+
let sourceHash;
|
|
171
|
+
try {
|
|
172
|
+
sourceHash = await computeThemeSourceHash(root, files);
|
|
173
|
+
}
|
|
174
|
+
catch (error) {
|
|
175
|
+
if (error instanceof CheckSetupError)
|
|
176
|
+
throw error;
|
|
177
|
+
throw new CheckSetupError(`Unable to read the theme files that use the computed evidence in ${path}.`);
|
|
122
178
|
}
|
|
179
|
+
if (evidence.sourceHash !== sourceHash) {
|
|
180
|
+
throw refused("the theme source hash differs, so a theme file changed after the capture. Capture the evidence again.");
|
|
181
|
+
}
|
|
182
|
+
// A referenced theme has to carry every required token; any other theme
|
|
183
|
+
// in the file only has to be sound in what it does carry.
|
|
184
|
+
const checkValues = (subject, tokens, referenced) => {
|
|
185
|
+
const hasNoValue = (token) => tokens[token] === undefined ? referenced : tokens[token].trim().length === 0;
|
|
186
|
+
const missing = [...requiredTokens].filter(hasNoValue);
|
|
187
|
+
if (missing.length > 0) {
|
|
188
|
+
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.`);
|
|
189
|
+
}
|
|
190
|
+
// Empty means undeclared, which only an optional token may be: a derived
|
|
191
|
+
// token left to its default, or a brand token the theme does not use.
|
|
192
|
+
const empty = Object.keys(tokens).filter((token) => hasNoValue(token) && !optionalTokens.has(token));
|
|
193
|
+
if (empty.length > 0) {
|
|
194
|
+
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.`);
|
|
195
|
+
}
|
|
196
|
+
const unresolved = Object.keys(tokens).filter((token) => /var\s*\(/i.test(tokens[token]));
|
|
197
|
+
if (unresolved.length > 0) {
|
|
198
|
+
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.`);
|
|
199
|
+
}
|
|
200
|
+
};
|
|
123
201
|
for (const theme of group) {
|
|
124
202
|
const name = theme.computedEvidence.theme;
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
|
|
203
|
+
const subject = `its theme ${JSON.stringify(name)}`;
|
|
204
|
+
const computedTheme = Object.hasOwn(evidence.themes, name)
|
|
205
|
+
? evidence.themes[name]
|
|
206
|
+
: undefined;
|
|
207
|
+
if (!computedTheme) {
|
|
208
|
+
throw refused(`it has no theme named ${JSON.stringify(name)}, the name in computedEvidence.theme. Correct the name or capture the evidence again.`);
|
|
128
209
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
throw new Error("tokens");
|
|
132
|
-
}
|
|
210
|
+
if (computedTheme.selector !== theme.selector) {
|
|
211
|
+
throw refused(`${subject} was captured for another selector than the configured one. Capture the evidence again.`);
|
|
133
212
|
}
|
|
213
|
+
checkValues(subject, computedTheme.tokens, true);
|
|
134
214
|
const tokens = Object.fromEntries(Object.entries(computedTheme.tokens).filter(([, tokenValue]) => tokenValue.trim().length > 0));
|
|
135
215
|
// A derived token left to its default takes its source's value,
|
|
136
216
|
// following sources that are derived themselves. A chain that does not
|
|
@@ -141,18 +221,34 @@ export async function loadComputedEvidence({ contracts, evidenceByPath, now = ne
|
|
|
141
221
|
return value;
|
|
142
222
|
const source = contracts.theme.derived[token];
|
|
143
223
|
if (source === undefined || seen.has(token))
|
|
144
|
-
|
|
224
|
+
return undefined;
|
|
145
225
|
return derivedValue(source, new Set([...seen, token]));
|
|
146
226
|
};
|
|
227
|
+
const underivable = [];
|
|
147
228
|
for (const token of Object.keys(contracts.theme.derived)) {
|
|
148
|
-
|
|
229
|
+
const value = derivedValue(token, new Set());
|
|
230
|
+
if (value === undefined)
|
|
231
|
+
underivable.push(token);
|
|
232
|
+
else
|
|
233
|
+
tokens[token] = value;
|
|
234
|
+
}
|
|
235
|
+
if (underivable.length > 0) {
|
|
236
|
+
throw refused(`${subject} has no value from which to derive ${counted(underivable, "token")}: ${named(underivable)}.`);
|
|
149
237
|
}
|
|
150
238
|
result[theme.name] = tokens;
|
|
151
239
|
}
|
|
240
|
+
const referenced = new Set(group.map((theme) => theme.computedEvidence.theme));
|
|
241
|
+
for (const [name, theme] of Object.entries(evidence.themes)) {
|
|
242
|
+
if (!referenced.has(name)) {
|
|
243
|
+
checkValues("a theme the configuration does not reference", theme.tokens, false);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
152
246
|
}
|
|
153
247
|
return result;
|
|
154
248
|
}
|
|
155
|
-
catch {
|
|
156
|
-
|
|
249
|
+
catch (error) {
|
|
250
|
+
if (error instanceof CheckSetupError)
|
|
251
|
+
throw error;
|
|
252
|
+
throw new CheckSetupError("Invalid computed theme evidence.");
|
|
157
253
|
}
|
|
158
254
|
}
|
|
@@ -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,
|
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.1.0",
|
|
3
3
|
"schemaVersion": 1,
|
|
4
4
|
"prefix": "--ui-",
|
|
5
5
|
"required": [
|
|
@@ -94,7 +94,10 @@
|
|
|
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"
|
|
98
101
|
},
|
|
99
102
|
"brandComposition": [
|
|
100
103
|
"--ui-brand-primary",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ksmv/ui-checks",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
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",
|