@agjs/tsforge 0.1.18 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (115) hide show
  1. package/package.json +4 -1
  2. package/scripts/build-rules-md.ts +78 -21
  3. package/scripts/sweep.ts +25 -20
  4. package/scripts/web-sweep.ts +292 -0
  5. package/src/browser/oracle.ts +29 -1
  6. package/src/cli.ts +9 -3
  7. package/src/config/index.ts +8 -0
  8. package/src/config/profiles.ts +150 -0
  9. package/src/config/tsforge-config.ts +64 -5
  10. package/src/detect-gate.ts +34 -1
  11. package/src/inference/inference.types.ts +8 -0
  12. package/src/inference/request.ts +5 -1
  13. package/src/inference/stream.ts +21 -2
  14. package/src/inference/wire.ts +0 -0
  15. package/src/loop/feedback/meta-rule-docs.ts +48 -0
  16. package/src/loop/feedback/rule-docs.ts +150 -0
  17. package/src/loop/rule-docs.generated.json +131 -1
  18. package/src/loop/run.ts +3 -0
  19. package/src/loop/session.ts +12 -5
  20. package/src/loop/ttsr-defaults.ts +175 -4
  21. package/src/meta-rules/registry.ts +32 -0
  22. package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
  23. package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
  24. package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
  25. package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
  26. package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
  27. package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
  28. package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
  29. package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
  30. package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
  31. package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
  32. package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
  33. package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
  34. package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
  35. package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
  36. package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
  37. package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
  38. package/src/meta-rules/utils/lockfiles.ts +105 -0
  39. package/src/meta-rules/utils/workflow-yaml.ts +86 -0
  40. package/src/rule-packs/authorization/index.ts +26 -0
  41. package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
  42. package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
  43. package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
  44. package/src/rule-packs/authorization/utils.ts +285 -0
  45. package/src/rule-packs/boundary-utils.ts +13 -0
  46. package/src/rule-packs/code-flow/index.ts +4 -1
  47. package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
  48. package/src/rule-packs/drizzle/index.ts +7 -0
  49. package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
  50. package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
  51. package/src/rule-packs/drizzle/utils.ts +133 -1
  52. package/src/rule-packs/fastify/index.ts +38 -0
  53. package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
  54. package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
  55. package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
  56. package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
  57. package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
  58. package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
  59. package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
  60. package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
  61. package/src/rule-packs/index.ts +10 -0
  62. package/src/rule-packs/jwt-cookies/index.ts +10 -0
  63. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
  64. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
  65. package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
  66. package/src/rule-packs/module-boundaries/index.ts +3 -0
  67. package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
  68. package/src/rule-packs/nextjs/index.ts +32 -0
  69. package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
  70. package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
  71. package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
  72. package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
  73. package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
  74. package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
  75. package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
  76. package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
  77. package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
  78. package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
  79. package/src/rule-packs/nextjs/utils.ts +18 -0
  80. package/src/rule-packs/react-component-architecture/index.ts +18 -0
  81. package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
  82. package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
  83. package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
  84. package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
  85. package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
  86. package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
  87. package/src/rule-packs/rule-catalog.types.ts +21 -0
  88. package/src/rule-packs/rule-metadata.ts +163 -0
  89. package/src/rule-packs/runtime-boundaries/index.ts +33 -0
  90. package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
  91. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
  92. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
  93. package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
  94. package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
  95. package/src/rule-packs/security/index.ts +35 -0
  96. package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
  97. package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
  98. package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
  99. package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
  100. package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
  101. package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
  102. package/src/rule-packs/structured-logging/index.ts +6 -0
  103. package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
  104. package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
  105. package/src/rule-packs/test-conventions/index.ts +9 -0
  106. package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
  107. package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
  108. package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
  109. package/src/rule-packs/typescript-core/index.ts +30 -0
  110. package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
  111. package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
  112. package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
  113. package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
  114. package/src/stack-detection/packs.ts +57 -0
  115. package/strict.web.eslint.config.mjs +32 -1
