@starci/hfs 4.15.0 → 4.16.0

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.16.0 - 2026-10-31
4
+
5
+ - Added: `starci app codemod spacing-roles [--execute] [--json] [--format json|markdown] [--out <file>]`: a dry run by default; plain flex column and row `div`s whose only spacing is one gap become `Stack` and `Cluster` bound from the file's Grammar import (gap-1 identity, gap-2 inline, gap-3 row, gap-4 section, gap-6 and gap-8 region, gap-8 marked for review); every other spacing utility is reported with its suggested role, and the report counts the utilities by family.
6
+ - Changed: `starci app lint` reports an ESLint warning (a rule of the canon's counting cohort) with severity `warning`, counts them per rule (`counts.warning`, `counting`) and keeps them out of the verdict and the error threshold.
7
+ - Changed: the canon pins name @starci/grammar, eslint-grammar-core and eslint-grammar-offset-pop 0.18.0, eslint-canon-fe 9.8.0, stylelint-canon 2.4.0 and hfs 4.16.0; R350 joins the bundled rule catalog.
8
+
3
9
  ## 4.15.0 - 2026-10-31
4
10
 
5
11
  - Added: R345 `FE_PANE_GRID_CHILD_NOT_PANE`, R346 `FE_PANE_CHILD_NOT_SECTION`, R347 `FE_PANE_SECTION_PADDING` and R348 `FE_PANE_GRID_FRAME` (eslint-grammar-core `pane-grid-children`, `pane-children`, `no-pane-section-padding`, `pane-grid-page`) and R349 `FE_HAND_BUILT_PANES` (eslint-canon-fe `no-hand-built-panes`) in the bundled rule catalog.
@@ -0,0 +1,16 @@
1
+ /** The codemods `starci app codemod` runs, by name. */
2
+ export declare const CODEMODS: readonly string[];
3
+ /** The arguments of `starci app codemod spacing-roles`: --execute, --json, --format and --out; any other is refused. */
4
+ export declare function spacingRolesArguments(argv: readonly string[]): {
5
+ write: boolean;
6
+ format: 'json' | 'markdown';
7
+ out?: string;
8
+ };
9
+ /**
10
+ * CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
11
+ * that leaves a case for a person exits 1 with the complete report.
12
+ */
13
+ export declare function codemodMain(argv: string[], io: {
14
+ cwd: string;
15
+ stdout: (text: string) => unknown;
16
+ }): number;
@@ -0,0 +1,60 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { codemodMain as buttonIntentMain } from './button-intent.mjs';
4
+ import { migrateSpacingRoles, spacingRolesMarkdown } from './spacing-roles.mjs';
5
+ /** The codemods `starci app codemod` runs, by name. */
6
+ export const CODEMODS = Object.freeze(['button-intent', 'spacing-roles']);
7
+ /** The arguments of `starci app codemod spacing-roles`: --execute, --json, --format and --out; any other is refused. */
8
+ export function spacingRolesArguments(argv) {
9
+ const options = { write: false, format: 'markdown' };
10
+ const args = argv.values();
11
+ for (const arg of args) {
12
+ const [flag, inline] = arg.split(/=(.*)/s);
13
+ if (flag === '--execute' || flag === '--json') {
14
+ if (inline !== undefined && !['true', 'false'].includes(inline))
15
+ throw new Error(`${flag} expects true or false`);
16
+ const value = inline === undefined || inline === 'true';
17
+ if (flag === '--execute')
18
+ options.write = value;
19
+ else if (value)
20
+ options.format = 'json';
21
+ }
22
+ else if (flag === '--format' || flag === '--out') {
23
+ const value = inline ?? args.next().value;
24
+ if (!value || value.startsWith('--'))
25
+ throw new Error(`${flag} needs a value`);
26
+ if (flag === '--out')
27
+ options.out = value;
28
+ else if (value === 'json' || value === 'markdown')
29
+ options.format = value;
30
+ else
31
+ throw new Error('--format accepts json or markdown');
32
+ }
33
+ else
34
+ throw new Error(`unknown codemod option ${arg}`);
35
+ }
36
+ return options;
37
+ }
38
+ /**
39
+ * CLI boundary of `starci app codemod <name>`. Dry run by default; --execute writes only the safe rewrites. A codemod
40
+ * that leaves a case for a person exits 1 with the complete report.
41
+ */
42
+ export function codemodMain(argv, io) {
43
+ const [name, ...rest] = argv;
44
+ if (name === 'button-intent')
45
+ return buttonIntentMain(argv, io);
46
+ if (name !== 'spacing-roles')
47
+ throw new Error(`starci app codemod takes ${CODEMODS.join(' or ')}`);
48
+ const options = spacingRolesArguments(rest);
49
+ if (options.out && !/\.(?:json|md)$/i.test(options.out))
50
+ throw new Error('--out needs a .json or .md report file');
51
+ const report = migrateSpacingRoles({ root: path.resolve(io.cwd), write: options.write });
52
+ const output = options.format === 'json' ? `${JSON.stringify(report, null, 2)}\n` : spacingRolesMarkdown(report);
53
+ if (options.out) {
54
+ const target = path.resolve(io.cwd, options.out);
55
+ fs.mkdirSync(path.dirname(target), { recursive: true });
56
+ fs.writeFileSync(target, output);
57
+ }
58
+ io.stdout(output);
59
+ return report.findings.length ? 1 : 0;
60
+ }
@@ -0,0 +1,60 @@
1
+ /** The TypeScript compiler API of the app's own install (loadTypescript), typed by the version this package develops against. */
2
+ type Ts = typeof import('typescript');
3
+ /** One rewrite of the report: a layout element whose classes were only a flex column or row and its gap. */
4
+ export interface SpacingChange {
5
+ file: string;
6
+ line: number;
7
+ column: number;
8
+ from: string;
9
+ to: string;
10
+ review?: string;
11
+ }
12
+ /** One spacing utility the codemod leaves for a person, with the role it suggests when it has one. */
13
+ export interface SpacingFinding {
14
+ file: string;
15
+ line: number;
16
+ column: number;
17
+ code: string;
18
+ utility: string;
19
+ suggestion: string;
20
+ reason: string;
21
+ }
22
+ /** The report of a migration (`starci app codemod spacing-roles`). */
23
+ export interface SpacingReport {
24
+ schema: string;
25
+ root: string;
26
+ write: boolean;
27
+ filesScanned: number;
28
+ filesChanged: string[];
29
+ /** Every spacing utility of the scanned source by family (gap, padding, margin, space), before the rewrite. */
30
+ counts: Record<string, number>;
31
+ changes: SpacingChange[];
32
+ findings: SpacingFinding[];
33
+ }
34
+ /** The role each Tailwind gap step names (the doctrine's mapping); `review` marks a step whose role a person confirms. */
35
+ export declare const GAP_ROLES: Readonly<Record<string, {
36
+ role: string;
37
+ review?: string;
38
+ }>>;
39
+ /** The spacing utilities of one class string. */
40
+ export declare const spacingUtilities: (text: string) => string[];
41
+ /** Rewrite one source text: plain `div` flex columns and rows whose only spacing is one gap become Stack and Cluster. */
42
+ export declare function transformSpacingRoles({ ts, text, file }: {
43
+ ts: Ts;
44
+ text: string;
45
+ file?: string;
46
+ }): {
47
+ text: string;
48
+ changes: SpacingChange[];
49
+ findings: SpacingFinding[];
50
+ counts: Record<string, number>;
51
+ };
52
+ /** Preview or apply the safe rewrites in one app's FE tree; every utility left is in the report with its suggested role. */
53
+ export declare function migrateSpacingRoles({ root, write, ts }: {
54
+ root: string;
55
+ write?: boolean;
56
+ ts?: Ts | null;
57
+ }): SpacingReport;
58
+ /** Render the migration and the utilities left for a person as Markdown; JSON consumers read the same report object. */
59
+ export declare function spacingRolesMarkdown(report: SpacingReport): string;
60
+ export {};
@@ -0,0 +1,198 @@
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
+ const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
7
+ const GRAMMAR = /^@starci\/grammar(?:\/(?:core|common|offset-pop))?$/;
8
+ const GENERATED = new Set(['node_modules', '.next', '.turbo', 'dist', 'coverage', '.git']);
9
+ const SPACING_UTILITY = /^-?(p|px|py|pt|pb|ps|pe|pl|pr|m|mx|my|mt|mb|ms|me|ml|mr|gap|gap-x|gap-y|space-x|space-y)-(.+)$/;
10
+ /** The role each Tailwind gap step names (the doctrine's mapping); `review` marks a step whose role a person confirms. */
11
+ export const GAP_ROLES = Object.freeze({
12
+ '1': { role: 'identity' },
13
+ '2': { role: 'inline' },
14
+ '3': { role: 'row' },
15
+ '4': { role: 'section' },
16
+ '6': { role: 'region' },
17
+ '8': { role: 'region', review: 'gap-8 and gap-6 both become region; confirm the two blocks are page regions with their own heading' },
18
+ });
19
+ /** Classes a Stack or Cluster already draws, so an element holding only these and one gap becomes the primitive. */
20
+ const STACK_CLASSES = new Set(['flex', 'flex-col', 'min-w-0']);
21
+ const CLUSTER_CLASSES = new Set(['flex', 'flex-row', 'flex-wrap', 'items-center', 'min-w-0']);
22
+ const family = (utility) => {
23
+ const name = SPACING_UTILITY.exec(utility.replace(/^!|!$/g, ''))?.[1] ?? '';
24
+ return name.startsWith('gap') ? 'gap' : name.startsWith('space') ? 'space' : name.startsWith('p') ? 'padding' : 'margin';
25
+ };
26
+ /** The utility once its variants are dropped (`sm:gap-2` is `gap-2`); a variant marks a responsive or state choice. */
27
+ const baseOf = (cls) => cls.slice(cls.lastIndexOf(':') + 1);
28
+ /** The spacing utilities of one class string. */
29
+ export const spacingUtilities = (text) => text.split(/\s+/).filter((cls) => SPACING_UTILITY.test(baseOf(cls).replace(/^!|!$/g, '')));
30
+ /** What a person writes instead of one utility the codemod does not rewrite. */
31
+ const suggestionFor = (utility) => {
32
+ const base = baseOf(utility);
33
+ const kind = family(base);
34
+ if (kind === 'gap' || kind === 'space') {
35
+ const step = /-(\d+)$/.exec(base)?.[1] ?? '';
36
+ const mapped = GAP_ROLES[step];
37
+ return mapped ? `gap="${mapped.role}" on a Stack or Cluster` : 'the role of the relation on a Stack, Cluster or Grid';
38
+ }
39
+ return kind === 'margin' ? "the parent's gap role" : 'the inset of the Grammar box that holds the content';
40
+ };
41
+ /** The Grammar import that binds Stack and Cluster in a file: the family specifier it already imports. */
42
+ function grammarImport(ts, source) {
43
+ for (const node of source.statements) {
44
+ if (!ts.isImportDeclaration(node) || !ts.isStringLiteral(node.moduleSpecifier) || !GRAMMAR.test(node.moduleSpecifier.text))
45
+ continue;
46
+ const bindings = node.importClause?.namedBindings;
47
+ if (bindings === undefined || !ts.isNamedImports(bindings) || node.importClause?.isTypeOnly)
48
+ continue;
49
+ return { specifier: node.moduleSpecifier.text, node, names: new Set(bindings.elements.map((element) => element.name.text)) };
50
+ }
51
+ return null;
52
+ }
53
+ /** The static class string of an element, or null when it is computed. */
54
+ function classText(ts, attribute) {
55
+ const value = attribute?.initializer;
56
+ if (value === undefined)
57
+ return null;
58
+ if (ts.isStringLiteral(value))
59
+ return value.text;
60
+ if (ts.isJsxExpression(value) && value.expression !== undefined && ts.isStringLiteralLike(value.expression))
61
+ return value.expression.text;
62
+ return null;
63
+ }
64
+ /** The primitive and its props an element's classes ask for, or why it stays for a person. */
65
+ function primitiveFor(classes) {
66
+ const gaps = classes.filter((cls) => /^gap-\d+$/.test(cls));
67
+ if (gaps.length !== 1)
68
+ return { reason: 'the element holds no single plain gap utility' };
69
+ const step = (gaps[0] ?? '').slice(4);
70
+ const mapped = GAP_ROLES[step];
71
+ if (mapped === undefined)
72
+ return { reason: `gap-${step} names no role; choose the relation` };
73
+ const rest = classes.filter((cls) => cls !== gaps[0]);
74
+ const review = mapped.review === undefined ? {} : { review: mapped.review };
75
+ if (rest.includes('flex-col') && rest.every((cls) => STACK_CLASSES.has(cls)))
76
+ return { tag: 'Stack', props: `gap="${mapped.role}"`, ...review };
77
+ if (rest.includes('flex') && !rest.includes('flex-col') && rest.every((cls) => CLUSTER_CLASSES.has(cls))) {
78
+ if (mapped.role === 'region')
79
+ return { reason: 'a cluster never holds regions; a region gap belongs to a Stack or a Grid' };
80
+ return { tag: 'Cluster', props: `gap="${mapped.role}"${rest.includes('flex-wrap') ? '' : ' wrap={false}'}`, ...review };
81
+ }
82
+ return { reason: 'the element carries layout classes a Stack or Cluster does not draw; move them to a Grammar API first' };
83
+ }
84
+ /** Rewrite one source text: plain `div` flex columns and rows whose only spacing is one gap become Stack and Cluster. */
85
+ export function transformSpacingRoles({ ts, text, file = 'component.tsx' }) {
86
+ const source = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, /\.jsx$/i.test(file) ? ts.ScriptKind.JSX : ts.ScriptKind.TSX);
87
+ const changes = [];
88
+ const findings = [];
89
+ const counts = {};
90
+ const edits = [];
91
+ const needed = new Set();
92
+ const imported = grammarImport(ts, source);
93
+ const leave = (node, utility, code, reason) => {
94
+ findings.push({ file, ...sourceLocation(source, node), code, utility, suggestion: suggestionFor(utility), reason });
95
+ };
96
+ const visitString = (node) => {
97
+ for (const utility of spacingUtilities(node.text)) {
98
+ counts[family(baseOf(utility))] = (counts[family(baseOf(utility))] ?? 0) + 1;
99
+ leave(node, utility, 'class-string', 'a class string outside a plain layout element; name the relation where it is used');
100
+ }
101
+ };
102
+ const visitElement = (opening, element) => {
103
+ const attributes = opening.attributes.properties;
104
+ const classAttribute = attributes.find((entry) => ts.isJsxAttribute(entry) && entry.name.getText(source) === 'className');
105
+ const text = classText(ts, classAttribute);
106
+ if (text === null)
107
+ return;
108
+ const utilities = spacingUtilities(text);
109
+ for (const utility of utilities)
110
+ counts[family(baseOf(utility))] = (counts[family(baseOf(utility))] ?? 0) + 1;
111
+ if (utilities.length === 0)
112
+ return;
113
+ const classes = text.split(/\s+/).filter(Boolean);
114
+ const others = attributes.filter((entry) => entry !== classAttribute && !(ts.isJsxAttribute(entry) && entry.name.getText(source) === 'key'));
115
+ const isDiv = opening.tagName.getText(source) === 'div';
116
+ const target = primitiveFor(classes);
117
+ const reason = !isDiv ? 'only a plain div becomes a layout primitive' : others.length > 0 ? 'the element carries props a layout primitive does not take' : imported === null ? 'the file imports no Grammar family to bind the primitive from' : 'reason' in target ? target.reason : null;
118
+ if (reason !== null || 'reason' in target) {
119
+ for (const utility of utilities)
120
+ leave(opening, utility, 'layout-element', reason ?? 'the element stays for a person');
121
+ return;
122
+ }
123
+ needed.add(target.tag);
124
+ const key = attributes.find((entry) => ts.isJsxAttribute(entry) && entry.name.getText(source) === 'key');
125
+ const props = `${target.tag} ${target.props}${key === undefined ? '' : ` ${key.getText(source)}`}`;
126
+ edits.push({ start: opening.getStart(source), end: opening.end, replacement: ts.isJsxSelfClosingElement(opening) ? `<${props} />` : `<${props}>` });
127
+ if (ts.isJsxElement(element))
128
+ edits.push({ start: element.closingElement.getStart(source), end: element.closingElement.end, replacement: `</${target.tag}>` });
129
+ changes.push({ file, ...sourceLocation(source, opening), from: text, to: `<${target.tag} ${target.props}>`, ...(target.review === undefined ? {} : { review: target.review }) });
130
+ };
131
+ const visit = (node) => {
132
+ if (ts.isJsxElement(node))
133
+ visitElement(node.openingElement, node);
134
+ else if (ts.isJsxSelfClosingElement(node))
135
+ visitElement(node, node);
136
+ else if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && !ts.isJsxAttribute(node.parent) && !ts.isImportDeclaration(node.parent) && !(ts.isJsxExpression(node.parent) && ts.isJsxAttribute(node.parent.parent)))
137
+ visitString(node);
138
+ ts.forEachChild(node, visit);
139
+ };
140
+ visit(source);
141
+ if (imported !== null) {
142
+ const missing = [...needed].filter((name) => !imported.names.has(name)).sort();
143
+ const bindings = imported.node.importClause?.namedBindings;
144
+ if (missing.length > 0 && bindings !== undefined && ts.isNamedImports(bindings)) {
145
+ const names = [...bindings.elements.map((element) => element.getText(source)), ...missing];
146
+ edits.push({ start: bindings.getStart(source), end: bindings.end, replacement: `{ ${names.join(', ')} }` });
147
+ }
148
+ }
149
+ let output = text;
150
+ for (const edit of edits.sort((first, second) => second.start - first.start))
151
+ output = output.slice(0, edit.start) + edit.replacement + output.slice(edit.end);
152
+ return { text: output, changes, findings, counts };
153
+ }
154
+ function sourceFiles(directory) {
155
+ return fs.readdirSync(directory, { withFileTypes: true }).flatMap((entry) => {
156
+ const target = path.join(directory, entry.name);
157
+ if (entry.isDirectory())
158
+ return GENERATED.has(entry.name) ? [] : sourceFiles(target);
159
+ return entry.isFile() && /\.[jt]sx?$/i.test(entry.name) && !/\.(?:spec|test|d)\.[jt]sx?$/i.test(entry.name) ? [target] : [];
160
+ }).sort();
161
+ }
162
+ /** Preview or apply the safe rewrites in one app's FE tree; every utility left is in the report with its suggested role. */
163
+ export function migrateSpacingRoles({ root, write = false, ts = loadTypescript(root, PACKAGE_ROOT) }) {
164
+ if (!ts)
165
+ throw new Error('spacing-roles needs TypeScript installed in the app or @starci/hfs development checkout');
166
+ const frontend = path.join(root, 'fe');
167
+ if (!fs.statSync(frontend, { throwIfNoEntry: false })?.isDirectory())
168
+ throw new Error('spacing-roles runs at an app root with a fe/ directory');
169
+ const report = { schema: 'starci/spacing-roles-codemod@1', root, write, filesScanned: 0, filesChanged: [], counts: {}, changes: [], findings: [] };
170
+ for (const target of sourceFiles(frontend)) {
171
+ const file = path.relative(root, target).split(path.sep).join('/');
172
+ const original = fs.readFileSync(target, 'utf8');
173
+ const result = transformSpacingRoles({ ts, text: original, file });
174
+ report.filesScanned += 1;
175
+ report.changes.push(...result.changes);
176
+ report.findings.push(...result.findings);
177
+ for (const [name, count] of Object.entries(result.counts))
178
+ report.counts[name] = (report.counts[name] ?? 0) + count;
179
+ if (result.text !== original) {
180
+ report.filesChanged.push(file);
181
+ if (write)
182
+ fs.writeFileSync(target, result.text);
183
+ }
184
+ }
185
+ return report;
186
+ }
187
+ const cell = (value) => String(value).replaceAll('|', '\\|').replaceAll('\n', ' ');
188
+ /** Render the migration and the utilities left for a person as Markdown; JSON consumers read the same report object. */
189
+ export function spacingRolesMarkdown(report) {
190
+ const counts = Object.entries(report.counts).sort().map(([name, count]) => `${name} ${count}`).join(', ') || 'none';
191
+ return ['# Spacing roles migration', '',
192
+ `${report.write ? 'Applied' : 'Dry run'}: ${report.changes.length} rewrites in ${report.filesChanged.length} files of ${report.filesScanned}; ${report.findings.length} utilities left for a person.`, '',
193
+ `Spacing utilities before: ${counts}.`, '',
194
+ '## Rewrites', '', '| Location | Classes | Becomes | Review |', '| --- | --- | --- | --- |',
195
+ ...report.changes.map((change) => `| ${cell(`${change.file}:${change.line}:${change.column}`)} | ${cell(change.from)} | ${cell(change.to)} | ${cell(change.review ?? '')} |`), '',
196
+ '## Left for a person', '', '| Location | Utility | Suggestion | Reason |', '| --- | --- | --- | --- |',
197
+ ...report.findings.map((finding) => `| ${cell(`${finding.file}:${finding.line}:${finding.column}`)} | ${cell(finding.utility)} | ${cell(finding.suggestion)} | ${cell(finding.reason)} |`), ''].join('\n');
198
+ }
package/lint/run.mjs CHANGED
@@ -168,7 +168,7 @@ export async function runLinter({ cwd, pkg, bin, args, bound = null }) {
168
168
  return { results, ...(completed ? {} : { error: `${pkg} could not complete in ${path.basename(cwd)}/ (exit ${run.status}, signal ${run.signal ?? 'none'}): ${String(run.error?.message || run.stderr || run.stdout || '').trim().split('\n')[0]}` }) };
169
169
  }
