@ksmv/ui-checks 0.2.0 → 0.3.1

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,17 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.1 - 2026-09-30
4
+
5
+ - **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.**
6
+ - **Um `#` que não inicia um nome deixa de travar a análise.** Um arquivo TypeScript ou TSX com `#` dentro de uma expressão regular literal, como `/^#\/rota/`, fazia a varredura nunca terminar, até o processo esgotar a memória. A varredura agora sempre avança. Um `#` solto que não compila continua sendo erro operacional de parse (saída 2).
7
+
8
+ ## 0.3.0 - 2026-09-30
9
+
10
+ - **Regra `components/override`**, opt-in pelo bloco `components` da configuração. Num componente da biblioteca, ajuste de encaixe por `className` ou `style` (margem, largura, item de flex/grid, posição, visibilidade, alinhamento do texto) é livre; ajuste de aparência ou de arranjo interno exige `data-ui-override="motivo"` no mesmo elemento. Classes fora do catálogo contam como aparência. `className` ou `style` que o checker não consegue ler e props repassadas depois de `className`/`style` também exigem a marca.
11
+ - Os achados são violações comuns (saída 1) e podem entrar em baseline durante a migração; os elementos marcados aparecem na lista nova `overrides` do relatório, com o motivo.
12
+ - O bloco `components` tem arquivos próprios (`include`, `exclude`), fontes (`sources`, padrão `@ksmv/ui-react`) e helpers de classe (`classHelpers`, que somam aos padrões). Arquivos só desse bloco recebem só esta regra: nem a cobertura dinâmica nem as regras de cor passam a valer neles.
13
+ - Furos conhecidos, documentados: componente do produto que repassa props para um componente da biblioteca, CSS do produto que mira as classes internas `.ui-*`, props repassadas sozinhas ou antes de `className`/`style`, componente usado como valor e arquivo que não compila (erro operacional de parse, fora do alcance da regra). Um `components.include` que não casa nenhum arquivo é erro operacional (saída 2).
14
+
3
15
  ## 0.2.0 - 2026-09-29
4
16
 
5
17
  - **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.
package/README.md CHANGED
@@ -147,6 +147,35 @@ await writeFile(target, `${JSON.stringify({
147
147
 
148
148
  Capture de novo sempre que um arquivo de tema mudar ou o pacote React for atualizado; a verificação recusa a evidência antiga, que é exatamente o objetivo.
149
149
 
150
+ ## Ajustes em componentes
151
+
152
+ A regra `components/override` confere como o produto ajusta os componentes da biblioteca. Ela só roda quando a configuração tem o bloco `components`:
153
+
154
+ ```js
155
+ components: {
156
+ include: ["src/**/*.tsx"],
157
+ exclude: ["src/**/*.test.tsx"],
158
+ sources: ["@ksmv/ui-react", "@/ui"],
159
+ classHelpers: ["tw"],
160
+ }
161
+ ```
162
+
163
+ - **Livre:** o que encaixa o componente no pai — margem, largura, item de flex/grid (`flex-1`, `self-*`, `col-span-*`…), posição, `z-*`, visibilidade (`hidden`, `sr-only`) e alinhamento do texto. No `style`, as propriedades equivalentes.
164
+ - **Exige `data-ui-override="motivo"` no mesmo elemento:** aparência (cor, fundo, borda, raio, sombra, fonte, altura, padding, `gap-*`, `overflow-*`), arranjo interno (`flex`, `grid-cols-*`, `justify-*`, `items-*`, `block`) e qualquer classe fora do catálogo, inclusive classes próprias do produto. Também exigem a marca `className` ou `style` que a regra não consegue ler e props repassadas depois de `className` ou `style`.
165
+ - **Relatório:** sem marca, é uma violação (saída 1), que pode entrar em baseline durante a migração. Com marca, o elemento aparece em `overrides`, com o motivo.
166
+ - **Arquivos:** os do `components.include` recebem só esta regra. Um arquivo que também está no `include` principal recebe as duas coisas, e aí a cobertura dinâmica volta a valer nele.
167
+ - **O que a regra não vê:** um componente do produto que repassa props para um componente da biblioteca, e CSS do produto que mira as classes internas `.ui-*`; props repassadas sozinhas ou antes de `className`/`style` (`<Button {...props} />`); componente usado como valor (`const X = Button; <X />`); arquivos que não compilam — aparecem como erro operacional de parse (saída 2) e não são analisados pela regra.
168
+
169
+ ### Detalhes
170
+
171
+ - `sources` substitui o padrão: mantenha `@ksmv/ui-react` na lista ao acrescentar um barril; a comparação é pelo especificador exato do import (subcaminhos e imports relativos precisam de entrada própria).
172
+ - `classHelpers` soma aos padrões (`cn`, `clsx`, `cx`, `classnames`, `classNames`, `twMerge`, `twJoin`, `cva`) e entende chamadas no estilo clsx; de `cva(...)` só os textos literais são lidos. Template marcado (`tw\`...\``) e funções de variante geradas por `cva` contam como ilegíveis.
173
+ - `undefined`, `null`, `true`, `false` e `0` dentro de uma expressão de classe não contribuem classe.
174
+ - `overrides[]` tem `path`, `line`, `column`, `component`, `reason` e `classes`; `classes` lista as classes visuais autorizadas, as propriedades de `style` como `style:<propriedade>` e os tipos de achado (`dynamic-class`, `dynamic-style`, `spread-after`).
175
+ - componente do produto com `forwardRef` que faz `<Button className={cn("mt-2", className)} {...props} />`: gera `dynamic-class` e `spread-after`; passe o spread antes do `className` e marque o elemento com o motivo ("repassa className do chamador"), ou use a baseline durante a migração.
176
+ - o padrão responsivo `hidden md:inline-flex` exige marca (`inline-flex` é arranjo interno).
177
+ - um `components.include` que não casa nenhum arquivo é erro operacional (saída 2).
178
+
150
179
  ## API programática