@@ -0,0 +1,150 @@
1
+ import type { ProfileId } from "../rule-packs/rule-catalog.types";
2
+
3
+ export type { ProfileId };
4
+
5
+ export interface IProfileDefinition {
6
+ readonly id: ProfileId;
7
+ readonly label: string;
8
+ readonly description: string;
9
+ /** Extra packs to include beyond stack detection (deduped at resolve time). */
10
+ readonly extraPacks?: readonly string[];
11
+ /** Rule severity overrides keyed by bare rule name. */
12
+ readonly ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>;
13
+ /** Meta-rule ids to elevate to error in this profile. */
14
+ readonly metaRulesAtError?: readonly string[];
15
+ }
16
+
17
+ /** Architecture-tier rules enabled only in the opinionated profile. */
18
+ export const ARCHITECTURE_RULES = [
19
+ "component-folder-structure",
20
+ "no-state-in-component-body",
21
+ "no-inline-jsx-functions",
22
+ "index-must-reexport-default",
23
+ "forwardref-display-name",
24
+ "max-hooks-per-file",
25
+ ] as const;
26
+
27
+ const architectureOffOverrides = Object.fromEntries(
28
+ ARCHITECTURE_RULES.map((rule) => [rule, "off" as const])
29
+ );
30
+
31
+ export const PROFILE_DEFINITIONS: Readonly<
32
+ Record<ProfileId, IProfileDefinition>
33
+ > = {
34
+ recommended: {
35
+ id: "recommended",
36
+ label: "Recommended",
37
+ description:
38
+ "Safety + framework packs from stack detection; architecture opinions off by default.",
39
+ ruleOverrides: {
40
+ ...architectureOffOverrides,
41
+ "prefer-early-return": "warn",
42
+ },
43
+ },
44
+ strict: {
45
+ id: "strict",
46
+ label: "Strict",
47
+ description:
48
+ "Recommended plus CI/supply-chain meta-rules at error and type-aware async rules.",
49
+ extraPacks: ["typescript-core"],
50
+ ruleOverrides: {
51
+ ...architectureOffOverrides,
52
+ "prefer-early-return": "warn",
53
+ },
54
+ metaRulesAtError: [
55
+ "workflow-permissions-explicit",
56
+ "lockfile-required",
57
+ "single-package-manager",
58
+ ],
59
+ },
60
+ security: {
61
+ id: "security",
62
+ label: "Security",
63
+ description:
64
+ "Recommended plus runtime-boundaries and experimental authorization heuristics.",
65
+ extraPacks: ["runtime-boundaries", "authorization"],
66
+ ruleOverrides: architectureOffOverrides,
67
+ },
68
+ frontend: {
69
+ id: "frontend",
70
+ label: "Frontend",
71
+ description: "Recommended with React/Next architecture rules at warn.",
72
+ ruleOverrides: {
73
+ "no-html-img-element": "warn",
74
+ "no-anonymous-useEffect": "warn",
75
+ "no-derived-state-in-effect": "warn",
76
+ ...architectureOffOverrides,
77
+ },
78
+ },
79
+ backend: {
80
+ id: "backend",
81
+ label: "Backend",
82
+ description:
83
+ "Recommended; stack detection adds Fastify/Elysia/Drizzle/BullMQ packs.",
84
+ ruleOverrides: architectureOffOverrides,
85
+ },
86
+ opinionated: {
87
+ id: "opinionated",
88
+ label: "Opinionated",
89
+ description:
90
+ "Full house-style architecture rules including component folder structure.",
91
+ ruleOverrides: {
92
+ "component-folder-structure": "error",
93
+ "no-state-in-component-body": "error",
94
+ "no-inline-jsx-functions": "warn",
95
+ "index-must-reexport-default": "error",
96
+ "forwardref-display-name": "error",
97
+ "max-hooks-per-file": "warn",
98
+ "prefer-early-return": "error",
99
+ },
100
+ },
101
+ };
102
+
103
+ export const DEFAULT_PROFILE: ProfileId = "recommended";
104
+
105
+ export function isProfileId(value: string): value is ProfileId {
106
+ return value in PROFILE_DEFINITIONS;
107
+ }
108
+
109
+ /** Merge profile overrides with user config overrides (user wins). */
110
+ export function resolveProfileRuleOverrides(
111
+ profileId: ProfileId | undefined
112
+ ): Record<string, "error" | "warn" | "off"> {
113
+ const id = profileId ?? DEFAULT_PROFILE;
114
+ const profile = PROFILE_DEFINITIONS[id];
115
+
116
+ return { ...(profile.ruleOverrides ?? {}) };
117
+ }
118
+
119
+ export function resolveProfileExtraPacks(
120
+ profileId: ProfileId | undefined
121
+ ): readonly string[] {
122
+ const id = profileId ?? DEFAULT_PROFILE;
123
+ const profile = PROFILE_DEFINITIONS[id];
124
+
125
+ return profile.extraPacks ?? [];
126
+ }
127
+
128
+ export function resolveProfileMetaRuleOverrides(
129
+ profileId: ProfileId | undefined
130
+ ): Record<string, "error"> {
131
+ const id = profileId ?? DEFAULT_PROFILE;
132
+ const profile = PROFILE_DEFINITIONS[id];
133
+ const result: Record<string, "error"> = {};
134
+
135
+ for (const ruleId of profile.metaRulesAtError ?? []) {
136
+ result[ruleId] = "error";
137
+ }
138
+
139
+ return result;
140
+ }
141
+
142
+ export function mergeRuleOverrides(
143
+ profileId: ProfileId | undefined,
144
+ userOverrides: Readonly<Record<string, "error" | "warn" | "off">>
145
+ ): Record<string, "error" | "warn" | "off"> {
146
+ return {
147
+ ...resolveProfileRuleOverrides(profileId),
148
+ ...userOverrides,
149
+ };
150
+ }
@@ -3,6 +3,14 @@ import { isRecord } from "../lib/guards";
3
3
  import { PACK_REGISTRY } from "../stack-detection";
