@ksmv/ui-checks 0.1.4 → 0.2.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 CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.2.0 - 2026-09-29
4
+
5
+ - **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.
6
+ - **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.
7
+ - `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.
8
+
9
+ ## 0.1.5 - 2026-09-28
10
+
11
+ - **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.
12
+ - **Baselines da 0.1.4 ou anteriores precisam ser regeneradas uma vez**, porque os fingerprints mudaram.
13
+ - Espaços dentro de valores entre aspas, como em `[data-state="a b"]`, continuam distinguindo achados. Uma regra de terceiros que não forneça âncora de conteúdo mantém o fingerprint posicional, para que uma mensagem genérica não faça um achado ocupar o lugar de outro.
14
+ - `css/no-literal-color` deixa de abortar o arquivo quando o parser de cores lança exceção em valores que ele não reconhece, como `calc(100vw-1.5rem)` num valor arbitrário do Tailwind. O valor passa a ser tratado como "não é cor".
15
+
3
16
  ## 0.1.4 - 2026-09-28
4
17
 
5
18
  - A evidência calculada aceita tokens derivados ausentes ou vazios, que é como o navegador informa um token que o tema deixou no padrão, e usa o valor do token de origem. Na 0.1.3, um tema que confiava nos padrões documentados dos derivados nunca produzia evidência válida pela receita do README.
package/README.md CHANGED
@@ -55,6 +55,8 @@ npx ksmv-ui-checks baseline ksmv-ui.config.js --write --first-seen-version=0.1.2
55
55
 
56
56
  O arquivo guarda fingerprints, regra, papel responsável e meta de redução, sem copiar o trecho encontrado. Uma atualização que aumente a dívida exige `--allow-growth` e uma justificativa em arquivo via `--reason-file`. Erro operacional e cobertura obrigatória ausente nunca entram na baseline.
57
57
 
58
+ O fingerprint segue o conteúdo do achado, como seletor, propriedade e valor, e a ordem entre achados idênticos no mesmo arquivo, não a linha. Linhas inseridas ou removidas em outro ponto do arquivo não transformam dívida conhecida em achado novo; mudar o próprio trecho, sim. Baselines gravadas pela `0.1.4` ou anterior usavam a posição e precisam ser regeneradas uma vez com a `0.1.5`.
59
+
58
60
  ## Códigos de saída
59
61
 
60
62
  - `0`: análise completa, sem violações novas;
@@ -86,6 +88,8 @@ A evidência não é uma exceção manual. O arquivo é recusado, com código `2
86
88
 
87
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.
88
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
+
89
93
  A captura usa a API pública deste pacote e um navegador à sua escolha. Com Playwright, e a aplicação servida localmente:
90
94
 
91
95
  ```js
@@ -0,0 +1,8 @@
1
+ import type { Declaration } from "postcss";
2
+ import type { Node, SourceFile } from "typescript/unstable/ast";
3
+ /** The selector chain and the declaration, value included. */
4
+ export declare function cssDeclarationAnchor(declaration: Declaration): string;
5
+ /** The selector chain and the property name, for findings about the name. */
6
+ export declare function cssPropertyAnchor(declaration: Declaration): string;
7
+ /** The source text of a TSX node. */
8
+ export declare function tsxNodeAnchor(node: Node, sourceFile: SourceFile): string;
package/dist/anchor.js ADDED
@@ -0,0 +1,62 @@
1
+ // Anchors identify a finding by its content rather than its position, so a
2
+ // baseline survives lines inserted or removed elsewhere in the file. They are
3
+ // hashed into the fingerprint and never stored or reported as text.
4
+ // Collapses whitespace outside quoted strings only: inside quotes, as in
5
+ // [data-state="a b"], whitespace is part of the value and tells findings apart.
6
+ function normalize(text) {
7
+ let result = "";
8
+ let quote;
9
+ let pendingSpace = false;
10
+ for (let index = 0; index < text.length; index += 1) {
11
+ const character = text[index];
12
+ if (quote !== undefined) {
13
+ result += character;
14
+ if (character === "\\" && index + 1 < text.length) {
15
+ index += 1;
16
+ result += text[index];
17
+ }
18
+ else if (character === quote) {
19
+ quote = undefined;
20
+ }
21
+ continue;
22
+ }
23
+ if (/\s/.test(character)) {
24
+ pendingSpace = result.length > 0;
25
+ continue;
26
+ }
27
+ if (pendingSpace) {
28
+ result += " ";
29
+ pendingSpace = false;
30
+ }
31
+ result += character;
32
+ if (character === '"' || character === "'" || character === "`") {
33
+ quote = character;
34
+ }
35
+ }
36
+ return result;
37
+ }
38
+ function cssContext(declaration) {
39
+ const context = [];
40
+ for (let parent = declaration.parent; parent && parent.type !== "root"; parent = parent.parent) {
41
+ if (parent.type === "rule") {
42
+ context.unshift(parent.selector);
43
+ }
44
+ else if (parent.type === "atrule") {
45
+ const atRule = parent;
46
+ context.unshift(`@${atRule.name} ${atRule.params}`);
47
+ }
48
+ }
49
+ return context;
50
+ }
51
+ /** The selector chain and the declaration, value included. */
52
+ export function cssDeclarationAnchor(declaration) {
53
+ return normalize([...cssContext(declaration), `${declaration.prop}: ${declaration.value}`].join(" > "));
54
+ }
55
+ /** The selector chain and the property name, for findings about the name. */
56
+ export function cssPropertyAnchor(declaration) {
57
+ return normalize([...cssContext(declaration), declaration.prop].join(" > "));
58
+ }
59
+ /** The source text of a TSX node. */
60
+ export function tsxNodeAnchor(node, sourceFile) {
61
+ return normalize(node.getText(sourceFile));
62
+ }
@@ -36,6 +36,14 @@ export interface GrowthApproval {
36
36
  errors: string[];
37
37
  }
