@howells/lint 0.2.6 → 0.2.8

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
@@ -101,7 +101,11 @@ Use this lane when a project wants Oxlint and Oxfmt instead of Biome. React and
101
101
 
102
102
  React Doctor severities are passed through as published by React Doctor. Native Oxlint Next.js severities come from Oxlint's official `nextjs` plugin via Ultracite's Next preset. `@howells/lint` adds canonical Howells policy on top for file naming, barrel files, env access, file size, function size, complexity, and tests.
103
103
 
104
- The core Oxlint preset enables native Oxlint rules that keep code files navigable: `max-lines` errors above 600 non-comment, non-blank lines; `max-lines-per-function` errors above 120 non-comment, non-blank lines; `max-statements` errors above 45 statements per function; and `complexity` errors above cyclomatic complexity 15. Test files keep the file-level `max-lines` guard but disable function-size, statement-count, and complexity limits, because test framework callbacks naturally wrap many independent cases. Generated files should be ignored at the project level; rare intentional exceptions should use an exact-file override with a short refactor note.
104
+ The core Oxlint preset enables native Oxlint rules that keep code files navigable: `max-lines` errors above 600 non-comment, non-blank lines; `max-lines-per-function` warns above 120 non-comment, non-blank lines; `max-statements` warns above 45 statements per function; and `complexity` warns above cyclomatic complexity 15. React projects promote React Doctor's `react-doctor/no-giant-component` rule to an error, so genuinely oversized components still block CI. Test files keep the file-level `max-lines` guard but disable function-size, statement-count, and complexity limits, because test framework callbacks naturally wrap many independent cases. Generated files should be ignored at the project level; rare intentional exceptions should use an exact-file override with a short refactor note.
105
+
106
+ React and Next presets also reject generic component suffixes that tend to hide responsibility: `wrapper`, `client`, `page`, `component`, `container`, and `manager`. The rule checks `.jsx` and `.tsx` filenames and PascalCase component declarations. It allows real Next App Router `app/**/page.tsx` files and their conventional `Page` export.
107
+
108
+ Next presets reject App Router pages that only pass through to one imported client component. Route pages should keep server composition, data loading, and route-level structure in the page, then push only the interactive leaves behind a client boundary.
105
109
 
106
110
  Choose the closest preset:
107
111
 
