@starci/hfs 4.7.33 → 4.7.35

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.
Files changed (48) hide show
  1. package/CHANGELOG.md +8 -1
  2. package/README.md +8 -11
  3. package/lint/run.mjs +70 -29
  4. package/package.json +2 -2
  5. package/runtime/knowledge/grammars/index.yaml +102 -0
  6. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  7. package/runtime/knowledge/grammars/starci/playbook.yaml +1159 -0
  8. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  9. package/runtime/knowledge/hfs/fe-stack.yaml +7 -0
  10. package/runtime/knowledge/hfs/rules.yaml +133 -83
  11. package/runtime/knowledge/hfs/slots.yaml +3 -3
  12. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  13. package/runtime/knowledge/ui/presentation/padding.yaml +216 -0
  14. package/runtime/modules/kernel/failure-codes.yaml +40 -20
  15. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  16. package/runtime/packages/cli/src/catalog.generated.mjs +7 -7
  17. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  18. package/runtime/scripts/gates/gate.mjs +3 -36
  19. package/runtime/scripts/gates/output.mjs +40 -0
  20. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  21. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  22. package/runtime/scripts/hfs/check.mjs +1 -1
  23. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  24. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  25. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  26. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  27. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  28. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  29. package/runtime/scripts/hfs/slots.mjs +1 -0
  30. package/runtime/scripts/hfs/view.mjs +1 -0
  31. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  32. package/runtime/scripts/lib/example-refs.mjs +1 -1
  33. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  34. package/runtime/scripts/lib/grammar-vendor-parity.mjs +31 -111
  35. package/runtime/scripts/lib/grammar-vendor-source.mjs +345 -0
  36. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  37. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  38. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  39. package/scaffold/app.mjs +7 -5
  40. package/src/main.mjs +26 -13
  41. package/stack/config.mjs +2 -1
  42. package/stack/render.mjs +24 -3
  43. package/sync/index.mjs +8 -2
  44. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  45. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  46. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  47. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  48. package/templates/fe/tool-config/eslint.config.mjs +4 -1
