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
package/dist/src/carve.js
CHANGED
|
@@ -8,15 +8,15 @@
|
|
|
8
8
|
* scripts off, the public registry only) and resolves the folder's
|
|
9
9
|
* lockfile with npm; `offline` skips both.
|
|
10
10
|
*/
|
|
11
|
-
import { execFileSync } from "node:child_process";
|
|
11
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
12
12
|
import { createHash } from "node:crypto";
|
|
13
13
|
import { existsSync, mkdtempSync, readFileSync, readdirSync, statSync } from "node:fs";
|
|
14
|
-
import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
|
14
|
+
import { copyFile, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
15
15
|
import { homedir, tmpdir } from "node:os";
|
|
16
|
-
import { basename, dirname, join, posix, relative, resolve } from "node:path";
|
|
16
|
+
import { basename, dirname, join, posix, relative, resolve, sep } from "node:path";
|
|
17
17
|
import { command } from "./command.js";
|
|
18
18
|
import ts from "typescript";
|
|
19
|
-
import { BASELINE_PATH, LAST_RUN_PATH, SLICE_BUDGET_DEFAULTS, coverageGateOf, declarationPath, detectExternals, purityRules, generateProject, isScript, isSecretPath, isTestPath, packageNameOf, pathMatcher, renderBoundaryMarkdown, resolveScope, runtimePath, exposureOf, renderTestsMarkdown, testsVerdict, } from "./core.js";
|
|
19
|
+
import { BASELINE_PATH, HARNESS_DIR, LAST_RUN_PATH, ENV_FILE, ASSETS_LOADER_PATH, COUNTS_PATH, TOOL_DIRS, VITEST_REPORT_PATH, SLICE_BUDGET_DEFAULTS, coverageGateOf, testsGlobOf, declarationPath, detectExternals, purityRules, generateProject, isScript, isSecretPath, isTestPath, isPackageName, isRegistryVersion, packageNameOf, pathMatcher, renderBoundaryMarkdown, resolveScope, runtimePath, exposureOf, renderTestsMarkdown, serviceOf, testRunnerOf, testsVerdict, } from "./core.js";
|
|
20
20
|
import { analyseSlice } from "./cut.js";
|
|
21
21
|
import { catalogResolver } from "./project/catalog.js";
|
|
22
22
|
import { testRunnerResolver } from "./project/runner.js";
|
|
@@ -24,12 +24,18 @@ import { nearestCompilerOptions, workspacePackages, } from "./extract/compiler-c
|
|
|
24
24
|
import { packageModules, packageUses } from "./extract/fixture.js";
|
|
25
25
|
import { npmPackageTypes, typesPackageOf } from "./extract/package-types.js";
|
|
26
26
|
import { NODE_BUILTINS, isNodeBuiltin } from "./extract/typescript.js";
|
|
27
|
-
import { walkGraph } from "./graph.js";
|
|
27
|
+
import { walkGraph, workspaceImportersOf } from "./graph.js";
|
|
28
|
+
import { ENVIRONMENT_PACKAGES, fileEnvironments, vitestSettings } from "./project/vitest-config.js";
|
|
29
|
+
import { packageRegistries } from "./extract/package-registry.js";
|
|
28
30
|
import { extensionStyles, packageFolder, restoreImports, specifiersOf, undoRewrites, } from "./imports.js";
|
|
29
31
|
import { moduleSpecifiers, parseSource } from "./specifiers.js";
|
|
30
32
|
import { scanSecrets, secretBlockers } from "./project/secrets.js";
|
|
31
33
|
import { checkRecordPaths, handoffPolicy, trustedRepo } from "./policy.js";
|
|
32
|
-
|
|
34
|
+
import { sandboxRun } from "./sandbox.js";
|
|
35
|
+
import { configuredFolders, liveCandidates } from "./readiness.js";
|
|
36
|
+
import { exactToFolder, fromFolder, specifierPackages, toFolder, keptNames, looseLeaks, maskKept, pseudonymize, vocabularyBlockers, vocabularyLeaks, workspaceNames, ownedNames, workspaceVocabulary, } from "./pseudonyms/index.js";
|
|
37
|
+
import { RECEIPT_SCHEMA_VERSION, folderRecordFor, folderView, newCutId, receiptPathFor, trustedBaseline, trustedCut, trustedCutWith, writeReceipt, } from "./receipt.js";
|
|
38
|
+
export const CARVEOUT_VERSION = "0.2.0";
|
|
33
39
|
export const RECORD_PATH = "carveout.json";
|
|
34
40
|
export const RECORD_SCHEMA_VERSION = 1;
|
|
35
41
|
/** Blockers that stop a cut before it writes anything. */
|
|
@@ -48,7 +54,7 @@ export const SLICE_CLAUDE_PATH = "CLAUDE.md";
|
|
|
48
54
|
export function renderSliceAgents(input) {
|
|
49
55
|
const tests = input.testsInFolder === 0
|
|
50
56
|
? "No test of this code came with the folder"
|
|
51
|
-
: `${input.testsInFolder} test(s) of this code run here`;
|
|
57
|
+
: `${input.testsInFolder} test file(s) of this code run here, beside the folder's own interface test`;
|
|
52
58
|
const left = input.testsLeft === 0
|
|
53
59
|
? "."
|
|
54
60
|
: `; ${input.testsLeft} more stay in the repository (BOUNDARY.md names them), and a change only they would catch passes here.`;
|
|
@@ -58,12 +64,193 @@ export function renderSliceAgents(input) {
|
|
|
58
64
|
"A slice of a larger repository, cut by carveout. The source files are copied at their repository paths; every module they import from the rest of the repository is a `.d.ts` stub with a recording mock behind it.",
|
|
59
65
|
"",
|
|
60
66
|
`- Entry points: ${input.entryPoints.map((entry) => `\`${entry}\``).join(", ")}.`,
|
|
61
|
-
"- `npm ci`, then `npm test`: it builds and runs the folder's tests. Nothing else needs setting up.",
|
|
67
|
+
"- `npm ci`, then `npm test`: it builds and runs the folder's tests. Nothing else needs setting up. npm keeps its cache in your home folder; to keep everything in this folder, run it with `--cache .npm-cache`, which the owner's patch leaves out.",
|
|
62
68
|
"- Edit the copied source, and add files beside it in the same packages. The stubs, everything under `carveout/`, `package.json`, the lockfile and the root configuration are generated: edits to them do not go back.",
|
|
63
69
|
"- A mock answers any call with another mock. To make one return something in a test, read the README's Mocks section; nothing else under `carveout/` needs reading.",
|
|
64
|
-
"-
|
|
70
|
+
"- If `carveout/baseline.json` is there, the tests it lists failed on the mocks before any edit and are not counted; with none, every test counts.",
|
|
71
|
+
"- The build writes a `.js` beside each `.ts` (`.cjs` beside `.cts`, `.mjs` beside `.mts`), a new file's too: leave them. Only the source comes back, never what the build wrote from it. A type error fails the build, and so `npm test`.",
|
|
65
72
|
`- ${tests}${left} A pass here is a pass against mocks.`,
|
|
73
|
+
...(input.runner === undefined
|
|
74
|
+
? []
|
|
75
|
+
: [
|
|
76
|
+
input.runner === "vitest"
|
|
77
|
+
? '- The package\'s tests run on Vitest: write new ones with it (`import { expect, test } from "vitest"`); `npm test` runs them as the repository does. A node:test test runs here but not in the repository.'
|
|
78
|
+
: "- The package's tests run on node:test: write new ones with it (`import { test } from \"node:test\"`); `npm test` runs them as the repository does.",
|
|
79
|
+
]),
|
|
80
|
+
...(input.testsAt === undefined
|
|
81
|
+
? []
|
|
82
|
+
: [
|
|
83
|
+
`- New tests go where the repository's test script finds them: \`${input.testsAt}\`. A test elsewhere runs here but never in the repository, and covers nothing there.`,
|
|
84
|
+
]),
|
|
85
|
+
...(input.prettier === undefined
|
|
86
|
+
? []
|
|
87
|
+
: [
|
|
88
|
+
`- The repository formats with Prettier ${input.prettier.version}, ${input.prettier.config === null ? "at its defaults" : `with the settings in \`${input.prettier.config}\``}: check what you change with \`npx prettier@${input.prettier.version} --check <files>\`.${input.lint === true ? " Its lint script does not run here; the repository's CI runs it." : ""}`,
|
|
89
|
+
]),
|
|
90
|
+
...(input.coverage === undefined
|
|
91
|
+
? []
|
|
92
|
+
: [
|
|
93
|
+
`- The package's test script requires coverage (${coverageTerms(input.coverage)}). \`npm test\` holds each file you add under \`${input.coverage.folder}\` to it${input.coverage.allCovered === undefined ? "" : ", and fails on one no test loads"}; code you add to a file that came with the folder is not held to it here, and the repository measures it with its whole package: test it as you would a new file. \`CARVEOUT_COVERAGE=1 npm test\` prints node's coverage of every file.`,
|
|
94
|
+
]),
|
|
66
95
|
"- When done, leave the edits in place: the repository's owner turns them into a patch with `carveout patch`.",
|
|
96
|
+
"",
|
|
97
|
+
"## What comes back, and what is held for the owner",
|
|
98
|
+
"",
|
|
99
|
+
"- Edits to the source files here, and new files beside them in the same packages, come back.",
|
|
100
|
+
"- A test you were handed may only grow: a new test or a new assertion, a new import or a name added to one, a new helper of your own. Changing an assertion, the data or setup it rests on, or a helper or fixture you were handed; skipping or focusing a test; or mocking, spying on or rewriting the code under test is held for the owner to read.",
|
|
101
|
+
"- New code may not reach what the slice does not: no new package (package.json is generated), no new disk, network, process or environment access, no code built from text (`eval`, `Function`), no module outside these packages.",
|
|
102
|
+
"- If the work truly needs a package the folder does not have, say which, at which exact version, and why. Installing it here builds for you, but the owner's check runs on the cut's package.json, and the files that import it are held until the owner adds it to the repository.",
|
|
103
|
+
"- No code that tells a test run from a real one, ends the run early, or rewrites the assertion library or a built-in; no new `@ts-ignore`, `eslint-disable` or coverage ignore; no edit to check or test-runner configuration.",
|
|
104
|
+
"- Generated files (the stubs, everything under `carveout/`, `package.json`, the lockfile, the root configuration) do not come back: when the change needs one changed, say so.",
|
|
105
|
+
...(input.task === true ? ["", "The work asked for, and when it is accepted, is in `TASK.md`: read it first."] : []),
|
|
106
|
+
].join("\n")}\n`;
|
|
107
|
+
}
|
|
108
|
+
/** A coverage gate's thresholds, as `lines 90%, branches 90%`. */
|
|
109
|
+
function coverageTerms(coverage) {
|
|
110
|
+
return ["lines", "branches", "functions"]
|
|
111
|
+
.flatMap((name) => (coverage[name] === undefined ? [] : [`${name} ${coverage[name]}%`]))
|
|
112
|
+
.join(", ");
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* The packages whose types a file's errors are about: of the packages
|
|
116
|
+
* `candidate` accepts that the file imports, those whose imported names a
|
|
117
|
+
* type names in the declaration around an error's line (`Pick<Stripe.
|
|
118
|
+
* Customer, …>`), or anywhere in the file for an error with no line. A
|
|
119
|
+
* service package imported beside one, for its values alone, is not:
|
|
120
|
+
* installed, it would run against a service the folder has none of.
|
|
121
|
+
*/
|
|
122
|
+
export function packagesTypingLines(path, text, lines, candidate) {
|
|
123
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, /\.[cm]?tsx$|\.jsx$/.test(path) ? ts.ScriptKind.TSX : ts.ScriptKind.TS);
|
|
124
|
+
const bindings = new Map();
|
|
125
|
+
for (const statement of source.statements) {
|
|
126
|
+
if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
|
|
127
|
+
continue;
|
|
128
|
+
const specifier = statement.moduleSpecifier.text;
|
|
129
|
+
if (specifier.startsWith(".") || isNodeBuiltin(specifier))
|
|
130
|
+
continue;
|
|
131
|
+
const name = packageNameOf(specifier);
|
|
132
|
+
const clause = statement.importClause;
|
|
133
|
+
if (!candidate(name) || clause === undefined)
|
|
134
|
+
continue;
|
|
135
|
+
if (clause.name !== undefined)
|
|
136
|
+
bindings.set(clause.name.text, name);
|
|
137
|
+
const named = clause.namedBindings;
|
|
138
|
+
if (named !== undefined && ts.isNamespaceImport(named))
|
|
139
|
+
bindings.set(named.name.text, name);
|
|
140
|
+
else if (named !== undefined)
|
|
141
|
+
for (const element of named.elements)
|
|
142
|
+
bindings.set(element.name.text, name);
|
|
143
|
+
}
|
|
144
|
+
const found = new Set();
|
|
145
|
+
if (bindings.size === 0)
|
|
146
|
+
return found;
|
|
147
|
+
// The file's own type declarations, which a type may name in place of
|
|
148
|
+
// the package's (`type Params = Anthropic.Beta.MessageCreateParams`).
|
|
149
|
+
const locals = new Map();
|
|
150
|
+
for (const statement of source.statements)
|
|
151
|
+
if ((ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isClassDeclaration(statement)) &&
|
|
152
|
+
statement.name !== undefined)
|
|
153
|
+
locals.set(statement.name.text, statement);
|
|
154
|
+
const followed = new Set();
|
|
155
|
+
/** The packages whose names a type within `node` names, through the file's own type declarations. */
|
|
156
|
+
const typesIn = (node, inType) => {
|
|
157
|
+
const typed = inType || ts.isTypeNode(node);
|
|
158
|
+
if (typed && ts.isIdentifier(node)) {
|
|
159
|
+
const name = bindings.get(node.text);
|
|
160
|
+
if (name !== undefined)
|
|
161
|
+
found.add(name);
|
|
162
|
+
const local = locals.get(node.text);
|
|
163
|
+
if (local !== undefined && !followed.has(local)) {
|
|
164
|
+
followed.add(local);
|
|
165
|
+
typesIn(local, false);
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
ts.forEachChild(node, (child) => typesIn(child, typed));
|
|
169
|
+
};
|
|
170
|
+
for (const line of lines) {
|
|
171
|
+
if (line === null || line < 1 || line > source.getLineStarts().length) {
|
|
172
|
+
typesIn(source, false);
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const at = source.getPositionOfLineAndCharacter(line - 1, 0);
|
|
176
|
+
const end = source.getLineEndOfPosition(at);
|
|
177
|
+
for (const statement of source.statements)
|
|
178
|
+
if (statement.getStart(source) <= end && at < statement.getEnd())
|
|
179
|
+
typesIn(statement, false);
|
|
180
|
+
}
|
|
181
|
+
return found;
|
|
182
|
+
}
|
|
183
|
+
/**
|
|
184
|
+
* The runner the entry points' package's tests run on, by its test script:
|
|
185
|
+
* `vitest` (Vitest), `node --test` (node:test), or null when the script
|
|
186
|
+
* says neither or there is none.
|
|
187
|
+
*/
|
|
188
|
+
export function packageTestRunner(repo, entryPoints) {
|
|
189
|
+
const first = entryPoints[0];
|
|
190
|
+
if (first === undefined)
|
|
191
|
+
return null;
|
|
192
|
+
const dir = packageFolder(resolve(repo))(`${first}/x`);
|
|
193
|
+
try {
|
|
194
|
+
const script = JSON.parse(readFileSync(join(repo, dir, "package.json"), "utf8")).scripts?.["test"];
|
|
195
|
+
if (typeof script !== "string")
|
|
196
|
+
return null;
|
|
197
|
+
if (/(^|[\s;&|(])vitest(\s|$)/.test(script))
|
|
198
|
+
return "vitest";
|
|
199
|
+
if (/(^|[\s;&|(])(node|tsx)\s+(--[\w-]+(=\S+)?\s+)*--test(\s|$)/.test(script))
|
|
200
|
+
return "node:test";
|
|
201
|
+
return null;
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
return null;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* The checks a package's own scripts run (test, lint, typecheck), by the
|
|
209
|
+
* scripts' names: what its CI holds a change to. Never their commands,
|
|
210
|
+
* which may carry a credential (`DATABASE_URL=postgres://user:pass@…`) or
|
|
211
|
+
* name the repository's tools and hosts.
|
|
212
|
+
*/
|
|
213
|
+
function packageChecks(repo, packageDir) {
|
|
214
|
+
if (packageDir === null)
|
|
215
|
+
return [];
|
|
216
|
+
try {
|
|
217
|
+
const scripts = JSON.parse(readFileSync(join(repo, packageDir, "package.json"), "utf8")).scripts ?? {};
|
|
218
|
+
return Object.entries(scripts)
|
|
219
|
+
.filter(([name, command]) => typeof command === "string" && /^(test|lint|typecheck|type-check|check|format:check|check-types)$/.test(name))
|
|
220
|
+
.map(([name]) => name);
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return [];
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/** The folder's TASK.md: the work asked for, in the owner's words, and when it is accepted. */
|
|
227
|
+
export function renderTask(input) {
|
|
228
|
+
return `${[
|
|
229
|
+
"# Task",
|
|
230
|
+
"",
|
|
231
|
+
input.task.trim(),
|
|
232
|
+
"",
|
|
233
|
+
"## Accepted when",
|
|
234
|
+
"",
|
|
235
|
+
"- `npm test` passes in this folder: the tests you write for the task, and the ones already here, but for any `carveout/baseline.json` names (they fail on the mocks before any edit).",
|
|
236
|
+
...(input.testsInFolder.length === 0
|
|
237
|
+
? []
|
|
238
|
+
: [`- The ${input.testsInFolder.length} test file(s) that came with the folder still pass, and only grew.`]),
|
|
239
|
+
...(input.testsAt === undefined ? [] : [`- New tests are where the repository's test script runs them: \`${input.testsAt}\`.`]),
|
|
240
|
+
...(input.surface === 0
|
|
241
|
+
? []
|
|
242
|
+
: [
|
|
243
|
+
`- The ${input.surface} export(s) code outside this folder uses keep their names and types${input.surfaceChecks === undefined || input.surfaceChecks === 0
|
|
244
|
+
? ": `npm test` checks them, and its last line counts those checks"
|
|
245
|
+
: ` (\`npm test\` makes ${input.surfaceChecks} checks of them, and its last line counts those that hold)`}. An addition old callers still accept (a new optional field, a new export) keeps a type.`,
|
|
246
|
+
]),
|
|
247
|
+
"- The owner's \`carveout patch\` of the folder keeps nothing out (see AGENTS.md: what comes back, and what is held).",
|
|
248
|
+
...(input.ownerTests === true
|
|
249
|
+
? ["- The owner's own acceptance tests of the behaviour above, which this folder does not hold, pass on the folder that comes back."]
|
|
250
|
+
: []),
|
|
251
|
+
`- The repository's own CI passes once the change is merged: a pass here is a pass against mocks.${input.ci === undefined || input.ci.length === 0
|
|
252
|
+
? ""
|
|
253
|
+
: ` For this package it runs its own ${input.ci.map((name) => `\`${name}\``).join(", ")} script${input.ci.length === 1 ? "" : "s"}; here \`npm test\` runs its tests and its build, and the rest run in the repository.`}`,
|
|
67
254
|
].join("\n")}\n`;
|
|
68
255
|
}
|
|
69
256
|
export const CONTRACT_PATH = "carveout/contract.json";
|
|
@@ -110,8 +297,36 @@ export function readRepoConfig(repo) {
|
|
|
110
297
|
set("widenLiterals", flag("widenLiterals"));
|
|
111
298
|
set("public", flag("public"));
|
|
112
299
|
set("copyPure", flag("copyPure"));
|
|
300
|
+
const positive = (key) => {
|
|
301
|
+
const value = record[key];
|
|
302
|
+
if (value === undefined)
|
|
303
|
+
return undefined;
|
|
304
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value <= 0)
|
|
305
|
+
throw new Error(`${CONFIG_PATH}: ${key} must be a number above 0.`);
|
|
306
|
+
return value;
|
|
307
|
+
};
|
|
308
|
+
set("maxExposureLines", positive("maxExposureLines"));
|
|
309
|
+
set("maxExposurePercent", positive("maxExposurePercent"));
|
|
113
310
|
set("private", paths("private"));
|
|
114
311
|
set("copy", paths("copy"));
|
|
312
|
+
set("names", paths("names"));
|
|
313
|
+
set("keepNames", paths("keepNames"));
|
|
314
|
+
const pseudonyms = record["pseudonyms"];
|
|
315
|
+
if (pseudonyms !== undefined) {
|
|
316
|
+
if (!Array.isArray(pseudonyms) ||
|
|
317
|
+
pseudonyms.some((rule) => rule === null ||
|
|
318
|
+
typeof rule !== "object" ||
|
|
319
|
+
typeof rule["before"] !== "string" ||
|
|
320
|
+
typeof rule["after"] !== "string" ||
|
|
321
|
+
!["identifier", "text", "path"].includes(String(rule["kind"] ?? "text"))))
|
|
322
|
+
throw new Error(`${CONFIG_PATH}: pseudonyms must be a list of { "before", "after", "kind" } with kind identifier, text or path.`);
|
|
323
|
+
set("pseudonyms", pseudonyms.map((rule) => ({
|
|
324
|
+
before: rule["before"],
|
|
325
|
+
after: rule["after"],
|
|
326
|
+
kind: (rule["kind"] ?? "text"),
|
|
327
|
+
paths: [],
|
|
328
|
+
})));
|
|
329
|
+
}
|
|
115
330
|
return config;
|
|
116
331
|
}
|
|
117
332
|
/** Paths as the repository spells them, `/`-separated, unique and sorted. */
|
|
@@ -141,10 +356,31 @@ function settle(request) {
|
|
|
141
356
|
// the owner says otherwise.
|
|
142
357
|
widenLiterals: request.widenLiterals ?? config.widenLiterals ?? (privatePaths.length > 0 || outside),
|
|
143
358
|
public: outside,
|
|
359
|
+
maxExposure: (() => {
|
|
360
|
+
const lines = request.maxExposure?.lines ?? config.maxExposureLines;
|
|
361
|
+
const percent = request.maxExposure?.percent ?? config.maxExposurePercent;
|
|
362
|
+
return { ...(lines === undefined ? {} : { lines }), ...(percent === undefined ? {} : { percent }) };
|
|
363
|
+
})(),
|
|
144
364
|
};
|
|
145
365
|
}
|
|
146
366
|
const compare = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
147
367
|
const sha256 = (data) => createHash("sha256").update(data).digest("hex");
|
|
368
|
+
/**
|
|
369
|
+
* Whether bytes are text a table may rename: no NUL in their first 8 KiB,
|
|
370
|
+
* and valid UTF-8 throughout. A PDF or an archive in a copied folder is
|
|
371
|
+
* not, whatever its name; decoded and written back it would be corrupted.
|
|
372
|
+
*/
|
|
373
|
+
export function isTextContent(bytes) {
|
|
374
|
+
if (bytes.subarray(0, 8192).includes(0))
|
|
375
|
+
return false;
|
|
376
|
+
try {
|
|
377
|
+
new TextDecoder("utf-8", { fatal: true }).decode(bytes);
|
|
378
|
+
return true;
|
|
379
|
+
}
|
|
380
|
+
catch {
|
|
381
|
+
return false;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
148
384
|
/** Assets that are not text: copied and compared as bytes. */
|
|
149
385
|
export function isBinaryPath(path) {
|
|
150
386
|
return /\.(png|jpe?g|gif|webp|avif|ico|woff2?|ttf|otf|mp4|webm|mp3|wav)$/i.test(path);
|
|
@@ -153,6 +389,48 @@ export function isBinaryPath(path) {
|
|
|
153
389
|
export function carveoutHome() {
|
|
154
390
|
return resolve(process.env["CARVEOUT_HOME"] ?? join(homedir(), ".carveout"));
|
|
155
391
|
}
|
|
392
|
+
/** The repository's TypeScript source lines, as `experiments/hero-exposure.mjs` counts them: tracked `.ts`, `.tsx`, `.mts`, `.cts` files, `.d.ts` and `node_modules` left out. */
|
|
393
|
+
export function repositoryTypeScriptLines(repo) {
|
|
394
|
+
const listed = git(repo, ["ls-files", "-z", "--", "*.ts", "*.tsx", "*.mts", "*.cts"]);
|
|
395
|
+
if (listed === null)
|
|
396
|
+
return 0;
|
|
397
|
+
let total = 0;
|
|
398
|
+
for (const file of listed.split("\0")) {
|
|
399
|
+
if (file === "" || file.endsWith(".d.ts") || file.includes("node_modules/"))
|
|
400
|
+
continue;
|
|
401
|
+
try {
|
|
402
|
+
total += readFileSync(join(repo, file), "utf8").split("\n").length;
|
|
403
|
+
}
|
|
404
|
+
catch {
|
|
405
|
+
// Listed but gone from the checkout.
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return total;
|
|
409
|
+
}
|
|
410
|
+
/** The folder's exposure against the bound the request settled on; null when there is none. */
|
|
411
|
+
export function exposureBudget(repo, exposure, bound) {
|
|
412
|
+
if (bound.lines === undefined && bound.percent === undefined)
|
|
413
|
+
return null;
|
|
414
|
+
const over = [];
|
|
415
|
+
if (bound.lines !== undefined && exposure.lines > bound.lines)
|
|
416
|
+
over.push(`it shows ${exposure.lines.toLocaleString("en-US")} lines, past the bound of ${bound.lines.toLocaleString("en-US")}`);
|
|
417
|
+
let measured = null;
|
|
418
|
+
if (bound.percent !== undefined) {
|
|
419
|
+
const repositoryLines = repositoryTypeScriptLines(repo);
|
|
420
|
+
const shownPercent = repositoryLines === 0 ? 100 : Math.round((10_000 * exposure.lines) / repositoryLines) / 100;
|
|
421
|
+
measured = { repositoryLines, shownPercent };
|
|
422
|
+
if (shownPercent > bound.percent)
|
|
423
|
+
over.push(`it shows ${shownPercent}% of the repository's ${repositoryLines.toLocaleString("en-US")} TypeScript lines, past the bound of ${bound.percent}%`);
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
...(bound.lines === undefined ? {} : { lines: bound.lines }),
|
|
427
|
+
...(bound.percent === undefined ? {} : { percent: bound.percent }),
|
|
428
|
+
...(measured ?? {}),
|
|
429
|
+
over: over.length === 0
|
|
430
|
+
? null
|
|
431
|
+
: `${over.join(", and ")}: narrow the cut (a lower --depth, no --whole-package or --copy-pure, --private <path> for the folders plan lists), or raise maxExposureLines or maxExposurePercent in ${CONFIG_PATH}`,
|
|
432
|
+
};
|
|
433
|
+
}
|
|
156
434
|
function git(repo, args) {
|
|
157
435
|
try {
|
|
158
436
|
return execFileSync("git", [...args], {
|
|
@@ -189,6 +467,84 @@ function entriesOf(request) {
|
|
|
189
467
|
.replace(/\/+$/, ""))),
|
|
190
468
|
].sort(compare);
|
|
191
469
|
}
|
|
470
|
+
/**
|
|
471
|
+
* The Vitest settings the entry points' package runs its tests under, when
|
|
472
|
+
* the cut brings tests (`vitest-config.ts`), and the entries with its setup
|
|
473
|
+
* files among them: a component's test that needs jsdom and the setup that
|
|
474
|
+
* registers its matchers runs in the folder as in the repository.
|
|
475
|
+
*/
|
|
476
|
+
function withTestSetup(repo, entries, tests) {
|
|
477
|
+
if (!tests)
|
|
478
|
+
return { setup: [], settings: null };
|
|
479
|
+
const packageOf = packageFolder(resolve(repo));
|
|
480
|
+
const dirs = [...new Set(entries.map((entry) => packageOf(`${entry}/x`)))];
|
|
481
|
+
const settings = dirs.map((dir) => vitestSettings(repo, dir)).find((found) => found !== null) ?? null;
|
|
482
|
+
// Walked from, never entry points: the package, the project's name and
|
|
483
|
+
// the interface test are the caller's entries' alone.
|
|
484
|
+
const setup = [...(settings?.setupFiles ?? []), ...(settings?.projects ?? []).flatMap((project) => project.setupFiles)];
|
|
485
|
+
return { setup: [...new Set(setup)].filter((file) => !entries.includes(file)).sort(compare), settings };
|
|
486
|
+
}
|
|
487
|
+
/** See `Plan.surface`. */
|
|
488
|
+
function surfaceOf(repo, walk, graph, followWorkspaces) {
|
|
489
|
+
const counted = followWorkspaces ? walk : workspaceImportersOf(repo)(walk, graph);
|
|
490
|
+
const at = (from, specifier, location) => `${from}\n${specifier ?? ""}\n${location ?? ""}`;
|
|
491
|
+
const links = new Map();
|
|
492
|
+
for (const link of graph.links)
|
|
493
|
+
if (link.source.startsWith("file:") && link.relation === "imports") {
|
|
494
|
+
const key = at(link.source.slice("file:".length), link.specifier, link.location);
|
|
495
|
+
links.set(key, [...(links.get(key) ?? []), link.names]);
|
|
496
|
+
}
|
|
497
|
+
const byModule = new Map();
|
|
498
|
+
for (const edge of counted.cuts.inbound) {
|
|
499
|
+
if (edge.relation !== "imports")
|
|
500
|
+
continue;
|
|
501
|
+
const entry = byModule.get(edge.to) ?? { names: new Set(), importers: new Set() };
|
|
502
|
+
entry.importers.add(edge.from);
|
|
503
|
+
const taken = links.get(at(edge.from, edge.specifier, edge.location)) ?? [null];
|
|
504
|
+
for (const names of taken)
|
|
505
|
+
if (names === null)
|
|
506
|
+
entry.names = "*";
|
|
507
|
+
else if (entry.names !== "*")
|
|
508
|
+
for (const name of names)
|
|
509
|
+
entry.names.add(name);
|
|
510
|
+
byModule.set(edge.to, entry);
|
|
511
|
+
}
|
|
512
|
+
return [...byModule]
|
|
513
|
+
.sort(([a], [b]) => compare(a, b))
|
|
514
|
+
.map(([module, { names, importers }]) => ({
|
|
515
|
+
module,
|
|
516
|
+
names: names === "*" ? "*" : [...names].sort(compare),
|
|
517
|
+
importers: [...importers].sort(compare),
|
|
518
|
+
}));
|
|
519
|
+
}
|
|
520
|
+
/** The runner of each test the walk brought, as it chose it: the runner a test imports, else Vitest's globals. */
|
|
521
|
+
function testRunnersOf(walk, graph) {
|
|
522
|
+
const specifiers = new Map();
|
|
523
|
+
for (const link of graph.links)
|
|
524
|
+
if (link.source.startsWith("file:") && link.target.startsWith("dependency:") && link.specifier !== null) {
|
|
525
|
+
const from = link.source.slice("file:".length);
|
|
526
|
+
specifiers.set(from, [...(specifiers.get(from) ?? []), link.specifier]);
|
|
527
|
+
}
|
|
528
|
+
const globals = new Set(walk.globalsTests);
|
|
529
|
+
const runners = {};
|
|
530
|
+
for (const file of walk.tests) {
|
|
531
|
+
const runner = globals.has(file) ? "vitest-globals" : testRunnerOf(specifiers.get(file) ?? []);
|
|
532
|
+
if (runner !== null)
|
|
533
|
+
runners[file] = runner;
|
|
534
|
+
}
|
|
535
|
+
return runners;
|
|
536
|
+
}
|
|
537
|
+
/** What stops a plan's cut, one line each: entry points that name nothing, imports no slice can carry, credentials files. */
|
|
538
|
+
export function planBlockers(plan) {
|
|
539
|
+
const broken = plan.walk.dependencies.filter((dependency) => dependency.status !== "external");
|
|
540
|
+
const readsCut = plan.walk.cuts.outbound.filter((edge) => edge.relation === "reads");
|
|
541
|
+
return [
|
|
542
|
+
...plan.walk.unknownEntryPoints.map((entry) => `${entry}: names no script file in the repository`),
|
|
543
|
+
...broken.map((dependency) => `${dependency.file}${dependency.location === null ? "" : `:${dependency.location.slice(1)}`}: ${dependency.status === "dynamic" ? "dynamic import of a computed name" : dependency.status === "path" ? `reads ${dependency.specifier ?? ""} by path, which a slice cannot copy` : `${dependency.specifier ?? ""} resolves to no file`}`),
|
|
544
|
+
...readsCut.map((edge) => `${edge.from}${edge.location === null ? "" : `:${edge.location.slice(1)}`}: reads ${edge.to} by path, and it is outside the slice`),
|
|
545
|
+
...plan.secretFiles.map((path) => `${path}: a credentials file is never copied`),
|
|
546
|
+
];
|
|
547
|
+
}
|
|
192
548
|
/** The walk's options for a settled request. */
|
|
193
549
|
function walkOptions(request, repo) {
|
|
194
550
|
return {
|
|
@@ -217,7 +573,12 @@ export function planSlice(request) {
|
|
|
217
573
|
const repo = resolve(request.repo);
|
|
218
574
|
const budget = request.budget ?? SLICE_BUDGET_DEFAULTS;
|
|
219
575
|
const entryPoints = entriesOf(request);
|
|
220
|
-
const {
|
|
576
|
+
const { setup } = withTestSetup(repo, entryPoints, request.tests === true);
|
|
577
|
+
const { built, walk } = walkGraph(repo, entryPoints, budget, {
|
|
578
|
+
...walkOptions(settled, repo),
|
|
579
|
+
...(setup.length === 0 ? {} : { roots: setup }),
|
|
580
|
+
...(request.unseen === undefined || request.unseen.length === 0 ? {} : { unseen: request.unseen }),
|
|
581
|
+
});
|
|
221
582
|
const texts = walk.included.map((path) => ({
|
|
222
583
|
path,
|
|
223
584
|
text: readFileSync(join(repo, path), "utf8"),
|
|
@@ -231,6 +592,8 @@ export function planSlice(request) {
|
|
|
231
592
|
dependency.specifier !== null &&
|
|
232
593
|
!isNodeBuiltin(dependency.specifier))
|
|
233
594
|
packages.add(packageNameOf(dependency.specifier));
|
|
595
|
+
const exposure = exposureOf(walk, (path) => textOf.get(path) ?? null);
|
|
596
|
+
const budgetOf = exposureBudget(repo, exposure, settled.maxExposure);
|
|
234
597
|
return {
|
|
235
598
|
repo,
|
|
236
599
|
entryPoints,
|
|
@@ -253,14 +616,74 @@ export function planSlice(request) {
|
|
|
253
616
|
followWorkspaces: settled.followWorkspaces,
|
|
254
617
|
copyPure: settled.copyPure,
|
|
255
618
|
private: settled.private,
|
|
256
|
-
exposure
|
|
619
|
+
exposure,
|
|
620
|
+
...(budgetOf === null ? {} : { exposureBudget: budgetOf }),
|
|
621
|
+
surface: surfaceOf(repo, walk, built.graph, settled.followWorkspaces),
|
|
622
|
+
testRunners: testRunnersOf(walk, built.graph),
|
|
623
|
+
};
|
|
624
|
+
}
|
|
625
|
+
/**
|
|
626
|
+
* What a cut could change so the folder's tests run on the repository's
|
|
627
|
+
* code instead of mocks (see `readiness.ts`): the pure modules
|
|
628
|
+
* `--copy-pure` would copy, read from a second walk with it on; the mocked
|
|
629
|
+
* database, cache and queue clients `--live` could keep real; the folders
|
|
630
|
+
* the slice's packages read by a configured name, for `--copy`. Only for a
|
|
631
|
+
* cut that carries tests: a folder without them has nothing to turn green.
|
|
632
|
+
*/
|
|
633
|
+
export function readinessOf(request, record) {
|
|
634
|
+
const tests = record.tests?.carried.length ?? 0;
|
|
635
|
+
if (tests === 0)
|
|
636
|
+
return { tests, copyPure: null, live: [], copy: [] };
|
|
637
|
+
const repo = resolve(request.repo);
|
|
638
|
+
const included = new Set(record.manifest.included.map((file) => file.path));
|
|
639
|
+
let copyPure = null;
|
|
640
|
+
if (record.request.copyPure !== true) {
|
|
641
|
+
const settled = settle({ ...request, copyPure: true });
|
|
642
|
+
const { walk } = walkGraph(repo, entriesOf(request), request.budget ?? SLICE_BUDGET_DEFAULTS, walkOptions(settled, repo));
|
|
643
|
+
// What the walk with it holds and this one does not: pure code copied
|
|
644
|
+
// past the depth, and the workspaces' source it follows to get there.
|
|
645
|
+
// Its tests are not counted: they are a cut's `--with-tests` choice.
|
|
646
|
+
const files = walk.included
|
|
647
|
+
.filter((file) => !included.has(file) && !isTestPath(file) && !walk.helpers.includes(file))
|
|
648
|
+
.sort(compare);
|
|
649
|
+
let lines = 0;
|
|
650
|
+
for (const file of files)
|
|
651
|
+
try {
|
|
652
|
+
lines += readFileSync(join(repo, file), "utf8").split("\n").length;
|
|
653
|
+
}
|
|
654
|
+
catch {
|
|
655
|
+
// Gone since the walk: not counted.
|
|
656
|
+
}
|
|
657
|
+
// A budget the walk with it outgrows leaves pure code and tests out:
|
|
658
|
+
// the advice says what budget holds them.
|
|
659
|
+
copyPure =
|
|
660
|
+
walk.dropped.length > 0
|
|
661
|
+
? { files, lines, needsMaxFiles: walk.included.length + walk.dropped.length }
|
|
662
|
+
: { files, lines };
|
|
663
|
+
}
|
|
664
|
+
const packageOf = packageFolder(repo);
|
|
665
|
+
const copied = record.request.copy ?? [];
|
|
666
|
+
const held = (path) => copied.some((dir) => path === dir || path.startsWith(`${dir}/`) || dir.startsWith(`${path}/`)) ||
|
|
667
|
+
[...included].some((file) => file.startsWith(`${path}/`));
|
|
668
|
+
return {
|
|
669
|
+
tests,
|
|
670
|
+
copyPure,
|
|
671
|
+
live: liveCandidates(record.dependencies, (name) => serviceOf(name)),
|
|
672
|
+
copy: configuredFolders(repo, [...included].map((file) => packageOf(file)), held),
|
|
257
673
|
};
|
|
258
674
|
}
|
|
259
675
|
/** A name npm accepts for the folder's package. */
|
|
260
676
|
function projectName(repo, entries) {
|
|
261
677
|
const words = [
|
|
262
678
|
...(repo === null ? [] : [basename(repo)]),
|
|
263
|
-
|
|
679
|
+
// A public folder's name keeps each word of an entry's name apart
|
|
680
|
+
// (`acme-cart`, not `acmecart`): a name the folder must not carry is
|
|
681
|
+
// found, and renamed, only on its word's edges.
|
|
682
|
+
...entries.map((entry) => repo === null
|
|
683
|
+
? basename(entry)
|
|
684
|
+
.replace(/(\p{Ll}|\p{N})(\p{Lu})/gu, "$1-$2")
|
|
685
|
+
.replace(/(\p{Lu})(\p{Lu}\p{Ll})/gu, "$1-$2")
|
|
686
|
+
: basename(entry)),
|
|
264
687
|
]
|
|
265
688
|
.join("-")
|
|
266
689
|
.toLowerCase()
|
|
@@ -301,11 +724,19 @@ export function seededLockfile(repo, workspaces, root) {
|
|
|
301
724
|
}
|
|
302
725
|
const source = lock.packages ?? {};
|
|
303
726
|
const packages = {};
|
|
727
|
+
// A package resolved from a registry other than the public one keeps its
|
|
728
|
+
// version, never its URL or hash: a private registry's host names whose
|
|
729
|
+
// repository this is, and the folder's npm resolves from the public one.
|
|
730
|
+
const publicRegistry = /^https:\/\/registry\.(npmjs\.org|yarnpkg\.com)\//;
|
|
304
731
|
const take = (prefix) => {
|
|
305
732
|
for (const [key, entry] of Object.entries(source)) {
|
|
306
733
|
if (!key.startsWith(`${prefix}node_modules/`) || entry["link"] === true)
|
|
307
734
|
continue;
|
|
308
|
-
|
|
735
|
+
const resolved = entry["resolved"];
|
|
736
|
+
packages[key.slice(prefix.length)] =
|
|
737
|
+
typeof resolved === "string" && !publicRegistry.test(resolved)
|
|
738
|
+
? Object.fromEntries(Object.entries(entry).filter(([field]) => field !== "resolved" && field !== "integrity"))
|
|
739
|
+
: entry;
|
|
309
740
|
}
|
|
310
741
|
};
|
|
311
742
|
take("");
|
|
@@ -320,6 +751,34 @@ export function seededLockfile(repo, workspaces, root) {
|
|
|
320
751
|
packages: { "": root, ...packages },
|
|
321
752
|
};
|
|
322
753
|
}
|
|
754
|
+
/**
|
|
755
|
+
* Takes out of a resolved lock what npm left with nothing to install it
|
|
756
|
+
* under: an entry nested under a package the lock no longer has
|
|
757
|
+
* (`node_modules/vitest/node_modules/magic-string` once `vitest` went),
|
|
758
|
+
* and one npm marked extraneous. npm's `--package-lock-only` marks such
|
|
759
|
+
* entries rather than dropping them, and an npm other than the one that
|
|
760
|
+
* resolved the lock may install them. How many it took out.
|
|
761
|
+
*/
|
|
762
|
+
export function pruneLock(lock) {
|
|
763
|
+
const packages = lock.packages ?? {};
|
|
764
|
+
let removed = 0;
|
|
765
|
+
for (let changed = true; changed;) {
|
|
766
|
+
changed = false;
|
|
767
|
+
for (const key of Object.keys(packages)) {
|
|
768
|
+
if (key === "")
|
|
769
|
+
continue;
|
|
770
|
+
const at = key.lastIndexOf("/node_modules/");
|
|
771
|
+
const parent = at === -1 ? null : key.slice(0, at);
|
|
772
|
+
const orphan = parent !== null && parent.includes("node_modules/") && packages[parent] === undefined;
|
|
773
|
+
if (orphan || packages[key]?.["extraneous"] === true) {
|
|
774
|
+
delete packages[key];
|
|
775
|
+
removed += 1;
|
|
776
|
+
changed = true;
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
return removed;
|
|
781
|
+
}
|
|
323
782
|
/**
|
|
324
783
|
* Peers of the installed packages, and of what they install, that the
|
|
325
784
|
* repository's npm lockfile installs at its root for production: a
|
|
@@ -465,7 +924,13 @@ export async function computeCut(request) {
|
|
|
465
924
|
const log = request.log ?? (() => { });
|
|
466
925
|
const budget = request.budget ?? SLICE_BUDGET_DEFAULTS;
|
|
467
926
|
const entryPoints = entriesOf(request);
|
|
468
|
-
const {
|
|
927
|
+
const { setup, settings: vitest } = withTestSetup(repo, entryPoints, request.tests === true);
|
|
928
|
+
const testRunner = request.tests === true ? packageTestRunner(repo, entryPoints) : null;
|
|
929
|
+
const { built } = walkGraph(repo, entryPoints, budget, {
|
|
930
|
+
...walkOptions(settled, repo),
|
|
931
|
+
...(setup.length === 0 ? {} : { roots: setup }),
|
|
932
|
+
...(request.unseen === undefined || request.unseen.length === 0 ? {} : { unseen: request.unseen }),
|
|
933
|
+
});
|
|
469
934
|
log(`Read the imports of ${built.files.length} files.`);
|
|
470
935
|
const commitSha = git(repo, ["rev-parse", "HEAD"]);
|
|
471
936
|
const signal = AbortSignal.timeout(15 * 60_000);
|
|
@@ -478,6 +943,8 @@ export async function computeCut(request) {
|
|
|
478
943
|
entryPoints,
|
|
479
944
|
budget,
|
|
480
945
|
tests: request.tests === true,
|
|
946
|
+
...(setup.length === 0 ? {} : { setupFiles: setup }),
|
|
947
|
+
...(testRunner === "vitest" ? { testRunner } : {}),
|
|
481
948
|
...(live.length === 0 ? {} : { live }),
|
|
482
949
|
...(settled.wholePackage ? { wholePackage: true } : {}),
|
|
483
950
|
...(settled.followWorkspaces ? { followWorkspaces: true } : {}),
|
|
@@ -497,11 +964,22 @@ export async function computeCut(request) {
|
|
|
497
964
|
? {}
|
|
498
965
|
: {
|
|
499
966
|
packageTypes: npmPackageTypes({
|
|
500
|
-
root: join(carveoutHome(), "types"),
|
|
967
|
+
root: request.typesCache ?? join(carveoutHome(), "types"),
|
|
501
968
|
}),
|
|
502
969
|
}),
|
|
503
970
|
});
|
|
504
971
|
const { contract, contractJson } = analysis;
|
|
972
|
+
// What the folder copies that the commit does not hold as it is: the
|
|
973
|
+
// owner's work in progress, which goes out as the working tree has it.
|
|
974
|
+
if (analysis.manifest.sourceDirty) {
|
|
975
|
+
const paths = analysis.manifest.included.map((file) => file.path);
|
|
976
|
+
const uncommitted = (git(repo, ["status", "--porcelain", "--", ...paths]) ?? "")
|
|
977
|
+
.split("\n")
|
|
978
|
+
.map((line) => line.slice(3).trim())
|
|
979
|
+
.filter((path) => path !== "");
|
|
980
|
+
if (uncommitted.length > 0)
|
|
981
|
+
log(`Copies ${uncommitted.length} file(s) the commit does not hold as they are (the working tree's, uncommitted): ${uncommitted.slice(0, 8).join(", ")}${uncommitted.length > 8 ? `, and ${uncommitted.length - 8} more` : ""}. Commit them, or keep one out with --private, before the folder goes out.`);
|
|
982
|
+
}
|
|
505
983
|
// A public folder names a file outside the slice only by its package.
|
|
506
984
|
const outsideName = (() => {
|
|
507
985
|
const packageOf = packageFolder(repo);
|
|
@@ -511,9 +989,11 @@ export async function computeCut(request) {
|
|
|
511
989
|
};
|
|
512
990
|
})();
|
|
513
991
|
// The tests left in the repository, named as the folder may name them.
|
|
514
|
-
const testsLeft = analysis.walk.testsLeft.map(({ file, reason }) => ({
|
|
992
|
+
const testsLeft = analysis.walk.testsLeft.map(({ file, reason, imports }) => ({
|
|
515
993
|
file: settled.public ? outsideName(file) : file,
|
|
516
994
|
reason,
|
|
995
|
+
// The slice's own files it imports, which the folder shows anyway.
|
|
996
|
+
...(imports === undefined ? {} : { imports }),
|
|
517
997
|
}));
|
|
518
998
|
const manifest = settled.public
|
|
519
999
|
? {
|
|
@@ -532,6 +1012,36 @@ export async function computeCut(request) {
|
|
|
532
1012
|
...request.choices,
|
|
533
1013
|
...Object.fromEntries(live.map((name) => [name, "approved-package"])),
|
|
534
1014
|
};
|
|
1015
|
+
// The environment the package's Vitest tests run in, installed as the
|
|
1016
|
+
// repository locks it (`jsdom` for a component's tests).
|
|
1017
|
+
// So is the one a carried test asks for itself (`// @vitest-environment
|
|
1018
|
+
// happy-dom`), and a package the config's setupFiles name by specifier
|
|
1019
|
+
// (`@testing-library/jest-dom/vitest`).
|
|
1020
|
+
const environments = new Set([
|
|
1021
|
+
...(vitest?.environment === undefined ? [] : [vitest.environment]),
|
|
1022
|
+
...(vitest?.projects ?? []).flatMap((project) => (project.environment === undefined ? [] : [project.environment])),
|
|
1023
|
+
...analysis.walk.tests.flatMap((test) => {
|
|
1024
|
+
try {
|
|
1025
|
+
return fileEnvironments(readFileSync(join(repo, test), "utf8"));
|
|
1026
|
+
}
|
|
1027
|
+
catch {
|
|
1028
|
+
return [];
|
|
1029
|
+
}
|
|
1030
|
+
}),
|
|
1031
|
+
]);
|
|
1032
|
+
for (const environment of [...environments].sort(compare))
|
|
1033
|
+
for (const name of ENVIRONMENT_PACKAGES[environment] ?? [])
|
|
1034
|
+
if (choices[name] === undefined && lockedVersion(repo, name) !== null) {
|
|
1035
|
+
choices[name] = "approved-package";
|
|
1036
|
+
log(`Installs ${name}: the package's Vitest tests run in the ${environment} environment.`);
|
|
1037
|
+
}
|
|
1038
|
+
for (const specifier of [...(vitest?.setupPackages ?? []), ...(vitest?.projects ?? []).flatMap((project) => project.setupPackages ?? [])]) {
|
|
1039
|
+
const name = packageNameOf(specifier);
|
|
1040
|
+
if (choices[name] === undefined && lockedVersion(repo, name) !== null) {
|
|
1041
|
+
choices[name] = "approved-package";
|
|
1042
|
+
log(`Installs ${name}: the package's Vitest setup runs ${specifier}.`);
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
535
1045
|
// So is a package whose class a carried test patches by its prototype
|
|
536
1046
|
// (`S3Client.prototype.send = ...`): a mock has no prototype to patch, so
|
|
537
1047
|
// the test fails on the mock before any change (E6c: 30 such tests).
|
|
@@ -574,6 +1084,34 @@ export async function computeCut(request) {
|
|
|
574
1084
|
choices[name] = "approved-package";
|
|
575
1085
|
log(`Installs ${name}: the code tests a value with instanceof ${cls}, which a mock cannot narrow.`);
|
|
576
1086
|
}
|
|
1087
|
+
// And a service package the code's own types are built from: the check
|
|
1088
|
+
// with the packages untyped failed in a file that imports it (a
|
|
1089
|
+
// `Pick<Params, …>` of an SDK's type demands what the real type leaves
|
|
1090
|
+
// optional). The cut counted that error as the untyped packages' cost,
|
|
1091
|
+
// which installing them pays; mocked, the package would stay untyped and
|
|
1092
|
+
// the folder's build would fail where the cut said ready.
|
|
1093
|
+
const untypedLines = new Map();
|
|
1094
|
+
for (const diagnostic of contract.compilation.untypedPackageErrors ?? [])
|
|
1095
|
+
if (diagnostic.file !== undefined && diagnostic.file !== null)
|
|
1096
|
+
untypedLines.set(String(diagnostic.file), [...(untypedLines.get(String(diagnostic.file)) ?? []), diagnostic.line]);
|
|
1097
|
+
const typedBy = new Map();
|
|
1098
|
+
for (const file of manifest.included) {
|
|
1099
|
+
const lines = untypedLines.get(file.path);
|
|
1100
|
+
if (lines === undefined || !isScript(file.path))
|
|
1101
|
+
continue;
|
|
1102
|
+
const candidate = (name) => serviceOf(name) !== null && !workspaces.has(name) && isPackageName(name);
|
|
1103
|
+
for (const name of packagesTypingLines(file.path, readFileSync(join(repo, file.path), "utf8"), lines, candidate))
|
|
1104
|
+
if (!typedBy.has(name))
|
|
1105
|
+
typedBy.set(name, file.path);
|
|
1106
|
+
}
|
|
1107
|
+
// Never one from a private registry: installing it is what the cut
|
|
1108
|
+
// refuses (the folder's npm reads the public one), so it stays mocked.
|
|
1109
|
+
const registry = packageRegistries(repo);
|
|
1110
|
+
for (const [name, file] of [...typedBy].sort(([a], [b]) => compare(a, b)))
|
|
1111
|
+
if (choices[name] === undefined && registry(name)?.source !== "private" && repositoryVersion(repo, name, [file]) !== null) {
|
|
1112
|
+
choices[name] = "approved-package";
|
|
1113
|
+
log(`Installs ${name}: ${file} does not compile with it mocked; its types are the code's.`);
|
|
1114
|
+
}
|
|
577
1115
|
const scope = resolveScope({
|
|
578
1116
|
manifest,
|
|
579
1117
|
contract,
|
|
@@ -621,11 +1159,25 @@ export async function computeCut(request) {
|
|
|
621
1159
|
// folder's `npm test`.
|
|
622
1160
|
const packageDir = entryPoints[0] === undefined ? null : packageFolder(repo)(`${entryPoints[0]}/`);
|
|
623
1161
|
let coverage = null;
|
|
1162
|
+
// Where the package's test script finds its tests, when it names them.
|
|
1163
|
+
let testsAt;
|
|
624
1164
|
if (packageDir !== null)
|
|
625
1165
|
try {
|
|
626
1166
|
const script = JSON.parse(readFileSync(join(repo, packageDir, "package.json"), "utf8")).scripts?.["test"];
|
|
627
|
-
if (typeof script === "string")
|
|
1167
|
+
if (typeof script === "string") {
|
|
628
1168
|
coverage = coverageGateOf(packageDir, script);
|
|
1169
|
+
// node:test's glob says where its tests go; a package whose tests
|
|
1170
|
+
// run on Vitest takes Vitest's include (another runner's glob in
|
|
1171
|
+
// the same script, `node --test scripts/*.cjs`, is not theirs).
|
|
1172
|
+
const glob = testsGlobOf(script);
|
|
1173
|
+
if (testRunner === "vitest") {
|
|
1174
|
+
const include = vitest?.include?.[0];
|
|
1175
|
+
if (include !== undefined)
|
|
1176
|
+
testsAt = include;
|
|
1177
|
+
}
|
|
1178
|
+
else if (glob !== undefined)
|
|
1179
|
+
testsAt = posix.join(packageDir, glob);
|
|
1180
|
+
}
|
|
629
1181
|
}
|
|
630
1182
|
catch {
|
|
631
1183
|
// No readable package.json: no gate.
|
|
@@ -647,6 +1199,9 @@ export async function computeCut(request) {
|
|
|
647
1199
|
...(coverage === null ? {} : { coverage }),
|
|
648
1200
|
...(live.length === 0 ? {} : { live, liveEnvironment }),
|
|
649
1201
|
keepsImplicitAny: analysis.keepsImplicitAny,
|
|
1202
|
+
implicitAnyFiles: analysis.implicitAnyFiles,
|
|
1203
|
+
...(vitest === null ? {} : { vitest }),
|
|
1204
|
+
...(testRunner === "vitest" ? { testRunner } : {}),
|
|
650
1205
|
hasNodeTypes: nodeTypes !== null,
|
|
651
1206
|
});
|
|
652
1207
|
// Peers the installed packages need, and packages asked for by name
|
|
@@ -759,17 +1314,74 @@ export async function computeCut(request) {
|
|
|
759
1314
|
log(`Types installed: ${typesPackages.map((types) => types.name).join(", ")}.`);
|
|
760
1315
|
if (peers.length > 0)
|
|
761
1316
|
log(`Also installed: ${peers.map((peer) => peer.name).join(", ")}.`);
|
|
1317
|
+
// The formatter the repository's code is held to, when it is Prettier
|
|
1318
|
+
// with settings a file states (not a script, which may load plugins).
|
|
1319
|
+
const prettierVersion = lockedVersion(repo, "prettier");
|
|
1320
|
+
// Only its formatting options, as JSON: plugins and overrides name other
|
|
1321
|
+
// parts of the repository, and a config that is no JSON is left behind.
|
|
1322
|
+
const PRETTIER_OPTIONS = new Set([
|
|
1323
|
+
"printWidth", "tabWidth", "useTabs", "semi", "singleQuote", "quoteProps", "jsxSingleQuote", "trailingComma",
|
|
1324
|
+
"bracketSpacing", "bracketSameLine", "objectWrap", "arrowParens", "endOfLine", "proseWrap",
|
|
1325
|
+
"htmlWhitespaceSensitivity", "singleAttributePerLine", "experimentalTernaries", "experimentalOperatorPosition",
|
|
1326
|
+
]);
|
|
1327
|
+
const prettierConfig = (() => {
|
|
1328
|
+
if (prettierVersion === null)
|
|
1329
|
+
return null;
|
|
1330
|
+
for (const dir of [...new Set([packageDir ?? "", ""])])
|
|
1331
|
+
for (const name of [".prettierrc", ".prettierrc.json"]) {
|
|
1332
|
+
const at = join(repo, dir, name);
|
|
1333
|
+
if (!existsSync(at) || !statSync(at).isFile())
|
|
1334
|
+
continue;
|
|
1335
|
+
try {
|
|
1336
|
+
const parsed = JSON.parse(readFileSync(at, "utf8"));
|
|
1337
|
+
const kept = Object.fromEntries(Object.entries(parsed).filter(([key, value]) => PRETTIER_OPTIONS.has(key) && ["string", "number", "boolean"].includes(typeof value)));
|
|
1338
|
+
return { name: ".prettierrc.json", text: `${JSON.stringify(kept, null, 2)}\n` };
|
|
1339
|
+
}
|
|
1340
|
+
catch {
|
|
1341
|
+
return null;
|
|
1342
|
+
}
|
|
1343
|
+
}
|
|
1344
|
+
return null;
|
|
1345
|
+
})();
|
|
762
1346
|
const extra = [
|
|
1347
|
+
...(prettierConfig === null ? [] : [{ path: prettierConfig.name, text: prettierConfig.text, class: "config", public: true }]),
|
|
763
1348
|
{
|
|
764
1349
|
path: SLICE_AGENTS_PATH,
|
|
765
1350
|
text: renderSliceAgents({
|
|
766
1351
|
entryPoints: manifest.entryPoints,
|
|
767
1352
|
testsInFolder: analysis.walk.tests.length,
|
|
768
1353
|
testsLeft: testsLeft.length,
|
|
1354
|
+
...(request.task === undefined || request.task.trim() === "" ? {} : { task: true }),
|
|
1355
|
+
...(testsAt === undefined ? {} : { testsAt }),
|
|
1356
|
+
...(coverage === null ? {} : { coverage }),
|
|
1357
|
+
...(testRunner === null ? {} : { runner: testRunner }),
|
|
1358
|
+
...(prettierVersion === null ? {} : { prettier: { version: prettierVersion, config: prettierConfig?.name ?? null } }),
|
|
1359
|
+
...(packageChecks(repo, packageDir).includes("lint") ? { lint: true } : {}),
|
|
769
1360
|
}),
|
|
770
1361
|
class: "config",
|
|
771
1362
|
public: true,
|
|
772
1363
|
},
|
|
1364
|
+
...(request.task === undefined || request.task.trim() === ""
|
|
1365
|
+
? []
|
|
1366
|
+
: [
|
|
1367
|
+
{
|
|
1368
|
+
path: "TASK.md",
|
|
1369
|
+
text: renderTask({
|
|
1370
|
+
task: request.task,
|
|
1371
|
+
testsInFolder: analysis.walk.tests,
|
|
1372
|
+
surface: contract.inbound.reduce((sum, module) => sum + module.symbols.length, 0),
|
|
1373
|
+
// As npm test's last line will count them: one per export
|
|
1374
|
+
// whose type is held, and one per module it imports.
|
|
1375
|
+
surfaceChecks: (project.files.find((file) => file.path === "carveout/surface/check.ts")?.text.match(/ \/\/ .+ \| .+ \| (value|type)$/gm)?.length ?? 0) +
|
|
1376
|
+
(project.files.find((file) => file.path === "carveout/tests/interface.test.ts")?.text.match(/^test\(/gm)?.length ?? 0),
|
|
1377
|
+
ci: packageChecks(repo, packageDir),
|
|
1378
|
+
...(testsAt === undefined ? {} : { testsAt }),
|
|
1379
|
+
...(request.unseen === undefined || request.unseen.length === 0 ? {} : { ownerTests: true }),
|
|
1380
|
+
}),
|
|
1381
|
+
class: "config",
|
|
1382
|
+
public: true,
|
|
1383
|
+
},
|
|
1384
|
+
]),
|
|
773
1385
|
{ path: SLICE_CLAUDE_PATH, text: "@AGENTS.md\n", class: "config", public: true },
|
|
774
1386
|
{
|
|
775
1387
|
path: BOUNDARY_PATH,
|
|
@@ -789,6 +1401,9 @@ export async function computeCut(request) {
|
|
|
789
1401
|
},
|
|
790
1402
|
];
|
|
791
1403
|
const files = [...projectFiles, ...extra];
|
|
1404
|
+
// The folder's own documents leave the repository too, the owner's task
|
|
1405
|
+
// text among them: scanned as everything else the cut writes is.
|
|
1406
|
+
const documentBlockers = secretBlockers(scanSecrets(extra)).map(({ code, file, detail }) => ({ code, file: file ?? null, detail }));
|
|
792
1407
|
// The `copy` folders, file by file, less what the slice already holds
|
|
793
1408
|
// and any credentials file, which stays behind and blocks the cut.
|
|
794
1409
|
const held = new Set([
|
|
@@ -826,13 +1441,25 @@ export async function computeCut(request) {
|
|
|
826
1441
|
extraBlockers.push(...secretBlockers(scanSecrets(extras
|
|
827
1442
|
.filter((path) => !isBinaryPath(path))
|
|
828
1443
|
.map((path) => ({ path, text: readFileSync(join(repo, path), "utf8") })))).map(({ code, file, detail }) => ({ code, file: file ?? null, detail })));
|
|
1444
|
+
const exposure = exposureOf(analysis.walk, (path) => {
|
|
1445
|
+
try {
|
|
1446
|
+
return readFileSync(join(repo, path), "utf8");
|
|
1447
|
+
}
|
|
1448
|
+
catch {
|
|
1449
|
+
return null;
|
|
1450
|
+
}
|
|
1451
|
+
});
|
|
1452
|
+
// Past the owner's bound on what a folder may show: written, never ready.
|
|
1453
|
+
const overExposed = exposureBudget(repo, exposure, settled.maxExposure)?.over ?? null;
|
|
829
1454
|
const blockers = [
|
|
1455
|
+
...(overExposed === null ? [] : [{ code: "exposure_exceeded", file: null, detail: `The folder shows more of the repository than the owner allows: ${overExposed}.` }]),
|
|
830
1456
|
...manifest.blockers.map(({ code, file, detail }) => ({
|
|
831
1457
|
code,
|
|
832
1458
|
file,
|
|
833
1459
|
detail,
|
|
834
1460
|
})),
|
|
835
1461
|
...extraBlockers,
|
|
1462
|
+
...documentBlockers,
|
|
836
1463
|
...scope.blockers
|
|
837
1464
|
.filter((blocker) => blocker.code === "dependency_unresolved")
|
|
838
1465
|
.map(({ code, detail }) => ({ code, file: null, detail })),
|
|
@@ -884,6 +1511,7 @@ export async function computeCut(request) {
|
|
|
884
1511
|
...(settled.private.length === 0 ? {} : { private: settled.private }),
|
|
885
1512
|
...(settled.copy.length === 0 ? {} : { copy: settled.copy }),
|
|
886
1513
|
...(settled.public ? { public: true } : {}),
|
|
1514
|
+
...(request.task === undefined || request.task.trim() === "" ? {} : { task: request.task }),
|
|
887
1515
|
},
|
|
888
1516
|
inputs: resolutionInputs(repo, manifest.requiredBuildInputs.configs),
|
|
889
1517
|
tests: {
|
|
@@ -891,14 +1519,7 @@ export async function computeCut(request) {
|
|
|
891
1519
|
carried: analysis.walk.tests,
|
|
892
1520
|
left: testsLeft,
|
|
893
1521
|
},
|
|
894
|
-
exposure
|
|
895
|
-
try {
|
|
896
|
-
return readFileSync(join(repo, path), "utf8");
|
|
897
|
-
}
|
|
898
|
-
catch {
|
|
899
|
-
return null;
|
|
900
|
-
}
|
|
901
|
-
}),
|
|
1522
|
+
exposure,
|
|
902
1523
|
},
|
|
903
1524
|
};
|
|
904
1525
|
}
|
|
@@ -934,7 +1555,7 @@ function prototypePatchedPackages(sources, workspaces) {
|
|
|
934
1555
|
if (!element.isTypeOnly)
|
|
935
1556
|
names.push(element.name.text);
|
|
936
1557
|
const name = packageNameOf(specifier);
|
|
937
|
-
if (workspaces.has(name) || found.has(name))
|
|
1558
|
+
if (workspaces.has(name) || found.has(name) || !isPackageName(name))
|
|
938
1559
|
continue;
|
|
939
1560
|
for (const binding of names)
|
|
940
1561
|
if (new RegExp(`\\b${binding.replace(/\$/g, "\\$")}\\.prototype\\b`).test(source.text)) {
|
|
@@ -969,7 +1590,7 @@ export function instanceofPackages(sources, workspaces) {
|
|
|
969
1590
|
isNodeBuiltin(specifier))
|
|
970
1591
|
continue;
|
|
971
1592
|
const name = packageNameOf(specifier);
|
|
972
|
-
if (workspaces.has(name))
|
|
1593
|
+
if (workspaces.has(name) || !isPackageName(name))
|
|
973
1594
|
continue;
|
|
974
1595
|
const clause = statement.importClause;
|
|
975
1596
|
if (clause === undefined || clause.phaseModifier === ts.SyntaxKind.TypeKeyword)
|
|
@@ -1023,16 +1644,125 @@ export async function cutSlice(request) {
|
|
|
1023
1644
|
if (credentials.length > 0)
|
|
1024
1645
|
throw new Error(`Nothing written: the cut would carry a credential.\n${credentials
|
|
1025
1646
|
.map((blocker) => ` ${blocker.code}${blocker.file === null ? "" : ` ${blocker.file}`}: ${blocker.detail}`)
|
|
1026
|
-
.join("\n")}
|
|
1647
|
+
.join("\n")}\nOr keep a file out of the slice: ${[
|
|
1648
|
+
...new Set(credentials.flatMap((blocker) => (blocker.file === null ? [] : [`--private ${blocker.file}`]))),
|
|
1649
|
+
].join(" ")}`);
|
|
1027
1650
|
const fresh = !existsSync(out);
|
|
1028
|
-
|
|
1029
|
-
|
|
1030
|
-
|
|
1031
|
-
|
|
1651
|
+
const cutId = newCutId();
|
|
1652
|
+
// Whether the cut keeps a receipt, and the file it goes to (none in memory).
|
|
1653
|
+
const keepsReceipt = request.receipt !== false;
|
|
1654
|
+
const receipt = !keepsReceipt || request.receiptInMemory === true ? null : resolve(request.receipt ?? receiptPathFor(cutId));
|
|
1655
|
+
// The receipt holds what the folder must not: never inside it, nor in
|
|
1656
|
+
// the checkout, where a commit could carry it.
|
|
1657
|
+
if (receipt !== null && [out, repo].some((dir) => receipt === dir || receipt.startsWith(`${dir}${sep}`)))
|
|
1658
|
+
throw new Error(`--receipt ${receipt}: the receipt cannot be kept in the folder or the repository; name a file elsewhere.`);
|
|
1659
|
+
// A public folder is written in pseudonyms (pseudonyms/folder.ts): the
|
|
1660
|
+
// names the repository goes by, hidden under a table only the receipt
|
|
1661
|
+
// keeps. Without a receipt there is nowhere to keep it: the names stay,
|
|
1662
|
+
// and the scan says where.
|
|
1663
|
+
const config = readRepoConfig(repo);
|
|
1664
|
+
const publicCut = computed.request.public === true;
|
|
1665
|
+
const textExtras = extras.filter((path) => !isBinaryPath(path) && isTextContent(readFileSync(join(repo, path))));
|
|
1666
|
+
const binaryPaths = [...binaries, ...extras.filter((path) => !textExtras.includes(path))];
|
|
1667
|
+
const extraText = new Map(textExtras.map((path) => [path, readFileSync(join(repo, path), "utf8")]));
|
|
1668
|
+
let table = null;
|
|
1669
|
+
let pathOf = (path) => path;
|
|
1670
|
+
let folderText = (_path, text) => text;
|
|
1671
|
+
let leaks = [];
|
|
1672
|
+
let leakScan = null;
|
|
1673
|
+
if (publicCut) {
|
|
1674
|
+
const packageOf = packageFolder(repo);
|
|
1675
|
+
const kept = new Set((config.keepNames ?? []).map((name) => workspaceVocabulary([name])[0] ?? name.toLowerCase()));
|
|
1676
|
+
const names = workspaceNames(repo, [...new Set(computed.manifest.included.map((file) => packageOf(file.path)))], [...(config.names ?? []), ...(request.names ?? [])]);
|
|
1677
|
+
for (const name of [...(config.names ?? []), ...(request.names ?? [])])
|
|
1678
|
+
if (workspaceVocabulary([name]).length === 0)
|
|
1679
|
+
log(`--name ${name}: too short to rename safely (fewer than four letters), so not renamed: the folder may hold it.`);
|
|
1680
|
+
// The owner's other repositories, by what the folder's text writes
|
|
1681
|
+
// after the owner's name.
|
|
1682
|
+
const owned = ownedNames([...files.map((file) => ({ text: file.text })), ...textExtras.map((path) => ({ text: extraText.get(path) ?? "" }))], workspaceVocabulary(names));
|
|
1683
|
+
const vocabulary = workspaceVocabulary([...names, ...owned]).filter((term) => !kept.has(term));
|
|
1684
|
+
// What the folder installs from the registry keeps its name: npm needs it.
|
|
1685
|
+
const manifestJson = JSON.parse(files.find((file) => file.path === "package.json")?.text ?? "{}");
|
|
1686
|
+
const installed = Object.entries({ ...manifestJson.dependencies, ...manifestJson.devDependencies })
|
|
1687
|
+
.filter(([, version]) => !version.startsWith("file:"))
|
|
1688
|
+
.map(([name]) => name);
|
|
1689
|
+
const keptPackages = keptNames(installed, vocabulary);
|
|
1690
|
+
const texts = [
|
|
1691
|
+
...files.map((file) => ({ path: file.path, text: file.text })),
|
|
1692
|
+
...textExtras.map((path) => ({ path, text: extraText.get(path) ?? "" })),
|
|
1693
|
+
];
|
|
1694
|
+
if (keepsReceipt && request.pseudonyms !== false && vocabulary.length > 0) {
|
|
1695
|
+
const sliceFiles = new Set([...computed.manifest.included.map((file) => file.path), ...extras]);
|
|
1696
|
+
const result = pseudonymize({
|
|
1697
|
+
files: texts,
|
|
1698
|
+
binaries: binaryPaths,
|
|
1699
|
+
vocabulary,
|
|
1700
|
+
owner: config.pseudonyms ?? [],
|
|
1701
|
+
kept: keptPackages,
|
|
1702
|
+
readBack: (path) => sliceFiles.has(path),
|
|
1703
|
+
...(request.earlierPseudonyms === undefined ? {} : { earlier: request.earlierPseudonyms }),
|
|
1704
|
+
});
|
|
1705
|
+
if (!result.ok)
|
|
1706
|
+
throw new Error(`Nothing written: the public folder's pseudonyms would not map back byte for byte.\n${result.problems
|
|
1707
|
+
.map((problem) => ` ${problem}`)
|
|
1708
|
+
.join("\n")}\nSet pseudonyms in ${CONFIG_PATH} for the names it could not rename, or cut with --keep-names.`);
|
|
1709
|
+
table = result.table;
|
|
1710
|
+
pathOf = result.pathOf;
|
|
1711
|
+
const renamedFiles = result.files;
|
|
1712
|
+
folderText = (path, text) => renamedFiles.get(path)?.text ?? text;
|
|
1713
|
+
leaks = [...result.leaks];
|
|
1714
|
+
if (table.rules.length > 0)
|
|
1715
|
+
log(`Pseudonyms: ${table.rules.length} rename(s) hide ${vocabulary.length} name(s) the repository goes by; the receipt keeps the table.`);
|
|
1716
|
+
if (keptPackages.length > 0)
|
|
1717
|
+
log(`Kept as the registry names them (npm installs them so), though they carry a hidden name: ${keptPackages.join(", ")}. --mock one to rename it.`);
|
|
1718
|
+
}
|
|
1719
|
+
else
|
|
1720
|
+
leaks = vocabularyLeaks(texts.map((file) => ({ path: file.path, text: maskKept(file.text, keptPackages) })), vocabulary);
|
|
1721
|
+
leakScan = { vocabulary, kept: keptPackages };
|
|
1032
1722
|
}
|
|
1033
|
-
|
|
1723
|
+
// The generated files' text as the folder holds them, which the receipt
|
|
1724
|
+
// keeps; and the test side's (tests, their helpers, setup files).
|
|
1725
|
+
const generatedText = {};
|
|
1726
|
+
const testText = {};
|
|
1727
|
+
const testSide = (() => {
|
|
1728
|
+
const importersOf = new Map();
|
|
1729
|
+
for (const edge of computed.manifest.internalImports)
|
|
1730
|
+
importersOf.set(edge.to, [...(importersOf.get(edge.to) ?? []), edge.from]);
|
|
1731
|
+
const setup = new Set(computed.manifest.params.setupFiles ?? []);
|
|
1732
|
+
const side = new Set([...(computed.tests?.carried ?? []), ...setup]);
|
|
1733
|
+
// The code is never test side: an entry point, or what code outside imports.
|
|
1734
|
+
const code = new Set([...computed.request.entryPoints, ...computed.manifest.cuts.inbound.map((edge) => edge.to)]);
|
|
1735
|
+
for (const file of computed.manifest.included) {
|
|
1736
|
+
if (code.has(file.path))
|
|
1737
|
+
continue;
|
|
1738
|
+
if (isTestPath(file.path))
|
|
1739
|
+
side.add(file.path);
|
|
1740
|
+
const importers = importersOf.get(file.path) ?? [];
|
|
1741
|
+
if (importers.length > 0 && importers.every((from) => isTestPath(from) || setup.has(from)))
|
|
1742
|
+
side.add(file.path);
|
|
1743
|
+
}
|
|
1744
|
+
return side;
|
|
1745
|
+
})();
|
|
1746
|
+
for (const file of files) {
|
|
1747
|
+
const path = pathOf(file.path);
|
|
1748
|
+
const text = folderText(file.path, file.text);
|
|
1034
1749
|
await mkdir(dirname(join(out, path)), { recursive: true });
|
|
1035
|
-
await
|
|
1750
|
+
await writeFile(join(out, path), text, "utf8");
|
|
1751
|
+
if (file.class !== "source")
|
|
1752
|
+
generatedText[path] = text;
|
|
1753
|
+
else if (testSide.has(file.path))
|
|
1754
|
+
testText[path] = text;
|
|
1755
|
+
}
|
|
1756
|
+
for (const path of [...binaryPaths, ...textExtras]) {
|
|
1757
|
+
const target = pathOf(path);
|
|
1758
|
+
await mkdir(dirname(join(out, target)), { recursive: true });
|
|
1759
|
+
if (table !== null && extraText.has(path)) {
|
|
1760
|
+
const text = folderText(path, extraText.get(path) ?? "");
|
|
1761
|
+
await writeFile(join(out, target), text, "utf8");
|
|
1762
|
+
}
|
|
1763
|
+
else {
|
|
1764
|
+
await copyFile(join(repo, path), join(out, target));
|
|
1765
|
+
}
|
|
1036
1766
|
}
|
|
1037
1767
|
log(`Wrote ${files.length + binaries.length + extras.length} files to ${out}.`);
|
|
1038
1768
|
let lockfile = false;
|
|
@@ -1051,14 +1781,45 @@ export async function cutSlice(request) {
|
|
|
1051
1781
|
}
|
|
1052
1782
|
if (request.offline !== true)
|
|
1053
1783
|
try {
|
|
1054
|
-
|
|
1055
|
-
"install",
|
|
1056
|
-
"
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
"
|
|
1060
|
-
|
|
1061
|
-
|
|
1784
|
+
const resolveLock = async () => {
|
|
1785
|
+
await command("npm", ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], { cwd: out, signal: AbortSignal.timeout(5 * 60_000) });
|
|
1786
|
+
const path = join(out, "package-lock.json");
|
|
1787
|
+
if (!existsSync(path))
|
|
1788
|
+
return;
|
|
1789
|
+
const lock = JSON.parse(readFileSync(path, "utf8"));
|
|
1790
|
+
if (pruneLock(lock) > 0)
|
|
1791
|
+
await writeFile(path, `${JSON.stringify(lock, null, 2)}\n`, "utf8");
|
|
1792
|
+
};
|
|
1793
|
+
/** Whether `npm ci` would take the lock: it refuses one out of step with package.json. */
|
|
1794
|
+
const lockHolds = async () => {
|
|
1795
|
+
try {
|
|
1796
|
+
await command("npm", ["ci", "--dry-run", "--ignore-scripts", "--no-audit", "--no-fund", "--loglevel=error"], {
|
|
1797
|
+
cwd: out,
|
|
1798
|
+
signal: AbortSignal.timeout(5 * 60_000),
|
|
1799
|
+
});
|
|
1800
|
+
return true;
|
|
1801
|
+
}
|
|
1802
|
+
catch {
|
|
1803
|
+
return false;
|
|
1804
|
+
}
|
|
1805
|
+
};
|
|
1806
|
+
await resolveLock();
|
|
1807
|
+
// npm 11 can leave a seeded lock short of entries (vite's and
|
|
1808
|
+
// vitest's own dependencies), which npm ci then refuses: a second
|
|
1809
|
+
// pass fills them and keeps the repository's versions; failing
|
|
1810
|
+
// that, a lock resolved from the registry alone.
|
|
1811
|
+
if (existsSync(join(out, "package-lock.json")) && !(await lockHolds())) {
|
|
1812
|
+
await resolveLock();
|
|
1813
|
+
if (!(await lockHolds())) {
|
|
1814
|
+
await rm(join(out, "package-lock.json"), { force: true });
|
|
1815
|
+
await resolveLock();
|
|
1816
|
+
if (!(await lockHolds())) {
|
|
1817
|
+
await rm(join(out, "package-lock.json"), { force: true });
|
|
1818
|
+
throw new Error("npm ci would not take the lock npm resolved");
|
|
1819
|
+
}
|
|
1820
|
+
log("Resolved package-lock.json from the registry: the repository's own did not resolve into one npm ci takes.");
|
|
1821
|
+
}
|
|
1822
|
+
}
|
|
1062
1823
|
lockfile = existsSync(join(out, "package-lock.json"));
|
|
1063
1824
|
log("Resolved package-lock.json.");
|
|
1064
1825
|
}
|
|
@@ -1083,16 +1844,13 @@ export async function cutSlice(request) {
|
|
|
1083
1844
|
.map((blocker) => blocker.detail)
|
|
1084
1845
|
.join(" ")}`);
|
|
1085
1846
|
}
|
|
1847
|
+
const lockText = lockfile ? readFileSync(join(out, "package-lock.json"), "utf8") : null;
|
|
1848
|
+
if (lockText !== null) {
|
|
1849
|
+
generatedText["package-lock.json"] = lockText;
|
|
1850
|
+
}
|
|
1086
1851
|
const generated = [
|
|
1087
1852
|
...files.filter((file) => file.class !== "source"),
|
|
1088
|
-
...(
|
|
1089
|
-
? [
|
|
1090
|
-
{
|
|
1091
|
-
path: "package-lock.json",
|
|
1092
|
-
text: readFileSync(join(out, "package-lock.json"), "utf8"),
|
|
1093
|
-
},
|
|
1094
|
-
]
|
|
1095
|
-
: []),
|
|
1853
|
+
...(lockText === null ? [] : [{ path: "package-lock.json", text: lockText }]),
|
|
1096
1854
|
]
|
|
1097
1855
|
.map((file) => ({ path: file.path, sha256: sha256(file.text) }))
|
|
1098
1856
|
.sort((a, b) => compare(a.path, b.path));
|
|
@@ -1100,21 +1858,66 @@ export async function cutSlice(request) {
|
|
|
1100
1858
|
.filter((file) => file.class === "source")
|
|
1101
1859
|
.map((file) => ({ path: file.path, sha256: sha256(file.text) }))
|
|
1102
1860
|
.sort((a, b) => compare(a.path, b.path));
|
|
1861
|
+
const readiness = readinessOf(request, computed);
|
|
1862
|
+
// A name the table left is a blocker, named in full to the maintainer;
|
|
1863
|
+
// so is one a looser reading finds in anything the folder holds, its
|
|
1864
|
+
// binary files and its resolved lock included.
|
|
1865
|
+
if (leakScan !== null) {
|
|
1866
|
+
const held = listTree(out).map((path) => {
|
|
1867
|
+
const bytes = readFileSync(join(out, path));
|
|
1868
|
+
return { path, text: isTextContent(bytes) ? bytes.toString("utf8") : bytes.toString("latin1") };
|
|
1869
|
+
});
|
|
1870
|
+
const strict = new Set(leaks.map((leak) => leak.term));
|
|
1871
|
+
for (const leak of looseLeaks(held, leakScan.vocabulary, leakScan.kept))
|
|
1872
|
+
if (!strict.has(leak.term))
|
|
1873
|
+
leaks.push(leak);
|
|
1874
|
+
}
|
|
1875
|
+
const leakBlockers = vocabularyBlockers(leaks).map(({ code, file, detail }) => ({ code, file, detail }));
|
|
1103
1876
|
const record = {
|
|
1104
1877
|
...computed,
|
|
1878
|
+
cutId,
|
|
1879
|
+
...(computed.request.public === true ? {} : { readiness }),
|
|
1105
1880
|
generated,
|
|
1106
1881
|
copied,
|
|
1107
1882
|
...(extras.length === 0
|
|
1108
1883
|
? {}
|
|
1109
1884
|
: {
|
|
1110
1885
|
extras: extras
|
|
1111
|
-
.map((path) => ({ path, sha256: sha256(readFileSync(join(
|
|
1886
|
+
.map((path) => ({ path, sha256: sha256(readFileSync(join(repo, path))) }))
|
|
1112
1887
|
.sort((a, b) => compare(a.path, b.path)),
|
|
1113
1888
|
}),
|
|
1114
1889
|
lockfile,
|
|
1890
|
+
blockers: [...computed.blockers, ...leakBlockers],
|
|
1891
|
+
ready: computed.ready && leakBlockers.length === 0,
|
|
1115
1892
|
};
|
|
1116
|
-
|
|
1117
|
-
|
|
1893
|
+
// The folder's own record: a public one leaves out what names the
|
|
1894
|
+
// repository beyond the slice, and one in pseudonyms holds its files by
|
|
1895
|
+
// the paths and hashes the folder has. A leak's blocker there does not
|
|
1896
|
+
// say the name it is about.
|
|
1897
|
+
const folderRecord = keepsReceipt && publicCut ? folderRecordFor(record, table ?? undefined, out) : record;
|
|
1898
|
+
// The maintainer's copy, outside the folder: what a folder that comes
|
|
1899
|
+
// back is judged by, with the pseudonyms' table.
|
|
1900
|
+
const receiptData = !keepsReceipt
|
|
1901
|
+
? null
|
|
1902
|
+
: {
|
|
1903
|
+
schemaVersion: RECEIPT_SCHEMA_VERSION,
|
|
1904
|
+
cutId,
|
|
1905
|
+
createdAt: new Date().toISOString(),
|
|
1906
|
+
repo,
|
|
1907
|
+
out,
|
|
1908
|
+
record,
|
|
1909
|
+
...(folderRecord === record ? {} : { folderRecord }),
|
|
1910
|
+
...(table === null ? {} : { pseudonyms: table }),
|
|
1911
|
+
// The advice a public folder keeps out of its own record.
|
|
1912
|
+
readiness,
|
|
1913
|
+
generatedText,
|
|
1914
|
+
...(Object.keys(testText).length === 0 ? {} : { testText }),
|
|
1915
|
+
...(request.unseen === undefined || request.unseen.length === 0 ? {} : { unseen: [...request.unseen] }),
|
|
1916
|
+
};
|
|
1917
|
+
if (receipt !== null && receiptData !== null)
|
|
1918
|
+
writeReceipt(receipt, receiptData);
|
|
1919
|
+
await writeFile(join(out, RECORD_PATH), `${JSON.stringify(folderRecord, null, 2)}\n`, "utf8");
|
|
1920
|
+
return { out, record, contract, readiness, receipt, receiptData, pseudonyms: table };
|
|
1118
1921
|
}
|
|
1119
1922
|
export function readRecord(dir) {
|
|
1120
1923
|
const path = join(resolve(dir), RECORD_PATH);
|
|
@@ -1141,7 +1944,8 @@ export function readRecord(dir) {
|
|
|
1141
1944
|
export function checkRecordFiles(record, repo) {
|
|
1142
1945
|
const commit = record.manifest.sourceCommitSha;
|
|
1143
1946
|
for (const file of [...record.manifest.included, ...(record.extras ?? [])]) {
|
|
1144
|
-
const
|
|
1947
|
+
const at = record.bases?.[file.path] ?? commit;
|
|
1948
|
+
const committed = at === null ? null : gitShow(repo, at, file.path);
|
|
1145
1949
|
if (committed !== null && sha256(committed) === file.sha256)
|
|
1146
1950
|
continue;
|
|
1147
1951
|
const current = join(repo, file.path);
|
|
@@ -1152,24 +1956,107 @@ export function checkRecordFiles(record, repo) {
|
|
|
1152
1956
|
throw new Error(`carveout.json is not one carveout wrote: it lists ${JSON.stringify(file.path)} as a slice file, which the repository does not hold at those bytes. Cut the folder again rather than trust it.`);
|
|
1153
1957
|
}
|
|
1154
1958
|
}
|
|
1959
|
+
/** The counts the runner wrote as JSON (`COUNTS_PATH`), or undefined when they do not read. */
|
|
1960
|
+
export function countsFromJson(text) {
|
|
1961
|
+
try {
|
|
1962
|
+
const parsed = JSON.parse(text);
|
|
1963
|
+
const number = (value) => (typeof value === "number" && Number.isFinite(value) ? value : 0);
|
|
1964
|
+
const runners = {};
|
|
1965
|
+
for (const each of parsed.counted ?? []) {
|
|
1966
|
+
const name = String(each.runner ?? "");
|
|
1967
|
+
const sum = runners[name] ?? { tests: 0, pass: 0, fail: 0 };
|
|
1968
|
+
runners[name] = { tests: sum.tests + number(each.tests), pass: sum.pass + number(each.pass), fail: sum.fail + number(each.fail) };
|
|
1969
|
+
}
|
|
1970
|
+
const all = Object.values(runners);
|
|
1971
|
+
const checks = number(parsed.surface?.checks);
|
|
1972
|
+
return {
|
|
1973
|
+
tests: all.reduce((sum, each) => sum + each.tests, 0),
|
|
1974
|
+
pass: all.reduce((sum, each) => sum + each.pass, 0),
|
|
1975
|
+
fail: all.reduce((sum, each) => sum + each.fail, 0),
|
|
1976
|
+
runners,
|
|
1977
|
+
...(checks > 0 ? { surface: { checks, fail: number(parsed.surface?.fail) } } : {}),
|
|
1978
|
+
};
|
|
1979
|
+
}
|
|
1980
|
+
catch {
|
|
1981
|
+
return undefined;
|
|
1982
|
+
}
|
|
1983
|
+
}
|
|
1155
1984
|
/**
|
|
1156
|
-
*
|
|
1157
|
-
*
|
|
1158
|
-
*
|
|
1159
|
-
* them. What a build emits and what the harness writes do not count.
|
|
1985
|
+
* The counts in a run's TAP: the runner's `# carveout` lines, or, from a
|
|
1986
|
+
* folder cut before they were written, node:test's own summary (which
|
|
1987
|
+
* counts the interface test's checks among the tests).
|
|
1160
1988
|
*/
|
|
1161
|
-
function
|
|
1989
|
+
export function countsOf(tap) {
|
|
1990
|
+
const runners = {};
|
|
1991
|
+
for (const [, runner = "", tests, pass, fail] of tap.matchAll(/^# carveout (\S+) tests (\d+) pass (\d+) fail (\d+)$/gm)) {
|
|
1992
|
+
const sum = runners[runner] ?? { tests: 0, pass: 0, fail: 0 };
|
|
1993
|
+
runners[runner] = { tests: sum.tests + Number(tests), pass: sum.pass + Number(pass), fail: sum.fail + Number(fail) };
|
|
1994
|
+
}
|
|
1995
|
+
let surface;
|
|
1996
|
+
for (const [, checks, fail] of tap.matchAll(/^# carveout surface checks (\d+) fail (\d+)$/gm))
|
|
1997
|
+
surface = { checks: (surface?.checks ?? 0) + Number(checks), fail: (surface?.fail ?? 0) + Number(fail) };
|
|
1998
|
+
const each = Object.values(runners);
|
|
1999
|
+
if (each.length > 0 || surface !== undefined)
|
|
2000
|
+
return {
|
|
2001
|
+
tests: each.reduce((sum, { tests }) => sum + tests, 0),
|
|
2002
|
+
pass: each.reduce((sum, { pass }) => sum + pass, 0),
|
|
2003
|
+
fail: each.reduce((sum, { fail }) => sum + fail, 0),
|
|
2004
|
+
runners,
|
|
2005
|
+
...(surface === undefined ? {} : { surface }),
|
|
2006
|
+
};
|
|
2007
|
+
const count = (key) => Number(new RegExp(`^# ${key} (\\d+)$`, "m").exec(tap)?.[1] ?? Number.NaN);
|
|
2008
|
+
return Number.isNaN(count("tests")) ? undefined : { tests: count("tests"), pass: count("pass"), fail: count("fail") };
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* Whether a path is what the folder's build or test run writes beside a
|
|
2012
|
+
* source (`cart.js` and `cart.js.map` beside `cart.ts`), or the run's own
|
|
2013
|
+
* records: neither is an edit.
|
|
2014
|
+
*/
|
|
2015
|
+
function buildOutput(path, present) {
|
|
2016
|
+
if (path === BASELINE_PATH || path === LAST_RUN_PATH || path === COUNTS_PATH || path === VITEST_REPORT_PATH || path.endsWith(".tsbuildinfo"))
|
|
2017
|
+
return true;
|
|
2018
|
+
return tscOutput(path.replace(/\.map$/, ""), present);
|
|
2019
|
+
}
|
|
2020
|
+
/**
|
|
2021
|
+
* Whether tsc writes `path` from a source beside it: a `.js` (or `.d.ts`)
|
|
2022
|
+
* from a `.ts` or `.tsx`, a `.mjs` from a `.mts`, a `.cjs` from a `.cts`.
|
|
2023
|
+
* Nothing else: a `.mjs` beside a `.ts` is no build's, and is judged.
|
|
2024
|
+
*/
|
|
2025
|
+
function tscOutput(path, present) {
|
|
2026
|
+
const pairs = [
|
|
2027
|
+
[/\.d\.ts$/, [".ts", ".tsx"]],
|
|
2028
|
+
[/\.d\.mts$/, [".mts"]],
|
|
2029
|
+
[/\.d\.cts$/, [".cts"]],
|
|
2030
|
+
[/\.js$/, [".ts", ".tsx"]],
|
|
2031
|
+
[/\.mjs$/, [".mts"]],
|
|
2032
|
+
[/\.cjs$/, [".cts"]],
|
|
2033
|
+
];
|
|
2034
|
+
for (const [suffix, sources] of pairs)
|
|
2035
|
+
if (suffix.test(path)) {
|
|
2036
|
+
const stem = path.replace(suffix, "");
|
|
2037
|
+
return sources.some((ext) => present.has(`${stem}${ext}`));
|
|
2038
|
+
}
|
|
2039
|
+
return false;
|
|
2040
|
+
}
|
|
2041
|
+
/**
|
|
2042
|
+
* Whether the folder holds an edit since the cut: a slice file whose bytes
|
|
2043
|
+
* differ from what the cut wrote, one gone, a generated file changed, or
|
|
2044
|
+
* any new file but what a build or a test run writes. Read against the
|
|
2045
|
+
* record as the folder holds it (the receipt's, when there is one); the
|
|
2046
|
+
* folder's own `.gitignore` hides nothing.
|
|
2047
|
+
*/
|
|
2048
|
+
function folderEdited(dir, record) {
|
|
1162
2049
|
const root = resolve(dir);
|
|
1163
|
-
|
|
2050
|
+
// A file a patch brought in is no cut's: its tests' failures are no
|
|
2051
|
+
// baseline's (a failing test apply added would be recorded as one).
|
|
2052
|
+
if ((record.copied ?? []).some((file) => file.applied === true))
|
|
2053
|
+
return true;
|
|
1164
2054
|
const written = new Map([
|
|
1165
2055
|
...(record.copied ?? []).map((file) => [file.path, file.sha256]),
|
|
1166
2056
|
...(record.extras ?? []).map((file) => [file.path, file.sha256]),
|
|
1167
2057
|
]);
|
|
1168
|
-
const generated = new
|
|
1169
|
-
const
|
|
1170
|
-
? readFileSync(join(root, ".gitignore"), "utf8").split("\n").filter(Boolean)
|
|
1171
|
-
: []);
|
|
1172
|
-
const tree = listTree(root);
|
|
2058
|
+
const generated = new Map(record.generated.map((file) => [file.path, file.sha256]));
|
|
2059
|
+
const tree = listTree(root, "", [], toolDirsIn(root, record));
|
|
1173
2060
|
const present = new Set(tree);
|
|
1174
2061
|
for (const [path, hash] of written) {
|
|
1175
2062
|
if (!present.has(path))
|
|
@@ -1177,14 +2064,63 @@ function folderEdited(dir) {
|
|
|
1177
2064
|
if (sha256(readFileSync(join(root, path))) !== hash)
|
|
1178
2065
|
return true;
|
|
1179
2066
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
2067
|
+
for (const [path, hash] of generated)
|
|
2068
|
+
if (path !== "package-lock.json" && present.has(path) && sha256(readFileSync(join(root, path))) !== hash)
|
|
2069
|
+
return true;
|
|
2070
|
+
return tree.some((path) => !written.has(path) && !generated.has(path) && path !== RECORD_PATH && path !== "package-lock.json" && !buildOutput(path, present));
|
|
2071
|
+
}
|
|
2072
|
+
/**
|
|
2073
|
+
* Whether a folder holds someone else's work: edited since the cut, by the
|
|
2074
|
+
* receipt's hashes. With no receipt it is taken as edited: the folder's own
|
|
2075
|
+
* record came back with it and cannot vouch that it is not.
|
|
2076
|
+
*/
|
|
2077
|
+
export function folderCameBack(dir, options = {}) {
|
|
2078
|
+
const trusted = trustedCut(dir, options.receipt, { optional: true, ...(options.noReceipt === true ? { noReceipt: true } : {}) });
|
|
2079
|
+
if (trusted.receipt === null)
|
|
2080
|
+
return true;
|
|
2081
|
+
const record = folderView(trusted.receipt);
|
|
2082
|
+
// A record from before the cut's own files were hashed: no telling.
|
|
2083
|
+
if (record.copied === undefined)
|
|
2084
|
+
return true;
|
|
2085
|
+
return folderEdited(resolve(dir), record);
|
|
2086
|
+
}
|
|
2087
|
+
/** The package names the slice's files spell as specifiers, at the cut's commit (or as the checkout holds them). */
|
|
2088
|
+
export function sliceSpecifierPackages(record, repo) {
|
|
2089
|
+
if (repo === null || repo === undefined || !existsSync(repo))
|
|
2090
|
+
return [];
|
|
2091
|
+
const commit = record.manifest.sourceCommitSha;
|
|
2092
|
+
return [...record.manifest.included, ...(record.extras ?? [])].flatMap((file) => {
|
|
2093
|
+
if (!isScript(file.path))
|
|
2094
|
+
return [];
|
|
2095
|
+
const bytes = (commit === null ? null : gitShow(repo, commit, file.path)) ?? (existsSync(join(repo, file.path)) ? readFileSync(join(repo, file.path)) : null);
|
|
2096
|
+
return bytes === null ? [] : specifierPackages(bytes.toString("utf8"));
|
|
2097
|
+
});
|
|
2098
|
+
}
|
|
2099
|
+
/** The node_modules folders under a folder, below its root. */
|
|
2100
|
+
function nodeModulesIn(root, dir = "", into = []) {
|
|
2101
|
+
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
|
|
2102
|
+
if (!entry.isDirectory() || entry.name === ".git")
|
|
2103
|
+
continue;
|
|
2104
|
+
const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
|
|
2105
|
+
if (entry.name === "node_modules") {
|
|
2106
|
+
if (dir !== "")
|
|
2107
|
+
into.push(path);
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
nodeModulesIn(root, path, into);
|
|
2111
|
+
}
|
|
2112
|
+
return into;
|
|
2113
|
+
}
|
|
2114
|
+
/** The paths under a folder that are symbolic links, outside node_modules. */
|
|
2115
|
+
function symlinksIn(root, dir = "", into = []) {
|
|
2116
|
+
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
|
|
2117
|
+
const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
|
|
2118
|
+
if (entry.isSymbolicLink())
|
|
2119
|
+
into.push(path);
|
|
2120
|
+
else if (entry.isDirectory() && entry.name !== "node_modules" && entry.name !== ".git")
|
|
2121
|
+
symlinksIn(root, path, into);
|
|
2122
|
+
}
|
|
2123
|
+
return into;
|
|
1188
2124
|
}
|
|
1189
2125
|
/**
|
|
1190
2126
|
* The first check of a folder: the tests that fail before any edit fail
|
|
@@ -1192,8 +2128,8 @@ function folderEdited(dir) {
|
|
|
1192
2128
|
* failed (the build, the coverage gate) and the folder has no edits nor a
|
|
1193
2129
|
* baseline yet; null otherwise.
|
|
1194
2130
|
*/
|
|
1195
|
-
async function recordBaseline(source, work) {
|
|
1196
|
-
if (existsSync(join(source, BASELINE_PATH)))
|
|
2131
|
+
async function recordBaseline(source, work, record, baselineKnown) {
|
|
2132
|
+
if (baselineKnown || existsSync(join(source, BASELINE_PATH)))
|
|
1197
2133
|
return null;
|
|
1198
2134
|
const tap = existsSync(join(work, LAST_RUN_PATH))
|
|
1199
2135
|
? readFileSync(join(work, LAST_RUN_PATH), "utf8")
|
|
@@ -1204,21 +2140,47 @@ async function recordBaseline(source, work) {
|
|
|
1204
2140
|
if (failing.length === 0 || /coverage does not meet threshold/.test(tap))
|
|
1205
2141
|
return null;
|
|
1206
2142
|
// Records from before `copied` was kept are judged by a patch, as before.
|
|
1207
|
-
const edited =
|
|
1208
|
-
? (await patchProject(source)).diff !== ""
|
|
1209
|
-
: folderEdited(source);
|
|
2143
|
+
const edited = record.copied === undefined
|
|
2144
|
+
? (await patchProject(source, { noReceipt: true })).diff !== ""
|
|
2145
|
+
: folderEdited(source, record);
|
|
1210
2146
|
if (edited)
|
|
1211
2147
|
return null;
|
|
1212
|
-
|
|
2148
|
+
// Written as a new file: never through a link the folder put there.
|
|
2149
|
+
await rm(join(source, BASELINE_PATH), { force: true });
|
|
2150
|
+
await writeFile(join(source, BASELINE_PATH), `${JSON.stringify({ failing }, null, 2)}\n`, { flag: "wx" });
|
|
1213
2151
|
return failing;
|
|
1214
2152
|
}
|
|
1215
2153
|
/**
|
|
1216
2154
|
* Installs, builds and tests the folder, in a scratch copy so the folder
|
|
1217
2155
|
* itself is left as it was; stops at the first step that fails.
|
|
2156
|
+
*
|
|
2157
|
+
* A folder that came back is run as the receipt's cut made it: its
|
|
2158
|
+
* generated files (the test runner, the harness, the manifests and the
|
|
2159
|
+
* stubs) are put back from the receipt, a file the folder added under
|
|
2160
|
+
* `carveout/` is taken out, the baseline read is the receipt's, and a
|
|
2161
|
+
* folder with a symbolic link is not run at all. Its code runs, as the
|
|
2162
|
+
* caller, with the caller's network: the receipt is put back if the run
|
|
2163
|
+
* changed it, and the check fails.
|
|
1218
2164
|
*/
|
|
1219
2165
|
export async function checkProject(dir, options = {}) {
|
|
1220
|
-
|
|
2166
|
+
// The receipt's record and baseline when there is one: a folder that
|
|
2167
|
+
// came back may have edited its own, to look unedited or to leave a
|
|
2168
|
+
// failing test uncounted.
|
|
2169
|
+
const trusted = trustedCut(dir, options.receipt, { optional: true, ...(options.noReceipt === true ? { noReceipt: true } : {}) });
|
|
2170
|
+
// What the folder's files are measured against: the record as the folder
|
|
2171
|
+
// holds it (a public folder in pseudonyms has its own paths and hashes),
|
|
2172
|
+
// as the receipt keeps it.
|
|
2173
|
+
const record = trusted.receipt === null ? trusted.record : folderView(trusted.receipt);
|
|
1221
2174
|
const source = resolve(dir);
|
|
2175
|
+
const links = symlinksIn(source);
|
|
2176
|
+
if (links.length > 0)
|
|
2177
|
+
return [
|
|
2178
|
+
{
|
|
2179
|
+
command: "copy",
|
|
2180
|
+
ok: false,
|
|
2181
|
+
output: `The folder holds symbolic links, which a slice never has; nothing was run:\n${links.map((path) => ` ${path}`).join("\n")}`,
|
|
2182
|
+
},
|
|
2183
|
+
];
|
|
1222
2184
|
let work = source;
|
|
1223
2185
|
const scratch = options.inPlace !== true || options.isolated === true
|
|
1224
2186
|
? mkdtempSync(join(tmpdir(), "carveout-check-"))
|
|
@@ -1227,6 +2189,68 @@ export async function checkProject(dir, options = {}) {
|
|
|
1227
2189
|
work = join(scratch, "project");
|
|
1228
2190
|
execFileSync("cp", ["-R", source, work]);
|
|
1229
2191
|
await rm(join(work, "node_modules"), { recursive: true, force: true });
|
|
2192
|
+
// Nor a node_modules folder anywhere in it: no patch reads one, so what
|
|
2193
|
+
// the folder holds there never runs (npm ci writes the root's anew).
|
|
2194
|
+
for (const nested of nodeModulesIn(work))
|
|
2195
|
+
await rm(join(work, nested), { recursive: true, force: true });
|
|
2196
|
+
// A tool's folder is no part of a change, and a patch never judges what
|
|
2197
|
+
// it holds: none of it runs (a test there could write the baseline).
|
|
2198
|
+
for (const tool of toolDirsIn(work, record))
|
|
2199
|
+
await rm(join(work, tool), { recursive: true, force: true });
|
|
2200
|
+
// Nor what the build writes from a source: the build writes it anew,
|
|
2201
|
+
// and patch never shows the folder's copy of it.
|
|
2202
|
+
{
|
|
2203
|
+
const tree = listTree(work);
|
|
2204
|
+
const present = new Set(tree);
|
|
2205
|
+
const generatedPaths = new Set(record.generated.map((file) => file.path));
|
|
2206
|
+
for (const path of tree)
|
|
2207
|
+
if (!generatedPaths.has(path) && tscOutput(path.replace(/\.map$/, ""), present))
|
|
2208
|
+
await rm(join(work, path), { force: true });
|
|
2209
|
+
}
|
|
2210
|
+
}
|
|
2211
|
+
const steps = [];
|
|
2212
|
+
// The generated files the folder changed, put back from the receipt; the
|
|
2213
|
+
// files it added under carveout/, taken out. In place, nothing is
|
|
2214
|
+
// changed: a folder that does not match is not run.
|
|
2215
|
+
const restored = [];
|
|
2216
|
+
const addedPackages = [];
|
|
2217
|
+
const texts = trusted.receipt?.generatedText;
|
|
2218
|
+
if (texts !== undefined) {
|
|
2219
|
+
const tree = listTree(work);
|
|
2220
|
+
const extra = tree.filter((path) => path.startsWith(`${HARNESS_DIR}/`) &&
|
|
2221
|
+
texts[path] === undefined &&
|
|
2222
|
+
path !== BASELINE_PATH &&
|
|
2223
|
+
path !== LAST_RUN_PATH &&
|
|
2224
|
+
path !== COUNTS_PATH &&
|
|
2225
|
+
path !== VITEST_REPORT_PATH);
|
|
2226
|
+
const changed = Object.entries(texts).filter(([path, text]) => {
|
|
2227
|
+
const at = join(work, path);
|
|
2228
|
+
return !existsSync(at) || readFileSync(at, "utf8") !== text;
|
|
2229
|
+
});
|
|
2230
|
+
if (options.inPlace === true && options.isolated !== true && (extra.length > 0 || changed.length > 0)) {
|
|
2231
|
+
if (scratch !== null)
|
|
2232
|
+
await rm(scratch, { recursive: true, force: true });
|
|
2233
|
+
return [
|
|
2234
|
+
{
|
|
2235
|
+
command: "compare",
|
|
2236
|
+
ok: false,
|
|
2237
|
+
output: `The folder's generated files are not the cut's (${[...changed.map(([path]) => path), ...extra].join(", ")}); check it without --in-place, which puts them back.`,
|
|
2238
|
+
},
|
|
2239
|
+
];
|
|
2240
|
+
}
|
|
2241
|
+
for (const path of extra)
|
|
2242
|
+
await rm(join(work, path), { force: true });
|
|
2243
|
+
// A package the folder installed for itself: named, since the run that
|
|
2244
|
+
// judges the slice installs what the cut wrote, as the repository does.
|
|
2245
|
+
if (changed.some(([path]) => path === "package.json") && existsSync(join(work, "package.json")))
|
|
2246
|
+
addedPackages.push(...packagesAdded(readFileSync(join(work, "package.json"), "utf8"), texts["package.json"] ?? ""));
|
|
2247
|
+
for (const [path, text] of changed) {
|
|
2248
|
+
await mkdir(dirname(join(work, path)), { recursive: true });
|
|
2249
|
+
await rm(join(work, path), { force: true });
|
|
2250
|
+
await writeFile(join(work, path), text);
|
|
2251
|
+
restored.push(path);
|
|
2252
|
+
}
|
|
2253
|
+
restored.push(...extra.map((path) => `${path} (taken out)`));
|
|
1230
2254
|
}
|
|
1231
2255
|
const env = {};
|
|
1232
2256
|
if (options.isolated === true && scratch !== null) {
|
|
@@ -1238,9 +2262,12 @@ export async function checkProject(dir, options = {}) {
|
|
|
1238
2262
|
const installFlags = [
|
|
1239
2263
|
"--no-audit",
|
|
1240
2264
|
"--no-fund",
|
|
1241
|
-
...(options.isolated === true ? ["--ignore-scripts"] : []),
|
|
2265
|
+
...(options.isolated === true || options.sandbox !== undefined ? ["--ignore-scripts"] : []),
|
|
1242
2266
|
];
|
|
1243
|
-
|
|
2267
|
+
// The one step with a network: npm reads a project .npmrc, which could
|
|
2268
|
+
// point it elsewhere or set its options; the cut writes none.
|
|
2269
|
+
if (options.sandbox !== undefined && work !== source && !record.generated.some((file) => file.path === ".npmrc"))
|
|
2270
|
+
await rm(join(work, ".npmrc"), { force: true });
|
|
1244
2271
|
const run = [
|
|
1245
2272
|
record.lockfile || existsSync(join(work, "package-lock.json"))
|
|
1246
2273
|
? ["npm", "ci", ...installFlags]
|
|
@@ -1249,19 +2276,219 @@ export async function checkProject(dir, options = {}) {
|
|
|
1249
2276
|
["npm", "test"],
|
|
1250
2277
|
];
|
|
1251
2278
|
await rm(join(work, LAST_RUN_PATH), { force: true });
|
|
2279
|
+
await rm(join(work, VITEST_REPORT_PATH), { force: true });
|
|
2280
|
+
await rm(join(work, COUNTS_PATH), { force: true });
|
|
2281
|
+
// The baseline the receipt holds is the one `npm test` reads; a folder's
|
|
2282
|
+
// own that the receipt does not know is not read at all.
|
|
2283
|
+
const folderBaseline = existsSync(join(source, BASELINE_PATH))
|
|
2284
|
+
? (() => {
|
|
2285
|
+
try {
|
|
2286
|
+
const failing = JSON.parse(readFileSync(join(source, BASELINE_PATH), "utf8")).failing;
|
|
2287
|
+
return Array.isArray(failing) ? failing.filter((name) => typeof name === "string") : [];
|
|
2288
|
+
}
|
|
2289
|
+
catch {
|
|
2290
|
+
return [];
|
|
2291
|
+
}
|
|
2292
|
+
})()
|
|
2293
|
+
: null;
|
|
2294
|
+
// With no receipt, the folder's own baseline is read only when the
|
|
2295
|
+
// caller says there is none to be had (`--no-receipt`).
|
|
2296
|
+
const chosen = trusted.receipt === null && options.noReceipt !== true
|
|
2297
|
+
? { use: null, edited: folderBaseline ?? [] }
|
|
2298
|
+
: trustedBaseline(trusted.receipt, folderBaseline);
|
|
2299
|
+
const baselineEdited = chosen.edited.length > 0 ? chosen.edited : undefined;
|
|
2300
|
+
// The owner's acceptance tests, in the check's copy only.
|
|
2301
|
+
const acceptedAt = [];
|
|
2302
|
+
const table = trusted.receipt?.pseudonyms;
|
|
2303
|
+
// What patch keeps out does not run: a test, a helper or a setup file the
|
|
2304
|
+
// folder edited is put back as the cut wrote it, a file it added is taken
|
|
2305
|
+
// out. Any other (a slice file that gained reach, say) would run as the
|
|
2306
|
+
// folder holds it: with `refuseHeld`, nothing runs at all.
|
|
2307
|
+
if (options.held !== undefined && options.held.length > 0) {
|
|
2308
|
+
if (work === source)
|
|
2309
|
+
return [{ command: "policy", ok: false, output: "What patch keeps out is put back in a copy of the folder, never in it: check without --in-place." }];
|
|
2310
|
+
const cutPaths = new Set([...record.manifest.included.map((file) => file.path), ...record.generated.map((file) => file.path)]);
|
|
2311
|
+
const asHeld = [];
|
|
2312
|
+
for (const path of options.held) {
|
|
2313
|
+
const folderPath = table === undefined ? path : (exactToFolder(table, path, "")?.path ?? toFolder(table, path, "").path);
|
|
2314
|
+
const original = trusted.receipt?.testText?.[folderPath];
|
|
2315
|
+
if (original !== undefined) {
|
|
2316
|
+
await mkdir(dirname(join(work, folderPath)), { recursive: true });
|
|
2317
|
+
await writeFile(join(work, folderPath), original);
|
|
2318
|
+
}
|
|
2319
|
+
else if (!cutPaths.has(folderPath))
|
|
2320
|
+
await rm(join(work, folderPath), { force: true });
|
|
2321
|
+
else if (existsSync(join(work, folderPath)))
|
|
2322
|
+
asHeld.push(path);
|
|
2323
|
+
}
|
|
2324
|
+
if (asHeld.length > 0 && options.refuseHeld === true) {
|
|
2325
|
+
if (scratch !== null)
|
|
2326
|
+
await rm(scratch, { recursive: true, force: true });
|
|
2327
|
+
return [
|
|
2328
|
+
{
|
|
2329
|
+
command: "policy",
|
|
2330
|
+
ok: false,
|
|
2331
|
+
output: `patch keeps out ${asHeld.length} file(s) that would run as the folder holds them, so nothing of the folder ran:\n${asHeld
|
|
2332
|
+
.map((path) => ` ${path}`)
|
|
2333
|
+
.join("\n")}\nRead them (carveout patch names why), then check again with --allow <path> for each you accept.`,
|
|
2334
|
+
},
|
|
2335
|
+
];
|
|
2336
|
+
}
|
|
2337
|
+
}
|
|
2338
|
+
if (options.accept !== undefined && options.accept.length > 0) {
|
|
2339
|
+
if (work === source)
|
|
2340
|
+
return [{ command: "accept", ok: false, output: "Acceptance tests run in a copy of the folder, never in it: check without --in-place." }];
|
|
2341
|
+
for (const test of options.accept) {
|
|
2342
|
+
const folder = table === undefined ? { path: test.path, text: test.text } : exactToFolder(table, test.path, test.text);
|
|
2343
|
+
if (folder === null)
|
|
2344
|
+
return [
|
|
2345
|
+
{
|
|
2346
|
+
command: "accept",
|
|
2347
|
+
ok: false,
|
|
2348
|
+
output: `${test.path} would not map into the folder's names byte for byte; rename what it names, or check a folder cut with --keep-names.`,
|
|
2349
|
+
},
|
|
2350
|
+
];
|
|
2351
|
+
await mkdir(dirname(join(work, folder.path)), { recursive: true });
|
|
2352
|
+
// A test the folder added at the same path moves aside, as patch
|
|
2353
|
+
// carries it (`contributedPath`): it runs with the folder's tests,
|
|
2354
|
+
// never in the owner's test's place.
|
|
2355
|
+
if (existsSync(join(work, folder.path))) {
|
|
2356
|
+
const aside = contributedPath(folder.path, (candidate) => existsSync(join(work, candidate)));
|
|
2357
|
+
await rename(join(work, folder.path), join(work, aside));
|
|
2358
|
+
}
|
|
2359
|
+
await writeFile(join(work, folder.path), folder.text);
|
|
2360
|
+
// A file planted to stand in for it (a twin the walk takes instead,
|
|
2361
|
+
// or one tsc would compile over it) is taken out.
|
|
2362
|
+
const stem = folder.path.replace(/\.[cm]?[jt]sx?$/, "");
|
|
2363
|
+
for (const ext of [".ts", ".tsx", ".mts", ".cts", ".js", ".mjs", ".cjs"])
|
|
2364
|
+
if (`${stem}${ext}` !== folder.path)
|
|
2365
|
+
await rm(join(work, `${stem}${ext}`), { force: true });
|
|
2366
|
+
acceptedAt.push(folder.path);
|
|
2367
|
+
}
|
|
2368
|
+
}
|
|
2369
|
+
if (chosen.use === null)
|
|
2370
|
+
await rm(join(work, BASELINE_PATH), { force: true });
|
|
2371
|
+
else if (chosen.use !== "folder") {
|
|
2372
|
+
await mkdir(dirname(join(work, BASELINE_PATH)), { recursive: true });
|
|
2373
|
+
await rm(join(work, BASELINE_PATH), { force: true });
|
|
2374
|
+
await writeFile(join(work, BASELINE_PATH), `${JSON.stringify({ failing: chosen.use }, null, 2)}\n`, { flag: "wx" });
|
|
2375
|
+
}
|
|
2376
|
+
const inEffect = chosen.use === "folder" ? (folderBaseline ?? []) : (chosen.use ?? []);
|
|
2377
|
+
// The receipt as it was before any of the folder's code ran.
|
|
2378
|
+
const receiptBefore = trusted.receiptPath !== null && existsSync(trusted.receiptPath) ? readFileSync(trusted.receiptPath) : null;
|
|
2379
|
+
let receiptNow = receiptBefore;
|
|
2380
|
+
let recorded = null;
|
|
2381
|
+
// The owner's acceptance tests, each on its own runner with no baseline:
|
|
2382
|
+
// each must report a test, and every one must pass. Run on its own, so
|
|
2383
|
+
// no file pattern, twin or name the baseline holds can pass it unseen;
|
|
2384
|
+
// and on the build, before the folder's own tests run any of its code in
|
|
2385
|
+
// the copy, so nothing it writes there stands in for them.
|
|
2386
|
+
/** Runs a command of the folder's in its copy: in the container with no network when sandboxed, else here. */
|
|
2387
|
+
const runFolder = (argv, runEnv) => {
|
|
2388
|
+
const [executable = "true", ...args] = argv;
|
|
2389
|
+
return options.sandbox === undefined
|
|
2390
|
+
? runGroup(executable, args, work, runEnv)
|
|
2391
|
+
: sandboxRun(options.sandbox, work, argv, { cwd: work, env: env, network: false });
|
|
2392
|
+
};
|
|
2393
|
+
const accept = async () => {
|
|
2394
|
+
// A child's environment is the one given, whole: PATH finds node for
|
|
2395
|
+
// a runner's `#!/usr/bin/env node`, as every other step has it.
|
|
2396
|
+
const acceptEnv = {
|
|
2397
|
+
PATH: process.env["PATH"] ?? "",
|
|
2398
|
+
...(process.env["HOME"] === undefined ? {} : { HOME: process.env["HOME"] }),
|
|
2399
|
+
...(process.env["SystemRoot"] === undefined ? {} : { SystemRoot: process.env["SystemRoot"] }),
|
|
2400
|
+
...env,
|
|
2401
|
+
};
|
|
2402
|
+
const lines = [];
|
|
2403
|
+
let passed = true;
|
|
2404
|
+
for (const path of acceptedAt) {
|
|
2405
|
+
const text = readFileSync(join(work, path), "utf8");
|
|
2406
|
+
const vitestFile = /['"]vitest(\/[^'"]*)?['"]/.test(text);
|
|
2407
|
+
let tests = 0;
|
|
2408
|
+
let failures = 0;
|
|
2409
|
+
let output = "";
|
|
2410
|
+
if (vitestFile) {
|
|
2411
|
+
const report = join(work, HARNESS_DIR, ".accept.json");
|
|
2412
|
+
await rm(report, { force: true });
|
|
2413
|
+
const bin = join(work, "node_modules", ".bin", "vitest");
|
|
2414
|
+
if (!existsSync(bin)) {
|
|
2415
|
+
passed = false;
|
|
2416
|
+
lines.push(`FAIL ${path}: it runs on Vitest, which the folder does not install (cut again with --install vitest)`);
|
|
2417
|
+
continue;
|
|
2418
|
+
}
|
|
2419
|
+
const result = await runFolder([bin, "run", "--config", "vitest.config.mjs", "--reporter=json", `--outputFile.json=${report}`, path], acceptEnv);
|
|
2420
|
+
output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
2421
|
+
try {
|
|
2422
|
+
const json = JSON.parse(readFileSync(report, "utf8"));
|
|
2423
|
+
for (const file of json.testResults ?? []) {
|
|
2424
|
+
const cases = file.assertionResults ?? [];
|
|
2425
|
+
tests += cases.length;
|
|
2426
|
+
// Only a test that ran and passed counts: a skipped, todo or
|
|
2427
|
+
// pending one passes nothing.
|
|
2428
|
+
failures += cases.filter((each) => each.status !== "passed").length + (cases.length === 0 && file.status === "failed" ? 1 : 0);
|
|
2429
|
+
}
|
|
2430
|
+
}
|
|
2431
|
+
catch {
|
|
2432
|
+
failures += 1;
|
|
2433
|
+
}
|
|
2434
|
+
}
|
|
2435
|
+
else {
|
|
2436
|
+
const compiled = path.replace(/\.tsx?$/, ".js").replace(/\.mts$/, ".mjs").replace(/\.cts$/, ".cjs");
|
|
2437
|
+
const result = await runFolder([process.execPath, `--env-file=${ENV_FILE}`, `--import=./${ASSETS_LOADER_PATH}`, "--test", "--test-reporter=tap", "--test-reporter-destination=stdout", compiled], acceptEnv);
|
|
2438
|
+
output = `${result.stdout ?? ""}${result.stderr ?? ""}`;
|
|
2439
|
+
// The tests it holds: a file with none is reported as one test named
|
|
2440
|
+
// for the file, which is no test of the work.
|
|
2441
|
+
const entries = [...(result.stdout ?? "").matchAll(/^\s*(not )?ok \d+ - (.+?)(?: # .*)?$/gm)].filter((match) => !(match[2] ?? "").endsWith(compiled));
|
|
2442
|
+
tests = entries.length;
|
|
2443
|
+
failures = entries.filter((match) => match[1] !== undefined).length + (result.status !== 0 && entries.every((match) => match[1] === undefined) ? 1 : 0);
|
|
2444
|
+
}
|
|
2445
|
+
const ok = tests > 0 && failures === 0;
|
|
2446
|
+
if (!ok)
|
|
2447
|
+
passed = false;
|
|
2448
|
+
lines.push(ok
|
|
2449
|
+
? `ok ${path}: ${tests} test(s) pass`
|
|
2450
|
+
: tests === 0
|
|
2451
|
+
? `FAIL ${path}: no test of it ran (it must name its runner and hold a test)\n${output.trim().slice(-2000)}`
|
|
2452
|
+
: `FAIL ${path}: ${failures} of ${tests} test(s) fail\n${output.trim().slice(-4000)}`);
|
|
2453
|
+
}
|
|
2454
|
+
steps.push({ command: "accept", ok: passed, output: lines.join("\n") });
|
|
2455
|
+
};
|
|
1252
2456
|
for (const argv of run) {
|
|
1253
2457
|
const [executable = "npm", ...args] = argv;
|
|
1254
2458
|
try {
|
|
1255
|
-
const output =
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
2459
|
+
const output = options.sandbox === undefined
|
|
2460
|
+
? await command(executable, args, {
|
|
2461
|
+
cwd: work,
|
|
2462
|
+
signal: AbortSignal.timeout(10 * 60_000),
|
|
2463
|
+
env,
|
|
2464
|
+
})
|
|
2465
|
+
: await sandboxRun(options.sandbox, work, argv, {
|
|
2466
|
+
cwd: work,
|
|
2467
|
+
env,
|
|
2468
|
+
// Only the install reaches the registry, with scripts off.
|
|
2469
|
+
network: argv[1] === "ci" || argv[1] === "install",
|
|
2470
|
+
}).then((result) => {
|
|
2471
|
+
if (result.status === 0)
|
|
2472
|
+
return result.stdout;
|
|
2473
|
+
throw new Error(`${argv.join(" ")} exited ${result.status ?? "on a signal"} in the sandbox:\n${`${result.stdout.trim()}\n${result.stderr.trim()}`.trim().split("\n").slice(-40).join("\n")}`);
|
|
2474
|
+
});
|
|
1260
2475
|
steps.push({ command: argv.join(" "), ok: true, output });
|
|
2476
|
+
if (argv[1] === "run" && argv[2] === "build" && acceptedAt.length > 0)
|
|
2477
|
+
// Its own failure, never the build's.
|
|
2478
|
+
await accept().catch((error) => {
|
|
2479
|
+
steps.push({ command: "accept", ok: false, output: error instanceof Error ? error.message : String(error) });
|
|
2480
|
+
});
|
|
1261
2481
|
}
|
|
1262
2482
|
catch (error) {
|
|
1263
2483
|
const output = error instanceof Error ? error.message : String(error);
|
|
1264
|
-
const baseline = argv[1] === "test"
|
|
2484
|
+
const baseline = argv[1] === "test" && (options.accept ?? []).length === 0
|
|
2485
|
+
? await recordBaseline(source, work, record, trusted.receipt !== null && (trusted.receipt.baseline !== undefined || folderBaseline !== null))
|
|
2486
|
+
: null;
|
|
2487
|
+
if (baseline !== null && trusted.receipt !== null && trusted.receiptPath !== null) {
|
|
2488
|
+
writeReceipt(trusted.receiptPath, { ...trusted.receipt, baseline });
|
|
2489
|
+
receiptNow = readFileSync(trusted.receiptPath);
|
|
2490
|
+
recorded = baseline;
|
|
2491
|
+
}
|
|
1265
2492
|
steps.push(baseline === null
|
|
1266
2493
|
? { command: argv.join(" "), ok: false, output }
|
|
1267
2494
|
: {
|
|
@@ -1274,23 +2501,211 @@ export async function checkProject(dir, options = {}) {
|
|
|
1274
2501
|
break;
|
|
1275
2502
|
}
|
|
1276
2503
|
}
|
|
2504
|
+
// The folder's code ran as the caller: if it changed the receipt, the
|
|
2505
|
+
// receipt is put back and the check fails.
|
|
2506
|
+
if (trusted.receiptPath !== null && receiptNow !== null) {
|
|
2507
|
+
const after = existsSync(trusted.receiptPath) ? readFileSync(trusted.receiptPath) : null;
|
|
2508
|
+
if (after === null || !after.equals(receiptNow)) {
|
|
2509
|
+
await rm(trusted.receiptPath, { force: true });
|
|
2510
|
+
writeReceipt(trusted.receiptPath, JSON.parse(receiptNow.toString("utf8")));
|
|
2511
|
+
steps.push({
|
|
2512
|
+
command: "receipt",
|
|
2513
|
+
ok: false,
|
|
2514
|
+
output: `The folder's code changed the maintainer's receipt (${trusted.receiptPath}) while it ran; it was put back. Do not merge this folder: read its tests and scripts first.`,
|
|
2515
|
+
});
|
|
2516
|
+
}
|
|
2517
|
+
}
|
|
2518
|
+
// The run's counts, for the verdict: how many tests the folder runs and
|
|
2519
|
+
// how many pass before any edit.
|
|
2520
|
+
const tapPath = join(work, LAST_RUN_PATH);
|
|
2521
|
+
const tap = existsSync(tapPath) ? readFileSync(tapPath, "utf8") : "";
|
|
2522
|
+
// The runner's counts as it wrote them after every test ran; from a folder
|
|
2523
|
+
// cut before, the TAP's.
|
|
2524
|
+
const countsPath = join(work, COUNTS_PATH);
|
|
2525
|
+
const counts = existsSync(countsPath) ? (countsFromJson(readFileSync(countsPath, "utf8")) ?? countsOf(tap)) : countsOf(tap);
|
|
2526
|
+
const testStep = steps.findIndex((step) => step.command === "npm test");
|
|
2527
|
+
const edited = record.copied === undefined ? undefined : folderEdited(source, record);
|
|
2528
|
+
if (testStep !== -1) {
|
|
2529
|
+
const step = steps[testStep];
|
|
2530
|
+
steps[testStep] = {
|
|
2531
|
+
...step,
|
|
2532
|
+
...(edited === undefined ? {} : { edited }),
|
|
2533
|
+
...(counts === undefined ? {} : { counts }),
|
|
2534
|
+
...(baselineEdited === undefined ? {} : { baselineEdited }),
|
|
2535
|
+
baselineInEffect: recorded ?? inEffect,
|
|
2536
|
+
...(restored.length === 0 ? {} : { restored }),
|
|
2537
|
+
...(addedPackages.length === 0 ? {} : { addedPackages }),
|
|
2538
|
+
receiptRead: trusted.receipt !== null,
|
|
2539
|
+
};
|
|
2540
|
+
}
|
|
2541
|
+
else if (steps.length > 0 && restored.length > 0) {
|
|
2542
|
+
// The run stopped before the tests (a build the restored package.json
|
|
2543
|
+
// cannot satisfy): the step that failed says what was put back.
|
|
2544
|
+
const last = steps.length - 1;
|
|
2545
|
+
steps[last] = {
|
|
2546
|
+
...steps[last],
|
|
2547
|
+
restored,
|
|
2548
|
+
...(addedPackages.length === 0 ? {} : { addedPackages }),
|
|
2549
|
+
};
|
|
2550
|
+
}
|
|
1277
2551
|
if (scratch !== null)
|
|
1278
2552
|
await rm(scratch, { recursive: true, force: true });
|
|
1279
2553
|
return steps;
|
|
1280
2554
|
}
|
|
2555
|
+
/**
|
|
2556
|
+
* Runs a command in its own process group and gathers what it prints: at
|
|
2557
|
+
* the time limit the whole group is killed, workers and anything the
|
|
2558
|
+
* folder's code started included, not only the first process.
|
|
2559
|
+
*/
|
|
2560
|
+
function runGroup(executable, args, cwd, env, limitMs = 10 * 60_000) {
|
|
2561
|
+
return new Promise((resolvePromise) => {
|
|
2562
|
+
const group = process.platform !== "win32";
|
|
2563
|
+
const child = spawn(executable, [...args], { cwd, env, detached: group, stdio: ["ignore", "pipe", "pipe"] });
|
|
2564
|
+
let stdout = "";
|
|
2565
|
+
let stderr = "";
|
|
2566
|
+
child.stdout.setEncoding("utf8").on("data", (chunk) => (stdout += chunk));
|
|
2567
|
+
child.stderr.setEncoding("utf8").on("data", (chunk) => (stderr += chunk));
|
|
2568
|
+
const timer = setTimeout(() => {
|
|
2569
|
+
try {
|
|
2570
|
+
if (group && child.pid !== undefined)
|
|
2571
|
+
process.kill(-child.pid, "SIGKILL");
|
|
2572
|
+
else
|
|
2573
|
+
child.kill("SIGKILL");
|
|
2574
|
+
}
|
|
2575
|
+
catch {
|
|
2576
|
+
// Already gone.
|
|
2577
|
+
}
|
|
2578
|
+
}, limitMs);
|
|
2579
|
+
const done = (status) => {
|
|
2580
|
+
clearTimeout(timer);
|
|
2581
|
+
resolvePromise({ stdout, stderr, status });
|
|
2582
|
+
};
|
|
2583
|
+
child.on("error", (error) => {
|
|
2584
|
+
stderr += error.message;
|
|
2585
|
+
done(null);
|
|
2586
|
+
});
|
|
2587
|
+
child.on("close", (code) => done(code));
|
|
2588
|
+
});
|
|
2589
|
+
}
|
|
2590
|
+
/** The dependencies one package.json text pins that another does not. */
|
|
2591
|
+
function dependenciesAdded(folder, cut) {
|
|
2592
|
+
const entries = (text) => {
|
|
2593
|
+
try {
|
|
2594
|
+
const manifest = JSON.parse(text);
|
|
2595
|
+
return [...Object.entries(manifest["dependencies"] ?? {}), ...Object.entries(manifest["devDependencies"] ?? {})];
|
|
2596
|
+
}
|
|
2597
|
+
catch {
|
|
2598
|
+
return [];
|
|
2599
|
+
}
|
|
2600
|
+
};
|
|
2601
|
+
const known = new Set(entries(cut).map(([name]) => name));
|
|
2602
|
+
return entries(folder)
|
|
2603
|
+
.filter(([name]) => !known.has(name))
|
|
2604
|
+
.map(([name, version]) => ({ name, version: typeof version === "string" ? version : JSON.stringify(version) }))
|
|
2605
|
+
.sort((a, b) => compare(a.name, b.name));
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* A dependency as the owner reads it: quoted, so a name or pin carrying a
|
|
2609
|
+
* newline or an escape prints as text, and a pin the registry does not
|
|
2610
|
+
* resolve (a URL, a path, an alias) said to be one.
|
|
2611
|
+
*/
|
|
2612
|
+
function dependencyText(item) {
|
|
2613
|
+
return `${JSON.stringify(item.name)}: ${JSON.stringify(item.version)}${isRegistryVersion(item.version) ? "" : " (not a registry version: a URL, a path or an alias; read it first)"}`;
|
|
2614
|
+
}
|
|
2615
|
+
/** The dependencies one package.json text pins that another does not, as the owner reads them. */
|
|
2616
|
+
export function packagesAdded(folder, cut) {
|
|
2617
|
+
return dependenciesAdded(folder, cut).map(dependencyText);
|
|
2618
|
+
}
|
|
1281
2619
|
export { undoRewrites } from "./imports.js";
|
|
1282
|
-
|
|
2620
|
+
/**
|
|
2621
|
+
* A free path beside `path` for a contributor's new file that would land on
|
|
2622
|
+
* one the repository already holds (the owner's acceptance test, kept out of
|
|
2623
|
+
* the cut): `-contributed` before the test suffix, or before the extension
|
|
2624
|
+
* (`quote.test.ts` becomes `quote-contributed.test.ts`), numbered past any
|
|
2625
|
+
* name `taken` says is in use. Same folder, so its relative imports hold,
|
|
2626
|
+
* and the same suffix, so the test script still runs it.
|
|
2627
|
+
*/
|
|
2628
|
+
export function contributedPath(path, taken) {
|
|
2629
|
+
const slash = path.lastIndexOf("/");
|
|
2630
|
+
const dir = path.slice(0, slash + 1);
|
|
2631
|
+
const name = path.slice(slash + 1);
|
|
2632
|
+
const at = /\.(test|spec)\.[^/]+$/.exec(name)?.index ?? /\.[^./]+$/.exec(name)?.index ?? name.length;
|
|
2633
|
+
for (let n = 1;; n += 1) {
|
|
2634
|
+
const candidate = `${dir}${name.slice(0, at)}-contributed${n === 1 ? "" : `-${n}`}${name.slice(at)}`;
|
|
2635
|
+
if (!taken(candidate))
|
|
2636
|
+
return candidate;
|
|
2637
|
+
}
|
|
2638
|
+
}
|
|
2639
|
+
/**
|
|
2640
|
+
* A folder in pseudonyms, renamed back into the repository's names, path
|
|
2641
|
+
* and text, in a scratch copy with the receipt's record: what a patch is
|
|
2642
|
+
* made from, as from a folder cut in those names. Two files that would
|
|
2643
|
+
* land on one path are refused, never merged.
|
|
2644
|
+
*/
|
|
2645
|
+
async function unaliasFolder(root, table, record,
|
|
2646
|
+
/** A package the repository knows: a renamed specifier that names one goes back. */
|
|
2647
|
+
repositoryPackage = () => false) {
|
|
2648
|
+
const scratch = mkdtempSync(join(tmpdir(), "carveout-names-"));
|
|
2649
|
+
const folder = join(scratch, "folder");
|
|
2650
|
+
const from = new Map();
|
|
2651
|
+
// The folder's own mock packages go by the table's names; any other
|
|
2652
|
+
// package a file imports keeps the name it has.
|
|
2653
|
+
const own = new Set();
|
|
2654
|
+
const mocks = join(root, HARNESS_DIR, "packages");
|
|
2655
|
+
if (existsSync(mocks))
|
|
2656
|
+
for (const entry of readdirSync(mocks, { withFileTypes: true })) {
|
|
2657
|
+
if (!entry.isDirectory())
|
|
2658
|
+
continue;
|
|
2659
|
+
if (entry.name.startsWith("@"))
|
|
2660
|
+
for (const inner of readdirSync(join(mocks, entry.name), { withFileTypes: true }))
|
|
2661
|
+
if (inner.isDirectory())
|
|
2662
|
+
own.add(`${entry.name}/${inner.name}`);
|
|
2663
|
+
else
|
|
2664
|
+
own.add(entry.name);
|
|
2665
|
+
}
|
|
2666
|
+
for (const path of listTree(root, "", [], toolDirsIn(root, record))) {
|
|
2667
|
+
if (path === RECORD_PATH)
|
|
2668
|
+
continue;
|
|
2669
|
+
const bytes = readFileSync(join(root, path));
|
|
2670
|
+
const binary = isBinaryPath(path) || !isTextContent(bytes);
|
|
2671
|
+
const back = fromFolder(table, path, binary ? "" : bytes.toString("utf8"), (name) => own.has(name), repositoryPackage);
|
|
2672
|
+
const earlier = from.get(back.path);
|
|
2673
|
+
if (earlier !== undefined) {
|
|
2674
|
+
await rm(scratch, { recursive: true, force: true });
|
|
2675
|
+
throw new Error(`${earlier} and ${path} both map back to one file of the repository; rename one of them in the folder.`);
|
|
2676
|
+
}
|
|
2677
|
+
from.set(back.path, path);
|
|
2678
|
+
await mkdir(dirname(join(folder, back.path)), { recursive: true });
|
|
2679
|
+
await writeFile(join(folder, back.path), binary ? bytes : back.text);
|
|
2680
|
+
}
|
|
2681
|
+
await writeFile(join(folder, RECORD_PATH), `${JSON.stringify(record, null, 2)}\n`);
|
|
2682
|
+
return folder;
|
|
2683
|
+
}
|
|
2684
|
+
function listTree(root, dir = "", into = [], skip = new Set()) {
|
|
1283
2685
|
for (const entry of readdirSync(join(root, dir), { withFileTypes: true })) {
|
|
1284
2686
|
const path = dir === "" ? entry.name : `${dir}/${entry.name}`;
|
|
1285
2687
|
if (entry.isDirectory()) {
|
|
1286
|
-
if (entry.name !== "node_modules" && entry.name !== ".git")
|
|
1287
|
-
listTree(root, path, into);
|
|
2688
|
+
if (entry.name !== "node_modules" && entry.name !== ".git" && !(dir === "" && skip.has(entry.name)))
|
|
2689
|
+
listTree(root, path, into, skip);
|
|
1288
2690
|
}
|
|
1289
2691
|
else if (entry.isFile())
|
|
1290
2692
|
into.push(path);
|
|
1291
2693
|
}
|
|
1292
2694
|
return into;
|
|
1293
2695
|
}
|
|
2696
|
+
/**
|
|
2697
|
+
* The tool folders at a returned folder's root that hold none of the
|
|
2698
|
+
* slice's files: left out of what `patch` and `check` read, and named.
|
|
2699
|
+
*/
|
|
2700
|
+
function toolDirsIn(root, record) {
|
|
2701
|
+
const held = [
|
|
2702
|
+
...record.manifest.included.map((file) => file.path),
|
|
2703
|
+
...(record.copied ?? []).map((file) => file.path),
|
|
2704
|
+
...(record.extras ?? []).map((file) => file.path),
|
|
2705
|
+
...record.generated.map((file) => file.path),
|
|
2706
|
+
];
|
|
2707
|
+
return new Set(TOOL_DIRS.filter((dir) => existsSync(join(root, dir)) && statSync(join(root, dir)).isDirectory() && !held.some((path) => path.startsWith(`${dir}/`))));
|
|
2708
|
+
}
|
|
1294
2709
|
/** The folder files a cut writes beside its stubs that only the folder runs. */
|
|
1295
2710
|
const HARNESS_FILES = new Set([
|
|
1296
2711
|
"carveout/mock.js",
|
|
@@ -1305,7 +2720,9 @@ const CONFIG_FILES = new Set([
|
|
|
1305
2720
|
"carveout.env",
|
|
1306
2721
|
]);
|
|
1307
2722
|
/** What an edit to a generated file means for the patch, if anything. */
|
|
1308
|
-
function boundaryEdit(root, path, record, stubFiles
|
|
2723
|
+
function boundaryEdit(root, path, record, stubFiles,
|
|
2724
|
+
/** The package.json the cut wrote, from the receipt: what was there before the folder's edits. */
|
|
2725
|
+
cutPackageJson) {
|
|
1309
2726
|
const module = stubFiles.get(path);
|
|
1310
2727
|
if (module !== undefined)
|
|
1311
2728
|
return {
|
|
@@ -1314,29 +2731,18 @@ function boundaryEdit(root, path, record, stubFiles) {
|
|
|
1314
2731
|
detail: `${module} is cut: the edit to its stub stays in the folder, and the patch does not change ${module}.`,
|
|
1315
2732
|
};
|
|
1316
2733
|
if (path === "package.json") {
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
...record.dependencies.map((dependency) => dependency.name),
|
|
1323
|
-
]);
|
|
1324
|
-
names = [
|
|
1325
|
-
...Object.keys(manifest["dependencies"] ?? {}),
|
|
1326
|
-
...Object.keys(manifest["devDependencies"] ?? {}),
|
|
1327
|
-
]
|
|
1328
|
-
.filter((name) => !known.has(name))
|
|
1329
|
-
.sort(compare);
|
|
1330
|
-
}
|
|
1331
|
-
catch {
|
|
1332
|
-
// Not JSON any more: reported as edited.
|
|
1333
|
-
}
|
|
2734
|
+
// Against the cut's own package.json (its type and peer packages
|
|
2735
|
+
// included) when the receipt has it; else the record's dependencies.
|
|
2736
|
+
const cut = cutPackageJson ??
|
|
2737
|
+
JSON.stringify({ dependencies: Object.fromEntries(["typescript", ...record.dependencies.map((dependency) => dependency.name)].map((name) => [name, ""])) });
|
|
2738
|
+
const added = existsSync(join(root, path)) ? dependenciesAdded(readFileSync(join(root, path), "utf8"), cut) : [];
|
|
1334
2739
|
return {
|
|
1335
2740
|
path,
|
|
1336
2741
|
kind: "dependency",
|
|
1337
|
-
detail:
|
|
2742
|
+
detail: added.length === 0
|
|
1338
2743
|
? "package.json was edited; the repository's package files are not patched."
|
|
1339
|
-
: `adds ${
|
|
2744
|
+
: `adds ${added.map(dependencyText).join(", ")}, which the repository's package files do not get.`,
|
|
2745
|
+
...(added.length === 0 ? {} : { added }),
|
|
1340
2746
|
};
|
|
1341
2747
|
}
|
|
1342
2748
|
if (CONFIG_FILES.has(path))
|
|
@@ -1362,7 +2768,46 @@ function boundaryEdit(root, path, record, stubFiles) {
|
|
|
1362
2768
|
*/
|
|
1363
2769
|
export async function patchProject(dir, options = {}) {
|
|
1364
2770
|
const root = resolve(dir);
|
|
1365
|
-
|
|
2771
|
+
// The receipt's record when there is one: the folder's own came back
|
|
2772
|
+
// with the folder, and is checked against the repository instead.
|
|
2773
|
+
const trusted = options.receiptData !== undefined
|
|
2774
|
+
? trustedCutWith(root, options.receiptData)
|
|
2775
|
+
: trustedCut(root, options.receipt, options.noReceipt === true ? { noReceipt: true } : {});
|
|
2776
|
+
// A folder in pseudonyms is renamed back first; the patch is made from
|
|
2777
|
+
// that copy, in the repository's names.
|
|
2778
|
+
const table = trusted.receipt?.pseudonyms;
|
|
2779
|
+
if (table !== undefined && options.unaliased !== true) {
|
|
2780
|
+
const named = trusted.receipt?.record ?? trusted.record;
|
|
2781
|
+
// The packages the repository knows: its workspaces', and those the
|
|
2782
|
+
// cut found the slice depends on.
|
|
2783
|
+
let workspaceNames = [];
|
|
2784
|
+
try {
|
|
2785
|
+
workspaceNames = [...workspacePackages(trustedRepo({ ...named, repo: named.repo ?? trusted.receipt?.repo ?? null }, options.repo)).keys()];
|
|
2786
|
+
}
|
|
2787
|
+
catch {
|
|
2788
|
+
// No checkout to read: the record's own names alone.
|
|
2789
|
+
}
|
|
2790
|
+
const known = new Set([
|
|
2791
|
+
...workspaceNames,
|
|
2792
|
+
...named.dependencies.map((dependency) => dependency.name),
|
|
2793
|
+
...named.manifest.requiredBuildInputs.packages.map((item) => item.name),
|
|
2794
|
+
// And the names the slice's own files spell as specifiers at the
|
|
2795
|
+
// cut's commit, as the cut read them back.
|
|
2796
|
+
...sliceSpecifierPackages(named, trusted.receipt?.repo ?? named.repo ?? options.repo),
|
|
2797
|
+
]);
|
|
2798
|
+
const folder = await unaliasFolder(root, table, named, (name) => known.has(name));
|
|
2799
|
+
try {
|
|
2800
|
+
return await patchProject(folder, {
|
|
2801
|
+
...options,
|
|
2802
|
+
...(trusted.receiptPath === null ? {} : { receipt: trusted.receiptPath }),
|
|
2803
|
+
unaliased: true,
|
|
2804
|
+
});
|
|
2805
|
+
}
|
|
2806
|
+
finally {
|
|
2807
|
+
await rm(dirname(folder), { recursive: true, force: true });
|
|
2808
|
+
}
|
|
2809
|
+
}
|
|
2810
|
+
const record = trusted.record;
|
|
1366
2811
|
const repo = trustedRepo(record, options.repo);
|
|
1367
2812
|
checkRecordFiles(record, repo);
|
|
1368
2813
|
const rewritesOf = new Map();
|
|
@@ -1377,7 +2822,12 @@ export async function patchProject(dir, options = {}) {
|
|
|
1377
2822
|
// in the patch against the repository's copy.
|
|
1378
2823
|
for (const extra of record.extras ?? [])
|
|
1379
2824
|
included.set(extra.path, extra.sha256);
|
|
1380
|
-
|
|
2825
|
+
// The .gitignore the cut wrote (the receipt's), not the folder's own: a
|
|
2826
|
+
// file the folder's holder lists there would be left unjudged, and check
|
|
2827
|
+
// runs the cut's.
|
|
2828
|
+
const gitignoreText = trusted.receipt?.generatedText?.[".gitignore"] ??
|
|
2829
|
+
(existsSync(join(root, ".gitignore")) ? readFileSync(join(root, ".gitignore"), "utf8") : "");
|
|
2830
|
+
const ignored = new Set(gitignoreText.split("\n").filter(Boolean));
|
|
1381
2831
|
const stubModules = new Set([
|
|
1382
2832
|
...record.manifest.cuts.outbound.map((edge) => edge.to),
|
|
1383
2833
|
...record.dependencies
|
|
@@ -1393,7 +2843,8 @@ export async function patchProject(dir, options = {}) {
|
|
|
1393
2843
|
stubFiles.set(declarationPath(module), module);
|
|
1394
2844
|
stubFiles.set(runtimePath(module), module);
|
|
1395
2845
|
}
|
|
1396
|
-
const
|
|
2846
|
+
const tools = toolDirsIn(root, record);
|
|
2847
|
+
const tree = listTree(root, "", [], tools).sort(compare);
|
|
1397
2848
|
const present = new Set(tree);
|
|
1398
2849
|
const sources = new Set(tree.filter((path) => !generated.has(path) &&
|
|
1399
2850
|
!path.startsWith("carveout/") &&
|
|
@@ -1401,15 +2852,101 @@ export async function patchProject(dir, options = {}) {
|
|
|
1401
2852
|
const packageOf = packageFolder(repo);
|
|
1402
2853
|
// The packages the slice holds files of: where a new file may go.
|
|
1403
2854
|
const packages = new Set([...included.keys()].map((path) => packageOf(path)));
|
|
2855
|
+
// The packages the folder has, its own workspaces' and those it stubs
|
|
2856
|
+
// included: a bare import of any other reaches past it. And the modules
|
|
2857
|
+
// it holds or stubs, which an import may name by path.
|
|
2858
|
+
const cutModules = record.manifest.cuts.outbound.map((edge) => edge.to);
|
|
2859
|
+
const touched = new Set([...packages, ...cutModules.map((path) => packageOf(path))]);
|
|
2860
|
+
const knownPackages = new Set([
|
|
2861
|
+
...record.dependencies.filter((dependency) => dependency.kind === "package").map((dependency) => dependency.name),
|
|
2862
|
+
...record.manifest.requiredBuildInputs.packages.map((item) => item.name),
|
|
2863
|
+
...[...workspacePackages(repo)]
|
|
2864
|
+
.filter(([, directory]) => touched.has(packageOf(`${relative(repo, directory).split("\\").join("/")}/package.json`)))
|
|
2865
|
+
.map(([name]) => name),
|
|
2866
|
+
]);
|
|
2867
|
+
const bare = (path) => path.replace(/\.(d\.)?[cm]?[jt]sx?$/, "").replace(/\/index$/, "");
|
|
2868
|
+
const heldModules = new Set([...included.keys(), ...cutModules].map(bare));
|
|
2869
|
+
const holds = (path) => heldModules.has(bare(path));
|
|
2870
|
+
// A slice file only tests import is a test's helper: an edit to it is a
|
|
2871
|
+
// test edit, wherever it is kept and whatever it is named. Not an entry
|
|
2872
|
+
// file the cut was asked for by name, which is the task's code even when
|
|
2873
|
+
// only its own test imports it. A folder given as an entry names no file:
|
|
2874
|
+
// a file under it that only tests import is a helper like any other
|
|
2875
|
+
// (else every helper there escapes the rule). A file code imports has an
|
|
2876
|
+
// importer that is no test, so it is no helper either.
|
|
2877
|
+
// The setup files the package's Vitest tests run before them: held as
|
|
2878
|
+
// the runner's configuration is. A cut from before they were recorded
|
|
2879
|
+
// apart named them among its entry points, even among the entries asked
|
|
2880
|
+
// for: the package's Vitest config, read again, names them then.
|
|
2881
|
+
const configured = (() => {
|
|
2882
|
+
try {
|
|
2883
|
+
const dirs = [...new Set(record.request.entryPoints.map((entry) => packageOf(`${entry}/x`)))];
|
|
2884
|
+
return dirs.flatMap((dir) => vitestSettings(repo, dir)?.setupFiles ?? []);
|
|
2885
|
+
}
|
|
2886
|
+
catch {
|
|
2887
|
+
return [];
|
|
2888
|
+
}
|
|
2889
|
+
})();
|
|
2890
|
+
const setupFiles = new Set([
|
|
2891
|
+
...(record.manifest.params.setupFiles ??
|
|
2892
|
+
record.manifest.params.entryPoints.filter((entry) => !record.request.entryPoints.includes(entry) && !isTestPath(entry))),
|
|
2893
|
+
...configured.filter((path) => included.has(path)),
|
|
2894
|
+
]);
|
|
2895
|
+
const code = new Set(record.request.entryPoints.filter((entry) => included.has(entry) && !isTestPath(entry) && !setupFiles.has(entry)));
|
|
2896
|
+
const importersOf = new Map();
|
|
2897
|
+
for (const edge of [...record.manifest.internalImports, ...record.manifest.cuts.inbound])
|
|
2898
|
+
importersOf.set(edge.to, [...(importersOf.get(edge.to) ?? []), edge.from]);
|
|
2899
|
+
// A setup file runs before every test: what only it and tests import is
|
|
2900
|
+
// a test's helper too (a `vi.mock` in a module the setup file loads
|
|
2901
|
+
// replaces the code under test everywhere). A module only its own test
|
|
2902
|
+
// imports (`rounding.ts`, `rounding.test.ts`) is the code that test is
|
|
2903
|
+
// of, whatever folder was asked for.
|
|
2904
|
+
const stem = (path) => (path.split("/").pop() ?? path).replace(/\.(test|spec)(?=\.)/, "").replace(/\.[cm]?[jt]sx?$/, "");
|
|
2905
|
+
// The module's own test alone imports it: one importer, a test of the
|
|
2906
|
+
// same name beside it (or in the `__tests__` folder beside it). A helper
|
|
2907
|
+
// a test of its name shares with other tests stays a helper.
|
|
2908
|
+
const ownTestOnly = (path, importers) => {
|
|
2909
|
+
const [only] = importers;
|
|
2910
|
+
if (importers.length !== 1 || only === undefined || !isTestPath(only) || stem(only) !== stem(path))
|
|
2911
|
+
return false;
|
|
2912
|
+
const dir = posix.dirname(path);
|
|
2913
|
+
const testDir = posix.dirname(only);
|
|
2914
|
+
return testDir === dir || testDir === posix.join(dir, "__tests__");
|
|
2915
|
+
};
|
|
2916
|
+
const testOnly = new Set([...included.keys()].filter((path) => {
|
|
2917
|
+
const importers = importersOf.get(path) ?? [];
|
|
2918
|
+
return (!isTestPath(path) &&
|
|
2919
|
+
!code.has(path) &&
|
|
2920
|
+
!setupFiles.has(path) &&
|
|
2921
|
+
importers.length > 0 &&
|
|
2922
|
+
importers.every((from) => isTestPath(from) || setupFiles.has(from)) &&
|
|
2923
|
+
!ownTestOnly(path, importers));
|
|
2924
|
+
}));
|
|
1404
2925
|
const judge = handoffPolicy({
|
|
1405
2926
|
packages,
|
|
1406
2927
|
packageOf,
|
|
2928
|
+
knownPackages,
|
|
2929
|
+
holds,
|
|
2930
|
+
testSupport: (path) => testOnly.has(path),
|
|
2931
|
+
testSetup: (path) => setupFiles.has(path),
|
|
1407
2932
|
reach: true,
|
|
2933
|
+
integrity: true,
|
|
2934
|
+
checks: true,
|
|
1408
2935
|
...(options.allow === undefined ? {} : { allow: options.allow }),
|
|
1409
2936
|
});
|
|
1410
2937
|
const refused = [];
|
|
1411
2938
|
/** Whether the policy lets `path` into the patch; a refusal is named. */
|
|
2939
|
+
/** Bare packages each edited script imports, for a dependency it adds. */
|
|
2940
|
+
const importedBy = new Map();
|
|
1412
2941
|
const admits = (path, change, texts) => {
|
|
2942
|
+
if (texts !== undefined && isScript(path))
|
|
2943
|
+
for (const specifier of specifiersOf(texts.after, path))
|
|
2944
|
+
if (isPackageName(packageNameOf(specifier))) {
|
|
2945
|
+
const name = packageNameOf(specifier);
|
|
2946
|
+
const files = importedBy.get(name) ?? new Set();
|
|
2947
|
+
files.add(path);
|
|
2948
|
+
importedBy.set(name, files);
|
|
2949
|
+
}
|
|
1413
2950
|
const reason = judge(path, change, texts);
|
|
1414
2951
|
if (reason !== null)
|
|
1415
2952
|
refused.push({ path, reason });
|
|
@@ -1430,8 +2967,7 @@ export async function patchProject(dir, options = {}) {
|
|
|
1430
2967
|
extensionStyle: extensionStyles(edges, packageOf),
|
|
1431
2968
|
};
|
|
1432
2969
|
// What `npm run build` wrote for a source the folder's edits added.
|
|
1433
|
-
const emitted = (path) => /\.[cm]?js$/.test(path) &&
|
|
1434
|
-
["ts", "tsx", "mts", "cts"].some((ext) => present.has(path.replace(/\.[cm]?js$/, `.${ext}`)));
|
|
2970
|
+
const emitted = (path) => /\.[cm]?js$/.test(path) && tscOutput(path, present);
|
|
1435
2971
|
/** Imports a restored text still makes of the folder's harness. */
|
|
1436
2972
|
const harnessImport = (path, text) => [...specifiersOf(text, path)].some((specifier) => specifier.startsWith(".") &&
|
|
1437
2973
|
posix
|
|
@@ -1447,6 +2983,12 @@ export async function patchProject(dir, options = {}) {
|
|
|
1447
2983
|
const crossPackage = [];
|
|
1448
2984
|
const drifted = [];
|
|
1449
2985
|
const noThreeWay = [];
|
|
2986
|
+
const renamed = [];
|
|
2987
|
+
/** Whether the repository holds `path`, in the checkout or at the cut's commit. */
|
|
2988
|
+
const repositoryHolds = (path) => existsSync(join(repo, path)) ||
|
|
2989
|
+
(record.manifest.sourceCommitSha !== null && gitShow(repo, record.manifest.sourceCommitSha, path) !== null);
|
|
2990
|
+
// A tool's folder (npm's cache, a coverage report) is named, not listed.
|
|
2991
|
+
const skipped = [...tools].map((dir) => `${dir}/`);
|
|
1450
2992
|
/**
|
|
1451
2993
|
* The bytes the cut read for an included file, from the commit when the
|
|
1452
2994
|
* checkout still has them there, else the checkout's file if it has not
|
|
@@ -1455,9 +2997,9 @@ export async function patchProject(dir, options = {}) {
|
|
|
1455
2997
|
const readBase = (path, original) => {
|
|
1456
2998
|
const repoPath = join(repo, path);
|
|
1457
2999
|
const current = existsSync(repoPath) ? readFileSync(repoPath) : null;
|
|
1458
|
-
|
|
1459
|
-
|
|
1460
|
-
|
|
3000
|
+
// At the file's own base, where refresh moved it alone.
|
|
3001
|
+
const baseCommit = record.bases?.[path] ?? record.manifest.sourceCommitSha;
|
|
3002
|
+
const committed = baseCommit === null ? null : gitShow(repo, baseCommit, path);
|
|
1461
3003
|
const moved = current === null || sha256(current) !== original;
|
|
1462
3004
|
return {
|
|
1463
3005
|
moved,
|
|
@@ -1482,7 +3024,9 @@ export async function patchProject(dir, options = {}) {
|
|
|
1482
3024
|
for (const path of tree) {
|
|
1483
3025
|
if (path === RECORD_PATH ||
|
|
1484
3026
|
path === "package-lock.json" ||
|
|
1485
|
-
|
|
3027
|
+
// The folder's .gitignore is the contributor's to edit: it hides
|
|
3028
|
+
// build output, never a slice file's or a generated file's edit.
|
|
3029
|
+
(ignored.has(path) && !included.has(path) && !generated.has(path) && (emitted(path) || buildOutput(path, present) || (skipped.push(path), true))))
|
|
1486
3030
|
continue;
|
|
1487
3031
|
const bytes = await readFile(join(root, path));
|
|
1488
3032
|
const text = bytes.toString("utf8");
|
|
@@ -1490,7 +3034,7 @@ export async function patchProject(dir, options = {}) {
|
|
|
1490
3034
|
if (hash !== undefined) {
|
|
1491
3035
|
if (sha256(text) !== hash) {
|
|
1492
3036
|
generatedEdits.push(path);
|
|
1493
|
-
const edit = boundaryEdit(root, path, record, stubFiles);
|
|
3037
|
+
const edit = boundaryEdit(root, path, record, stubFiles, trusted.receipt?.generatedText?.["package.json"]);
|
|
1494
3038
|
if (edit !== null)
|
|
1495
3039
|
boundary.push(edit);
|
|
1496
3040
|
}
|
|
@@ -1498,7 +3042,12 @@ export async function patchProject(dir, options = {}) {
|
|
|
1498
3042
|
}
|
|
1499
3043
|
const original = included.get(path);
|
|
1500
3044
|
if (original === undefined) {
|
|
1501
|
-
if (path.startsWith("carveout/")
|
|
3045
|
+
if (path.startsWith("carveout/")) {
|
|
3046
|
+
if (!buildOutput(path, present) && !emitted(path))
|
|
3047
|
+
skipped.push(path);
|
|
3048
|
+
continue;
|
|
3049
|
+
}
|
|
3050
|
+
if (emitted(path))
|
|
1502
3051
|
continue;
|
|
1503
3052
|
let content = bytes;
|
|
1504
3053
|
if (isScript(path)) {
|
|
@@ -1515,9 +3064,25 @@ export async function patchProject(dir, options = {}) {
|
|
|
1515
3064
|
// the whole repository once the patch is applied.
|
|
1516
3065
|
if (!admits(path, "added", isBinaryPath(path) ? undefined : { before: null, after: content.toString("utf8") }))
|
|
1517
3066
|
continue;
|
|
1518
|
-
|
|
1519
|
-
|
|
1520
|
-
|
|
3067
|
+
// A new file where the repository already holds one the slice did
|
|
3068
|
+
// not (the owner's acceptance test, kept out of every cut): as a new
|
|
3069
|
+
// file, `git apply --3way` stops on it. A test goes in beside it under
|
|
3070
|
+
// a free name; any other file is held for the owner to place.
|
|
3071
|
+
let target = path;
|
|
3072
|
+
if (repositoryHolds(path)) {
|
|
3073
|
+
if (!isTestPath(path)) {
|
|
3074
|
+
refused.push({
|
|
3075
|
+
path,
|
|
3076
|
+
reason: "the repository already holds a file at this path that the slice did not (one of the owner's, kept out of the cut); the folder's new file would overwrite it: rename it, or read both and pass --allow",
|
|
3077
|
+
});
|
|
3078
|
+
continue;
|
|
3079
|
+
}
|
|
3080
|
+
target = contributedPath(path, (candidate) => present.has(candidate) || added.includes(candidate) || repositoryHolds(candidate));
|
|
3081
|
+
renamed.push({ from: path, to: target });
|
|
3082
|
+
}
|
|
3083
|
+
added.push(target);
|
|
3084
|
+
await mkdir(dirname(join(scratch, "b", target)), { recursive: true });
|
|
3085
|
+
await writeFile(join(scratch, "b", target), content);
|
|
1521
3086
|
continue;
|
|
1522
3087
|
}
|
|
1523
3088
|
const rewrites = rewritesOf.get(path) ?? [];
|
|
@@ -1563,6 +3128,22 @@ export async function patchProject(dir, options = {}) {
|
|
|
1563
3128
|
await mkdir(dirname(join(scratch, "a", path)), { recursive: true });
|
|
1564
3129
|
await writeFile(join(scratch, "a", path), base ?? "");
|
|
1565
3130
|
}
|
|
3131
|
+
// A package the folder added goes into the repository's package file by
|
|
3132
|
+
// hand: name the file, from where the edits import it, so the patch
|
|
3133
|
+
// applies onto a repository that installs it.
|
|
3134
|
+
for (const [index, edit] of boundary.entries()) {
|
|
3135
|
+
if (edit.added === undefined)
|
|
3136
|
+
continue;
|
|
3137
|
+
const where = edit.added.map((item) => {
|
|
3138
|
+
const dirs = [...new Set([...(importedBy.get(item.name) ?? [])].map((path) => packageOf(path)))].sort(compare);
|
|
3139
|
+
const files = dirs.map((dir) => (dir === "" ? "package.json" : `${dir}/package.json`));
|
|
3140
|
+
return `${dependencyText(item)} ${files.length === 0 ? "to the package that needs it" : `to ${files.join(" and ")}`}`;
|
|
3141
|
+
});
|
|
3142
|
+
boundary[index] = {
|
|
3143
|
+
...edit,
|
|
3144
|
+
detail: `${edit.detail} Add ${where.join("; ")} before applying, then pass --allow for the files that import it.`,
|
|
3145
|
+
};
|
|
3146
|
+
}
|
|
1566
3147
|
let diff = "";
|
|
1567
3148
|
if (changed.length + added.length + deleted.length > 0) {
|
|
1568
3149
|
// A scratch repository holding the bases, with the edits staged on
|
|
@@ -1583,6 +3164,18 @@ export async function patchProject(dir, options = {}) {
|
|
|
1583
3164
|
}).toString("utf8");
|
|
1584
3165
|
await mkdir(repoDir, { recursive: true });
|
|
1585
3166
|
run(["init", "-q"]);
|
|
3167
|
+
// A file that is not UTF-8 goes as a binary patch: as text, the diff
|
|
3168
|
+
// would carry its bytes through a string and lose them.
|
|
3169
|
+
const opaque = [...changed, ...added, ...deleted].filter((path) => {
|
|
3170
|
+
for (const side of ["a", "b"]) {
|
|
3171
|
+
const at = join(scratch, side, path);
|
|
3172
|
+
if (existsSync(at) && !isTextContent(readFileSync(at)))
|
|
3173
|
+
return true;
|
|
3174
|
+
}
|
|
3175
|
+
return false;
|
|
3176
|
+
});
|
|
3177
|
+
if (opaque.length > 0)
|
|
3178
|
+
await writeFile(join(repoDir, ".git", "info", "attributes"), `${opaque.map((path) => `/${path.replace(/ /g, "[[:space:]]")} binary`).join("\n")}\n`);
|
|
1586
3179
|
for (const path of [...changed, ...deleted]) {
|
|
1587
3180
|
await mkdir(dirname(join(repoDir, path)), { recursive: true });
|
|
1588
3181
|
await writeFile(join(repoDir, path), readFileSync(join(scratch, "a", path)));
|
|
@@ -1620,6 +3213,9 @@ export async function patchProject(dir, options = {}) {
|
|
|
1620
3213
|
drifted,
|
|
1621
3214
|
noThreeWay,
|
|
1622
3215
|
refused,
|
|
3216
|
+
recordFrom: trusted.receipt === null ? "folder" : "receipt",
|
|
3217
|
+
skipped,
|
|
3218
|
+
...(renamed.length === 0 ? {} : { renamed }),
|
|
1623
3219
|
};
|
|
1624
3220
|
}
|
|
1625
3221
|
finally {
|