@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.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- 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
|
+
});
|