@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 CHANGED
@@ -1,5 +1,11 @@
1
1
  # Changelog
2
2
 
3
+ ## 4.7.36 - 2026-10-26
4
+ - Changed: the bundled canon pins select @starci/eslint-canon-be 3.4.45, @starci/eslint-canon-fe 9.1.0, the Grammar lint families 0.12.4 and @starci/grammar 0.12.4.
5
+ - Added: the five R319 button-intent finding codes are catalogued and proven, and the `app codemod button-intent` migration is bundled.
6
+ - Changed: the Grammar vendor-parity proof is strict. It reads the DNA vendor, parity and arrangement declarations, judges undeclared wraps and stale overrides, and exports repaint findings with `starci work export-repaints`; `wrappers.yaml` carries the owner-ruled overrides and one pin.
7
+ - Changed: releases follow the alpha.N.M counter (owner ruling `release-waves`): the next tag is derived from the highest released one.
8
+
3
9
  ## 4.7.35 - 2026-10-26
4
10
  - Changed: the bundled canon pins select @starci/stylelint-canon 2.0.12, @starci/eslint-canon-be 3.4.44, @starci/eslint-canon-fe 9.0.0, the Grammar lint families 0.12.3 and @starci/grammar 0.12.3.
5
11
  - Added: `app lint` composes the selected Grammar family lint with the universal front-end canon and refuses canon-fe 9 without it; the Grammar vendor-parity proof reads the generated HeroUI reference; the UI proof checks component ownership and surface layers.
@@ -7,6 +13,7 @@
7
13
  - Changed: the runtime centralizes its parsing and gate summaries.
8
14
 
9
15
  ## 4.7.34 - 2026-10-26
16
+ - Added: `starci app codemod button-intent` previews legacy Grammar button migrations, applies safe edits with `--write`, and reports ambiguous consequences, competing commits and repeated item commits in JSON or Markdown.
10
17
  - Fixed: `starci app db restamp` considers only active migrations when validating names and choosing unoccupied versions. Legacy archive names no longer block preview or apply, and archived files stay untouched.
11
18
  - Changed: the loosening check reads the tree as it stands and never its history. The bundled `scripts/lib/loosening-state.mjs` (with `js-lex.mjs` and `loosening-config.mjs`) replaces the diff detector; `HFS_BYPASS_MARKER` (R303) reads it: a suppression word opens a comment, a test marker stands in code, a swallowed failure stands in a shell line or in the string a script hands a shell, a package.json is judged by its scripts, and the same words quoted as data are no bypass. It also refuses a coverage target or threshold below the canon, a relaxing switch, a disabled ESLint rule, a tsconfig strictness option turned off and an exception file that `knowledge/hfs/design-truths.yaml` does not list. `--passWithNoTests`, `--coverageThreshold`, `--no-error-on-unmatched-pattern` and a `--max-warnings` budget are bypasses in a script, a hook or a workflow.
12
19
  - Added: `starci app emit` carries class-validator `MaxLength`, `ArrayMaxSize`, `Min` and `Max` of GraphQL input types and REST operation DTOs into the contract (a GraphQL `@inputBounds` directive and an OpenAPI constraint), and the `@starci/hfs/form-bounds` codegen plugin turns them into typed `INPUT_BOUNDS` and `inputBoundsIssues` for the form canon. The app codegen template refuses legacy or unsupported bounds metadata.
