@zedtech/zedkit-quality 0.3.1 → 0.4.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/README.md CHANGED
@@ -14,7 +14,8 @@ pnpm add -D @zedtech/zedkit-quality
14
14
  ```
15
15
 
16
16
  Peer dependencies (`eslint`, `eslint-plugin-jsx-a11y`,
17
- `eslint-plugin-better-tailwindcss`) install alongside it.
17
+ `eslint-plugin-better-tailwindcss`, `typescript-eslint`) install
18
+ alongside it.
18
19
 
19
20
  ## Usage
20
21
 
@@ -53,8 +54,10 @@ subagents, commands) see `@zedtech/zedkit-ai` instead.
53
54
 
54
55
  - `uiPreset({ entryPoint })` — flat-config blocks for `.tsx`/`.jsx`:
55
56
  jsx-a11y recommended, Tailwind hygiene, tokens-only classes, no `style`
56
- attributes, no raw `try`/`catch`. `entryPoint` (your Tailwind CSS
57
- entry) is required: it is the one thing that varies per repo.
57
+ attributes, no `forwardRef` (ref is a prop; see `packages/ui/src/button.tsx`),
58
+ no explicit `any`, files ≤ 300 lines and functions ≤ 60, no raw `try`/`catch`.
59
+ `entryPoint` (your Tailwind CSS entry) is required: it is the one thing
60
+ that varies per repo.
58
61
  - `defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, extraPatterns })` —
59
62
  `no-restricted-imports` blocks for routes -> views -> data: the UI kit
60
63
  is only imported from views, views never import data clients or
@@ -62,6 +65,11 @@ subagents, commands) see `@zedtech/zedkit-ai` instead.
62
65
  `{ kitScope?, viewData?, dataUi? }`) merges your own bans into each
63
66
  block — use it instead of a second `no-restricted-imports` block,
64
67
  which would replace these per flat-config key semantics.
68
+ - `defineLayers({ preset: "feature-folders", kitPackage, featureRoot })` —
69
+ same helper, feature-folder mapping: UI imports ports (not adapters),
70
+ adapters are injected at the composition root, model is pure. Bans
71
+ apply only under `featureRoot`. Live example:
72
+ `apps/docs/features/note-list` (running at `/examples/note-list`).
65
73
  - `@zedtech/zedkit-quality/tsconfig` — the strict base (`strict`,
66
74
  `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`,
67
75
  `noUnusedLocals`, and more). Extend it, do not copy it.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zedtech/zedkit-quality",
3
- "version": "0.3.1",
3
+ "version": "0.4.0",
4
4
  "publishConfig": {
5
5
  "access": "public",
6
6
  "registry": "https://registry.npmjs.org/"
@@ -60,14 +60,16 @@
60
60
  "peerDependencies": {
61
61
  "eslint": "9.39.5",
62
62
  "eslint-plugin-better-tailwindcss": "4.10.0",
63
- "eslint-plugin-jsx-a11y": "6.10.2"
63
+ "eslint-plugin-jsx-a11y": "6.10.2",
64
+ "typescript-eslint": "8.72.0"
64
65
  },
65
66
  "devDependencies": {
66
67
  "eslint": "9.39.5",
67
68
  "eslint-plugin-better-tailwindcss": "4.10.0",
68
69
  "eslint-plugin-jsx-a11y": "6.10.2",
69
70
  "typescript": "5.9.3",
70
- "vitest": "2.1.9"
71
+ "typescript-eslint": "8.72.0",
72
+ "vitest": "5.0.5"
71
73
  },
72
74
  "scripts": {
73
75
  "test": "vitest run",
package/src/eslint.d.mts CHANGED
@@ -15,25 +15,40 @@ export interface RestrictedImportPattern {
15
15
  readonly allowTypeImports?: boolean;
16
16
  }
17
17
 
18
- export interface DefineLayersOptions {
19
- /** UI kit specifier (e.g. `@acme/ui`). */
20
- readonly kitPackage: string;
21
- /** Minimatch globs for view folders. */
22
- readonly viewDirs: readonly string[];
23
- /** Minimatch globs for data-access folders. */
24
- readonly dataDirs: readonly string[];
25
- /** Import specifiers for data clients and server actions. */
26
- readonly actionPatterns: readonly string[];
27
- /**
28
- * Consumer bans merged into each block's own patterns (flat-config
29
- * rules replace per key, so a consumer block setting
30
- * `no-restricted-imports` would otherwise drop these).
31
- */
32
- readonly extraPatterns?: {
33
- readonly kitScope?: readonly RestrictedImportPattern[];
34
- readonly viewData?: readonly RestrictedImportPattern[];
35
- readonly dataUi?: readonly RestrictedImportPattern[];
36
- };
37
- }
18
+ export type DefineLayersOptions =
19
+ | {
20
+ readonly preset?: undefined;
21
+ /** UI kit specifier (e.g. `@acme/ui`). */
22
+ readonly kitPackage: string;
23
+ /** Minimatch globs for view folders. */
24
+ readonly viewDirs: readonly string[];
25
+ /** Minimatch globs for data-access folders. */
26
+ readonly dataDirs: readonly string[];
27
+ /** Import specifiers for data clients and server actions. May be empty. */
28
+ readonly actionPatterns: readonly string[];
29
+ /**
30
+ * Consumer bans merged into each block's own patterns (flat-config
31
+ * rules replace per key, so a consumer block setting
32
+ * `no-restricted-imports` would otherwise drop these).
33
+ */
34
+ readonly extraPatterns?: {
35
+ readonly kitScope?: readonly RestrictedImportPattern[];
36
+ readonly viewData?: readonly RestrictedImportPattern[];
37
+ readonly dataUi?: readonly RestrictedImportPattern[];
38
+ };
39
+ }
40
+ | {
41
+ readonly preset: "feature-folders";
42
+ /** UI kit specifier (e.g. `@acme/ui`). */
43
+ readonly kitPackage: string;
44
+ /** Feature-folder root (e.g. `features`). Bans apply only under this tree. */
45
+ readonly featureRoot: string;
46
+ readonly extraPatterns?: {
47
+ readonly kitScope?: readonly RestrictedImportPattern[];
48
+ readonly viewData?: readonly RestrictedImportPattern[];
49
+ readonly dataUi?: readonly RestrictedImportPattern[];
50
+ readonly model?: readonly RestrictedImportPattern[];
51
+ };
52
+ };
38
53
 
39
54
  export function defineLayers(options: DefineLayersOptions): Linter.Config[];
package/src/eslint.mjs CHANGED
@@ -1,17 +1,20 @@
1
- // Shareable UI lint preset: enforces docs/frontend-charter.md by tools.
1
+ // Shareable UI lint preset: charter rule ids live in docs/frontend-charter.md;
2
+ // the messages below carry the explanation.
2
3
  //
3
4
  // What it enforces on every .tsx/.jsx file:
4
5
  // - jsx-a11y recommended (labels, keyboard, focus, ARIA)
5
6
  // - Tailwind class hygiene (order, no duplicates, no conflicts, no unknown)
6
7
  // - Tokens only: no raw palette colors (bg-red-500), no arbitrary hex/rgb
7
8
  // colors, no raw px font sizes — use the semantic theme tokens
8
- // - No `style` attributes; no raw try/catch (use a tryCatch wrapper)
9
+ // - No `style` attributes; no forwardRef (ref is a prop); no explicit `any`;
10
+ // files ≤ 300 lines, functions ≤ 60; no raw try/catch (use a tryCatch wrapper)
9
11
  //
10
12
  // Consumer setup (no copy: install the package, pass your Tailwind entry):
11
13
  // `import { uiPreset } from "@zedtech/zedkit-quality"` then
12
14
  // `export default [...uiPreset({ entryPoint: "./app/globals.css" })]`.
13
15
  import jsxA11y from "eslint-plugin-jsx-a11y";
14
16
  import betterTailwindcss from "eslint-plugin-better-tailwindcss";
17
+ import { plugin as tsPlugin } from "typescript-eslint";
15
18
 
16
19
  const PALETTE =
17
20
  "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|fuchsia|pink|rose";
@@ -57,36 +60,67 @@ export function uiPreset({ entryPoint }) {
57
60
  {
58
61
  pattern: `(^|:)(${COLOR_UTILS})-(${PALETTE}|white|black)(-|/|$)`,
59
62
  message:
60
- "Raw palette colors are banned. Use a semantic theme token (see docs/frontend-charter.md).",
63
+ "tokens-color: raw palette classes (bg-red-500) are banned. Use a semantic token (background, foreground, primary, destructive, …) so light and dark stay in sync.",
61
64
  },
62
65
  {
63
66
  pattern: "\\[(#|rgb|hsl)",
64
67
  message:
65
- "Arbitrary color values are banned. Use a semantic theme token (see docs/frontend-charter.md).",
68
+ "tokens-color: arbitrary hex/rgb/hsl colors are banned. Use a semantic token (background, foreground, primary, …).",
66
69
  },
67
70
  {
68
71
  pattern: "text-\\[[^\\]]*px",
69
- message: "Raw px font sizes are banned. Use the type scale (see docs/frontend-charter.md).",
72
+ message:
73
+ "tokens-style: raw px font sizes are banned. Use the type scale (text-xs … text-2xl).",
70
74
  },
71
75
  ],
72
76
  },
73
77
  ],
74
78
  // NOTE: flat-config rules replace (not merge) by key, so this block
75
79
  // restates the root TryStatement ban alongside the style-attribute
76
- // ban; without the restatement, .tsx files would lose the ban.
80
+ // and forwardRef bans; without the restatement, .tsx files would lose them.
77
81
  "no-restricted-syntax": [
78
82
  "error",
79
83
  {
80
84
  selector: "TryStatement",
81
- message: "Raw try/catch is banned. Use tryCatch/tryCatchAsync from @zedtech/zedkit-core.",
85
+ message:
86
+ "try-catch: raw try/catch is banned. Use tryCatch/tryCatchAsync from @zedtech/zedkit-core so thrown values become Error with cause.",
82
87
  },
83
88
  {
84
89
  selector: 'JSXAttribute[name.name="style"]',
85
- message: "Inline style attributes are banned. Use theme tokens (see docs/frontend-charter.md).",
90
+ message:
91
+ "tokens-style: inline style attributes are banned. Use theme tokens and utility classes.",
92
+ },
93
+ {
94
+ selector: "Identifier[name='forwardRef']",
95
+ message:
96
+ "ui-ref: forwardRef is banned. Pass ref as a prop on ComponentProps<\"button\"> (see packages/ui/src/button.tsx).",
86
97
  },
87
98
  ],
88
99
  },
89
100
  },
101
+ {
102
+ name: "zedkit/ui-types",
103
+ files: ["**/*.tsx", "**/*.jsx"],
104
+ plugins: { "@typescript-eslint": tsPlugin },
105
+ rules: {
106
+ "@typescript-eslint/no-explicit-any": "error",
107
+ },
108
+ },
109
+ {
110
+ name: "zedkit/ui-size",
111
+ files: ["**/*.tsx", "**/*.jsx"],
112
+ ignores: ["**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**"],
113
+ rules: {
114
+ "max-lines": [
115
+ "error",
116
+ { max: 300, skipBlankLines: true, skipComments: true },
117
+ ],
118
+ "max-lines-per-function": [
119
+ "error",
120
+ { max: 60, skipBlankLines: true, skipComments: true, IIFEs: true },
121
+ ],
122
+ },
123
+ },
90
124
  ];
91
125
  }
92
126
 
@@ -109,6 +143,18 @@ function asFiles(patterns) {
109
143
  });
110
144
  }
111
145
 
146
+ function assertExtraPatterns(extraPatterns, keys) {
147
+ const want = keys.join(", ").replace(/, ([^,]+)$/, " or $1");
148
+ for (const [key, value] of Object.entries(extraPatterns)) {
149
+ if (!keys.includes(key)) {
150
+ throw new Error(`defineLayers: unknown extraPatterns key "${key}" (want ${want})`);
151
+ }
152
+ if (!Array.isArray(value)) {
153
+ throw new Error(`defineLayers: extraPatterns.${key} must be a list of no-restricted-imports pattern objects`);
154
+ }
155
+ }
156
+ }
157
+
112
158
  /**
113
159
  * Route/view/data layer boundaries as `no-restricted-imports` blocks.
114
160
  * Dependencies run one way (routes -> views -> data):
@@ -116,31 +162,50 @@ function asFiles(patterns) {
116
162
  * - views receive data as props and never import data clients or actions;
117
163
  * - data files import no UI (no React runtime, no UI kit).
118
164
  *
119
- * @param {{ kitPackage: string, viewDirs: string[], dataDirs: string[], actionPatterns: string[], extraPatterns?: { kitScope?: object[], viewData?: object[], dataUi?: object[] } }} options
120
- * - `kitPackage`: UI kit specifier (e.g. `@acme/ui`).
121
- * - `viewDirs`: view folders (e.g. `app/ui/**`) or file globs.
122
- * - `dataDirs`: data-access folders or file globs, same shapes.
123
- * - `actionPatterns`: import specifiers for data clients and server actions.
124
- * - `extraPatterns`: consumer bans merged into each block's own
125
- * patterns (flat-config rules replace per key, so a consumer block
126
- * setting `no-restricted-imports` would otherwise drop these).
127
- * Keys: `kitScope`, `viewData`, `dataUi`; each a list of standard
128
- * `no-restricted-imports` pattern objects, passed through untouched.
165
+ * @param {object} options
166
+ * - Classic: `{ kitPackage, viewDirs, dataDirs, actionPatterns, extraPatterns? }`.
167
+ * `actionPatterns` may be empty (views legitimately import data): the
168
+ * view ban is then omitted, and the view block is dropped when it
169
+ * would carry no bans at all. `extraPatterns` merges consumer bans
170
+ * into each block (keys: `kitScope`, `viewData`, `dataUi`).
171
+ * - Preset `feature-folders`: `{ preset: "feature-folders", kitPackage,
172
+ * featureRoot, extraPatterns? }`. UI may import ports, not adapters;
173
+ * adapters are injected at the feature composition root; model is
174
+ * pure. Bans apply only under `featureRoot` (extraPatterns also
175
+ * accepts `model`).
129
176
  * @returns {import("eslint").Linter.Config[]} Flat config blocks.
130
177
  */
