carveout 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 (78) hide show
  1. package/AGENTS.md +21 -3
  2. package/NOTICE.md +2 -1
  3. package/README.md +248 -473
  4. package/dist/src/account.d.ts +37 -0
  5. package/dist/src/account.js +275 -0
  6. package/dist/src/api.d.ts +162 -0
  7. package/dist/src/api.js +134 -0
  8. package/dist/src/carve.d.ts +788 -0
  9. package/dist/src/carve.js +1721 -125
  10. package/dist/src/cli.d.ts +10 -0
  11. package/dist/src/cli.js +674 -47
  12. package/dist/src/command.d.ts +10 -0
  13. package/dist/src/core.d.ts +11 -0
  14. package/dist/src/cut.d.ts +78 -0
  15. package/dist/src/cut.js +25 -7
  16. package/dist/src/extract/compiler-config.d.ts +49 -0
  17. package/dist/src/extract/fixture.d.ts +103 -0
  18. package/dist/src/extract/hash.d.ts +6 -0
  19. package/dist/src/extract/package-registry.d.ts +47 -0
  20. package/dist/src/extract/package-types.d.ts +52 -0
  21. package/dist/src/extract/surface.d.ts +44 -0
  22. package/dist/src/extract/typescript.d.ts +180 -0
  23. package/dist/src/gate.d.ts +55 -0
  24. package/dist/src/gate.js +1 -0
  25. package/dist/src/graph.d.ts +142 -0
  26. package/dist/src/graph.js +4 -3
  27. package/dist/src/impact.d.ts +113 -0
  28. package/dist/src/imports.d.ts +67 -0
  29. package/dist/src/mergeback.d.ts +182 -0
  30. package/dist/src/mergeback.js +580 -35
  31. package/dist/src/platform.d.ts +69 -0
  32. package/dist/src/platform.js +184 -0
  33. package/dist/src/policy.d.ts +181 -0
  34. package/dist/src/policy.js +716 -230
  35. package/dist/src/project/build.d.ts +23 -0
  36. package/dist/src/project/catalog.d.ts +25 -0
  37. package/dist/src/project/project.d.ts +287 -0
  38. package/dist/src/project/project.js +479 -52
  39. package/dist/src/project/provenance.d.ts +73 -0
  40. package/dist/src/project/provenance.js +12 -0
  41. package/dist/src/project/runner.d.ts +19 -0
  42. package/dist/src/project/secrets.d.ts +64 -0
  43. package/dist/src/project/vitest-config.d.ts +70 -0
  44. package/dist/src/project/vitest-config.js +291 -0
  45. package/dist/src/pseudonyms/aliases.d.ts +107 -0
  46. package/dist/src/pseudonyms/aliases.js +394 -0
  47. package/dist/src/pseudonyms/draft.d.ts +64 -0
  48. package/dist/src/pseudonyms/draft.js +284 -0
  49. package/dist/src/pseudonyms/folder.d.ts +172 -0
  50. package/dist/src/pseudonyms/folder.js +527 -0
  51. package/dist/src/pseudonyms/index.d.ts +11 -0
  52. package/dist/src/pseudonyms/index.js +11 -0
  53. package/dist/src/pseudonyms/vocabulary.d.ts +113 -0
  54. package/dist/src/pseudonyms/vocabulary.js +522 -0
  55. package/dist/src/readiness.d.ts +71 -0
  56. package/dist/src/readiness.js +131 -0
  57. package/dist/src/receipt.d.ts +154 -0
  58. package/dist/src/receipt.js +296 -0
  59. package/dist/src/sandbox.d.ts +53 -0
  60. package/dist/src/sandbox.js +154 -0
  61. package/dist/src/slice/analysis.d.ts +75 -0
  62. package/dist/src/slice/externals.d.ts +51 -0
  63. package/dist/src/slice/graph.d.ts +69 -0
  64. package/dist/src/slice/manifest.d.ts +262 -0
  65. package/dist/src/slice/manifest.js +4 -3
  66. package/dist/src/slice/reach.d.ts +204 -0
  67. package/dist/src/slice/reach.js +11 -5
  68. package/dist/src/slice/report.d.ts +49 -0
  69. package/dist/src/slice/report.js +5 -2
  70. package/dist/src/specifiers.d.ts +48 -0
  71. package/dist/src/status.d.ts +46 -0
  72. package/dist/src/status.js +3 -2
  73. package/dist/src/test-integrity.d.ts +54 -0
  74. package/dist/src/test-integrity.js +812 -0
  75. package/package.json +24 -5
  76. package/skills/carveout/SKILL.md +518 -0
  77. package/skills/carveout-gate/SKILL.md +98 -0
  78. package/SKILL.md +0 -423
@@ -16,19 +16,37 @@
16
16
  */
17
17
  import { execFileSync, spawnSync } from "node:child_process";
18
18
  import { createHash } from "node:crypto";
19
- import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
19
+ import { copyFileSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readdirSync, readFileSync, readlinkSync, symlinkSync, writeFileSync } from "node:fs";
20
20
  import { copyFile, mkdir, rm, writeFile } from "node:fs/promises";
21
21
  import { tmpdir } from "node:os";
22
22
  import { dirname, join, resolve } from "node:path";
23
- import { RECORD_PATH, checkRecordFiles, computeCut, patchProject, readRecord, } from "./carve.js";
23
+ import { checkRecordFiles, computeCut, cutSlice, patchProject, sliceSpecifierPackages, } from "./carve.js";
24
24
  import { isTestPath, runtimePath, } from "./core.js";
25
25
  import { symbolDiff } from "./status.js";
26
26
  import { trustedRepo } from "./policy.js";
27
+ import { sandboxRun } from "./sandbox.js";
28
+ import { folderView, saveRecord, trustedCut } from "./receipt.js";
29
+ import { exactToFolder, fromFolder, looseLeaks, toFolder, wordsToFolder } from "./pseudonyms/index.js";
30
+ import { workspacePackages } from "./extract/compiler-config.js";
27
31
  const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
