dlinter-ts-react 0.2.0 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -79,6 +79,7 @@ export default createRecommendedConfig({
79
79
  | `infrastructure` | `undefined` (rule off) | Import patterns + runtime globals that views must never touch directly |
80
80
  | `deliveryGlobs` | `['src/App.tsx', 'src/app/**/*.{ts,tsx}']` | Which files form the composition-only delivery layer |
81
81
  | `tsconfigPath` | `./tsconfig.json` | tsconfig used by the import resolver |
82
+ | `reactCompiler` | `false` | Project compiles with React Compiler → `react-doctor/react-compiler-no-manual-memoization` activates at its upstream severity (manual memoization becomes redundant noise). Without the compiler it stays off — manual memoization is load-bearing |
82
83
 
83
84
  Presets are named after **architecture concepts** (`recommended`, `dumb-ui`), never after projects. Your project's specifics are options, not preset names.
84
85
 
@@ -90,9 +91,18 @@ Beyond the `dlinter/` rules, the preset composes the bundled ecosystem with prov
90
91
  - **`import-x`**: cycle detection (`no-cycle`), duplicate imports, TypeScript-aware resolution.
91
92
  - **`jsdoc/require-jsdoc`** on every exported function, interface, and type alias.
92
93
  - **`check-file`**: tests belong in `__tests__/`, feature folders are kebab-case, `utils.ts` is banned (name the role: `*.helpers.ts`).
93
- - **`sonarjs` + `react-doctor` as warnings** — advisory signal, never gate failures.
94
94
  - **`max-lines`**: 500 effective lines, hard error.
95
- - **`react-hooks`**: `rules-of-hooks` stays an error; nothing else replaces that safety net.
95
+ - **`react-hooks`**: `rules-of-hooks` blocks (zero false positives; violations corrupt React's hook order at runtime). `exhaustive-deps` is deliberately advisory — its documented false-positive patterns would force worse code or disable-comments at `error`.
96
+
97
+ ### Upstream severities are respected, never blanket-downgraded
98
+
99
+ Every bundled plugin whose recommended config the preset spreads keeps **its own author's per-rule triage** — that triage is exactly the work this package does for you, and sweeping a whole ruleset to `warn` would throw it away. Only *named* rules are re-tuned, each with a documented reason in the source:
100
+
101
+ - **`@typescript-eslint`** — the recommended tier (20 definite-bug rules at `error`: `no-explicit-any`, `no-misused-new`, `no-unsafe-function-type`, …) applies to all TypeScript. The **type-checked tier** (`no-floating-promises`, `no-misused-promises`, `await-thenable`, the `no-unsafe-*` family) runs on production `src/**` through the TypeScript project service — **your tsconfig must cover `src/`**. Tests are exempt (mock-assertion patterns are documented `unbound-method` misfires).
102
+ - **`sonarjs`** — upstream ships 206 bug and security rules at `error` and they block as-is, including `cognitive-complexity`, `no-identical-functions`, and `no-nested-conditional` (kept at `error` so the ESLint gate and a Fallow audit agree on one standard) and `prefer-read-only-props` (kept for SonarQube Cloud parity — it coexists with `dlinter/readonly-props`, which additionally governs `*.types.ts` contracts and needs no type information). Surgical exceptions, each with a reason: `todo-tag`/`fixme-tag` warn instead of blocking tracked work, `pseudo-random` warns (Math.random is legitimate for non-crypto frontend uses), `no-unused-vars` is off because `@typescript-eslint` owns that contract with the underscore convention. In test files only the fake-credential rules go quiet — mirroring SonarQube Cloud's reduced test-file profile — while `no-exclusive-tests` keeps guarding your suite.
103
+ - **`react-doctor`** — 34 upstream `error` rules block (definite bugs and a11y: `jsx-key`, `no-direct-mutation-state`, `require-render-return`, `alt-text`, the `aria-*` set…); ~198 heuristics stay warnings. Sole override: `react-compiler-no-manual-memoization` is off by default and returns at upstream severity via `createRecommendedConfig({ reactCompiler: true })`.
104
+
105
+ Each spread plugin is **pinned to an exact version**, and a drift test locks its upstream error-set (`src/configs/__tests__/upstream-severity-contracts/`) — a bump that changes the set fails CI and forces a deliberate re-triage, never silent drift.
96
106
 
97
107
  ## CLI
98
108
 
@@ -110,17 +120,22 @@ Scaffolds `lefthook.yml` with a lint + typecheck + test pre-commit gate. Never o
110
120
  | `typescript` | `>=5.0.0 <6.1.0` — TypeScript 7 (native) is not yet supported by `@typescript-eslint`; the ceiling moves when the ecosystem does |
111
121
  | `node` | `>=20.19.0` |
112
122
 
123
+ The type-checked rule tier builds a real TypeScript program via the project service: every file under `src/` must be covered by a `tsconfig.json` (the nearest one to each file wins). A standard `"include": ["src"]` already satisfies this.
124
+
113
125
  ## Design principles
114
126
 
115
127
  | Principle | In practice |
116
128
  |-----------|-------------|
117
129
  | First-class rules, never selector catalogs | `no-restricted-syntax` is a single rule slot consumers can silently override; every constraint here has its own rule ID, its own docs, and per-rule disable |
118
130
  | Don't reinvent the wheel | Existing plugins are bundled wheels (`max-lines`, `check-file`, `import-x`, …). Custom rules exist only for contracts no plugin ships — e.g. conditional folder ownership, pure-barrel enforcement |
131
+ | Respect upstream triage, never blanket-downgrade | A bundled plugin's own per-rule severities are the plugin author's triage — honored as-is. Only specific rule IDs are re-tuned, each with a documented reason; a drift test locks the upstream error-set so a version bump forces a deliberate re-triage instead of silent drift. Bulk-demoting a whole ruleset is the exact config archaeology this package exists to spare consumers |
119
132
  | Tests are the proof | Every rule is TDD-built with RED/GREEN `RuleTester` cases; presets are tested through a real `ESLint` instance; releases are gated by installing the actual tarball into a fresh consumer project |
120
133
  | Validated against production | The full preset reproduces the source system's lint verdicts at exact file:line parity (30/30) on the codebase it was extracted from |
121
134
 
122
135
  ## Development
123
136
 
137
+ Contributor and agent documentation lives in [`docs/`](./docs/README.md) — architecture (with diagrams), the severity policy and its override ledger, the add-a-rule recipe, and the testing harnesses.
138
+
124
139
  ```bash
125
140
  bun install
126
141
  bun run test # vitest: RuleTester per rule + ESLint-instance integration harness
@@ -132,13 +147,23 @@ bun run validate # typecheck + test
132
147
 
133
148
  The repo self-hosts its own gate (lefthook: fallow audit + typecheck + test). Contributions follow strict TDD — a rule without a failing test proving it fires is a disabled rule — and conventional commits.
134
149
 
150
+ ### Self-governance
151
+
152
+ This package enforces its own rules on itself. A permanent test (`src/__tests__/self-governance.test.ts`, part of `bun run validate`) lints `src/**` with the universal subset of the shipped rules — strict colocation, folder ownership, pure barrels, exported-variable JSDoc, `max-lines` — plus a layer contract on import direction:
153
+
154
+ ```
155
+ src/index.ts → src/configs/ → src/plugin.ts → src/rules/ (src/cli/ is a standalone bin)
156
+ ```
157
+
158
+ Every commit and release must pass the same architecture this package sells. Two documented exceptions: rule modules are exported const objects (the ESLint plugin contract requires it), and the package entrypoints (`main`, `bin`) are composition modules, not barrels.
159
+
135
160
  ### Releasing
136
161
 
137
- Releases are automated with [release-please](https://github.com/googleapis/release-please): conventional commits on `main` drive the next semver (`fix:` patch, `feat:` minor, `feat!:`/`BREAKING CHANGE:` major). The bot maintains a Release PR with the computed version and CHANGELOG; **merging that PR** cuts the GitHub release and publishes to npm with provenance — after the tarball end-to-end gate passes.
162
+ Code lands on `main` only through **feature-branch pull requests** `main` is protected and rejects direct pushes, so CI and SonarCloud analyze the real diff on every change. See [docs/release-and-ci.md](./docs/release-and-ci.md) for branch protection, CI, and the CI-based Sonar setup.
138
163
 
139
- Manual escape hatch: pushing a `v*` tag triggers the same publish pipeline directly.
164
+ Releases are automated with [release-please](https://github.com/googleapis/release-please): the conventional commits merged into `main` drive the next semver (`fix:` → patch, `feat:` → minor, `feat!:`/`BREAKING CHANGE:` → major). The bot maintains a Release PR with the computed version and CHANGELOG; **merging that PR** cuts the GitHub release and publishes to npm — after the tarball end-to-end gate passes.
140
165
 
141
- Requires the `NPM_TOKEN` secret in the repository settings.
166
+ Publishing authenticates via [npm trusted publishing](https://docs.npmjs.com/trusted-publishers/) (OIDC): no tokens, nothing to rotate, provenance built in. The trusted publisher is configured in the package settings on npmjs.com, bound to this repository's `release-please.yml` workflow. Emergency escape hatch: publish locally with `npm publish` (2FA OTP applies).
142
167
 
143
168
  ## Roadmap
144
169
 
@@ -1,7 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
  import { existsSync, writeFileSync } from "node:fs";
3
3
  import path from "node:path";
4
- //#region src/cli/init.ts
4
+ //#region src/cli/init/init.constants.ts
5
+ /** Default lefthook pre-commit gate scaffolded into consumer projects. */
5
6
  const lefthookTemplate = `pre-commit:
6
7
  parallel: false
7
8
  jobs:
@@ -14,6 +15,8 @@ const lefthookTemplate = `pre-commit:
14
15
  - name: test
15
16
  run: npm run test
16
17
  `;
18
+ //#endregion
19
+ //#region src/cli/init/init.ts
17
20
  /**
18
21
  * Scaffolds the dlinter pre-commit gate into a consumer project. Existing
19
22
  * files are never overwritten — a hand-tuned gate always wins over the template.
@@ -49,9 +52,12 @@ async function main() {
49
52
  for (const file of result.created) process.stdout.write(`created ${file}\n`);
50
53
  for (const file of result.skipped) process.stdout.write(`skipped ${file} (already exists)\n`);
51
54
  }
52
- main().catch((error) => {
53
- process.stderr.write(`dlinter failed: ${error.message}\n`);
55
+ try {
56
+ await main();
57
+ } catch (error) {
58
+ const message = error instanceof Error ? error.message : String(error);
59
+ process.stderr.write(`dlinter failed: ${message}\n`);
54
60
  process.exitCode = 1;
55
- });
61
+ }
56
62
  //#endregion
57
63
  export {};
package/dist/index.d.mts CHANGED
@@ -1106,7 +1106,7 @@ interface Directive {
1106
1106
  justification?: string;
1107
1107
  }
1108
1108
  //#endregion
1109
- //#region src/configs/recommended.types.d.ts
1109
+ //#region src/configs/recommended/recommended.types.d.ts
1110
1110
  /**
1111
1111
  * Consumer-defined infrastructure edge: which import specifiers and runtime
1112
1112
  * globals count as "infrastructure" that views must never touch directly.
@@ -1128,9 +1128,17 @@ interface RecommendedConfigOptions {
1128
1128
  readonly deliveryGlobs?: readonly string[];
1129
1129
  /** tsconfig used by the import resolver. Default: `./tsconfig.json`. */
1130
1130
  readonly tsconfigPath?: string;
1131
+ /**
1132
+ * The project compiles with React Compiler. Activates
1133
+ * `react-doctor/react-compiler-no-manual-memoization` at its upstream
1134
+ * severity (manual `useMemo`/`useCallback` become redundant noise once the
1135
+ * compiler memoizes). Default `false`: without the compiler, manual
1136
+ * memoization is load-bearing and the rule would only mislead.
1137
+ */
1138
+ readonly reactCompiler?: boolean;
1131
1139
  }
1132
1140
  //#endregion
1133
- //#region src/configs/recommended.d.ts
1141
+ //#region src/configs/recommended/recommended.d.ts
1134
1142
  /**
1135
1143
  * Builds the full governance preset: bundled third-party plugin stack plus
1136
1144
  * every dlinter architecture rule, composed with the glob topology proven in
package/dist/index.mjs CHANGED
@@ -1,6 +1,5 @@
1
1
  import tsParser from "@typescript-eslint/parser";
2
2
  import js from "@eslint/js";
3
- import tsPlugin from "@typescript-eslint/eslint-plugin";
4
3
  import { createTypeScriptImportResolver } from "eslint-import-resolver-typescript";
5
4
  import checkFilePlugin from "eslint-plugin-check-file";
6
5
  import { createNodeResolver, importX } from "eslint-plugin-import-x";
@@ -11,6 +10,7 @@ import reactHooksPlugin from "eslint-plugin-react-hooks";
11
10
  import sonarjsPlugin from "eslint-plugin-sonarjs";
12
11
  import { existsSync } from "node:fs";
13
12
  import path from "node:path";
13
+ import tsPlugin from "@typescript-eslint/eslint-plugin";
14
14
  //#region src/configs/dumb-ui.ts
15
15
  /**
16
16
  * Dumb UI preset: every `.tsx` file is presentational. Side effects live in
@@ -33,9 +33,13 @@ function createDumbUiConfig(plugin) {
33
33
  }];
34
34
  }
35
35
  //#endregion
36
- //#region src/rules/composition-only-delivery.ts
36
+ //#region src/rules/composition-only-delivery/composition-only-delivery.constants.ts
37
+ /** Matches every built-in React hook name, including bare `use`. */
37
38
  const reactHookNamePattern = "^use(State|Reducer|Effect|LayoutEffect|InsertionEffect|SyncExternalStore|Memo|Callback|Ref|Context|Transition|DeferredValue|ImperativeHandle|DebugValue|Id|Optimistic|ActionState)?$";
39
+ /** Matches module specifiers that resolve to a custom `use-*` hook file. */
38
40
  const customHookModulePattern = /(^|\/)use-[^/]+$/u;
41
+ //#endregion
42
+ //#region src/rules/composition-only-delivery/composition-only-delivery.ts
39
43
  /**
40
44
  * Delivery Layer Rule: delivery files (App.tsx, app/**) compose feature
41
45
  * entrypoints — they never orchestrate. No React hooks, no custom hook
@@ -76,13 +80,16 @@ const compositionOnlyDelivery = {
76
80
  }
77
81
  };
78
82
  //#endregion
79
- //#region src/rules/folder-ownership.ts
83
+ //#region src/rules/folder-ownership/folder-ownership.constants.ts
84
+ /** Role-file suffixes a split main module may own next to its entrypoint. */
80
85
  const roleSuffixes = [
81
86
  ".constants.ts",
82
87
  ".helpers.ts",
83
88
  ".schema.ts",
84
89
  ".types.ts"
85
90
  ];
91
+ //#endregion
92
+ //#region src/rules/folder-ownership/folder-ownership.ts
86
93
  /**
87
94
  * Split Module Ownership Rule: when a main module splits into role files
88
95
  * (`*.helpers.ts`, `*.types.ts`, `*.constants.ts`, `*.schema.ts`), the whole
@@ -124,8 +131,11 @@ const folderOwnership = {
124
131
  }
125
132
  };
126
133
  //#endregion
127
- //#region src/rules/hook-anatomy.ts
134
+ //#region src/rules/hook-anatomy/hook-anatomy.constants.ts
135
+ /** Selector prefix targeting the body of every exported `use*` hook declaration. */
128
136
  const hookBlock = "ExportNamedDeclaration > FunctionDeclaration[id.name=/^use[A-Z0-9]/] > BlockStatement";
137
+ //#endregion
138
+ //#region src/rules/hook-anatomy/hook-anatomy.ts
129
139
  /**
130
140
  * Hook Anatomy Rule: exported hooks follow one ordering — derived state
131
141
  * (useMemo) before callbacks (useCallback) before effects (useEffect) — and
@@ -200,7 +210,7 @@ const noInfrastructureInView = {
200
210
  type: "array",
201
211
  items: {
202
212
  type: "string",
203
- pattern: "^[A-Za-z_$][\\w$]*\\.[A-Za-z_$][\\w$]*$"
213
+ pattern: String.raw`^[A-Za-z_$][\w$]*\.[A-Za-z_$][\w$]*$`
204
214
  }
205
215
  }
206
216
  },
@@ -243,8 +253,11 @@ const noInfrastructureInView = {
243
253
  }
244
254
  };
245
255
  //#endregion
246
- //#region src/rules/no-view-effects.ts
256
+ //#region src/rules/no-view-effects/no-view-effects.constants.ts
257
+ /** Matches the React effect hooks that are forbidden inside view components. */
247
258
  const effectHookPattern = /^use(?:Effect|LayoutEffect)$/u;
259
+ //#endregion
260
+ //#region src/rules/no-view-effects/no-view-effects.ts
248
261
  /**
249
262
  * Dumb UI Rule: view components (.tsx) must stay presentational. Side effects
250
263
  * belong in the colocated `use-*.ts` hook, never in the view itself.
@@ -299,8 +312,11 @@ const pureIndexBarrel = {
299
312
  }
300
313
  };
301
314
  //#endregion
302
- //#region src/rules/readonly-props.ts
315
+ //#region src/rules/readonly-props/readonly-props.constants.ts
316
+ /** Selector fragment matching parameters annotated with a bare `*Props` type reference. */
303
317
  const propsBoundary = "[typeAnnotation.typeAnnotation.type=\"TSTypeReference\"][typeAnnotation.typeAnnotation.typeName.name=/Props$/]";
318
+ //#endregion
319
+ //#region src/rules/readonly-props/readonly-props.ts
304
320
  /**
305
321
  * Type Contract Rule: component props are immutable. Parameters use
306
322
  * `Readonly<Props>` at the function boundary, and every field of a `*Props`
@@ -366,7 +382,11 @@ const requireExportedVariableJsdoc = {
366
382
  }
367
383
  };
368
384
  //#endregion
369
- //#region src/rules/strict-colocation.ts
385
+ //#region src/rules/strict-colocation/strict-colocation.constants.ts
386
+ /**
387
+ * Every check id the rule can enforce; the `checks` option narrows this set
388
+ * for role files that legitimately own a subset of declaration kinds.
389
+ */
370
390
  const allChecks = [
371
391
  "root-variable",
372
392
  "root-helper-function",
@@ -376,13 +396,86 @@ const allChecks = [
376
396
  "inline-type-alias",
377
397
  "zod-import"
378
398
  ];
399
+ /** Matches `zod` itself and any `zod/*` subpath import. */
379
400
  const zodImportPattern = /^zod(?:\/.*)?$/u;
380
401
  //#endregion
402
+ //#region src/rules/strict-colocation/strict-colocation.helpers.ts
403
+ /**
404
+ * Collects every identifier a module exports through specifier statements —
405
+ * `export default App` and `export { App }` — so root function declarations
406
+ * exported that way are recognized as main modules rather than helpers.
407
+ */
408
+ function collectSpecifierExportedNames(program) {
409
+ const names = /* @__PURE__ */ new Set();
410
+ for (const statement of program.body) {
411
+ if (statement.type === "ExportDefaultDeclaration" && statement.declaration.type === "Identifier") names.add(statement.declaration.name);
412
+ if (statement.type === "ExportNamedDeclaration" && statement.declaration === null) {
413
+ for (const specifier of statement.specifiers) if (specifier.local.type === "Identifier") names.add(specifier.local.name);
414
+ }
415
+ }
416
+ return names;
417
+ }
418
+ /**
419
+ * Builds the visitor fragment owned by each check id; the rule merges the
420
+ * fragments for whichever checks its options enable.
421
+ */
422
+ function createCheckVisitors(context) {
423
+ return {
424
+ "root-variable": { "Program > VariableDeclaration": (node) => {
425
+ context.report({
426
+ node,
427
+ messageId: "rootVariable"
428
+ });
429
+ } },
430
+ "root-helper-function": { "Program > FunctionDeclaration": (node) => {
431
+ if (node.type === "FunctionDeclaration" && node.parent.type === "Program") {
432
+ const exportedNames = collectSpecifierExportedNames(node.parent);
433
+ if (node.id !== null && exportedNames.has(node.id.name)) return;
434
+ }
435
+ context.report({
436
+ node,
437
+ messageId: "rootHelperFunction"
438
+ });
439
+ } },
440
+ "exported-const": { "Program > ExportNamedDeclaration > VariableDeclaration": (node) => {
441
+ context.report({
442
+ node,
443
+ messageId: "exportedConst"
444
+ });
445
+ } },
446
+ "default-arrow-export": { "Program > ExportDefaultDeclaration > ArrowFunctionExpression": (node) => {
447
+ context.report({
448
+ node,
449
+ messageId: "defaultArrowExport"
450
+ });
451
+ } },
452
+ "inline-interface": { TSInterfaceDeclaration: (node) => {
453
+ context.report({
454
+ node,
455
+ messageId: "inlineInterface"
456
+ });
457
+ } },
458
+ "inline-type-alias": { TSTypeAliasDeclaration: (node) => {
459
+ context.report({
460
+ node,
461
+ messageId: "inlineTypeAlias"
462
+ });
463
+ } },
464
+ "zod-import": { ImportDeclaration: (node) => {
465
+ if (node.type === "ImportDeclaration" && zodImportPattern.test(String(node.source.value))) context.report({
466
+ node,
467
+ messageId: "zodImport"
468
+ });
469
+ } }
470
+ };
471
+ }
472
+ //#endregion
381
473
  //#region src/plugin.ts
382
474
  /**
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).
475
+ * Plugin core shared by every preset: metadata plus the dlinter rule registry
476
+ * keyed by published rule name. Presets receive this object so config
477
+ * factories never import the package entrypoint back (which would create a
478
+ * module cycle).
386
479
  */
387
480
  const pluginBase = {
388
481
  meta: {
@@ -423,73 +516,72 @@ const pluginBase = {
423
516
  create(context) {
424
517
  const configuredChecks = context.options[0]?.checks ?? allChecks;
425
518
  const enabled = new Set(configuredChecks);
519
+ const checkVisitors = createCheckVisitors(context);
426
520
  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
- };
521
+ for (const check of allChecks) if (enabled.has(check)) Object.assign(visitor, checkVisitors[check]);
469
522
  return visitor;
470
523
  }
471
524
  }
472
525
  }
473
526
  };
474
527
  //#endregion
475
- //#region src/configs/severities.ts
528
+ //#region src/configs/recommended/recommended.constants.ts
476
529
  /**
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".
530
+ * The @typescript-eslint plugin object types its rules more richly than core
531
+ * ESLint's Plugin contract; the runtime shape is compatible.
482
532
  */
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
533
  const typescriptPlugin = tsPlugin;
534
+ const tsPluginConfigs = tsPlugin.configs;
535
+ /**
536
+ * @typescript-eslint's recommended (non-type-checked) rules — the plugin's own
537
+ * per-rule triage (20 definite-bug rules at "error"), spread as-is across all
538
+ * maintained TypeScript. The error-set is locked by the drift test in
539
+ * upstream-severity-drift.test.ts; the plugin is pinned to an exact version.
540
+ */
541
+ const typescriptRecommendedRules = tsPluginConfigs["recommended"]?.rules ?? {};
542
+ /**
543
+ * @typescript-eslint's type-checked-only tier — the highest-value bug rules a
544
+ * real TypeScript program unlocks (no-floating-promises, no-misused-promises,
545
+ * await-thenable, the no-unsafe-* family), upstream severities respected
546
+ * as-is. Applied only where the project service can build a program
547
+ * (production src); locked by the same drift test.
548
+ */
549
+ const typescriptTypeCheckedOnlyRules = tsPluginConfigs["recommended-type-checked-only"]?.rules ?? {};
550
+ /**
551
+ * sonarjs' recommended rules — the plugin author's per-rule triage (206 rules
552
+ * at "error", 62 off upstream), spread AS-IS. The preset never
553
+ * blanket-downgrades a bundled plugin: only the named rules in
554
+ * sonarjsSurgicalOverrides are re-tuned, each with a documented reason. The
555
+ * upstream error-set is locked by the drift test and sonarjs is pinned exact.
556
+ */
557
+ const sonarjsRecommendedRules = sonarjsPlugin.configs.recommended?.rules ?? {};
558
+ /**
559
+ * Surgical severity overrides for sonarjs' recommended set — the ONLY rules
560
+ * re-tuned from upstream, each a deliberate decision with a reason. Everything
561
+ * else in the 206-rule error tier blocks, exactly as sonarjs triaged it.
562
+ */
563
+ const sonarjsSurgicalOverrides = {
564
+ "sonarjs/fixme-tag": "warn",
565
+ "sonarjs/todo-tag": "warn",
566
+ "sonarjs/no-unused-vars": "off",
567
+ "sonarjs/pseudo-random": "warn"
568
+ };
569
+ /**
570
+ * sonarjs rules that misfire specifically on test code: fixtures legitimately
571
+ * contain fake credentials, fake secrets, and literal IPs. This MIRRORS
572
+ * SonarQube Cloud behavior — files under `sonar.tests` get a reduced rule
573
+ * profile there and these main-code rules do not run on them — so local
574
+ * verdicts and Cloud verdicts stay aligned. Everything else in sonarjs stays
575
+ * ON in tests — no-exclusive-tests, no-skipped-tests, and the assertion rules
576
+ * earn their keep there.
577
+ */
578
+ const sonarjsTestContextOverrides = {
579
+ "sonarjs/hardcoded-secret-signatures": "off",
580
+ "sonarjs/no-hardcoded-ip": "off",
581
+ "sonarjs/no-hardcoded-passwords": "off",
582
+ "sonarjs/no-hardcoded-secrets": "off"
583
+ };
584
+ /** Extensions the import-x node resolver probes when resolving specifiers. */
493
585
  const importXExtensions = [
494
586
  ".js",
495
587
  ".jsx",
@@ -497,15 +589,20 @@ const importXExtensions = [
497
589
  ".tsx",
498
590
  ".d.ts"
499
591
  ];
592
+ /** Globs identifying test files, which stay outside the architecture contract. */
500
593
  const productionTestGlobs = [
501
594
  "**/__tests__/**/*.{ts,tsx}",
502
595
  "**/*.test.{ts,tsx}",
503
596
  "src/test/**/*.{ts,tsx}",
504
597
  "test/**/*.{ts,tsx}"
505
598
  ];
599
+ /** Globs for repository automation scripts that run under Node. */
506
600
  const nodeScriptGlobs = ["scripts/**/*.{js,mjs,cjs}"];
601
+ /** Globs for application source that runs in the browser. */
507
602
  const sourceBrowserGlobs = ["src/**/*.{ts,tsx,js,jsx}"];
603
+ /** Files exempt from the public-documentation contract: tests and documented-invalid fixtures. */
508
604
  const documentationExemptGlobs = [...productionTestGlobs, "**/*.invalid.{ts,tsx,js,jsx}"];
605
+ /** Files that are not governed main modules: exempt files plus barrels and role files. */
509
606
  const governedMainModuleExemptGlobs = [
510
607
  ...documentationExemptGlobs,
511
608
  "**/index.ts",
@@ -514,6 +611,7 @@ const governedMainModuleExemptGlobs = [
514
611
  "**/*.schema.ts",
515
612
  "**/*.types.ts"
516
613
  ];
614
+ /** AST selector contexts that `jsdoc/require-jsdoc` treats as public documentation surface. */
517
615
  const documentationContexts = [
518
616
  "ExportNamedDeclaration > FunctionDeclaration",
519
617
  "ExportNamedDeclaration > TSInterfaceDeclaration",
@@ -522,6 +620,22 @@ const documentationContexts = [
522
620
  "ExportDefaultDeclaration > ArrowFunctionExpression",
523
621
  "ExportDefaultDeclaration > CallExpression > ArrowFunctionExpression"
524
622
  ];
623
+ /**
624
+ * Surgical severity overrides for react-doctor's recommended set.
625
+ *
626
+ * POLICY — never blanket-downgrade a third-party plugin's ruleset. react-doctor
627
+ * already triages every rule it ships (upstream: 34 error / 198 warn), and that
628
+ * per-rule judgment is exactly the work this package exists to do for consumers.
629
+ * The preset therefore spreads react-doctor's recommended rules AS-IS and
630
+ * overrides ONLY the specific rule IDs that genuinely misfire in this
631
+ * architecture — each entry below is a deliberate decision with a documented
632
+ * reason, not a bulk sweep. The upstream error-set is locked by a drift test
633
+ * (`recommended.test.ts`), and react-doctor is pinned to an exact version, so
634
+ * the set cannot shift silently: a bump that changes it fails CI and forces a
635
+ * re-triage here.
636
+ */
637
+ const reactDoctorSurgicalOverrides = { "react-doctor/react-compiler-no-manual-memoization": "off" };
638
+ /** Full dlinter rule map at severity "off" — the final reset applied to test files. */
525
639
  const allDlinterRulesOff = {
526
640
  "dlinter/composition-only-delivery": "off",
527
641
  "dlinter/folder-ownership": "off",
@@ -533,6 +647,8 @@ const allDlinterRulesOff = {
533
647
  "dlinter/require-exported-variable-jsdoc": "off",
534
648
  "dlinter/strict-colocation": "off"
535
649
  };
650
+ //#endregion
651
+ //#region src/configs/recommended/recommended.ts
536
652
  /**
537
653
  * Builds the full governance preset: bundled third-party plugin stack plus
538
654
  * every dlinter architecture rule, composed with the glob topology proven in
@@ -547,7 +663,6 @@ function createRecommendedConfig(options = {}) {
547
663
  importPatterns: [...options.infrastructure.importPatterns],
548
664
  runtimeGlobals: [...options.infrastructure.runtimeGlobals]
549
665
  }] : "off";
550
- const reactDoctorWarnRules = downgradeRuleSeverities(reactDoctor.configs.recommended.rules);
551
666
  return [
552
667
  js.configs.recommended,
553
668
  importX.flatConfigs.recommended,
@@ -589,11 +704,8 @@ function createRecommendedConfig(options = {}) {
589
704
  "import-x/no-cycle": ["error", { maxDepth: 1 }],
590
705
  "import-x/no-duplicates": "error",
591
706
  "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"
707
+ ...sonarjsRecommendedRules,
708
+ ...sonarjsSurgicalOverrides
597
709
  }
598
710
  },
599
711
  {
@@ -608,6 +720,7 @@ function createRecommendedConfig(options = {}) {
608
720
  files: ["**/*.ts", "**/*.tsx"],
609
721
  plugins: { "@typescript-eslint": typescriptPlugin },
610
722
  rules: {
723
+ ...typescriptRecommendedRules,
611
724
  "max-lines": ["error", {
612
725
  max: 500,
613
726
  skipBlankLines: true,
@@ -622,6 +735,13 @@ function createRecommendedConfig(options = {}) {
622
735
  }]
623
736
  }
624
737
  },
738
+ {
739
+ files: ["src/**/*.{ts,tsx}"],
740
+ ignores: productionTestGlobs,
741
+ languageOptions: { parserOptions: { projectService: true } },
742
+ plugins: { "@typescript-eslint": typescriptPlugin },
743
+ rules: { ...typescriptTypeCheckedOnlyRules }
744
+ },
625
745
  {
626
746
  files: productionTestGlobs,
627
747
  plugins: { "@typescript-eslint": typescriptPlugin },
@@ -649,8 +769,9 @@ function createRecommendedConfig(options = {}) {
649
769
  ignores: productionTestGlobs,
650
770
  plugins: { "react-doctor": reactDoctor },
651
771
  rules: {
652
- ...reactDoctorWarnRules,
653
- "react-doctor/react-compiler-no-manual-memoization": "off"
772
+ ...reactDoctor.configs.recommended.rules,
773
+ ...reactDoctorSurgicalOverrides,
774
+ ...options.reactCompiler ? { "react-doctor/react-compiler-no-manual-memoization": reactDoctor.configs.recommended.rules["react-doctor/react-compiler-no-manual-memoization"] ?? "warn" } : {}
654
775
  }
655
776
  },
656
777
  {
@@ -741,7 +862,11 @@ function createRecommendedConfig(options = {}) {
741
862
  files: ["src/**/*.helpers.ts"],
742
863
  ignores: productionTestGlobs,
743
864
  plugins: { dlinter: pluginBase },
744
- rules: { "dlinter/strict-colocation": ["error", { checks: ["inline-interface", "inline-type-alias"] }] }
865
+ rules: { "dlinter/strict-colocation": ["error", { checks: [
866
+ "root-variable",
867
+ "inline-interface",
868
+ "inline-type-alias"
869
+ ] }] }
745
870
  },
746
871
  {
747
872
  files: productionTestGlobs,
@@ -751,7 +876,8 @@ function createRecommendedConfig(options = {}) {
751
876
  },
752
877
  rules: {
753
878
  ...allDlinterRulesOff,
754
- "jsdoc/require-jsdoc": "off"
879
+ "jsdoc/require-jsdoc": "off",
880
+ ...sonarjsTestContextOverrides
755
881
  }
756
882
  }
757
883
  ];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dlinter-ts-react",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "Deterministic architecture governance for TypeScript + React projects: custom ESLint rules, architecture-concept presets, and pre-commit gate scaffolding.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -25,7 +25,7 @@
25
25
  }
26
26
  },
27
27
  "bin": {
28
- "dlinter": "./dist/cli/index.mjs"
28
+ "dlinter": "dist/cli/index.mjs"
29
29
  },
30
30
  "files": [
31
31
  "dist"
@@ -35,6 +35,7 @@
35
35
  "typecheck": "tsc --noEmit",
36
36
  "test": "vitest run",
37
37
  "test:watch": "vitest",
38
+ "test:coverage": "vitest run --coverage",
38
39
  "validate": "bun run typecheck && bun run test",
39
40
  "e2e:pack": "node ./scripts/pack-e2e.mjs",
40
41
  "prepublishOnly": "bun run validate && bun run build"
@@ -54,7 +55,9 @@
54
55
  "typescript": ">=5.0.0 <6.1.0"
55
56
  },
56
57
  "devDependencies": {
58
+ "@types/estree": "^1.0.9",
57
59
  "@types/node": "^26.1.1",
60
+ "@vitest/coverage-v8": "^4.1.10",
58
61
  "eslint": "^10.6.0",
59
62
  "fallow": "^3.3.0",
60
63
  "lefthook": "^2.1.10",
@@ -71,8 +74,8 @@
71
74
  "eslint-plugin-import-x": "^4.17.1",
72
75
  "eslint-plugin-jsdoc": "^63.0.13",
73
76
  "eslint-plugin-react": "^7.37.5",
74
- "eslint-plugin-react-doctor": "^0.7.4",
77
+ "eslint-plugin-react-doctor": "0.7.4",
75
78
  "eslint-plugin-react-hooks": "^7.1.1",
76
- "eslint-plugin-sonarjs": "^4.1.0"
79
+ "eslint-plugin-sonarjs": "4.1.0"
77
80
  }
78
81
  }