@agjs/tsforge 0.4.0 → 0.5.1

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.
@@ -30,6 +30,7 @@ const HANDLERS: Record<ToolName, ToolHandler> = {
30
30
  [TOOL_NAME.typeAt]: (a, c) => doLsp(TOOL_NAME.typeAt, a, c),
31
31
  [TOOL_NAME.diagnostics]: (a, c) => doLsp(TOOL_NAME.diagnostics, a, c),
32
32
  [TOOL_NAME.renameSymbol]: (a, c) => doLsp(TOOL_NAME.renameSymbol, a, c),
33
+ [TOOL_NAME.moveFile]: (a, c) => doLsp(TOOL_NAME.moveFile, a, c),
33
34
  [TOOL_NAME.organizeImports]: (a, c) => doLsp(TOOL_NAME.organizeImports, a, c),
34
35
  [TOOL_NAME.scaffoldUi]: doScaffoldUi,
35
36
  [TOOL_NAME.scaffoldRoutes]: doScaffoldRoutes,
@@ -1,7 +1,7 @@
1
1
  import { relative } from "node:path";
2
2
  import { fileArg, TOOL_NAME, type ToolName } from "../../agent";
3
3
  import { runArgvCommand } from "../../lib/fs";
4
- import { writable } from "../../lib/scope";
4
+ import { writable, isVendored } from "../../lib/scope";
5
5
  import { LOOP_LIMITS } from "../loop.constants";
6
6
  import { str, reject, type IToolContext } from "./tool-context";
7
7
 
@@ -137,10 +137,57 @@ export function doLsp(
137
137
  return `organize_imports: ${n} change(s) in ${file}`;
138
138
  }
139
139
 
140
+ // move_file takes {from, to} (not {file, symbol}) — handle before doSymbolLsp's
141
+ // symbol guard. Scope-enforced: a move rewrites importers across the project; it
142
+ // must NOT touch read-only/out-of-scope/vendored files.
143
+ if (name === TOOL_NAME.moveFile) {
144
+ return doMoveFile(svc, args, ctx, rel);
145
+ }
146
+
140
147
  // The remaining tools address a symbol by name within a file.
141
148
  return doSymbolLsp(name, svc, file, args, ctx, rel);
142
149
  }
143
150
 
151
+ /** move_file: relocate a file and rewrite every importer's specifier. */
152
+ function doMoveFile(
153
+ svc: LspService,
154
+ args: Record<string, unknown>,
155
+ ctx: IToolContext,
156
+ rel: (abs: string) => string
157
+ ): string {
158
+ const from = str(args, "from");
159
+ const to = str(args, "to");
160
+
161
+ if (from.length === 0 || to.length === 0) {
162
+ return "move_file: need {from, to}";
163
+ }
164
+
165
+ const targets = svc.moveTargets(from, to).map(rel);
166
+ const blocked = targets.filter(
167
+ (t) => !writable(t, ctx.files) || isVendored(t, ctx.vendored ?? [])
168
+ );
169
+
170
+ if (blocked.length > 0) {
171
+ return reject(
172
+ ctx,
173
+ "move_file",
174
+ `move '${from}' → '${to}' REJECTED: would touch out-of-scope/read-only file(s): ${blocked.join(", ")}. Move files only when the source, destination, and every importer are in your editable scope.`
175
+ );
176
+ }
177
+
178
+ const changed = svc.moveFile(from, to);
179
+
180
+ ctx.report({
181
+ kind: "tool",
182
+ task: ctx.task,
183
+ message: `move_file ${from}→${to} (${changed ?? 0})`,
184
+ });
185
+
186
+ return changed === null
187
+ ? `move_file: source '${from}' not found`
188
+ : `moved '${from}' → '${to}', updated imports across ${changed} file(s)`;
189
+ }
190
+
144
191
  type LspService = NonNullable<IToolContext["tsService"]>;
145
192
 