@@ -0,0 +1,129 @@
1
+ // Canon release binding edits preserve authored profile text and update only the selected binding's identity.
2
+
3
+ const KEY = /^[A-Za-z_][A-Za-z0-9_-]*$/;
4
+
5
+ function supportedBinding(value) {
6
+ const keys = typeof value === 'string' ? value.split('.') : [];
7
+ return (keys.length === 1 && keys[0] === 'canon') || (keys.length === 2 && keys[0] === 'familyCanons' && KEY.test(keys[1]));
8
+ }
9
+
10
+ function lineEntry(line, index) {
11
+ const text = line.slice(0, line.length - lineEnding(line).length);
12
+ if (!text.trim() || text.trimStart().startsWith('#')) return null;
13
+ let indent = 0;
14
+ while (text[indent] === ' ') indent++;
15
+ const colon = text.indexOf(':', indent);
16
+ const key = colon < 0 ? '' : text.slice(indent, colon);
17
+ if (!KEY.test(key)) return { index, indent };
18
+ let valueAt = colon + 1;
19
+ while (text[valueAt] === ' ' || text[valueAt] === '\t') valueAt++;
20
+ return { index, indent, key, prefix: text.slice(0, valueAt), value: text.slice(valueAt) };
21
+ }
22
+
23
+ function lineEnding(line) {
24
+ if (line.endsWith('\r\n')) return '\r\n';
25
+ return line.endsWith('\n') ? '\n' : '';
26
+ }
27
+
28
+ function linesOf(text) {
29
+ const lines = [];
30
+ let start = 0;
31
+ for (let index = 0; index < text.length; index++) {
32
+ if (text[index] !== '\n') continue;
33
+ lines.push(text.slice(start, index + 1));
34
+ start = index + 1;
35
+ }
36
+ if (start < text.length) lines.push(text.slice(start));
37
+ return lines;
38
+ }
39
+
40
+ function child(entries, parent, key, at) {
41
+ const nested = entries.filter(entry => entry.index > parent.start && entry.index < parent.end && entry.indent > parent.indent);
42
+ const directIndent = Math.min(...nested.map(entry => entry.indent));
43
+ const found = nested.filter(entry => entry.indent === directIndent && entry.key === key);
44
+ if (found.length !== 1) throw new Error(`${at}: expected one ${key} field, found ${found.length}`);
45
+ const entry = found[0];
46
+ const boundary = entries.find(candidate => candidate.index > entry.index && candidate.index < parent.end && candidate.indent <= entry.indent);
47
+ return { ...entry, start: entry.index, end: boundary?.index ?? parent.end };
48
+ }
49
+
50
+ function mapping(entries, parent, key, at) {
51
+ const node = child(entries, parent, key, at);
52
+ if (node.value.trim() && !node.value.trim().startsWith('#')) throw new Error(`${at}.${key}: expected a block mapping`);
53
+ return node;
54
+ }
55
+
56
+ function scalar(entries, parent, key, at) {
57
+ const node = child(entries, parent, key, at);
58
+ if (!node.value.trim() || node.value.trim().startsWith('#')) throw new Error(`${at}.${key}: expected a scalar value`);
59
+ return node;
60
+ }
61
+
62
+ function scalarParts(value) {
63
+ const quote = value[0] === "'" || value[0] === '"' ? value[0] : null;
64
+ let end = quote ? value.indexOf(quote, 1) : 0;
65
+ if (quote && end < 1) return null;
66
+ if (quote) end++;
67
+ else {
68
+ while (end < value.length && value[end].trim() && !['#', "'", '"'].includes(value[end])) end++;
69
+ }
70
+ if (!end) return null;
71
+ let tail = end;
72
+ while (value[tail] === ' ' || value[tail] === '\t') tail++;
73
+ if (tail < value.length && (tail === end || value[tail] !== '#')) return null;
74
+ return { value: quote ? value.slice(1, end - 1) : value.slice(0, end), suffix: value.slice(end) };
75
+ }
76
+
77
+ function packageValue(value) {
78
+ return scalarParts(value)?.value;
79
+ }
80
+
81
+ function replaceScalar(lines, node, value) {
82
+ const parts = scalarParts(node.value);
83
+ if (!parts) throw new Error(`Cannot replace malformed scalar ${node.key}`);
84
+ lines[node.index] = `${node.prefix}${value}${parts.suffix}${lineEnding(lines[node.index])}`;
85
+ }
86
+
87
+ function bindingNode(entries, root, row) {
88
+ const at = `profiles.${row.profile}.${row.binding}`;
89
+ let node = mapping(entries, root, 'profiles', 'document');
90
+ node = mapping(entries, node, row.profile, 'profiles');
91
+ for (const key of row.binding.split('.')) node = mapping(entries, node, key, at);
92
+ const declared = scalar(entries, node, 'package', at);
93
+ if (packageValue(declared.value) !== row.package) throw new Error(`${at}: package does not match ${row.package}`);
94
+ return { node, at };
95
+ }
96
+
97
+ /**
98
+ * Render release version and digest updates at their exact profile binding paths without reserializing authored YAML.
99
+ * Unrelated bytes, comments and newline style are preserved. Missing or duplicate mapping paths, unexpected packages,
100
+ * incomplete digest blocks and invalid replacement identities throw before any caller writes the returned text.
101
+ * @param {string} original - The authored code-pattern profile YAML.
102
+ * @param {Array<{profile: string, binding: string, package: string, to: {version: string, value: string, files: number}}>} rows - Exact canon or family binding identities to replace.
103
+ * @returns {string} Updated profile YAML; the input is returned unchanged when no rows are supplied.
104
+ */
105
+ export function renderCanonBindingChanges(original, rows) {
106
+ if (typeof original !== 'string' || !Array.isArray(rows)) throw new Error('canon binding changes need YAML text and identity rows');
107
+ const lines = linesOf(original);
108
+ const entries = lines.map((line, index) => lineEntry(line, index)).filter(Boolean);
109
+ const root = { start: -1, end: lines.length, indent: -1 };
110
+ const selected = new Set();
111
+ for (const row of rows) {
112
+ if (!KEY.test(row?.profile ?? '') || !supportedBinding(row?.binding) || typeof row?.package !== 'string' || !row.package) throw new Error('canon binding changes need a supported profile, package and binding path');
113
+ const identity = `${row.profile}.${row.binding}`;
114
+ if (selected.has(identity)) throw new Error(`${identity}: duplicate binding change`);
115
+ selected.add(identity);
116
+ if (!/^(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)\.(?:0|[1-9]\d*)(?:-[0-9A-Za-z.-]+)?$/.test(row.to?.version ?? '') || !/^[a-f0-9]{64}$/i.test(row.to?.value ?? '')
117
+ || !Number.isSafeInteger(row.to?.files) || row.to.files < 1) throw new Error(`${identity}: invalid replacement version or digest`);
118
+ const { node, at } = bindingNode(entries, root, row);
119
+ const version = scalar(entries, node, 'version', at);
120
+ const digest = mapping(entries, node, 'contentDigest', at);
121
+ const value = scalar(entries, digest, 'value', `${at}.contentDigest`);
122
+ const files = scalar(entries, digest, 'files', `${at}.contentDigest`);
123
+ replaceScalar(lines, version, row.to.version);
124
+ // A hexadecimal digest can contain digits only; quoting preserves its string identity in YAML.
125
+ replaceScalar(lines, value, `'${row.to.value}'`);
126
+ replaceScalar(lines, files, row.to.files);
127
+ }
128
+ return lines.join('');
129
+ }
@@ -51,7 +51,7 @@ export const REFERENCE_EXAMPLE_APP = 'full-app';
51
51
 
