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,527 @@
1
+ /**
2
+ * A public folder in pseudonyms, and the way back.
3
+ *
4
+ * A public cut writes the slice for someone who must not learn whose code
5
+ * it is. The names that say so (the organization, its handle and the
6
+ * account that owns the repository, the repository's and the workspace's
7
+ * package names, the maintainer's own name and address) are found by
8
+ * `workspaceNames`, and every file the folder holds is written under a
9
+ * table of renames that hides them: drafted by rule over the folder's own
10
+ * text (`draftPseudonyms`), after any renames the owner wrote, and proved
11
+ * to map back byte for byte (`applyAliases`). A name still in the folder
12
+ * after the table is a `vocabulary_leaked` blocker.
13
+ *
14
+ * The table never leaves the maintainer: it is kept in the cut's receipt.
15
+ * A folder that comes back is renamed back, path and text, into a scratch
16
+ * copy at the repository's own paths (`unaliasFolder`), and the patch is
17
+ * made from that copy as from any folder: the maintainer reads, and the
18
+ * hand-off policy judges, the change in the repository's own names.
19
+ *
20
+ * A package the folder installs from the public registry keeps its name,
21
+ * which npm needs: its occurrences are masked from the table both ways,
22
+ * and named to the maintainer when they carry a term (`kept`).
23
+ */
24
+ import { execFileSync } from "node:child_process";
25
+ import { existsSync, readFileSync } from "node:fs";
26
+ import { basename, join } from "node:path";
27
+ import { applyAliases, invertAliasRules, renameFile } from "./aliases.js";
28
+ import { draftPseudonyms, restyle } from "./draft.js";
29
+ import { emailNames, termMatches, termWords, tooCommonAlone, vocabularyLeaks, } from "./vocabulary.js";
30
+ /** A package.json's fields that name its owner, or null when unreadable. */
31
+ function manifestNames(path) {
32
+ if (!existsSync(path))
33
+ return [];
34
+ let manifest;
35
+ try {
36
+ manifest = JSON.parse(readFileSync(path, "utf8"));
37
+ }
38
+ catch {
39
+ return [];
40
+ }
41
+ const names = [];
42
+ const name = manifest["name"];
43
+ if (typeof name === "string") {
44
+ const slash = name.indexOf("/");
45
+ if (name.startsWith("@") && slash > 1)
46
+ names.push(ownerName(name.slice(1, slash)));
47
+ else
48
+ names.push({ name, kind: "name" });
49
+ }
50
+ const people = [manifest["author"], ...(Array.isArray(manifest["contributors"]) ? manifest["contributors"] : [])];
51
+ for (const person of people) {
52
+ if (typeof person === "string") {
53
+ // "Name <mail@host> (url)"
54
+ const mail = /<([^>]+)>/.exec(person)?.[1];
55
+ const who = person.replace(/<[^>]*>|\([^)]*\)/g, "").trim();
56
+ // Whole and by each distinctive word: a company's name, or a person's surname alone.
57
+ if (who !== "")
58
+ names.push({ name: who, kind: "organization" });
59
+ if (mail !== undefined)
60
+ names.push(...emailNames(mail));
61
+ }
62
+ else if (person !== null && typeof person === "object") {
63
+ const record = person;
64
+ if (typeof record["name"] === "string")
65
+ names.push({ name: record["name"], kind: "organization" });
66
+ if (typeof record["email"] === "string")
67
+ names.push(...emailNames(record["email"]));
68
+ }
69
+ }
70
+ for (const field of ["repository", "homepage", "bugs"]) {
71
+ const value = manifest[field];
72
+ const url = typeof value === "string"
73
+ ? value
74
+ : value !== null && typeof value === "object" && typeof value["url"] === "string"
75
+ ? value["url"]
76
+ : null;
77
+ if (url !== null)
78
+ names.push(...hostedNames(url));
79
+ }
80
+ return names;
81
+ }
82
+ /**
83
+ * A package scope: one word is scanned as an organization's (`acme`),
84
+ * several as one name (`sandbox-factory`), since a scope is often named
85
+ * after what the code is about, and those words (`sandbox`) are the code's.
86
+ */
87
+ function ownerName(name) {
88
+ return termWords(name).length > 1 ? { name, kind: "name" } : { name, kind: "organization" };
89
+ }
90
+ /** Hosts anyone's code is kept on: their names say nothing of whose it is. */
91
+ const PUBLIC_HOSTS = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com|visualstudio\.com|npmjs\.(com|org)|codeberg\.org|sr\.ht|sourceforge\.net|googlesource\.com|vercel\.app|netlify\.app|pages\.dev)$/i;
92
+ /** The labels of a host that name its owner: `lunox` of `git.lunox.work`. */
93
+ function hostNames(host) {
94
+ const clean = host.toLowerCase().replace(/:\d+$/, "");
95
+ if (clean === "" || PUBLIC_HOSTS.test(clean) || /^\d+(\.\d+){3}$/.test(clean) || clean === "localhost")
96
+ return [];
97
+ const labels = clean.split(".").filter(Boolean);
98
+ // The label before the public suffix (`co.uk` counts as one).
99
+ const suffix = labels.length >= 3 && /^(co|com|org|net|ac|gov)$/.test(labels[labels.length - 2] ?? "") ? 2 : 1;
100
+ const main = labels[labels.length - 1 - suffix];
101
+ return main === undefined ? [] : [{ name: main, kind: "organization" }];
102
+ }
103
+ /**
104
+ * The names a repository URL or a remote gives: its account and its
105
+ * repository, in any of the forms Git and npm write (`git@host:acme/shop.git`,
106
+ * `ssh://git@host:7999/acme/shop.git`, `https://host/acme/shop`, Azure's
107
+ * `dev.azure.com/acme/project/_git/shop`, npm's `github:acme/shop` or
108
+ * `acme/shop`), and a private host's own name.
109
+ */
110
+ function hostedNames(url) {
111
+ const value = url.trim();
112
+ const names = [];
113
+ const shorthand = /^(?:(github|gitlab|bitbucket):)?([A-Za-z0-9_.-]+)\/([A-Za-z0-9_.-]+?)(?:\.git)?$/.exec(value);
114
+ if (shorthand !== null && !value.includes("://") && !value.includes("@")) {
115
+ names.push({ name: shorthand[2] ?? "", kind: "organization" }, { name: shorthand[3] ?? "", kind: "name" });
116
+ return names;
117
+ }
118
+ const scp = /^[^@/\s]+@([^:/\s]+):(.+)$/.exec(value);
119
+ let host = "";
120
+ let path = "";
121
+ if (scp !== null && !value.includes("://")) {
122
+ host = scp[1] ?? "";
123
+ path = scp[2] ?? "";
124
+ }
125
+ else {
126
+ try {
127
+ const parsed = new URL(value.replace(/^git\+/, ""));
128
+ host = parsed.hostname;
129
+ path = parsed.pathname;
130
+ }
131
+ catch {
132
+ return names;
133
+ }
134
+ }
135
+ names.push(...hostNames(host));
136
+ // A page's path (`/support`, `/lunox-work/shop/issues` past the repo) names
137
+ // nothing; a code host's or a remote's names the account and repository.
138
+ const remote = scp !== null || /^(ssh|git)(\+\w+)?:/.test(value) || /\.git\/?$/.test(value);
139
+ const codeHost = /(^|\.)(github\.com|gitlab\.com|bitbucket\.org|dev\.azure\.com|visualstudio\.com)$/i.test(host);
140
+ if (!remote && !codeHost)
141
+ return names;
142
+ const raw = path
143
+ .replace(/^\/+|\/+$/g, "")
144
+ .replace(/\.git$/, "")
145
+ .split("/")
146
+ .filter((part) => part !== "");
147
+ // Azure: org/project/_git/repo; Bitbucket Server: scm/project/repo.
148
+ const azure = raw.indexOf("_git");
149
+ const parts = raw.filter((part) => !/^v\d+$/.test(part) && !/^(scm|projects|repos|browse)$/i.test(part));
150
+ const owner = parts[0];
151
+ const repo = azure !== -1 ? raw[azure + 1] : parts[1];
152
+ if (owner !== undefined)
153
+ names.push({ name: owner, kind: "organization" });
154
+ if (repo !== undefined && repo !== owner)
155
+ names.push({ name: repo, kind: "name" });
156
+ return names;
157
+ }
158
+ /** Affixes a handle wraps a name in: `lunoxhq`, `getlunox`, `lunoxapp`. */
159
+ const HANDLE_PREFIXES = ["get", "try", "use", "join", "go", "hello", "meet"];
160
+ const HANDLE_SUFFIXES = ["hq", "app", "apps", "inc", "io", "dev", "devs", "labs", "lab", "team", "co", "ai", "hub", "official", "corp", "tech", "studio", "group"];
161
+ /**
162
+ * More spellings an organization's or a person's name goes by: the stem of
163
+ * a handle (`lunox` of `lunoxhq`), its words split at a digit (`web3 labs`
164
+ * of `web3labs`), and its letters without accents (`lunox` of `Lunöx`).
165
+ */
166
+ function spellings(entry) {
167
+ const out = [entry];
168
+ const plain = entry.name.normalize("NFD").replace(/\p{M}/gu, "");
169
+ if (plain !== entry.name)
170
+ out.push({ ...entry, name: plain });
171
+ // Matched whole: its words alone (`web3`) are everyone's.
172
+ const split = plain.replace(/(\d)(\p{L})/gu, "$1 $2");
173
+ if (split !== plain)
174
+ out.push({ name: split, kind: "name" });
175
+ if (entry.kind === "organization") {
176
+ const word = plain.toLowerCase();
177
+ // A stem of letters alone, and not one everyone's code uses (`web3`).
178
+ const stem = (candidate) => {
179
+ if (candidate.length >= 4 && /^\p{L}+$/u.test(candidate) && !tooCommonAlone(candidate))
180
+ out.push({ name: candidate, kind: "organization" });
181
+ };
182
+ for (const prefix of HANDLE_PREFIXES)
183
+ if (word.startsWith(prefix))
184
+ stem(word.slice(prefix.length));
185
+ for (const suffix of HANDLE_SUFFIXES)
186
+ if (word.endsWith(suffix))
187
+ stem(word.slice(0, -suffix.length));
188
+ }
189
+ return out;
190
+ }
191
+ /** A Git setting of the checkout, or null. */
192
+ function gitValue(repo, args) {
193
+ try {
194
+ const value = execFileSync("git", [...args], { cwd: repo, stdio: ["ignore", "pipe", "ignore"] })
195
+ .toString("utf8")
196
+ .trim();
197
+ return value === "" ? null : value;
198
+ }
199
+ catch {
200
+ return null;
201
+ }
202
+ }
203
+ /**
204
+ * The names a repository goes by, for a public folder to hide: the owner's
205
+ * (`names` in the configuration, `--name`), the root's and the slice's
206
+ * packages' names, scopes, authors and repository URLs, the account and
207
+ * repository its `origin` remote names, the checkout's folder name, and the
208
+ * maintainer's own Git name and address. `workspaceVocabulary` drops what
209
+ * is too short or too generic to say whose code it is.
210
+ */
211
+ export function workspaceNames(repo, packageDirs, owner = []) {
212
+ const found = [];
213
+ const names = found;
214
+ for (const dir of ["", ...packageDirs])
215
+ names.push(...manifestNames(join(repo, dir, "package.json")));
216
+ const origin = gitValue(repo, ["remote", "get-url", "origin"]);
217
+ if (origin !== null)
218
+ names.push(...hostedNames(origin));
219
+ names.push({ name: basename(repo), kind: "name" });
220
+ // A person is named whole and by each distinctive word: a surname alone
221
+ // in a comment says who wrote it as well.
222
+ const person = gitValue(repo, ["config", "user.name"]);
223
+ if (person !== null)
224
+ names.push({ name: person, kind: "organization" });
225
+ const mail = gitValue(repo, ["config", "user.email"]);
226
+ if (mail !== null)
227
+ names.push(...emailNames(mail));
228
+ // A name found, of one word the code calls its own things by, is not
229
+ // scanned for alone; the owner's own names always are.
230
+ return [
231
+ ...owner.map((name) => ({ name, kind: "organization" })),
232
+ ...found.filter((entry) => !tooCommonAlone(entry.name)),
233
+ ].flatMap(spellings);
234
+ }
235
+ /**
236
+ * The names written after a name the repository goes by, the way its
237
+ * owner's other repositories are (`lunox-work/sandbox-factory` in a default
238
+ * list, a URL, a comment): each is the owner's too, and goes in the
239
+ * vocabulary. Only a name of two words or more (`sandbox-factory`): one
240
+ * word after a slash is as often a folder or a file (`lunox/README`), and
241
+ * a common word renamed everywhere hides nothing.
242
+ */
243
+ export function ownedNames(texts, vocabulary) {
244
+ const found = new Set();
245
+ const known = new Set(vocabulary);
246
+ for (const term of vocabulary) {
247
+ const spelled = term
248
+ .split("-")
249
+ .map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"))
250
+ .join("[\\s_\\-.]?");
251
+ const pattern = new RegExp(`(?<![A-Za-z0-9])${spelled}\\/([a-z0-9]+(?:-[a-z0-9]+)+)(?![A-Za-z0-9._-]*\\.(?!git\\b)[a-z]{1,5}\\b)`, "giu");
252
+ for (const { text } of texts)
253
+ for (const match of text.matchAll(pattern)) {
254
+ const name = (match[1] ?? "").toLowerCase();
255
+ if (name !== "" && !known.has(name))
256
+ found.add(name);
257
+ }
258
+ }
259
+ return [...found].sort().map((name) => ({ name, kind: "name" }));
260
+ }
261
+ /** Characters no source holds, around a kept name's index while the table runs. */
262
+ const MASK_OPEN = "";
263
+ const MASK_CLOSE = "";
264
+ /**
265
+ * `text` with each kept name (longest first) behind a mask the table cannot
266
+ * match, where it is written as a package: in quotes, alone or before a
267
+ * subpath (`"@lunox/ui"`, `'@lunox/ui/button'`). Elsewhere it is a word
268
+ * like any other, renamed and scanned.
269
+ */
270
+ export function maskKept(text, kept) {
271
+ if (kept.length === 0)
272
+ return text;
273
+ let out = text;
274
+ [...kept]
275
+ .map((name, index) => ({ name, index }))
276
+ .sort((a, b) => b.name.length - a.name.length)
277
+ .forEach(({ name, index }) => {
278
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
279
+ out = out.replace(new RegExp(`(["'\`]|node_modules/)${escaped}(?=["'\`/])`, "g"), `$1${MASK_OPEN}${index}${MASK_CLOSE}`);
280
+ });
281
+ return out;
282
+ }
283
+ /** `maskKept` undone. */
284
+ export function unmaskKept(text, kept) {
285
+ if (kept.length === 0 || !text.includes(MASK_OPEN))
286
+ return text;
287
+ return text.replace(new RegExp(`${MASK_OPEN}(\\d+)${MASK_CLOSE}`, "g"), (whole, index) => kept[Number(index)] ?? whole);
288
+ }
289
+ /** The kept names among the packages: those that carry one of the terms. */
290
+ export function keptNames(packages, vocabulary) {
291
+ return [...new Set(packages)]
292
+ .filter((name) => vocabulary.some((term) => termMatches(name, term).length > 0))
293
+ .sort();
294
+ }
295
+ /**
296
+ * The folder's files under a table that hides `vocabulary`: the owner's
297
+ * rules, then rules drafted over what they leave. `files` are the text
298
+ * files at their repository paths, `binaries` the paths of the rest (only
299
+ * their paths are renamed). Fails, writing nothing, when the table does
300
+ * not map back byte for byte.
301
+ */
302
+ export function pseudonymize(input) {
303
+ const masked = [
304
+ ...input.files.map((file) => ({ path: file.path, text: maskKept(file.text, input.kept) })),
305
+ ...input.binaries.map((path) => ({ path, text: "" })),
306
+ ];
307
+ // Owner rules apply everywhere: a scope would need the original path,
308
+ // which a file added in the folder does not have on the way back.
309
+ const owner = input.owner.map((rule) => ({ ...rule, paths: [] }));
310
+ const settled = masked.map((file) => renameFile(file, owner));
311
+ const chosen = new Map();
312
+ const preferred = new Map((input.earlier?.words ?? []).map(({ term, placeholder }) => [term, placeholder.split("-")[0] ?? placeholder]));
313
+ const drafted = draftPseudonyms({ files: settled, vocabulary: input.vocabulary, imports: [], placeholders: chosen, preferred });
314
+ const rules = [...owner, ...drafted];
315
+ const applied = applyAliases(masked, rules);
316
+ if (!applied.ok)
317
+ return {
318
+ ok: false,
319
+ problems: applied.problems.map((problem) => `${problem.code}${problem.file === null ? "" : ` ${problem.file}`}: ${problem.detail}`),
320
+ };
321
+ const renamed = new Map();
322
+ const files = new Map();
323
+ masked.forEach((file, index) => {
324
+ const out = applied.files[index];
325
+ if (out === undefined)
326
+ return;
327
+ renamed.set(file.path, out.path);
328
+ if (index < input.files.length)
329
+ files.set(file.path, { path: out.path, text: unmaskKept(out.text, input.kept) });
330
+ });
331
+ const leaks = vocabularyLeaks([...files.values()].map((file) => ({ path: file.path, text: maskKept(file.text, input.kept) })), input.vocabulary);
332
+ const table = {
333
+ rules,
334
+ kept: [...input.kept],
335
+ vocabulary: [...input.vocabulary],
336
+ words: [...chosen].map(([term, placeholder]) => ({ term, placeholder })).sort((a, b) => (a.term < b.term ? -1 : 1)),
337
+ };
338
+ if (input.readBack !== undefined) {
339
+ // The folder's own mock packages, by their folder names, as patch finds them.
340
+ const own = new Set([...files.values()].flatMap((file) => {
341
+ const match = /^carveout\/packages\/((?:@[^/]+\/)?[^/]+)\//.exec(file.path);
342
+ return match?.[1] === undefined ? [] : [match[1]];
343
+ }));
344
+ // The packages the slice's own files name as specifiers, which the
345
+ // repository knows (patch reads them back the same way).
346
+ const imported = new Set(input.files.flatMap((file) => specifierPackages(file.text)));
347
+ const problems = input.files
348
+ .filter((file) => input.readBack?.(file.path) === true)
349
+ .flatMap((file) => {
350
+ const folder = files.get(file.path);
351
+ if (folder === undefined)
352
+ return [];
353
+ const back = fromFolder(table, folder.path, folder.text, (name) => own.has(name), (name) => imported.has(name));
354
+ return back.path === file.path && back.text === file.text
355
+ ? []
356
+ : [`round_trip ${file.path}: read back from the folder's names, it would not be the repository's byte for byte`];
357
+ });
358
+ if (problems.length > 0)
359
+ return { ok: false, problems };
360
+ }
361
+ return {
362
+ ok: true,
363
+ table,
364
+ files,
365
+ pathOf: (path) => renamed.get(path) ?? renameFile({ path, text: "" }, rules).path,
366
+ leaks,
367
+ };
368
+ }
369
+ /**
370
+ * Each term found in the files by a looser reading than the table's: inside
371
+ * a longer word (`getlunox`, `lunoxes`), whatever joins its words, in any
372
+ * case, its accents stripped, and in binary files' bytes. The table renames
373
+ * a name on its word's edges; a name it could not reach still says whose
374
+ * code it is, and blocks the folder.
375
+ */
376
+ export function looseLeaks(files, vocabulary, kept = []) {
377
+ const plain = (text) => text.normalize("NFD").replace(/\p{M}/gu, "");
378
+ const leaks = [];
379
+ for (const term of vocabulary) {
380
+ const pattern = new RegExp(term.split("-").map((word) => word.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\s_\\-.]*"), "giu");
381
+ let occurrences = 0;
382
+ const found = [];
383
+ for (const file of files) {
384
+ const text = plain(maskKept(`${file.path}\n${file.text}`, kept));
385
+ const count = [...text.matchAll(pattern)].length;
386
+ if (count === 0)
387
+ continue;
388
+ occurrences += count;
389
+ found.push(file.path);
390
+ }
391
+ if (occurrences > 0)
392
+ leaks.push({ term, occurrences, files: found });
393
+ }
394
+ return leaks;
395
+ }
396
+ /**
397
+ * Each term the table's rules left in a path or a text, given its
398
+ * placeholder, spelled as the term was (`AcmeDiscount` → `ZorvexDiscount`,
399
+ * `acme-cases-v2.ts` → `zorvex-cases-v2.ts`): what the cut never saw, as
400
+ * `apply` writes it. The rules match what the cut found, file by file; a
401
+ * file the repository added since, or a line it wrote, may spell a term
402
+ * where no rule reaches. `fromFolder` puts each back by the same words. A
403
+ * kept package's name stays as it is, and a term the cut gave no
404
+ * placeholder (it found none) is left for the leak scan to name.
405
+ */
406
+ export function wordsToFolder(table, text) {
407
+ let out = maskKept(text, table.kept);
408
+ for (const { term, placeholder } of table.words ?? [])
409
+ for (const match of [...termMatches(out, term)].reverse()) {
410
+ const surface = out.slice(match.start, match.end);
411
+ out = `${out.slice(0, match.start)}${restyle(placeholder.split("-"), surface)}${out.slice(match.end)}`;
412
+ }
413
+ return unmaskKept(out, table.kept);
414
+ }
415
+ /** The package names a text spells as module specifiers (`from "x"`, `import("x")`), relative ones aside. */
416
+ export function specifierPackages(text) {
417
+ return [...text.matchAll(SPECIFIER)].flatMap((match) => {
418
+ const specifier = match[3] ?? "";
419
+ if (specifier.startsWith(".") || specifier.startsWith("/"))
420
+ return [];
421
+ return [specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : (specifier.split("/")[0] ?? specifier)];
422
+ });
423
+ }
424
+ /**
425
+ * A path or a text in the folder's names that maps back byte for byte: the
426
+ * table's rules with a placeholder for each term they left
427
+ * (`wordsToFolder`) when `fromFolder` takes that back to what it was, else
428
+ * the rules alone when those do; null when neither does (a mixed-case term
429
+ * `restyle` cannot keep, a package name the restore shields, a placeholder
430
+ * word the repository's own text holds). What `apply` writes, and only
431
+ * that: a text that maps back wrong would have `patch` rewrite the
432
+ * repository's.
433
+ */
434
+ export function exactToFolder(table, path, text, ownPackage = () => false, repositoryPackage = () => false) {
435
+ const ruled = toFolder(table, path, text);
436
+ const worded = { path: wordsToFolder(table, ruled.path), text: wordsToFolder(table, ruled.text) };
437
+ const back = (folder) => fromFolder(table, folder.path, folder.text, ownPackage, repositoryPackage);
438
+ const exact = (folder) => {
439
+ const restored = back(folder);
440
+ return restored.path === path && restored.text === text;
441
+ };
442
+ if (exact(worded))
443
+ return worded;
444
+ if (exact(ruled))
445
+ return ruled;
446
+ return null;
447
+ }
448
+ /** A path or text in the repository's names, put in the folder's. */
449
+ export function toFolder(table, path, text) {
450
+ const out = renameFile({ path, text: maskKept(text, table.kept) }, table.rules);
451
+ return { path: out.path, text: unmaskKept(out.text, table.kept) };
452
+ }
453
+ /** `text` with each placeholder still in it put back as its term, spelled as the placeholder was. */
454
+ function restoreWords(text, words) {
455
+ let out = text;
456
+ for (const { term, placeholder } of words ?? []) {
457
+ for (const match of [...termMatches(out, placeholder)].reverse()) {
458
+ const surface = out.slice(match.start, match.end);
459
+ out = `${out.slice(0, match.start)}${restyle(term.split("-"), surface)}${out.slice(match.end)}`;
460
+ }
461
+ }
462
+ return out;
463
+ }
464
+ /** A module specifier as code writes one: after `from`, `import`, `import(` or `require(`. */
465
+ const SPECIFIER = /(\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|^\s*import\s+|\bexport\s+\*\s+from\s*)(["'])([^"'\n]+)\2/gm;
466
+ const SHIELD_OPEN = "\uE002";
467
+ const SHIELD_CLOSE = "\uE003";
468
+ /**
469
+ * A path or text in the folder's names, put back in the repository's: the
470
+ * table undone, then any placeholder still there (a name the folder's
471
+ * holder made) put back as its term. A package the contributor imports by
472
+ * its own name (`acme-client`) is shielded from both, unless it is one of
473
+ * the folder's own mock packages (`ownPackage`), whose names are the
474
+ * table's.
475
+ */
476
+ export function fromFolder(table, path, text, ownPackage = () => false,
477
+ /**
478
+ * A package the repository knows (a workspace package, a dependency it
479
+ * declares): a specifier the table's rules take back to one is the
480
+ * repository's own name the cut renamed (in an import, in a test's
481
+ * data), and goes back with the rest.
482
+ */
483
+ repositoryPackage = () => false) {
484
+ const shielded = [];
485
+ const inverted = invertAliasRules(table.rules);
486
+ const masked = maskKept(text, table.kept).replace(SPECIFIER, (whole, lead, quote, specifier) => {
487
+ if (specifier.startsWith(".") || specifier.startsWith("/"))
488
+ return whole;
489
+ const name = specifier.startsWith("@") ? specifier.split("/").slice(0, 2).join("/") : (specifier.split("/")[0] ?? specifier);
490
+ if (ownPackage(name))
491
+ return whole;
492
+ // A name the table's rules take back to a package the repository knows
493
+ // is the repository's own, renamed by the cut: it goes back. Any other
494
+ // (a package the holder imports by its own name, `zorvex-client`) is
495
+ // kept from the table and the words alike.
496
+ const quoted = `${quote}${specifier}${quote}`;
497
+ const viaRules = renameFile({ path, text: quoted }, inverted).text;
498
+ const restored = viaRules.startsWith(quote) && viaRules.endsWith(quote) ? viaRules.slice(1, -1) : specifier;
499
+ const restoredName = restored.startsWith("@") ? restored.split("/").slice(0, 2).join("/") : (restored.split("/")[0] ?? restored);
500
+ if (restored !== specifier && repositoryPackage(restoredName))
501
+ return whole;
502
+ shielded.push(specifier);
503
+ return `${lead}${quote}${SHIELD_OPEN}${shielded.length - 1}${SHIELD_CLOSE}${quote}`;
504
+ });
505
+ const out = renameFile({ path, text: masked }, inverted);
506
+ const restored = restoreWords(out.text, table.words).replace(new RegExp(`${SHIELD_OPEN}(\\d+)${SHIELD_CLOSE}`, "g"), (whole, index) => shielded[Number(index)] ?? whole);
507
+ return { path: restoreWords(out.path, table.words), text: unmaskKept(restored, table.kept) };
508
+ }
509
+ /** Record fields that hold hashes or ids: never renamed, whatever they spell. */
510
+ const OPAQUE_KEYS = new Set(["sha256", "blobId", "cutId", "graphSha256", "boundaryContractSha256"]);
511
+ /**
512
+ * A record's string leaves in the folder's names, its hashes and ids left
513
+ * as they are (a term made of hex letters could match inside one): the
514
+ * folder's own `carveout.json`, which its own commands read.
515
+ */
516
+ export function recordToFolder(table, value) {
517
+ const walk = (node, key) => {
518
+ if (typeof node === "string")
519
+ return key !== null && OPAQUE_KEYS.has(key) ? node : toFolder(table, "carveout.json", node).text;
520
+ if (Array.isArray(node))
521
+ return node.map((item) => walk(item, key));
522
+ if (node !== null && typeof node === "object")
523
+ return Object.fromEntries(Object.entries(node).map(([name, item]) => [name, walk(item, name)]));
524
+ return node;
525
+ };
526
+ return walk(value, null);
527
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Pseudonyms for a public folder: the names a workspace goes by
3
+ * (`vocabulary.ts`), a reversible table of renames that hides them
4
+ * (`aliases.ts`), drafted by rule (`draft.ts`), and how a cut and a patch
5
+ * use them (`folder.ts`). Carved out of sandbox-factory's
6
+ * `packages/core/src/sandbox`, as the slicing core was (NOTICE.md).
7
+ */
8
+ export * from "./aliases.js";
9
+ export * from "./draft.js";
10
+ export * from "./vocabulary.js";
11
+ export * from "./folder.js";
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Pseudonyms for a public folder: the names a workspace goes by
3
+ * (`vocabulary.ts`), a reversible table of renames that hides them
4
+ * (`aliases.ts`), drafted by rule (`draft.ts`), and how a cut and a patch
5
+ * use them (`folder.ts`). Carved out of sandbox-factory's
6
+ * `packages/core/src/sandbox`, as the slicing core was (NOTICE.md).
7
+ */
8
+ export * from "./aliases.js";
9
+ export * from "./draft.js";
10
+ export * from "./vocabulary.js";
11
+ export * from "./folder.js";
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Workspace vocabulary: the names that say whose code a sandbox was cut
3
+ * from (the organization, its repository, its package scope, its author)
4
+ * and the check that none of them reaches a public file.
5
+ *
6
+ * Pseudonyms are what hide those names, but a table can be empty or miss
7
+ * one, and a sliced version is the workspace's own code. So a build scans
8
+ * what contributors would read, paths and text, for every name the
9
+ * workspace goes by, and a name still there blocks the build until the
10
+ * table renames it.
11
+ *
12
+ * A name is matched by its words, whatever joins them: `sandbox-factory`
13
+ * is found as `sandboxFactory`, `SANDBOX_FACTORY` and `sandbox factory`.
14
+ * A match must start and end on a word boundary, where a case change in an
15
+ * identifier counts as one: `acme` is found in `acmeOrders`, `MyAcme` and
16
+ * `ACMEOrders`, not in `acmes`. A name too short or too generic to say
17
+ * whose code it is (`api`, `web`, `monorepo`) is not scanned for.
18
+ *
19
+ * An organization's names are scanned word by word as well: `Acme Corp`
20
+ * writes its packages `@acme/ledger` and its code `acmeOrders`, never
21
+ * `acme-corp`. Only its distinctive words, though: one of at least
22
+ * `VOCABULARY_TERM_MIN` letters that is not a legal form or trade
23
+ * (`ORGANIZATION_WORDS`) nor an everyday word (`COMMON_WORDS`), whatever its
24
+ * length. `First Choice Logistics` is scanned whole, never as `first`, or
25
+ * every `firstName` would block; `acme` and `payflow` are scanned alone.
26
+ * Other names (a repository's, a package's) are matched only whole, since
27
+ * `billing-service` names one product but `billing` is everyone's word. A
28
+ * name or word without a letter (`12345`) names nothing and is dropped.
29
+ *
30
+ * A package the slice imports from the workspace itself or from a private
31
+ * registry is mocked under its own name unless the table renames it, so its
32
+ * scope is scanned as an organization's name (`@acme/ledger` gives `acme`)
33
+ * and an unscoped one by its whole name (`payflow-sdk`). A package's own
34
+ * one word (`ledger`, `logger`) is the code's vocabulary, not whose code it
35
+ * is, and a public package mocked by default (`lodash`) names no one.
36
+ */
37
+ import type { RequiredBuildInputs } from "../slice/manifest.js";
38
+ import type { ProjectBlocker } from "../project/project.js";
39
+ /** Shorter names, separators aside, match too much to block on. */
40
+ export declare const VOCABULARY_TERM_MIN = 4;
41
+ /**
42
+ * A name the workspace goes by. A bare string is `kind: "name"`: matched
43
+ * only as a whole. An `organization` name (its name, its handle, the
44
+ * account that owns its repositories, its mail domain) is matched whole and
45
+ * by each of its distinctive words.
46
+ */
47
+ export interface VocabularyName {
48
+ readonly name: string;
49
+ readonly kind: "organization" | "name";
50
+ }
51
+ /** Names that say who the organization is, to be scanned word by word too. */
52
+ export declare function organizationNames(names: readonly string[]): VocabularyName[];
53
+ /**
54
+ * The names the slice's privately hosted packages give (see the module
55
+ * comment): one of the workspace's own, or one a lockfile or `.npmrc` says
56
+ * comes from another registry. A package nothing says is public is mocked
57
+ * too, but by default, so it is not one.
58
+ */
59
+ export declare function privatePackageNames(packages: RequiredBuildInputs["packages"]): VocabularyName[];
60
+ /**
61
+ * The names a package author's address gives: its local part unless a role
62
+ * mailbox's (`support@`) or without a letter, and its domain as the
63
+ * organization's unless it is a public mail provider's. A GitHub noreply
64
+ * address (`12345+octocat@users.noreply.github.com`) gives the login after
65
+ * the `+`; any other drops its `+tag`.
66
+ */
67
+ export declare function emailNames(email: string): VocabularyName[];
68
+ /**
69
+ * A name's words, lowercased: split at anything not a letter or digit, and
70
+ * at each case change an identifier marks (`acmeOrders`, `XMLParser`).
71
+ */
72
+ export declare function termWords(name: string): string[];
73
+ /**
74
+ * The names worth scanning for, each as its words joined by `-`, in the
75
+ * order first given; an organization's name is followed by each of its
76
+ * distinctive words (see the module comment). Repeats, names under
77
+ * `VOCABULARY_TERM_MIN`, names without a letter and names made only of
78
+ * generic words are dropped.
79
+ */
80
+ export declare function workspaceVocabulary(names: readonly (string | VocabularyName)[]): string[];
81
+ /**
82
+ * Whether a one-word name is too common to scan for alone: generic, an
83
+ * organization's trade or form, an everyday word, or a word code is made
84
+ * of. A name of several words is scanned whole whatever its words.
85
+ */
86
+ export declare function tooCommonAlone(name: string): boolean;
87
+ export interface VocabularyLeak {
88
+ readonly term: string;
89
+ readonly occurrences: number;
90
+ /** The files it was found in, path or text, in the order given. */
91
+ readonly files: readonly string[];
92
+ }
93
+ /** Where a term was found in a text: `text.slice(start, end)`. */
94
+ export interface TermMatch {
95
+ readonly start: number;
96
+ readonly end: number;
97
+ }
98
+ /**
99
+ * Every match of `term` in `text` that starts and ends on a word boundary:
100
+ * not a letter, or a case change an identifier marks (`myAcme`, `acmeOrders`,
101
+ * and in a run of capitals the last one before a lowercase, `XMLAcme`,
102
+ * `ACMEOrders`), or a capital after a digit (`web3Labs`). A term that starts
103
+ * or ends on a digit (`web3`) needs a non-digit there too, so `web3` is not
104
+ * found in `web30`.
105
+ */
106
+ export declare function termMatches(text: string, term: string): TermMatch[];
107
+ /** Each term found in the files' paths or text, in the vocabulary's order. */
108
+ export declare function vocabularyLeaks(files: readonly {
109
+ readonly path: string;
110
+ readonly text: string;
111
+ }[], vocabulary: readonly string[]): VocabularyLeak[];
112
+ /** A blocker for each leak, naming where it was found and what clears it. */
113
+ export declare function vocabularyBlockers(leaks: readonly VocabularyLeak[]): ProjectBlocker[];