@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
package/src/loop/turn.ts
ADDED
|
@@ -0,0 +1,856 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { basename, join } from "node:path";
|
|
3
|
+
import type { ITask } from "../spec";
|
|
4
|
+
import type { IChatMessage, IToolCall } from "../inference";
|
|
5
|
+
import {
|
|
6
|
+
validate,
|
|
7
|
+
runAccept,
|
|
8
|
+
sameErrorSet,
|
|
9
|
+
type ErrorParser,
|
|
10
|
+
type ErrorSet,
|
|
11
|
+
} from "../validate";
|
|
12
|
+
import { isInScope } from "../lib/scope";
|
|
13
|
+
import { fileExists, resolveScopeFiles } from "../lib/fs";
|
|
14
|
+
import { RUN_STATUS, STUCK_REASON, LOOP_LIMITS } from "./loop.constants";
|
|
15
|
+
import type { IRunResult, Reporter } from "./loop.types";
|
|
16
|
+
import { flags } from "../config";
|
|
17
|
+
import type { IStackProfile } from "../stack-detection";
|
|
18
|
+
import { gateFeedback } from "./feedback";
|
|
19
|
+
import { executeTool } from "./tools";
|
|
20
|
+
import {
|
|
21
|
+
astGrepFix,
|
|
22
|
+
dropRedundantAnnotations,
|
|
23
|
+
stripLiteralCasts,
|
|
24
|
+
} from "./astgrep-fix";
|
|
25
|
+
import {
|
|
26
|
+
EDIT_TOOL,
|
|
27
|
+
EDIT_LINES_TOOL,
|
|
28
|
+
CREATE_TOOL,
|
|
29
|
+
RUN_TOOL,
|
|
30
|
+
READ_TOOL,
|
|
31
|
+
LSP_TOOLS,
|
|
32
|
+
TOOL_NAME,
|
|
33
|
+
} from "../agent";
|
|
34
|
+
import { TsService, type ITsDiagnostic } from "../lsp";
|
|
35
|
+
import type { FileLinter, IFileLintProblem } from "../detect-gate";
|
|
36
|
+
import {
|
|
37
|
+
buildMetaRuleContext,
|
|
38
|
+
runMetaRules,
|
|
39
|
+
META_RULES,
|
|
40
|
+
type IMetaRuleViolation,
|
|
41
|
+
} from "../meta-rules";
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The shared turn primitives — one tool-using-conversation step and the
|
|
45
|
+
* deterministic gate that confirms "done". Both drivers compose these: `runTask`
|
|
46
|
+
* (the RED-first, drive-to-green eval wrapper in run.ts) and the interactive
|
|
47
|
+
* `Session` (the CLI's persistent conversation). Keeping them here means there is
|
|
48
|
+
* exactly ONE turn-loop and ONE gate, never two implementations to drift apart.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
// The base tools the model always has, plus the semantic LSP/search tools
|
|
52
|
+
// (rename/type_at/find_references/symbol_search/diagnostics/organize_imports/
|
|
53
|
+
// search). The LSP set is for NAVIGATING an existing codebase. Measured (money
|
|
54
|
+
// vs react-board, 2026-06-06): handing the 7 nav tools to a SCRATCH create-from-
|
|
55
|
+
// spec task DILUTES the create path — the small model narrates/explores
|
|
56
|
+
// ("let me check existing files…") instead of emitting `create`, and stalls.
|
|
57
|
+
// react-board (existing code) used them cleanly. So gate them on whether there
|
|
58
|
+
// is existing code to navigate. TSFORGE_NO_LSP_TOOLS=1 forces them off entirely.
|
|
59
|
+
const BASE_TOOLS = [READ_TOOL, RUN_TOOL, EDIT_TOOL, CREATE_TOOL];
|
|
60
|
+
|
|
61
|
+
const HASHLINE_TOOLS = flags.hashlineEditTool() ? [EDIT_LINES_TOOL] : [];
|
|
62
|
+
|
|
63
|
+
const ALL_TOOLS = [...BASE_TOOLS, ...HASHLINE_TOOLS, ...LSP_TOOLS];
|
|
64
|
+
|
|
65
|
+
export function toolsFor(hasExistingCode: boolean): typeof ALL_TOOLS {
|
|
66
|
+
if (flags.noLspTools() || !hasExistingCode) {
|
|
67
|
+
return [...BASE_TOOLS, ...HASHLINE_TOOLS];
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
return ALL_TOOLS;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** The model wrote prose but issued NO tool call while the gate is still red —
|
|
74
|
+
* a narration-without-action turn (seen on money + react-board). Nudge it to ACT. */
|
|
75
|
+
export const NO_TOOL_CALL_NUDGE =
|
|
76
|
+
"You replied with text but called no tool. Writing code or a plan in your " +
|
|
77
|
+
"message does NOT change any file. Don't describe the next step — emit the " +
|
|
78
|
+
"actual tool call now (create/edit to change a file, read/search to inspect one).";
|
|
79
|
+
|
|
80
|
+
/** A build turn ended with the model writing whole files INTO its chat message
|
|
81
|
+
* (fenced code blocks) instead of calling `create` — the narrate-instead-of-build
|
|
82
|
+
* failure. A chat message is never written to disk, so this nudges it to act. */
|
|
83
|
+
export const BUILD_NUDGE =
|
|
84
|
+
"STOP — you wrote file contents in your message, but that does NOT create any " +
|
|
85
|
+
"files on disk and cannot run. Write them for real now: call `create` once per " +
|
|
86
|
+
"file (relative path + full contents), ONE file per call, starting with the " +
|
|
87
|
+
"first. Do not paste code into your reply again — emit the create tool call.";
|
|
88
|
+
|
|
89
|
+
/** The coordinator's per-task working context (immutable inputs). */
|
|
90
|
+
export interface ILoopCtx {
|
|
91
|
+
task: ITask;
|
|
92
|
+
cwd: string;
|
|
93
|
+
tsService: TsService | null;
|
|
94
|
+
/** Write-time single-file linter (the gate's eslint rules, applied per write so
|
|
95
|
+
* moat violations tsc can't see surface inline). Omitted ⇒ type-only guard. */
|
|
96
|
+
lintFile?: FileLinter;
|
|
97
|
+
parse: ErrorParser | undefined;
|
|
98
|
+
report: Reporter;
|
|
99
|
+
messages: IChatMessage[];
|
|
100
|
+
/** Detected stack profile — determines which rule packs are enabled. */
|
|
101
|
+
stackProfile?: IStackProfile;
|
|
102
|
+
/** Rule severity overrides from tsforge.config.json (maps rule ID to "error" | "warn" | "off"). */
|
|
103
|
+
ruleOverrides?: Readonly<Record<string, "error" | "warn" | "off">>;
|
|
104
|
+
/** When set, the gate's command output is streamed here live (the CLI wires
|
|
105
|
+
* this so a slow gate like `vite build` + browser isn't silent dead air).
|
|
106
|
+
* Omitted on the eval path, where output is just captured for scoring. */
|
|
107
|
+
onGateChunk?: (text: string) => void;
|
|
108
|
+
/** Cancellation for the in-flight turn — threaded into tool `run` commands and
|
|
109
|
+
* the gate so a Ctrl-C (or a kill-timeout) reaches the child processes, not
|
|
110
|
+
* just the model call. Set per-send by the Session. */
|
|
111
|
+
signal?: AbortSignal;
|
|
112
|
+
/** Wired by the interactive CLI: turn this workspace into a web project (the
|
|
113
|
+
* `scaffold_web` tool calls it). Threaded into the tool context. */
|
|
114
|
+
setupWeb?: (framework: string) => Promise<void>;
|
|
115
|
+
/** PLAN MODE (set via Session.setPlanMode): threaded into the tool context so
|
|
116
|
+
* mutating tools are rejected at dispatch — the model only plans. */
|
|
117
|
+
readOnly?: boolean;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Mutable state threaded across turns (the gradient the loop descends). */
|
|
121
|
+
export interface ILoopState {
|
|
122
|
+
prevGateErrors: ErrorSet;
|
|
123
|
+
gateNoProgress: number;
|
|
124
|
+
lastGateCount: number;
|
|
125
|
+
edits: number;
|
|
126
|
+
regressions: number;
|
|
127
|
+
/** Count of TTSR rule interrupts this task. Hard cap at 3 to prevent loops. */
|
|
128
|
+
ttsrInterrupts: number;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Build the in-process TS LanguageService if the project has a tsconfig. Guarded
|
|
132
|
+
* so a setup failure can't break the loop (the `tsc -p` gate stays authority). */
|
|
133
|
+
export async function buildTsService(cwd: string): Promise<TsService | null> {
|
|
134
|
+
try {
|
|
135
|
+
if (await fileExists(cwd, "tsconfig.json")) {
|
|
136
|
+
return new TsService(cwd);
|
|
137
|
+
}
|
|
138
|
+
} catch {
|
|
139
|
+
// degrade silently — the gate runs regardless
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Diagnostic codes that are EXPECTED noise mid-build, not real mistakes: 2307 =
|
|
146
|
+
* "cannot find module" (a sibling the model hasn't created yet in this batch). */
|
|
147
|
+
const TRANSIENT_DIAG_CODES = new Set<number>([2307]);
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The write-guard and interim check run `tsc` WITHOUT a build, so they see the STUB
|
|
151
|
+
* `routeTree.gen.ts` (only `/` + `__root__` registered). TanStack Router's types are
|
|
152
|
+
* driven by that tree, so EVERY route-API type usage is a phantom error there — and
|
|
153
|
+
* route correctness can ONLY be validated against the real tree, which the gate's
|
|
154
|
+
* build-first step regenerates. So we ignore ALL route-API type errors at write/
|
|
155
|
+
* interim time; the GATE still validates routes for real.
|
|
156
|
+
*
|
|
157
|
+
* Measured across overnight CRM builds, these phantoms took MANY message shapes
|
|
158
|
+
* (≈51 of ~67 errors in build #2 alone): `to` unions in any order (`"/" | "." | ".."`,
|
|
159
|
+
* `"." | ".." | "/"`), bare `"/"` params, `createFileRoute` constraints, and
|
|
160
|
+
* `params={{…}}` excess-property errors. Path/shape matching was whack-a-mole, so we
|
|
161
|
+
* match the ROUTER'S INTERNAL TYPE SIGNATURES instead — robust to ordering and form.
|
|
162
|
+
* SAFE: a genuinely wrong route still fails the gate (real tree); we only suppress
|
|
163
|
+
* the un-fixable write/interim noise the model would otherwise chase.
|
|
164
|
+
*/
|
|
165
|
+
const ROUTE_API_SIGNATURES: readonly RegExp[] = [
|
|
166
|
+
/ConstrainLiteral</, // `<Link to>` / createFileRoute path constraint
|
|
167
|
+
/ParamsReducerFn</, // `params={{…}}` on a typed route
|
|
168
|
+
/"__root__"/, // stub route-id union (`"__root__" | "/"`)
|
|
169
|
+
/keyof FileRoutesByPath/, // navigate({ to }) target union
|
|
170
|
+
// A target type union composed ONLY of the nav literals "/", ".", ".." (any
|
|
171
|
+
// order/subset) — the EARLY stub tree, before any real route exists.
|
|
172
|
+
/assignable to (?:parameter of )?type '(?:"\/"|"\."|"\.\.")(?:\s*\|\s*(?:"\/"|"\."|"\.\."))*'/,
|
|
173
|
+
// The same `<Link to>` / navigate target-union error AFTER real routes exist: the
|
|
174
|
+
// union ALWAYS still contains the ".." nav literal — which a normal string-literal
|
|
175
|
+
// union never does — so an "assignable to … type '…\"..\"…'" error is a route
|
|
176
|
+
// phantom REGARDLESS of which real routes are also in the union. (The nav-only
|
|
177
|
+
// pattern above STOPPED matching once the union grew, so the model got nagged to
|
|
178
|
+
// "fix" forward-referenced links (e.g. to="/x/create" written before x.create.tsx
|
|
179
|
+
// lands + routeTree regenerates) that the build resolves on its own — ~1/3 of a
|
|
180
|
+
// multi-route build burned chasing them. The real gate rebuilds the tree and still
|
|
181
|
+
// catches a genuinely missing route.)
|
|
182
|
+
/assignable to (?:parameter of )?type '[^']*"\.\."[^']*'/,
|
|
183
|
+
// `Route.useParams()` against the stub gives EMPTY params (`{}` or `never`), so a
|
|
184
|
+
// `route.params.<name>` access fails. ≥2 builds (night2 `never` ×84, twitter `{}` ×6).
|
|
185
|
+
// Broadest signal here — a real `{}`/`never` property access would also match — but
|
|
186
|
+
// those are rare and the GATE (real tree) still catches any genuine one.
|
|
187
|
+
/Property '[^']+' does not exist on type '(?:\{\}|never)'/,
|
|
188
|
+
];
|
|
189
|
+
|
|
190
|
+
/** A TanStack route-API type error seen at write/interim time against the stub tree —
|
|
191
|
+
* a phantom the build erases; never surface it (the gate validates routes for real). */
|
|
192
|
+
export function isPhantomRouteError(message: string): boolean {
|
|
193
|
+
return ROUTE_API_SIGNATURES.some((re) => re.test(message));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/** A diagnostic that's expected mid-build noise (missing sibling module, or the
|
|
197
|
+
* stub-route-tree phantom) — not a real mistake the model should chase. */
|
|
198
|
+
function isTransientDiag(d: { code: number; message: string }): boolean {
|
|
199
|
+
return TRANSIENT_DIAG_CODES.has(d.code) || isPhantomRouteError(d.message);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/** Max diagnostics surfaced per write — keep the in-band feedback tight. */
|
|
203
|
+
const MAX_WRITE_GUARD_DIAGS = 5;
|
|
204
|
+
|
|
205
|
+
/** Render the per-issue lines (type errors + lint problems), capped + ordered. */
|
|
206
|
+
function writeGuardLines(
|
|
207
|
+
absPath: string,
|
|
208
|
+
typeErrors: readonly ITsDiagnostic[],
|
|
209
|
+
lintProblems: readonly IFileLintProblem[]
|
|
210
|
+
): string {
|
|
211
|
+
let text = "";
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
text = readFileSync(absPath, "utf8");
|
|
215
|
+
} catch {
|
|
216
|
+
text = "";
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const lineOf = (offset: number): number =>
|
|
220
|
+
text.slice(0, offset).split("\n").length;
|
|
221
|
+
const typeLines = typeErrors.map(
|
|
222
|
+
(d) => ` L${String(lineOf(d.start))}: ${d.message} (TS${String(d.code)})`
|
|
223
|
+
);
|
|
224
|
+
const lintLines = lintProblems.map(
|
|
225
|
+
(p) => ` L${String(p.line)}: ${p.message} (${p.ruleId})`
|
|
226
|
+
);
|
|
227
|
+
const all = [...typeLines, ...lintLines];
|
|
228
|
+
const shown = all.slice(0, MAX_WRITE_GUARD_DIAGS).join("\n");
|
|
229
|
+
const more =
|
|
230
|
+
all.length > MAX_WRITE_GUARD_DIAGS
|
|
231
|
+
? `\n …and ${String(all.length - MAX_WRITE_GUARD_DIAGS)} more`
|
|
232
|
+
: "";
|
|
233
|
+
|
|
234
|
+
return `${shown}${more}`;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Max dependant files (and errors each) to name in the blast-radius section. */
|
|
238
|
+
const MAX_DEP_FILES = 4;
|
|
239
|
+
const MAX_DEP_ERRORS = 2;
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Render the CROSS-FILE blast radius: the dependant files an edit broke, with the
|
|
243
|
+
* first error(s) in each. Empty string when nothing downstream broke. This is the
|
|
244
|
+
* write-guard reaching across the import graph (see TsService.dependantErrors).
|
|
245
|
+
*/
|
|
246
|
+
function dependantBlastRadius(
|
|
247
|
+
dependants: readonly { file: string; errors: readonly ITsDiagnostic[] }[]
|
|
248
|
+
): string {
|
|
249
|
+
if (dependants.length === 0) {
|
|
250
|
+
return "";
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const blocks = dependants.slice(0, MAX_DEP_FILES).map((d) => {
|
|
254
|
+
let text = "";
|
|
255
|
+
|
|
256
|
+
try {
|
|
257
|
+
text = readFileSync(d.file, "utf8");
|
|
258
|
+
} catch {
|
|
259
|
+
text = "";
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
const lineOf = (offset: number): number =>
|
|
263
|
+
text.slice(0, offset).split("\n").length;
|
|
264
|
+
|
|
265
|
+
return d.errors
|
|
266
|
+
.slice(0, MAX_DEP_ERRORS)
|
|
267
|
+
.map(
|
|
268
|
+
(e) =>
|
|
269
|
+
` ${basename(d.file)}:${String(lineOf(e.start))} ${e.message} (TS${String(e.code)})`
|
|
270
|
+
)
|
|
271
|
+
.join("\n");
|
|
272
|
+
});
|
|
273
|
+
const more =
|
|
274
|
+
dependants.length > MAX_DEP_FILES
|
|
275
|
+
? `\n …and ${String(dependants.length - MAX_DEP_FILES)} more file(s)`
|
|
276
|
+
: "";
|
|
277
|
+
|
|
278
|
+
return (
|
|
279
|
+
`\n\n⚠ BLAST RADIUS — this change broke ${String(dependants.length)} file(s) that ` +
|
|
280
|
+
`depend on it. Fix them too, or revert the change (e.g. a signature you altered):\n` +
|
|
281
|
+
`${blocks.join("\n")}${more}`
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/**
|
|
286
|
+
* WRITE-TIME GUARD: the moment the model writes a file, check JUST that file and
|
|
287
|
+
* hand any real problems back AS THE TOOL RESULT — so the model fixes them THIS
|
|
288
|
+
* turn, while the file is fresh, before building more on a broken foundation. Two
|
|
289
|
+
* layers: (1) tsc diagnostics via the in-process language service (real type
|
|
290
|
+
* errors); (2) the gate's eslint rules on this one file (when `lintFile` is wired)
|
|
291
|
+
* — the STRICTNESS MOAT (`no-as`, `I`-prefix, `prefer-template`) that tsc is blind
|
|
292
|
+
* to. A run log showed `Object.keys(x) as unknown as …` written in every domain
|
|
293
|
+
* file: type-valid, so the old type-only guard passed it, and the `as` violations
|
|
294
|
+
* piled up unseen until the gate. Together they convert the dominant failure mode
|
|
295
|
+
* (write 8 files → discover a 40-issue cascade at the gate → many repair turns)
|
|
296
|
+
* into (write 1 file → see its issue now → fix it). Transient "cannot find module"
|
|
297
|
+
* for not-yet-created siblings is filtered as expected noise.
|
|
298
|
+
*/
|
|
299
|
+
async function writeGuard(
|
|
300
|
+
ctx: { tsService: TsService; cwd: string; lintFile?: FileLinter },
|
|
301
|
+
file: string,
|
|
302
|
+
report: Reporter,
|
|
303
|
+
taskId: string
|
|
304
|
+
): Promise<string> {
|
|
305
|
+
const { tsService, cwd, lintFile } = ctx;
|
|
306
|
+
// `file` is workspace-relative (the create/edit handler normalized it); the
|
|
307
|
+
// strip/linter/readFileSync need the absolute path.
|
|
308
|
+
const absPath = join(cwd, file);
|
|
309
|
+
// Strip the model's reflexive needless literal-to-union casts NOW (deterministic,
|
|
310
|
+
// safe) so it's never told about them and never spends a turn removing them.
|
|
311
|
+
const stripped = await stripLiteralCasts(absPath).catch(() => 0);
|
|
312
|
+
|
|
313
|
+
if (stripped > 0) {
|
|
314
|
+
report({
|
|
315
|
+
kind: "tool",
|
|
316
|
+
task: taskId,
|
|
317
|
+
message: `stripped ${String(stripped)} needless literal cast(s) in ${basename(absPath)}`,
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
tsService.refresh(file);
|
|
322
|
+
|
|
323
|
+
const typeErrors = tsService
|
|
324
|
+
.diagnostics(file)
|
|
325
|
+
.filter((d) => !isTransientDiag(d));
|
|
326
|
+
const lintProblems = lintFile === undefined ? [] : await lintFile(absPath);
|
|
327
|
+
// BLAST RADIUS: files that depend on this one and now have errors. Computed even
|
|
328
|
+
// when THIS file is clean — a signature change can compile here but break callers.
|
|
329
|
+
// Drop the stub-route phantom here too (the build regenerates the tree).
|
|
330
|
+
const dependants = tsService
|
|
331
|
+
.dependantErrors(file, TRANSIENT_DIAG_CODES)
|
|
332
|
+
.map((d) => ({
|
|
333
|
+
file: d.file,
|
|
334
|
+
errors: d.errors.filter((e) => !isPhantomRouteError(e.message)),
|
|
335
|
+
}))
|
|
336
|
+
.filter((d) => d.errors.length > 0);
|
|
337
|
+
const total = typeErrors.length + lintProblems.length;
|
|
338
|
+
|
|
339
|
+
if (total === 0 && dependants.length === 0) {
|
|
340
|
+
return "";
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
const detail = writeGuardLines(absPath, typeErrors, lintProblems);
|
|
344
|
+
const blast = dependantBlastRadius(dependants);
|
|
345
|
+
const depNote =
|
|
346
|
+
dependants.length > 0
|
|
347
|
+
? `, ${String(dependants.length)} dependant file(s) broken`
|
|
348
|
+
: "";
|
|
349
|
+
|
|
350
|
+
// Surface the ACTUAL errors (codes + messages) into the event — not just a count —
|
|
351
|
+
// so the log shows WHAT failed (the corrective feedback also rides the tool result).
|
|
352
|
+
// A log without the real errors can't tell us which mistakes are systematic.
|
|
353
|
+
report({
|
|
354
|
+
kind: "tool",
|
|
355
|
+
task: taskId,
|
|
356
|
+
message: `⚠ write-check: ${String(typeErrors.length)} type + ${String(lintProblems.length)} lint issue(s)${depNote} in ${basename(absPath)}:\n${detail}${blast}`,
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
if (total === 0) {
|
|
360
|
+
return blast;
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
return (
|
|
364
|
+
`\n\n⚠ CHECK of this file found ${String(total)} issue(s) — fix them now ` +
|
|
365
|
+
"(edit this file) before writing others; ignore any 'cannot find module' for " +
|
|
366
|
+
`files you'll create next:\n${detail}${blast}`
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* Invoke the write-guard for a just-written file — best-effort: a guard failure
|
|
372
|
+
* must NEVER break the build (the gate stays the authority), so a null service or
|
|
373
|
+
* any thrown error degrades to no feedback. Extracted so `runToolCalls` stays
|
|
374
|
+
* under the cognitive-complexity bar.
|
|
375
|
+
*/
|
|
376
|
+
async function runWriteGuard(ctx: ILoopCtx, path: string): Promise<string> {
|
|
377
|
+
if (
|
|
378
|
+
ctx.tsService === null ||
|
|
379
|
+
path.length === 0 ||
|
|
380
|
+
!flags.lspWriteFeedback()
|
|
381
|
+
) {
|
|
382
|
+
return "";
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
try {
|
|
386
|
+
return await writeGuard(
|
|
387
|
+
{
|
|
388
|
+
tsService: ctx.tsService,
|
|
389
|
+
cwd: ctx.cwd,
|
|
390
|
+
...(ctx.lintFile === undefined ? {} : { lintFile: ctx.lintFile }),
|
|
391
|
+
},
|
|
392
|
+
path,
|
|
393
|
+
ctx.report,
|
|
394
|
+
ctx.task.id
|
|
395
|
+
);
|
|
396
|
+
} catch {
|
|
397
|
+
return "";
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Run the model's tool calls: execute each, feed the result back, and report
|
|
403
|
+
* whether any touched an editable file (which means we should re-gate). Mutates
|
|
404
|
+
* `state.edits`. The semantic WRITE tools (rename/organize) also touch disk.
|
|
405
|
+
*/
|
|
406
|
+
export async function runToolCalls(
|
|
407
|
+
toolCalls: readonly IToolCall[],
|
|
408
|
+
ctx: ILoopCtx,
|
|
409
|
+
state: ILoopState
|
|
410
|
+
): Promise<boolean> {
|
|
411
|
+
let touchedEditable = false;
|
|
412
|
+
|
|
413
|
+
for (let i = 0; i < toolCalls.length; i += 1) {
|
|
414
|
+
const call = toolCalls[i];
|
|
415
|
+
|
|
416
|
+
if (call === undefined) {
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
// Count an edit/create ONLY when it actually wrote an in-scope file. We read
|
|
421
|
+
// this from the handler's `edit`/`create` event — which carries the path it
|
|
422
|
+
// ACTUALLY wrote, already normalized (absolute / repeated-root / backslash
|
|
423
|
+
// paths resolved). Scope-checking the raw tool arg here instead would miss a
|
|
424
|
+
// write the handler normalized into scope, skipping the gate. The event fires
|
|
425
|
+
// only on a successful write, so failures/rejects never count. See P1/P2.
|
|
426
|
+
// (Object ref, not a captured `let`: CFA de-narrows a property after a call.)
|
|
427
|
+
const wrote = { value: false, path: "" };
|
|
428
|
+
|
|
429
|
+
const report: Reporter = (event) => {
|
|
430
|
+
if (
|
|
431
|
+
(event.kind === "edit" || event.kind === "create") &&
|
|
432
|
+
event.file !== undefined &&
|
|
433
|
+
isInScope(event.file, ctx.task.files)
|
|
434
|
+
) {
|
|
435
|
+
wrote.value = true;
|
|
436
|
+
wrote.path = event.file;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
ctx.report(event);
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
const result = await executeTool(call, {
|
|
443
|
+
cwd: ctx.cwd,
|
|
444
|
+
files: ctx.task.files,
|
|
445
|
+
report,
|
|
446
|
+
task: ctx.task.id,
|
|
447
|
+
tsService: ctx.tsService,
|
|
448
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
449
|
+
...(ctx.setupWeb === undefined ? {} : { setupWeb: ctx.setupWeb }),
|
|
450
|
+
...(ctx.readOnly === undefined ? {} : { readOnly: ctx.readOnly }),
|
|
451
|
+
});
|
|
452
|
+
|
|
453
|
+
let feedback = "";
|
|
454
|
+
|
|
455
|
+
if (wrote.value) {
|
|
456
|
+
touchedEditable = true;
|
|
457
|
+
state.edits += 1;
|
|
458
|
+
feedback = await runWriteGuard(ctx, wrote.path);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A semantic write (rename/organize_imports) is scope-enforced internally and
|
|
462
|
+
// mutates on success — re-gate to confirm. (These don't feed state.edits.)
|
|
463
|
+
if (
|
|
464
|
+
call.name === TOOL_NAME.renameSymbol ||
|
|
465
|
+
call.name === TOOL_NAME.organizeImports
|
|
466
|
+
) {
|
|
467
|
+
touchedEditable = true;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
ctx.messages.push({
|
|
471
|
+
role: "tool",
|
|
472
|
+
content: `${result}${feedback}`,
|
|
473
|
+
toolCallId: call.id ?? `call_${i}`,
|
|
474
|
+
});
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
return touchedEditable;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
/**
|
|
481
|
+
* Deterministic auto-fixes applied before the gate — mechanical fixes the model
|
|
482
|
+
* shouldn't burn turns on. TypeScript's own safe quick-fixes (missing imports,
|
|
483
|
+
* unused) + ast-grep SAFE idiom rewrites (`new Array(n).fill` → `Array.from`).
|
|
484
|
+
* The `tsc -p` gate re-validates, so a bad fix can't ship; never throws.
|
|
485
|
+
*/
|
|
486
|
+
async function applyDeterministicFixes(ctx: ILoopCtx): Promise<void> {
|
|
487
|
+
const { task, cwd, tsService, report } = ctx;
|
|
488
|
+
// Resolve globs to concrete files — iterating task.files literally would skip a
|
|
489
|
+
// glob scope like `["**/*"]` (the common interactive default), so the fixes
|
|
490
|
+
// never ran there. See P1 review.
|
|
491
|
+
const files = await resolveScopeFiles(cwd, task.files);
|
|
492
|
+
|
|
493
|
+
if (tsService !== null) {
|
|
494
|
+
let tsFixed = 0;
|
|
495
|
+
|
|
496
|
+
for (const f of files) {
|
|
497
|
+
try {
|
|
498
|
+
if (await fileExists(cwd, f)) {
|
|
499
|
+
tsService.refresh(f);
|
|
500
|
+
tsFixed += tsService.fixAll(f);
|
|
501
|
+
// Dedupe/sort imports + drop unused ones the model left behind — free
|
|
502
|
+
// mechanical cleanup so it never spends a repair turn on import hygiene.
|
|
503
|
+
tsFixed += tsService.organizeImports(f);
|
|
504
|
+
}
|
|
505
|
+
} catch {
|
|
506
|
+
// degrade silently — the gate still runs below
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
if (tsFixed > 0) {
|
|
511
|
+
report({
|
|
512
|
+
kind: "tool",
|
|
513
|
+
task: task.id,
|
|
514
|
+
message: `tsFixAll: applied ${tsFixed} TypeScript quick-fix(es)`,
|
|
515
|
+
});
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (flags.noAstgrep()) {
|
|
520
|
+
return;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
let astFixed = 0;
|
|
524
|
+
|
|
525
|
+
for (const f of files) {
|
|
526
|
+
try {
|
|
527
|
+
if (await fileExists(cwd, f)) {
|
|
528
|
+
astFixed += await astGrepFix(join(cwd, f));
|
|
529
|
+
// Backstop the write-time strip (covers files changed via rename/organize
|
|
530
|
+
// or any path that skipped the write-guard).
|
|
531
|
+
astFixed += await stripLiteralCasts(join(cwd, f));
|
|
532
|
+
}
|
|
533
|
+
} catch {
|
|
534
|
+
// degrade silently — gate is the authority
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
if (astFixed > 0) {
|
|
539
|
+
report({
|
|
540
|
+
kind: "tool",
|
|
541
|
+
task: task.id,
|
|
542
|
+
message: `astGrepFix: applied ${astFixed} idiom rewrite(s)`,
|
|
543
|
+
});
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* On a GREEN task, strip the redundant `const` annotations no stock lint rule
|
|
549
|
+
* catches (over-annotation of call/expression-initialized locals) — then re-gate
|
|
550
|
+
* and REVERT the whole file if anything regressed. Verified-safe: the structural
|
|
551
|
+
* rewrite only sticks when the full gate (incl. prettier --check) stays green,
|
|
552
|
+
* so a drop that changed an inferred type can never ship. Runs once, on the turn
|
|
553
|
+
* the task goes green; a no-op when ast-grep is off or nothing is redundant.
|
|
554
|
+
*/
|
|
555
|
+
async function polishOnGreen(ctx: ILoopCtx): Promise<void> {
|
|
556
|
+
const { task, cwd, parse, report } = ctx;
|
|
557
|
+
|
|
558
|
+
if (flags.noAstgrep()) {
|
|
559
|
+
return;
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
// Resolve globs so a glob scope is polished too (not silently skipped).
|
|
563
|
+
const files = await resolveScopeFiles(cwd, task.files);
|
|
564
|
+
const snapshot = new Map<string, string>();
|
|
565
|
+
|
|
566
|
+
for (const f of files) {
|
|
567
|
+
if (await fileExists(cwd, f)) {
|
|
568
|
+
snapshot.set(f, await Bun.file(join(cwd, f)).text());
|
|
569
|
+
}
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
let dropped = 0;
|
|
573
|
+
|
|
574
|
+
for (const f of files) {
|
|
575
|
+
if (await fileExists(cwd, f)) {
|
|
576
|
+
try {
|
|
577
|
+
dropped += await dropRedundantAnnotations(join(cwd, f));
|
|
578
|
+
} catch {
|
|
579
|
+
// degrade silently — we revalidate and revert below
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
if (dropped === 0) {
|
|
585
|
+
return;
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// Re-format (the drop strips trailing semicolons) before re-gating.
|
|
589
|
+
if (task.fix !== undefined && task.fix.length > 0) {
|
|
590
|
+
await runAccept(
|
|
591
|
+
{ ...task, accept: task.fix },
|
|
592
|
+
cwd,
|
|
593
|
+
ctx.signal === undefined ? {} : { signal: ctx.signal }
|
|
594
|
+
);
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const recheck = await validate(
|
|
598
|
+
task,
|
|
599
|
+
cwd,
|
|
600
|
+
parse,
|
|
601
|
+
ctx.signal === undefined ? {} : { signal: ctx.signal }
|
|
602
|
+
);
|
|
603
|
+
|
|
604
|
+
if (recheck.passed) {
|
|
605
|
+
report({
|
|
606
|
+
kind: "tool",
|
|
607
|
+
task: task.id,
|
|
608
|
+
message: `polish: dropped ${dropped} redundant annotation(s)`,
|
|
609
|
+
});
|
|
610
|
+
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// A drop changed an inferred type — roll the whole file set back to green.
|
|
615
|
+
for (const [f, content] of snapshot) {
|
|
616
|
+
await Bun.write(join(cwd, f), content);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
/** Snapshot the editable files' mtimes (ms) — cheap stat, used to detect which
|
|
621
|
+
* files the deterministic fixers + fix command rewrote. */
|
|
622
|
+
async function snapshotMtimes(
|
|
623
|
+
cwd: string,
|
|
624
|
+
files: string[]
|
|
625
|
+
): Promise<Map<string, number>> {
|
|
626
|
+
const out = new Map<string, number>();
|
|
627
|
+
|
|
628
|
+
for (const f of await resolveScopeFiles(cwd, files)) {
|
|
629
|
+
try {
|
|
630
|
+
out.set(f, Bun.file(join(cwd, f)).lastModified);
|
|
631
|
+
} catch {
|
|
632
|
+
// ignore — a file that can't be stat'd just isn't tracked
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
return out;
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/** Files whose mtime advanced between two snapshots — i.e. a fixer rewrote them. */
|
|
640
|
+
function changedSince(
|
|
641
|
+
before: Map<string, number>,
|
|
642
|
+
after: Map<string, number>
|
|
643
|
+
): string[] {
|
|
644
|
+
const changed: string[] = [];
|
|
645
|
+
|
|
646
|
+
for (const [f, mtime] of after) {
|
|
647
|
+
const prev = before.get(f);
|
|
648
|
+
|
|
649
|
+
if (prev === undefined || mtime > prev) {
|
|
650
|
+
changed.push(f);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
return changed;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/** Max auto-fixed files to name in the notice before eliding. */
|
|
658
|
+
const MAX_AUTOFIX_NAMED = 20;
|
|
659
|
+
|
|
660
|
+
/** Tell the model what the janitor just changed, so it re-reads before editing and
|
|
661
|
+
* doesn't waste turns re-fixing formatting/imports (or edit stale text → reject). */
|
|
662
|
+
function autoFixNotice(files: string[]): string {
|
|
663
|
+
const shown = files.slice(0, MAX_AUTOFIX_NAMED).join(", ");
|
|
664
|
+
const more =
|
|
665
|
+
files.length > MAX_AUTOFIX_NAMED
|
|
666
|
+
? ` (+${String(files.length - MAX_AUTOFIX_NAMED)} more)`
|
|
667
|
+
: "";
|
|
668
|
+
|
|
669
|
+
return (
|
|
670
|
+
`NOTE: automatic fixers (prettier, eslint --fix, organize-imports, TS quick-fixes) ` +
|
|
671
|
+
`just reformatted/fixed and SAVED these files: ${shown}${more}. Those style/import/` +
|
|
672
|
+
`formatting fixes are DONE — do not redo them. Their on-disk text now DIFFERS from ` +
|
|
673
|
+
`what you wrote, so \`read\` a file before editing it. Fix ONLY the errors below.`
|
|
674
|
+
);
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* The deterministic gate — the only authority on "done". Auto-fix, run the
|
|
679
|
+
* optional fix command, validate, and return a terminal result (done/stuck) or
|
|
680
|
+
* null to keep going (having fed the failures back into the conversation).
|
|
681
|
+
*/
|
|
682
|
+
export async function settleGate(
|
|
683
|
+
ctx: ILoopCtx,
|
|
684
|
+
state: ILoopState,
|
|
685
|
+
turn: number
|
|
686
|
+
): Promise<IRunResult | null> {
|
|
687
|
+
const { task, cwd, parse, report, messages } = ctx;
|
|
688
|
+
// Snapshot before the fixers so we can tell the model exactly what they changed
|
|
689
|
+
// (else it re-fixes already-fixed style and edits now-stale text → rejects).
|
|
690
|
+
const beforeFix = await snapshotMtimes(cwd, task.files);
|
|
691
|
+
|
|
692
|
+
await applyDeterministicFixes(ctx);
|
|
693
|
+
|
|
694
|
+
if (task.fix !== undefined && task.fix.length > 0) {
|
|
695
|
+
await runAccept(
|
|
696
|
+
{ ...task, accept: task.fix },
|
|
697
|
+
cwd,
|
|
698
|
+
ctx.signal === undefined ? {} : { signal: ctx.signal }
|
|
699
|
+
);
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
const autoFixed = changedSince(
|
|
703
|
+
beforeFix,
|
|
704
|
+
await snapshotMtimes(cwd, task.files)
|
|
705
|
+
);
|
|
706
|
+
|
|
707
|
+
if (autoFixed.length > 0) {
|
|
708
|
+
report({
|
|
709
|
+
kind: "tool",
|
|
710
|
+
task: task.id,
|
|
711
|
+
message: `auto-fixed ${String(autoFixed.length)} file(s) (prettier/eslint/imports) — noted to the model`,
|
|
712
|
+
});
|
|
713
|
+
}
|
|
714
|
+
|
|
715
|
+
if (ctx.onGateChunk !== undefined) {
|
|
716
|
+
report({
|
|
717
|
+
kind: "tool",
|
|
718
|
+
task: task.id,
|
|
719
|
+
message: `⚙ running gate · turn ${turn}…`,
|
|
720
|
+
});
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
const gate = await validate(task, cwd, parse, {
|
|
724
|
+
...(ctx.onGateChunk === undefined ? {} : { onChunk: ctx.onGateChunk }),
|
|
725
|
+
...(ctx.signal === undefined ? {} : { signal: ctx.signal }),
|
|
726
|
+
});
|
|
727
|
+
|
|
728
|
+
// Run meta-rules against the project — project structure invariants the gate
|
|
729
|
+
// can't express. Convert error-severity violations to gate failures; warn
|
|
730
|
+
// violations are surfaced in feedback but don't block. Apply config overrides
|
|
731
|
+
// from ctx.ruleOverrides (already loaded and normalized in run.ts).
|
|
732
|
+
let metaViolations: IMetaRuleViolation[] = [];
|
|
733
|
+
|
|
734
|
+
try {
|
|
735
|
+
const metaContext = buildMetaRuleContext(
|
|
736
|
+
cwd,
|
|
737
|
+
ctx.stackProfile?.packs ?? []
|
|
738
|
+
);
|
|
739
|
+
|
|
740
|
+
metaViolations = runMetaRules(META_RULES, metaContext, ctx.ruleOverrides);
|
|
741
|
+
} catch {
|
|
742
|
+
// Degrade silently — meta-rules are supplementary to the gate
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
const metaErrors = metaViolations.filter((v) => v.severity === "error");
|
|
746
|
+
const gateErrors = gate.errors.concat(
|
|
747
|
+
metaErrors.map((v) => ({
|
|
748
|
+
key: `${v.file}:${v.ruleId}`,
|
|
749
|
+
file: v.file,
|
|
750
|
+
rule: v.ruleId,
|
|
751
|
+
message: v.message,
|
|
752
|
+
}))
|
|
753
|
+
);
|
|
754
|
+
|
|
755
|
+
if (state.lastGateCount >= 0 && gateErrors.length > state.lastGateCount) {
|
|
756
|
+
state.regressions += 1;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
state.lastGateCount = gateErrors.length;
|
|
760
|
+
|
|
761
|
+
// Determine pass/fail: the gate passes only if BOTH gate command AND meta-rules are clean
|
|
762
|
+
const gatePassed = gate.passed && metaErrors.length === 0;
|
|
763
|
+
|
|
764
|
+
// On red, surface the ACTUAL errors (codes + messages) into the event — so the
|
|
765
|
+
// log records WHAT failed at the gate, not just a count (the analysis substrate
|
|
766
|
+
// for finding systematic mistakes to fix in the harness).
|
|
767
|
+
const gateDetail = gatePassed
|
|
768
|
+
? ""
|
|
769
|
+
: `:\n${gateErrors
|
|
770
|
+
.slice(0, 20)
|
|
771
|
+
.map((e) => ` ${e.message}`)
|
|
772
|
+
.join("\n")}`;
|
|
773
|
+
|
|
774
|
+
report({
|
|
775
|
+
kind: "validated",
|
|
776
|
+
task: task.id,
|
|
777
|
+
cycle: turn,
|
|
778
|
+
passed: gatePassed,
|
|
779
|
+
errors: gateErrors.length,
|
|
780
|
+
message: gatePassed
|
|
781
|
+
? `task ${task.id} · turn ${turn}: GREEN`
|
|
782
|
+
: `task ${task.id} · turn ${turn}: red (${String(gateErrors.length)} error(s))${gateDetail}`,
|
|
783
|
+
});
|
|
784
|
+
|
|
785
|
+
if (gatePassed) {
|
|
786
|
+
await polishOnGreen(ctx);
|
|
787
|
+
|
|
788
|
+
report({
|
|
789
|
+
kind: "done",
|
|
790
|
+
task: task.id,
|
|
791
|
+
cycles: turn,
|
|
792
|
+
message: `task ${task.id}: done in ${turn} turn(s)`,
|
|
793
|
+
});
|
|
794
|
+
|
|
795
|
+
return {
|
|
796
|
+
task: task.id,
|
|
797
|
+
redConfirmed: true,
|
|
798
|
+
status: RUN_STATUS.done,
|
|
799
|
+
cycles: turn,
|
|
800
|
+
};
|
|
801
|
+
}
|
|
802
|
+
|
|
803
|
+
state.gateNoProgress = sameErrorSet(state.prevGateErrors, gateErrors)
|
|
804
|
+
? state.gateNoProgress + 1
|
|
805
|
+
: 0;
|
|
806
|
+
state.prevGateErrors = gateErrors;
|
|
807
|
+
|
|
808
|
+
if (state.gateNoProgress >= LOOP_LIMITS.gateStuckRepeats) {
|
|
809
|
+
report({
|
|
810
|
+
kind: "stuck",
|
|
811
|
+
task: task.id,
|
|
812
|
+
cycles: turn,
|
|
813
|
+
message: `task ${task.id}: stuck (gate unchanged ${LOOP_LIMITS.gateStuckRepeats}x)`,
|
|
814
|
+
});
|
|
815
|
+
|
|
816
|
+
return {
|
|
817
|
+
task: task.id,
|
|
818
|
+
redConfirmed: true,
|
|
819
|
+
status: RUN_STATUS.stuck,
|
|
820
|
+
cycles: turn,
|
|
821
|
+
reason: STUCK_REASON.stalled,
|
|
822
|
+
};
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
const feedback = await gateFeedback(gateErrors, task, cwd, metaViolations);
|
|
826
|
+
const notice = autoFixed.length > 0 ? `${autoFixNotice(autoFixed)}\n\n` : "";
|
|
827
|
+
|
|
828
|
+
messages.push({ role: "user", content: `${notice}${feedback}` });
|
|
829
|
+
|
|
830
|
+
return null;
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/** Report how long a turn took (and cumulative). */
|
|
834
|
+
export function emitTiming(
|
|
835
|
+
report: Reporter,
|
|
836
|
+
task: string,
|
|
837
|
+
turn: number,
|
|
838
|
+
turnStart: number,
|
|
839
|
+
taskStart: number
|
|
840
|
+
): void {
|
|
841
|
+
const turnMs = Math.round(performance.now() - turnStart);
|
|
842
|
+
const totalMs = Math.round(performance.now() - taskStart);
|
|
843
|
+
|
|
844
|
+
report({
|
|
845
|
+
kind: "timing",
|
|
846
|
+
task,
|
|
847
|
+
cycle: turn,
|
|
848
|
+
ms: turnMs,
|
|
849
|
+
message: `turn ${turn} took ${secs(turnMs)} (total ${secs(totalMs)})`,
|
|
850
|
+
});
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
/** Human-readable duration: ms under a second, else seconds with one decimal. */
|
|
854
|
+
function secs(ms: number): string {
|
|
855
|
+
return ms < 1000 ? `${ms}ms` : `${(ms / 1000).toFixed(1)}s`;
|
|
856
|
+
}
|