28
32
  const sha256 = (data) => createHash("sha256").update(data).digest("hex");
29
33
  function git(cwd, args) {
30
34
  return execFileSync("git", ["-c", "user.name=carveout", "-c", "user.email=carveout@localhost", ...args], { cwd, stdio: ["ignore", "pipe", "pipe"], maxBuffer: 256 * 1024 * 1024 }).toString("utf8");
31
35
  }
36
+ /** `theirs` merged into `ours` from `base`, as `git merge-file` does; conflicts when they overlap. */
37
+ function mergeText(scratch, ours, base, theirs) {
38
+ const at = mkdtempSync(join(scratch, "merge-"));
39
+ writeFileSync(join(at, "ours"), ours);
40
+ writeFileSync(join(at, "base"), base);
41
+ writeFileSync(join(at, "theirs"), theirs);
42
+ const result = spawnSync("git", ["merge-file", "-p", join(at, "ours"), join(at, "base"), join(at, "theirs")], {
43
+ encoding: "utf8",
44
+ maxBuffer: 256 * 1024 * 1024,
45
+ });
46
+ if (result.status === null || result.status < 0)
47
+ throw new Error(`git merge-file failed: ${result.stderr}`);
48
+ return { text: result.stdout, conflicts: result.status > 0 };
49
+ }
32
50
  /**
33
51
  * Brings `patchText`, a patch for the repository at the cut's commit, into
34
52
  * the folder. `stubs` also writes the stubs and mocks the change needs, and
@@ -46,8 +64,19 @@ function packageDirOf(repo, path) {
46
64
  }
47
65
  export async function applyPatch(dir, patchText, options = {}) {
48
66
  const root = resolve(dir);
49
- const record = readRecord(root);
67
+ let trusted = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
68
+ // A public folder in pseudonyms takes the change in its own names: each
69
+ // file at its folder path, its text renamed by the receipt's table.
70
+ const record = trusted.record;
50
71
  const source = trustedRepo(record, options.repo);
72
+ // The packages the repository knows, as patch reads them back.
73
+ const known = new Set([
74
+ ...workspacePackages(source).keys(),
75
+ ...record.dependencies.map((dependency) => dependency.name),
76
+ ...record.manifest.requiredBuildInputs.packages.map((item) => item.name),
77
+ ...sliceSpecifierPackages(record, source),
78
+ ]);
79
+ const names = namesOf(trusted.receipt?.pseudonyms, ownPackagesOf(root), (name) => known.has(name));
51
80
  checkRecordFiles(record, source);
52
81
  const commit = record.manifest.sourceCommitSha;
53
82
  if (commit === null)
@@ -57,14 +86,34 @@ export async function applyPatch(dir, patchText, options = {}) {
57
86
  try {
58
87
  git(scratch, ["clone", "-q", "--shared", "--no-checkout", source, repo]);
59
88
  git(repo, ["checkout", "-q", "--detach", commit]);
89
+ // The slice files the folder holds edits in, by their folder paths: the
90
+ // change is merged into those, never written over them.
91
+ const view = trusted.receipt === null ? record : folderView(trusted.receipt);
92
+ // A file the folder deleted is an edit too: the change neither brings
93
+ // it back nor writes over its absence.
94
+ const deletedInFolder = new Set((view.copied ?? []).filter((file) => !existsSync(join(root, file.path))).map((file) => file.path));
95
+ const editedInFolder = new Set((view.copied ?? [])
96
+ .filter((file) => {
97
+ const at = join(root, file.path);
98
+ return !existsSync(at) || createHash("sha256").update(readFileSync(at)).digest("hex") !== file.sha256;
99
+ })
100
+ .map((file) => file.path));
60
101
  const file = join(scratch, "change.patch");
61
102
  writeFileSync(file, patchText);
62
103
  try {
63
104
  git(repo, ["apply", "--whitespace=nowarn", file]);
64
105
  }
65
- catch (error) {
66
- const stderr = error.stderr?.toString("utf8") ?? "";
67
- throw new Error(`The patch does not apply at the cut's commit ${commit.slice(0, 12)}: ${stderr.trim()}`);
106
+ catch (plain) {
107
+ // A patch made against an earlier commit (a folder widened after the
108
+ // repository moved) merges by the blobs its index lines name.
109
+ try {
110
+ git(repo, ["apply", "--3way", "--whitespace=nowarn", file]);
111
+ git(repo, ["reset", "-q"]);
112
+ }
113
+ catch {
114
+ const stderr = plain.stderr?.toString("utf8") ?? "";
115
+ throw new Error(`The patch does not apply at the cut's commit ${commit.slice(0, 12)}: ${stderr.trim()}`);
116
+ }
68
117
  }
69
118
  git(repo, ["add", "-A"]);
70
119
  const touched = new Map();
@@ -90,11 +139,24 @@ export async function applyPatch(dir, patchText, options = {}) {
90
139
  followWorkspaces: record.request.followWorkspaces === true,
91
140
  copyPure: record.request.copyPure === true,
92
141
  private: record.request.private ?? [],
93
- copy: record.request.copy ?? [],
142
+ // A copied folder the change deleted whole is copied no more: its
143
+ // files go as any deleted file does.
144
+ copy: (record.request.copy ?? []).filter((dir) => existsSync(join(repo, dir))),
94
145
  public: record.request.public === true,
146
+ // The owner's acceptance tests stay unread in every later cut.
147
+ ...(trusted.receipt?.unseen === undefined ? {} : { unseen: trusted.receipt.unseen }),
95
148
  ...(options.log === undefined ? {} : { log: options.log }),
96
149
  };
97
150
  const cuts = [await computeCut(request)];
151
+ // The text the cut wrote for each slice file, for the three-way merge:
152
+ // a cut of the repository at the cut's commit, before the change.
153
+ let baseCut = null;
154
+ if (editedInFolder.size > 0) {
155
+ const baseRepo = join(scratch, "base");
156
+ git(scratch, ["clone", "-q", "--shared", "--no-checkout", source, baseRepo]);
157
+ git(baseRepo, ["checkout", "-q", "--detach", commit]);
158
+ baseCut = await computeCut({ ...request, repo: baseRepo });
159
+ }
98
160
  const reached = (cut) => new Set(cut.record.manifest.included.map((file) => file.path));
99
161
  // What the patch adds that the entries do not reach is still the
100
162
  // change: a module added past the depth, and the tests of it or of the
@@ -122,17 +184,43 @@ export async function applyPatch(dir, patchText, options = {}) {
122
184
  maxFiles: record.request.budget.maxFiles + beyond.length,
123
185
  },
124
186
  }));
187
+ // A credential the change brings never reaches the folder, as a cut of
188
+ // the same commit would write nothing: refused before anything is.
189
+ const credentials = cuts.flatMap((cut) => cut.record.blockers.filter((blocker) => blocker.code === "secret_found" || blocker.code === "secret_file"));
190
+ if (credentials.length > 0)
191
+ throw new Error(`Nothing applied: the change would bring a credential into the folder.\n${[
192
+ ...new Set(credentials.map((blocker) => ` ${blocker.code}${blocker.file === null ? "" : ` ${blocker.file}`}: ${blocker.detail}`)),
193
+ ].join("\n")}`);
125
194
  const included = new Set(record.manifest.included.map((file) => file.path));
126
195
  const changed = [];
127
196
  const added = [];
128
197
  const deleted = [];
129
198
  const outside = [];
199
+ const merged = [];
200
+ const conflicts = [];
201
+ const unmapped = [];
130
202
  const grown = [];
131
203
  const rewrites = [];
204
+ /** Folder paths the command wrote, for the leak scan. */
205
+ const wrote = [];
206
+ /** Each slice file written, by repository path, as the cut writes it (the folder's edits aside). */
207
+ const newly = new Map();
208
+ /** The import rewrites of the cut that wrote each file. */
209
+ const rewrittenBy = new Map();
132
210
  for (const [path, status] of [...touched.entries()].sort(([a], [b]) => compare(a, b))) {
133
211
  if (status === "D") {
134
212
  if (included.has(path)) {
135
- await rm(join(root, path), { force: true });
213
+ // Deleted on both sides: nothing to do.
214
+ if (deletedInFolder.has(names.path(path))) {
215
+ deleted.push(path);
216
+ continue;
217
+ }
218
+ // A file the folder edited is never deleted under it.
219
+ if (editedInFolder.has(names.path(path))) {
220
+ conflicts.push(path);
221
+ continue;
222
+ }
223
+ await rm(join(root, names.path(path)), { force: true });
136
224
  deleted.push(path);
137
225
  }
138
226
  else
@@ -151,38 +239,170 @@ export async function applyPatch(dir, patchText, options = {}) {
151
239
  outside.push(path);
152
240
  continue;
153
241
  }
154
- await mkdir(dirname(join(root, path)), { recursive: true });
155
- if (text === null)
156
- await copyFile(join(repo, path), join(root, path));
157
- else
158
- await writeFile(join(root, path), text);
242
+ await mkdir(dirname(join(root, names.path(path))), { recursive: true });
243
+ const folderPath = names.path(path);
244
+ const base = baseCut?.files.find((item) => item.path === path)?.text;
245
+ if (deletedInFolder.has(folderPath)) {
246
+ // The folder deleted what the change edits: left deleted, named.
247
+ conflicts.push(path);
248
+ continue;
249
+ }
250
+ if (editedInFolder.has(folderPath) && (text === null || base === undefined)) {
251
+ // The folder edited a file the change writes and there is no text
252
+ // at the cut's commit to merge from (one an earlier apply added, a
253
+ // binary): left as the folder has it, for a person.
254
+ conflicts.push(path);
255
+ continue;
256
+ }
257
+ if (text !== null && editedInFolder.has(folderPath) && base !== undefined) {
258
+ // The folder's edit, read back in the repository's names, with the
259
+ // change merged in; left alone where the two overlap.
260
+ const ours = names.back(folderPath, readFileSync(join(root, folderPath), "utf8"));
261
+ const result = mergeText(scratch, ours, base, text);
262
+ if (result.conflicts) {
263
+ conflicts.push(path);
264
+ continue;
265
+ }
266
+ const folderText = names.exact(path, result.text);
267
+ if (folderText === null) {
268
+ unmapped.push(path);
269
+ continue;
270
+ }
271
+ await writeFile(join(root, folderPath), folderText);
272
+ merged.push(path);
273
+ }
274
+ else if (text === null)
275
+ await copyFile(join(repo, path), join(root, folderPath));
276
+ else {
277
+ const folderText = names.exact(path, text);
278
+ if (folderText === null) {
279
+ unmapped.push(path);
280
+ continue;
281
+ }
282
+ await writeFile(join(root, folderPath), folderText);
283
+ }
284
+ wrote.push(folderPath);
285
+ const clean = text === null
286
+ ? (() => {
287
+ const bytes = readFileSync(join(repo, path));
288
+ return { folderPath, sha256: sha256(bytes), folderSha256: sha256(bytes) };
289
+ })()
290
+ : { folderPath, sha256: sha256(text), folderSha256: sha256(names.exact(path, text) ?? "") };
291
+ newly.set(path, clean);
292
+ // The imports the cut that wrote it rewrote, which a patch undoes.
293
+ rewrittenBy.set(path, cut.record.importRewrites.filter((rewrite) => rewrite.file === path));
294
+ options.written?.set(path, clean);
159
295
  if (!included.has(path) && status === "M") {
160
296
  // A repository file the slice did not hold: it joins the slice at
161
297
  // the bytes of the cut's commit, so `patch` diffs it from there.
162
298
  grown.push(includedAt(repo, path));
163
299
  rewrites.push(...cut.record.importRewrites.filter((rewrite) => rewrite.file === path));
164
300
  }
301
+ else if (!included.has(path) && status === "A")
302
+ // A file the change adds keeps the imports the cut rewrote in it on
303
+ // record, so a patch reads them back as the repository writes them.
304
+ rewrites.push(...cut.record.importRewrites.filter((rewrite) => rewrite.file === path));
165
305
  (included.has(path) || status === "M" ? changed : added).push(path);
166
306
  }
167
- const stubs = await neededStubs(root, record, cuts, options.stubs === true);
168
- if (grown.length > 0) {
169
- const current = readRecord(root);
307
+ const stubs = await neededStubs(root, record, cuts, options.stubs === true, names, (updated, written) => {
308
+ wrote.push(...Object.keys(written));
309
+ trusted = saveRecord(root, trusted, updated, written);
310
+ });
311
+ // A file the change added or the slice grew is the folder's copy from
312
+ // now on, at what the command wrote: a later apply sees the folder's
313
+ // edits to it, and merges into them or leaves them, never over them.
314
+ const copiedNow = new Set((trusted.record.copied ?? []).map((file) => file.path));
315
+ const fresh = trusted.record.copied === undefined ? [] : [...newly.entries()].filter(([path]) => !copiedNow.has(path));
316
+ // A file written takes the rewrites of the cut that wrote it, in place of
317
+ // the ones its earlier text had (an import the change added, rewritten).
318
+ const rewritesNow = [
319
+ ...trusted.record.importRewrites.filter((rewrite) => !rewrittenBy.has(rewrite.file)),
320
+ ...[...rewrittenBy.values()].flat(),
321
+ ].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`));
322
+ const rewritesMoved = JSON.stringify(rewritesNow) !==
323
+ JSON.stringify([...trusted.record.importRewrites].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`)));
324
+ if (grown.length > 0 || fresh.length > 0 || rewritesMoved) {
325
+ const current = trusted.record;
170
326
  const updated = {
171
327
  ...current,
172
328
  manifest: {
173
329
  ...current.manifest,
174
330
  included: [...current.manifest.included, ...grown].sort((a, b) => compare(a.path, b.path)),
175
331
  },
176
- importRewrites: [...current.importRewrites, ...rewrites].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`)),
332
+ importRewrites: [
333
+ ...current.importRewrites.filter((rewrite) => !rewrittenBy.has(rewrite.file)),
334
+ ...[...rewrittenBy.values()].flat(),
335
+ ...rewrites.filter((rewrite) => !rewrittenBy.has(rewrite.file)),
336
+ ].sort((a, b) => compare(`${a.file}\n${a.from}`, `${b.file}\n${b.from}`)),
337
+ ...(current.copied === undefined
338
+ ? {}
339
+ : {
340
+ copied: [...current.copied, ...fresh.map(([path, { sha256: hash }]) => ({ path, sha256: hash, applied: true }))].sort((a, b) => compare(a.path, b.path)),
341
+ }),
177
342
  };
178
- await writeFile(join(root, RECORD_PATH), `${JSON.stringify(updated, null, 2)}\n`);
343
+ trusted = saveRecord(root, trusted, updated, {}, new Map(fresh.map(([, { folderPath, folderSha256 }]) => [folderPath, folderSha256])));
179
344
  }
180
- return { changed, added, deleted, outside, stubs };
345
+ // The folder's own record names paths too: scanned with what was written.
346
+ return {
347
+ changed,
348
+ added,
349
+ deleted,
350
+ outside,
351
+ stubs,
352
+ merged,
353
+ conflicts,
354
+ leaks: leaksIn(root, wrote.length === 0 ? [] : [...wrote, "carveout.json"], trusted.receipt?.pseudonyms),
355
+ unmapped,
356
+ };
181
357
  }
182
358
  finally {
183
359
  await rm(scratch, { recursive: true, force: true });
184
360
  }
185
361
  }
362
+ /** The folder's own mock packages, which go by the table's names. */
363
+ function ownPackagesOf(root) {
364
+ const own = new Set();
365
+ const mocks = join(root, "carveout", "packages");
366
+ if (existsSync(mocks))
367
+ for (const entry of readdirSync(mocks, { withFileTypes: true })) {
368
+ if (!entry.isDirectory())
369
+ continue;
370
+ if (entry.name.startsWith("@"))
371
+ for (const inner of readdirSync(join(mocks, entry.name), { withFileTypes: true }))
372
+ if (inner.isDirectory())
373
+ own.add(`${entry.name}/${inner.name}`);
374
+ else
375
+ own.add(entry.name);
376
+ }
377
+ return (name) => own.has(name);
378
+ }
379
+ /** The folder's names for a cut in pseudonyms (its table), or the repository's own. */
380
+ function namesOf(table, own = () => false, known = () => false) {
381
+ if (table === undefined)
382
+ return { path: (path) => path, text: (_path, text) => text, exact: (_path, text) => text, back: (_path, text) => text };
383
+ // The table's rules, then a placeholder for each term they did not reach:
384
+ // a file the repository added since the cut, or a line it wrote, spells
385
+ // a term where no rule of the cut's matches.
386
+ return {
387
+ path: (path) => exactToFolder(table, path, "", own, known)?.path ?? toFolder(table, path, "").path,
388
+ text: (path, text) => wordsToFolder(table, toFolder(table, path, text).text),
389
+ exact: (path, text) => exactToFolder(table, path, text, own, known)?.text ?? null,
390
+ back: (path, text) => fromFolder(table, path, text, own, known).text,
391
+ };
392
+ }
393
+ /** The names a folder in pseudonyms must not hold, in the files a command wrote there. */
394
+ function leaksIn(root, paths, table) {
395
+ if (table === undefined || paths.length === 0)
396
+ return [];
397
+ const files = [...new Set(paths)].sort(compare).flatMap((path) => {
398
+ const at = join(root, path);
399
+ if (!existsSync(at))
400
+ return [];
401
+ const bytes = readFileSync(at);
402
+ return [{ path, text: bytes.includes(0) ? bytes.toString("latin1") : bytes.toString("utf8") }];
403
+ });
404
+ return looseLeaks(files, table.vocabulary, table.kept).map(({ term, files: found }) => ({ term, files: found }));
405
+ }
186
406
  /** A file as the slice's record holds it, at the clone's HEAD (the cut's commit). */
187
407
  function includedAt(repo, path) {
188
408
  const bytes = execFileSync("git", ["show", `HEAD:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024 });