151
180
 
152
181
  O ponto de entrada `@ksmv/ui-checks` exporta `runChecks`, as regras, `resolveContractSet`, `computeThemeSourceHash`, `loadComputedEvidence`, as funções de baseline e os formatadores de relatório, com os tipos TypeScript correspondentes. Os contratos embutidos estão em `@ksmv/ui-checks/theme-contract.json` e `@ksmv/ui-checks/contrast-contract.json`.
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,9 @@
1
1
  #!/usr/bin/env node
2
2
  export declare function main(argv?: string[]): Promise<import("./result.js").ExitCode>;
3
+ /**
4
+ * Whether this module is the script Node was started with. Compared by real
5
+ * path: through a symbolic link (npm's `.bin` on POSIX, a linked node_modules)
6
+ * `process.argv[1]` names the link while `import.meta.url` names the file, and
7
+ * comparing them as written made the CLI do nothing and exit 0.
8
+ */
9
+ export declare function isEntryPoint(scriptPath: string | undefined, moduleUrl: string): boolean;
package/dist/cli.js CHANGED
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { realpathSync } from "node:fs";
2
3
  import { readFile } from "node:fs/promises";
3
4
  import { resolve } from "node:path";
4
- import { pathToFileURL } from "node:url";
5
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
6
  import { approveBaselineGrowth, baselineFor, validateBaseline, writeBaselineFile, } from "./baseline.js";
6
7
  import { applyConfiguredSuppressions } from "./governance.js";
7
8
  import { formatHumanReport, formatJsonReport, } from "./result.js";
@@ -169,9 +170,23 @@ export async function main(argv = process.argv.slice(2)) {
169
170
  return 2;
170
171
  }
171
172
  }
