@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.
Files changed (54) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/README.md +8 -11
  3. package/codemod/button-intent.mjs +356 -0
  4. package/lint/run.mjs +70 -29
  5. package/package.json +3 -2
  6. package/runtime/knowledge/grammars/index.yaml +111 -0
  7. package/runtime/knowledge/grammars/offset-pop/playbook.yaml +302 -0
  8. package/runtime/knowledge/grammars/starci/playbook.yaml +1190 -0
  9. package/runtime/knowledge/hfs/canon-pins.yaml +22 -8
  10. package/runtime/knowledge/hfs/fe-stack.yaml +12 -4
  11. package/runtime/knowledge/hfs/rules.yaml +163 -83
  12. package/runtime/knowledge/hfs/slots.yaml +3 -3
  13. package/runtime/knowledge/ui/presentation/font.yaml +101 -0
  14. package/runtime/knowledge/ui/presentation/padding.yaml +217 -0
  15. package/runtime/modules/kernel/failure-codes.yaml +102 -20
  16. package/runtime/modules/kernel/removed-vocabulary.yaml +29 -0
  17. package/runtime/modules/kernel/roles.yaml +1 -1
  18. package/runtime/packages/cli/src/catalog.generated.mjs +119 -9
  19. package/runtime/scripts/gates/canon-pins.mjs +103 -0
  20. package/runtime/scripts/gates/gate.mjs +3 -36
  21. package/runtime/scripts/gates/output.mjs +40 -0
  22. package/runtime/scripts/gates/test-world-run.mjs +2 -4
  23. package/runtime/scripts/guards/release-definition.mjs +22 -3
  24. package/runtime/scripts/hfs/architecture/config.mjs +8 -8
  25. package/runtime/scripts/hfs/check.mjs +1 -1
  26. package/runtime/scripts/hfs/db-restamp-verb.mjs +2 -2
  27. package/runtime/scripts/hfs/declaration-shape.mjs +8 -2
  28. package/runtime/scripts/hfs/grammar-presentation.mjs +152 -0
  29. package/runtime/scripts/hfs/rule-catalog.mjs +2 -2
  30. package/runtime/scripts/hfs/rules/a15-findings.mjs +1 -0
  31. package/runtime/scripts/hfs/rules/fe-stack.mjs +44 -1
  32. package/runtime/scripts/hfs/slots.mjs +1 -0
  33. package/runtime/scripts/hfs/view.mjs +1 -0
  34. package/runtime/scripts/lib/canon-binding-text.mjs +129 -0
  35. package/runtime/scripts/lib/css-rules.mjs +18 -0
  36. package/runtime/scripts/lib/example-refs.mjs +1 -1
  37. package/runtime/scripts/lib/grammar-lint.mjs +48 -0
  38. package/runtime/scripts/lib/grammar-vendor-parity-jsx.mjs +360 -0
  39. package/runtime/scripts/lib/grammar-vendor-parity.mjs +110 -157
  40. package/runtime/scripts/lib/numeric-match.mjs +5 -0
  41. package/runtime/scripts/lib/release-version.mjs +119 -0
  42. package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
  43. package/runtime/scripts/lib/source-package-imports.mjs +111 -0
  44. package/runtime/scripts/work/draw/draw-layer.mjs +5 -4
  45. package/scaffold/app.mjs +7 -5
  46. package/src/main.mjs +30 -15
  47. package/stack/config.mjs +2 -1
  48. package/stack/render.mjs +24 -3
  49. package/sync/index.mjs +8 -2
  50. package/templates/app/skeleton/.starcistacks/dev/infra/compose/stack.yaml +0 -1
  51. package/templates/app/skeleton/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  52. package/templates/app/skeleton-lite/.starcistacks/dev/infra/swarm/stack.yaml +0 -1
  53. package/templates/app/skeleton-lite/.starcistacks/production/infra/swarm/stack.yaml +0 -1
  54. package/templates/fe/tool-config/eslint.config.mjs +4 -1
@@ -8,143 +8,50 @@
8
8
  //
9
9
  // Two layers. The UNIVERSAL layer binds every Grammar built on HeroUI: use the HeroUI component for each role (no hand-rolled menu, list
10
10
  // box, dialog or switch: `role`), keep its anatomy and parts (`anatomy`, `undeclared`). The PER-GRAMMAR layer is what a Grammar declares in the
11
- // manifest (`grammars: [{id, paths, vendor: {name: heroui, parity}}]`, never inferred from its code): `strict` keeps HeroUI's default CSS and
11
+ // source DNA (`paths`, `vendor: {name: heroui, parity}`, `arrangement`, never inferred from its CSS): `strict` keeps HeroUI's default CSS and
12
12
  // arrangement (`slot`, `style`, `default`, `stale`; the pinned overrides are the only deviations), `custom` may re-skin and re-arrange
13
13
  // (tokens, radius, padding, colour, where the indicator sits, separators, icon size) and is judged by the universal layer only.
