@starci/hfs 4.7.34 → 4.7.36
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +8 -11
- package/codemod/button-intent.mjs +356 -0
- package/lint/run.mjs +70 -29
- package/package.json +3 -2
- package/runtime/knowledge/grammars/index.yaml +111 -0
- package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +1190 -0
- package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
- package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
- package/runtime/knowledge/hfs/rules.yaml +163 -83
- package/runtime/knowledge/hfs/slots.yaml +3 -3
- package/runtime/knowledge/ui/presentation/font.yaml +101 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +217 -0
- package/runtime/modules/kernel/failure-codes.yaml +102 -20
- package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
- package/runtime/modules/kernel/roles.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +119 -9
- package/runtime/scripts/gates/canon-pins.mjs +103 -0
- package/runtime/scripts/gates/gate.mjs +3 -36
- package/runtime/scripts/gates/output.mjs +40 -0
- package/runtime/scripts/gates/test-world-run.mjs +2 -4
- package/runtime/scripts/guards/release-definition.mjs +22 -3
- package/runtime/scripts/hfs/architecture/config.mjs +8 -8
- package/runtime/scripts/hfs/check.mjs +1 -1
- package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
- package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
- package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
- package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
- package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
- package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
- package/runtime/scripts/hfs/slots.mjs +1 -0
- package/runtime/scripts/hfs/view.mjs +1 -0
- package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
- package/runtime/scripts/lib/css-rules.mjs +18 -0
- package/runtime/scripts/lib/example-refs.mjs +1 -1
- package/runtime/scripts/lib/grammar-lint.mjs +48 -0
- package/runtime/scripts/lib/grammar-vendor-parity-jsx.mjs +360 -0
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +110 -157
- package/runtime/scripts/lib/numeric-match.mjs +5 -0
- package/runtime/scripts/lib/release-version.mjs +119 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
- package/runtime/scripts/lib/source-package-imports.mjs +111 -0
- package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
- package/scaffold/app.mjs +7 -5
- package/src/main.mjs +30 -15
- package/stack/config.mjs +2 -1
- package/stack/render.mjs +24 -3
- package/sync/index.mjs +8 -2
- package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
- package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
- package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
- package/templates/fe/tool-config/eslint.config.mjs +4 -1
|
@@ -0,0 +1,360 @@
|
|
|
1
|
+
// JSX and static role-expression readers shared by the Grammar vendor judge.
|
|
2
|
+
const lineOf = (text, index) => text.slice(0, index).split('\n').length;
|
|
3
|
+
|
|
4
|
+
/** The index after the quoted string that starts at `start`. */
|
|
5
|
+
function skipQuoted(text, start) {
|
|
6
|
+
const quote = text[start];
|
|
7
|
+
let index = start + 1;
|
|
8
|
+
while (index < text.length && text[index] !== quote) index += text[index] === '\\' ? 2 : 1;
|
|
9
|
+
return index + 1;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
/** The index after the template literal that starts at `start`, `${...}` substitutions included. */
|
|
13
|
+
function skipTemplate(text, start) {
|
|
14
|
+
let index = start + 1;
|
|
15
|
+
while (index < text.length && text[index] !== '`') {
|
|
16
|
+
if (text[index] === '\\') index += 2;
|
|
17
|
+
else if (text[index] === '$' && text[index + 1] === '{') index = skipBraces(text, index + 1);
|
|
18
|
+
else index += 1;
|
|
19
|
+
}
|
|
20
|
+
return index + 1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The index after the balanced `{...}` that starts at `start`. */
|
|
24
|
+
function skipBraces(text, start) {
|
|
25
|
+
let depth = 0;
|
|
26
|
+
let index = start;
|
|
27
|
+
while (index < text.length) {
|
|
28
|
+
const char = text[index];
|
|
29
|
+
if (char === '"' || char === "'") index = skipQuoted(text, index);
|
|
30
|
+
else if (char === '`') index = skipTemplate(text, index);
|
|
31
|
+
else {
|
|
32
|
+
if (char === '{') depth += 1;
|
|
33
|
+
if (char === '}') depth -= 1;
|
|
34
|
+
index += 1;
|
|
35
|
+
if (depth === 0 && char === '}') return index;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return index;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** The index after the value (a string, a template or a braced expression) that starts at `start`. */
|
|
42
|
+
function skipValue(text, start) {
|
|
43
|
+
const char = text[start];
|
|
44
|
+
if (char === '"' || char === "'") return skipQuoted(text, start);
|
|
45
|
+
if (char === '`') return skipTemplate(text, start);
|
|
46
|
+
return char === '{' ? skipBraces(text, start) : start + 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Attributes of the opening tag whose name ended at `from`: `{name, raw}` (a spread is named `...`). */
|
|
50
|
+
function readAttributes(text, from) {
|
|
51
|
+
const attrs = [];
|
|
52
|
+
let index = from;
|
|
53
|
+
while (index < text.length) {
|
|
54
|
+
while (/\s/.test(text[index] ?? '')) index += 1;
|
|
55
|
+
if (text[index] === '>' || text.startsWith('/>', index)) break;
|
|
56
|
+
if (text[index] === '{') {
|
|
57
|
+
const end = skipBraces(text, index);
|
|
58
|
+
attrs.push({ name: '...', raw: text.slice(index + 1, end - 1) });
|
|
59
|
+
index = end;
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
const name = /^[\w:-]+/.exec(text.slice(index, index + 80))?.[0];
|
|
63
|
+
if (!name) break;
|
|
64
|
+
index += name.length;
|
|
65
|
+
if (text[index] !== '=') {
|
|
66
|
+
attrs.push({ name, raw: 'true' });
|
|
67
|
+
continue;
|
|
68
|
+
}
|
|
69
|
+
index += 1;
|
|
70
|
+
const end = skipValue(text, index);
|
|
71
|
+
const raw = text.slice(index, end);
|
|
72
|
+
attrs.push({ name, raw: raw.startsWith('{') ? raw.slice(1, -1) : raw, offset: raw.startsWith('{') ? index + 1 : index });
|
|
73
|
+
index = end;
|
|
74
|
+
}
|
|
75
|
+
return attrs;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** The opening tags of the given import aliases: `{alias, part, attrs, line}` (`part` is `Root` for the bare alias). */
|
|
79
|
+
export function heroTags(text, aliases) {
|
|
80
|
+
const known = new Set(aliases);
|
|
81
|
+
return jsxTags(text).filter((tag) => known.has(tag.alias)).map(({ index, ...tag }) => ({ ...tag, line: lineOf(text, index) }));
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The string literals of an expression, without their quotes. */
|
|
85
|
+
export const stringLiterals = (raw) => (String(raw).match(/"[^"\n]*"|'[^'\n]*'|`[^`$]*`/g) ?? []).map((literal) => literal.slice(1, -1));
|
|
86
|
+
|
|
87
|
+
/** The class tokens an element carries: the literals of its className, with a bare constant resolved one level in the file. */
|
|
88
|
+
export function classTokens(text, attrs) {
|
|
89
|
+
const raw = attrs.find((attr) => attr.name === 'className')?.raw;
|
|
90
|
+
if (raw === undefined) return [];
|
|
91
|
+
const names = String(raw).match(/\b[A-Za-z_]\w*\b/g) ?? [];
|
|
92
|
+
const resolved = names.flatMap((name) => {
|
|
93
|
+
const declaration = new RegExp(String.raw`\bconst\s+${name}\s*=\s*([^\n]*(?:\n[^\n;]*)?)`).exec(text);
|
|
94
|
+
return declaration ? stringLiterals(declaration[1]) : [];
|
|
95
|
+
});
|
|
96
|
+
return [...stringLiterals(raw), ...resolved].flatMap((value) => value.split(/\s+/)).filter(Boolean);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** The vendor imports of a source: alias -> vendor component name. */
|
|
100
|
+
export function importAliases(text) {
|
|
101
|
+
const aliases = new Map();
|
|
102
|
+
for (const match of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']@heroui\/react["']/g)) {
|
|
103
|
+
for (const entry of match[1].split(',').map((part) => part.trim()).filter(Boolean)) {
|
|
104
|
+
const found = /^(?!type\s)(\w+)(?:\s+as\s+(\w+))?$/.exec(entry);
|
|
105
|
+
if (found) aliases.set(found[2] ?? found[1], found[1]);
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
return aliases;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether the cursor starts a JavaScript string or template. */
|
|
112
|
+
const isQuoted = (char) => char === '"' || char === "'" || char === '`';
|
|
113
|
+
|
|
114
|
+
/** Skip strings and comments while looking for JSX rather than text that only resembles it. */
|
|
115
|
+
function skipJsTrivia(text, index) {
|
|
116
|
+
if (isQuoted(text[index])) return skipValue(text, index);
|
|
117
|
+
if (text.startsWith('//', index)) {
|
|
118
|
+
const end = text.indexOf('\n', index);
|
|
119
|
+
return end < 0 ? text.length : end;
|
|
120
|
+
}
|
|
121
|
+
if (text.startsWith('/*', index)) {
|
|
122
|
+
const end = text.indexOf('*/', index + 2);
|
|
123
|
+
return end < 0 ? text.length : end + 2;
|
|
124
|
+
}
|
|
125
|
+
return undefined;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** The end of an opening tag, keeping greater-than signs inside attribute expressions intact. */
|
|
129
|
+
function jsxOpeningEnd(text, start) {
|
|
130
|
+
let index = start;
|
|
131
|
+
while (index < text.length && text[index] !== '>') {
|
|
132
|
+
index = /["'{`]/.test(text[index]) ? skipValue(text, index) : index + 1;
|
|
133
|
+
}
|
|
134
|
+
return index;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Read one opening tag and the cursor after its attributes. */
|
|
138
|
+
function jsxOpening(text, index) {
|
|
139
|
+
if (text[index] !== '<') return undefined;
|
|
140
|
+
const opening = /^<([A-Za-z][\w.:-]*)(?=[\s/>])/.exec(text.slice(index));
|
|
141
|
+
if (!opening) return undefined;
|
|
142
|
+
const from = index + opening[0].length;
|
|
143
|
+
const [alias, part = 'Root'] = opening[1].split('.');
|
|
144
|
+
return { tag: { alias, part, attrs: readAttributes(text, from), index }, end: jsxOpeningEnd(text, from) };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Opening JSX tags, excluding JavaScript strings and comments; attribute expressions remain intact. */
|
|
148
|
+
function jsxTags(text) {
|
|
149
|
+
const tags = [];
|
|
150
|
+
let index = 0;
|
|
151
|
+
while (index < text.length) {
|
|
152
|
+
const skipped = skipJsTrivia(text, index);
|
|
153
|
+
if (skipped !== undefined) {
|
|
154
|
+
index = skipped;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
const opening = jsxOpening(text, index);
|
|
158
|
+
if (!opening) {
|
|
159
|
+
index += 1;
|
|
160
|
+
continue;
|
|
161
|
+
}
|
|
162
|
+
tags.push(opening.tag);
|
|
163
|
+
for (const attr of opening.tag.attrs.filter((candidate) => candidate.name !== '...')) {
|
|
164
|
+
tags.push(...jsxTags(attr.raw).map((tag) => ({ ...tag, index: tag.index + attr.offset })));
|
|
165
|
+
}
|
|
166
|
+
index = opening.end + 1;
|
|
167
|
+
}
|
|
168
|
+
return tags;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** Apply the delimiter at a cursor to an expression's nesting depth. */
|
|
172
|
+
const expressionDepth = (depth, char) => {
|
|
173
|
+
if ('({['.includes(char)) return depth + 1;
|
|
174
|
+
return ')}]'.includes(char) ? depth - 1 : depth;
|
|
175
|
+
};
|
|
176
|
+
|
|
177
|
+
/** The top-level operator at this cursor, with nullish and logical operators kept whole. */
|
|
178
|
+
function expressionOperator(raw, index) {
|
|
179
|
+
const logical = /^(?:\?\?|\|\||&&)/.exec(raw.slice(index));
|
|
180
|
+
if (logical) return logical[0];
|
|
181
|
+
if (/\s/.test(raw[index - 1] ?? '') && /^(?:as|satisfies)\s/.test(raw.slice(index))) return 'type';
|
|
182
|
+
return ['?', ':', ','].includes(raw[index]) ? raw[index] : undefined;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Top-level expression operators, excluding strings, nested expressions and nullish `??`. */
|
|
186
|
+
function expressionOperators(raw) {
|
|
187
|
+
const operators = [];
|
|
188
|
+
let depth = 0;
|
|
189
|
+
let index = 0;
|
|
190
|
+
while (index < raw.length) {
|
|
191
|
+
const char = raw[index];
|
|
192
|
+
if (isQuoted(char)) {
|
|
193
|
+
index = skipValue(raw, index);
|
|
194
|
+
continue;
|
|
195
|
+
}
|
|
196
|
+
depth = expressionDepth(depth, char);
|
|
197
|
+
const operator = depth === 0 ? expressionOperator(raw, index) : undefined;
|
|
198
|
+
if (operator !== undefined) operators.push({ operator, index });
|
|
199
|
+
index += ['??', '||', '&&'].includes(operator) ? 2 : 1;
|
|
200
|
+
}
|
|
201
|
+
return operators;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The first top-level expression terminator; constant initializers can continue a conditional on the next line. */
|
|
205
|
+
function expressionEnd(text, start, continuedConditional = false) {
|
|
206
|
+
let depth = 0;
|
|
207
|
+
let index = start;
|
|
208
|
+
while (index < text.length) {
|
|
209
|
+
const char = text[index];
|
|
210
|
+
if (isQuoted(char)) {
|
|
211
|
+
index = skipValue(text, index);
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
depth = expressionDepth(depth, char);
|
|
215
|
+
if (depth === 0 && char === ';') break;
|
|
216
|
+
if (depth === 0 && char === '\n') {
|
|
217
|
+
const continues = continuedConditional && /^\s*(?:\?(?!\.)|:|\|\||&&)/.test(text.slice(index + 1));
|
|
218
|
+
if (!continues) break;
|
|
219
|
+
}
|
|
220
|
+
index += 1;
|
|
221
|
+
}
|
|
222
|
+
return index;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** A local constant initializer, including balanced objects and multiline conditional branches. */
|
|
226
|
+
function constantExpression(text, identifier) {
|
|
227
|
+
const declaration = new RegExp(String.raw`\bconst\s+${identifier}(?:\s*:\s*[^=;\n]+)?\s*=`).exec(text);
|
|
228
|
+
if (!declaration) return undefined;
|
|
229
|
+
const start = declaration.index + declaration[0].length;
|
|
230
|
+
return text.slice(start, expressionEnd(text, start, true)).trim();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** The static entries of an object constant used as a role lookup table. */
|
|
234
|
+
function expressionEntries(raw) {
|
|
235
|
+
raw = raw.trim();
|
|
236
|
+
const assertion = expressionOperators(raw).find((entry) => entry.operator === 'type');
|
|
237
|
+
if (assertion) raw = raw.slice(0, assertion.index).trim();
|
|
238
|
+
if (!raw.startsWith('{') || !raw.endsWith('}')) return [];
|
|
239
|
+
const body = raw.slice(1, -1);
|
|
240
|
+
const separators = expressionOperators(body).filter((entry) => entry.operator === ',').map((entry) => entry.index);
|
|
241
|
+
const entries = [];
|
|
242
|
+
let start = 0;
|
|
243
|
+
for (const end of [...separators, body.length]) {
|
|
244
|
+
const entry = body.slice(start, end).trim();
|
|
245
|
+
const colon = expressionOperators(entry).find((operator) => operator.operator === ':');
|
|
246
|
+
if (colon) entries.push({ key: entry.slice(0, colon.index).trim().replace(/^['"]|['"]$/g, ''), value: entry.slice(colon.index + 1) });
|
|
247
|
+
start = end + 1;
|
|
248
|
+
}
|
|
249
|
+
return entries;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Whether one balanced pair encloses the complete expression rather than only its first operand. */
|
|
253
|
+
function enclosesExpression(raw) {
|
|
254
|
+
if (!raw.startsWith('(') || !raw.endsWith(')')) return false;
|
|
255
|
+
let depth = 0;
|
|
256
|
+
let index = 0;
|
|
257
|
+
while (index < raw.length) {
|
|
258
|
+
const char = raw[index];
|
|
259
|
+
if (isQuoted(char)) {
|
|
260
|
+
index = skipValue(raw, index);
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
if (char === '(') depth += 1;
|
|
264
|
+
if (char === ')') depth -= 1;
|
|
265
|
+
if (depth === 0) return index === raw.length - 1;
|
|
266
|
+
index += 1;
|
|
267
|
+
}
|
|
268
|
+
return false;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
/** Match a conditional's colon without confusing nested conditionals with its own branches. */
|
|
272
|
+
function conditionalColon(operators, question) {
|
|
273
|
+
let nested = 0;
|
|
274
|
+
return operators.slice(question + 1).find((entry) => {
|
|
275
|
+
if (entry.operator === '?') nested += 1;
|
|
276
|
+
if (entry.operator !== ':') return false;
|
|
277
|
+
if (nested > 0) {
|
|
278
|
+
nested -= 1;
|
|
279
|
+
return false;
|
|
280
|
+
}
|
|
281
|
+
return true;
|
|
282
|
+
});
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Resolve a role lookup through a local object constant, considering every possible computed key. */
|
|
286
|
+
function memberExpressionStrings(text, member, seen) {
|
|
287
|
+
if (seen.has(member[1])) return [];
|
|
288
|
+
const initializer = constantExpression(text, member[1]);
|
|
289
|
+
if (!initializer) return [];
|
|
290
|
+
seen.add(member[1]);
|
|
291
|
+
const literalKey = member[3] ?? (/^(['"])([^'"]+)\1$/.exec(member[2]?.trim() ?? '')?.[2]);
|
|
292
|
+
return expressionEntries(initializer).filter((entry) => literalKey === undefined || entry.key === literalKey).flatMap((entry) => expressionStrings(text, entry.value, new Set(seen)));
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/** Result literals of a JSX expression, including local constants; condition literals are not result values. */
|
|
296
|
+
function expressionStrings(text, raw, seen = new Set()) {
|
|
297
|
+
raw = raw.trim();
|
|
298
|
+
if (enclosesExpression(raw)) return expressionStrings(text, raw.slice(1, -1), seen);
|
|
299
|
+
if (/^(?:"[^"\n]*"|'[^'\n]*'|`[^`$]*`)$/.test(raw)) return [raw.slice(1, -1)];
|
|
300
|
+
const operators = expressionOperators(raw);
|
|
301
|
+
const assertion = operators.find((entry) => entry.operator === 'type');
|
|
302
|
+
if (assertion) return expressionStrings(text, raw.slice(0, assertion.index), seen);
|
|
303
|
+
const question = operators.findIndex((entry) => entry.operator === '?');
|
|
304
|
+
const colon = question >= 0 ? conditionalColon(operators, question) : undefined;
|
|
305
|
+
if (colon) return [...expressionStrings(text, raw.slice(operators[question].index + 1, colon.index), new Set(seen)), ...expressionStrings(text, raw.slice(colon.index + 1), new Set(seen))];
|
|
306
|
+
const logical = operators.find((entry) => ['??', '||', '&&'].includes(entry.operator));
|
|
307
|
+
if (logical) return [...expressionStrings(text, raw.slice(0, logical.index), new Set(seen)), ...expressionStrings(text, raw.slice(logical.index + 2), new Set(seen))];
|
|
308
|
+
const member = /^([A-Za-z_$][\w$]*)(?:\[([\s\S]+)\]|\.([A-Za-z_$][\w$]*))$/.exec(raw);
|
|
309
|
+
if (member) return memberExpressionStrings(text, member, seen);
|
|
310
|
+
const identifier = /^([A-Za-z_$][\w$]*)$/.exec(raw)?.[1];
|
|
311
|
+
if (!identifier || seen.has(identifier)) return [];
|
|
312
|
+
seen.add(identifier);
|
|
313
|
+
const initializer = constantExpression(text, identifier);
|
|
314
|
+
return initializer ? expressionStrings(text, initializer, seen) : [];
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** The first callback parameter, after optional parentheses and surrounding whitespace. */
|
|
318
|
+
function callbackParameter(source) {
|
|
319
|
+
const trimmed = source.trimStart();
|
|
320
|
+
const parameter = trimmed.startsWith('(') ? trimmed.slice(1).trimStart() : trimmed;
|
|
321
|
+
return /^([A-Za-z_$][\w$]*)/.exec(parameter)?.[1];
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/** A render callback's source offset, resolving a named local callback when present. */
|
|
325
|
+
function callbackStart(text, callback) {
|
|
326
|
+
if (!/^[\w$]+$/.test(callback.raw)) return callback.offset;
|
|
327
|
+
const declaration = new RegExp(String.raw`\bconst\s+${callback.raw}\s*=`).exec(text);
|
|
328
|
+
return declaration ? declaration.index + declaration[0].length : undefined;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** The native root receiving the vendor's render props and the role contracts it therefore inherits. */
|
|
332
|
+
function vendorRenderRoot(text, callback, declarations) {
|
|
333
|
+
const start = callbackStart(text, callback);
|
|
334
|
+
if (start === undefined) return [];
|
|
335
|
+
const parameter = callbackParameter(text.slice(start));
|
|
336
|
+
if (!parameter) return [];
|
|
337
|
+
const root = jsxTags(text.slice(start, expressionEnd(text, start)))[0];
|
|
338
|
+
if (!root || !root.attrs.some((attr) => attr.name === '...' && attr.raw.trim() === `...${parameter}`)) return [];
|
|
339
|
+
const roles = Object.entries(declarations).filter(([, entry]) => entry.contracts?.includes(`${callback.owner}.Root`)).map(([role]) => role);
|
|
340
|
+
return [{ index: root.index + start, roles }];
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** The local render callbacks supplied to vendor roots keep the vendor's state, semantics and prop custody. */
|
|
344
|
+
function vendorRenderRoots(text, aliases, declarations) {
|
|
345
|
+
const callbacks = heroTags(text, [...aliases.keys()]).flatMap((tag) => tag.attrs.filter((attr) => attr.name === 'render').map((attr) => ({ raw: attr.raw.trim(), offset: attr.offset, owner: aliases.get(tag.alias) })));
|
|
346
|
+
return callbacks.flatMap((callback) => vendorRenderRoot(text, callback, declarations));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
/** The JSX roles a file writes by hand that the measured vendor references expose. */
|
|
350
|
+
export function roleFindings({ file, text, allowed, vendorRoles, roleDeclarations, finding }) {
|
|
351
|
+
const vendorAliases = importAliases(text);
|
|
352
|
+
const vendorRenders = vendorRenderRoots(text, vendorAliases, roleDeclarations);
|
|
353
|
+
const vendorOwnsRole = (tag, role) => {
|
|
354
|
+
const owner = vendorAliases.get(tag.alias);
|
|
355
|
+
const declaration = roleDeclarations[role];
|
|
356
|
+
return owner !== undefined && (declaration?.contracts.includes(`${owner}.${tag.part}`) || (tag.part === 'Root' && declaration?.publicApi === owner));
|
|
357
|
+
};
|
|
358
|
+
const roles = jsxTags(text).flatMap((tag) => tag.attrs.filter((attr) => attr.name === 'role').flatMap((attr) => expressionStrings(text, attr.raw)).filter((role) => !vendorOwnsRole(tag, role) && !vendorRenders.some((root) => tag.index === root.index && root.roles.includes(role)))).filter((role) => vendorRoles.has(role));
|
|
359
|
+
return [...new Set(roles)].filter((role) => !allowed('role', `${file}:${role}`)).map((role) => finding('role', file, 0, `hand-rolls role="${role}", which HeroUI has a component for: use it (override target role:${file}:${role})`));
|
|
360
|
+
}
|