@agjs/tsforge 0.3.4 → 0.5.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.
Files changed (29) hide show
  1. package/package.json +2 -1
  2. package/scripts/boot-check.ts +106 -0
  3. package/scripts/build-rule-docs.ts +5 -2
  4. package/scripts/build-rules-md.ts +5 -2
  5. package/scripts/test-coverage-check.ts +138 -0
  6. package/src/cli.ts +2 -2
  7. package/src/detect-gate.ts +48 -2
  8. package/src/loop/feedback/meta-rule-docs.ts +16 -0
  9. package/src/loop/feedback/rule-docs.ts +44 -1
  10. package/src/loop/prompt/prompt.ts +1 -0
  11. package/src/loop/rule-docs.generated.json +242 -222
  12. package/src/meta-rules/context.ts +50 -0
  13. package/src/meta-rules/meta-rules.types.ts +3 -1
  14. package/src/meta-rules/registry.ts +14 -0
  15. package/src/meta-rules/rules/docker/dockerfile-base-image-pinned.ts +73 -0
  16. package/src/meta-rules/rules/docker/dockerfile-no-secrets-in-env-arg.ts +67 -0
  17. package/src/meta-rules/rules/docker/dockerfile-non-root-user.ts +58 -0
  18. package/src/meta-rules/rules/docker/utils.ts +58 -0
  19. package/src/meta-rules/rules/structure/no-circular-imports.ts +195 -0
  20. package/src/meta-rules/rules/supply-chain/no-undeclared-dependencies.ts +180 -0
  21. package/src/rule-packs/ai-sdk/index.ts +28 -0
  22. package/src/rule-packs/ai-sdk/rules/no-api-key-in-client.ts +92 -0
  23. package/src/rule-packs/ai-sdk/rules/no-user-input-in-system-prompt.ts +91 -0
  24. package/src/rule-packs/ai-sdk/rules/require-completion-token-limit.ts +112 -0
  25. package/src/rule-packs/index.ts +2 -0
  26. package/src/stack-detection/packs.ts +19 -0
  27. package/strict.eslint.config.mjs +12 -0
  28. package/strict.type-aware.eslint.config.mjs +36 -3
  29. package/strict.web.eslint.config.mjs +9 -0
