@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.
Files changed (122) hide show
  1. package/package.json +6 -2
  2. package/scripts/browser-check.ts +41 -5
  3. package/scripts/build-rules-md.ts +78 -21
  4. package/scripts/cli-metrics.ts +10 -0
  5. package/scripts/sweep.ts +53 -23
  6. package/scripts/web-sweep.ts +292 -0
  7. package/src/browser/index.ts +3 -0
  8. package/src/browser/oracle.ts +215 -8
  9. package/src/cli.ts +22 -4
  10. package/src/config/index.ts +8 -0
  11. package/src/config/profiles.ts +150 -0
  12. package/src/config/tsforge-config.ts +64 -5
  13. package/src/detect-gate.ts +144 -13
  14. package/src/eval/eval.types.ts +9 -0
  15. package/src/eval/failure-class.ts +263 -0
  16. package/src/eval/index.ts +8 -0
  17. package/src/eval/metrics.ts +7 -0
  18. package/src/eval/parse-log.ts +105 -0
  19. package/src/eval/report.ts +19 -0
  20. package/src/eval/score.ts +10 -0
  21. package/src/loop/feedback/meta-rule-docs.ts +48 -0
  22. package/src/loop/feedback/rule-docs.ts +150 -0
  23. package/src/loop/loop.types.ts +4 -0
  24. package/src/loop/rule-docs.generated.json +131 -1
  25. package/src/loop/ttsr-defaults.ts +175 -4
  26. package/src/loop/turn.ts +3 -0
  27. package/src/meta-rules/registry.ts +32 -0
  28. package/src/meta-rules/rules/ci/no-github-context-in-shell.ts +40 -0
  29. package/src/meta-rules/rules/ci/no-pull-request-target-untrusted-checkout.ts +42 -0
  30. package/src/meta-rules/rules/ci/workflow-permissions-explicit.ts +49 -0
  31. package/src/meta-rules/rules/ci/workflow-permissions-least-privilege.ts +44 -0
  32. package/src/meta-rules/rules/config/next-image-remote-patterns-no-wildcards.ts +77 -0
  33. package/src/meta-rules/rules/config/next-instrumentation-present.ts +66 -0
  34. package/src/meta-rules/rules/config/next-proxy-over-middleware.ts +64 -0
  35. package/src/meta-rules/rules/config/tsconfig-recommended-flags.ts +75 -0
  36. package/src/meta-rules/rules/supply-chain/dependency-overrides-require-comment.ts +61 -0
  37. package/src/meta-rules/rules/supply-chain/fastify-security-plugins.ts +54 -0
  38. package/src/meta-rules/rules/supply-chain/lockfile-required.ts +51 -0
  39. package/src/meta-rules/rules/supply-chain/migrations-must-be-checked-in.ts +49 -0
  40. package/src/meta-rules/rules/supply-chain/no-git-or-tarball-dependencies.ts +70 -0
  41. package/src/meta-rules/rules/supply-chain/package-manager-field-required.ts +31 -0
  42. package/src/meta-rules/rules/supply-chain/production-must-not-use-drizzle-push.ts +75 -0
  43. package/src/meta-rules/rules/supply-chain/single-package-manager.ts +30 -0
  44. package/src/meta-rules/utils/lockfiles.ts +105 -0
  45. package/src/meta-rules/utils/workflow-yaml.ts +86 -0
  46. package/src/rule-packs/authorization/index.ts +26 -0
  47. package/src/rule-packs/authorization/rules/id-param-requires-object-authz.ts +87 -0
  48. package/src/rule-packs/authorization/rules/mutating-route-requires-authz.ts +116 -0
  49. package/src/rule-packs/authorization/rules/server-action-requires-authz.ts +101 -0
  50. package/src/rule-packs/authorization/utils.ts +285 -0
  51. package/src/rule-packs/boundary-utils.ts +13 -0
  52. package/src/rule-packs/code-flow/index.ts +4 -1
  53. package/src/rule-packs/code-flow/rules/no-throw-literal.ts +67 -0
  54. package/src/rule-packs/drizzle/index.ts +7 -0
  55. package/src/rule-packs/drizzle/rules/update-delete-account-scoped-must-filter-scope.ts +106 -0
  56. package/src/rule-packs/drizzle/rules/update-delete-must-have-where.ts +73 -0
  57. package/src/rule-packs/drizzle/utils.ts +133 -1
  58. package/src/rule-packs/fastify/index.ts +38 -0
  59. package/src/rule-packs/fastify/rules/error-handler-must-set-status.ts +78 -0
  60. package/src/rule-packs/fastify/rules/prefer-return-over-reply-send.ts +104 -0
  61. package/src/rule-packs/fastify/rules/require-fp-for-shared-plugins.ts +106 -0
  62. package/src/rule-packs/fastify/rules/require-plugin-name.ts +54 -0
  63. package/src/rule-packs/fastify/rules/require-response-schema.ts +62 -0
  64. package/src/rule-packs/fastify/rules/require-route-schema.ts +104 -0
  65. package/src/rule-packs/fastify/rules/test-inject-must-close-app.ts +44 -0
  66. package/src/rule-packs/fastify/utils/fastifyChain.ts +231 -0
  67. package/src/rule-packs/index.ts +10 -0
  68. package/src/rule-packs/jwt-cookies/index.ts +10 -0
  69. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-maxage-or-expires.ts +132 -0
  70. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-set-samesite.ts +151 -0
  71. package/src/rule-packs/jwt-cookies/rules/jwt-must-verify-not-decode.ts +124 -0
  72. package/src/rule-packs/module-boundaries/index.ts +3 -0
  73. package/src/rule-packs/module-boundaries/rules/no-react-in-services.ts +111 -0
  74. package/src/rule-packs/nextjs/index.ts +32 -0
  75. package/src/rule-packs/nextjs/rules/await-dynamic-request-apis.ts +65 -0
  76. package/src/rule-packs/nextjs/rules/error-boundary-require-use-client.ts +38 -0
  77. package/src/rule-packs/nextjs/rules/mutation-should-revalidate-cache.ts +152 -0
  78. package/src/rule-packs/nextjs/rules/no-html-img-element.ts +45 -0
  79. package/src/rule-packs/nextjs/rules/no-internal-api-fetch.ts +126 -0
  80. package/src/rule-packs/nextjs/rules/no-secret-props-to-client.ts +118 -0
  81. package/src/rule-packs/nextjs/rules/no-sensitive-next-public-env.ts +72 -0
  82. package/src/rule-packs/nextjs/rules/prefer-lazy-use-state-init.ts +85 -0
  83. package/src/rule-packs/nextjs/rules/server-action-requires-authz-and-validation.ts +178 -0
  84. package/src/rule-packs/nextjs/rules/server-only-modules-import-server-only.ts +87 -0
  85. package/src/rule-packs/nextjs/utils.ts +18 -0
  86. package/src/rule-packs/react-component-architecture/index.ts +18 -0
  87. package/src/rule-packs/react-component-architecture/rules/dangerous-html-requires-sanitize.ts +83 -0
  88. package/src/rule-packs/react-component-architecture/rules/no-anonymous-useEffect.ts +61 -0
  89. package/src/rule-packs/react-component-architecture/rules/no-component-invocation.ts +55 -0
  90. package/src/rule-packs/react-component-architecture/rules/no-derived-state-in-effect.ts +204 -0
  91. package/src/rule-packs/react-component-architecture/rules/no-nested-component.ts +152 -0
  92. package/src/rule-packs/react-component-architecture/rules/no-react-fc.ts +57 -0
  93. package/src/rule-packs/rule-catalog.types.ts +21 -0
  94. package/src/rule-packs/rule-metadata.ts +163 -0
  95. package/src/rule-packs/runtime-boundaries/index.ts +33 -0
  96. package/src/rule-packs/runtime-boundaries/rules/no-prototype-polluting-merge.ts +113 -0
  97. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-fetch-url.ts +69 -0
  98. package/src/rule-packs/runtime-boundaries/rules/no-user-controlled-redirect.ts +79 -0
  99. package/src/rule-packs/runtime-boundaries/rules/upload-must-set-limits.ts +126 -0
  100. package/src/rule-packs/runtime-boundaries/rules/webhook-must-verify-signature-before-parse.ts +87 -0
  101. package/src/rule-packs/security/index.ts +35 -0
  102. package/src/rule-packs/security/rules/catch-must-handle.ts +126 -0
  103. package/src/rule-packs/security/rules/no-auth-token-in-storage.ts +107 -0
  104. package/src/rule-packs/security/rules/no-child-process-exec.ts +72 -0
  105. package/src/rule-packs/security/rules/no-dynamic-regexp.ts +56 -0
  106. package/src/rule-packs/security/rules/no-inner-html-assignment.ts +42 -0
  107. package/src/rule-packs/security/rules/no-spawn-with-shell.ts +106 -0
  108. package/src/rule-packs/structured-logging/index.ts +6 -0
  109. package/src/rule-packs/structured-logging/rules/caught-error-log-requires-cause.ts +234 -0
  110. package/src/rule-packs/structured-logging/rules/logger-not-console.ts +146 -0
  111. package/src/rule-packs/test-conventions/index.ts +9 -0
  112. package/src/rule-packs/test-conventions/rules/fake-timers-must-be-restored.ts +143 -0
  113. package/src/rule-packs/test-conventions/rules/no-conditional-expect.ts +77 -0
  114. package/src/rule-packs/test-conventions/rules/no-real-network-in-unit-tests.ts +174 -0
  115. package/src/rule-packs/typescript-core/index.ts +30 -0
  116. package/src/rule-packs/typescript-core/rules/exported-functions-require-return-type.ts +74 -0
  117. package/src/rule-packs/typescript-core/rules/fetch-must-check-ok.ts +106 -0
  118. package/src/rule-packs/typescript-core/rules/json-parse-must-validate.ts +97 -0
  119. package/src/rule-packs/typescript-core/rules/no-unsafe-boundary-cast.ts +70 -0
  120. package/src/stack-detection/packs.ts +57 -0
  121. package/strict.type-aware.eslint.config.mjs +33 -0
  122. package/strict.web.eslint.config.mjs +32 -1
