@zedtech/zedkit-quality 0.3.2 → 0.5.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
 
@@ -52,9 +53,12 @@ subagents, commands) see `@zedtech/zedkit-ai` instead.
52
53
  ## API
53
54
 
54
55
  - `uiPreset({ entryPoint })` — flat-config blocks for `.tsx`/`.jsx`:
55
- 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.
56
+ jsx-a11y recommended, Tailwind hygiene, tokens-only classes (no
57
+ `w-[13px]` / `gap-[0.75rem]`; Radix `w-[var(--radix-*)]` stays), no `style`
58
+ attributes, no `forwardRef` (ref is a prop; see `packages/ui/src/button.tsx`),
59
+ no explicit `any`, files ≤ 300 lines and functions ≤ 60, no raw `try`/`catch`.
60
+ `entryPoint` (your Tailwind CSS entry) is required: it is the one thing
61
+ that varies per repo.
58
62
  - `defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, extraPatterns })` —
59
63
  `no-restricted-imports` blocks for routes -> views -> data: the UI kit
60
64
  is only imported from views, views never import data clients or
@@ -62,6 +66,11 @@ subagents, commands) see `@zedtech/zedkit-ai` instead.
62
66
  `{ kitScope?, viewData?, dataUi? }`) merges your own bans into each
63
67
  block — use it instead of a second `no-restricted-imports` block,
64
68
  which would replace these per flat-config key semantics.