@@ -190,7 +410,7 @@ function includedAt(repo, path) {
190
410
  return { path, blobId, sha256: sha256(bytes), mode, sizeBytes: bytes.length, operations: ["edit"] };
191
411
  }
192
412
  /** Stubs the cuts write that the folder's record says it does not hold. */
193
- async function neededStubs(root, record, cuts, write) {
413
+ async function neededStubs(root, record, cuts, write, names, save) {
194
414
  const generated = new Map(record.generated.map((file) => [file.path, file.sha256]));
195
415
  const wanted = new Map();
196
416
  for (const cut of cuts)
@@ -207,7 +427,8 @@ async function neededStubs(root, record, cuts, write) {
207
427
  // The declaration names the difference; its runtime follows it.
208
428
  if (!/\.d\.[cm]?ts$/.test(path))
209
429
  continue;
210
- const old = existsSync(join(root, path)) ? readFileSync(join(root, path), "utf8") : null;
430
+ const held = join(root, names.path(path));
431
+ const old = existsSync(held) ? names.back(names.path(path), readFileSync(held, "utf8")) : null;
211
432
  found.push({
212
433
  path,
213
434
  detail: was === undefined || old === null
@@ -218,8 +439,8 @@ async function neededStubs(root, record, cuts, write) {
218
439
  }
219
440
  if (write && writes.length > 0) {
220
441
  for (const { path, text } of writes) {
221
- await mkdir(dirname(join(root, path)), { recursive: true });
222
- await writeFile(join(root, path), text);
442
+ await mkdir(dirname(join(root, names.path(path))), { recursive: true });
443
+ await writeFile(join(root, names.path(path)), names.text(path, text));
223
444
  }
224
445
  const hashes = new Map(generated);
225
446
  for (const { path, text } of writes)
@@ -230,10 +451,53 @@ async function neededStubs(root, record, cuts, write) {
230
451
  .map(([path, hash]) => ({ path, sha256: hash }))
231
452
  .sort((a, b) => compare(a.path, b.path)),
232
453
  };
233
- await writeFile(join(root, RECORD_PATH), `${JSON.stringify(updated, null, 2)}\n`);
454
+ save(updated, Object.fromEntries(writes.map(({ path, text }) => [names.path(path), names.text(path, text)])));
234
455
  }
235
456
  return found;
236
457
  }
458
+ /**
459
+ * The checkout's tracked and unignored files, copied to `to`: what a
460
+ * sandboxed verify mounts. A symbolic link is copied as a link, which in the
461
+ * container reaches nothing outside the copy.
462
+ */
463
+ function copyCheckout(from, to) {
464
+ const listed = execFileSync("git", ["ls-files", "-z", "--cached", "--others", "--exclude-standard"], {
465
+ cwd: from,
466
+ maxBuffer: 256 * 1024 * 1024,
467
+ }).toString("utf8");
468
+ for (const path of new Set(listed.split("\0"))) {
469
+ if (path === "")
470
+ continue;
471
+ const source = join(from, path);
472
+ let stat;
473
+ try {
474
+ stat = lstatSync(source);
475
+ }
476
+ catch {
477
+ continue; // Listed, deleted from the working tree.
478
+ }
479
+ const target = join(to, path);
480
+ mkdirSync(dirname(target), { recursive: true });
481
+ if (stat.isSymbolicLink())
482
+ symlinkSync(readlinkSync(source), target);
483
+ else if (stat.isFile())
484
+ copyFileSync(source, target);
485
+ }
486
+ }
487
+ /** The install a lockfile names, lifecycle scripts off; null with no package.json. */
488
+ export function installCommand(root) {
489
+ if (existsSync(join(root, "package-lock.json")) || existsSync(join(root, "npm-shrinkwrap.json")))
490
+ return "npm ci --ignore-scripts --no-audit --no-fund";
491
+ if (existsSync(join(root, "pnpm-lock.yaml")))
492
+ return "corepack pnpm install --frozen-lockfile --ignore-scripts";
493
+ if (existsSync(join(root, "yarn.lock")))
494
+ return existsSync(join(root, ".yarnrc.yml"))
495
+ ? "corepack yarn install --immutable --mode=skip-build"
496
+ : "corepack yarn install --frozen-lockfile --ignore-scripts";
497
+ if (existsSync(join(root, "package.json")))
498
+ return "npm install --ignore-scripts --no-audit --no-fund";
499
+ return null;
500
+ }
237
501
  /**
238
502
  * The folder's edits merged into `into`, a checkout of the repository the
239
503
  * caller chose: `git apply --3way` there, then `verify` as a shell command
@@ -246,6 +510,8 @@ export async function mergeProject(dir, options) {
246
510
  const patch = await patchProject(dir, {
247
511
  repo: options.repo ?? into,
248
512
  ...(options.allow === undefined ? {} : { allow: options.allow }),
513
+ ...(options.receipt === undefined ? {} : { receipt: options.receipt }),
514
+ ...(options.noReceipt === true ? { noReceipt: true } : {}),
249
515
  });
250
516
  const steps = [];
251
517
  // Nothing is applied while the policy keeps a path out: the maintainer
@@ -295,17 +561,46 @@ export async function mergeProject(dir, options) {
295
561
  if (value !== undefined)
296
562
  env[name] = value;
297
563
  }
298
- const verify = spawnSync("sh", ["-c", options.verify], {
299
- cwd: into,
300
- encoding: "utf8",
301
- maxBuffer: 256 * 1024 * 1024,
302
- env,
303
- });
304
- steps.push({
305
- step: "verify",
306
- ok: verify.status === 0,
307
- output: `${verify.stdout ?? ""}${verify.stderr ?? ""}`.slice(-20_000),
308
- });
564
+ if (options.sandbox !== undefined) {
565
+ // A copy of the checkout as the patch left it, so nothing of this
566
+ // machine but that copy is mounted: tracked and unignored files.
567
+ const work = join(scratch, "checkout");
568
+ copyCheckout(into, work);
569
+ const setupCommand = options.verifySetup ?? installCommand(work);
570
+ if (setupCommand !== null) {
571
+ const setup = await sandboxRun(options.sandbox, work, ["sh", "-c", setupCommand], { network: true, limitMs: 20 * 60_000 });
572
+ steps.push({
573
+ step: "setup",
574
+ ok: setup.status === 0,
575
+ output: `${setupCommand}\n${setup.stdout}${setup.stderr}`.slice(-20_000),
576
+ });
577
+ if (setup.status !== 0)
578
+ return { patch, steps, ok: false };
579
+ }
580
+ const passed = {};
581
+ for (const [key, value] of Object.entries(env))
582
+ if (key !== "HOME" && key !== "PATH")
583
+ passed[key] = value;
584
+ const verify = await sandboxRun(options.sandbox, work, ["sh", "-c", options.verify], { env: passed, network: false, limitMs: 30 * 60_000 });
585
+ steps.push({
586
+ step: "verify",
587
+ ok: verify.status === 0,
588
+ output: `${verify.stdout}${verify.stderr}`.slice(-20_000),
589
+ });
590
+ }
591
+ else {
592
+ const verify = spawnSync("sh", ["-c", options.verify], {
593
+ cwd: into,
594
+ encoding: "utf8",
595
+ maxBuffer: 256 * 1024 * 1024,
596
+ env,
597
+ });
598
+ steps.push({
599
+ step: "verify",
600
+ ok: verify.status === 0,
601
+ output: `${verify.stdout ?? ""}${verify.stderr ?? ""}`.slice(-20_000),
602
+ });
603
+ }
309
604
  }
310
605
  }
311
606
  finally {
@@ -313,3 +608,253 @@ export async function mergeProject(dir, options) {
313
608
  }
314
609
  return { patch, steps, ok: steps.every((step) => step.ok) };
315
610
  }
611
+ /**
612
+ * A folder cut again, wider, with the contributor's work in it: a change
613
+ * that turns out to need a module the slice cut. The work so far is taken
614
+ * as a patch (`patchProject`: none of its code runs, and a file the policy
615
+ * holds stops the widening), the cut is made again with the entries added
616
+ * and every other bound as it was, its pseudonyms keep the placeholders the
617
+ * folder had where they fit, and the work is applied to the new folder in
618
+ * its names. The old folder is left as it was.
619
+ */
620
+ export async function widenProject(dir, options) {
621
+ const root = resolve(dir);
622
+ const trusted = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
623
+ const repo = trustedRepo(trusted.record, options.repo);
624
+ const patch = await patchProject(root, {
625
+ repo,
626
+ ...(options.receipt === undefined ? {} : { receipt: options.receipt }),
627
+ ...(options.noReceipt === true ? { noReceipt: true } : {}),
628
+ ...(options.allow === undefined ? {} : { allow: options.allow }),
629
+ });
630
+ if (patch.refused.length > 0)
631
+ throw new Error(`The folder's work holds what the hand-off policy keeps out, which a wider folder would carry on:\n${patch.refused
632
+ .map((refusal) => ` ${refusal.path}: ${refusal.reason}`)
633
+ .join("\n")}\nRead those files, and pass --allow for the ones you accept.`);
634
+ const request = trusted.record.request;
635
+ const out = resolve(options.out ?? `${root}-wide`);
636
+ const cut = await cutSlice({
637
+ repo,
638
+ out,
639
+ entryPoints: [...new Set([...request.entryPoints, ...options.entries])],
640
+ budget: {
641
+ maxFiles: options.maxFiles ?? request.budget.maxFiles,
642
+ maxDepth: options.maxDepth ?? request.budget.maxDepth,
643
+ },
644
+ choices: request.choices,
645
+ offline: request.offline,
646
+ widenLiterals: request.widenLiterals,
647
+ tests: request.tests === true,
648
+ ...(request.live === undefined ? {} : { live: request.live }),
649
+ wholePackage: request.wholePackage === true,
650
+ followWorkspaces: request.followWorkspaces === true,
651
+ copyPure: request.copyPure === true,
652
+ private: request.private ?? [],
653
+ copy: request.copy ?? [],
654
+ public: request.public === true,
655
+ ...(request.task === undefined ? {} : { task: request.task }),
656
+ ...(trusted.receipt?.pseudonyms === undefined ? {} : { earlierPseudonyms: trusted.receipt.pseudonyms }),
657
+ ...(trusted.receipt?.unseen === undefined ? {} : { unseen: trusted.receipt.unseen }),
658
+ ...(trusted.receipt === null ? { receipt: false } : {}),
659
+ ...(options.log === undefined ? {} : { log: options.log }),
660
+ });
661
+ const applied = patch.diff === ""
662
+ ? null
663
+ : await applyPatch(out, patch.diff, {
664
+ repo,
665
+ ...(cut.receipt === null ? { noReceipt: true } : { receipt: cut.receipt }),
666
+ ...(options.log === undefined ? {} : { log: options.log }),
667
+ });
668
+ return { out, cut, patch, applied };
669
+ }
670
+ /**
671
+ * The repository's commits since the cut, brought into the folder: the
672
+ * change from the cut's commit to the checkout's HEAD in the packages the
673
+ * slice holds files of and in the modules it stubs, applied as `apply`
674
+ * applies a patch (the folder's own edits merged, an overlap left for a
675
+ * person, the stubs the change needs written). For a job long enough that
676
+ * the repository moves under it. Null when nothing it reads has moved.
677
+ */
678
+ export async function refreshProject(dir, options = {}) {
679
+ const root = resolve(dir);
680
+ const trusted = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
681
+ const record = trusted.record;
682
+ const repo = trustedRepo(record, options.repo);
683
+ const from = record.manifest.sourceCommitSha;
684
+ if (from === null)
685
+ throw new Error("The cut was not made in a Git checkout: there is no commit to refresh from.");
686
+ const to = git(repo, ["rev-parse", "HEAD"]).trim();
687
+ const packages = [...new Set(record.manifest.included.map((file) => packageDirOf(repo, file.path)))];
688
+ const stubbed = [...new Set(record.manifest.cuts.outbound.map((edge) => edge.to))];
689
+ // And the copied folders' files, which may sit outside those packages.
690
+ const copiedFolders = (record.extras ?? []).map((file) => file.path);
691
+ const paths = [...new Set([...packages.map((dir) => (dir === "" ? "." : dir)), ...stubbed, ...copiedFolders])].sort(compare);
692
+ const resolved = [...new Set(options.resolved ?? [])];
693
+ // Only the slice's own files and the copied folders': a path outside
694
+ // would join the record, and the next patch would delete it.
695
+ const sliceFiles = new Set([...record.manifest.included.map((file) => file.path), ...copiedFolders]);
696
+ const outsideSlice = resolved.filter((path) => !sliceFiles.has(path));
697
+ if (outsideSlice.length > 0)
698
+ throw new Error(`--resolved takes the slice's own files and its copied folders'; these are not: ${outsideSlice.join(", ")}.`);
699
+ const change = git(repo, ["diff", "--binary", "--full-index", from, to, "--", ...paths, ...resolved.map((path) => `:(exclude)${path}`)]);
700
+ if (change.trim() === "" && resolved.length === 0)
701
+ return null;
702
+ const written = new Map();
703
+ const applied = change.trim() === ""
704
+ ? { changed: [], added: [], deleted: [], outside: [], stubs: [], merged: [], conflicts: [], leaks: [], unmapped: [] }
705
+ : await applyPatch(root, change, {
706
+ stubs: true,
707
+ repo,
708
+ written,
709
+ ...(options.receipt === undefined ? {} : { receipt: options.receipt }),
710
+ ...(options.noReceipt === true ? { noReceipt: true } : {}),
711
+ ...(options.log === undefined ? {} : { log: options.log }),
712
+ });
713
+ // A file resolved by hand moves to the new base as the folder holds it:
714
+ // the repository's bytes there are its base, the folder's text an edit.
715
+ // One whose text leaves out some of the repository's change is named.
716
+ const table = trusted.receipt?.pseudonyms;
717
+ const droppedUpstream = [];
718
+ const overlapResolved = [];
719
+ const mergeScratch = resolved.length === 0 ? null : mkdtempSync(join(tmpdir(), "carveout-resolved-"));
720
+ for (const path of resolved) {
721
+ try {
722
+ const baseCommit = record.bases?.[path] ?? from;
723
+ const show = (commit) => execFileSync("git", ["show", `${commit}:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] }).toString("utf8");
724
+ const folderPathOf = table === undefined ? path : (exactToFolder(table, path, "")?.path ?? toFolder(table, path, "").path);
725
+ const folderText = readFileSync(join(root, folderPathOf), "utf8");
726
+ const ours = table === undefined ? folderText : fromFolder(table, folderPathOf, folderText).text;
727
+ const merged = mergeText(mergeScratch ?? tmpdir(), ours, show(baseCommit), show(to));
728
+ // Clean, a merge shows what the folder's text leaves out; over the
729
+ // same lines it cannot, and the person's merge stands, named.
730
+ if (merged.conflicts)
731
+ overlapResolved.push(path);
732
+ else if (merged.text !== ours)
733
+ droppedUpstream.push(path);
734
+ }
735
+ catch {
736
+ // A file the commit deleted, or the folder did: nothing to compare.
737
+ }
738
+ }
739
+ if (mergeScratch !== null)
740
+ await rm(mergeScratch, { recursive: true, force: true });
741
+ /** Copied files the new commit deleted: no longer the repository's copies. */
742
+ const copiedGone = [];
743
+ for (const path of resolved) {
744
+ let bytes;
745
+ try {
746
+ bytes = execFileSync("git", ["show", `${to}:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
747
+ }
748
+ catch {
749
+ // A copied file the repository deleted, taken as resolved: what the
750
+ // folder holds of it is the folder's own, a new file to a patch.
751
+ if (copiedFolders.includes(path))
752
+ copiedGone.push(path);
753
+ continue;
754
+ }
755
+ const folderPath = table === undefined ? path : (exactToFolder(table, path, "")?.path ?? toFolder(table, path, "").path);
756
+ // The folder's record holds hashes in the folder's names, never the
757
+ // repository's (which would confirm a guess at a pseudonym).
758
+ const text = bytes.toString("utf8");
759
+ const folderText = table === undefined ? text : (exactToFolder(table, path, text)?.text ?? toFolder(table, path, text).text);
760
+ written.set(path, { folderPath, sha256: sha256(bytes), folderSha256: sha256(folderText) });
761
+ }
762
+ // The repository holds these commits: the folder's base moves to them, so
763
+ // a patch of it carries the contributor's work alone, not the commits it
764
+ // was brought (and `git apply` takes it at the new HEAD). Each file
765
+ // written moves on its own (patch reads its base from the checkout, whose
766
+ // bytes it has); the commit moves only when nothing was left at the old
767
+ // base, an overlap or a file that would not map back to the folder's
768
+ // names.
769
+ // And only when every slice file the commits changed was written or
770
+ // deleted: one the cut no longer reaches, or a copied folder's, keeps the
771
+ // bytes of the old base, which the new commit does not hold.
772
+ const changedNames = git(repo, ["diff", "--name-only", from, to, "--", ...paths]).split("\n").filter((name) => name !== "");
773
+ // A copied file the folder never edited takes the repository's bytes as
774
+ // they are: nothing of the folder's to merge. In pseudonyms, where the
775
+ // bytes would need renaming, it is left to the person, named.
776
+ const copiedTaken = [];
777
+ if (table === undefined) {
778
+ const copiedHashes = new Map((trusted.record.extras ?? []).map((file) => [file.path, file.sha256]));
779
+ for (const name of changedNames) {
780
+ const original = copiedHashes.get(name);
781
+ const at = join(root, name);
782
+ if (original === undefined || written.has(name) || !existsSync(at) || sha256(readFileSync(at)) !== original)
783
+ continue;
784
+ let bytes;
785
+ try {
786
+ bytes = execFileSync("git", ["show", `${to}:${name}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024, stdio: ["ignore", "pipe", "ignore"] });
787
+ }
788
+ catch {
789
+ // Deleted upstream, and never edited here: deleted here too.
790
+ await rm(at, { force: true });
791
+ copiedGone.push(name);
792
+ copiedTaken.push(name);
793
+ continue;
794
+ }
795
+ writeFileSync(at, bytes);
796
+ written.set(name, { folderPath: name, sha256: sha256(bytes), folderSha256: sha256(bytes) });
797
+ copiedTaken.push(name);
798
+ }
799
+ }
800
+ const held = new Set([
801
+ ...trusted.record.manifest.included.map((file) => file.path),
802
+ ...(trusted.record.extras ?? []).map((file) => file.path),
803
+ ]);
804
+ const left = changedNames.filter((name) => held.has(name) && !written.has(name) && !applied.deleted.includes(name) && !copiedGone.includes(name));
805
+ // A copied folder's file the repository changed is never written over
806
+ // what the folder holds: named, to bring by hand and pass as resolved.
807
+ const copiedSet = new Set(copiedFolders);
808
+ const copiedLeft = left.filter((name) => copiedSet.has(name));
809
+ const whole = applied.conflicts.length === 0 && applied.unmapped.length === 0 && left.length === 0;
810
+ if (written.size > 0 || whole || copiedGone.length > 0) {
811
+ const now = trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
812
+ const gone = new Set(applied.deleted);
813
+ const known = new Set(now.record.manifest.included.map((file) => file.path));
814
+ const atTo = (path) => {
815
+ const bytes = execFileSync("git", ["show", `${to}:${path}`], { cwd: repo, maxBuffer: 256 * 1024 * 1024 });
816
+ const [mode = "100644", , blobId = ""] = git(repo, ["ls-tree", to, "--", path]).trim().split(/\s+/);
817
+ return { path, blobId, sha256: sha256(bytes), mode, sizeBytes: bytes.length, operations: ["edit"] };
818
+ };
819
+ // A copied folder's file stays one, at the new base's bytes: patch
820
+ // reads its base by the hash `extras` keeps.
821
+ const extrasPaths = new Set((now.record.extras ?? []).map((file) => file.path));
822
+ const included = [
823
+ ...now.record.manifest.included.filter((file) => !gone.has(file.path)).map((file) => (written.has(file.path) ? { ...file, ...atTo(file.path) } : file)),
824
+ ...[...written.keys()].filter((path) => !known.has(path) && !extrasPaths.has(path)).map(atTo),
825
+ ].sort((a, b) => compare(a.path, b.path));
826
+ const extras = now.record.extras
827
+ ?.filter((file) => !gone.has(file.path) && !copiedGone.includes(file.path))
828
+ .map((file) => {
829
+ const moved = written.get(file.path);
830
+ return moved === undefined ? file : { path: file.path, sha256: moved.sha256 };
831
+ });
832
+ // A file refresh wrote is the repository's at the new base; any other
833
+ // keeps what it was, a patch's mark included.
834
+ const copiedNow = new Map((now.record.copied ?? []).filter((file) => !gone.has(file.path)).map((file) => [file.path, file]));
835
+ for (const [path, { sha256: hash }] of written)
836
+ copiedNow.set(path, { path, sha256: hash });
837
+ // Each file written moved to the new commit; the record's commit too
838
+ // when whole, when no file is left behind it.
839
+ const bases = whole
840
+ ? undefined
841
+ : { ...(now.record.bases ?? {}), ...Object.fromEntries([...written.keys()].map((path) => [path, to])) };
842
+ const updated = {
843
+ ...now.record,
844
+ manifest: { ...now.record.manifest, ...(whole ? { sourceCommitSha: to } : {}), included },
845
+ ...(extras === undefined ? {} : { extras }),
846
+ ...(bases === undefined ? { bases: undefined } : { bases }),
847
+ ...(now.record.copied === undefined ? {} : { copied: [...copiedNow.values()].sort((a, b) => compare(a.path, b.path)) }),
848
+ };
849
+ saveRecord(root, now, updated, {}, new Map([...written.values()].map(({ folderPath, folderSha256 }) => [folderPath, folderSha256])));
850
+ }
851
+ return {
852
+ from,
853
+ to,
854
+ applied,
855
+ ...(droppedUpstream.length === 0 ? {} : { droppedUpstream }),
856
+ ...(overlapResolved.length === 0 ? {} : { overlapResolved }),
857
+ ...(copiedLeft.length === 0 ? {} : { copiedLeft }),
858
+ ...(copiedTaken.length === 0 ? {} : { copiedTaken }),
859
+ };
860
+ }