toolfactory 0.1.1 → 0.2.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.
Files changed (110) hide show
  1. package/README.md +31 -3
  2. package/dist/bindings/python.js +1 -1
  3. package/dist/commands.d.ts +1 -1
  4. package/dist/distribution/native.d.ts +50 -0
  5. package/dist/distribution/native.js +414 -0
  6. package/dist/distribution/native.test.d.ts +1 -0
  7. package/dist/distribution/native.test.js +127 -0
  8. package/dist/distribution/system.d.ts +57 -0
  9. package/dist/distribution/system.js +637 -0
  10. package/dist/distribution/system.test.d.ts +1 -0
  11. package/dist/distribution/system.test.js +116 -0
  12. package/dist/e2e.test.js +7 -1
  13. package/dist/hosts/openclaw.js +6 -2
  14. package/dist/hosts/vscode.d.ts +1 -0
  15. package/dist/hosts/vscode.js +67 -0
  16. package/dist/model.d.ts +63 -2
  17. package/dist/model.js +18 -1
  18. package/dist/ops.d.ts +52 -0
  19. package/dist/project/apply.d.ts +1 -1
  20. package/dist/project/apply.js +194 -17
  21. package/dist/project/apply.test.js +103 -0
  22. package/dist/project/gate.d.ts +17 -2
  23. package/dist/project/gate.js +102 -23
  24. package/dist/project/history.d.ts +30 -1
  25. package/dist/project/lock.d.ts +5 -0
  26. package/dist/project/lock.js +9 -0
  27. package/dist/surfaces/agent-plugins.js +5 -4
  28. package/dist/surfaces/agents.js +27 -3
  29. package/dist/surfaces/agents.test.js +12 -0
  30. package/dist/surfaces/claude.js +8 -3
  31. package/dist/surfaces/codex.js +9 -4
  32. package/dist/surfaces/codex.test.js +16 -6
  33. package/dist/surfaces/cursor.js +2 -2
  34. package/dist/surfaces/distribution.test.js +121 -7
  35. package/dist/surfaces/dsh.js +29 -7
  36. package/dist/surfaces/dsh.test.js +11 -6
  37. package/dist/surfaces/gemini.js +2 -2
  38. package/dist/surfaces/gemini.test.js +4 -4
  39. package/dist/surfaces/hermes-native.js +43 -12
  40. package/dist/surfaces/hermes-native.test.js +53 -11
  41. package/dist/surfaces/mcp-registry.js +59 -7
  42. package/dist/surfaces/mcp-registry.test.js +23 -0
  43. package/dist/surfaces/mcpb.d.ts +1 -0
  44. package/dist/surfaces/mcpb.js +36 -23
  45. package/dist/surfaces/mcpb.test.js +31 -3
  46. package/dist/surfaces/npm.js +45 -14
  47. package/dist/surfaces/openclaw-native.js +10 -8
  48. package/dist/surfaces/openclaw-native.test.js +30 -0
  49. package/dist/surfaces/pypi.js +27 -3
  50. package/dist/surfaces/readme.d.ts +16 -0
  51. package/dist/surfaces/readme.js +17 -7
  52. package/dist/surfaces/registry.js +9 -3
  53. package/dist/surfaces/vscode-extension.d.ts +14 -0
  54. package/dist/surfaces/vscode-extension.js +312 -0
  55. package/dist/surfaces/vscode-extension.test.d.ts +1 -0
  56. package/dist/surfaces/vscode-extension.test.js +81 -0
  57. package/dist/surfaces/vscode-scaffold.d.ts +43 -0
  58. package/dist/surfaces/vscode-scaffold.js +43 -0
  59. package/dist/surfaces/workflows.js +57 -11
  60. package/dist/surfaces/workflows.test.js +71 -3
  61. package/dist/toolfactory/cli.js +1 -1
  62. package/dist/toolfactory/mcp.js +1 -1
  63. package/package.json +2 -1
  64. package/src/bindings/python.ts +1 -1
  65. package/src/distribution/native.test.ts +161 -0
  66. package/src/distribution/native.ts +460 -0
  67. package/src/distribution/system.test.ts +136 -0
  68. package/src/distribution/system.ts +688 -0
  69. package/src/e2e.test.ts +7 -1
  70. package/src/hosts/openclaw.ts +4 -2
  71. package/src/hosts/vscode.ts +79 -0
  72. package/src/model.ts +26 -2
  73. package/src/project/apply.test.ts +120 -0
  74. package/src/project/apply.ts +257 -15
  75. package/src/project/gate.ts +131 -24
  76. package/src/project/lock.ts +12 -0
  77. package/src/surfaces/agent-plugins.ts +5 -4
  78. package/src/surfaces/agents.test.ts +16 -0
  79. package/src/surfaces/agents.ts +38 -3
  80. package/src/surfaces/claude.ts +7 -3
  81. package/src/surfaces/codex.test.ts +15 -5
  82. package/src/surfaces/codex.ts +12 -6
  83. package/src/surfaces/cursor.ts +2 -10
  84. package/src/surfaces/distribution.test.ts +141 -6
  85. package/src/surfaces/dsh.test.ts +11 -5
  86. package/src/surfaces/dsh.ts +30 -7
  87. package/src/surfaces/gemini.test.ts +3 -3
  88. package/src/surfaces/gemini.ts +1 -2
  89. package/src/surfaces/hermes-native.test.ts +61 -10
  90. package/src/surfaces/hermes-native.ts +46 -13
  91. package/src/surfaces/mcp-registry.test.ts +28 -0
  92. package/src/surfaces/mcp-registry.ts +66 -8
  93. package/src/surfaces/mcpb.test.ts +32 -5
  94. package/src/surfaces/mcpb.ts +44 -23
  95. package/src/surfaces/npm.ts +47 -15
  96. package/src/surfaces/openclaw-native.test.ts +37 -0
  97. package/src/surfaces/openclaw-native.ts +49 -47
  98. package/src/surfaces/pypi.ts +27 -3
  99. package/src/surfaces/readme.ts +21 -6
  100. package/src/surfaces/registry.ts +9 -3
  101. package/src/surfaces/vscode-extension.test.ts +89 -0
  102. package/src/surfaces/vscode-extension.ts +345 -0
  103. package/src/surfaces/vscode-scaffold.ts +55 -0
  104. package/src/surfaces/workflows.test.ts +86 -3
  105. package/src/surfaces/workflows.ts +57 -11
  106. package/src/toolfactory/cli.ts +1 -1
  107. package/src/toolfactory/mcp.ts +1 -1
  108. package/web/dist/assets/{index-CCXhQKK3.css → index-BuT2czb2.css} +1 -1
  109. package/web/dist/assets/{index-D1PwZ3qS.js → index-CVm6m4s0.js} +1 -1
  110. package/web/dist/index.html +2 -2
