carveout 0.1.1 → 0.1.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.
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 +148 -0
  7. package/dist/src/api.js +120 -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 +517 -0
  77. package/skills/carveout-gate/SKILL.md +98 -0
  78. package/SKILL.md +0 -423
package/dist/src/cli.js CHANGED
@@ -4,61 +4,115 @@
4
4
  * Exit codes: 0 done, 1 refused or failed, 2 written with blockers (cut)
5
5
  * or a step failed (check, merge), 3 nothing to patch, 4 stale (status),
6
6
  * 5 the edits reach past the slice (patch, merge), 6 the hand-off policy
7
- * kept files out of the patch (patch, merge) or held a change (gate).
7
+ * kept files out of the patch (patch, merge) or held a change (gate), 7 the
8
+ * folder's edits and an applied change overlap (apply).
8
9
  */
9
- import { readFileSync, writeFileSync } from "node:fs";
10
- import { resolve } from "node:path";
10
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
11
+ import { basename, dirname, relative, resolve } from "node:path";
11
12
  import { parseArgs } from "node:util";
12
- import { CARVEOUT_VERSION, carveoutHome, checkProject, cutSlice, patchProject, planSlice, readRecord, } from "./carve.js";
13
+ import { defaultSandbox, sandboxUnavailable } from "./sandbox.js";
14
+ import { CARVEOUT_VERSION, carveoutHome, checkProject, cutSlice, folderCameBack, patchProject, planBlockers, planSlice, readRecord, readRepoConfig, } from "./carve.js";
13
15
  import { SLICE_BUDGET_DEFAULTS, TEST_LEFT_REASON, renderExposure, renderOutside, testsVerdict, } from "./core.js";
14
16
  import { gateRepository } from "./gate.js";
15
17
  import { impactOf, runImpactTests } from "./impact.js";
16
- import { applyPatch, mergeProject } from "./mergeback.js";
18
+ import { applyPatch, mergeProject, refreshProject, widenProject } from "./mergeback.js";
17
19
  import { statusProject } from "./status.js";
20
+ import { hasAdvice, renderReadiness } from "./readiness.js";
21
+ import { trustedCut, writeReceipt } from "./receipt.js";
22
+ import { runAccount } from "./account.js";
18
23
  const USAGE = `carveout ${CARVEOUT_VERSION}: carve a slice out of a TypeScript repository.
19
24
 
25
+ carveout handoff <entry>... --out <dir> [--task <file|text>] [--depth N] [--max-files N]
26
+ [--private <path>]... [--name <word>]... [--copy <dir>]... [--live <pkg>]...
27
+ [--accept <test>...] [--no-whole-package] [--no-copy-pure] [--receipt <file>]
28
+ [--max-exposure-lines N] [--max-exposure-percent P] [--json]
29
+ The hand-off in one step: a cut with the tests that run the code, its
30
+ whole package and the pure code it runs, for someone outside (public,
31
+ in pseudonyms), and its check. Prints whether the folder is ready (its
32
+ tests pass before any edit), what it shows, the receipt to keep, and
33
+ what to send. --task writes TASK.md: the work, and when it is accepted.
34
+ Exits 0 only when the folder is ready to hand over.
35
+
20
36
  carveout plan <entry>... [--repo <dir>] [--max-files N] [--depth N]
21
37
  [--with-tests] [--whole-package] [--follow-workspaces]
22
- [--copy-pure] [--private <path>]... [--json]
38
+ [--copy-pure] [--private <path>]... [--max-exposure-lines N]
39
+ [--max-exposure-percent P] [--json]
23
40
  What a cut would hold, from the import graph alone. Writes nothing.
41
+ --max-exposure-lines and --max-exposure-percent (or maxExposureLines
42
+ and maxExposurePercent in carveout.config.json) bound the lines a
43
+ folder shows, and the share of the repository's TypeScript lines; a
44
+ cut past either is written with an exposure_exceeded blocker.
24
45
 
25
46
  carveout cut <entry>... --out <dir> [--repo <dir>] [--max-files N] [--depth N]
26
47
  [--with-tests] [--whole-package] [--follow-workspaces]
27
48
  [--copy-pure] [--private <path>]... [--copy <dir>]...
28
- [--mock <package>]...
49
+ [--max-exposure-lines N] [--max-exposure-percent P] [--mock <package>]...
29
50
  [--install <package>]... [--live <package>]... [--offline]
30
- [--widen-literals] [--public] [--json]
51
+ [--widen-literals] [--public] [--name <word>]... [--keep-names] [--task <file|text>]
52
+ [--receipt <file> | --no-receipt] [--json]
31
53
  Copies the slice to <dir> at its repository paths, with a .d.ts stub and a
32
54
  recording mock for every module it imports from outside, mock packages for
33
55
  what it does not install, package.json, tsconfig.json and a lockfile.
34
56
  Writes nothing when a file it would write holds a credential. --public
35
57
  makes a folder for someone outside: literals widened, and neither the
36
58
  checkout's path nor the files outside the slice that import it named.
59
+ Writes the maintainer's receipt to ~/.carveout/receipts/<cut id>.json
60
+ (or --receipt <file>): the record the folder that comes back is judged
61
+ by, which a public folder's own record leaves the repository's names to.
62
+ A public folder is written in pseudonyms: the names the repository goes
63
+ by (its packages', its owner's, its remote's, --name and "names" in the
64
+ configuration) renamed under a table the receipt keeps, and patch
65
+ renames the folder back. --keep-names writes it in the repository's
66
+ names; a name left in the folder is a blocker either way.
37
67
 
38
- carveout check <dir> [--in-place] [--isolated] [--json]
39
- npm ci, npm run build and npm test in a scratch copy of <dir>. --isolated,
40
- for a folder that came back from someone else: a scratch HOME and npm
41
- cache and no install scripts (not a sandbox).
68
+ carveout check <dir> [--in-place] [--isolated] [--accept <test>...] [--allow <path>]...
69
+ [--no-sandbox] [--sandbox-image <image>] [--receipt <file>] [--json]
70
+ npm ci, npm run build and npm test in a scratch copy of <dir>. A folder
71
+ that came back (edited since the cut, or with no receipt to say it is
72
+ not) is read by the hand-off policy first: what patch keeps out of the
73
+ test side is put back as the cut wrote it, what the folder added is taken
74
+ out, and any other file it keeps out stops the check (exit 6) until
75
+ --allow names it. Then every step runs in a container with only the
76
+ copy mounted and no network but for npm ci (scripts off); --no-sandbox
77
+ runs it here as you. --isolated: a scratch HOME and npm cache and no
78
+ install scripts, and the same as a folder that came back.
42
79
 
43
- carveout status <dir> [--repo <checkout>] [--json]
80
+ carveout status <dir> [--repo <checkout>] [--receipt <file>] [--json]
44
81
  Whether <dir> still matches the repository, and what changed if not.
45
82
 
46
- carveout patch <dir> [--repo <checkout>] [--allow <path>]... [--output <file>] [--json]
83
+ carveout patch <dir> [--repo <checkout>] [--allow <path>]... [--receipt <file>]
84
+ [--output <file>] [--json]
47
85
  The edits made in <dir> as a patch for the repository (git apply). Runs
48
86
  none of the folder's code. Keeps out, and names, new files outside the
49
87
  slice's packages, protected paths (CI, hooks, manifests, lockfiles,
50
88
  environment) and deleted tests; --allow <path|glob> lets one through.
51
89
 
52
- carveout apply <dir> <patch-file> [--repo <checkout>] [--stubs] [--json]
90
+ carveout apply <dir> <patch-file> [--repo <checkout>] [--stubs] [--receipt <file>] [--json]
53
91
  Brings a patch for the repository at the cut's commit into <dir>, with its
54
92
  imports rewritten as a cut writes them. --stubs also writes the stubs the
55
93
  change needs from the modules the slice cuts.
56
94
 
95
+ carveout refresh <dir> [--resolved <path>...] [--repo <checkout>] [--receipt <file>] [--json]
96
+ The repository's commits since the cut, brought into the folder: the
97
+ change in the slice's packages and the modules it stubs, merged into
98
+ the folder's own edits (an overlap is left, and named: exit 7).
99
+
100
+ carveout widen <dir> --entry <path>... [--out <dir>] [--depth N] [--max-files N]
101
+ [--allow <path>]... [--receipt <file>] [--json]
102
+ The folder cut again with more entry points, every other bound as it
103
+ was, and the contributor's work so far carried into it: for a change
104
+ that needs a module the slice cut. A public folder keeps its names.
105
+
57
106
  carveout merge <dir> --into <checkout> [--verify <command>] [--verify-env <name>]...
58
- [--allow <path>]... [--json]
59
- patch, then git apply --3way in <checkout>, then <command> at its root,
60
- with a scratch HOME and only the environment variables named. Applies
61
- nothing while the hand-off policy keeps a file out.
107
+ [--verify-setup <command>] [--no-sandbox] [--sandbox-image <image>]
108
+ [--allow <path>]... [--receipt <file>] [--json]
109
+ patch, then git apply --3way in <checkout>, then <command> in a
110
+ container on a copy of the checkout (tracked and unignored files): the
111
+ install the lockfile names (or --verify-setup), scripts off, with a
112
+ network; then <command> with none, and only the environment variables
113
+ named. --no-sandbox runs it at the checkout's root as you. Applies
114
+ nothing while the hand-off policy keeps a file out, or when the
115
+ container cannot run.
62
116
 
63
117
  carveout gate [--repo <dir>] [--base <ref>] [--scope <path>]... [--allow <path>]...
64
118
  [--hook] [--json]
@@ -82,6 +136,16 @@ const USAGE = `carveout ${CARVEOUT_VERSION}: carve a slice out of a TypeScript r
82
136
  carveout where