@@ -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
+ };
@@ -0,0 +1,28 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { noApiKeyInClientRule } from "./rules/no-api-key-in-client";
4
+ import { requireCompletionTokenLimitRule } from "./rules/require-completion-token-limit";
5
+ import { noUserInputInSystemPromptRule } from "./rules/no-user-input-in-system-prompt";
6
+ import type { IRulePack } from "../rule-packs.types";
7
+
8
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
9
+ "no-api-key-in-client": noApiKeyInClientRule,
10
+ "require-completion-token-limit": requireCompletionTokenLimitRule,
11
+ "no-user-input-in-system-prompt": noUserInputInSystemPromptRule,
12
+ };
13
+
14
+ export const aiSdkPack: IRulePack = {
15
+ id: "ai-sdk",
16
+ description:
17
+ "LLM/AI-SDK security and cost guardrails: no provider key in client bundles, bounded completion tokens, and no request data spliced into the system prompt",
18
+ rules,
19
+ // Structural checks block (error); the injection heuristic warns until proven
20
+ // precise — a false positive on an un-bypassable gate would deadlock the model.
21
+ rulesConfig: {
22
+ "no-api-key-in-client": "error",
23
+ "require-completion-token-limit": "error",
24
+ "no-user-input-in-system-prompt": "warn",
25
+ },
26
+ };
27
+
28
+ export default aiSdkPack;
@@ -0,0 +1,92 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "no-api-key-in-client";
6
+
7
+ type MessageIds = "clientProvider";
8
+
9
+ // Providers whose constructor / factory takes an API key. Building one in a
10
+ // `"use client"` file ships the key into the browser bundle.
11
+ const PROVIDER_CONSTRUCTORS = new Set([
12
+ "OpenAI",
13
+ "Anthropic",
14
+ "GoogleGenerativeAI",
15
+ ]);
16
+ const PROVIDER_FACTORIES = new Set([
17
+ "createOpenAI",
18
+ "createAnthropic",
19
+ "createGoogleGenerativeAI",
20
+ "createAzure",
21
+ "createMistral",
22
+ ]);
23
+
24
+ /** True when the file opens with a `"use client"` directive (client component). */
25
+ function hasUseClientDirective(
26
+ body: readonly TSESTree.ProgramStatement[]
27
+ ): boolean {
28
+ for (const stmt of body) {
29
+ if (stmt.type !== AST_NODE_TYPES.ExpressionStatement) {
30
+ return false; // directives must lead; first non-expression ends the prologue
31
+ }
32
+
33
+ const expr = stmt.expression;
34
+
35
+ if (expr.type === AST_NODE_TYPES.Literal && expr.value === "use client") {
36
+ return true;
37
+ }
38
+ }
39
+
40
+ return false;
41
+ }
42
+
43
+ /** `new OpenAI(...)` etc. */
44
+ function isProviderConstruction(node: TSESTree.NewExpression): boolean {
45
+ return (
46
+ node.callee.type === AST_NODE_TYPES.Identifier &&
47
+ PROVIDER_CONSTRUCTORS.has(node.callee.name)
48
+ );
49
+ }
50
+
51
+ /** `createOpenAI(...)` etc. */
52
+ function isProviderFactory(node: TSESTree.CallExpression): boolean {
53
+ return (
54
+ node.callee.type === AST_NODE_TYPES.Identifier &&
55
+ PROVIDER_FACTORIES.has(node.callee.name)
56
+ );
57
+ }
58
+
59
+ export const noApiKeyInClientRule = createRule<[], MessageIds>({
60
+ name: RULE_NAME,
61
+ meta: {
62
+ type: "problem",
63
+ docs: {
64
+ description:
65
+ "Disallow constructing an AI provider client in a client component — it leaks the API key into the browser bundle. Call the model from a server route/action.",
66
+ },
67
+ schema: [],
68
+ messages: {
69
+ clientProvider:
70
+ "Do not create an AI provider client in a `'use client'` file — the API key would ship to the browser. Move the call to a server route or server action.",
71
+ },
72
+ },
73
+ defaultOptions: [],
74
+ create(context) {
75
+ if (!hasUseClientDirective(context.sourceCode.ast.body)) {
76
+ return {};
77
+ }
78
+
79
+ return {
80
+ NewExpression(node: TSESTree.NewExpression) {
81
+ if (isProviderConstruction(node)) {
82
+ context.report({ node, messageId: "clientProvider" });
83
+ }
84
+ },
85
+ CallExpression(node: TSESTree.CallExpression) {
86
+ if (isProviderFactory(node)) {
87
+ context.report({ node, messageId: "clientProvider" });
88
+ }
89
+ },
90
+ };
91
+ },
92
+ });
@@ -0,0 +1,91 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "no-user-input-in-system-prompt";
6
+
7
+ type MessageIds = "dynamicSystemPrompt";
8
+
9
+ /** A value built by interpolation/concatenation rather than a constant string —
10
+ * the shape that splices request data into the system prompt (injection). A
11
+ * plain string, identifier, or constant template (no `${}`) is fine. */
12
+ function isDynamicString(node: TSESTree.Node): boolean {
13
+ if (node.type === AST_NODE_TYPES.TemplateLiteral) {
14
+ return node.expressions.length > 0;
15
+ }
16
+
17
+ return node.type === AST_NODE_TYPES.BinaryExpression && node.operator === "+";
18
+ }
19
+
20
+ /** Find a non-computed string-keyed property on an object literal. */
21
+ function findProperty(
22
+ obj: TSESTree.ObjectExpression,
23
+ name: string
24
+ ): TSESTree.Property | null {
25
+ for (const p of obj.properties) {
26
+ if (
27
+ p.type === AST_NODE_TYPES.Property &&
28
+ !p.computed &&
29
+ p.key.type === AST_NODE_TYPES.Identifier &&
30
+ p.key.name === name
31
+ ) {
32
+ return p;
33
+ }
34
+ }
35
+
36
+ return null;
37
+ }
38
+
39
+ /** True when the object is a chat message with `role: "system"`. */
40
+ function isSystemMessage(obj: TSESTree.ObjectExpression): boolean {
41
+ const role = findProperty(obj, "role");
42
+
43
+ return (
44
+ role !== null &&
45
+ role.value.type === AST_NODE_TYPES.Literal &&
46
+ role.value.value === "system"
47
+ );
48
+ }
49
+
50
+ export const noUserInputInSystemPromptRule = createRule<[], MessageIds>({
51
+ name: RULE_NAME,
52
+ meta: {
53
+ type: "suggestion",
54
+ docs: {
55
+ description:
56
+ "Warn when a system prompt is built by string interpolation/concatenation — splicing request data into the system role enables prompt injection. Keep the system prompt constant; pass user input as a user message.",
57
+ },
58
+ schema: [],
59
+ messages: {
60
+ dynamicSystemPrompt:
61
+ "System prompt is built dynamically — do not interpolate request/user data into the system role (prompt injection). Keep it a constant and pass user input as a `user` message.",
62
+ },
63
+ },
64
+ defaultOptions: [],
65
+ create(context) {
66
+ const reportIfDynamic = (value: TSESTree.Node | null): void => {
67
+ if (value !== null && isDynamicString(value)) {
68
+ context.report({ node: value, messageId: "dynamicSystemPrompt" });
69
+ }
70
+ };
71
+
72
+ return {
73
+ // Vercel AI SDK: `{ system: `...${x}...` }`
74
+ "Property[key.name='system']"(node: TSESTree.Property) {
75
+ if (!node.computed) {
76
+ reportIfDynamic(node.value);
77
+ }
78
+ },
79
+ // Chat messages: `{ role: "system", content: `...${x}...` }`
80
+ ObjectExpression(node: TSESTree.ObjectExpression) {
81
+ if (!isSystemMessage(node)) {
82
+ return;
83
+ }
84
+
85
+ const content = findProperty(node, "content");
86
+
87
+ reportIfDynamic(content === null ? null : content.value);
88
+ },
89
+ };
90
+ },
91
+ });
@@ -0,0 +1,112 @@
1
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
2
+
3
+ import { createRule } from "../../create-rule";
4
+
5
+ export const RULE_NAME = "require-completion-token-limit";
6
+
7
+ type MessageIds = "missingLimit";
8
+
9
+ // Vercel AI SDK top-level generators.
10
+ const VERCEL_FNS = new Set([
11
+ "generateText",
12
+ "streamText",
13
+ "generateObject",
14
+ "streamObject",
15
+ ]);
16
+ // Provider-SDK members that own a `.create(...)` completion call.
17
+ const CREATE_OWNERS = new Set(["completions", "messages", "responses"]);
18
+ // Any of these keys bounds the output, across SDKs.
19
+ const TOKEN_KEYS = new Set([
20
+ "maxTokens",
21
+ "max_tokens",
22
+ "maxOutputTokens",
23
+ "max_output_tokens",
24
+ "max_completion_tokens",
25
+ ]);
26
+
27
+ /** The options object literal for a Vercel generator call, or null. */
28
+ function vercelOptionsArg(
29
+ node: TSESTree.CallExpression
30
+ ): TSESTree.ObjectExpression | null {
31
+ if (node.callee.type !== AST_NODE_TYPES.Identifier) {
32
+ return null;
33
+ }
34
+
35
+ if (!VERCEL_FNS.has(node.callee.name)) {
36
+ return null;
37
+ }
38
+
39
+ const arg = node.arguments[0];
40
+
41
+ return arg?.type === AST_NODE_TYPES.ObjectExpression ? arg : null;
42
+ }
43
+
44
+ /** The options object for an `x.<owner>.create({...})` SDK call, or null. */
45
+ function createCallOptionsArg(
46
+ node: TSESTree.CallExpression
47
+ ): TSESTree.ObjectExpression | null {
48
+ const callee = node.callee;
49
+
50
+ if (
51
+ callee.type !== AST_NODE_TYPES.MemberExpression ||
52
+ callee.computed ||
53
+ callee.property.type !== AST_NODE_TYPES.Identifier ||
54
+ callee.property.name !== "create"
55
+ ) {
56
+ return null;
57
+ }
58
+
59
+ const owner = callee.object;
60
+
61
+ if (
62
+ owner.type !== AST_NODE_TYPES.MemberExpression ||
63
+ owner.computed ||
64
+ owner.property.type !== AST_NODE_TYPES.Identifier ||
65
+ !CREATE_OWNERS.has(owner.property.name)
66
+ ) {
67
+ return null;
68
+ }
69
+
70
+ const arg = node.arguments[0];
71
+
72
+ return arg?.type === AST_NODE_TYPES.ObjectExpression ? arg : null;
73
+ }
74
+
75
+ /** True when the object literal sets one of the recognized token-limit keys. */
76
+ function hasTokenLimit(obj: TSESTree.ObjectExpression): boolean {
77
+ return obj.properties.some(
78
+ (p) =>
79
+ p.type === AST_NODE_TYPES.Property &&
80
+ !p.computed &&
81
+ p.key.type === AST_NODE_TYPES.Identifier &&
82
+ TOKEN_KEYS.has(p.key.name)
83
+ );
84
+ }
85
+
86
+ export const requireCompletionTokenLimitRule = createRule<[], MessageIds>({
87
+ name: RULE_NAME,
88
+ meta: {
89
+ type: "problem",
90
+ docs: {
91
+ description:
92
+ "Require a token limit (maxTokens / max_tokens) on AI completion calls to bound runaway cost and latency.",
93
+ },
94
+ schema: [],
95
+ messages: {
96
+ missingLimit:
97
+ "AI completion call has no token limit — set `maxTokens` (Vercel AI SDK) or `max_tokens` (OpenAI/Anthropic) to bound cost and latency.",
98
+ },
99
+ },
100
+ defaultOptions: [],
101
+ create(context) {
102
+ return {
103
+ CallExpression(node: TSESTree.CallExpression) {
104
+ const options = vercelOptionsArg(node) ?? createCallOptionsArg(node);
105
+
106
+ if (options !== null && !hasTokenLimit(options)) {
107
+ context.report({ node, messageId: "missingLimit" });
108
+ }
109
+ },
110
+ };
111
+ },
112
+ });
@@ -1,6 +1,7 @@
1
1
  import type { TSESLint } from "@typescript-eslint/utils";
