@valbuild/cli 0.97.3 → 0.97.4
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/cli/dist/valbuild-cli-cli.cjs.dev.js +1471 -118
- package/cli/dist/valbuild-cli-cli.cjs.prod.js +1471 -118
- package/cli/dist/valbuild-cli-cli.esm.js +1469 -118
- package/package.json +6 -4
- package/src/__fixtures__/basic/val.config.ts +2 -2
- package/src/__fixtures__/basic/val.modules.ts +16 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/11111111-1111-4111-8111-111111111111/patch.json +19 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/22222222-2222-4222-8222-222222222222/patch.json +20 -0
- package/src/__fixtures__/debug-snapshot/.val/patches/head/patch.json +20 -0
- package/src/__fixtures__/debug-snapshot/content/projects.val.ts +23 -0
- package/src/__fixtures__/debug-snapshot/content/summary.ts +6 -0
- package/src/__fixtures__/debug-snapshot/content/tags.val.ts +10 -0
- package/src/__fixtures__/debug-snapshot/content/unrelated.val.ts +5 -0
- package/src/__fixtures__/debug-snapshot/tsconfig.json +12 -0
- package/src/__fixtures__/debug-snapshot/val.config.ts +5 -0
- package/src/__fixtures__/debug-snapshot/val.modules.ts +8 -0
- package/src/cli.ts +89 -2
- package/src/debug/context.ts +173 -0
- package/src/debug/importGraph.ts +126 -0
- package/src/debug/moduleClosure.ts +167 -0
- package/src/debug/report.ts +80 -0
- package/src/debug/snapshot.ts +497 -0
- package/src/debug/snapshotRoundTrip.test.ts +95 -0
- package/src/debug.test.ts +107 -0
- package/src/debug.ts +120 -0
- package/src/deleteUnappliablePatches.ts +139 -0
- package/src/listUnusedFiles.ts +16 -4
- package/src/runValidation.test.ts +6 -6
- package/src/runValidation.ts +40 -15
- package/src/utils/evalValConfigFile.ts +13 -5
- package/src/utils/sourcePathToFileLocation.ts +184 -0
- package/src/validate.ts +415 -154
|
@@ -0,0 +1,497 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import { ModuleFilePath, PatchId } from "@valbuild/core";
|
|
4
|
+
import {
|
|
5
|
+
formatPatchSourceError,
|
|
6
|
+
OrderedPatches,
|
|
7
|
+
PatchAnalysis,
|
|
8
|
+
PreparedCommit,
|
|
9
|
+
} from "@valbuild/server";
|
|
10
|
+
import { DebugContext } from "./context";
|
|
11
|
+
import { InclusionReason, resolveModuleClosure } from "./moduleClosure";
|
|
12
|
+
import { collectImportedProjectFiles } from "./importGraph";
|
|
13
|
+
import { getVersions } from "../getVersions";
|
|
14
|
+
|
|
15
|
+
/** Long strings (base64 payloads) are elided so a snapshot stays attachable. */
|
|
16
|
+
const MAX_PATCH_STRING_LENGTH = 4096;
|
|
17
|
+
|
|
18
|
+
export type SnapshotPatch = {
|
|
19
|
+
patchId: PatchId;
|
|
20
|
+
path: ModuleFilePath;
|
|
21
|
+
createdAt: string;
|
|
22
|
+
authorId: string | null;
|
|
23
|
+
baseSha: string;
|
|
24
|
+
appliedAt: { commitSha: string } | null;
|
|
25
|
+
/** Parent in the chain: null means it is the first ("head"). */
|
|
26
|
+
parentPatchId: PatchId | null;
|
|
27
|
+
patch: unknown;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
export type SnapshotManifest = {
|
|
31
|
+
generatedAt: string;
|
|
32
|
+
mode: "fs" | "http";
|
|
33
|
+
project: string | null;
|
|
34
|
+
branch: string | null;
|
|
35
|
+
/** The commit the module sources were read at. */
|
|
36
|
+
commit: string | null;
|
|
37
|
+
baseSha: string;
|
|
38
|
+
filesDirectory: string;
|
|
39
|
+
authKind: "pat" | "api-key" | "none";
|
|
40
|
+
versions: {
|
|
41
|
+
/** Resolved at runtime by the cli that captured this. */
|
|
42
|
+
core?: string;
|
|
43
|
+
next?: string;
|
|
44
|
+
/**
|
|
45
|
+
* The @valbuild/* versions the project declares. This is what to check out
|
|
46
|
+
* in the val repo in order to replay on the code the customer was running.
|
|
47
|
+
*/
|
|
48
|
+
project: Record<string, string>;
|
|
49
|
+
node: string;
|
|
50
|
+
platform: string;
|
|
51
|
+
};
|
|
52
|
+
modules: {
|
|
53
|
+
moduleFilePath: ModuleFilePath;
|
|
54
|
+
/** Why the snapshot includes it. */
|
|
55
|
+
reasons: InclusionReason[];
|
|
56
|
+
/** Whether the text came from the ops (authoritative) or the local disk. */
|
|
57
|
+
source: "ops" | "local" | "missing";
|
|
58
|
+
}[];
|
|
59
|
+
patchCount: number;
|
|
60
|
+
unappliablePatchCount: number;
|
|
61
|
+
/**
|
|
62
|
+
* The content api does not return parentRef, so the on-disk chain was rebuilt
|
|
63
|
+
* from the order the api returned. A replay therefore reproduces the server's
|
|
64
|
+
* ordering, which is the thing we are usually chasing.
|
|
65
|
+
*/
|
|
66
|
+
patchChainSynthesised: boolean;
|
|
67
|
+
/** Import specifiers that could not be resolved to a project file. */
|
|
68
|
+
unresolvedImports: { from: string; specifier: string }[];
|
|
69
|
+
elidedPatchValues: { patchId: PatchId; path: string[] }[];
|
|
70
|
+
includesBinaryFiles: boolean;
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
export type SnapshotReport = {
|
|
74
|
+
unappliablePatches: PreparedCommit["unappliablePatches"];
|
|
75
|
+
appliedPatches: PreparedCommit["appliedPatches"];
|
|
76
|
+
triedPatches: PreparedCommit["triedPatches"];
|
|
77
|
+
skippedPatches: PreparedCommit["skippedPatches"];
|
|
78
|
+
sourceFilePatchErrors: Record<ModuleFilePath, string[]>;
|
|
79
|
+
binaryFilePatchErrors: PreparedCommit["binaryFilePatchErrors"];
|
|
80
|
+
hasErrors: boolean;
|
|
81
|
+
validationErrors: Record<string, unknown>;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type SnapshotResult = {
|
|
85
|
+
manifest: SnapshotManifest;
|
|
86
|
+
report: SnapshotReport;
|
|
87
|
+
/** Snapshot-relative path -> contents. Everything that goes in the zip. */
|
|
88
|
+
entries: Record<string, string>;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
export async function buildSnapshot(
|
|
92
|
+
ctx: DebugContext,
|
|
93
|
+
options: { includeFiles?: boolean } = {},
|
|
94
|
+
): Promise<SnapshotResult> {
|
|
95
|
+
const { serverOps } = ctx;
|
|
96
|
+
const patchesRes = await serverOps.fetchPatches({
|
|
97
|
+
patchIds: undefined,
|
|
98
|
+
excludePatchOps: false,
|
|
99
|
+
});
|
|
100
|
+
if (patchesRes.error) {
|
|
101
|
+
throw new Error(`Could not fetch patches: ${patchesRes.error.message}`);
|
|
102
|
+
}
|
|
103
|
+
const orderedPatches = patchesRes.patches;
|
|
104
|
+
const analysis: PatchAnalysis & OrderedPatches = {
|
|
105
|
+
...serverOps.analyzePatches(orderedPatches),
|
|
106
|
+
...patchesRes,
|
|
107
|
+
};
|
|
108
|
+
const prepared = await serverOps.prepare(analysis, {
|
|
109
|
+
continueOnError: true,
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
const serializedSchemas = await serverOps.getSerializedSchemas();
|
|
113
|
+
const patchedModules = Object.keys(analysis.patchesByModule).map(
|
|
114
|
+
(moduleFilePathS) => moduleFilePathS as ModuleFilePath,
|
|
115
|
+
);
|
|
116
|
+
const closure = resolveModuleClosure(patchedModules, serializedSchemas);
|
|
117
|
+
|
|
118
|
+
// Read every included module at the revision the ops point at. prepare()
|
|
119
|
+
// already read the patched ones, so reuse those rather than fetching twice.
|
|
120
|
+
const moduleTexts: Record<string, string> = {};
|
|
121
|
+
const moduleProvenance: Record<ModuleFilePath, "ops" | "local" | "missing"> =
|
|
122
|
+
{};
|
|
123
|
+
for (const moduleFilePath of closure.keys()) {
|
|
124
|
+
const fromPrepare = prepared.previousSourceFiles[moduleFilePath];
|
|
125
|
+
if (fromPrepare !== undefined) {
|
|
126
|
+
moduleTexts[moduleFilePath] = fromPrepare;
|
|
127
|
+
moduleProvenance[moduleFilePath] = "ops";
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
const res = await serverOps.readProjectFile(moduleFilePath);
|
|
131
|
+
if (res.error) {
|
|
132
|
+
const local = readLocalFile(ctx.projectRoot, moduleFilePath);
|
|
133
|
+
if (local !== null) {
|
|
134
|
+
moduleTexts[moduleFilePath] = local;
|
|
135
|
+
moduleProvenance[moduleFilePath] = "local";
|
|
136
|
+
} else {
|
|
137
|
+
moduleProvenance[moduleFilePath] = "missing";
|
|
138
|
+
console.warn(
|
|
139
|
+
`Could not read module ${moduleFilePath}: ${res.error.message}`,
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
moduleTexts[moduleFilePath] = res.data;
|
|
145
|
+
moduleProvenance[moduleFilePath] = "ops";
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
// Files the modules import, at the same revision, falling back to local disk.
|
|
149
|
+
const readProjectFileOrLocal = async (
|
|
150
|
+
projectRelativePath: string,
|
|
151
|
+
): Promise<string | null> => {
|
|
152
|
+
const res = await serverOps.readProjectFile(projectRelativePath);
|
|
153
|
+
if (!res.error) {
|
|
154
|
+
return res.data;
|
|
155
|
+
}
|
|
156
|
+
return readLocalFile(ctx.projectRoot, projectRelativePath);
|
|
157
|
+
};
|
|
158
|
+
const imported = await collectImportedProjectFiles(
|
|
159
|
+
Object.entries(moduleTexts).map(([p, contents]) => ({ path: p, contents })),
|
|
160
|
+
readProjectFileOrLocal,
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
const entries: Record<string, string> = {};
|
|
164
|
+
for (const [projectPath, contents] of Object.entries(moduleTexts)) {
|
|
165
|
+
entries[toSnapshotPath(projectPath)] = contents;
|
|
166
|
+
}
|
|
167
|
+
for (const [projectPath, contents] of Object.entries(imported.files)) {
|
|
168
|
+
entries[toSnapshotPath(projectPath)] = contents;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// getCompilerOptions() throws without one of these at the root, so a snapshot
|
|
172
|
+
// without it cannot be loaded at all.
|
|
173
|
+
const tsConfig =
|
|
174
|
+
readLocalFile(ctx.projectRoot, "/tsconfig.json") ??
|
|
175
|
+
readLocalFile(ctx.projectRoot, "/jsconfig.json");
|
|
176
|
+
if (tsConfig === null) {
|
|
177
|
+
throw new Error(
|
|
178
|
+
`Could not read tsconfig.json nor jsconfig.json in ${ctx.projectRoot}. ` +
|
|
179
|
+
`A snapshot cannot be replayed without one.`,
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
entries["tsconfig.json"] = tsConfig;
|
|
183
|
+
|
|
184
|
+
const originalValModules =
|
|
185
|
+
readLocalFile(ctx.projectRoot, "/val.modules.ts") ??
|
|
186
|
+
readLocalFile(ctx.projectRoot, "/val.modules.js");
|
|
187
|
+
if (originalValModules !== null) {
|
|
188
|
+
entries["val.modules.original.ts"] = originalValModules;
|
|
189
|
+
}
|
|
190
|
+
entries["val.modules.ts"] = generateValModules(
|
|
191
|
+
Object.keys(moduleTexts).sort(),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
const patches = toSnapshotPatches(orderedPatches);
|
|
195
|
+
const elidedPatchValues: { patchId: PatchId; path: string[] }[] = [];
|
|
196
|
+
for (const patch of patches) {
|
|
197
|
+
const elided = elideLongStrings(patch.patch);
|
|
198
|
+
patch.patch = elided.value;
|
|
199
|
+
for (const p of elided.elided) {
|
|
200
|
+
elidedPatchValues.push({ patchId: patch.patchId, path: p });
|
|
201
|
+
}
|
|
202
|
+
entries[`.val/patches/${patch.parentPatchId ?? "head"}/patch.json`] =
|
|
203
|
+
JSON.stringify(toFsPatch(patch), null, 2);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
let includesBinaryFiles = false;
|
|
207
|
+
if (options.includeFiles) {
|
|
208
|
+
includesBinaryFiles = await writeBinaryFiles(
|
|
209
|
+
ctx,
|
|
210
|
+
analysis.fileLastUpdatedByPatchId,
|
|
211
|
+
patches,
|
|
212
|
+
entries,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const validation = await validateSnapshotSources(ctx, analysis);
|
|
217
|
+
|
|
218
|
+
const report: SnapshotReport = {
|
|
219
|
+
unappliablePatches: prepared.unappliablePatches,
|
|
220
|
+
appliedPatches: prepared.appliedPatches,
|
|
221
|
+
triedPatches: prepared.triedPatches,
|
|
222
|
+
skippedPatches: prepared.skippedPatches,
|
|
223
|
+
sourceFilePatchErrors: Object.fromEntries(
|
|
224
|
+
Object.entries(prepared.sourceFilePatchErrors).map(([key, errors]) => [
|
|
225
|
+
key,
|
|
226
|
+
errors.map(formatPatchSourceError),
|
|
227
|
+
]),
|
|
228
|
+
),
|
|
229
|
+
binaryFilePatchErrors: prepared.binaryFilePatchErrors,
|
|
230
|
+
hasErrors: prepared.hasErrors,
|
|
231
|
+
validationErrors: validation,
|
|
232
|
+
};
|
|
233
|
+
|
|
234
|
+
const manifest: SnapshotManifest = {
|
|
235
|
+
generatedAt: new Date().toISOString(),
|
|
236
|
+
mode: ctx.mode,
|
|
237
|
+
project: ctx.project,
|
|
238
|
+
branch: ctx.branch,
|
|
239
|
+
commit: ctx.commit,
|
|
240
|
+
baseSha: await serverOps.getBaseSha(),
|
|
241
|
+
filesDirectory: ctx.filesDirectory,
|
|
242
|
+
authKind: ctx.authKind,
|
|
243
|
+
versions: {
|
|
244
|
+
core: getVersions().coreVersion,
|
|
245
|
+
next: getVersions().nextVersion,
|
|
246
|
+
project: readProjectValVersions(ctx.projectRoot),
|
|
247
|
+
node: process.version,
|
|
248
|
+
platform: `${process.platform}-${process.arch}`,
|
|
249
|
+
},
|
|
250
|
+
modules: Array.from(closure.entries()).map(([moduleFilePath, reasons]) => ({
|
|
251
|
+
moduleFilePath,
|
|
252
|
+
reasons,
|
|
253
|
+
source: moduleProvenance[moduleFilePath] ?? "missing",
|
|
254
|
+
})),
|
|
255
|
+
patchCount: patches.length,
|
|
256
|
+
unappliablePatchCount: Object.keys(prepared.unappliablePatches).length,
|
|
257
|
+
patchChainSynthesised: ctx.mode === "http",
|
|
258
|
+
unresolvedImports: imported.unresolved,
|
|
259
|
+
elidedPatchValues,
|
|
260
|
+
includesBinaryFiles,
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
entries["manifest.json"] = JSON.stringify(manifest, null, 2);
|
|
264
|
+
entries["report.json"] = JSON.stringify(report, null, 2);
|
|
265
|
+
entries["README.md"] = renderReadme(manifest);
|
|
266
|
+
|
|
267
|
+
return { manifest, report, entries };
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Validation errors as the studio would compute them: patches applied to the
|
|
272
|
+
* evaluated json, then the schemas run over the result.
|
|
273
|
+
*/
|
|
274
|
+
async function validateSnapshotSources(
|
|
275
|
+
ctx: DebugContext,
|
|
276
|
+
analysis: PatchAnalysis & OrderedPatches,
|
|
277
|
+
): Promise<Record<string, unknown>> {
|
|
278
|
+
const { serverOps } = ctx;
|
|
279
|
+
const schemas = await serverOps.getSchemas();
|
|
280
|
+
const validationRes = await serverOps.validateSources(
|
|
281
|
+
schemas,
|
|
282
|
+
(await serverOps.getSourcesWithPatchesApplied(analysis)).sources,
|
|
283
|
+
analysis.patchesByModule,
|
|
284
|
+
);
|
|
285
|
+
return validationRes.errors;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
function toSnapshotPatches(
|
|
289
|
+
orderedPatches: {
|
|
290
|
+
path: ModuleFilePath;
|
|
291
|
+
patchId: PatchId;
|
|
292
|
+
patch: unknown;
|
|
293
|
+
createdAt: string;
|
|
294
|
+
authorId: string | null;
|
|
295
|
+
baseSha: string;
|
|
296
|
+
appliedAt: { commitSha: string } | null;
|
|
297
|
+
}[],
|
|
298
|
+
): SnapshotPatch[] {
|
|
299
|
+
return orderedPatches.map((patch, i) => ({
|
|
300
|
+
patchId: patch.patchId,
|
|
301
|
+
path: patch.path,
|
|
302
|
+
createdAt: patch.createdAt,
|
|
303
|
+
authorId: patch.authorId,
|
|
304
|
+
baseSha: patch.baseSha,
|
|
305
|
+
appliedAt: patch.appliedAt,
|
|
306
|
+
parentPatchId: i === 0 ? null : orderedPatches[i - 1].patchId,
|
|
307
|
+
patch: patch.patch,
|
|
308
|
+
}));
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The shape ValOpsFS writes per patch, so an unzipped snapshot is a patch store
|
|
313
|
+
* a plain ValOpsFS can read. The directory is named after the PARENT, and
|
|
314
|
+
* createPatchChain walks the linked list from "head".
|
|
315
|
+
*/
|
|
316
|
+
function toFsPatch(patch: SnapshotPatch) {
|
|
317
|
+
return {
|
|
318
|
+
patch: patch.patch,
|
|
319
|
+
patchId: patch.patchId,
|
|
320
|
+
parentRef:
|
|
321
|
+
patch.parentPatchId === null
|
|
322
|
+
? { type: "head", headBaseSha: patch.baseSha }
|
|
323
|
+
: { type: "patch", patchId: patch.parentPatchId },
|
|
324
|
+
path: patch.path,
|
|
325
|
+
authorId: patch.authorId,
|
|
326
|
+
sessionId: null,
|
|
327
|
+
baseSha: patch.baseSha,
|
|
328
|
+
coreVersion: null,
|
|
329
|
+
createdAt: patch.createdAt,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
async function writeBinaryFiles(
|
|
334
|
+
ctx: DebugContext,
|
|
335
|
+
fileLastUpdatedByPatchId: Record<
|
|
336
|
+
string,
|
|
337
|
+
{ patchId: PatchId; remote: boolean; isDelete: boolean }
|
|
338
|
+
>,
|
|
339
|
+
patches: SnapshotPatch[],
|
|
340
|
+
entries: Record<string, string>,
|
|
341
|
+
): Promise<boolean> {
|
|
342
|
+
const parentByPatchId = new Map(
|
|
343
|
+
patches.map((p) => [p.patchId, p.parentPatchId ?? "head"]),
|
|
344
|
+
);
|
|
345
|
+
let wrote = false;
|
|
346
|
+
for (const [filePath, data] of Object.entries(fileLastUpdatedByPatchId)) {
|
|
347
|
+
if (data.isDelete) {
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const parentPatchId = parentByPatchId.get(data.patchId);
|
|
351
|
+
if (parentPatchId === undefined) {
|
|
352
|
+
continue;
|
|
353
|
+
}
|
|
354
|
+
const buffer = await ctx.serverOps.getBase64EncodedBinaryFileFromPatch(
|
|
355
|
+
filePath,
|
|
356
|
+
data.patchId,
|
|
357
|
+
data.remote,
|
|
358
|
+
);
|
|
359
|
+
if (!buffer) {
|
|
360
|
+
continue;
|
|
361
|
+
}
|
|
362
|
+
// Base64 so the snapshot stays a text-only entry map; the replay decodes it.
|
|
363
|
+
entries[
|
|
364
|
+
`.val/patches/${parentPatchId}/files${filePath}/${path.posix.basename(filePath)}.base64`
|
|
365
|
+
] = buffer.toString("base64");
|
|
366
|
+
wrote = true;
|
|
367
|
+
}
|
|
368
|
+
return wrote;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function generateValModules(moduleFilePaths: string[]): string {
|
|
372
|
+
const imports = moduleFilePaths
|
|
373
|
+
.map((moduleFilePath) => {
|
|
374
|
+
const withoutExt = moduleFilePath.replace(/\.(ts|js|tsx|jsx)$/, "");
|
|
375
|
+
return ` { def: () => import(".${withoutExt}") },`;
|
|
376
|
+
})
|
|
377
|
+
.join("\n");
|
|
378
|
+
return `// GENERATED by \`val debug\`: trimmed to the modules this snapshot carries.
|
|
379
|
+
// The project's original is kept as val.modules.original.ts.
|
|
380
|
+
import { modules } from "@valbuild/next";
|
|
381
|
+
import { config } from "./val.config";
|
|
382
|
+
|
|
383
|
+
export default modules(config, [
|
|
384
|
+
${imports}
|
|
385
|
+
]);
|
|
386
|
+
`;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
function renderReadme(manifest: SnapshotManifest): string {
|
|
390
|
+
return `# Val debug snapshot
|
|
391
|
+
|
|
392
|
+
Captured ${manifest.generatedAt} from project \`${manifest.project ?? "(fs mode)"}\`,
|
|
393
|
+
branch \`${manifest.branch ?? "?"}\`, commit \`${manifest.commit ?? "?"}\`.
|
|
394
|
+
|
|
395
|
+
- @valbuild/core: \`${manifest.versions.core ?? "?"}\`
|
|
396
|
+
- @valbuild/next: \`${manifest.versions.next ?? "?"}\`
|
|
397
|
+
- ${manifest.patchCount} pending patches, ${manifest.unappliablePatchCount} of which could not be applied.
|
|
398
|
+
|
|
399
|
+
## Replaying it
|
|
400
|
+
|
|
401
|
+
This directory is a minimal Val project: the modules the patches touch (plus the
|
|
402
|
+
ones they reference), a generated \`val.modules.ts\`, and the patch chain under
|
|
403
|
+
\`.val/patches\`. Unzip it into \`debug/\` in the val repo, check out the version
|
|
404
|
+
above, and run:
|
|
405
|
+
|
|
406
|
+
\`\`\`bash
|
|
407
|
+
pnpm debug:replay debug/<this-directory>
|
|
408
|
+
\`\`\`
|
|
409
|
+
|
|
410
|
+
That applies the patches the same way \`/save\` does and validates the result, then
|
|
411
|
+
diffs what it finds against \`report.json\` (captured at the time of the bug).
|
|
412
|
+
|
|
413
|
+
## Notes
|
|
414
|
+
|
|
415
|
+
${manifest.patchChainSynthesised ? "- The content api does not return `parentRef`, so the patch chain was rebuilt from the order the api returned.\n" : ""}${manifest.unresolvedImports.length > 0 ? `- ${manifest.unresolvedImports.length} import specifier(s) could not be resolved to a project file - see manifest.json. Bare package imports are expected; a tsconfig path alias means the snapshot may not evaluate.\n` : ""}${manifest.elidedPatchValues.length > 0 ? `- ${manifest.elidedPatchValues.length} long patch value(s) were elided - see manifest.json.\n` : ""}${manifest.includesBinaryFiles ? "- Binary files are included, base64 encoded with a `.base64` suffix.\n" : "- Binary files are NOT included (source patching does not need them). Re-run with `--include-files` if you need them.\n"}
|
|
416
|
+
This snapshot contains unpublished content. Treat it as customer data.
|
|
417
|
+
`;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
function toSnapshotPath(projectRelativePath: string): string {
|
|
421
|
+
return projectRelativePath.replace(/^\//, "");
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/** The @valbuild/* versions the project depends on, so we know what to check out. */
|
|
425
|
+
function readProjectValVersions(projectRoot: string): Record<string, string> {
|
|
426
|
+
const contents = readLocalFile(projectRoot, "/package.json");
|
|
427
|
+
if (contents === null) {
|
|
428
|
+
return {};
|
|
429
|
+
}
|
|
430
|
+
let parsed: unknown;
|
|
431
|
+
try {
|
|
432
|
+
parsed = JSON.parse(contents);
|
|
433
|
+
} catch {
|
|
434
|
+
return {};
|
|
435
|
+
}
|
|
436
|
+
if (parsed === null || typeof parsed !== "object") {
|
|
437
|
+
return {};
|
|
438
|
+
}
|
|
439
|
+
const versions: Record<string, string> = {};
|
|
440
|
+
for (const field of ["dependencies", "devDependencies"]) {
|
|
441
|
+
const deps = (parsed as Record<string, unknown>)[field];
|
|
442
|
+
if (deps === null || typeof deps !== "object") {
|
|
443
|
+
continue;
|
|
444
|
+
}
|
|
445
|
+
for (const [name, version] of Object.entries(deps)) {
|
|
446
|
+
if (name.startsWith("@valbuild/") && typeof version === "string") {
|
|
447
|
+
versions[name] = version;
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
return versions;
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
function readLocalFile(
|
|
455
|
+
projectRoot: string,
|
|
456
|
+
projectRelativePath: string,
|
|
457
|
+
): string | null {
|
|
458
|
+
const absPath = path.join(projectRoot, projectRelativePath);
|
|
459
|
+
try {
|
|
460
|
+
return fs.readFileSync(absPath, "utf-8");
|
|
461
|
+
} catch {
|
|
462
|
+
return null;
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/**
|
|
467
|
+
* Replaces oversized strings (base64 file payloads that were not swapped for a
|
|
468
|
+
* hash) with a marker, so a snapshot stays small enough to attach.
|
|
469
|
+
*/
|
|
470
|
+
function elideLongStrings(value: unknown): {
|
|
471
|
+
value: unknown;
|
|
472
|
+
elided: string[][];
|
|
473
|
+
} {
|
|
474
|
+
const elided: string[][] = [];
|
|
475
|
+
const walk = (node: unknown, atPath: string[]): unknown => {
|
|
476
|
+
if (typeof node === "string") {
|
|
477
|
+
if (node.length > MAX_PATCH_STRING_LENGTH) {
|
|
478
|
+
elided.push(atPath);
|
|
479
|
+
return `<elided ${node.length} chars by val debug>`;
|
|
480
|
+
}
|
|
481
|
+
return node;
|
|
482
|
+
}
|
|
483
|
+
if (Array.isArray(node)) {
|
|
484
|
+
return node.map((item, i) => walk(item, atPath.concat(i.toString())));
|
|
485
|
+
}
|
|
486
|
+
if (node !== null && typeof node === "object") {
|
|
487
|
+
return Object.fromEntries(
|
|
488
|
+
Object.entries(node).map(([key, item]) => [
|
|
489
|
+
key,
|
|
490
|
+
walk(item, atPath.concat(key)),
|
|
491
|
+
]),
|
|
492
|
+
);
|
|
493
|
+
}
|
|
494
|
+
return node;
|
|
495
|
+
};
|
|
496
|
+
return { value: walk(value, []), elided };
|
|
497
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import fs from "fs";
|
|
2
|
+
import path from "path";
|
|
3
|
+
import os from "os";
|
|
4
|
+
import {
|
|
5
|
+
compareWithCapturedReport,
|
|
6
|
+
readCapturedReport,
|
|
7
|
+
replaySnapshot,
|
|
8
|
+
} from "@valbuild/server";
|
|
9
|
+
import { createDebugContext } from "./context";
|
|
10
|
+
import { buildSnapshot } from "./snapshot";
|
|
11
|
+
|
|
12
|
+
const FIXTURE = path.resolve(__dirname, "..", "__fixtures__/debug-snapshot");
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The whole point of a snapshot: capture it in one project, then reproduce the
|
|
16
|
+
* same failure somewhere else with no network and no access to the original repo.
|
|
17
|
+
*
|
|
18
|
+
* If this passes, `val debug` -> unzip into debug/ -> `pnpm debug:replay` works.
|
|
19
|
+
*/
|
|
20
|
+
describe("debug snapshot round trip", () => {
|
|
21
|
+
let snapshotDir: string;
|
|
22
|
+
|
|
23
|
+
beforeAll(async () => {
|
|
24
|
+
const ctx = await createDebugContext({ root: FIXTURE });
|
|
25
|
+
const snapshot = await buildSnapshot(ctx);
|
|
26
|
+
|
|
27
|
+
// The OS temp dir, not the repo's .tmp: ValOpsFS.test.ts rmSync's .tmp
|
|
28
|
+
// wholesale on startup, and jest runs test files in parallel workers.
|
|
29
|
+
snapshotDir = fs.mkdtempSync(
|
|
30
|
+
path.join(os.tmpdir(), "snapshot-round-trip-"),
|
|
31
|
+
);
|
|
32
|
+
// Same thing the zip does, minus the zipping.
|
|
33
|
+
for (const [entryPath, contents] of Object.entries(snapshot.entries)) {
|
|
34
|
+
const absPath = path.join(snapshotDir, entryPath);
|
|
35
|
+
fs.mkdirSync(path.dirname(absPath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(absPath, contents);
|
|
37
|
+
}
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
test("the snapshot is a loadable Val project on its own", async () => {
|
|
41
|
+
const result = await replaySnapshot(snapshotDir);
|
|
42
|
+
|
|
43
|
+
// Loading at all means the generated val.modules.ts, val.config, tsconfig
|
|
44
|
+
// and the imported schema fragment all made it in.
|
|
45
|
+
expect(result.patches).toHaveLength(3);
|
|
46
|
+
expect(result.patches.map((patch) => patch.moduleFilePath)).toEqual([
|
|
47
|
+
"/content/projects.val.ts",
|
|
48
|
+
"/content/projects.val.ts",
|
|
49
|
+
"/content/projects.val.ts",
|
|
50
|
+
]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("the replay reproduces the same failure, attributed to the same patch", async () => {
|
|
54
|
+
const result = await replaySnapshot(snapshotDir);
|
|
55
|
+
|
|
56
|
+
expect(Object.entries(result.unappliablePatches)).toEqual([
|
|
57
|
+
[
|
|
58
|
+
"33333333-3333-4333-8333-333333333333",
|
|
59
|
+
{
|
|
60
|
+
moduleFilePath: "/content/projects.val.ts",
|
|
61
|
+
message: "Array index out of bounds",
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
]);
|
|
65
|
+
const failing = result.patches.find((patch) => patch.error);
|
|
66
|
+
expect(failing?.authorId).toBe("author-a");
|
|
67
|
+
expect(failing?.createdAt).toBe("2026-08-11T09:00:05.000Z");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("the replay agrees with the report captured at snapshot time", async () => {
|
|
71
|
+
const result = await replaySnapshot(snapshotDir);
|
|
72
|
+
const captured = readCapturedReport(snapshotDir);
|
|
73
|
+
if (!captured) {
|
|
74
|
+
throw new Error("Snapshot has no report.json");
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
const comparison = compareWithCapturedReport(result, captured);
|
|
78
|
+
|
|
79
|
+
expect(comparison).toEqual({
|
|
80
|
+
stillFailing: ["33333333-3333-4333-8333-333333333333"],
|
|
81
|
+
nowApplying: [],
|
|
82
|
+
newlyFailing: [],
|
|
83
|
+
reproduced: true,
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
test("the appliable patches are applied, so the diff is inspectable", async () => {
|
|
88
|
+
const result = await replaySnapshot(snapshotDir);
|
|
89
|
+
|
|
90
|
+
const patched = result.patchedSourceFiles["/content/projects.val.ts"];
|
|
91
|
+
expect(patched).toContain("BBL Housing");
|
|
92
|
+
// The removal applied; the edit to the removed index did not.
|
|
93
|
+
expect(patched).not.toContain("Development");
|
|
94
|
+
});
|
|
95
|
+
});
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import path from "path";
|
|
2
|
+
import { ModuleFilePath } from "@valbuild/core";
|
|
3
|
+
import { createDebugContext } from "./debug/context";
|
|
4
|
+
import { buildSnapshot } from "./debug/snapshot";
|
|
5
|
+
|
|
6
|
+
const FIXTURE = path.resolve(__dirname, "__fixtures__/debug-snapshot");
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The fixture is a project whose patch chain reproduces the blankno incident: a
|
|
10
|
+
* patch removes `services[2]`, and a later patch from another editor tries to
|
|
11
|
+
* replace it.
|
|
12
|
+
*/
|
|
13
|
+
describe("val debug snapshot", () => {
|
|
14
|
+
const build = async () => {
|
|
15
|
+
const ctx = await createDebugContext({ root: FIXTURE });
|
|
16
|
+
return buildSnapshot(ctx);
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
test("reports the unappliable patch, attributed to its author", async () => {
|
|
20
|
+
const { manifest, report } = await build();
|
|
21
|
+
|
|
22
|
+
expect(manifest.mode).toBe("fs");
|
|
23
|
+
expect(manifest.patchCount).toBe(3);
|
|
24
|
+
expect(manifest.unappliablePatchCount).toBe(1);
|
|
25
|
+
const [patchId, failure] = Object.entries(report.unappliablePatches)[0];
|
|
26
|
+
expect(patchId).toBe("33333333-3333-4333-8333-333333333333");
|
|
27
|
+
expect(failure.message).toBe("Array index out of bounds");
|
|
28
|
+
expect(failure.moduleFilePath).toBe("/content/projects.val.ts");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
test("does not report spurious validation errors for cross-module references", async () => {
|
|
32
|
+
// keyOf resolves against another module's source, so validating with only
|
|
33
|
+
// the patched modules' sources reports the referenced module as missing.
|
|
34
|
+
const { report } = await build();
|
|
35
|
+
|
|
36
|
+
expect(report.validationErrors).toEqual({});
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
test("carries the patched module, what it references, and nothing else", async () => {
|
|
40
|
+
const { manifest, entries } = await build();
|
|
41
|
+
|
|
42
|
+
const included = manifest.modules.map((m) => m.moduleFilePath).sort();
|
|
43
|
+
expect(included).toEqual([
|
|
44
|
+
"/content/projects.val.ts",
|
|
45
|
+
"/content/tags.val.ts",
|
|
46
|
+
]);
|
|
47
|
+
expect(
|
|
48
|
+
manifest.modules.find(
|
|
49
|
+
(m) => m.moduleFilePath === ("/content/tags.val.ts" as ModuleFilePath),
|
|
50
|
+
)?.reasons,
|
|
51
|
+
).toEqual([{ type: "keyOf", from: "/content/projects.val.ts" }]);
|
|
52
|
+
|
|
53
|
+
// The shared schema fragment is reached through the import graph: without it
|
|
54
|
+
// loadValModules cannot evaluate the snapshot.
|
|
55
|
+
expect(entries["content/summary.ts"]).toContain("summarySchema");
|
|
56
|
+
expect(entries["val.config.ts"]).toBeDefined();
|
|
57
|
+
// getCompilerOptions() throws without this.
|
|
58
|
+
expect(entries["tsconfig.json"]).toBeDefined();
|
|
59
|
+
|
|
60
|
+
expect(entries["content/unrelated.val.ts"]).toBeUndefined();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("generates a val.modules.ts limited to the carried modules", async () => {
|
|
64
|
+
const { entries } = await build();
|
|
65
|
+
|
|
66
|
+
const generated = entries["val.modules.ts"];
|
|
67
|
+
expect(generated).toContain('import("./content/projects.val")');
|
|
68
|
+
expect(generated).toContain('import("./content/tags.val")');
|
|
69
|
+
expect(generated).not.toContain("unrelated");
|
|
70
|
+
// The project's own version is kept for reference.
|
|
71
|
+
expect(entries["val.modules.original.ts"]).toContain("unrelated");
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
test("writes the patch chain in the layout ValOpsFS reads back", async () => {
|
|
75
|
+
const { entries } = await build();
|
|
76
|
+
|
|
77
|
+
// The directory is named after the PARENT patch; the first is "head".
|
|
78
|
+
const head = JSON.parse(entries[".val/patches/head/patch.json"]);
|
|
79
|
+
expect(head.patchId).toBe("11111111-1111-4111-8111-111111111111");
|
|
80
|
+
expect(head.parentRef.type).toBe("head");
|
|
81
|
+
// A head ref without headBaseSha does not parse, so the snapshot would be
|
|
82
|
+
// unreadable.
|
|
83
|
+
expect(typeof head.parentRef.headBaseSha).toBe("string");
|
|
84
|
+
|
|
85
|
+
const second = JSON.parse(
|
|
86
|
+
entries[".val/patches/11111111-1111-4111-8111-111111111111/patch.json"],
|
|
87
|
+
);
|
|
88
|
+
expect(second.patchId).toBe("22222222-2222-4222-8222-222222222222");
|
|
89
|
+
expect(second.parentRef).toEqual({
|
|
90
|
+
type: "patch",
|
|
91
|
+
patchId: "11111111-1111-4111-8111-111111111111",
|
|
92
|
+
});
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("records why the snapshot may be incomplete rather than failing silently", async () => {
|
|
96
|
+
const { manifest } = await build();
|
|
97
|
+
|
|
98
|
+
// Bare package specifiers are expected and harmless; the point is that they
|
|
99
|
+
// are listed, so a tsconfig path alias (which would break the replay) is
|
|
100
|
+
// visible too.
|
|
101
|
+
expect(manifest.unresolvedImports).toEqual([
|
|
102
|
+
{ from: "/val.config.ts", specifier: "@valbuild/core" },
|
|
103
|
+
]);
|
|
104
|
+
expect(manifest.elidedPatchValues).toEqual([]);
|
|
105
|
+
expect(manifest.includesBinaryFiles).toBe(false);
|
|
106
|
+
});
|
|
107
|
+
});
|