52
52
  /** The grammar css families a product repository may declare (`[data-grammar-family="<id>"]`); the
53
53
  * runtime's own product maps to the core sheet. */
54
- export const GRAMMAR_FAMILIES = Object.freeze({ starci: 'core', nivo: 'nivo' });
54
+ export const GRAMMAR_FAMILIES = Object.freeze({ starci: 'core', nivo: 'nivo', 'offset-pop': 'offset-pop' });
55
55
 
56
56
  /** A feature-folder segment that is only the product's own name - no useful leaf (display-names pathLabel). */
57
57
  export const PRODUCT_NAME_SEGMENT = /^(nivo|starci|mia)[-\w]*$/i;
@@ -0,0 +1,48 @@
1
+ // Grammar-family lint selection reads the family catalog; an absent selection never chooses a family.
2
+ import fs from 'node:fs';
3
+ import { parseYamlCached } from './yaml-cached.mjs';
4
+
5
+ const CATALOG_FILE = new URL('../../knowledge/grammars/index.yaml', import.meta.url);
6
+
7
+ function selectedFamily(family) {
8
+ if (typeof family !== 'string' || !family.trim()) throw new Error('sides.fe.grammar must explicitly name the selected Grammar family');
9
+ const catalog = parseYamlCached(fs.readFileSync(CATALOG_FILE, 'utf8'));
10
+ const matches = (catalog.families ?? []).filter(entry => entry.id === family);
11
+ if (matches.length !== 1) throw new Error(`Grammar family ${family} has no declared lint contract in knowledge/grammars/index.yaml`);
12
+ return { catalog, selected: matches[0] };
13
+ }
14
+
15
+ /** The explicitly selected authored family entry; callers share the catalog lookup rather than inferring a family. */
16
+ export function grammarFamilyFor(family) {
17
+ return Object.freeze(selectedFamily(family).selected);
18
+ }
19
+
20
+ /** The declared family's lint contract, including an explicit null package when that family has no rules. */
21
+ export function grammarLintFor(family) {
22
+ const { selected } = selectedFamily(family);
23
+ if (!selected.lint || !Object.hasOwn(selected.lint, 'package')) {
24
+ throw new Error(`Grammar family ${family} has no declared lint contract in knowledge/grammars/index.yaml`);
25
+ }
26
+ const lint = selected.lint;
27
+ if (lint.package === null) return Object.freeze({ family, package: null });
28
+ if (typeof lint.package !== 'string' || !lint.package.startsWith('@starci/eslint-grammar-')
29
+ || typeof lint.factory !== 'string' || !/^[a-zA-Z][a-zA-Z0-9]*$/.test(lint.factory)
30
+ || typeof lint.prefix !== 'string' || !/^[a-z][a-z0-9-]*$/.test(lint.prefix)) {
31
+ throw new Error(`Grammar family ${family} has an invalid lint contract in knowledge/grammars/index.yaml`);
32
+ }
33
+ return Object.freeze({ family, package: lint.package, factory: lint.factory, prefix: lint.prefix });
34
+ }
35
+
36
+ /** The selected family's shared component/export contract, resolved through its catalog reference. */
37
+ export function grammarContractFor(family) {
38
+ const { catalog, selected } = selectedFamily(family);
39
+ const owners = (catalog.families ?? []).filter(entry => entry.id === selected.contractRef);
40
+ const contract = owners.length === 1 ? owners[0].componentContract : null;
41
+ if (!contract || typeof contract.package !== 'string' || !contract.package
42
+ || typeof contract.entry !== 'string' || !contract.entry.startsWith(`${contract.package}/`)
43
+ || typeof contract.styleEntry !== 'string' || !contract.styleEntry.startsWith(`${contract.package}/`)
44
+ || !Array.isArray(contract.peers) || !contract.peers.every(peer => typeof peer === 'string' && peer.trim())) {
45
+ throw new Error(`Grammar family ${family} has no valid component contract in knowledge/grammars/index.yaml`);
46
+ }
47
+ return Object.freeze({ ...contract, peers: Object.freeze([...contract.peers]) });
48
+ }
@@ -31,105 +31,29 @@
31
31
  import { createHash } from 'node:crypto';
