@starci/eslint-canon-fe 2.0.0 → 3.0.1

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/README.md CHANGED
@@ -1,92 +1,38 @@
1
- # @starci/eslint-canon-fe
2
-
3
- **74 ESLint rules, from 18 laws, that hold a React front end to one way of being written.**
4
-
5
- Not a style pack. These rules enforce *architecture*: which tier a component belongs to, whether
6
- structure may be typed as a raw class string, where a vendor primitive may be imported, which file
7
- is allowed to fetch. Prettier decides how code looks; this decides what it is allowed to be.
8
-
9
- ```bash
10
- npm i -D @starci/eslint-canon-fe
11
- ```
12
-
13
- ## Use it
14
-
15
- One block in `eslint.config.mjs`. It attaches every rule at the severity the canon asks for, and
16
- makes inline disable comments ineffective rather than merely discouraged.
17
-
18
- ```js
19
- import starciFe, {
20
- recommended,
21
- linterOptions,
22
- starciFeConfig,
23
- } from "@starci/eslint-canon-fe"
24
-
25
- export default [
26
- // …your own ignores, language options and other plugins…
27
- starciFeConfig({
28
- layout: "single-app", // or "monorepo"
29
- plugin: starciFe,
30
- recommended,
31
- linterOptions,
32
- }),
33
- ]
34
- ```
35
-
36
- `layout` is the one thing a repository owns. `single-app` governs `src/**`; `monorepo` governs
37
- `packages/ui/src/**` and `apps/*/src/**`. **Which globs the law applies to is your fact. What the
38
- law says is not** — so there is no option to switch a rule off or lower it to a warning.
39
-
40
- Prefer to wire it by hand? Every piece is exported: `rules`, `recommended`, `linterOptions`,
41
- `ruleOwners`, `audits`, `auditOwners`, `lawOwners`.
42
-
43
- ## What it actually catches
44
-
45
- A sample, not the list:
46
-
47
- | Rule | What it stops |
48
- |---|---|
49
- | `no-literal-structural-class` | Layout classes typed straight onto an element instead of named in the contract registry |
50
- | `no-unknown-contract-key` | A structure key that no registry entry declares |
51
- | `presentational-purity` | A drawing component that fetches, so it can never be rendered from a test |
52
- | `connected-block-has-presentational-twin` | A block wired to data with no pure half to render |
53
- | `no-vendor-icon-outside-icon-leaf` | A vendor glyph imported anywhere but the one leaf that wraps it |
54
- | `no-arbitrary-value` · `no-fractional-step` | Values off the spacing scale, invented per call site |
55
- | `no-resting-twin-component` | A hand-kept skeleton tree that drifts from the thing it stands in for |
56
- | `no-inline-lint-config` | A disable comment switching the architecture off for one line |
57
- | `require-export-jsdoc` | An exported symbol with no stated reason to exist |
58
- | `no-second-language-in-source` | Copy hardcoded where the translation layer should own it |
59
- | `no-core-grammar-value-import-outside-adapter` | Core grammar values imported by product code instead of one adapter owner |
60
- | `surface-branch-requires-contract-render` | A surface used as a JSX children container instead of the contract/render lane |
61
- | `no-reactnode-escape-slot` | Pre-built ReactNode/JSX escaping a typed component boundary |
62
- | `runtime-contract-content-requires-component-type` | Runtime props paired with bound content instead of a ComponentType render |
63
- | `tree-contract-render-identity` | Tree/surface and its render contract claiming different identities |
64
- | `block-root-is-typed-composition` | A block rooted in an anonymous fragment or structural host element |
65
-
66
- Every rule names the law that declares it — `ruleOwners` maps rule name to law, so a failing build
67
- line leads straight to the document that explains why.
68
-
69
- ## Why the rules are all errors
70
-
71
- Existing debt gets fixed before adoption. Lowering architecture to `warn` teaches every later author
72
- that the boundary is optional, and a boundary everyone learns is optional is not a boundary. There
73
- is no staged rollout mode, on purpose.
74
-
75
- ## Companion
76
-
77
- - **[@starci/eslint-canon-be](https://www.npmjs.com/package/@starci/eslint-canon-be)** — the
78
- back-end half: 37 rules from 15 laws covering CQRS, exceptions, transport, observability and
79
- end-to-end flows.
80
-
81
- ## Where the laws live
82
-
83
- Each rule is the enforceable half of a written law. The prose — why the rule exists, what it
84
- refuses, which cases sit just outside it — is published openly at
85
- [starci183/starci-claude-skills](https://github.com/starci183/starci-claude-skills).
86
-
87
- A rule that cannot be pointed at in real code is a proposal, not a law. Everything here is
88
- pointed at.
89
-
90
- ## Requirements
91
-
92
- ESLint 9+ (flat config), Node 20.9+.
1
+ # @starci/eslint-canon-fe
2
+
3
+ The StarCi front-end lint canon for traditional React and TypeScript applications. It keeps
4
+ component ownership, accessibility, loading behavior, vendor boundaries, translations, naming,
5
+ and colocated class-name composition consistent without prescribing a custom rendering protocol.
6
+
7
+ ```bash
8
+ npm i -D @starci/eslint-canon-fe
9
+ ```
10
+
11
+ ```js
12
+ import starciFe, { recommended, linterOptions, starciFeConfig } from "@starci/eslint-canon-fe"
13
+
14
+ export default [starciFeConfig({
15
+ layout: "single-app",
16
+ plugin: starciFe,
17
+ recommended,
18
+ linterOptions,
19
+ })]
20
+ ```
21
+
22
+ The `single-app` layout governs `src/**`; `monorepo` governs shared package and app source trees.
23
+ The package publishes the plugin, recommended levels, repository audits, and layout helper from
24
+ the root entry point.
25
+
26
+ ## Rules
27
+
28
+ Rules cover comments, file layout, icons and vendor ownership, landmarks, loading states, naming,
29
+ props, served locales, component splits, design tokens, translations, type safety, typography, and
30
+ class-name ownership. Reusable class names belong in a colocated `classNames.ts` module and should
31
+ be composed with HeroUI `cn` using one utility token per argument.
32
+
33
+ Grammar is a business-neutral HeroUI-backed component package. Its components accept ordinary
34
+ typed React props and children.
35
+
36
+ ## Requirements
37
+
38
+ ESLint 9+ (flat config) and Node.js 20.9+.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * The classNames law: component markup consumes named styling declarations, while colocated
3
+ * classNames files own the utility-token composition.
4
+ */
5
+
6
+ const normalizePath = (filename) => String(filename || "").replace(/\\/g, "/")
7
+ const isComponentSource = (filename) => normalizePath(filename).includes("/src/components/")
8
+ const isClassNamesFile = (filename) => /\/classNames\.tsx?$/.test(normalizePath(filename))
9
+ const isClassAttribute = (node) => node.type === "JSXAttribute" && ["className", "class"].includes(node.name?.name)
10
+ const isCn = (node) => node?.type === "Identifier" && node.name === "cn"
11
+
12
+ const importedClassNames = (context) => {
13
+ const imports = new Set()
14
+ for (const statement of context.getSourceCode().ast.body) {
15
+ if (statement.type !== "ImportDeclaration" || statement.source.value !== "./classNames") continue
16
+ for (const specifier of statement.specifiers) imports.add(specifier.local.name)
17
+ }
18
+ return imports
19
+ }
20
+
21
+ const isExportedOwner = (node) => {
22
+ let current = node
23
+ while (current) {
24
+ if (["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"].includes(current.type)) {
25
+ current = current.parent
26
+ if (current?.type === "VariableDeclarator") current = current.parent
27
+ if (current?.type === "VariableDeclaration") current = current.parent
28
+ return ["ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(current?.type)
29
+ }
30
+ if (current.type === "VariableDeclarator") {
31
+ current = current.parent?.parent
32
+ return ["ExportNamedDeclaration", "ExportDefaultDeclaration"].includes(current?.type)
33
+ }
34
+ current = current.parent
35
+ }
36
+ return false
37
+ }
38
+
39
+ const visitTokenLeaves = (node, report) => {
40
+ if (!node) return
41
+ if (node.type === "ArrayExpression") {
42
+ report(node, "array")
43
+ return
44
+ }
45
+ if (node.type === "Literal" && typeof node.value === "string") {
46
+ if (/\s/.test(node.value) || /\d+,\d+/.test(node.value)) report(node, "token")
47
+ return
48
+ }
49
+ if (node.type === "ConditionalExpression" || node.type === "LogicalExpression") {
50
+ visitTokenLeaves(node.consequent ?? node.left, report)
51
+ visitTokenLeaves(node.alternate ?? node.right, report)
52
+ }
53
+ }
54
+
55
+ const isEmptyClassName = (node) => node?.type === "Literal" && node.value === null || node?.type === "Identifier" && node.name === "undefined"
56
+
57
+ const usesImportedClassName = (node, imported) => {
58
+ if (!node) return false
59
+ if (node.type === "Identifier") return imported.has(node.name) || node.name === "undefined"
60
+ if (isEmptyClassName(node)) return true
61
+ if (node.type === "CallExpression") {
62
+ if (node.callee.type === "Identifier") return imported.has(node.callee.name)
63
+ return node.callee.type === "MemberExpression" && node.callee.object.type === "Identifier" && imported.has(node.callee.object.name)
64
+ }
65
+ if (node.type === "MemberExpression") return node.object.type === "Identifier" && imported.has(node.object.name)
66
+ if (node.type === "ConditionalExpression") return usesImportedClassName(node.consequent, imported) && usesImportedClassName(node.alternate, imported)
67
+ if (node.type === "LogicalExpression") {
68
+ if (node.operator === "&&") return usesImportedClassName(node.right, imported)
69
+ return usesImportedClassName(node.left, imported) && usesImportedClassName(node.right, imported)
70
+ }
71
+ return false
72
+ }
73
+
74
+ /** Report className values written directly in component JSX. */
75
+ export const noInlineClassName = {
76
+ meta: {
77
+ type: "problem",
78
+ docs: { description: "Component JSX must import a named className declaration." },
79
+ schema: [],
80
+ messages: { inline: "Move this className value to an imported colocated classNames.ts export." },
81
+ },
82
+ create(context) {
83
+ if (!isComponentSource(context.filename || context.getFilename())) return {}
84
+ const imported = importedClassNames(context)
85
+ return {
86
+ JSXAttribute(node) {
87
+ if (!isClassAttribute(node) || !node.value) return
88
+ const expression = node.value.type === "JSXExpressionContainer" ? node.value.expression : node.value
89
+ if (!usesImportedClassName(expression, imported)) context.report({ node, messageId: "inline" })
90
+ },
91
+ }
92
+ },
93
+ }
94
+
95
+ /** Require every classNames declaration to use the colocated classNames.ts convention. */
96
+ export const classNamesInColocatedFile = {
97
+ meta: {
98
+ type: "problem",
99
+ docs: { description: "Reusable className declarations belong in colocated classNames.ts files." },
100
+ schema: [],
101
+ messages: { misplaced: "Put reusable className declarations in a colocated classNames.ts file." },
102
+ },
103
+ create(context) {
104
+ const filename = context.filename || context.getFilename()
105
+ if (!isComponentSource(filename)) return {}
106
+ return {
107
+ CallExpression(node) {
108
+ if (!isCn(node.callee) || isClassNamesFile(filename)) return
109
+ context.report({ node, messageId: "misplaced" })
110
+ },
111
+ }
112
+ },
113
+ }
114
+
115
+ /** Require variadic cn utility tokens: no arrays, whitespace-packed values, or fractional commas. */
116
+ export const cnArgumentsAreSingleTokens = {
117
+ meta: {
118
+ type: "problem",
119
+ docs: { description: "Each cn argument is one valid utility token." },
120
+ schema: [],
121
+ messages: {
122
+ array: "Pass utility tokens to cn as variadic arguments, not an array.",
123
+ token: "Each cn argument must be one utility token without whitespace or malformed fractional commas.",
124
+ unexported: "Export every className declaration or function that produces a cn value.",
125
+ },
126
+ },
127
+ create(context) {
128
+ const filename = context.filename || context.getFilename()
129
+ if (!isClassNamesFile(filename)) return {}
130
+ return {
131
+ CallExpression(node) {
132
+ if (!isCn(node.callee)) return
133
+ if (!isExportedOwner(node)) context.report({ node, messageId: "unexported" })
134
+ for (const argument of node.arguments) visitTokenLeaves(argument, (reportedNode, messageId) => context.report({ node: reportedNode, messageId }))
135
+ },
136
+ }
137
+ },
138
+ }
139
+
140
+ /** Every rule this law publishes. */
141
+ export const rules = {
142
+ "no-inline-class-name": noInlineClassName,
143
+ "class-names-in-colocated-file": classNamesInColocatedFile,
144
+ "cn-arguments-are-single-tokens": cnArgumentsAreSingleTokens,
145
+ }
146
+
147
+ /** Recommended severity for the classNames law. */
148
+ export const recommended = Object.fromEntries(Object.keys(rules).map((name) => [`starci-fe/${name}`, "error"]))
package/file-layout.mjs CHANGED
@@ -22,7 +22,7 @@ const TWO_FILE_TIERS = /\/src\/components\/(pages|layouts)\/([^/]+)\/(.+)$/
22
22
  const TWO_FILE_TIERS_WITH_CATEGORY = /\/src\/components\/(overlays)\/[^/]+\/([^/]+)\/(.+)$/
23
23
 
24
24
  /** The two halves, and the twin test of each, are the whole of what a surface folder may hold. */
25
- const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index)(?:\.spec)?\.tsx?$/
25
+ const ALLOWED_IN_SURFACE_FOLDER = /^(?:component|index|classNames)(?:\.spec)?\.tsx?$/
26
26
 
27
27
  /** Folders that are not component code, whatever they are nested inside. */
28
28
  const NON_COMPONENT_FOLDERS = /\/src\/components\/.*\/(constants|utils|types|hooks)\//
@@ -36,7 +36,7 @@ const surfaceFolder = (filename) => {
36
36
 
37
37
  // -- FILE-2 --------------------------------------------------------------------------------------
38
38
 
39
- /** A page, layout or overlay folder holds its two halves and nothing else. */
39
+ /** A page, layout or overlay folder holds its two halves and its colocated class-name module. */
40
40
  export const surfaceFolderTwoFilesOnly = {
41
41
  meta: {
42
42
  type: "problem",
@@ -44,7 +44,7 @@ export const surfaceFolderTwoFilesOnly = {
44
44
  schema: [],
45
45
  messages: {
46
46
  extra:
47
- "`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds its two halves ONLY (`component.tsx` is the shape, `index.tsx` is the wiring). Whatever this is, it has a real home: a component of its own goes to `blocks/<category>/`, a closed arrangement to `composites/`, a fetch to `hooks/`, a pure helper to `modules/utils/`, a shape to `modules/types/`, copy or a config map to `resources/`. \"Only this screen uses it\" is how a folder becomes a second codebase.",
47
+ "`{{tier}}/{{name}}/` contains `{{rest}}` - a surface folder holds `component.tsx`, `index.tsx`, and an optional `classNames.ts`. Whatever else this is has a real home: a component of its own goes to `blocks/<category>/`, a closed arrangement to `composites/`, a fetch to `hooks/`, a pure helper to `modules/utils/`, a shape to `modules/types/`, copy or a config map to `resources/`.",
48
48
  },
49
49
  },
50
50
  create(context) {
@@ -164,7 +164,7 @@ export const noRuntimeNamespace = {
164
164
  const FEATURE_TIERS = /\/packages\/[^/]+\/src\/(blocks|overlays|pages|layouts)\//
165
165
 
166
166
  /** Tiers that know no feature, and therefore belong to the shared package. */
167
- const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(contracts|leaves|composites|branches)\//
167
+ const VOCABULARY_TIERS = /\/apps\/[^/]+\/src\/(?:components\/)?(leaves|composites|branches)\//
168
168
 
169
169
  /**
170
170
  * In a monorepo, the shared package stops below the block.
@@ -183,7 +183,7 @@ export const monorepoTierBelongsToItsSide = {
183
183
  type: "problem",
184
184
  docs: {
185
185
  description:
186
- "In a monorepo the shared package holds contracts, leaves, composites and branches; blocks, overlays, layouts and pages belong to the app that owns the feature.",
186
+ "In a monorepo the shared package holds leaves, composites and branches; blocks, overlays, layouts and pages belong to the app that owns the feature.",
187
187
  },
188
188
  schema: [],
189
189
  messages: {
@@ -316,7 +316,6 @@ export const sourceTierMarkerMatchesFolder = {
316
316
  blocks: "block",
317
317
  branches: "branch",
318
318
  composites: "composite",
319
- contracts: "contract",
320
319
  layouts: "layout",
321
320
  leaves: "leaf",
322
321
  overlays: "overlay",
@@ -353,7 +352,7 @@ export const noShellTier = {
353
352
  schema: [],
354
353
  messages: {
355
354
  shell:
356
- "`shells/` is an untyped branch exemption. Move the owner to a named branch, replace children with typed contract content, and keep vendor mechanics closed there.",
355
+ "`shells/` is an untyped branch exemption. Move the owner to a named branch and keep vendor mechanics closed there.",
357
356
  },
358
357
  },
359
358
  create(context) {