@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
|
@@ -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
|
-
//
|
|
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
|
|
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
|
|
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
|
-
//
|
|
29
|
-
//
|
|
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,
|
|
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
|
-
/**
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
const
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
|
135
|
-
const
|
|
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
|
-
|
|
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'
|
|
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
|
-
*
|
|
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]) => !
|
|
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
|
|
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 || !
|
|
207
|
-
.map((grammar) =>
|
|
208
|
-
|
|
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
|
|
212
|
-
function
|
|
213
|
-
const
|
|
214
|
-
|
|
215
|
-
|
|
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
|
|
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,
|
|
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.
|
|
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(
|
|
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,
|
|
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) =>
|
|
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)) ? [] :
|
|
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,
|
|
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
|
|
358
|
-
const strictOf = (file) =>
|
|
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,
|
|
320
|
+
...cssFindings({ styles, references, stamps: stampsOf(sources, rows, byName, references, grammarFor), allowed, grammarFor }),
|
|
368
321
|
...stale(),
|
|
369
322
|
];
|
|
370
323
|
}
|
|
@@ -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
|
|
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 }) {
|