32
32
  import { cssRules, ruleFamilies, selectorAttributes, selectorClasses, subjectCompound } from './css-rules.mjs';
33
33
  import { byCodeUnit } from './list.mjs';
34
+ import { expressionStrings, heroTags, importAliases, jsxTags, vendorRenderRoots } from './grammar-vendor-source.mjs';
35
+
36
+ export { heroTags, importAliases } from './grammar-vendor-source.mjs';
34
37
 
35
38
  export const CODE = 'GRAMMAR_VENDOR_PARITY';
36
39
  export const WRAPPERS_SCHEMA = 'starci/vendor-heroui-wrappers@1';
37
40
 
41
+ /** Which measured roles name component contracts: ARIA widgets/windows or an exact public structural role API. */
42
+ export function classifyRoleContracts({ carriers, publicApis, ariaRoles }) {
43
+ const apis = new Map(publicApis.filter((name) => ariaRoles.has(name.toLowerCase())).map((name) => [name.toLowerCase(), name]));
44
+ const all = new Map(carriers);
45
+ for (const [role] of apis) if (!all.has(role)) all.set(role, []);
46
+ return Object.fromEntries([...all].sort(([a], [b]) => byCodeUnit(a, b)).map(([role, contracts]) => {
47
+ const definition = ariaRoles.get(role);
48
+ const componentRole = definition?.superClass.some((chain) => chain.some((category) => ['widget', 'window', 'composite'].includes(category))) || apis.has(role);
49
+ return [role, { componentRole: Boolean(componentRole), contracts: [...new Set(contracts)].sort(byCodeUnit), ...(apis.has(role) ? { publicApi: apis.get(role) } : {}) }];
50
+ }));
51
+ }
52
+
38
53
  /** The sha256 of a pinned file's text, line endings normalised. */
39
54
  export const manifestDigest = (text) => createHash('sha256').update(String(text ?? '').replaceAll('\r\n', '\n')).digest('hex');
40
55
 
41
56
  const finding = (rule, file, line, message) => ({ code: CODE, rule, file, line, message: `${rule}: ${message}` });