@@ -0,0 +1,150 @@
1
+ import type { ProfileId } from "../rule-packs/rule-catalog.types";
2
+
3
+ export type { ProfileId };
4
+
5
+ export interface IProfileDefinition {
6
+ readonly id: ProfileId;
7
+ readonly label: string;
8
+ readonly description: string;
9
+ /** Extra packs to include beyond stack detection (deduped at resolve time). */
10
+ readonly extraPacks?: readonly string[];
11
+ /** Rule severity overrides keyed by bare rule name. */
12
+ readonly ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>;
13
+ /** Meta-rule ids to elevate to error in this profile. */
14
+ readonly metaRulesAtError?: readonly string[];
15
+ }
16
+
17
+ /** Architecture-tier rules enabled only in the opinionated profile. */
18
+ export const ARCHITECTURE_RULES = [
19
+ "component-folder-structure",
20
+ "no-state-in-component-body",
21
+ "no-inline-jsx-functions",
22
+ "index-must-reexport-default",
23
+ "forwardref-display-name",
24
+ "max-hooks-per-file",
25
+ ] as const;
26
+
27
+ const architectureOffOverrides = Object.fromEntries(
28
+ ARCHITECTURE_RULES.map((rule) => [rule, "off" as const])
29
+ );
30
+
31
+ export const PROFILE_DEFINITIONS: Readonly<
32
+ Record<ProfileId, IProfileDefinition>
33
+ > = {
34
+ recommended: {
35
+ id: "recommended",
36
+ label: "Recommended",
37
+ description:
38
+ "Safety + framework packs from stack detection; architecture opinions off by default.",
39
+ ruleOverrides: {
40
+ ...architectureOffOverrides,
41
+ "prefer-early-return": "warn",
42
+ },
43
+ },
44
+ strict: {
45
+ id: "strict",
46
+ label: "Strict",
47
+ description:
48
+ "Recommended plus CI/supply-chain meta-rules at error and type-aware async rules.",
49
+ extraPacks: ["typescript-core"],
50
+ ruleOverrides: {
51
+ ...architectureOffOverrides,
52
+ "prefer-early-return": "warn",
53
+ },
54
+ metaRulesAtError: [
55
+ "workflow-permissions-explicit",
56
+ "lockfile-required",
57
+ "single-package-manager",
58
+ ],
59
+ },
60
+ security: {
61
+ id: "security",
62
+ label: "Security",
63
+ description:
64
+ "Recommended plus runtime-boundaries and experimental authorization heuristics.",
65
+ extraPacks: ["runtime-boundaries", "authorization"],
66
+ ruleOverrides: architectureOffOverrides,
67
+ },
68
+ frontend: {
69
+ id: "frontend",
70
+ label: "Frontend",
71
+ description: "Recommended with React/Next architecture rules at warn.",
72
+ ruleOverrides: {
73
+ "no-html-img-element": "warn",
74
+ "no-anonymous-useEffect": "warn",
75
+ "no-derived-state-in-effect": "warn",
76
+ ...architectureOffOverrides,
77
+ },
78
+ },
79
+ backend: {
80
+ id: "backend",
81
+ label: "Backend",
82
+ description:
83
+ "Recommended; stack detection adds Fastify/Elysia/Drizzle/BullMQ packs.",
84
+ ruleOverrides: architectureOffOverrides,
85
+ },
86
+ opinionated: {
87
+ id: "opinionated",
88
+ label: "Opinionated",
89
+ description:
90
+ "Full house-style architecture rules including component folder structure.",
91
+ ruleOverrides: {
92
+ "component-folder-structure": "error",
93
+ "no-state-in-component-body": "error",
94
+ "no-inline-jsx-functions": "warn",
95
+ "index-must-reexport-default": "error",
96
+ "forwardref-display-name": "error",
97
+ "max-hooks-per-file": "warn",
98
+ "prefer-early-return": "error",
99
+ },
100
+ },
101
+ };
102
+
103
+ export const DEFAULT_PROFILE: ProfileId = "recommended";
104
+
105
+ export function isProfileId(value: string): value is ProfileId {
106
+ return value in PROFILE_DEFINITIONS;
107
+ }
108
+
109
+ /** Merge profile overrides with user config overrides (user wins). */
110
+ export function resolveProfileRuleOverrides(
111
+ profileId: ProfileId | undefined
112
+ ): Record<string, "error" | "warn" | "off"> {
113
+ const id = profileId ?? DEFAULT_PROFILE;
114
+ const profile = PROFILE_DEFINITIONS[id];
115
+
116
+ return { ...(profile.ruleOverrides ?? {}) };
117
+ }
118
+
119
+ export function resolveProfileExtraPacks(
120
+ profileId: ProfileId | undefined
121
+ ): readonly string[] {
122
+ const id = profileId ?? DEFAULT_PROFILE;
123
+ const profile = PROFILE_DEFINITIONS[id];
124
+
125
+ return profile.extraPacks ?? [];
126
+ }
127
+
128
+ export function resolveProfileMetaRuleOverrides(
129
+ profileId: ProfileId | undefined
130
+ ): Record<string, "error"> {
131
+ const id = profileId ?? DEFAULT_PROFILE;
132
+ const profile = PROFILE_DEFINITIONS[id];
133
+ const result: Record<string, "error"> = {};
134
+
135
+ for (const ruleId of profile.metaRulesAtError ?? []) {
136
+ result[ruleId] = "error";
137
+ }
138
+
139
+ return result;
140
+ }
141
+
142
+ export function mergeRuleOverrides(
143
+ profileId: ProfileId | undefined,
144
+ userOverrides: Readonly<Record<string, "error" | "warn" | "off">>
145
+ ): Record<string, "error" | "warn" | "off"> {
146
+ return {
147
+ ...resolveProfileRuleOverrides(profileId),
148
+ ...userOverrides,
149
+ };
150
+ }
@@ -3,6 +3,14 @@ import { isRecord } from "../lib/guards";
3
3
  import { PACK_REGISTRY } from "../stack-detection";