@@ -0,0 +1,356 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { fileURLToPath } from 'node:url';
4
+ import { loadTypescript } from '../runtime/scripts/lib/package-at.mjs';
5
+ import { sourceLocation } from '../runtime/scripts/lib/ts-ast.mjs';
6
+
7
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
8
+ const GRAMMAR = /^@starci\/grammar(?:\/|$)/;
9
+ const VARIANTS = Object.freeze({ primary: 'commit', tertiary: 'dismiss', danger: 'destructive', 'danger-soft': 'destructive', outline: 'neutral', ghost: 'neutral' });
10
+ const WEAK = new Set(['outline', 'ghost', 'secondary']);
11
+ const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
12
+
13
+ function attributes(ts, opening) {
14
+ return new Map(opening.attributes.properties.filter(ts.isJsxAttribute).map(prop => [prop.name.getText(), prop]));
15
+ }
16
+
17
+ function literal(ts, attribute) {
18
+ const value = attribute?.initializer;
19
+ if (value && ts.isStringLiteralLike(value)) return value.text;
20
+ return value && ts.isJsxExpression(value) && value.expression && ts.isStringLiteralLike(value.expression) ? value.expression.text : null;
21
+ }
22
+
23
+ function buttonImports(ts, source) {
24
+ const names = new Set();
25
+ const namespaces = new Set();
26
+ for (const node of source.statements) {
27
+ if (!ts.isImportDeclaration(node) || !GRAMMAR.test(node.moduleSpecifier.text)) continue;
28
+ const bindings = node.importClause?.namedBindings;
29
+ if (bindings && ts.isNamespaceImport(bindings)) namespaces.add(bindings.name.text);
30
+ if (bindings && ts.isNamedImports(bindings)) {
31
+ for (const binding of bindings.elements) {
32
+ if (['Button', 'IconButton'].includes(binding.propertyName?.text ?? binding.name.text)) names.add(binding.name.text);
33
+ }
34
+ }
35
+ }
36
+ return { names, namespaces };
37
+ }
38
+
39
+ function isButton(ts, opening, imports) {
40
+ const tag = opening.tagName;
41
+ if (ts.isIdentifier(tag)) return imports.names.has(tag.text);
42
+ return ts.isPropertyAccessExpression(tag) && ts.isIdentifier(tag.expression)
43
+ && imports.namespaces.has(tag.expression.text) && ['Button', 'IconButton'].includes(tag.name.text);
44
+ }
45
+
46
+ function bindingNames(ts, binding) {
47
+ if (ts.isIdentifier(binding)) return [binding.text];
48
+ if (ts.isObjectBindingPattern(binding) || ts.isArrayBindingPattern(binding)) return binding.elements.flatMap(element => ts.isBindingElement(element) ? bindingNames(ts, element.name) : []);
49
+ return [];
50
+ }
51
+
52
+ function shadowedBinding(ts, opening) {
53
+ const tag = opening.tagName;
54
+ const name = ts.isIdentifier(tag) ? tag.text : tag.expression.text;
55
+ for (let at = opening.parent; at && !ts.isSourceFile(at); at = at.parent) {
56
+ if (ts.isFunctionLike(at) && (at.parameters.some(parameter => bindingNames(ts, parameter.name).includes(name))
57
+ || (ts.isFunctionExpression(at) && at.name?.text === name))) return true;
58
+ if (ts.isCatchClause(at) && at.variableDeclaration && bindingNames(ts, at.variableDeclaration.name).includes(name)) return true;
59
+ const statements = ts.isBlock(at) ? at.statements : [];
60
+ for (const statement of statements) {
61
+ if ((ts.isFunctionDeclaration(statement) || ts.isClassDeclaration(statement)) && statement.name?.text === name) return true;
62
+ if (ts.isVariableStatement(statement) && statement.declarationList.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name))) return true;
63
+ }
64
+ if ((ts.isForStatement(at) || ts.isForOfStatement(at) || ts.isForInStatement(at)) && at.initializer && ts.isVariableDeclarationList(at.initializer)
65
+ && at.initializer.declarations.some(declaration => bindingNames(ts, declaration.name).includes(name))) return true;
66
+ }
67
+ return false;
68
+ }
69
+
70
+ function isMap(ts, node) {
71
+ if (!ts.isCallExpression(node)) return false;
72
+ return ts.isPropertyAccessExpression(node.expression) ? node.expression.name.text === 'map'
73
+ : ts.isElementAccessExpression(node.expression) && ts.isStringLiteralLike(node.expression.argumentExpression) && node.expression.argumentExpression.text === 'map';
74
+ }
75
+
76
+ function callbackArgument(ts, node) {
77
+ let expression = node;
78
+ while (expression.parent && ts.isParenthesizedExpression(expression.parent)) expression = expression.parent;
79
+ return isMap(ts, expression.parent) && expression.parent.arguments[0] === expression;
80
+ }
81
+
82
+ function inlineRenderCallback(ts, node) {
83
+ let expression = node;
84
+ while (expression.parent && ts.isParenthesizedExpression(expression.parent)) expression = expression.parent;
85
+ if (!expression.parent || !ts.isJsxExpression(expression.parent)) return false;
86
+ const container = expression.parent.parent;
87
+ if (ts.isJsxElement(container) || ts.isJsxFragment(container)) return true;
88
+ return ts.isJsxAttribute(container) && /^(?:children|render[A-Z].*|.*Renderer)$/.test(container.name.getText());
89
+ }
90
+
91
+ /** Resolve same-file callback aliases before walking buttons: ancestry alone misses rows.map(renderRow). */
92
+ function collectionCallbacks(ts, source) {
93
+ const definitions = new Map();
94
+ const callbacks = [];
95
+ const collect = node => {
96
+ if (isMap(ts, node) && node.arguments[0]) callbacks.push(node.arguments[0]);
97
+ const name = (ts.isVariableDeclaration(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) && node.name;
98
+ if (name && ts.isIdentifier(name)) definitions.set(name.text, [...(definitions.get(name.text) ?? []), ts.isVariableDeclaration(node) ? node.initializer : node]);
99
+ ts.forEachChild(node, collect);
100
+ };
101
+ collect(source);
102
+ const owners = new Set();
103
+ const seen = new Set();
104
+ const resolve = node => {
105
+ if (!node || seen.has(node)) return;
106
+ seen.add(node);
107
+ if (ts.isArrowFunction(node) || ts.isFunctionExpression(node) || ts.isFunctionDeclaration(node) || ts.isMethodDeclaration(node)) owners.add(node);
108
+ else if (ts.isIdentifier(node)) for (const definition of definitions.get(node.text) ?? []) resolve(definition);
109
+ else if (ts.isParenthesizedExpression(node)) resolve(node.expression);
110
+ else if (ts.isPropertyAccessExpression(node)) for (const definition of definitions.get(node.name.text) ?? []) resolve(definition);
111
+ else if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) && node.expression.name.text === 'bind') resolve(node.expression.expression);
112
+ };
113
+ for (const callback of callbacks) resolve(callback);
114
+ return owners;
115
+ }
116
+
117
+ function renderOwner(ts, node, source) {
118
+ for (let at = node.parent; at; at = at.parent) {
119
+ if (ts.isArrowFunction(at) || ts.isFunctionExpression(at) || ts.isFunctionDeclaration(at) || ts.isMethodDeclaration(at)) {
120
+ // Collection and JSX render callbacks belong to their enclosing component's render, including nested SlotView children.
121
+ if (callbackArgument(ts, at) || inlineRenderCallback(ts, at)) continue;
122
+ return at;
123
+ }
124
+ }
125
+ return source;
126
+ }
127
+
128
+ function inMap(ts, node, owner) {
129
+ for (let at = node.parent; at && at !== owner; at = at.parent) {
130
+ if (isMap(ts, at)) return true;
131
+ }
132
+ return false;
133
+ }
134
+
135
+ function siblingGroup(ts, node, owner) {
136
+ for (let at = node.parent; at && at !== owner; at = at.parent) {
137
+ if (ts.isJsxElement(at) || ts.isJsxFragment(at)) return at;
138
+ }
139
+ return owner;
140
+ }
141
+
142
+ function stableName(ts, node) {
143
+ if (ts.isIdentifier(node)) return node.text;
144
+ if (ts.isPropertyAccessExpression(node) && stableName(ts, node.expression)) return `${stableName(ts, node.expression)}.${node.name.text}`;
145
+ return null;
146
+ }
147
+
148
+ function conditionFact(ts, expression, choice) {
149
+ if (ts.isParenthesizedExpression(expression)) return conditionFact(ts, expression.expression, choice);
150
+ if (ts.isPrefixUnaryExpression(expression) && expression.operator === ts.SyntaxKind.ExclamationToken) return conditionFact(ts, expression.operand, !choice);
151
+ if (ts.isBinaryExpression(expression)) {
152
+ const operator = expression.operatorToken.kind;
153
+ const equality = operator === ts.SyntaxKind.EqualsEqualsEqualsToken;
154
+ const inequality = operator === ts.SyntaxKind.ExclamationEqualsEqualsToken;
155
+ if (!equality && !inequality) return null;
156
+ const name = stableName(ts, expression.left);
157
+ const value = expression.right;
158
+ if (name && (ts.isStringLiteralLike(value) || ts.isNumericLiteral(value) || [ts.SyntaxKind.TrueKeyword, ts.SyntaxKind.FalseKeyword, ts.SyntaxKind.NullKeyword].includes(value.kind))) {
159
+ const canonical = ts.isStringLiteralLike(value) ? `string:${value.text}` : ts.isNumericLiteral(value) ? `number:${Number(value.text)}` : value.getText();
160
+ return { name, value: canonical, equal: equality === choice };
161
+ }
162
+ }
163
+ const name = stableName(ts, expression);
164
+ return name ? { name, value: 'truthy', equal: choice } : null;
165
+ }
166
+
167
+ function branchChoices(ts, node, owner) {
168
+ const choices = new Map();
169
+ const facts = [];
170
+ const record = (branch, expression, choice) => {
171
+ choices.set(branch, choice);
172
+ const fact = conditionFact(ts, expression, choice);
173
+ if (fact) facts.push(fact);
174
+ };
175
+ for (let at = node; at.parent && at.parent !== owner; at = at.parent) {
176
+ const parent = at.parent;
177
+ if (ts.isConditionalExpression(parent)) {
178
+ if (at === parent.whenTrue) record(parent, parent.condition, true);
179
+ if (at === parent.whenFalse) record(parent, parent.condition, false);
180
+ }
181
+ if (ts.isIfStatement(parent)) {
182
+ if (at === parent.thenStatement) record(parent, parent.expression, true);
183
+ if (at === parent.elseStatement) record(parent, parent.expression, false);
184
+ }
185
+ if (ts.isBinaryExpression(parent) && at === parent.right) {
186
+ if (parent.operatorToken.kind === ts.SyntaxKind.AmpersandAmpersandToken) record(parent, parent.left, true);
187
+ if (parent.operatorToken.kind === ts.SyntaxKind.BarBarToken) record(parent, parent.left, false);
188
+ }
189
+ }
190
+ return { choices, facts };
191
+ }
192
+
193
+ function canCoexist(first, second) {
194
+ for (const [branch, choice] of first.branches) if (second.branches.has(branch) && second.branches.get(branch) !== choice) return false;
195
+ for (const left of first.facts) for (const right of second.facts) {
196
+ if (left.name !== right.name) continue;
197
+ if (left.value === right.value && left.equal !== right.equal) return false;
198
+ if (left.equal && right.equal && left.value !== right.value && left.value !== 'truthy' && right.value !== 'truthy') return false;
199
+ }
200
+ return true;
201
+ }
202
+
203
+ function destructiveSignal(text) {
204
+ const words = text.normalize('NFD').replace(/\p{M}/gu, '').replaceAll('\u0111', 'd').replaceAll('\u0110', 'D')
205
+ .replace(/([a-z])([A-Z])/g, '$1 $2').replace(/[_-]+/g, ' ').toLowerCase();
206
+ return /\b(delete|remove|revoke|discard|destroy|purge|trash|terminate|clear|erase|reset|reject|decline|xoa)\b|\bthu[\s_-]*hoi\b|\b(?:cancel|huy)[\s_-]+(?:offer|booking|appointment|order|subscription|lich|don|de\s+nghi)\b/.test(words);
207
+ }
208
+
209
+ function describe(ts, source, opening, imports, callbacks) {
210
+ const props = attributes(ts, opening);
211
+ const element = ts.isJsxOpeningElement(opening) ? opening.parent : opening;
212
+ const owner = renderOwner(ts, element, source);
213
+ const variant = literal(ts, props.get('variant'));
214
+ const intent = literal(ts, props.get('intent'));
215
+ const branch = branchChoices(ts, element, owner);
216
+ return { opening, element, owner, props, variant, intent, branches: branch.choices, facts: branch.facts,
217
+ group: siblingGroup(ts, element, owner), mapped: variant === 'secondary' ? null : VARIANTS[variant],
218
+ repeated: callbacks.has(owner) || inMap(ts, element, owner), shadowed: shadowedBinding(ts, opening), name: opening.tagName.getText(source), imports };
219
+ }
220
+
221
+ /** Migrate imported Grammar buttons using source edits; ambiguous buttons retain their original props and gain a report finding. */
222
+ export function transformButtonIntent({ ts, text, file = 'component.tsx' }) {
223
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
224
+ const imports = buttonImports(ts, source);
225
+ const callbacks = collectionCallbacks(ts, source);
226
+ const buttons = [];
227
+ const visit = node => {
228
+ if ((ts.isJsxOpeningElement(node) || ts.isJsxSelfClosingElement(node)) && isButton(ts, node, imports)) buttons.push(describe(ts, source, node, imports, callbacks));
229
+ ts.forEachChild(node, visit);
230
+ };
231
+ visit(source);
232
+ const findings = [];
233
+ const changes = [];
234
+ const edits = [];
235
+ const flag = (button, code, reason) => findings.push({ file, ...sourceLocation(source, button.opening), code, reason,
236
+ component: button.name, variant: button.variant, source: button.element.getText(source) });
237
+ if (source.parseDiagnostics.length) {
238
+ return { text, changes, findings: [{ file, line: 1, column: 1, code: 'parse-error', reason: ts.flattenDiagnosticMessageText(source.parseDiagnostics[0].messageText, '\n') }] };
239
+ }
240
+ const commits = buttons.filter(button => button.variant === 'primary' || button.intent === 'commit');
241
+ for (const button of buttons) {
242
+ if (button.shadowed) {
243
+ flag(button, 'shadowed-button-binding', 'A local binding shadows the Grammar import; inspect its actual component before migrating.');
244
+ continue;
245
+ }
246
+ const pending = [];
247
+ if (button.intent === 'commit' || button.variant === 'primary') {
248
+ if (button.repeated) pending.push(['commit-in-collection', 'A repeated item needs a human choice of alternative or neutral.']);
249
+ if (commits.some(other => other !== button && other.owner === button.owner && canCoexist(button, other))) pending.push(['multiple-commits', 'More than one commit can render in this component; choose the decision owner and next action.']);
250
+ }
251
+ if (button.props.has('intent') && !button.props.has('variant')) {
252
+ for (const [code, reason] of pending) flag(button, code, reason);
253
+ continue;
254
+ }
255
+ if (button.props.has('intent')) pending.push(['intent-and-variant', 'Existing intent and variant need a human reconciliation.']);
256
+ if (!button.props.has('variant')) pending.push(['missing-variant', 'No old variant declares the command role; choose its consequence and rank.']);
257
+ else if (button.variant === null) pending.push(['dynamic-variant', 'A dynamic variant needs a human intent for every branch.']);
258
+ else if (button.variant !== 'secondary' && !button.mapped) pending.push(['unknown-variant', 'This variant has no published mechanical intent mapping.']);
259
+ if (button.opening.attributes.properties.some(ts.isJsxSpreadAttribute)) pending.push(['spread-props', 'Spread props can supply or override intent, step or variant; inspect the source.']);
260
+ if (WEAK.has(button.variant) && destructiveSignal(button.element.getText(source))) pending.push(['destructive-verb', 'A destructive or negative command on a weak variant needs its actual consequence and confirmation step reviewed.']);
261
+ if (button.props.has('step')) pending.push(['existing-step', 'An existing destructive step needs reconciliation with the migrated intent.']);
262
+ if (pending.length) {
263
+ for (const [code, reason] of pending) flag(button, code, reason);
264
+ continue;
265
+ }
266
+ const intent = button.variant === 'secondary'
267
+ ? commits.some(other => other.group === button.group && canCoexist(button, other)) ? 'alternative' : 'neutral'
268
+ : button.mapped;
269
+ const step = button.variant === 'danger' ? 'confirm' : button.variant === 'danger-soft' ? 'entry' : null;
270
+ const attribute = button.props.get('variant');
271
+ const replacement = `intent="${intent}"${step ? ` step="${step}"` : ''}`;
272
+ edits.push({ start: attribute.getStart(source), end: attribute.end, replacement });
273
+ changes.push({ file, ...sourceLocation(source, button.opening), component: button.name, from: button.variant, intent, ...(step ? { step } : {}) });
274
+ }
275
+ let output = text;
276
+ for (const edit of edits.sort((first, second) => second.start - first.start)) output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
277
+ return { text: output, changes, findings };
278
+ }
279
+
280
+ function sourceFiles(directory) {
281
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap(entry => {
282
+ const target = path.join(directory, entry.name);
283
+ if (entry.isDirectory()) return GENERATED.has(entry.name) ? [] : sourceFiles(target);
284
+ return entry.isFile() && /\.[jt]sx$/i.test(entry.name) ? [target] : [];
285
+ }).sort();
286
+ }
287
+
288
+ /** Preview or apply safe migrations in one app's FE tree; every unresolved case is retained in the returned report. */
289
+ export function migrateButtonIntent({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
290
+ if (!ts) throw new Error('button-intent needs TypeScript installed in the app or @starci/hfs development checkout');
291
+ const frontend = path.join(root, 'fe');
292
+ if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory()) throw new Error('button-intent runs at an app root with a fe/ directory');
293
+ const report = { schema: 'starci/button-intent-codemod@1', root, write, filesScanned: 0, filesChanged: [], changes: [], findings: [] };
294
+ for (const target of sourceFiles(frontend)) {
295
+ const file = path.relative(root, target).split(path.sep).join('/');
296
+ const original = fs.readFileSync(target, 'utf8');
297
+ const result = transformButtonIntent({ ts, text: original, file });
298
+ report.filesScanned += 1;
299
+ report.changes.push(...result.changes);
300
+ report.findings.push(...result.findings);
301
+ if (result.text !== original) {
302
+ report.filesChanged.push(file);
303
+ if (write) fs.writeFileSync(target, result.text);
304
+ }
305
+ }
306
+ return report;
307
+ }
308
+
309
+ const cell = value => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
310
+
311
+ /** Render the migration and human decision list as Markdown; JSON consumers use the same unabridged report object. */
312
+ export function buttonIntentMarkdown(report) {
313
+ const lines = ['# Button intent migration', '', `${report.write ? 'Applied' : 'Dry run'}: ${report.changes.length} safe changes in ${report.filesChanged.length} files; ${report.findings.length} unresolved findings.`, '',
314
+ '## Safe changes', '', '| Location | Before | Intent | Step |', '| --- | --- | --- | --- |',
315
+ ...report.changes.map(change => `| ${cell(`${change.file}:${change.line}:${change.column}`)} | ${change.from} | ${change.intent} | ${change.step ?? ''} |`), '',
316
+ '## Human decisions', '', '| Location | Code | Reason |', '| --- | --- | --- |',
317
+ ...report.findings.map(finding => `| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${finding.code} | ${cell(finding.reason)} |`), ''];
318
+ return lines.join('\n');
319
+ }
320
+
321
+ function parse(argv) {
322
+ const [name, ...rest] = argv;
323
+ if (name !== 'button-intent') throw new Error('starci app codemod takes button-intent');
324
+ const options = { write: false, format: 'markdown' };
325
+ const args = rest.values();
326
+ for (const arg of args) {
327
+ const [flag, inline] = arg.split(/=(.*)/s);
328
+ if (flag === '--write' || flag === '--json') {
329
+ if (inline !== undefined && !['true', 'false'].includes(inline)) throw new Error(`${flag} expects true or false`);
330
+ const value = inline === undefined || inline === 'true';
331
+ if (flag === '--write') options.write = value;
332
+ else if (value) options.format = 'json';
333
+ } else if (flag === '--format' || flag === '--report') {
334
+ const value = inline ?? args.next().value;
335
+ if (!value || value.startsWith('--')) throw new Error(`${flag} needs a value`);
336
+ options[flag.slice(2)] = value;
337
+ } else throw new Error(`unknown codemod option ${arg}`);
338
+ }
339
+ if (!['json', 'markdown'].includes(options.format)) throw new Error('--format accepts json or markdown');
340
+ return options;
341
+ }
342
+
343
+ /** CLI boundary: dry-run by default, --write mutates only safe FE source; findings return exit 1 with a complete report. */
344
+ export function codemodMain(argv, { cwd, stdout }) {
345
+ const options = parse(argv);
346
+ if (options.report && !/\.(?:json|md)$/i.test(options.report)) throw new Error('--report needs a .json or .md report file');
347
+ const report = migrateButtonIntent({ root: path.resolve(cwd), write: options.write });
348
+ const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : buttonIntentMarkdown(report);
349
+ if (options.report) {
350
+ const target = path.resolve(cwd, options.report);
351
+ fs.mkdirSync(path.dirname(target), { recursive: true });
352
+ fs.writeFileSync(target, output);
353
+ }
354
+ stdout(output);
355
+ return report.findings.length ? 1 : 0;
356
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.7.35",
3
+ "version": "4.7.36",
4
4
  "description": "The StarCi app command implementation for scaffolding, linting, checking and synchronizing one product repository.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -39,6 +39,7 @@
39
39
  "db/**",
40
40
  "ci/**",
41
41
  "coverage/**",
42
+ "codemod/**",
42
43
  "lint/**",
43
44
  "scaffold/**",
44
45
  "report/**",
@@ -61,7 +61,16 @@ topics:
61
61
  shadow, one pink decision accent with a text-safe accent for text — with DNA, idioms, a
62
62
  mobile-first playbook for playful learning or consumer apps, and family boundaries including two
63
63
  decisions flagged for the family owner.
64
+ - id: heritage
65
+ path: heritage/DNA.yaml
66
+ title: Heritage Grammar
67
+ summary: |
68
+ Generated snapshot of the Heritage family's parity declaration, arrangement, scoped tokens and Common renderers.
64
69
  families:
70
+ - id: heritage
71
+ title: heritage
72
+ contractRef: common
73
+ packageSnapshot: heritage/DNA.yaml
65
74
  - id: common
66
75
  title: common
67
76
  componentContract:
@@ -132,7 +132,10 @@ shapes:
132
132
  `cancelLabel` in the product's words; `onConfirm` may return a promise, which keeps the confirm
133
133
  pending and the dialog open until it settles
134
134
  - renderer: Button
135
- role: The trigger, inside the Core root; never a red Button (Core has no destructive variant)
135
+ role: |
136
+ The trigger, inside the Core root: `intent="destructive"` at the entry step, which resolves to
137
+ `danger-soft` (CTA-6); the confirm inside the AlertDialog is the solid `danger` confirm, dismiss
138
+ comes first and takes initial focus
136
139
  notes: |
137
140
  Initial focus lands on the cancel action and an outside press never dismisses. A reversible
138
141
  action is not a confirmation: act, then offer undo through a Toast `action`.
@@ -189,7 +192,8 @@ shapes:
189
192
  implements: [FE-APP-SHELL-2]
190
193
  compose: |
191
194
  IdentityLockup sits inside PlainPressable size="control". Its square logo mark has a
192
- transparent background, no radius and object-fit contain, with a monogram fallback. The title
195
+ transparent background, no radius and object-fit contain; without an image it uses Avatar's
196
+ bundled DiceBear initials. The title
193
197
  has a muted role subtitle and no caret. PlainPressable has no button shape or hover effect;
194
198
  press uses scale(0.96), focus remains visible and reduced motion removes the zoom.
195
199
  - id: navbar-right-cluster
@@ -199,7 +203,9 @@ shapes:
199
203
  single-select DropdownMenu, Switch size="control" with sun/moon size-5 thumb icons and
200
204
  isLabelHidden, a bell IconButton with count badge opening Popover, then a circle-only
201
205
  AvatarButton size="control" opening the account DropdownMenu. AvatarButton uses uploaded src
202
- first and otherwise a DiceBear fallback seeded by userId. DropdownMenu preserves HeroUI's
206
+ first and otherwise the bundled DiceBear initials style: the first and last words of name
207
+ supply the letters, and userId passed as seed determines colour, falling back to name when
208
+ seed is absent (owner:avatar-default-dicebear-initials). DropdownMenu preserves HeroUI's
203
209
  anatomy and default selection indicator. The supported language labels use the "Vietnamese"
204
210
  and "English" entries in modules/i18n/messages/app-shell.yaml; each language keeps its autonym.
205
211
  - id: account-menu
@@ -245,9 +251,12 @@ shapes:
245
251
  when those categories exist in the product; labels come from modules/i18n/messages/ui.yaml
246
252
  and modules/i18n/messages/app-shell.yaml. ListBox panel alignment keeps rows and section
247
253
  captions on the same content edge without padding wrappers. A row shows avatar, name,
248
- trailing time, last message and unread badge; the open row is highlighted. Group identity
249
- uses AvatarGroup layout="grid": a 2×2 composite with the first three members and a +N
250
- overflow count when more than four exist, plus a group icon before the name.
254
+ trailing time, last message and unread badge; the open row is selected through HeroUI.
255
+ The name and preview each occupy one line with an ellipsis, and the title carries no group
256
+ glyph (owner:item-description-one-line). Pass emphasis="unread" for unread rows so Grammar
257
+ strengthens the label and preview; read rows keep the vendor label and muted preview.
258
+ Group identity uses AvatarGroup layout="grid" with the member/count arrangement declared
259
+ by presentation.groupIdentity.
251
260
  notes: |
252
261
  FE-RULE-OWNERSHIP in knowledge/patterns/fe/index.yaml separates universal app-shell semantics
253
262
  from these family APIs, values, shapes and effects. @starci/eslint-grammar-starci owns their
@@ -463,6 +472,16 @@ guidance:
463
472
  in a component.
464
473
 
465
474
  presentation:
475
+ collectionItems:
476
+ ownership: FE-RULE-OWNERSHIP
477
+ requirement: |
478
+ ListBoxItem, DropdownMenuItem, SidebarItem and the ListOption shared by Select and ComboBox
479
+ render descriptions in HeroUI's Description slot. The family DNA's collectionItem.descriptionLines
480
+ defaults to one line with an ellipsis (owner:item-description-one-line); descriptionLines accepts
481
+ 1, 2 or 3 for a bounded override through the packaged stylesheet's line clamp. The complete
482
+ description remains available to assistive technology. ListBoxItem emphasis="unread" uses
483
+ collectionItem.unreadWeight for both label and description; omit it for HeroUI's read treatment.
484
+ labelLeading remains an optional decorative title slot, separate from conversation identity.
466
485
  glyphs:
467
486
  ownership: FE-RULE-OWNERSHIP
468
487
  implements: FE-ICON
@@ -478,13 +497,23 @@ presentation:
478
497
  implements: COLLECTION-3
479
498
  requirement: |
480
499
  Compose AvatarGroup layout="grid" at one Avatar size, including size="control". Two members
481
- split the round mark; three show one member above two below; four occupy a 2 by 2 grid. A hairline
482
- separates cells. Above four members the first three remain visible and the fourth cell shows
483
- the remaining-member count as +N. Each member uses Avatar's image, initials or DiceBear fallback.
484
- A single person uses Avatar. Pass the uploaded avatar as `src`; otherwise pass the user or identity
485
- id as `seed` on Avatar, AvatarButton or each AvatarGroup member, and Grammar draws its fixed
486
- deterministic DiceBear style; with neither, Grammar uses initials. Apps neither declare nor import
487
- `@dicebear/*`. Apps supply member identity data; the renderer owns the geometry.
500
+ split the round mark; three show one member above two below; four occupy a 2 by 2 grid. Every cell
501
+ has a ring in the surface colour. At five or more members the first three remain visible and the
502
+ fourth cell shows the total member count, such as 5 or 30, through Avatar's shared fallback path.
503
+ The row layout keeps its +N count. overflowLabel receives the hidden-member count in both layouts
504
+ for accessible naming, while the grid digits show the total. A single person uses Avatar.
505
+ Pass an uploaded image as `src`; otherwise Grammar always draws the bundled DiceBear `initials`
506
+ style, with the first letter of the first word and the first letter of the last word of `name`
507
+ (one word supplies one letter). `seed`, the user or entity id, determines colour when present;
508
+ otherwise `name` does. The image clips to the Avatar shape, circle by default, and keeps text
509
+ contrast in both themes (owner:avatar-default-dicebear-initials).
510
+ Avatar, AvatarButton and each AvatarGroup member accept `kind="person" | "agent"`, default person.
511
+ An agent's name is free text and uses exactly the same initials rule, without role-suffix parsing.
512
+ `kind="agent"` adds a HeroUI Badge at the Avatar's bottom-right edge with a bot glyph, a surface-colour
513
+ ring and an accessible name containing AI (owner:agent-avatar-initials-bot-badge). The family hides
514
+ the badge below 32px, including the 16px to 24px cells in a 2 by 2 grid; the identity's accessible
515
+ name still includes AI. Apps neither declare nor import `@dicebear/*`. Apps supply member identity
516
+ data; the renderer owns the geometry.
488
517
  padding:
489
518
  ownership: FE-RULE-OWNERSHIP
490
519
  knowledge: knowledge/ui/presentation/padding.yaml
@@ -731,8 +760,10 @@ presentation:
731
760
  when: A Grammar list sits inside a panel or its rows own their own inset
732
761
  owner: "`SurfaceListCard` / `ListBox`"
733
762
  render: ListBox resolves inset="panel" in a surface nesting context, or callers select it explicitly. Its
734
- list container extends into the panel gutter; items retain HeroUI's default padding, and row
735
- backgrounds extend into that gutter. Its sections captions share the row content edge. Padding
763
+ row boxes align with the panel content edge, like the search field and tabs boxes, without
764
+ negative-margin bleed (owner:panel-list-box-edge). The list retains HeroUI p-1; items retain
765
+ px-2 py-1.5, rounded-2xl and mt-1 between items, with the same padding and radius for hover,
766
+ pressed and selected backgrounds. Section captions share the inset content edge. Padding
736
767
  wrappers with px-*, ps-* or pe-* stack this inset and fail FE_LIST_PANEL_PADDING in the universal
737
768
  FE canon (R318).
738
769
  - rule: PADDING-1
@@ -23,35 +23,35 @@ pins:
23
23
  source: packages/heroicons/package.json
24
24
  why: 'StarCi custom Heroicons-compatible cuts; eslint-grammar-starci (icon.mjs) admits @starci/heroicons/24/outline and /16/solid as icon sources, so an app that uses them gets this exact version.'
25
25
  '@starci/grammar':
26
- version: 0.12.3
26
+ version: 0.12.4
27
27
  group: starci
28
28
  install: registry
29
29
  side: fe
30
30
  source: packages/grammar/package.json
31
31
  why: 'the grammar front ends read: the brand layer sets `--font-sans` and `--font-mono` and the grammar reads them; it carries the Input tel kind, the copyable and read-only Input, the IconButton disclosure props and count badge, ChatBubble, FillViewport, Textarea mentions and Enter-to-send, the leading PrimaryRailLayout rail, the plate-less Image, ListRow, the soft Alert, Progress steps, the floating primary IconButton, FlowGraph, the STATE-8 loading views and the bordered selectable TagGroup chips. The runtime source version wins for a @starci package.'
32
32
  '@starci/eslint-canon-be':
33
- version: 3.4.44
33
+ version: 3.4.45
34
34
  group: starci
35
35
  install: registry
36
36
  side: be
37
37
  source: packages/eslint/be/package.json
38
38
  why: 'the back-end ESLint canon: `loadHfs(import.meta.url)` of be/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the be side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; `starciBeConfig({ hfs: loadHfs(import.meta.url) })` is the typed factory and the BE-CONVENTION laws are its rules.'
39
39
  '@starci/eslint-canon-fe':
40
- version: 9.0.0
40
+ version: 9.1.0
41
41
  group: starci
42
42
  install: registry
43
43
  side: fe
44
44
  source: packages/eslint/fe/package.json
45
45
  why: 'the front-end ESLint canon: `loadHfs(import.meta.url)` of fe/eslint.config.mjs finds the app-root hfs.json (kind app) and gives every linted file the view of the fe side, and the project graph is built per side; its bundled runtime copies (slots, canon-pins, failure codes, the architecture machine) put .starcistacks and .sops.yaml at the app root; the front end has no tests (FE_NO_TESTS R97 in hfs) and no-vietnamese-in-source (R91) applies.'
46
46
  '@starci/eslint-grammar-starci':
47
- version: 0.12.3
47
+ version: 0.12.4
48
48
  group: starci
49
49
  install: registry
50
50
  side: fe
51
51
  source: packages/eslint/grammar-starci/package.json
52
52
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
53
53
  '@starci/eslint-grammar-offset-pop':
54
- version: 0.12.3
54
+ version: 0.12.4
55
55
  group: starci
56
56
  install: registry
57
57
  side: fe
@@ -91,14 +91,14 @@ pins:
91
91
  source: packages/test-world/package.json
92
92
  why: 'the shared e2e library of every back end (R47, R48), a devDependency of every back end: the warm stack behind toxiproxy, the network-edge fakes, the Nest boot, the typed useTestWorld handle, useSandbox for contract specs and the outage lock. Kafka is real own infra (the one digest-pinned apache/kafka KRaft image, a broker listener and proxy per slot, slot-prefixed topics, consumer groups and client ids) and Postgres is schema-per-context (a schema and a login role per context in a shared database). Each worker has its own data slot (<ns>_w<k>) with its own databases, Keycloak realm, leased Redis DB, bucket/collection/topic prefixes, fakes host, toxiproxy proxies and outage lock; a run provisions min(jest workers, workers cap, default 2) slots, and state file protocol 2 pairs exactly with @starci/jest-preset (TEST_WORLD_PAIR_MISMATCH otherwise). A modules world boots real peer apps beside its modules, world.apps.<name>.during(fn) is the outage of a peer app, w.keycloak.clientSecret(client) answers the run-generated secret of a confidential realm client, world.keycloak.events/sessions read the realm user events and live sessions, world.infra.postgresql.connection(name) takes one database connection down while the others serve, and world.resolve and scope.resolve take any Nest token. Every path a declaration names (`stack`, seeds, the realm, Dockerfiles) resolves from the app root (the directory of hfs.json); the globalSetup registers the path aliases as TypeScript resolves them. Its CLI main is exported at ./cli for starci app stack.'
93
93
  '@starci/cli':
94
- version: 1.7.35
94
+ version: 1.7.36
95
95
  group: starci
96
96
  install: registry
97
97
  side: both
98
98
  source: packages/cli/package.json
99
99
  why: 'the only StarCi command package; product repositories install it for starci app, while @starci/hfs remains its pinned transitive implementation.'
100
100
  '@starci/hfs':
101
- version: 4.7.35
101
+ version: 4.7.36
102
102
  group: starci
103
103
  install: registry
104
104
  side: both
@@ -121,10 +121,11 @@ concerns:
121
121
  pattern: FE-UI
122
122
  forbids: ['@dicebear/*', boring-avatars, 'react-nice-avatar', multiavatar]
123
123
  why: >-
124
- Grammar owns circle avatars: pass the uploaded avatar as src, otherwise pass the user or identity id as seed on
125
- Avatar, AvatarButton or AvatarGroup members. Grammar renders deterministic DiceBear images with its fixed DNA style;
126
- @dicebear/core and @dicebear/styles are Grammar runtime dependencies pinned in canon-pins.yaml. Apps declare and
127
- import @starci/grammar, never @dicebear/*, and draw no avatar visuals. With neither src nor seed, initials remain.
124
+ Grammar owns avatars: pass the uploaded avatar as src and the user or identity id as seed on Avatar, AvatarButton
125
+ or AvatarGroup members. Without src, Grammar renders bundled DiceBear initials using the first and last words of
126
+ name, with colour seeded by seed when present and by name otherwise. kind="agent" adds the Grammar-owned bot badge
127
+ without changing the name rule. @dicebear/core and @dicebear/styles are Grammar runtime dependencies pinned in
128
+ canon-pins.yaml. Apps declare and import @starci/grammar, never @dicebear/*, and draw no avatar visuals.
128
129
  - id: theme
129
130
  tier: core
130
131
  packages: [next-themes]
@@ -5598,3 +5598,33 @@ rules:
5598
5598
  source: owner
5599
5599
  quote: "apps on Grammar write layout-only CSS; every visual element lives in @starci/grammar"
5600
5600
  ruling: "ANATOMY-2 owns contextual field defaults, PADDING-0 owns panel list alignment, and COLLECTION-3 owns composite member avatars for conversation and group identity. Grammar supplies these capabilities; the FE canon rejects static field variants on either layer, primary Tabs inside painted surfaces, raw app surface paint and stacked horizontal list-wrapper padding inside painted surfaces."
5601
+
5602
+ - id: R319
5603
+ code: "FE_BUTTON_INTENT"
5604
+ law: "An app Button or IconButton declares an intent and never a variant; Grammar resolves the variant from intent and placement (CTA-6). A destructive confirm lives in a confirmation dialog, a render holds at most one commit, and a collection item holds none."
5605
+ kinds: [codemod, lint, design]
5606
+ gates: [pre-commit, settle, land, ci]
5607
+ failureCodes: ["FE_BUTTON_INTENT", "FE_BUTTON_INTENT_REQUIRED", "FE_BUTTON_NO_VARIANT", "FE_DESTRUCTIVE_CONFIRM_IN_DIALOG", "FE_SINGLE_COMMIT_PER_RENDER", "FE_NO_COMMIT_IN_COLLECTION_ITEM"]
5608
+ enforcers:
5609
+ - {kind: eslint-fe, id: button-intent-required}
5610
+ - {kind: eslint-fe, id: button-no-variant}
5611
+ - {kind: eslint-fe, id: destructive-confirm-in-dialog}
5612
+ - {kind: eslint-fe, id: single-commit-per-render}
5613
+ - {kind: eslint-fe, id: no-commit-in-collection-item}
5614
+ editions: [lite, full]
5615
+ docs:
5616
+ - knowledge/ui/composition/cta.yaml
5617
+ - docs/code-pattern-enforcement.md#r319-button-intent
5618
+ proves:
5619
+ - {path: packages/eslint/fe/button-intent.spec.mjs, editions: [lite, full], codes: [FE_BUTTON_INTENT, FE_BUTTON_INTENT_REQUIRED, FE_BUTTON_NO_VARIANT, FE_DESTRUCTIVE_CONFIRM_IN_DIALOG, FE_SINGLE_COMMIT_PER_RENDER, FE_NO_COMMIT_IN_COLLECTION_ITEM]}
5620
+ exemplifiedBy:
5621
+ - {path: packages/eslint/fe/button-intent.spec.mjs, editions: [lite, full], codes: [FE_BUTTON_INTENT, FE_BUTTON_INTENT_REQUIRED, FE_BUTTON_NO_VARIANT, FE_DESTRUCTIVE_CONFIRM_IN_DIALOG, FE_SINGLE_COMMIT_PER_RENDER, FE_NO_COMMIT_IN_COLLECTION_ITEM]}
5622
+ witness:
5623
+ - {path: packages/eslint/fe/button-intent.spec.mjs, editions: [lite, full], codes: [FE_BUTTON_INTENT, FE_BUTTON_INTENT_REQUIRED, FE_BUTTON_NO_VARIANT, FE_DESTRUCTIVE_CONFIRM_IN_DIALOG, FE_SINGLE_COMMIT_PER_RENDER, FE_NO_COMMIT_IN_COLLECTION_ITEM]}
5624
+ templateNa: "The intent of an action comes from the product decision in the .starciwork specification; a template cannot choose it."
5625
+ cases:
5626
+ - id: case-1
5627
+ date: "2026-10-25"
5628
+ source: owner
5629
+ quote: "Critique this carefully so the button choice is a system and not a feeling, understand? Update .claude."
5630
+ ruling: "The button variant follows the declared intent (owner ruling button-variant-follows-intent): the app passes intent and step, Grammar derives the variant from them and from the placement it reads from React context. The semantics are HeroUI's, so the lint is universal in the FE canon."