42
- const lineOf = (text, index) => text.slice(0, index).split('\n').length;
43
-
44
- // ---------------------------------------------------------------------------
45
- // Reading JSX without a parser: the opening tags of the aliases a file imports from @heroui/react.
46
- // ---------------------------------------------------------------------------
47
-
48
- /** The index after the quoted string that starts at `start`. */
49
- function skipQuoted(text, start) {
50
- const quote = text[start];
51
- let index = start + 1;
52
- while (index < text.length && text[index] !== quote) index += text[index] === '\\' ? 2 : 1;
53
- return index + 1;
54
- }
55
-
56
- /** The index after the template literal that starts at `start`, `${...}` substitutions included. */
57
- function skipTemplate(text, start) {
58
- let index = start + 1;
59
- while (index < text.length && text[index] !== '`') {
60
- if (text[index] === '\\') index += 2;
61
- else if (text[index] === '$' && text[index + 1] === '{') index = skipBraces(text, index + 1);
62
- else index += 1;
63
- }
64
- return index + 1;
65
- }
66
-
67
- /** The index after the balanced `{...}` that starts at `start`. */
68
- function skipBraces(text, start) {
69
- let depth = 0;
70
- let index = start;
71
- while (index < text.length) {
72
- const char = text[index];
73
- if (char === '"' || char === "'") index = skipQuoted(text, index);
74
- else if (char === '`') index = skipTemplate(text, index);
75
- else {
76
- if (char === '{') depth += 1;
77
- if (char === '}') depth -= 1;
78
- index += 1;
79
- if (depth === 0 && char === '}') return index;
80
- }
81
- }
82
- return index;
83
- }
84
-
85
- /** The index after the value (a string, a template or a braced expression) that starts at `start`. */
86
- function skipValue(text, start) {
87
- const char = text[start];
88
- if (char === '"' || char === "'") return skipQuoted(text, start);
89
- if (char === '`') return skipTemplate(text, start);
90
- return char === '{' ? skipBraces(text, start) : start + 1;
91
- }
92
-
93
- /** Attributes of the opening tag whose name ended at `from`: `{name, raw}` (a spread is named `...`). */
94
- function readAttributes(text, from) {
95
- const attrs = [];
96
- let index = from;
97
- while (index < text.length) {
98
- while (/\s/.test(text[index] ?? '')) index += 1;
99
- if (text[index] === '>' || text.startsWith('/>', index)) break;
100
- if (text[index] === '{') {
101
- const end = skipBraces(text, index);
102
- attrs.push({ name: '...', raw: text.slice(index + 1, end - 1) });
103
- index = end;
104
- continue;
105
- }
106
- const name = /^[\w:-]+/.exec(text.slice(index, index + 80))?.[0];
107
- if (!name) break;
108
- index += name.length;
109
- if (text[index] !== '=') {
110
- attrs.push({ name, raw: 'true' });
111
- continue;
112
- }
113
- index += 1;
114
- const end = skipValue(text, index);
115
- const raw = text.slice(index, end);
116
- attrs.push({ name, raw: raw.startsWith('{') ? raw.slice(1, -1) : raw });
117
- index = end;
118
- }
119
- return attrs;
120
- }
121
-
122
- /** The opening tags of the given import aliases: `{alias, part, attrs, line}` (`part` is `Root` for the bare alias). */
123
- export function heroTags(text, aliases) {
124
- if (!aliases.length) return [];
125
- const pattern = new RegExp(String.raw`<(${aliases.join('|')})(?:\.(\w+))?(?=[\s/>])`, 'g');
126
- return [...text.matchAll(pattern)].map((match) => ({
127
- alias: match[1],
128
- part: match[2] ?? 'Root',
129
- attrs: readAttributes(text, match.index + match[0].length),
130
- line: lineOf(text, match.index),
131
- }));
132
- }
133
57
 
134
58
  /** The string literals of an expression, without their quotes. */
