@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,146 @@
1
+ /** Drain a spawned process's piped stdout + stderr to strings (the one place
2
+ * this Bun pattern lives). Pass `proc.stdout, proc.stderr` from a process
3
+ * spawned with `stdout: "pipe", stderr: "pipe"`. */
4
+ export async function readProcessOutput(
5
+ stdout: ReadableStream<Uint8Array>,
6
+ stderr: ReadableStream<Uint8Array>
7
+ ): Promise<{ stdout: string; stderr: string }> {
8
+ const [out, err] = await Promise.all([
9
+ new Response(stdout).text(),
10
+ new Response(stderr).text(),
11
+ ]);
12
+
13
+ return { stdout: out, stderr: err };
14
+ }
15
+
16
+ /** Options for `runShellCommand` — cancellation, an optional kill-timeout, and
17
+ * optional live output streaming. */
18
+ export interface IShellRunOptions {
19
+ /** Abort signal — when it fires, the child process is killed. */
20
+ signal?: AbortSignal;
21
+ /** Kill the process after this many ms (0 / omitted = no timeout). */
22
+ timeoutMs?: number;
23
+ /** Forward each decoded output chunk live; omit to just capture. */
24
+ onChunk?: (text: string) => void;
25
+ }
26
+
27
+ /** Result of `runShellCommand` — captured streams + how it ended. */
28
+ export interface IShellRun {
29
+ stdout: string;
30
+ stderr: string;
31
+ exitCode: number;
32
+ /** True when the kill-timeout fired (vs the command exiting on its own). */
33
+ timedOut: boolean;
34
+ }
35
+
36
+ /**
37
+ * Spawn `sh -c command` in `cwd` and capture stdout/stderr — the ONE place that
38
+ * runs a shell command for the harness (the `run` tool and the gate both route
39
+ * here), so cancellation and the kill-timeout are enforced uniformly. A pending
40
+ * `signal` abort or an elapsed `timeoutMs` kills the child (otherwise a model-
41
+ * issued `vite dev`/`tail -f`/hung test would wedge the harness with no escape).
42
+ * `onChunk` streams output live; without it output is just captured.
43
+ */
44
+ export async function runShellCommand(
45
+ cwd: string,
46
+ command: string,
47
+ opts: IShellRunOptions = {}
48
+ ): Promise<IShellRun> {
49
+ return runArgvCommand(cwd, ["sh", "-c", command], opts);
50
+ }
51
+
52
+ /**
53
+ * Like `runShellCommand`, but spawns an explicit argv with NO shell — so
54
+ * arguments are passed literally and can't be expanded/injected (`$()`, backticks,
55
+ * globbing). Use this for any command built from model- or content-supplied
56
+ * values (e.g. ripgrep patterns). A missing binary resolves to exit 127, not a
57
+ * throw, so callers can degrade gracefully.
58
+ */
59
+ export async function runArgvCommand(
60
+ cwd: string,
61
+ argv: string[],
62
+ opts: IShellRunOptions = {}
63
+ ): Promise<IShellRun> {
64
+ const { signal, timeoutMs = 0, onChunk } = opts;
65
+
66
+ let proc: Bun.Subprocess<"ignore", "pipe", "pipe">;
67
+
68
+ try {
69
+ proc = Bun.spawn(argv, { cwd, stdout: "pipe", stderr: "pipe" });
70
+ } catch (err) {
71
+ const message = err instanceof Error ? err.message : String(err);
72
+
73
+ return { stdout: "", stderr: message, exitCode: 127, timedOut: false };
74
+ }
75
+
76
+ let timedOut = false;
77
+ const timer =
78
+ timeoutMs > 0
79
+ ? setTimeout(() => {
80
+ timedOut = true;
81
+ proc.kill();
82
+ }, timeoutMs)
83
+ : null;
84
+
85
+ const onAbort = (): void => {
86
+ proc.kill();
87
+ };
88
+
89
+ if (signal !== undefined) {
90
+ if (signal.aborted) {
91
+ proc.kill();
92
+ } else {
93
+ signal.addEventListener("abort", onAbort, { once: true });
94
+ }
95
+ }
96
+
97
+ const decoder = new TextDecoder();
98
+ const buf: { out: string; err: string } = { out: "", err: "" };
99
+ // Read through a closure so control-flow analysis treats these as `boolean`
100
+ // (the setTimeout/abort mutations are invisible to it, else it narrows to
101
+ // literal `false` and the kill branch reads as dead code).
102
+ const wasKilled = (): boolean => timedOut || (signal?.aborted ?? false);
103
+
104
+ const pump = async (
105
+ stream: ReadableStream<Uint8Array>,
106
+ key: "out" | "err"
107
+ ): Promise<void> => {
108
+ for await (const bytes of stream) {
109
+ const text = decoder.decode(bytes, { stream: true });
110
+
111
+ buf[key] += text;
112
+
113
+ if (onChunk !== undefined) {
114
+ onChunk(text);
115
+ }
116
+ }
117
+ };
118
+
119
+ try {
120
+ const pumps = Promise.all([
121
+ pump(proc.stdout, "out"),
122
+ pump(proc.stderr, "err"),
123
+ ]);
124
+ const exitCode = await proc.exited;
125
+
126
+ // A KILLED process can leave its piped streams open in Bun, so the pumps
127
+ // would hang forever — flush briefly, then return what we captured. On a
128
+ // normal exit the streams close and the pumps resolve on their own.
129
+ await (wasKilled()
130
+ ? Promise.race([
131
+ pumps,
132
+ new Promise<void>((resolve) => setTimeout(resolve, 100)),
133
+ ])
134
+ : pumps);
135
+
136
+ return { stdout: buf.out, stderr: buf.err, exitCode, timedOut };
137
+ } finally {
138
+ if (timer !== null) {
139
+ clearTimeout(timer);
140
+ }
141
+
142
+ if (signal !== undefined) {
143
+ signal.removeEventListener("abort", onAbort);
144
+ }
145
+ }
146
+ }
@@ -0,0 +1,9 @@
1
+ /** Narrow `unknown` to a record without a type assertion. */
2
+ export function isRecord(value: unknown): value is Record<string, unknown> {
3
+ return typeof value === "object" && value !== null;
4
+ }
5
+
6
+ /** Narrow `unknown` to an array of `unknown` (not `any[]`). */
7
+ export function isArray(value: unknown): value is unknown[] {
8
+ return Array.isArray(value);
9
+ }
@@ -0,0 +1 @@
1
+ export * from "./guards";
@@ -0,0 +1 @@
1
+ export * from "./json";
@@ -0,0 +1,12 @@
1
+ /** Pull a JSON object out of a fenced ```json block or raw text. */
2
+ export function extractJson(text: string): string {
3
+ const fenced = /```(?:json)?\s*([\s\S]*?)```/.exec(text);
4
+
5
+ if (fenced?.[1] !== undefined) {
6
+ return fenced[1];
7
+ }
8
+
9
+ const braced = /\{[\s\S]*\}/.exec(text);
10
+
11
+ return braced?.[0] ?? text;
12
+ }
@@ -0,0 +1,2 @@
1
+ export * from "./scope";
2
+ export * from "./scope.constants";
@@ -0,0 +1,3 @@
1
+ /** A throwaway prefix the model may always write to — `scratch/` experiments are
2
+ * ignored by the gate, so it can test hypotheses by running code. */
3
+ export const SCRATCH_PREFIX = "scratch/";
@@ -0,0 +1,40 @@
1
+ import { resolve, relative } from "node:path";
2
+ import { SCRATCH_PREFIX } from "./scope.constants";
3
+
4
+ /**
5
+ * Normalize a model-supplied path against the workspace root, fixing the common
6
+ * small-model footguns that otherwise nest files wrongly: an ABSOLUTE path inside
7
+ * the workspace, or a RELATIVE path that redundantly repeats the workspace
8
+ * location (`agjs/code/app/x.ts` while cwd is `/agjs/code/app` → without this it
9
+ * lands at `…/app/agjs/code/app/x.ts`). Returns a path relative to `cwd`; a path
10
+ * that escapes the workspace comes back with `../` and is then rejected by scope.
11
+ */
12
+ export function normalizeWorkspacePath(cwd: string, file: string): string {
13
+ const cwdNoSlash = cwd.replace(/^\/+/, "");
14
+ let candidate = file;
15
+
16
+ if (candidate.startsWith(`${cwd}/`)) {
17
+ candidate = candidate.slice(cwd.length + 1);
18
+ } else if (candidate.startsWith(`${cwdNoSlash}/`)) {
19
+ candidate = candidate.slice(cwdNoSlash.length + 1);
20
+ }
21
+
22
+ return relative(cwd, resolve(cwd, candidate));
23
+ }
24
+
25
+ /** True when `file` matches any of the glob `patterns` (the editable scope). */
26
+ export function isInScope(file: string, patterns: string[]): boolean {
27
+ return patterns.some((pattern) => new Bun.Glob(pattern).match(file));
28
+ }
29
+
30
+ /** A file the model may write: its editable scope, OR a throwaway scratch file.
31
+ * A path that escapes the workspace (`../…`) or is absolute is NEVER writable —
32
+ * a recursive glob would otherwise match a traversal path. Normalize with
33
+ * `normalizeWorkspacePath` first so this sees the workspace-relative form. */
34
+ export function writable(file: string, patterns: string[]): boolean {
35
+ if (file.startsWith("..") || file.startsWith("/")) {
36
+ return false;
37
+ }
38
+
39
+ return isInScope(file, patterns) || file.startsWith(SCRATCH_PREFIX);
40
+ }
@@ -0,0 +1,228 @@
1
+ import { join } from "node:path";
2
+ import { readProcessOutput } from "../lib/fs";
3
+
4
+ /**
5
+ * Deterministic, SAFE strict-TS idiom rewrites via ast-grep (structural, not
6
+ * regex — it won't match inside strings/comments, and it understands the AST).
7
+ * This is the "tsFixAll, but for idioms": mechanical quality fixes the model
8
+ * shouldn't burn turns on. The `tsc -p` gate stays the authority — a rewrite
9
+ * that broke anything would fail it.
10
+ *
11
+ * ONLY semantics-preserving rewrites belong here. `new Array(n).fill(x)` →
12
+ * `Array.from({length:n},()=>x)` is safe (same array, but typed `T[]` instead of
13
+ * `any[]`, which is the whole point). Loop restructures (bind-and-guard →
14
+ * `.entries()`) are NOT safe blind codemods — those stay as guidance.
15
+ */
16
+ interface IRewrite {
17
+ pattern: string;
18
+ rewrite: string;
19
+ note: string;
20
+ }
21
+
22
+ const SAFE_REWRITES: readonly IRewrite[] = [
23
+ {
24
+ pattern: "new Array($N).fill($X)",
25
+ rewrite: "Array.from({ length: $N }, () => $X)",
26
+ note: "new Array().fill() is any[] under strict; Array.from is typed",
27
+ },
28
+ ];
29
+
30
+ /**
31
+ * Drop a redundant type annotation on a `const` whose initializer already makes
32
+ * the type obvious to inference — the over-annotation a reviewer flags but no
33
+ * stock eslint rule catches (`no-inferrable-types` fires only on LITERAL
34
+ * initializers, never on calls/expressions). Structural, so it can't misfire on
35
+ * strings/comments. The `constraints` exclude the cases where dropping the
36
+ * annotation would CHANGE the inferred type: an empty/array/object literal
37
+ * (`number[] = []` infers `never[]`), `null`, or a function/arrow (loses its
38
+ * contextual parameter types under strict). Even so this is only HALF the
39
+ * safety — the caller re-runs the full gate and reverts the file if anything
40
+ * regressed, so an unsafe drop we didn't foresee can never ship.
41
+ */
42
+ const DROP_ANNOTATION_RULE = `
43
+ id: drop-redundant-annotation
44
+ language: typescript
45
+ rule:
46
+ pattern: 'const $A: $T = $B'
47
+ constraints:
48
+ B:
49
+ not:
50
+ any:
51
+ - { kind: array }
52
+ - { kind: object }
53
+ - { kind: 'null' }
54
+ - { kind: arrow_function }
55
+ - { kind: function_expression }
56
+ fix: 'const $A = $B'
57
+ `;
58
+
59
+ /**
60
+ * Strip the model's #1 needless `as` cast: a LITERAL annotated with its own union
61
+ * type in data — e.g. `"open" as Status` or `200 as HttpCode`. A
62
+ * string/number/boolean literal that's a member of the target union is ALREADY
63
+ * assignable, so the cast is pure ceremony the gate rejects anyway. The model writes
64
+ * these reflexively (a deep TS prior) on the very first file and then burns turns
65
+ * removing them; stripping deterministically means it never has to. SAFE by
66
+ * construction: only string/number/true/false operands (not identifiers, so a real
67
+ * value-cast like `x as Foo` is left for the model to narrow), and `as const` is
68
+ * excluded. tsc re-validates — if a literal genuinely wasn't assignable, the real
69
+ * error surfaces (better than a cast hiding it).
70
+ */
71
+ const STRIP_LITERAL_CAST_RULE = `
72
+ id: strip-literal-cast
73
+ language: typescript
74
+ rule:
75
+ pattern: $LIT as $TYPE
76
+ constraints:
77
+ LIT:
78
+ any:
79
+ - { kind: string }
80
+ - { kind: number }
81
+ - { kind: 'true' }
82
+ - { kind: 'false' }
83
+ TYPE:
84
+ not: { regex: '^const$' }
85
+ fix: $LIT
86
+ `;
87
+
88
+ const REPO_ROOT = join(import.meta.dir, "..", "..", "..", "..");
89
+ const AST_GREP = join(REPO_ROOT, "node_modules", ".bin", "ast-grep");
90
+
91
+ async function astGrep(
92
+ args: string[]
93
+ ): Promise<{ stdout: string; ok: boolean }> {
94
+ const proc = Bun.spawn([AST_GREP, ...args], {
95
+ stdout: "pipe",
96
+ stderr: "pipe",
97
+ });
98
+ const { stdout } = await readProcessOutput(proc.stdout, proc.stderr);
99
+ const code = await proc.exited;
100
+
101
+ return { stdout, ok: code === 0 };
102
+ }
103
+
104
+ /**
105
+ * Apply the safe idiom rewrites to `absFile` in place. Returns how many matches
106
+ * were rewritten. No-ops (returns 0) if ast-grep isn't available, so it can
107
+ * never break the loop — the gate re-validates regardless.
108
+ */
109
+ export async function astGrepFix(absFile: string): Promise<number> {
110
+ if (!(await Bun.file(AST_GREP).exists())) {
111
+ return 0;
112
+ }
113
+
114
+ let applied = 0;
115
+
116
+ for (const r of SAFE_REWRITES) {
117
+ // Count matches first (JSON), then apply — so we can report an exact count.
118
+ const found = await astGrep([
119
+ "run",
120
+ "-p",
121
+ r.pattern,
122
+ "-l",
123
+ "ts",
124
+ "--json",
125
+ absFile,
126
+ ]);
127
+
128
+ if (!found.ok) {
129
+ continue;
130
+ }
131
+
132
+ let matches = 0;
133
+
134
+ try {
135
+ const parsed: unknown = JSON.parse(found.stdout);
136
+
137
+ matches = Array.isArray(parsed) ? parsed.length : 0;
138
+ } catch {
139
+ // non-JSON output → leave matches at 0
140
+ }
141
+
142
+ if (matches === 0) {
143
+ continue;
144
+ }
145
+
146
+ const done = await astGrep([
147
+ "run",
148
+ "-p",
149
+ r.pattern,
150
+ "--rewrite",
151
+ r.rewrite,
152
+ "-l",
153
+ "ts",
154
+ "--update-all",
155
+ absFile,
156
+ ]);
157
+
158
+ if (done.ok) {
159
+ applied += matches;
160
+ }
161
+ }
162
+
163
+ return applied;
164
+ }
165
+
166
+ /**
167
+ * Apply an inline ast-grep RULE (with constraints) to `absFile` in place: count the
168
+ * constraint-filtered matches, then rewrite them, returning the count. No-ops to 0
169
+ * if ast-grep is absent. NOT verified here — a caller that does an unsafe rewrite
170
+ * MUST re-gate (the full `tsc`/eslint gate is the authority and reverts can't ship).
171
+ */
172
+ async function applyInlineRule(rule: string, absFile: string): Promise<number> {
173
+ if (!(await Bun.file(AST_GREP).exists())) {
174
+ return 0;
175
+ }
176
+
177
+ const found = await astGrep([
178
+ "scan",
179
+ "--inline-rules",
180
+ rule,
181
+ "--json=compact",
182
+ absFile,
183
+ ]);
184
+
185
+ if (!found.ok) {
186
+ return 0;
187
+ }
188
+
189
+ let matches: number;
190
+
191
+ try {
192
+ const parsed: unknown = JSON.parse(found.stdout);
193
+
194
+ matches = Array.isArray(parsed) ? parsed.length : 0;
195
+ } catch {
196
+ return 0;
197
+ }
198
+
199
+ if (matches === 0) {
200
+ return 0;
201
+ }
202
+
203
+ const done = await astGrep([
204
+ "scan",
205
+ "--inline-rules",
206
+ rule,
207
+ "--update-all",
208
+ absFile,
209
+ ]);
210
+
211
+ return done.ok ? matches : 0;
212
+ }
213
+
214
+ /**
215
+ * Strip redundant `const` type annotations in `absFile` (see DROP_ANNOTATION_RULE).
216
+ * Caller MUST re-gate and revert on regression; this only performs the rewrite.
217
+ */
218
+ export async function dropRedundantAnnotations(
219
+ absFile: string
220
+ ): Promise<number> {
221
+ return applyInlineRule(DROP_ANNOTATION_RULE, absFile);
222
+ }
223
+
224
+ /** Strip needless literal-to-union `as` casts in `absFile` (see the rule's note).
225
+ * Safe by construction; the gate re-validates regardless. */
226
+ export async function stripLiteralCasts(absFile: string): Promise<number> {
227
+ return applyInlineRule(STRIP_LITERAL_CAST_RULE, absFile);
228
+ }
@@ -0,0 +1,138 @@
1
+ import { join, basename, isAbsolute } from "node:path";
2
+ import type { ITask } from "../../spec";
3
+ import type { ErrorSet } from "../../validate";
4
+ import type { IMetaRuleViolation } from "../../meta-rules";
5
+ import { isInScope } from "../../lib/scope";
6
+ import { readFiles } from "../../lib/fs";
7
+ import { ruleHelp, idiomHints } from "../feedback/rule-docs";
8
+ import {
9
+ metaRuleHelp,
10
+ renderMetaViolations,
11
+ } from "../feedback/meta-rule-feedback";
12
+
13
+ /** Cap rendered source lines so a large error set can't wall the model. */
14
+ const FEEDBACK_MAX_LINES = 20;
15
+
16
+ /**
17
+ * Gate failures the model can act on (its editable files), each rendered WITH
18
+ * its location and the offending source line — so the model fixes the exact
19
+ * spot instead of reading the file and hand-counting to find it (which it did
20
+ * for 3 turns on `money` when feedback was message-only). Plus the rules' fix
21
+ * examples. Async because it reads the source lines from disk.
22
+ */
23
+ export async function gateFeedback(
24
+ errors: ErrorSet,
25
+ task: ITask,
26
+ cwd: string,
27
+ metaViolations: readonly IMetaRuleViolation[] = []
28
+ ): Promise<string> {
29
+ const own = errors.filter(
30
+ (e) =>
31
+ e.file === undefined ||
32
+ isInScope(e.file, task.files) ||
33
+ isInScope(basename(e.file), task.files)
34
+ );
35
+ const readOnly = errors.length - own.length;
36
+
37
+ const list =
38
+ own.length > 0
39
+ ? await renderErrors(own.slice(0, FEEDBACK_MAX_LINES), cwd)
40
+ : "(no failures in your editable files)";
41
+ const capped =
42
+ own.length > FEEDBACK_MAX_LINES
43
+ ? `\n… and ${own.length - FEEDBACK_MAX_LINES} more — fix the above first.`
44
+ : "";
45
+
46
+ const note =
47
+ readOnly > 0
48
+ ? `\n(${readOnly} other error(s) are in read-only files — not yours to fix; they resolve once your files are correct.)`
49
+ : "";
50
+
51
+ const help = ruleHelp(own);
52
+ const helpBlock =
53
+ help.length > 0 ? `\n\nHow to satisfy the gate:\n${help}` : "";
54
+
55
+ const sources = await readFiles(cwd, task.files);
56
+ const idioms = idiomHints(
57
+ sources.map((s) => s.content),
58
+ own
59
+ );
60
+ const idiomBlock =
61
+ idioms.length > 0 ? `\n\nWatch for these strict-TS idioms:\n${idioms}` : "";
62
+
63
+ // Render meta-rule violations (project structure violations)
64
+ const metaViolationsList =
65
+ metaViolations.length > 0 ? renderMetaViolations(metaViolations) : "";
66
+ const metaBlock =
67
+ metaViolationsList.length > 0
68
+ ? `\n\n## Project structure\n${metaViolationsList}`
69
+ : "";
70
+
71
+ const metaHelp = metaRuleHelp(metaViolations);
72
+ const metaHelpBlock = metaHelp.length > 0 ? `\n${metaHelp}` : "";
73
+
74
+ // Tool-use lapse guard: if an editable file doesn't exist, the model likely
75
+ // wrote the code as message TEXT instead of calling `create`. Code in your
76
+ // reply is NEVER applied — only tool calls touch disk. Say so explicitly.
77
+ const present = new Set(sources.map((s) => s.path));
78
+ const missing = task.files.filter((f) => !present.has(f));
79
+ const missingBlock =
80
+ missing.length > 0
81
+ ? `\n\n⚠ These editable files do NOT exist yet: ${missing.join(", ")}. ` +
82
+ "Code written in your message text is NOT applied — you MUST call the " +
83
+ "`create` tool with the file path and full content."
84
+ : "";
85
+
86
+ return `The acceptance command still fails:\n${list}${capped}${note}${helpBlock}${idiomBlock}${metaBlock}${metaHelpBlock}${missingBlock}\n\nFix your editable files and run it again.`;
87
+ }
88
+
89
+ /**
90
+ * Render each error as `- file:line [rule] message` followed by the offending
91
+ * source line, so the model sees the exact code to change. Reads each file once
92
+ * (cached); falls back to the bare message when there's no location.
93
+ */
94
+ async function renderErrors(errors: ErrorSet, cwd: string): Promise<string> {
95
+ const sources = new Map<string, string[]>();
96
+
97
+ const linesOf = async (file: string): Promise<string[]> => {
98
+ const cached = sources.get(file);
99
+
100
+ if (cached !== undefined) {
101
+ return cached;
102
+ }
103
+
104
+ const abs = isAbsolute(file) ? file : join(cwd, file);
105
+ const handle = Bun.file(abs);
106
+ const lines = (await handle.exists())
107
+ ? (await handle.text()).split("\n")
108
+ : [];
109
+
110
+ sources.set(file, lines);
111
+
112
+ return lines;
113
+ };
114
+
115
+ const rendered: string[] = [];
116
+
117
+ for (const e of errors) {
118
+ const loc =
119
+ e.file !== undefined && e.line !== undefined
120
+ ? `${basename(e.file)}:${e.line} `
121
+ : "";
122
+ const rule = e.rule !== undefined ? `[${e.rule}] ` : "";
123
+ const head = `- ${loc}${rule}${e.message}`;
124
+
125
+ if (e.file !== undefined && e.line !== undefined) {
126
+ const src = (await linesOf(e.file))[e.line - 1];
127
+
128
+ if (src !== undefined && src.trim().length > 0) {
129
+ rendered.push(`${head}\n ${e.line} │ ${src.trim()}`);
130
+ continue;
131
+ }
132
+ }
133
+
134
+ rendered.push(head);
135
+ }
136
+
137
+ return rendered.join("\n");
138
+ }
@@ -0,0 +1,8 @@
1
+ export { gateFeedback } from "./feedback";
2
+ export {
3
+ ruleHelp,
4
+ idiomHints,
5
+ ruleHelpFromOutput,
6
+ parseRuleMdx,
7
+ qualityHints,
8
+ } from "./rule-docs";
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Documentation for meta-rules: project structure and config guardrails.
3
+ * Each rule is keyed by its ID and includes a one-sentence explanation of the fix.
4
+ */
5
+
6
+ export const META_RULE_DOCS: Record<string, string> = {
7
+ // Supply chain
8
+ "package-exact-deps":
9
+ "Use exact versions in dependencies and devDependencies (no ^, ~, or ranges); only peerDependencies should use ranges.",
10
+
11
+ "no-overlapping-libs":
12
+ "Remove duplicate or conflicting library versions from the dependency tree; only one canonical version per library is allowed.",
13
+
14
+ // Source text
15
+ "no-eslint-disable-comments":
16
+ "Remove `// eslint-disable` comments — they hide warnings. Fix the underlying violation or refactor the code.",
17
+
18
+ "no-ts-suppressions":
19
+ "Remove `// @ts-ignore` and `// @ts-expect-error` comments. Use proper type guards or narrowing instead of suppressing type errors.",
20
+
21
+ // Config
22
+ "tsconfig-paths-exist":
23
+ "Verify that all paths defined in tsconfig.json point to existing files or directories; remove non-existent paths.",
24
+
25
+ "tsconfig-strict":
26
+ "Enable all strict mode flags in tsconfig.json (strict: true or all strict flags individually).",
27
+
28
+ // Testing
29
+ "test-sibling-required":
30
+ "Add a test file for each source file; follow naming conventions (foo.ts → foo.test.ts or foo.spec.ts).",
31
+
32
+ // CI
33
+ "workflow-actions-pinned":
34
+ "Pin GitHub Actions to specific versions in .github/workflows/*.yml (e.g., `actions/checkout@v4`, not `@main`).",
35
+
36
+ "workflow-runner-pinned":
37
+ "Specify an exact runner version in GitHub Actions workflows (e.g., `ubuntu-22.04`, not `ubuntu-latest`).",
38
+
39
+ "workflow-timeout-required":
40
+ "Add a timeout-minutes setting to each GitHub Actions job to prevent hanging workflows.",
41
+ };