package/src/e2e.test.ts CHANGED
@@ -163,7 +163,13 @@ describe.skipIf(!existsSync(repoNodeModules))(
163
163
  authorProse(fresh);
164
164
  commands.build(fresh);
165
165
 
166
- expect(tree(walked)).toEqual(tree(fresh));
166
+ const walkedTree = tree(walked);
167
+ // Deselecting a region surface preserves its author-owned scaffold, including an empty
168
+ // Docker ignore file. All managed bytes still converge with the freshly generated tree.
169
+ expect(walkedTree[".dockerignore"]).toContain("# tf:dockerignore# /tf:dockerignore");
170
+ expect(walkedTree[".dockerignore"]).not.toContain("node_modules/");
171
+ delete walkedTree[".dockerignore"];
172
+ expect(walkedTree).toEqual(tree(fresh));
167
173
  });
168
174
  },
169
175
  );
@@ -62,8 +62,10 @@ function missing(scaffold: unknown, ours: unknown, path: string, skip: Set<strin
62
62
 
63
63
  function planned(files: PlannedFile[], path: string): string {
64
64
  const file = files.find((candidate) => candidate.path === `${HOST_DIR}/${path}`);
65
- if (file?.kind !== "file") throw new Error(`the openclaw surface no longer plans ${path}`);
66
- return file.content;
65
+ if (!file) throw new Error(`the openclaw surface no longer plans ${path}`);
66
+ if (file.kind === "file") return file.content;
67
+ if (file.kind === "merge" && file.format === "json") return JSON.stringify(file.patch, null, 2);
68
+ throw new Error(`the openclaw surface no longer plans ${path} as JSON`);
67
69
  }
68
70
 
69
71
  /** Human-readable drift lines; empty means the generator still mirrors upstream. */
@@ -0,0 +1,79 @@
1
+ /** Verify the extension scaffold against Microsoft's generator, without touching the checkout. */
2
+ import { execFileSync } from "node:child_process";
3
+ import { mkdtempSync, readFileSync, rmSync } from "node:fs";
4
+ import { tmpdir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { argv } from "node:process";
7
+ import { fileURLToPath } from "node:url";
8
+ import {
9
+ ESBUILD,
10
+ ESLINT,
11
+ GITIGNORE,
12
+ IGNORE,
13
+ LAUNCH,
14
+ TASKS,
15
+ VSCODE_GENERATOR,
16
+ VSCODE_SCAFFOLD,
17
+ } from "../surfaces/vscode-scaffold.js";
18
+
19
+ export function scaffoldDrift(): string[] {
20
+ const root = mkdtempSync(join(tmpdir(), "toolfactory-vscode-scaffold-"));
21
+ const target = join(root, "probe");
22
+ try {
23
+ execFileSync(
24
+ "npx",
25
+ [
26
+ "--yes",
27
+ "--package",
28
+ "yo",
29
+ "--package",
30
+ `generator-code@${VSCODE_GENERATOR}`,
31
+ "--",
32
+ "yo",
33
+ "code",
34
+ target,
35
+ "--extensionType",
36
+ "ts",
37
+ "--extensionDisplayName",
38
+ "ToolFactory Probe",
39
+ "--extensionId",
40
+ "tf-probe",
41
+ "--extensionDescription",
42
+ "ToolFactory scaffold probe",
43
+ "--pkgManager",
44
+ "npm",
45
+ "--bundler",
46
+ "esbuild",
47
+ "--gitInit",
48
+ "false",
49
+ "--skip-install",
50
+ "--skipOpen",
51
+ "--quick",
52
+ ],
53
+ { stdio: "pipe", cwd: root },
54
+ );
55
+ const pkg = JSON.parse(readFileSync(join(target, "package.json"), "utf8"));
56
+ const drift = Object.entries(VSCODE_SCAFFOLD)
57
+ .filter(([key, expected]) => JSON.stringify(pkg[key]) !== JSON.stringify(expected))
58
+ .map(([key]) => `package.json ${key}`);
59
+ for (const [path, expected] of [
60
+ ["esbuild.js", ESBUILD],
61
+ ["eslint.config.mjs", ESLINT],
62
+ [".vscodeignore", IGNORE],
63
+ [".gitignore", GITIGNORE],
64
+ [".vscode/launch.json", LAUNCH],
65
+ [".vscode/tasks.json", TASKS],
66
+ ]) {
67
+ if (readFileSync(join(target, path), "utf8") !== expected) drift.push(path);
68
+ }
69
+ return drift;
70
+ } finally {
71
+ rmSync(root, { recursive: true, force: true });
72
+ }
73
+ }
74
+
75
+ if (argv[1] === fileURLToPath(import.meta.url)) {
76
+ const drift = scaffoldDrift();
77
+ if (drift.length) throw new Error(`VS Code scaffold drifted: ${drift.join(", ")}`);
78
+ process.stdout.write("VS Code scaffold matches generator-code.\n");
79
+ }
package/src/model.ts CHANGED
@@ -7,6 +7,9 @@
7
7
  * rest is derived and written in-tree, tracked by a lock file.
8
8
  */
9
9
  import { z } from "zod";
10
+ import { NATIVE_PACKAGE_IDS, nativePackageConfigSchema } from "./distribution/native.js";
11
+ import { SYSTEM_PACKAGE_IDS, systemPackageConfigSchema } from "./distribution/system.js";
12
+ import { vscodeConfigSchema } from "./surfaces/vscode-extension.js";
10
13
 
11
14
  export const CAPABILITIES = [
12
15
  "net",
@@ -48,6 +51,9 @@ export const SURFACE_IDS = [
48
51
  "gemini",
49
52
  "mcpb",
50
53
  "browser-extension",
54
+ "vscode-extension",
55
+ ...NATIVE_PACKAGE_IDS,
56
+ ...SYSTEM_PACKAGE_IDS,
51
57
  ] as const;
52
58
  export type SurfaceId = (typeof SURFACE_IDS)[number];
53
59
 
@@ -129,7 +135,17 @@ export const ToolConfigSchema = z
129
135
  .default({ examples: {} })
130
136
  .describe("Example arguments per operation, used by the generated surface-smoke tests."),
131
137
  npm: z.object({ scope: z.string().optional() }).optional(),
132
- codex: z.object({ interface: jsonObject.optional() }).optional(),
138
+ codex: z
139
+ .object({
140
+ interface: jsonObject.optional(),
141
+ mcp: z
142
+ .boolean()
143
+ .default(true)
144
+ .describe(
145
+ "Whether the Codex plugin manifest eagerly configures the generated MCP server.",
146
+ ),
147
+ })
148
+ .optional(),
133
149
  browserExtension: z
134
150
  .object({
135
151
  endpoint: z
@@ -177,6 +193,9 @@ export const ToolConfigSchema = z
177
193
  "Privacy-policy URLs of the services this tool talks to; Anthropic's Connectors Directory submission requires them in the MCPB manifest.",
178
194
  ),
179
195
  hermes: z.object({ toolset: z.string().optional() }).optional(),
196
+ vscode: vscodeConfigSchema.optional(),
197
+ nativePackages: z.array(nativePackageConfigSchema).optional(),
198
+ systemPackages: z.array(systemPackageConfigSchema).optional(),
180
199
  openclaw: z
181
200
  .object({
182
201
  registers: z
@@ -304,10 +323,15 @@ export interface RegionFile {
304
323
  export interface MergeFile {
305
324
  kind: "merge";
306
325
  path: string;
307
- format: "json" | "toml";
326
+ format: "json" | "toml" | "yaml";
308
327
  patch: Record<string, unknown>;
309
328
  /** Dotted paths inside `patch` whose object toolfactory owns whole: replaced, never merged. */
310
329
  owned?: string[];
330
+ /**
331
+ * Arrays whose entries are shared by a stable identifier. Toolfactory replaces only entries it
332
+ * previously wrote, preserves the author's other entries, and rejects an id collision.
333
+ */
334
+ keyedArrays?: Record<string, string>;
311
335
  }
312
336
 
313
337
  export type PlannedFile = FullFile | RegionFile | MergeFile;
@@ -279,9 +279,129 @@ describe("apply / check — merge files", () => {
279
279
  expect(text).toContain('dependencies = [ "zod" ]');
280
280
  expect(check(root, plan, "0.1.0")).toEqual([]);
281
281
  });
282
+
283
+ it("merges YAML and keeps authored keyed-array entries while removing stale generated ones", () => {
284
+ const root = tmp();
285
+ const plan = (commands: string[]): MergeFile[] => [
286
+ {
287
+ kind: "merge",
288
+ path: "extension.yaml",
289
+ format: "yaml",
290
+ patch: {
291
+ name: "hello",
292
+ commands: commands.map((command) => ({ command, title: command })),
293
+ },
294
+ keyedArrays: { commands: "command" },
295
+ },
296
+ ];
297
+ apply(root, plan(["hello.run", "hello.old"]), "0.1.0");
298
+ writeFileSync(
299
+ join(root, "extension.yaml"),
300
+ `${readFileSync(join(root, "extension.yaml"), "utf8")}commands:\n - command: author.run\n title: Author\n - command: hello.run\n title: hello.run\n - command: hello.old\n title: hello.old\n`,
301
+ );
302
+ // Recreate the document with a custom entry and the generated entries, as a hand-built host
303
+ // manifest would; the generated ids remain Toolfactory-owned from the lock.
304
+ writeFileSync(
305
+ join(root, "extension.yaml"),
306
+ "name: hello\ncommands:\n - command: author.run\n title: Author\n - command: hello.run\n title: hello.run\n - command: hello.old\n title: hello.old\n",
307
+ );
308
+ apply(root, plan(["hello.run", "hello.new"]), "0.1.0");
309
+ const text = readFileSync(join(root, "extension.yaml"), "utf8");
310
+ expect(text).toContain("author.run");
311
+ expect(text).toContain("hello.new");
312
+ expect(text).not.toContain("hello.old");
313
+ expect(check(root, plan(["hello.run", "hello.new"]), "0.1.0")).toEqual([]);
314
+ expect(() => apply(root, plan(["author.run"]), "0.1.0")).toThrow(/already has author entry/);
315
+ });
316
+
317
+ it("preserves unknown keyed-entry fields while removing stale projected leaves", () => {
318
+ const root = tmp();
319
+ const plan = (title: string, category?: string): MergeFile[] => [
320
+ {
321
+ kind: "merge",
322
+ path: "package.json",
323
+ format: "json",
324
+ patch: {
325
+ contributes: {
326
+ commands: [{ command: "hello.run", title, ...(category ? { category } : {}) }],
327
+ },
328
+ },
329
+ keyedArrays: { "contributes.commands": "command" },
330
+ },
331
+ ];
332
+ apply(root, plan("Run", "Toolfactory"), "0.1.0");
333
+ const path = join(root, "package.json");
334
+ const document = JSON.parse(readFileSync(path, "utf8"));
335
+ document.contributes.commands[0].icon = "$(rocket)";
336
+ writeFileSync(path, JSON.stringify(document));
337
+ // Native command details remain authored: they are not drift merely because Toolfactory
338
+ // owns the command's id and projected leaves.
339
+ expect(check(root, plan("Run", "Toolfactory"), "0.1.0")).toEqual([]);
340
+ apply(root, plan("Run tool"), "0.1.0");
341
+ expect(JSON.parse(readFileSync(path, "utf8")).contributes.commands).toEqual([
342
+ { command: "hello.run", title: "Run tool", icon: "$(rocket)" },
343
+ ]);
344
+ expect(check(root, plan("Run tool"), "0.1.0")).toEqual([]);
345
+ });
346
+
347
+ it("upgrades an untouched generated full file to a region but protects changed files", () => {
348
+ const root = tmp();
349
+ const old: PlannedFile[] = [{ kind: "file", path: "entry.ts", content: "old generated\n" }];
350
+ const region: RegionFile = {
351
+ kind: "region",
352
+ path: "entry.ts",
353
+ regions: [{ begin: "// tf:entry", end: "// /tf:entry", content: "new generated\n" }],
354
+ template: "// tf:entry\n// /tf:entry\n\n// author tail\n",
355
+ };
356
+ apply(root, old, "0.1.0");
357
+ apply(root, [region], "0.2.0");
358
+ expect(readFileSync(join(root, "entry.ts"), "utf8")).toContain("new generated");
359
+
360
+ const changed = tmp();
361
+ apply(changed, old, "0.1.0");
362
+ writeFileSync(join(changed, "entry.ts"), "author changed\n");
363
+ expect(() => apply(changed, [region], "0.2.0")).toThrow(/missing a toolfactory region marker/);
364
+ });
282
365
  });
283
366
 
284
367
  describe("apply / check — inverses and outputs", () => {
368
+ it("restores deleted keyed rows, leaves steady YAML bytes alone, and uninstalls keys and rows", () => {
369
+ const root = tmp();
370
+ const plan: MergeFile[] = [
371
+ {
372
+ kind: "merge",
373
+ path: "extension.yaml",
374
+ format: "yaml",
375
+ patch: {
376
+ generated: true,
377
+ commands: [{ command: "hello.run", title: "Run" }],
378
+ },
379
+ keyedArrays: { commands: "command" },
380
+ },
381
+ ];
382
+ apply(root, plan, "0.1.0");
383
+ writeFileSync(
384
+ join(root, "extension.yaml"),
385
+ "# author comment\nauthor: true\ngenerated: true\ncommands:\n - command: author.run\n title: Author\n",
386
+ );
387
+
388
+ // The prior lock owns hello.run, so removing it by hand does not turn it into an omission.
389
+ apply(root, plan, "0.1.0");
390
+ expect(readFileSync(join(root, "extension.yaml"), "utf8")).toContain("hello.run");
391
+
392
+ // Once current, no rewrite means YAML comments and formatting survive a rebuild.
393
+ const path = join(root, "extension.yaml");
394
+ writeFileSync(path, `# preserved comment\n${readFileSync(path, "utf8")}`);
395
+ const before = readFileSync(path, "utf8");
396
+ expect(apply(root, plan, "0.1.0").unchanged).toContain("extension.yaml");
397
+ expect(readFileSync(path, "utf8")).toBe(before);
398
+
399
+ apply(root, [], "0.1.0");
400
+ expect(readFileSync(path, "utf8")).toContain("author.run");
401
+ expect(readFileSync(path, "utf8")).not.toContain("hello.run");
402
+ expect(readFileSync(path, "utf8")).not.toContain("generated:");
403
+ });
404
+
285
405
  it("uninstalls the keys a patch stops writing, keeping every other key", () => {
286
406
  const root = tmp();
287
407
  mkdirSync(root, { recursive: true });
@@ -17,6 +17,7 @@ import {
17
17
  } from "node:fs";
18
18
  import { dirname, join } from "node:path";
19
19
  import { parse as parseToml, stringify as stringifyToml } from "smol-toml";
20
+ import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
20
21
  import type { MergeFile, PlannedFile, Region } from "../model.js";
21
22
  import { LOCK_PATH, type Lock, type Markers, readLock, serializeLock, sha256 } from "./lock.js";
22
23
 
@@ -114,17 +115,211 @@ export function pickPatch(
114
115
  return out;
115
116
  }
116
117
 
118
+ function valueAt(document: Record<string, unknown>, path: string): unknown {
119
+ return path
120
+ .split(".")
121
+ .reduce<unknown>((value, part) => (isRecord(value) ? value[part] : undefined), document);
122
+ }
123
+
124
+ function setAt(document: Record<string, unknown>, path: string, value: unknown): void {
125
+ const parts = path.split(".");
126
+ let target = document;
127
+ for (const part of parts.slice(0, -1)) {
128
+ const next = target[part];
129
+ if (isRecord(next)) target = next;
130
+ else {
131
+ const created: Record<string, unknown> = {};
132
+ target[part] = created;
133
+ target = created;
134
+ }
135
+ }
136
+ target[parts.at(-1) as string] = value;
137
+ }
138
+
139
+ function omitKeyedArrays(
140
+ patch: Record<string, unknown>,
141
+ keyedArrays: Record<string, string>,
142
+ prefix = "",
143
+ ): Record<string, unknown> {
144
+ const out: Record<string, unknown> = {};
145
+ for (const [key, value] of Object.entries(patch)) {
146
+ const path = prefix ? `${prefix}.${key}` : key;
147
+ if (path in keyedArrays) continue;
148
+ out[key] = isRecord(value) ? omitKeyedArrays(value, keyedArrays, path) : value;
149
+ }
150
+ return out;
151
+ }
152
+
153
+ function keyedArrayEntries(value: unknown, id: string, path: string): Record<string, unknown>[] {
154
+ if (
155
+ !Array.isArray(value) ||
156
+ value.some((entry) => !isRecord(entry) || typeof entry[id] !== "string")
157
+ ) {
158
+ throw new Error(`${path} must be an array of objects with a string ${id} field.`);
159
+ }
160
+ return value as Record<string, unknown>[];
161
+ }
162
+
163
+ function keyedArrayState(
164
+ file: MergeFile,
165
+ ):
166
+ | Record<
167
+ string,
168
+ { id: string; values: string[]; entries: Record<string, Record<string, unknown>> }
169
+ >
170
+ | undefined {
171
+ if (!file.keyedArrays || Object.keys(file.keyedArrays).length === 0) return undefined;
172
+ return Object.fromEntries(
173
+ Object.entries(file.keyedArrays).map(([path, id]) => [
174
+ path,
175
+ {
176
+ id,
177
+ values: keyedArrayEntries(valueAt(file.patch, path), id, path).map(
178
+ (entry) => entry[id] as string,
179
+ ),
180
+ entries: Object.fromEntries(
181
+ keyedArrayEntries(valueAt(file.patch, path), id, path).map((entry) => [
182
+ entry[id] as string,
183
+ entry,
184
+ ]),
185
+ ),
186
+ },
187
+ ]),
188
+ );
189
+ }
190
+
191
+ function removeProjectedLeaves(
192
+ entry: Record<string, unknown>,
193
+ previous: Record<string, unknown>,
194
+ next: Record<string, unknown>,
195
+ ): Record<string, unknown> {
196
+ const out = { ...entry };
197
+ for (const [key, oldValue] of Object.entries(previous)) {
198
+ if (!(key in next)) delete out[key];
199
+ else if (isRecord(oldValue) && isRecord(next[key]) && isRecord(out[key]))
200
+ out[key] = removeProjectedLeaves(
201
+ out[key] as Record<string, unknown>,
202
+ oldValue,
203
+ next[key] as Record<string, unknown>,
204
+ );
205
+ }
206
+ return out;
207
+ }
208
+
209
+ function mergeKeyedArrays(
210
+ document: Record<string, unknown>,
211
+ file: MergeFile,
212
+ previous: Lock,
213
+ ): boolean {
214
+ let changed = false;
215
+ for (const [path, id] of Object.entries(file.keyedArrays ?? {})) {
216
+ const desired = keyedArrayEntries(valueAt(file.patch, path), id, path);
217
+ const previousIds = new Set(previous.files[file.path]?.keyedArrays?.[path]?.values ?? []);
218
+ const current = valueAt(document, path);
219
+ const entries = current === undefined ? [] : keyedArrayEntries(current, id, path);
220
+ const desiredById = new Map(desired.map((entry) => [entry[id] as string, entry]));
221
+ const retained: Record<string, unknown>[] = [];
222
+ const generated: Record<string, unknown>[] = [];
223
+ for (const entry of entries) {
224
+ const key = entry[id] as string;
225
+ const replacement = desiredById.get(key);
226
+ if (previousIds.has(key)) {
227
+ if (replacement) {
228
+ const previousEntry = previous.files[file.path]?.keyedArrays?.[path]?.entries?.[key];
229
+ generated.push(
230
+ deepMerge(
231
+ previousEntry ? removeProjectedLeaves(entry, previousEntry, replacement) : entry,
232
+ replacement,
233
+ ),
234
+ );
235
+ }
236
+ continue;
237
+ }
238
+ // Migration from a formerly whole generated file: an identical entry is ours already.
239
+ if (replacement && JSON.stringify(entry) === JSON.stringify(replacement)) {
240
+ generated.push(entry);
241
+ continue;
242
+ }
243
+ if (replacement)
244
+ throw new Error(`${file.path} ${path} already has author entry ${id}=${key}.`);
245
+ retained.push(entry);
246
+ }
247
+ const next = [
248
+ ...retained,
249
+ ...generated,
250
+ // A generated row may have been deleted by hand. It remains owned, so put its desired
251
+ // replacement back even when the previous lock knows its id already.
252
+ ...desired.filter((entry) => !generated.some((current) => current[id] === entry[id])),
253
+ ];
254
+ if (JSON.stringify(entries) !== JSON.stringify(next)) changed = true;
255
+ setAt(document, path, next);
256
+ }
257
+ return changed;
258
+ }
259
+
260
+ /** Select only generated leaves from an entry, leaving authored extension fields out of drift. */
261
+ function pickProjectedEntry(
262
+ current: Record<string, unknown> | undefined,
263
+ projected: Record<string, unknown>,
264
+ ): Record<string, unknown> | undefined {
265
+ if (!current) return undefined;
266
+ const picked: Record<string, unknown> = {};
267
+ for (const [key, value] of Object.entries(projected)) {
268
+ const actual = current[key];
269
+ picked[key] = isRecord(value) && isRecord(actual) ? pickProjectedEntry(actual, value) : actual;
270
+ }
271
+ return picked;
272
+ }
273
+
274
+ function removeKeyedArrayEntries(
275
+ document: Record<string, unknown>,
276
+ keyedArrays: Record<string, { id: string; values: string[] }> | undefined,
277
+ ): boolean {
278
+ let changed = false;
279
+ for (const [path, state] of Object.entries(keyedArrays ?? {})) {
280
+ const current = valueAt(document, path);
281
+ if (!Array.isArray(current)) continue;
282
+ const next = current.filter(
283
+ (entry) => !isRecord(entry) || !state.values.includes(entry[state.id] as string),
284
+ );
285
+ if (next.length !== current.length) {
286
+ setAt(document, path, next);
287
+ changed = true;
288
+ }
289
+ }
290
+ return changed;
291
+ }
292
+
293
+ function pickMerge(document: Record<string, unknown>, file: MergeFile): Record<string, unknown> {
294
+ const picked = pickPatch(document, file.patch, file.owned);
295
+ for (const [path, id] of Object.entries(file.keyedArrays ?? {})) {
296
+ const desired = keyedArrayEntries(valueAt(file.patch, path), id, path);
297
+ const current = Array.isArray(valueAt(document, path))
298
+ ? (valueAt(document, path) as unknown[])
299
+ : [];
300
+ const byId = new Map(current.filter(isRecord).map((entry) => [entry[id] as string, entry]));
301
+ setAt(
302
+ picked,
303
+ path,
304
+ desired.map((entry) => pickProjectedEntry(byId.get(entry[id] as string), entry)),
305
+ );
306
+ }
307
+ return picked;
308
+ }
309
+
117
310
  /** The dotted paths a patch writes: one per leaf value, and one per object it owns whole. */
118
311
  export function patchKeys(
119
312
  patch: Record<string, unknown>,
120
313
  owned: string[] = [],
314
+ keyedArrays: Record<string, string> = {},
121
315
  prefix = "",
122
316
  ): string[] {
123
317
  const keys: string[] = [];
124
318
  for (const [key, value] of Object.entries(patch)) {
125
319
  const path = prefix ? `${prefix}.${key}` : key;
320
+ if (path in keyedArrays) continue;
126
321
  if (isRecord(value) && !owned.includes(path) && Object.keys(value).length > 0) {
127
- keys.push(...patchKeys(value, owned, path));
322
+ keys.push(...patchKeys(value, owned, keyedArrays, path));
128
323
  } else {
129
324
  keys.push(path);
130
325
  }
@@ -167,13 +362,15 @@ export function removeKeys(document: Record<string, unknown>, keys: readonly str
167
362
  }
168
363
 
169
364
  function parseDocument(text: string, format: MergeFile["format"]): Record<string, unknown> {
170
- return (format === "toml" ? parseToml(text) : JSON.parse(text)) as Record<string, unknown>;
365
+ return (
366
+ format === "toml" ? parseToml(text) : format === "yaml" ? parseYaml(text) : JSON.parse(text)
367
+ ) as Record<string, unknown>;
171
368
  }
172
369
 
173
370
  function serializeDocument(document: Record<string, unknown>, format: MergeFile["format"]): string {
174
- return format === "toml"
175
- ? `${stringifyToml(document)}\n`
176
- : `${JSON.stringify(document, null, 2)}\n`;
371
+ if (format === "toml") return `${stringifyToml(document)}\n`;
372
+ if (format === "yaml") return stringifyYaml(document);
373
+ return `${JSON.stringify(document, null, 2)}\n`;
177
374
  }
178
375
 
179
376
  /** The content toolfactory manages, as one string for hashing and comparison. */
@@ -199,12 +396,16 @@ function currentManagedContent(root: string, file: PlannedFile): string | undefi
199
396
  const text = readFileSync(path, "utf8");
200
397
  if (file.kind === "file") return text;
201
398
  if (file.kind === "region") return extractRegions(text, file)?.join(" ");
202
- return JSON.stringify(pickPatch(parseDocument(text, file.format), file.patch, file.owned));
399
+ return JSON.stringify(pickMerge(parseDocument(text, file.format), file));
203
400
  }
204
401
 
205
402
  /** The two structured formats merge files use; the extension is the only discriminator needed. */
206
403
  function documentFormat(path: string): MergeFile["format"] {
207
- return path.endsWith(".toml") ? "toml" : "json";
404
+ return path.endsWith(".toml")
405
+ ? "toml"
406
+ : path.endsWith(".yaml") || path.endsWith(".yml")
407
+ ? "yaml"
408
+ : "json";
208
409
  }
209
410
 
210
411
  /** The keys the lock recorded for a path that the current patch no longer writes. */
@@ -219,6 +420,22 @@ function stranded(root: string, path: string, keys: string[]): boolean {
219
420
  return keys.some((key) => hasKey(document, key));
220
421
  }
221
422
 
423
+ function keyedEntriesRemain(
424
+ root: string,
425
+ path: string,
426
+ keyedArrays: Record<string, { id: string; values: string[] }> | undefined,
427
+ ): boolean {
428
+ if (!keyedArrays || !existsSync(join(root, path))) return false;
429
+ const document = parseDocument(readFileSync(join(root, path), "utf8"), documentFormat(path));
430
+ return Object.entries(keyedArrays).some(([arrayPath, state]) => {
431
+ const current = valueAt(document, arrayPath);
432
+ return (
433
+ Array.isArray(current) &&
434
+ current.some((entry) => isRecord(entry) && state.values.includes(entry[state.id] as string))
435
+ );
436
+ });
437
+ }
438
+
222
439
  /** The marker pairs the lock recorded for a path that the current plan no longer writes. */
223
440
  function staleRegions(lock: Lock, path: string, current: readonly Markers[] = []): Markers[] {
224
441
  return (lock.files[path]?.regions ?? []).filter(
@@ -271,7 +488,12 @@ export function check(root: string, plan: PlannedFile[], toolfactoryVersion: str
271
488
  const entry = lock.files[path];
272
489
  if (planned.has(path) || entry?.state !== "generated" || !present(root, path)) continue;
273
490
  // A merge file toolfactory stops writing loses its keys, not its existence.
274
- if (entry.keys && !stranded(root, path, entry.keys)) continue;
491
+ if (
492
+ (entry.keys || entry.keyedArrays) &&
493
+ !stranded(root, path, entry.keys ?? []) &&
494
+ !keyedEntriesRemain(root, path, entry.keyedArrays)
495
+ )
496
+ continue;
275
497
  // A region file likewise loses its regions: once they are empty, nothing of ours is stranded.
276
498
  if (entry.regions && !filled(root, path, entry.regions)) continue;
277
499
  drift.push({ path, kind: "orphan" });
@@ -290,6 +512,12 @@ function render(root: string, file: PlannedFile, previous: Lock): string {
290
512
  const stale = staleRegions(previous, file.path, file.regions);
291
513
  const replaced = replaceRegions(emptyRegions(text, stale) ?? text, file);
292
514
  if (replaced === undefined) {
515
+ const prior = previous.files[file.path];
516
+ // A generated full file can become a region file without forcing an author to recreate
517
+ // markers. Its lock hash proves every byte is still the old generator's, so replacing it
518
+ // cannot discard authored work. Manual or changed files still require `adopt`.
519
+ if (prior?.state === "generated" && sha256(text) === prior.sha256)
520
+ return replaceRegions(file.template, file) ?? file.template;
293
521
  throw new Error(
294
522
  `${file.path} exists but is missing a toolfactory region marker; restore the markers or run \`toolfactory adopt ${file.path}\`.`,
295
523
  );
@@ -298,23 +526,32 @@ function render(root: string, file: PlannedFile, previous: Lock): string {
298
526
  }
299
527
  return replaceRegions(file.template, file) ?? file.template;
300
528
  }
301
- if (!existsSync(path)) return serializeDocument(deepMerge({}, file.patch), file.format);
529
+ if (!existsSync(path)) {
530
+ const document = deepMerge({}, omitKeyedArrays(file.patch, file.keyedArrays ?? {}), file.owned);
531
+ mergeKeyedArrays(document, file, previous);
532
+ return serializeDocument(document, file.format);
533
+ }
302
534
  const text = readFileSync(path, "utf8");
303
535
  const document = parseDocument(text, file.format);
304
536
  // The patch's inverse first: keys a previous patch wrote and this one dropped are uninstalled.
305
537
  const uninstalled = removeKeys(
306
538
  document,
307
- staleKeys(previous, file.path, patchKeys(file.patch, file.owned)),
539
+ staleKeys(previous, file.path, patchKeys(file.patch, file.owned, file.keyedArrays)),
308
540
  );
541
+ const arraysChanged = mergeKeyedArrays(document, file, previous);
309
542
  // A document that already carries the patch is left byte-for-byte alone, so a rebuild never
310
543
  // reserializes the author's file (and, for TOML, never drops their comments).
311
544
  if (
312
545
  !uninstalled &&
313
- JSON.stringify(pickPatch(document, file.patch, file.owned)) === JSON.stringify(file.patch)
546
+ !arraysChanged &&
547
+ JSON.stringify(pickMerge(document, file)) === JSON.stringify(file.patch)
314
548
  ) {
315
549
  return text;
316
550
  }
317
- return serializeDocument(deepMerge(document, file.patch, file.owned), file.format);
551
+ return serializeDocument(
552
+ deepMerge(document, omitKeyedArrays(file.patch, file.keyedArrays ?? {}), file.owned),
553
+ file.format,
554
+ );
318
555
  }
319
556
 
320
557
  /** Write a plan to the tree and refresh the lock. */
@@ -336,7 +573,9 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
336
573
  result.manual.push(file.path);
337
574
  continue;
338
575
  }
339
- const keys = file.kind === "merge" ? patchKeys(file.patch, file.owned) : undefined;
576
+ const keys =
577
+ file.kind === "merge" ? patchKeys(file.patch, file.owned, file.keyedArrays) : undefined;
578
+ const keyedArrays = file.kind === "merge" ? keyedArrayState(file) : undefined;
340
579
  const regions =
341
580
  file.kind === "region" ? file.regions.map(({ begin, end }) => ({ begin, end })) : undefined;
342
581
  if (isLink(file)) {
@@ -363,6 +602,7 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
363
602
  sha256: sha256(managedContent(file)),
364
603
  state: "generated",
365
604
  keys,
605
+ keyedArrays,
366
606
  regions,
367
607
  };
368
608
  }
@@ -373,11 +613,13 @@ export function apply(root: string, plan: PlannedFile[], toolfactoryVersion: str
373
613
  continue;
374
614
  }
375
615
  if (!present(root, path)) continue;
376
- if (entry.keys) {
616
+ if (entry.keys || entry.keyedArrays) {
377
617
  // The inverse of a merge file is its keys: the author keeps the file and everything else in it.
378
618
  const format = documentFormat(path);
379
619
  const document = parseDocument(readFileSync(join(root, path), "utf8"), format);
380
- if (removeKeys(document, entry.keys)) {
620
+ const removedKeys = removeKeys(document, entry.keys ?? []);
621
+ const removedEntries = removeKeyedArrayEntries(document, entry.keyedArrays);
622
+ if (removedKeys || removedEntries) {
381
623
  writeFileSync(join(root, path), serializeDocument(document, format));
382
624
  result.written.push(path);
383
625
  }