@agjs/tsforge 0.5.0 → 0.5.2
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/package.json +1 -1
- package/src/agent/agent.constants.ts +14 -0
- package/src/detect-gate.ts +26 -4
- package/src/loop/tools/execute-tool.ts +1 -0
- package/src/loop/tools/lsp-ops.ts +48 -1
- package/src/lsp/service.ts +69 -1
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@ export const TOOL_NAME = {
|
|
|
16
16
|
typeAt: "type_at",
|
|
17
17
|
diagnostics: "diagnostics",
|
|
18
18
|
renameSymbol: "rename_symbol",
|
|
19
|
+
moveFile: "move_file",
|
|
19
20
|
organizeImports: "organize_imports",
|
|
20
21
|
scaffoldUi: "scaffold_ui",
|
|
21
22
|
scaffoldRoutes: "scaffold_routes",
|
|
@@ -379,4 +380,17 @@ export const LSP_TOOLS = [
|
|
|
379
380
|
},
|
|
380
381
|
},
|
|
381
382
|
},
|
|
383
|
+
{
|
|
384
|
+
type: "function",
|
|
385
|
+
function: {
|
|
386
|
+
name: TOOL_NAME.moveFile,
|
|
387
|
+
description:
|
|
388
|
+
"Move/rename a FILE and rewrite every import that points at it (and its own relative imports) in one step — compiler-accurate, no manual edits. Rejected if the source, destination, or any importer is read-only/out-of-scope.",
|
|
389
|
+
parameters: {
|
|
390
|
+
type: "object",
|
|
391
|
+
properties: { from: { type: "string" }, to: { type: "string" } },
|
|
392
|
+
required: ["from", "to"],
|
|
393
|
+
},
|
|
394
|
+
},
|
|
395
|
+
},
|
|
382
396
|
];
|
package/src/detect-gate.ts
CHANGED
|
@@ -142,6 +142,11 @@ const STRICT_TSCONFIG_OVERLAY = `{
|
|
|
142
142
|
/** The gate overlay's home: tsforge's cache dir + the overlay filename. */
|
|
143
143
|
const GATE_TSCONFIG_DIR = ".tsforge";
|
|
144
144
|
const GATE_TSCONFIG_FILE = "tsconfig.gate.json";
|
|
145
|
+
/** Persistent incremental-typecheck cache (in .tsforge/, git-ignored). Reused
|
|
146
|
+
* across settles so a warm `tsc` only re-checks what changed — tsc stays the
|
|
147
|
+
* authority, just amortized. */
|
|
148
|
+
const GATE_TSBUILDINFO_FILE = "gate.tsbuildinfo";
|
|
149
|
+
const INCREMENTAL_FLAGS = `--incremental --tsBuildInfoFile ${GATE_TSCONFIG_DIR}/${GATE_TSBUILDINFO_FILE}`;
|
|
145
150
|
|
|
146
151
|
// The web-stack scaffolds (Vite + React full-kit, or Vite vanilla) live in the
|
|
147
152
|
// registry; this module just lays them down and builds their gate. shadcn/TanStack
|
|
@@ -780,7 +785,7 @@ async function tscPart(cwd: string): Promise<string | null> {
|
|
|
780
785
|
);
|
|
781
786
|
await ignoreGateArtifact(cwd);
|
|
782
787
|
|
|
783
|
-
return `"${TSC_BIN}" --noEmit -p ${GATE_TSCONFIG_DIR}/${GATE_TSCONFIG_FILE}`;
|
|
788
|
+
return `"${TSC_BIN}" --noEmit ${INCREMENTAL_FLAGS} -p ${GATE_TSCONFIG_DIR}/${GATE_TSCONFIG_FILE}`;
|
|
784
789
|
}
|
|
785
790
|
|
|
786
791
|
// Greenfield: bring a strict tsconfig so tsc can gate — but only when this is
|
|
@@ -788,8 +793,11 @@ async function tscPart(cwd: string): Promise<string | null> {
|
|
|
788
793
|
// Unlike the overlay, a greenfield tsconfig.json is a DURABLE project file.
|
|
789
794
|
if (await Bun.file(join(cwd, "package.json")).exists()) {
|
|
790
795
|
await Bun.write(join(cwd, "tsconfig.json"), STRICT_TSCONFIG);
|
|
796
|
+
// The buildinfo lives in .tsforge/ (git-ignored), NOT next to the durable
|
|
797
|
+
// tsconfig — so incremental never leaks a cache file into the user's tree.
|
|
798
|
+
await ignoreGateArtifact(cwd);
|
|
791
799
|
|
|
792
|
-
return `"${TSC_BIN}" --noEmit -p tsconfig.json`;
|
|
800
|
+
return `"${TSC_BIN}" --noEmit ${INCREMENTAL_FLAGS} -p tsconfig.json`;
|
|
793
801
|
}
|
|
794
802
|
|
|
795
803
|
return null;
|
|
@@ -801,12 +809,26 @@ async function tscPart(cwd: string): Promise<string | null> {
|
|
|
801
809
|
* that intentionally tracks rules.json) is never clobbered. */
|
|
802
810
|
async function ignoreGateArtifact(cwd: string): Promise<void> {
|
|
803
811
|
const ignore = join(cwd, GATE_TSCONFIG_DIR, ".gitignore");
|
|
812
|
+
const entries = [GATE_TSCONFIG_FILE, GATE_TSBUILDINFO_FILE];
|
|
813
|
+
const file = Bun.file(ignore);
|
|
814
|
+
|
|
815
|
+
if (!(await file.exists())) {
|
|
816
|
+
await Bun.write(ignore, `${entries.join("\n")}\n`);
|
|
804
817
|
|
|
805
|
-
if (await Bun.file(ignore).exists()) {
|
|
806
818
|
return;
|
|
807
819
|
}
|
|
808
820
|
|
|
809
|
-
|
|
821
|
+
// Exists (maybe a user's, or an older tsforge one without the buildinfo line):
|
|
822
|
+
// append only the missing entries so we never clobber what's there.
|
|
823
|
+
const current = await file.text();
|
|
824
|
+
const missing = entries.filter((e) => !current.split("\n").includes(e));
|
|
825
|
+
|
|
826
|
+
if (missing.length > 0) {
|
|
827
|
+
await Bun.write(
|
|
828
|
+
ignore,
|
|
829
|
+
`${current.replace(/\n*$/u, "\n")}${missing.join("\n")}\n`
|
|
830
|
+
);
|
|
831
|
+
}
|
|
810
832
|
}
|
|
811
833
|
|
|
812
834
|
/** The syntactic idiom layer — ALWAYS tsforge's bundled strict eslint config
|
|
@@ -30,6 +30,7 @@ const HANDLERS: Record<ToolName, ToolHandler> = {
|
|
|
30
30
|
[TOOL_NAME.typeAt]: (a, c) => doLsp(TOOL_NAME.typeAt, a, c),
|
|
31
31
|
[TOOL_NAME.diagnostics]: (a, c) => doLsp(TOOL_NAME.diagnostics, a, c),
|
|
32
32
|
[TOOL_NAME.renameSymbol]: (a, c) => doLsp(TOOL_NAME.renameSymbol, a, c),
|
|
33
|
+
[TOOL_NAME.moveFile]: (a, c) => doLsp(TOOL_NAME.moveFile, a, c),
|
|
33
34
|
[TOOL_NAME.organizeImports]: (a, c) => doLsp(TOOL_NAME.organizeImports, a, c),
|
|
34
35
|
[TOOL_NAME.scaffoldUi]: doScaffoldUi,
|
|
35
36
|
[TOOL_NAME.scaffoldRoutes]: doScaffoldRoutes,
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { relative } from "node:path";
|
|
2
2
|
import { fileArg, TOOL_NAME, type ToolName } from "../../agent";
|
|
3
3
|
import { runArgvCommand } from "../../lib/fs";
|
|
4
|
-
import { writable } from "../../lib/scope";
|
|
4
|
+
import { writable, isVendored } from "../../lib/scope";
|
|
5
5
|
import { LOOP_LIMITS } from "../loop.constants";
|
|
6
6
|
import { str, reject, type IToolContext } from "./tool-context";
|
|
7
7
|
|
|
@@ -137,10 +137,57 @@ export function doLsp(
|
|
|
137
137
|
return `organize_imports: ${n} change(s) in ${file}`;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
// move_file takes {from, to} (not {file, symbol}) — handle before doSymbolLsp's
|
|
141
|
+
// symbol guard. Scope-enforced: a move rewrites importers across the project; it
|
|
142
|
+
// must NOT touch read-only/out-of-scope/vendored files.
|
|
143
|
+
if (name === TOOL_NAME.moveFile) {
|
|
144
|
+
return doMoveFile(svc, args, ctx, rel);
|
|
145
|
+
}
|
|
146
|
+
|
|
140
147
|
// The remaining tools address a symbol by name within a file.
|
|
141
148
|
return doSymbolLsp(name, svc, file, args, ctx, rel);
|
|
142
149
|
}
|
|
143
150
|
|
|
151
|
+
/** move_file: relocate a file and rewrite every importer's specifier. */
|
|
152
|
+
function doMoveFile(
|
|
153
|
+
svc: LspService,
|
|
154
|
+
args: Record<string, unknown>,
|
|
155
|
+
ctx: IToolContext,
|
|
156
|
+
rel: (abs: string) => string
|
|
157
|
+
): string {
|
|
158
|
+
const from = str(args, "from");
|
|
159
|
+
const to = str(args, "to");
|
|
160
|
+
|
|
161
|
+
if (from.length === 0 || to.length === 0) {
|
|
162
|
+
return "move_file: need {from, to}";
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const targets = svc.moveTargets(from, to).map(rel);
|
|
166
|
+
const blocked = targets.filter(
|
|
167
|
+
(t) => !writable(t, ctx.files) || isVendored(t, ctx.vendored ?? [])
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
if (blocked.length > 0) {
|
|
171
|
+
return reject(
|
|
172
|
+
ctx,
|
|
173
|
+
"move_file",
|
|
174
|
+
`move '${from}' → '${to}' REJECTED: would touch out-of-scope/read-only file(s): ${blocked.join(", ")}. Move files only when the source, destination, and every importer are in your editable scope.`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const changed = svc.moveFile(from, to);
|
|
179
|
+
|
|
180
|
+
ctx.report({
|
|
181
|
+
kind: "tool",
|
|
182
|
+
task: ctx.task,
|
|
183
|
+
message: `move_file ${from}→${to} (${changed ?? 0})`,
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
return changed === null
|
|
187
|
+
? `move_file: source '${from}' not found`
|
|
188
|
+
: `moved '${from}' → '${to}', updated imports across ${changed} file(s)`;
|
|
189
|
+
}
|
|
190
|
+
|
|
144
191
|
type LspService = NonNullable<IToolContext["tsService"]>;
|
|
145
192
|
|
|
146
193
|
/** The LSP tools that resolve a symbol position first (type_at, find_references,
|
package/src/lsp/service.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { join, isAbsolute } from "node:path";
|
|
1
|
+
import { join, isAbsolute, dirname } from "node:path";
|
|
2
|
+
import { mkdirSync, rmSync } from "node:fs";
|
|
2
3
|
import ts from "typescript";
|
|
3
4
|
import type {
|
|
4
5
|
ITsDiagnostic,
|
|
@@ -451,6 +452,73 @@ export class TsService {
|
|
|
451
452
|
return [...new Set((locs ?? []).map((l) => l.fileName))];
|
|
452
453
|
}
|
|
453
454
|
|
|
455
|
+
/** Edits a file move would produce (rewriting importers + the file's own
|
|
456
|
+
* relative imports). Absolute paths. */
|
|
457
|
+
private moveEdits(
|
|
458
|
+
fromAbs: string,
|
|
459
|
+
toAbs: string
|
|
460
|
+
): readonly ts.FileTextChanges[] {
|
|
461
|
+
return this.service.getEditsForFileRename(
|
|
462
|
+
fromAbs,
|
|
463
|
+
toAbs,
|
|
464
|
+
ts.getDefaultFormatCodeSettings("\n"),
|
|
465
|
+
{}
|
|
466
|
+
);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/** Which files a move would touch — every importer plus the source and
|
|
470
|
+
* destination — for scope-checking BEFORE applying. Absolute paths. */
|
|
471
|
+
moveTargets(fromRel: string, toRel: string): string[] {
|
|
472
|
+
const fromAbs = this.toAbs(fromRel);
|
|
473
|
+
const toAbs = this.toAbs(toRel);
|
|
474
|
+
const touched = new Set(
|
|
475
|
+
this.moveEdits(fromAbs, toAbs).map((e) => e.fileName)
|
|
476
|
+
);
|
|
477
|
+
|
|
478
|
+
touched.add(fromAbs);
|
|
479
|
+
touched.add(toAbs);
|
|
480
|
+
|
|
481
|
+
return [...touched];
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
/**
|
|
485
|
+
* Move a file and rewrite every import that points at it (and its own relative
|
|
486
|
+
* imports), compiler-accurately via getEditsForFileRename. Returns the number
|
|
487
|
+
* of files changed (incl. the moved file), or null if the source can't be read.
|
|
488
|
+
* Callers MUST enforce scope first (a move can touch read-only files).
|
|
489
|
+
*/
|
|
490
|
+
moveFile(fromRel: string, toRel: string): number | null {
|
|
491
|
+
const fromAbs = this.toAbs(fromRel);
|
|
492
|
+
const toAbs = this.toAbs(toRel);
|
|
493
|
+
const original = ts.sys.readFile(fromAbs);
|
|
494
|
+
|
|
495
|
+
if (original === undefined) {
|
|
496
|
+
return null;
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
const edits = this.moveEdits(fromAbs, toAbs);
|
|
500
|
+
|
|
501
|
+
// Apply the import rewrites (importers + the moved file's own imports) while
|
|
502
|
+
// the file still lives at its old path, THEN relocate the edited content.
|
|
503
|
+
this.applyChanges(edits);
|
|
504
|
+
|
|
505
|
+
const moved = ts.sys.readFile(fromAbs) ?? original;
|
|
506
|
+
|
|
507
|
+
mkdirSync(dirname(toAbs), { recursive: true });
|
|
508
|
+
ts.sys.writeFile(toAbs, moved);
|
|
509
|
+
rmSync(fromAbs, { force: true });
|
|
510
|
+
|
|
511
|
+
const stale = this.files.indexOf(fromAbs);
|
|
512
|
+
|
|
513
|
+
if (stale >= 0) {
|
|
514
|
+
this.files.splice(stale, 1);
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
this.refresh(toRel);
|
|
518
|
+
|
|
519
|
+
return new Set([...edits.map((e) => e.fileName), fromAbs]).size;
|
|
520
|
+
}
|
|
521
|
+
|
|
454
522
|
/** Organize imports (dedupe/sort/drop unused) for one file. Returns edits made. */
|
|
455
523
|
organizeImports(file: string): number {
|
|
456
524
|
const changes = this.service.organizeImports(
|