@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,80 @@
|
|
|
1
|
+
import type { IToolCall } from "../../inference";
|
|
2
|
+
import { TOOL_NAME, READ_ONLY_TOOL_NAMES, type ToolName } from "../../agent";
|
|
3
|
+
import { readFile, runShell, doEdit, doCreate } from "./file-ops";
|
|
4
|
+
import { doHashlineEdit } from "./edit-hashline";
|
|
5
|
+
import { doSearch, doLsp } from "./lsp-ops";
|
|
6
|
+
import { doScaffoldUi } from "./scaffold-ui";
|
|
7
|
+
import { doScaffoldRoutes } from "./scaffold-routes";
|
|
8
|
+
import { doScaffoldWeb } from "./scaffold-web";
|
|
9
|
+
import { doAddDependency } from "./add-dependency";
|
|
10
|
+
import { reject, type IToolContext } from "./tool-context";
|
|
11
|
+
|
|
12
|
+
export type { IToolContext } from "./tool-context";
|
|
13
|
+
|
|
14
|
+
type ToolHandler = (
|
|
15
|
+
args: Record<string, unknown>,
|
|
16
|
+
ctx: IToolContext
|
|
17
|
+
) => Promise<string> | string;
|
|
18
|
+
|
|
19
|
+
/** Name → handler. The LSP entries close over their tool name so `doLsp` keeps
|
|
20
|
+
* one body. Keyed by ToolName, so a new tool must register here (exhaustive). */
|
|
21
|
+
const HANDLERS: Record<ToolName, ToolHandler> = {
|
|
22
|
+
[TOOL_NAME.read]: readFile,
|
|
23
|
+
[TOOL_NAME.run]: runShell,
|
|
24
|
+
[TOOL_NAME.edit]: doEdit,
|
|
25
|
+
[TOOL_NAME.editLines]: doHashlineEdit,
|
|
26
|
+
[TOOL_NAME.create]: doCreate,
|
|
27
|
+
[TOOL_NAME.search]: doSearch,
|
|
28
|
+
[TOOL_NAME.symbolSearch]: (a, c) => doLsp(TOOL_NAME.symbolSearch, a, c),
|
|
29
|
+
[TOOL_NAME.findReferences]: (a, c) => doLsp(TOOL_NAME.findReferences, a, c),
|
|
30
|
+
[TOOL_NAME.typeAt]: (a, c) => doLsp(TOOL_NAME.typeAt, a, c),
|
|
31
|
+
[TOOL_NAME.diagnostics]: (a, c) => doLsp(TOOL_NAME.diagnostics, a, c),
|
|
32
|
+
[TOOL_NAME.renameSymbol]: (a, c) => doLsp(TOOL_NAME.renameSymbol, a, c),
|
|
33
|
+
[TOOL_NAME.organizeImports]: (a, c) => doLsp(TOOL_NAME.organizeImports, a, c),
|
|
34
|
+
[TOOL_NAME.scaffoldUi]: doScaffoldUi,
|
|
35
|
+
[TOOL_NAME.scaffoldRoutes]: doScaffoldRoutes,
|
|
36
|
+
[TOOL_NAME.scaffoldWeb]: doScaffoldWeb,
|
|
37
|
+
[TOOL_NAME.addDependency]: doAddDependency,
|
|
38
|
+
// yield_status is intercepted by the Session BEFORE tool dispatch (it ends the
|
|
39
|
+
// turn); this handler only fires if one slips through with other calls.
|
|
40
|
+
[TOOL_NAME.yieldStatus]: () =>
|
|
41
|
+
"(turn continues — finish the work, then yield alone)",
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function isToolName(name: string): name is ToolName {
|
|
45
|
+
return Object.hasOwn(HANDLERS, name);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Perform one tool call and return the text result fed back to the model as a
|
|
50
|
+
* tool message. Dispatch only — the handlers live in file-ops (read/run/edit/
|
|
51
|
+
* create) and lsp-ops (search + the semantic tools); scope enforcement and arg
|
|
52
|
+
* parsing live with each handler (tool-context holds the shared helpers).
|
|
53
|
+
*/
|
|
54
|
+
export async function executeTool(
|
|
55
|
+
call: IToolCall,
|
|
56
|
+
ctx: IToolContext
|
|
57
|
+
): Promise<string> {
|
|
58
|
+
if (!isToolName(call.name)) {
|
|
59
|
+
return `unknown tool: ${call.name}`;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// PLAN MODE hard guard: the advertised tool list already omits mutating tools,
|
|
63
|
+
// but a salvaged/forced call can name anything — reject it here so plan mode
|
|
64
|
+
// is a guarantee, not a convention. (`run` passes; its handler enforces a
|
|
65
|
+
// read-only command allowlist.)
|
|
66
|
+
if (
|
|
67
|
+
ctx.readOnly === true &&
|
|
68
|
+
!READ_ONLY_TOOL_NAMES.has(call.name) &&
|
|
69
|
+
call.name !== TOOL_NAME.run
|
|
70
|
+
) {
|
|
71
|
+
return reject(
|
|
72
|
+
ctx,
|
|
73
|
+
call.name,
|
|
74
|
+
`plan mode: \`${call.name}\` is disabled — explore with read-only tools and ` +
|
|
75
|
+
"present your plan as text; the user must approve it before files can change."
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return HANDLERS[call.name](call.arguments, ctx);
|
|
80
|
+
}
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { applyEdits } from "../../files/edit";
|
|
3
|
+
import { applyCreate } from "../../files/create";
|
|
4
|
+
import { EDIT_FAIL_REASON } from "../../files";
|
|
5
|
+
import { writable, normalizeWorkspacePath } from "../../lib/scope";
|
|
6
|
+
import { LOOP_LIMITS } from "../loop.constants";
|
|
7
|
+
import { toEdits, toCreate, toRun, toRead, runCommand } from "../../agent";
|
|
8
|
+
import { ruleHelpFromOutput } from "../feedback/rule-docs";
|
|
9
|
+
import { condenseToolOutput } from "./condense";
|
|
10
|
+
import { parseOrRepair, reject, type IToolContext } from "./tool-context";
|
|
11
|
+
import { formatHashHeader, HL_LINE_SEP } from "../../files/hashline-format";
|
|
12
|
+
import { SessionSnapshotStore } from "../../files/hashline";
|
|
13
|
+
import { flags } from "../../config";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Read a file for the model. TRUSTED-MODE (by design): `read` and `run` are NOT
|
|
17
|
+
* sandboxed to the workspace — a `../config` read or any shell command the
|
|
18
|
+
* process can run is permitted, like a local human-run coding agent (Claude Code,
|
|
19
|
+
* etc.). Only WRITES (`edit`/`create`) are scope-enforced, since those are what
|
|
20
|
+
* mutate the user's project. tsforge runs locally on the user's own machine
|
|
21
|
+
* against their own code; the threat model is mistakes, not a hostile operator.
|
|
22
|
+
* (Sandboxing reads would be a separate, explicit execution profile.)
|
|
23
|
+
*/
|
|
24
|
+
export async function readFile(
|
|
25
|
+
args: Record<string, unknown>,
|
|
26
|
+
ctx: IToolContext & { snapshotStore?: SessionSnapshotStore }
|
|
27
|
+
): Promise<string> {
|
|
28
|
+
const { value: r, feedback } = parseOrRepair(args, toRead, ctx, "read");
|
|
29
|
+
|
|
30
|
+
if (r === null) {
|
|
31
|
+
if (feedback !== undefined && feedback.length > 0) {
|
|
32
|
+
return feedback;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return "read: malformed args (need `file`)";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
r.file = normalizeWorkspacePath(ctx.cwd, r.file);
|
|
39
|
+
|
|
40
|
+
ctx.report({ kind: "tool", task: ctx.task, message: `read ${r.file}` });
|
|
41
|
+
|
|
42
|
+
const handle = Bun.file(join(ctx.cwd, r.file));
|
|
43
|
+
|
|
44
|
+
if (!(await handle.exists())) {
|
|
45
|
+
return `read: ${r.file} does not exist`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const content = await handle.text();
|
|
49
|
+
|
|
50
|
+
// Annotate with hashline header if enabled
|
|
51
|
+
if (flags.hashlineEditTool()) {
|
|
52
|
+
ctx.snapshotStore ??= new SessionSnapshotStore();
|
|
53
|
+
|
|
54
|
+
const hash = ctx.snapshotStore.record(r.file, content);
|
|
55
|
+
const header = formatHashHeader(r.file, hash);
|
|
56
|
+
const lines = content.split("\n");
|
|
57
|
+
const annotated = lines
|
|
58
|
+
.map((line, i) => `${i + 1}${HL_LINE_SEP}${line}`)
|
|
59
|
+
.join("\n");
|
|
60
|
+
|
|
61
|
+
return `${header}\n${annotated}`;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return content;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Commands a plan-mode `run` may execute — pure inspection, never mutation. */
|
|
68
|
+
const READ_ONLY_COMMANDS = new Set([
|
|
69
|
+
"ls",
|
|
70
|
+
"cat",
|
|
71
|
+
"head",
|
|
72
|
+
"tail",
|
|
73
|
+
"wc",
|
|
74
|
+
"rg",
|
|
75
|
+
"grep",
|
|
76
|
+
"find",
|
|
77
|
+
"tree",
|
|
78
|
+
"stat",
|
|
79
|
+
"file",
|
|
80
|
+
"which",
|
|
81
|
+
"du",
|
|
82
|
+
"tsc",
|
|
83
|
+
"git",
|
|
84
|
+
]);
|
|
85
|
+
|
|
86
|
+
/** Git subcommands that only inspect the repo. */
|
|
87
|
+
const READ_ONLY_GIT = new Set(["status", "log", "diff", "show", "branch"]);
|
|
88
|
+
|
|
89
|
+
/** Shell metacharacters that could turn a read into a write (`> out`, `&& rm`,
|
|
90
|
+
* `| tee`, command substitution). Their PRESENCE disqualifies — conservative. */
|
|
91
|
+
const SHELL_WRITE_RE = /[>;&|`]|\$\(/;
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Deterministically read-only: exactly one allowlisted command, no redirects/
|
|
95
|
+
* pipes/chaining. Used by plan mode so the model can explore (`ls`, `rg`,
|
|
96
|
+
* `git log`, `tsc --noEmit`) without any path to mutating the workspace.
|
|
97
|
+
*/
|
|
98
|
+
export function isReadOnlyCommand(command: string): boolean {
|
|
99
|
+
if (SHELL_WRITE_RE.test(command)) {
|
|
100
|
+
return false;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const [head, sub] = command.trim().split(/\s+/);
|
|
104
|
+
|
|
105
|
+
if (head === undefined || !READ_ONLY_COMMANDS.has(head)) {
|
|
106
|
+
return false;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
if (head === "git") {
|
|
110
|
+
return sub !== undefined && READ_ONLY_GIT.has(sub);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
return true;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export async function runShell(
|
|
117
|
+
args: Record<string, unknown>,
|
|
118
|
+
ctx: IToolContext
|
|
119
|
+
): Promise<string> {
|
|
120
|
+
const { value: r, feedback } = parseOrRepair(args, toRun, ctx, "run");
|
|
121
|
+
|
|
122
|
+
if (r === null) {
|
|
123
|
+
if (feedback !== undefined && feedback.length > 0) {
|
|
124
|
+
return feedback;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return "run: malformed args (need `command`)";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (ctx.readOnly === true && !isReadOnlyCommand(r.command)) {
|
|
131
|
+
return reject(
|
|
132
|
+
ctx,
|
|
133
|
+
"run",
|
|
134
|
+
"plan mode: only read-only commands are allowed (ls, cat, rg, grep, find, " +
|
|
135
|
+
`git status/log/diff/show/branch, tsc — no pipes/redirects). Blocked: ${r.command}`
|
|
136
|
+
);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const res = await runCommand(
|
|
140
|
+
ctx.cwd,
|
|
141
|
+
r.command,
|
|
142
|
+
ctx.signal === undefined ? {} : { signal: ctx.signal }
|
|
143
|
+
);
|
|
144
|
+
const raw = `${res.stdout}${res.stderr}`;
|
|
145
|
+
// Condition the output for the model through the single condensing pipeline
|
|
146
|
+
// (progress-strip → shape/per-tool condensers → signal-preserving truncation).
|
|
147
|
+
// Anything unrecognized — and any real FAILURE's errors — passes through.
|
|
148
|
+
const { text: output, via } = condenseToolOutput(
|
|
149
|
+
{ command: r.command, output: raw, exitCode: res.exitCode },
|
|
150
|
+
LOOP_LIMITS.maxToolOutputChars
|
|
151
|
+
);
|
|
152
|
+
|
|
153
|
+
// Make the saving observable (and let us SPOT over-condensing in the log).
|
|
154
|
+
if (via !== null && output.length < raw.length) {
|
|
155
|
+
ctx.report({
|
|
156
|
+
kind: "tool",
|
|
157
|
+
task: ctx.task,
|
|
158
|
+
message: `condensed ${String(raw.length)}→${String(output.length)} chars via ${via}`,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// If the command surfaced lint/type errors, attach the failing rules' own
|
|
163
|
+
// bad→good docs to what the model reads — so it fixes from examples, not blind.
|
|
164
|
+
const help = res.exitCode === 0 ? "" : ruleHelpFromOutput(output);
|
|
165
|
+
const guidance =
|
|
166
|
+
help.length > 0 ? `\n\nFix guidance for the failing rules:\n${help}` : "";
|
|
167
|
+
|
|
168
|
+
// Log the guidance too (in the event output) so we can SEE the injection fire,
|
|
169
|
+
// not just feed it silently to the model.
|
|
170
|
+
ctx.report({
|
|
171
|
+
kind: "run",
|
|
172
|
+
task: ctx.task,
|
|
173
|
+
message: `$ ${r.command}`,
|
|
174
|
+
command: r.command,
|
|
175
|
+
exitCode: res.exitCode,
|
|
176
|
+
output: `${output}${guidance}`,
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
return `exit ${res.exitCode}\n${output}${guidance}`;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export async function doEdit(
|
|
183
|
+
args: Record<string, unknown>,
|
|
184
|
+
ctx: IToolContext
|
|
185
|
+
): Promise<string> {
|
|
186
|
+
const { value: edit, feedback } = parseOrRepair(args, toEdits, ctx, "edit");
|
|
187
|
+
|
|
188
|
+
if (edit === null) {
|
|
189
|
+
if (feedback !== undefined && feedback.length > 0) {
|
|
190
|
+
return feedback;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return "edit: malformed args (need `file` plus either `oldString`/`newString` or an `edits` array of {oldString,newString})";
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
edit.file = normalizeWorkspacePath(ctx.cwd, edit.file);
|
|
197
|
+
|
|
198
|
+
if (!writable(edit.file, ctx.files)) {
|
|
199
|
+
return reject(
|
|
200
|
+
ctx,
|
|
201
|
+
"edit",
|
|
202
|
+
`edit ${edit.file} REJECTED: out of scope. You may only edit/create: ${ctx.files.join(", ")} (or throwaway files under scratch/).`
|
|
203
|
+
);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// The size cap is PER replacement — each piece must be surgical (no lazy
|
|
207
|
+
// whole-function rewrite) — but a batch may carry many pieces, so the model
|
|
208
|
+
// can fix the same issue at several spread-out sites in ONE turn.
|
|
209
|
+
for (let i = 0; i < edit.edits.length; i += 1) {
|
|
210
|
+
const span = (edit.edits[i]?.oldString ?? "").split("\n").length;
|
|
211
|
+
|
|
212
|
+
if (span > LOOP_LIMITS.maxEditLines) {
|
|
213
|
+
return reject(
|
|
214
|
+
ctx,
|
|
215
|
+
"edit",
|
|
216
|
+
`edit ${edit.file} REJECTED: replacement #${i + 1} is too large (${span} lines). Change ONLY the broken lines — make small, targeted replacements (the gate names the exact lines). To fix several spots, pass each as its own entry in \`edits\`; don't rewrite a whole function.`
|
|
217
|
+
);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
const result = await applyEdits(ctx.cwd, edit.file, edit.edits);
|
|
222
|
+
|
|
223
|
+
if (result.ok) {
|
|
224
|
+
for (const r of edit.edits) {
|
|
225
|
+
ctx.report({
|
|
226
|
+
kind: "edit",
|
|
227
|
+
task: ctx.task,
|
|
228
|
+
file: edit.file,
|
|
229
|
+
message: `edit ${edit.file}`,
|
|
230
|
+
oldString: r.oldString,
|
|
231
|
+
newString: r.newString,
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
return `edited ${edit.file} (${result.count} change${result.count === 1 ? "" : "s"})`;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
const where =
|
|
239
|
+
edit.edits.length > 1 ? ` (replacement #${result.index + 1})` : "";
|
|
240
|
+
|
|
241
|
+
return reject(
|
|
242
|
+
ctx,
|
|
243
|
+
`edit:${result.reason}`,
|
|
244
|
+
`edit ${edit.file} REJECTED${where}: ${editFailHelp(edit.file, result)}`
|
|
245
|
+
);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
/**
|
|
249
|
+
* Turn an edit-failure reason into ACTIONABLE feedback. The bare reason strings
|
|
250
|
+
* ("not-found", "missing-file") were fatally ambiguous: a slow local model read
|
|
251
|
+
* an edit's "not-found" (= the oldString wasn't in the file) as "the FILE wasn't
|
|
252
|
+
* found", switched to `create`, hit "already exists", and thrashed edit↔create to
|
|
253
|
+
* the turn cap. Each message now says exactly what failed AND what to do next —
|
|
254
|
+
* crucially, whether the file exists (don't `create`) or not (do `create`).
|
|
255
|
+
*/
|
|
256
|
+
function editFailHelp(
|
|
257
|
+
file: string,
|
|
258
|
+
result: { reason: string; matches?: number }
|
|
259
|
+
): string {
|
|
260
|
+
if (result.reason === EDIT_FAIL_REASON.ambiguous) {
|
|
261
|
+
return `oldString matched ${result.matches ?? 0} places — include more surrounding lines to make it unique`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (result.reason === EDIT_FAIL_REASON.missingFile) {
|
|
265
|
+
return `the file ${file} does not exist yet — use \`create\` to make it (NOT edit)`;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (result.reason === EDIT_FAIL_REASON.notFound) {
|
|
269
|
+
return `the file ${file} EXISTS, but your oldString text was not found in it. Do NOT use \`create\` (it already exists). \`read\` the file to see its exact current contents, then edit with text copied verbatim from it.`;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
return result.reason;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export async function doCreate(
|
|
276
|
+
args: Record<string, unknown>,
|
|
277
|
+
ctx: IToolContext
|
|
278
|
+
): Promise<string> {
|
|
279
|
+
const { value: create, feedback } = parseOrRepair(
|
|
280
|
+
args,
|
|
281
|
+
toCreate,
|
|
282
|
+
ctx,
|
|
283
|
+
"create"
|
|
284
|
+
);
|
|
285
|
+
|
|
286
|
+
if (create === null) {
|
|
287
|
+
if (feedback !== undefined && feedback.length > 0) {
|
|
288
|
+
return feedback;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
return "create: malformed args (need file, content)";
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
create.file = normalizeWorkspacePath(ctx.cwd, create.file);
|
|
295
|
+
|
|
296
|
+
if (!writable(create.file, ctx.files)) {
|
|
297
|
+
return reject(
|
|
298
|
+
ctx,
|
|
299
|
+
"create",
|
|
300
|
+
`create ${create.file} REJECTED: out of scope. You may only edit/create: ${ctx.files.join(", ")} (or throwaway files under scratch/).`
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const result = await applyCreate(ctx.cwd, create);
|
|
305
|
+
|
|
306
|
+
if (result.ok) {
|
|
307
|
+
ctx.report({
|
|
308
|
+
kind: "create",
|
|
309
|
+
task: ctx.task,
|
|
310
|
+
file: create.file,
|
|
311
|
+
message: `create ${create.file}`,
|
|
312
|
+
content: create.content,
|
|
313
|
+
});
|
|
314
|
+
|
|
315
|
+
return `created ${create.file}`;
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
return reject(
|
|
319
|
+
ctx,
|
|
320
|
+
"create:exists",
|
|
321
|
+
`create ${create.file} REJECTED: already exists — use \`edit\``
|
|
322
|
+
);
|
|
323
|
+
}
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { fileArg, TOOL_NAME, type ToolName } from "../../agent";
|
|
3
|
+
import { runArgvCommand } from "../../lib/fs";
|
|
4
|
+
import { writable } from "../../lib/scope";
|
|
5
|
+
import { LOOP_LIMITS } from "../loop.constants";
|
|
6
|
+
import { str, reject, type IToolContext } from "./tool-context";
|
|
7
|
+
|
|
8
|
+
/** ripgrep search over the working dir — the model's primary navigation at
|
|
9
|
+
* scale (structural/text, fast). Falls back gracefully if `rg` is absent. */
|
|
10
|
+
export async function doSearch(
|
|
11
|
+
args: Record<string, unknown>,
|
|
12
|
+
ctx: IToolContext
|
|
13
|
+
): Promise<string> {
|
|
14
|
+
const fromPattern = str(args, "pattern");
|
|
15
|
+
const pattern = fromPattern.length > 0 ? fromPattern : str(args, "query");
|
|
16
|
+
|
|
17
|
+
if (pattern.length === 0) {
|
|
18
|
+
return "search: malformed args (need `pattern`)";
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const glob = str(args, "glob");
|
|
22
|
+
// Spawn rg with an explicit argv (NO shell) — the pattern/glob come from the
|
|
23
|
+
// model, so a `sh -c` string would let `$()`/backticks inside them execute.
|
|
24
|
+
const argv = [
|
|
25
|
+
"rg",
|
|
26
|
+
"--line-number",
|
|
27
|
+
"--no-heading",
|
|
28
|
+
"--color",
|
|
29
|
+
"never",
|
|
30
|
+
"-e",
|
|
31
|
+
pattern,
|
|
32
|
+
...(glob.length > 0 ? ["-g", glob] : []),
|
|
33
|
+
];
|
|
34
|
+
const res = await runArgvCommand(
|
|
35
|
+
ctx.cwd,
|
|
36
|
+
argv,
|
|
37
|
+
ctx.signal === undefined ? {} : { signal: ctx.signal }
|
|
38
|
+
);
|
|
39
|
+
// rg exits 1 when there are simply no matches (not an error); 2 = real error,
|
|
40
|
+
// 127 = rg absent. Surface stderr only on a real failure, not on "no matches".
|
|
41
|
+
const body = res.exitCode > 1 ? `${res.stdout}${res.stderr}` : res.stdout;
|
|
42
|
+
const out = body.slice(0, LOOP_LIMITS.maxToolOutputChars);
|
|
43
|
+
|
|
44
|
+
ctx.report({
|
|
45
|
+
kind: "tool",
|
|
46
|
+
task: ctx.task,
|
|
47
|
+
message: `search ${pattern}${glob.length > 0 ? ` (${glob})` : ""}`,
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
return out.trim().length > 0 ? out : `no matches for ${pattern}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Dispatch the semantic (LanguageService-backed) tools. The model addresses
|
|
54
|
+
* symbols by NAME + file; read-only tools are unrestricted, the two that WRITE
|
|
55
|
+
* (rename_symbol, organize_imports) are scope-enforced. */
|
|
56
|
+
export function doLsp(
|
|
57
|
+
name: ToolName,
|
|
58
|
+
args: Record<string, unknown>,
|
|
59
|
+
ctx: IToolContext
|
|
60
|
+
): string {
|
|
61
|
+
const svc = ctx.tsService;
|
|
62
|
+
|
|
63
|
+
if (svc === undefined || svc === null) {
|
|
64
|
+
return `${name}: unavailable (this project has no TypeScript LanguageService)`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const file = fileArg(args) ?? "";
|
|
68
|
+
const rel = (abs: string): string => relative(ctx.cwd, abs);
|
|
69
|
+
|
|
70
|
+
if (name === TOOL_NAME.symbolSearch) {
|
|
71
|
+
const q = str(args, "query");
|
|
72
|
+
const query = q.length > 0 ? q : str(args, "symbol");
|
|
73
|
+
|
|
74
|
+
if (query.length === 0) {
|
|
75
|
+
return "symbol_search: need `query`";
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const hits = svc.symbols(query);
|
|
79
|
+
|
|
80
|
+
ctx.report({
|
|
81
|
+
kind: "tool",
|
|
82
|
+
task: ctx.task,
|
|
83
|
+
message: `symbol_search ${query} → ${hits.length}`,
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
return hits.length === 0
|
|
87
|
+
? `no symbols matching '${query}'`
|
|
88
|
+
: hits
|
|
89
|
+
.map((h) => `${h.kind} ${h.name} — ${rel(h.file)}:${h.line}`)
|
|
90
|
+
.join("\n");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
if (name === TOOL_NAME.diagnostics) {
|
|
94
|
+
if (file.length === 0) {
|
|
95
|
+
return "diagnostics: need `file`";
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const diags = svc.diagnostics(file);
|
|
99
|
+
|
|
100
|
+
ctx.report({
|
|
101
|
+
kind: "tool",
|
|
102
|
+
task: ctx.task,
|
|
103
|
+
message: `diagnostics ${file} → ${diags.length}`,
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
return diags.length === 0
|
|
107
|
+
? `no semantic diagnostics in ${file}`
|
|
108
|
+
: diags
|
|
109
|
+
.map((d) => `TS${d.code}: ${d.message.split("\n")[0] ?? d.message}`)
|
|
110
|
+
.join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// organize_imports operates on a whole FILE (no symbol/position) — handle it
|
|
114
|
+
// here, before doSymbolLsp's `need {file, symbol}` guard would wrongly reject a
|
|
115
|
+
// valid `{file}`-only call (its schema requires only `file`). See P1 review.
|
|
116
|
+
if (name === TOOL_NAME.organizeImports) {
|
|
117
|
+
if (file.length === 0) {
|
|
118
|
+
return "organize_imports: need `file`";
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!writable(file, ctx.files)) {
|
|
122
|
+
return reject(
|
|
123
|
+
ctx,
|
|
124
|
+
"organize_imports",
|
|
125
|
+
`organize_imports ${file} REJECTED: out of scope.`
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const n = svc.organizeImports(file);
|
|
130
|
+
|
|
131
|
+
ctx.report({
|
|
132
|
+
kind: "tool",
|
|
133
|
+
task: ctx.task,
|
|
134
|
+
message: `organize_imports ${file} (${n})`,
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
return `organize_imports: ${n} change(s) in ${file}`;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// The remaining tools address a symbol by name within a file.
|
|
141
|
+
return doSymbolLsp(name, svc, file, args, ctx, rel);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
type LspService = NonNullable<IToolContext["tsService"]>;
|
|
145
|
+
|
|
146
|
+
/** The LSP tools that resolve a symbol position first (type_at, find_references,
|
|
147
|
+
* rename_symbol). Split out of doLsp to keep each function's branching small. */
|
|
148
|
+
function doSymbolLsp(
|
|
149
|
+
name: ToolName,
|
|
150
|
+
svc: LspService,
|
|
151
|
+
file: string,
|
|
152
|
+
args: Record<string, unknown>,
|
|
153
|
+
ctx: IToolContext,
|
|
154
|
+
rel: (abs: string) => string
|
|
155
|
+
): string {
|
|
156
|
+
const symbol = str(args, "symbol");
|
|
157
|
+
|
|
158
|
+
if (file.length === 0 || symbol.length === 0) {
|
|
159
|
+
return `${name}: need {file, symbol}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const pos = svc.positionOfSymbol(file, symbol);
|
|
163
|
+
|
|
164
|
+
if (pos === undefined) {
|
|
165
|
+
return `${name}: '${symbol}' not found in ${file}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (name === TOOL_NAME.typeAt) {
|
|
169
|
+
const type = svc.typeAt(file, pos);
|
|
170
|
+
|
|
171
|
+
ctx.report({ kind: "tool", task: ctx.task, message: `type_at ${symbol}` });
|
|
172
|
+
|
|
173
|
+
return type.length > 0
|
|
174
|
+
? `${symbol}: ${type}`
|
|
175
|
+
: `no type info for ${symbol}`;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
if (name === TOOL_NAME.findReferences) {
|
|
179
|
+
const refs = svc.references(file, pos);
|
|
180
|
+
|
|
181
|
+
ctx.report({
|
|
182
|
+
kind: "tool",
|
|
183
|
+
task: ctx.task,
|
|
184
|
+
message: `find_references ${symbol} → ${refs.length}`,
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
return refs.length === 0
|
|
188
|
+
? `no references to '${symbol}'`
|
|
189
|
+
: refs.map((r) => `${rel(r.file)}:${r.line}`).join("\n");
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// rename_symbol — scope-enforced: a semantic rename can touch many files; it
|
|
193
|
+
// must NOT edit read-only/out-of-scope ones.
|
|
194
|
+
const newName = str(args, "newName");
|
|
195
|
+
|
|
196
|
+
if (newName.length === 0) {
|
|
197
|
+
return "rename_symbol: need `newName`";
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const targets = svc.renameTargets(file, pos).map(rel);
|
|
201
|
+
const outOfScope = targets.filter((t) => !writable(t, ctx.files));
|
|
202
|
+
|
|
203
|
+
if (outOfScope.length > 0) {
|
|
204
|
+
return reject(
|
|
205
|
+
ctx,
|
|
206
|
+
"rename_symbol",
|
|
207
|
+
`rename '${symbol}' REJECTED: would edit out-of-scope/read-only file(s): ${outOfScope.join(", ")}. Rename only symbols whose every reference is in your editable files.`
|
|
208
|
+
);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const changed = svc.rename(file, pos, newName);
|
|
212
|
+
|
|
213
|
+
ctx.report({
|
|
214
|
+
kind: "tool",
|
|
215
|
+
task: ctx.task,
|
|
216
|
+
message: `rename_symbol ${symbol}→${newName} (${changed ?? 0})`,
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
return changed === null
|
|
220
|
+
? `rename_symbol: '${symbol}' can't be renamed here`
|
|
221
|
+
: `renamed '${symbol}' → '${newName}' across ${changed} location(s)`;
|
|
222
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { writable, normalizeWorkspacePath } from "../../lib/scope";
|
|
3
|
+
import { materializeRoutes, asRoutePaths } from "../../web-routes";
|
|
4
|
+
import { type IToolContext } from "./tool-context";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* `scaffold_routes` — materialize ALL of an app's routes as file-based stubs from
|
|
8
|
+
* a model-declared path list, so the route union is complete up front and no
|
|
9
|
+
* `<Link to>` can forward-reference a missing route (the error class that ate ~1/3
|
|
10
|
+
* of multi-route builds). The model owns WHAT (which pages — app-specific); this
|
|
11
|
+
* owns HOW (correct createFileRoute + $param filename mapping). Like scaffold_ui:
|
|
12
|
+
* overwrites stubs idempotently and reports ONE summary event (not per-file
|
|
13
|
+
* `create`s) so the write-guard doesn't re-check the generated shells.
|
|
14
|
+
*/
|
|
15
|
+
export async function doScaffoldRoutes(
|
|
16
|
+
args: Record<string, unknown>,
|
|
17
|
+
ctx: IToolContext
|
|
18
|
+
): Promise<string> {
|
|
19
|
+
const paths = asRoutePaths(args.routes);
|
|
20
|
+
|
|
21
|
+
if (paths.length === 0) {
|
|
22
|
+
return 'scaffold_routes REJECTED: `routes` must be a non-empty array of path strings (e.g. ["/", "/accounts", "/accounts/$accountId"]).';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const files = materializeRoutes(paths);
|
|
26
|
+
const written: string[] = [];
|
|
27
|
+
const kept: string[] = [];
|
|
28
|
+
|
|
29
|
+
for (const [rel, content] of Object.entries(files)) {
|
|
30
|
+
const path = normalizeWorkspacePath(ctx.cwd, rel);
|
|
31
|
+
|
|
32
|
+
if (!writable(path, ctx.files)) {
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// NEVER overwrite an existing route file — it may already be FILLED with the
|
|
37
|
+
// real page. scaffold_routes is idempotent + additive: it only stubs routes
|
|
38
|
+
// that don't exist yet. (Re-calling it to add new pages used to clobber every
|
|
39
|
+
// already-built page back to a stub → an endless re-fill loop.)
|
|
40
|
+
if (await Bun.file(join(ctx.cwd, path)).exists()) {
|
|
41
|
+
kept.push(path);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
await Bun.write(join(ctx.cwd, path), content);
|
|
46
|
+
written.push(path);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const keptNote =
|
|
50
|
+
kept.length > 0
|
|
51
|
+
? ` (kept ${String(kept.length)} existing route(s) untouched)`
|
|
52
|
+
: "";
|
|
53
|
+
|
|
54
|
+
ctx.report({
|
|
55
|
+
kind: "tool",
|
|
56
|
+
task: ctx.task,
|
|
57
|
+
message: `scaffold_routes: created ${String(written.length)} new route stub(s)${keptNote}`,
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return (
|
|
61
|
+
`scaffold_routes: created ${String(written.length)} NEW route stub(s)${keptNote}. ` +
|
|
62
|
+
`It is ADDITIVE and safe to call again — it NEVER overwrites a route you've already ` +
|
|
63
|
+
`built, only adds missing ones. New stubs are PLACEHOLDERS (data-tsforge-stub): replace ` +
|
|
64
|
+
`EACH with the real page (its list/detail/form using your types + useCollection(service) ` +
|
|
65
|
+
`+ @/components/ui). The gate FAILS while any stub remains. To FILL a route, EDIT its file ` +
|
|
66
|
+
`directly — do NOT call scaffold_routes again to "reset" it.`
|
|
67
|
+
);
|
|
68
|
+
}
|