4
4
  import { parseMcpServers, type IMcpServerConfig } from "../mcp";
5
5
  import { parsePlugins, type IExternalPlugin } from "./external-plugins";
6
+ import {
7
+ DEFAULT_PROFILE,
8
+ isProfileId,
9
+ mergeRuleOverrides,
10
+ resolveProfileExtraPacks,
11
+ resolveProfileMetaRuleOverrides,
12
+ type ProfileId,
13
+ } from "./profiles";
6
14
 
7
15
  /**
8
16
  * User-defined configuration from tsforge.config.json
@@ -10,6 +18,9 @@ import { parsePlugins, type IExternalPlugin } from "./external-plugins";
10
18
  * include/exclude packs, and tune rule severities (eslint packs + meta-rules).
11
19
  */
12
20
  export interface ITsforgeProjectConfig {
21
+ /** Rule profile: recommended (default), strict, security, frontend, backend, opinionated. */
22
+ readonly profile?: ProfileId;
23
+
13
24
  /** Force-enable a stack by name (skip detection heuristics, force-add its packs). */
14
25
  readonly stack?: string;
15
26
 
@@ -100,6 +111,31 @@ function warnUnknownPackInInclude(packId: string): void {
100
111
  warnConfig(msg);
101
112
  }
102
113
 
114
+ function warnInvalidProfile(profileValue: unknown): void {
115
+ warnConfig(
116
+ `tsforge.config.json: "profile" must be one of recommended, strict, security, frontend, backend, opinionated — got "${String(profileValue)}"`
117
+ );
118
+ }
119
+
120
+ /** Validate and extract profile field. */
121
+ function validateProfile(parsed: unknown): ProfileId | undefined {
122
+ if (typeof parsed !== "string") {
123
+ if (parsed !== undefined) {
124
+ warnInvalidProfile(parsed);
125
+ }
126
+
127
+ return undefined;
128
+ }
129
+
130
+ if (isProfileId(parsed)) {
131
+ return parsed;
132
+ }
133
+
134
+ warnInvalidProfile(parsed);
135
+
136
+ return undefined;
137
+ }
138
+
103
139
  /** Validate and extract stack field. */
104
140
  function validateStack(parsed: unknown): string | undefined {
105
141
  if (typeof parsed === "string") {
@@ -181,6 +217,7 @@ function buildConfigFields(
181
217
  parsed: Record<string, unknown>
182
218
  ): ITsforgeProjectConfig {
183
219
  const configFields: {
220
+ profile?: ProfileId;
184
221
  stack?: string;
185
222
  packs?: { include?: readonly string[]; exclude?: readonly string[] };
186
223
  rules?: Record<string, "error" | "warn" | "off">;
@@ -188,6 +225,14 @@ function buildConfigFields(
188
225
  plugins?: readonly IExternalPlugin[];
189
226
  } = {};
190
227
 
228
+ if (parsed.profile !== undefined) {
229
+ const profile = validateProfile(parsed.profile);
230
+
231
+ if (profile !== undefined) {
232
+ configFields.profile = profile;
233
+ }
234
+ }
235
+
191
236
  if (parsed.stack !== undefined) {
192
237
  const stack = validateStack(parsed.stack);
193
238
 
@@ -290,6 +335,13 @@ export function resolveActivePacks(
290
335
  packs.add(config.stack);
291
336
  }
292
337
 
338
+ // Profile extra packs (runtime-boundaries, authorization, typescript-core, …)
339
+ for (const packId of resolveProfileExtraPacks(config.profile)) {
340
+ if (packId in PACK_REGISTRY) {
341
+ packs.add(packId);
342
+ }
343
+ }
344
+
293
345
  // Include: add packs (unknown ids are kept out of the registry lookup warning only)
294
346
  for (const packId of config.packs?.include ?? []) {
295
347
  if (packId.length === 0) {
@@ -324,11 +376,9 @@ function isSeverityOverride(value: unknown): value is "error" | "warn" | "off" {
324
376
  export function normalizeRuleOverrides(
325
377
  config: ITsforgeProjectConfig
326
378
  ): Record<string, "error" | "warn" | "off"> {
327
- const normalized: Record<string, "error" | "warn" | "off"> = {};
379
+ const userOverrides: Record<string, "error" | "warn" | "off"> = {};
328
380
 
329
381
  for (const [key, severity] of Object.entries(config.rules ?? {})) {
330
- // Runtime data can violate the declared union (hand-built configs in tests,
331
- // partially validated JSON) — re-check before trusting it.
332
382
  if (!isSeverityOverride(severity)) {
333
383
  continue;
334
384
  }
@@ -336,9 +386,18 @@ export function normalizeRuleOverrides(
336
386
  const bareKey = key.startsWith("tsforge/") ? key.slice(8) : key;
337
387
 
338
388
  if (bareKey.length > 0) {
339
- normalized[bareKey] = severity;
389
+ userOverrides[bareKey] = severity;
340
390
  }
341
391
  }
342
392
 
343
- return normalized;
393
+ return {
394
+ ...resolveProfileMetaRuleOverrides(config.profile),
395
+ ...mergeRuleOverrides(config.profile, userOverrides),
396
+ };
397
+ }
398
+
399
+ export function resolveProjectProfile(
400
+ config: ITsforgeProjectConfig
401
+ ): ProfileId {
402
+ return config.profile ?? DEFAULT_PROFILE;
344
403
  }
@@ -63,6 +63,11 @@ const ESLINT_BIN = resolveToolBin("eslint");
63
63
  const TSC_BIN = resolveToolBin("tsc");
64
64
  const PRETTIER_BIN = resolveToolBin("prettier");
65
65
  const STRICT_CONFIG = join(import.meta.dir, "..", "strict.eslint.config.mjs");
66
+ const TYPE_AWARE_CONFIG = join(
67
+ import.meta.dir,
68
+ "..",
69
+ "strict.type-aware.eslint.config.mjs"
70
+ );
66
71
  const BROWSER_CHECK = join(
67
72
  import.meta.dir,
68
73
  "..",
@@ -86,6 +91,8 @@ const STRICT_TSCONFIG = `{
86
91
  "noUncheckedIndexedAccess": true,
87
92
  "noImplicitOverride": true,
88
93
  "noFallthroughCasesInSwitch": true,
94
+ "useUnknownInCatchVariables": true,
95
+ "erasableSyntaxOnly": true,
89
96
  "esModuleInterop": true,
90
97
  "forceConsistentCasingInFileNames": true,
91
98
  "skipLibCheck": true,
@@ -108,6 +115,8 @@ const STRICT_TSCONFIG_OVERRIDE = `{
108
115
  "noUncheckedIndexedAccess": true,
109
116
  "noImplicitOverride": true,
110
117
  "noFallthroughCasesInSwitch": true,
118
+ "useUnknownInCatchVariables": true,
119
+ "erasableSyntaxOnly": true,
111
120
  "skipLibCheck": true,
112
121
  "noEmit": true
113
122
  }
@@ -458,7 +467,8 @@ export function prettierWriteCommand(): string {
458
467
  export async function buildGate(
459
468
  cwd: string,
460
469
  packs?: readonly string[],
461
- ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>
470
+ ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>,
471
+ options?: { enableTypeAware?: boolean }
462
472
  ): Promise<IGate> {
463
473
  const parts: string[] = [];
464
474
  const labels: string[] = [];
@@ -475,6 +485,15 @@ export async function buildGate(
475
485
  parts.push(lint.command);
476
486
  labels.push(lint.label);
477
487
 
488
+ if (options?.enableTypeAware === true) {
489
+ const typeAware = await typeAwareLintPart(cwd);
490
+
491
+ if (typeAware !== null) {
492
+ parts.push(typeAware.command);
493
+ labels.push(typeAware.label);
494
+ }
495
+ }
496
+
478
497
  return { command: parts.join(" && "), label: labels.join(" + ") };
479
498
  }
480
499
 
@@ -538,3 +557,17 @@ function lintPart(
538
557
  label: "strict TypeScript (tsforge)",
539
558
  };
540
559
  }
560
+
561
+ /** Optional type-aware async rules — only when target has tsconfig.json. */
562
+ async function typeAwareLintPart(cwd: string): Promise<IGate | null> {
563
+ const hasTsconfig = await Bun.file(join(cwd, "tsconfig.json")).exists();
564
+
565
+ if (!hasTsconfig) {
566
+ return null;
567
+ }
568
+
569
+ return {
570
+ command: `bun "${ESLINT_BIN}" --no-config-lookup -c "${TYPE_AWARE_CONFIG}" --format json .`,
571
+ label: "type-aware async (tsforge)",
572
+ };
573
+ }
@@ -8,6 +8,10 @@ export interface IChatMessage {
8
8
  toolCalls?: IToolCall[];
9
9
  /** Tool messages only: the id of the call this message is the result of. */
10
10
  toolCallId?: string;
11
+ /** Assistant only: the model's chain-of-thought. DeepSeek's thinking mode
12
+ * REQUIRES the prior turn's `reasoning_content` to be replayed, so it's kept
13
+ * on the message and re-sent (for the deepseek reasoning style). */
14
+ reasoningContent?: string;
11
15
  }
12
16
 
13
17
  /** A parsed tool call from the model (name + decoded JSON arguments). */
@@ -29,6 +33,10 @@ export interface ITokenUsage {
29
33
  export interface IModelResponse {
30
34
  content: string;
31
35
  toolCalls: IToolCall[];
36
+ /** The model's chain-of-thought (`reasoning`/`reasoning_content`), when it
37
+ * produced any. Stored on the assistant message for providers (DeepSeek) that
38
+ * require it replayed on the next turn. */
39
+ reasoning?: string;
32
40
  /** Server-reported token usage for this call, when available. `promptTokens`
33
41
  * is the full context the model just saw — what auto-compaction will watch. */
34
42
  usage?: ITokenUsage;
@@ -99,9 +99,13 @@ export function buildRequestBody(
99
99
  const omitTemperature =
100
100
  style(cfg) === "openai" || opts.temperature === undefined;
101
101
 
102
+ // DeepSeek's thinking mode requires each prior assistant turn's
103
+ // `reasoning_content` replayed; other providers don't want it.
104
+ const includeReasoning = style(cfg) === "deepseek";
105
+
102
106
  return {
103
107
  model: cfg.model,
104
- messages: messages.map(toWire),
108
+ messages: messages.map((m) => toWire(m, includeReasoning)),
105
109
  ...tokenCapField(cfg),
106
110
  ...(omitTemperature ? {} : { temperature: opts.temperature }),
107
111
  ...(cfg.repetitionPenalty === undefined
@@ -35,6 +35,7 @@ export async function streamResponse(
35
35
  calls: new Map(),
36
36
  guard: new StreamGuard(),
37
37
  content: "",
38
+ reasoning: "",
38
39
  ttsr: ttsrManager,
39
40
  ttsrFired: null,
40
41
  };
@@ -88,6 +89,7 @@ interface IStreamAcc {
88
89
  calls: Map<number, IStreamingCall>;
89
90
  guard: StreamGuard;
90
91
  content: string;
92
+ reasoning: string;
91
93
  usage?: ITokenUsage;
92
94
  ttsr?: ITtsrWatcher;
93
95
  ttsrFired: { readonly name: string; readonly guidance: string } | null;
@@ -137,6 +139,7 @@ function consumeLines(
137
139
  // less, not by hiding it from the log.)
138
140
  if (delta.reasoning !== undefined && delta.reasoning.length > 0) {
139
141
  onToken(delta.reasoning, "reasoning");
142
+ acc.reasoning += delta.reasoning;
140
143
 
141
144
  if (acc.guard.observe(delta.reasoning, "reasoning")) {
142
145
  return true;
@@ -163,6 +166,8 @@ function consumeLines(
163
166
 
164
167
  function assemble(acc: IStreamAcc, degenerated: boolean): IModelResponse {
165
168
  const usage = acc.usage === undefined ? {} : { usage: acc.usage };
169
+ const reasoning =
170
+ acc.reasoning.length > 0 ? { reasoning: acc.reasoning } : {};
166
171
  const toolCalls: IToolCall[] = [...acc.calls.values()].map((c) => ({
167
172
  id: c.id,
168
173
  name: c.name,
@@ -181,8 +186,21 @@ function assemble(acc: IStreamAcc, degenerated: boolean): IModelResponse {
181
186
 
182
187
  if (toolCalls.length > 0) {
183
188
  return degenerated
184
- ? { content: acc.content, toolCalls, degenerated, ...ttsrFired, ...usage }
185
- : { content: acc.content, toolCalls, ...ttsrFired, ...usage };
189
+ ? {
190
+ content: acc.content,
191
+ toolCalls,
192
+ degenerated,
193
+ ...reasoning,
194
+ ...ttsrFired,
195
+ ...usage,
196
+ }
197
+ : {
198
+ content: acc.content,
199
+ toolCalls,
200
+ ...reasoning,
201
+ ...ttsrFired,
202
+ ...usage,
203
+ };
186
204
  }
187
205
 
188
206
  const salvaged = salvageToolCalls(acc.content);
@@ -192,6 +210,7 @@ function assemble(acc: IStreamAcc, degenerated: boolean): IModelResponse {
192
210
  toolCalls: salvaged,
193
211
  salvaged: salvaged.length,
194
212
  ...(degenerated ? { degenerated } : {}),
213
+ ...reasoning,
195
214
  ...ttsrFired,
196
215
  ...usage,
197
216
  };
Binary file
@@ -11,6 +11,30 @@ export const META_RULE_DOCS: Record<string, string> = {
11
11
  "no-overlapping-libs":
12
12
  "Remove duplicate or conflicting library versions from the dependency tree; only one canonical version per library is allowed.",
13
13
 
14
+ "fastify-security-plugins":
15
+ "Add @fastify/helmet, @fastify/cors, and @fastify/rate-limit when using fastify in production.",
16
+
17
+ "lockfile-required":
18
+ "Commit the lockfile for your package manager (package-lock.json, yarn.lock, pnpm-lock.yaml, or bun.lockb) and keep it in sync with package.json.",
19
+
20
+ "single-package-manager":
21
+ "Remove extra lockfiles — use one package manager and delete lockfiles from other tools.",
22
+
23
+ "package-manager-field-required":
24
+ 'Add a "packageManager" field to package.json (e.g. "bun@1.3.14") so installs are reproducible across environments.',
25
+
26
+ "no-git-or-tarball-dependencies":
27
+ "Replace git+, git:, or HTTP tarball dependency URLs with registry versions from npm.",
28
+
29
+ "dependency-overrides-require-comment":
30
+ "Add a comment next to overrides/resolutions in package.json explaining why each override is needed.",
31
+
32
+ "production-must-not-use-drizzle-push":
33
+ "Replace `drizzle-kit push` in scripts and CI with checked-in SQL migrations and `drizzle-kit migrate`.",
34
+
35
+ "migrations-must-be-checked-in":
36
+ "Add a drizzle/ or migrations/ folder with generated SQL migration files when using Drizzle ORM.",
37
+
14
38
  // Source text
15
39
  "no-eslint-disable-comments":
16
40
  "Remove `// eslint-disable` comments — they hide warnings. Fix the underlying violation or refactor the code.",
@@ -25,6 +49,18 @@ export const META_RULE_DOCS: Record<string, string> = {
25
49
  "tsconfig-strict":
26
50
  "Enable all strict mode flags in tsconfig.json (strict: true or all strict flags individually).",
27
51
 
52
+ "tsconfig-recommended-flags":
53
+ "Enable useUnknownInCatchVariables, erasableSyntaxOnly, exactOptionalPropertyTypes, verbatimModuleSyntax, and noPropertyAccessFromIndexSignature in tsconfig.json compilerOptions.",
54
+
55
+ "next-proxy-over-middleware":
56
+ "Migrate middleware.ts to proxy.ts for Next.js 16 early request interception.",
57
+
58
+ "next-instrumentation-present":
59
+ "Add instrumentation.ts with registerOTel for OpenTelemetry tracing in Next.js apps.",
60
+
61
+ "next-image-remote-patterns-no-wildcards":
62
+ "Remove `**` hostname wildcards from next.config remotePatterns — allowlist specific image hostnames.",
63
+
28
64
  // Testing
29
65
  "test-sibling-required":
30
66
  "Add a test file for each source file; follow naming conventions (foo.ts → foo.test.ts or foo.spec.ts).",
@@ -38,4 +74,16 @@ export const META_RULE_DOCS: Record<string, string> = {
38
74
 
39
75
  "workflow-timeout-required":
40
76
  "Add a timeout-minutes setting to each GitHub Actions job to prevent hanging workflows.",
77
+
78
+ "workflow-permissions-explicit":
79
+ "Add a top-level permissions: block or job-level permissions to every GitHub Actions workflow.",
80
+
81
+ "workflow-permissions-least-privilege":
82
+ "Avoid workflow-level contents: write or id-token: write — scope write permissions to the job that needs them.",
83
+
84
+ "no-pull-request-target-untrusted-checkout":
85
+ "Do not combine pull_request_target with checkout of the PR head ref — use pull_request or checkout the base ref.",
86
+
87
+ "no-github-context-in-shell":
88
+ "Pass github.event values through env: instead of interpolating them directly in run: shell scripts.",
41
89
  };
@@ -154,6 +154,156 @@ const RULE_DOCS: Record<string, IRuleDoc> = {
154
154
  bad: "<button onClick={() => doThing(id)} />",
155
155
  good: "const onClickRow = useCallback(() => doThing(id), [id]); <button onClick={onClickRow} />",
156
156
  },
157
+ "tsforge/no-throw-literal": {
158
+ what: "Throw `Error` instances, not string or number literals.",
159
+ bad: "throw 'Unauthorized';",
160
+ good: "throw new Error('Unauthorized');",
161
+ },
162
+ "tsforge/no-react-fc": {
163
+ what: "Do not use React.FC — type props on the function parameter.",
164
+ bad: "const Button: React.FC<IButtonProps> = ({ onClick }) => <button onClick={onClick} />;",
165
+ good: "function Button({ onClick }: IButtonProps) { return <button onClick={onClick} />; }",
166
+ },
167
+ "tsforge/no-component-invocation": {
168
+ what: "Render components as JSX, not function calls.",
169
+ bad: "<div>{Header()}</div>",
170
+ good: "<div><Header /></div>",
171
+ },
172
+ "tsforge/no-nested-component": {
173
+ what: "Declare components at module scope, not inside another component.",
174
+ bad: "function App() { function Inner() { return <span />; } return <Inner />; }",
175
+ good: "function Inner() { return <span />; } function App() { return <Inner />; }",
176
+ },
177
+ "tsforge/dangerous-html-requires-sanitize": {
178
+ what: "Sanitize HTML before dangerouslySetInnerHTML — import DOMPurify.",
179
+ bad: "<div dangerouslySetInnerHTML={{ __html: rawHtml }} />",
180
+ good: "import DOMPurify from 'isomorphic-dompurify'; <div dangerouslySetInnerHTML={{ __html: DOMPurify.sanitize(rawHtml) }} />",
181
+ },
182
+ "tsforge/no-child-process-exec": {
183
+ what: "Do not use child_process.exec/execSync — shell execution enables command injection.",
184
+ bad: "import { exec } from 'child_process'; exec(`rm -rf ${dir}`);",
185
+ good: "import { execFile } from 'child_process'; execFile('rm', ['-rf', dir], callback);",
186
+ },
187
+ "tsforge/no-spawn-with-shell": {
188
+ what: "Do not pass `{ shell: true }` to spawn/spawnSync.",
189
+ bad: "spawn('sh', ['-c', cmd], { shell: true });",
190
+ good: "spawn('node', ['script.js', arg]);",
191
+ },
192
+ "tsforge/no-dynamic-regexp": {
193
+ what: "Do not build RegExp from runtime input — ReDoS risk.",
194
+ bad: "const re = new RegExp(userPattern);",
195
+ good: "const re = /^fixed-pattern$/;",
196
+ },
197
+ "tsforge/no-inner-html-assignment": {
198
+ what: "Do not assign to innerHTML — XSS risk in vanilla DOM code.",
199
+ bad: "el.innerHTML = userHtml;",
200
+ good: "el.textContent = userText;",
201
+ },
202
+ "tsforge/catch-must-handle": {
203
+ what: "Catch blocks must log, rethrow, or return a typed error — not silently mask failure.",
204
+ bad: "catch (e) { return null; }",
205
+ good: "catch (e) { logger.error(e); throw e; }",
206
+ },
207
+ "tsforge/no-react-in-services": {
208
+ what: "Service/data modules must not import React — keep business logic decoupled from UI.",
209
+ bad: "import { useMemo } from 'react'; // in src/services/users.ts",
210
+ good: "Move React hooks to components; keep services as plain TypeScript.",
211
+ },
212
+ "tsforge/no-anonymous-useEffect": {
213
+ what: "Pass a named function to useEffect for debuggable stack traces.",
214
+ bad: "useEffect(() => { sync(); }, [id]);",
215
+ good: "useEffect(function syncOnIdChange() { sync(); }, [id]);",
216
+ },
217
+ "tsforge/no-derived-state-in-effect": {
218
+ what: "Do not set local state inside useEffect when the value can be derived during render.",
219
+ bad: "useEffect(() => { setFullName(first + ' ' + last); }, [first, last]);",
220
+ good: "const fullName = `${first} ${last}`;",
221
+ },
222
+ "tsforge/no-internal-api-fetch": {
223
+ what: "Server Components must not fetch the app's own /api routes.",
224
+ bad: "await fetch('/api/users');",
225
+ good: "import { listUsers } from '@/services/users'; const users = await listUsers();",
226
+ },
227
+ "tsforge/await-dynamic-request-apis": {
228
+ what: "Await Next.js dynamic request APIs in Server Components.",
229
+ bad: "const jar = cookies();",
230
+ good: "const jar = await cookies();",
231
+ },
232
+ "tsforge/error-boundary-require-use-client": {
233
+ what: "error.tsx and global-error.tsx must be Client Components.",
234
+ bad: "export default function Error() { return <div />; }",
235
+ good: "'use client'; export default function Error() { return <div />; }",
236
+ },
237
+ "tsforge/no-html-img-element": {
238
+ what: "Prefer next/image over raw img elements.",
239
+ bad: "<img src='/hero.jpg' alt='hero' />",
240
+ good: "import Image from 'next/image'; <Image src='/hero.jpg' alt='hero' width={800} height={400} />",
241
+ },
242
+ "tsforge/no-sensitive-next-public-env": {
243
+ what: "NEXT_PUBLIC_* vars are exposed in the client bundle — never use for secrets.",
244
+ bad: "process.env.NEXT_PUBLIC_STRIPE_SECRET",
245
+ good: "process.env.STRIPE_SECRET_KEY // server-only, no NEXT_PUBLIC prefix",
246
+ },
247
+ "tsforge/prefer-lazy-use-state-init": {
248
+ what: "Use lazy useState when parsing localStorage on mount.",
249
+ bad: "useState(JSON.parse(localStorage.getItem('cfg') ?? '{}'))",
250
+ good: "useState(() => JSON.parse(localStorage.getItem('cfg') ?? '{}'))",
251
+ },
252
+ "tsforge/no-auth-token-in-storage": {
253
+ what: "Never store auth tokens in localStorage/sessionStorage.",
254
+ bad: "localStorage.setItem('auth_token', token);",
255
+ good: "Set an httpOnly session cookie on the server instead.",
256
+ },
257
+ "tsforge/fetch-must-check-ok": {
258
+ what: "Check response.ok before calling .json() on fetch results.",
259
+ bad: "const data = await fetch(url).then(r => r.json());",
260
+ good: "const res = await fetch(url); if (!res.ok) { throw new Error('fetch failed'); } const data = await res.json();",
261
+ },
262
+ "tsforge/json-parse-must-validate": {
263
+ what: "Parse external JSON through a schema library, not bare JSON.parse.",
264
+ bad: "const body = JSON.parse(raw);",
265
+ good: "const body = UserSchema.parse(JSON.parse(raw));",
266
+ },
267
+ "tsforge/no-unsafe-boundary-cast": {
268
+ what: "Do not cast untrusted parsed input with `as` — validate at the boundary.",
269
+ bad: "const user = (await req.json()) as IUser;",
270
+ good: "const user = UserSchema.parse(await req.json());",
271
+ },
272
+ "tsforge/no-user-controlled-redirect": {
273
+ what: "Redirect URLs must be string literals or allowlisted helpers — not user input.",
274
+ bad: "redirect(searchParams.get('next')!);",
275
+ good: "redirect('/dashboard');",
276
+ },
277
+ "tsforge/no-user-controlled-fetch-url": {
278
+ what: "fetch/axios URLs must be literals or pass through an allowlisted URL builder.",
279
+ bad: "await fetch(userSuppliedUrl);",
280
+ good: "await fetch('https://api.example.com/v1/status');",
281
+ },
282
+ "tsforge/no-prototype-polluting-merge": {
283
+ what: "Do not merge request body/query/params into objects wholesale.",
284
+ bad: "Object.assign(config, req.body);",
285
+ good: "const name = UserSchema.parse(req.body).name; config.name = name;",
286
+ },
287
+ "tsforge/server-only-modules-import-server-only": {
288
+ what: "Server-only modules importing DB/env must include `import 'server-only'`.",
289
+ bad: "import { db } from '@/lib/db'; // in lib/admin.ts",
290
+ good: "import 'server-only'; import { db } from '@/lib/db';",
291
+ },
292
+ "tsforge/server-action-requires-authz-and-validation": {
293
+ what: "Server actions must validate input and call authz before mutations.",
294
+ bad: "'use server'; export async function deleteUser(id: string) { await db.delete(users).where(eq(users.id, id)); }",
295
+ good: "'use server'; export async function deleteUser(raw: unknown) { const user = await requireUser(); const { id } = IdSchema.parse(raw); await authorize(user, id); ... }",
296
+ },
297
+ "tsforge/require-route-schema": {
298
+ what: "Fastify routes need a schema object with input validation.",
299
+ bad: "fastify.post('/users', async () => ({ ok: true }));",
300
+ good: "fastify.post('/users', { schema: { body: UserSchema } }, async () => ({ ok: true }));",
301
+ },
302
+ "tsforge/require-plugin-name": {
303
+ what: "fastify-plugin wrappers need a name option.",
304
+ bad: "export default fp(dbPlugin);",
305
+ good: "export default fp(dbPlugin, { name: 'db-connector', fastify: '5.x' });",
306
+ },
157
307
  };
158
308
 
159
309
  /**