@kerfjs/ui 5.0.0-beta.18 → 5.0.0-beta.21
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/README.md +114 -3
- package/ai/application-ui-diagnostic-ids-v1.json +92 -0
- package/ai/application-ui-profile-sync.cjs +529 -0
- package/ai/application-ui-profile.d.mts +1 -0
- package/ai/application-ui-profile.d.ts +102 -0
- package/ai/application-ui-profile.defaults.json +41 -0
- package/ai/application-ui-profile.mjs +626 -0
- package/ai/application-ui-profile.schema.json +130 -0
- package/ai/catalog-authoring.json +25 -0
- package/ai/catalog-authoring.schema.json +61 -0
- package/ai/compile-time-contracts-v1.json +101 -0
- package/ai/compile-time-contracts-v1.schema.json +49 -0
- package/ai/component-catalog-extension-v2.d.ts +7 -0
- package/ai/component-catalog-extension-v2.schema.json +7 -0
- package/ai/component-catalog-v2-overrides.json +326 -0
- package/ai/component-catalog-v2.d.ts +67 -0
- package/ai/component-catalog-v2.json +4441 -0
- package/ai/component-catalog-v2.schema.json +246 -0
- package/ai/component-catalog.json +150 -6
- package/ai/public-api-signatures-v1.md +294 -64
- package/ai/skill.md +41 -5
- package/ai/webawesome-jsx-signatures-v1.md +1 -1
- package/analyzer/cli.mjs +46 -0
- package/analyzer/index.d.mts +45 -0
- package/analyzer/index.mjs +743 -0
- package/analyzer/report.schema.json +66 -0
- package/dist/browser/pane.js +3 -0
- package/dist/browser/sunken-panel.js +3 -0
- package/dist/catalog-resources.d.ts +34 -0
- package/dist/catalog-resources.js +22 -0
- package/dist/catalog-resources.js.map +1 -0
- package/dist/catalog.d.ts +21 -3
- package/dist/catalog.js +239 -210
- package/dist/catalog.js.map +1 -1
- package/dist/{chunk-ZK2G3VR5.js → chunk-5VTB2BKP.js} +1 -1
- package/dist/chunk-5VTB2BKP.js.map +1 -0
- package/dist/{chunk-S3HKPI7I.js → chunk-7KEUJIIC.js} +1 -1
- package/dist/chunk-7KEUJIIC.js.map +1 -0
- package/dist/{chunk-NVWXKU37.js → chunk-AASLYRGE.js} +1 -1
- package/dist/chunk-AASLYRGE.js.map +1 -0
- package/dist/{chunk-ENVTNLYC.js → chunk-CN2XNTAM.js} +6 -6
- package/dist/chunk-CN2XNTAM.js.map +1 -0
- package/dist/{chunk-QIRXYKCE.js → chunk-DSNIYOZ3.js} +3 -2
- package/dist/chunk-DSNIYOZ3.js.map +1 -0
- package/dist/chunk-JRKSK2HX.js +79 -0
- package/dist/chunk-JRKSK2HX.js.map +1 -0
- package/dist/{chunk-U5IFGQCD.js → chunk-K57KRAOV.js} +1 -1
- package/dist/chunk-K57KRAOV.js.map +1 -0
- package/dist/{chunk-ZVI5YST2.js → chunk-M7JBER5U.js} +1 -1
- package/dist/chunk-M7JBER5U.js.map +1 -0
- package/dist/{chunk-43FUQH35.js → chunk-OHU6CI22.js} +2 -2
- package/dist/{chunk-43FUQH35.js.map → chunk-OHU6CI22.js.map} +1 -1
- package/dist/{chunk-4TRDV6RL.js → chunk-OZWU5ZPZ.js} +1 -1
- package/dist/chunk-OZWU5ZPZ.js.map +1 -0
- package/dist/{chunk-JTHUXDN2.js → chunk-RYRIFZCT.js} +3 -3
- package/dist/chunk-RYRIFZCT.js.map +1 -0
- package/dist/{chunk-6ETZKKAE.js → chunk-VLYV65LK.js} +1 -1
- package/dist/chunk-VLYV65LK.js.map +1 -0
- package/dist/chunk-W4CXQYSG.js +23 -0
- package/dist/chunk-W4CXQYSG.js.map +1 -0
- package/dist/{chunk-S3PCZ7NV.js → chunk-W7YD6E4N.js} +1 -1
- package/dist/chunk-W7YD6E4N.js.map +1 -0
- package/dist/index.d.ts +6 -4
- package/dist/index.js +14 -12
- package/dist/list-header.d.ts +29 -8
- package/dist/list-header.js +1 -1
- package/dist/nav-stack.js +1 -1
- package/dist/pane.d.ts +49 -0
- package/dist/pane.js +4 -0
- package/dist/pane.js.map +1 -0
- package/dist/panel-header.d.ts +9 -3
- package/dist/panel-header.js +1 -1
- package/dist/segmented-control.d.ts +6 -6
- package/dist/segmented-control.js +1 -1
- package/dist/select.d.ts +10 -4
- package/dist/select.js +1 -1
- package/dist/split-view.js +2 -2
- package/dist/state-banner.d.ts +3 -2
- package/dist/state-banner.js +1 -1
- package/dist/styles/catalog.css +6 -6
- package/dist/styles/layout.css +2 -20
- package/dist/styles/pane.css +62 -0
- package/dist/styles/segmented-control.css +3 -7
- package/dist/styles/sunken-panel.css +18 -0
- package/dist/sunken-panel.d.ts +16 -0
- package/dist/sunken-panel.js +3 -0
- package/dist/sunken-panel.js.map +1 -0
- package/dist/tab-bar.d.ts +3 -2
- package/dist/tab-bar.js +1 -1
- package/dist/tab-scaffold.d.ts +6 -6
- package/dist/tab-scaffold.js.map +1 -1
- package/dist/token-search-field.d.ts +25 -9
- package/dist/token-search-field.js +1 -1
- package/dist/toolbar-control-group.d.ts +9 -5
- package/dist/toolbar-control-group.js +1 -1
- package/dist/toolbar-text.d.ts +10 -11
- package/dist/toolbar-text.js +1 -1
- package/dist/wire-tab-bars.d.ts +5 -2
- package/dist/wire-tab-bars.js +1 -1
- package/dist/wire-token-search-fields.d.ts +11 -6
- package/dist/wire-token-search-fields.js +1 -1
- package/docs/catalog.md +186 -67
- package/docs/component-contract.md +102 -5
- package/docs/component-selection.md +9 -5
- package/docs/design-philosophy.md +2 -4
- package/docs/examples/application-ui-profile.json +60 -0
- package/docs/examples/component-catalog-extension-v2.json +81 -0
- package/docs/layout.md +42 -23
- package/docs/sunken-panel.md +43 -0
- package/docs/type-contracts.md +99 -0
- package/docs/ui-analyzer.md +81 -0
- package/docs/ui-doctor.md +77 -0
- package/docs/ui-evaluator.md +122 -0
- package/doctor/cli.mjs +115 -0
- package/doctor/config.schema.json +68 -0
- package/doctor/index.d.mts +116 -0
- package/doctor/index.mjs +1256 -0
- package/doctor/report.schema.json +118 -0
- package/evaluator/cli.mjs +89 -0
- package/evaluator/index.d.mts +90 -0
- package/evaluator/index.mjs +1138 -0
- package/evaluator/report.schema.json +152 -0
- package/llms.txt +40 -3
- package/package.json +66 -8
- package/ux-demo/recipes/compact-toolbar.tsx +2 -2
- package/ux-demo/recipes/composer-form.tsx +1 -1
- package/dist/chunk-4TRDV6RL.js.map +0 -1
- package/dist/chunk-6ETZKKAE.js.map +0 -1
- package/dist/chunk-ENVTNLYC.js.map +0 -1
- package/dist/chunk-JTHUXDN2.js.map +0 -1
- package/dist/chunk-NVWXKU37.js.map +0 -1
- package/dist/chunk-QIRXYKCE.js.map +0 -1
- package/dist/chunk-S3HKPI7I.js.map +0 -1
- package/dist/chunk-S3PCZ7NV.js.map +0 -1
- package/dist/chunk-U5IFGQCD.js.map +0 -1
- package/dist/chunk-ZK2G3VR5.js.map +0 -1
- package/dist/chunk-ZVI5YST2.js.map +0 -1
|
@@ -0,0 +1,743 @@
|
|
|
1
|
+
import { readFile, readdir, stat } from 'node:fs/promises';
|
|
2
|
+
import {
|
|
3
|
+
basename,
|
|
4
|
+
dirname,
|
|
5
|
+
extname,
|
|
6
|
+
isAbsolute,
|
|
7
|
+
relative,
|
|
8
|
+
resolve,
|
|
9
|
+
} from 'node:path';
|
|
10
|
+
import process from 'node:process';
|
|
11
|
+
|
|
12
|
+
import postcss from 'postcss';
|
|
13
|
+
import ts from 'typescript';
|
|
14
|
+
|
|
15
|
+
import { loadApplicationUiProfile } from '../ai/application-ui-profile.mjs';
|
|
16
|
+
|
|
17
|
+
export const UI_ANALYSIS_SCHEMA_VERSION = 1;
|
|
18
|
+
|
|
19
|
+
export const UI_ANALYSIS_RULES = Object.freeze({
|
|
20
|
+
'KUI-L001': { severity: 'error', title: 'Private or unknown Kerf selector' },
|
|
21
|
+
'KUI-L002': { severity: 'error', title: 'Unknown Kerf token' },
|
|
22
|
+
'KUI-L003': { severity: 'error', title: 'Competing geometry owners' },
|
|
23
|
+
'KUI-L004': { severity: 'review', title: 'Repeated content inset' },
|
|
24
|
+
'KUI-L005': { severity: 'review', title: 'Forced component dimension' },
|
|
25
|
+
'KUI-L006': {
|
|
26
|
+
severity: 'review',
|
|
27
|
+
title: 'Hard-coded spacing outside the approved scale',
|
|
28
|
+
},
|
|
29
|
+
'KUI-L007': { severity: 'error', title: 'Nested scroll owners' },
|
|
30
|
+
'KUI-L008': { severity: 'review', title: 'Dynamic class requires review' },
|
|
31
|
+
'KUI-L009': { severity: 'error', title: 'Stylesheet could not be parsed' },
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const sourceExtensions = new Set(['.tsx', '.jsx']);
|
|
35
|
+
const spacingProperties = /^(?:margin|padding|gap|inset)(?:-|$)/;
|
|
36
|
+
const dimensionProperties =
|
|
37
|
+
/^(?:width|height|min-width|max-width|min-height|max-height)$/;
|
|
38
|
+
const approvedSpacing = new Set([0, 4, 8, 16, 24]);
|
|
39
|
+
const ignoredDirectories = new Set([
|
|
40
|
+
'.git',
|
|
41
|
+
'coverage',
|
|
42
|
+
'dist',
|
|
43
|
+
'node_modules',
|
|
44
|
+
]);
|
|
45
|
+
|
|
46
|
+
async function collectFiles(root, paths) {
|
|
47
|
+
const files = [];
|
|
48
|
+
const visit = async (path) => {
|
|
49
|
+
const details = await stat(path);
|
|
50
|
+
if (details.isFile()) {
|
|
51
|
+
if (sourceExtensions.has(extname(path)) || path.endsWith('.css'))
|
|
52
|
+
files.push(path);
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const entries = await readdir(path, { withFileTypes: true });
|
|
56
|
+
for (const entry of entries) {
|
|
57
|
+
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
|
|
58
|
+
const child = resolve(path, entry.name);
|
|
59
|
+
if (entry.isDirectory()) await visit(child);
|
|
60
|
+
else if (
|
|
61
|
+
sourceExtensions.has(extname(entry.name)) ||
|
|
62
|
+
entry.name.endsWith('.css')
|
|
63
|
+
)
|
|
64
|
+
files.push(child);
|
|
65
|
+
}
|
|
66
|
+
};
|
|
67
|
+
for (const path of paths?.length ? paths : [root])
|
|
68
|
+
await visit(resolve(root, path));
|
|
69
|
+
return files.sort();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function location(file, node, source) {
|
|
73
|
+
if (!source) {
|
|
74
|
+
const point = node.source?.start;
|
|
75
|
+
return {
|
|
76
|
+
file,
|
|
77
|
+
line: point?.line ?? 1,
|
|
78
|
+
column: point?.column ?? point?.offset ?? 1,
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
const point = source.getLineAndCharacterOfPosition(node.getStart(source));
|
|
82
|
+
return {
|
|
83
|
+
file,
|
|
84
|
+
line: point.line + 1,
|
|
85
|
+
column: point.character + 1,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function diagnostic(ruleId, at, message, evidence, chain) {
|
|
90
|
+
const rule = UI_ANALYSIS_RULES[ruleId];
|
|
91
|
+
return {
|
|
92
|
+
ruleId,
|
|
93
|
+
severity: rule.severity,
|
|
94
|
+
message,
|
|
95
|
+
location: at,
|
|
96
|
+
evidence,
|
|
97
|
+
chain,
|
|
98
|
+
};
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function isSuppressed(item, profile, root) {
|
|
102
|
+
const target = relative(root, item.location.file).replaceAll('\\', '/');
|
|
103
|
+
return (profile?.exceptions ?? []).some(
|
|
104
|
+
(exception) =>
|
|
105
|
+
exception.rules?.includes(item.ruleId) && exception.target === target,
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function relativeStyleImports(file, source) {
|
|
110
|
+
const imports = [];
|
|
111
|
+
if (file.endsWith('.css')) {
|
|
112
|
+
let stylesheet;
|
|
113
|
+
try {
|
|
114
|
+
stylesheet = postcss.parse(source, { from: file });
|
|
115
|
+
} catch {
|
|
116
|
+
return imports;
|
|
117
|
+
}
|
|
118
|
+
stylesheet.walkAtRules('import', (rule) => {
|
|
119
|
+
const match = rule.params.match(
|
|
120
|
+
/^\s*(?:["']([^"']+)["']|url\(\s*(?:["']([^"']+)["']|([^\s)]+))\s*\))/,
|
|
121
|
+
);
|
|
122
|
+
const specifier = match?.[1] ?? match?.[2] ?? match?.[3];
|
|
123
|
+
if (specifier?.startsWith('.'))
|
|
124
|
+
imports.push(resolve(dirname(file), specifier));
|
|
125
|
+
});
|
|
126
|
+
return imports;
|
|
127
|
+
}
|
|
128
|
+
const syntax = file.endsWith('.tsx') ? ts.ScriptKind.TSX : ts.ScriptKind.JSX;
|
|
129
|
+
const module = ts.createSourceFile(
|
|
130
|
+
file,
|
|
131
|
+
source,
|
|
132
|
+
ts.ScriptTarget.Latest,
|
|
133
|
+
true,
|
|
134
|
+
syntax,
|
|
135
|
+
);
|
|
136
|
+
for (const statement of module.statements) {
|
|
137
|
+
if (
|
|
138
|
+
!ts.isImportDeclaration(statement) ||
|
|
139
|
+
!ts.isStringLiteral(statement.moduleSpecifier)
|
|
140
|
+
)
|
|
141
|
+
continue;
|
|
142
|
+
const specifier = statement.moduleSpecifier.text;
|
|
143
|
+
if (specifier.startsWith('.') && specifier.endsWith('.css'))
|
|
144
|
+
imports.push(resolve(dirname(file), specifier));
|
|
145
|
+
}
|
|
146
|
+
return imports;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function reachableStyleFacts(file, imports, styleFacts) {
|
|
150
|
+
const result = new Map();
|
|
151
|
+
for (const style of reachableStyleFiles(file, imports, styleFacts)) {
|
|
152
|
+
for (const [className, facts] of styleFacts.get(style)) {
|
|
153
|
+
const current = result.get(className) ?? { scroll: false, inset: false };
|
|
154
|
+
current.scroll ||= facts.scroll;
|
|
155
|
+
current.inset ||= facts.inset;
|
|
156
|
+
result.set(className, current);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function reachableStyleFiles(file, imports, styleFacts) {
|
|
163
|
+
const visited = new Set();
|
|
164
|
+
const visit = (style) => {
|
|
165
|
+
if (visited.has(style) || !styleFacts.has(style)) return;
|
|
166
|
+
visited.add(style);
|
|
167
|
+
for (const dependency of imports.get(style) ?? []) visit(dependency);
|
|
168
|
+
};
|
|
169
|
+
for (const style of imports.get(file) ?? []) visit(style);
|
|
170
|
+
return visited;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function loadCatalogs(profileResult) {
|
|
174
|
+
const entries = [];
|
|
175
|
+
for (const catalog of profileResult.profile?.catalogs ?? []) {
|
|
176
|
+
const owner = profileResult.provenance[`$catalogs.${catalog.package}`];
|
|
177
|
+
if (!owner) continue;
|
|
178
|
+
try {
|
|
179
|
+
const artifact = JSON.parse(
|
|
180
|
+
await readFile(
|
|
181
|
+
resolve(dirname(owner), catalog.composition.path),
|
|
182
|
+
'utf8',
|
|
183
|
+
),
|
|
184
|
+
);
|
|
185
|
+
entries.push(...(artifact.entries ?? []));
|
|
186
|
+
} catch {
|
|
187
|
+
// Profile validation reports the precise catalog load failure.
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return entries;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
function catalogFacts(entries) {
|
|
194
|
+
const publicClasses = new Set();
|
|
195
|
+
const publicTokens = new Set();
|
|
196
|
+
const classEntries = new Map();
|
|
197
|
+
const exportEntries = new Map();
|
|
198
|
+
for (const entry of entries) {
|
|
199
|
+
exportEntries.set(entry.name, entry);
|
|
200
|
+
for (const item of entry.publicExports ?? [])
|
|
201
|
+
exportEntries.set(typeof item === 'string' ? item : item.name, entry);
|
|
202
|
+
for (const className of entry.boundaries?.publicClasses ?? []) {
|
|
203
|
+
publicClasses.add(className);
|
|
204
|
+
const owners = classEntries.get(className) ?? [];
|
|
205
|
+
owners.push(entry);
|
|
206
|
+
classEntries.set(className, owners);
|
|
207
|
+
}
|
|
208
|
+
for (const token of entry.boundaries?.publicTokens ?? [])
|
|
209
|
+
publicTokens.add(token);
|
|
210
|
+
}
|
|
211
|
+
return { publicClasses, publicTokens, classEntries, exportEntries };
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function spacingValues(value) {
|
|
215
|
+
const results = [];
|
|
216
|
+
for (const match of value.matchAll(/(-?\d*\.?\d+)(px|rem)\b/g)) {
|
|
217
|
+
const numeric = Number(match[1]) * (match[2] === 'rem' ? 16 : 1);
|
|
218
|
+
results.push({ text: match[0], pixels: numeric });
|
|
219
|
+
}
|
|
220
|
+
return results;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function inspectCss(file, source, facts, diagnostics, cssFacts) {
|
|
224
|
+
let root;
|
|
225
|
+
try {
|
|
226
|
+
root = postcss.parse(source, { from: file });
|
|
227
|
+
} catch (error) {
|
|
228
|
+
diagnostics.push(
|
|
229
|
+
diagnostic(
|
|
230
|
+
'KUI-L009',
|
|
231
|
+
{ file, line: error.line ?? 1, column: error.column ?? 1 },
|
|
232
|
+
error.reason ?? error.message,
|
|
233
|
+
),
|
|
234
|
+
);
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
root.walkRules((rule) => {
|
|
238
|
+
const classes = [
|
|
239
|
+
...rule.selector.matchAll(/\.([_a-zA-Z]+[_a-zA-Z0-9-]*)/g),
|
|
240
|
+
].map((match) => match[1]);
|
|
241
|
+
for (const className of classes) {
|
|
242
|
+
if (className.startsWith('kui-') && !facts.publicClasses.has(className))
|
|
243
|
+
diagnostics.push(
|
|
244
|
+
diagnostic(
|
|
245
|
+
'KUI-L001',
|
|
246
|
+
location(file, rule),
|
|
247
|
+
`.${className} is not a cataloged public Kerf class.`,
|
|
248
|
+
{ selector: rule.selector, className },
|
|
249
|
+
),
|
|
250
|
+
);
|
|
251
|
+
}
|
|
252
|
+
rule.walkDecls((decl) => {
|
|
253
|
+
const at = location(file, decl);
|
|
254
|
+
const tokens = [
|
|
255
|
+
...(decl.prop.match(/--kui-[a-z0-9-]+/g) ?? []),
|
|
256
|
+
...(decl.value.match(/--kui-[a-z0-9-]+/g) ?? []),
|
|
257
|
+
];
|
|
258
|
+
for (const token of new Set(tokens))
|
|
259
|
+
if (!facts.publicTokens.has(token))
|
|
260
|
+
diagnostics.push(
|
|
261
|
+
diagnostic(
|
|
262
|
+
'KUI-L002',
|
|
263
|
+
at,
|
|
264
|
+
`${token} is not a cataloged public Kerf token.`,
|
|
265
|
+
{ property: decl.prop, value: decl.value, token },
|
|
266
|
+
),
|
|
267
|
+
);
|
|
268
|
+
if (
|
|
269
|
+
spacingProperties.test(decl.prop) &&
|
|
270
|
+
!/var\(|calc\(|remify\(/.test(decl.value)
|
|
271
|
+
)
|
|
272
|
+
for (const value of spacingValues(decl.value))
|
|
273
|
+
if (!approvedSpacing.has(value.pixels))
|
|
274
|
+
diagnostics.push(
|
|
275
|
+
diagnostic(
|
|
276
|
+
'KUI-L006',
|
|
277
|
+
at,
|
|
278
|
+
`${decl.prop}: ${value.text} is outside the approved Kerf spacing scale.`,
|
|
279
|
+
{
|
|
280
|
+
property: decl.prop,
|
|
281
|
+
value: decl.value,
|
|
282
|
+
pixels: value.pixels,
|
|
283
|
+
},
|
|
284
|
+
),
|
|
285
|
+
);
|
|
286
|
+
for (const className of classes) {
|
|
287
|
+
const record = cssFacts.get(className) ?? {
|
|
288
|
+
scroll: false,
|
|
289
|
+
inset: false,
|
|
290
|
+
};
|
|
291
|
+
if (
|
|
292
|
+
/^overflow(?:-|$)/.test(decl.prop) &&
|
|
293
|
+
/\b(?:auto|scroll)\b/.test(decl.value)
|
|
294
|
+
)
|
|
295
|
+
record.scroll = true;
|
|
296
|
+
if (/^padding(?:-|$)/.test(decl.prop) && decl.value !== '0')
|
|
297
|
+
record.inset = true;
|
|
298
|
+
cssFacts.set(className, record);
|
|
299
|
+
if (
|
|
300
|
+
facts.publicClasses.has(className) &&
|
|
301
|
+
dimensionProperties.test(decl.prop)
|
|
302
|
+
)
|
|
303
|
+
diagnostics.push(
|
|
304
|
+
diagnostic(
|
|
305
|
+
'KUI-L005',
|
|
306
|
+
at,
|
|
307
|
+
`.${className} forces ${decl.prop}; prefer the component's public sizing contract.`,
|
|
308
|
+
{
|
|
309
|
+
selector: rule.selector,
|
|
310
|
+
property: decl.prop,
|
|
311
|
+
value: decl.value,
|
|
312
|
+
},
|
|
313
|
+
),
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function literalClasses(attribute) {
|
|
321
|
+
if (!attribute?.initializer) return { values: [], dynamic: false };
|
|
322
|
+
const value = attribute.initializer;
|
|
323
|
+
if (ts.isStringLiteral(value))
|
|
324
|
+
return { values: value.text.split(/\s+/).filter(Boolean), dynamic: false };
|
|
325
|
+
if (
|
|
326
|
+
ts.isJsxExpression(value) &&
|
|
327
|
+
value.expression &&
|
|
328
|
+
ts.isStringLiteralLike(value.expression)
|
|
329
|
+
)
|
|
330
|
+
return {
|
|
331
|
+
values: value.expression.text.split(/\s+/).filter(Boolean),
|
|
332
|
+
dynamic: false,
|
|
333
|
+
};
|
|
334
|
+
return { values: [], dynamic: true };
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function inspectTsx(file, sourceText, facts, cssFacts, diagnostics) {
|
|
338
|
+
const source = ts.createSourceFile(
|
|
339
|
+
file,
|
|
340
|
+
sourceText,
|
|
341
|
+
ts.ScriptTarget.Latest,
|
|
342
|
+
true,
|
|
343
|
+
ts.ScriptKind.TSX,
|
|
344
|
+
);
|
|
345
|
+
const imports = new Map();
|
|
346
|
+
for (const statement of source.statements) {
|
|
347
|
+
if (
|
|
348
|
+
!ts.isImportDeclaration(statement) ||
|
|
349
|
+
!ts.isStringLiteral(statement.moduleSpecifier)
|
|
350
|
+
)
|
|
351
|
+
continue;
|
|
352
|
+
const module = statement.moduleSpecifier.text;
|
|
353
|
+
if (!module.includes('kerf') && !module.startsWith('@')) continue;
|
|
354
|
+
const bindings = statement.importClause?.namedBindings;
|
|
355
|
+
if (!bindings || !ts.isNamedImports(bindings)) continue;
|
|
356
|
+
for (const item of bindings.elements) {
|
|
357
|
+
const exported = item.propertyName?.text ?? item.name.text;
|
|
358
|
+
const entry = facts.exportEntries.get(exported);
|
|
359
|
+
if (entry) imports.set(item.name.text, entry);
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
const stack = [];
|
|
363
|
+
const visit = (node) => {
|
|
364
|
+
if (ts.isJsxElement(node) || ts.isJsxSelfClosingElement(node)) {
|
|
365
|
+
const opening = ts.isJsxElement(node) ? node.openingElement : node;
|
|
366
|
+
const tag = opening.tagName.getText(source);
|
|
367
|
+
const entry = imports.get(tag);
|
|
368
|
+
const classAttribute = opening.attributes.properties.find(
|
|
369
|
+
(item) =>
|
|
370
|
+
ts.isJsxAttribute(item) &&
|
|
371
|
+
['class', 'className'].includes(item.name.getText(source)),
|
|
372
|
+
);
|
|
373
|
+
const classes = literalClasses(classAttribute);
|
|
374
|
+
const at = location(file, opening, source);
|
|
375
|
+
if (classes.dynamic)
|
|
376
|
+
diagnostics.push(
|
|
377
|
+
diagnostic(
|
|
378
|
+
'KUI-L008',
|
|
379
|
+
at,
|
|
380
|
+
'Dynamic class expression was left unclassified; review it against cataloged public classes.',
|
|
381
|
+
{ tag },
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
const owners = classes.values.flatMap(
|
|
385
|
+
(className) => facts.classEntries.get(className) ?? [],
|
|
386
|
+
);
|
|
387
|
+
for (const property of ['margin', 'border', 'padding']) {
|
|
388
|
+
const selfOwners = owners.filter(
|
|
389
|
+
(owner) => owner.layout?.geometry?.[property] === 'self',
|
|
390
|
+
);
|
|
391
|
+
if (new Set(selfOwners.map((owner) => owner.key)).size > 1)
|
|
392
|
+
diagnostics.push(
|
|
393
|
+
diagnostic(
|
|
394
|
+
'KUI-L003',
|
|
395
|
+
at,
|
|
396
|
+
`One element combines multiple ${property} owners: ${selfOwners.map((owner) => owner.key).join(', ')}.`,
|
|
397
|
+
{ property, classes: classes.values },
|
|
398
|
+
selfOwners.map((owner) => owner.key),
|
|
399
|
+
),
|
|
400
|
+
);
|
|
401
|
+
}
|
|
402
|
+
const scroll = classes.values.some(
|
|
403
|
+
(className) => cssFacts.get(className)?.scroll,
|
|
404
|
+
);
|
|
405
|
+
const inset = classes.values.some(
|
|
406
|
+
(className) => cssFacts.get(className)?.inset,
|
|
407
|
+
);
|
|
408
|
+
const parent = stack.at(-1);
|
|
409
|
+
if (scroll && parent?.scroll)
|
|
410
|
+
diagnostics.push(
|
|
411
|
+
diagnostic(
|
|
412
|
+
'KUI-L007',
|
|
413
|
+
at,
|
|
414
|
+
'A scroll-owning element is nested inside another declared scroll owner.',
|
|
415
|
+
{ classes: classes.values },
|
|
416
|
+
[parent.label, entry?.key ?? tag],
|
|
417
|
+
),
|
|
418
|
+
);
|
|
419
|
+
if (inset && parent?.inset)
|
|
420
|
+
diagnostics.push(
|
|
421
|
+
diagnostic(
|
|
422
|
+
'KUI-L004',
|
|
423
|
+
at,
|
|
424
|
+
'Nested elements both add content padding; confirm that the repeated inset is intentional.',
|
|
425
|
+
{ classes: classes.values },
|
|
426
|
+
[parent.label, entry?.key ?? tag],
|
|
427
|
+
),
|
|
428
|
+
);
|
|
429
|
+
stack.push({
|
|
430
|
+
scroll: scroll || parent?.scroll,
|
|
431
|
+
inset: inset || parent?.inset,
|
|
432
|
+
label: entry?.key ?? tag,
|
|
433
|
+
});
|
|
434
|
+
if (ts.isJsxElement(node))
|
|
435
|
+
for (const child of node.children) visit(child);
|
|
436
|
+
stack.pop();
|
|
437
|
+
return;
|
|
438
|
+
}
|
|
439
|
+
ts.forEachChild(node, visit);
|
|
440
|
+
};
|
|
441
|
+
visit(source);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
export async function analyzeUiProject({
|
|
445
|
+
root: requestedRoot = process.cwd(),
|
|
446
|
+
paths,
|
|
447
|
+
knownRules = [],
|
|
448
|
+
profile: packageProfile = resolve(
|
|
449
|
+
import.meta.dirname,
|
|
450
|
+
'../ai/application-ui-profile.defaults.json',
|
|
451
|
+
),
|
|
452
|
+
} = {}) {
|
|
453
|
+
const root = resolve(requestedRoot);
|
|
454
|
+
const files = await collectFiles(root, paths);
|
|
455
|
+
const diagnosticDecisions = new Map();
|
|
456
|
+
const contents = new Map(
|
|
457
|
+
await Promise.all(
|
|
458
|
+
files.map(async (file) => [file, await readFile(file, 'utf8')]),
|
|
459
|
+
),
|
|
460
|
+
);
|
|
461
|
+
const discoveredFiles = new Set(files);
|
|
462
|
+
for (let index = 0; index < files.length; index += 1) {
|
|
463
|
+
const file = files[index];
|
|
464
|
+
for (const dependency of relativeStyleImports(file, contents.get(file))) {
|
|
465
|
+
const projectPath = relative(root, dependency);
|
|
466
|
+
if (
|
|
467
|
+
discoveredFiles.has(dependency) ||
|
|
468
|
+
projectPath === '..' ||
|
|
469
|
+
projectPath.startsWith('../')
|
|
470
|
+
)
|
|
471
|
+
continue;
|
|
472
|
+
try {
|
|
473
|
+
const source = await readFile(dependency, 'utf8');
|
|
474
|
+
discoveredFiles.add(dependency);
|
|
475
|
+
files.push(dependency);
|
|
476
|
+
contents.set(dependency, source);
|
|
477
|
+
} catch {
|
|
478
|
+
// TypeScript or the CSS toolchain reports missing imports; analysis stays read-only.
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
files.sort();
|
|
483
|
+
const profileContexts = new Map();
|
|
484
|
+
const loadContext = async (file) => {
|
|
485
|
+
const startDirectory = file === root ? root : dirname(file);
|
|
486
|
+
if (!profileContexts.has(startDirectory))
|
|
487
|
+
profileContexts.set(
|
|
488
|
+
startDirectory,
|
|
489
|
+
(async () => {
|
|
490
|
+
const profileResult = await loadApplicationUiProfile({
|
|
491
|
+
workspaceRoot: root,
|
|
492
|
+
startDirectory,
|
|
493
|
+
packageProfile,
|
|
494
|
+
knownRules: [...Object.keys(UI_ANALYSIS_RULES), ...knownRules],
|
|
495
|
+
});
|
|
496
|
+
const entries = await loadCatalogs(profileResult);
|
|
497
|
+
return {
|
|
498
|
+
profileResult,
|
|
499
|
+
facts: catalogFacts(entries),
|
|
500
|
+
startDirectory,
|
|
501
|
+
};
|
|
502
|
+
})(),
|
|
503
|
+
);
|
|
504
|
+
return profileContexts.get(startDirectory);
|
|
505
|
+
};
|
|
506
|
+
const contexts = new Map(
|
|
507
|
+
await Promise.all(
|
|
508
|
+
files.map(async (file) => [file, await loadContext(file)]),
|
|
509
|
+
),
|
|
510
|
+
);
|
|
511
|
+
if (!files.length) await loadContext(root);
|
|
512
|
+
const imports = new Map(
|
|
513
|
+
files.map((file) => [file, relativeStyleImports(file, contents.get(file))]),
|
|
514
|
+
);
|
|
515
|
+
const styleFacts = new Map();
|
|
516
|
+
const recordDiagnostics = (items, context) => {
|
|
517
|
+
for (const item of items) {
|
|
518
|
+
const key = JSON.stringify([
|
|
519
|
+
item.ruleId,
|
|
520
|
+
item.location,
|
|
521
|
+
item.message,
|
|
522
|
+
item.evidence,
|
|
523
|
+
item.chain,
|
|
524
|
+
]);
|
|
525
|
+
const decision = diagnosticDecisions.get(key) ?? {
|
|
526
|
+
item,
|
|
527
|
+
active: false,
|
|
528
|
+
suppressed: false,
|
|
529
|
+
activeConsumers: new Set(),
|
|
530
|
+
suppressedConsumers: new Set(),
|
|
531
|
+
};
|
|
532
|
+
const consumer =
|
|
533
|
+
relative(root, context.startDirectory).replaceAll('\\', '/') || '.';
|
|
534
|
+
if (isSuppressed(item, context.profileResult.profile, root)) {
|
|
535
|
+
decision.suppressed = true;
|
|
536
|
+
decision.suppressedConsumers.add(consumer);
|
|
537
|
+
} else {
|
|
538
|
+
decision.active = true;
|
|
539
|
+
decision.activeConsumers.add(consumer);
|
|
540
|
+
}
|
|
541
|
+
diagnosticDecisions.set(key, decision);
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
for (const file of files.filter((item) => item.endsWith('.css'))) {
|
|
545
|
+
const context = contexts.get(file);
|
|
546
|
+
const fileFacts = new Map();
|
|
547
|
+
await inspectCss(file, contents.get(file), context.facts, [], fileFacts);
|
|
548
|
+
styleFacts.set(file, fileFacts);
|
|
549
|
+
}
|
|
550
|
+
const styleConsumers = new Map(
|
|
551
|
+
[...styleFacts.keys()].map((file) => [file, new Set()]),
|
|
552
|
+
);
|
|
553
|
+
for (const file of files.filter((item) =>
|
|
554
|
+
sourceExtensions.has(extname(item)),
|
|
555
|
+
)) {
|
|
556
|
+
const context = contexts.get(file);
|
|
557
|
+
for (const style of reachableStyleFiles(file, imports, styleFacts))
|
|
558
|
+
styleConsumers.get(style).add(context);
|
|
559
|
+
const fileDiagnostics = [];
|
|
560
|
+
inspectTsx(
|
|
561
|
+
file,
|
|
562
|
+
contents.get(file),
|
|
563
|
+
context.facts,
|
|
564
|
+
reachableStyleFacts(file, imports, styleFacts),
|
|
565
|
+
fileDiagnostics,
|
|
566
|
+
);
|
|
567
|
+
recordDiagnostics(fileDiagnostics, context);
|
|
568
|
+
}
|
|
569
|
+
for (const [file, consumers] of styleConsumers) {
|
|
570
|
+
const applicableContexts = consumers.size
|
|
571
|
+
? consumers
|
|
572
|
+
: new Set([contexts.get(file)]);
|
|
573
|
+
for (const context of applicableContexts) {
|
|
574
|
+
const fileDiagnostics = [];
|
|
575
|
+
await inspectCss(
|
|
576
|
+
file,
|
|
577
|
+
contents.get(file),
|
|
578
|
+
context.facts,
|
|
579
|
+
fileDiagnostics,
|
|
580
|
+
new Map(),
|
|
581
|
+
);
|
|
582
|
+
recordDiagnostics(fileDiagnostics, context);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const active = [...diagnosticDecisions.values()]
|
|
587
|
+
.filter((decision) => decision.active)
|
|
588
|
+
.map((decision) => {
|
|
589
|
+
if (
|
|
590
|
+
!decision.suppressedConsumers.size &&
|
|
591
|
+
decision.activeConsumers.size <= 1
|
|
592
|
+
)
|
|
593
|
+
return decision.item;
|
|
594
|
+
return {
|
|
595
|
+
...decision.item,
|
|
596
|
+
evidence: {
|
|
597
|
+
...decision.item.evidence,
|
|
598
|
+
consumers: {
|
|
599
|
+
active: [...decision.activeConsumers].sort(),
|
|
600
|
+
suppressed: [...decision.suppressedConsumers].sort(),
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
};
|
|
604
|
+
});
|
|
605
|
+
const suppressed = [...diagnosticDecisions.values()].filter(
|
|
606
|
+
(decision) => !decision.active && decision.suppressed,
|
|
607
|
+
).length;
|
|
608
|
+
active.sort(
|
|
609
|
+
(a, b) =>
|
|
610
|
+
a.location.file.localeCompare(b.location.file) ||
|
|
611
|
+
a.location.line - b.location.line ||
|
|
612
|
+
a.ruleId.localeCompare(b.ruleId),
|
|
613
|
+
);
|
|
614
|
+
const portablePath = (file) => {
|
|
615
|
+
if (!file || !isAbsolute(file)) return file;
|
|
616
|
+
const path = relative(root, file).replaceAll('\\', '/');
|
|
617
|
+
return path === '..' || path.startsWith('../')
|
|
618
|
+
? `<external>/${basename(file)}`
|
|
619
|
+
: path;
|
|
620
|
+
};
|
|
621
|
+
const resolvedContexts = await Promise.all(profileContexts.values());
|
|
622
|
+
const profileFiles = [
|
|
623
|
+
...new Set(
|
|
624
|
+
resolvedContexts.flatMap(
|
|
625
|
+
({ profileResult }) => profileResult.files ?? [],
|
|
626
|
+
),
|
|
627
|
+
),
|
|
628
|
+
];
|
|
629
|
+
const profilePackageRoot = dirname(profileFiles[0] ?? root);
|
|
630
|
+
const portableMessage = (message) =>
|
|
631
|
+
String(message)
|
|
632
|
+
.replaceAll(root, '<repo-root>')
|
|
633
|
+
.replaceAll(profilePackageRoot, '<package-root>');
|
|
634
|
+
const profileDiagnostics = [
|
|
635
|
+
...new Map(
|
|
636
|
+
resolvedContexts
|
|
637
|
+
.flatMap(({ profileResult }) => profileResult.diagnostics ?? [])
|
|
638
|
+
.map((item) => [
|
|
639
|
+
JSON.stringify([item.code, item.source, item.path, item.message]),
|
|
640
|
+
{
|
|
641
|
+
...item,
|
|
642
|
+
source: portablePath(item.source),
|
|
643
|
+
message: portableMessage(item.message),
|
|
644
|
+
},
|
|
645
|
+
]),
|
|
646
|
+
).values(),
|
|
647
|
+
];
|
|
648
|
+
return {
|
|
649
|
+
schemaVersion: UI_ANALYSIS_SCHEMA_VERSION,
|
|
650
|
+
tool: { name: '@kerfjs/ui analyzer', version: 1 },
|
|
651
|
+
root: '.',
|
|
652
|
+
files: files.map((file) => relative(root, file).replaceAll('\\', '/')),
|
|
653
|
+
profile: {
|
|
654
|
+
files: profileFiles.map(portablePath),
|
|
655
|
+
diagnostics: profileDiagnostics,
|
|
656
|
+
},
|
|
657
|
+
diagnostics: active.map((item) => ({
|
|
658
|
+
...item,
|
|
659
|
+
location: {
|
|
660
|
+
...item.location,
|
|
661
|
+
file: portablePath(item.location.file),
|
|
662
|
+
},
|
|
663
|
+
})),
|
|
664
|
+
summary: {
|
|
665
|
+
errors:
|
|
666
|
+
active.filter((item) => item.severity === 'error').length +
|
|
667
|
+
profileDiagnostics.length,
|
|
668
|
+
review: active.filter((item) => item.severity === 'review').length,
|
|
669
|
+
suppressed,
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
export function formatUiAnalysisText(report) {
|
|
675
|
+
const lines = report.profile.diagnostics.map(
|
|
676
|
+
(item) =>
|
|
677
|
+
`${item.source ?? '<profile>'}:1:1 error ${item.code} ${item.message}`,
|
|
678
|
+
);
|
|
679
|
+
lines.push(
|
|
680
|
+
...report.diagnostics.map(
|
|
681
|
+
(item) =>
|
|
682
|
+
`${item.location.file}:${item.location.line}:${item.location.column} ${item.severity} ${item.ruleId} ${item.message}`,
|
|
683
|
+
),
|
|
684
|
+
);
|
|
685
|
+
lines.push(
|
|
686
|
+
`Kerf UI analysis: ${report.summary.errors} error(s), ${report.summary.review} review finding(s), ${report.summary.suppressed} suppressed.`,
|
|
687
|
+
);
|
|
688
|
+
return lines.join('\n');
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
export function formatUiAnalysisSarif(report) {
|
|
692
|
+
return {
|
|
693
|
+
version: '2.1.0',
|
|
694
|
+
$schema: 'https://json.schemastore.org/sarif-2.1.0.json',
|
|
695
|
+
runs: [
|
|
696
|
+
{
|
|
697
|
+
tool: {
|
|
698
|
+
driver: {
|
|
699
|
+
name: report.tool.name,
|
|
700
|
+
version: String(report.tool.version),
|
|
701
|
+
rules: Object.entries(UI_ANALYSIS_RULES).map(([id, rule]) => ({
|
|
702
|
+
id,
|
|
703
|
+
shortDescription: { text: rule.title },
|
|
704
|
+
})),
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
results: [
|
|
708
|
+
...report.profile.diagnostics.map((item) => ({
|
|
709
|
+
ruleId: item.code,
|
|
710
|
+
level: 'error',
|
|
711
|
+
message: { text: item.message },
|
|
712
|
+
locations: [
|
|
713
|
+
{
|
|
714
|
+
physicalLocation: {
|
|
715
|
+
artifactLocation: { uri: item.source ?? '<profile>' },
|
|
716
|
+
region: { startLine: 1, startColumn: 1 },
|
|
717
|
+
},
|
|
718
|
+
},
|
|
719
|
+
],
|
|
720
|
+
properties: { path: item.path },
|
|
721
|
+
})),
|
|
722
|
+
...report.diagnostics.map((item) => ({
|
|
723
|
+
ruleId: item.ruleId,
|
|
724
|
+
level: item.severity === 'error' ? 'error' : 'warning',
|
|
725
|
+
message: { text: item.message },
|
|
726
|
+
locations: [
|
|
727
|
+
{
|
|
728
|
+
physicalLocation: {
|
|
729
|
+
artifactLocation: { uri: item.location.file },
|
|
730
|
+
region: {
|
|
731
|
+
startLine: item.location.line,
|
|
732
|
+
startColumn: item.location.column,
|
|
733
|
+
},
|
|
734
|
+
},
|
|
735
|
+
},
|
|
736
|
+
],
|
|
737
|
+
properties: { evidence: item.evidence, chain: item.chain },
|
|
738
|
+
})),
|
|
739
|
+
],
|
|
740
|
+
},
|
|
741
|
+
],
|
|
742
|
+
};
|
|
743
|
+
}
|