@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,152 @@
|
|
|
1
|
+
import { SPEC_MODE } from "./spec.constants";
|
|
2
|
+
import type { ISpec, ITask } from "./spec.types";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Parse our minimal one-file spec format (see docs: /spec/format/).
|
|
6
|
+
*
|
|
7
|
+
* Deliberately small: enough for the walking skeleton — frontmatter keys plus a
|
|
8
|
+
* `## Tasks` list where each numbered task carries indented `accept:`/`files:`
|
|
9
|
+
* lines. Richer fields (needs/covers/docs) land in the spec-engine slice.
|
|
10
|
+
*/
|
|
11
|
+
export function parseSpec(markdown: string): ISpec {
|
|
12
|
+
const fm = parseFrontmatter(markdown);
|
|
13
|
+
const tasks = parseTasks(markdown);
|
|
14
|
+
const intent = parseSection(markdown, "Acceptance criteria");
|
|
15
|
+
|
|
16
|
+
if (intent.length > 0) {
|
|
17
|
+
for (const task of tasks) {
|
|
18
|
+
task.intent = intent;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
id: fm.id ?? "",
|
|
24
|
+
title: fm.title ?? "",
|
|
25
|
+
verify: fm.verify ?? "",
|
|
26
|
+
tasks,
|
|
27
|
+
mode:
|
|
28
|
+
fm.mode === SPEC_MODE.existing ? SPEC_MODE.existing : SPEC_MODE.scratch,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Return the text under a `## <heading>` block, up to the next `##` or EOF. */
|
|
33
|
+
function parseSection(md: string, heading: string): string {
|
|
34
|
+
const out: string[] = [];
|
|
35
|
+
let capturing = false;
|
|
36
|
+
|
|
37
|
+
for (const line of md.split("\n")) {
|
|
38
|
+
if (/^##\s+/.test(line)) {
|
|
39
|
+
if (capturing) {
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (line.slice(2).trim().toLowerCase() === heading.toLowerCase()) {
|
|
44
|
+
capturing = true;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (capturing) {
|
|
51
|
+
out.push(line);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
return out.join("\n").trim();
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function parseFrontmatter(md: string): Record<string, string> {
|
|
59
|
+
const out: Record<string, string> = {};
|
|
60
|
+
const match = /^---\n([\s\S]*?)\n---/.exec(md);
|
|
61
|
+
const body = match?.[1];
|
|
62
|
+
|
|
63
|
+
if (body === undefined) {
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
for (const line of body.split("\n")) {
|
|
68
|
+
const idx = line.indexOf(":");
|
|
69
|
+
|
|
70
|
+
if (idx === -1) {
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const key = line.slice(0, idx).trim();
|
|
75
|
+
const val = line
|
|
76
|
+
.slice(idx + 1)
|
|
77
|
+
.trim()
|
|
78
|
+
.replace(/^["']|["']$/g, "");
|
|
79
|
+
|
|
80
|
+
if (key.length > 0) {
|
|
81
|
+
out[key] = val;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return out;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function splitList(value: string): string[] {
|
|
89
|
+
return value
|
|
90
|
+
.split(",")
|
|
91
|
+
.map((s) => s.trim())
|
|
92
|
+
.filter((s) => s.length > 0);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function parseTasks(md: string): ITask[] {
|
|
96
|
+
const tasks: ITask[] = [];
|
|
97
|
+
let current: ITask | null = null;
|
|
98
|
+
|
|
99
|
+
for (const line of md.split("\n")) {
|
|
100
|
+
const start = /^(\d+)\.\s+/.exec(line);
|
|
101
|
+
|
|
102
|
+
if (start) {
|
|
103
|
+
if (current) {
|
|
104
|
+
tasks.push(current);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
current = { id: start[1] ?? "", accept: "", files: [], context: [] };
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (!current) {
|
|
112
|
+
continue;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const acceptMatch = /^\s+accept:\s*(.+)$/.exec(line);
|
|
116
|
+
const acceptValue = acceptMatch?.[1];
|
|
117
|
+
|
|
118
|
+
if (acceptValue !== undefined) {
|
|
119
|
+
current.accept = acceptValue.trim();
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const filesMatch = /^\s+files:\s*(.+)$/.exec(line);
|
|
124
|
+
const filesValue = filesMatch?.[1];
|
|
125
|
+
|
|
126
|
+
if (filesValue !== undefined) {
|
|
127
|
+
current.files = splitList(filesValue);
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const contextMatch = /^\s+context:\s*(.+)$/.exec(line);
|
|
132
|
+
const contextValue = contextMatch?.[1];
|
|
133
|
+
|
|
134
|
+
if (contextValue !== undefined) {
|
|
135
|
+
current.context = splitList(contextValue);
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const fixMatch = /^\s+fix:\s*(.+)$/.exec(line);
|
|
140
|
+
const fixValue = fixMatch?.[1];
|
|
141
|
+
|
|
142
|
+
if (fixValue !== undefined) {
|
|
143
|
+
current.fix = fixValue.trim();
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
if (current) {
|
|
148
|
+
tasks.push(current);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
return tasks;
|
|
152
|
+
}
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import type { IProvider } from "../inference";
|
|
3
|
+
import type { Reporter } from "../loop";
|
|
4
|
+
import { isRecord, isArray } from "../lib/guards";
|
|
5
|
+
import { extractJson } from "../lib/json";
|
|
6
|
+
import { runTests, isRealRed } from "../validate";
|
|
7
|
+
import { FINDING_KIND } from "./spec.constants";
|
|
8
|
+
import type {
|
|
9
|
+
FindingKind,
|
|
10
|
+
ITestFinding,
|
|
11
|
+
IReviewResult,
|
|
12
|
+
IReviewInput,
|
|
13
|
+
IReviewFixOptions,
|
|
14
|
+
IReviewFixResult,
|
|
15
|
+
} from "./spec.types";
|
|
16
|
+
|
|
17
|
+
const FINDING_KINDS = new Set<string>(Object.values(FINDING_KIND));
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Offline teacher review of a generated suite: the `runTests` oracle proves a
|
|
21
|
+
* suite is real/runnable/RED, but NOT that its assertions are CORRECT. A live
|
|
22
|
+
* run found two ways a model's tests go wrong — assertions that are
|
|
23
|
+
* *unsatisfiable* given runtime reality (e.g. `1.005 * 100 === 100.4999…` under
|
|
24
|
+
* IEEE-754) and *ambiguity-overreach* (asserting one arbitrary resolution of a
|
|
25
|
+
* tie the criteria leave open). This vets for both, plus *over-strict* (testing
|
|
26
|
+
* behaviour the criteria never required).
|
|
27
|
+
*
|
|
28
|
+
* Point `provider` at a flagship — this is an OFFLINE teacher step, never a
|
|
29
|
+
* runtime dependency. It returns findings plus a corrected suite; the CALLER
|
|
30
|
+
* must re-run the RED oracle on `correctedSuite` (a fix must stay real + RED)
|
|
31
|
+
* before trusting it.
|
|
32
|
+
*/
|
|
33
|
+
const SYSTEM = [
|
|
34
|
+
"You are a senior TypeScript test reviewer. You are given acceptance criteria and a generated `bun:test` suite, and you find assertions that would block a CORRECT implementation.",
|
|
35
|
+
"Flag three kinds: (1) `unsatisfiable` — no correct implementation can pass it given JS/TS runtime reality (e.g. IEEE-754: `1.005 * 100` is `100.4999…`, so cents can't recover `101` from the number `1.005`); (2) `over-strict` — it asserts behaviour the criteria never required; (3) `ambiguous` — the criteria under-specify and the test asserts one arbitrary resolution (e.g. which index gets the leftover penny on a tie).",
|
|
36
|
+
'Respond with ONLY JSON: {"findings":[{"test":"<name>","kind":"unsatisfiable|over-strict|ambiguous|ok","reason":"<short>"}],"correctedSuite":"<full corrected test file, or empty string if nothing needs changing>"}.',
|
|
37
|
+
"When correcting: fix or remove ONLY the flawed assertions — keep all the sound coverage, keep the same imports, and do not weaken the suite otherwise.",
|
|
38
|
+
].join("\n");
|
|
39
|
+
|
|
40
|
+
export async function reviewTests(
|
|
41
|
+
provider: IProvider,
|
|
42
|
+
input: IReviewInput
|
|
43
|
+
): Promise<IReviewResult> {
|
|
44
|
+
const res = await provider.complete(
|
|
45
|
+
[
|
|
46
|
+
{ role: "system", content: SYSTEM },
|
|
47
|
+
{
|
|
48
|
+
role: "user",
|
|
49
|
+
content: `Goal: ${input.goal}\n\nAcceptance criteria:\n${input.criteria}\n\nThe suite imports the implementation from "${input.moduleSpecifier}".\n\nGenerated suite:\n${input.testCode}`,
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
{ temperature: 0 }
|
|
53
|
+
);
|
|
54
|
+
|
|
55
|
+
const empty: IReviewResult = { findings: [], correctedSuite: "" };
|
|
56
|
+
|
|
57
|
+
let data: unknown;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
data = JSON.parse(extractJson(res.content));
|
|
61
|
+
} catch {
|
|
62
|
+
return empty;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (!isRecord(data)) {
|
|
66
|
+
return empty;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
findings: parseFindings(data.findings),
|
|
71
|
+
correctedSuite:
|
|
72
|
+
typeof data.correctedSuite === "string" ? data.correctedSuite : "",
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function parseFindings(raw: unknown): ITestFinding[] {
|
|
77
|
+
if (!isArray(raw)) {
|
|
78
|
+
return [];
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const findings: ITestFinding[] = [];
|
|
82
|
+
|
|
83
|
+
for (const entry of raw) {
|
|
84
|
+
if (!isRecord(entry)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const { test, kind, reason } = entry;
|
|
89
|
+
|
|
90
|
+
if (typeof test === "string" && isFindingKind(kind)) {
|
|
91
|
+
findings.push({
|
|
92
|
+
test,
|
|
93
|
+
kind,
|
|
94
|
+
reason: typeof reason === "string" ? reason : "",
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
return findings;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function isFindingKind(value: unknown): value is FindingKind {
|
|
103
|
+
return typeof value === "string" && FINDING_KINDS.has(value);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Run the offline review against the suite on disk and apply a correction —
|
|
108
|
+
* but only if it survives the SAME RED oracle the suite originally passed. A
|
|
109
|
+
* proposed fix that breaks loadability or goes vacuous is reverted, so review
|
|
110
|
+
* can never hand the implement loop anything less sound than what it got.
|
|
111
|
+
*/
|
|
112
|
+
export async function reviewAndFixSuite(
|
|
113
|
+
provider: IProvider,
|
|
114
|
+
cwd: string,
|
|
115
|
+
opts: IReviewFixOptions
|
|
116
|
+
): Promise<IReviewFixResult> {
|
|
117
|
+
const report: Reporter = opts.onEvent ?? (() => undefined);
|
|
118
|
+
const testPath = join(cwd, opts.testFile);
|
|
119
|
+
const original = await Bun.file(testPath).text();
|
|
120
|
+
|
|
121
|
+
const review = await reviewTests(provider, {
|
|
122
|
+
goal: opts.goal,
|
|
123
|
+
criteria: opts.criteria,
|
|
124
|
+
testCode: original,
|
|
125
|
+
moduleSpecifier: `./${opts.implFile.replace(/\.ts$/, "")}`,
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
for (const f of review.findings) {
|
|
129
|
+
report({
|
|
130
|
+
kind: "fix",
|
|
131
|
+
task: opts.testFile,
|
|
132
|
+
message: `review: ${f.kind} — ${f.test}: ${f.reason}`,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (review.correctedSuite.length === 0) {
|
|
137
|
+
return { findings: review.findings, applied: false };
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
await Bun.write(testPath, review.correctedSuite);
|
|
141
|
+
|
|
142
|
+
const run = await runTests(opts.testFile, cwd);
|
|
143
|
+
|
|
144
|
+
if (!isRealRed(run)) {
|
|
145
|
+
await Bun.write(testPath, original);
|
|
146
|
+
report({
|
|
147
|
+
kind: "fix",
|
|
148
|
+
task: opts.testFile,
|
|
149
|
+
message: `review correction broke the RED guarantee (${run.pass} pass / ${run.errors} err) — reverted`,
|
|
150
|
+
});
|
|
151
|
+
|
|
152
|
+
return { findings: review.findings, applied: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
report({
|
|
156
|
+
kind: "fix",
|
|
157
|
+
task: opts.testFile,
|
|
158
|
+
message: `review correction applied — ${run.total} tests, still RED`,
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
return { findings: review.findings, applied: true };
|
|
162
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** Spec run mode — compare against these, never the bare string. */
|
|
2
|
+
export const SPEC_MODE = {
|
|
3
|
+
scratch: "scratch",
|
|
4
|
+
existing: "existing",
|
|
5
|
+
} as const;
|
|
6
|
+
|
|
7
|
+
/** Verdict on a generated test — compare against these, never the bare string. */
|
|
8
|
+
export const FINDING_KIND = {
|
|
9
|
+
unsatisfiable: "unsatisfiable",
|
|
10
|
+
overStrict: "over-strict",
|
|
11
|
+
ambiguous: "ambiguous",
|
|
12
|
+
ok: "ok",
|
|
13
|
+
} as const;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { Reporter } from "../loop";
|
|
2
|
+
import { type SPEC_MODE, type FINDING_KIND } from "./spec.constants";
|
|
3
|
+
|
|
4
|
+
export type SpecMode = (typeof SPEC_MODE)[keyof typeof SPEC_MODE];
|
|
5
|
+
|
|
6
|
+
/** One chunk of work, derived from the spec's task breakdown. */
|
|
7
|
+
export interface ITask {
|
|
8
|
+
/** Stable id (the task number in v1). */
|
|
9
|
+
id: string;
|
|
10
|
+
/** The per-chunk proof: a shell command that must pass to close the chunk. */
|
|
11
|
+
accept: string;
|
|
12
|
+
/** Editable scope globs. Edits/creates outside these are rejected (drift tripwire). */
|
|
13
|
+
files: string[];
|
|
14
|
+
/** Read-only files shown to the model for context but never edited (e.g. tests). */
|
|
15
|
+
context?: string[];
|
|
16
|
+
/**
|
|
17
|
+
* The spec's intent/contract (its `## Acceptance criteria` prose) shown to the
|
|
18
|
+
* implement agent so it works from the stated goal instead of reverse-engineering
|
|
19
|
+
* it from positional test calls — where one ambiguous case can mislead it.
|
|
20
|
+
*/
|
|
21
|
+
intent?: string;
|
|
22
|
+
/** Optional auto-fix command run after each edit, before re-validating (e.g. `eslint --fix`). */
|
|
23
|
+
fix?: string;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A parsed spec: intent up top, decomposition in tasks. */
|
|
27
|
+
export interface ISpec {
|
|
28
|
+
id: string;
|
|
29
|
+
title: string;
|
|
30
|
+
/** The whole-spec gate, run once all tasks are green. */
|
|
31
|
+
verify: string;
|
|
32
|
+
tasks: ITask[];
|
|
33
|
+
/**
|
|
34
|
+
* Run mode. `scratch` (default): the harness DELETES the editable files to
|
|
35
|
+
* start RED, and the model regenerates them from the spec. `existing`: the
|
|
36
|
+
* project already exists and is kept — RED comes from a new failing test /
|
|
37
|
+
* stated goal, and the model edits in place (a feature/bugfix/refactor).
|
|
38
|
+
*/
|
|
39
|
+
mode?: SpecMode;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export type FindingKind = (typeof FINDING_KIND)[keyof typeof FINDING_KIND];
|
|
43
|
+
|
|
44
|
+
/** A teacher-review verdict on one generated test. */
|
|
45
|
+
export interface ITestFinding {
|
|
46
|
+
/** Name of the test the finding refers to. */
|
|
47
|
+
test: string;
|
|
48
|
+
kind: FindingKind;
|
|
49
|
+
reason: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface IReviewResult {
|
|
53
|
+
findings: ITestFinding[];
|
|
54
|
+
/** Full corrected test file, or "" when no change is needed. */
|
|
55
|
+
correctedSuite: string;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface IReviewInput {
|
|
59
|
+
goal: string;
|
|
60
|
+
criteria: string;
|
|
61
|
+
/** The generated test file's contents. */
|
|
62
|
+
testCode: string;
|
|
63
|
+
/** Module specifier the suite imports the impl from (e.g. "./money"). */
|
|
64
|
+
moduleSpecifier: string;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export interface IReviewFixOptions {
|
|
68
|
+
testFile: string;
|
|
69
|
+
implFile: string;
|
|
70
|
+
goal: string;
|
|
71
|
+
criteria: string;
|
|
72
|
+
onEvent?: Reporter;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface IReviewFixResult {
|
|
76
|
+
findings: ITestFinding[];
|
|
77
|
+
/** True only when a correction was written AND it stayed real + RED. */
|
|
78
|
+
applied: boolean;
|
|
79
|
+
}
|
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { isRecord } from "../lib/guards";
|
|
3
|
+
import { resolveScopeFiles } from "../lib/fs";
|
|
4
|
+
import type { IStackProfile } from "./stack-detection.types";
|
|
5
|
+
import {
|
|
6
|
+
PACK_REGISTRY,
|
|
7
|
+
ALWAYS_ON_PACKS,
|
|
8
|
+
type IPackRegistry,
|
|
9
|
+
type IPackId,
|
|
10
|
+
} from "./packs";
|
|
11
|
+
|
|
12
|
+
/** Parse package.json and extract deps/devDeps, tolerating missing/invalid JSON. */
|
|
13
|
+
async function loadPackageDeps(cwd: string): Promise<{
|
|
14
|
+
deps: Set<string>;
|
|
15
|
+
devDeps: Set<string>;
|
|
16
|
+
exists: boolean;
|
|
17
|
+
valid: boolean;
|
|
18
|
+
}> {
|
|
19
|
+
const pkgPath = join(cwd, "package.json");
|
|
20
|
+
const file = Bun.file(pkgPath);
|
|
21
|
+
|
|
22
|
+
const exists = await file.exists();
|
|
23
|
+
|
|
24
|
+
if (!exists) {
|
|
25
|
+
return { deps: new Set(), devDeps: new Set(), exists: false, valid: false };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
try {
|
|
29
|
+
const text = await file.text();
|
|
30
|
+
const parsed: unknown = JSON.parse(text);
|
|
31
|
+
|
|
32
|
+
const deps = extractDeps(parsed, "dependencies");
|
|
33
|
+
const devDeps = extractDeps(parsed, "devDependencies");
|
|
34
|
+
|
|
35
|
+
return { deps, devDeps, exists: true, valid: true };
|
|
36
|
+
} catch {
|
|
37
|
+
return { deps: new Set(), devDeps: new Set(), exists: true, valid: false };
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Extract dependency keys from a parsed package.json object. */
|
|
42
|
+
function extractDeps(obj: unknown, field: string): Set<string> {
|
|
43
|
+
if (!isRecord(obj)) {
|
|
44
|
+
return new Set();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const depsObj = obj[field];
|
|
48
|
+
|
|
49
|
+
if (!isRecord(depsObj)) {
|
|
50
|
+
return new Set();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return new Set(Object.keys(depsObj));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Check which packs have file-based matches, returning both match set and whether any were found. */
|
|
57
|
+
async function checkFileMatches(
|
|
58
|
+
cwd: string,
|
|
59
|
+
registry: IPackRegistry
|
|
60
|
+
): Promise<{ matches: Set<string>; anyFound: boolean }> {
|
|
61
|
+
const matches = new Set<string>();
|
|
62
|
+
|
|
63
|
+
for (const descriptor of Object.values(registry)) {
|
|
64
|
+
const filesOption =
|
|
65
|
+
"files" in descriptor.appliesWhen
|
|
66
|
+
? descriptor.appliesWhen.files
|
|
67
|
+
: undefined;
|
|
68
|
+
|
|
69
|
+
if (!filesOption) {
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const filesList = Array.from(filesOption);
|
|
74
|
+
|
|
75
|
+
if (filesList.length === 0) {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
try {
|
|
80
|
+
const resolved = await resolveScopeFiles(cwd, filesList);
|
|
81
|
+
|
|
82
|
+
if (resolved.length > 0) {
|
|
83
|
+
matches.add(descriptor.id);
|
|
84
|
+
}
|
|
85
|
+
} catch {
|
|
86
|
+
// If file resolution fails, skip this pack's file checks
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
return { matches, anyFound: matches.size > 0 };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Check if any required dependencies are present. */
|
|
94
|
+
function matchAnyDeps(
|
|
95
|
+
required: readonly string[] | undefined,
|
|
96
|
+
allDeps: Set<string>
|
|
97
|
+
): string | undefined {
|
|
98
|
+
if (!required || required.length === 0) {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return Array.from(required).find((dep) => allDeps.has(dep));
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Check if all required dependencies are present. */
|
|
106
|
+
function matchAllDeps(
|
|
107
|
+
required: readonly string[] | undefined,
|
|
108
|
+
allDeps: Set<string>
|
|
109
|
+
): boolean {
|
|
110
|
+
if (!required || required.length === 0) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return Array.from(required).every((dep) => allDeps.has(dep));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Evaluate a single pack descriptor against available deps and file matches. */
|
|
118
|
+
function evaluatePack(
|
|
119
|
+
packId: IPackId,
|
|
120
|
+
descriptor: IPackRegistry[IPackId],
|
|
121
|
+
allDeps: Set<string>,
|
|
122
|
+
fileMatches: Set<string>
|
|
123
|
+
): { enabled: boolean; signal?: string } {
|
|
124
|
+
// Always-on packs are handled separately
|
|
125
|
+
if ("always" in descriptor.appliesWhen) {
|
|
126
|
+
return { enabled: false };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if ("anyDeps" in descriptor.appliesWhen) {
|
|
130
|
+
const matched = matchAnyDeps(descriptor.appliesWhen.anyDeps, allDeps);
|
|
131
|
+
|
|
132
|
+
if (matched !== undefined) {
|
|
133
|
+
return { enabled: true, signal: `${descriptor.label} (${matched})` };
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
if ("allDeps" in descriptor.appliesWhen) {
|
|
138
|
+
const hasAll = matchAllDeps(descriptor.appliesWhen.allDeps, allDeps);
|
|
139
|
+
|
|
140
|
+
if (hasAll) {
|
|
141
|
+
return { enabled: true, signal: descriptor.label };
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
if ("files" in descriptor.appliesWhen) {
|
|
146
|
+
const filesList = Array.from(descriptor.appliesWhen.files);
|
|
147
|
+
|
|
148
|
+
if (filesList.length > 0 && fileMatches.has(packId)) {
|
|
149
|
+
return { enabled: true, signal: `${descriptor.label} (file detected)` };
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
return { enabled: false };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Detect the target project's technology stack and return an IStackProfile
|
|
158
|
+
* that determines which rule packs should be enabled.
|
|
159
|
+
*
|
|
160
|
+
* Detection layers:
|
|
161
|
+
* 1. Parse package.json to extract dependencies and devDependencies
|
|
162
|
+
* 2. Check file existence for packs with file-based triggers
|
|
163
|
+
* 3. Evaluate every pack descriptor and collect enabled pack IDs
|
|
164
|
+
*
|
|
165
|
+
* Always-on packs are emitted first (deterministic), then framework/library packs.
|
|
166
|
+
*/
|
|
167
|
+
export async function detectStack(cwd: string): Promise<IStackProfile> {
|
|
168
|
+
const { deps, devDeps, exists, valid } = await loadPackageDeps(cwd);
|
|
169
|
+
|
|
170
|
+
// No package.json or invalid JSON — return minimal profile
|
|
171
|
+
if (!valid) {
|
|
172
|
+
const reason = exists ? "invalid package.json" : "no package.json found";
|
|
173
|
+
|
|
174
|
+
return {
|
|
175
|
+
name: "generic",
|
|
176
|
+
packs: ["generic-ts"],
|
|
177
|
+
confidence: "guess",
|
|
178
|
+
reason,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const allDeps = new Set([...deps, ...devDeps]);
|
|
183
|
+
const { matches: fileMatches, anyFound: filesFound } = await checkFileMatches(
|
|
184
|
+
cwd,
|
|
185
|
+
PACK_REGISTRY
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
const enabledPacks: string[] = [];
|
|
189
|
+
const matchedSignals: string[] = [];
|
|
190
|
+
|
|
191
|
+
// Add always-on packs first (deterministic order)
|
|
192
|
+
for (const packId of Array.from(ALWAYS_ON_PACKS)) {
|
|
193
|
+
enabledPacks.push(packId);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// Evaluate and add framework/library packs
|
|
197
|
+
const alwaysOnSet = new Set<string>(Array.from(ALWAYS_ON_PACKS));
|
|
198
|
+
|
|
199
|
+
for (const descriptor of Object.values(PACK_REGISTRY)) {
|
|
200
|
+
if (alwaysOnSet.has(descriptor.id)) {
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const { enabled, signal } = evaluatePack(
|
|
205
|
+
descriptor.id,
|
|
206
|
+
descriptor,
|
|
207
|
+
allDeps,
|
|
208
|
+
fileMatches
|
|
209
|
+
);
|
|
210
|
+
|
|
211
|
+
if (enabled) {
|
|
212
|
+
enabledPacks.push(descriptor.id);
|
|
213
|
+
|
|
214
|
+
if (signal !== undefined) {
|
|
215
|
+
matchedSignals.push(signal);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// Determine confidence level
|
|
221
|
+
const hasDepMatches = matchedSignals.some(
|
|
222
|
+
(s) => !s.includes("(file detected)")
|
|
223
|
+
);
|
|
224
|
+
const confidence: "certain" | "likely" | "guess" = hasDepMatches
|
|
225
|
+
? "certain"
|
|
226
|
+
: filesFound
|
|
227
|
+
? "likely"
|
|
228
|
+
: "guess";
|
|
229
|
+
|
|
230
|
+
// Determine stack name (framework/library packs only, excluding always-on)
|
|
231
|
+
const frameworkPacks = enabledPacks.filter((p) => !alwaysOnSet.has(p));
|
|
232
|
+
const stackName =
|
|
233
|
+
frameworkPacks.length > 0 ? frameworkPacks.join("+") : "generic";
|
|
234
|
+
|
|
235
|
+
const reason =
|
|
236
|
+
matchedSignals.length > 0
|
|
237
|
+
? `Detected: ${matchedSignals.join(", ")}`
|
|
238
|
+
: "Generic TypeScript project (no framework/library detected)";
|
|
239
|
+
|
|
240
|
+
return {
|
|
241
|
+
name: stackName,
|
|
242
|
+
packs: enabledPacks,
|
|
243
|
+
confidence,
|
|
244
|
+
reason,
|
|
245
|
+
};
|
|
246
|
+
}
|