@agjs/tsforge 0.1.19 → 0.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +6 -2
- package/scripts/browser-check.ts +41 -5
- package/scripts/build-rules-md.ts +78 -21
- package/scripts/cli-metrics.ts +10 -0
- package/scripts/sweep.ts +53 -23
- package/scripts/web-sweep.ts +292 -0
- package/src/browser/index.ts +3 -0
- package/src/browser/oracle.ts +215 -8
- package/src/cli.ts +22 -4
- package/src/config/index.ts +8 -0
- package/src/config/profiles.ts +150 -0
- package/src/config/tsforge-config.ts +64 -5
- package/src/detect-gate.ts +144 -13
- package/src/eval/eval.types.ts +9 -0
- package/src/eval/failure-class.ts +263 -0
- package/src/eval/index.ts +8 -0
- package/src/eval/metrics.ts +7 -0
- package/src/eval/parse-log.ts +105 -0
- package/src/eval/report.ts +19 -0
- package/src/eval/score.ts +10 -0
- package/src/loop/feedback/meta-rule-docs.ts +48 -0
- package/src/loop/feedback/rule-docs.ts +150 -0
- package/src/loop/loop.types.ts +4 -0
- package/src/loop/rule-docs.generated.json +131 -1
- package/src/loop/ttsr-defaults.ts +175 -4
- package/src/loop/turn.ts +3 -0
- package/src/meta-rules/registry.ts +32 -0
- package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
- package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
- package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
- package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
- package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
- package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
- package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
- package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
- package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
- package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
- package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
- package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
- package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
- package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
- package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
- package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
- package/src/meta-rules/utils/lockfiles.ts +105 -0
- package/src/meta-rules/utils/workflow-yaml.ts +86 -0
- package/src/rule-packs/authorization/index.ts +26 -0
- package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
- package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
- package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
- package/src/rule-packs/authorization/utils.ts +285 -0
- package/src/rule-packs/boundary-utils.ts +13 -0
- package/src/rule-packs/code-flow/index.ts +4 -1
- package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
- package/src/rule-packs/drizzle/index.ts +7 -0
- package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
- package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
- package/src/rule-packs/drizzle/utils.ts +133 -1
- package/src/rule-packs/fastify/index.ts +38 -0
- package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
- package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
- package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
- package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
- package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
- package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
- package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
- package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
- package/src/rule-packs/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/index.ts +10 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
- package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
- package/src/rule-packs/module-boundaries/index.ts +3 -0
- package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
- package/src/rule-packs/nextjs/index.ts +32 -0
- package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
- package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
- package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
- package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
- package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
- package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
- package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
- package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
- package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
- package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
- package/src/rule-packs/nextjs/utils.ts +18 -0
- package/src/rule-packs/react-component-architecture/index.ts +18 -0
- package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
- package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
- package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
- package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
- package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
- package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
- package/src/rule-packs/rule-catalog.types.ts +21 -0
- package/src/rule-packs/rule-metadata.ts +163 -0
- package/src/rule-packs/runtime-boundaries/index.ts +33 -0
- package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
- package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
- package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
- package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
- package/src/rule-packs/security/index.ts +35 -0
- package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
- package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
- package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
- package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
- package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
- package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
- package/src/rule-packs/structured-logging/index.ts +6 -0
- package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
- package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
- package/src/rule-packs/test-conventions/index.ts +9 -0
- package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
- package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
- package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
- package/src/rule-packs/typescript-core/index.ts +30 -0
- package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
- package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
- package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
- package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
- package/src/stack-detection/packs.ts +57 -0
- package/strict.type-aware.eslint.config.mjs +33 -0
- 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
|
|
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
|
-
|
|
389
|
+
userOverrides[bareKey] = severity;
|
|
340
390
|
}
|
|
341
391
|
}
|
|
342
392
|
|
|
343
|
-
return
|
|
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
|
}
|
package/src/detect-gate.ts
CHANGED
|
@@ -2,6 +2,7 @@ import { join, dirname } from "node:path";
|
|
|
2
2
|
import { existsSync } from "node:fs";
|
|
3
3
|
import { ESLint } from "eslint";
|
|
4
4
|
import { WEB_TEMPLATES, type WebFramework } from "./web-templates";
|
|
5
|
+
import { isRecord } from "./lib/guards";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Build the gate that confirms "done" — and makes tsforge a TypeScript-SPECIALIZED
|
|
@@ -63,6 +64,11 @@ const ESLINT_BIN = resolveToolBin("eslint");
|
|
|
63
64
|
const TSC_BIN = resolveToolBin("tsc");
|
|
64
65
|
const PRETTIER_BIN = resolveToolBin("prettier");
|
|
65
66
|
const STRICT_CONFIG = join(import.meta.dir, "..", "strict.eslint.config.mjs");
|
|
67
|
+
const TYPE_AWARE_CONFIG = join(
|
|
68
|
+
import.meta.dir,
|
|
69
|
+
"..",
|
|
70
|
+
"strict.type-aware.eslint.config.mjs"
|
|
71
|
+
);
|
|
66
72
|
const BROWSER_CHECK = join(
|
|
67
73
|
import.meta.dir,
|
|
68
74
|
"..",
|
|
@@ -86,6 +92,8 @@ const STRICT_TSCONFIG = `{
|
|
|
86
92
|
"noUncheckedIndexedAccess": true,
|
|
87
93
|
"noImplicitOverride": true,
|
|
88
94
|
"noFallthroughCasesInSwitch": true,
|
|
95
|
+
"useUnknownInCatchVariables": true,
|
|
96
|
+
"erasableSyntaxOnly": true,
|
|
89
97
|
"esModuleInterop": true,
|
|
90
98
|
"forceConsistentCasingInFileNames": true,
|
|
91
99
|
"skipLibCheck": true,
|
|
@@ -99,21 +107,35 @@ const STRICT_TSCONFIG = `{
|
|
|
99
107
|
/** Strict overlay for a project that ALREADY has a tsconfig: extend it (so the
|
|
100
108
|
* project's paths/jsx/module/lib still resolve — a bare strict config would
|
|
101
109
|
* mis-compile a real app) but FORCE every strictness flag on top, so a loosely-
|
|
102
|
-
* configured repo still gets tsforge's strict-TS floor.
|
|
103
|
-
*
|
|
104
|
-
|
|
105
|
-
|
|
110
|
+
* configured repo still gets tsforge's strict-TS floor.
|
|
111
|
+
*
|
|
112
|
+
* PERSISTENCE POLICY: written under `.tsforge/` (tsforge's cache namespace), NOT
|
|
113
|
+
* as a sibling in the project root — so the gate never litters the user's repo
|
|
114
|
+
* with a `tsforge.tsconfig.json`. `extends` points one level up to the project's
|
|
115
|
+
* own config, and `include`/`exclude` are re-stated relative to the subdir
|
|
116
|
+
* because `extends` does not inherit them (they default to the config's own
|
|
117
|
+
* directory otherwise — which under `.tsforge/` would compile nothing). */
|
|
118
|
+
const STRICT_TSCONFIG_OVERLAY = `{
|
|
119
|
+
"extends": "../tsconfig.json",
|
|
106
120
|
"compilerOptions": {
|
|
107
121
|
"strict": true,
|
|
108
122
|
"noUncheckedIndexedAccess": true,
|
|
109
123
|
"noImplicitOverride": true,
|
|
110
124
|
"noFallthroughCasesInSwitch": true,
|
|
125
|
+
"useUnknownInCatchVariables": true,
|
|
126
|
+
"erasableSyntaxOnly": true,
|
|
111
127
|
"skipLibCheck": true,
|
|
112
128
|
"noEmit": true
|
|
113
|
-
}
|
|
129
|
+
},
|
|
130
|
+
"include": ["../**/*.ts", "../**/*.tsx"],
|
|
131
|
+
"exclude": ["../node_modules", "../dist", "../build", "../scratch", "../.tsforge"]
|
|
114
132
|
}
|
|
115
133
|
`;
|
|
116
134
|
|
|
135
|
+
/** The gate overlay's home: tsforge's cache dir + the overlay filename. */
|
|
136
|
+
const GATE_TSCONFIG_DIR = ".tsforge";
|
|
137
|
+
const GATE_TSCONFIG_FILE = "tsconfig.gate.json";
|
|
138
|
+
|
|
117
139
|
// The web-stack scaffolds (Vite + React full-kit, or Vite vanilla) live in the
|
|
118
140
|
// registry; this module just lays them down and builds their gate. shadcn/TanStack
|
|
119
141
|
// boilerplate is held to a web-tailored strict config (no `I`-prefix — React names
|
|
@@ -364,7 +386,12 @@ export function buildWebGate(framework: WebFramework): IGate {
|
|
|
364
386
|
// HARNESS-authored and app-agnostic: we deliberately do NOT run a model-authored
|
|
365
387
|
// checks.json — the 27b writes over-strict interaction assertions (exact
|
|
366
388
|
// placeholders/fill flows) it then can't satisfy and spirals on (iter3/4).
|
|
367
|
-
|
|
389
|
+
// OPT-IN quality oracles (default OFF so existing web runs are unchanged):
|
|
390
|
+
// TSFORGE_A11Y=1 adds axe (serious/critical fail), TSFORGE_SCREENSHOTS=1 writes
|
|
391
|
+
// per-route PNGs. A "frontend"/"strict" profile can set these.
|
|
392
|
+
const a11y = process.env.TSFORGE_A11Y === "1" ? " --a11y" : "";
|
|
393
|
+
const shots = process.env.TSFORGE_SCREENSHOTS === "1" ? " --screenshots" : "";
|
|
394
|
+
const render = `bun "${BROWSER_CHECK}" dist/index.html --smoke --crawl${a11y}${shots}`;
|
|
368
395
|
// Prettier enforces formatting (the fix step runs `prettier --write` first, so
|
|
369
396
|
// this passes without the model ever hand-formatting). Respects .prettierignore
|
|
370
397
|
// (vendored ui/ + lib/ skipped). Runs after lint so a parse error fails there.
|
|
@@ -458,7 +485,8 @@ export function prettierWriteCommand(): string {
|
|
|
458
485
|
export async function buildGate(
|
|
459
486
|
cwd: string,
|
|
460
487
|
packs?: readonly string[],
|
|
461
|
-
ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off"
|
|
488
|
+
ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>,
|
|
489
|
+
options?: { enableTypeAware?: boolean; includeTests?: boolean }
|
|
462
490
|
): Promise<IGate> {
|
|
463
491
|
const parts: string[] = [];
|
|
464
492
|
const labels: string[] = [];
|
|
@@ -475,29 +503,104 @@ export async function buildGate(
|
|
|
475
503
|
parts.push(lint.command);
|
|
476
504
|
labels.push(lint.label);
|
|
477
505
|
|
|
506
|
+
if (options?.enableTypeAware === true) {
|
|
507
|
+
const typeAware = await typeAwareLintPart(cwd);
|
|
508
|
+
|
|
509
|
+
if (typeAware !== null) {
|
|
510
|
+
parts.push(typeAware.command);
|
|
511
|
+
labels.push(typeAware.label);
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// Tests run LAST (after the cheap static floor) so a type/lint error fails
|
|
516
|
+
// fast without paying for a test run. Only appended when the project actually
|
|
517
|
+
// has tests to run — a strict-floor-only run, or a project with none, skips it.
|
|
518
|
+
if (options?.includeTests === true) {
|
|
519
|
+
const test = await discoverTestCommand(cwd);
|
|
520
|
+
|
|
521
|
+
if (test !== null) {
|
|
522
|
+
parts.push(test);
|
|
523
|
+
labels.push("tests");
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
|
|
478
527
|
return { command: parts.join(" && "), label: labels.join(" + ") };
|
|
479
528
|
}
|
|
480
529
|
|
|
530
|
+
/** The npm-init placeholder test script — running it always fails, so it must
|
|
531
|
+
* NOT count as "the project has tests". */
|
|
532
|
+
const PLACEHOLDER_TEST = /no test specified/i;
|
|
533
|
+
|
|
534
|
+
/**
|
|
535
|
+
* The project's test command for the gate, or null when there's nothing to run.
|
|
536
|
+
* Prefers an explicit, real package.json `test` script (run via `bun run test`);
|
|
537
|
+
* else falls back to `bun test` when the project has test files; else null — so
|
|
538
|
+
* a greenfield app with no tests yet stays at the strict floor instead of
|
|
539
|
+
* failing a gate that runs a placeholder/absent test command.
|
|
540
|
+
*/
|
|
541
|
+
export async function discoverTestCommand(cwd: string): Promise<string | null> {
|
|
542
|
+
const pkgFile = Bun.file(join(cwd, "package.json"));
|
|
543
|
+
|
|
544
|
+
if (await pkgFile.exists()) {
|
|
545
|
+
try {
|
|
546
|
+
const pkg: unknown = await pkgFile.json();
|
|
547
|
+
const scripts = isRecord(pkg) ? pkg.scripts : undefined;
|
|
548
|
+
const script = isRecord(scripts) ? scripts.test : undefined;
|
|
549
|
+
|
|
550
|
+
if (
|
|
551
|
+
typeof script === "string" &&
|
|
552
|
+
script.trim().length > 0 &&
|
|
553
|
+
!PLACEHOLDER_TEST.test(script)
|
|
554
|
+
) {
|
|
555
|
+
return "bun run test";
|
|
556
|
+
}
|
|
557
|
+
} catch {
|
|
558
|
+
// Malformed package.json — fall through to file detection.
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return (await hasTestFiles(cwd)) ? "bun test" : null;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/** True when the project has at least one *.test.* / *.spec.* file (outside
|
|
566
|
+
* node_modules) — the signal that a bare `bun test` has something to run. */
|
|
567
|
+
async function hasTestFiles(cwd: string): Promise<boolean> {
|
|
568
|
+
const glob = new Bun.Glob("**/*.{test,spec}.{ts,tsx,js,jsx}");
|
|
569
|
+
|
|
570
|
+
for await (const path of glob.scan({ cwd, onlyFiles: true })) {
|
|
571
|
+
if (!path.includes("node_modules")) {
|
|
572
|
+
return true;
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
return false;
|
|
577
|
+
}
|
|
578
|
+
|
|
481
579
|
/**
|
|
482
580
|
* The type-aware floor — ALWAYS tsforge-strict (user policy: a repo's own config
|
|
483
|
-
* is never trusted to be strict enough). With a project tsconfig, extend it
|
|
484
|
-
* force the strict flags; greenfield, bring the full strict one.
|
|
485
|
-
* TS project. (The strict
|
|
581
|
+
* is never trusted to be strict enough). With a project tsconfig, extend it under
|
|
582
|
+
* `.tsforge/` but force the strict flags; greenfield, bring the full strict one.
|
|
583
|
+
* null when not a TS project. (The strict overlay / bundled config win over
|
|
584
|
+
* whatever the repo set.)
|
|
486
585
|
*/
|
|
487
586
|
async function tscPart(cwd: string): Promise<string | null> {
|
|
488
587
|
const hasTsconfig = await Bun.file(join(cwd, "tsconfig.json")).exists();
|
|
489
588
|
|
|
490
589
|
if (hasTsconfig) {
|
|
590
|
+
// EPHEMERAL gate artifact: lives in .tsforge/ (Bun.write makes the dir), so
|
|
591
|
+
// we never drop a tsforge.tsconfig.json in the user's project root.
|
|
491
592
|
await Bun.write(
|
|
492
|
-
join(cwd,
|
|
493
|
-
|
|
593
|
+
join(cwd, GATE_TSCONFIG_DIR, GATE_TSCONFIG_FILE),
|
|
594
|
+
STRICT_TSCONFIG_OVERLAY
|
|
494
595
|
);
|
|
596
|
+
await ignoreGateArtifact(cwd);
|
|
495
597
|
|
|
496
|
-
return `"${TSC_BIN}" --noEmit -p
|
|
598
|
+
return `"${TSC_BIN}" --noEmit -p ${GATE_TSCONFIG_DIR}/${GATE_TSCONFIG_FILE}`;
|
|
497
599
|
}
|
|
498
600
|
|
|
499
601
|
// Greenfield: bring a strict tsconfig so tsc can gate — but only when this is
|
|
500
602
|
// actually a TS project (has a package.json), so we never litter a random dir.
|
|
603
|
+
// Unlike the overlay, a greenfield tsconfig.json is a DURABLE project file.
|
|
501
604
|
if (await Bun.file(join(cwd, "package.json")).exists()) {
|
|
502
605
|
await Bun.write(join(cwd, "tsconfig.json"), STRICT_TSCONFIG);
|
|
503
606
|
|
|
@@ -507,6 +610,20 @@ async function tscPart(cwd: string): Promise<string | null> {
|
|
|
507
610
|
return null;
|
|
508
611
|
}
|
|
509
612
|
|
|
613
|
+
/** Keep the ephemeral gate overlay out of git WITHOUT touching the user's root
|
|
614
|
+
* .gitignore: drop a scoped `.tsforge/.gitignore` ignoring just the overlay.
|
|
615
|
+
* Created only when absent, so a user-authored `.tsforge/.gitignore` (e.g. one
|
|
616
|
+
* that intentionally tracks rules.json) is never clobbered. */
|
|
617
|
+
async function ignoreGateArtifact(cwd: string): Promise<void> {
|
|
618
|
+
const ignore = join(cwd, GATE_TSCONFIG_DIR, ".gitignore");
|
|
619
|
+
|
|
620
|
+
if (await Bun.file(ignore).exists()) {
|
|
621
|
+
return;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
await Bun.write(ignore, `${GATE_TSCONFIG_FILE}\n`);
|
|
625
|
+
}
|
|
626
|
+
|
|
510
627
|
/** The syntactic idiom layer — ALWAYS tsforge's bundled strict eslint config
|
|
511
628
|
* (user policy). We deliberately do NOT defer to the project's own `lint`
|
|
512
629
|
* script: that's exactly how a weak repo would dodge the strict-TS floor. The
|
|
@@ -538,3 +655,17 @@ function lintPart(
|
|
|
538
655
|
label: "strict TypeScript (tsforge)",
|
|
539
656
|
};
|
|
540
657
|
}
|
|
658
|
+
|
|
659
|
+
/** Optional type-aware async rules — only when target has tsconfig.json. */
|
|
660
|
+
async function typeAwareLintPart(cwd: string): Promise<IGate | null> {
|
|
661
|
+
const hasTsconfig = await Bun.file(join(cwd, "tsconfig.json")).exists();
|
|
662
|
+
|
|
663
|
+
if (!hasTsconfig) {
|
|
664
|
+
return null;
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
return {
|
|
668
|
+
command: `bun "${ESLINT_BIN}" --no-config-lookup -c "${TYPE_AWARE_CONFIG}" --format json .`,
|
|
669
|
+
label: "type-aware async (tsforge)",
|
|
670
|
+
};
|
|
671
|
+
}
|
package/src/eval/eval.types.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { FailureClass } from "./failure-class";
|
|
2
|
+
|
|
1
3
|
export interface IJudgeInput {
|
|
2
4
|
goal: string;
|
|
3
5
|
criteria: string;
|
|
@@ -21,6 +23,9 @@ export interface IRunRecord {
|
|
|
21
23
|
ms: number;
|
|
22
24
|
/** LLM-judge quality score (1–5), when available. */
|
|
23
25
|
quality?: number;
|
|
26
|
+
/** Structured reason a failed run failed (from classifyRun); omitted/`none`
|
|
27
|
+
* for a passing run. The substrate for turning failures into interventions. */
|
|
28
|
+
failureClass?: FailureClass;
|
|
24
29
|
}
|
|
25
30
|
|
|
26
31
|
/** Aggregated metrics for a variant across its runs. */
|
|
@@ -33,4 +38,8 @@ export interface IVariantSummary {
|
|
|
33
38
|
avgMs: number;
|
|
34
39
|
/** Average quality across runs that were scored (0 if none). */
|
|
35
40
|
avgQuality: number;
|
|
41
|
+
/** Count of failed runs by failure class (e.g. {"type-error": 2}); empty when
|
|
42
|
+
* no run carried a class. Lets a sweep show WHY a variant failed, not just how
|
|
43
|
+
* often. */
|
|
44
|
+
failureClasses: Record<string, number>;
|
|
36
45
|
}
|