2
2
 
3
3
  import type { IRulePack } from "./rule-packs.types";
4
+ import { aiSdkPack } from "./ai-sdk";
4
5
  import { authorizationPack } from "./authorization";
5
6
  import { bullmqPack } from "./bullmq";
6
7
  import { commentHygienePack } from "./comment-hygiene";
@@ -25,6 +26,7 @@ import { PACK_REGISTRY } from "../stack-detection";
25
26
 
26
27
  /** Registry of all available rule packs, keyed by pack ID. */
27
28
  export const RULE_PACKS = {
29
+ "ai-sdk": aiSdkPack,
28
30
  authorization: authorizationPack,
29
31
  bullmq: bullmqPack,
30
32
  "code-flow": codeFlowPack,
@@ -222,6 +222,25 @@ export const PACK_REGISTRY = {
222
222
  appliesWhen: { anyDeps: ["i18next", "react-i18next"] },
223
223
  guidance: "Keep i18n keys organized and validated.",
224
224
  } as const satisfies IRulePackDescriptor,
225
+
226
+ "ai-sdk": {
227
+ id: "ai-sdk",
228
+ label: "AI SDK Security",
229
+ description:
230
+ "LLM/AI-SDK security and cost guardrails: no provider key in client bundles, bounded completion tokens, no request data in the system prompt",
231
+ category: "library",
232
+ appliesWhen: {
233
+ anyDeps: [
234
+ "ai",
235
+ "openai",
236
+ "@anthropic-ai/sdk",
237
+ "@ai-sdk/openai",
238
+ "@ai-sdk/anthropic",
239
+ ],
240
+ },
241
+ guidance:
242
+ "Call models server-side, bound output tokens, and keep the system prompt constant.",
243
+ } as const satisfies IRulePackDescriptor,
225
244
  } as const;