package/oxlint/core.mjs CHANGED
@@ -16,7 +16,7 @@ export default defineConfig({
16
16
  plugins: [...new Set([...(ultraciteCore.plugins ?? []), "vitest"])],
17
17
  rules: {
18
18
  complexity: [
19
- "error",
19
+ "warn",
20
20
  {
21
21
  max: 15,
22
22
  },
@@ -30,7 +30,7 @@ export default defineConfig({
30
30
  },
31
31
  ],
32
32
  "max-lines-per-function": [
33
- "error",
33
+ "warn",
34
34
  {
35
35
  IIFEs: true,
36
36
  max: 120,
@@ -39,7 +39,7 @@ export default defineConfig({
39
39
  },
40
40
  ],
41
41
  "max-statements": [
42
- "error",
42
+ "warn",
43
43
  {
44
44
  max: 45,
45
45
  },
@@ -0,0 +1,289 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+
4
+ const BANNED_SUFFIXES = [
5
+ { kebab: "wrapper", pascal: "Wrapper" },
6
+ { kebab: "client", pascal: "Client" },
7
+ { kebab: "page", pascal: "Page" },
8
+ { kebab: "component", pascal: "Component" },
9
+ { kebab: "container", pascal: "Container" },
10
+ { kebab: "manager", pascal: "Manager" },
11
+ ];
12
+
13
+ const COMPONENT_FILE_PATTERN = /\.[jt]sx$/u;
14
+ const PASCAL_CASE_PATTERN = /^[A-Z]/u;
15
+ const RELATIVE_IMPORT_PATTERN = /^\.{1,2}(?:\/|$)/u;
16
+ const MODULE_EXTENSIONS = [".tsx", ".jsx", ".ts", ".js", ".mts", ".mjs"];
17
+
18
+ function normalizeFilename(filename) {
19
+ return filename.replaceAll("\\", "/");
20
+ }
21
+
22
+ function basename(filename) {
23
+ const normalized = normalizeFilename(filename);
24
+ const lastSlash = normalized.lastIndexOf("/");
25
+ return lastSlash === -1 ? normalized : normalized.slice(lastSlash + 1);
26
+ }
27
+
28
+ function withoutExtension(filename) {
29
+ return filename.replace(/\.[^.]+$/u, "");
30
+ }
31
+
32
+ function isNextAppPageFile(filename) {
33
+ return /(?:^|\/)(?:src\/)?app\/(?:.*\/)?page\.[jt]sx$/u.test(normalizeFilename(filename));
34
+ }
35
+
36
+ function findFilenameSuffix(filename) {
37
+ if (!COMPONENT_FILE_PATTERN.test(filename)) {
38
+ return null;
39
+ }
40
+
41
+ if (isNextAppPageFile(filename)) {
42
+ return null;
43
+ }
44
+
45
+ const stem = withoutExtension(basename(filename)).toLowerCase();
46
+ return BANNED_SUFFIXES.find(({ kebab }) => stem === kebab || stem.endsWith(`-${kebab}`));
47
+ }
48
+
49
+ function findComponentNameSuffix(name) {
50
+ if (!PASCAL_CASE_PATTERN.test(name)) {
51
+ return null;
52
+ }
53
+
54
+ return BANNED_SUFFIXES.find(({ pascal }) => name === pascal || name.endsWith(pascal));
55
+ }
56
+
57
+ function isAllowedNextPageComponent(name, filename) {
58
+ return name === "Page" && isNextAppPageFile(filename);
59
+ }
60
+
61
+ function hasUseClientDirective(source) {
62
+ return /^(?:\uFEFF)?\s*["']use client["']\s*;/u.test(source);
63
+ }
64
+
65
+ function resolveRelativeModule(filename, specifier) {
66
+ const basePath = path.resolve(path.dirname(filename), specifier);
67
+ const candidates = path.extname(basePath)
68
+ ? [basePath]
69
+ : [
70
+ ...MODULE_EXTENSIONS.map((extension) => `${basePath}${extension}`),
71
+ ...MODULE_EXTENSIONS.map((extension) => path.join(basePath, `index${extension}`)),
72
+ ];
73
+
74
+ return candidates.find((candidate) => fs.existsSync(candidate));
75
+ }
76
+
77
+ function isClientComponentModule(filename, specifier) {
78
+ if (!RELATIVE_IMPORT_PATTERN.test(specifier)) {
79
+ return false;
80
+ }
81
+
82
+ const modulePath = resolveRelativeModule(filename, specifier);
83
+ if (!modulePath) {
84
+ return false;
85
+ }
86
+
87
+ return hasUseClientDirective(fs.readFileSync(modulePath, "utf8"));
88
+ }
89
+
90
+ function isPascalCaseJsxName(nameNode) {
91
+ return nameNode?.type === "JSXIdentifier" && PASCAL_CASE_PATTERN.test(nameNode.name);
92
+ }
93
+
94
+ function getSingleJsxElement(expression) {
95
+ if (!expression) {
96
+ return null;
97
+ }
98
+
99
+ if (expression.type === "JSXElement") {
100
+ return expression;
101
+ }
102
+
103
+ if (expression.type === "ParenthesizedExpression") {
104
+ return getSingleJsxElement(expression.expression);
105
+ }
106
+
107
+ if (expression.type !== "JSXFragment") {
108
+ return null;
109
+ }
110
+
111
+ const elementChildren = expression.children.filter(
112
+ (child) => child.type === "JSXElement" || child.type === "JSXFragment",
113
+ );
114
+ const meaningfulTextChildren = expression.children.filter(
115
+ (child) => child.type === "JSXText" && child.value.trim() !== "",
116
+ );
117
+
118
+ if (elementChildren.length !== 1 || meaningfulTextChildren.length > 0) {
119
+ return null;
120
+ }
121
+
122
+ return getSingleJsxElement(elementChildren[0]);
123
+ }
124
+
125
+ function getSingleReturnedComponentName(body) {
126
+ if (!body || body.type !== "BlockStatement" || body.body.length !== 1) {
127
+ return null;
128
+ }
129
+
130
+ const statement = body.body[0];
131
+ if (statement.type !== "ReturnStatement") {
132
+ return null;
133
+ }
134
+
135
+ const element = getSingleJsxElement(statement.argument);
136
+ const jsxName = element?.openingElement?.name;
137
+ if (!isPascalCaseJsxName(jsxName)) {
138
+ return null;
139
+ }
140
+
141
+ return jsxName.name;
142
+ }
143
+
144
+ function createNoGenericComponentSuffixRule(context) {
145
+ const filename = normalizeFilename(context.filename ?? "");
146
+ const reportedNodes = new WeakSet();
147
+
148
+ function reportFilename(programNode) {
149
+ const suffix = findFilenameSuffix(filename);
150
+ if (!suffix) {
151
+ return;
152
+ }
153
+
154
+ context.report({
155
+ node: programNode,
156
+ message: `Avoid generic component suffix "${suffix.kebab}" in filename "${basename(filename)}". Name the component after the specific UI, behavior, or domain responsibility it owns.`,
157
+ });
158
+ }
159
+
160
+ function reportComponentName(nameNode) {
161
+ if (!nameNode || reportedNodes.has(nameNode)) {
162
+ return;
163
+ }
164
+
165
+ const suffix = findComponentNameSuffix(nameNode.name);
166
+ if (!suffix || isAllowedNextPageComponent(nameNode.name, filename)) {
167
+ return;
168
+ }
169
+
170
+ reportedNodes.add(nameNode);
171
+ context.report({
172
+ node: nameNode,
173
+ message: `Avoid generic component suffix "${suffix.kebab}" in component "${nameNode.name}". Name the component after the specific UI, behavior, or domain responsibility it owns.`,
174
+ });
175
+ }
176
+
177
+ if (!COMPONENT_FILE_PATTERN.test(filename)) {
178
+ return {};
179
+ }
180
+
181
+ return {
182
+ Program: reportFilename,
183
+ ClassDeclaration(node) {
184
+ reportComponentName(node.id);
185
+ },
186
+ FunctionDeclaration(node) {
187
+ reportComponentName(node.id);
188
+ },
189
+ VariableDeclarator(node) {
190
+ if (node.id?.type === "Identifier") {
191
+ reportComponentName(node.id);
192
+ }
193
+ },
194
+ ExportDefaultDeclaration(node) {
195
+ const declaration = node.declaration;
196
+ if (declaration?.type === "FunctionDeclaration" || declaration?.type === "ClassDeclaration") {
197
+ reportComponentName(declaration.id);
198
+ }
199
+ },
200
+ };
201
+ }
202
+
203
+ function createNoSingleClientComponentPageRule(context) {
204
+ const filename = normalizeFilename(context.filename ?? "");
205
+ const importedComponentSources = new Map();
206
+
207
+ function reportSingleClientComponentPage(node, componentName) {
208
+ const source = importedComponentSources.get(componentName);
209
+ if (!source || !isClientComponentModule(filename, source)) {
210
+ return;
211
+ }
212
+
213
+ context.report({
214
+ node,
215
+ message: `Avoid making a Next page a pass-through to one client component "${componentName}". Keep route-level data loading and server composition in the page, and move only interactive leaves behind the client boundary.`,
216
+ });
217
+ }
218
+
219
+ function checkPageFunction(node) {
220
+ const componentName = getSingleReturnedComponentName(node.body);
221
+ if (!componentName) {
222
+ return;
223
+ }
224
+
225
+ reportSingleClientComponentPage(node, componentName);
226
+ }
227
+
228
+ if (!isNextAppPageFile(filename)) {
229
+ return {};
230
+ }
231
+
232
+ return {
233
+ ImportDeclaration(node) {
234
+ if (
235
+ typeof node.source?.value !== "string" ||
236
+ !RELATIVE_IMPORT_PATTERN.test(node.source.value)
237
+ ) {
238
+ return;
239
+ }
240
+
241
+ for (const specifier of node.specifiers ?? []) {
242
+ if (specifier.type === "ImportSpecifier" && specifier.local?.type === "Identifier") {
243
+ importedComponentSources.set(specifier.local.name, node.source.value);
244
+ }
245
+
246
+ if (specifier.type === "ImportDefaultSpecifier" && specifier.local?.type === "Identifier") {
247
+ importedComponentSources.set(specifier.local.name, node.source.value);
248
+ }
249
+ }
250
+ },
251
+ ExportDefaultDeclaration(node) {
252
+ if (node.declaration?.type === "FunctionDeclaration") {
253
+ checkPageFunction(node.declaration);
254
+ }
255
+ },
256
+ };
257
+ }
258
+
259
+ const plugin = {
260
+ meta: {
261
+ name: "howells",
262
+ },
263
+ rules: {
264
+ "no-generic-component-suffix": {
265
+ meta: {
266
+ type: "suggestion",
267
+ docs: {
268
+ description: "Disallow generic React component suffixes that hide responsibility.",
269
+ },
270
+ messages: {},
271
+ schema: [],
272
+ },
273
+ create: createNoGenericComponentSuffixRule,
274
+ },
275
+ "no-single-client-component-page": {
276
+ meta: {
277
+ type: "problem",
278
+ docs: {
279
+ description: "Disallow Next page files that only pass through to one client component.",
280
+ },
281
+ messages: {},
282
+ schema: [],
283
+ },
284
+ create: createNoSingleClientComponentPageRule,
285
+ },
286
+ },
287
+ };
288
+
289
+ export default plugin;
package/oxlint/next.mjs CHANGED
@@ -4,6 +4,9 @@ import ultraciteNext from "ultracite/oxlint/next";
4
4
  import react from "./react.mjs";
5
5
 
6
6
  export default defineConfig({
7
- extends: [react, ultraciteNext],
8
- rules: NEXTJS_RULES,
7
+ extends: [react, ultraciteNext],
8
+ rules: {
9
+ ...NEXTJS_RULES,
10
+ "howells/no-single-client-component-page": "error",
11
+ },
9
12
  });
package/oxlint/react.mjs CHANGED
@@ -3,10 +3,18 @@ import { RECOMMENDED_RULES } from "oxlint-plugin-react-doctor";
3
3
  import ultraciteReact from "ultracite/oxlint/react";
4
4
  import core from "./core.mjs";
5
5
 
6
+ const reactDoctorPluginSpecifier = import.meta.resolve("oxlint-plugin-react-doctor");
7
+ const howellsPolicyPluginSpecifier = new URL("./howells-policy-plugin.mjs", import.meta.url).href;
8
+
6
9
  export default defineConfig({
7
- extends: [core, ultraciteReact],
8
- jsPlugins: [
9
- { name: "react-doctor", specifier: "oxlint-plugin-react-doctor" },
10
- ],
11
- rules: RECOMMENDED_RULES,
10
+ extends: [core, ultraciteReact],
11
+ jsPlugins: [
12
+ { name: "react-doctor", specifier: reactDoctorPluginSpecifier },
13
+ { name: "howells", specifier: howellsPolicyPluginSpecifier },
14
+ ],
15
+ rules: {
16
+ ...RECOMMENDED_RULES,
17
+ "howells/no-generic-component-suffix": "error",
18
+ "react-doctor/no-giant-component": "error",
19
+ },
12
20
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@howells/lint",
3
- "version": "0.2.6",
3
+ "version": "0.2.8",
4
4
  "description": "Pinned Biome, Oxlint/Oxfmt, Ultracite, and React Doctor presets for Howells projects.",
5
5
  "keywords": [],
6
6
  "homepage": "https://github.com/howells/lint#readme",
@@ -87,6 +87,6 @@
87
87
  "node": ">=22.18.0"
88
88
  },
89
89
  "scripts": {
90
- "test": "echo \"Error: no test specified\" && exit 1"
90
+ "test": "node --test"
91
91
  }
92
92
  }