@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 +6 -0
- package/codemod/main.d.mts +16 -0
- package/codemod/main.mjs +60 -0
- package/codemod/spacing-roles.d.mts +60 -0
- package/codemod/spacing-roles.mjs +198 -0
- package/lint/run.mjs +15 -6
- package/package.json +1 -1
- package/runtime/knowledge/hfs/canon-pins.yaml +7 -7
- package/runtime/knowledge/hfs/rules.yaml +24 -0
- package/src/main.mjs +2 -2
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;
|
package/codemod/main.mjs
ADDED
|
@@ -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
|
-
|
|
681
|
-
const
|
|
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
|
@@ -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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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/
|
|
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]'],
|