@agjs/tsforge 0.1.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 (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,173 @@
1
+ import { readFileSync, existsSync } from "node:fs";
2
+ import { isAbsolute, resolve } from "node:path";
3
+
4
+ import { AST_NODE_TYPES, type TSESTree } from "@typescript-eslint/utils";
5
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
6
+
7
+ import { createRule } from "../../create-rule";
8
+
9
+ type MessageIds = "missingKey" | "dictionaryReadFailed";
10
+
11
+ export interface StaticTranslationKeyExistsOptions {
12
+ readonly dictionary: string;
13
+ }
14
+
15
+ type RuleOptions = [StaticTranslationKeyExistsOptions];
16
+
17
+ const optionSchema: JSONSchema4 = {
18
+ type: "object",
19
+ additionalProperties: false,
20
+ required: ["dictionary"],
21
+ properties: {
22
+ dictionary: { type: "string", minLength: 1 },
23
+ },
24
+ };
25
+
26
+ function collectLeafKeys(
27
+ value: unknown,
28
+ prefix: string,
29
+ out: Set<string>
30
+ ): void {
31
+ if (typeof value === "string") {
32
+ if (prefix !== "") {
33
+ out.add(prefix);
34
+ }
35
+
36
+ return;
37
+ }
38
+
39
+ if (value === null || typeof value !== "object" || Array.isArray(value)) {
40
+ return;
41
+ }
42
+
43
+ for (const [k, v] of Object.entries(value)) {
44
+ const next = prefix === "" ? k : `${prefix}.${k}`;
45
+
46
+ collectLeafKeys(v, next, out);
47
+ }
48
+ }
49
+
50
+ function loadDictionary(pathFromRoot: string, cwd: string): Set<string> {
51
+ const abs = isAbsolute(pathFromRoot)
52
+ ? pathFromRoot
53
+ : resolve(cwd, pathFromRoot);
54
+
55
+ if (!existsSync(abs)) {
56
+ throw new Error(`eslint-plugin-i18n-keys: dictionary not found: ${abs}`);
57
+ }
58
+
59
+ const raw = readFileSync(abs, "utf8");
60
+ const parsed: unknown = JSON.parse(raw);
61
+ const keys = new Set<string>();
62
+
63
+ if (parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)) {
64
+ for (const [k, v] of Object.entries(parsed)) {
65
+ collectLeafKeys(v, k, keys);
66
+ }
67
+ }
68
+
69
+ return keys;
70
+ }
71
+
72
+ function getStringLiteral(
73
+ node: TSESTree.CallExpressionArgument | undefined
74
+ ): string | null {
75
+ if (node === undefined) {
76
+ return null;
77
+ }
78
+
79
+ if (node.type === AST_NODE_TYPES.Literal && typeof node.value === "string") {
80
+ return node.value;
81
+ }
82
+
83
+ return null;
84
+ }
85
+
86
+ function isTranslationCall(node: TSESTree.CallExpression): boolean {
87
+ const { callee } = node;
88
+
89
+ if (callee.type === AST_NODE_TYPES.Identifier && callee.name === "t") {
90
+ return true;
91
+ }
92
+
93
+ if (
94
+ callee.type === AST_NODE_TYPES.MemberExpression &&
95
+ callee.property.type === AST_NODE_TYPES.Identifier &&
96
+ callee.property.name === "t" &&
97
+ callee.object.type === AST_NODE_TYPES.Identifier &&
98
+ callee.object.name === "i18n"
99
+ ) {
100
+ return true;
101
+ }
102
+
103
+ return false;
104
+ }
105
+
106
+ export const staticTranslationKeyExistsRule = createRule<
107
+ RuleOptions,
108
+ MessageIds
109
+ >({
110
+ name: "static-translation-key-exists",
111
+ meta: {
112
+ type: "problem",
113
+ docs: {
114
+ description:
115
+ 'Static string passed to `t("...")` or `i18n.t("...")` must exist as a leaf path in the canonical locale JSON.',
116
+ },
117
+ schema: [optionSchema],
118
+ messages: {
119
+ missingKey:
120
+ 'Translation key "{{key}}" is not defined in {{dictionary}} (static keys only; dynamic templates are not checked).',
121
+ dictionaryReadFailed:
122
+ "Could not read i18n dictionary at {{path}} (cwd: {{cwd}}).",
123
+ },
124
+ },
125
+ defaultOptions: [{ dictionary: "src/lib/i18n/locales/en/common.json" }],
126
+ create(context, [options]) {
127
+ const cwd = context.cwd ?? process.cwd();
128
+ let keys: Set<string> | undefined;
129
+
130
+ try {
131
+ keys = loadDictionary(options.dictionary, cwd);
132
+ } catch {
133
+ keys = undefined;
134
+ }
135
+
136
+ return {
137
+ Program(node: TSESTree.Program): void {
138
+ if (keys === undefined) {
139
+ context.report({
140
+ node,
141
+ messageId: "dictionaryReadFailed",
142
+ data: { path: options.dictionary, cwd },
143
+ });
144
+ }
145
+ },
146
+ CallExpression(node: TSESTree.CallExpression): void {
147
+ if (keys === undefined) {
148
+ return;
149
+ }
150
+
151
+ if (!isTranslationCall(node)) {
152
+ return;
153
+ }
154
+
155
+ const key = getStringLiteral(node.arguments[0]);
156
+
157
+ if (key === null || key === "") {
158
+ return;
159
+ }
160
+
161
+ if (keys.has(key)) {
162
+ return;
163
+ }
164
+
165
+ context.report({
166
+ node: node.arguments[0] ?? node,
167
+ messageId: "missingKey",
168
+ data: { key, dictionary: options.dictionary },
169
+ });
170
+ },
171
+ };
172
+ },
173
+ });
@@ -0,0 +1,139 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { bullmqPack } from "./bullmq";
4
+ import { commentHygienePack } from "./comment-hygiene";
5
+ import { codeFlowPack } from "./code-flow";
6
+ import { drizzlePack } from "./drizzle";
7
+ import { elysiaPack } from "./elysia";
8
+ import { envAccessPack } from "./env-access";
9
+ import { i18nKeysPack } from "./i18n-keys";
10
+ import { jwtCookiesPack } from "./jwt-cookies";
11
+ import { oauthSecurityPack } from "./oauth-security";
12
+ import { reactComponentArchitecturePack } from "./react-component-architecture";
13
+ import { structuredLoggingPack } from "./structured-logging";
14
+ import { tanstackQueryPack } from "./tanstack-query";
15
+ import { testConventionsPack } from "./test-conventions";
16
+ import { PACK_REGISTRY } from "../stack-detection";
17
+
18
+ /** Registry of all available rule packs, keyed by pack ID. */
19
+ export const RULE_PACKS = {
20
+ bullmq: bullmqPack,
21
+ "code-flow": codeFlowPack,
22
+ "comment-hygiene": commentHygienePack,
23
+ drizzle: drizzlePack,
24
+ elysia: elysiaPack,
25
+ "env-access": envAccessPack,
26
+ "i18n-keys": i18nKeysPack,
27
+ "jwt-cookies": jwtCookiesPack,
28
+ "oauth-security": oauthSecurityPack,
29
+ "react-component-architecture": reactComponentArchitecturePack,
30
+ "structured-logging": structuredLoggingPack,
31
+ "tanstack-query": tanstackQueryPack,
32
+ "test-conventions": testConventionsPack,
33
+ } as const;
34
+
35
+ export type IRulePackId = keyof typeof RULE_PACKS;
36
+
37
+ /** Type guard: check if a string is a valid RULE_PACKS key. */
38
+ function isRulePackId(id: unknown): id is IRulePackId {
39
+ return typeof id === "string" && id in RULE_PACKS;
40
+ }
41
+
42
+ /** Apply rule overrides: "off" drops a rule, error/warn replaces its severity. */
43
+ function applyOverrides(
44
+ mergedRulesConfig: Readonly<Record<string, "error" | "warn">>,
45
+ overrides?: Readonly<Record<string, "error" | "warn" | "off">>
46
+ ): Record<string, "error" | "warn"> {
47
+ const result: Record<string, "error" | "warn"> = {};
48
+
49
+ for (const [key, severity] of Object.entries(mergedRulesConfig)) {
50
+ const bareName = key.startsWith("tsforge/") ? key.slice(8) : key;
51
+ const override = overrides?.[bareName];
52
+
53
+ if (override === "off") {
54
+ continue;
55
+ }
56
+
57
+ result[key] = override ?? severity;
58
+ }
59
+
60
+ return result;
61
+ }
62
+
63
+ /**
64
+ * Builds an ESLint plugin and merged config from a selection of rule packs.
65
+ * Pack IDs present in stack-detection's PACK_REGISTRY but absent from RULE_PACKS
66
+ * are silently skipped (they may carry meta-rules later, not eslint rules).
67
+ * Throws only for IDs unknown to both registries.
68
+ *
69
+ * @param packIds Pack IDs to build config from
70
+ * @param overrides Optional rule severity overrides (keyed by bare rule name, not tsforge-prefixed)
71
+ * - "off" removes the rule from the config
72
+ * - "error"/"warn" replaces the severity
73
+ *
74
+ * @throws if any two packs define the same rule name
75
+ * @throws if a pack ID is unknown to both RULE_PACKS and PACK_REGISTRY
76
+ */
77
+ export function buildPackEslintConfig(
78
+ packIds: readonly string[],
79
+ overrides?: Readonly<Record<string, "error" | "warn" | "off">>
80
+ ): {
81
+ plugin: TSESLint.FlatConfig.Plugin;
82
+ rules: Record<string, "error" | "warn">;
83
+ } {
84
+ const mergedRules: Record<
85
+ string,
86
+ TSESLint.RuleModule<string, readonly unknown[]>
87
+ > = {};
88
+ const mergedRulesConfig: Record<string, "error" | "warn"> = {};
89
+ const seenRuleNames = new Set<string>();
90
+
91
+ for (const packId of packIds) {
92
+ const pack = isRulePackId(packId) ? RULE_PACKS[packId] : undefined;
93
+
94
+ // Skip pack IDs known to stack-detection but absent from RULE_PACKS
95
+ if (pack === undefined) {
96
+ const knownInRegistry = packId in PACK_REGISTRY;
97
+
98
+ if (!knownInRegistry) {
99
+ throw new Error(`Unknown rule pack: ${packId}`);
100
+ }
101
+
102
+ // Pack is in registry but not in RULE_PACKS — skip silently
103
+ continue;
104
+ }
105
+
106
+ for (const [ruleName, ruleModule] of Object.entries(pack.rules)) {
107
+ if (seenRuleNames.has(ruleName)) {
108
+ throw new Error(
109
+ `Rule collision: '${ruleName}' defined in multiple packs`
110
+ );
111
+ }
112
+
113
+ seenRuleNames.add(ruleName);
114
+ mergedRules[ruleName] = ruleModule;
115
+ const severity = pack.rulesConfig[ruleName];
116
+
117
+ if (severity !== undefined) {
118
+ mergedRulesConfig[`tsforge/${ruleName}`] = severity;
119
+ }
120
+ }
121
+ }
122
+
123
+ const finalRulesConfig = applyOverrides(mergedRulesConfig, overrides);
124
+
125
+ const plugin: TSESLint.FlatConfig.Plugin = {
126
+ meta: {
127
+ name: "tsforge",
128
+ version: "0.1.0",
129
+ },
130
+ rules: mergedRules,
131
+ };
132
+
133
+ return {
134
+ plugin,
135
+ rules: finalRulesConfig,
136
+ };
137
+ }
138
+
139
+ export type { IRulePack } from "./rule-packs.types";
@@ -0,0 +1,25 @@
1
+ import type { TSESLint } from "@typescript-eslint/utils";
2
+
3
+ import { authCookieMustBeHttpOnlyRule } from "./rules/auth-cookie-must-be-httponly";
4
+ import { authCookieMustBeSecureInProdRule } from "./rules/auth-cookie-must-be-secure-in-prod";
5
+ import { bcryptRoundsMinRule } from "./rules/bcrypt-rounds-min";
6
+ import type { IRulePack } from "../rule-packs.types";
7
+
8
+ const rules: Record<string, TSESLint.RuleModule<string, readonly unknown[]>> = {
9
+ "auth-cookie-must-be-httponly": authCookieMustBeHttpOnlyRule,
10
+ "auth-cookie-must-be-secure-in-prod": authCookieMustBeSecureInProdRule,
11
+ "bcrypt-rounds-min": bcryptRoundsMinRule,
12
+ };
13
+
14
+ export const jwtCookiesPack: IRulePack = {
15
+ id: "jwt-cookies",
16
+ description: "Secure JWT and cookie handling patterns",
17
+ rules,
18
+ rulesConfig: {
19
+ "auth-cookie-must-be-httponly": "error",
20
+ "auth-cookie-must-be-secure-in-prod": "error",
21
+ "bcrypt-rounds-min": "error",
22
+ },
23
+ };
24
+
25
+ export default jwtCookiesPack;
@@ -0,0 +1,150 @@
1
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+
4
+ import { createRule } from "../../create-rule";
5
+ import {
6
+ DEFAULT_AUTH_COOKIE_NAMES,
7
+ DEFAULT_SET_COOKIE_FUNCTIONS,
8
+ DEFAULT_TRUSTED_CONFIG_NAMES,
9
+ lookupCookieOption,
10
+ matchAuthCookieSet,
11
+ } from "../utils";
12
+
13
+ export const RULE_NAME = "auth-cookie-must-be-httponly";
14
+
15
+ export interface AuthCookieMustBeHttpOnlyOptions {
16
+ readonly authCookieNames?: readonly string[];
17
+ readonly trustedConfigNames?: readonly string[];
18
+ readonly setCookieFunctions?: readonly string[];
19
+ }
20
+
21
+ type RuleOptions = [AuthCookieMustBeHttpOnlyOptions];
22
+ type MessageIds = "missingHttpOnly";
23
+
24
+ const optionSchema: JSONSchema4 = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ authCookieNames: {
29
+ type: "array",
30
+ items: { type: "string" },
31
+ uniqueItems: true,
32
+ minItems: 1,
33
+ },
34
+ trustedConfigNames: {
35
+ type: "array",
36
+ items: { type: "string" },
37
+ uniqueItems: true,
38
+ },
39
+ setCookieFunctions: {
40
+ type: "array",
41
+ items: { type: "string" },
42
+ uniqueItems: true,
43
+ minItems: 1,
44
+ },
45
+ },
46
+ };
47
+
48
+ export const authCookieMustBeHttpOnlyRule = createRule<RuleOptions, MessageIds>(
49
+ {
50
+ name: RULE_NAME,
51
+ meta: {
52
+ type: "problem",
53
+ docs: {
54
+ description:
55
+ "Auth-cookie writes must set `httpOnly: true` (or spread a trusted cookie-config helper). JS-readable session cookies leak via XSS.",
56
+ },
57
+ schema: [optionSchema],
58
+ messages: {
59
+ missingHttpOnly:
60
+ "Auth cookie '{{name}}' missing `httpOnly: true` — JS-readable cookies leak via XSS.",
61
+ },
62
+ },
63
+ defaultOptions: [
64
+ {
65
+ authCookieNames: [...DEFAULT_AUTH_COOKIE_NAMES],
66
+ trustedConfigNames: [...DEFAULT_TRUSTED_CONFIG_NAMES],
67
+ setCookieFunctions: [...DEFAULT_SET_COOKIE_FUNCTIONS],
68
+ },
69
+ ],
70
+ create(context, [options]) {
71
+ const authCookieNames = new Set(
72
+ options.authCookieNames ?? DEFAULT_AUTH_COOKIE_NAMES
73
+ );
74
+ const trustedConfigNames = new Set(
75
+ options.trustedConfigNames ?? DEFAULT_TRUSTED_CONFIG_NAMES
76
+ );
77
+ const setCookieFunctions = new Set(
78
+ options.setCookieFunctions ?? DEFAULT_SET_COOKIE_FUNCTIONS
79
+ );
80
+
81
+ return {
82
+ CallExpression(node) {
83
+ const match = matchAuthCookieSet(
84
+ node,
85
+ authCookieNames,
86
+ setCookieFunctions
87
+ );
88
+
89
+ if (match === null) {
90
+ return;
91
+ }
92
+
93
+ if (match.optionsNode === null) {
94
+ context.report({
95
+ node,
96
+ messageId: "missingHttpOnly",
97
+ data: { name: match.cookieName },
98
+ });
99
+
100
+ return;
101
+ }
102
+
103
+ const { value, hasTrustedSpread } = lookupCookieOption(
104
+ match.optionsNode,
105
+ "httpOnly",
106
+ trustedConfigNames
107
+ );
108
+
109
+ if (hasTrustedSpread) {
110
+ // Spreads a trusted helper — assume it sets httpOnly correctly.
111
+ // If `httpOnly` is also explicitly present, require it to be `true`.
112
+ if (
113
+ value !== null &&
114
+ value.type === AST_NODE_TYPES.Literal &&
115
+ value.value === false
116
+ ) {
117
+ context.report({
118
+ node: value,
119
+ messageId: "missingHttpOnly",
120
+ data: { name: match.cookieName },
121
+ });
122
+ }
123
+
124
+ return;
125
+ }
126
+
127
+ if (value === null) {
128
+ context.report({
129
+ node,
130
+ messageId: "missingHttpOnly",
131
+ data: { name: match.cookieName },
132
+ });
133
+
134
+ return;
135
+ }
136
+
137
+ // Literal `true` ok. Literal `false` flagged. Anything else (env-derived
138
+ // expression, identifier) is conservatively accepted.
139
+ if (value.type === AST_NODE_TYPES.Literal && value.value !== true) {
140
+ context.report({
141
+ node: value,
142
+ messageId: "missingHttpOnly",
143
+ data: { name: match.cookieName },
144
+ });
145
+ }
146
+ },
147
+ };
148
+ },
149
+ }
150
+ );
@@ -0,0 +1,149 @@
1
+ import { AST_NODE_TYPES } from "@typescript-eslint/utils";
2
+ import type { JSONSchema4 } from "@typescript-eslint/utils/json-schema";
3
+
4
+ import { createRule } from "../../create-rule";
5
+ import {
6
+ DEFAULT_AUTH_COOKIE_NAMES,
7
+ DEFAULT_SET_COOKIE_FUNCTIONS,
8
+ DEFAULT_TRUSTED_CONFIG_NAMES,
9
+ lookupCookieOption,
10
+ matchAuthCookieSet,
11
+ } from "../utils";
12
+
13
+ export const RULE_NAME = "auth-cookie-must-be-secure-in-prod";
14
+
15
+ export interface AuthCookieMustBeSecureInProdOptions {
16
+ readonly authCookieNames?: readonly string[];
17
+ readonly trustedConfigNames?: readonly string[];
18
+ readonly setCookieFunctions?: readonly string[];
19
+ }
20
+
21
+ type RuleOptions = [AuthCookieMustBeSecureInProdOptions];
22
+ type MessageIds = "missingSecure";
23
+
24
+ const optionSchema: JSONSchema4 = {
25
+ type: "object",
26
+ additionalProperties: false,
27
+ properties: {
28
+ authCookieNames: {
29
+ type: "array",
30
+ items: { type: "string" },
31
+ uniqueItems: true,
32
+ minItems: 1,
33
+ },
34
+ trustedConfigNames: {
35
+ type: "array",
36
+ items: { type: "string" },
37
+ uniqueItems: true,
38
+ },
39
+ setCookieFunctions: {
40
+ type: "array",
41
+ items: { type: "string" },
42
+ uniqueItems: true,
43
+ minItems: 1,
44
+ },
45
+ },
46
+ };
47
+
48
+ export const authCookieMustBeSecureInProdRule = createRule<
49
+ RuleOptions,
50
+ MessageIds
51
+ >({
52
+ name: RULE_NAME,
53
+ meta: {
54
+ type: "problem",
55
+ docs: {
56
+ description:
57
+ "Auth-cookie writes must set `secure:` to `true` or an env-derived expression (anything non-literal). Cookies leak over HTTP without it.",
58
+ },
59
+ schema: [optionSchema],
60
+ messages: {
61
+ missingSecure:
62
+ "Auth cookie '{{name}}' missing `secure:` — cookies leak over HTTP in transit.",
63
+ },
64
+ },
65
+ defaultOptions: [
66
+ {
67
+ authCookieNames: [...DEFAULT_AUTH_COOKIE_NAMES],
68
+ trustedConfigNames: [...DEFAULT_TRUSTED_CONFIG_NAMES],
69
+ setCookieFunctions: [...DEFAULT_SET_COOKIE_FUNCTIONS],
70
+ },
71
+ ],
72
+ create(context, [options]) {
73
+ const authCookieNames = new Set(
74
+ options.authCookieNames ?? DEFAULT_AUTH_COOKIE_NAMES
75
+ );
76
+ const trustedConfigNames = new Set(
77
+ options.trustedConfigNames ?? DEFAULT_TRUSTED_CONFIG_NAMES
78
+ );
79
+ const setCookieFunctions = new Set(
80
+ options.setCookieFunctions ?? DEFAULT_SET_COOKIE_FUNCTIONS
81
+ );
82
+
83
+ return {
84
+ CallExpression(node) {
85
+ const match = matchAuthCookieSet(
86
+ node,
87
+ authCookieNames,
88
+ setCookieFunctions
89
+ );
90
+
91
+ if (match === null) {
92
+ return;
93
+ }
94
+
95
+ if (match.optionsNode === null) {
96
+ context.report({
97
+ node,
98
+ messageId: "missingSecure",
99
+ data: { name: match.cookieName },
100
+ });
101
+
102
+ return;
103
+ }
104
+
105
+ const { value, hasTrustedSpread } = lookupCookieOption(
106
+ match.optionsNode,
107
+ "secure",
108
+ trustedConfigNames
109
+ );
110
+
111
+ if (hasTrustedSpread) {
112
+ if (
113
+ value !== null &&
114
+ value.type === AST_NODE_TYPES.Literal &&
115
+ value.value === false
116
+ ) {
117
+ context.report({
118
+ node: value,
119
+ messageId: "missingSecure",
120
+ data: { name: match.cookieName },
121
+ });
122
+ }
123
+
124
+ return;
125
+ }
126
+
127
+ if (value === null) {
128
+ context.report({
129
+ node,
130
+ messageId: "missingSecure",
131
+ data: { name: match.cookieName },
132
+ });
133
+
134
+ return;
135
+ }
136
+
137
+ // Literal `false` is a hard fail. Literal `true` ok. Any non-literal
138
+ // (env-derived) is accepted on trust.
139
+ if (value.type === AST_NODE_TYPES.Literal && value.value !== true) {
140
+ context.report({
141
+ node: value,
142
+ messageId: "missingSecure",
143
+ data: { name: match.cookieName },
144
+ });
145
+ }
146
+ },
147
+ };
148
+ },
149
+ });