172
- const entryPoint = process.argv[1]
173
- ? pathToFileURL(resolve(process.argv[1])).href
174
- : undefined;
175
- if (entryPoint === import.meta.url) {
173
+ /**
174
+ * Whether this module is the script Node was started with. Compared by real
175
+ * path: through a symbolic link (npm's `.bin` on POSIX, a linked node_modules)
176
+ * `process.argv[1]` names the link while `import.meta.url` names the file, and
177
+ * comparing them as written made the CLI do nothing and exit 0.
178
+ */
179
+ export function isEntryPoint(scriptPath, moduleUrl) {
180
+ if (!scriptPath)
181
+ return false;
182
+ try {
183
+ return (realpathSync.native(resolve(scriptPath)) ===
184
+ realpathSync.native(fileURLToPath(moduleUrl)));
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ if (isEntryPoint(process.argv[1], import.meta.url)) {
176
191
  process.exitCode = await main();
177
192
  }
package/dist/config.d.ts CHANGED
@@ -10,8 +10,15 @@ export interface ThemeConfig {
10
10
  name: string;
11
11
  selector: string;
12
12
  }
13
+ export interface ComponentsConfig {
14
+ classHelpers?: string[];
15
+ exclude?: string[];
16
+ include: string[];
17
+ sources?: string[];
18
+ }
13
19
  export interface ChecksConfig {
14
20
  baseline?: string;
21
+ components?: ComponentsConfig;
15
22
  contractSource?: ContractSource;
16
23
  exclude?: string[];
17
24
  failOnUncovered?: boolean;
package/dist/index.d.ts CHANGED
@@ -1,11 +1,11 @@
1
1
  export { defineConfig } from "./config.js";
2
- export type { ChecksConfig, ComputedEvidenceRef, ThemeConfig } from "./config.js";
2
+ export type { ChecksConfig, ComponentsConfig, ComputedEvidenceRef, ThemeConfig, } from "./config.js";
3
3
  export { parseContrastManifest, parseThemeContract, resolveContractSet, tokenNamePattern, } from "./contracts.js";
4
4
  export type { ContractIdentity, ContractSet, ContractSource, ContrastManifest, ContrastPair, ThemeContract, } from "./contracts.js";
5
5
  export { computeThemeSourceHash, loadComputedEvidence } from "./evidence.js";
6
6
  export type { ThemeEvidence } from "./evidence.js";
7
7
  export { formatHumanReport, formatJsonReport } from "./result.js";
8
- export type { BaselineDebt, CheckReport, ExitCode, IgnoredFile, OperationalError, SourceLocation, Suppression, Uncovered, Violation, } from "./result.js";
8
+ export type { BaselineDebt, CheckReport, ComponentOverride, ExitCode, IgnoredFile, OperationalError, SourceLocation, Suppression, Uncovered, Violation, } from "./result.js";
9
9
  export { approveBaselineGrowth, baselineFor, compareBaseline, fingerprintViolation, normalizeRelativePath, serializeBaseline, validateBaseline, writeBaselineFile, } from "./baseline.js";
10
10
  export type { BaselineComparison, BaselineEntry, BaselineFile, BaselineMetadata, BaselineValidation, GrowthApproval, GrowthApprovalOptions, } from "./baseline.js";
11
11
  export { validateSuppressions } from "./suppressions.js";
@@ -17,6 +17,8 @@ export type { NoLiteralColorOptions } from "./rules/noLiteralColor.js";
17
17
  export { createDefaultRules } from "./rules/default.js";
18
18
  export type { DefaultRulesOptions } from "./rules/default.js";
19
19
  export { createTokenPrefixRule } from "./rules/tokenPrefix.js";
20
+ export { createComponentOverrideRule } from "./rules/componentOverride.js";
21
+ export type { ComponentOverrideOptions } from "./rules/componentOverride.js";
20
22
  export { createTokenCompleteRule, loadThemeContract, } from "./rules/tokenComplete.js";
21
23
  export type { TokenCompleteOptions, } from "./rules/tokenComplete.js";
22
24
  export { createTokenContrastRule, loadContrastManifest, } from "./rules/tokenContrast.js";
package/dist/index.js CHANGED
@@ -8,5 +8,6 @@ export { runChecks } from "./scan.js";
8
8
  export { createNoLiteralColorRule } from "./rules/noLiteralColor.js";
9
9
  export { createDefaultRules } from "./rules/default.js";
10
10
  export { createTokenPrefixRule } from "./rules/tokenPrefix.js";
11
+ export { createComponentOverrideRule } from "./rules/componentOverride.js";
11
12
  export { createTokenCompleteRule, loadThemeContract, } from "./rules/tokenComplete.js";
12
13
  export { createTokenContrastRule, loadContrastManifest, } from "./rules/tokenContrast.js";
package/dist/result.d.ts CHANGED
@@ -24,6 +24,12 @@ export interface Uncovered extends SourceLocation {
24
24
  rule: string;
25
25
  required?: boolean;
26
26
  }
27
+ export interface ComponentOverride extends SourceLocation {
28
+ classes: string[];
29
+ component: string;
30
+ path: string;
31
+ reason: string;
32
+ }
27
33
  export interface Suppression {
28
34
  expires: string;
29
35
  fingerprint: string;
@@ -62,6 +68,7 @@ export interface CheckReport {
62
68
  filesRead: number;
63
69
  nodesChecked: number;
64
70
  operationalErrors: OperationalError[];
71
+ overrides: ComponentOverride[];
65
72
  rulesExecuted: string[];
66
73
  schemaVersion: 1;
67
74
  suppressions: Suppression[];
package/dist/result.js CHANGED
@@ -15,6 +15,7 @@ export function formatHumanReport(report) {
15
15
  `Baseline matches: ${report.baselineMatches.length}`,
16
16
  `Uncovered: ${report.uncovered.length}`,
17
17
  `Suppressions: ${report.suppressions.length}`,
18
+ `Overrides: ${report.overrides.length}`,
18
19
  `Operational errors: ${report.operationalErrors.length}`,
19
20
  ];
20
21
  for (const debt of report.baselineDebt) {
@@ -32,6 +33,9 @@ export function formatHumanReport(report) {
32
33
  for (const suppression of report.suppressions) {
33
34
  lines.push(`Suppression: ${suppression.path} ${suppression.rule} expires ${suppression.expires}`);
34
35
  }
36
+ for (const override of report.overrides) {
37
+ lines.push(`Override: ${override.path}:${override.line}:${override.column} ${override.component} "${override.reason}" ${override.classes.join(" ")}`.trimEnd());
38
+ }
35
39
  for (const finding of [...report.violations, ...report.uncovered]) {
36
40
  lines.push(`${finding.path}:${finding.line}:${finding.column} ${finding.rule} ${finding.message}`);
37
41
  }
@@ -0,0 +1,6 @@
1
+ import type { CheckRule } from "../scan.js";
2
+ export interface ComponentOverrideOptions {
3
+ classHelpers?: string[];
4
+ sources?: string[];
5
+ }
6
+ export declare function createComponentOverrideRule({ classHelpers, sources, }?: ComponentOverrideOptions): CheckRule;
@@ -0,0 +1,354 @@
1
+ import { isArrayLiteralExpression, isAsExpression, isBinaryExpression, isCallExpression, isComputedPropertyName, isConditionalExpression, isFalseLiteral, isIdentifier, isImportDeclaration, isJsxAttribute, isJsxExpression, isJsxOpeningElement, isJsxSelfClosingElement, isJsxSpreadAttribute, isNamedImports, isNamespaceImport, isNonNullExpression, isNullLiteral, isNumericLiteral, isObjectLiteralExpression, isParenthesizedExpression, isPropertyAccessExpression, isPropertyAssignment, isSatisfiesExpression, isShorthandPropertyAssignment, isStringLiteral, isStringLiteralLikeNode, isTrueLiteral, SyntaxKind, } from "typescript/unstable/ast";
2
+ import { classifyClass, classifyStyleProperty } from "./overrideCatalog.js";
3
+ const defaultHelpers = ["cn", "clsx", "cx", "cva", "twMerge", "twJoin", "classnames", "classNames"];
4
+ const kindMessages = {
5
+ "dynamic-class": "a class expression the checker cannot read",
6
+ "dynamic-style": "a style the checker cannot read",
7
+ "spread-after": "props spread after className or style",
8
+ };
9
+ function collectImports(sourceFile, sources) {
10
+ const bindings = new Map();
11
+ for (const statement of sourceFile.statements) {
12
+ if (!isImportDeclaration(statement) || !isStringLiteral(statement.moduleSpecifier))
13
+ continue;
14
+ if (!sources.has(statement.moduleSpecifier.text))
15
+ continue;
16
+ const clause = statement.importClause;
17
+ if (!clause || clause.phaseModifier === SyntaxKind.TypeKeyword)
18
+ continue;
19
+ const named = clause.namedBindings;
20
+ if (named && isNamespaceImport(named))
21
+ bindings.set(named.name.text, { namespace: true });
22
+ if (named && isNamedImports(named)) {
23
+ for (const element of named.elements) {
24
+ if (element.isTypeOnly)
25
+ continue;
26
+ bindings.set(element.name.text, {
27
+ imported: (element.propertyName ?? element.name).text,
28
+ namespace: false,
29
+ });
30
+ }
31
+ }
32
+ }
33
+ return bindings;
34
+ }
35
+ function bindingNames(name, into) {
36
+ if (!name)
37
+ return;
38
+ if (isIdentifier(name)) {
39
+ into.add(name.text);
40
+ return;
41
+ }
42
+ name.forEachChild((child) => {
43
+ if (child.kind === SyntaxKind.BindingElement) {
44
+ bindingNames(child.name, into);
45
+ }
46
+ });
47
+ }
48
+ /** Names declared directly in a scope-creating node (parameters or block statements). */
49
+ function scopeNames(node) {
50
+ const record = node;
51
+ const isBlock = node.kind === SyntaxKind.Block;
52
+ if (!record.parameters && !isBlock)
53
+ return undefined;
54
+ const names = new Set();
55
+ for (const parameter of record.parameters ?? []) {
56
+ bindingNames(parameter.name, names);
57
+ }
58
+ if (isBlock) {
59
+ for (const statement of record.statements ?? []) {
60
+ if (statement.kind === SyntaxKind.VariableStatement) {
61
+ const list = statement.declarationList;
62
+ for (const declaration of list.declarations) {
63
+ bindingNames(declaration.name, names);
64
+ }
65
+ }
66
+ if (statement.kind === SyntaxKind.FunctionDeclaration || statement.kind === SyntaxKind.ClassDeclaration) {
67
+ bindingNames(statement.name, names);
68
+ }
69
+ }
70
+ }
71
+ return names;
72
+ }
73
+ function tagParts(tagName) {
74
+ const path = [];
75
+ let current = tagName;
76
+ while (isPropertyAccessExpression(current)) {
77
+ path.unshift(current.name.text);
78
+ current = current.expression;
79
+ }
80
+ return isIdentifier(current) ? { path, root: current.text } : undefined;
81
+ }
82
+ function propertyKey(node) {
83
+ if (isIdentifier(node) || isStringLiteralLikeNode(node))
84
+ return node.text;
85
+ return isComputedPropertyName(node) && isStringLiteralLikeNode(node.expression)
86
+ ? node.expression.text
87
+ : undefined;
88
+ }
89
+ /** Unwraps type assertions, `satisfies` and `!` before reading an expression. */
90
+ function unwrapTypeWrappers(node) {
91
+ let current = node;
92
+ while (isParenthesizedExpression(current) ||
93
+ isAsExpression(current) ||
94
+ isSatisfiesExpression(current) ||
95
+ isNonNullExpression(current)) {
96
+ current = current.expression;
97
+ }
98
+ return current;
99
+ }
100
+ /** `undefined`, `null`, `true`, `false` and `0` contribute no classes and are not unreadable. */
101
+ function isInertLiteral(expression) {
102
+ return ((isIdentifier(expression) && expression.text === "undefined") ||
103
+ isNullLiteral(expression) ||
104
+ isTrueLiteral(expression) ||
105
+ isFalseLiteral(expression) ||
106
+ (isNumericLiteral(expression) && expression.text === "0"));
107
+ }
108
+ function readClassExpression(node, helpers) {
109
+ const reading = { literals: [] };
110
+ const markUnreadable = (at) => {
111
+ reading.unreadableAt ??= at;
112
+ };
113
+ const visit = (rawExpression) => {
114
+ const expression = unwrapTypeWrappers(rawExpression);
115
+ if (isStringLiteralLikeNode(expression)) {
116
+ reading.literals.push(...expression.text.split(/\s+/u).filter(Boolean));
117
+ }
118
+ else if (isInertLiteral(expression)) {
119
+ // no contribution
120
+ }
121
+ else if (isJsxExpression(expression)) {
122
+ if (expression.expression)
123
+ visit(expression.expression);
124
+ else
125
+ markUnreadable(expression);
126
+ }
127
+ else if (isCallExpression(expression) && isIdentifier(expression.expression) && helpers.has(expression.expression.text)) {
128
+ const isCva = expression.expression.text === "cva";
129
+ for (const argument of expression.arguments) {
130
+ if (isCva && isObjectLiteralExpression(unwrapTypeWrappers(argument)))
131
+ continue;
132
+ visit(argument);
133
+ }
134
+ }
135
+ else if (isArrayLiteralExpression(expression)) {
136
+ for (const element of expression.elements)
137
+ visit(element);
138
+ }
139
+ else if (isConditionalExpression(expression)) {
140
+ visit(expression.whenTrue);
141
+ visit(expression.whenFalse);
142
+ }
143
+ else if (isBinaryExpression(expression) && expression.operatorToken.kind === SyntaxKind.AmpersandAmpersandToken) {
144
+ visit(expression.right);
145
+ }
146
+ else if (isBinaryExpression(expression) &&
147
+ (expression.operatorToken.kind === SyntaxKind.BarBarToken ||
148
+ expression.operatorToken.kind === SyntaxKind.QuestionQuestionToken)) {
149
+ visit(expression.left);
150
+ visit(expression.right);
151
+ }
152
+ else if (isObjectLiteralExpression(expression)) {
153
+ for (const property of expression.properties) {
154
+ const key = isPropertyAssignment(property) || isShorthandPropertyAssignment(property)
155
+ ? propertyKey(property.name)
156
+ : undefined;
157
+ if (key === undefined)
158
+ markUnreadable(property);
159
+ else
160
+ reading.literals.push(...key.split(/\s+/u).filter(Boolean));
161
+ }
162
+ }
163
+ else {
164
+ markUnreadable(expression);
165
+ }
166
+ };
167
+ visit(node);
168
+ return reading;
169
+ }
170
+ function markerReason(initializer) {
171
+ const text = initializer && isStringLiteralLikeNode(initializer)
172
+ ? initializer.text
173
+ : initializer && isJsxExpression(initializer) && initializer.expression && isStringLiteralLikeNode(initializer.expression)
174
+ ? initializer.expression.text
175
+ : undefined;
176
+ const reason = text?.trim().replace(/\s+/gu, " ");
177
+ return reason ? reason : undefined;
178
+ }
179
+ export function createComponentOverrideRule({ classHelpers = [], sources = ["@ksmv/ui-react"], } = {}) {
180
+ const sourceSet = new Set(sources);
181
+ const helpers = new Set([...defaultHelpers, ...classHelpers]);
182
+ return {
183
+ name: "components/override",
184
+ scope: "components",
185
+ check(file) {
186
+ const result = {
187
+ overrides: [],
188
+ uncovered: [],
189
+ violations: [],
190
+ };
191
+ const sourceFile = file.sourceFile;
192
+ if (!sourceFile)
193
+ return result;
194
+ const imports = collectImports(sourceFile, sourceSet);
195
+ if (imports.size === 0)
196
+ return result;
197
+ const scopes = [];
198
+ const analyze = (element) => {
199
+ const opening = element;
200
+ const parts = tagParts(opening.tagName);
201
+ if (!parts)
202
+ return;
203
+ const binding = imports.get(parts.root);
204
+ if (!binding || scopes.some((names) => names.has(parts.root)))
205
+ return;
206
+ if (binding.namespace && parts.path.length === 0)
207
+ return;
208
+ const component = binding.namespace ? parts.path.join(".") : [binding.imported, ...parts.path].join(".");
209
+ const visual = new Set();
210
+ const styleVisual = new Set();
211
+ const kinds = new Set();
212
+ // Where each kind of unreadable finding originates, so two findings on
213
+ // the same element sort by position instead of falling back to message text.
214
+ const kindNodes = new Map();
215
+ let reason;
216
+ let sawClassOrStyle = false;
217
+ const readClass = (initializer) => {
218
+ sawClassOrStyle = true;
219
+ if (!initializer)
220
+ return;
221
+ const reading = readClassExpression(initializer, helpers);
222
+ for (const token of reading.literals) {
223
+ if (classifyClass(token) === "visual")
224
+ visual.add(token);
225
+ }
226
+ if (reading.unreadableAt) {
227
+ kinds.add("dynamic-class");
228
+ kindNodes.set("dynamic-class", reading.unreadableAt);
229
+ }
230
+ };
231
+ const readStyle = (initializer) => {
232
+ sawClassOrStyle = true;
233
+ const unwrappedInitializer = initializer && isJsxExpression(initializer) ? initializer.expression : initializer;
234
+ const expression = unwrappedInitializer && unwrapTypeWrappers(unwrappedInitializer);
235
+ if (!expression || !isObjectLiteralExpression(expression)) {
236
+ kinds.add("dynamic-style");
237
+ kindNodes.set("dynamic-style", expression ?? initializer ?? element);
238
+ return;
239
+ }
240
+ for (const property of expression.properties) {
241
+ const key = isPropertyAssignment(property) || isShorthandPropertyAssignment(property)
242
+ ? propertyKey(property.name)
243
+ : undefined;
244
+ if (key === undefined) {
245
+ kinds.add("dynamic-style");
246
+ kindNodes.set("dynamic-style", kindNodes.get("dynamic-style") ?? property);
247
+ }
248
+ else if (classifyStyleProperty(key) === "visual")
249
+ styleVisual.add(key);
250
+ }
251
+ };
252
+ for (const attribute of opening.attributes.properties) {
253
+ if (isJsxSpreadAttribute(attribute)) {
254
+ const markSpread = (node) => {
255
+ if (!sawClassOrStyle)
256
+ return;
257
+ kinds.add("spread-after");
258
+ kindNodes.set("spread-after", kindNodes.get("spread-after") ?? node);
259
+ };
260
+ const spread = unwrapTypeWrappers(attribute.expression);
261
+ if (!isObjectLiteralExpression(spread)) {
262
+ markSpread(attribute);
263
+ continue;
264
+ }
265
+ for (const property of spread.properties) {
266
+ // A nested spread or a key the checker cannot name may carry
267
+ // className or style, so it counts as a spread at that point.
268
+ const value = isPropertyAssignment(property)
269
+ ? property.initializer
270
+ : isShorthandPropertyAssignment(property)
271
+ ? property.name
272
+ : undefined;
273
+ const key = isPropertyAssignment(property) || isShorthandPropertyAssignment(property)
274
+ ? propertyKey(property.name)
275
+ : undefined;
276
+ if (value === undefined || key === undefined)
277
+ markSpread(property);
278
+ else if (key === "className")
279
+ readClass(value);
280
+ else if (key === "style")
281
+ readStyle(value);
282
+ }
283
+ continue;
284
+ }
285
+ if (!isJsxAttribute(attribute))
286
+ continue;
287
+ const name = isIdentifier(attribute.name) ? attribute.name.text : undefined;
288
+ if (name === "data-ui-override")
289
+ reason = markerReason(attribute.initializer);
290
+ if (name === "className")
291
+ readClass(attribute.initializer);
292
+ if (name === "style")
293
+ readStyle(attribute.initializer);
294
+ }
295
+ const locationOf = (node) => {
296
+ const position = sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile));
297
+ return { column: position.character + 1, line: position.line + 1, path: file.path };
298
+ };
299
+ const location = locationOf(element);
300
+ const classes = [...visual].sort();
301
+ const styles = [...styleVisual].sort();
302
+ const findingKinds = [...kinds].sort();
303
+ if (reason !== undefined) {
304
+ result.overrides.push({
305
+ ...location,
306
+ classes: [...classes, ...styles.map((key) => `style:${key}`), ...findingKinds],
307
+ component,
308
+ reason,
309
+ });
310
+ return;
311
+ }
312
+ if (classes.length > 0) {
313
+ result.violations.push({
314
+ ...location,
315
+ anchor: `${component}|classes|${classes.join(" ")}`,
316
+ message: `${component} has visual classes without data-ui-override: ${classes.join(", ")}.`,
317
+ nodeKind: "component-override:classes",
318
+ rule: "components/override",
319
+ });
320
+ }
321
+ if (styles.length > 0) {
322
+ result.violations.push({
323
+ ...location,
324
+ anchor: `${component}|style|${styles.join(" ")}`,
325
+ message: `${component} has visual style properties without data-ui-override: ${styles.join(", ")}.`,
326
+ nodeKind: "component-override:style",
327
+ rule: "components/override",
328
+ });
329
+ }
330
+ for (const kind of findingKinds) {
331
+ result.violations.push({
332
+ ...locationOf(kindNodes.get(kind) ?? element),
333
+ anchor: `${component}|${kind}`,
334
+ message: `${component} has ${kindMessages[kind]} without data-ui-override.`,
335
+ nodeKind: `component-override:${kind}`,
336
+ rule: "components/override",
337
+ });
338
+ }
339
+ };
340
+ const visit = (node) => {
341
+ const names = scopeNames(node);
342
+ if (names)
343
+ scopes.push(names);
344
+ if (isJsxOpeningElement(node) || isJsxSelfClosingElement(node))
345
+ analyze(node);
346
+ node.forEachChild(visit);
347
+ if (names)
348
+ scopes.pop();
349
+ };
350
+ sourceFile.forEachChild(visit);
351
+ return result;
352
+ },
353
+ };
354
+ }
@@ -1,10 +1,11 @@
1
- import type { ThemeConfig } from "../config.js";
1
+ import type { ComponentsConfig, ThemeConfig } from "../config.js";
2
2
  import type { ContrastManifest, ThemeContract } from "../contracts.js";
3
3
  import type { CheckRule } from "../scan.js";
4
4
  export interface DefaultRulesOptions {
5
+ components?: ComponentsConfig;
5
6
  computed?: Record<string, Record<string, string>>;
6
7
  contract?: ThemeContract;
7
8
  manifest?: ContrastManifest;
8
9
  themes: ThemeConfig[];
9
10
  }
10
- export declare function createDefaultRules({ computed, contract, manifest, themes, }: DefaultRulesOptions): Promise<CheckRule[]>;
11
+ export declare function createDefaultRules({ components, computed, contract, manifest, themes, }: DefaultRulesOptions): Promise<CheckRule[]>;
@@ -1,8 +1,9 @@
1
+ import { createComponentOverrideRule } from "./componentOverride.js";
1
2
  import { createNoLiteralColorRule } from "./noLiteralColor.js";
2
3
  import { createTokenCompleteRule, loadThemeContract } from "./tokenComplete.js";
3
4
  import { createTokenContrastRule, loadContrastManifest, } from "./tokenContrast.js";
4
5
  import { createTokenPrefixRule } from "./tokenPrefix.js";
5
- export async function createDefaultRules({ computed, contract, manifest, themes, }) {
6
+ export async function createDefaultRules({ components, computed, contract, manifest, themes, }) {
6
7
  const [resolvedContract, resolvedManifest] = await Promise.all([
7
8
  contract ?? loadThemeContract(),
8
9
  manifest ?? loadContrastManifest(),
@@ -12,5 +13,8 @@ export async function createDefaultRules({ computed, contract, manifest, themes,
12
13
  createTokenPrefixRule(),
13
14
  createTokenCompleteRule({ computed, contract: resolvedContract, themes }),
14
15
  createTokenContrastRule({ computed, manifest: resolvedManifest, themes }),
16
+ ...(components
17
+ ? [createComponentOverrideRule({ classHelpers: components.classHelpers, sources: components.sources })]
18
+ : []),
15
19
  ];
16
20
  }
@@ -0,0 +1,5 @@
1
+ export type OverrideClass = "free" | "visual";
2
+ /** The utility a class applies, without variants, importance or negative sign. */
3
+ export declare function utilityOf(token: string): string;
4
+ export declare function classifyClass(token: string): OverrideClass;
5
+ export declare function classifyStyleProperty(name: string): OverrideClass;
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Splits a class at its variant separators: the colons outside square
3
+ * brackets, so `[&:hover]:bg-red-500` and `bg-[color:var(--x)]` stay intact.
4
+ */
5
+ function splitVariants(token) {
6
+ const parts = [];
7
+ let depth = 0;
8
+ let current = "";
9
+ for (const char of token) {
10
+ if (char === "[")
11
+ depth += 1;
12
+ if (char === "]")
13
+ depth = Math.max(0, depth - 1);
14
+ if (char === ":" && depth === 0) {
15
+ parts.push(current);
16
+ current = "";
17
+ continue;
18
+ }
19
+ current += char;
20
+ }
21
+ parts.push(current);
22
+ return parts;
23
+ }
24
+ /** The utility a class applies, without variants, importance or negative sign. */
25
+ export function utilityOf(token) {
26
+ let utility = splitVariants(token).at(-1) ?? "";
27
+ if (utility.startsWith("!"))
28
+ utility = utility.slice(1);
29
+ if (utility.endsWith("!"))
30
+ utility = utility.slice(0, -1);
31
+ if (utility.startsWith("-"))
32
+ utility = utility.slice(1);
33
+ return utility;
34
+ }
35
+ // Spec §4.1: fitting into the parent is free; everything else, including any
36
+ // class outside this list, changes the component and needs a marker. A class
37
+ // is free only when its utility AND its value match the family's grammar —
38
+ // matching only the prefix would also free look-alike product classes such as
39
+ // `top-bar` or `order-summary`.
40
+ const num = String.raw `\d+(?:\.\d+)?`;
41
+ const frac = String.raw `\d+\/\d+`;
42
+ // An arbitrary value may nest brackets or parentheses (`mt-[theme(spacing[2])]`);
43
+ // the family prefix already fixes the property, so any bracketed value is fine.
44
+ const arb = String.raw `\[\S+\]|\(\S+\)`;
45
+ const size = String.raw `3xs|2xs|xs|sm|md|lg|xl|2xl|3xl|4xl|5xl|6xl|7xl|prose`;
46
+ const vw = String.raw `screen|svw|lvw|dvw`;
47
+ const freeUtilities = [
48
+ new RegExp(`^m[trblxyse]?-(?:${num}|px|auto|${arb})$`, "u"),
49
+ new RegExp(`^(?:min-|max-)?w-(?:${num}|${frac}|px|auto|full|${vw}|min|max|fit|${size}|${arb})$`, "u"),
50
+ /^max-w-none$/u,
51
+ /^flex-(?:1|none|auto|initial)$/u,
52
+ /^grow$/u,
53
+ new RegExp(`^grow-(?:${num}|${arb})$`, "u"),
54
+ /^shrink$/u,
55
+ new RegExp(`^shrink-(?:${num}|${arb})$`, "u"),
56
+ new RegExp(`^basis-(?:${num}|${frac}|px|auto|full|${size}|${arb})$`, "u"),
57
+ new RegExp(`^order-(?:${num}|first|last|none|${arb})$`, "u"),
58
+ /^(?:self|justify-self|place-self)-(?:auto|start|end|center|stretch|baseline)$/u,
59
+ new RegExp(`^(?:col|row)-span-(?:${num}|full|${arb})$`, "u"),
60
+ new RegExp(`^(?:col|row)-(?:start|end)-(?:${num}|auto|${arb})$`, "u"),
61
+ /^(?:static|relative|absolute|fixed|sticky)$/u,
62
+ new RegExp(`^inset(?:-[xy])?-(?:${num}|${frac}|px|auto|full|${arb})$`, "u"),
63
+ new RegExp(`^(?:top|right|bottom|left|start|end)-(?:${num}|${frac}|px|auto|full|${arb})$`, "u"),
64
+ new RegExp(`^z-(?:${num}|auto|${arb})$`, "u"),
65
+ /^(?:hidden|sr-only|not-sr-only|invisible|visible)$/u,
66
+ /^text-(?:left|center|right|justify|start|end)$/u,
67
+ ];
68
+ export function classifyClass(token) {
69
+ const utility = utilityOf(token);
70
+ return freeUtilities.some((pattern) => pattern.test(utility)) ? "free" : "visual";
71
+ }
72
+ // Spec §4.2, by property name; kebab-case keys are read as camelCase.
73
+ const freeStyleProperties = new Set([
74
+ "margin", "marginTop", "marginRight", "marginBottom", "marginLeft", "marginBlock",
75
+ "marginBlockStart", "marginBlockEnd", "marginInline", "marginInlineStart", "marginInlineEnd",
76
+ "width", "minWidth", "maxWidth", "flex", "flexGrow", "flexShrink", "flexBasis", "order",
77
+ "alignSelf", "justifySelf", "placeSelf", "gridColumn", "gridColumnStart", "gridColumnEnd",
78
+ "gridRow", "gridRowStart", "gridRowEnd", "gridArea", "position", "inset", "insetBlock",
79
+ "insetBlockStart", "insetBlockEnd", "insetInline", "insetInlineStart", "insetInlineEnd",
80
+ "top", "right", "bottom", "left", "zIndex", "textAlign", "visibility",
81
+ ]);
82
+ export function classifyStyleProperty(name) {
83
+ const camel = name.replace(/-([a-z])/gu, (_, letter) => letter.toUpperCase());
84
+ return freeStyleProperties.has(camel) ? "free" : "visual";
85
+ }
package/dist/scan.d.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import { type Root } from "postcss";
2
2
  import { SyntaxKind, type SourceFile } from "typescript/unstable/ast";
3
3
  import type { ChecksConfig } from "./config.js";
4
- import type { CheckReport, Uncovered, Violation } from "./result.js";
4
+ import type { CheckReport, ComponentOverride, Uncovered, Violation } from "./result.js";
5
5
  export interface ScannedFile {
6
6
  content: string;
7
7
  cssRoot?: Root;
@@ -16,6 +16,7 @@ export interface TypeScriptToken {
16
16
  text: string;
17
17
  }
18
18
  export interface RuleResult {
19
+ overrides?: ComponentOverride[];
19
20
  uncovered: Uncovered[];
20
21
  violations: Violation[];
21
22
  }
@@ -24,6 +25,8 @@ export interface CheckRule {
24
25
  check(file: ScannedFile): Promise<RuleResult> | RuleResult;
25
26
  complete?(): Promise<RuleResult> | RuleResult;
26
27
  name: string;
28
+ /** Which file set the rule runs on; "main" (the default) is `include`. */
29
+ scope?: "main" | "components";
27
30
  }
28
31
  export interface RunChecksOptions {
29
32
  applyGovernance?: boolean;
package/dist/scan.js CHANGED
@@ -24,6 +24,19 @@ function scanTypeScript(content, path) {
24
24
  const uncovered = [];
25
25
  let kind = scanner.scan();
26
26
  while (kind !== SyntaxKind.EndOfFile) {
27
+ // This scan has no parser behind it, so a "#" that starts no name (the one
28
+ // in /#/, read here as a division) comes back as a zero-width token and
29
+ // the scanner stays where it was. Read it as the hash it is; if a token
30
+ // still has no width, step over one character so the scan always ends.
31
+ if (scanner.getTokenEnd() <= scanner.getTokenStart()) {
32
+ if (kind === SyntaxKind.PrivateIdentifier)
33
+ kind = scanner.reScanHashToken();
34
+ if (scanner.getTokenEnd() <= scanner.getTokenStart()) {
35
+ scanner.resetTokenState(scanner.getTokenStart() + 1);
36
+ kind = scanner.scan();
37
+ continue;
38
+ }
39
+ }
27
40
  tokens.push({
28
41
  end: scanner.getTokenEnd(),
29
42
  kind,
@@ -88,6 +101,7 @@ function emptyReport(rulesExecuted) {
88
101
  filesRead: 0,
89
102
  nodesChecked: 0,
90
103
  operationalErrors: [],
104
+ overrides: [],
91
105
  rulesExecuted,
92
106
  schemaVersion: 1,
93
107
  suppressions: [],
@@ -150,6 +164,25 @@ function validateRuntimeConfig(value) {
150
164
  theme.computedEvidence.theme.length === 0))))) {
151
165
  return "Themes must be an array of named selectors with files.";
152
166
  }
167
+ if (candidate.components !== undefined) {
168
+ const components = candidate.components;
169
+ const isList = (value, nonEmpty) => Array.isArray(value) &&
170
+ (!nonEmpty || value.length > 0) &&
171
+ value.every((item) => typeof item === "string" && item.length > 0);
172
+ if (!components ||
173
+ typeof components !== "object" ||
174
+ Array.isArray(components) ||
175
+ Object.keys(components).some((key) => !["classHelpers", "exclude", "include", "sources"].includes(key)) ||
176
+ !isList(components.include, true) ||
177
+ (components.exclude !== undefined &&
178
+ !isList(components.exclude, false)) ||
179
+ (components.sources !== undefined &&
180
+ !isList(components.sources, true)) ||
181
+ (components.classHelpers !== undefined &&
182
+ !isList(components.classHelpers, false))) {
183
+ return "components needs include patterns and optional exclude, sources and classHelpers string lists.";
184
+ }
185
+ }
153
186
  return undefined;
154
187
  }
155
188
  export async function runChecks(config, options = {}) {
@@ -192,6 +225,7 @@ export async function runChecks(config, options = {}) {
192
225
  themes: config.themes ?? [],
193
226
  });
194
227
  configuredRules = await (await import("./rules/default.js")).createDefaultRules({
228
+ components: config.components,
195
229
  computed,
196
230
  contract: contracts.theme,
197
231
  manifest: contracts.contrast,
@@ -251,7 +285,19 @@ export async function runChecks(config, options = {}) {
251
285
  }))
252
286
  .sort((left, right) => left.path.localeCompare(right.path));
253
287
  report.filesDiscovered = includedBeforeExcludes.length;
254
- if (absolutePaths.length === 0) {
288
+ const componentPaths = config.components
289
+ ? (await discoverFiles(root, config.components.include, config.components.exclude)).filter((path) => extname(path).toLowerCase() !== ".css")
290
+ : [];
291
+ if (config.components && componentPaths.length === 0) {
292
+ report.operationalErrors.push({
293
+ code: "no-files",
294
+ message: "No files matched components.include.",
295
+ });
296
+ }
297
+ const mainPaths = new Set(absolutePaths);
298
+ const componentSet = new Set(componentPaths);
299
+ const allPaths = [...new Set([...absolutePaths, ...componentPaths])].sort((left, right) => left.localeCompare(right));
300
+ if (allPaths.length === 0) {
255
301
  report.operationalErrors.push({
256
302
  code: "no-files",
257
303
  message: "No files matched the configured include patterns.",
@@ -267,7 +313,7 @@ export async function runChecks(config, options = {}) {
267
313
  // in one report, so type analysis is refused rather than silently mixed.
268
314
  const typeScriptPaths = options.readFile
269
315
  ? []
270
- : absolutePaths.filter((path) => extname(path).toLowerCase() !== ".css");
316
+ : allPaths.filter((path) => extname(path).toLowerCase() !== ".css");
271
317
  if (options.readFile) {
272
318
  report.operationalErrors.push({
273
319
  code: "internal",
@@ -292,8 +338,10 @@ export async function runChecks(config, options = {}) {
292
338
  });
293
339
  }
294
340
  }
295
- for (const absolutePath of absolutePaths) {
341
+ for (const absolutePath of allPaths) {
296
342
  const path = toPosixPath(relative(root, absolutePath));
343
+ const inMain = mainPaths.has(absolutePath);
344
+ const inComponents = componentSet.has(absolutePath);
297
345
  let content;
298
346
  try {
299
347
  content = await readSource(absolutePath);
@@ -307,7 +355,8 @@ export async function runChecks(config, options = {}) {
307
355
  });
308
356
  continue;
309
357
  }
310
- if (usesDefaultRules &&
358
+ if (inMain &&
359
+ usesDefaultRules &&
311
360
  config.contractSource === "bundled" &&
312
361
  /(?:from\s*|import\s*(?:\(\s*)?)["']@ksmv\/ui-react(?:\/[^"']*)?["']/.test(content) &&
313
362
  !report.operationalErrors.some(({ code, message }) => code === "config" &&
@@ -341,7 +390,8 @@ export async function runChecks(config, options = {}) {
341
390
  file.sourceFile = project.program.getSourceFile(absolutePath);
342
391
  }
343
392
  report.nodesChecked += coverage.nodesChecked;
344
- report.uncovered.push(...coverage.uncovered);
393
+ if (inMain)
394
+ report.uncovered.push(...coverage.uncovered);
345
395
  }
346
396
  }
347
397
  catch {
@@ -353,10 +403,14 @@ export async function runChecks(config, options = {}) {
353
403
  continue;
354
404
  }
355
405
  for (const rule of rules) {
406
+ const runsHere = (rule.scope ?? "main") === "components" ? inComponents : inMain;
407
+ if (!runsHere)
408
+ continue;
356
409
  try {
357
410
  const result = await rule.check(file);
358
411
  report.violations.push(...result.violations);
359
412
  report.uncovered.push(...result.uncovered);
413
+ report.overrides.push(...(result.overrides ?? []));
360
414
  }
361
415
  catch {
362
416
  report.operationalErrors.push({
@@ -375,6 +429,7 @@ export async function runChecks(config, options = {}) {
375
429
  const result = await rule.complete();
376
430
  report.violations.push(...result.violations);
377
431
  report.uncovered.push(...result.uncovered);
432
+ report.overrides.push(...(result.overrides ?? []));
378
433
  }
379
434
  catch {
380
435
  report.operationalErrors.push({
@@ -394,6 +449,10 @@ export async function runChecks(config, options = {}) {
394
449
  report.uncovered.sort(compareFindings);
395
450
  report.operationalErrors.sort((left, right) => (left.path ?? "").localeCompare(right.path ?? "") ||
396
451
  left.code.localeCompare(right.code));
452
+ report.overrides.sort((left, right) => left.path.localeCompare(right.path) ||
453
+ left.line - right.line ||
454
+ left.column - right.column ||
455
+ left.component.localeCompare(right.component));
397
456
  report.exitCode = resolveExitCode(report, config.failOnUncovered ?? true);
398
457
  report.durationMs = Math.round((performance.now() - startedAt) * 100) / 100;
399
458
  return report;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ksmv/ui-checks",
3
- "version": "0.2.0",
3
+ "version": "0.3.1",
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",