131
- export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, extraPatterns = {} }) {
132
- for (const [key, value] of Object.entries(extraPatterns)) {
133
- if (key !== "kitScope" && key !== "viewData" && key !== "dataUi") {
134
- throw new Error(`defineLayers: unknown extraPatterns key "${key}" (want kitScope, viewData or dataUi)`);
135
- }
136
- if (!Array.isArray(value)) {
137
- throw new Error(`defineLayers: extraPatterns.${key} must be a list of no-restricted-imports pattern objects`);
138
- }
178
+ export function defineLayers(options) {
179
+ const { preset, extraPatterns = {} } = options;
180
+ if (preset === "feature-folders") {
181
+ return featureFolderLayers(options);
182
+ }
183
+ if (preset !== undefined) {
184
+ throw new Error(`defineLayers: unknown preset "${preset}" (want feature-folders)`);
185
+ }
186
+ const { kitPackage, viewDirs, dataDirs, actionPatterns } = options;
187
+ assertExtraPatterns(extraPatterns, ["kitScope", "viewData", "dataUi"]);
188
+ if (!Array.isArray(actionPatterns)) {
189
+ throw new Error("defineLayers: actionPatterns must be a list of import specifiers (may be empty)");
139
190
  }
140
191
  const kitPatterns = [kitPackage, `${kitPackage}/*`];
141
192
  const views = asFiles(viewDirs);
142
193
  const data = asFiles(dataDirs);
143
194
  const extras = { kitScope: [], viewData: [], dataUi: [], ...extraPatterns };
195
+ // An empty group fails ESLint schema validation, so an empty
196
+ // actionPatterns omits the ban; with no viewData extras either,
197
+ // the whole block is dropped (a ban-less block is noise).
198
+ const viewDataPatterns = [
199
+ ...(actionPatterns.length > 0
200
+ ? [
201
+ {
202
+ group: [...actionPatterns],
203
+ message: "Views receive data as props and never import data clients or server actions.",
204
+ },
205
+ ]
206
+ : []),
207
+ ...extras.viewData,
208
+ ];
144
209
  return [
145
210
  {
146
211
  name: "zedkit/layers-kit-scope",
@@ -161,6 +226,97 @@ export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, e
161
226
  ],
162
227
  },
163
228
  },
229
+ ...(viewDataPatterns.length > 0
230
+ ? [
231
+ {
232
+ name: "zedkit/layers-view-data",
233
+ files: views,
234
+ rules: {
235
+ "no-restricted-imports": ["error", { patterns: viewDataPatterns }],
236
+ },
237
+ },
238
+ ]
239
+ : []),
240
+ {
241
+ name: "zedkit/layers-data-ui",
242
+ files: data,
243
+ rules: {
244
+ "no-restricted-imports": [
245
+ "error",
246
+ {
247
+ patterns: [
248
+ {
249
+ group: ["react", "react-dom", "react-dom/*", "react/jsx-runtime"],
250
+ message: "Data code is UI-free: no React runtime imports.",
251
+ },
252
+ {
253
+ group: kitPatterns,
254
+ message: "Data code is UI-free: no UI kit imports.",
255
+ },
256
+ ...extras.dataUi,
257
+ ],
258
+ },
259
+ ],
260
+ },
261
+ },
262
+ ];
263
+ }
264
+
265
+ const ADAPTER_GROUP = ["**/adapters", "**/adapters/**"];
266
+ const REACT_GROUP = ["react", "react-dom", "react-dom/*", "react/jsx-runtime"];
267
+
268
+ function featureFolderLayers({
269
+ kitPackage,
270
+ featureRoot,
271
+ extraPatterns = {},
272
+ viewDirs,
273
+ dataDirs,
274
+ actionPatterns,
275
+ }) {
276
+ if (viewDirs !== undefined || dataDirs !== undefined || actionPatterns !== undefined) {
277
+ throw new Error("defineLayers: preset feature-folders uses featureRoot, not viewDirs/dataDirs/actionPatterns");
278
+ }
279
+ if (typeof featureRoot !== "string" || featureRoot === "") {
280
+ throw new Error("defineLayers: featureRoot is required for preset feature-folders");
281
+ }
282
+ assertExtraPatterns(extraPatterns, ["kitScope", "viewData", "dataUi", "model"]);
283
+ const root = featureRoot.replace(/\/$/, "");
284
+ const kitPatterns = [kitPackage, `${kitPackage}/*`];
285
+ const views = asFiles([`${root}/**/ui/**`]);
286
+ const adapters = asFiles([`${root}/**/adapters/**`]);
287
+ const model = asFiles([`${root}/**/model/**`]);
288
+ const compose = [`${root}/*/index.ts`, `${root}/*/index.tsx`, `${root}/*/compose.ts`, `${root}/*/compose.tsx`];
289
+ const extras = { kitScope: [], viewData: [], dataUi: [], model: [], ...extraPatterns };
290
+ return [
291
+ {
292
+ name: "zedkit/layers-kit-scope",
293
+ files: asFiles([root]),
294
+ ignores: [...views, ...adapters, ...model, ...compose],
295
+ rules: {
296
+ "no-restricted-imports": [
297
+ "error",
298
+ {
299
+ patterns: [
300
+ {
301
+ group: kitPatterns,
302
+ message:
303
+ "layers-features: UI kit imports are only allowed from view files. Move this import into a view.",
304
+ },
305
+ {
306
+ group: REACT_GROUP,
307
+ message: "layers-features: only ui/ and the composition root import React.",
308
+ },
309
+ {
310
+ group: ADAPTER_GROUP,
311
+ message:
312
+ "layers-features: inject adapters at the feature composition root (index.ts or compose.ts).",
313
+ },
314
+ ...extras.kitScope,
315
+ ],
316
+ },
317
+ ],
318
+ },
319
+ },
164
320
  {
165
321
  name: "zedkit/layers-view-data",
166
322
  files: views,
@@ -170,8 +326,9 @@ export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, e
170
326
  {
171
327
  patterns: [
172
328
  {
173
- group: [...actionPatterns],
174
- message: "Views receive data as props and never import data clients or server actions.",
329
+ group: ADAPTER_GROUP,
330
+ message:
331
+ "layers-features: UI files import ports, not adapters. Inject the adapter at the feature composition root.",
175
332
  },
176
333
  ...extras.viewData,
177
334
  ],
@@ -181,19 +338,19 @@ export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, e
181
338
  },
182
339
  {
183
340
  name: "zedkit/layers-data-ui",
184
- files: data,
341
+ files: adapters,
185
342
  rules: {
186
343
  "no-restricted-imports": [
187
344
  "error",
188
345
  {
189
346
  patterns: [
190
347
  {
191
- group: ["react", "react-dom", "react-dom/*", "react/jsx-runtime"],
192
- message: "Data code is UI-free: no React runtime imports.",
348
+ group: REACT_GROUP,
349
+ message: "layers-features: data code is UI-free: no React runtime imports.",
193
350
  },
194
351
  {
195
352
  group: kitPatterns,
196
- message: "Data code is UI-free: no UI kit imports.",
353
+ message: "layers-features: data code is UI-free: no UI kit imports.",
197
354
  },
198
355
  ...extras.dataUi,
199
356
  ],
@@ -201,5 +358,31 @@ export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, e
201
358
  ],
202
359
  },
203
360
  },
361
+ {
362
+ name: "zedkit/layers-model-pure",
363
+ files: model,
364
+ rules: {
365
+ "no-restricted-imports": [
366
+ "error",
367
+ {
368
+ patterns: [
369
+ {
370
+ group: REACT_GROUP,
371
+ message: "layers-features: model is pure: no React runtime. Keep I/O in adapters.",
372
+ },
373
+ {
374
+ group: kitPatterns,
375
+ message: "layers-features: model is pure: no UI kit imports.",
376
+ },
377
+ {
378
+ group: ADAPTER_GROUP,
379
+ message: "layers-features: model is pure: adapters are injected at the composition root.",
380
+ },
381
+ ...extras.model,
382
+ ],
383
+ },
384
+ ],
385
+ },
386
+ },
204
387
  ];
205
388
  }