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.
- package/AGENTS.md +21 -3
- package/NOTICE.md +2 -1
- package/README.md +248 -473
- package/dist/src/account.d.ts +37 -0
- package/dist/src/account.js +275 -0
- package/dist/src/api.d.ts +162 -0
- package/dist/src/api.js +134 -0
- package/dist/src/carve.d.ts +788 -0
- package/dist/src/carve.js +1721 -125
- package/dist/src/cli.d.ts +10 -0
- package/dist/src/cli.js +674 -47
- package/dist/src/command.d.ts +10 -0
- package/dist/src/core.d.ts +11 -0
- package/dist/src/cut.d.ts +78 -0
- package/dist/src/cut.js +25 -7
- package/dist/src/extract/compiler-config.d.ts +49 -0
- package/dist/src/extract/fixture.d.ts +103 -0
- package/dist/src/extract/hash.d.ts +6 -0
- package/dist/src/extract/package-registry.d.ts +47 -0
- package/dist/src/extract/package-types.d.ts +52 -0
- package/dist/src/extract/surface.d.ts +44 -0
- package/dist/src/extract/typescript.d.ts +180 -0
- package/dist/src/gate.d.ts +55 -0
- package/dist/src/gate.js +1 -0
- package/dist/src/graph.d.ts +142 -0
- package/dist/src/graph.js +4 -3
- package/dist/src/impact.d.ts +113 -0
- package/dist/src/imports.d.ts +67 -0
- package/dist/src/mergeback.d.ts +182 -0
- package/dist/src/mergeback.js +580 -35
- package/dist/src/platform.d.ts +69 -0
- package/dist/src/platform.js +184 -0
- package/dist/src/policy.d.ts +181 -0
- package/dist/src/policy.js +716 -230
- package/dist/src/project/build.d.ts +23 -0
- package/dist/src/project/catalog.d.ts +25 -0
- package/dist/src/project/project.d.ts +287 -0
- package/dist/src/project/project.js +479 -52
- package/dist/src/project/provenance.d.ts +73 -0
- package/dist/src/project/provenance.js +12 -0
- package/dist/src/project/runner.d.ts +19 -0
- package/dist/src/project/secrets.d.ts +64 -0
- package/dist/src/project/vitest-config.d.ts +70 -0
- package/dist/src/project/vitest-config.js +291 -0
- package/dist/src/pseudonyms/aliases.d.ts +107 -0
- package/dist/src/pseudonyms/aliases.js +394 -0
- package/dist/src/pseudonyms/draft.d.ts +64 -0
- package/dist/src/pseudonyms/draft.js +284 -0
- package/dist/src/pseudonyms/folder.d.ts +172 -0
- package/dist/src/pseudonyms/folder.js +527 -0
- package/dist/src/pseudonyms/index.d.ts +11 -0
- package/dist/src/pseudonyms/index.js +11 -0
- package/dist/src/pseudonyms/vocabulary.d.ts +113 -0
- package/dist/src/pseudonyms/vocabulary.js +522 -0
- package/dist/src/readiness.d.ts +71 -0
- package/dist/src/readiness.js +131 -0
- package/dist/src/receipt.d.ts +154 -0
- package/dist/src/receipt.js +296 -0
- package/dist/src/sandbox.d.ts +53 -0
- package/dist/src/sandbox.js +154 -0
- package/dist/src/slice/analysis.d.ts +75 -0
- package/dist/src/slice/externals.d.ts +51 -0
- package/dist/src/slice/graph.d.ts +69 -0
- package/dist/src/slice/manifest.d.ts +262 -0
- package/dist/src/slice/manifest.js +4 -3
- package/dist/src/slice/reach.d.ts +204 -0
- package/dist/src/slice/reach.js +11 -5
- package/dist/src/slice/report.d.ts +49 -0
- package/dist/src/slice/report.js +5 -2
- package/dist/src/specifiers.d.ts +48 -0
- package/dist/src/status.d.ts +46 -0
- package/dist/src/status.js +3 -2
- package/dist/src/test-integrity.d.ts +54 -0
- package/dist/src/test-integrity.js +812 -0
- package/package.json +24 -5
- package/skills/carveout/SKILL.md +518 -0
- package/skills/carveout-gate/SKILL.md +98 -0
- 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[];
|