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/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 };