@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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@agjs/tsforge",
3
3
  "type": "module",
4
- "version": "0.5.0",
4
+ "version": "0.5.2",
5
5
  "license": "MIT",
6
6
  "description": "TypeScript coding harness with a deterministic gate, stack-aware guardrails, and stream-level correction.",
7
7
  "repository": {
@@ -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
  ];
@@ -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
- await Bun.write(ignore, `${GATE_TSCONFIG_FILE}\n`);
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,
@@ -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(