146
193
  /** The LSP tools that resolve a symbol position first (type_at, find_references,
@@ -1,4 +1,5 @@
1
- import { join, isAbsolute } from "node:path";
1
+ import { join, isAbsolute, dirname } from "node:path";
2
+ import { mkdirSync, rmSync } from "node:fs";
2
3
  import ts from "typescript";
3
4
  import type {
4
5
  ITsDiagnostic,
@@ -451,6 +452,73 @@ export class TsService {
451
452
  return [...new Set((locs ?? []).map((l) => l.fileName))];
452
453
  }
453
454
 
455
+ /** Edits a file move would produce (rewriting importers + the file's own
456
+ * relative imports). Absolute paths. */
457
+ private moveEdits(
458
+ fromAbs: string,
459
+ toAbs: string
460
+ ): readonly ts.FileTextChanges[] {
461
+ return this.service.getEditsForFileRename(
462
+ fromAbs,
463
+ toAbs,
464
+ ts.getDefaultFormatCodeSettings("\n"),
465
+ {}
466
+ );
467
+ }
468
+
469
+ /** Which files a move would touch — every importer plus the source and
470
+ * destination — for scope-checking BEFORE applying. Absolute paths. */
471
+ moveTargets(fromRel: string, toRel: string): string[] {
472
+ const fromAbs = this.toAbs(fromRel);
473
+ const toAbs = this.toAbs(toRel);
474
+ const touched = new Set(
475
+ this.moveEdits(fromAbs, toAbs).map((e) => e.fileName)
476
+ );
477
+
478
+ touched.add(fromAbs);
479
+ touched.add(toAbs);
480
+
481
+ return [...touched];
482
+ }
483
+
484
+ /**
485
+ * Move a file and rewrite every import that points at it (and its own relative
486
+ * imports), compiler-accurately via getEditsForFileRename. Returns the number
487
+ * of files changed (incl. the moved file), or null if the source can't be read.
488
+ * Callers MUST enforce scope first (a move can touch read-only files).
489
+ */
490
+ moveFile(fromRel: string, toRel: string): number | null {
491
+ const fromAbs = this.toAbs(fromRel);
492
+ const toAbs = this.toAbs(toRel);
493
+ const original = ts.sys.readFile(fromAbs);
494
+
495
+ if (original === undefined) {
496
+ return null;
497
+ }
498
+
499
+ const edits = this.moveEdits(fromAbs, toAbs);
500
+
501
+ // Apply the import rewrites (importers + the moved file's own imports) while
502
+ // the file still lives at its old path, THEN relocate the edited content.
503
+ this.applyChanges(edits);
504
+
505
+ const moved = ts.sys.readFile(fromAbs) ?? original;
506
+
507
+ mkdirSync(dirname(toAbs), { recursive: true });
508
+ ts.sys.writeFile(toAbs, moved);
509
+ rmSync(fromAbs, { force: true });
510
+
511
+ const stale = this.files.indexOf(fromAbs);
512
+
513
+ if (stale >= 0) {
514
+ this.files.splice(stale, 1);
515
+ }
516
+
517
+ this.refresh(toRel);
518
+
519
+ return new Set([...edits.map((e) => e.fileName), fromAbs]).size;
520
+ }
521
+
454
522
  /** Organize imports (dedupe/sort/drop unused) for one file. Returns edits made. */