135
59
  const stringLiterals = (raw) => (String(raw).match(/"[^"\n]*"|'[^'\n]*'|`[^`$]*`/g) ?? []).map((literal) => literal.slice(1, -1));
@@ -154,22 +78,8 @@ function classTokens(text, attrs) {
154
78
  const ACCESSIBILITY_MEDIA = /forced-colors|prefers-contrast|pointer:\s*coarse/;
155
79
  const OVERRIDE_KINDS = ['slot', 'style', 'default', 'role', 'canon'];
156
80
  const PARITY_MODES = ['strict', 'custom'];
157
- /** The ARIA roles HeroUI has a component for: a Grammar file that writes one by hand has hand-rolled that component. */
158
- const HEROUI_ROLES = new Set(['menu', 'menuitem', 'menuitemradio', 'menuitemcheckbox', 'listbox', 'option', 'combobox', 'dialog', 'alertdialog', 'tablist', 'tab', 'tabpanel', 'switch', 'slider', 'checkbox', 'radio', 'radiogroup']);
159
81
  const overrideKey = (kind, target) => `${kind}:${target}`;
160
82
 
161
- /** The vendor imports of a source: alias -> vendor component name. */
162
- export function importAliases(text) {
163
- const aliases = new Map();
164
- for (const match of text.matchAll(/import\s*\{([^}]*)\}\s*from\s*["']@heroui\/react["']/g)) {
165
- for (const entry of match[1].split(',').map((part) => part.trim()).filter(Boolean)) {
166
- const found = /^(?!type\s)(\w+)(?:\s+as\s+(\w+))?$/.exec(entry);
167
- if (found) aliases.set(found[2] ?? found[1], found[1]);
168
- }
169
- }
170
- return aliases;
171
- }
172
-
173
83
  /**
174
84
  * The declared overrides as a ledger: `allowed(kind, target)` answers and remembers the use, so an override nothing needs is found stale.
175
85
  * A `canon` override records a StarCi canon decision that is not vendor drift (the glyph size of the icon rule) for the owner; it permits nothing.
@@ -208,10 +118,16 @@ function grammarFindings(grammars, files) {
208
118
  return [...found, ...files.filter((file) => grammarOf(file, grammars) === undefined).map((file) => finding('grammar', file, 0, 'belongs to no declared Grammar: declare it in the grammars of the wrapper manifest'))];
209
119
  }
210
120
 
211
- /** The ARIA roles a file writes by hand in JSX (comments excluded) that HeroUI has a component for. */
212
- function roleFindings({ file, text, allowed }) {
213
- const code = text.replaceAll(/\/\*[\s\S]*?\*\//g, (comment) => comment.replaceAll(/[^\n]/g, ' ')).replaceAll(/\/\/[^\n]*/g, '');
214
- const roles = [...code.matchAll(/\srole=["']([a-z]+)["']/g)].map((match) => match[1]).filter((role) => HEROUI_ROLES.has(role));
121
+ /** The JSX roles a file writes by hand that the measured vendor references expose. */
122
+ function roleFindings({ file, text, allowed, vendorRoles, roleDeclarations }) {
123
+ const vendorAliases = importAliases(text);
124
+ const vendorRenders = vendorRenderRoots(text, vendorAliases, roleDeclarations);
125
+ const vendorOwnsRole = (tag, role) => {
126
+ const owner = vendorAliases.get(tag.alias);
127
+ const declaration = roleDeclarations[role];
128
+ return owner !== undefined && (declaration?.contracts.includes(`${owner}.${tag.part}`) || (tag.part === 'Root' && declaration?.publicApi === owner));
129
+ };
130
+ 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));
215
131
  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})`));
216
132
  }
217
133
 
@@ -347,10 +263,14 @@ function cssFindings({ styles, references, stamps, allowed, strictOf }) {
347
263
  * sources [{file, text}] Grammar .ts/.tsx (stories excluded), file relative to packages/grammar/src
348
264
  * styles [{file, text}] every Grammar .css; a sheet is judged only when its Grammar declares `parity: strict`
349
265
  */
350
- export function parityFindings({ references, manifest, sources, styles }) {
266
+ export function parityFindings({ references: vendorReferences, manifest, sources, styles }) {
351
267
  if (manifest?.schema !== WRAPPERS_SCHEMA) return [finding('manifest', 'knowledge/vendor/heroui/wrappers.yaml', 0, `schema must be ${WRAPPERS_SCHEMA}`)];
352
268
  const wrappers = Array.isArray(manifest.wrappers) ? manifest.wrappers : [];
269
+ const roleReference = vendorReferences.roles;
270
+ if (roleReference?.schema !== 'starci/vendor-heroui-roles@1' || !roleReference.roles || typeof roleReference.roles !== 'object' || Array.isArray(roleReference.roles) || Object.keys(roleReference.roles).length === 0 || Object.values(roleReference.roles).some((entry) => !entry || typeof entry.componentRole !== 'boolean' || !Array.isArray(entry.contracts))) return [finding('reference', 'knowledge/vendor/heroui/roles.yaml', 0, 'the generated vendor role index is missing or malformed: regenerate the vendor reference')];
271
+ const references = Object.fromEntries(Object.entries(vendorReferences).filter(([, reference]) => reference.component));
353
272
  const byName = new Map(Object.entries(references).map(([dir, reference]) => [reference.component, dir]));
273
+ const vendorRoles = new Set(Object.entries(vendorReferences.roles?.roles ?? {}).filter(([, declaration]) => declaration.componentRole).map(([role]) => role));
354
274
  const rows = new Map(wrappers.map((row) => [row.file, row]));
355
275
  const known = new Set(sources.map((source) => source.file));
356
276
  const { allowed, stale, problems } = declaredOverrides(wrappers);
@@ -362,7 +282,7 @@ export function parityFindings({ references, manifest, sources, styles }) {
362
282
  ...problems,
363
283
  ...sources.flatMap(({ file, text }) => [
364
284
  ...wrapperFindings({ file, text, row: rows.get(file), references, byName, allowed, strict: strictOf(file) }),
365
- ...roleFindings({ file, text, allowed }),
285
+ ...roleFindings({ file, text, allowed, vendorRoles, roleDeclarations: roleReference.roles }),
366
286
  ]),
367
287
  ...cssFindings({ styles, references, stamps: stampsOf(sources, rows, byName, references, strictOf), allowed, strictOf }),
368
288
  ...stale(),