4
4
  import { parseMcpServers, type IMcpServerConfig } from "../mcp";
5
5
  import { parsePlugins, type IExternalPlugin } from "./external-plugins";
6
+ import {
7
+ DEFAULT_PROFILE,
8
+ isProfileId,
9
+ mergeRuleOverrides,
10
+ resolveProfileExtraPacks,
11
+ resolveProfileMetaRuleOverrides,
12
+ type ProfileId,
13
+ } from "./profiles";
6
14
 
7
15
  /**
8
16
  * User-defined configuration from tsforge.config.json
@@ -10,6 +18,9 @@ import { parsePlugins, type IExternalPlugin } from "./external-plugins";
10
18
  * include/exclude packs, and tune rule severities (eslint packs + meta-rules).
11
19
  */
12
20
  export interface ITsforgeProjectConfig {
21
+ /** Rule profile: recommended (default), strict, security, frontend, backend, opinionated. */
22
+ readonly profile?: ProfileId;
23
+
13
24
  /** Force-enable a stack by name (skip detection heuristics, force-add its packs). */
14
25
  readonly stack?: string;
15
26
 
@@ -100,6 +111,31 @@ function warnUnknownPackInInclude(packId: string): void {
100
111
  warnConfig(msg);
101
112
  }
102
113
 
114
+ function warnInvalidProfile(profileValue: unknown): void {
115
+ warnConfig(
116
+ `tsforge.config.json: "profile" must be one of recommended, strict, security, frontend, backend, opinionated — got "${String(profileValue)}"`
117
+ );
118
+ }
119
+
120
+ /** Validate and extract profile field. */
121
+ function validateProfile(parsed: unknown): ProfileId | undefined {
122
+ if (typeof parsed !== "string") {
123
+ if (parsed !== undefined) {
124
+ warnInvalidProfile(parsed);
125
+ }
126
+
127
+ return undefined;
128
+ }
129
+
130
+ if (isProfileId(parsed)) {
131
+ return parsed;
132
+ }
133
+
134
+ warnInvalidProfile(parsed);
135
+
136
+ return undefined;
137
+ }
138
+
103
139
  /** Validate and extract stack field. */
104
140
  function validateStack(parsed: unknown): string | undefined {
105
141
  if (typeof parsed === "string") {
@@ -181,6 +217,7 @@ function buildConfigFields(
181
217
  parsed: Record<string, unknown>
182
218
  ): ITsforgeProjectConfig {
183
219
  const configFields: {
220
+ profile?: ProfileId;
184
221
  stack?: string;
185
222
  packs?: { include?: readonly string[]; exclude?: readonly string[] };
186
223
  rules?: Record<string, "error" | "warn" | "off">;
@@ -188,6 +225,14 @@ function buildConfigFields(
188
225
  plugins?: readonly IExternalPlugin[];
189
226
  } = {};
190
227
 
228
+ if (parsed.profile !== undefined) {
229
+ const profile = validateProfile(parsed.profile);
230
+
231
+ if (profile !== undefined) {
232
+ configFields.profile = profile;
233
+ }
234
+ }
235
+
191
236
  if (parsed.stack !== undefined) {
192
237
  const stack = validateStack(parsed.stack);
193
238
 
@@ -290,6 +335,13 @@ export function resolveActivePacks(
290
335
  packs.add(config.stack);
291
336
  }
292
337
 
338
+ // Profile extra packs (runtime-boundaries, authorization, typescript-core, …)
339
+ for (const packId of resolveProfileExtraPacks(config.profile)) {
340
+ if (packId in PACK_REGISTRY) {
341
+ packs.add(packId);
342
+ }
343
+ }
344
+
293
345
  // Include: add packs (unknown ids are kept out of the registry lookup warning only)
294
346
  for (const packId of config.packs?.include ?? []) {
295
347
  if (packId.length === 0) {
@@ -324,11 +376,9 @@ function isSeverityOverride(value: unknown): value is "error" | "warn" | "off" {
324
376
  export function normalizeRuleOverrides(
325
377
  config: ITsforgeProjectConfig
326
378
  ): Record<string, "error" | "warn" | "off"> {
327
- const normalized: Record<string, "error" | "warn" | "off"> = {};
379
+ const userOverrides: Record<string, "error" | "warn" | "off"> = {};
328
380
 
329
381
  for (const [key, severity] of Object.entries(config.rules ?? {})) {
330
- // Runtime data can violate the declared union (hand-built configs in tests,
331
- // partially validated JSON) — re-check before trusting it.
332
382
  if (!isSeverityOverride(severity)) {
333
383
  continue;
334
384
  }
@@ -336,9 +386,18 @@ export function normalizeRuleOverrides(
336
386
  const bareKey = key.startsWith("tsforge/") ? key.slice(8) : key;
337
387
 
338
388
  if (bareKey.length > 0) {
339
- normalized[bareKey] = severity;
389
+ userOverrides[bareKey] = severity;
340
390
  }
341
391
  }
342
392
 
343
- return normalized;
393
+ return {
394
+ ...resolveProfileMetaRuleOverrides(config.profile),
395
+ ...mergeRuleOverrides(config.profile, userOverrides),
396
+ };
397
+ }
398
+
399
+ export function resolveProjectProfile(
400
+ config: ITsforgeProjectConfig
401
+ ): ProfileId {
402
+ return config.profile ?? DEFAULT_PROFILE;
344
403
  }
@@ -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. Written as a sibling
103
- * `tsforge.tsconfig.json` and gated with `tsc -p`. */
104
- const STRICT_TSCONFIG_OVERRIDE = `{
105
- "extends": "./tsconfig.json",
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
- const render = `bun "${BROWSER_CHECK}" dist/index.html --smoke --crawl`;
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 but
484
- * force the strict flags; greenfield, bring the full strict one. null when not a
485
- * TS project. (The strict override / bundled config win over whatever the repo set.)
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, "tsforge.tsconfig.json"),
493
- STRICT_TSCONFIG_OVERRIDE
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 tsforge.tsconfig.json`;
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
+ }
@@ -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
  }