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,394 @@
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 const ALIAS_KINDS = ["identifier", "path", "text"];
21
+ export const ALIAS_PROBLEM_CODES = [
22
+ "empty",
23
+ "identity",
24
+ "invalid_identifier",
25
+ "invalid_path",
26
+ "duplicate_source",
27
+ "duplicate_target",
28
+ "chained",
29
+ "casing_variant",
30
+ "target_collision",
31
+ "irreversible",
32
+ "too_many",
33
+ "unsupported_kind",
34
+ ];
35
+ export const ALIAS_RULES_MAX = 500;
36
+ const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/;
37
+ // Any character a file name may hold but a NUL, a backslash or a quote:
38
+ // a framework's `app/[lunoxId]` or an `assets/Lunox Logo.png` is a path.
39
+ const PATH = /^[^\0\\"'`\n]+$/;
40
+ /** Whether the set is well formed and free of interactions. */
41
+ export function validateAliasRules(rules) {
42
+ const problems = [];
43
+ if (rules.length > ALIAS_RULES_MAX)
44
+ problems.push({
45
+ code: "too_many",
46
+ rule: null,
47
+ file: null,
48
+ detail: `${rules.length} rules exceed the limit of ${ALIAS_RULES_MAX}.`,
49
+ });
50
+ const sources = new Map();
51
+ const targets = new Map();
52
+ const folded = new Map();
53
+ rules.forEach((rule, index) => {
54
+ if (!ALIAS_KINDS.includes(rule.kind)) {
55
+ problems.push({
56
+ code: "unsupported_kind",
57
+ rule: index,
58
+ file: null,
59
+ detail: `Kind ${String(rule.kind)} is not supported.`,
60
+ });
61
+ return;
62
+ }
63
+ if (rule.before === "" || rule.after === "") {
64
+ problems.push({
65
+ code: "empty",
66
+ rule: index,
67
+ file: null,
68
+ detail: "Both sides of a rule must be non-empty.",
69
+ });
70
+ return;
71
+ }
72
+ if (rule.before === rule.after)
73
+ problems.push({
74
+ code: "identity",
75
+ rule: index,
76
+ file: null,
77
+ detail: `${rule.before} maps to itself.`,
78
+ });
79
+ if (rule.kind === "identifier" &&
80
+ (!IDENTIFIER.test(rule.before) || !IDENTIFIER.test(rule.after)))
81
+ problems.push({
82
+ code: "invalid_identifier",
83
+ rule: index,
84
+ file: null,
85
+ detail: `${rule.before} → ${rule.after} is not an identifier pair.`,
86
+ });
87
+ if (rule.kind === "path" &&
88
+ (!PATH.test(rule.before) ||
89
+ !PATH.test(rule.after) ||
90
+ rule.before.startsWith("/") ||
91
+ rule.after.startsWith("/") ||
92
+ rule.before.split("/").includes("..") ||
93
+ rule.after.split("/").includes("..")))
94
+ problems.push({
95
+ code: "invalid_path",
96
+ rule: index,
97
+ file: null,
98
+ detail: `${rule.before} → ${rule.after} is not a repository path pair.`,
99
+ });
100
+ const scope = rule.paths.length === 0 ? "" : [...rule.paths].sort().join("\n");
101
+ const sourceKey = `${rule.kind}\n${scope}\n${rule.before}`;
102
+ const targetKey = `${rule.kind}\n${scope}\n${rule.after}`;
103
+ const earlier = sources.get(sourceKey);
104
+ if (earlier !== undefined)
105
+ problems.push({
106
+ code: "duplicate_source",
107
+ rule: index,
108
+ file: null,
109
+ detail: `${rule.before} is already renamed by rule ${earlier}.`,
110
+ });
111
+ else
112
+ sources.set(sourceKey, index);
113
+ const shared = targets.get(targetKey);
114
+ if (shared !== undefined)
115
+ problems.push({
116
+ code: "duplicate_target",
117
+ rule: index,
118
+ file: null,
119
+ detail: `${rule.after} is already the target of rule ${shared}.`,
120
+ });
121
+ else
122
+ targets.set(targetKey, index);
123
+ // Identifier and path names that differ only by case are refused. Text
124
+ // is exempt: a text rule is applied with an exact, case-sensitive
125
+ // pattern and inverted exactly, so ` lunox ` → ` acme ` beside
126
+ // ` LUNOX ` → ` ACME ` is unambiguous both ways, and it is how a
127
+ // spelling no identifier rule can reach is covered.
128
+ if (rule.kind === "text")
129
+ return;
130
+ for (const [name, side] of [
131
+ [rule.before, "source"],
132
+ [rule.after, "target"],
133
+ ]) {
134
+ const key = `${rule.kind}\n${name.toLowerCase()}`;
135
+ const seen = folded.get(key);
136
+ if (seen !== undefined && seen !== index) {
137
+ const other = rules[seen];
138
+ if (other !== undefined &&
139
+ other.before !== name &&
140
+ other.after !== name)
141
+ problems.push({
142
+ code: "casing_variant",
143
+ rule: index,
144
+ file: null,
145
+ detail: `${name} (${side}) differs only by case from a name in rule ${seen}.`,
146
+ });
147
+ }
148
+ else
149
+ folded.set(key, index);
150
+ }
151
+ });
152
+ // A target that is another rule's source would be renamed again by a
153
+ // later rule, or would make the inverse table ambiguous.
154
+ rules.forEach((rule, index) => {
155
+ rules.forEach((other, otherIndex) => {
156
+ if (index !== otherIndex &&
157
+ rule.kind === other.kind &&
158
+ rule.after === other.before &&
159
+ scopesOverlap(rule.paths, other.paths))
160
+ problems.push({
161
+ code: "chained",
162
+ rule: index,
163
+ file: null,
164
+ detail: `${rule.after} is produced by rule ${index} and consumed by rule ${otherIndex}.`,
165
+ });
166
+ });
167
+ });
168
+ return { ok: problems.length === 0, problems: dedupe(problems) };
169
+ }
170
+ function dedupe(problems) {
171
+ const seen = new Set();
172
+ const result = [];
173
+ for (const problem of problems) {
174
+ const key = `${problem.code}\n${problem.rule ?? ""}\n${problem.file ?? ""}\n${problem.detail}`;
175
+ if (seen.has(key))
176
+ continue;
177
+ seen.add(key);
178
+ result.push(problem);
179
+ }
180
+ return result;
181
+ }
182
+ function inScope(path, scopes) {
183
+ if (scopes.length === 0)
184
+ return true;
185
+ return scopes.some((scope) => path === scope || path.startsWith(`${scope.replace(/\/$/, "")}/`));
186
+ }
187
+ function scopesOverlap(a, b) {
188
+ if (a.length === 0 || b.length === 0)
189
+ return true;
190
+ return a.some((x) => b.some((y) => inScope(x, [y]) || inScope(y, [x])));
191
+ }
192
+ const escape = (text) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
193
+ const identifierPattern = (name) => new RegExp(`(?<![A-Za-z0-9_$])${escape(name)}(?![A-Za-z0-9_$])`, "g");
194
+ // A path's rule matches after a `/` too: `../../packages/lunox-money/x`
195
+ // is the same folder, from another file.
196
+ const pathPattern = (name) => new RegExp(`(?<![A-Za-z0-9_.\\-@])${escape(name)}(?![A-Za-z0-9_\\-@])`, "g");
197
+ const textPattern = (name) => new RegExp(escape(name), "g");
198
+ const patternFor = (rule, name) => rule.kind === "identifier"
199
+ ? identifierPattern(name)
200
+ : rule.kind === "path"
201
+ ? pathPattern(name)
202
+ : textPattern(name);
203
+ /**
204
+ * A path renamed by the table, renamed back: the path rules undone in
205
+ * reverse order, each scoped by the original path as the forward pass was.
206
+ */
207
+ function invertPath(path, original, reversed) {
208
+ let back = path;
209
+ for (const rule of reversed)
210
+ if (rule.kind === "path" && inScope(original, rule.paths))
211
+ back = back.replace(patternFor(rule, rule.after), rule.before);
212
+ return back;
213
+ }
214
+ function rewrite(files, rules, direction, counts, problems) {
215
+ const renames = [];
216
+ const result = files.map((file) => {
217
+ let text = file.text;
218
+ let path = file.path;
219
+ rules.forEach((rule, index) => {
220
+ if (!inScope(file.path, rule.paths))
221
+ return;
222
+ const from = direction === "forward" ? rule.before : rule.after;
223
+ const to = direction === "forward" ? rule.after : rule.before;
224
+ if (problems !== null) {
225
+ // The target must not already occur in the file as it was, or the
226
+ // inverse table could not tell a renamed occurrence from a natural
227
+ // one. An occurrence an earlier rule made is not natural: two rules
228
+ // that give one term the same placeholder (a path and an
229
+ // identifier) make it on purpose, and the round trip below proves
230
+ // the table maps back.
231
+ const existing = file.text.match(patternFor(rule, to));
232
+ if (existing !== null && existing.length > 0)
233
+ problems.push({
234
+ code: "target_collision",
235
+ rule: index,
236
+ file: file.path,
237
+ detail: `${to} already occurs ${existing.length} time(s) in ${file.path}.`,
238
+ });
239
+ }
240
+ const pattern = patternFor(rule, from);
241
+ let made = 0;
242
+ text = text.replace(pattern, () => {
243
+ made += 1;
244
+ return to;
245
+ });
246
+ if (counts !== null)
247
+ counts[index] = (counts[index] ?? 0) + made;
248
+ if (rule.kind === "path") {
249
+ const renamed = path.replace(patternFor(rule, from), to);
250
+ if (renamed !== path) {
251
+ path = renamed;
252
+ if (counts !== null)
253
+ counts[index] = (counts[index] ?? 0) + 1;
254
+ }
255
+ }
256
+ });
257
+ if (path !== file.path)
258
+ renames.push({ from: file.path, to: path });
259
+ return { path, text };
260
+ });
261
+ return { files: result, renames };
262
+ }
263
+ /**
264
+ * Applies the rules to the files and proves the result maps back. Problems
265
+ * refuse the whole application: a partially aliased candidate is worse
266
+ * than none, because it looks finished.
267
+ */
268
+ export function applyAliases(files, rules) {
269
+ const validation = validateAliasRules(rules);
270
+ if (!validation.ok)
271
+ return {
272
+ ok: false,
273
+ files: [],
274
+ renames: [],
275
+ problems: validation.problems,
276
+ applied: [],
277
+ };
278
+ const problems = [];
279
+ const counts = rules.map(() => 0);
280
+ const forward = rewrite(files, rules, "forward", counts, problems);
281
+ // The inverse walks the rules backwards, undoing the last rename first.
282
+ const reversed = [...rules].reverse();
283
+ const inverse = rewrite(forward.files.map((file, index) => ({
284
+ // Scope checks use the original path: the inverse must see what the
285
+ // forward pass saw.
286
+ path: files[index]?.path ?? file.path,
287
+ text: file.text,
288
+ })), reversed, "inverse", null, null);
289
+ files.forEach((file, index) => {
290
+ const back = inverse.files[index];
291
+ if (back === undefined || back.text !== file.text)
292
+ problems.push({
293
+ code: "irreversible",
294
+ rule: null,
295
+ file: file.path,
296
+ detail: `Applying the inverse table to ${file.path} does not give the original back.`,
297
+ });
298
+ // Paths too: with `src → lib` beside a `lib/` the repository already
299
+ // has, `lib/x.ts` would map back to `src/x.ts`, and the table alone
300
+ // could not say where a public file came from.
301
+ const aliased = forward.files[index]?.path ?? file.path;
302
+ if (invertPath(aliased, file.path, reversed) !== file.path)
303
+ problems.push({
304
+ code: "irreversible",
305
+ rule: null,
306
+ file: file.path,
307
+ detail: `${aliased} does not map back to ${file.path}.`,
308
+ });
309
+ });
310
+ const renamedPaths = new Set(forward.files.map((file) => file.path));
311
+ if (renamedPaths.size !== files.length)
312
+ problems.push({
313
+ code: "target_collision",
314
+ rule: null,
315
+ file: null,
316
+ detail: "Two files would share one aliased path.",
317
+ });
318
+ const unique = dedupe(problems);
319
+ return unique.length > 0
320
+ ? { ok: false, files: [], renames: [], problems: unique, applied: counts }
321
+ : {
322
+ ok: true,
323
+ files: forward.files,
324
+ renames: forward.renames,
325
+ problems: [],
326
+ applied: counts,
327
+ };
328
+ }
329
+ /**
330
+ * A JSON value's string leaves, each as a file of its own, for aliasing a
331
+ * structure without touching its shape.
332
+ *
333
+ * Aliasing a serialized object as one text renames its keys too: a quote is
334
+ * a word boundary, so a rule `name → label` turned `"name":` into
335
+ * `"label":` and the object lost the field. Leaves alone keep every key,
336
+ * number and boolean as they were. Each is named `base#pointer`, and
337
+ * `withJsonLeaves` puts them back in the same order.
338
+ */
339
+ export function jsonLeaves(base, value) {
340
+ const leaves = [];
341
+ const walk = (node, pointer) => {
342
+ if (typeof node === "string") {
343
+ leaves.push({ path: `${base}#${pointer}`, text: node });
344
+ }
345
+ else if (Array.isArray(node)) {
346
+ node.forEach((item, index) => walk(item, `${pointer}/${index}`));
347
+ }
348
+ else if (node !== null && typeof node === "object") {
349
+ for (const [key, item] of Object.entries(node))
350
+ walk(item, `${pointer}/${key.replace(/~/g, "~0").replace(/\//g, "~1")}`);
351
+ }
352
+ };
353
+ walk(value, "");
354
+ return leaves;
355
+ }
356
+ /**
357
+ * `value` with its string leaves replaced by `texts`, in `jsonLeaves`'s
358
+ * order; a leaf with no text left keeps its own. A fresh value: the one
359
+ * given is not changed.
360
+ */
361
+ export function withJsonLeaves(value, texts) {
362
+ let at = 0;
363
+ const walk = (node) => {
364
+ if (typeof node === "string")
365
+ return texts[at++] ?? node;
366
+ if (Array.isArray(node))
367
+ return node.map(walk);
368
+ if (node !== null && typeof node === "object")
369
+ return Object.fromEntries(Object.entries(node).map(([key, item]) => [key, walk(item)]));
370
+ return node;
371
+ };
372
+ return walk(value);
373
+ }
374
+ /**
375
+ * The name a build gives its alias table among the private files it writes:
376
+ * `private/pseudonym.lunox`, beside the hidden tests.
377
+ */
378
+ export const PSEUDONYMS_FILE = "pseudonym.lunox";
379
+ export const PSEUDONYMS_SCHEMA_VERSION = 1;
380
+ /**
381
+ * One file renamed by the rules, without the checks `applyAliases` makes:
382
+ * for reading back a file already proved to map back, such as a public
383
+ * file in its private names under the inverse table. `file.path` is its
384
+ * repository path, which scoped rules are matched against.
385
+ */
386
+ export function renameFile(file, rules) {
387
+ return rewrite([file], rules, "forward", null, null).files[0] ?? file;
388
+ }
389
+ /** The inverse table, as a contribution's import applies it. */
390
+ export function invertAliasRules(rules) {
391
+ return [...rules]
392
+ .reverse()
393
+ .map((rule) => ({ ...rule, before: rule.after, after: rule.before }));
394
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Pseudonyms drafted by rule: the name table a sliced version needs to keep
3
+ * the workspace's vocabulary out of its public files, written without a
4
+ * model wherever the vocabulary is where rules can find it.
5
+ *
6
+ * An import of one of the workspace's own packages (`@acme/db`) resolves to
7
+ * a module the slice copies or stubs, so it becomes the relative path the
8
+ * generator would give it, scoped to the files that import it: a renamed
9
+ * package would no longer resolve. Every other occurrence of a vocabulary
10
+ * term becomes a placeholder of the same case, one placeholder per term
11
+ * that no input already uses: a path segment by a path rule, an identifier
12
+ * that contains it renamed whole (`acmeOrders` becomes `globexOrders`), and
13
+ * anything else as text, held in place by the characters around it. One
14
+ * identifier rule covers each spelling up to case; a second spelling that
15
+ * differs from it only by case (`LUNOX` beside `Lunox`) is text too, and
16
+ * several casings may each get a text rule of their own, since a text rule
17
+ * matches and inverts its exact characters.
18
+ *
19
+ * The draft is a candidate, not a guarantee: the caller checks it as the
20
+ * build applies it, and one that does not hold is where the pseudonyms agent
21
+ * starts. Only the vocabulary is covered; a name that says whose code it is
22
+ * without being in it (a customer, a product's feature) is the agent's to
23
+ * find.
24
+ */
25
+ import type { AliasRule, AliasedFile } from "./aliases.js";
26
+ /** An import the slice resolved: from a file, as written, to a module. */
27
+ export interface PseudonymImport {
28
+ readonly from: string;
29
+ readonly specifier: string;
30
+ readonly to: string;
31
+ }
32
+ /**
33
+ * Placeholders, in the order tried: a term takes the first that no input
34
+ * contains, in any case.
35
+ */
36
+ export declare const PSEUDONYM_PLACEHOLDERS: readonly string[];
37
+ /** A term's placeholder as words: its base, and a tail word for each word more the term has. */
38
+ export declare function placeholderWords(term: string, base: string): string[];
39
+ /**
40
+ * `words` spelled as `surface` spells its own: each word in the case of the
41
+ * surface's word in its place, joined as they are (`contosoLabs` for
42
+ * `sandboxFactory`, `CONTOSO_LABS` for `SANDBOX_FACTORY`, `contoso-labs`
43
+ * for `sandbox-factory`). A surface whose words run together with no mark
44
+ * between them (`sandboxfactory`) gets the words run together too.
45
+ */
46
+ export declare function restyle(words: readonly string[], surface: string): string;
47
+ export declare function draftPseudonyms(input: {
48
+ /** Everything the table is applied to, as `applyAliases` is given it. */
49
+ readonly files: readonly AliasedFile[];
50
+ readonly vocabulary: readonly string[];
51
+ /** The slice's resolved imports, inside it and across its cuts. */
52
+ readonly imports: readonly PseudonymImport[];
53
+ /**
54
+ * Filled with the placeholder each term was given: how a name the
55
+ * folder's holder writes in the placeholder's spelling maps back.
56
+ */
57
+ readonly placeholders?: Map<string, string>;
58
+ /**
59
+ * The placeholder each term had in an earlier cut of the repository, by
60
+ * its base word (`zorvex` of `zorvex-labs`): kept where it still fits, so
61
+ * a folder cut again (wider, or later) reads as the one before did.
62
+ */
63
+ readonly preferred?: ReadonlyMap<string, string>;
64
+ }): AliasRule[];