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 +30 -5
- package/dist/cli/index.mjs +10 -4
- package/dist/index.d.mts +10 -2
- package/dist/index.mjs +204 -78
- package/package.json +7 -4
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`
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
package/dist/cli/index.mjs
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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:
|
|
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
|
|
385
|
-
* entrypoint back (which would create a
|
|
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(
|
|
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/
|
|
528
|
+
//#region src/configs/recommended/recommended.constants.ts
|
|
476
529
|
/**
|
|
477
|
-
*
|
|
478
|
-
*
|
|
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
|
-
|
|
593
|
-
|
|
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
|
-
...
|
|
653
|
-
|
|
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: [
|
|
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.
|
|
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": "
|
|
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": "
|
|
77
|
+
"eslint-plugin-react-doctor": "0.7.4",
|
|
75
78
|
"eslint-plugin-react-hooks": "^7.1.1",
|
|
76
|
-
"eslint-plugin-sonarjs": "
|
|
79
|
+
"eslint-plugin-sonarjs": "4.1.0"
|
|
77
80
|
}
|
|
78
81
|
}
|