@starci/hfs 4.7.35 → 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 +7 -0
- package/codemod/button-intent.mjs +356 -0
- package/package.json +2 -1
- package/runtime/knowledge/grammars/index.yaml +9 -0
- package/runtime/knowledge/grammars/starci/playbook.yaml +46 -15
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/fe-stack.yaml +5 -4
- package/runtime/knowledge/hfs/rules.yaml +30 -0
- package/runtime/knowledge/ui/presentation/padding.yaml +5 -4
- package/runtime/modules/kernel/failure-codes.yaml +62 -0
- package/runtime/modules/kernel/roles.yaml +1 -1
- package/runtime/packages/cli/src/catalog.generated.mjs +113 -3
- package/runtime/scripts/guards/release-definition.mjs +22 -3
- package/runtime/scripts/lib/css-rules.mjs +18 -0
- package/runtime/scripts/lib/{grammar-vendor-source.mjs → grammar-vendor-parity-jsx.mjs} +34 -19
- package/runtime/scripts/lib/grammar-vendor-parity.mjs +94 -61
- package/runtime/scripts/lib/release-version.mjs +119 -0
- package/runtime/scripts/lib/runtime-publication-tags.mjs +3 -46
- package/src/main.mjs +4 -2
|
@@ -8,32 +8,30 @@
|
|
|
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';
|
|
34
|
-
import {
|
|
35
|
-
|
|
36
|
-
export { heroTags, importAliases } from './grammar-vendor-source.mjs';
|
|
33
|
+
import { classTokens, heroTags, importAliases, roleFindings, stringLiterals } from './grammar-vendor-parity-jsx.mjs';
|
|
34
|
+
export { heroTags, importAliases } from './grammar-vendor-parity-jsx.mjs';
|
|
37
35
|
|
|
38
36
|
export const CODE = 'GRAMMAR_VENDOR_PARITY';
|
|
39
37
|
export const WRAPPERS_SCHEMA = 'starci/vendor-heroui-wrappers@1';
|
|
@@ -55,34 +53,19 @@ export const manifestDigest = (text) => createHash('sha256').update(String(text
|
|
|
55
53
|
|
|
56
54
|
const finding = (rule, file, line, message) => ({ code: CODE, rule, file, line, message: `${rule}: ${message}` });
|
|
57
55
|
|
|
58
|
-
/** The string literals of an expression, without their quotes. */
|
|
59
|
-
const stringLiterals = (raw) => (String(raw).match(/"[^"\n]*"|'[^'\n]*'|`[^`$]*`/g) ?? []).map((literal) => literal.slice(1, -1));
|
|
60
|
-
|
|
61
|
-
/** The class tokens an element carries: the literals of its className, with a bare constant resolved one level in the file. */
|
|
62
|
-
function classTokens(text, attrs) {
|
|
63
|
-
const raw = attrs.find((attr) => attr.name === 'className')?.raw;
|
|
64
|
-
if (raw === undefined) return [];
|
|
65
|
-
const names = String(raw).match(/\b[A-Za-z_]\w*\b/g) ?? [];
|
|
66
|
-
const resolved = names.flatMap((name) => {
|
|
67
|
-
const declaration = new RegExp(String.raw`\bconst\s+${name}\s*=\s*([^\n]*(?:\n[^\n;]*)?)`).exec(text);
|
|
68
|
-
return declaration ? stringLiterals(declaration[1]) : [];
|
|
69
|
-
});
|
|
70
|
-
return [...stringLiterals(raw), ...resolved].flatMap((value) => value.split(/\s+/)).filter(Boolean);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
56
|
// ---------------------------------------------------------------------------
|
|
74
57
|
// The judgment.
|
|
75
58
|
// ---------------------------------------------------------------------------
|
|
76
59
|
|
|
77
60
|
/** Accessibility adaptations of the user's input or display (forced colours, contrast, the touch target floor) are not restyles of the vendor look. */
|
|
78
61
|
const ACCESSIBILITY_MEDIA = /forced-colors|prefers-contrast|pointer:\s*coarse/;
|
|
79
|
-
const OVERRIDE_KINDS = ['slot', 'style', 'default', 'role'
|
|
62
|
+
const OVERRIDE_KINDS = ['slot', 'style', 'default', 'role'];
|
|
80
63
|
const PARITY_MODES = ['strict', 'custom'];
|
|
81
64
|
const overrideKey = (kind, target) => `${kind}:${target}`;
|
|
82
65
|
|
|
83
66
|
/**
|
|
84
67
|
* The declared overrides as a ledger: `allowed(kind, target)` answers and remembers the use, so an override nothing needs is found stale.
|
|
85
|
-
*
|
|
68
|
+
* Arrangement belongs in DNA, so it cannot grant permission through the override ledger.
|
|
86
69
|
*/
|
|
87
70
|
function declaredOverrides(wrappers) {
|
|
88
71
|
const declared = new Map();
|
|
@@ -100,38 +83,55 @@ function declaredOverrides(wrappers) {
|
|
|
100
83
|
return declared.has(key);
|
|
101
84
|
};
|
|
102
85
|
const stale = () => [...declared]
|
|
103
|
-
.filter(([key]) => !
|
|
86
|
+
.filter(([key]) => !used.has(key))
|
|
104
87
|
.map(([key, file]) => finding('stale', file, 0, `the override ${key} is declared but nothing needs it: remove it`));
|
|
105
88
|
return { allowed, stale, problems };
|
|
106
89
|
}
|
|
107
90
|
|
|
108
91
|
/** The Grammar a file belongs to (the longest declared path prefix), or undefined. */
|
|
109
92
|
const grammarOf = (file, grammars) => grammars
|
|
110
|
-
.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 })))
|
|
111
94
|
.sort((a, b) => b.length - a.length)[0]?.grammar;
|
|
112
95
|
|
|
113
96
|
/** The malformed Grammar declarations and the files that belong to no declared Grammar. */
|
|
114
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));
|
|
115
100
|
const found = grammars
|
|
116
|
-
.filter((grammar) => !grammar.id || !
|
|
117
|
-
.map((grammar) =>
|
|
118
|
-
|
|
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'))];
|
|
119
107
|
}
|
|
120
108
|
|
|
121
|
-
/** The
|
|
122
|
-
function
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
const
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
return
|
|
129
|
-
};
|
|
130
|
-
|
|
131
|
-
|
|
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];
|
|
132
122
|
}
|
|
133
123
|
|
|
134
|
-
/**
|
|
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
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The part findings of one wrapped component: unknown parts, required parts or claimed slots left out, and section dividers. */
|
|
135
135
|
function anatomyFindings({ file, name, reference, mine, row, tags, aliases }) {
|
|
136
136
|
const found = [];
|
|
137
137
|
for (const tag of mine) {
|
|
@@ -144,6 +144,7 @@ function anatomyFindings({ file, name, reference, mine, row, tags, aliases }) {
|
|
|
144
144
|
for (const part of reference.anatomy.required.filter((candidate) => !rendered.has(candidate) && !delegates.has(candidate))) {
|
|
145
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)`));
|
|
146
146
|
}
|
|
147
|
+
found.push(...missingSlotFindings({ file, name, reference, row, rendered, delegates }));
|
|
147
148
|
if (!rendered.has('Section')) return found;
|
|
148
149
|
for (const embed of reference.anatomy.embeds.filter((entry) => entry.slot === 'separator')) {
|
|
149
150
|
const separatorAliases = new Set([...aliases].filter(([, exported]) => exported === embed.component).map(([local]) => local));
|
|
@@ -191,16 +192,16 @@ function wrapperFindings({ file, text, row, references, byName, allowed, strict
|
|
|
191
192
|
}
|
|
192
193
|
|
|
193
194
|
/** The stamps (class tokens and `data-grammar-*` names) each wrapped vendor part carries: stamp -> [{name, part, dir, file}]. */
|
|
194
|
-
function stampsOf(sources, rows, byName, references,
|
|
195
|
+
function stampsOf(sources, rows, byName, references, grammarFor) {
|
|
195
196
|
const stamps = new Map();
|
|
196
197
|
const add = (key, entry) => stamps.set(key, [...(stamps.get(key) ?? []), entry]);
|
|
197
|
-
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')) {
|
|
198
199
|
const aliases = importAliases(text);
|
|
199
200
|
for (const tag of heroTags(text, [...aliases.keys()])) {
|
|
200
201
|
const name = aliases.get(tag.alias);
|
|
201
202
|
const dir = byName.get(name);
|
|
202
203
|
if (dir === undefined || !references[dir].parts[tag.part]) continue;
|
|
203
|
-
const entry = { name, part: tag.part, dir, file };
|
|
204
|
+
const entry = { name, part: tag.part, dir, file, family: grammarFor(file).id };
|
|
204
205
|
classTokens(text, tag.attrs).forEach((token) => add(`.${token}`, entry));
|
|
205
206
|
tag.attrs.filter((attr) => attr.name.startsWith('data-grammar-')).forEach((attr) => add(`[${attr.name}]`, entry));
|
|
206
207
|
}
|
|
@@ -222,34 +223,65 @@ function slotFindings({ file, selector, vendor, allowed }) {
|
|
|
222
223
|
return found;
|
|
223
224
|
}
|
|
224
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
|
+
|
|
225
253
|
/** The paint families Grammar CSS repaints on a vendor part through a stamp of the subject compound, as style findings. */
|
|
226
|
-
function styleFindings({ file, selector, families, stamps, references, allowed }) {
|
|
254
|
+
function styleFindings({ file, selector, families, stamps, references, allowed, rule, grammar }) {
|
|
227
255
|
const found = [];
|
|
228
256
|
const subject = subjectCompound(selector);
|
|
229
257
|
const tokens = [...selectorClasses(subject).map((cls) => `.${cls}`), ...selectorAttributes(subject).map((attribute) => `[${attribute.name}]`)];
|
|
230
258
|
for (const token of tokens) {
|
|
231
|
-
for (const entry of stamps.get(token) ?? []) {
|
|
259
|
+
for (const entry of (stamps.get(token) ?? []).filter((candidate) => candidate.family === grammar.id)) {
|
|
232
260
|
const paints = references[entry.dir].parts[entry.part].paints;
|
|
233
261
|
for (const family of families.filter((candidate) => paints.includes(candidate))) {
|
|
234
262
|
const target = `${entry.name}.${entry.part}:${family}`;
|
|
235
|
-
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
|
+
});
|
|
236
268
|
}
|
|
237
269
|
}
|
|
238
270
|
}
|
|
239
271
|
return found;
|
|
240
272
|
}
|
|
241
273
|
|
|
242
|
-
function cssFindings({ styles, references, stamps, allowed,
|
|
274
|
+
function cssFindings({ styles, references, stamps, allowed, grammarFor }) {
|
|
243
275
|
const vendor = {
|
|
244
276
|
classes: new Set(Object.values(references).flatMap((reference) => reference.css.classes)),
|
|
245
277
|
slots: new Set(Object.values(references).flatMap((reference) => reference.css.dataSlots)),
|
|
246
278
|
};
|
|
247
279
|
const found = [];
|
|
248
|
-
for (const { file, text } of styles.filter((sheet) =>
|
|
280
|
+
for (const { file, text } of styles.filter((sheet) => grammarFor(sheet.file)?.vendor?.parity === 'strict')) {
|
|
249
281
|
for (const rule of cssRules(text)) {
|
|
250
|
-
const families = rule.wraps.some((wrap) => ACCESSIBILITY_MEDIA.test(wrap)) ? [] :
|
|
282
|
+
const families = rule.wraps.some((wrap) => ACCESSIBILITY_MEDIA.test(wrap)) ? [] : repaintFamilies(rule);
|
|
251
283
|
for (const selector of rule.selectors) {
|
|
252
|
-
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) }) : []));
|
|
253
285
|
}
|
|
254
286
|
}
|
|
255
287
|
}
|
|
@@ -259,11 +291,12 @@ function cssFindings({ styles, references, stamps, allowed, strictOf }) {
|
|
|
259
291
|
/**
|
|
260
292
|
* Every GRAMMAR_VENDOR_PARITY finding.
|
|
261
293
|
* references { <dir>: parsed vendor reference }
|
|
262
|
-
* 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
|
|
263
296
|
* sources [{file, text}] Grammar .ts/.tsx (stories excluded), file relative to packages/grammar/src
|
|
264
297
|
* styles [{file, text}] every Grammar .css; a sheet is judged only when its Grammar declares `parity: strict`
|
|
265
298
|
*/
|
|
266
|
-
export function parityFindings({ references: vendorReferences, manifest, sources, styles }) {
|
|
299
|
+
export function parityFindings({ references: vendorReferences, manifest, sources, styles, grammars = [] }) {
|
|
267
300
|
if (manifest?.schema !== WRAPPERS_SCHEMA) return [finding('manifest', 'knowledge/vendor/heroui/wrappers.yaml', 0, `schema must be ${WRAPPERS_SCHEMA}`)];
|
|
268
301
|
const wrappers = Array.isArray(manifest.wrappers) ? manifest.wrappers : [];
|
|
269
302
|
const roleReference = vendorReferences.roles;
|
|
@@ -274,17 +307,17 @@ export function parityFindings({ references: vendorReferences, manifest, sources
|
|
|
274
307
|
const rows = new Map(wrappers.map((row) => [row.file, row]));
|
|
275
308
|
const known = new Set(sources.map((source) => source.file));
|
|
276
309
|
const { allowed, stale, problems } = declaredOverrides(wrappers);
|
|
277
|
-
const
|
|
278
|
-
const strictOf = (file) =>
|
|
310
|
+
const grammarFor = (file) => grammarOf(file, grammars);
|
|
311
|
+
const strictOf = (file) => grammarFor(file)?.vendor?.parity === 'strict';
|
|
279
312
|
return [
|
|
280
313
|
...grammarFindings(grammars, [...sources, ...styles].map((source) => source.file)),
|
|
281
314
|
...wrappers.filter((row) => !known.has(row.file)).map((row) => finding('undeclared', row.file, 0, 'the manifest names a file that does not exist')),
|
|
282
315
|
...problems,
|
|
283
316
|
...sources.flatMap(({ file, text }) => [
|
|
284
317
|
...wrapperFindings({ file, text, row: rows.get(file), references, byName, allowed, strict: strictOf(file) }),
|
|
285
|
-
...roleFindings({ file, text, allowed, vendorRoles, roleDeclarations: roleReference.roles }),
|
|
318
|
+
...roleFindings({ file, text, allowed, vendorRoles, roleDeclarations: roleReference.roles, finding }),
|
|
286
319
|
]),
|
|
287
|
-
...cssFindings({ styles, references, stamps: stampsOf(sources, rows, byName, references,
|
|
320
|
+
...cssFindings({ styles, references, stamps: stampsOf(sources, rows, byName, references, grammarFor), allowed, grammarFor }),
|
|
288
321
|
...stale(),
|
|
289
322
|
];
|
|
290
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 }) {
|
package/src/main.mjs
CHANGED
|
@@ -91,13 +91,15 @@ import { dbMigrateMain } from '../db/migrate.mjs';
|
|
|
91
91
|
import { dbPostCutoverMain } from '../db/post-cutover.mjs';
|
|
92
92
|
import { dbDumpMain } from '../db/dump.mjs';
|
|
93
93
|
import { dbResetMain } from '../db/reset.mjs';
|
|
94
|
+
import { codemodMain } from '../codemod/button-intent.mjs';
|
|
94
95
|
|
|
95
|
-
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage']);
|
|
96
|
+
export const VERBS = Object.freeze(['scaffold', 'add', 'lint', 'sync', 'check', 'upgrade', 'stack', 'release', 'explain', 'emit', 'new', 'secret', 'ci', 'db', 'hygiene', 'coverage', 'codemod']);
|
|
96
97
|
const DATABASE_VERBS = new Map([['baseline', dbBaselineMain], ['restamp', dbRestampMain], ['plan', dbPlanMain], ['migrate', dbMigrateMain], ['post-cutover', dbPostCutoverMain], ['dump', dbDumpMain], ['reset', dbResetMain]]);
|
|
97
98
|
|
|
98
99
|
const USAGE = `starci app scaffold <name> [--into <dir>] [--edition full|lite] [--registry-visibility public|private] [--cwd <dir>]
|
|
99
100
|
starci app add <api|webhook|realtime|saga|job|reactor|queue|projection|cli|table|app> <name> [options] [--cwd <dir>]
|
|
100
101
|
starci app lint [--cwd <dir>] [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--format text|json] [--sonar <file>]
|
|
102
|
+
starci app codemod button-intent [--write] [--format json|markdown] [--report <file.json|file.md>] [--cwd <dir>]
|
|
101
103
|
starci app sync (--check | --write) [--cwd <dir>]
|
|
102
104
|
starci app check [--cwd <dir>] [--json] [--fast] [--base <ref>] [--db-types] [--wiring] [--coverage-from <lcov>]
|
|
103
105
|
starci app db baseline [--stamp <version>] [--local] [--apply] [--cwd <dir>]
|
|
@@ -431,7 +433,7 @@ async function stackAppMain(argv, { cwd, edition, stackMain, stdout: out, stderr
|
|
|
431
433
|
return run(argv, { cwd, out, err });
|
|
432
434
|
}
|
|
433
435
|
|
|
434
|
-
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)]]);
|
|
436
|
+
const DIRECT_VERBS = new Map([['ci', async (argv, io) => (await import('../ci/cli.mjs')).ciMain(argv, io)], ['lint', lintMain], ['secret', secretMain], ['db', databaseMain], ['upgrade', upgradeAppMain], ['stack', stackAppMain], ['release', releaseMain], ['coverage', async (argv, io) => (await import('../coverage/cli.mjs')).coverageMain(argv, io)], ['codemod', codemodMain]]);
|
|
435
437
|
const PARSED_VERBS = new Map([['check', checkMain], ['emit', emitMain], ['new', newMain], ['add', addMain], ['scaffold', scaffoldMain], ['explain', explainMain]]);
|
|
436
438
|
|
|
437
439
|
function dispatchMain(verb, argv, io) {
|