dlinter-ts-react 0.2.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/LICENSE +21 -0
- package/README.md +151 -0
- package/dist/cli/index.d.mts +1 -0
- package/dist/cli/index.mjs +57 -0
- package/dist/index.d.mts +1157 -0
- package/dist/index.mjs +775 -0
- package/package.json +78 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,775 @@
|
|
|
1
|
+
import tsParser from "@typescript-eslint/parser";
|
|
2
|
+
import js from "@eslint/js";
|
|
3
|
+
import tsPlugin from "@typescript-eslint/eslint-plugin";
|
|
4
|
+
import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
|
|
5
|
+
import checkFilePlugin from "eslint-plugin-check-file";
|
|
6
|
+
import { createNodeResolver, importX } from "eslint-plugin-import-x";
|
|
7
|
+
import jsdocPlugin from "eslint-plugin-jsdoc";
|
|
8
|
+
import reactPlugin from "eslint-plugin-react";
|
|
9
|
+
import reactDoctor from "eslint-plugin-react-doctor";
|
|
10
|
+
import reactHooksPlugin from "eslint-plugin-react-hooks";
|
|
11
|
+
import sonarjsPlugin from "eslint-plugin-sonarjs";
|
|
12
|
+
import { existsSync } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
//#region src/configs/dumb-ui.ts
|
|
15
|
+
/**
|
|
16
|
+
* Dumb UI preset: every `.tsx` file is presentational. Side effects live in
|
|
17
|
+
* the colocated `use-*.ts` hook, never in the view.
|
|
18
|
+
* @param plugin - the dlinter plugin instance the config registers itself with.
|
|
19
|
+
* @returns flat config entries scoped to view files.
|
|
20
|
+
*/
|
|
21
|
+
function createDumbUiConfig(plugin) {
|
|
22
|
+
return [{
|
|
23
|
+
name: "dlinter/dumb-ui",
|
|
24
|
+
files: ["**/*.tsx"],
|
|
25
|
+
languageOptions: {
|
|
26
|
+
parser: tsParser,
|
|
27
|
+
ecmaVersion: "latest",
|
|
28
|
+
sourceType: "module",
|
|
29
|
+
parserOptions: { ecmaFeatures: { jsx: true } }
|
|
30
|
+
},
|
|
31
|
+
plugins: { dlinter: plugin },
|
|
32
|
+
rules: { "dlinter/no-view-effects": "error" }
|
|
33
|
+
}];
|
|
34
|
+
}
|
|
35
|
+
//#endregion
|
|
36
|
+
//#region src/rules/composition-only-delivery.ts
|
|
37
|
+
const reactHookNamePattern = "^use(State|Reducer|Effect|LayoutEffect|InsertionEffect|SyncExternalStore|Memo|Callback|Ref|Context|Transition|DeferredValue|ImperativeHandle|DebugValue|Id|Optimistic|ActionState)?$";
|
|
38
|
+
const customHookModulePattern = /(^|\/)use-[^/]+$/u;
|
|
39
|
+
/**
|
|
40
|
+
* Delivery Layer Rule: delivery files (App.tsx, app/**) compose feature
|
|
41
|
+
* entrypoints — they never orchestrate. No React hooks, no custom hook
|
|
42
|
+
* imports; screen logic belongs in feature hooks and components.
|
|
43
|
+
*/
|
|
44
|
+
const compositionOnlyDelivery = {
|
|
45
|
+
meta: {
|
|
46
|
+
type: "problem",
|
|
47
|
+
docs: { description: "restrict delivery-layer files to pure composition" },
|
|
48
|
+
messages: {
|
|
49
|
+
reactHookImport: "Delivery Rule: composition files cannot import React hooks. Move screen logic into feature hooks or components.",
|
|
50
|
+
reactHookNamespace: "Delivery Rule: composition files cannot call React hooks through the React namespace. Move screen logic into feature hooks or components.",
|
|
51
|
+
customHookImport: "Delivery Rule: composition files cannot import custom hooks directly. Render a feature entrypoint component instead."
|
|
52
|
+
},
|
|
53
|
+
schema: []
|
|
54
|
+
},
|
|
55
|
+
create(context) {
|
|
56
|
+
return {
|
|
57
|
+
[`ImportDeclaration[source.value='react'] ImportSpecifier[imported.name=/${reactHookNamePattern}/]`](node) {
|
|
58
|
+
context.report({
|
|
59
|
+
node,
|
|
60
|
+
messageId: "reactHookImport"
|
|
61
|
+
});
|
|
62
|
+
},
|
|
63
|
+
[`MemberExpression[object.name='React'][property.name=/${reactHookNamePattern}/]`](node) {
|
|
64
|
+
context.report({
|
|
65
|
+
node,
|
|
66
|
+
messageId: "reactHookNamespace"
|
|
67
|
+
});
|
|
68
|
+
},
|
|
69
|
+
ImportDeclaration(node) {
|
|
70
|
+
if (customHookModulePattern.test(String(node.source.value))) context.report({
|
|
71
|
+
node,
|
|
72
|
+
messageId: "customHookImport"
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
};
|
|
78
|
+
//#endregion
|
|
79
|
+
//#region src/rules/folder-ownership.ts
|
|
80
|
+
const roleSuffixes = [
|
|
81
|
+
".constants.ts",
|
|
82
|
+
".helpers.ts",
|
|
83
|
+
".schema.ts",
|
|
84
|
+
".types.ts"
|
|
85
|
+
];
|
|
86
|
+
/**
|
|
87
|
+
* Split Module Ownership Rule: when a main module splits into role files
|
|
88
|
+
* (`*.helpers.ts`, `*.types.ts`, `*.constants.ts`, `*.schema.ts`), the whole
|
|
89
|
+
* unit moves into a folder named after the module with a pure `index.ts`
|
|
90
|
+
* entrypoint. Sibling detection reads the real filesystem, so the rule guards
|
|
91
|
+
* its own scope: role files, `index.ts`, and declaration files are skipped
|
|
92
|
+
* regardless of config globs.
|
|
93
|
+
*/
|
|
94
|
+
const folderOwnership = {
|
|
95
|
+
meta: {
|
|
96
|
+
type: "problem",
|
|
97
|
+
docs: { description: "require folder-owned entrypoints for main modules split into role files" },
|
|
98
|
+
messages: {
|
|
99
|
+
flatSplitModule: "Split module ownership: flat main modules with sibling role files must move into a dedicated folder with a pure index.ts entrypoint.",
|
|
100
|
+
missingFolderIndex: "Split module ownership: folder-owned modules with role files must publish a pure index.ts entrypoint in the same folder."
|
|
101
|
+
},
|
|
102
|
+
schema: []
|
|
103
|
+
},
|
|
104
|
+
create(context) {
|
|
105
|
+
return { Program(node) {
|
|
106
|
+
const filename = context.filename;
|
|
107
|
+
const baseName = path.basename(filename);
|
|
108
|
+
if (!baseName.endsWith(".ts") || baseName.endsWith(".d.ts") || baseName === "index.ts" || roleSuffixes.some((suffix) => baseName.endsWith(suffix))) return;
|
|
109
|
+
const moduleStem = baseName.slice(0, -3);
|
|
110
|
+
const moduleDirectory = path.dirname(filename);
|
|
111
|
+
if (roleSuffixes.filter((suffix) => existsSync(path.join(moduleDirectory, `${moduleStem}${suffix}`))).length === 0) return;
|
|
112
|
+
if (path.basename(moduleDirectory) !== moduleStem) {
|
|
113
|
+
context.report({
|
|
114
|
+
node,
|
|
115
|
+
messageId: "flatSplitModule"
|
|
116
|
+
});
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (!existsSync(path.join(moduleDirectory, "index.ts"))) context.report({
|
|
120
|
+
node,
|
|
121
|
+
messageId: "missingFolderIndex"
|
|
122
|
+
});
|
|
123
|
+
} };
|
|
124
|
+
}
|
|
125
|
+
};
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/rules/hook-anatomy.ts
|
|
128
|
+
const hookBlock = "ExportNamedDeclaration > FunctionDeclaration[id.name=/^use[A-Z0-9]/] > BlockStatement";
|
|
129
|
+
/**
|
|
130
|
+
* Hook Anatomy Rule: exported hooks follow one ordering — derived state
|
|
131
|
+
* (useMemo) before callbacks (useCallback) before effects (useEffect) — and
|
|
132
|
+
* always end with a return statement.
|
|
133
|
+
*/
|
|
134
|
+
const hookAnatomy = {
|
|
135
|
+
meta: {
|
|
136
|
+
type: "problem",
|
|
137
|
+
docs: { description: "enforce useMemo → useCallback → useEffect ordering and a final return in exported hooks" },
|
|
138
|
+
messages: {
|
|
139
|
+
effectBeforeDerived: "Hook Anatomy Rule: useEffect must come after derived state and callbacks in hooks.",
|
|
140
|
+
memoAfterCallback: "Hook Anatomy Rule: useMemo derived state must come before useCallback callbacks in hooks.",
|
|
141
|
+
missingReturn: "Hook Anatomy Rule: hooks must end with a return statement."
|
|
142
|
+
},
|
|
143
|
+
schema: []
|
|
144
|
+
},
|
|
145
|
+
create(context) {
|
|
146
|
+
return {
|
|
147
|
+
[`${hookBlock} > ExpressionStatement:has(CallExpression[callee.name="useEffect"]) ~ VariableDeclaration:has(CallExpression[callee.name=/^use(Memo|Callback)$/])`](node) {
|
|
148
|
+
context.report({
|
|
149
|
+
node,
|
|
150
|
+
messageId: "effectBeforeDerived"
|
|
151
|
+
});
|
|
152
|
+
},
|
|
153
|
+
[`${hookBlock} > ExpressionStatement:has(CallExpression[callee.object.name="React"][callee.property.name="useEffect"]) ~ VariableDeclaration:has(CallExpression[callee.name=/^use(Memo|Callback)$/])`](node) {
|
|
154
|
+
context.report({
|
|
155
|
+
node,
|
|
156
|
+
messageId: "effectBeforeDerived"
|
|
157
|
+
});
|
|
158
|
+
},
|
|
159
|
+
[`${hookBlock} > VariableDeclaration:has(CallExpression[callee.name="useCallback"]) ~ VariableDeclaration:has(CallExpression[callee.name="useMemo"])`](node) {
|
|
160
|
+
context.report({
|
|
161
|
+
node,
|
|
162
|
+
messageId: "memoAfterCallback"
|
|
163
|
+
});
|
|
164
|
+
},
|
|
165
|
+
[`${hookBlock} > :not(ReturnStatement):last-child`](node) {
|
|
166
|
+
context.report({
|
|
167
|
+
node,
|
|
168
|
+
messageId: "missingReturn"
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
};
|
|
174
|
+
//#endregion
|
|
175
|
+
//#region src/rules/no-infrastructure-in-view.ts
|
|
176
|
+
/**
|
|
177
|
+
* Feature Boundary Rule: presentational and composition files never touch the
|
|
178
|
+
* infrastructure edge directly — neither by importing generated bindings nor
|
|
179
|
+
* through runtime globals. The edge itself is consumer configuration:
|
|
180
|
+
* `importPatterns` are regex sources matched against import specifiers, and
|
|
181
|
+
* `runtimeGlobals` are `object.property` member paths (e.g. `window.go`).
|
|
182
|
+
* Without options the rule is inert by design.
|
|
183
|
+
*/
|
|
184
|
+
const noInfrastructureInView = {
|
|
185
|
+
meta: {
|
|
186
|
+
type: "problem",
|
|
187
|
+
docs: { description: "forbid direct access to the configured infrastructure edge from views" },
|
|
188
|
+
messages: {
|
|
189
|
+
infrastructureImport: "Feature Boundary: this file cannot import infrastructure bindings ({{source}}) directly. Route the call through the colocated use-*.ts hook or adapter.",
|
|
190
|
+
infrastructureRuntime: "Feature Boundary: this file cannot access the {{memberPath}} runtime directly. Route the call through the colocated use-*.ts hook or adapter."
|
|
191
|
+
},
|
|
192
|
+
schema: [{
|
|
193
|
+
type: "object",
|
|
194
|
+
properties: {
|
|
195
|
+
importPatterns: {
|
|
196
|
+
type: "array",
|
|
197
|
+
items: { type: "string" }
|
|
198
|
+
},
|
|
199
|
+
runtimeGlobals: {
|
|
200
|
+
type: "array",
|
|
201
|
+
items: {
|
|
202
|
+
type: "string",
|
|
203
|
+
pattern: "^[A-Za-z_$][\\w$]*\\.[A-Za-z_$][\\w$]*$"
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
},
|
|
207
|
+
additionalProperties: false
|
|
208
|
+
}]
|
|
209
|
+
},
|
|
210
|
+
create(context) {
|
|
211
|
+
const importPatterns = context.options[0]?.importPatterns ?? [];
|
|
212
|
+
const runtimeGlobals = context.options[0]?.runtimeGlobals ?? [];
|
|
213
|
+
const importMatchers = importPatterns.map((pattern) => new RegExp(pattern, "u"));
|
|
214
|
+
const runtimePaths = runtimeGlobals.map((memberPath) => {
|
|
215
|
+
const [objectName, propertyName] = memberPath.split(".");
|
|
216
|
+
return {
|
|
217
|
+
memberPath,
|
|
218
|
+
objectName,
|
|
219
|
+
propertyName
|
|
220
|
+
};
|
|
221
|
+
});
|
|
222
|
+
return {
|
|
223
|
+
ImportDeclaration(node) {
|
|
224
|
+
const source = String(node.source.value);
|
|
225
|
+
if (importMatchers.some((matcher) => matcher.test(source))) context.report({
|
|
226
|
+
node,
|
|
227
|
+
messageId: "infrastructureImport",
|
|
228
|
+
data: { source }
|
|
229
|
+
});
|
|
230
|
+
},
|
|
231
|
+
MemberExpression(node) {
|
|
232
|
+
if (node.object.type !== "Identifier" || node.property.type !== "Identifier") return;
|
|
233
|
+
const objectName = node.object.name;
|
|
234
|
+
const propertyName = node.property.name;
|
|
235
|
+
const match = runtimePaths.find((candidate) => candidate.objectName === objectName && candidate.propertyName === propertyName);
|
|
236
|
+
if (match) context.report({
|
|
237
|
+
node,
|
|
238
|
+
messageId: "infrastructureRuntime",
|
|
239
|
+
data: { memberPath: match.memberPath }
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
};
|
|
245
|
+
//#endregion
|
|
246
|
+
//#region src/rules/no-view-effects.ts
|
|
247
|
+
const effectHookPattern = /^use(?:Effect|LayoutEffect)$/u;
|
|
248
|
+
/**
|
|
249
|
+
* Dumb UI Rule: view components (.tsx) must stay presentational. Side effects
|
|
250
|
+
* belong in the colocated `use-*.ts` hook, never in the view itself.
|
|
251
|
+
*/
|
|
252
|
+
const noViewEffects = {
|
|
253
|
+
meta: {
|
|
254
|
+
type: "problem",
|
|
255
|
+
docs: { description: "forbid useEffect/useLayoutEffect inside view components" },
|
|
256
|
+
messages: { viewEffectForbidden: "Dumb UI Rule: view components cannot call {{hookName}}. Move side effects into the colocated use-*.ts hook." },
|
|
257
|
+
schema: []
|
|
258
|
+
},
|
|
259
|
+
create(context) {
|
|
260
|
+
return { CallExpression(node) {
|
|
261
|
+
const { callee } = node;
|
|
262
|
+
if (callee.type === "Identifier" && effectHookPattern.test(callee.name)) {
|
|
263
|
+
context.report({
|
|
264
|
+
node,
|
|
265
|
+
messageId: "viewEffectForbidden",
|
|
266
|
+
data: { hookName: callee.name }
|
|
267
|
+
});
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
if (callee.type === "MemberExpression" && callee.object.type === "Identifier" && callee.object.name === "React" && callee.property.type === "Identifier" && effectHookPattern.test(callee.property.name)) context.report({
|
|
271
|
+
node,
|
|
272
|
+
messageId: "viewEffectForbidden",
|
|
273
|
+
data: { hookName: callee.property.name }
|
|
274
|
+
});
|
|
275
|
+
} };
|
|
276
|
+
}
|
|
277
|
+
};
|
|
278
|
+
//#endregion
|
|
279
|
+
//#region src/rules/pure-index-barrel.ts
|
|
280
|
+
/**
|
|
281
|
+
* Pure Barrel Contract: an `index.ts` entrypoint only re-exports from sibling
|
|
282
|
+
* modules — no local declarations, no imports, no side effects. Scope this
|
|
283
|
+
* rule to `**/index.ts` globs; the rule judges content, the config owns scope.
|
|
284
|
+
*/
|
|
285
|
+
const pureIndexBarrel = {
|
|
286
|
+
meta: {
|
|
287
|
+
type: "problem",
|
|
288
|
+
docs: { description: "restrict barrel entrypoints to re-export statements only" },
|
|
289
|
+
messages: { impureBarrel: "Pure barrel contract: index.ts must only contain re-export statements with explicit module targets." },
|
|
290
|
+
schema: []
|
|
291
|
+
},
|
|
292
|
+
create(context) {
|
|
293
|
+
return { Program(node) {
|
|
294
|
+
for (const statement of node.body) if (!(statement.type === "ExportAllDeclaration" || statement.type === "ExportNamedDeclaration" && statement.source != null && statement.declaration == null)) context.report({
|
|
295
|
+
node: statement,
|
|
296
|
+
messageId: "impureBarrel"
|
|
297
|
+
});
|
|
298
|
+
} };
|
|
299
|
+
}
|
|
300
|
+
};
|
|
301
|
+
//#endregion
|
|
302
|
+
//#region src/rules/readonly-props.ts
|
|
303
|
+
const propsBoundary = "[typeAnnotation.typeAnnotation.type=\"TSTypeReference\"][typeAnnotation.typeAnnotation.typeName.name=/Props$/]";
|
|
304
|
+
/**
|
|
305
|
+
* Type Contract Rule: component props are immutable. Parameters use
|
|
306
|
+
* `Readonly<Props>` at the function boundary, and every field of a `*Props`
|
|
307
|
+
* interface is declared readonly.
|
|
308
|
+
*/
|
|
309
|
+
const readonlyProps = {
|
|
310
|
+
meta: {
|
|
311
|
+
type: "problem",
|
|
312
|
+
docs: { description: "require Readonly<Props> boundaries and readonly Props fields" },
|
|
313
|
+
messages: {
|
|
314
|
+
boundaryNotReadonly: "Type Contract Rule: component props parameters must use Readonly<Props> at the function boundary.",
|
|
315
|
+
propsFieldNotReadonly: "Type Contract Rule: every Props field must be declared as readonly."
|
|
316
|
+
},
|
|
317
|
+
schema: []
|
|
318
|
+
},
|
|
319
|
+
create(context) {
|
|
320
|
+
return {
|
|
321
|
+
[`ExportNamedDeclaration > FunctionDeclaration > Identifier${propsBoundary}`](node) {
|
|
322
|
+
context.report({
|
|
323
|
+
node,
|
|
324
|
+
messageId: "boundaryNotReadonly"
|
|
325
|
+
});
|
|
326
|
+
},
|
|
327
|
+
[`ExportNamedDeclaration > FunctionDeclaration > ObjectPattern${propsBoundary}`](node) {
|
|
328
|
+
context.report({
|
|
329
|
+
node,
|
|
330
|
+
messageId: "boundaryNotReadonly"
|
|
331
|
+
});
|
|
332
|
+
},
|
|
333
|
+
["TSInterfaceDeclaration[id.name=/Props$/] TSPropertySignature[readonly!=true]"](node) {
|
|
334
|
+
context.report({
|
|
335
|
+
node,
|
|
336
|
+
messageId: "propsFieldNotReadonly"
|
|
337
|
+
});
|
|
338
|
+
}
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
};
|
|
342
|
+
//#endregion
|
|
343
|
+
//#region src/rules/require-exported-variable-jsdoc.ts
|
|
344
|
+
/**
|
|
345
|
+
* Documentation Contract Rule: every exported variable declaration carries an
|
|
346
|
+
* immediately preceding JSDoc block. Complements `jsdoc/require-jsdoc`, whose
|
|
347
|
+
* contexts cover functions, interfaces, and type aliases but not variables.
|
|
348
|
+
*/
|
|
349
|
+
const requireExportedVariableJsdoc = {
|
|
350
|
+
meta: {
|
|
351
|
+
type: "suggestion",
|
|
352
|
+
docs: { description: "require JSDoc immediately before exported variable declarations" },
|
|
353
|
+
messages: { missingJsdoc: "Exported variables must have an immediately preceding JSDoc block." },
|
|
354
|
+
schema: []
|
|
355
|
+
},
|
|
356
|
+
create(context) {
|
|
357
|
+
const sourceCode = context.sourceCode;
|
|
358
|
+
return { ExportNamedDeclaration(node) {
|
|
359
|
+
if (node.declaration?.type !== "VariableDeclaration") return;
|
|
360
|
+
const precedingComment = sourceCode.getCommentsBefore(node).at(-1);
|
|
361
|
+
if (!(precedingComment?.type === "Block" && precedingComment.value.startsWith("*"))) context.report({
|
|
362
|
+
node,
|
|
363
|
+
messageId: "missingJsdoc"
|
|
364
|
+
});
|
|
365
|
+
} };
|
|
366
|
+
}
|
|
367
|
+
};
|
|
368
|
+
//#endregion
|
|
369
|
+
//#region src/rules/strict-colocation.ts
|
|
370
|
+
const allChecks = [
|
|
371
|
+
"root-variable",
|
|
372
|
+
"root-helper-function",
|
|
373
|
+
"exported-const",
|
|
374
|
+
"default-arrow-export",
|
|
375
|
+
"inline-interface",
|
|
376
|
+
"inline-type-alias",
|
|
377
|
+
"zod-import"
|
|
378
|
+
];
|
|
379
|
+
const zodImportPattern = /^zod(?:\/.*)?$/u;
|
|
380
|
+
//#endregion
|
|
381
|
+
//#region src/plugin.ts
|
|
382
|
+
/**
|
|
383
|
+
* Plugin core shared by every preset: metadata plus the dlinter rule registry.
|
|
384
|
+
* Presets receive this object so config factories never import the package
|
|
385
|
+
* entrypoint back (which would create a module cycle).
|
|
386
|
+
*/
|
|
387
|
+
const pluginBase = {
|
|
388
|
+
meta: {
|
|
389
|
+
name: "dlinter-ts-react",
|
|
390
|
+
version: "0.1.0"
|
|
391
|
+
},
|
|
392
|
+
rules: {
|
|
393
|
+
"composition-only-delivery": compositionOnlyDelivery,
|
|
394
|
+
"folder-ownership": folderOwnership,
|
|
395
|
+
"hook-anatomy": hookAnatomy,
|
|
396
|
+
"no-infrastructure-in-view": noInfrastructureInView,
|
|
397
|
+
"no-view-effects": noViewEffects,
|
|
398
|
+
"pure-index-barrel": pureIndexBarrel,
|
|
399
|
+
"readonly-props": readonlyProps,
|
|
400
|
+
"require-exported-variable-jsdoc": requireExportedVariableJsdoc,
|
|
401
|
+
"strict-colocation": {
|
|
402
|
+
meta: {
|
|
403
|
+
type: "problem",
|
|
404
|
+
docs: { description: "enforce role-file colocation for declarations in governed main modules" },
|
|
405
|
+
messages: {
|
|
406
|
+
rootVariable: "Strict Colocation: root-level variables are forbidden in governed modules. Move constants to *.constants.ts and helper state into the function body.",
|
|
407
|
+
rootHelperFunction: "Strict Colocation: root-level helper functions are forbidden in governed modules. Move them to *.helpers.ts.",
|
|
408
|
+
exportedConst: "Strict Colocation: export governed main modules as named function declarations, not root-level consts.",
|
|
409
|
+
defaultArrowExport: "Strict Colocation: export governed main modules as named function declarations.",
|
|
410
|
+
inlineInterface: "Strict Colocation: interfaces must be declared in a separate *.types.ts file.",
|
|
411
|
+
inlineTypeAlias: "Strict Colocation: type aliases must be declared in a separate *.types.ts file.",
|
|
412
|
+
zodImport: "Strict Colocation: Zod schemas must live in a dedicated *.schema.ts file."
|
|
413
|
+
},
|
|
414
|
+
schema: [{
|
|
415
|
+
type: "object",
|
|
416
|
+
properties: { checks: {
|
|
417
|
+
type: "array",
|
|
418
|
+
items: { enum: [...allChecks] }
|
|
419
|
+
} },
|
|
420
|
+
additionalProperties: false
|
|
421
|
+
}]
|
|
422
|
+
},
|
|
423
|
+
create(context) {
|
|
424
|
+
const configuredChecks = context.options[0]?.checks ?? allChecks;
|
|
425
|
+
const enabled = new Set(configuredChecks);
|
|
426
|
+
const visitor = {};
|
|
427
|
+
if (enabled.has("root-variable")) visitor["Program > VariableDeclaration"] = (node) => {
|
|
428
|
+
context.report({
|
|
429
|
+
node,
|
|
430
|
+
messageId: "rootVariable"
|
|
431
|
+
});
|
|
432
|
+
};
|
|
433
|
+
if (enabled.has("root-helper-function")) visitor["Program > FunctionDeclaration"] = (node) => {
|
|
434
|
+
context.report({
|
|
435
|
+
node,
|
|
436
|
+
messageId: "rootHelperFunction"
|
|
437
|
+
});
|
|
438
|
+
};
|
|
439
|
+
if (enabled.has("exported-const")) visitor["Program > ExportNamedDeclaration > VariableDeclaration"] = (node) => {
|
|
440
|
+
context.report({
|
|
441
|
+
node,
|
|
442
|
+
messageId: "exportedConst"
|
|
443
|
+
});
|
|
444
|
+
};
|
|
445
|
+
if (enabled.has("default-arrow-export")) visitor["Program > ExportDefaultDeclaration > ArrowFunctionExpression"] = (node) => {
|
|
446
|
+
context.report({
|
|
447
|
+
node,
|
|
448
|
+
messageId: "defaultArrowExport"
|
|
449
|
+
});
|
|
450
|
+
};
|
|
451
|
+
if (enabled.has("inline-interface")) visitor["TSInterfaceDeclaration"] = (node) => {
|
|
452
|
+
context.report({
|
|
453
|
+
node,
|
|
454
|
+
messageId: "inlineInterface"
|
|
455
|
+
});
|
|
456
|
+
};
|
|
457
|
+
if (enabled.has("inline-type-alias")) visitor["TSTypeAliasDeclaration"] = (node) => {
|
|
458
|
+
context.report({
|
|
459
|
+
node,
|
|
460
|
+
messageId: "inlineTypeAlias"
|
|
461
|
+
});
|
|
462
|
+
};
|
|
463
|
+
if (enabled.has("zod-import")) visitor["ImportDeclaration"] = (node) => {
|
|
464
|
+
if (node.type === "ImportDeclaration" && zodImportPattern.test(String(node.source.value))) context.report({
|
|
465
|
+
node,
|
|
466
|
+
messageId: "zodImport"
|
|
467
|
+
});
|
|
468
|
+
};
|
|
469
|
+
return visitor;
|
|
470
|
+
}
|
|
471
|
+
}
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
//#endregion
|
|
475
|
+
//#region src/configs/severities.ts
|
|
476
|
+
/**
|
|
477
|
+
* Re-emits a rules map with every active severity downgraded to "warn".
|
|
478
|
+
* Used to surface advisory plugins (react-doctor) as warnings instead of hard
|
|
479
|
+
* errors that would break the gate on pre-existing patterns.
|
|
480
|
+
* @param rules - source rules map keyed by rule name.
|
|
481
|
+
* @returns the same rules with non-off severities set to "warn".
|
|
482
|
+
*/
|
|
483
|
+
function downgradeRuleSeverities(rules) {
|
|
484
|
+
return Object.fromEntries(Object.entries(rules).map(([ruleName, ruleValue]) => {
|
|
485
|
+
if (ruleValue === "off" || ruleValue === 0) return [ruleName, "off"];
|
|
486
|
+
if (Array.isArray(ruleValue)) return [ruleName, ["warn", ...ruleValue.slice(1)]];
|
|
487
|
+
return [ruleName, "warn"];
|
|
488
|
+
}));
|
|
489
|
+
}
|
|
490
|
+
//#endregion
|
|
491
|
+
//#region src/configs/recommended.ts
|
|
492
|
+
const typescriptPlugin = tsPlugin;
|
|
493
|
+
const importXExtensions = [
|
|
494
|
+
".js",
|
|
495
|
+
".jsx",
|
|
496
|
+
".ts",
|
|
497
|
+
".tsx",
|
|
498
|
+
".d.ts"
|
|
499
|
+
];
|
|
500
|
+
const productionTestGlobs = [
|
|
501
|
+
"**/__tests__/**/*.{ts,tsx}",
|
|
502
|
+
"**/*.test.{ts,tsx}",
|
|
503
|
+
"src/test/**/*.{ts,tsx}",
|
|
504
|
+
"test/**/*.{ts,tsx}"
|
|
505
|
+
];
|
|
506
|
+
const nodeScriptGlobs = ["scripts/**/*.{js,mjs,cjs}"];
|
|
507
|
+
const sourceBrowserGlobs = ["src/**/*.{ts,tsx,js,jsx}"];
|
|
508
|
+
const documentationExemptGlobs = [...productionTestGlobs, "**/*.invalid.{ts,tsx,js,jsx}"];
|
|
509
|
+
const governedMainModuleExemptGlobs = [
|
|
510
|
+
...documentationExemptGlobs,
|
|
511
|
+
"**/index.ts",
|
|
512
|
+
"**/*.constants.ts",
|
|
513
|
+
"**/*.helpers.ts",
|
|
514
|
+
"**/*.schema.ts",
|
|
515
|
+
"**/*.types.ts"
|
|
516
|
+
];
|
|
517
|
+
const documentationContexts = [
|
|
518
|
+
"ExportNamedDeclaration > FunctionDeclaration",
|
|
519
|
+
"ExportNamedDeclaration > TSInterfaceDeclaration",
|
|
520
|
+
"ExportNamedDeclaration > TSTypeAliasDeclaration",
|
|
521
|
+
"ExportDefaultDeclaration > FunctionDeclaration",
|
|
522
|
+
"ExportDefaultDeclaration > ArrowFunctionExpression",
|
|
523
|
+
"ExportDefaultDeclaration > CallExpression > ArrowFunctionExpression"
|
|
524
|
+
];
|
|
525
|
+
const allDlinterRulesOff = {
|
|
526
|
+
"dlinter/composition-only-delivery": "off",
|
|
527
|
+
"dlinter/folder-ownership": "off",
|
|
528
|
+
"dlinter/hook-anatomy": "off",
|
|
529
|
+
"dlinter/no-infrastructure-in-view": "off",
|
|
530
|
+
"dlinter/no-view-effects": "off",
|
|
531
|
+
"dlinter/pure-index-barrel": "off",
|
|
532
|
+
"dlinter/readonly-props": "off",
|
|
533
|
+
"dlinter/require-exported-variable-jsdoc": "off",
|
|
534
|
+
"dlinter/strict-colocation": "off"
|
|
535
|
+
};
|
|
536
|
+
/**
|
|
537
|
+
* Builds the full governance preset: bundled third-party plugin stack plus
|
|
538
|
+
* every dlinter architecture rule, composed with the glob topology proven in
|
|
539
|
+
* autoreas-bridge (views / hooks / delivery / role files / tests).
|
|
540
|
+
* @param options - project-specific knobs: infrastructure edge, delivery globs, tsconfig path.
|
|
541
|
+
* @returns flat config array ready to spread into `eslint.config.js`.
|
|
542
|
+
*/
|
|
543
|
+
function createRecommendedConfig(options = {}) {
|
|
544
|
+
const deliveryGlobs = [...options.deliveryGlobs ?? ["src/App.tsx", "src/app/**/*.{ts,tsx}"]];
|
|
545
|
+
const tsconfigPath = options.tsconfigPath ?? "./tsconfig.json";
|
|
546
|
+
const infrastructureRule = options.infrastructure ? ["error", {
|
|
547
|
+
importPatterns: [...options.infrastructure.importPatterns],
|
|
548
|
+
runtimeGlobals: [...options.infrastructure.runtimeGlobals]
|
|
549
|
+
}] : "off";
|
|
550
|
+
const reactDoctorWarnRules = downgradeRuleSeverities(reactDoctor.configs.recommended.rules);
|
|
551
|
+
return [
|
|
552
|
+
js.configs.recommended,
|
|
553
|
+
importX.flatConfigs.recommended,
|
|
554
|
+
importX.flatConfigs.typescript,
|
|
555
|
+
reactPlugin.configs.flat["jsx-runtime"],
|
|
556
|
+
{ ignores: ["dist/**/*", "coverage/**/*"] },
|
|
557
|
+
{
|
|
558
|
+
files: nodeScriptGlobs,
|
|
559
|
+
languageOptions: { globals: {
|
|
560
|
+
console: "readonly",
|
|
561
|
+
process: "readonly"
|
|
562
|
+
} }
|
|
563
|
+
},
|
|
564
|
+
{
|
|
565
|
+
files: ["**/*.{ts,tsx,js,jsx,mjs,cjs}"],
|
|
566
|
+
languageOptions: {
|
|
567
|
+
parser: tsParser,
|
|
568
|
+
ecmaVersion: "latest",
|
|
569
|
+
sourceType: "module"
|
|
570
|
+
},
|
|
571
|
+
plugins: { sonarjs: sonarjsPlugin },
|
|
572
|
+
settings: {
|
|
573
|
+
react: { version: "detect" },
|
|
574
|
+
"import-x/resolver-next": [createTypeScriptImportResolver({
|
|
575
|
+
alwaysTryTypes: true,
|
|
576
|
+
project: tsconfigPath
|
|
577
|
+
}), createNodeResolver({ extensions: importXExtensions })]
|
|
578
|
+
},
|
|
579
|
+
rules: {
|
|
580
|
+
"import/default": "off",
|
|
581
|
+
"import/export": "off",
|
|
582
|
+
"import/named": "off",
|
|
583
|
+
"import/namespace": "off",
|
|
584
|
+
"import/no-duplicates": "off",
|
|
585
|
+
"import/no-named-as-default": "off",
|
|
586
|
+
"import/no-named-as-default-member": "off",
|
|
587
|
+
"import/no-unresolved": "off",
|
|
588
|
+
"no-redeclare": "off",
|
|
589
|
+
"import-x/no-cycle": ["error", { maxDepth: 1 }],
|
|
590
|
+
"import-x/no-duplicates": "error",
|
|
591
|
+
"import-x/no-unresolved": "error",
|
|
592
|
+
"sonarjs/cognitive-complexity": ["warn", 15],
|
|
593
|
+
"sonarjs/no-all-duplicated-branches": "warn",
|
|
594
|
+
"sonarjs/no-identical-functions": "warn",
|
|
595
|
+
"sonarjs/no-redundant-boolean": "warn",
|
|
596
|
+
"sonarjs/no-small-switch": "warn"
|
|
597
|
+
}
|
|
598
|
+
},
|
|
599
|
+
{
|
|
600
|
+
files: sourceBrowserGlobs,
|
|
601
|
+
languageOptions: { globals: {
|
|
602
|
+
document: "readonly",
|
|
603
|
+
navigator: "readonly",
|
|
604
|
+
window: "readonly"
|
|
605
|
+
} }
|
|
606
|
+
},
|
|
607
|
+
{
|
|
608
|
+
files: ["**/*.ts", "**/*.tsx"],
|
|
609
|
+
plugins: { "@typescript-eslint": typescriptPlugin },
|
|
610
|
+
rules: {
|
|
611
|
+
"max-lines": ["error", {
|
|
612
|
+
max: 500,
|
|
613
|
+
skipBlankLines: true,
|
|
614
|
+
skipComments: true
|
|
615
|
+
}],
|
|
616
|
+
"no-undef": "off",
|
|
617
|
+
"no-unused-vars": "off",
|
|
618
|
+
"@typescript-eslint/no-unused-vars": ["error", {
|
|
619
|
+
argsIgnorePattern: "^_",
|
|
620
|
+
varsIgnorePattern: "^_",
|
|
621
|
+
caughtErrors: "none"
|
|
622
|
+
}]
|
|
623
|
+
}
|
|
624
|
+
},
|
|
625
|
+
{
|
|
626
|
+
files: productionTestGlobs,
|
|
627
|
+
plugins: { "@typescript-eslint": typescriptPlugin },
|
|
628
|
+
rules: {
|
|
629
|
+
"no-unused-vars": "off",
|
|
630
|
+
"@typescript-eslint/no-unused-vars": "off"
|
|
631
|
+
}
|
|
632
|
+
},
|
|
633
|
+
{
|
|
634
|
+
files: ["src/**/*.{ts,tsx}"],
|
|
635
|
+
plugins: { "react-hooks": reactHooksPlugin },
|
|
636
|
+
rules: {
|
|
637
|
+
"react-hooks/rules-of-hooks": "error",
|
|
638
|
+
"react-hooks/exhaustive-deps": "warn"
|
|
639
|
+
}
|
|
640
|
+
},
|
|
641
|
+
{
|
|
642
|
+
files: ["**/*.{ts,tsx}"],
|
|
643
|
+
ignores: governedMainModuleExemptGlobs,
|
|
644
|
+
plugins: { dlinter: pluginBase },
|
|
645
|
+
rules: { "dlinter/strict-colocation": "error" }
|
|
646
|
+
},
|
|
647
|
+
{
|
|
648
|
+
files: ["src/**/*.{ts,tsx}"],
|
|
649
|
+
ignores: productionTestGlobs,
|
|
650
|
+
plugins: { "react-doctor": reactDoctor },
|
|
651
|
+
rules: {
|
|
652
|
+
...reactDoctorWarnRules,
|
|
653
|
+
"react-doctor/react-compiler-no-manual-memoization": "off"
|
|
654
|
+
}
|
|
655
|
+
},
|
|
656
|
+
{
|
|
657
|
+
files: ["src/**/*.{ts,tsx}"],
|
|
658
|
+
plugins: { "check-file": checkFilePlugin },
|
|
659
|
+
rules: {
|
|
660
|
+
"check-file/filename-blocklist": ["error", {
|
|
661
|
+
"src/**/utils.ts": "*.helpers.ts",
|
|
662
|
+
"src/**/Utils.ts": "*.helpers.ts"
|
|
663
|
+
}],
|
|
664
|
+
"check-file/folder-match-with-fex": ["error", {
|
|
665
|
+
"src/**/*.test.ts": "**/__tests__/",
|
|
666
|
+
"src/**/*.test.tsx": "**/__tests__/"
|
|
667
|
+
}],
|
|
668
|
+
"check-file/folder-naming-convention": ["error", { "src/features/*/": "KEBAB_CASE" }]
|
|
669
|
+
}
|
|
670
|
+
},
|
|
671
|
+
{
|
|
672
|
+
files: ["src/**/*.tsx"],
|
|
673
|
+
ignores: [...deliveryGlobs, ...productionTestGlobs],
|
|
674
|
+
plugins: { dlinter: pluginBase },
|
|
675
|
+
rules: {
|
|
676
|
+
"dlinter/no-view-effects": "error",
|
|
677
|
+
"dlinter/readonly-props": "error",
|
|
678
|
+
"dlinter/strict-colocation": "error",
|
|
679
|
+
"dlinter/no-infrastructure-in-view": infrastructureRule
|
|
680
|
+
}
|
|
681
|
+
},
|
|
682
|
+
{
|
|
683
|
+
files: deliveryGlobs,
|
|
684
|
+
plugins: { dlinter: pluginBase },
|
|
685
|
+
rules: {
|
|
686
|
+
"dlinter/composition-only-delivery": "error",
|
|
687
|
+
"dlinter/strict-colocation": "error",
|
|
688
|
+
"dlinter/no-infrastructure-in-view": infrastructureRule
|
|
689
|
+
}
|
|
690
|
+
},
|
|
691
|
+
{
|
|
692
|
+
files: ["**/index.ts"],
|
|
693
|
+
ignores: productionTestGlobs,
|
|
694
|
+
plugins: { dlinter: pluginBase },
|
|
695
|
+
rules: { "dlinter/pure-index-barrel": "error" }
|
|
696
|
+
},
|
|
697
|
+
{
|
|
698
|
+
files: ["src/**/*.ts"],
|
|
699
|
+
ignores: documentationExemptGlobs,
|
|
700
|
+
plugins: { dlinter: pluginBase },
|
|
701
|
+
rules: { "dlinter/folder-ownership": "error" }
|
|
702
|
+
},
|
|
703
|
+
{
|
|
704
|
+
files: ["**/*.{ts,tsx}"],
|
|
705
|
+
ignores: documentationExemptGlobs,
|
|
706
|
+
plugins: {
|
|
707
|
+
jsdoc: jsdocPlugin,
|
|
708
|
+
dlinter: pluginBase
|
|
709
|
+
},
|
|
710
|
+
rules: {
|
|
711
|
+
"jsdoc/require-jsdoc": ["error", {
|
|
712
|
+
contexts: documentationContexts,
|
|
713
|
+
exemptEmptyConstructors: false,
|
|
714
|
+
publicOnly: false,
|
|
715
|
+
require: {
|
|
716
|
+
ClassDeclaration: false,
|
|
717
|
+
ClassExpression: false,
|
|
718
|
+
FunctionDeclaration: false,
|
|
719
|
+
MethodDefinition: false
|
|
720
|
+
}
|
|
721
|
+
}],
|
|
722
|
+
"dlinter/require-exported-variable-jsdoc": "error"
|
|
723
|
+
}
|
|
724
|
+
},
|
|
725
|
+
{
|
|
726
|
+
files: ["src/**/use-*.ts"],
|
|
727
|
+
ignores: productionTestGlobs,
|
|
728
|
+
plugins: { dlinter: pluginBase },
|
|
729
|
+
rules: {
|
|
730
|
+
"dlinter/hook-anatomy": "error",
|
|
731
|
+
"dlinter/strict-colocation": "error"
|
|
732
|
+
}
|
|
733
|
+
},
|
|
734
|
+
{
|
|
735
|
+
files: ["src/**/*.types.ts"],
|
|
736
|
+
ignores: productionTestGlobs,
|
|
737
|
+
plugins: { dlinter: pluginBase },
|
|
738
|
+
rules: { "dlinter/readonly-props": "error" }
|
|
739
|
+
},
|
|
740
|
+
{
|
|
741
|
+
files: ["src/**/*.helpers.ts"],
|
|
742
|
+
ignores: productionTestGlobs,
|
|
743
|
+
plugins: { dlinter: pluginBase },
|
|
744
|
+
rules: { "dlinter/strict-colocation": ["error", { checks: ["inline-interface", "inline-type-alias"] }] }
|
|
745
|
+
},
|
|
746
|
+
{
|
|
747
|
+
files: productionTestGlobs,
|
|
748
|
+
plugins: {
|
|
749
|
+
dlinter: pluginBase,
|
|
750
|
+
jsdoc: jsdocPlugin
|
|
751
|
+
},
|
|
752
|
+
rules: {
|
|
753
|
+
...allDlinterRulesOff,
|
|
754
|
+
"jsdoc/require-jsdoc": "off"
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
];
|
|
758
|
+
}
|
|
759
|
+
//#endregion
|
|
760
|
+
//#region src/index.ts
|
|
761
|
+
/**
|
|
762
|
+
* Architecture-concept presets exposed to consumers as flat config arrays.
|
|
763
|
+
* `recommended` composes the full governance stack with default options;
|
|
764
|
+
* projects with an infrastructure edge use `createRecommendedConfig` instead.
|
|
765
|
+
*/
|
|
766
|
+
const configs = {
|
|
767
|
+
"dumb-ui": createDumbUiConfig(pluginBase),
|
|
768
|
+
recommended: createRecommendedConfig()
|
|
769
|
+
};
|
|
770
|
+
const dlinter = {
|
|
771
|
+
...pluginBase,
|
|
772
|
+
configs
|
|
773
|
+
};
|
|
774
|
+
//#endregion
|
|
775
|
+
export { createRecommendedConfig, dlinter as default };
|