83
137
  The folder fetched package types are cached in (CARVEOUT_HOME).
84
138
 
139
+ carveout login [--platform <url>] [--org <handle|id>] [--open]
140
+ carveout whoami [--platform <url>]
141
+ carveout logout [--platform <url>]
142
+ Optional: sign in to the Lunox platform (https://platform.lunox.work, or
143
+ LUNOX_PLATFORM_URL) with a code approved in the browser, and keep a
144
+ workspace token in ~/.lunox/credentials (LUNOX_CREDENTIALS), shared with
145
+ duediligence; whoami prints the account, workspace and token expiry;
146
+ logout deletes the token here (the platform cannot revoke it from the
147
+ CLI). No other command talks to the platform.
148
+
85
149
  An entry point is a file or a directory, relative to --repo (default: the
86
150
  current directory, which should be the repository's root). Defaults:
87
151
  --max-files ${SLICE_BUDGET_DEFAULTS.maxFiles}, --depth ${SLICE_BUDGET_DEFAULTS.maxDepth}.
@@ -97,6 +161,67 @@ or folder (or glob) out of every slice: stubbed wherever reached, literals
97
161
  widened. --copy <dir> copies a folder the code reads by name (migrations).
98
162
  A carveout.config.json at the repository's root sets the same for every cut.
99
163
  `;
164
+ /**
165
+ * A run's counts as a sentence: the folder's tests (node:test's and
166
+ * Vitest's), then carveout's own checks of the public surface, which are
167
+ * no test of the work's.
168
+ */
169
+ function countsSentence(counts, edited) {
170
+ if (counts === undefined)
171
+ return "";
172
+ // A runner with no test of the folder's (node:test's only file the interface test) is left out.
173
+ const runners = Object.entries(counts.runners ?? {}).filter(([, each]) => each.tests > 0);
174
+ const label = (runner) => (runner === "vitest" ? "Vitest" : runner);
175
+ const by = runners.length > 1
176
+ ? ` (${runners.map(([runner, each]) => `${label(runner)} ${each.pass} of ${each.tests}`).join(", ")})`
177
+ : runners.length === 1
178
+ ? ` (${label(runners[0]?.[0] ?? "")})`
179
+ : "";
180
+ const tests = counts.tests === 0 && counts.surface !== undefined
181
+ ? ""
182
+ : ` ${counts.pass} of ${counts.tests} test(s) pass${edited ? "" : " before any edit"}${by}.`;
183
+ const surface = counts.surface === undefined
184
+ ? ""
185
+ : ` ${counts.surface.checks - counts.surface.fail} of ${counts.surface.checks} check(s) that the public surface keeps its exports and types hold.`;
186
+ return `${tests}${surface}`;
187
+ }
188
+ /** What a check says of a folder as a hand-off: whether "make the tests pass" is a task someone can finish there. */
189
+ function verdictOf(steps, record) {
190
+ const own = steps.filter((step) => step.command !== "accept");
191
+ const ok = own.length === 3 && own.every((step) => step.ok);
192
+ const testStep = steps.find((step) => step.command === "npm test");
193
+ const baseline = testStep?.baselineInEffect !== undefined && testStep.baselineInEffect.length > 0 ? testStep.baselineInEffect : undefined;
194
+ const counts = testStep?.counts;
195
+ const carried = record.tests?.carried.length ?? 0;
196
+ return {
197
+ ok,
198
+ // A cut with blockers (a name left in the folder, a stub it could not
199
+ // write) is not ready whatever its tests say.
200
+ handoff: !record.ready ? "blocked" : !ok ? "failed" : baseline !== undefined ? "red-baseline" : carried === 0 ? "no-tests" : "ready",
201
+ ...(counts === undefined ? {} : { counts }),
202
+ ...(baseline === undefined ? {} : { baseline }),
203
+ carried,
204
+ };
205
+ }
206
+ /** The owner's acceptance tests, by their paths in the repository (`--accept`). */
207
+ function acceptOption(files, repo) {
208
+ const root = resolve(repo ?? ".");
209
+ return (files ?? []).map((file) => {
210
+ const at = resolve(file);
211
+ const path = relative(root, at).split("\\").join("/");
212
+ if (path.startsWith("..") || !existsSync(at))
213
+ fail(`--accept ${file}: give a test file inside the repository (run from its root, or pass --repo), at the path it would have there.`);
214
+ return { path, text: readFileSync(at, "utf8") };
215
+ });
216
+ }
217
+ /** What a command wrote into a folder in pseudonyms that still names the repository: for the maintainer, in full. */
218
+ function leaksText(leaks) {
219
+ return `The folder now holds names the repository goes by; do not send it as it is (rename them, or cut again):\n${list(leaks.map(({ term, files }) => `${term}: ${files.join(", ")}`))}\n`;
220
+ }
221
+ /** Slice files a command left unwritten: in the folder's names they would not map back. */
222
+ function unmappedText(paths) {
223
+ return `Not written, left as the folder had them: in the folder's names their text would not map back to the repository's byte for byte (a mixed-case name, a placeholder word the repository's text holds), so a patch would rewrite it. Cut again to take them:\n${list(paths)}\n`;
224
+ }
100
225
  function fail(message) {
101
226
  process.stderr.write(`carveout: ${message}\n`);
102
227
  process.exit(1);
@@ -123,9 +248,16 @@ async function main(argv) {
123
248
  process.stdout.write(`${CARVEOUT_VERSION}\n`);
124
249
  return;
125
250
  }
251
+ if (commandName === "login" || commandName === "whoami" || commandName === "logout") {
252
+ process.exitCode = await runAccount(commandName, rest, { client: "carveout", version: CARVEOUT_VERSION });
253
+ return;
254
+ }
126
255
  const { values, positionals } = parseArgs({
127
256
  args: [...rest],
128
257
  allowPositionals: true,
258
+ // `--no-whole-package`, `--no-copy-pure`, `--no-public`: handoff's
259
+ // defaults turned off by name.
260
+ allowNegative: true,
129
261
  options: {
130
262
  repo: { type: "string" },
131
263
  out: { type: "string" },
@@ -141,23 +273,45 @@ async function main(argv) {
141
273
  "whole-package": { type: "boolean" },
142
274
  "follow-workspaces": { type: "boolean" },
143
275
  "copy-pure": { type: "boolean" },
276
+ "max-exposure-lines": { type: "string" },
277
+ "max-exposure-percent": { type: "string" },
144
278
  private: { type: "string", multiple: true },
145
279
  copy: { type: "string", multiple: true },
146
280
  "in-place": { type: "boolean" },
281
+ accept: { type: "string", multiple: true },
282
+ resolved: { type: "string", multiple: true },
147
283
  stubs: { type: "boolean" },
148
284
  into: { type: "string" },
149
285
  verify: { type: "string" },
150
286
  "verify-env": { type: "string", multiple: true },
287
+ "verify-setup": { type: "string" },
151
288
  allow: { type: "string", multiple: true },
152
289
  public: { type: "boolean" },
153
290
  isolated: { type: "boolean" },
291
+ sandbox: { type: "boolean" },
292
+ "sandbox-image": { type: "string" },
154
293
  base: { type: "string" },
155
294
  scope: { type: "string", multiple: true },
156
295
  hook: { type: "boolean" },
157
296
  run: { type: "boolean" },
158
297
  json: { type: "boolean" },
298
+ receipt: { type: "string" },
299
+ "no-receipt": { type: "boolean" },
300
+ name: { type: "string", multiple: true },
301
+ "keep-names": { type: "boolean" },
302
+ task: { type: "string" },
303
+ entry: { type: "string", multiple: true },
159
304
  },
160
305
  });
306
+ // With negated flags read, `--no-receipt` comes back as `receipt: false`
307
+ // rather than as its own flag: the same thing, said one way.
308
+ {
309
+ const parsed = values;
310
+ if (parsed["receipt"] === false) {
311
+ delete parsed["receipt"];
312
+ parsed["no-receipt"] = true;
313
+ }
314
+ }
161
315
  const budget = {
162
316
  maxFiles: integer(values["max-files"], "max-files", SLICE_BUDGET_DEFAULTS.maxFiles),
163
317
  maxDepth: integer(values.depth, "depth", SLICE_BUDGET_DEFAULTS.maxDepth),
@@ -170,12 +324,64 @@ async function main(argv) {
170
324
  * else the record's, or the current directory when the record names none
171
325
  * (a public cut). The library checks it holds the cut's commit.
172
326
  */
173
- const repoFor = (dir) => values.repo !== undefined ? repo : readRecord(dir).repo === undefined ? repo : undefined;
327
+ const repoFor = (dir) => {
328
+ if (values.repo !== undefined)
329
+ return repo;
330
+ let named;
331
+ try {
332
+ named = trustedCut(dir, values.receipt, { optional: true }).record.repo;
333
+ }
334
+ catch {
335
+ named = readRecord(dir).repo;
336
+ }
337
+ return named === undefined ? repo : undefined;
338
+ };
339
+ /** `--mock`, `--install` and `--live` as the cut's dependency choices, refused when they clash. */
340
+ const dependencyChoices = () => {
341
+ const choices = {};
342
+ for (const name of values.mock ?? [])
343
+ choices[name] = "runtime-mock";
344
+ for (const name of values.install ?? []) {
345
+ if (choices[name] !== undefined)
346
+ fail(`${name} is given to both --mock and --install.`);
347
+ choices[name] = "approved-package";
348
+ }
349
+ for (const name of values.live ?? [])
350
+ if (choices[name] === "runtime-mock")
351
+ fail(`${name} is given to both --mock and --live.`);
352
+ return choices;
353
+ };
354
+ /** `--task`: a file's text, or the text given. */
355
+ const taskOption = () => values.task === undefined
356
+ ? {}
357
+ : { task: existsSync(values.task) && statSync(values.task).isFile() ? readFileSync(values.task, "utf8") : values.task };
358
+ /** The receipt a folder's command was given, as an option; or none, by `--no-receipt`. */
359
+ const receiptOption = {
360
+ ...(values.receipt === undefined ? {} : { receipt: values.receipt }),
361
+ ...(values["no-receipt"] === true ? { noReceipt: true } : {}),
362
+ };
363
+ /** `--max-exposure-lines` and `--max-exposure-percent`: over the configuration's. */
364
+ const maxExposure = (() => {
365
+ const positive = (value, name) => {
366
+ if (value === undefined)
367
+ return undefined;
368
+ const parsed = Number(value);
369
+ if (!Number.isFinite(parsed) || parsed <= 0)
370
+ fail(`--${name} takes a number above 0, not ${value}.`);
371
+ return parsed;
372
+ };
373
+ const lines = positive(values["max-exposure-lines"], "max-exposure-lines");
374
+ const percent = positive(values["max-exposure-percent"], "max-exposure-percent");
375
+ return lines === undefined && percent === undefined
376
+ ? {}
377
+ : { maxExposure: { ...(lines === undefined ? {} : { lines }), ...(percent === undefined ? {} : { percent }) } };
378
+ })();
174
379
  const bounds = {
175
380
  ...(values["whole-package"] === true ? { wholePackage: true } : {}),
176
381
  ...(values["follow-workspaces"] === true ? { followWorkspaces: true } : {}),
177
382
  ...(values["copy-pure"] === true ? { copyPure: true } : {}),
178
383
  ...(values.private === undefined ? {} : { private: values.private }),
384
+ ...maxExposure,
179
385
  };
180
386
  switch (commandName) {
181
387
  case "where":
@@ -213,6 +419,7 @@ async function main(argv) {
213
419
  testsVerdict: testsVerdict(plan.walk),
214
420
  originOf: plan.walk.originOf,
215
421
  exposure: plan.exposure,
422
+ exposureBudget: plan.exposureBudget ?? null,
216
423
  cutModules: plan.cutModules,
217
424
  typeOnlyCuts: plan.typeOnlyCuts,
218
425
  inbound: [...new Set(plan.walk.cuts.inbound.map((e) => e.from))].sort(),
@@ -228,12 +435,21 @@ async function main(argv) {
228
435
  const importers = [
229
436
  ...new Set(plan.walk.cuts.inbound.map((edge) => edge.from)),
230
437
  ].sort();
231
- const broken = plan.walk.dependencies.filter((dependency) => dependency.status !== "external");
232
- const readsCut = plan.walk.cuts.outbound.filter((edge) => edge.relation === "reads");
438
+ const blockers = planBlockers(plan);
233
439
  process.stdout.write([
234
440
  `${plan.walk.included.length} files from ${plan.entryPoints.join(", ")} (budget ${plan.budget.maxFiles} files, depth ${plan.budget.maxDepth}${plan.wholePackage ? ", the whole package" : ""}${plan.followWorkspaces ? ", workspace packages followed" : ""}${plan.copyPure ? ", pure code copied" : ""}; ${plan.graphFiles} script files read)${atLimit ? ", at the file limit: raise --max-files or narrow the entry points" : ""}.`,
235
441
  "",
236
442
  `Shows ${renderExposure(plan.exposure)}`,
443
+ ...(plan.exposureBudget === undefined
444
+ ? []
445
+ : [
446
+ plan.exposureBudget.over === null
447
+ ? `Within the owner's bound on what a folder shows (${[
448
+ ...(plan.exposureBudget.lines === undefined ? [] : [`${plan.exposureBudget.lines.toLocaleString("en-US")} lines`]),
449
+ ...(plan.exposureBudget.percent === undefined ? [] : [`${plan.exposureBudget.percent}%; this is ${plan.exposureBudget.shownPercent ?? 0}%`]),
450
+ ].join(", ")}).`
451
+ : `Past the owner's bound on what a folder shows: ${plan.exposureBudget.over}. A cut would be written with an exposure_exceeded blocker, never ready.`,
452
+ ]),
237
453
  "",
238
454
  ...(renderOutside(plan.exposure).length === 0 ? [] : [...renderOutside(plan.exposure), ""]),
239
455
  "Included:",
@@ -293,26 +509,150 @@ async function main(argv) {
293
509
  ...plan.externals.packages.map((item) => `${item.specifier} (${item.service})`),
294
510
  ...plan.externals.environment.map((item) => `$${item.name}`),
295
511
  ]),
296
- ...(plan.walk.unknownEntryPoints.length +
297
- broken.length +
298
- readsCut.length +
299
- plan.secretFiles.length ===
300
- 0
301
- ? []
302
- : [
303
- "",
304
- "Blockers:",
305
- list([
306
- ...plan.walk.unknownEntryPoints.map((entry) => `${entry}: names no script file in the repository`),
307
- ...broken.map((dependency) => `${dependency.file}${dependency.location === null ? "" : `:${dependency.location.slice(1)}`}: ${dependency.status === "dynamic" ? "dynamic import of a computed name" : dependency.status === "path" ? `reads ${dependency.specifier ?? ""} by path, which a slice cannot copy` : `${dependency.specifier ?? ""} resolves to no file`}`),
308
- ...readsCut.map((edge) => `${edge.from}${edge.location === null ? "" : `:${edge.location.slice(1)}`}: reads ${edge.to} by path, and it is outside the slice`),
309
- ...plan.secretFiles.map((path) => `${path}: a credentials file is never copied`),
310
- ]),
311
- ]),
512
+ ...(blockers.length === 0 ? [] : ["", "Blockers:", list(blockers)]),
312
513
  "",
313
514
  ].join("\n"));
314
515
  return;
315
516
  }
517
+ case "handoff": {
518
+ if (positionals.length === 0)
519
+ fail("handoff needs at least one entry point.");
520
+ if (values.out === undefined)
521
+ fail("handoff needs --out <dir>.");
522
+ // The hand-off's own defaults, unless a flag or the owner's
523
+ // configuration says otherwise: the tests that run the code, its
524
+ // whole package, the pure code it runs, and a folder for someone
525
+ // outside, in pseudonyms.
526
+ const config = readRepoConfig(repo);
527
+ // The owner's acceptance tests are never sent: a test that imports the
528
+ // slice would otherwise come with --with-tests, from the working tree.
529
+ const accepted = acceptOption(values.accept, values.repo);
530
+ const result = await cutSlice({
531
+ repo,
532
+ out: values.out,
533
+ entryPoints: positionals,
534
+ budget,
535
+ choices: dependencyChoices(),
536
+ ...(values.live === undefined ? {} : { live: values.live }),
537
+ tests: values["with-tests"] ?? true,
538
+ wholePackage: values["whole-package"] ?? config.wholePackage ?? true,
539
+ copyPure: values["copy-pure"] ?? config.copyPure ?? true,
540
+ ...(values["follow-workspaces"] === true ? { followWorkspaces: true } : {}),
541
+ ...(values.private === undefined ? {} : { private: values.private }),
542
+ ...maxExposure,
543
+ ...(accepted.length === 0 ? {} : { unseen: accepted.map((test) => test.path) }),
544
+ public: values.public ?? config.public ?? true,
545
+ ...(values.offline === true ? { offline: true } : {}),
546
+ ...(values.copy === undefined ? {} : { copy: values.copy }),
547
+ ...(values["no-receipt"] === true ? { receipt: false } : values.receipt === undefined ? {} : { receipt: values.receipt }),
548
+ ...(values.name === undefined ? {} : { names: values.name }),
549
+ ...(values["keep-names"] === true ? { pseudonyms: false } : {}),
550
+ ...taskOption(),
551
+ log: (line) => {
552
+ if (values.json !== true)
553
+ process.stderr.write(`${line}\n`);
554
+ },
555
+ });
556
+ const steps = await checkProject(result.out, {
557
+ ...(result.receipt === null ? { noReceipt: true } : { receipt: result.receipt }),
558
+ });
559
+ // The owner's acceptance tests on the folder as it goes out: they
560
+ // should not pass before the work, or they test nothing of it.
561
+ const preSteps = accepted.length === 0 || steps.some((step) => !step.ok)
562
+ ? null
563
+ : await checkProject(result.out, {
564
+ ...(result.receipt === null ? { noReceipt: true } : { receipt: result.receipt }),
565
+ accept: accepted,
566
+ });
567
+ // The acceptance tests' own verdict: the folder's tests are judged apart.
568
+ const vacuous = preSteps === null ? null : preSteps.find((step) => step.command === "accept")?.ok === true;
569
+ // A build that fails on an acceptance test itself (an import it makes
570
+ // that the folder lacks) is no failure of the work's.
571
+ const unbuilt = preSteps === null || preSteps.some((step) => step.command === "accept")
572
+ ? undefined
573
+ : preSteps
574
+ .find((step) => !step.ok)
575
+ ?.output.split("\n")
576
+ // A module it imports that is not there (TS2307), not a name
577
+ // the work is yet to add (TS2305, TS2339: failing as it should).
578
+ .find((line) => /TS2307/.test(line) && accepted.some((test) => line.includes(test.path)));
579
+ const verdict = verdictOf(steps, result.record);
580
+ const record = result.record;
581
+ // The step that failed, and what it printed last: what to fix first.
582
+ const failedStep = steps.find((step) => !step.ok);
583
+ if (values.json === true)
584
+ print({
585
+ out: result.out,
586
+ ready: verdict.handoff === "ready",
587
+ handoff: verdict.handoff,
588
+ counts: verdict.counts ?? null,
589
+ baseline: verdict.baseline ?? [],
590
+ blockers: record.blockers,
591
+ exposure: record.exposure ?? null,
592
+ pseudonyms: result.pseudonyms === null ? null : { renames: result.pseudonyms.rules.length, kept: result.pseudonyms.kept },
593
+ readiness: result.readiness,
594
+ receipt: result.receipt,
595
+ // The owner's acceptance tests on the folder as it goes out.
596
+ acceptance: vacuous === null ? null : vacuous ? "pass-before-the-work" : "fail-before-the-work",
597
+ steps,
598
+ });
599
+ else {
600
+ const lines = [
601
+ `Hand-off folder: ${result.out}`,
602
+ ` Verdict: ${!record.ready
603
+ ? "not ready: the cut has blockers (below)"
604
+ : verdict.handoff === "ready"
605
+ ? "ready: it installs, builds, and its tests pass before any edit"
606
+ : verdict.handoff === "red-baseline"
607
+ ? `its tests run, but ${verdict.baseline?.length ?? 0} fail on the mocks before any edit (not counted)`
608
+ : verdict.handoff === "no-tests"
609
+ ? "it builds, but no test of this code came with it: write the tests the work must pass"
610
+ : `it does not ${failedStep === undefined ? "install, build or test" : failedStep.command} on its own (below)`}`,
611
+ ...(verdict.counts === undefined
612
+ ? []
613
+ : [` ${verdict.counts.tests === 0 && verdict.counts.surface !== undefined ? "Checks" : "Tests"}:${countsSentence(verdict.counts, false)}`]),
614
+ ...(record.exposure === undefined ? [] : [` Shows ${renderExposure(record.exposure)}`]),
615
+ ` Names: ${result.pseudonyms === null
616
+ ? record.request.public === true
617
+ ? "in the repository's own (--keep-names or no receipt)"
618
+ : "in the repository's own (not public)"
619
+ : result.pseudonyms.rules.length === 0
620
+ ? "none of the repository's is in the folder"
621
+ : `${result.pseudonyms.rules.length} rename(s) hide the repository's; the receipt keeps the table`}.`,
622
+ ...(record.blockers.length === 0
623
+ ? []
624
+ : ["", "Blockers:", list(record.blockers.map((b) => `${b.code}${b.file === null ? "" : ` ${b.file}`}: ${b.detail}`))]),
625
+ ...(verdict.handoff === "red-baseline" && hasAdvice(result.readiness)
626
+ ? ["", "To run those on real code, cut again with:", ...renderReadiness(result.readiness)]
627
+ : []),
628
+ ...(failedStep === undefined
629
+ ? []
630
+ : ["", `${failedStep.command} failed; its last lines:`, ...failedStep.output.trim().split("\n").slice(-25).map((line) => ` ${line}`)]),
631
+ ...(vacuous === null
632
+ ? []
633
+ : [
634
+ "",
635
+ vacuous
636
+ ? `The owner's ${accepted.length} acceptance test file(s) already pass on the folder as it goes out: they test nothing of the work (or it is done). Make them fail before it.`
637
+ : unbuilt !== undefined
638
+ ? `The owner's acceptance tests do not build in the folder, whatever the work does: ${unbuilt.trim()}. Import what they test by a relative path the folder holds, and run handoff again.`
639
+ : `The owner's ${accepted.length} acceptance test file(s) fail on the folder as it goes out, as they should before the work; keep them, and run them on the folder that comes back with carveout check --accept.`,
640
+ ]),
641
+ "",
642
+ result.receipt === null
643
+ ? "No receipt: the folder that comes back will be judged by its own record (--no-receipt on patch)."
644
+ : `Receipt: ${result.receipt}\n Keep it, and never send it: the folder that comes back is judged by it.`,
645
+ `Send the folder without node_modules: (cd ${dirname(result.out)} && zip -qr ${basename(result.out)}.zip ${basename(result.out)} -x '*/node_modules/*')`,
646
+ "When it comes back, from the repository's root: carveout patch <returned folder> --output change.patch; read all of it; then git apply --3way change.patch on a branch, and let CI decide.",
647
+ "",
648
+ ];
649
+ process.stdout.write(lines.join("\n"));
650
+ }
651
+ // Acceptance tests that pass before the work would accept anything.
652
+ if (!record.ready || verdict.handoff !== "ready" || vacuous === true)
653
+ process.exitCode = 2;
654
+ return;
655
+ }
316
656
  case "cut": {
317
657
  if (positionals.length === 0)
318
658
  fail("cut needs at least one entry point.");
@@ -342,6 +682,10 @@ async function main(argv) {
342
682
  ...(values.public === true ? { public: true } : {}),
343
683
  ...bounds,
344
684
  ...(values.copy === undefined ? {} : { copy: values.copy }),
685
+ ...(values["no-receipt"] === true ? { receipt: false } : values.receipt === undefined ? {} : { receipt: values.receipt }),
686
+ ...(values.name === undefined ? {} : { names: values.name }),
687
+ ...(values["keep-names"] === true ? { pseudonyms: false } : {}),
688
+ ...taskOption(),
345
689
  log: (line) => {
346
690
  if (values.json !== true)
347
691
  process.stderr.write(`${line}\n`);
@@ -357,6 +701,12 @@ async function main(argv) {
357
701
  dependencies: record.dependencies,
358
702
  optionalPeers: record.optionalPeers,
359
703
  importRewrites: record.importRewrites,
704
+ tests: record.tests,
705
+ readiness: result.readiness,
706
+ receipt: result.receipt,
707
+ pseudonyms: result.pseudonyms === null
708
+ ? null
709
+ : { renames: result.pseudonyms.rules.length, kept: result.pseudonyms.kept },
360
710
  });
361
711
  else {
362
712
  const mocked = record.dependencies
@@ -393,6 +743,30 @@ async function main(argv) {
393
743
  "Blockers:",
394
744
  list(record.blockers.map((b) => `${b.code}${b.file === null ? "" : ` ${b.file}`}: ${b.detail}`)),
395
745
  ]),
746
+ ...(result.pseudonyms === null
747
+ ? []
748
+ : [
749
+ "",
750
+ result.pseudonyms.rules.length === 0
751
+ ? `Pseudonyms: none of the ${result.pseudonyms.vocabulary.length} name(s) the repository goes by is in the folder; nothing was renamed.`
752
+ : `Pseudonyms: the folder holds the slice under ${result.pseudonyms.rules.length} rename(s) that hide the names the repository goes by (the receipt keeps the table; patch renames the folder back).${result.pseudonyms.kept.length === 0 ? "" : ` Kept, as npm installs them: ${result.pseudonyms.kept.join(", ")}.`}`,
753
+ ]),
754
+ ...(result.readiness.tests === 0
755
+ ? [
756
+ "",
757
+ "No test of this code is in the folder: a pass means it installs, builds and keeps its exports. Cut with --with-tests, or write the tests the work must pass before handing it over.",
758
+ ]
759
+ : hasAdvice(result.readiness)
760
+ ? [
761
+ "",
762
+ "If check finds tests that fail on the mocks before any edit, these turn them toward real code:",
763
+ ...renderReadiness(result.readiness),
764
+ ]
765
+ : []),
766
+ "",
767
+ result.receipt === null
768
+ ? "No receipt: patch, status and apply will judge the folder by its own record, checked against the repository."
769
+ : `Receipt: ${result.receipt}\n Keep it, and do not send it: patch, status, apply and merge judge the folder that comes back by it${record.request.public === true ? ", and the folder's own record leaves the repository's names to it" : ""}.`,
396
770
  "",
397
771
  `Next: carveout check ${values.out}`,
398
772
  "",
@@ -407,14 +781,106 @@ async function main(argv) {
407
781
  if (dir === undefined)
408
782
  fail("check needs the folder a cut wrote.");
409
783
  readRecord(dir);
784
+ const accept = acceptOption(values.accept, values.repo);
785
+ // Every later cut of the folder (widen, apply, refresh) reads them as
786
+ // absent, as handoff --accept records them.
787
+ if (accept.length > 0) {
788
+ const held = trustedCut(dir, values.receipt, { optional: true });
789
+ if (held.receipt !== null && held.receiptPath !== null) {
790
+ const unseen = [...new Set([...(held.receipt.unseen ?? []), ...accept.map((test) => test.path)])].sort();
791
+ if (unseen.length !== (held.receipt.unseen ?? []).length)
792
+ writeReceipt(held.receiptPath, { ...held.receipt, unseen });
793
+ }
794
+ }
795
+ // A folder that came back holds someone else's code: the hand-off
796
+ // policy reads it first, and it runs only in a container.
797
+ const cameBack = folderCameBack(dir, receiptOption);
798
+ const returned = cameBack || accept.length > 0 || values.isolated === true;
799
+ // What patch keeps out never runs (a weakened helper the acceptance
800
+ // tests import, a new test that reads the disk): read first, without
801
+ // running anything of the folder's.
802
+ const heldBack = !returned
803
+ ? []
804
+ : await patchProject(dir, {
805
+ ...(values.repo === undefined ? {} : { repo: resolve(values.repo) }),
806
+ ...(values.allow === undefined ? {} : { allow: values.allow }),
807
+ ...receiptOption,
808
+ })
809
+ .then((patch) => patch.refused.map((refusal) => refusal.path))
810
+ // Unread, nothing would be held back: the folder's own code
811
+ // would run unread.
812
+ .catch((error) => fail(`check reads what the patch of a folder that came back holds back before running any of it, and could not (run it at the repository's root, or pass --repo): ${error instanceof Error ? error.message : String(error)}`));
813
+ let sandbox;
814
+ if (values.sandbox === true || (values.sandbox !== false && returned)) {
815
+ sandbox = defaultSandbox(values["sandbox-image"]);
816
+ const why = sandboxUnavailable(sandbox);
817
+ if (why !== null)
818
+ fail(`A folder that came back runs only in a container, with no network but for the install and nothing of this machine's mounted but its copy; ${why} Or pass --no-sandbox to run its code as you, with your files and network.`);
819
+ }
410
820
  const steps = await checkProject(dir, {
411
821
  ...(values["in-place"] === true ? { inPlace: true } : {}),
412
822
  ...(values.isolated === true ? { isolated: true } : {}),
823
+ ...(accept.length === 0 ? {} : { accept }),
824
+ ...(returned ? { held: heldBack, refuseHeld: true } : {}),
825
+ ...(sandbox === undefined ? {} : { sandbox }),
826
+ ...receiptOption,
413
827
  });
414
- const ok = steps.length === 3 && steps.every((step) => step.ok);
828
+ const refusedStep = steps.find((step) => step.command === "policy");
829
+ if (refusedStep !== undefined) {
830
+ if (values.json === true)
831
+ print({ ok: false, handoff: "failed", policy: refusedStep.output, steps });
832
+ else
833
+ process.stderr.write(`carveout: ${refusedStep.output}\n`);
834
+ process.exitCode = 6;
835
+ return;
836
+ }
837
+ const own = steps.filter((step) => step.command !== "accept");
838
+ const ok = own.length === 3 && own.every((step) => step.ok);
839
+ const acceptStep = steps.find((step) => step.command === "accept");
840
+ const checked = trustedCut(dir, values.receipt, { optional: true }).record;
841
+ const testStep = steps.find((step) => step.command === "npm test");
842
+ const restoredStep = steps.find((step) => step.restored !== undefined);
843
+ const baselineOf = testStep?.baselineInEffect !== undefined && testStep.baselineInEffect.length > 0 ? testStep.baselineInEffect : undefined;
844
+ const baselineEdited = steps.find((step) => step.baselineEdited !== undefined)?.baselineEdited;
845
+ const counts = steps.find((step) => step.counts !== undefined)?.counts;
846
+ const carried = checked.tests?.carried.length ?? 0;
847
+ // Whether "make the tests pass" is a task someone can finish here; a
848
+ // cut with blockers (a name left in a public folder) is not, whatever
849
+ // its tests say.
850
+ const handoff = !checked.ready
851
+ ? "blocked"
852
+ : !ok
853
+ ? "failed"
854
+ : baselineOf !== undefined
855
+ ? "red-baseline"
856
+ : carried === 0
857
+ ? "no-tests"
858
+ : "ready";
415
859
  if (values.json === true)
416
- print({ ok, steps });
860
+ print({
861
+ ok,
862
+ handoff,
863
+ counts: counts ?? null,
864
+ baseline: baselineOf ?? [],
865
+ baselineEdited: baselineEdited ?? [],
866
+ restored: restoredStep?.restored ?? [],
867
+ addedPackages: restoredStep?.addedPackages ?? [],
868
+ receipt: testStep?.receiptRead ?? false,
869
+ steps,
870
+ });
417
871
  else {
872
+ if (restoredStep?.restored !== undefined)
873
+ process.stdout.write(`The folder's generated files were not the cut's; the check ran on the receipt's:\n${list(restoredStep.restored)}\n`);
874
+ if (restoredStep?.addedPackages !== undefined)
875
+ process.stdout.write(`The folder's package.json adds packages the repository does not install, so the check did not either:\n${list(restoredStep.addedPackages)}\nThe repository's package files are not patched: \`carveout patch\` names the file each one goes into, for the owner to add, or take them out.\n`);
876
+ if (testStep !== undefined && testStep.receiptRead === false && values["no-receipt"] !== true)
877
+ process.stdout.write("No receipt for this folder: its own carveout/baseline.json was not read, so every failing test counts.\n");
878
+ if (baselineEdited !== undefined)
879
+ process.stdout.write(`The folder's carveout/baseline.json names ${baselineEdited.length} test(s) the receipt's baseline does not; they were counted:\n${list(baselineEdited)}\n`);
880
+ if (sandbox !== undefined)
881
+ process.stdout.write(`Ran in a container (${sandbox.runtime}, ${sandbox.image}): only the check's copy mounted, no network but for the install (scripts off).${heldBack.length === 0 ? "" : ` patch keeps ${heldBack.length} file(s) out (${heldBack.join(", ")}): the tests, helpers and setup files among them ran as the cut wrote them, and none the folder added.`}\n`);
882
+ else if (cameBack)
883
+ process.stdout.write("Ran on this machine, as you (--no-sandbox): the folder's code had your files and network.\n");
418
884
  for (const step of steps) {
419
885
  process.stdout.write(`${step.ok ? "ok " : "FAIL"} ${step.command}\n`);
420
886
  if (!step.ok || step.baseline !== undefined)
@@ -428,14 +894,45 @@ async function main(argv) {
428
894
  process.stdout.write(record.optionalPeers.some((peer) => peer.name === name)
429
895
  ? `${name} is an optional peer the repository installs: cut again with --install ${name}.\n`
430
896
  : `${name} is not installed: cut again with --install ${name} if the repository installs it, or --mock ${name}.\n`);
431
- const baseline = steps.find((step) => step.baseline !== undefined)?.baseline;
432
- process.stdout.write(!ok
433
- ? "The slice does not stand on its own yet.\n"
434
- : baseline === undefined
435
- ? "The slice installs, builds and tests on its own.\n"
436
- : `The slice installs, builds and tests on its own, but for ${baseline.length} test(s) that fail on the mocks (carveout/baseline.json).\n`);
897
+ const edited = steps.some((step) => step.edited === true);
898
+ const passing = countsSentence(counts, edited);
899
+ const readiness = checked.readiness ?? trustedCut(dir, values.receipt, { optional: true }).receipt?.readiness;
900
+ const advice = readiness !== undefined && hasAdvice(readiness)
901
+ ? ["To run them on real code instead, cut again with:", ...renderReadiness(readiness)]
902
+ : readiness !== undefined
903
+ ? [
904
+ "Nothing a cut's flags change runs them on real code: they test what the slice cuts. Leave them out of the task, or widen the cut (more entries, or --depth) to hold what they test.",
905
+ ]
906
+ : ["Cut again, on this machine, to see what would run them on real code (--copy-pure, --live, --copy)."];
907
+ process.stdout.write(`${[
908
+ handoff === "blocked"
909
+ ? `Not ready to hand over: the cut has blockers (${checked.blockers.map((blocker) => blocker.code).join(", ")}); carveout status names them.${passing}`
910
+ : handoff === "failed"
911
+ ? edited && steps.some((step) => step.command === "npm test" && !step.ok)
912
+ ? "npm test fails on the folder's edits (above): what it lists failed after the edits and not before them, on the same mocks."
913
+ : "The slice does not stand on its own yet."
914
+ : handoff === "red-baseline"
915
+ ? edited
916
+ ? `The edited folder installs, builds and passes its tests, but for the ${baselineOf?.length ?? 0} test(s) that failed on the mocks before any edit (not counted). A pass here is a pass against mocks: the repository's CI decides.${passing}`
917
+ : `The slice installs, builds and tests on its own, but ${baselineOf?.length ?? 0} test(s) fail on the mocks before any edit (carveout/baseline.json): a contributor cannot make those pass here, so a fixed task should not rest on them.${passing}`
918
+ : handoff === "no-tests"
919
+ ? `The slice installs, builds and tests on its own, but no test of this code came with it: a pass means it builds and keeps its exports. Cut with --with-tests, or write the tests the work must pass before handing it over.${passing}`
920
+ : edited
921
+ ? `The edited folder installs, builds and passes its tests (${carried} test file(s) from the repository among them). A pass here is a pass against mocks: the repository's CI decides.${passing}`
922
+ : `Ready to hand over: the slice installs, builds, and its tests pass before any edit (${carried} test file(s) from the repository).${passing}`,
923
+ ...(handoff === "red-baseline" ? advice : []),
924
+ ...(accept.length === 0
925
+ ? []
926
+ : [
927
+ acceptStep === undefined
928
+ ? `The owner's ${accept.length} acceptance test file(s) did not run: the folder did not install or build (above).`
929
+ : acceptStep.ok
930
+ ? `The owner's ${accept.length} acceptance test file(s) ran on their own, in the check's copy, and pass.${heldBack.length === 0 ? "" : ` patch keeps ${heldBack.length} file(s) out (${heldBack.join(", ")}): the run had the tests, helpers and setup files among them as the cut wrote them and none the folder added.`}`
931
+ : `The owner's acceptance tests do not pass on the folder that came back:\n${acceptStep.output}`,
932
+ ]),
933
+ ].join("\n")}\n`);
437
934
  }
438
- if (!ok)
935
+ if (!ok || (accept.length > 0 && acceptStep?.ok !== true))
439
936
  process.exitCode = 2;
440
937
  return;
441
938
  }
@@ -446,6 +943,7 @@ async function main(argv) {
446
943
  const given = repoFor(dir);
447
944
  const status = await statusProject(dir, {
448
945
  ...(given === undefined ? {} : { repo: given }),
946
+ ...receiptOption,
449
947
  });
450
948
  if (values.json === true)
451
949
  print(status);
@@ -473,9 +971,13 @@ async function main(argv) {
473
971
  const patch = await patchProject(dir, {
474
972
  ...(given === undefined ? {} : { repo: given }),
475
973
  ...(values.allow === undefined ? {} : { allow: values.allow }),
974
+ ...receiptOption,
476
975
  });
477
976
  const boundary = new Set(patch.boundary.map((edit) => edit.path));
478
977
  const notes = [
978
+ ...(patch.recordFrom === "folder"
979
+ ? ["no receipt for this folder: it was judged by its own carveout.json, checked against the repository (pass --receipt <file> if the cut wrote one elsewhere)."]
980
+ : []),
479
981
  ...patch.generatedEdits
480
982
  .filter((path) => !boundary.has(path))
481
983
  .map((path) => `${path} is generated; its edit is not in the patch.`),
@@ -490,6 +992,12 @@ async function main(argv) {
490
992
  ]),
491
993
  ...patch.noThreeWay.map((path) => `${path} was cut from uncommitted changes, so --3way cannot merge it; apply it by hand if git apply refuses.`),
492
994
  ...patch.deleted.map((path) => `deletes ${path}.`),
995
+ ...(patch.renamed ?? []).map(({ from, to }) => `${from} is a new test where the repository already holds a file the slice did not (an acceptance test of yours, say); the patch adds it as ${to}, so both run.`),
996
+ ...(patch.skipped.length === 0
997
+ ? []
998
+ : [
999
+ `not in the patch, and not judged (the folder's own tests may run them): ${patch.skipped.join(", ")}.`,
1000
+ ]),
493
1001
  ...patch.refused.map(({ path, reason }) => `kept out: ${path}, ${reason}.`),
494
1002
  ...(patch.sliceOnly.length === 0
495
1003
  ? []
@@ -536,6 +1044,7 @@ async function main(argv) {
536
1044
  const applied = await applyPatch(dir, readFileSync(file, "utf8"), {
537
1045
  ...(values.stubs === true ? { stubs: true } : {}),
538
1046
  ...(given === undefined ? {} : { repo: given }),
1047
+ ...receiptOption,
539
1048
  });
540
1049
  if (values.json === true)
541
1050
  print(applied);
@@ -543,11 +1052,115 @@ async function main(argv) {
543
1052
  process.stdout.write(`${applied.changed.length} changed, ${applied.added.length} new and ${applied.deleted.length} deleted slice files.\n`);
544
1053
  if (applied.outside.length > 0)
545
1054
  process.stdout.write(`Outside the slice, not applied:\n${list(applied.outside)}\n`);
1055
+ if (applied.merged.length > 0)
1056
+ process.stdout.write(`Merged into the folder's own edits:\n${list(applied.merged)}\n`);
1057
+ if (applied.conflicts.length > 0)
1058
+ process.stdout.write(`The folder's edits and the change overlap here; left as the folder had them, to merge by hand:\n${list(applied.conflicts)}\n`);
546
1059
  if (applied.stubs.length > 0)
547
1060
  process.stdout.write(`${applied.stubs.some((stub) => stub.written) ? "Stubs written" : "Stubs the change needs (apply with --stubs to write them)"}:\n${list(applied.stubs.map((stub) => `${stub.path}: ${stub.detail}`))}\n`);
1061
+ if (applied.leaks.length > 0)
1062
+ process.stdout.write(leaksText(applied.leaks));
1063
+ if (applied.unmapped.length > 0)
1064
+ process.stdout.write(unmappedText(applied.unmapped));
548
1065
  }
549
1066
  if (applied.stubs.some((stub) => !stub.written))
550
1067
  process.exitCode = 5;
1068
+ if (applied.leaks.length > 0 || applied.unmapped.length > 0)
1069
+ process.exitCode = 2;
1070
+ if (applied.conflicts.length > 0)
1071
+ process.exitCode = 7;
1072
+ return;
1073
+ }
1074
+ case "refresh": {
1075
+ const [dir] = positionals;
1076
+ if (dir === undefined)
1077
+ fail("refresh needs the folder a cut wrote.");
1078
+ const given = repoFor(dir);
1079
+ const refreshed = await refreshProject(dir, {
1080
+ ...(given === undefined ? {} : { repo: given }),
1081
+ ...(values.resolved === undefined ? {} : { resolved: values.resolved }),
1082
+ ...receiptOption,
1083
+ log: (line) => {
1084
+ if (values.json !== true)
1085
+ process.stderr.write(`${line}\n`);
1086
+ },
1087
+ });
1088
+ if (values.json === true)
1089
+ print(refreshed);
1090
+ else if (refreshed === null)
1091
+ process.stdout.write("Nothing the slice reads has moved since the cut.\n");
1092
+ else {
1093
+ const { applied } = refreshed;
1094
+ process.stdout.write([
1095
+ `Brought ${refreshed.from.slice(0, 8)}..${refreshed.to.slice(0, 8)} into the folder: ${applied.changed.length} changed, ${applied.added.length} new, ${applied.deleted.length} deleted.`,
1096
+ ...(applied.merged.length === 0 ? [] : [`Merged into the folder's own edits:\n${list(applied.merged)}`]),
1097
+ ...(applied.conflicts.length === 0
1098
+ ? []
1099
+ : [
1100
+ `The folder's edits and the change overlap here; left as the folder had them, to merge by hand:\n${list(applied.conflicts)}\nMerge each by hand, then: carveout refresh ${dir} --resolved <path> (each), which takes the folder's text as resolved.`,
1101
+ ]),
1102
+ ...(applied.stubs.length === 0 ? [] : [`Stubs written:\n${list(applied.stubs.map((stub) => `${stub.path}: ${stub.detail}`))}`]),
1103
+ "",
1104
+ ].join("\n"));
1105
+ if (applied.leaks.length > 0)
1106
+ process.stdout.write(leaksText(applied.leaks));
1107
+ if (applied.unmapped.length > 0)
1108
+ process.stdout.write(unmappedText(applied.unmapped));
1109
+ if (refreshed.copiedTaken !== undefined)
1110
+ process.stdout.write(`Copied files the folder had not edited, taken as the repository has them:\n${list(refreshed.copiedTaken)}\n`);
1111
+ if (refreshed.copiedLeft !== undefined)
1112
+ process.stdout.write(`Changed in the repository, in a folder the cut copied, and edited in the folder: not written over its copy. Bring each change by hand, then: carveout refresh ${dir} --resolved <path>:\n${list(refreshed.copiedLeft)}\n`);
1113
+ if (refreshed.overlapResolved !== undefined)
1114
+ process.stdout.write(`Taken as resolved where the repository changed the same lines: check the folder's text keeps what it needs of that change (the next patch reverts what it leaves out):\n${list(refreshed.overlapResolved)}\n`);
1115
+ if (refreshed.droppedUpstream !== undefined)
1116
+ process.stdout.write(`Taken as resolved, though the folder's text leaves out some of the repository's change to it (the next patch reverts those lines; merge them in if that is not meant):\n${list(refreshed.droppedUpstream)}\n`);
1117
+ }
1118
+ if (refreshed !== null && (refreshed.applied.leaks.length > 0 || refreshed.applied.unmapped.length > 0))
1119
+ process.exitCode = 2;
1120
+ // An edited copy the repository changed is an overlap too: merged by hand.
1121
+ if (refreshed !== null && (refreshed.applied.conflicts.length > 0 || refreshed.copiedLeft !== undefined))
1122
+ process.exitCode = 7;
1123
+ return;
1124
+ }
1125
+ case "widen": {
1126
+ const [dir] = positionals;
1127
+ if (dir === undefined || values.entry === undefined || values.entry.length === 0)
1128
+ fail("widen needs the folder and at least one --entry <path> to add.");
1129
+ const widened = await widenProject(dir, {
1130
+ entries: values.entry,
1131
+ ...(values.out === undefined ? {} : { out: values.out }),
1132
+ ...(values.depth === undefined ? {} : { maxDepth: integer(values.depth, "depth", 0) }),
1133
+ ...(values["max-files"] === undefined ? {} : { maxFiles: integer(values["max-files"], "max-files", 0) }),
1134
+ ...(values.repo === undefined ? {} : { repo }),
1135
+ ...(values.allow === undefined ? {} : { allow: values.allow }),
1136
+ ...receiptOption,
1137
+ log: (line) => {
1138
+ if (values.json !== true)
1139
+ process.stderr.write(`${line}\n`);
1140
+ },
1141
+ });
1142
+ if (values.json === true)
1143
+ print({
1144
+ out: widened.out,
1145
+ ready: widened.cut.record.ready,
1146
+ receipt: widened.cut.receipt,
1147
+ carried: widened.applied === null ? [] : [...widened.applied.changed, ...widened.applied.added],
1148
+ outside: widened.applied?.outside ?? [],
1149
+ blockers: widened.cut.record.blockers,
1150
+ });
1151
+ else
1152
+ process.stdout.write([
1153
+ `Widened: ${widened.out}`,
1154
+ ` Entries: ${widened.cut.record.request.entryPoints.join(", ")}.`,
1155
+ widened.applied === null
1156
+ ? " The folder held no work to carry."
1157
+ : ` The work carried into it: ${[...widened.applied.changed, ...widened.applied.added].length} file(s)${widened.applied.outside.length === 0 ? "" : `; outside it still: ${widened.applied.outside.join(", ")}`}.`,
1158
+ ...(widened.cut.receipt === null ? [] : [`Receipt: ${widened.cut.receipt}`]),
1159
+ `Next: carveout check ${widened.out}, then hand it over in place of the old one.`,
1160
+ "",
1161
+ ].join("\n"));
1162
+ if (!widened.cut.record.ready)
1163
+ process.exitCode = 2;
551
1164
  return;
552
1165
  }
553
1166
  case "merge": {
@@ -556,18 +1169,32 @@ async function main(argv) {
556
1169
  fail("merge needs the folder a cut wrote.");
557
1170
  if (values.into === undefined)
558
1171
  fail("merge needs --into <checkout>: the checkout to apply the edits in.");
1172
+ // The verify command runs the folder's code: in a container unless
1173
+ // told otherwise, checked before anything is applied.
1174
+ let verifySandbox;
1175
+ if (values.verify !== undefined && values.sandbox !== false) {
1176
+ verifySandbox = defaultSandbox(values["sandbox-image"]);
1177
+ const why = sandboxUnavailable(verifySandbox);
1178
+ if (why !== null)
1179
+ fail(`merge --verify runs the folder's code only in a container, on a copy of the checkout with no network but for the install; nothing was applied. ${why} Or pass --no-sandbox to run it in the checkout as you, with your files and network.`);
1180
+ }
559
1181
  const merge = await mergeProject(dir, {
560
1182
  into: values.into,
561
1183
  ...(values.verify === undefined ? {} : { verify: values.verify }),
562
1184
  ...(values.repo === undefined ? {} : { repo }),
563
1185
  ...(values.allow === undefined ? {} : { allow: values.allow }),
564
1186
  ...(values["verify-env"] === undefined ? {} : { verifyEnv: values["verify-env"] }),
1187
+ ...(verifySandbox === undefined ? {} : { sandbox: verifySandbox }),
1188
+ ...(values["verify-setup"] === undefined ? {} : { verifySetup: values["verify-setup"] }),
1189
+ ...receiptOption,
565
1190
  });
566
1191
  if (values.json === true)
567
1192
  print(merge);
568
1193
  else {
569
1194
  for (const edit of merge.patch.boundary)
570
1195
  process.stderr.write(`carveout: boundary: ${edit.detail}\n`);
1196
+ for (const { from, to } of merge.patch.renamed ?? [])
1197
+ process.stderr.write(`carveout: ${from} is a new test where the repository already holds a file; applied as ${to}.\n`);
571
1198
  for (const step of merge.steps) {
572
1199
  process.stdout.write(`${step.ok ? "ok " : "FAIL"} ${step.step}: ${step.ok && step.step === "verify" ? "" : step.output}\n`);
573
1200
  }