170
170
  const eslintFindings = (results, appRoot) => results.flatMap((result) => (result.messages ?? []).map((message) => ({
171
- engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: 'error',
171
+ engine: 'eslint', rule: message.ruleId ?? 'eslint-error', code: CODE_PREFIX.exec(message.message ?? '')?.[1] ?? null, severity: message.severity === 1 ? 'warning' : 'error',
172
172
  path: posix(path.relative(appRoot, result.filePath)), line: message.line ?? null, column: message.column ?? null, message: message.message,
173
173
  })));
174
174
  const stylelintFindings = (results, appRoot) => results.flatMap((result) => (result.warnings ?? []).map((warning) => ({
@@ -676,18 +676,27 @@ export async function lintRepository({ repoRoot, opts, hfsCheck, trackedFiles =
676
676
  linterReport('eslint', raw.eslint, { root: repoRoot, sourceRoots, tracked }),
677
677
  linterReport('stylelint', raw.stylelint, { root: repoRoot, sourceRoots, tracked }),
678
678
  ]);
679
- // Green under the threshold (gate-threshold.mjs): every finding is still reported, only the verdict changes.
680
- const gate = gateVerdict(findings);
681
- const report = { schema: LINT_SCHEMA, ok: gate.pass && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: findings.length }, engines, errors, findings, gate };
679
+ // Green under the threshold (gate-threshold.mjs): every finding is still reported, only the verdict changes. A warning is a
680
+ // rule of the canon's counting cohort (eslint-canon-fe COUNTING): reported and counted per rule, never part of the verdict.
681
+ const failing = findings.filter((finding) => finding.severity !== 'warning');
682
+ const counting = {};
683
+ for (const finding of findings)
684
+ if (finding.severity === 'warning')
685
+ counting[finding.rule] = (counting[finding.rule] ?? 0) + 1;
686
+ const gate = gateVerdict(failing);
687
+ const report = { schema: LINT_SCHEMA, ok: gate.pass && errors.length === 0, repoRoot, changed: changed ? [...changed].sort(byCodeUnit) : null, workspace, ...(opts.side === undefined ? {} : { side: opts.side }), counts: { error: failing.length, warning: findings.length - failing.length }, counting, engines, errors, findings, gate };
682
688
  return { report, sonar, exit: errors.length ? 2 : gate.pass ? 0 : 1 };
683
689
  }
684
690
  export function printLintText(report, out) {
685
691
  for (const f of report.findings)
686
- out(`${f.path ?? '-'}${f.line ? `:${f.line}${f.column ? `:${f.column}` : ''}` : ''} ${f.engine}/${f.rule} ${f.message}\n`);
692
+ out(`${f.path ?? '-'}${f.line ? `:${f.line}${f.column ? `:${f.column}` : ''}` : ''} ${f.severity === 'warning' ? 'warn ' : ''}${f.engine}/${f.rule} ${f.message}\n`);
687
693
  for (const e of report.errors)
688
694
  out(`ERROR ${e}\n`);
689
- const per = Object.keys(report.engines).map((engine) => `${engine} ${report.findings.filter((f) => f.engine === engine).length}`).join(', ');
695
+ const per = Object.keys(report.engines).map((engine) => `${engine} ${report.findings.filter((f) => f.engine === engine && f.severity !== 'warning').length}`).join(', ');
690
696
  out(`\nstarci app lint: ${report.counts.error} finding${report.counts.error === 1 ? '' : 's'} (${per})${report.errors.length ? `, ${report.errors.length} tool error${report.errors.length === 1 ? '' : 's'}` : ''}\n`);
697
+ const counting = Object.entries((report.counting ?? {})).sort(([a], [b]) => a.localeCompare(b));
698
+ if (counting.length)
699
+ out(`counting (warn, not in the verdict): ${counting.map(([rule, count]) => `${rule} ${count}`).join(', ')}\n`);
691
700
  if (report.gate?.line)
692
701
  out(`${report.gate.line}\n`);
693
702
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@starci/hfs",
3
- "version": "4.15.0",
3
+ "version": "4.16.0",
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",
@@ -31,42 +31,42 @@ pins:
31
31
  source: packages/event-stream/package.json
32
32
  why: 'the one server-sent-events client of every front end (fe-stack event-stream, owner:event-stream-package): the transport client layer (modules/api/sse/clients) creates one instance for the app with the bearer token source and the stream operations follow on it; it carries Last-Event-ID resume, the capped doubling backoff, the heartbeat timeout, the single 401 renewal and the useSWRSubscription adapter, so no app keeps a hand-written reader.'
33
33
  '@starci/grammar':
34
- version: 0.17.0
34
+ version: 0.18.0
35
35
  group: starci
36
36
  install: registry
37
37
  side: fe
38
38
  source: packages/grammar/package.json
39
39
  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.'
40
40
  '@starci/eslint-canon-be':
41
- version: 3.11.0
41
+ version: 3.11.1
42
42
  group: starci
43
43
  install: registry
44
44
  side: be
45
45
  source: packages/eslint-canon-be/package.json
46
46
  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.'
47
47
  '@starci/eslint-canon-fe':
48
- version: 9.7.0
48
+ version: 9.8.0
49
49
  group: starci
50
50
  install: registry
51
51
  side: fe
52
52
  source: packages/eslint-canon-fe/package.json
53
53
  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.'
54
54
  '@starci/eslint-grammar-core':
55
- version: 0.17.0
55
+ version: 0.18.0
56
56
  group: starci
57
57
  install: registry
58
58
  side: fe
59
59
  source: packages/eslint-grammar-core/package.json
60
60
  why: 'the StarCi Grammar component API lint, versioned with @starci/grammar; front ends compose this family package with the universal FE canon.'
61
61
  '@starci/eslint-grammar-offset-pop':
62
- version: 0.17.0
62
+ version: 0.18.0
63
63
  group: starci
64
64
  install: registry
65
65
  side: fe
66
66
  source: packages/eslint-grammar-offset-pop/package.json
67
67
  why: 'the Offset Pop Grammar component API lint, versioned with @starci/grammar; its shared component contract comes from the StarCi family lint without copying rules.'
68
68
  '@starci/stylelint-canon':
69
- version: 2.3.0
69
+ version: 2.4.0
70
70
  group: starci
71
71
  install: registry
72
72
  side: fe
@@ -106,7 +106,7 @@ pins:
106
106
  source: packages/ai-router/package.json
107
107
  why: 'the AI pool router of a back end: a NestJS module that routes OpenAI-compatible chat, stream and embeddings calls across keys, models and providers, with @starci/ai-router/testing for its specs; @starci/jest-preset compiles it as an ESM-only dependency.'
108
108
  '@starci/hfs':
109
- version: 4.15.0
109
+ version: 4.16.0
110
110
  group: starci
111
111
  install: registry
112
112
  side: both
@@ -6184,3 +6184,27 @@ rules:
6184
6184
  source: owner
6185
6185
  quote: "sao trang lan agent có gap 1 khoảng trên vậy??? … phải dính vào chứ"
6186
6186
  ruling: "A multi-pane frame is the Grammar's PaneGrid, never columns and a vertical Divider composed by hand inside a card."
6187
+ - id: R350
6188
+ code: "FE_SPACING_UTILITY"
6189
+ law: "Every spacing value is the Grammar's, on its one scale 0 4 8 12 24 32: an app names the relation between the blocks it places through a role prop (Stack and Cluster gap identity, inline, row, section or region; Grid kind cards, form or main-rail; PaneSection; Page) and writes no padding, margin, gap or space utility and no arbitrary spacing value in a class string; a Grammar stylesheet writes no spacing length off the scale and no fluid spacing value."
6190
+ kinds: [lint]
6191
+ gates: [pre-commit, settle, land, ci]
6192
+ failureCodes: ["FE_SPACING_UTILITY", "FE_SPACING_OFF_SCALE"]
6193
+ enforcers:
6194
+ - {kind: eslint-fe, id: no-spacing-utility}
6195
+ - {kind: stylelint, id: spacing-scale}
6196
+ editions: [lite, full]
6197
+ docs: [contexts/ui/presentation.md]
6198
+ templateNa: "A template's own layout is moved onto the role props by starci app codemod spacing-roles; the rule counts at warn for one cohort."
6199
+ proves:
6200
+ - {path: packages/eslint-canon-fe/src/spacing.spec.ts, editions: [lite, full], codes: [FE_SPACING_UTILITY]}
6201
+ exemplifiedBy:
6202
+ - {path: packages/eslint-canon-fe/src/spacing.spec.ts, editions: [lite, full], codes: [FE_SPACING_UTILITY]}
6203
+ witness:
6204
+ - {path: packages/eslint-canon-fe/src/spacing.spec.ts, editions: [lite, full], codes: [FE_SPACING_UTILITY]}
6205
+ cases:
6206
+ - id: case-1
6207
+ date: "2026-10-31"
6208
+ source: owner
6209
+ quote: "hay full 3 bỏ 4 đi!!! thầy nghĩ nên v"
6210
+ ruling: "One spacing scale with no 16; every container holds its content 12 in, and an app names relations through role props, never spacing utilities."
package/src/main.mjs CHANGED
@@ -129,7 +129,7 @@ import { BOOLEAN as CI_BOOLEAN, COMMANDS as CI_COMMANDS } from '../ci/cli.mjs';
129
129
  import { LOCAL_FLAGS as CI_LOCAL_FLAGS } from '../ci/local.mjs';
130
130
  import { USAGE as COVERAGE_USAGE } from '../coverage/cli.mjs';
131
131
  import { SYNC_USAGE } from '../sync/cli.mjs';
132
- import { codemodMain } from '../codemod/button-intent.mjs';
132
+ import { codemodMain } from '../codemod/main.mjs';
133
133
  /** A value flag as parse stores it: the string given, else undefined. */
134
134
  const valueFlag = (opts, flag) => { const given = opts[flag]; return typeof given === 'string' ? given : undefined; };
135
135
  /** A boolean flag as parse stores it: true when given, else undefined. */
@@ -192,7 +192,7 @@ const DATABASE_USAGE = [['new', '<kebab-name>', DB_NEW_FLAGS], ['baseline', '',
192
192
  const VERB_USAGE = {
193
193
  ...Object.fromEntries(Object.entries(PARSED).map(([verb, spec]) => [verb, spec.forms.map((form) => line(`starci app ${verb}`, form.operands, specUsage(form.only ?? (form.flags === undefined ? spec : { value: form.flags })), spec.edition && form.edition !== false ? flagUsage('--edition', { value: true }) : ''))])),
194
194
  lint: ['starci app lint [--changed <file>...] [--workspace <dir>] [--side be|fe] [--fix] [--full] [--full] [--json] [--out <file>]'],
195
- codemod: ['starci app codemod button-intent [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]'],
195
+ codemod: ['starci app codemod button-intent [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]', 'starci app codemod spacing-roles [--execute] [--json] [--format json|markdown] [--out <file.json|file.md>]'],
196
196
  upgrade: ['starci app upgrade [--execute]'],
197
197
  sync: [SYNC_USAGE.sync],
198
198
  world: ['starci app world <up|down|status> [--stack <dir>] [--services <list>] [--k3d] [--force] [--json] [--edition full|lite]'],