14
14
  // Grammar StarCi is strict.
15
15
  //
16
16
  // A finding is red when
17
- // anatomy a wrapper renders a part the vendor does not have, omits a part the vendor requires, or omits the vendor's embedded
18
- // divider (`Separator`) while it renders a `Section`;
17
+ // anatomy a wrapper renders a part the vendor does not have, omits a part the vendor requires or a slot its row claims,
18
+ // or omits the vendor's embedded divider (`Separator`) while it renders a `Section`;
19
19
  // undeclared a Grammar file wraps a HeroUI component its manifest row does not declare (or the row names a file that is gone);
20
20
  // slot Grammar CSS targets a vendor class or `data-slot` no override declares;
21
- // style (rows with `styleParity: true`) Grammar CSS restyles a paint family (padding, radius, colour, size, ...) of a vendor part
21
+ // style Grammar CSS restyles a paint family (padding, radius, colour, size, ...) of any wrapped vendor part
22
22
  // through a class or `data-grammar-*` stamp that wrapper puts on that part, and no override declares it (accessibility
23
23
  // media - forced colors, the touch target floor - is not judged);
24
24
  // default a wrapper passes a vendor part a prop value other than the vendor default (the dot indicator in place of the check);
25
25
  // role a Grammar file hand-rolls an ARIA role HeroUI already has a component for (unless a pinned `role` override declares it);
26
26
  // grammar a Grammar file belongs to no declared Grammar, or a declaration is malformed;
27
27
  // stale an override nothing needs any more.
28
- // A StarCi canon decision that is not drift (the glyph size of the icon rule) is declared as a `canon` override: justified, pinned, and it
29
- // permits nothing. Family skins (`offset-pop`, `heritage`) are brand directions that re-skin HeroUI on purpose and are not judged; the
30
- // base look (`common`, `core`) is.
28
+ // Arrangement choices (including glyph size) live in the family's DNA and never authorize vendor paint overrides.
29
+ // Custom family skins keep the universal component/anatomy/role contract; strict families also keep vendor paint defaults.
31
30
  import { createHash } from 'node:crypto';
32
- import { cssRules, ruleFamilies, selectorAttributes, selectorClasses, subjectCompound } from './css-rules.mjs';
31
+ import { cssRules, propertyFamily, repaintFamilies, selectorAttributes, selectorClasses, subjectCompound, utilityFamilies } from './css-rules.mjs';
33
32
  import { byCodeUnit } from './list.mjs';
33
+ import { classTokens, heroTags, importAliases, roleFindings, stringLiterals } from './grammar-vendor-parity-jsx.mjs';
34
+ export { heroTags, importAliases } from './grammar-vendor-parity-jsx.mjs';
34
35
 
35
36
  export const CODE = 'GRAMMAR_VENDOR_PARITY';
36
37
  export const WRAPPERS_SCHEMA = 'starci/vendor-heroui-wrappers@1';
37
38
 
38
- /** The sha256 of a pinned file's text, line endings normalised. */
39
- export const manifestDigest = (text) => createHash('sha256').update(String(text ?? '').replaceAll('\r\n', '\n')).digest('hex');
40
-
41
- 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),
39
+ /** Which measured roles name component contracts: ARIA widgets/windows or an exact public structural role API. */
40
+ export function classifyRoleContracts({ carriers, publicApis, ariaRoles }) {
41
+ const apis = new Map(publicApis.filter((name) => ariaRoles.has(name.toLowerCase())).map((name) => [name.toLowerCase(), name]));
42
+ const all = new Map(carriers);
43
+ for (const [role] of apis) if (!all.has(role)) all.set(role, []);
44
+ return Object.fromEntries([...all].sort(([a], [b]) => byCodeUnit(a, b)).map(([role, contracts]) => {
45
+ const definition = ariaRoles.get(role);
46
+ const componentRole = definition?.superClass.some((chain) => chain.some((category) => ['widget', 'window', 'composite'].includes(category))) || apis.has(role);
47
+ return [role, { componentRole: Boolean(componentRole), contracts: [...new Set(contracts)].sort(byCodeUnit), ...(apis.has(role) ? { publicApi: apis.get(role) } : {}) }];
131
48
  }));
132
49
  }
133
50
 
