carveout 0.2.0 → 0.3.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/NOTICE.md +44 -0
- package/dist/src/api.d.ts +135 -3
- package/dist/src/api.js +144 -2
- package/dist/src/carve.d.ts +55 -2
- package/dist/src/carve.js +31 -19
- package/dist/src/cut.d.ts +17 -0
- package/dist/src/cut.js +13 -7
- package/dist/src/extract.d.ts +31 -0
- package/dist/src/extract.js +31 -0
- package/dist/src/graph.d.ts +7 -2
- package/dist/src/graph.js +7 -4
- package/dist/src/project/mock-runtime.d.ts +9 -0
- package/dist/src/project/mock-runtime.js +81 -0
- package/dist/src/project/paths.d.ts +10 -0
- package/dist/src/project/paths.js +35 -0
- package/dist/src/project/project.d.ts +3 -6
- package/dist/src/project/project.js +3 -104
- package/dist/src/pseudonyms/carry.d.ts +161 -0
- package/dist/src/pseudonyms/carry.js +301 -0
- package/dist/src/pseudonyms/draft.js +1 -1
- package/dist/src/pseudonyms/folder.js +2 -40
- package/dist/src/pseudonyms/legacy/aliases-v1.d.ts +107 -0
- package/dist/src/pseudonyms/legacy/aliases-v1.js +391 -0
- package/dist/src/pseudonyms/legacy/vocabulary-v1.d.ts +107 -0
- package/dist/src/pseudonyms/legacy/vocabulary-v1.js +460 -0
- package/dist/src/pseudonyms/legacy.d.ts +34 -0
- package/dist/src/pseudonyms/legacy.js +46 -0
- package/dist/src/pseudonyms/vocabulary.d.ts +20 -0
- package/dist/src/pseudonyms/vocabulary.js +57 -0
- package/dist/src/pseudonyms.d.ts +20 -0
- package/dist/src/pseudonyms.js +20 -0
- package/package.json +9 -1
- package/skills/carveout/SKILL.md +15 -13
- package/skills/carveout-gate/SKILL.md +3 -3
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// Frozen: sandbox-factory's packages/core/src/sandbox/aliases.ts as of its
|
|
2
|
+
// commit f31d780, the renaming every platform version built before carveout
|
|
3
|
+
// 0.3.0 was made with. Never edited: a version carries back only under the
|
|
4
|
+
// renaming it was built with. carveout's own is ../aliases.ts. Exported, with a
|
|
5
|
+
// V1 suffix, from ../legacy.ts.
|
|
6
|
+
/**
|
|
7
|
+
* Alias rules: the private, bidirectional name table a sandbox version
|
|
8
|
+
* applies to copied source before it can leave the organization.
|
|
9
|
+
*
|
|
10
|
+
* A rule is an explicit pair with a kind and a scope. Rules are ordered,
|
|
11
|
+
* and applied in that order; they are validated as a set before any text
|
|
12
|
+
* is touched, because two rules that look harmless alone can interact (one
|
|
13
|
+
* rule's output is another's input, two rules share a target, two
|
|
14
|
+
* identifier or path names differ only by case). Application is
|
|
15
|
+
* deterministic and textual, bounded to identifier or path tokens so a name
|
|
16
|
+
* inside a longer identifier is never touched, and it is checked to be
|
|
17
|
+
* reversible: applying the inverse table to the output must give the input
|
|
18
|
+
* back byte for byte. A text rule matches its exact, case-sensitive
|
|
19
|
+
* characters and is inverted the same way, so two text rules that differ
|
|
20
|
+
* only by case are distinct and both hold. A
|
|
21
|
+
* transformation that cannot be mapped back is refused, never silently
|
|
22
|
+
* dropped. Redaction of implementation is not a rename and has no place
|
|
23
|
+
* here.
|
|
24
|
+
*/
|
|
25
|
+
export const ALIAS_KINDS = ["identifier", "path", "text"];
|
|
26
|
+
export const ALIAS_PROBLEM_CODES = [
|
|
27
|
+
"empty",
|
|
28
|
+
"identity",
|
|
29
|
+
"invalid_identifier",
|
|
30
|
+
"invalid_path",
|
|
31
|
+
"duplicate_source",
|
|
32
|
+
"duplicate_target",
|
|
33
|
+
"chained",
|
|
34
|
+
"casing_variant",
|
|
35
|
+
"target_collision",
|
|
36
|
+
"irreversible",
|
|
37
|
+
"too_many",
|
|
38
|
+
"unsupported_kind",
|
|
39
|
+
];
|
|
40
|
+
export const ALIAS_RULES_MAX = 500;
|
|
41
|
+
const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
|
|
42
|
+
const PATH = /^[A-Za-z0-9_.\-/@]+$/;
|
|
43
|
+
/** Whether the set is well formed and free of interactions. */
|
|
44
|
+
export function validateAliasRules(rules) {
|
|
45
|
+
const problems = [];
|
|
46
|
+
if (rules.length > ALIAS_RULES_MAX)
|
|
47
|
+
problems.push({
|
|
48
|
+
code: "too_many",
|
|
49
|
+
rule: null,
|
|
50
|
+
file: null,
|
|
51
|
+
detail: `${rules.length} rules exceed the limit of ${ALIAS_RULES_MAX}.`,
|
|
52
|
+
});
|
|
53
|
+
const sources = new Map();
|
|
54
|
+
const targets = new Map();
|
|
55
|
+
const folded = new Map();
|
|
56
|
+
rules.forEach((rule, index) => {
|
|
57
|
+
if (!ALIAS_KINDS.includes(rule.kind)) {
|
|
58
|
+
problems.push({
|
|
59
|
+
code: "unsupported_kind",
|
|
60
|
+
rule: index,
|
|
61
|
+
file: null,
|
|
62
|
+
detail: `Kind ${String(rule.kind)} is not supported.`,
|
|
63
|
+
});
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
if (rule.before === "" || rule.after === "") {
|
|
67
|
+
problems.push({
|
|
68
|
+
code: "empty",
|
|
69
|
+
rule: index,
|
|
70
|
+
file: null,
|
|
71
|
+
detail: "Both sides of a rule must be non-empty.",
|
|
72
|
+
});
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (rule.before === rule.after)
|
|
76
|
+
problems.push({
|
|
77
|
+
code: "identity",
|
|
78
|
+
rule: index,
|
|
79
|
+
file: null,
|
|
80
|
+
detail: `${rule.before} maps to itself.`,
|
|
81
|
+
});
|
|
82
|
+
if (rule.kind === "identifier" &&
|
|
83
|
+
(!IDENTIFIER.test(rule.before) || !IDENTIFIER.test(rule.after)))
|
|
84
|
+
problems.push({
|
|
85
|
+
code: "invalid_identifier",
|
|
86
|
+
rule: index,
|
|
87
|
+
file: null,
|
|
88
|
+
detail: `${rule.before} → ${rule.after} is not an identifier pair.`,
|
|
89
|
+
});
|
|
90
|
+
if (rule.kind === "path" &&
|
|
91
|
+
(!PATH.test(rule.before) ||
|
|
92
|
+
!PATH.test(rule.after) ||
|
|
93
|
+
rule.before.startsWith("/") ||
|
|
94
|
+
rule.after.startsWith("/") ||
|
|
95
|
+
rule.before.split("/").includes("..") ||
|
|
96
|
+
rule.after.split("/").includes("..")))
|
|
97
|
+
problems.push({
|
|
98
|
+
code: "invalid_path",
|
|
99
|
+
rule: index,
|
|
100
|
+
file: null,
|
|
101
|
+
detail: `${rule.before} → ${rule.after} is not a repository path pair.`,
|
|
102
|
+
});
|
|
103
|
+
const scope = rule.paths.length === 0 ? "" : [...rule.paths].sort().join("\n");
|
|
104
|
+
const sourceKey = `${rule.kind}\n${scope}\n${rule.before}`;
|
|
105
|
+
const targetKey = `${rule.kind}\n${scope}\n${rule.after}`;
|
|
106
|
+
const earlier = sources.get(sourceKey);
|
|
107
|
+
if (earlier !== undefined)
|
|
108
|
+
problems.push({
|
|
109
|
+
code: "duplicate_source",
|
|
110
|
+
rule: index,
|
|
111
|
+
file: null,
|
|
112
|
+
detail: `${rule.before} is already renamed by rule ${earlier}.`,
|
|
113
|
+
});
|
|
114
|
+
else
|
|
115
|
+
sources.set(sourceKey, index);
|
|
116
|
+
const shared = targets.get(targetKey);
|
|
117
|
+
if (shared !== undefined)
|
|
118
|
+
problems.push({
|
|
119
|
+
code: "duplicate_target",
|
|
120
|
+
rule: index,
|
|
121
|
+
file: null,
|
|
122
|
+
detail: `${rule.after} is already the target of rule ${shared}.`,
|
|
123
|
+
});
|
|
124
|
+
else
|
|
125
|
+
targets.set(targetKey, index);
|
|
126
|
+
// Identifier and path names that differ only by case are refused. Text
|
|
127
|
+
// is exempt: a text rule is applied with an exact, case-sensitive
|
|
128
|
+
// pattern and inverted exactly, so ` lunox ` → ` acme ` beside
|
|
129
|
+
// ` LUNOX ` → ` ACME ` is unambiguous both ways, and it is how a
|
|
130
|
+
// spelling no identifier rule can reach is covered.
|
|
131
|
+
if (rule.kind === "text")
|
|
132
|
+
return;
|
|
133
|
+
for (const [name, side] of [
|
|
134
|
+
[rule.before, "source"],
|
|
135
|
+
[rule.after, "target"],
|
|
136
|
+
]) {
|
|
137
|
+
const key = `${rule.kind}\n${name.toLowerCase()}`;
|
|
138
|
+
const seen = folded.get(key);
|
|
139
|
+
if (seen !== undefined && seen !== index) {
|
|
140
|
+
const other = rules[seen];
|
|
141
|
+
if (other !== undefined &&
|
|
142
|
+
other.before !== name &&
|
|
143
|
+
other.after !== name)
|
|
144
|
+
problems.push({
|
|
145
|
+
code: "casing_variant",
|
|
146
|
+
rule: index,
|
|
147
|
+
file: null,
|
|
148
|
+
detail: `${name} (${side}) differs only by case from a name in rule ${seen}.`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
else
|
|
152
|
+
folded.set(key, index);
|
|
153
|
+
}
|
|
154
|
+
});
|
|
155
|
+
// A target that is another rule's source would be renamed again by a
|
|
156
|
+
// later rule, or would make the inverse table ambiguous.
|
|
157
|
+
rules.forEach((rule, index) => {
|
|
158
|
+
rules.forEach((other, otherIndex) => {
|
|
159
|
+
if (index !== otherIndex &&
|
|
160
|
+
rule.kind === other.kind &&
|
|
161
|
+
rule.after === other.before &&
|
|
162
|
+
scopesOverlap(rule.paths, other.paths))
|
|
163
|
+
problems.push({
|
|
164
|
+
code: "chained",
|
|
165
|
+
rule: index,
|
|
166
|
+
file: null,
|
|
167
|
+
detail: `${rule.after} is produced by rule ${index} and consumed by rule ${otherIndex}.`,
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
return { ok: problems.length === 0, problems: dedupe(problems) };
|
|
172
|
+
}
|
|
173
|
+
function dedupe(problems) {
|
|
174
|
+
const seen = new Set();
|
|
175
|
+
const result = [];
|
|
176
|
+
for (const problem of problems) {
|
|
177
|
+
const key = `${problem.code}\n${problem.rule ?? ""}\n${problem.file ?? ""}\n${problem.detail}`;
|
|
178
|
+
if (seen.has(key))
|
|
179
|
+
continue;
|
|
180
|
+
seen.add(key);
|
|
181
|
+
result.push(problem);
|
|
182
|
+
}
|
|
183
|
+
return result;
|
|
184
|
+
}
|
|
185
|
+
function inScope(path, scopes) {
|
|
186
|
+
if (scopes.length === 0)
|
|
187
|
+
return true;
|
|
188
|
+
return scopes.some((scope) => path === scope || path.startsWith(`${scope.replace(/\/$/, "")}/`));
|
|
189
|
+
}
|
|
190
|
+
function scopesOverlap(a, b) {
|
|
191
|
+
if (a.length === 0 || b.length === 0)
|
|
192
|
+
return true;
|
|
193
|
+
return a.some((x) => b.some((y) => inScope(x, [y]) || inScope(y, [x])));
|
|
194
|
+
}
|
|
195
|
+
const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
196
|
+
const identifierPattern = (name) => new RegExp(`(?<![A-Za-z0-9_$])${escape(name)}(?![A-Za-z0-9_$])`, "g");
|
|
197
|
+
const pathPattern = (name) => new RegExp(`(?<![A-Za-z0-9_.\\-/@])${escape(name)}(?![A-Za-z0-9_\\-@])`, "g");
|
|
198
|
+
const textPattern = (name) => new RegExp(escape(name), "g");
|
|
199
|
+
const patternFor = (rule, name) => rule.kind === "identifier"
|
|
200
|
+
? identifierPattern(name)
|
|
201
|
+
: rule.kind === "path"
|
|
202
|
+
? pathPattern(name)
|
|
203
|
+
: textPattern(name);
|
|
204
|
+
/**
|
|
205
|
+
* A path renamed by the table, renamed back: the path rules undone in
|
|
206
|
+
* reverse order, each scoped by the original path as the forward pass was.
|
|
207
|
+
*/
|
|
208
|
+
function invertPath(path, original, reversed) {
|
|
209
|
+
let back = path;
|
|
210
|
+
for (const rule of reversed)
|
|
211
|
+
if (rule.kind === "path" && inScope(original, rule.paths))
|
|
212
|
+
back = back.replace(patternFor(rule, rule.after), rule.before);
|
|
213
|
+
return back;
|
|
214
|
+
}
|
|
215
|
+
function rewrite(files, rules, direction, counts, problems) {
|
|
216
|
+
const renames = [];
|
|
217
|
+
const result = files.map((file) => {
|
|
218
|
+
let text = file.text;
|
|
219
|
+
let path = file.path;
|
|
220
|
+
rules.forEach((rule, index) => {
|
|
221
|
+
if (!inScope(file.path, rule.paths))
|
|
222
|
+
return;
|
|
223
|
+
const from = direction === "forward" ? rule.before : rule.after;
|
|
224
|
+
const to = direction === "forward" ? rule.after : rule.before;
|
|
225
|
+
if (problems !== null) {
|
|
226
|
+
// The target must not already occur, or the inverse table could not
|
|
227
|
+
// tell a renamed occurrence from a natural one.
|
|
228
|
+
const existing = text.match(patternFor(rule, to));
|
|
229
|
+
if (existing !== null && existing.length > 0)
|
|
230
|
+
problems.push({
|
|
231
|
+
code: "target_collision",
|
|
232
|
+
rule: index,
|
|
233
|
+
file: file.path,
|
|
234
|
+
detail: `${to} already occurs ${existing.length} time(s) in ${file.path}.`,
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
const pattern = patternFor(rule, from);
|
|
238
|
+
let made = 0;
|
|
239
|
+
text = text.replace(pattern, () => {
|
|
240
|
+
made += 1;
|
|
241
|
+
return to;
|
|
242
|
+
});
|
|
243
|
+
if (counts !== null)
|
|
244
|
+
counts[index] = (counts[index] ?? 0) + made;
|
|
245
|
+
if (rule.kind === "path") {
|
|
246
|
+
const renamed = path.replace(patternFor(rule, from), to);
|
|
247
|
+
if (renamed !== path) {
|
|
248
|
+
path = renamed;
|
|
249
|
+
if (counts !== null)
|
|
250
|
+
counts[index] = (counts[index] ?? 0) + 1;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
});
|
|
254
|
+
if (path !== file.path)
|
|
255
|
+
renames.push({ from: file.path, to: path });
|
|
256
|
+
return { path, text };
|
|
257
|
+
});
|
|
258
|
+
return { files: result, renames };
|
|
259
|
+
}
|
|
260
|
+
/**
|
|
261
|
+
* Applies the rules to the files and proves the result maps back. Problems
|
|
262
|
+
* refuse the whole application: a partially aliased candidate is worse
|
|
263
|
+
* than none, because it looks finished.
|
|
264
|
+
*/
|
|
265
|
+
export function applyAliases(files, rules) {
|
|
266
|
+
const validation = validateAliasRules(rules);
|
|
267
|
+
if (!validation.ok)
|
|
268
|
+
return {
|
|
269
|
+
ok: false,
|
|
270
|
+
files: [],
|
|
271
|
+
renames: [],
|
|
272
|
+
problems: validation.problems,
|
|
273
|
+
applied: [],
|
|
274
|
+
};
|
|
275
|
+
const problems = [];
|
|
276
|
+
const counts = rules.map(() => 0);
|
|
277
|
+
const forward = rewrite(files, rules, "forward", counts, problems);
|
|
278
|
+
// The inverse walks the rules backwards, undoing the last rename first.
|
|
279
|
+
const reversed = [...rules].reverse();
|
|
280
|
+
const inverse = rewrite(forward.files.map((file, index) => ({
|
|
281
|
+
// Scope checks use the original path: the inverse must see what the
|
|
282
|
+
// forward pass saw.
|
|
283
|
+
path: files[index]?.path ?? file.path,
|
|
284
|
+
text: file.text,
|
|
285
|
+
})), reversed, "inverse", null, null);
|
|
286
|
+
files.forEach((file, index) => {
|
|
287
|
+
const back = inverse.files[index];
|
|
288
|
+
if (back === undefined || back.text !== file.text)
|
|
289
|
+
problems.push({
|
|
290
|
+
code: "irreversible",
|
|
291
|
+
rule: null,
|
|
292
|
+
file: file.path,
|
|
293
|
+
detail: `Applying the inverse table to ${file.path} does not give the original back.`,
|
|
294
|
+
});
|
|
295
|
+
// Paths too: with `src → lib` beside a `lib/` the repository already
|
|
296
|
+
// has, `lib/x.ts` would map back to `src/x.ts`, and the table alone
|
|
297
|
+
// could not say where a public file came from.
|
|
298
|
+
const aliased = forward.files[index]?.path ?? file.path;
|
|
299
|
+
if (invertPath(aliased, file.path, reversed) !== file.path)
|
|
300
|
+
problems.push({
|
|
301
|
+
code: "irreversible",
|
|
302
|
+
rule: null,
|
|
303
|
+
file: file.path,
|
|
304
|
+
detail: `${aliased} does not map back to ${file.path}.`,
|
|
305
|
+
});
|
|
306
|
+
});
|
|
307
|
+
const renamedPaths = new Set(forward.files.map((file) => file.path));
|
|
308
|
+
if (renamedPaths.size !== files.length)
|
|
309
|
+
problems.push({
|
|
310
|
+
code: "target_collision",
|
|
311
|
+
rule: null,
|
|
312
|
+
file: null,
|
|
313
|
+
detail: "Two files would share one aliased path.",
|
|
314
|
+
});
|
|
315
|
+
const unique = dedupe(problems);
|
|
316
|
+
return unique.length > 0
|
|
317
|
+
? { ok: false, files: [], renames: [], problems: unique, applied: counts }
|
|
318
|
+
: {
|
|
319
|
+
ok: true,
|
|
320
|
+
files: forward.files,
|
|
321
|
+
renames: forward.renames,
|
|
322
|
+
problems: [],
|
|
323
|
+
applied: counts,
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
/**
|
|
327
|
+
* A JSON value's string leaves, each as a file of its own, for aliasing a
|
|
328
|
+
* structure without touching its shape.
|
|
329
|
+
*
|
|
330
|
+
* Aliasing a serialized object as one text renames its keys too: a quote is
|
|
331
|
+
* a word boundary, so a rule `name → label` turned `"name":` into
|
|
332
|
+
* `"label":` and the object lost the field. Leaves alone keep every key,
|
|
333
|
+
* number and boolean as they were. Each is named `base#pointer`, and
|
|
334
|
+
* `withJsonLeaves` puts them back in the same order.
|
|
335
|
+
*/
|
|
336
|
+
export function jsonLeaves(base, value) {
|
|
337
|
+
const leaves = [];
|
|
338
|
+
const walk = (node, pointer) => {
|
|
339
|
+
if (typeof node === "string") {
|
|
340
|
+
leaves.push({ path: `${base}#${pointer}`, text: node });
|
|
341
|
+
}
|
|
342
|
+
else if (Array.isArray(node)) {
|
|
343
|
+
node.forEach((item, index) => walk(item, `${pointer}/${index}`));
|
|
344
|
+
}
|
|
345
|
+
else if (node !== null && typeof node === "object") {
|
|
346
|
+
for (const [key, item] of Object.entries(node))
|
|
347
|
+
walk(item, `${pointer}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`);
|
|
348
|
+
}
|
|
349
|
+
};
|
|
350
|
+
walk(value, "");
|
|
351
|
+
return leaves;
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* `value` with its string leaves replaced by `texts`, in `jsonLeaves`'s
|
|
355
|
+
* order; a leaf with no text left keeps its own. A fresh value: the one
|
|
356
|
+
* given is not changed.
|
|
357
|
+
*/
|
|
358
|
+
export function withJsonLeaves(value, texts) {
|
|
359
|
+
let at = 0;
|
|
360
|
+
const walk = (node) => {
|
|
361
|
+
if (typeof node === "string")
|
|
362
|
+
return texts[at++] ?? node;
|
|
363
|
+
if (Array.isArray(node))
|
|
364
|
+
return node.map(walk);
|
|
365
|
+
if (node !== null && typeof node === "object")
|
|
366
|
+
return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item)]));
|
|
367
|
+
return node;
|
|
368
|
+
};
|
|
369
|
+
return walk(value);
|
|
370
|
+
}
|
|
371
|
+
/**
|
|
372
|
+
* The name a build gives its alias table among the private files it writes:
|
|
373
|
+
* `private/pseudonym.lunox`, beside the hidden tests.
|
|
374
|
+
*/
|
|
375
|
+
export const PSEUDONYMS_FILE = "pseudonym.lunox";
|
|
376
|
+
export const PSEUDONYMS_SCHEMA_VERSION = 1;
|
|
377
|
+
/**
|
|
378
|
+
* One file renamed by the rules, without the checks `applyAliases` makes:
|
|
379
|
+
* for reading back a file already proved to map back, such as a public
|
|
380
|
+
* file in its private names under the inverse table. `file.path` is its
|
|
381
|
+
* repository path, which scoped rules are matched against.
|
|
382
|
+
*/
|
|
383
|
+
export function renameFile(file, rules) {
|
|
384
|
+
return rewrite([file], rules, "forward", null, null).files[0] ?? file;
|
|
385
|
+
}
|
|
386
|
+
/** The inverse table, as a contribution's import applies it. */
|
|
387
|
+
export function invertAliasRules(rules) {
|
|
388
|
+
return [...rules]
|
|
389
|
+
.reverse()
|
|
390
|
+
.map((rule) => ({ ...rule, before: rule.after, after: rule.before }));
|
|
391
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
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
|
+
export interface VocabularyLeak {
|
|
82
|
+
readonly term: string;
|
|
83
|
+
readonly occurrences: number;
|
|
84
|
+
/** The files it was found in, path or text, in the order given. */
|
|
85
|
+
readonly files: readonly string[];
|
|
86
|
+
}
|
|
87
|
+
/** Where a term was found in a text: `text.slice(start, end)`. */
|
|
88
|
+
export interface TermMatch {
|
|
89
|
+
readonly start: number;
|
|
90
|
+
readonly end: number;
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Every match of `term` in `text` that starts and ends on a word boundary:
|
|
94
|
+
* not a letter, or a case change an identifier marks (`myAcme`, `acmeOrders`,
|
|
95
|
+
* and in a run of capitals the last one before a lowercase, `XMLAcme`,
|
|
96
|
+
* `ACMEOrders`), or a capital after a digit (`web3Labs`). A term that starts
|
|
97
|
+
* or ends on a digit (`web3`) needs a non-digit there too, so `web3` is not
|
|
98
|
+
* found in `web30`.
|
|
99
|
+
*/
|
|
100
|
+
export declare function termMatches(text: string, term: string): TermMatch[];
|
|
101
|
+
/** Each term found in the files' paths or text, in the vocabulary's order. */
|
|
102
|
+
export declare function vocabularyLeaks(files: readonly {
|
|
103
|
+
readonly path: string;
|
|
104
|
+
readonly text: string;
|
|
105
|
+
}[], vocabulary: readonly string[]): VocabularyLeak[];
|
|
106
|
+
/** A blocker for each leak, naming where it was found and what clears it. */
|
|
107
|
+
export declare function vocabularyBlockers(leaks: readonly VocabularyLeak[]): ProjectBlocker[];
|