226
245
 
227
246
  /** Ordered list of always-on pack IDs (for deterministic ordering). */
@@ -13,6 +13,7 @@
13
13
  // map of bare rule names to "error" | "warn" | "off").
14
14
  import tseslint from "typescript-eslint";
15
15
  import stylistic from "@stylistic/eslint-plugin";
16
+ import sonarjs from "eslint-plugin-sonarjs";
16
17
 
17
18
  // Load stack-aware packs if TSFORGE_PACKS env var is set
18
19
  let packConfig = [];
@@ -56,8 +57,19 @@ export default tseslint.config(
56
57
  plugins: {
57
58
  "@typescript-eslint": tseslint.plugin,
58
59
  "@stylistic": stylistic,
60
+ sonarjs,
59
61
  },
60
62
  rules: {
63
+ // Concern-mixing / copy-paste ceiling (syntactic — no type info needed).
64
+ // cc <= 20 is the house rule tsforge holds ITSELF to (eslint.config.js); it
65
+ // forces the model to decompose a sprawling function into named helpers
66
+ // instead of one un-reviewable block. Not auto-fixable, so it surfaces as a
67
+ // hand-fix error — the intended "split this up" signal. max-depth/max-params
68
+ // are zero-dep ESLint-core complements.
69
+ "sonarjs/cognitive-complexity": ["error", 20],
70
+ "sonarjs/no-identical-functions": "error",
71
+ "max-depth": ["error", 4],
72
+ "max-params": ["error", 4],
61
73
  // The idioms the model habitually violates — all caught WITHOUT type info.
62
74
  "@typescript-eslint/consistent-type-assertions": [
63
75
  "error",
@@ -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
  );
@@ -13,6 +13,7 @@ import stylistic from "@stylistic/eslint-plugin";
13
13
  import pluginReact from "eslint-plugin-react";
14
14
  import pluginReactHooks from "eslint-plugin-react-hooks";
15
15
  import pluginJsxA11y from "eslint-plugin-jsx-a11y";
16
+ import sonarjs from "eslint-plugin-sonarjs";
16
17
 
17
18
  // Load stack-aware packs if TSFORGE_PACKS env var is set
18
19
  let packConfig = [];
@@ -112,6 +113,7 @@ export default tseslint.config(
112
113
  "@stylistic": stylistic,
113
114
  react: pluginReact,
114
115
  "react-hooks": pluginReactHooks,
116
+ sonarjs,
115
117
  boringstack: { rules: { "one-component-per-file": oneComponentPerFile } },
116
118
  ...packConfig
117
119
  .filter(
@@ -125,6 +127,13 @@ export default tseslint.config(
125
127
  // literal/tuple data (and it makes a fixed array a tuple, so literal-index
126
128
  // access is defined, not `T | undefined`). Instead we ban only the
127
129
  // value-changing forms (`x as Foo`, `<Foo>x`) via AST selectors below.
130
+ // Concern-mixing / copy-paste ceiling (syntactic — mirrors the core config).
131
+ // cc <= 20 forces decomposition into named helpers; max-depth/max-params are
132
+ // zero-dep ESLint-core complements.
133
+ "sonarjs/cognitive-complexity": ["error", 20],
134
+ "sonarjs/no-identical-functions": "error",
135
+ "max-depth": ["error", 4],
136
+ "max-params": ["error", 4],
128
137
  "@typescript-eslint/no-explicit-any": "error",
129
138
  "@typescript-eslint/no-non-null-assertion": "error",
130
139
  "@typescript-eslint/no-inferrable-types": "error",