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
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Whether a folder is ready to hand to someone at a fixed scope: its tests
3
+ * must pass before any edit, or "make the tests pass" is not a task anyone
4
+ * can finish in it. A test fails at the folder's baseline when it reaches a
5
+ * mock for something the repository runs for real: pure code past the cut
6
+ * (a validator, a formatter), a database or queue client, or a folder the
7
+ * code reads by a name no import spells (drizzle's `drizzle/` migrations,
8
+ * Prisma's `prisma/`). The cut knows which of these the slice has, and
9
+ * names the flag that brings each: `--copy-pure`, `--live`, `--copy`.
10
+ *
11
+ * The advice is the maintainer's: a public cut prints it and keeps it out
12
+ * of the folder, since the paths it names may lie outside what the folder
13
+ * shows.
14
+ */
15
+ import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
16
+ import { join, posix } from "node:path";
17
+ /** Services a maintainer can run beside the folder, and what each needs. */
18
+ const LIVE_SERVICES = {
19
+ postgres: "a Postgres server",
20
+ mysql: "a MySQL server",
21
+ mongodb: "a MongoDB server",
22
+ redis: "a Redis server",
23
+ queue: "the queue's broker",
24
+ orm: "the database the ORM talks to",
25
+ sqlite: "nothing: SQLite is a file",
26
+ };
27
+ /** The mocked service packages `--live` could keep real, by name. */
28
+ export function liveCandidates(dependencies, serviceOf) {
29
+ return dependencies
30
+ .filter((dependency) => dependency.kind === "package" && dependency.resolution === "runtime-mock")
31
+ .flatMap((dependency) => {
32
+ const service = serviceOf(dependency.name);
33
+ return service !== null && LIVE_SERVICES[service] !== undefined
34
+ ? [{ name: dependency.name, service }]
35
+ : [];
36
+ })
37
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
38
+ }
39
+ /** The files under a folder, as a count; 0 when it is not one. */
40
+ function fileCount(absolute) {
41
+ if (!existsSync(absolute) || !statSync(absolute).isDirectory())
42
+ return 0;
43
+ let count = 0;
44
+ for (const entry of readdirSync(absolute, { recursive: true, withFileTypes: true }))
45
+ if (entry.isFile())
46
+ count += 1;
47
+ return count;
48
+ }
49
+ /** The text of the first of `names` under `dir` that exists, with its name. */
50
+ function firstText(repo, dir, names) {
51
+ for (const name of names) {
52
+ const path = join(repo, dir, name);
53
+ if (existsSync(path) && statSync(path).isFile())
54
+ return { name, text: readFileSync(path, "utf8") };
55
+ }
56
+ return null;
57
+ }
58
+ /** A relative folder named in a config file, under the package, normalized. */
59
+ function underPackage(dir, named) {
60
+ const joined = posix.normalize(posix.join(dir, named.replace(/^\.\//, ""))).replace(/\/+$/, "");
61
+ // The package itself (`out: "./"`) is no folder to copy beside the slice.
62
+ if (joined.startsWith("..") || posix.isAbsolute(joined) || joined === "." || joined === posix.normalize(dir || "."))
63
+ return null;
64
+ return joined;
65
+ }
66
+ /**
67
+ * The folders the slice's packages read by a name their tools default to
68
+ * or their configuration spells, which the walk cannot follow: drizzle's
69
+ * migrations (`out` in `drizzle.config.*`, else `drizzle/` with its
70
+ * journal), Prisma's schema and migrations (`prisma/`), knex's
71
+ * (`directory` in a knexfile, else `migrations/`), and a package's own
72
+ * `migrations/` folder. Folders the cut copies already, or holds files of,
73
+ * are left out.
74
+ */
75
+ export function configuredFolders(repo, packageDirs, held) {
76
+ const found = new Map();
77
+ const add = (path, why) => {
78
+ if (path === null || found.has(path) || held(path))
79
+ return;
80
+ const files = fileCount(join(repo, path));
81
+ if (files > 0)
82
+ found.set(path, { path, files, why });
83
+ };
84
+ for (const dir of [...new Set(packageDirs)].sort()) {
85
+ const drizzle = firstText(repo, dir, [
86
+ "drizzle.config.ts",
87
+ "drizzle.config.mts",
88
+ "drizzle.config.js",
89
+ "drizzle.config.mjs",
90
+ "drizzle.config.cjs",
91
+ "drizzle.config.json",
92
+ ]);
93
+ const out = drizzle === null ? null : /["']?\bout["']?\s*:\s*["'`]([^"'`]+)["'`]/.exec(drizzle.text)?.[1];
94
+ if (out !== undefined && out !== null)
95
+ add(underPackage(dir, out), `drizzle's migrations (out in ${drizzle?.name})`);
96
+ else if (existsSync(join(repo, dir, "drizzle", "meta", "_journal.json")))
97
+ add(underPackage(dir, "drizzle"), "drizzle's migrations, which migrate() reads by its default name");
98
+ if (existsSync(join(repo, dir, "prisma", "schema.prisma")))
99
+ add(underPackage(dir, "prisma"), "Prisma's schema and migrations");
100
+ const knex = firstText(repo, dir, ["knexfile.ts", "knexfile.js", "knexfile.mjs", "knexfile.cjs"]);
101
+ // The migrations' directory, not the seeds': inside `migrations: { … }`.
102
+ const directory = knex === null ? undefined : /\bmigrations\s*:\s*\{[^}]*?\bdirectory\s*:\s*["'`]([^"'`]+)["'`]/s.exec(knex.text)?.[1];
103
+ if (directory !== undefined)
104
+ add(underPackage(dir, directory), `knex's migrations (directory in ${knex?.name})`);
105
+ add(underPackage(dir, "migrations"), "a migrations folder the code reads by name");
106
+ }
107
+ return [...found.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
108
+ }
109
+ /** Whether the readiness names anything the maintainer could do. */
110
+ export function hasAdvice(readiness) {
111
+ return ((readiness.copyPure !== null && readiness.copyPure.files.length > 0) ||
112
+ readiness.live.length > 0 ||
113
+ readiness.copy.length > 0);
114
+ }
115
+ /**
116
+ * The advice as lines for a terminal: each flag with what it brings and
117
+ * what it costs (more of the repository shown, or a service to run).
118
+ */
119
+ export function renderReadiness(readiness) {
120
+ const lines = [];
121
+ if (readiness.copyPure !== null && readiness.copyPure.files.length > 0) {
122
+ const shown = readiness.copyPure.files.slice(0, 5).join(", ");
123
+ const more = readiness.copyPure.files.length > 5 ? `, and ${readiness.copyPure.files.length - 5} more` : "";
124
+ lines.push(` --copy-pure${readiness.copyPure.needsMaxFiles === undefined ? "" : ` --max-files ${readiness.copyPure.needsMaxFiles}`}: copies ${readiness.copyPure.files.length} module(s) the slice mocks now (${readiness.copyPure.lines} lines: ${shown}${more}): the pure code its tests run, and the workspace source on the way; the contributor reads them.${readiness.copyPure.needsMaxFiles === undefined ? "" : ` The current file budget would leave some out, and the tests with them.`}`);
125
+ }
126
+ for (const { name, service } of readiness.live)
127
+ lines.push(` --live ${name}: keeps the ${service} client real; the folder's tests then need ${LIVE_SERVICES[service] ?? "the service"}.`);
128
+ for (const { path, files, why } of readiness.copy)
129
+ lines.push(` --copy ${path}: ${why}, ${files} file(s); the contributor reads them.`);
130
+ return lines;
131
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * The maintainer's copy of a cut, kept outside the folder.
3
+ *
4
+ * A folder handed to someone outside comes back in their hands: its
5
+ * `carveout.json` and `carveout/baseline.json` are theirs to edit. The
6
+ * record decides what a patch may carry (the slice's files and packages)
7
+ * and how its imports are restored; the baseline decides which failing
8
+ * tests `npm test` does not count. Both are written again, at the cut and
9
+ * at the first check, into a receipt under `CARVEOUT_HOME/receipts/`, named
10
+ * by the cut's random id, which the folder carries. `patch`, `status`,
11
+ * `apply`, `merge` and `check` read the receipt when there is one, and the
12
+ * folder's own record only when there is none (a folder cut on another
13
+ * machine, or a receipt deleted): then the record is checked against the
14
+ * repository as before.
15
+ *
16
+ * With the receipt holding the record, a public folder's own record needs
17
+ * none of what names the repository beyond the slice: the commit, the
18
+ * hashes of the repository's other files, the import specifiers the cut
19
+ * rewrote (workspace package names, path aliases), the private paths.
20
+ * `publicRecord` leaves them out.
21
+ */
22
+ import { type CarveoutRecord } from "./carve.js";
23
+ import { type FolderPseudonyms } from "./pseudonyms/index.js";
24
+ import type { Readiness } from "./readiness.js";
25
+ export declare const RECEIPT_SCHEMA_VERSION = 1;
26
+ /** What the maintainer keeps of a cut. */
27
+ export interface Receipt {
28
+ readonly schemaVersion: typeof RECEIPT_SCHEMA_VERSION;
29
+ /** The cut's id, which the folder's record carries too. */
30
+ readonly cutId: string;
31
+ readonly createdAt: string;
32
+ /** The checkout the cut read, absolute. */
33
+ readonly repo: string;
34
+ /** The folder the cut wrote, absolute, as it was then. */
35
+ readonly out: string;
36
+ /** The record as the cut made it, nothing left out. */
37
+ readonly record: CarveoutRecord;
38
+ /** The tests that failed on the mocks before any edit, once a check recorded them. */
39
+ readonly baseline?: readonly string[];
40
+ /**
41
+ * The record as the folder holds it, when that differs from `record`: a
42
+ * public folder's, scrubbed, and in pseudonyms by its own paths and
43
+ * hashes. What an edit in the folder is measured against.
44
+ */
45
+ readonly folderRecord?: CarveoutRecord;
46
+ /** A public folder's pseudonyms: the table and the kept names. */
47
+ readonly pseudonyms?: FolderPseudonyms;
48
+ /**
49
+ * The generated files as the folder holds them, by folder path: the test
50
+ * runner, the harness, the stubs and mocks, the manifests. `check` runs a
51
+ * folder that came back on these, not on what it brought.
52
+ */
53
+ readonly generatedText?: Readonly<Record<string, string>>;
54
+ /** What would make the folder's tests run on real code (`readinessOf`), public cut or not. */
55
+ readonly readiness?: Readiness;
56
+ /** Files every cut of this folder reads as absent: the owner's acceptance tests. */
57
+ readonly unseen?: readonly string[];
58
+ /**
59
+ * The test side of the slice as the folder held it at the cut (its tests,
60
+ * their helpers, the setup files), by folder path: what an acceptance
61
+ * run puts back where patch keeps the folder's edit out.
62
+ */
63
+ readonly testText?: Readonly<Record<string, string>>;
64
+ }
65
+ /** The record as the folder holds it: what its files are measured against. */
66
+ export declare const folderView: (receipt: Receipt) => CarveoutRecord;
67
+ /** A new cut id: 128 random bits, as hex. */
68
+ export declare function newCutId(): string;
69
+ /** Where a cut's receipt is kept unless the maintainer names a file. */
70
+ export declare function receiptPathFor(cutId: string): string;
71
+ /** Writes a receipt where only its owner reads it, replacing it whole. */
72
+ export declare function writeReceipt(path: string, receipt: Receipt): void;
73
+ /** Reads a receipt, or throws when the file is not one. */
74
+ export declare function readReceipt(path: string): Receipt;
75
+ /** A receipt given as data (`api.ts`: kept by the caller, not a file), or throws when it is not one; `label` names it. */
76
+ export declare function asReceipt(parsed: unknown, label?: string): Receipt;
77
+ /** A folder's record, and the receipt that vouches for it when there is one. */
78
+ export interface TrustedCut {
79
+ /** The record to act on: the receipt's, else the folder's own. */
80
+ readonly record: CarveoutRecord;
81
+ /** The folder's own record, as it came back. */
82
+ readonly folder: CarveoutRecord;
83
+ readonly receipt: Receipt | null;
84
+ readonly receiptPath: string | null;
85
+ }
86
+ /**
87
+ * The record a command on a folder acts on. `given` names a receipt file
88
+ * (`--receipt`); else the receipt of the cut id the folder's record names
89
+ * is looked for under `CARVEOUT_HOME`. A receipt of another cut is refused.
90
+ * Without one, the folder's own record is all there is. `trustedCutWith`
91
+ * takes the receipt as data instead, and reads no file but the folder's.
92
+ */
93
+ export declare function trustedCut(dir: string, given?: string, options?: {
94
+ /**
95
+ * Go on without a receipt: the folder's own record is all there is,
96
+ * checked against the repository (`--no-receipt`). Without it, a folder
97
+ * whose receipt cannot be found is refused: the folder decides which
98
+ * id it carries, and so whether one is found.
99
+ */
100
+ readonly noReceipt?: boolean;
101
+ /** `check`: go on without one, and trust nothing of the folder's own (its baseline). */
102
+ readonly optional?: boolean;
103
+ }): TrustedCut;
104
+ /**
105
+ * The record a command on a folder acts on, by a receipt given as data
106
+ * (`api.ts`): checked as `trustedCut` checks a receipt file, with no
107
+ * receipt looked for under `CARVEOUT_HOME`.
108
+ */
109
+ export declare function trustedCutWith(dir: string, given: unknown): TrustedCut;
110
+ /**
111
+ * The record a public folder carries: what the folder's own commands need
112
+ * (its files and their hashes, what it stubs, its tests), less what names
113
+ * the repository beyond the slice, which the receipt keeps: the commit and
114
+ * the graph's hash, the hashes of the repository's other files, the
115
+ * specifiers the cut rewrote (a workspace package's name, a path alias),
116
+ * and the private paths the owner keeps out.
117
+ */
118
+ export declare function publicRecord(record: CarveoutRecord): CarveoutRecord;
119
+ /**
120
+ * The record a public folder holds, made from the cut's: scrubbed
121
+ * (`publicRecord`), a leak's blocker kept without the name it is about,
122
+ * and for a folder in pseudonyms, in its names, with its files' folder
123
+ * paths and the hashes of what the folder holds at them (`dir`), so its
124
+ * own commands (the coverage guard, check) read it as written.
125
+ */
126
+ export declare function folderRecordFor(record: CarveoutRecord, table: FolderPseudonyms | undefined, dir: string,
127
+ /** A hash already known for a folder path, over what the folder holds there now. */
128
+ known?: (folderPath: string) => string | undefined): CarveoutRecord;
129
+ /**
130
+ * Writes a record a command changed (`apply` growing the slice, writing
131
+ * stubs) to the folder and, when the cut has one, to its receipt: the
132
+ * folder's copy public-scrubbed as the cut wrote it.
133
+ */
134
+ export declare function saveRecord(dir: string, trusted: TrustedCut, updated: CarveoutRecord,
135
+ /** Generated files the command wrote into the folder, by folder path. */
136
+ written?: Readonly<Record<string, string>>,
137
+ /**
138
+ * Slice files whose hash the record takes anew, by folder path: as the
139
+ * command wrote them, the folder's edits aside (`refresh` moving the
140
+ * folder's base). Every other path keeps the hash it had.
141
+ */
142
+ rebased?: ReadonlyMap<string, string>): TrustedCut;
143
+ /**
144
+ * The baseline a check of the folder reads, given its receipt and the
145
+ * folder's own `carveout/baseline.json` (its failing names, or null when it
146
+ * has none): the receipt's when it holds one, none when the receipt holds
147
+ * none (a baseline the receipt never saw is not read), the folder's own
148
+ * when there is no receipt to judge it by. `edited` names what the
149
+ * folder's own adds beyond the receipt's.
150
+ */
151
+ export declare function trustedBaseline(receipt: Pick<Receipt, "baseline"> | null, folder: readonly string[] | null): {
152
+ readonly use: readonly string[] | null | "folder";
153
+ readonly edited: readonly string[];
154
+ };
@@ -0,0 +1,296 @@
1
+ /**
2
+ * The maintainer's copy of a cut, kept outside the folder.
3
+ *
4
+ * A folder handed to someone outside comes back in their hands: its
5
+ * `carveout.json` and `carveout/baseline.json` are theirs to edit. The
6
+ * record decides what a patch may carry (the slice's files and packages)
7
+ * and how its imports are restored; the baseline decides which failing
8
+ * tests `npm test` does not count. Both are written again, at the cut and
9
+ * at the first check, into a receipt under `CARVEOUT_HOME/receipts/`, named
10
+ * by the cut's random id, which the folder carries. `patch`, `status`,
11
+ * `apply`, `merge` and `check` read the receipt when there is one, and the
12
+ * folder's own record only when there is none (a folder cut on another
13
+ * machine, or a receipt deleted): then the record is checked against the
14
+ * repository as before.
15
+ *
16
+ * With the receipt holding the record, a public folder's own record needs
17
+ * none of what names the repository beyond the slice: the commit, the
18
+ * hashes of the repository's other files, the import specifiers the cut
19
+ * rewrote (workspace package names, path aliases), the private paths.
20
+ * `publicRecord` leaves them out.
21
+ */
22
+ import { createHash, randomBytes } from "node:crypto";
23
+ import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
24
+ import { dirname, join, resolve } from "node:path";
25
+ import { RECORD_PATH, carveoutHome, readRecord } from "./carve.js";
26
+ import { exactToFolder, recordToFolder, toFolder } from "./pseudonyms/index.js";
27
+ export const RECEIPT_SCHEMA_VERSION = 1;
28
+ /** The record as the folder holds it: what its files are measured against. */
29
+ export const folderView = (receipt) => receipt.folderRecord ?? receipt.record;
30
+ /** A new cut id: 128 random bits, as hex. */
31
+ export function newCutId() {
32
+ return randomBytes(16).toString("hex");
33
+ }
34
+ const CUT_ID = /^[0-9a-f]{32}$/;
35
+ /** Where a cut's receipt is kept unless the maintainer names a file. */
36
+ export function receiptPathFor(cutId) {
37
+ if (!CUT_ID.test(cutId))
38
+ throw new Error(`${JSON.stringify(cutId)} is not a cut id.`);
39
+ return join(carveoutHome(), "receipts", `${cutId}.json`);
40
+ }
41
+ /** Writes a receipt where only its owner reads it, replacing it whole. */
42
+ export function writeReceipt(path, receipt) {
43
+ const target = resolve(path);
44
+ mkdirSync(dirname(target), { recursive: true, mode: 0o700 });
45
+ // A name no one can have made first: the file is created, never followed.
46
+ const temporary = `${target}.${randomBytes(6).toString("hex")}.tmp`;
47
+ writeFileSync(temporary, `${JSON.stringify(receipt, null, 2)}\n`, { mode: 0o600, flag: "wx" });
48
+ chmodSync(temporary, 0o600);
49
+ renameSync(temporary, target);
50
+ }
51
+ /** Reads a receipt, or throws when the file is not one. */
52
+ export function readReceipt(path) {
53
+ let parsed;
54
+ try {
55
+ parsed = JSON.parse(readFileSync(path, "utf8"));
56
+ }
57
+ catch (error) {
58
+ throw new Error(`${path} is not a receipt: ${error instanceof Error ? error.message : String(error)}`);
59
+ }
60
+ return asReceipt(parsed, path);
61
+ }
62
+ /** A receipt given as data (`api.ts`: kept by the caller, not a file), or throws when it is not one; `label` names it. */
63
+ export function asReceipt(parsed, label = "The receipt given") {
64
+ const receipt = parsed;
65
+ if (receipt === null ||
66
+ typeof receipt !== "object" ||
67
+ receipt.schemaVersion !== RECEIPT_SCHEMA_VERSION ||
68
+ typeof receipt.cutId !== "string" ||
69
+ !CUT_ID.test(receipt.cutId) ||
70
+ typeof receipt.repo !== "string" ||
71
+ receipt.record === undefined)
72
+ throw new Error(`${label} is not a carveout receipt this version reads.`);
73
+ return receipt;
74
+ }
75
+ /** A Git object id, SHA-1 or SHA-256. */
76
+ const COMMIT_ID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
77
+ /**
78
+ * The record a command on a folder acts on. `given` names a receipt file
79
+ * (`--receipt`); else the receipt of the cut id the folder's record names
80
+ * is looked for under `CARVEOUT_HOME`. A receipt of another cut is refused.
81
+ * Without one, the folder's own record is all there is. `trustedCutWith`
82
+ * takes the receipt as data instead, and reads no file but the folder's.
83
+ */
84
+ export function trustedCut(dir, given, options = {}) {
85
+ const hasRecord = existsSync(join(resolve(dir), RECORD_PATH));
86
+ const named = given !== undefined ? resolve(given) : null;
87
+ if (!hasRecord && named === null)
88
+ readRecord(dir);
89
+ const folderOwn = hasRecord ? readRecord(dir) : null;
90
+ const path = named ??
91
+ (folderOwn !== null && typeof folderOwn.cutId === "string" && CUT_ID.test(folderOwn.cutId) && existsSync(receiptPathFor(folderOwn.cutId))
92
+ ? receiptPathFor(folderOwn.cutId)
93
+ : null);
94
+ if (path === null) {
95
+ const folder = folderOwn;
96
+ if (options.noReceipt === true || options.optional === true) {
97
+ if (folder.publicScrubbed === true && options.optional !== true)
98
+ throw new Error(`${dir} is a public folder whose record keeps the repository's names out; its receipt holds them. Pass --receipt <file> (the cut printed where it wrote it), or work on the machine that cut it.`);
99
+ // A folder's own record names the commits patch, refresh and status
100
+ // pass to git: each must be an object id, never an option.
101
+ const commits = [folder.manifest?.sourceCommitSha ?? null, ...Object.values(folder.bases ?? {})].filter((commit) => commit !== null);
102
+ const notCommits = commits.filter((commit) => typeof commit !== "string" || !COMMIT_ID.test(commit));
103
+ if (notCommits.length > 0)
104
+ throw new Error(`${dir}/carveout.json names a commit that is not an object id (${notCommits.map(String).join(", ")}): cut the folder again.`);
105
+ return { record: folder, folder, receipt: null, receiptPath: null };
106
+ }
107
+ throw new Error(`No receipt for ${dir}: ${typeof folder.cutId === "string" ? `cut ${folder.cutId.slice(0, 8)} has none under ${dirname(receiptPathFor(CUT_ID.test(folder.cutId) ? folder.cutId : "0".repeat(32)))}` : "its carveout.json names no cut"}. A folder that comes back is judged by the receipt its cut wrote: pass --receipt <file>, or --no-receipt to judge it by its own record, checked against the repository.`);
108
+ }
109
+ if (!existsSync(path))
110
+ throw new Error(`${path}: no such receipt.`);
111
+ return vouched(dir, folderOwn, readReceipt(path), path);
112
+ }
113
+ /**
114
+ * The record a command on a folder acts on, by a receipt given as data
115
+ * (`api.ts`): checked as `trustedCut` checks a receipt file, with no
116
+ * receipt looked for under `CARVEOUT_HOME`.
117
+ */
118
+ export function trustedCutWith(dir, given) {
119
+ const receipt = asReceipt(given);
120
+ const folderOwn = existsSync(join(resolve(dir), RECORD_PATH)) ? readRecord(dir) : null;
121
+ return vouched(dir, folderOwn, receipt, null);
122
+ }
123
+ /** The folder's record held to its receipt: refused when they are not one cut's. */
124
+ function vouched(dir, folderOwn, receipt, path) {
125
+ const named = path ?? "The receipt given";
126
+ const view = folderView(receipt);
127
+ const folder = folderOwn ?? view;
128
+ if (folder.cutId !== undefined && folder.cutId !== receipt.cutId)
129
+ throw new Error(`${named} is the receipt of cut ${receipt.cutId.slice(0, 8)}, and the folder says it is cut ${String(folder.cutId).slice(0, 8)}: name the receipt of this folder's cut.`);
130
+ // A folder whose generated files are another cut's is not this receipt's.
131
+ const shape = (record) => record.generated
132
+ .map((file) => file.path)
133
+ .filter((file) => file !== "package-lock.json")
134
+ .sort()
135
+ .join("\n");
136
+ // A folder holds the cut's own files (the folder's view), or, renamed
137
+ // back for a patch, the cut's record itself.
138
+ if (folderOwn !== null && shape(folderOwn) !== shape(view) && shape(folderOwn) !== shape(receipt.record))
139
+ throw new Error(`${dir} does not hold the files of cut ${receipt.cutId.slice(0, 8)}'s folder: name the receipt of the cut that wrote it.`);
140
+ // A public record names no checkout; the receipt does.
141
+ const record = receipt.record.repo === undefined ? { ...receipt.record, repo: receipt.repo } : receipt.record;
142
+ return { record, folder, receipt, receiptPath: path };
143
+ }
144
+ /**
145
+ * The record a public folder carries: what the folder's own commands need
146
+ * (its files and their hashes, what it stubs, its tests), less what names
147
+ * the repository beyond the slice, which the receipt keeps: the commit and
148
+ * the graph's hash, the hashes of the repository's other files, the
149
+ * specifiers the cut rewrote (a workspace package's name, a path alias),
150
+ * and the private paths the owner keeps out.
151
+ */
152
+ export function publicRecord(record) {
153
+ const { private: _private, ...params } = record.manifest.params;
154
+ const { private: _requestPrivate, choices: _choices, task: _task, ...request } = record.request;
155
+ const { repo: _repo, ...rest } = record;
156
+ const unnamed = (edge) => ({ ...edge, specifier: null });
157
+ // A slice file's original hash, blob id and size confirm a guess at what
158
+ // it held before the cut rewrote it: the folder's own copy's stand in.
159
+ const copied = new Map((record.copied ?? []).map((file) => [file.path, file.sha256]));
160
+ return {
161
+ ...rest,
162
+ manifest: {
163
+ ...record.manifest,
164
+ sourceCommitSha: null,
165
+ graphSha256: "",
166
+ boundaryContractSha256: "",
167
+ params,
168
+ included: record.manifest.included.map((file) => {
169
+ const own = copied.get(file.path);
170
+ return own === undefined ? file : { ...file, blobId: "", sha256: own };
171
+ }),
172
+ internalImports: record.manifest.internalImports.map(unnamed),
173
+ cuts: {
174
+ ...record.manifest.cuts,
175
+ outbound: record.manifest.cuts.outbound.map(unnamed),
176
+ inbound: record.manifest.cuts.inbound.map(unnamed),
177
+ },
178
+ },
179
+ importRewrites: [],
180
+ inputs: [],
181
+ request: { ...request, choices: {} },
182
+ // Commits name the repository's history: the receipt keeps them.
183
+ bases: undefined,
184
+ publicScrubbed: true,
185
+ };
186
+ }
187
+ /**
188
+ * The record a public folder holds, made from the cut's: scrubbed
189
+ * (`publicRecord`), a leak's blocker kept without the name it is about,
190
+ * and for a folder in pseudonyms, in its names, with its files' folder
191
+ * paths and the hashes of what the folder holds at them (`dir`), so its
192
+ * own commands (the coverage guard, check) read it as written.
193
+ */
194
+ export function folderRecordFor(record, table, dir,
195
+ /** A hash already known for a folder path, over what the folder holds there now. */
196
+ known = () => undefined) {
197
+ const scrubbed = publicRecord(record);
198
+ const quiet = {
199
+ ...scrubbed,
200
+ blockers: scrubbed.blockers.map((blocker) => blocker.code === "vocabulary_leaked"
201
+ ? { code: blocker.code, file: null, detail: "A name the repository goes by is still in the folder; the maintainer has the list." }
202
+ : blocker),
203
+ };
204
+ if (table === undefined)
205
+ return quiet;
206
+ const renamed = recordToFolder(table, quiet);
207
+ // A path the cut never saw (a file apply grew) takes its words too, when
208
+ // they map back.
209
+ const pathOf = (path) => exactToFolder(table, path, "")?.path ?? toFolder(table, path, "").path;
210
+ const hashAt = (path) => {
211
+ const given = known(path);
212
+ if (given !== undefined)
213
+ return given;
214
+ const at = join(resolve(dir), path);
215
+ return existsSync(at) ? createHash("sha256").update(readFileSync(at)).digest("hex") : "";
216
+ };
217
+ const asWritten = (entry) => ({
218
+ path: pathOf(entry.path),
219
+ sha256: hashAt(pathOf(entry.path)),
220
+ ...(entry.applied === true ? { applied: true } : {}),
221
+ });
222
+ return {
223
+ ...renamed,
224
+ manifest: {
225
+ ...renamed.manifest,
226
+ included: record.manifest.included.map((file) => ({ ...file, path: pathOf(file.path), blobId: "", sha256: hashAt(pathOf(file.path)) })),
227
+ },
228
+ generated: record.generated.map(asWritten),
229
+ ...(record.copied === undefined ? {} : { copied: record.copied.map(asWritten) }),
230
+ ...(record.extras === undefined ? {} : { extras: record.extras.map(asWritten) }),
231
+ };
232
+ }
233
+ /**
234
+ * Writes a record a command changed (`apply` growing the slice, writing
235
+ * stubs) to the folder and, when the cut has one, to its receipt: the
236
+ * folder's copy public-scrubbed as the cut wrote it.
237
+ */
238
+ export function saveRecord(dir, trusted, updated,
239
+ /** Generated files the command wrote into the folder, by folder path. */
240
+ written = {},
241
+ /**
242
+ * Slice files whose hash the record takes anew, by folder path: as the
243
+ * command wrote them, the folder's edits aside (`refresh` moving the
244
+ * folder's base). Every other path keeps the hash it had.
245
+ */
246
+ rebased = new Map()) {
247
+ const scrub = trusted.receipt !== null && updated.request.public === true;
248
+ // Each path keeps the hash the folder's record had for it: the folder's
249
+ // edits since are still edits, and a file an earlier apply wrote still
250
+ // differs from the cut's, so the next apply merges into it rather than
251
+ // over it. Only a generated file the command wrote, or a path new to the
252
+ // record, is hashed as the folder holds it now.
253
+ const previous = new Map();
254
+ const before = trusted.receipt?.folderRecord;
255
+ if (before !== undefined)
256
+ for (const file of [...before.manifest.included, ...before.generated, ...(before.copied ?? []), ...(before.extras ?? [])])
257
+ if (file.sha256 !== "")
258
+ previous.set(file.path, file.sha256);
259
+ const known = (path) => {
260
+ const text = written[path];
261
+ return text !== undefined ? createHash("sha256").update(text).digest("hex") : (rebased.get(path) ?? previous.get(path));
262
+ };
263
+ const folder = scrub ? folderRecordFor(updated, trusted.receipt?.pseudonyms, dir, known) : updated;
264
+ writeFileSync(join(resolve(dir), RECORD_PATH), `${JSON.stringify(folder, null, 2)}\n`);
265
+ if (trusted.receipt === null || trusted.receiptPath === null)
266
+ return { ...trusted, record: updated, folder };
267
+ // The receipt keeps the record as the cut makes it, checkout included,
268
+ // and the folder's own when it differs.
269
+ const { repo: _repo, ...kept } = updated;
270
+ const record = (trusted.receipt.record.repo === undefined ? kept : updated);
271
+ const receipt = {
272
+ ...trusted.receipt,
273
+ record,
274
+ ...(trusted.receipt.folderRecord === undefined ? {} : { folderRecord: folder }),
275
+ ...(trusted.receipt.generatedText === undefined
276
+ ? {}
277
+ : { generatedText: { ...trusted.receipt.generatedText, ...written } }),
278
+ };
279
+ writeReceipt(trusted.receiptPath, receipt);
280
+ return { ...trusted, record: updated, folder, receipt };
281
+ }
282
+ /**
283
+ * The baseline a check of the folder reads, given its receipt and the
284
+ * folder's own `carveout/baseline.json` (its failing names, or null when it
285
+ * has none): the receipt's when it holds one, none when the receipt holds
286
+ * none (a baseline the receipt never saw is not read), the folder's own
287
+ * when there is no receipt to judge it by. `edited` names what the
288
+ * folder's own adds beyond the receipt's.
289
+ */
290
+ export function trustedBaseline(receipt, folder) {
291
+ if (receipt === null)
292
+ return { use: "folder", edited: [] };
293
+ const known = receipt.baseline ?? null;
294
+ const edited = (folder ?? []).filter((name) => known === null || !known.includes(name)).sort();
295
+ return { use: known, edited };
296
+ }
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Running code that came back from outside in a container.
3
+ *
4
+ * A folder that came back holds the contributor's code, and its tests run
5
+ * it. On the maintainer's machine that code would run as the maintainer,
6
+ * with their files (the closed repository, the receipts that map the
7
+ * folder's pseudonyms back to the repository's names, credentials) and
8
+ * their network. Here each step is its own `docker run --rm` that mounts one
9
+ * directory, the scratch copy, and nothing else: no HOME, no receipts, no
10
+ * checkout beyond the copy. Only the install step has a network, and it runs
11
+ * a package manager with lifecycle scripts off on the receipt's manifests,
12
+ * so none of the folder's code runs while the network is there. Every other
13
+ * step (the build, the tests, the owner's acceptance tests, a merge's
14
+ * verify command) runs with `--network none`, all capabilities dropped, a
15
+ * read-only root, and bounds on memory and processes.
16
+ *
17
+ * Docker by default; `CARVEOUT_CONTAINER` names another runtime that takes
18
+ * the same arguments (podman), and `CARVEOUT_SANDBOX_IMAGE` the image (a
19
+ * Node image at this process's major version unless given).
20
+ */
21
+ export interface SandboxSpec {
22
+ /** The container runtime's executable: `docker`, or one that takes its arguments. */
23
+ readonly runtime: string;
24
+ /** The image every step runs in: it needs `node`, `npm` and `sh`. */
25
+ readonly image: string;
26
+ }
27
+ /** The runtime and image from the environment, or Docker and a Node image at this process's major version. */
28
+ export declare function defaultSandbox(image?: string): SandboxSpec;
29
+ /**
30
+ * Why the sandbox cannot run, or null when it can: the runtime answers, its
31
+ * daemon runs Linux containers, and the image is there or can be pulled.
32
+ */
33
+ export declare function sandboxUnavailable(spec: SandboxSpec): string | null;
34
+ /** Where a directory under the mounted one is inside the container. */
35
+ export declare const MOUNT = "/work";
36
+ /**
37
+ * Runs one command in a fresh container that mounts `root` at `/work` and
38
+ * nothing else, in `cwd` (a directory under `root`). Host paths under
39
+ * `root` in the arguments are rewritten to the container's, and this Node's
40
+ * own path to `node`. Only the names in `env` are passed, with HOME and the
41
+ * npm cache in the container's own /tmp. With `network` false the container
42
+ * has none. At `limitMs` the container is killed.
43
+ */
44
+ export declare function sandboxRun(spec: SandboxSpec, root: string, command: readonly string[], options: {
45
+ readonly cwd?: string;
46
+ readonly env?: Readonly<Record<string, string>>;
47
+ readonly network: boolean;
48
+ readonly limitMs?: number;
49
+ }): Promise<{
50
+ stdout: string;
51
+ stderr: string;
52
+ status: number | null;
53
+ }>;