134
- /** The string literals of an expression, without their quotes. */
135
- const stringLiterals = (raw) => (String(raw).match(/"[^"\n]*"|'[^'\n]*'|`[^`$]*`/g) ?? []).map((literal) => literal.slice(1, -1));
51
+ /** The sha256 of a pinned file's text, line endings normalised. */
52
+ export const manifestDigest = (text) => createHash('sha256').update(String(text ?? '').replaceAll('\r\n', '\n')).digest('hex');
136
53
 
137
- /** The class tokens an element carries: the literals of its className, with a bare constant resolved one level in the file. */
138
- function classTokens(text, attrs) {
139
- const raw = attrs.find((attr) => attr.name === 'className')?.raw;
140
- if (raw === undefined) return [];
141
- const names = String(raw).match(/\b[A-Za-z_]\w*\b/g) ?? [];
142
- const resolved = names.flatMap((name) => {
143
- const declaration = new RegExp(String.raw`\bconst\s+${name}\s*=\s*([^\n]*(?:\n[^\n;]*)?)`).exec(text);
144
- return declaration ? stringLiterals(declaration[1]) : [];
145
- });
146
- return [...stringLiterals(raw), ...resolved].flatMap((value) => value.split(/\s+/)).filter(Boolean);
147
- }
54
+ const finding = (rule, file, line, message) => ({ code: CODE, rule, file, line, message: `${rule}: ${message}` });
148
55
 
149
56
  // ---------------------------------------------------------------------------
150
57
  // The judgment.
@@ -152,27 +59,13 @@ function classTokens(text, attrs) {
152
59
 
153
60
  /** Accessibility adaptations of the user's input or display (forced colours, contrast, the touch target floor) are not restyles of the vendor look. */
154
61
  const ACCESSIBILITY_MEDIA = /forced-colors|prefers-contrast|pointer:\s*coarse/;
155
- const OVERRIDE_KINDS = ['slot', 'style', 'default', 'role', 'canon'];
62
+ const OVERRIDE_KINDS = ['slot', 'style', 'default', 'role'];
156
63
  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
64
  const overrideKey = (kind, target) => `${kind}:${target}`;
160
65
 
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
66
  /**
174
67
  * The declared overrides as a ledger: `allowed(kind, target)` answers and remembers the use, so an override nothing needs is found stale.
175
- * 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.
68
+ * Arrangement belongs in DNA, so it cannot grant permission through the override ledger.
176
69
  */
177
70
  function declaredOverrides(wrappers) {
178
71
  const declared = new Map();
@@ -190,32 +83,55 @@ function declaredOverrides(wrappers) {
190
83
  return declared.has(key);
191
84
  };
192
85
  const stale = () => [...declared]
193
- .filter(([key]) => !key.startsWith('canon:') && !used.has(key))
86
+ .filter(([key]) => !used.has(key))
194
87
  .map(([key, file]) => finding('stale', file, 0, `the override ${key} is declared but nothing needs it: remove it`));
195
88
  return { allowed, stale, problems };
196
89
  }
197
90
 
198
91
  /** The Grammar a file belongs to (the longest declared path prefix), or undefined. */
199
92
  const grammarOf = (file, grammars) => grammars
200
- .flatMap((grammar) => (grammar.paths ?? []).filter((prefix) => file.startsWith(prefix)).map((prefix) => ({ grammar, length: prefix.length })))
93
+ .flatMap((grammar) => (Array.isArray(grammar.paths) ? grammar.paths : []).filter((prefix) => typeof prefix === 'string' && file.startsWith(prefix)).map((prefix) => ({ grammar, length: prefix.length })))
201
94
  .sort((a, b) => b.length - a.length)[0]?.grammar;
202
95
 
203
96
  /** The malformed Grammar declarations and the files that belong to no declared Grammar. */
204
97
  function grammarFindings(grammars, files) {
98
+ const validValue = (value) => typeof value === 'string' ? value.length > 0 : value !== null && typeof value === 'object' && !Array.isArray(value) && Object.keys(value).length > 0 && Object.values(value).every(validValue);
99
+ const validPaths = (paths) => Array.isArray(paths) && paths.length > 0 && paths.every((prefix) => typeof prefix === 'string' && /^(?:[\w-]+\/)+$/.test(prefix));
205
100
  const found = grammars
206
- .filter((grammar) => !grammar.id || !Array.isArray(grammar.paths) || grammar.vendor?.name !== 'heroui' || !PARITY_MODES.includes(grammar.vendor?.parity))
207
- .map((grammar) => finding('grammar', 'knowledge/vendor/heroui/wrappers.yaml', 0, `the Grammar ${grammar.id ?? '?'} declares an id, its paths and vendor: {name: heroui, parity: ${PARITY_MODES.join(' | ')}}`));
208
- 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'))];
101
+ .filter((grammar) => !grammar.id || !validPaths(grammar.paths) || grammar.vendor?.name !== 'heroui' || !PARITY_MODES.includes(grammar.vendor?.parity) || !['indicatorSide', 'separators', 'iconSize', 'density'].every((key) => validValue(grammar.arrangement?.[key])) || grammar.declarationError)
102
+ .map((grammar) => {
103
+ const error = grammar.declarationError ? ': ' + grammar.declarationError : '';
104
+ return finding('grammar', grammar.dnaFile ?? 'packages/grammar/src', 0, `the Grammar ${grammar.id ?? '?'} declares an id, its paths, vendor: {name: heroui, parity: ${PARITY_MODES.join(' | ')}} and arrangement: {indicatorSide, separators, iconSize, density} in its DNA${error}`);
105
+ });
106
+ return [...found, ...files.filter((file) => grammarOf(file, grammars) === undefined).map((file) => finding('grammar', file, 0, 'belongs to no declared Grammar: declare its owned paths, vendor parity and arrangement in its DNA'))];
209
107
  }
210
108
 
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));
215
- 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})`));
109
+ /** The compound part that supplies a claimed vendor slot, including nested slots supplied by a structural ancestor. */
110
+ function ownerOfSlot(reference, target) {
111
+ const parts = Object.entries(reference.parts);
112
+ if (!target.startsWith('.')) return parts.find(([, part]) => part.dataSlot === target)?.[0];
113
+ const cls = target.slice(1);
114
+ const slots = Object.entries(reference.anatomy.slots ?? {}).flatMap(([key, classes]) => {
115
+ const part = parts.map(([name]) => name).filter((name) => key.toLowerCase().endsWith(name.toLowerCase())).sort((a, b) => b.length - a.length)[0];
116
+ return part ? String(classes).split(/\s+/).map((name) => ({ cls: name, part })) : [];
117
+ });
118
+ // A List implementation may mention all its container's internal classes. The
119
+ // named compound ancestor (ListContainer) still has to be mounted to emit them.
120
+ const ancestor = slots.filter((slot) => cls === slot.cls || cls.startsWith(`${slot.cls}__`)).sort((a, b) => b.cls.length - a.cls.length)[0];
121
+ return ancestor?.part ?? parts.find(([, part]) => part.classes?.includes(cls))?.[0];
122
+ }
123
+
124
+ /** Slots claimed by the wrapper row need their vendor compound part, even when that part is optional in the vendor API. */
125
+ function missingSlotFindings({ file, name, reference, row, rendered, delegates }) {
126
+ return (row.overrides ?? []).filter((entry) => entry?.kind === 'slot' && typeof entry.target === 'string').flatMap((entry) => {
127
+ const part = ownerOfSlot(reference, entry.target);
128
+ return part && !rendered.has(part) && !delegates.has(part)
129
+ ? [finding('anatomy', file, 1, `omits vendor slot ${entry.target} declared by its wrapper row: render ${name}.${part}, which supplies it (or delegate that part when the caller supplies it)`)]
130
+ : [];
131
+ });
216
132
  }
217
133
 
218
- /** The part findings of one wrapped component: parts that are not the vendor's, required parts left out, the divider between sections. */
134
+ /** The part findings of one wrapped component: unknown parts, required parts or claimed slots left out, and section dividers. */
219
135
  function anatomyFindings({ file, name, reference, mine, row, tags, aliases }) {
220
136
  const found = [];
221
137
  for (const tag of mine) {
@@ -228,6 +144,7 @@ function anatomyFindings({ file, name, reference, mine, row, tags, aliases }) {
228
144
  for (const part of reference.anatomy.required.filter((candidate) => !rendered.has(candidate) && !delegates.has(candidate))) {
229
145
  found.push(finding('anatomy', file, 1, `omits ${name}.${part}, which HeroUI ${name} requires (render it, or declare it under delegates when the caller supplies it)`));
230
146
  }
147
+ found.push(...missingSlotFindings({ file, name, reference, row, rendered, delegates }));
231
148
  if (!rendered.has('Section')) return found;
232
149
  for (const embed of reference.anatomy.embeds.filter((entry) => entry.slot === 'separator')) {
233
150
  const separatorAliases = new Set([...aliases].filter(([, exported]) => exported === embed.component).map(([local]) => local));
@@ -275,16 +192,16 @@ function wrapperFindings({ file, text, row, references, byName, allowed, strict
275
192
  }
276
193
 
277
194
  /** The stamps (class tokens and `data-grammar-*` names) each wrapped vendor part carries: stamp -> [{name, part, dir, file}]. */
278
- function stampsOf(sources, rows, byName, references, strictOf) {
195
+ function stampsOf(sources, rows, byName, references, grammarFor) {
279
196
  const stamps = new Map();
280
197
  const add = (key, entry) => stamps.set(key, [...(stamps.get(key) ?? []), entry]);
281
- for (const { file, text } of sources.filter((source) => rows.get(source.file)?.styleParity && strictOf(source.file))) {
198
+ for (const { file, text } of sources.filter((source) => rows.has(source.file) && grammarFor(source.file)?.vendor?.parity === 'strict')) {
282
199
  const aliases = importAliases(text);
283
200
  for (const tag of heroTags(text, [...aliases.keys()])) {
284
201
  const name = aliases.get(tag.alias);
285
202
  const dir = byName.get(name);
286
203
  if (dir === undefined || !references[dir].parts[tag.part]) continue;
287
- const entry = { name, part: tag.part, dir, file };
204
+ const entry = { name, part: tag.part, dir, file, family: grammarFor(file).id };
288
205
  classTokens(text, tag.attrs).forEach((token) => add(`.${token}`, entry));
289
206
  tag.attrs.filter((attr) => attr.name.startsWith('data-grammar-')).forEach((attr) => add(`[${attr.name}]`, entry));
290
207
  }
@@ -306,34 +223,65 @@ function slotFindings({ file, selector, vendor, allowed }) {
306
223
  return found;
307
224
  }
308
225
 
226
+ /** The declarations and utilities that set one paint family, with their at-rule context intact. */
227
+ function familyValue(rule, family) {
228
+ return {
229
+ apply: (rule.apply ?? []).filter((utility) => utilityFamilies(utility).includes(family)),
230
+ declarations: (rule.declarations ?? []).map((entry) => {
231
+ if (typeof entry !== 'string') return entry;
232
+ const colon = entry.indexOf(':');
233
+ return { property: entry.slice(0, colon).trim(), value: entry.slice(colon + 1).trim() };
234
+ }).filter((entry) => propertyFamily(entry.property) === family),
235
+ wraps: rule.wraps ?? [],
236
+ };
237
+ }
238
+
239
+ /** Measured vendor rules for this part and paint family; preserve state/variant selectors instead of guessing a cascade. */
240
+ function vendorDefaults(reference, part, family) {
241
+ const declared = reference.parts[part];
242
+ const owns = (selector) => {
243
+ const subject = subjectCompound(selector);
244
+ return selectorClasses(subject).some((cls) => declared.classes?.includes(cls)) || selectorAttributes(subject).some((attribute) => attribute.name === 'data-slot' && attribute.value === declared.dataSlot);
245
+ };
246
+ return (reference.css.rules ?? []).flatMap((rule) => {
247
+ const selectors = rule.selectors.filter(owns);
248
+ const value = familyValue(rule, family);
249
+ return selectors.length && (value.apply.length || value.declarations.length) ? [{ file: rule.file, selectors, ...value }] : [];
250
+ });
251
+ }
252
+
309
253
  /** The paint families Grammar CSS repaints on a vendor part through a stamp of the subject compound, as style findings. */
310
- function styleFindings({ file, selector, families, stamps, references, allowed }) {
254
+ function styleFindings({ file, selector, families, stamps, references, allowed, rule, grammar }) {
311
255
  const found = [];
312
256
  const subject = subjectCompound(selector);
313
257
  const tokens = [...selectorClasses(subject).map((cls) => `.${cls}`), ...selectorAttributes(subject).map((attribute) => `[${attribute.name}]`)];
314
258
  for (const token of tokens) {
315
- for (const entry of stamps.get(token) ?? []) {
259
+ for (const entry of (stamps.get(token) ?? []).filter((candidate) => candidate.family === grammar.id)) {
316
260
  const paints = references[entry.dir].parts[entry.part].paints;
317
261
  for (const family of families.filter((candidate) => paints.includes(candidate))) {
318
262
  const target = `${entry.name}.${entry.part}:${family}`;
319
- if (!allowed('style', target)) found.push(finding('style', file, 0, `${selector} restyles ${family} of HeroUI ${entry.name}.${entry.part}, which the vendor decides (stamp ${token} on ${entry.file}; override target style:${target})`));
263
+ if (!allowed('style', target)) found.push({
264
+ ...finding('style', file, 0, `${selector} restyles ${family} of HeroUI ${entry.name}.${entry.part}, which the vendor decides (stamp ${token} on ${entry.file}; override target style:${target})`),
265
+ selector, part: `${entry.name}.${entry.part}`, property: family, wrapperRow: entry.file, family: grammar.id,
266
+ grammarValue: familyValue(rule, family), vendorDefault: vendorDefaults(references[entry.dir], entry.part, family),
267
+ });
320
268
  }
321
269
  }
322
270
  }
323
271
  return found;
324
272
  }
325
273
 
326
- function cssFindings({ styles, references, stamps, allowed, strictOf }) {
274
+ function cssFindings({ styles, references, stamps, allowed, grammarFor }) {
327
275
  const vendor = {
328
276
  classes: new Set(Object.values(references).flatMap((reference) => reference.css.classes)),
329
277
  slots: new Set(Object.values(references).flatMap((reference) => reference.css.dataSlots)),
330
278
  };
331
279
  const found = [];
332
- for (const { file, text } of styles.filter((sheet) => strictOf(sheet.file))) {
280
+ for (const { file, text } of styles.filter((sheet) => grammarFor(sheet.file)?.vendor?.parity === 'strict')) {
333
281
  for (const rule of cssRules(text)) {
334
- const families = rule.wraps.some((wrap) => ACCESSIBILITY_MEDIA.test(wrap)) ? [] : ruleFamilies(rule);
282
+ const families = rule.wraps.some((wrap) => ACCESSIBILITY_MEDIA.test(wrap)) ? [] : repaintFamilies(rule);
335
283
  for (const selector of rule.selectors) {
336
- found.push(...slotFindings({ file, selector, vendor, allowed }), ...(families.length ? styleFindings({ file, selector, families, stamps, references, allowed }) : []));
284
+ found.push(...slotFindings({ file, selector, vendor, allowed }), ...(families.length ? styleFindings({ file, selector, families, stamps, references, allowed, rule, grammar: grammarFor(file) }) : []));
337
285
  }
338
286
  }
339
287
  }
@@ -343,28 +291,33 @@ function cssFindings({ styles, references, stamps, allowed, strictOf }) {
343
291
  /**
344
292
  * Every GRAMMAR_VENDOR_PARITY finding.
345
293
  * references { <dir>: parsed vendor reference }
346
- * manifest the parsed wrapper manifest ({schema, wrappers: [{file, wraps, delegates, styleParity, overrides}]})
294
+ * manifest the parsed wrapper manifest ({schema, wrappers: [{file, wraps, delegates, overrides}]})
295
+ * grammars source DNA declarations ({id, paths, vendor, arrangement}), loaded separately from the manifest
347
296
  * sources [{file, text}] Grammar .ts/.tsx (stories excluded), file relative to packages/grammar/src
348
297
  * styles [{file, text}] every Grammar .css; a sheet is judged only when its Grammar declares `parity: strict`
349
298
  */
350
- export function parityFindings({ references, manifest, sources, styles }) {
299
+ export function parityFindings({ references: vendorReferences, manifest, sources, styles, grammars = [] }) {
351
300
  if (manifest?.schema !== WRAPPERS_SCHEMA) return [finding('manifest', 'knowledge/vendor/heroui/wrappers.yaml', 0, `schema must be ${WRAPPERS_SCHEMA}`)];
352
301
  const wrappers = Array.isArray(manifest.wrappers) ? manifest.wrappers : [];
302
+ const roleReference = vendorReferences.roles;
303
+ 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')];
304
+ const references = Object.fromEntries(Object.entries(vendorReferences).filter(([, reference]) => reference.component));
353
305
  const byName = new Map(Object.entries(references).map(([dir, reference]) => [reference.component, dir]));
306
+ const vendorRoles = new Set(Object.entries(vendorReferences.roles?.roles ?? {}).filter(([, declaration]) => declaration.componentRole).map(([role]) => role));
354
307
  const rows = new Map(wrappers.map((row) => [row.file, row]));
355
308
  const known = new Set(sources.map((source) => source.file));
356
309
  const { allowed, stale, problems } = declaredOverrides(wrappers);
357
- const grammars = Array.isArray(manifest.grammars) ? manifest.grammars : [];
358
- const strictOf = (file) => grammarOf(file, grammars)?.vendor?.parity === 'strict';
310
+ const grammarFor = (file) => grammarOf(file, grammars);
311
+ const strictOf = (file) => grammarFor(file)?.vendor?.parity === 'strict';
359
312
  return [
360
313
  ...grammarFindings(grammars, [...sources, ...styles].map((source) => source.file)),
361
314
  ...wrappers.filter((row) => !known.has(row.file)).map((row) => finding('undeclared', row.file, 0, 'the manifest names a file that does not exist')),
362
315
  ...problems,
363
316
  ...sources.flatMap(({ file, text }) => [
364
317
  ...wrapperFindings({ file, text, row: rows.get(file), references, byName, allowed, strict: strictOf(file) }),
365
- ...roleFindings({ file, text, allowed }),
318
+ ...roleFindings({ file, text, allowed, vendorRoles, roleDeclarations: roleReference.roles, finding }),
366
319
  ]),
367
- ...cssFindings({ styles, references, stamps: stampsOf(sources, rows, byName, references, strictOf), allowed, strictOf }),
320
+ ...cssFindings({ styles, references, stamps: stampsOf(sources, rows, byName, references, grammarFor), allowed, grammarFor }),
368
321
  ...stale(),
369
322
  ];
370
323
  }
@@ -0,0 +1,5 @@
1
+ /** Read the first numeric capture of a matching recognizer; unmatched values have no number. */
2
+ export function numericMatch(value, pattern) {
3
+ const match = pattern.exec(String(value ?? ''));
4
+ return match ? Number(match[1]) : null;
5
+ }
@@ -0,0 +1,119 @@
1
+ // release-version.mjs - the one reading of a release version (owner ruling release-waves). A prerelease `<label>.N` opens the release
2
+ // line N and `<label>.N.M` is the M-th release after it, a wave or a fix alike, so alpha.31 < alpha.31.1 < alpha.31.2 < alpha.32 in
3
+ // SemVer order. The next release is derived from the highest one already released, never guessed. The legacy `<label>.N-fix.K` still
4
+ // reads as release K after `<label>.N` and is never produced again. Pure: the callers read the tags and the registry.
5
+
6
+ const NUMERIC = /^(?:0|[1-9]\d*)$/;
7
+ const POSITIVE = /^[1-9]\d*$/;
8
+ const IDENTIFIERS = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
9
+ const LEGACY_FIX = '-fix.';
10
+
11
+ /** {base, wave, legacy}: the release a version counts its waves from, and its wave M (0 for the release that opens the line). */
12
+ export function waveOf(version) {
13
+ const text = String(version ?? '').split('+')[0];
14
+ const fix = text.lastIndexOf(LEGACY_FIX);
15
+ const legacyWave = fix > 0 ? text.slice(fix + LEGACY_FIX.length) : '';
16
+ if (POSITIVE.test(legacyWave)) return { base: text.slice(0, fix), wave: Number(legacyWave), legacy: true };
17
+ const ids = text.split('.');
18
+ const counted = ids.length > 3 && POSITIVE.test(ids.at(-1)) && (text.includes('-') ? NUMERIC.test(ids.at(-2)) : ids.length === 4);
19
+ return counted ? { base: ids.slice(0, -1).join('.'), wave: Number(ids.at(-1)), legacy: false } : { base: text, wave: 0, legacy: false };
20
+ }
21
+
22
+ /** Parsed SemVer identifiers {core, pre} of `version`; an unreadable version throws. */
23
+ export function versionParts(version) {
24
+ const parts = typeof version === 'string' ? version.split('+') : [];
25
+ if (!parts.length || parts.length > 2 || (parts.length === 2 && !IDENTIFIERS.test(parts[1]))) throw new Error(`unreadable release version ${version}`);
26
+ const boundary = parts[0].indexOf('-');
27
+ const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
28
+ const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
29
+ if (core.length !== 3 || core.some((part) => !NUMERIC.test(part)) || (prerelease !== null && !IDENTIFIERS.test(prerelease))) throw new Error(`unreadable release version ${version}`);
30
+ const pre = prerelease?.split('.') ?? [];
31
+ if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
32
+ return { core: core.map(BigInt), pre };
33
+ }
34
+
35
+ const readable = (version) => {
36
+ try {
37
+ versionParts(version);
38
+ return true;
39
+ } catch {
40
+ return false;
41
+ }
42
+ };
43
+
44
+ /** The SemVer spelling a version is ordered by: a legacy `-fix.K` orders as wave K of its line. */
45
+ const ordered = (version) => {
46
+ const { base, wave, legacy } = waveOf(version);
47
+ return legacy ? `${base}.${wave}` : version;
48
+ };
49
+
50
+ const compareIdentifier = (a, b) => {
51
+ const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
52
+ if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
53
+ if (numericA !== numericB) return numericA ? -1 : 1;
54
+ return a < b ? -1 : Number(a > b);
55
+ };
56
+
57
+ /** SemVer precedence, numeric prerelease identifiers included; build metadata has no precedence. */
58
+ export function compareVersions(a, b) {
59
+ const left = versionParts(ordered(a)), right = versionParts(ordered(b));
60
+ for (const i of left.core.keys()) {
61
+ if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
62
+ }
63
+ if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
64
+ for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
65
+ if (left.pre[i] === undefined) return -1;
66
+ if (right.pre[i] === undefined) return 1;
67
+ const compared = compareIdentifier(left.pre[i], right.pre[i]);
68
+ if (compared) return compared;
69
+ }
70
+ return 0;
71
+ }
72
+
73
+ /** The release line of `version`: a prerelease's core and the identifiers before its first number (every alpha.N and alpha.N.M share one), a stable release's major and minor. */
74
+ export function releaseLine(version) {
75
+ const { core, pre } = versionParts(ordered(version));
76
+ if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
77
+ const first = pre.findIndex((id) => /^\d+$/.test(id));
78
+ return `${core.join('.')}-${(first < 0 ? pre : pre.slice(0, first)).join('.')}`;
79
+ }
80
+
81
+ /** {family, line} of a release that opens a line (`<x.y.z>-<label>.N`), or null for any other shape. */
82
+ function lineStart(base) {
83
+ if (!readable(base)) return null;
84
+ const { core, pre } = versionParts(base);
85
+ return pre.length === 2 && !/^\d+$/.test(pre[0]) && NUMERIC.test(pre[1]) ? { family: `${core.join('.')}-${pre[0]}`, line: Number(pre[1]) } : null;
86
+ }
87
+
88
+ /** The next wave after `version`: its base with M one past the highest wave of that base among `version` and `known`. */
89
+ export function nextWave(version, known = []) {
90
+ const { base } = waveOf(version);
91
+ const waves = [version, ...known].map(waveOf).filter((entry) => entry.base === base).map((entry) => entry.wave);
92
+ return `${base}.${Math.max(0, ...waves) + 1}`;
93
+ }
94
+
95
+ /** What may follow the highest line-shaped release of `versions`: {highest, family, wave, line}, or null when none has that shape. */
96
+ export function nextReleases(versions) {
97
+ const shaped = versions.filter((version) => readable(version) && lineStart(waveOf(version).base));
98
+ if (!shaped.length) return null;
99
+ const highest = shaped.reduce((top, version) => (compareVersions(version, top) > 0 ? version : top));
100
+ const { family, line } = lineStart(waveOf(highest).base);
101
+ return { highest, family, wave: nextWave(highest, shaped), line: `${family}.${line + 1}` };
102
+ }
103
+
104
+ /**
105
+ * Why `version` cannot be released after `released` (the versions already tagged), or null when it can: the next wave of the highest
106
+ * release, the first release of the next line, or a later release of another label or core. The legacy `-fix` form is always refused.
107
+ */
108
+ export function releaseVersionRefusal(version, released) {
109
+ const next = nextReleases(released.filter((entry) => entry !== version));
110
+ const named = next ? `; the next release is ${next.wave} (the next wave or fix after ${next.highest}) or ${next.line} (the next line)` : '';
111
+ if (waveOf(version).legacy) return `${version} is the legacy -fix form, which is read and never released again${named}`;
112
+ if (!next || version === next.wave || version === next.line) return null;
113
+ if (!readable(version)) return `${version} is not a SemVer version${named}`;
114
+ if (lineStart(waveOf(version).base)?.family !== next.family && compareVersions(version, next.highest) > 0) return null;
115
+ return `${version} does not follow ${next.highest}${named}`;
116
+ }
117
+
118
+ /** The versions of the release tags `v<version>` in the output of `git tag --list`. */
119
+ export const taggedVersions = (listing) => String(listing ?? '').split(/\r?\n/).map((name) => name.trim()).filter((name) => /^v\d/.test(name)).map((name) => name.slice(1));
@@ -1,53 +1,10 @@
1
1
  // The release owner's dist-tag judgment and idempotent reconciliation, independent of npm processes.
2
-
3
- /** Parsed SemVer identifiers, used only to order registry releases and prevent tag rollback. */
4
- function versionParts(version) {
5
- const identifiers = /^[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*$/;
6
- const parts = typeof version === 'string' ? version.split('+') : [];
7
- if (!parts.length || parts.length > 2 || (parts.length === 2 && !identifiers.test(parts[1]))) throw new Error(`unreadable registry release version ${version}`);
8
- const boundary = parts[0].indexOf('-');
9
- const core = (boundary < 0 ? parts[0] : parts[0].slice(0, boundary)).split('.');
10
- const prerelease = boundary < 0 ? null : parts[0].slice(boundary + 1);
11
- if (core.length !== 3 || core.some((part) => !/^(0|[1-9]\d*)$/.test(part)) || (prerelease !== null && !identifiers.test(prerelease))) throw new Error(`unreadable registry release version ${version}`);
12
- const pre = prerelease?.split('.') ?? [];
13
- if (pre.some((part) => /^0\d+$/.test(part))) throw new Error(`invalid numeric prerelease identifier in ${version}`);
14
- return { core: core.map(BigInt), pre };
15
- }
16
-
17
- const compareIdentifier = (a, b) => {
18
- const numericA = /^\d+$/.test(a), numericB = /^\d+$/.test(b);
19
- if (numericA && numericB) return BigInt(a) < BigInt(b) ? -1 : Number(BigInt(a) > BigInt(b));
20
- if (numericA !== numericB) return numericA ? -1 : 1;
21
- return a < b ? -1 : Number(a > b);
22
- };
23
-
24
- /** SemVer precedence, including numeric prerelease ordering; build metadata has no precedence. */
25
- function compareVersions(a, b) {
26
- const left = versionParts(a), right = versionParts(b);
27
- for (const i of left.core.keys()) {
28
- if (left.core[i] !== right.core[i]) return left.core[i] < right.core[i] ? -1 : 1;
29
- }
30
- if (!left.pre.length || !right.pre.length) return Number(!left.pre.length) - Number(!right.pre.length);
31
- for (let i = 0; i < Math.max(left.pre.length, right.pre.length); i += 1) {
32
- if (left.pre[i] === undefined) return -1;
33
- if (right.pre[i] === undefined) return 1;
34
- const compared = compareIdentifier(left.pre[i], right.pre[i]);
35
- if (compared) return compared;
36
- }
37
- return 0;
38
- }
39
-
40
- /** Numbered prereleases share their core and nonnumeric prefix; stable releases share major/minor. */
41
- const releaseLine = (version) => {
42
- const { core, pre } = versionParts(version);
43
- if (!pre.length) return `${core.slice(0, 2).join('.')}-stable`;
44
- const prefix = /^\d+$/.test(pre.at(-1)) ? pre.slice(0, -1) : pre;
45
- return `${core.join('.')}-${prefix.join('.')}`;
46
- };
2
+ import { compareVersions, releaseLine, versionParts } from './release-version.mjs';
47
3
 
48
4
  /**
49
5
  * Plan the release's declared tags from a complete registry read. Unknown reads and historical
50
- * releases fail closed; a newer tag on another release line is preserved rather than rolled back.
6
+ * releases fail closed (alpha.31.1 is behind alpha.31.2 and alpha.32 on the one alpha line); a newer
7
+ * tag on another release line is preserved rather than rolled back.
51
8
  * Returns serializable {tag, version, current, action} entries for the native dry-run plan.
52
9
  */
53
10
  export function runtimeTagPlan({ version, tags, state, uploadTag = null }) {