@agjs/tsforge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tsforge.js +2 -0
- package/package.json +35 -0
- package/src/agent/agent.constants.ts +382 -0
- package/src/agent/agent.types.ts +34 -0
- package/src/agent/index.ts +4 -0
- package/src/agent/model-agent.ts +297 -0
- package/src/agent/tool-repair.ts +194 -0
- package/src/agent/tools.ts +190 -0
- package/src/browser/checks.ts +96 -0
- package/src/browser/index.ts +8 -0
- package/src/browser/oracle.ts +303 -0
- package/src/classify.ts +48 -0
- package/src/cli.ts +1333 -0
- package/src/config/config.constants.ts +9 -0
- package/src/config/flags.ts +32 -0
- package/src/config/index.ts +8 -0
- package/src/config/tsforge-config.ts +301 -0
- package/src/constitution/baseline.ts +257 -0
- package/src/detect-gate.ts +498 -0
- package/src/eval/eval.types.ts +36 -0
- package/src/eval/index.ts +3 -0
- package/src/eval/judge.ts +62 -0
- package/src/eval/score.ts +39 -0
- package/src/files/create.ts +22 -0
- package/src/files/edit.ts +193 -0
- package/src/files/files.constants.ts +11 -0
- package/src/files/files.types.ts +81 -0
- package/src/files/hashline-format.ts +110 -0
- package/src/files/hashline.ts +689 -0
- package/src/files/index.ts +19 -0
- package/src/index.ts +8 -0
- package/src/inference/index.ts +6 -0
- package/src/inference/inference.constants.ts +34 -0
- package/src/inference/inference.types.ts +123 -0
- package/src/inference/openai-compatible.ts +113 -0
- package/src/inference/stream-guard.ts +161 -0
- package/src/inference/stream.ts +370 -0
- package/src/inference/transport.ts +78 -0
- package/src/inference/wire.ts +0 -0
- package/src/lib/fs/fs.ts +126 -0
- package/src/lib/fs/fs.types.ts +5 -0
- package/src/lib/fs/index.ts +3 -0
- package/src/lib/fs/process.ts +146 -0
- package/src/lib/guards/guards.ts +9 -0
- package/src/lib/guards/index.ts +1 -0
- package/src/lib/json/index.ts +1 -0
- package/src/lib/json/json.ts +12 -0
- package/src/lib/scope/index.ts +2 -0
- package/src/lib/scope/scope.constants.ts +3 -0
- package/src/lib/scope/scope.ts +40 -0
- package/src/loop/astgrep-fix.ts +228 -0
- package/src/loop/feedback/feedback.ts +138 -0
- package/src/loop/feedback/index.ts +8 -0
- package/src/loop/feedback/meta-rule-docs.ts +41 -0
- package/src/loop/feedback/meta-rule-feedback.ts +61 -0
- package/src/loop/feedback/rule-docs.generated.json +112 -0
- package/src/loop/feedback/rule-docs.ts +342 -0
- package/src/loop/index.ts +19 -0
- package/src/loop/loop.constants.ts +68 -0
- package/src/loop/loop.types.ts +99 -0
- package/src/loop/prompt/index.ts +2 -0
- package/src/loop/prompt/project-map.ts +69 -0
- package/src/loop/prompt/prompt.ts +107 -0
- package/src/loop/quality.ts +174 -0
- package/src/loop/rule-docs.generated.json +367 -0
- package/src/loop/run-spec.ts +88 -0
- package/src/loop/run.ts +400 -0
- package/src/loop/session.ts +1410 -0
- package/src/loop/tools/add-dependency.ts +71 -0
- package/src/loop/tools/condense.ts +498 -0
- package/src/loop/tools/edit-hashline.ts +80 -0
- package/src/loop/tools/execute-tool.ts +80 -0
- package/src/loop/tools/file-ops.ts +323 -0
- package/src/loop/tools/index.ts +2 -0
- package/src/loop/tools/lsp-ops.ts +222 -0
- package/src/loop/tools/scaffold-routes.ts +68 -0
- package/src/loop/tools/scaffold-ui.ts +62 -0
- package/src/loop/tools/scaffold-web.ts +35 -0
- package/src/loop/tools/tool-context.ts +126 -0
- package/src/loop/ttsr-defaults.ts +53 -0
- package/src/loop/ttsr.ts +322 -0
- package/src/loop/turn.ts +856 -0
- package/src/lsp/index.ts +2 -0
- package/src/lsp/lsp.types.ts +56 -0
- package/src/lsp/service.ts +500 -0
- package/src/meta-rules/context.ts +195 -0
- package/src/meta-rules/index.ts +9 -0
- package/src/meta-rules/meta-rules.types.ts +47 -0
- package/src/meta-rules/parsers/package-json-parser.ts +51 -0
- package/src/meta-rules/registry.ts +37 -0
- package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
- package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
- package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
- package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
- package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
- package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
- package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
- package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
- package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
- package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
- package/src/meta-rules/runner.ts +64 -0
- package/src/models-config.ts +196 -0
- package/src/render/ansi.ts +289 -0
- package/src/render/banner.ts +113 -0
- package/src/render/box.ts +134 -0
- package/src/render/index.ts +7 -0
- package/src/render/markdown.ts +123 -0
- package/src/render/render.types.ts +21 -0
- package/src/render/stream-markdown.ts +128 -0
- package/src/render/style.ts +26 -0
- package/src/rule-packs/bullmq/index.ts +39 -0
- package/src/rule-packs/bullmq/rules/index.ts +7 -0
- package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
- package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
- package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
- package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
- package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
- package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
- package/src/rule-packs/bullmq/utils.ts +334 -0
- package/src/rule-packs/code-flow/index.ts +25 -0
- package/src/rule-packs/code-flow/rules/index.ts +3 -0
- package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
- package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
- package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
- package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
- package/src/rule-packs/comment-hygiene/index.ts +25 -0
- package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
- package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
- package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
- package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
- package/src/rule-packs/create-rule.ts +9 -0
- package/src/rule-packs/drizzle/index.ts +41 -0
- package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
- package/src/rule-packs/drizzle/rules/index.ts +8 -0
- package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
- package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
- package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
- package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
- package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
- package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
- package/src/rule-packs/drizzle/utils.ts +115 -0
- package/src/rule-packs/elysia/index.ts +43 -0
- package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
- package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
- package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
- package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
- package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
- package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
- package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
- package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
- package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
- package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
- package/src/rule-packs/env-access/index.ts +23 -0
- package/src/rule-packs/env-access/rules/index.ts +2 -0
- package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
- package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
- package/src/rule-packs/i18n-keys/index.ts +19 -0
- package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
- package/src/rule-packs/index.ts +139 -0
- package/src/rule-packs/jwt-cookies/index.ts +25 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
- package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
- package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
- package/src/rule-packs/jwt-cookies/utils.ts +188 -0
- package/src/rule-packs/oauth-security/index.ts +25 -0
- package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
- package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
- package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
- package/src/rule-packs/oauth-security/utils.ts +127 -0
- package/src/rule-packs/react-component-architecture/index.ts +35 -0
- package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
- package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
- package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
- package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
- package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
- package/src/rule-packs/react-component-architecture/utils.ts +47 -0
- package/src/rule-packs/rule-packs.types.ts +18 -0
- package/src/rule-packs/structured-logging/index.ts +26 -0
- package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
- package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
- package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
- package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
- package/src/rule-packs/tanstack-query/index.ts +20 -0
- package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
- package/src/rule-packs/test-conventions/index.ts +23 -0
- package/src/rule-packs/test-conventions/rules/index.ts +2 -0
- package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
- package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
- package/src/rule-packs/utils.ts +142 -0
- package/src/session-store.ts +359 -0
- package/src/spec/generate-tests.ts +213 -0
- package/src/spec/index.ts +5 -0
- package/src/spec/parse.ts +152 -0
- package/src/spec/review-tests.ts +162 -0
- package/src/spec/spec.constants.ts +13 -0
- package/src/spec/spec.types.ts +79 -0
- package/src/stack-detection/detect.ts +246 -0
- package/src/stack-detection/index.ts +3 -0
- package/src/stack-detection/packs.ts +174 -0
- package/src/stack-detection/stack-detection.types.ts +47 -0
- package/src/validate/accept.ts +49 -0
- package/src/validate/errors.ts +35 -0
- package/src/validate/index.ts +12 -0
- package/src/validate/parse.ts +148 -0
- package/src/validate/run-tests.ts +59 -0
- package/src/validate/validate.ts +40 -0
- package/src/validate/validate.types.ts +52 -0
- package/src/web-components.ts +638 -0
- package/src/web-coverage.ts +89 -0
- package/src/web-routes.ts +151 -0
- package/src/web-templates.ts +1011 -0
- package/strict.eslint.config.mjs +84 -0
- package/strict.web.eslint.config.mjs +185 -0
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import type { TSESTree } from "@typescript-eslint/utils";
|
|
3
|
+
|
|
4
|
+
import { isRecord } from "../lib/guards";
|
|
5
|
+
|
|
6
|
+
/** Keys on AST nodes that never hold child nodes (or would walk upward). */
|
|
7
|
+
export const NON_AST_KEYS = new Set([
|
|
8
|
+
"parent",
|
|
9
|
+
"loc",
|
|
10
|
+
"range",
|
|
11
|
+
"tokens",
|
|
12
|
+
"comments",
|
|
13
|
+
"start",
|
|
14
|
+
"end",
|
|
15
|
+
"leadingComments",
|
|
16
|
+
"trailingComments",
|
|
17
|
+
"innerComments",
|
|
18
|
+
]);
|
|
19
|
+
|
|
20
|
+
/** AST nodes are plain objects with a string `type` discriminant. */
|
|
21
|
+
export function isNodeLike(value: unknown): value is TSESTree.Node {
|
|
22
|
+
return isRecord(value) && typeof value.type === "string";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Push every direct child AST node of `node` onto `stack`. */
|
|
26
|
+
export function pushChildNodes(
|
|
27
|
+
node: TSESTree.Node,
|
|
28
|
+
stack: TSESTree.Node[]
|
|
29
|
+
): void {
|
|
30
|
+
for (const [key, value] of Object.entries(node)) {
|
|
31
|
+
if (NON_AST_KEYS.has(key)) {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (Array.isArray(value)) {
|
|
36
|
+
for (const child of value) {
|
|
37
|
+
if (isNodeLike(child)) {
|
|
38
|
+
stack.push(child);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
} else if (isNodeLike(value)) {
|
|
42
|
+
stack.push(value);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Depth-first visit of `root` and all descendants (cycle-safe). */
|
|
48
|
+
export function walkAll(
|
|
49
|
+
root: TSESTree.Node,
|
|
50
|
+
callback: (node: TSESTree.Node) => void
|
|
51
|
+
): void {
|
|
52
|
+
const stack: TSESTree.Node[] = [root];
|
|
53
|
+
const visited = new WeakSet();
|
|
54
|
+
|
|
55
|
+
for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
|
|
56
|
+
if (visited.has(node)) {
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
visited.add(node);
|
|
61
|
+
callback(node);
|
|
62
|
+
pushChildNodes(node, stack);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** True if any node in the subtree satisfies `predicate` (cycle-safe). */
|
|
67
|
+
export function walkSome(
|
|
68
|
+
root: TSESTree.Node,
|
|
69
|
+
predicate: (node: TSESTree.Node) => boolean
|
|
70
|
+
): boolean {
|
|
71
|
+
const stack: TSESTree.Node[] = [root];
|
|
72
|
+
const visited = new WeakSet();
|
|
73
|
+
|
|
74
|
+
for (let node = stack.pop(); node !== undefined; node = stack.pop()) {
|
|
75
|
+
if (visited.has(node)) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
visited.add(node);
|
|
80
|
+
|
|
81
|
+
if (predicate(node)) {
|
|
82
|
+
return true;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
pushChildNodes(node, stack);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return false;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Returns the file's repo-relative path with forward slashes for cross-platform consistency.
|
|
93
|
+
*/
|
|
94
|
+
export function toPosixRelative(filename: string, cwd: string): string {
|
|
95
|
+
const rel = path.relative(cwd, filename);
|
|
96
|
+
|
|
97
|
+
return rel.split(path.sep).join("/");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Simple glob-like matching for patterns. Supports:
|
|
102
|
+
* - `**` (match any directory levels)
|
|
103
|
+
* - `*` (match any characters except `/`)
|
|
104
|
+
* - `?` (match single character)
|
|
105
|
+
* - literal strings
|
|
106
|
+
*/
|
|
107
|
+
export function matchesGlobPattern(path: string, pattern: string): boolean {
|
|
108
|
+
// Escape regex special chars except * and ?
|
|
109
|
+
const regexPattern = pattern
|
|
110
|
+
.split("**/")
|
|
111
|
+
.join("<<<GLOBSTAR>>>")
|
|
112
|
+
.split("/")
|
|
113
|
+
.map((seg) => {
|
|
114
|
+
if (seg === "<<<GLOBSTAR>>>") {
|
|
115
|
+
return ".*";
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
return seg
|
|
119
|
+
.replace(/[.+^${}()|[\]\\]/g, "\\$&") // escape regex chars
|
|
120
|
+
.replace(/\*/g, "[^/]*") // * matches anything except /
|
|
121
|
+
.replace(/\?/g, "[^/]"); // ? matches single char except /
|
|
122
|
+
})
|
|
123
|
+
.join("/");
|
|
124
|
+
|
|
125
|
+
const regex = new RegExp(`^${regexPattern}$`);
|
|
126
|
+
|
|
127
|
+
return regex.test(path);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Returns true if the path matches any of the glob patterns.
|
|
132
|
+
*/
|
|
133
|
+
export function matchesAnyGlobPattern(
|
|
134
|
+
filePath: string,
|
|
135
|
+
patterns: readonly string[]
|
|
136
|
+
): boolean {
|
|
137
|
+
if (patterns.length === 0) {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return patterns.some((pattern) => matchesGlobPattern(filePath, pattern));
|
|
142
|
+
}
|
|
@@ -0,0 +1,359 @@
|
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { readdir, mkdir, chmod, stat, unlink } from "node:fs/promises";
|
|
4
|
+
import type { IChatMessage, IToolCall } from "./inference";
|
|
5
|
+
import { isRecord } from "./lib/guards";
|
|
6
|
+
|
|
7
|
+
/** Days to keep a session before `pruneSessions` deletes it (env-overridable). */
|
|
8
|
+
const DEFAULT_TTL_DAYS = 30;
|
|
9
|
+
const MS_PER_DAY = 86_400_000;
|
|
10
|
+
|
|
11
|
+
/** Persistence is off when TSFORGE_NO_PERSIST is set — sessions stay in memory only. */
|
|
12
|
+
export function persistenceEnabled(): boolean {
|
|
13
|
+
return (process.env.TSFORGE_NO_PERSIST ?? "") === "";
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* On-disk persistence for interactive CLI sessions, so `tsforge --continue` can
|
|
18
|
+
* resume the most recent conversation for a working directory. One JSON file per
|
|
19
|
+
* session under `~/.tsforge/sessions/`, rewritten after each turn. Deliberately
|
|
20
|
+
* simple (flat files, no index) — a session is small and there are never many.
|
|
21
|
+
*/
|
|
22
|
+
export interface ISessionRecord {
|
|
23
|
+
/** Stable id (also the filename stem). */
|
|
24
|
+
id: string;
|
|
25
|
+
/** The working directory this session ran against — `--continue` matches on it. */
|
|
26
|
+
cwd: string;
|
|
27
|
+
/** Gate command, if one was set. */
|
|
28
|
+
accept: string;
|
|
29
|
+
/** Editable scope globs. */
|
|
30
|
+
files: string[];
|
|
31
|
+
/** Last-write time (ms) — newest wins for `--continue`. */
|
|
32
|
+
updatedAt: number;
|
|
33
|
+
/** Plan mode was on when last saved — restored on `--continue` so a resumed
|
|
34
|
+
* session doesn't silently drop its read-only guarantee. */
|
|
35
|
+
planMode?: boolean;
|
|
36
|
+
/** The full conversation, including the system message. */
|
|
37
|
+
messages: IChatMessage[];
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** The sessions directory — under `$TSFORGE_HOME` if set (tests/sandboxing),
|
|
41
|
+
* else the user's home. Read at call time so it can be redirected per process. */
|
|
42
|
+
function storeDir(): string {
|
|
43
|
+
return join(process.env.TSFORGE_HOME ?? homedir(), ".tsforge", "sessions");
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The run-logs directory (`--log`): `$TSFORGE_HOME`/.tsforge/logs, else under the
|
|
47
|
+
* user's home. A fixed, predictable location so logs are always findable. */
|
|
48
|
+
export function logsDir(): string {
|
|
49
|
+
return join(process.env.TSFORGE_HOME ?? homedir(), ".tsforge", "logs");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Persist (create or overwrite) a session record — unless persistence is off.
|
|
54
|
+
* Secrets in message text are redacted FIRST (so they never hit disk), the dir
|
|
55
|
+
* is created 0700 and the file written 0600 (owner-only). We deliberately do NOT
|
|
56
|
+
* encrypt: a key stored beside the data only deters backup/sync exfil, not local
|
|
57
|
+
* compromise — data minimization (redaction) + perms is the higher-value floor.
|
|
58
|
+
*/
|
|
59
|
+
export async function saveSession(record: ISessionRecord): Promise<void> {
|
|
60
|
+
if (!persistenceEnabled()) {
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const dir = storeDir();
|
|
65
|
+
const path = join(dir, `${record.id}.json`);
|
|
66
|
+
const safe: ISessionRecord = {
|
|
67
|
+
...record,
|
|
68
|
+
messages: redactMessages(record.messages),
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
await mkdir(dir, { recursive: true, mode: 0o700 });
|
|
72
|
+
await Bun.write(path, JSON.stringify(safe, null, 2));
|
|
73
|
+
await chmod(path, 0o600);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Delete sessions older than `ttlDays` (by file mtime). Best-effort; never throws. */
|
|
77
|
+
export async function pruneSessions(ttlDays?: number): Promise<void> {
|
|
78
|
+
const envTtl = Number(process.env.TSFORGE_SESSION_TTL_DAYS);
|
|
79
|
+
const ttl = ttlDays ?? (Number.isFinite(envTtl) ? envTtl : DEFAULT_TTL_DAYS);
|
|
80
|
+
const dir = storeDir();
|
|
81
|
+
let names: string[];
|
|
82
|
+
|
|
83
|
+
try {
|
|
84
|
+
names = await readdir(dir);
|
|
85
|
+
} catch {
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
const cutoff = Date.now() - ttl * MS_PER_DAY;
|
|
90
|
+
|
|
91
|
+
for (const name of names) {
|
|
92
|
+
if (!name.endsWith(".json")) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const path = join(dir, name);
|
|
97
|
+
|
|
98
|
+
try {
|
|
99
|
+
const info = await stat(path);
|
|
100
|
+
|
|
101
|
+
if (info.mtimeMs < cutoff) {
|
|
102
|
+
await unlink(path);
|
|
103
|
+
}
|
|
104
|
+
} catch {
|
|
105
|
+
// racing deletion / unreadable — skip
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** All saved sessions for `cwd`, newest first. */
|
|
111
|
+
export async function listSessions(cwd: string): Promise<ISessionRecord[]> {
|
|
112
|
+
const dir = storeDir();
|
|
113
|
+
let names: string[];
|
|
114
|
+
|
|
115
|
+
try {
|
|
116
|
+
names = await readdir(dir);
|
|
117
|
+
} catch {
|
|
118
|
+
return []; // no store yet
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const records: ISessionRecord[] = [];
|
|
122
|
+
|
|
123
|
+
for (const name of names) {
|
|
124
|
+
if (!name.endsWith(".json")) {
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const record = await readRecord(join(dir, name));
|
|
129
|
+
|
|
130
|
+
if (record !== null && record.cwd === cwd) {
|
|
131
|
+
records.push(record);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return records.sort((a, b) => b.updatedAt - a.updatedAt);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The most recently-updated session for `cwd`, or null if there is none. */
|
|
139
|
+
export async function latestSession(
|
|
140
|
+
cwd: string
|
|
141
|
+
): Promise<ISessionRecord | null> {
|
|
142
|
+
return (await listSessions(cwd))[0] ?? null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Load a specific session by id (for `--resume <id>`), or null if absent. */
|
|
146
|
+
export async function loadSession(id: string): Promise<ISessionRecord | null> {
|
|
147
|
+
return readRecord(join(storeDir(), `${id}.json`));
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
async function readRecord(path: string): Promise<ISessionRecord | null> {
|
|
151
|
+
try {
|
|
152
|
+
const data: unknown = JSON.parse(await Bun.file(path).text());
|
|
153
|
+
|
|
154
|
+
if (
|
|
155
|
+
isRecord(data) &&
|
|
156
|
+
typeof data.id === "string" &&
|
|
157
|
+
typeof data.cwd === "string" &&
|
|
158
|
+
typeof data.updatedAt === "number" &&
|
|
159
|
+
Array.isArray(data.messages)
|
|
160
|
+
) {
|
|
161
|
+
return {
|
|
162
|
+
id: data.id,
|
|
163
|
+
cwd: data.cwd,
|
|
164
|
+
accept: typeof data.accept === "string" ? data.accept : "",
|
|
165
|
+
files: Array.isArray(data.files)
|
|
166
|
+
? data.files.filter((f): f is string => typeof f === "string")
|
|
167
|
+
: [],
|
|
168
|
+
updatedAt: data.updatedAt,
|
|
169
|
+
messages: toMessages(data.messages),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
} catch {
|
|
173
|
+
// unreadable / malformed → skip it
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
return null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** Validate a persisted message array back into IChatMessage[] (no `as` casts). */
|
|
180
|
+
function toMessages(raw: readonly unknown[]): IChatMessage[] {
|
|
181
|
+
const messages: IChatMessage[] = [];
|
|
182
|
+
|
|
183
|
+
for (const item of raw) {
|
|
184
|
+
const message = toMessage(item);
|
|
185
|
+
|
|
186
|
+
if (message !== null) {
|
|
187
|
+
messages.push(message);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return messages;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function toMessage(raw: unknown): IChatMessage | null {
|
|
195
|
+
if (!isRecord(raw)) {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const role = toRole(raw.role);
|
|
200
|
+
|
|
201
|
+
if (role === null) {
|
|
202
|
+
return null;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const message: IChatMessage = {
|
|
206
|
+
role,
|
|
207
|
+
content: typeof raw.content === "string" ? raw.content : "",
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
if (typeof raw.toolCallId === "string") {
|
|
211
|
+
message.toolCallId = raw.toolCallId;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
const toolCalls = toToolCalls(raw.toolCalls);
|
|
215
|
+
|
|
216
|
+
if (toolCalls.length > 0) {
|
|
217
|
+
message.toolCalls = toolCalls;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
return message;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** Narrow an unknown to the message role union via case-narrowing (no `as`). */
|
|
224
|
+
function toRole(value: unknown): IChatMessage["role"] | null {
|
|
225
|
+
switch (value) {
|
|
226
|
+
case "system":
|
|
227
|
+
case "user":
|
|
228
|
+
case "assistant":
|
|
229
|
+
case "tool":
|
|
230
|
+
return value;
|
|
231
|
+
default:
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
const REDACTED = "[redacted]";
|
|
237
|
+
|
|
238
|
+
// A PEM private-key block — redact the whole thing.
|
|
239
|
+
const PRIVATE_KEY_BLOCK =
|
|
240
|
+
/-----BEGIN[ A-Z]*PRIVATE KEY-----[\s\S]*?-----END[ A-Z]*PRIVATE KEY-----/g;
|
|
241
|
+
|
|
242
|
+
// A password embedded in a connection-string's userinfo (`scheme://user:PASS@host`).
|
|
243
|
+
const CONNECTION_PASSWORD = /(:\/\/[^\s:/@]*:)[^\s:/@]+(@)/g;
|
|
244
|
+
|
|
245
|
+
// `<key>: <value>` / `<key> = <value>` where the key NAME contains a secret word.
|
|
246
|
+
// The value is only redacted when it actually looks like a secret (see
|
|
247
|
+
// valueLooksSecret) — so `password: string` (a type annotation) survives.
|
|
248
|
+
const SECRET_KEYWORD =
|
|
249
|
+
/\b([\w.]*(?:password|passwd|secret|token|api[_-]?key|apikey|access[_-]?key|client[_-]?secret|credentials?|auth[_-]?token|private[_-]?key)[\w.]*)(\s*["']?\s*[:=]\s*)("[^"]*"|'[^']*'|[^\s,;{}()]+)/gi;
|
|
250
|
+
|
|
251
|
+
// Standalone secret SHAPES — the whole match is the secret, replaced wholesale.
|
|
252
|
+
const SECRET_SHAPES: readonly RegExp[] = [
|
|
253
|
+
/\bsk-[A-Za-z0-9_-]{16,}\b/g, // OpenAI-style keys (incl. sk-proj-)
|
|
254
|
+
/\b[sprk]k_(?:live|test)_[A-Za-z0-9]{8,}\b/g, // Stripe sk_live_/pk_test_/rk_…
|
|
255
|
+
/\bgh[posru]_[A-Za-z0-9]{20,}\b/g, // GitHub tokens (ghp_, gho_, …)
|
|
256
|
+
/\bgithub_pat_[A-Za-z0-9_]{20,}\b/g, // GitHub fine-grained PAT
|
|
257
|
+
/\bAKIA[0-9A-Z]{16}\b/g, // AWS access key id
|
|
258
|
+
/\bAIza[0-9A-Za-z_-]{20,}\b/g, // Google API key
|
|
259
|
+
/\bxox[baprs]-[A-Za-z0-9-]{10,}\b/g, // Slack tokens
|
|
260
|
+
/\bnpm_[A-Za-z0-9]{36}\b/g, // npm token
|
|
261
|
+
/\bey[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\b/g, // JWTs
|
|
262
|
+
/\bBearer\s+[A-Za-z0-9._~+/=-]{12,}/gi, // Authorization: Bearer …
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
/** Does a `key=` value look like a real secret (vs a type/identifier)? */
|
|
266
|
+
function valueLooksSecret(value: string): boolean {
|
|
267
|
+
const quoted =
|
|
268
|
+
(value.startsWith('"') && value.endsWith('"')) ||
|
|
269
|
+
(value.startsWith("'") && value.endsWith("'"));
|
|
270
|
+
|
|
271
|
+
if (quoted) {
|
|
272
|
+
return value.length - 2 >= 3; // a non-trivial quoted literal
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// A bare token: secret-looking if it has non-letters (digits/symbols) or is
|
|
276
|
+
// long. A short pure-letter word (`string`, `number`, `getInput`) is kept.
|
|
277
|
+
return /[^A-Za-z]/.test(value) || value.length >= 16;
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
/**
|
|
281
|
+
* Scrub secrets from text before it is persisted (data minimization). Covers
|
|
282
|
+
* private-key blocks, connection-string passwords, `<secret-key>: <value>`
|
|
283
|
+
* assignments (only when the value looks like a real secret), and a battery of
|
|
284
|
+
* known token shapes (OpenAI/Stripe/GitHub/AWS/Google/Slack/npm/JWT/Bearer).
|
|
285
|
+
*/
|
|
286
|
+
export function redactText(text: string): string {
|
|
287
|
+
let out = text.replace(PRIVATE_KEY_BLOCK, REDACTED);
|
|
288
|
+
|
|
289
|
+
out = out.replace(CONNECTION_PASSWORD, `$1${REDACTED}$2`);
|
|
290
|
+
|
|
291
|
+
out = out.replace(
|
|
292
|
+
SECRET_KEYWORD,
|
|
293
|
+
(match: string, key: string, sep: string, value: string) =>
|
|
294
|
+
valueLooksSecret(value) ? `${key}${sep}${REDACTED}` : match
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
for (const shape of SECRET_SHAPES) {
|
|
298
|
+
out = out.replace(shape, REDACTED);
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
return out;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Redact every message before persisting: its text (user prompts, assistant
|
|
306
|
+
* answers, AND tool output — a `cat .env` dump is a real leak vector) AND the
|
|
307
|
+
* string values inside tool-call arguments (a token pasted into a `run` command
|
|
308
|
+
* or `create` content). Structure is preserved; only string values are scrubbed.
|
|
309
|
+
*/
|
|
310
|
+
function redactMessages(messages: readonly IChatMessage[]): IChatMessage[] {
|
|
311
|
+
return messages.map((message) => ({
|
|
312
|
+
...message,
|
|
313
|
+
content: redactText(message.content),
|
|
314
|
+
...(message.toolCalls === undefined
|
|
315
|
+
? {}
|
|
316
|
+
: { toolCalls: redactToolCalls(message.toolCalls) }),
|
|
317
|
+
}));
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
function redactToolCalls(calls: readonly IToolCall[]): IToolCall[] {
|
|
321
|
+
return calls.map((call) => ({
|
|
322
|
+
...call,
|
|
323
|
+
arguments: redactArgs(call.arguments),
|
|
324
|
+
}));
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function redactArgs(args: Record<string, unknown>): Record<string, unknown> {
|
|
328
|
+
const out: Record<string, unknown> = {};
|
|
329
|
+
|
|
330
|
+
for (const [key, value] of Object.entries(args)) {
|
|
331
|
+
out[key] = typeof value === "string" ? redactText(value) : value;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
return out;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
function toToolCalls(raw: unknown): IToolCall[] {
|
|
338
|
+
if (!Array.isArray(raw)) {
|
|
339
|
+
return [];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
const calls: IToolCall[] = [];
|
|
343
|
+
|
|
344
|
+
for (const call of raw) {
|
|
345
|
+
if (
|
|
346
|
+
isRecord(call) &&
|
|
347
|
+
typeof call.name === "string" &&
|
|
348
|
+
isRecord(call.arguments)
|
|
349
|
+
) {
|
|
350
|
+
calls.push({
|
|
351
|
+
id: typeof call.id === "string" ? call.id : undefined,
|
|
352
|
+
name: call.name,
|
|
353
|
+
arguments: call.arguments,
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
return calls;
|
|
359
|
+
}
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { rm } from "node:fs/promises";
|
|
3
|
+
import type { IChatMessage, IProvider } from "../inference";
|
|
4
|
+
import type { Reporter } from "../loop";
|
|
5
|
+
import { CREATE_TOOL, TOOL_NAME, toCreate } from "../agent";
|
|
6
|
+
import { applyCreate } from "../files/create";
|
|
7
|
+
import { isInScope } from "../lib/scope";
|
|
8
|
+
import { runTests, isRealRed, type IRunTestsResult } from "../validate";
|
|
9
|
+
|
|
10
|
+
export interface IGenerateTestsOptions {
|
|
11
|
+
/** Where to write the suite. */
|
|
12
|
+
testFile: string;
|
|
13
|
+
/** Where to write the throwing stub the suite runs against (the future impl). */
|
|
14
|
+
implFile: string;
|
|
15
|
+
/** One-line statement of what's under test (e.g. the spec title). */
|
|
16
|
+
goal: string;
|
|
17
|
+
/** The acceptance criteria prose the tests must encode. */
|
|
18
|
+
criteria: string;
|
|
19
|
+
/** Max model turns (default 6). The suite + stub usually land over 1-2 turns. */
|
|
20
|
+
maxAttempts?: number;
|
|
21
|
+
onEvent?: Reporter;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface IGenerateTestsResult {
|
|
25
|
+
testFile: string;
|
|
26
|
+
/** True once the suite loads, collects tests, and is RED against the stub. */
|
|
27
|
+
ok: boolean;
|
|
28
|
+
testCount: number;
|
|
29
|
+
attempts: number;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Turn a spec's acceptance criteria into an executable `bun:test` suite — the
|
|
34
|
+
* step that lets an *untested* spec enter the deterministic gate.
|
|
35
|
+
*
|
|
36
|
+
* The model writes the suite plus a throwing stub of the impl (over one or more
|
|
37
|
+
* turns), so the suite is runnable (imports resolve) yet starts RED. `runTests`
|
|
38
|
+
* is the oracle, and a suite is accepted only when it: (1) loads cleanly (no errors),
|
|
39
|
+
* (2) collects >= 1 test, and (3) is fully RED — every test fails against the
|
|
40
|
+
* do-nothing stub. (3) is the load-bearing check: a vacuous test that never
|
|
41
|
+
* calls the implementation would PASS against the stub, and we reject exactly
|
|
42
|
+
* those. The result is the precise RED seed the implement loop then drives green.
|
|
43
|
+
*/
|
|
44
|
+
export async function generateTests(
|
|
45
|
+
provider: IProvider,
|
|
46
|
+
cwd: string,
|
|
47
|
+
opts: IGenerateTestsOptions
|
|
48
|
+
): Promise<IGenerateTestsResult> {
|
|
49
|
+
const maxAttempts = opts.maxAttempts ?? 6;
|
|
50
|
+
const report: Reporter = opts.onEvent ?? (() => undefined);
|
|
51
|
+
const scope = [opts.testFile, opts.implFile];
|
|
52
|
+
|
|
53
|
+
let attempts = 0;
|
|
54
|
+
let feedback = "";
|
|
55
|
+
|
|
56
|
+
while (attempts < maxAttempts) {
|
|
57
|
+
attempts += 1;
|
|
58
|
+
|
|
59
|
+
// Re-prompt with the CURRENT file state each turn rather than wiping
|
|
60
|
+
// progress: tool-calling models emit roughly one `create` per turn, so the
|
|
61
|
+
// suite lands one turn and the stub the next. The prompt names which files
|
|
62
|
+
// are still missing; `create` upserts so a bad file can be overwritten.
|
|
63
|
+
const present = await whichExist(cwd, [opts.testFile, opts.implFile]);
|
|
64
|
+
const res = await provider.complete(buildPrompt(opts, present, feedback), {
|
|
65
|
+
tools: [CREATE_TOOL],
|
|
66
|
+
temperature: 0,
|
|
67
|
+
onToken: (text) => {
|
|
68
|
+
report({ kind: "token", task: opts.testFile, message: text });
|
|
69
|
+
},
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
for (const call of res.toolCalls) {
|
|
73
|
+
await applyCreateCall(scope, cwd, call.name, call.arguments, report);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const run = await runTests(opts.testFile, cwd);
|
|
77
|
+
const verdict = assess(run);
|
|
78
|
+
|
|
79
|
+
report({
|
|
80
|
+
kind: "fix",
|
|
81
|
+
task: opts.testFile,
|
|
82
|
+
message: `turn ${attempts}: ${run.total} tests, ${run.pass} pass, ${run.fail} fail, ${run.errors} err — ${verdict.ok ? "accepted (RED)" : verdict.reason}`,
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (verdict.ok) {
|
|
86
|
+
return {
|
|
87
|
+
testFile: opts.testFile,
|
|
88
|
+
ok: true,
|
|
89
|
+
testCount: run.total,
|
|
90
|
+
attempts,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
feedback = `${verdict.reason}\nRunner output:\n${run.output}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return { testFile: opts.testFile, ok: false, testCount: 0, attempts };
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Which of `files` currently exist in `cwd` (for telling the model what's left). */
|
|
101
|
+
async function whichExist(cwd: string, files: string[]): Promise<Set<string>> {
|
|
102
|
+
const present = new Set<string>();
|
|
103
|
+
|
|
104
|
+
for (const file of files) {
|
|
105
|
+
if (await Bun.file(join(cwd, file)).exists()) {
|
|
106
|
+
present.add(file);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return present;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
interface IVerdict {
|
|
114
|
+
ok: boolean;
|
|
115
|
+
reason: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The deterministic "are these real tests?" oracle. */
|
|
119
|
+
function assess(run: IRunTestsResult): IVerdict {
|
|
120
|
+
if (run.errors > 0) {
|
|
121
|
+
return { ok: false, reason: "the suite failed to load/parse" };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (run.total === 0) {
|
|
125
|
+
return { ok: false, reason: "no tests were collected" };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (run.pass > 0) {
|
|
129
|
+
return {
|
|
130
|
+
ok: false,
|
|
131
|
+
reason: `${run.pass} test(s) passed against a stub that throws on every call — those tests don't exercise the implementation`,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
return { ok: isRealRed(run), reason: "" };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
async function applyCreateCall(
|
|
139
|
+
scope: string[],
|
|
140
|
+
cwd: string,
|
|
141
|
+
name: string,
|
|
142
|
+
args: Record<string, unknown>,
|
|
143
|
+
report: Reporter
|
|
144
|
+
): Promise<void> {
|
|
145
|
+
if (name !== TOOL_NAME.create) {
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const create = toCreate(args);
|
|
150
|
+
|
|
151
|
+
if (create === null || !isInScope(create.file, scope)) {
|
|
152
|
+
return;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Upsert: generateTests is the authority for these two scoped files, so a
|
|
156
|
+
// `create` re-writes (drop any prior, then create) rather than no-clobbering —
|
|
157
|
+
// the model regenerates a whole file to fix it.
|
|
158
|
+
await rm(join(cwd, create.file), { force: true });
|
|
159
|
+
|
|
160
|
+
const result = await applyCreate(cwd, create);
|
|
161
|
+
|
|
162
|
+
if (result.ok) {
|
|
163
|
+
report({
|
|
164
|
+
kind: "create",
|
|
165
|
+
task: create.file,
|
|
166
|
+
file: create.file,
|
|
167
|
+
message: `create ${create.file}`,
|
|
168
|
+
content: create.content,
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function buildPrompt(
|
|
174
|
+
opts: IGenerateTestsOptions,
|
|
175
|
+
present: Set<string>,
|
|
176
|
+
feedback: string
|
|
177
|
+
): IChatMessage[] {
|
|
178
|
+
const moduleSpecifier = `./${opts.implFile.replace(/\.ts$/, "")}`;
|
|
179
|
+
|
|
180
|
+
const system = [
|
|
181
|
+
"You are a TypeScript test author. From acceptance criteria you write a rigorous, executable `bun:test` suite that pins behaviour with concrete, literal expected values.",
|
|
182
|
+
"Cover the happy path AND the edge cases the criteria imply (zero, negative, empty, boundary, rounding, large values). Every acceptance item maps to at least one assertion, and EVERY test must call the implementation under test.",
|
|
183
|
+
`You must produce TWO files via \`create\` calls (emit as many per turn as you can):\n 1. The test file — \`import { test, expect } from "bun:test";\` and import the implementation from "${moduleSpecifier}".\n 2. The implementation stub — export every function the tests import, with the correct signature but a body that does \`throw new Error("not implemented")\`.`,
|
|
184
|
+
"The stub makes the suite runnable and guarantees it starts RED: if any test passes against a stub that throws on every call, that test isn't really testing the implementation — don't write tests like that.",
|
|
185
|
+
"A `create` overwrites the file if it already exists, so to fix a file just create it again with the corrected full contents.",
|
|
186
|
+
"House rules: no `any`, no `as`, no non-null `!`; prefer `const`.",
|
|
187
|
+
].join("\n");
|
|
188
|
+
|
|
189
|
+
const missing = [opts.testFile, opts.implFile].filter((f) => !present.has(f));
|
|
190
|
+
const state =
|
|
191
|
+
missing.length > 0
|
|
192
|
+
? `Files still MISSING — create them now: ${missing.join(", ")}.`
|
|
193
|
+
: "Both files exist; fix whichever the runner rejected by re-creating it.";
|
|
194
|
+
|
|
195
|
+
const retry =
|
|
196
|
+
feedback.length > 0 ? `The current state was rejected: ${feedback}` : "";
|
|
197
|
+
|
|
198
|
+
const user = [
|
|
199
|
+
`Write the test suite at: ${opts.testFile}`,
|
|
200
|
+
`Write the implementation stub at: ${opts.implFile}`,
|
|
201
|
+
`Goal: ${opts.goal}`,
|
|
202
|
+
`Acceptance criteria:\n${opts.criteria}`,
|
|
203
|
+
state,
|
|
204
|
+
retry,
|
|
205
|
+
]
|
|
206
|
+
.filter((s) => s.length > 0)
|
|
207
|
+
.join("\n\n");
|
|
208
|
+
|
|
209
|
+
return [
|
|
210
|
+
{ role: "system", content: system },
|
|
211
|
+
{ role: "user", content: user },
|
|
212
|
+
];
|
|
213
|
+
}
|