69
+ - `defineLayers({ preset: "feature-folders", kitPackage, featureRoot })` —
70
+ same helper, feature-folder mapping: UI imports ports (not adapters),
71
+ adapters are injected at the composition root, model is pure. Bans
72
+ apply only under `featureRoot`. Live example:
73
+ `apps/docs/features/note-list` (running at `/examples/note-list`).
65
74
  - `@zedtech/zedkit-quality/tsconfig` — the strict base (`strict`,
66
75
  `exactOptionalPropertyTypes`, `noUncheckedIndexedAccess`,
67
76
  `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.2",
3
+ "version": "0.5.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. May be empty. */
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,21 @@
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
- // colors, no raw px font sizes — use the semantic theme tokens
8
- // - No `style` attributes; no raw try/catch (use a tryCatch wrapper)
8
+ // colors, no raw px font sizes, no arbitrary px/rem in className
9
+ // (w-[13px], gap-[0.75rem]) — use the semantic theme tokens
10
+ // - No `style` attributes; no forwardRef (ref is a prop); no explicit `any`;
11
+ // files ≤ 300 lines, functions ≤ 60; no raw try/catch (use a tryCatch wrapper)
9
12
  //
10
13
  // Consumer setup (no copy: install the package, pass your Tailwind entry):
11
14
  // `import { uiPreset } from "@zedtech/zedkit-quality"` then
12
15
  // `export default [...uiPreset({ entryPoint: "./app/globals.css" })]`.
13
16
  import jsxA11y from "eslint-plugin-jsx-a11y";
14
17
  import betterTailwindcss from "eslint-plugin-better-tailwindcss";
18
+ import { plugin as tsPlugin } from "typescript-eslint";
15
19
 
16
20
  const PALETTE =
17
21
  "slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|fuchsia|pink|rose";
@@ -57,36 +61,76 @@ export function uiPreset({ entryPoint }) {
57
61
  {
58
62
  pattern: `(^|:)(${COLOR_UTILS})-(${PALETTE}|white|black)(-|/|$)`,
59
63
  message:
60
- "Raw palette colors are banned. Use a semantic theme token (see docs/frontend-charter.md).",
64
+ "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
65
  },
62
66
  {
63
67
  pattern: "\\[(#|rgb|hsl)",
64
68
  message:
65
- "Arbitrary color values are banned. Use a semantic theme token (see docs/frontend-charter.md).",
69
+ "tokens-color: arbitrary hex/rgb/hsl colors are banned. Use a semantic token (background, foreground, primary, …).",
66
70
  },
67
71
  {
68
72
  pattern: "text-\\[[^\\]]*px",
69
- message: "Raw px font sizes are banned. Use the type scale (see docs/frontend-charter.md).",
73
+ message:
74
+ "tokens-style: raw px font sizes are banned. Use the type scale (text-xs … text-2xl).",
75
+ },
76
+ // Radix CSS variables (w-[var(--radix-popover-trigger-width)])
77
+ // are not matched: they are not a px/rem literal. Overlays
78
+ // must follow the trigger size. Hardcoded colours are already
79
+ // tokens-color (palette + hex/rgb/hsl).
80
+ {
81
+ pattern: "\\[[0-9.]+(?:px|rem)\\]",
82
+ message:
83
+ "tokens-arbitrary: raw px/rem in className (w-[13px], gap-[0.75rem]) are banned — use the spacing scale (w-4, gap-3) or the type scale (text-sm).",
70
84
  },
71
85
  ],
72
86
  },
73
87
  ],
74
88
  // NOTE: flat-config rules replace (not merge) by key, so this block
75
89
  // restates the root TryStatement ban alongside the style-attribute
76
- // ban; without the restatement, .tsx files would lose the ban.
90
+ // and forwardRef bans; without the restatement, .tsx files would lose them.
77
91
  "no-restricted-syntax": [
78
92
  "error",
79
93
  {
80
94
  selector: "TryStatement",
81
- message: "Raw try/catch is banned. Use tryCatch/tryCatchAsync from @zedtech/zedkit-core.",
95
+ message:
96
+ "try-catch: raw try/catch is banned. Use tryCatch/tryCatchAsync from @zedtech/zedkit-core so thrown values become Error with cause.",
82
97
  },
83
98
  {
84
99
  selector: 'JSXAttribute[name.name="style"]',
85
- message: "Inline style attributes are banned. Use theme tokens (see docs/frontend-charter.md).",
100
+ message:
101
+ "tokens-style: inline style attributes are banned. Use theme tokens and utility classes.",
102
+ },
103
+ {
104
+ selector: "Identifier[name='forwardRef']",
105
+ message:
106
+ "ui-ref: forwardRef is banned. Pass ref as a prop on ComponentProps<\"button\"> (see packages/ui/src/button.tsx).",
86
107
  },
87
108
  ],
88
109
  },
89
110
  },
111
+ {
112
+ name: "zedkit/ui-types",
113
+ files: ["**/*.tsx", "**/*.jsx"],
114
+ plugins: { "@typescript-eslint": tsPlugin },
115
+ rules: {
116
+ "@typescript-eslint/no-explicit-any": "error",
117
+ },
118
+ },
119
+ {
120
+ name: "zedkit/ui-size",
121
+ files: ["**/*.tsx", "**/*.jsx"],
122
+ ignores: ["**/*.test.*", "**/*.spec.*", "**/tests/**", "**/__tests__/**"],
123
+ rules: {
124
+ "max-lines": [
125
+ "error",
126
+ { max: 300, skipBlankLines: true, skipComments: true },
127
+ ],
128
+ "max-lines-per-function": [
129
+ "error",
130
+ { max: 60, skipBlankLines: true, skipComments: true, IIFEs: true },
131
+ ],
132
+ },
133
+ },
90
134
  ];
91
135
  }
92
136
 
@@ -109,6 +153,18 @@ function asFiles(patterns) {
109
153
  });
110
154
  }
111
155
 
156
+ function assertExtraPatterns(extraPatterns, keys) {
157
+ const want = keys.join(", ").replace(/, ([^,]+)$/, " or $1");
158
+ for (const [key, value] of Object.entries(extraPatterns)) {
159
+ if (!keys.includes(key)) {
160
+ throw new Error(`defineLayers: unknown extraPatterns key "${key}" (want ${want})`);
161
+ }
162
+ if (!Array.isArray(value)) {
163
+ throw new Error(`defineLayers: extraPatterns.${key} must be a list of no-restricted-imports pattern objects`);
164
+ }
165
+ }
166
+ }
167
+
112
168
  /**
113
169
  * Route/view/data layer boundaries as `no-restricted-imports` blocks.
114
170
  * Dependencies run one way (routes -> views -> data):
@@ -116,30 +172,29 @@ function asFiles(patterns) {
116
172
  * - views receive data as props and never import data clients or actions;
117
173
  * - data files import no UI (no React runtime, no UI kit).
118
174
  *
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
- * May be empty (views legitimately import data): the view ban is
125
- * then omitted, and the view block is dropped when it would carry
126
- * no bans at all.
127
- * - `extraPatterns`: consumer bans merged into each block's own
128
- * patterns (flat-config rules replace per key, so a consumer block
129
- * setting `no-restricted-imports` would otherwise drop these).
130
- * Keys: `kitScope`, `viewData`, `dataUi`; each a list of standard
131
- * `no-restricted-imports` pattern objects, passed through untouched.
175
+ * @param {object} options
176
+ * - Classic: `{ kitPackage, viewDirs, dataDirs, actionPatterns, extraPatterns? }`.
177
+ * `actionPatterns` may be empty (views legitimately import data): the
178
+ * view ban is then omitted, and the view block is dropped when it
179
+ * would carry no bans at all. `extraPatterns` merges consumer bans
180
+ * into each block (keys: `kitScope`, `viewData`, `dataUi`).
181
+ * - Preset `feature-folders`: `{ preset: "feature-folders", kitPackage,
182
+ * featureRoot, extraPatterns? }`. UI may import ports, not adapters;
183
+ * adapters are injected at the feature composition root; model is
184
+ * pure. Bans apply only under `featureRoot` (extraPatterns also
185
+ * accepts `model`).
132
186
  * @returns {import("eslint").Linter.Config[]} Flat config blocks.
133
187
  */
134
- export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, extraPatterns = {} }) {
135
- for (const [key, value] of Object.entries(extraPatterns)) {
136
- if (key !== "kitScope" && key !== "viewData" && key !== "dataUi") {
137
- throw new Error(`defineLayers: unknown extraPatterns key "${key}" (want kitScope, viewData or dataUi)`);
138
- }
139
- if (!Array.isArray(value)) {
140
- throw new Error(`defineLayers: extraPatterns.${key} must be a list of no-restricted-imports pattern objects`);
141
- }
188
+ export function defineLayers(options) {
189
+ const { preset, extraPatterns = {} } = options;
190
+ if (preset === "feature-folders") {
191
+ return featureFolderLayers(options);
142
192
  }
193
+ if (preset !== undefined) {
194
+ throw new Error(`defineLayers: unknown preset "${preset}" (want feature-folders)`);
195
+ }
196
+ const { kitPackage, viewDirs, dataDirs, actionPatterns } = options;
197
+ assertExtraPatterns(extraPatterns, ["kitScope", "viewData", "dataUi"]);
143
198
  if (!Array.isArray(actionPatterns)) {
144
199
  throw new Error("defineLayers: actionPatterns must be a list of import specifiers (may be empty)");
145
200
  }
@@ -216,3 +271,128 @@ export function defineLayers({ kitPackage, viewDirs, dataDirs, actionPatterns, e
216
271
  },
217
272
  ];
218
273
  }
274
+
275
+ const ADAPTER_GROUP = ["**/adapters", "**/adapters/**"];
276
+ const REACT_GROUP = ["react", "react-dom", "react-dom/*", "react/jsx-runtime"];
277
+
278
+ function featureFolderLayers({
279
+ kitPackage,
280
+ featureRoot,
281
+ extraPatterns = {},
282
+ viewDirs,
283
+ dataDirs,
284
+ actionPatterns,
285
+ }) {
286
+ if (viewDirs !== undefined || dataDirs !== undefined || actionPatterns !== undefined) {
287
+ throw new Error("defineLayers: preset feature-folders uses featureRoot, not viewDirs/dataDirs/actionPatterns");
288
+ }
289
+ if (typeof featureRoot !== "string" || featureRoot === "") {
290
+ throw new Error("defineLayers: featureRoot is required for preset feature-folders");
291
+ }
292
+ assertExtraPatterns(extraPatterns, ["kitScope", "viewData", "dataUi", "model"]);
293
+ const root = featureRoot.replace(/\/$/, "");
294
+ const kitPatterns = [kitPackage, `${kitPackage}/*`];
295
+ const views = asFiles([`${root}/**/ui/**`]);
296
+ const adapters = asFiles([`${root}/**/adapters/**`]);
297
+ const model = asFiles([`${root}/**/model/**`]);
298
+ const compose = [`${root}/*/index.ts`, `${root}/*/index.tsx`, `${root}/*/compose.ts`, `${root}/*/compose.tsx`];
299
+ const extras = { kitScope: [], viewData: [], dataUi: [], model: [], ...extraPatterns };
300
+ return [
301
+ {
302
+ name: "zedkit/layers-kit-scope",
303
+ files: asFiles([root]),
304
+ ignores: [...views, ...adapters, ...model, ...compose],
305
+ rules: {
306
+ "no-restricted-imports": [
307
+ "error",
308
+ {
309
+ patterns: [
310
+ {
311
+ group: kitPatterns,
312
+ message:
313
+ "layers-features: UI kit imports are only allowed from view files. Move this import into a view.",
314
+ },
315
+ {
316
+ group: REACT_GROUP,
317
+ message: "layers-features: only ui/ and the composition root import React.",
318
+ },
319
+ {
320
+ group: ADAPTER_GROUP,
321
+ message:
322
+ "layers-features: inject adapters at the feature composition root (index.ts or compose.ts).",
323
+ },
324
+ ...extras.kitScope,
325
+ ],
326
+ },
327
+ ],
328
+ },
329
+ },
330
+ {
331
+ name: "zedkit/layers-view-data",
332
+ files: views,
333
+ rules: {
334
+ "no-restricted-imports": [
335
+ "error",
336
+ {
337
+ patterns: [
338
+ {
339
+ group: ADAPTER_GROUP,
340
+ message:
341
+ "layers-features: UI files import ports, not adapters. Inject the adapter at the feature composition root.",
342
+ },
343
+ ...extras.viewData,
344
+ ],
345
+ },
346
+ ],
347
+ },
348
+ },
349
+ {
350
+ name: "zedkit/layers-data-ui",
351
+ files: adapters,
352
+ rules: {
353
+ "no-restricted-imports": [
354
+ "error",
355
+ {
356
+ patterns: [
357
+ {
358
+ group: REACT_GROUP,
359
+ message: "layers-features: data code is UI-free: no React runtime imports.",
360
+ },
361
+ {
362
+ group: kitPatterns,
363
+ message: "layers-features: data code is UI-free: no UI kit imports.",
364
+ },
365
+ ...extras.dataUi,
366
+ ],
367
+ },
368
+ ],
369
+ },
370
+ },
371
+ {
372
+ name: "zedkit/layers-model-pure",
373
+ files: model,
374
+ rules: {
375
+ "no-restricted-imports": [
376
+ "error",
377
+ {
378
+ patterns: [
379
+ {
380
+ group: REACT_GROUP,
381
+ message: "layers-features: model is pure: no React runtime. Keep I/O in adapters.",
382
+ },
383
+ {
384
+ group: kitPatterns,
385
+ message: "layers-features: model is pure: no UI kit imports.",
386
+ },
387
+ {
388
+ group: ADAPTER_GROUP,
389
+ message: "layers-features: model is pure: adapters are injected at the composition root.",
390
+ },
391
+ ...extras.model,
392
+ ],
393
+ },
394
+ ],
395
+ },
396
+ },
397
+ ];
398
+ }