38
38
  export declare function normalizeRelativePath(path: string): string;
39
+ /**
40
+ * Gives every violation a fingerprint that follows its content and its order
41
+ * among identical findings in the same file, not its line and column, so lines
42
+ * inserted or removed elsewhere do not turn baselined debt into new findings.
43
+ * The content comes from the rule's anchor, or the message when a rule sets
44
+ * none; either way only its hash is kept.
45
+ */
46
+ export declare function assignFingerprints(violations: Violation[]): void;
39
47
  export declare function fingerprintViolation(violation: Violation): string;
40
48
  export declare function baselineFor(violations: Violation[], metadata: BaselineMetadata, previous?: BaselineFile): BaselineFile;
41
49
  export declare function validateBaseline(value: unknown): BaselineValidation;
package/dist/baseline.js CHANGED
@@ -48,7 +48,51 @@ function compareEntries(left, right) {
48
48
  left.rule.localeCompare(right.rule) ||
49
49
  left.fingerprint.localeCompare(right.fingerprint));
50
50
  }
51
+ const fingerprintPattern = /^[a-f0-9]{64}$/;
52
+ /**
53
+ * Gives every violation a fingerprint that follows its content and its order
54
+ * among identical findings in the same file, not its line and column, so lines
55
+ * inserted or removed elsewhere do not turn baselined debt into new findings.
56
+ * The content comes from the rule's anchor, or the message when a rule sets
57
+ * none; either way only its hash is kept.
58
+ */
59
+ export function assignFingerprints(violations) {
60
+ const ordered = [...violations].sort((left, right) => normalizeRelativePath(left.path).localeCompare(normalizeRelativePath(right.path)) ||
61
+ left.line - right.line ||
62
+ left.column - right.column);
63
+ const occurrences = new Map();
64
+ for (const violation of ordered) {
65
+ // Without an anchor there is no content to follow, and a generic message
66
+ // could let one finding stand in for another; such a violation keeps its
67
+ // positional fingerprint.
68
+ if (violation.anchor === undefined) {
69
+ violation.fingerprint = positionalFingerprint(violation);
70
+ continue;
71
+ }
72
+ const identity = [
73
+ violation.rule.trim(),
74
+ normalizeRelativePath(violation.path),
75
+ normalizeNodeKind(violation.nodeKind),
76
+ violation.anchor,
77
+ ];
78
+ const key = JSON.stringify(identity);
79
+ const occurrence = occurrences.get(key) ?? 0;
80
+ occurrences.set(key, occurrence + 1);
81
+ violation.fingerprint = createHash("sha256")
82
+ .update(JSON.stringify([2, ...identity, occurrence]))
83
+ .digest("hex");
84
+ delete violation.anchor;
85
+ }
86
+ }
51
87
  export function fingerprintViolation(violation) {
88
+ if (violation.fingerprint !== undefined && fingerprintPattern.test(violation.fingerprint)) {
89
+ return violation.fingerprint;
90
+ }
91
+ return positionalFingerprint(violation);
92
+ }
93
+ // The identity of a violation without an anchor, and of one built outside
94
+ // runChecks: its position, which only compares with others built the same way.
95
+ function positionalFingerprint(violation) {
52
96
  const input = JSON.stringify([
53
97
  1,
54
98
  violation.rule.trim(),
package/dist/color.js CHANGED
@@ -38,7 +38,15 @@ export function isColorBearingProperty(property) {
38
38
  cssProperty === "column-rule");
39
39
  }
40
40
  function parsedColor(value) {
41
- const parsed = parse(value);
41
+ // culori throws, instead of answering "not a color", on some values Tailwind
42
+ // allows in arbitrary classes, such as calc(100vw-1.5rem).
43
+ let parsed;
44
+ try {
45
+ parsed = parse(value);
46
+ }
47
+ catch {
48
+ return undefined;
49
+ }
42
50
  const color = parsed ? toRgb(parsed) : undefined;
43
51
  if (!color ||
44
52
  !Number.isFinite(color.r) ||
@@ -92,6 +100,8 @@ export function containsLiteralColor(value) {
92
100
  if (node.type === "word" &&
93
101
  node.value.toLowerCase() !== "currentcolor" &&
94
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) &&
95
105
  parsedColor(node.value)) {
96
106
  literal = true;
97
107
  return false;
@@ -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;
@@ -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/result.d.ts CHANGED
@@ -4,6 +4,14 @@ export interface SourceLocation {
4
4
  line: number;
5
5
  }
6
6
  export interface Violation extends SourceLocation {
7
+ /**
8
+ * Content that identifies the finding independently of its position. Rules
9
+ * set it; `runChecks` hashes it into `fingerprint` and removes it, so it is
10
+ * never reported or stored.
11
+ */
12
+ anchor?: string;
13
+ /** Stable fingerprint assigned by `runChecks`; see `fingerprintViolation`. */
14
+ fingerprint?: string;
7
15
  message: string;
8
16
  nodeKind: string;
9
17
  path: string;
@@ -1,4 +1,5 @@
1
1
  import { isComputedPropertyName, isIdentifier, isJsxAttribute, isJsxExpression, isObjectLiteralExpression, isPropertyAssignment, isStringLiteralLikeNode, } from "typescript/unstable/ast";
2
+ import { cssDeclarationAnchor, tsxNodeAnchor } from "../anchor.js";
2
3
  import { containsLiteralColor, isColorBearingProperty } from "../color.js";
3
4
  import { isAllowedThemeDeclaration } from "./theme.js";
4
5
  function emptyResult() {
@@ -56,6 +57,7 @@ export function createNoLiteralColorRule({ themes, }) {
56
57
  containsLiteralColor(declaration.value) &&
57
58
  !isAllowedThemeDeclaration(file, declaration, themes)) {
58
59
  result.violations.push({
60
+ anchor: cssDeclarationAnchor(declaration),
59
61
  column: declaration.source?.start?.column ?? 1,
60
62
  line: declaration.source?.start?.line ?? 1,
61
63
  message: "Literal colors are allowed only in configured themes.",
@@ -79,7 +81,13 @@ export function createNoLiteralColorRule({ themes, }) {
79
81
  if (value !== undefined &&
80
82
  ((isDirectColor && containsLiteralColor(value)) || isLiteralClass)) {
81
83
  const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
84
+ // For className, only the classes that carry the literal color
85
+ // identify the finding; the other classes may change freely.
86
+ const anchor = isLiteralClass && !isDirectColor
87
+ ? `className: ${value.split(/\s+/).filter(classContainsLiteralColor).join(" ")}`
88
+ : tsxNodeAnchor(node, sourceFile);
82
89
  result.violations.push({
90
+ anchor,
83
91
  column: position.character + 1,
84
92
  line: position.line + 1,
85
93
  message: "Literal colors are allowed only in configured themes.",
@@ -107,6 +115,7 @@ export function createNoLiteralColorRule({ themes, }) {
107
115
  containsLiteralColor(property.initializer.text)) {
108
116
  const position = sourceFile.getLineAndCharacterOfPosition(property.initializer.getStart(sourceFile));
109
117
  result.violations.push({
118
+ anchor: tsxNodeAnchor(property, sourceFile),
110
119
  column: position.character + 1,
111
120
  line: position.line + 1,
112
121
  message: "Literal colors are allowed only in configured themes.",
@@ -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");
@@ -58,6 +59,7 @@ export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
58
59
  missingRequired = true;
59
60
  result.violations.push({
60
61
  ...location,
62
+ anchor: theme.name,
61
63
  message: `Theme ${theme.name} is missing required token ${token}.`,
62
64
  nodeKind: `theme-token:${token}`,
63
65
  rule: "tokens/complete",
@@ -78,6 +80,7 @@ export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
78
80
  !theme.declarations.has(fallback)) {
79
81
  result.violations.push({
80
82
  ...location,
83
+ anchor: theme.name,
81
84
  message: `Derived token ${token} has no available documented fallback.`,
82
85
  nodeKind: `theme-token:${token}`,
83
86
  rule: "tokens/complete",
@@ -114,6 +117,38 @@ export function createTokenCompleteRule({ computed = {}, contract, themes, }) {
114
117
  }
115
118
  }
116
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
+ }
117
152
  }
118
153
  return result;
119
154
  },
@@ -157,6 +157,7 @@ export function createTokenContrastRule({ computed = {}, manifest, themes, }) {
157
157
  else if (ratio < pair.minimum) {
158
158
  result.violations.push({
159
159
  ...location,
160
+ anchor: theme.name,
160
161
  message: `Contrast pair ${pair.context} is below ${pair.minimum}:1.`,
161
162
  nodeKind: `contrast-pair:${pair.context}:${pair.foreground}:${pair.background}`,
162
163
  rule: "tokens/contrast",
@@ -1,4 +1,5 @@
1
1
  import { isComputedPropertyName, isIdentifier, isJsxAttribute, isJsxExpression, isObjectLiteralExpression, isPropertyAssignment, isStringLiteralLikeNode, } from "typescript/unstable/ast";
2
+ import { cssPropertyAnchor, tsxNodeAnchor } from "../anchor.js";
2
3
  export function createTokenPrefixRule() {
3
4
  return {
4
5
  name: "tokens/prefix",
@@ -8,6 +9,7 @@ export function createTokenPrefixRule() {
8
9
  if (declaration.prop.startsWith("--") &&
9
10
  !declaration.prop.startsWith("--ui-")) {
10
11
  violations.push({
12
+ anchor: cssPropertyAnchor(declaration),
11
13
  column: declaration.source?.start?.column ?? 1,
12
14
  line: declaration.source?.start?.line ?? 1,
13
15
  message: "Public custom properties must use the --ui- prefix.",
@@ -42,6 +44,7 @@ export function createTokenPrefixRule() {
42
44
  if (name?.startsWith("--") && !name.startsWith("--ui-")) {
43
45
  const position = sourceFile.getLineAndCharacterOfPosition(propertyNode.getStart(sourceFile));
44
46
  violations.push({
47
+ anchor: tsxNodeAnchor(propertyNode, sourceFile),
45
48
  column: position.character + 1,
46
49
  line: position.line + 1,
47
50
  message: "Public custom properties must use the --ui- prefix.",
package/dist/scan.js CHANGED
@@ -5,6 +5,7 @@ import { computeLineStarts, createScanner, LanguageVariant, SyntaxKind, } from "
5
5
  import { API } from "typescript/unstable/sync";
6
6
  import { resolveContractSet } from "./contracts.js";
7
7
  import { loadComputedEvidence } from "./evidence.js";
8
+ import { assignFingerprints } from "./baseline.js";
8
9
  import { applyDebtControls } from "./governance.js";
9
10
  const supportedExtensions = [".css", ".js", ".jsx", ".ts", ".tsx"];
10
11
  function toPosixPath(path) {
@@ -384,6 +385,7 @@ export async function runChecks(config, options = {}) {
384
385
  }
385
386
  typeScriptSnapshot?.dispose();
386
387
  typeScriptApi?.close();
388
+ assignFingerprints(report.violations);
387
389
  if (options.applyGovernance ?? usesDefaultRules) {
388
390
  await applyDebtControls(report, config);
389
391
  }
@@ -1,5 +1,5 @@
1
1
  {
2
- "contractVersion": "1.0.0",
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.1.4",
3
+ "version": "0.2.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",