@agjs/tsforge 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,62 @@
1
+ import { join } from "node:path";
2
+ import { writable, normalizeWorkspacePath } from "../../lib/scope";
3
+ import {
4
+ materializeComponents,
5
+ asThemeName,
6
+ asComponentNames,
7
+ COMPONENT_NAMES,
8
+ THEME_NAMES,
9
+ } from "../../web-components";
10
+ import { type IToolContext } from "./tool-context";
11
+
12
+ /**
13
+ * `scaffold_ui` — materialize tested, THEMED UI primitives so the model never
14
+ * authors (or re-authors) a button/card/input/etc. Writes `src/index.css` (the
15
+ * vibe's design-token block) + the requested `src/components/ui/*.tsx` primitives
16
+ * with the theme's per-component classes baked in. Overwrites (re-theming is
17
+ * idempotent). Reports ONE summary event — deliberately NOT per-file `create`
18
+ * events, so the write-guard doesn't re-check known-good vendored files.
19
+ */
20
+ export async function doScaffoldUi(
21
+ args: Record<string, unknown>,
22
+ ctx: IToolContext
23
+ ): Promise<string> {
24
+ const theme = asThemeName(args.theme);
25
+
26
+ if (theme === undefined) {
27
+ return `scaffold_ui REJECTED: \`theme\` must be one of: ${THEME_NAMES.join(", ")}.`;
28
+ }
29
+
30
+ const components = asComponentNames(args.components);
31
+
32
+ if (components.length === 0) {
33
+ return `scaffold_ui REJECTED: \`components\` must be a non-empty array from: ${COMPONENT_NAMES.join(", ")}.`;
34
+ }
35
+
36
+ const files = materializeComponents(theme, components);
37
+ const written: string[] = [];
38
+
39
+ for (const [rel, content] of Object.entries(files)) {
40
+ const path = normalizeWorkspacePath(ctx.cwd, rel);
41
+
42
+ if (!writable(path, ctx.files)) {
43
+ continue;
44
+ }
45
+
46
+ await Bun.write(join(ctx.cwd, path), content);
47
+ written.push(path);
48
+ }
49
+
50
+ ctx.report({
51
+ kind: "tool",
52
+ task: ctx.task,
53
+ message: `scaffold_ui: wrote ${String(written.length)} themed file(s) [${theme}] — ${components.join(", ")}`,
54
+ });
55
+
56
+ return (
57
+ `scaffold_ui: wrote ${String(written.length)} file(s) with the "${theme}" theme ` +
58
+ `(${written.join(", ")}). Import these from @/components/ui (e.g. \`import { Button } ` +
59
+ `from "@/components/ui/button"\`) and COMPOSE them — do NOT re-create any primitive ` +
60
+ `or edit the files under src/components/ui.`
61
+ );
62
+ }
@@ -0,0 +1,35 @@
1
+ import { str, type IToolContext } from "./tool-context";
2
+
3
+ /**
4
+ * `scaffold_web` — the AGENT's decision to turn this workspace into a from-scratch
5
+ * web app. It calls this ONLY when the request is "build a web app/UI"; for a
6
+ * question, a CLI script, or editing existing code it just does the work. This
7
+ * replaces the old up-front classifier (which mis-fired — e.g. "render a table in
8
+ * the CLI" was scaffolded as a Vite app). The host (interactive CLI) supplies
9
+ * `ctx.setupWeb`, which scaffolds the stack + deps and switches the session to the
10
+ * web gate/guidance; here we just invoke it and tell the model how to proceed.
11
+ */
12
+ export async function doScaffoldWeb(
13
+ args: Record<string, unknown>,
14
+ ctx: IToolContext
15
+ ): Promise<string> {
16
+ if (ctx.setupWeb === undefined) {
17
+ return (
18
+ "scaffold_web is unavailable here — this workspace isn't set up for " +
19
+ "interactive scaffolding. Build directly against the existing project."
20
+ );
21
+ }
22
+
23
+ const requested = str(args, "framework").toLowerCase();
24
+ const framework = requested === "vanilla" ? "vanilla" : "react";
25
+
26
+ await ctx.setupWeb(framework);
27
+
28
+ return (
29
+ `Scaffolded a ${framework} project (stack + deps installed) and switched to ` +
30
+ "the web gate. Now BUILD it: first write the type contract — each domain's " +
31
+ "`src/<domain>/<domain>.types.ts` (+ `.constants.ts`) — then implement the " +
32
+ "routes/features against those types using @/components/ui. Run the gate when " +
33
+ "done; it confirms the build, types, lint, and a browser render."
34
+ );
35
+ }
@@ -0,0 +1,126 @@
1
+ import { repairArgs } from "../../agent/tool-repair";
2
+ import type { TsService } from "../../lsp";
3
+ import type { Reporter } from "../loop.types";
4
+ import type { SessionSnapshotStore } from "../../files/hashline";
5
+
6
+ export interface IToolContext {
7
+ cwd: string;
8
+ /** Editable scope — `edit`/`create` outside it are rejected. */
9
+ files: string[];
10
+ report: Reporter;
11
+ task: string;
12
+ /** In-process TypeScript LanguageService — backs the semantic tools
13
+ * (rename/type_at/find_references/symbol_search/diagnostics/organize_imports).
14
+ * Null when the project has no tsconfig. */
15
+ tsService?: TsService | null;
16
+ /** Cancellation for the in-flight turn — passed to the `run` tool (and search)
17
+ * so a model-issued command is killed on Ctrl-C, not left running. */
18
+ signal?: AbortSignal;
19
+ /** Turn this workspace into a web project: scaffold the stack + deps and switch
20
+ * the session to the web gate/guidance. Wired by the interactive CLI so the
21
+ * AGENT decides whether to scaffold (via the `scaffold_web` tool) instead of a
22
+ * brittle up-front classifier. Absent where unsupported (headless already
23
+ * scaffolds up front), in which case the tool reports it's unavailable. */
24
+ setupWeb?: (framework: string) => Promise<void>;
25
+ /** PLAN MODE: mutating tools are rejected at dispatch and `run` only accepts
26
+ * read-only commands — the hard guarantee behind the filtered tool list (a
27
+ * salvaged/forced call could otherwise still write). */
28
+ readOnly?: boolean;
29
+ /** Hashline snapshot store for stale-anchor recovery (per-session, lazily initialized). */
30
+ snapshotStore?: SessionSnapshotStore;
31
+ }
32
+
33
+ /** A required string arg, or "" if missing/wrong-type. */
34
+ export function str(args: Record<string, unknown>, key: string): string {
35
+ const v = args[key];
36
+
37
+ return typeof v === "string" ? v : "";
38
+ }
39
+
40
+ export interface IParseResult<T> {
41
+ value: T | null;
42
+ feedback?: string; // L3 feedback when recoverable=false
43
+ }
44
+
45
+ /**
46
+ * Parse a tool's args, with VALIDATE-THEN-REPAIR: try the tool's own parser; if
47
+ * it rejects, apply the repair ladder (L0→L1→L2→L3). Emits telemetry:
48
+ * - `repair:L0:<rule>` / `repair:L1:<rule>` / `repair:L2:<rule>` per applied rule
49
+ * - `repair:L3` when re-asking (feedback included in result)
50
+ * - `tool_input_rejected:<tool>` when (rarely) no parse succeeded
51
+ * Returns both the parsed value and optional L3 feedback to surface to the model.
52
+ */
53
+ export function parseOrRepair<T>(
54
+ raw: Record<string, unknown>,
55
+ normalize: (a: Record<string, unknown>) => T | null,
56
+ ctx: IToolContext,
57
+ tool: string
58
+ ): IParseResult<T> {
59
+ const direct = normalize(raw);
60
+
61
+ if (direct !== null) {
62
+ return { value: direct };
63
+ }
64
+
65
+ const repair = repairArgs(raw);
66
+
67
+ if (repair.applied.length > 0) {
68
+ for (const rule of repair.applied) {
69
+ ctx.report({
70
+ kind: "repair",
71
+ task: ctx.task,
72
+ message: `${tool}:${rule}`,
73
+ });
74
+ }
75
+ }
76
+
77
+ const repaired = repair.applied.length > 0 ? normalize(repair.args) : null;
78
+
79
+ if (repaired !== null) {
80
+ ctx.report({
81
+ kind: "tool",
82
+ task: ctx.task,
83
+ message: `tool_input_repaired:${tool} (${repair.applied.join(", ")})`,
84
+ });
85
+
86
+ return { value: repaired };
87
+ }
88
+
89
+ // L3: If still broken after L0-L2, return feedback if provided (recoverable=false).
90
+ if (
91
+ !repair.recoverable &&
92
+ repair.feedback !== undefined &&
93
+ repair.feedback.length > 0
94
+ ) {
95
+ ctx.report({
96
+ kind: "repair",
97
+ task: ctx.task,
98
+ message: `${tool}:L3-re-ask`,
99
+ });
100
+
101
+ return { value: null, feedback: repair.feedback };
102
+ }
103
+
104
+ ctx.report({
105
+ kind: "tool",
106
+ task: ctx.task,
107
+ message: `tool_input_rejected:${tool}`,
108
+ });
109
+
110
+ return { value: null };
111
+ }
112
+
113
+ /** Log a tool rejection (scope / size / match failure) so it's measurable. */
114
+ export function reject(
115
+ ctx: IToolContext,
116
+ tool: string,
117
+ reason: string
118
+ ): string {
119
+ ctx.report({
120
+ kind: "tool",
121
+ task: ctx.task,
122
+ message: `tool_rejected:${tool} (${reason})`,
123
+ });
124
+
125
+ return reason;
126
+ }
@@ -0,0 +1,53 @@
1
+ import type { ITtsrRule } from "./ttsr";
2
+
3
+ /**
4
+ * Built-in TTSR rules: code quality patterns to abort and correct.
5
+ * All scope tool-args (source of the problem), fileGlobs target src/**\/*.ts(x).
6
+ * Each rule guides the model toward the matching gate rule.
7
+ */
8
+ export const DEFAULT_TTSR_RULES: readonly ITtsrRule[] = [
9
+ {
10
+ name: "no-as-any",
11
+ condition: [/\bas\s+any\b/],
12
+ scope: "tool-args",
13
+ fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
14
+ guidance:
15
+ "Never use 'as any'. If the type is unknown, use 'unknown' or a proper type. " +
16
+ "If the API is untyped, consider a declaration file.",
17
+ repeatMode: "cooldown",
18
+ repeatGap: 5,
19
+ },
20
+ {
21
+ name: "no-ts-suppression",
22
+ condition: [/@ts-(?:ignore|nocheck)/],
23
+ scope: "tool-args",
24
+ fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
25
+ guidance:
26
+ "Never suppress TypeScript with @ts-ignore/@ts-nocheck. Fix the real error; " +
27
+ "if the library is untyped, add a declaration file instead.",
28
+ repeatMode: "cooldown",
29
+ repeatGap: 5,
30
+ },
31
+ {
32
+ name: "no-empty-catch",
33
+ condition: [/catch\s*(?:\([^)]*\))?\s*\{\s*\}/],
34
+ scope: "tool-args",
35
+ fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
36
+ guidance:
37
+ "Empty catch blocks hide errors. Log them or handle them: " +
38
+ "catch (e) { console.error(e); } at minimum.",
39
+ repeatMode: "cooldown",
40
+ repeatGap: 5,
41
+ },
42
+ {
43
+ name: "no-console-log",
44
+ condition: [/\bconsole\.(?:log|debug)\s*\(/],
45
+ scope: "tool-args",
46
+ fileGlobs: ["src/**/*.ts", "src/**/*.tsx"],
47
+ guidance:
48
+ "Remove console.log/debug before shipping. Use a logger or remove the line. " +
49
+ "Tests can call console.log; production code must not.",
50
+ repeatMode: "cooldown",
51
+ repeatGap: 5,
52
+ },
53
+ ];
@@ -0,0 +1,322 @@
1
+ import { isArray, isRecord } from "../lib/guards";
2
+ import { matchesAnyGlobPattern } from "../rule-packs/utils";
3
+
4
+ /**
5
+ * A TTSR rule — triggers on matching stream text, aborts generation, and injects
6
+ * corrective guidance on retry. Rules watch specific channels (prose vs tool args)
7
+ * and optionally scope to files matching a glob.
8
+ */
9
+ export interface ITtsrRule {
10
+ readonly name: string;
11
+ /** Regex patterns (as source strings for JSON, or RegExp objects for code).
12
+ * Compiled once at manager init. Each is OR'd; any match fires. */
13
+ readonly condition: readonly (string | RegExp)[];
14
+ /** Which stream channel(s) to monitor: "content" (prose), "tool-args" (tool call
15
+ * arguments being assembled), or "both". */
16
+ readonly scope: "content" | "tool-args" | "both";
17
+ /** Optional file globs: for tool-args scope, only match when the edit/create
18
+ * targets a file path matching one of these globs. */
19
+ readonly fileGlobs?: readonly string[];
20
+ /** Guidance text (≤300 chars) appended to the corrective message on retry. */
21
+ readonly guidance: string;
22
+ /** "once" = fires at most once per session; "cooldown" = re-arms after repeatGap
23
+ * turns without a match. */
24
+ readonly repeatMode: "once" | "cooldown";
25
+ /** Gap (in completed turns) before a "cooldown"-mode rule can re-fire. */
26
+ readonly repeatGap?: number;
27
+ }
28
+
29
+ /** Match context: information about the stream being monitored. */
30
+ export interface IMatchContext {
31
+ source: "content" | "tool-args";
32
+ /** Current file being edited/created (when tool-args scope), if available. */
33
+ currentFile?: string;
34
+ }
35
+
36
+ /**
37
+ * Manages TTSR rule matching and firing. Keeps a rolling buffer per scope/channel
38
+ * to catch patterns spanning chunk boundaries; respects repeat policy + scope gates.
39
+ */
40
+ export class TtsrManager {
41
+ private disabled = false;
42
+
43
+ private readonly rules: Map<string, ITtsrRule> = new Map<string, ITtsrRule>();
44
+ private readonly compiledConditions: Map<string, RegExp[]> = new Map<
45
+ string,
46
+ RegExp[]
47
+ >();
48
+ private buffers: Record<"content" | "tool-args", string> = {
49
+ content: "",
50
+ "tool-args": "",
51
+ };
52
+ private readonly turnCounts: Map<string, number> = new Map<string, number>();
53
+ private messageCount = 0;
54
+
55
+ /** Max chars to retain in rolling buffer (patterns spanning ~10 chunks). */
56
+ private readonly BUFFER_SIZE = 512;
57
+
58
+ /**
59
+ * Register a rule. Validates regex conditions; skips invalid or duplicate rules.
60
+ * Returns true if registered, false if skipped.
61
+ */
62
+ addRule(rule: ITtsrRule): boolean {
63
+ if (this.rules.has(rule.name)) {
64
+ return false;
65
+ }
66
+
67
+ const compiled: RegExp[] = [];
68
+
69
+ for (const pattern of rule.condition) {
70
+ try {
71
+ if (pattern instanceof RegExp) {
72
+ compiled.push(pattern);
73
+ } else {
74
+ compiled.push(new RegExp(pattern));
75
+ }
76
+ } catch {
77
+ // Invalid regex — skip this rule entirely.
78
+ return false;
79
+ }
80
+ }
81
+
82
+ if (compiled.length === 0) {
83
+ return false;
84
+ }
85
+
86
+ this.rules.set(rule.name, rule);
87
+ this.compiledConditions.set(rule.name, compiled);
88
+
89
+ return true;
90
+ }
91
+
92
+ /** Feed a delta into the rolling buffer and check all rules. Returns the first
93
+ * matching rule or null. Accumulates text into buffers per source channel. */
94
+ /** Permanently stop matching (used when the per-task interrupt cap is hit). */
95
+ disable(): void {
96
+ this.disabled = true;
97
+ }
98
+
99
+ checkDelta(text: string, context: IMatchContext): ITtsrRule | null {
100
+ if (this.disabled) {
101
+ return null;
102
+ }
103
+
104
+ const bufferKey = context.source;
105
+
106
+ this.buffers[bufferKey] += text;
107
+
108
+ // Trim buffer to max size, keeping the tail (so patterns spanning boundaries match).
109
+ if (this.buffers[bufferKey].length > this.BUFFER_SIZE) {
110
+ this.buffers[bufferKey] = this.buffers[bufferKey].slice(
111
+ -this.BUFFER_SIZE
112
+ );
113
+ }
114
+
115
+ const buf = this.buffers[bufferKey];
116
+
117
+ // Check all rules; return first match that passes repeat policy and scope gates.
118
+ for (const [ruleName, rule] of this.rules) {
119
+ if (!this.isScopeActive(rule, context)) {
120
+ continue;
121
+ }
122
+
123
+ if (!this.isFileScopeSatisfied(rule, context)) {
124
+ continue;
125
+ }
126
+
127
+ if (!this.isRepeatEligible(ruleName, rule)) {
128
+ continue;
129
+ }
130
+
131
+ if (this.doesMatch(ruleName, buf)) {
132
+ return rule;
133
+ }
134
+ }
135
+
136
+ return null;
137
+ }
138
+
139
+ /** Mark a rule as fired at the current turn (for repeat policy tracking). */
140
+ markFired(ruleName: string, turn: number): void {
141
+ this.turnCounts.set(ruleName, turn);
142
+ }
143
+
144
+ /** Increment message count (call on turn_end for cooldown gap accounting). */
145
+ incrementTurnCount(): void {
146
+ this.messageCount += 1;
147
+ }
148
+
149
+ /** Reset the rolling buffers (call on turn start so each turn's patterns start fresh). */
150
+ resetBuffer(): void {
151
+ this.buffers.content = "";
152
+ this.buffers["tool-args"] = "";
153
+ }
154
+
155
+ /** fileGlobs constrain tool-args matches to edits of matching files. An
156
+ * unknown target (path not yet streamed) holds fire — conservative. */
157
+ private isFileScopeSatisfied(
158
+ rule: ITtsrRule,
159
+ context: IMatchContext
160
+ ): boolean {
161
+ if (rule.fileGlobs === undefined || rule.fileGlobs.length === 0) {
162
+ return true;
163
+ }
164
+
165
+ if (context.source !== "tool-args") {
166
+ return true;
167
+ }
168
+
169
+ if (context.currentFile === undefined) {
170
+ return false;
171
+ }
172
+
173
+ return matchesAnyGlobPattern(context.currentFile, rule.fileGlobs);
174
+ }
175
+
176
+ /** Check if a rule's scope includes the current source. */
177
+ private isScopeActive(rule: ITtsrRule, context: IMatchContext): boolean {
178
+ if (rule.scope === "both") {
179
+ return true;
180
+ }
181
+
182
+ return rule.scope === context.source;
183
+ }
184
+
185
+ /** Check if a rule is eligible to fire based on repeat policy. */
186
+ private isRepeatEligible(ruleName: string, rule: ITtsrRule): boolean {
187
+ const lastFired = this.turnCounts.get(ruleName);
188
+
189
+ if (lastFired === undefined) {
190
+ return true; // Never fired before.
191
+ }
192
+
193
+ if (rule.repeatMode === "once") {
194
+ return false; // Fired once, never again.
195
+ }
196
+
197
+ const gap = rule.repeatGap ?? 1;
198
+
199
+ return this.messageCount - lastFired >= gap;
200
+ }
201
+
202
+ /** Check if any regex condition matches the buffer. */
203
+ private doesMatch(ruleName: string, buf: string): boolean {
204
+ const conditions = this.compiledConditions.get(ruleName);
205
+
206
+ if (!conditions) {
207
+ return false;
208
+ }
209
+
210
+ return conditions.some((re) => re.test(buf));
211
+ }
212
+ }
213
+
214
+ /** Extract scope from parsed item, validating the value. */
215
+ function extractScope(scopeValue: unknown): "content" | "tool-args" | "both" {
216
+ if (
217
+ scopeValue === "tool-args" ||
218
+ scopeValue === "content" ||
219
+ scopeValue === "both"
220
+ ) {
221
+ return scopeValue;
222
+ }
223
+
224
+ return "content";
225
+ }
226
+
227
+ /** Extract conditions (regex patterns) from parsed item. */
228
+ function extractConditions(conditionValue: unknown): string[] {
229
+ if (isArray(conditionValue)) {
230
+ return conditionValue.filter((x) => typeof x === "string");
231
+ }
232
+
233
+ if (typeof conditionValue === "string") {
234
+ return [conditionValue];
235
+ }
236
+
237
+ return [];
238
+ }
239
+
240
+ /** Extract optional file globs from parsed item. */
241
+ function extractFileGlobs(fileGlobsValue: unknown): string[] | undefined {
242
+ if (!isArray(fileGlobsValue)) {
243
+ return undefined;
244
+ }
245
+
246
+ const globs = fileGlobsValue.filter((x) => typeof x === "string");
247
+
248
+ return globs.length > 0 ? globs : undefined;
249
+ }
250
+
251
+ /** Parse .tsforge/rules.json (optional project rules). */
252
+ export function parseProjectRules(content: string): ITtsrRule[] {
253
+ let parsed: unknown;
254
+
255
+ try {
256
+ parsed = JSON.parse(content);
257
+ } catch {
258
+ return [];
259
+ }
260
+
261
+ if (!isArray(parsed)) {
262
+ return [];
263
+ }
264
+
265
+ const rules: ITtsrRule[] = [];
266
+
267
+ for (const item of parsed) {
268
+ if (!isRecord(item) || typeof item.name !== "string") {
269
+ continue;
270
+ }
271
+
272
+ const condition = extractConditions(item.condition);
273
+ const guidance = typeof item.guidance === "string" ? item.guidance : "";
274
+
275
+ if (condition.length === 0 || guidance.length === 0) {
276
+ continue;
277
+ }
278
+
279
+ const scope = extractScope(item.scope);
280
+ const repeatMode = item.repeatMode === "cooldown" ? "cooldown" : "once";
281
+ const repeatGap =
282
+ typeof item.repeatGap === "number" && item.repeatGap >= 0
283
+ ? Math.floor(item.repeatGap)
284
+ : undefined;
285
+ const fileGlobs = extractFileGlobs(item.fileGlobs);
286
+
287
+ const rule = createRule(
288
+ item.name,
289
+ condition,
290
+ scope,
291
+ guidance,
292
+ repeatMode,
293
+ repeatGap,
294
+ fileGlobs
295
+ );
296
+
297
+ rules.push(rule);
298
+ }
299
+
300
+ return rules;
301
+ }
302
+
303
+ /** Create a rule from parsed/validated components. */
304
+ function createRule(
305
+ name: string,
306
+ condition: readonly (string | RegExp)[],
307
+ scope: "content" | "tool-args" | "both",
308
+ guidance: string,
309
+ repeatMode: "once" | "cooldown",
310
+ repeatGap: number | undefined,
311
+ fileGlobs: string[] | undefined
312
+ ): ITtsrRule {
313
+ return {
314
+ name,
315
+ condition,
316
+ scope,
317
+ guidance,
318
+ repeatMode,
319
+ ...(fileGlobs && fileGlobs.length > 0 ? { fileGlobs } : {}),
320
+ ...(repeatGap !== undefined && repeatGap > 0 ? { repeatGap } : {}),
321
+ };
322
+ }