carveout 0.2.0 → 0.3.1

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.
@@ -0,0 +1,301 @@
1
+ /**
2
+ * Carrying a change between a version's public names and the repository's:
3
+ * the reverse path merge-back needs (a submission, made in public names,
4
+ * as a patch on the repository at the pinned commit), and the forward path
5
+ * the CI judge needs (a pull request's files, in the repository's names,
6
+ * as a submission on the version).
7
+ *
8
+ * The build's transforms are token-for-token and never add or remove a
9
+ * line: the name table, import specifiers rewritten to the slice's layout,
10
+ * mocked packages and environment variables renamed. So a version's file
11
+ * and the repository's line up line by line, and a change carries by line:
12
+ *
13
+ * - A line the change kept is the other side's line at the same place,
14
+ * byte for byte. A change that touches nothing carries to the original.
15
+ * - A line the change added is renamed token by token, first by what the
16
+ * two originals' differing lines show the build renamed in this file
17
+ * (an import specifier, a package, an environment variable), then by the
18
+ * name table. It is refused unless renaming it back gives it again, so
19
+ * new text holding a name the side it was written on never shows (the
20
+ * repository's own names, in a contribution) cannot pass through.
21
+ * - A line the change added that imports the sandbox's harness (a mock, a
22
+ * stub), which the repository does not have, is refused.
23
+ *
24
+ * Pure and platform-neutral: the worker reads the files, the API writes
25
+ * the pull request.
26
+ *
27
+ * Moved here verbatim from sandbox-factory's packages/core/src/sandbox/carry.ts
28
+ * (commit f31d780), on carveout's alias implementation (`aliases.ts`). One
29
+ * addition: `aliases` on `ReverseInput`, `ForwardInput` and `projectPathOf`,
30
+ * so a version built with an older renaming carries back under it
31
+ * (`ALIASES_V1`, `legacy.ts`).
32
+ */
33
+ import { invertAliasRules, renameFile } from "./aliases.js";
34
+ /** carveout's renaming (`aliases.ts`). */
35
+ export const CURRENT_ALIASES = { renameFile, invertAliasRules };
36
+ /** More than this many lines changed in one file, and added lines are not matched to kept ones. */
37
+ const MATCH_CELLS = 4_000_000;
38
+ /** Splits text into lines, each with its line ending. */
39
+ export function linesOf(text) {
40
+ return text === "" ? [] : (text.match(/[^\n]*\n|[^\n]+$/g) ?? []);
41
+ }
42
+ /** Where a string literal opened at `start` closes, past its quote; -1 for none on the line. */
43
+ function literalEnd(line, start) {
44
+ const quote = line[start];
45
+ for (let at = start + 1; at < line.length; at += 1) {
46
+ const char = line[at];
47
+ if (char === "\\")
48
+ at += 1;
49
+ else if (char === quote)
50
+ return at + 1;
51
+ else if (char === "\n" || (quote === "`" && char === "$"))
52
+ return -1;
53
+ }
54
+ return -1;
55
+ }
56
+ const IDENTIFIER_START = /[A-Za-z_$]/;
57
+ const IDENTIFIER_PART = /[A-Za-z0-9_$]/;
58
+ const SPACE = /\s/;
59
+ /**
60
+ * A line as tokens: string literals whole (a template literal only without
61
+ * `${`), identifiers, runs of space, single characters. One pass, linear in
62
+ * the line: a quote that does not close on it is a character of its own.
63
+ */
64
+ export function tokensOf(line) {
65
+ const tokens = [];
66
+ // A quote that found no close cannot find one from further on: a later
67
+ // quote of its kind was either escaped in that scan, which then went on
68
+ // as a scan from it would, or would have closed it.
69
+ const unclosed = new Set();
70
+ let at = 0;
71
+ while (at < line.length) {
72
+ const char = line[at];
73
+ let end = at + 1;
74
+ if (char === '"' || char === "'" || char === "`") {
75
+ const close = unclosed.has(char) ? -1 : literalEnd(line, at);
76
+ if (close > 0)
77
+ end = close;
78
+ else
79
+ unclosed.add(char);
80
+ }
81
+ else if (IDENTIFIER_START.test(char))
82
+ while (end < line.length && IDENTIFIER_PART.test(line[end]))
83
+ end += 1;
84
+ else if (SPACE.test(char))
85
+ while (end < line.length && SPACE.test(line[end]))
86
+ end += 1;
87
+ tokens.push(line.slice(at, end));
88
+ at = end;
89
+ }
90
+ return tokens;
91
+ }
92
+ /**
93
+ * What the two sides' originals show was renamed in this file: each token
94
+ * that differs where the lines otherwise line up, from one side to the
95
+ * other. A token renamed two ways is listed as ambiguous.
96
+ */
97
+ export function learnedRenames(from, to) {
98
+ const renames = new Map();
99
+ const ambiguous = new Set();
100
+ from.forEach((line, index) => {
101
+ const other = to[index];
102
+ if (other === undefined || other === line)
103
+ return;
104
+ const a = tokensOf(line);
105
+ const b = tokensOf(other);
106
+ if (a.length !== b.length)
107
+ return;
108
+ a.forEach((token, at) => {
109
+ const target = b[at];
110
+ if (token === target)
111
+ return;
112
+ const known = renames.get(token);
113
+ if (known === undefined)
114
+ renames.set(token, target);
115
+ else if (known !== target)
116
+ ambiguous.add(token);
117
+ });
118
+ });
119
+ return { renames, ambiguous };
120
+ }
121
+ /**
122
+ * For each line of `b`, the line of `a` it keeps, or -1 for one the change
123
+ * added: a longest common subsequence, after the shared head and tail.
124
+ */
125
+ export function keptLines(a, b) {
126
+ const kept = b.map(() => -1);
127
+ let head = 0;
128
+ while (head < a.length && head < b.length && a[head] === b[head]) {
129
+ kept[head] = head;
130
+ head += 1;
131
+ }
132
+ let tail = 0;
133
+ while (tail < a.length - head &&
134
+ tail < b.length - head &&
135
+ a[a.length - 1 - tail] === b[b.length - 1 - tail]) {
136
+ kept[b.length - 1 - tail] = a.length - 1 - tail;
137
+ tail += 1;
138
+ }
139
+ const n = a.length - head - tail;
140
+ const m = b.length - head - tail;
141
+ if (n === 0 || m === 0 || (n + 1) * (m + 1) > MATCH_CELLS)
142
+ return kept;
143
+ const width = m + 1;
144
+ const length = new Int32Array((n + 1) * width);
145
+ for (let i = n - 1; i >= 0; i -= 1)
146
+ for (let j = m - 1; j >= 0; j -= 1)
147
+ length[i * width + j] =
148
+ a[head + i] === b[head + j]
149
+ ? length[(i + 1) * width + j + 1] + 1
150
+ : Math.max(length[(i + 1) * width + j], length[i * width + j + 1]);
151
+ let i = 0;
152
+ let j = 0;
153
+ while (i < n && j < m) {
154
+ if (a[head + i] === b[head + j]) {
155
+ kept[head + j] = head + i;
156
+ i += 1;
157
+ j += 1;
158
+ }
159
+ else if (length[(i + 1) * width + j] >=
160
+ length[i * width + j + 1])
161
+ i += 1;
162
+ else
163
+ j += 1;
164
+ }
165
+ return kept;
166
+ }
167
+ const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(["'])(\.{1,2}\/[^"']*)\1/g;
168
+ /** A relative specifier resolved against the importing file's folder. */
169
+ export function resolveRelative(importer, specifier) {
170
+ const parts = importer.split("/").slice(0, -1);
171
+ for (const part of specifier.split("/")) {
172
+ if (part === "" || part === ".")
173
+ continue;
174
+ if (part === "..")
175
+ parts.pop();
176
+ else
177
+ parts.push(part);
178
+ }
179
+ return parts.join("/");
180
+ }
181
+ /**
182
+ * The change carried to the other side, file by file, with what cannot be
183
+ * carried refused. A refused file is left out of `files`.
184
+ */
185
+ export function carryChange(files, renaming) {
186
+ const carried = [];
187
+ const refused = [];
188
+ for (const file of files) {
189
+ const refuse = (reason) => refused.push({ path: file.fromPath, reason });
190
+ if (file.edited === file.fromOriginal)
191
+ continue;
192
+ const from = linesOf(file.fromOriginal ?? "");
193
+ const to = linesOf(file.toOriginal ?? "");
194
+ if (from.length !== to.length) {
195
+ refuse("The version's copy of this file does not line up with the repository's, so the change cannot be carried.");
196
+ continue;
197
+ }
198
+ const { renames, ambiguous } = learnedRenames(from, to);
199
+ const back = new Map([...renames].map(([a, b]) => [b, a]));
200
+ const edited = linesOf(file.edited);
201
+ const kept = keptLines(from, edited);
202
+ const out = [];
203
+ const problems = new Set();
204
+ edited.forEach((line, index) => {
205
+ const keptAt = kept[index];
206
+ if (keptAt >= 0) {
207
+ out.push(to[keptAt]);
208
+ return;
209
+ }
210
+ const tokens = tokensOf(line);
211
+ if (tokens.some((token) => ambiguous.has(token))) {
212
+ problems.add("An added line uses a name the build renamed two ways in this file.");
213
+ return;
214
+ }
215
+ const renamed = renaming.to(tokens.map((token) => renames.get(token) ?? token).join(""), file);
216
+ const again = tokensOf(renaming.back(renamed, file))
217
+ .map((token) => back.get(token) ?? token)
218
+ .join("");
219
+ if (again !== line) {
220
+ problems.add("An added line holds a name the other side would read differently, so it does not map back.");
221
+ return;
222
+ }
223
+ if (renaming.isHarness !== undefined)
224
+ for (const match of line.matchAll(IMPORT_SPECIFIER))
225
+ if (renaming.isHarness(resolveRelative(file.fromPath, match[2])))
226
+ problems.add(`An added line imports ${match[2]}, part of the sandbox's harness, which the repository does not have.`);
227
+ out.push(renamed);
228
+ });
229
+ if (problems.size > 0) {
230
+ for (const problem of [...problems].sort())
231
+ refuse(problem);
232
+ continue;
233
+ }
234
+ carried.push({ path: file.toPath, text: out.join("") });
235
+ }
236
+ return {
237
+ files: carried.sort((a, b) => (a.path < b.path ? -1 : 1)),
238
+ refused,
239
+ };
240
+ }
241
+ /** A file's path without its script extension, for matching an import to it. */
242
+ const stem = (path) => path.replace(/\.(?:[cm]?[jt]sx?|d\.ts)$/, "");
243
+ /**
244
+ * A submission, made in the version's public names, as whole files on the
245
+ * repository at the pinned commit: merge-back's reverse path.
246
+ */
247
+ export function reverseSubmission(input) {
248
+ const { renameFile, invertAliasRules } = input.aliases ?? CURRENT_ALIASES;
249
+ const inverse = invertAliasRules(input.rules);
250
+ const repositoryPathOf = new Map(input.renames.map(({ from, to }) => [to, from]));
251
+ const harness = new Set(input.harness.map(stem));
252
+ const files = input.submitted.map((file) => {
253
+ const toPath = repositoryPathOf.get(file.path) ??
254
+ renameFile({ path: file.path, text: "" }, inverse).path;
255
+ return {
256
+ fromPath: file.path,
257
+ toPath,
258
+ fromOriginal: input.publicText(file.path),
259
+ toOriginal: input.repositoryText(toPath),
260
+ edited: file.text,
261
+ };
262
+ });
263
+ return carryChange(files, {
264
+ to: (text, file) => renameFile({ path: file.toPath, text }, inverse).text,
265
+ back: (text, file) => renameFile({ path: file.toPath, text }, input.rules).text,
266
+ isHarness: (path) => harness.has(stem(path)),
267
+ });
268
+ }
269
+ /**
270
+ * Where a repository file is in the version's project: where the build
271
+ * moved it, else where the name table's path rules put it.
272
+ */
273
+ export function projectPathOf(path, renames, rules, aliases = CURRENT_ALIASES) {
274
+ const { renameFile } = aliases;
275
+ return (renames.find((rename) => rename.from === path)?.to ??
276
+ renameFile({ path, text: "" }, rules).path);
277
+ }
278
+ /**
279
+ * A change made in the repository's names, as whole files on the version's
280
+ * project: the CI judge's forward path. Only files the version has (or its
281
+ * paths name) are carried; the caller keeps to the editable ones.
282
+ */
283
+ export function forwardChange(input) {
284
+ const aliases = input.aliases ?? CURRENT_ALIASES;
285
+ const { renameFile } = aliases;
286
+ const inverse = aliases.invertAliasRules(input.rules);
287
+ const files = input.changed.map((file) => {
288
+ const toPath = projectPathOf(file.path, input.renames, input.rules, aliases);
289
+ return {
290
+ fromPath: file.path,
291
+ toPath,
292
+ fromOriginal: input.repositoryText(file.path),
293
+ toOriginal: input.publicText(toPath),
294
+ edited: file.text,
295
+ };
296
+ });
297
+ return carryChange(files, {
298
+ to: (text, file) => renameFile({ path: file.fromPath, text }, input.rules).text,
299
+ back: (text, file) => renameFile({ path: file.fromPath, text }, inverse).text,
300
+ });
301
+ }
@@ -23,7 +23,7 @@
23
23
  * find.
24
24
  */
25
25
  import { renameFile } from "./aliases.js";
26
- import { relativeSpecifier } from "../project/project.js";
26
+ import { relativeSpecifier } from "../project/paths.js";
27
27
  import { termMatches } from "./vocabulary.js";
28
28
  /**
29
29
  * Placeholders, in the order tried: a term takes the first that no input
@@ -26,7 +26,7 @@ import { existsSync, readFileSync } from "node:fs";
26
26
  import { basename, join } from "node:path";
27
27
  import { applyAliases, invertAliasRules, renameFile } from "./aliases.js";
28
28
  import { draftPseudonyms, restyle } from "./draft.js";
29
- import { emailNames, termMatches, termWords, tooCommonAlone, vocabularyLeaks, } from "./vocabulary.js";
29
+ import { emailNames, scannedNames, termMatches, termWords, vocabularyLeaks, } from "./vocabulary.js";
30
30
  /** A package.json's fields that name its owner, or null when unreadable. */
31
31
  function manifestNames(path) {
32
32
  if (!existsSync(path))
@@ -155,39 +155,6 @@ function hostedNames(url) {
155
155
  names.push({ name: repo, kind: "name" });
156
156
  return names;
157
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
158
  /** A Git setting of the checkout, or null. */
192
159
  function gitValue(repo, args) {
193
160
  try {
@@ -225,12 +192,7 @@ export function workspaceNames(repo, packageDirs, owner = []) {
225
192
  const mail = gitValue(repo, ["config", "user.email"]);
226
193
  if (mail !== null)
227
194
  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);
195
+ return scannedNames(found, owner);
234
196
  }
235
197
  /**
236
198
  * The names written after a name the repository goes by, the way its
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Alias rules: the private, bidirectional name table a sandbox version
3
+ * applies to copied source before it can leave the organization.
4
+ *
5
+ * A rule is an explicit pair with a kind and a scope. Rules are ordered,
6
+ * and applied in that order; they are validated as a set before any text
7
+ * is touched, because two rules that look harmless alone can interact (one
8
+ * rule's output is another's input, two rules share a target, two
9
+ * identifier or path names differ only by case). Application is
10
+ * deterministic and textual, bounded to identifier or path tokens so a name
11
+ * inside a longer identifier is never touched, and it is checked to be
12
+ * reversible: applying the inverse table to the output must give the input
13
+ * back byte for byte. A text rule matches its exact, case-sensitive
14
+ * characters and is inverted the same way, so two text rules that differ
15
+ * only by case are distinct and both hold. A
16
+ * transformation that cannot be mapped back is refused, never silently
17
+ * dropped. Redaction of implementation is not a rename and has no place
18
+ * here.
19
+ */
20
+ export declare const ALIAS_KINDS: readonly ["identifier", "path", "text"];
21
+ export type AliasKind = (typeof ALIAS_KINDS)[number];
22
+ export interface AliasRule {
23
+ readonly before: string;
24
+ readonly after: string;
25
+ readonly kind: AliasKind;
26
+ /**
27
+ * Repository paths the rule applies to: a file, or a directory prefix.
28
+ * Empty means every file. Scoped rules let one name mean different
29
+ * things in different modules without a symbol table.
30
+ */
31
+ readonly paths: readonly string[];
32
+ }
33
+ export declare const ALIAS_PROBLEM_CODES: readonly ["empty", "identity", "invalid_identifier", "invalid_path", "duplicate_source", "duplicate_target", "chained", "casing_variant", "target_collision", "irreversible", "too_many", "unsupported_kind"];
34
+ export type AliasProblemCode = (typeof ALIAS_PROBLEM_CODES)[number];
35
+ export interface AliasProblem {
36
+ readonly code: AliasProblemCode;
37
+ /** Index into the rule list; null for a problem of the whole set. */
38
+ readonly rule: number | null;
39
+ readonly file: string | null;
40
+ readonly detail: string;
41
+ }
42
+ export declare const ALIAS_RULES_MAX = 500;
43
+ export interface AliasValidation {
44
+ readonly ok: boolean;
45
+ readonly problems: readonly AliasProblem[];
46
+ }
47
+ /** Whether the set is well formed and free of interactions. */
48
+ export declare function validateAliasRules(rules: readonly AliasRule[]): AliasValidation;
49
+ export interface AliasedFile {
50
+ readonly path: string;
51
+ readonly text: string;
52
+ }
53
+ export interface AliasApplication {
54
+ readonly ok: boolean;
55
+ readonly files: readonly AliasedFile[];
56
+ /** Original path to aliased path, for every file whose path changed. */
57
+ readonly renames: readonly {
58
+ readonly from: string;
59
+ readonly to: string;
60
+ }[];
61
+ readonly problems: readonly AliasProblem[];
62
+ /** Rule index to the number of replacements it made, for the record. */
63
+ readonly applied: readonly number[];
64
+ }
65
+ /**
66
+ * Applies the rules to the files and proves the result maps back. Problems
67
+ * refuse the whole application: a partially aliased candidate is worse
68
+ * than none, because it looks finished.
69
+ */
70
+ export declare function applyAliases(files: readonly AliasedFile[], rules: readonly AliasRule[]): AliasApplication;
71
+ /**
72
+ * A JSON value's string leaves, each as a file of its own, for aliasing a
73
+ * structure without touching its shape.
74
+ *
75
+ * Aliasing a serialized object as one text renames its keys too: a quote is
76
+ * a word boundary, so a rule `name → label` turned `"name":` into
77
+ * `"label":` and the object lost the field. Leaves alone keep every key,
78
+ * number and boolean as they were. Each is named `base#pointer`, and
79
+ * `withJsonLeaves` puts them back in the same order.
80
+ */
81
+ export declare function jsonLeaves(base: string, value: unknown): AliasedFile[];
82
+ /**
83
+ * `value` with its string leaves replaced by `texts`, in `jsonLeaves`'s
84
+ * order; a leaf with no text left keeps its own. A fresh value: the one
85
+ * given is not changed.
86
+ */
87
+ export declare function withJsonLeaves<T>(value: T, texts: readonly string[]): T;
88
+ /**
89
+ * The name a build gives its alias table among the private files it writes:
90
+ * `private/pseudonym.lunox`, beside the hidden tests.
91
+ */
92
+ export declare const PSEUDONYMS_FILE = "pseudonym.lunox";
93
+ export declare const PSEUDONYMS_SCHEMA_VERSION = 1;
94
+ /** `pseudonym.lunox`: the table a version's public files were renamed by. */
95
+ export interface PseudonymTable {
96
+ readonly schemaVersion: typeof PSEUDONYMS_SCHEMA_VERSION;
97
+ readonly rules: readonly AliasRule[];
98
+ }
99
+ /**
100
+ * One file renamed by the rules, without the checks `applyAliases` makes:
101
+ * for reading back a file already proved to map back, such as a public
102
+ * file in its private names under the inverse table. `file.path` is its
103
+ * repository path, which scoped rules are matched against.
104
+ */
105
+ export declare function renameFile(file: AliasedFile, rules: readonly AliasRule[]): AliasedFile;
106
+ /** The inverse table, as a contribution's import applies it. */
107
+ export declare function invertAliasRules(rules: readonly AliasRule[]): AliasRule[];