@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
package/doctor/index.mjs
ADDED
|
@@ -0,0 +1,1256 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import {
|
|
3
|
+
access,
|
|
4
|
+
mkdir,
|
|
5
|
+
readFile,
|
|
6
|
+
readdir,
|
|
7
|
+
rename,
|
|
8
|
+
stat,
|
|
9
|
+
writeFile,
|
|
10
|
+
} from 'node:fs/promises';
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
extname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
relative,
|
|
17
|
+
resolve,
|
|
18
|
+
} from 'node:path';
|
|
19
|
+
import process from 'node:process';
|
|
20
|
+
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
21
|
+
import { createRequire } from 'node:module';
|
|
22
|
+
|
|
23
|
+
import { loadApplicationUiProfile } from '../ai/application-ui-profile.mjs';
|
|
24
|
+
import { analyzeUiProject, UI_ANALYSIS_RULES } from '../analyzer/index.mjs';
|
|
25
|
+
import { evaluateUi, UI_EVALUATION_RULES } from '../evaluator/index.mjs';
|
|
26
|
+
|
|
27
|
+
export const UI_DOCTOR_SCHEMA_VERSION = 1;
|
|
28
|
+
export const UI_DOCTOR_EXIT = Object.freeze({
|
|
29
|
+
clean: 0,
|
|
30
|
+
findings: 1,
|
|
31
|
+
configuration: 2,
|
|
32
|
+
cancelled: 130,
|
|
33
|
+
});
|
|
34
|
+
export const UI_DOCTOR_RULES = Object.freeze({
|
|
35
|
+
'KUI-D001': { severity: 'error', title: 'Doctor configuration is invalid' },
|
|
36
|
+
'KUI-D002': { severity: 'error', title: 'Doctor run was cancelled' },
|
|
37
|
+
'KUI-D003': { severity: 'warning', title: 'Diagnostic identifier conflict' },
|
|
38
|
+
'KUI-D010': { severity: 'error', title: 'Required tool is unavailable' },
|
|
39
|
+
'KUI-D011': { severity: 'error', title: 'Tool execution failed' },
|
|
40
|
+
'KUI-D020': {
|
|
41
|
+
severity: 'error',
|
|
42
|
+
title: 'Local component catalog is stale or invalid',
|
|
43
|
+
},
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const sourceExtensions = new Set([
|
|
47
|
+
'.js',
|
|
48
|
+
'.jsx',
|
|
49
|
+
'.mjs',
|
|
50
|
+
'.cjs',
|
|
51
|
+
'.ts',
|
|
52
|
+
'.tsx',
|
|
53
|
+
'.mts',
|
|
54
|
+
'.cts',
|
|
55
|
+
'.css',
|
|
56
|
+
]);
|
|
57
|
+
const ignoredDirectories = new Set([
|
|
58
|
+
'.git',
|
|
59
|
+
'.kerf-cache',
|
|
60
|
+
'coverage',
|
|
61
|
+
'dist',
|
|
62
|
+
'kerf-ui-evidence',
|
|
63
|
+
'node_modules',
|
|
64
|
+
]);
|
|
65
|
+
const defaultStages = Object.freeze({
|
|
66
|
+
catalog: true,
|
|
67
|
+
typescript: true,
|
|
68
|
+
eslint: true,
|
|
69
|
+
analyzer: true,
|
|
70
|
+
browser: false,
|
|
71
|
+
});
|
|
72
|
+
const exists = async (path) => {
|
|
73
|
+
try {
|
|
74
|
+
await access(path);
|
|
75
|
+
return true;
|
|
76
|
+
} catch {
|
|
77
|
+
return false;
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
function portablePath(root, value) {
|
|
82
|
+
if (!value || !isAbsolute(value)) return value;
|
|
83
|
+
const path = relative(root, value).replaceAll('\\', '/');
|
|
84
|
+
return path === '..' || path.startsWith('../')
|
|
85
|
+
? `<external>/${basename(value)}`
|
|
86
|
+
: path || '.';
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function redact(root, value) {
|
|
90
|
+
if (typeof value === 'string') return value.replaceAll(root, '<repo-root>');
|
|
91
|
+
if (Array.isArray(value)) return value.map((item) => redact(root, item));
|
|
92
|
+
if (value && typeof value === 'object')
|
|
93
|
+
return Object.fromEntries(
|
|
94
|
+
Object.entries(value).map(([key, item]) => [key, redact(root, item)]),
|
|
95
|
+
);
|
|
96
|
+
return value;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function configDiagnostic(message, location = {}) {
|
|
100
|
+
return {
|
|
101
|
+
id: 'KUI-D001',
|
|
102
|
+
severity: 'error',
|
|
103
|
+
stage: 'doctor',
|
|
104
|
+
message,
|
|
105
|
+
location: {
|
|
106
|
+
file: location.file ?? '.kerf-ui-doctor.json',
|
|
107
|
+
line: location.line ?? 1,
|
|
108
|
+
column: location.column ?? 1,
|
|
109
|
+
path: location.path ?? '$',
|
|
110
|
+
},
|
|
111
|
+
action: 'Correct the doctor configuration and run kerf-ui-doctor again.',
|
|
112
|
+
meaning: UI_DOCTOR_RULES['KUI-D001'].title,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function validateUiDoctorConfig(
|
|
117
|
+
config,
|
|
118
|
+
source = '.kerf-ui-doctor.json',
|
|
119
|
+
) {
|
|
120
|
+
const diagnostics = [];
|
|
121
|
+
const add = (path, message) =>
|
|
122
|
+
diagnostics.push(configDiagnostic(message, { file: source, path }));
|
|
123
|
+
if (!config || typeof config !== 'object' || Array.isArray(config))
|
|
124
|
+
return [configDiagnostic('Expected a JSON object.', { file: source })];
|
|
125
|
+
const allowed = new Set([
|
|
126
|
+
'$schema',
|
|
127
|
+
'schemaVersion',
|
|
128
|
+
'mode',
|
|
129
|
+
'package',
|
|
130
|
+
'stages',
|
|
131
|
+
'browser',
|
|
132
|
+
'cache',
|
|
133
|
+
'suppressions',
|
|
134
|
+
]);
|
|
135
|
+
for (const key of Object.keys(config))
|
|
136
|
+
if (!allowed.has(key)) add(`$.${key}`, 'Unknown configuration property.');
|
|
137
|
+
if (config.$schema !== undefined && typeof config.$schema !== 'string')
|
|
138
|
+
add('$.$schema', '$schema must be a string.');
|
|
139
|
+
if (config.schemaVersion !== 1)
|
|
140
|
+
add('$.schemaVersion', 'schemaVersion must be 1.');
|
|
141
|
+
if (config.mode !== undefined && !['full', 'changed'].includes(config.mode))
|
|
142
|
+
add('$.mode', 'mode must be full or changed.');
|
|
143
|
+
if (
|
|
144
|
+
config.package !== undefined &&
|
|
145
|
+
(typeof config.package !== 'string' || !config.package)
|
|
146
|
+
)
|
|
147
|
+
add(
|
|
148
|
+
'$.package',
|
|
149
|
+
'package must be a non-empty workspace package name or path.',
|
|
150
|
+
);
|
|
151
|
+
if (config.stages !== undefined) {
|
|
152
|
+
if (
|
|
153
|
+
!config.stages ||
|
|
154
|
+
typeof config.stages !== 'object' ||
|
|
155
|
+
Array.isArray(config.stages)
|
|
156
|
+
)
|
|
157
|
+
add('$.stages', 'stages must be an object.');
|
|
158
|
+
else
|
|
159
|
+
for (const [name, enabled] of Object.entries(config.stages)) {
|
|
160
|
+
if (!(name in defaultStages)) add(`$.stages.${name}`, 'Unknown stage.');
|
|
161
|
+
else if (typeof enabled !== 'boolean')
|
|
162
|
+
add(`$.stages.${name}`, 'Stage values must be boolean.');
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (config.browser !== undefined) {
|
|
166
|
+
if (
|
|
167
|
+
!config.browser ||
|
|
168
|
+
typeof config.browser !== 'object' ||
|
|
169
|
+
Array.isArray(config.browser)
|
|
170
|
+
)
|
|
171
|
+
add('$.browser', 'browser must be an object.');
|
|
172
|
+
else {
|
|
173
|
+
const browserKeys = new Set([
|
|
174
|
+
'url',
|
|
175
|
+
'browsers',
|
|
176
|
+
'retention',
|
|
177
|
+
'outputDirectory',
|
|
178
|
+
'reportPath',
|
|
179
|
+
]);
|
|
180
|
+
for (const key of Object.keys(config.browser))
|
|
181
|
+
if (!browserKeys.has(key))
|
|
182
|
+
add(`$.browser.${key}`, 'Unknown browser property.');
|
|
183
|
+
if (
|
|
184
|
+
typeof config.browser.url !== 'string' ||
|
|
185
|
+
!/^https?:\/\//.test(config.browser.url)
|
|
186
|
+
)
|
|
187
|
+
add('$.browser.url', 'browser.url must be an explicit http(s) URL.');
|
|
188
|
+
if (
|
|
189
|
+
config.browser.browsers !== undefined &&
|
|
190
|
+
(!Array.isArray(config.browser.browsers) ||
|
|
191
|
+
config.browser.browsers.some(
|
|
192
|
+
(item) => !['chromium', 'firefox', 'webkit'].includes(item),
|
|
193
|
+
))
|
|
194
|
+
)
|
|
195
|
+
add(
|
|
196
|
+
'$.browser.browsers',
|
|
197
|
+
'browser.browsers must contain only chromium, firefox, or webkit.',
|
|
198
|
+
);
|
|
199
|
+
if (
|
|
200
|
+
config.browser.retention !== undefined &&
|
|
201
|
+
!['always', 'on-failure', 'never'].includes(config.browser.retention)
|
|
202
|
+
)
|
|
203
|
+
add(
|
|
204
|
+
'$.browser.retention',
|
|
205
|
+
'browser.retention must be always, on-failure, or never.',
|
|
206
|
+
);
|
|
207
|
+
for (const key of ['outputDirectory', 'reportPath'])
|
|
208
|
+
if (
|
|
209
|
+
config.browser[key] !== undefined &&
|
|
210
|
+
(typeof config.browser[key] !== 'string' ||
|
|
211
|
+
!config.browser[key] ||
|
|
212
|
+
isAbsolute(config.browser[key]) ||
|
|
213
|
+
config.browser[key].split(/[\\/]/).includes('..'))
|
|
214
|
+
)
|
|
215
|
+
add(
|
|
216
|
+
`$.browser.${key}`,
|
|
217
|
+
`${key} must be a portable workspace-relative path.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
if (config.cache !== undefined && typeof config.cache !== 'boolean')
|
|
222
|
+
add('$.cache', 'cache must be boolean.');
|
|
223
|
+
if (config.suppressions !== undefined) {
|
|
224
|
+
if (!Array.isArray(config.suppressions))
|
|
225
|
+
add('$.suppressions', 'suppressions must be an array.');
|
|
226
|
+
else {
|
|
227
|
+
const ids = new Set();
|
|
228
|
+
config.suppressions.forEach((item, index) => {
|
|
229
|
+
const at = `$.suppressions[${index}]`;
|
|
230
|
+
if (!item || typeof item !== 'object' || Array.isArray(item))
|
|
231
|
+
return add(at, 'Suppression must be an object.');
|
|
232
|
+
if (typeof item.id !== 'string' || !/^[a-z][a-z0-9-]*$/.test(item.id))
|
|
233
|
+
add(
|
|
234
|
+
`${at}.id`,
|
|
235
|
+
'Suppression id must be a stable lowercase identifier.',
|
|
236
|
+
);
|
|
237
|
+
else if (ids.has(item.id))
|
|
238
|
+
add(`${at}.id`, 'Suppression ids must be unique.');
|
|
239
|
+
else ids.add(item.id);
|
|
240
|
+
if (
|
|
241
|
+
!Array.isArray(item.rules) ||
|
|
242
|
+
!item.rules.length ||
|
|
243
|
+
item.rules.some((rule) => typeof rule !== 'string')
|
|
244
|
+
)
|
|
245
|
+
add(
|
|
246
|
+
`${at}.rules`,
|
|
247
|
+
'Suppression rules must be a non-empty string array.',
|
|
248
|
+
);
|
|
249
|
+
else if (new Set(item.rules).size !== item.rules.length)
|
|
250
|
+
add(`${at}.rules`, 'Suppression rules must be unique.');
|
|
251
|
+
if (
|
|
252
|
+
typeof item.target !== 'string' ||
|
|
253
|
+
!item.target ||
|
|
254
|
+
isAbsolute(item.target) ||
|
|
255
|
+
item.target.includes('..') ||
|
|
256
|
+
/[*?]/.test(item.target)
|
|
257
|
+
)
|
|
258
|
+
add(
|
|
259
|
+
`${at}.target`,
|
|
260
|
+
'Suppression target must be an exact portable relative path or browser selector.',
|
|
261
|
+
);
|
|
262
|
+
if (
|
|
263
|
+
typeof item.rationale !== 'string' ||
|
|
264
|
+
item.rationale.trim().length < 12
|
|
265
|
+
)
|
|
266
|
+
add(
|
|
267
|
+
`${at}.rationale`,
|
|
268
|
+
'Suppression rationale must contain at least 12 characters.',
|
|
269
|
+
);
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
return diagnostics;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
export async function readUiDoctorConfig(
|
|
277
|
+
root,
|
|
278
|
+
path = resolve(root, '.kerf-ui-doctor.json'),
|
|
279
|
+
) {
|
|
280
|
+
if (!(await exists(path)))
|
|
281
|
+
return { path, config: { schemaVersion: 1 }, diagnostics: [] };
|
|
282
|
+
try {
|
|
283
|
+
const config = JSON.parse(await readFile(path, 'utf8'));
|
|
284
|
+
return {
|
|
285
|
+
path,
|
|
286
|
+
config,
|
|
287
|
+
diagnostics: validateUiDoctorConfig(config, portablePath(root, path)),
|
|
288
|
+
};
|
|
289
|
+
} catch (error) {
|
|
290
|
+
return {
|
|
291
|
+
path,
|
|
292
|
+
config: {},
|
|
293
|
+
diagnostics: [
|
|
294
|
+
configDiagnostic(`Invalid JSON: ${error.message}`, {
|
|
295
|
+
file: portablePath(root, path),
|
|
296
|
+
}),
|
|
297
|
+
],
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function workspacePackages(root) {
|
|
303
|
+
let manifest;
|
|
304
|
+
try {
|
|
305
|
+
manifest = JSON.parse(
|
|
306
|
+
await readFile(resolve(root, 'package.json'), 'utf8'),
|
|
307
|
+
);
|
|
308
|
+
} catch {
|
|
309
|
+
return [];
|
|
310
|
+
}
|
|
311
|
+
const patterns = Array.isArray(manifest.workspaces)
|
|
312
|
+
? manifest.workspaces
|
|
313
|
+
: (manifest.workspaces?.packages ?? []);
|
|
314
|
+
const directories = [];
|
|
315
|
+
for (const pattern of patterns) {
|
|
316
|
+
if (typeof pattern !== 'string') continue;
|
|
317
|
+
if (!pattern.includes('*')) directories.push(resolve(root, pattern));
|
|
318
|
+
else {
|
|
319
|
+
const [prefix, suffix = ''] = pattern.split('*');
|
|
320
|
+
const parent = resolve(root, prefix);
|
|
321
|
+
try {
|
|
322
|
+
for (const entry of await readdir(parent, { withFileTypes: true }))
|
|
323
|
+
if (entry.isDirectory())
|
|
324
|
+
directories.push(resolve(parent, entry.name, suffix));
|
|
325
|
+
} catch {
|
|
326
|
+
/* A declared workspace glob may currently be empty. */
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
return Promise.all(
|
|
331
|
+
directories.map(async (directory) => {
|
|
332
|
+
try {
|
|
333
|
+
return {
|
|
334
|
+
directory,
|
|
335
|
+
manifest: JSON.parse(
|
|
336
|
+
await readFile(resolve(directory, 'package.json'), 'utf8'),
|
|
337
|
+
),
|
|
338
|
+
};
|
|
339
|
+
} catch {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
}),
|
|
343
|
+
).then((items) => items.filter(Boolean));
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
export async function resolveUiDoctorPackage(root, selector) {
|
|
347
|
+
if (!selector) return root;
|
|
348
|
+
const direct = resolve(root, selector);
|
|
349
|
+
if (
|
|
350
|
+
(await exists(resolve(direct, 'package.json'))) &&
|
|
351
|
+
!relative(root, direct).startsWith('..')
|
|
352
|
+
)
|
|
353
|
+
return direct;
|
|
354
|
+
const match = (await workspacePackages(root)).find(
|
|
355
|
+
({ manifest }) => manifest.name === selector,
|
|
356
|
+
);
|
|
357
|
+
if (!match) throw new Error(`Workspace package ${selector} was not found.`);
|
|
358
|
+
return match.directory;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
async function collectInputs(root, paths) {
|
|
362
|
+
const result = [];
|
|
363
|
+
const visit = async (path) => {
|
|
364
|
+
let details;
|
|
365
|
+
try {
|
|
366
|
+
details = await stat(path);
|
|
367
|
+
} catch {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
if (details.isFile()) {
|
|
371
|
+
if (
|
|
372
|
+
sourceExtensions.has(extname(path)) ||
|
|
373
|
+
extname(path) === '.json' ||
|
|
374
|
+
basename(path).startsWith('.kerf-ui-') ||
|
|
375
|
+
['package-lock.json', 'pnpm-lock.yaml', 'yarn.lock'].includes(
|
|
376
|
+
basename(path),
|
|
377
|
+
) ||
|
|
378
|
+
basename(path).startsWith('tsconfig')
|
|
379
|
+
)
|
|
380
|
+
result.push(path);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
for (const entry of await readdir(path, { withFileTypes: true })) {
|
|
384
|
+
if (entry.isDirectory() && ignoredDirectories.has(entry.name)) continue;
|
|
385
|
+
await visit(resolve(path, entry.name));
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
for (const path of paths?.length ? paths : [root])
|
|
389
|
+
await visit(resolve(root, path));
|
|
390
|
+
return result.sort();
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
async function installedPackageVersion(root, name) {
|
|
394
|
+
try {
|
|
395
|
+
const require = createRequire(resolve(root, 'package.json'));
|
|
396
|
+
let directory = dirname(require.resolve(name));
|
|
397
|
+
while (true) {
|
|
398
|
+
const manifestPath = resolve(directory, 'package.json');
|
|
399
|
+
if (await exists(manifestPath)) {
|
|
400
|
+
const manifest = JSON.parse(await readFile(manifestPath, 'utf8'));
|
|
401
|
+
if (manifest.name === name) return manifest.version ?? 'unknown';
|
|
402
|
+
}
|
|
403
|
+
const parent = dirname(directory);
|
|
404
|
+
if (parent === directory) break;
|
|
405
|
+
directory = parent;
|
|
406
|
+
}
|
|
407
|
+
} catch {
|
|
408
|
+
return 'unavailable';
|
|
409
|
+
}
|
|
410
|
+
return 'unavailable';
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
async function cacheKey(root, packageRoot, mode, paths, config) {
|
|
414
|
+
const files = await collectInputs(packageRoot, paths);
|
|
415
|
+
for (const directory of new Set([root, packageRoot])) {
|
|
416
|
+
for (const name of [
|
|
417
|
+
'package.json',
|
|
418
|
+
'package-lock.json',
|
|
419
|
+
'pnpm-lock.yaml',
|
|
420
|
+
'yarn.lock',
|
|
421
|
+
'tsconfig.json',
|
|
422
|
+
'.kerf-ui-profile.json',
|
|
423
|
+
'.kerf-ui-doctor.json',
|
|
424
|
+
]) {
|
|
425
|
+
const path = resolve(directory, name);
|
|
426
|
+
if (!files.includes(path) && (await exists(path))) files.push(path);
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
files.sort();
|
|
430
|
+
const toolVersions = Object.fromEntries(
|
|
431
|
+
await Promise.all(
|
|
432
|
+
[
|
|
433
|
+
'typescript',
|
|
434
|
+
'eslint',
|
|
435
|
+
'eslint-plugin-kerfjs',
|
|
436
|
+
'create-kerf-component',
|
|
437
|
+
].map(async (name) => [
|
|
438
|
+
name,
|
|
439
|
+
await installedPackageVersion(packageRoot, name),
|
|
440
|
+
]),
|
|
441
|
+
),
|
|
442
|
+
);
|
|
443
|
+
const hash = createHash('sha256')
|
|
444
|
+
.update(
|
|
445
|
+
JSON.stringify({
|
|
446
|
+
schema: 1,
|
|
447
|
+
package: portablePath(root, packageRoot),
|
|
448
|
+
mode,
|
|
449
|
+
paths,
|
|
450
|
+
config,
|
|
451
|
+
toolVersions,
|
|
452
|
+
}),
|
|
453
|
+
)
|
|
454
|
+
.update(await readFile(fileURLToPath(import.meta.url)));
|
|
455
|
+
for (const file of files)
|
|
456
|
+
hash.update(portablePath(root, file)).update(await readFile(file));
|
|
457
|
+
return hash.digest('hex');
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function readCache(path, key) {
|
|
461
|
+
try {
|
|
462
|
+
const value = JSON.parse(await readFile(path, 'utf8'));
|
|
463
|
+
return value.key === key ? value.report : undefined;
|
|
464
|
+
} catch {
|
|
465
|
+
return undefined;
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
async function writeCache(path, key, report) {
|
|
470
|
+
await mkdir(dirname(path), { recursive: true });
|
|
471
|
+
const temporary = `${path}.${process.pid}.tmp`;
|
|
472
|
+
await writeFile(temporary, `${JSON.stringify({ key, report }, null, 2)}\n`);
|
|
473
|
+
await rename(temporary, path);
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
function normalizedDiagnostic(item) {
|
|
477
|
+
const diagnostic = {
|
|
478
|
+
id: item.id,
|
|
479
|
+
severity: item.severity,
|
|
480
|
+
stage: item.stage,
|
|
481
|
+
message: item.message,
|
|
482
|
+
};
|
|
483
|
+
for (const key of [
|
|
484
|
+
'location',
|
|
485
|
+
'dom',
|
|
486
|
+
'evidence',
|
|
487
|
+
'catalogFacts',
|
|
488
|
+
'documentation',
|
|
489
|
+
'action',
|
|
490
|
+
'sources',
|
|
491
|
+
'meaning',
|
|
492
|
+
])
|
|
493
|
+
if (item[key] !== undefined) diagnostic[key] = item[key];
|
|
494
|
+
if (!diagnostic.documentation) {
|
|
495
|
+
const guides = {
|
|
496
|
+
catalog: '@kerfjs/ui/ai/application-ui-profile.schema.json',
|
|
497
|
+
typescript: 'https://www.typescriptlang.org/tsconfig/',
|
|
498
|
+
eslint: 'eslint-plugin-kerfjs/README.md',
|
|
499
|
+
analyzer: '@kerfjs/ui/docs/ui-analyzer.md',
|
|
500
|
+
browser: '@kerfjs/ui/docs/ui-evaluator.md',
|
|
501
|
+
doctor: '@kerfjs/ui/docs/ui-doctor.md',
|
|
502
|
+
merged: '@kerfjs/ui/docs/ui-doctor.md#report-and-exit-contract',
|
|
503
|
+
};
|
|
504
|
+
diagnostic.documentation = guides[item.stage];
|
|
505
|
+
}
|
|
506
|
+
return diagnostic;
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
function normalizeProfile(item, root) {
|
|
510
|
+
return normalizedDiagnostic({
|
|
511
|
+
id: item.code,
|
|
512
|
+
severity: 'error',
|
|
513
|
+
stage: 'catalog',
|
|
514
|
+
message: item.message,
|
|
515
|
+
location: {
|
|
516
|
+
file: portablePath(root, item.source),
|
|
517
|
+
line: 1,
|
|
518
|
+
column: 1,
|
|
519
|
+
path: item.path,
|
|
520
|
+
},
|
|
521
|
+
action:
|
|
522
|
+
'Repair the source-located application UI profile or catalog artifact.',
|
|
523
|
+
meaning: item.code,
|
|
524
|
+
});
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function normalizeAnalyzer(report) {
|
|
528
|
+
return [
|
|
529
|
+
...report.profile.diagnostics.map((item) =>
|
|
530
|
+
normalizedDiagnostic({
|
|
531
|
+
id: item.code,
|
|
532
|
+
severity: 'error',
|
|
533
|
+
stage: 'analyzer',
|
|
534
|
+
message: item.message,
|
|
535
|
+
location: { file: item.source, line: 1, column: 1, path: item.path },
|
|
536
|
+
action: 'Repair the application UI profile before layout analysis.',
|
|
537
|
+
meaning: item.code,
|
|
538
|
+
}),
|
|
539
|
+
),
|
|
540
|
+
...report.diagnostics.map((item) =>
|
|
541
|
+
normalizedDiagnostic({
|
|
542
|
+
id: item.ruleId,
|
|
543
|
+
severity: item.severity,
|
|
544
|
+
stage: 'analyzer',
|
|
545
|
+
message: item.message,
|
|
546
|
+
location: item.location,
|
|
547
|
+
evidence: item.evidence,
|
|
548
|
+
catalogFacts: item.chain,
|
|
549
|
+
action:
|
|
550
|
+
'Apply the catalog-declared composition or geometry boundary at this source location.',
|
|
551
|
+
meaning: UI_ANALYSIS_RULES[item.ruleId]?.title ?? item.ruleId,
|
|
552
|
+
}),
|
|
553
|
+
),
|
|
554
|
+
];
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
function normalizeEvaluator(report) {
|
|
558
|
+
return report.diagnostics.map((item) =>
|
|
559
|
+
normalizedDiagnostic({
|
|
560
|
+
id: item.code,
|
|
561
|
+
severity: item.severity,
|
|
562
|
+
stage: 'browser',
|
|
563
|
+
message: item.message,
|
|
564
|
+
dom: { context: item.context, selector: item.selector ?? '<page>' },
|
|
565
|
+
evidence: item.evidence,
|
|
566
|
+
action: item.repair,
|
|
567
|
+
meaning: UI_EVALUATION_RULES[item.code]?.title ?? item.code,
|
|
568
|
+
}),
|
|
569
|
+
);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
function mergeDiagnostics(items) {
|
|
573
|
+
const merged = [];
|
|
574
|
+
const exact = new Map();
|
|
575
|
+
const meanings = new Map();
|
|
576
|
+
for (const raw of items) {
|
|
577
|
+
const item = normalizedDiagnostic(raw);
|
|
578
|
+
const location = item.location
|
|
579
|
+
? JSON.stringify(item.location)
|
|
580
|
+
: JSON.stringify(item.dom ?? {});
|
|
581
|
+
const key = JSON.stringify([
|
|
582
|
+
item.id,
|
|
583
|
+
item.severity,
|
|
584
|
+
item.message,
|
|
585
|
+
location,
|
|
586
|
+
]);
|
|
587
|
+
if (exact.has(key)) {
|
|
588
|
+
const previous = exact.get(key);
|
|
589
|
+
previous.sources = [
|
|
590
|
+
...new Set([...(previous.sources ?? [previous.stage]), item.stage]),
|
|
591
|
+
].sort();
|
|
592
|
+
previous.stage = 'merged';
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
exact.set(key, item);
|
|
596
|
+
merged.push(item);
|
|
597
|
+
const signature = JSON.stringify([
|
|
598
|
+
item.severity,
|
|
599
|
+
item.meaning ?? item.message,
|
|
600
|
+
]);
|
|
601
|
+
const previous = meanings.get(item.id);
|
|
602
|
+
if (previous && previous !== signature)
|
|
603
|
+
merged.push(
|
|
604
|
+
normalizedDiagnostic({
|
|
605
|
+
id: 'KUI-D003',
|
|
606
|
+
severity: 'warning',
|
|
607
|
+
stage: 'doctor',
|
|
608
|
+
message: `${item.id} was emitted with conflicting severity or meaning; both findings were retained.`,
|
|
609
|
+
evidence: { diagnosticId: item.id },
|
|
610
|
+
action:
|
|
611
|
+
'Upgrade the conflicting tool versions so diagnostic identifiers have one stable meaning.',
|
|
612
|
+
}),
|
|
613
|
+
);
|
|
614
|
+
else meanings.set(item.id, signature);
|
|
615
|
+
}
|
|
616
|
+
for (const item of merged) delete item.meaning;
|
|
617
|
+
return merged.sort(
|
|
618
|
+
(left, right) =>
|
|
619
|
+
(left.location?.file ?? left.dom?.selector ?? '').localeCompare(
|
|
620
|
+
right.location?.file ?? right.dom?.selector ?? '',
|
|
621
|
+
) ||
|
|
622
|
+
(left.location?.line ?? 0) - (right.location?.line ?? 0) ||
|
|
623
|
+
left.id.localeCompare(right.id) ||
|
|
624
|
+
left.message.localeCompare(right.message),
|
|
625
|
+
);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function applySuppressions(diagnostics, suppressions) {
|
|
629
|
+
const active = [],
|
|
630
|
+
suppressed = [];
|
|
631
|
+
for (const item of diagnostics) {
|
|
632
|
+
const target = item.location?.file ?? item.dom?.selector;
|
|
633
|
+
const suppression = suppressions.find(
|
|
634
|
+
(candidate) =>
|
|
635
|
+
candidate.rules.includes(item.id) && candidate.target === target,
|
|
636
|
+
);
|
|
637
|
+
if (suppression)
|
|
638
|
+
suppressed.push({
|
|
639
|
+
...item,
|
|
640
|
+
suppression: { id: suppression.id, rationale: suppression.rationale },
|
|
641
|
+
});
|
|
642
|
+
else active.push(item);
|
|
643
|
+
}
|
|
644
|
+
return { active, suppressed };
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
function unavailable(stage, error) {
|
|
648
|
+
return normalizedDiagnostic({
|
|
649
|
+
id: 'KUI-D010',
|
|
650
|
+
severity: 'error',
|
|
651
|
+
stage,
|
|
652
|
+
message: `${stage} is unavailable: ${error.message}`,
|
|
653
|
+
action: `Install and configure the ${stage} tool in the selected workspace package.`,
|
|
654
|
+
meaning: UI_DOCTOR_RULES['KUI-D010'].title,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
async function importFrom(root, specifier) {
|
|
659
|
+
const require = createRequire(resolve(root, 'package.json'));
|
|
660
|
+
return import(pathToFileURL(require.resolve(specifier)).href);
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async function installedEslintRuleIds(packageRoot) {
|
|
664
|
+
try {
|
|
665
|
+
const module = await importFrom(packageRoot, 'eslint-plugin-kerfjs');
|
|
666
|
+
const plugin = module.default ?? module;
|
|
667
|
+
const ids = new Set();
|
|
668
|
+
for (const rule of Object.values(plugin.rules ?? {}))
|
|
669
|
+
for (const template of Object.values(rule.meta?.messages ?? {}))
|
|
670
|
+
for (const match of String(template).matchAll(/\bKUI-[A-Z]\d{3}\b/g))
|
|
671
|
+
ids.add(match[0]);
|
|
672
|
+
return [...ids];
|
|
673
|
+
} catch {
|
|
674
|
+
return [];
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
async function sharedDiagnosticRuleIds() {
|
|
679
|
+
const registry = JSON.parse(
|
|
680
|
+
await readFile(
|
|
681
|
+
resolve(
|
|
682
|
+
import.meta.dirname,
|
|
683
|
+
'../ai/application-ui-diagnostic-ids-v1.json',
|
|
684
|
+
),
|
|
685
|
+
'utf8',
|
|
686
|
+
),
|
|
687
|
+
);
|
|
688
|
+
if (registry.schemaVersion !== 1 || !Array.isArray(registry.ids))
|
|
689
|
+
throw new Error(
|
|
690
|
+
'The packaged application UI diagnostic registry must have schemaVersion 1 and an ids array.',
|
|
691
|
+
);
|
|
692
|
+
return registry.ids;
|
|
693
|
+
}
|
|
694
|
+
|
|
695
|
+
async function hasLocalCatalogConfiguration(root) {
|
|
696
|
+
const manifests = [
|
|
697
|
+
root,
|
|
698
|
+
...(await workspacePackages(root)).map((item) => item.directory),
|
|
699
|
+
];
|
|
700
|
+
for (const directory of manifests) {
|
|
701
|
+
try {
|
|
702
|
+
const manifest = JSON.parse(
|
|
703
|
+
await readFile(resolve(directory, 'package.json'), 'utf8'),
|
|
704
|
+
);
|
|
705
|
+
if (manifest.kerfComponentCatalog) return true;
|
|
706
|
+
} catch {
|
|
707
|
+
/* Profile validation reports other JSON failures. */
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return false;
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
async function validateLocalCatalogs(root) {
|
|
714
|
+
if (!(await hasLocalCatalogConfiguration(root)))
|
|
715
|
+
return {
|
|
716
|
+
diagnostics: [],
|
|
717
|
+
detail:
|
|
718
|
+
'No local component metadata; declared profile catalogs were validated.',
|
|
719
|
+
};
|
|
720
|
+
try {
|
|
721
|
+
const module = await importFrom(root, 'create-kerf-component/catalog.js');
|
|
722
|
+
module.runCatalogCommand({ root, check: true });
|
|
723
|
+
return { diagnostics: [] };
|
|
724
|
+
} catch (error) {
|
|
725
|
+
if (
|
|
726
|
+
['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'].includes(error?.code) ||
|
|
727
|
+
/Cannot find (?:module|package)/.test(error?.message ?? '')
|
|
728
|
+
)
|
|
729
|
+
throw error;
|
|
730
|
+
const messages = error.diagnostics ?? [error.message];
|
|
731
|
+
return {
|
|
732
|
+
diagnostics: messages.map((message) =>
|
|
733
|
+
normalizedDiagnostic({
|
|
734
|
+
id: 'KUI-D020',
|
|
735
|
+
severity: 'error',
|
|
736
|
+
stage: 'catalog',
|
|
737
|
+
message,
|
|
738
|
+
documentation:
|
|
739
|
+
'create-kerf-component/README.md#machine-readable-component-metadata',
|
|
740
|
+
action:
|
|
741
|
+
'Repair local kerf.components.json metadata or run kerf-component-catalog --write, then review the generated catalog.',
|
|
742
|
+
meaning: UI_DOCTOR_RULES['KUI-D020'].title,
|
|
743
|
+
}),
|
|
744
|
+
),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
function normalizeChangedPaths(root, packageRoot, paths) {
|
|
750
|
+
if (!paths) return undefined;
|
|
751
|
+
const normalized = [];
|
|
752
|
+
for (const input of paths) {
|
|
753
|
+
const fromRoot = resolve(root, input);
|
|
754
|
+
const fromPackage = resolve(packageRoot, input);
|
|
755
|
+
const candidate =
|
|
756
|
+
packageRoot === root || !relative(packageRoot, fromRoot).startsWith('..')
|
|
757
|
+
? fromRoot
|
|
758
|
+
: fromPackage;
|
|
759
|
+
const path = relative(packageRoot, candidate).replaceAll('\\', '/');
|
|
760
|
+
if (path && path !== '..' && !path.startsWith('../')) normalized.push(path);
|
|
761
|
+
}
|
|
762
|
+
return [...new Set(normalized)].sort();
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
async function runTypeScript({ packageRoot, paths }) {
|
|
766
|
+
const ts = await importFrom(packageRoot, 'typescript');
|
|
767
|
+
const configPath = ts.default.findConfigFile(
|
|
768
|
+
packageRoot,
|
|
769
|
+
ts.default.sys.fileExists,
|
|
770
|
+
'tsconfig.json',
|
|
771
|
+
);
|
|
772
|
+
if (!configPath)
|
|
773
|
+
return { diagnostics: [], detail: 'No tsconfig.json; skipped.' };
|
|
774
|
+
const parsed = ts.default.getParsedCommandLineOfConfigFile(
|
|
775
|
+
configPath,
|
|
776
|
+
{},
|
|
777
|
+
{ ...ts.default.sys, onUnRecoverableConfigFileDiagnostic: () => {} },
|
|
778
|
+
);
|
|
779
|
+
if (!parsed) throw new Error('TypeScript could not parse tsconfig.json.');
|
|
780
|
+
if (paths?.length) {
|
|
781
|
+
const selected = new Set(paths.map((path) => resolve(packageRoot, path)));
|
|
782
|
+
parsed.fileNames = parsed.fileNames.filter((file) => selected.has(file));
|
|
783
|
+
}
|
|
784
|
+
const program = ts.default.createProgram(
|
|
785
|
+
parsed.fileNames,
|
|
786
|
+
{ ...parsed.options, noEmit: true },
|
|
787
|
+
undefined,
|
|
788
|
+
undefined,
|
|
789
|
+
parsed.projectReferences,
|
|
790
|
+
);
|
|
791
|
+
const diagnostics = [
|
|
792
|
+
...parsed.errors,
|
|
793
|
+
...ts.default.getPreEmitDiagnostics(program),
|
|
794
|
+
].map((item) => {
|
|
795
|
+
const point =
|
|
796
|
+
item.file && item.start !== undefined
|
|
797
|
+
? item.file.getLineAndCharacterOfPosition(item.start)
|
|
798
|
+
: undefined;
|
|
799
|
+
return normalizedDiagnostic({
|
|
800
|
+
id: `TS${item.code}`,
|
|
801
|
+
severity:
|
|
802
|
+
item.category === ts.default.DiagnosticCategory.Error
|
|
803
|
+
? 'error'
|
|
804
|
+
: 'warning',
|
|
805
|
+
stage: 'typescript',
|
|
806
|
+
message: ts.default.flattenDiagnosticMessageText(item.messageText, '\n'),
|
|
807
|
+
location: item.file
|
|
808
|
+
? {
|
|
809
|
+
file: portablePath(packageRoot, item.file.fileName),
|
|
810
|
+
line: (point?.line ?? 0) + 1,
|
|
811
|
+
column: (point?.character ?? 0) + 1,
|
|
812
|
+
}
|
|
813
|
+
: undefined,
|
|
814
|
+
action: 'Repair the TypeScript diagnostic at this source location.',
|
|
815
|
+
meaning: `TypeScript diagnostic TS${item.code}`,
|
|
816
|
+
});
|
|
817
|
+
});
|
|
818
|
+
return { diagnostics };
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async function runEslint({
|
|
822
|
+
packageRoot,
|
|
823
|
+
paths,
|
|
824
|
+
eslintConfig = 'recommended-ui',
|
|
825
|
+
}) {
|
|
826
|
+
const [{ ESLint }, pluginModule] = await Promise.all([
|
|
827
|
+
importFrom(packageRoot, 'eslint'),
|
|
828
|
+
importFrom(packageRoot, 'eslint-plugin-kerfjs'),
|
|
829
|
+
]);
|
|
830
|
+
const plugin = pluginModule.default ?? pluginModule;
|
|
831
|
+
const meanings = new Map();
|
|
832
|
+
for (const rule of Object.values(plugin.rules ?? {}))
|
|
833
|
+
for (const template of Object.values(rule.meta?.messages ?? {}))
|
|
834
|
+
for (const match of String(template).matchAll(/\bKUI-[A-Z]\d{3}\b/g))
|
|
835
|
+
meanings.set(match[0], String(template));
|
|
836
|
+
const preset = plugin.configs?.[eslintConfig];
|
|
837
|
+
if (!preset)
|
|
838
|
+
throw new Error(`eslint-plugin-kerfjs does not export ${eslintConfig}.`);
|
|
839
|
+
const inputs = await collectInputs(packageRoot, paths);
|
|
840
|
+
const usesTypeScript = inputs.some((file) =>
|
|
841
|
+
/\.(?:ts|tsx|mts|cts)$/.test(file),
|
|
842
|
+
);
|
|
843
|
+
const base = {
|
|
844
|
+
files: ['**/*.{js,jsx,mjs,cjs}'],
|
|
845
|
+
languageOptions: {
|
|
846
|
+
parserOptions: {
|
|
847
|
+
ecmaVersion: 'latest',
|
|
848
|
+
sourceType: 'module',
|
|
849
|
+
ecmaFeatures: { jsx: true },
|
|
850
|
+
},
|
|
851
|
+
},
|
|
852
|
+
};
|
|
853
|
+
const configs = [base];
|
|
854
|
+
if (usesTypeScript) {
|
|
855
|
+
const parserModule = await importFrom(
|
|
856
|
+
packageRoot,
|
|
857
|
+
'@typescript-eslint/parser',
|
|
858
|
+
);
|
|
859
|
+
configs.push({
|
|
860
|
+
files: ['**/*.{ts,tsx,mts,cts}'],
|
|
861
|
+
languageOptions: {
|
|
862
|
+
parser: parserModule.default ?? parserModule,
|
|
863
|
+
parserOptions: {
|
|
864
|
+
ecmaVersion: 'latest',
|
|
865
|
+
sourceType: 'module',
|
|
866
|
+
ecmaFeatures: { jsx: true },
|
|
867
|
+
},
|
|
868
|
+
},
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
configs.push(preset);
|
|
872
|
+
const eslint = new ESLint({
|
|
873
|
+
cwd: packageRoot,
|
|
874
|
+
overrideConfigFile: true,
|
|
875
|
+
overrideConfig: configs,
|
|
876
|
+
});
|
|
877
|
+
const targets = paths?.length ? paths : ['.'];
|
|
878
|
+
const results = await eslint.lintFiles(targets);
|
|
879
|
+
const diagnostics = [];
|
|
880
|
+
for (const result of results)
|
|
881
|
+
for (const item of result.messages) {
|
|
882
|
+
const code = item.message.match(/\bKUI-[A-Z]\d{3}\b/)?.[0];
|
|
883
|
+
diagnostics.push(
|
|
884
|
+
normalizedDiagnostic({
|
|
885
|
+
id: code ?? `eslint:${item.ruleId ?? 'parse-error'}`,
|
|
886
|
+
severity: item.severity === 2 ? 'error' : 'warning',
|
|
887
|
+
stage: 'eslint',
|
|
888
|
+
message: item.message.replace(/^KUI-[A-Z]\d{3}:\s*/, ''),
|
|
889
|
+
location: {
|
|
890
|
+
file: portablePath(packageRoot, result.filePath),
|
|
891
|
+
line: item.line ?? 1,
|
|
892
|
+
column: item.column ?? 1,
|
|
893
|
+
},
|
|
894
|
+
documentation: item.ruleId
|
|
895
|
+
? `eslint-plugin-kerfjs/docs/rules/${item.ruleId.replace(/^kerfjs\//, '')}.md`
|
|
896
|
+
: 'https://eslint.org/docs/latest/use/troubleshooting',
|
|
897
|
+
action: item.suggestions?.length
|
|
898
|
+
? 'Apply one of ESLint’s safe suggestions after reviewing its edit.'
|
|
899
|
+
: 'Repair the ESLint diagnostic at this source location.',
|
|
900
|
+
meaning: meanings.get(code) ?? item.ruleId ?? 'ESLint parse error',
|
|
901
|
+
}),
|
|
902
|
+
);
|
|
903
|
+
}
|
|
904
|
+
return { diagnostics };
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
async function stage(name, runner, signal) {
|
|
908
|
+
if (signal?.aborted)
|
|
909
|
+
return { id: name, status: 'cancelled', diagnostics: [] };
|
|
910
|
+
try {
|
|
911
|
+
const value = await runner();
|
|
912
|
+
if (signal?.aborted)
|
|
913
|
+
return { id: name, status: 'cancelled', diagnostics: [] };
|
|
914
|
+
return {
|
|
915
|
+
id: name,
|
|
916
|
+
status: value.detail?.includes('skipped') ? 'skipped' : 'ran',
|
|
917
|
+
detail: value.detail,
|
|
918
|
+
diagnostics: value.diagnostics ?? [],
|
|
919
|
+
};
|
|
920
|
+
} catch (error) {
|
|
921
|
+
if (signal?.aborted || error?.name === 'AbortError')
|
|
922
|
+
return { id: name, status: 'cancelled', diagnostics: [] };
|
|
923
|
+
if (
|
|
924
|
+
['ERR_MODULE_NOT_FOUND', 'MODULE_NOT_FOUND'].includes(error?.code) ||
|
|
925
|
+
/Cannot find (?:module|package)/.test(error?.message ?? '')
|
|
926
|
+
)
|
|
927
|
+
return {
|
|
928
|
+
id: name,
|
|
929
|
+
status: 'unavailable',
|
|
930
|
+
diagnostics: [unavailable(name, error)],
|
|
931
|
+
};
|
|
932
|
+
return {
|
|
933
|
+
id: name,
|
|
934
|
+
status: 'failed',
|
|
935
|
+
diagnostics: [
|
|
936
|
+
normalizedDiagnostic({
|
|
937
|
+
id: 'KUI-D011',
|
|
938
|
+
severity: 'error',
|
|
939
|
+
stage: name,
|
|
940
|
+
message: error.message,
|
|
941
|
+
action: `Repair the ${name} configuration and rerun the doctor.`,
|
|
942
|
+
meaning: UI_DOCTOR_RULES['KUI-D011'].title,
|
|
943
|
+
}),
|
|
944
|
+
],
|
|
945
|
+
};
|
|
946
|
+
}
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
export async function runUiDoctor({
|
|
950
|
+
root: requestedRoot = process.cwd(),
|
|
951
|
+
package: packageSelector,
|
|
952
|
+
mode,
|
|
953
|
+
paths,
|
|
954
|
+
config: suppliedConfig,
|
|
955
|
+
configPath,
|
|
956
|
+
cache,
|
|
957
|
+
signal,
|
|
958
|
+
browser,
|
|
959
|
+
runners = {},
|
|
960
|
+
eslintConfig = 'recommended-ui',
|
|
961
|
+
} = {}) {
|
|
962
|
+
const root = resolve(requestedRoot);
|
|
963
|
+
const configResult = suppliedConfig
|
|
964
|
+
? {
|
|
965
|
+
path: configPath ?? '<options>',
|
|
966
|
+
config: suppliedConfig,
|
|
967
|
+
diagnostics: validateUiDoctorConfig(
|
|
968
|
+
suppliedConfig,
|
|
969
|
+
configPath ?? '<options>',
|
|
970
|
+
),
|
|
971
|
+
}
|
|
972
|
+
: await readUiDoctorConfig(root, configPath && resolve(root, configPath));
|
|
973
|
+
const config = configResult.config;
|
|
974
|
+
let packageRoot = root;
|
|
975
|
+
const initialDiagnostics = [...configResult.diagnostics];
|
|
976
|
+
try {
|
|
977
|
+
packageRoot = await resolveUiDoctorPackage(
|
|
978
|
+
root,
|
|
979
|
+
packageSelector ?? config.package,
|
|
980
|
+
);
|
|
981
|
+
} catch (error) {
|
|
982
|
+
initialDiagnostics.push(
|
|
983
|
+
configDiagnostic(error.message, {
|
|
984
|
+
file: 'package.json',
|
|
985
|
+
path: '$.workspaces',
|
|
986
|
+
}),
|
|
987
|
+
);
|
|
988
|
+
}
|
|
989
|
+
const selectedMode = mode ?? config.mode ?? 'full';
|
|
990
|
+
const stages = { ...defaultStages, ...config.stages };
|
|
991
|
+
if (browser?.url || config.browser?.url) stages.browser = true;
|
|
992
|
+
const selectedPaths =
|
|
993
|
+
selectedMode === 'changed'
|
|
994
|
+
? normalizeChangedPaths(root, packageRoot, paths ?? [])
|
|
995
|
+
: undefined;
|
|
996
|
+
if (selectedMode === 'changed' && !selectedPaths?.length)
|
|
997
|
+
initialDiagnostics.push(
|
|
998
|
+
configDiagnostic(
|
|
999
|
+
'Changed mode requires at least one explicit --path. This prevents a false-clean report.',
|
|
1000
|
+
{
|
|
1001
|
+
file:
|
|
1002
|
+
configResult.path === '<options>'
|
|
1003
|
+
? '<options>'
|
|
1004
|
+
: portablePath(root, configResult.path),
|
|
1005
|
+
path: '$.mode',
|
|
1006
|
+
},
|
|
1007
|
+
),
|
|
1008
|
+
);
|
|
1009
|
+
const cacheEnabled = cache ?? config.cache ?? true;
|
|
1010
|
+
const [eslintRuleIds, registeredRuleIds] = await Promise.all([
|
|
1011
|
+
installedEslintRuleIds(packageRoot),
|
|
1012
|
+
sharedDiagnosticRuleIds(),
|
|
1013
|
+
]);
|
|
1014
|
+
const cachePath = resolve(root, '.kerf-cache/ui-doctor-v1.json');
|
|
1015
|
+
const key = await cacheKey(root, packageRoot, selectedMode, selectedPaths, {
|
|
1016
|
+
...config,
|
|
1017
|
+
browser: config.browser
|
|
1018
|
+
? { ...config.browser, url: '<redacted-url>' }
|
|
1019
|
+
: undefined,
|
|
1020
|
+
});
|
|
1021
|
+
if (cacheEnabled && !initialDiagnostics.length && !stages.browser) {
|
|
1022
|
+
const cached = await readCache(cachePath, key);
|
|
1023
|
+
if (cached)
|
|
1024
|
+
return {
|
|
1025
|
+
...cached,
|
|
1026
|
+
stages: cached.stages.map((item) =>
|
|
1027
|
+
item.status === 'ran' ? { ...item, status: 'cached' } : item,
|
|
1028
|
+
),
|
|
1029
|
+
cache: { hit: true, key },
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
const results = [];
|
|
1033
|
+
if (stages.catalog)
|
|
1034
|
+
results.push(
|
|
1035
|
+
await stage(
|
|
1036
|
+
'catalog',
|
|
1037
|
+
async () =>
|
|
1038
|
+
runners.catalog
|
|
1039
|
+
? runners.catalog({
|
|
1040
|
+
root,
|
|
1041
|
+
packageRoot,
|
|
1042
|
+
paths: selectedPaths,
|
|
1043
|
+
signal,
|
|
1044
|
+
})
|
|
1045
|
+
: (async () => {
|
|
1046
|
+
const knownRules = [...registeredRuleIds, ...eslintRuleIds];
|
|
1047
|
+
const result = await loadApplicationUiProfile({
|
|
1048
|
+
workspaceRoot: root,
|
|
1049
|
+
startDirectory: packageRoot,
|
|
1050
|
+
knownRules,
|
|
1051
|
+
});
|
|
1052
|
+
const local = await validateLocalCatalogs(packageRoot);
|
|
1053
|
+
return {
|
|
1054
|
+
diagnostics: [
|
|
1055
|
+
...result.diagnostics.map((item) =>
|
|
1056
|
+
normalizeProfile(item, root),
|
|
1057
|
+
),
|
|
1058
|
+
...local.diagnostics,
|
|
1059
|
+
],
|
|
1060
|
+
detail: local.detail,
|
|
1061
|
+
};
|
|
1062
|
+
})(),
|
|
1063
|
+
signal,
|
|
1064
|
+
),
|
|
1065
|
+
);
|
|
1066
|
+
if (stages.typescript)
|
|
1067
|
+
results.push(
|
|
1068
|
+
await stage(
|
|
1069
|
+
'typescript',
|
|
1070
|
+
() =>
|
|
1071
|
+
(runners.typescript ?? runTypeScript)({
|
|
1072
|
+
root,
|
|
1073
|
+
packageRoot,
|
|
1074
|
+
paths: selectedPaths,
|
|
1075
|
+
signal,
|
|
1076
|
+
}),
|
|
1077
|
+
signal,
|
|
1078
|
+
),
|
|
1079
|
+
);
|
|
1080
|
+
if (stages.eslint)
|
|
1081
|
+
results.push(
|
|
1082
|
+
await stage(
|
|
1083
|
+
'eslint',
|
|
1084
|
+
() =>
|
|
1085
|
+
(runners.eslint ?? runEslint)({
|
|
1086
|
+
root,
|
|
1087
|
+
packageRoot,
|
|
1088
|
+
paths: selectedPaths,
|
|
1089
|
+
signal,
|
|
1090
|
+
eslintConfig,
|
|
1091
|
+
}),
|
|
1092
|
+
signal,
|
|
1093
|
+
),
|
|
1094
|
+
);
|
|
1095
|
+
if (stages.analyzer)
|
|
1096
|
+
results.push(
|
|
1097
|
+
await stage(
|
|
1098
|
+
'analyzer',
|
|
1099
|
+
() =>
|
|
1100
|
+
runners.analyzer
|
|
1101
|
+
? runners.analyzer({
|
|
1102
|
+
root,
|
|
1103
|
+
packageRoot,
|
|
1104
|
+
paths: selectedPaths,
|
|
1105
|
+
signal,
|
|
1106
|
+
})
|
|
1107
|
+
: (async () => ({
|
|
1108
|
+
diagnostics: normalizeAnalyzer(
|
|
1109
|
+
await analyzeUiProject({
|
|
1110
|
+
root: packageRoot,
|
|
1111
|
+
paths: selectedPaths,
|
|
1112
|
+
knownRules: [...registeredRuleIds, ...eslintRuleIds],
|
|
1113
|
+
}),
|
|
1114
|
+
),
|
|
1115
|
+
}))(),
|
|
1116
|
+
signal,
|
|
1117
|
+
),
|
|
1118
|
+
);
|
|
1119
|
+
if (stages.browser)
|
|
1120
|
+
results.push(
|
|
1121
|
+
await stage(
|
|
1122
|
+
'browser',
|
|
1123
|
+
async () =>
|
|
1124
|
+
runners.browser
|
|
1125
|
+
? runners.browser({
|
|
1126
|
+
root,
|
|
1127
|
+
packageRoot,
|
|
1128
|
+
paths: selectedPaths,
|
|
1129
|
+
signal,
|
|
1130
|
+
browser: { ...config.browser, ...browser },
|
|
1131
|
+
})
|
|
1132
|
+
: (async () => {
|
|
1133
|
+
const options = { ...config.browser, ...browser };
|
|
1134
|
+
if (!options.url)
|
|
1135
|
+
throw new Error(
|
|
1136
|
+
'The browser stage requires an explicit URL.',
|
|
1137
|
+
);
|
|
1138
|
+
const report = await evaluateUi({
|
|
1139
|
+
url: options.url,
|
|
1140
|
+
workspaceRoot: root,
|
|
1141
|
+
startDirectory: packageRoot,
|
|
1142
|
+
browsers: options.browsers,
|
|
1143
|
+
retention: options.retention ?? 'on-failure',
|
|
1144
|
+
outputDirectory: resolve(
|
|
1145
|
+
root,
|
|
1146
|
+
options.outputDirectory ?? 'kerf-ui-evidence',
|
|
1147
|
+
),
|
|
1148
|
+
reportPath: resolve(
|
|
1149
|
+
root,
|
|
1150
|
+
options.reportPath ?? 'kerf-ui-evidence/report.json',
|
|
1151
|
+
),
|
|
1152
|
+
signal,
|
|
1153
|
+
});
|
|
1154
|
+
return { diagnostics: normalizeEvaluator(report) };
|
|
1155
|
+
})(),
|
|
1156
|
+
signal,
|
|
1157
|
+
),
|
|
1158
|
+
);
|
|
1159
|
+
for (const name of Object.keys(stages))
|
|
1160
|
+
if (!stages[name])
|
|
1161
|
+
results.push({
|
|
1162
|
+
id: name,
|
|
1163
|
+
status: 'skipped',
|
|
1164
|
+
detail: 'Disabled by configuration.',
|
|
1165
|
+
diagnostics: [],
|
|
1166
|
+
});
|
|
1167
|
+
const cancelled =
|
|
1168
|
+
signal?.aborted || results.some((item) => item.status === 'cancelled');
|
|
1169
|
+
const suppressionSource = [...(config.suppressions ?? [])];
|
|
1170
|
+
const emitted = results.flatMap((item) => item.diagnostics);
|
|
1171
|
+
const knownSuppressionRules = new Set([
|
|
1172
|
+
...registeredRuleIds,
|
|
1173
|
+
...eslintRuleIds,
|
|
1174
|
+
...emitted.map((item) => item.id),
|
|
1175
|
+
]);
|
|
1176
|
+
suppressionSource.forEach((suppression, index) => {
|
|
1177
|
+
for (const rule of suppression.rules ?? [])
|
|
1178
|
+
if (
|
|
1179
|
+
!knownSuppressionRules.has(rule) &&
|
|
1180
|
+
!/^TS\d+$/.test(rule) &&
|
|
1181
|
+
!/^eslint:[a-z0-9@/_-]+$/i.test(rule)
|
|
1182
|
+
)
|
|
1183
|
+
initialDiagnostics.push(
|
|
1184
|
+
configDiagnostic(`Unknown or stale suppression rule id ${rule}.`, {
|
|
1185
|
+
file:
|
|
1186
|
+
configResult.path === '<options>'
|
|
1187
|
+
? '<options>'
|
|
1188
|
+
: portablePath(root, configResult.path),
|
|
1189
|
+
path: `$.suppressions[${index}].rules`,
|
|
1190
|
+
}),
|
|
1191
|
+
);
|
|
1192
|
+
});
|
|
1193
|
+
const merged = mergeDiagnostics(
|
|
1194
|
+
redact(root, [...initialDiagnostics, ...emitted]),
|
|
1195
|
+
);
|
|
1196
|
+
const { active, suppressed } = applySuppressions(merged, suppressionSource);
|
|
1197
|
+
const infrastructureFailure =
|
|
1198
|
+
initialDiagnostics.length ||
|
|
1199
|
+
results.some((item) => ['failed', 'unavailable'].includes(item.status));
|
|
1200
|
+
const summary = {
|
|
1201
|
+
errors: active.filter((item) => item.severity === 'error').length,
|
|
1202
|
+
warnings: active.filter((item) => item.severity === 'warning').length,
|
|
1203
|
+
review: active.filter((item) => item.severity === 'review').length,
|
|
1204
|
+
suppressed: suppressed.length,
|
|
1205
|
+
};
|
|
1206
|
+
const exitCode = cancelled
|
|
1207
|
+
? UI_DOCTOR_EXIT.cancelled
|
|
1208
|
+
: infrastructureFailure
|
|
1209
|
+
? UI_DOCTOR_EXIT.configuration
|
|
1210
|
+
: summary.errors
|
|
1211
|
+
? UI_DOCTOR_EXIT.findings
|
|
1212
|
+
: UI_DOCTOR_EXIT.clean;
|
|
1213
|
+
const report = redact(root, {
|
|
1214
|
+
schemaVersion: UI_DOCTOR_SCHEMA_VERSION,
|
|
1215
|
+
tool: { name: '@kerfjs/ui/doctor', reportVersion: 1 },
|
|
1216
|
+
mode: selectedMode,
|
|
1217
|
+
root: '.',
|
|
1218
|
+
package: portablePath(root, packageRoot),
|
|
1219
|
+
paths: selectedPaths ?? [],
|
|
1220
|
+
stages: results
|
|
1221
|
+
.map((item) => {
|
|
1222
|
+
const stageResult = { ...item };
|
|
1223
|
+
delete stageResult.diagnostics;
|
|
1224
|
+
return stageResult;
|
|
1225
|
+
})
|
|
1226
|
+
.sort((a, b) => a.id.localeCompare(b.id)),
|
|
1227
|
+
diagnostics: active,
|
|
1228
|
+
suppressions: suppressed,
|
|
1229
|
+
summary,
|
|
1230
|
+
exitCode,
|
|
1231
|
+
cache: { hit: false, key },
|
|
1232
|
+
});
|
|
1233
|
+
if (cacheEnabled && !cancelled && !infrastructureFailure && !stages.browser)
|
|
1234
|
+
await writeCache(cachePath, key, report);
|
|
1235
|
+
return report;
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
export function formatUiDoctorText(report) {
|
|
1239
|
+
const lines = [];
|
|
1240
|
+
for (const item of report.diagnostics) {
|
|
1241
|
+
const place = item.location
|
|
1242
|
+
? `${item.location.file}:${item.location.line}:${item.location.column}`
|
|
1243
|
+
: item.dom
|
|
1244
|
+
? `[${item.dom.context ?? 'browser'} ${item.dom.selector}]`
|
|
1245
|
+
: '<doctor>';
|
|
1246
|
+
lines.push(`${place} ${item.severity} ${item.id} ${item.message}`);
|
|
1247
|
+
if (item.action) lines.push(` Next: ${item.action}`);
|
|
1248
|
+
}
|
|
1249
|
+
lines.push(
|
|
1250
|
+
`Kerf UI doctor: ${report.summary.errors} error(s), ${report.summary.warnings} warning(s), ${report.summary.review} review finding(s), ${report.summary.suppressed} suppressed; exit ${report.exitCode}.`,
|
|
1251
|
+
);
|
|
1252
|
+
lines.push(
|
|
1253
|
+
`Stages: ${report.stages.map((item) => `${item.id}=${item.status}`).join(', ')}${report.cache.hit ? ' (cache hit)' : ''}`,
|
|
1254
|
+
);
|
|
1255
|
+
return lines.join('\n');
|
|
1256
|
+
}
|