455
523
  organizeImports(file: string): number {
456
524
  const changes = this.service.organizeImports(
@@ -28,6 +28,8 @@ import { noGithubContextInShellRule } from "./rules/ci/no-github-context-in-shel
28
28
  import { dockerfileBaseImagePinnedRule } from "./rules/docker/dockerfile-base-image-pinned";
29
29
  import { dockerfileNonRootUserRule } from "./rules/docker/dockerfile-non-root-user";
30
30
  import { dockerfileNoSecretsInEnvArgRule } from "./rules/docker/dockerfile-no-secrets-in-env-arg";
31
+ import { noUndeclaredDependenciesRule } from "./rules/supply-chain/no-undeclared-dependencies";
32
+ import { noCircularImportsRule } from "./rules/structure/no-circular-imports";
31
33
 
32
34
  /**
33
35
  * All available meta-rules, ordered by category for readability.
@@ -45,6 +47,7 @@ export const META_RULES: readonly IMetaRule[] = [
45
47
  dependencyOverridesRequireCommentRule,
46
48
  productionMustNotUseDrizzlePushRule,
47
49
  migrationsMustBeCheckedInRule,
50
+ noUndeclaredDependenciesRule,
48
51
 
49
52
  // Source text
50
53
  noEslintDisableCommentsRule,
@@ -74,4 +77,7 @@ export const META_RULES: readonly IMetaRule[] = [
74
77
  dockerfileBaseImagePinnedRule,
75
78
  dockerfileNonRootUserRule,
76
79
  dockerfileNoSecretsInEnvArgRule,
80
+
81
+ // Structure (cross-file)
82
+ noCircularImportsRule,
77
83
  ];
@@ -0,0 +1,195 @@
1
+ import { dirname, join, normalize } from "node:path";
2
+ import type {
3
+ IMetaRule,
4
+ IMetaRuleContext,
5
+ IMetaRuleViolation,
6
+ } from "../../meta-rules.types";
7
+
8
+ /**
9
+ * Circular imports (A → B → A) are a cross-file smell that per-file ESLint cannot
10
+ * see: they cause partially-initialized modules (TDZ/`undefined` at import time),
11
+ * defeat tree-shaking, and make refactors brittle. We build the module graph from
12
+ * the project's own relative imports and report each cyclic group once.
13
+ *
14
+ * Only RELATIVE imports (`./`, `../`) are followed — alias/bare specifiers need a
15
+ * resolver and would risk false positives. That keeps this high-precision: every
16
+ * reported cycle is real, in-project, and the model's to break.
17
+ */
18
+ const IMPORT_FROM = /(?:import|export)[^'"]*?from\s*['"](?<spec>[^'"]+)['"]/gu;
19
+ const DYNAMIC_IMPORT = /\bimport\s*\(\s*['"](?<spec>[^'"]+)['"]\s*\)/gu;
20
+
21
+ /** Relative import specifiers (`./x`, `../y`) found in one file's text. */
22
+ function relativeSpecifiers(text: string): string[] {
23
+ const out: string[] = [];
24
+
25
+ for (const re of [IMPORT_FROM, DYNAMIC_IMPORT]) {
26
+ re.lastIndex = 0;
27
+
28
+ for (const m of text.matchAll(re)) {
29
+ const spec = m.groups?.spec;
30
+
31
+ if (spec?.startsWith(".") === true) {
32
+ out.push(spec);
33
+ }
34
+ }
35
+ }
36
+
37
+ return out;
38
+ }
39
+
40
+ /** Resolve a relative specifier to a known source file, or null. */
41
+ function resolveToSourceFile(
42
+ fromFile: string,
43
+ spec: string,
44
+ fileSet: ReadonlySet<string>
45
+ ): string | null {
46
+ const base = normalize(join(dirname(fromFile), spec))
47
+ .split("\\")
48
+ .join("/");
49
+ const candidates =
50
+ base.endsWith(".ts") || base.endsWith(".tsx")
51
+ ? [base]
52
+ : [`${base}.ts`, `${base}.tsx`, `${base}/index.ts`, `${base}/index.tsx`];
53
+
54
+ for (const candidate of candidates) {
55
+ if (fileSet.has(candidate)) {
56
+ return candidate;
57
+ }
58
+ }
59
+
60
+ return null;
61
+ }
62
+
63
+ /** Adjacency list of in-project module edges. */
64
+ function buildGraph(ctx: IMetaRuleContext): Map<string, string[]> {
65
+ const fileSet = new Set(ctx.sourceFiles);
66
+ const graph = new Map<string, string[]>();
67
+
68
+ for (const file of ctx.sourceFiles) {
69
+ const text = ctx.readFile(file);
70
+ const edges: string[] = [];
71
+
72
+ if (text !== null) {
73
+ for (const spec of relativeSpecifiers(text)) {
74
+ const target = resolveToSourceFile(file, spec, fileSet);
75
+
76
+ if (target !== null && target !== file) {
77
+ edges.push(target);
78
+ }
79
+ }
80
+ }
81
+
82
+ graph.set(file, edges);
83
+ }
84
+
85
+ return graph;
86
+ }
87
+
88
+ interface ITarjanState {
89
+ readonly index: Map<string, number>;
90
+ readonly low: Map<string, number>;
91
+ readonly onStack: Set<string>;
92
+ readonly stack: string[];
93
+ readonly components: string[][];
94
+ counter: number;
95
+ }
96
+
97
+ /** Tarjan's strongly-connected-components — each SCC with >1 node (or a self-loop)
98
+ * is an import cycle. Iterative-free recursion is fine for project-sized graphs. */
99
+ function strongConnect(
100
+ node: string,
101
+ graph: Map<string, string[]>,
102
+ state: ITarjanState
103
+ ): void {
104
+ state.index.set(node, state.counter);
105
+ state.low.set(node, state.counter);
106
+ state.counter += 1;
107
+ state.stack.push(node);
108
+ state.onStack.add(node);
109
+
110
+ for (const next of graph.get(node) ?? []) {
111
+ if (!state.index.has(next)) {
112
+ strongConnect(next, graph, state);
113
+ state.low.set(
114
+ node,
115
+ Math.min(state.low.get(node) ?? 0, state.low.get(next) ?? 0)
116
+ );
117
+ } else if (state.onStack.has(next)) {
118
+ state.low.set(
119
+ node,
120
+ Math.min(state.low.get(node) ?? 0, state.index.get(next) ?? 0)
121
+ );
122
+ }
123
+ }
124
+
125
+ if (state.low.get(node) === state.index.get(node)) {
126
+ const component: string[] = [];
127
+
128
+ for (;;) {
129
+ const popped = state.stack.pop();
130
+
131
+ if (popped === undefined) {
132
+ break;
133
+ }
134
+
135
+ state.onStack.delete(popped);
136
+ component.push(popped);
137
+
138
+ if (popped === node) {
139
+ break;
140
+ }
141
+ }
142
+
143
+ state.components.push(component);
144
+ }
145
+ }
146
+
147
+ /** All cyclic groups: SCCs of size > 1, plus single nodes that import themselves. */
148
+ function findCycles(graph: Map<string, string[]>): string[][] {
149
+ const state: ITarjanState = {
150
+ index: new Map(),
151
+ low: new Map(),
152
+ onStack: new Set(),
153
+ stack: [],
154
+ components: [],
155
+ counter: 0,
156
+ };
157
+
158
+ for (const node of graph.keys()) {
159
+ if (!state.index.has(node)) {
160
+ strongConnect(node, graph, state);
161
+ }
162
+ }
163
+
164
+ return state.components.filter((c) => {
165
+ if (c.length > 1) {
166
+ return true;
167
+ }
168
+
169
+ const only = c[0];
170
+
171
+ return only !== undefined && (graph.get(only) ?? []).includes(only);
172
+ });
173
+ }
174
+
175
+ export const noCircularImportsRule: IMetaRule = {
176
+ id: "no-circular-imports",
177
+ category: "stack-layout",
178
+ description:
179
+ "Project modules must not form import cycles (A → B → A) — they cause partial-initialization bugs and defeat tree-shaking.",
180
+ severity: "error",
181
+ run(ctx) {
182
+ const cycles = findCycles(buildGraph(ctx));
183
+
184
+ return cycles.map((cycle): IMetaRuleViolation => {
185
+ const ordered = [...cycle].sort();
186
+
187
+ return {
188
+ file: ordered[0] ?? "src",
189
+ ruleId: "no-circular-imports",
190
+ severity: "error",
191
+ message: `Import cycle between ${ordered.length} modules: ${ordered.join(" ↔ ")}. Break it by extracting the shared piece into a third module both can import.`,
192
+ };
193
+ });
194
+ },
195
+ };
@@ -0,0 +1,180 @@
1
+ import { builtinModules } from "node:module";
2
+ import type {
3
+ IMetaRule,
4
+ IMetaRuleContext,
5
+ IMetaRuleViolation,
6
+ } from "../../meta-rules.types";
7
+
8
+ /**
9
+ * Every bare `import` must resolve to a DECLARED dependency. A classic AI mistake
10
+ * is importing a package it never added to package.json — it works locally via a
11
+ * hoisted/transitive copy, then breaks on a clean install in CI or for a teammate.
12
+ * We compare each imported package against package.json's declared deps (+ Node
13
+ * builtins, `bun:` specifiers, tsconfig path aliases, and the project's own name).
14
+ */
15
+ const IMPORT_FROM = /(?:import|export)[^'"]*?from\s*['"](?<spec>[^'"]+)['"]/gu;
16
+ const DYNAMIC_IMPORT = /\bimport\s*\(\s*['"](?<spec>[^'"]+)['"]\s*\)/gu;
17
+ const NODE_BUILTINS = new Set([
18
+ ...builtinModules,
19
+ ...builtinModules.map((m) => `node:${m}`),
20
+ ]);
21
+
22
+ /** Bare specifiers (packages), skipping relative/absolute paths. */
23
+ function bareSpecifiers(text: string): string[] {
24
+ const out: string[] = [];
25
+
26
+ for (const re of [IMPORT_FROM, DYNAMIC_IMPORT]) {
27
+ re.lastIndex = 0;
28
+
29
+ for (const m of text.matchAll(re)) {
30
+ const spec = m.groups?.spec;
31
+
32
+ if (
33
+ spec !== undefined &&
34
+ !spec.startsWith(".") &&
35
+ !spec.startsWith("/")
36
+ ) {
37
+ out.push(spec);
38
+ }
39
+ }
40
+ }
41
+
42
+ return out;
43
+ }
44
+
45
+ /** The package name a specifier belongs to (`@scope/pkg/sub` → `@scope/pkg`). */
46
+ function packageName(spec: string): string {
47
+ const parts = spec.split("/");
48
+
49
+ if (spec.startsWith("@")) {
50
+ return parts.slice(0, 2).join("/");
51
+ }
52
+
53
+ return parts[0] ?? spec;
54
+ }
55
+
56
+ /** Collect declared dependency names from every dependency field. */
57
+ function declaredDeps(pkg: Record<string, unknown> | null): Set<string> {
58
+ const names = new Set<string>();
59
+ const fields = [
60
+ "dependencies",
61
+ "devDependencies",
62
+ "peerDependencies",
63
+ "optionalDependencies",
64
+ ];
65
+
66
+ for (const field of fields) {
67
+ const map = pkg?.[field];
68
+
69
+ if (typeof map === "object" && map !== null) {
70
+ for (const name of Object.keys(map)) {
71
+ names.add(name);
72
+ }
73
+ }
74
+ }
75
+
76
+ return names;
77
+ }
78
+
79
+ /** Read a string-keyed property as `unknown` without surfacing `any`. */
80
+ function prop(value: unknown, key: string): unknown {
81
+ if (typeof value !== "object" || value === null || !(key in value)) {
82
+ return undefined;
83
+ }
84
+
85
+ const record: Record<string, unknown> = { ...value };
86
+
87
+ return record[key];
88
+ }
89
+
90
+ /** tsconfig `compilerOptions.paths` alias prefixes (e.g. `@/*` → `@/`). */
91
+ function aliasPrefixes(ctx: IMetaRuleContext): string[] {
92
+ const raw = ctx.readFile("tsconfig.json");
93
+
94
+ if (raw === null) {
95
+ return [];
96
+ }
97
+
98
+ try {
99
+ const parsed: unknown = JSON.parse(raw);
100
+ const paths = prop(prop(parsed, "compilerOptions"), "paths");
101
+
102
+ if (typeof paths !== "object" || paths === null) {
103
+ return [];
104
+ }
105
+
106
+ return Object.keys(paths).map((k) => k.replace(/\*$/u, ""));
107
+ } catch {
108
+ return [];
109
+ }
110
+ }
111
+
112
+ /** True when an import is satisfied without a runtime dep declaration. */
113
+ function isAllowed(
114
+ pkg: string,
115
+ spec: string,
116
+ declared: ReadonlySet<string>,
117
+ aliases: readonly string[],
118
+ ownName: string
119
+ ): boolean {
120
+ if (spec.startsWith("bun:") || pkg === "bun") {
121
+ return true;
122
+ }
123
+
124
+ if (NODE_BUILTINS.has(pkg) || NODE_BUILTINS.has(spec)) {
125
+ return true;
126
+ }
127
+
128
+ if (pkg === ownName || declared.has(pkg) || declared.has(`@types/${pkg}`)) {
129
+ return true;
130
+ }
131
+
132
+ return aliases.some((prefix) => prefix.length > 0 && spec.startsWith(prefix));
133
+ }
134
+
135
+ export const noUndeclaredDependenciesRule: IMetaRule = {
136
+ id: "no-undeclared-dependencies",
137
+ category: "supply-chain",
138
+ description:
139
+ "Every imported package must be declared in package.json — an undeclared import works via hoisting locally but breaks on a clean install.",
140
+ severity: "error",
141
+ run(ctx) {
142
+ if (ctx.packageJson === null) {
143
+ return []; // no manifest to check against
144
+ }
145
+
146
+ const declared = declaredDeps(ctx.packageJson);
147
+ const aliases = aliasPrefixes(ctx);
148
+ const ownNameRaw = prop(ctx.packageJson, "name");
149
+ const ownName = typeof ownNameRaw === "string" ? ownNameRaw : "";
150
+ const violations: IMetaRuleViolation[] = [];
151
+ const seen = new Set<string>();
152
+
153
+ for (const file of ctx.sourceFiles) {
154
+ const text = ctx.readFile(file);
155
+
156
+ if (text === null) {
157
+ continue;
158
+ }
159
+
160
+ for (const spec of bareSpecifiers(text)) {
161
+ const pkg = packageName(spec);
162
+ const key = `${file}::${pkg}`;
163
+
164
+ if (isAllowed(pkg, spec, declared, aliases, ownName) || seen.has(key)) {
165
+ continue;
166
+ }
167
+
168
+ seen.add(key);
169
+ violations.push({
170
+ file,
171
+ ruleId: "no-undeclared-dependencies",
172
+ severity: "error",
173
+ message: `Imports \`${pkg}\` but it is not in package.json — add it to dependencies (or devDependencies) so a clean install resolves it.`,
174
+ });
175
+ }
176
+ }
177
+
178
+ return violations;
179
+ },
180
+ };
@@ -1,7 +1,21 @@
1
1
  // Optional type-aware ESLint overlay — enabled only when the target has a
2
- // compiling tsconfig (see detect-gate.ts). Adds async correctness rules that
3
- // require parserOptions.project; kept separate from strict.eslint.config.mjs
4
- // so the syntactic gate still runs on any .ts file without type info.
2
+ // compiling tsconfig (see detect-gate.ts). These rules need full type info
3
+ // (parserOptions.project) and are kept separate from strict.eslint.config.mjs so
4
+ // the syntactic gate still runs on any .ts file without type info.
5
+ //
6
+ // Two jobs:
7
+ // 1. Async correctness — floating/misused promises (a dropped `await` is silent
8
+ // data loss / unhandled rejection).
9
+ // 2. IMPLICIT-`any` containment — the `no-unsafe-*` family. `no-explicit-any`
10
+ // (in the syntactic config) bans the literal `any` token, but it CANNOT see
11
+ // `any` that leaks in from an untyped boundary: `JSON.parse(s)`, `await
12
+ // res.json()`, an untyped dependency. `tsc --strict` propagates that `any`
13
+ // silently. These rules are the only thing that catches it — they make the
14
+ // generated-code gate enforce what tsforge already enforces on its OWN source
15
+ // via strictTypeChecked. Curated to the HIGH-SIGNAL, low-thrash subset;
16
+ // narrative rules (strict-boolean-expressions, no-unnecessary-condition,
17
+ // restrict-template-expressions) are deliberately left off until a sweep
18
+ // shows they don't thrash the local model.
5
19
  import tseslint from "typescript-eslint";
6
20
 
7
21
  export default tseslint.config(
@@ -19,6 +33,7 @@ export default tseslint.config(
19
33
  "@typescript-eslint": tseslint.plugin,
20
34
  },
21
35
  rules: {
36
+ // Async correctness.
22
37
  "@typescript-eslint/no-floating-promises": "error",
23
38
  "@typescript-eslint/no-misused-promises": [
24
39
  "error",
@@ -28,6 +43,24 @@ export default tseslint.config(
28
43
  },
29
44
  },
30
45
  ],
46
+ "@typescript-eslint/await-thenable": "error",
47
+
48
+ // Implicit-`any` containment: stop untyped boundary data from flowing
49
+ // through the program unchecked.
50
+ "@typescript-eslint/no-unsafe-assignment": "error",
51
+ "@typescript-eslint/no-unsafe-member-access": "error",
52
+ "@typescript-eslint/no-unsafe-call": "error",
53
+ "@typescript-eslint/no-unsafe-return": "error",
54
+ "@typescript-eslint/no-unsafe-argument": "error",
55
+
56
+ // Cheap, high-signal correctness rules that need type info.
57
+ "@typescript-eslint/no-for-in-array": "error",
58
+ "@typescript-eslint/no-base-to-string": "error",
59
+ "@typescript-eslint/restrict-plus-operands": "error",
60
+ "@typescript-eslint/switch-exhaustiveness-check": [
61
+ "error",
62
+ { considerDefaultExhaustiveForUnions: true },
63
+ ],
31
64
  },
32
65
  }
33
66
  );