@starci/hfs 4.7.35 → 4.7.37

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.
@@ -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
- // 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';
34
- import { expressionStrings, heroTags, importAliases, jsxTags, vendorRenderRoots } from './grammar-vendor-source.mjs';
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', 'canon'];
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
- * 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.
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]) => !key.startsWith('canon:') && !used.has(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 ?? []).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 })))
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 || !Array.isArray(grammar.paths) || grammar.vendor?.name !== 'heroui' || !PARITY_MODES.includes(grammar.vendor?.parity))
117
- .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(' | ')}}`));
118
- return [...found, ...files.filter((file) => grammarOf(file, grammars) === undefined).map((file) => finding('grammar', file, 0, 'belongs to no declared Grammar: declare it in the grammars of the wrapper manifest'))];
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 JSX roles a file writes by hand that the measured vendor references expose. */
122
- function roleFindings({ file, text, allowed, vendorRoles, roleDeclarations }) {
123
- const vendorAliases = importAliases(text);
124
- const vendorRenders = vendorRenderRoots(text, vendorAliases, roleDeclarations);
125
- const vendorOwnsRole = (tag, role) => {
126
- const owner = vendorAliases.get(tag.alias);
127
- const declaration = roleDeclarations[role];
128
- return owner !== undefined && (declaration?.contracts.includes(`${owner}.${tag.part}`) || (tag.part === 'Root' && declaration?.publicApi === owner));
129
- };
130
- const roles = jsxTags(text).flatMap((tag) => tag.attrs.filter((attr) => attr.name === 'role').flatMap((attr) => expressionStrings(text, attr.raw)).filter((role) => !vendorOwnsRole(tag, role) && !vendorRenders.some((root) => tag.index === root.index && root.roles.includes(role)))).filter((role) => vendorRoles.has(role));
131
- return [...new Set(roles)].filter((role) => !allowed('role', `${file}:${role}`)).map((role) => finding('role', file, 0, `hand-rolls role="${role}", which HeroUI has a component for: use it (override target role:${file}:${role})`));
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
- /** The part findings of one wrapped component: parts that are not the vendor's, required parts left out, the divider between sections. */
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, strictOf) {
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.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')) {
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(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
+ });
236
268
  }
237
269
  }
238
270
  }
239
271
  return found;
240
272
  }
241
273
 
242
- function cssFindings({ styles, references, stamps, allowed, strictOf }) {
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) => strictOf(sheet.file))) {
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)) ? [] : ruleFamilies(rule);
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, 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
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 grammars = Array.isArray(manifest.grammars) ? manifest.grammars : [];
278
- 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';
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, strictOf), allowed, strictOf }),
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; 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 }) {
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) {
package/sync/hygiene.mjs CHANGED
@@ -58,7 +58,7 @@ export function hygieneFindings(files, ignored) {
58
58
  if (file.startsWith(WORK) && ignored.has(file)) {
59
59
  findings.push({ file, code: 'HFS_WORK_AGENT_DATA', message: 'is agent output, which .starciwork/.gitignore refuses; keep it in the scratchpad or the blob store' });
60
60
  }
61
- if (file.startsWith(STACKS) && !file.endsWith('.enc') && !file.endsWith('.env.example')) {
61
+ if (file.startsWith(STACKS) && !file.endsWith('.enc') && !file.endsWith('.env.example') && !file.endsWith('/secrets/.gitkeep')) {
62
62
  if (file.includes('/secrets/')) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'sits under secrets/ but is not sealed; only <slug>.enc may be tracked' });
63
63
  else if (PLAINTEXT_NAME.test(file)) findings.push({ file, code: 'HFS_PLAINTEXT_SECRET', message: 'is a plaintext secret; seal it to .starcistacks/<env>/secrets/<slug>.enc' });
64
64
  }
@@ -167,8 +167,8 @@ describe("JsonLogger outside a correlation scope", () => {
167
167
  logger.warn("cart.slow")
168
168
 
169
169
  expect(written.map((entry) => entry.line)).toEqual([
170
- `${JSON.stringify({ level: "info", event: "cart.opened", time: "2026-05-06T07:08:09.000Z", correlationId: "00000000-0000-4000-8000-000000000001" })}\n`,
171
- `${JSON.stringify({ level: "warn", event: "cart.slow", time: "2026-05-06T07:08:09.000Z", correlationId: "00000000-0000-4000-8000-000000000001" })}\n`,
170
+ `${JSON.stringify({ correlationId: "00000000-0000-4000-8000-000000000001", level: "info", event: "cart.opened", time: "2026-05-06T07:08:09.000Z" })}\n`,
171
+ `${JSON.stringify({ correlationId: "00000000-0000-4000-8000-000000000001", level: "warn", event: "cart.slow", time: "2026-05-06T07:08:09.000Z" })}\n`,
172
172
  ])
173
173
  })
174
174
  })
@@ -1,6 +1,7 @@
1
1
  export const SiteShell = (props: SiteShellProps) => (
2
2
  <GrammarRoot className={siteShellClassNames.frame}>
3
3
  <WorkspaceShell
4
+ viewport="flow"
4
5
  primaryLabel={props.brand}
5
6
  header={<TopBar brand={<TextAction href={props.homeHref}>{props.brand}</TextAction>} />}
6
7
  primary={<PageContainer measure="reading">{props.children}</PageContainer>}