@kungfu-tech/buildchain 2.8.17 → 2.9.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.
@@ -0,0 +1,567 @@
1
+ import crypto from "node:crypto";
2
+ import { execFileSync, execSync } from "node:child_process";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { parse as parseToml } from "smol-toml";
7
+ import { loadBuildchainConfig } from "./buildchain-config.js";
8
+
9
+ export const BUILD_FACTS_GIT_CONTRACT = "kungfu-buildchain-git-source-facts";
10
+ export const BUILD_FACTS_VERSION_CONTRACT = "kungfu-buildchain-version-source-facts";
11
+ export const BUILD_FACTS_MODULE_CONTRACT = "kungfu-buildchain-module-build-facts";
12
+ export const BUILD_FACTS_PRODUCT_CONTRACT = "kungfu-buildchain-product-build-facts";
13
+ export const BUILD_FACTS_VERIFY_CONTRACT = "kungfu-buildchain-build-facts-verification";
14
+ export const BUILD_FACTS_LEGACY_KUNGFU_BUILDINFO_CONTRACT = "kungfu-buildchain-legacy-kungfu-buildinfo-projection";
15
+
16
+ function nowIso() {
17
+ return new Date().toISOString();
18
+ }
19
+
20
+ function posixPath(value) {
21
+ return String(value || "").split(path.sep).join("/");
22
+ }
23
+
24
+ function stableJson(value) {
25
+ if (Array.isArray(value)) {
26
+ return `[${value.map(stableJson).join(",")}]`;
27
+ }
28
+ if (value && typeof value === "object") {
29
+ return `{${Object.keys(value)
30
+ .sort()
31
+ .map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`)
32
+ .join(",")}}`;
33
+ }
34
+ return JSON.stringify(value);
35
+ }
36
+
37
+ function sha256Text(value) {
38
+ return crypto.createHash("sha256").update(String(value || "")).digest("hex");
39
+ }
40
+
41
+ function sha256File(filePath) {
42
+ return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
43
+ }
44
+
45
+ export function buildFactsDigest(value) {
46
+ return `sha256:${sha256Text(stableJson(value))}`;
47
+ }
48
+
49
+ function readJsonFile(filePath) {
50
+ return JSON.parse(fs.readFileSync(filePath, "utf8"));
51
+ }
52
+
53
+ function writeJsonFile(filePath, value) {
54
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
55
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
56
+ return filePath;
57
+ }
58
+
59
+ function git(cwd, args, fallback = "") {
60
+ try {
61
+ return execFileSync("git", args, {
62
+ cwd,
63
+ encoding: "utf8",
64
+ stdio: ["ignore", "pipe", "ignore"],
65
+ }).trim();
66
+ } catch {
67
+ return fallback;
68
+ }
69
+ }
70
+
71
+ function parseZeroSeparated(input) {
72
+ return String(input || "").split("\0").filter(Boolean);
73
+ }
74
+
75
+ function repositorySlug(cwd) {
76
+ const remote = git(cwd, ["config", "--get", "remote.origin.url"]);
77
+ const match = remote.match(/[:/]([^/:]+\/[^/]+?)(?:\.git)?$/);
78
+ return match ? match[1] : "";
79
+ }
80
+
81
+ function readTrackedFiles(cwd, root = ".") {
82
+ const repoRoot = fs.realpathSync(path.resolve(cwd));
83
+ const requestedRoot = fs.realpathSync(path.resolve(cwd, root));
84
+ const relativeRoot = posixPath(path.relative(repoRoot, requestedRoot) || ".");
85
+ const args = relativeRoot === "." ? ["ls-files", "-z"] : ["ls-files", "-z", "--", relativeRoot];
86
+ return parseZeroSeparated(git(repoRoot, args))
87
+ .filter((entry) => entry && !entry.startsWith(".git/"))
88
+ .sort();
89
+ }
90
+
91
+ function digestTrackedFiles(cwd, root = ".") {
92
+ const repoRoot = fs.realpathSync(path.resolve(cwd));
93
+ const hash = crypto.createHash("sha256");
94
+ const files = readTrackedFiles(repoRoot, root);
95
+ for (const file of files) {
96
+ const filePath = path.resolve(repoRoot, file);
97
+ if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
98
+ continue;
99
+ }
100
+ hash.update(file);
101
+ hash.update("\0");
102
+ hash.update(fs.readFileSync(filePath));
103
+ hash.update("\0");
104
+ }
105
+ return {
106
+ algorithm: "sha256",
107
+ value: hash.digest("hex"),
108
+ fileCount: files.length,
109
+ scope: posixPath(root || "."),
110
+ };
111
+ }
112
+
113
+ function readByDottedKey(value, key) {
114
+ return String(key || "").split(".").reduce((current, segment) => current?.[segment], value);
115
+ }
116
+
117
+ function normalizeVersionSource(source = {}, fallbackId = "version") {
118
+ if (typeof source === "string") {
119
+ return { id: fallbackId, type: "static", value: source };
120
+ }
121
+ if (!source || typeof source !== "object" || Array.isArray(source)) {
122
+ return { id: fallbackId, type: "none" };
123
+ }
124
+ return {
125
+ id: String(source.id || source.name || fallbackId),
126
+ type: String(source.type || "static"),
127
+ value: source.value,
128
+ path: source.path ? posixPath(source.path) : "",
129
+ key: source.key ? String(source.key) : "",
130
+ pattern: source.pattern ? String(source.pattern) : "",
131
+ command: source.command ? String(source.command) : "",
132
+ trust: source.trust ? String(source.trust) : "",
133
+ reproducible: source.reproducible === undefined ? undefined : Boolean(source.reproducible),
134
+ };
135
+ }
136
+
137
+ function resolveConfiguredVersionSource({ cwd, sourceId = "" } = {}) {
138
+ const loaded = loadBuildchainConfig(cwd);
139
+ const configured = loaded?.config?.facts?.versionSources || [];
140
+ if (sourceId) {
141
+ return configured.find((source) => source.id === sourceId);
142
+ }
143
+ return configured[0];
144
+ }
145
+
146
+ export function collectGitSourceFacts({ cwd = process.cwd(), root = "." } = {}) {
147
+ const resolvedCwd = fs.realpathSync(path.resolve(cwd));
148
+ const repoRoot = fs.realpathSync(git(resolvedCwd, ["rev-parse", "--show-toplevel"], resolvedCwd));
149
+ const digest = digestTrackedFiles(repoRoot, path.resolve(resolvedCwd, root));
150
+ const status = git(repoRoot, ["status", "--porcelain=v1"]);
151
+ const branch = git(repoRoot, ["symbolic-ref", "--quiet", "--short", "HEAD"]);
152
+ const headSha = git(repoRoot, ["rev-parse", "HEAD"]);
153
+ const tags = git(repoRoot, ["tag", "--points-at", "HEAD"])
154
+ .split("\n")
155
+ .map((entry) => entry.trim())
156
+ .filter(Boolean);
157
+ return {
158
+ schemaVersion: 1,
159
+ contract: BUILD_FACTS_GIT_CONTRACT,
160
+ repositoryRoot: repoRoot,
161
+ repository: repositorySlug(repoRoot),
162
+ headSha,
163
+ branch,
164
+ refName: process.env.GITHUB_REF_NAME || branch || "",
165
+ ref: process.env.GITHUB_REF || "",
166
+ tags,
167
+ dirty: status.length > 0,
168
+ pristine: status.length === 0,
169
+ sourceDigest: digest,
170
+ };
171
+ }
172
+
173
+ export function collectVersionSourceFact({ cwd = process.cwd(), source = undefined, sourceId = "", now = nowIso() } = {}) {
174
+ const resolvedCwd = path.resolve(cwd);
175
+ const normalized = normalizeVersionSource(source || resolveConfiguredVersionSource({ cwd: resolvedCwd, sourceId }));
176
+ const fact = {
177
+ schemaVersion: 1,
178
+ contract: BUILD_FACTS_VERSION_CONTRACT,
179
+ id: normalized.id,
180
+ type: normalized.type,
181
+ generatedAt: now,
182
+ value: "",
183
+ source: {
184
+ path: normalized.path,
185
+ key: normalized.key,
186
+ pattern: normalized.pattern,
187
+ command: normalized.command,
188
+ trust: normalized.trust || (normalized.type === "command" ? "explicit-command-output" : "declared-source"),
189
+ reproducible: normalized.reproducible ?? normalized.type !== "command",
190
+ },
191
+ sourceDigest: "",
192
+ extraction: {
193
+ method: normalized.type,
194
+ ok: false,
195
+ error: "",
196
+ },
197
+ };
198
+ try {
199
+ if (normalized.type === "static") {
200
+ fact.value = String(normalized.value || "");
201
+ fact.sourceDigest = `sha256:${sha256Text(fact.value)}`;
202
+ } else if (["json", "toml", "regex"].includes(normalized.type)) {
203
+ const filePath = path.resolve(resolvedCwd, normalized.path);
204
+ const sourceText = fs.readFileSync(filePath, "utf8");
205
+ fact.sourceDigest = `sha256:${sha256Text(sourceText)}`;
206
+ if (normalized.type === "json") {
207
+ fact.value = String(readByDottedKey(JSON.parse(sourceText), normalized.key) || "");
208
+ } else if (normalized.type === "toml") {
209
+ fact.value = String(readByDottedKey(parseToml(sourceText), normalized.key) || "");
210
+ } else {
211
+ const match = sourceText.match(new RegExp(normalized.pattern, "m"));
212
+ fact.value = String(match?.groups?.version || match?.[1] || "");
213
+ }
214
+ } else if (normalized.type === "command") {
215
+ fact.value = execSync(normalized.command, {
216
+ cwd: resolvedCwd,
217
+ encoding: "utf8",
218
+ stdio: ["ignore", "pipe", "pipe"],
219
+ }).trim();
220
+ fact.sourceDigest = `sha256:${sha256Text(`${normalized.command}\n${fact.value}`)}`;
221
+ } else if (normalized.type === "none") {
222
+ fact.extraction.error = "no version source declared";
223
+ } else {
224
+ throw new Error(`unsupported version source type: ${normalized.type}`);
225
+ }
226
+ if (fact.value) {
227
+ fact.extraction.ok = true;
228
+ } else if (!fact.extraction.error) {
229
+ fact.extraction.error = "version source produced an empty value";
230
+ }
231
+ } catch (error) {
232
+ fact.extraction.ok = false;
233
+ fact.extraction.error = error.message;
234
+ }
235
+ return fact;
236
+ }
237
+
238
+ function digestOutputPath(cwd, relativePath) {
239
+ const absolutePath = path.resolve(cwd, relativePath);
240
+ if (!fs.existsSync(absolutePath)) {
241
+ return {
242
+ path: posixPath(relativePath),
243
+ exists: false,
244
+ digest: "",
245
+ size: 0,
246
+ kind: "missing",
247
+ };
248
+ }
249
+ const stat = fs.statSync(absolutePath);
250
+ if (stat.isDirectory()) {
251
+ const files = [];
252
+ const walk = (dir) => {
253
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
254
+ const entryPath = path.join(dir, entry.name);
255
+ if (entry.isDirectory()) {
256
+ walk(entryPath);
257
+ } else if (entry.isFile()) {
258
+ files.push(posixPath(path.relative(absolutePath, entryPath)));
259
+ }
260
+ }
261
+ };
262
+ walk(absolutePath);
263
+ files.sort();
264
+ const hash = crypto.createHash("sha256");
265
+ let totalSize = 0;
266
+ for (const file of files) {
267
+ const filePath = path.join(absolutePath, file);
268
+ totalSize += fs.statSync(filePath).size;
269
+ hash.update(file);
270
+ hash.update("\0");
271
+ hash.update(fs.readFileSync(filePath));
272
+ hash.update("\0");
273
+ }
274
+ return {
275
+ path: posixPath(relativePath),
276
+ exists: true,
277
+ digest: `sha256:${hash.digest("hex")}`,
278
+ size: totalSize,
279
+ kind: "directory",
280
+ fileCount: files.length,
281
+ };
282
+ }
283
+ return {
284
+ path: posixPath(relativePath),
285
+ exists: true,
286
+ digest: `sha256:${sha256File(absolutePath)}`,
287
+ size: stat.size,
288
+ kind: "file",
289
+ };
290
+ }
291
+
292
+ function currentPlatform() {
293
+ return `${process.platform}-${process.arch}`;
294
+ }
295
+
296
+ function configuredModule({ cwd, moduleId }) {
297
+ const loaded = loadBuildchainConfig(cwd);
298
+ const modules = loaded?.config?.facts?.modules || [];
299
+ return modules.find((entry) => entry.id === moduleId) || modules[0] || {};
300
+ }
301
+
302
+ export function collectModuleBuildFacts({
303
+ cwd = process.cwd(),
304
+ moduleId = "",
305
+ moduleRoot = "",
306
+ scope = "",
307
+ versionSource = undefined,
308
+ versionSourceId = "",
309
+ outputs = [],
310
+ lifecycle = "",
311
+ platform = currentPlatform(),
312
+ dependencies = [],
313
+ now = nowIso(),
314
+ } = {}) {
315
+ const resolvedCwd = path.resolve(cwd);
316
+ const configured = configuredModule({ cwd: resolvedCwd, moduleId });
317
+ const id = moduleId || configured.id || "module";
318
+ const root = moduleRoot || configured.root || ".";
319
+ const outputPaths = outputs.length ? outputs : configured.outputs || [];
320
+ const versionFact = collectVersionSourceFact({
321
+ cwd: resolvedCwd,
322
+ source: versionSource,
323
+ sourceId: versionSourceId || configured.versionSource || "",
324
+ now,
325
+ });
326
+ const fact = {
327
+ schemaVersion: 1,
328
+ contract: BUILD_FACTS_MODULE_CONTRACT,
329
+ id,
330
+ kind: "module",
331
+ generatedAt: now,
332
+ cwd: resolvedCwd,
333
+ moduleRoot: posixPath(root),
334
+ scope: scope || configured.scope || id,
335
+ git: collectGitSourceFacts({ cwd: resolvedCwd, root }),
336
+ version: versionFact,
337
+ lifecycle: {
338
+ invocation: lifecycle || configured.lifecycle || "",
339
+ },
340
+ platform,
341
+ runtime: {
342
+ node: process.version,
343
+ os: os.type(),
344
+ osRelease: os.release(),
345
+ arch: os.arch(),
346
+ },
347
+ outputs: outputPaths.map((entry) => digestOutputPath(resolvedCwd, entry)),
348
+ dependencies: dependencies.map((entry) => String(entry)),
349
+ verification: {
350
+ ok: false,
351
+ status: "unknown",
352
+ issues: [],
353
+ },
354
+ };
355
+ fact.digest = buildFactsDigest({ ...fact, digest: undefined, verification: undefined });
356
+ fact.verification = verifyBuildFacts({ cwd: resolvedCwd, fact }).summary;
357
+ return fact;
358
+ }
359
+
360
+ function readFact(input) {
361
+ if (typeof input === "string") {
362
+ return readJsonFile(input);
363
+ }
364
+ return input;
365
+ }
366
+
367
+ function verifyModuleFact({ cwd, fact }) {
368
+ const issues = [];
369
+ if (fact.contract !== BUILD_FACTS_MODULE_CONTRACT) {
370
+ issues.push({ level: "error", id: "contract", message: "module fact contract is invalid" });
371
+ }
372
+ if (!fact.id) {
373
+ issues.push({ level: "error", id: "module.id", message: "module fact id is required" });
374
+ }
375
+ if (!fact.version?.extraction?.ok) {
376
+ issues.push({ level: "error", id: "version", message: fact.version?.extraction?.error || "version source is invalid" });
377
+ }
378
+ const gitFacts = collectGitSourceFacts({ cwd, root: fact.moduleRoot || "." });
379
+ if (fact.git?.headSha && gitFacts.headSha && fact.git.headSha !== gitFacts.headSha) {
380
+ issues.push({ level: "error", id: "git.headSha", message: "module fact was collected from a different HEAD" });
381
+ }
382
+ if (fact.git?.sourceDigest?.value && gitFacts.sourceDigest?.value && fact.git.sourceDigest.value !== gitFacts.sourceDigest.value) {
383
+ issues.push({ level: "error", id: "git.sourceDigest", message: "module source digest is stale" });
384
+ }
385
+ for (const output of fact.outputs || []) {
386
+ const current = digestOutputPath(cwd, output.path);
387
+ if (!current.exists) {
388
+ issues.push({ level: "error", id: `output.${output.path}`, message: "declared module output is missing" });
389
+ } else if (output.digest && current.digest !== output.digest) {
390
+ issues.push({ level: "error", id: `output.${output.path}`, message: "declared module output digest is stale" });
391
+ }
392
+ }
393
+ return issues;
394
+ }
395
+
396
+ function verifyProductFact({ cwd, fact }) {
397
+ const issues = [];
398
+ if (fact.contract !== BUILD_FACTS_PRODUCT_CONTRACT) {
399
+ issues.push({ level: "error", id: "contract", message: "product fact contract is invalid" });
400
+ }
401
+ if (!fact.id) {
402
+ issues.push({ level: "error", id: "product.id", message: "product fact id is required" });
403
+ }
404
+ for (const module of fact.modules || []) {
405
+ if (!module.digest) {
406
+ issues.push({ level: "error", id: `module.${module.id || "unknown"}`, message: "product module reference is missing digest" });
407
+ }
408
+ if (module.verificationStatus !== "passed") {
409
+ issues.push({ level: "error", id: `module.${module.id || "unknown"}.verification`, message: module.verificationReason || "module fact did not verify" });
410
+ }
411
+ }
412
+ for (const artifact of fact.artifacts || []) {
413
+ const current = digestOutputPath(cwd, artifact.path);
414
+ if (!current.exists) {
415
+ issues.push({ level: "error", id: `artifact.${artifact.path}`, message: "declared product artifact is missing" });
416
+ } else if (artifact.digest && current.digest !== artifact.digest) {
417
+ issues.push({ level: "error", id: `artifact.${artifact.path}`, message: "declared product artifact digest is stale" });
418
+ }
419
+ }
420
+ return issues;
421
+ }
422
+
423
+ export function verifyBuildFacts({ cwd = process.cwd(), fact, factPath = "" } = {}) {
424
+ const resolvedCwd = path.resolve(cwd);
425
+ const resolvedFact = fact || readJsonFile(path.resolve(resolvedCwd, factPath));
426
+ const issues = resolvedFact.contract === BUILD_FACTS_MODULE_CONTRACT
427
+ ? verifyModuleFact({ cwd: resolvedCwd, fact: resolvedFact })
428
+ : resolvedFact.contract === BUILD_FACTS_PRODUCT_CONTRACT
429
+ ? verifyProductFact({ cwd: resolvedCwd, fact: resolvedFact })
430
+ : [{ level: "error", id: "contract", message: `unsupported build facts contract: ${resolvedFact.contract || "<missing>"}` }];
431
+ const ok = issues.filter((issue) => issue.level === "error").length === 0;
432
+ return {
433
+ schemaVersion: 1,
434
+ contract: BUILD_FACTS_VERIFY_CONTRACT,
435
+ ok,
436
+ status: ok ? "passed" : "failed",
437
+ checkedAt: nowIso(),
438
+ summary: {
439
+ ok,
440
+ status: ok ? "passed" : "failed",
441
+ issues,
442
+ },
443
+ fact: {
444
+ contract: resolvedFact.contract,
445
+ id: resolvedFact.id || "",
446
+ digest: resolvedFact.digest || buildFactsDigest(resolvedFact),
447
+ },
448
+ issues,
449
+ };
450
+ }
451
+
452
+ function configuredProduct({ cwd, productId }) {
453
+ const loaded = loadBuildchainConfig(cwd);
454
+ const products = loaded?.config?.facts?.products || [];
455
+ return products.find((entry) => entry.id === productId) || products[0] || {};
456
+ }
457
+
458
+ export function aggregateBuildFacts({
459
+ cwd = process.cwd(),
460
+ productId = "",
461
+ moduleFacts = [],
462
+ artifacts = [],
463
+ now = nowIso(),
464
+ } = {}) {
465
+ const resolvedCwd = path.resolve(cwd);
466
+ const configured = configuredProduct({ cwd: resolvedCwd, productId });
467
+ const moduleInputs = moduleFacts.length ? moduleFacts : configured.moduleFacts || [];
468
+ const artifactInputs = artifacts.length ? artifacts : configured.artifacts || [];
469
+ const modules = moduleInputs.map((input) => {
470
+ const factPath = typeof input === "string" ? path.resolve(resolvedCwd, input) : "";
471
+ const fact = readFact(factPath || input);
472
+ const verification = verifyBuildFacts({ cwd: resolvedCwd, fact });
473
+ return {
474
+ id: fact.id || "",
475
+ contract: fact.contract || "",
476
+ path: factPath ? posixPath(path.relative(resolvedCwd, factPath)) : "",
477
+ digest: fact.digest || buildFactsDigest(fact),
478
+ version: fact.version?.value || "",
479
+ gitHeadSha: fact.git?.headSha || "",
480
+ verificationStatus: verification.status,
481
+ verificationReason: verification.issues.map((issue) => issue.message).join("; "),
482
+ };
483
+ });
484
+ const fact = {
485
+ schemaVersion: 1,
486
+ contract: BUILD_FACTS_PRODUCT_CONTRACT,
487
+ id: productId || configured.id || "product",
488
+ kind: "product",
489
+ generatedAt: now,
490
+ cwd: resolvedCwd,
491
+ git: collectGitSourceFacts({ cwd: resolvedCwd }),
492
+ modules,
493
+ artifacts: artifactInputs.map((entry) => digestOutputPath(resolvedCwd, entry)),
494
+ verification: {
495
+ ok: false,
496
+ status: "unknown",
497
+ issues: [],
498
+ },
499
+ };
500
+ fact.digest = buildFactsDigest({ ...fact, digest: undefined, verification: undefined });
501
+ fact.verification = verifyBuildFacts({ cwd: resolvedCwd, fact }).summary;
502
+ return fact;
503
+ }
504
+
505
+ function collectPythonVersion(cwd) {
506
+ for (const command of ["python3 --version", "python --version"]) {
507
+ try {
508
+ return execSync(command, {
509
+ cwd,
510
+ encoding: "utf8",
511
+ stdio: ["ignore", "pipe", "pipe"],
512
+ }).trim().replace(/^Python\s+/, "");
513
+ } catch {
514
+ // best effort legacy compatibility field
515
+ }
516
+ }
517
+ return "";
518
+ }
519
+
520
+ export function createKungfuBuildInfoProjection({ moduleFact, cwd = process.cwd(), now = nowIso() } = {}) {
521
+ const fact = readFact(moduleFact);
522
+ return {
523
+ schemaVersion: 1,
524
+ contract: BUILD_FACTS_LEGACY_KUNGFU_BUILDINFO_CONTRACT,
525
+ generatedAt: now,
526
+ source: {
527
+ contract: fact.contract,
528
+ moduleId: fact.id,
529
+ digest: fact.digest || buildFactsDigest(fact),
530
+ },
531
+ version: fact.version?.value || "",
532
+ python_version: collectPythonVersion(cwd),
533
+ build_user: os.userInfo().username,
534
+ build_os: `${os.type()} ${os.release()} ${os.arch()}`,
535
+ build_timestamp: now,
536
+ git_tag: fact.git?.tags?.[0] || "",
537
+ git_branch: fact.git?.branch || fact.git?.refName || "",
538
+ git_revision: fact.git?.headSha || "",
539
+ git_pristine: Boolean(fact.git?.pristine),
540
+ buildchain: {
541
+ contract: fact.contract,
542
+ moduleFactDigest: fact.digest || buildFactsDigest(fact),
543
+ versionSourceDigest: fact.version?.sourceDigest || "",
544
+ sourceDigest: fact.git?.sourceDigest?.value ? `sha256:${fact.git.sourceDigest.value}` : "",
545
+ },
546
+ };
547
+ }
548
+
549
+ export function writeBuildFacts({ cwd = process.cwd(), fact, output = "" } = {}) {
550
+ const resolvedOutput = output || path.join(cwd, ".buildchain", "facts", `${fact.id || "build-facts"}.json`);
551
+ return {
552
+ path: writeJsonFile(path.resolve(cwd, resolvedOutput), fact),
553
+ digest: buildFactsDigest(fact),
554
+ };
555
+ }
556
+
557
+ export function writeKungfuBuildInfoProjection({ cwd = process.cwd(), moduleFact, output } = {}) {
558
+ if (!output) {
559
+ throw new Error("legacy Kungfu buildinfo projection requires output");
560
+ }
561
+ const projection = createKungfuBuildInfoProjection({ cwd, moduleFact });
562
+ return {
563
+ projection,
564
+ path: writeJsonFile(path.resolve(cwd, output), projection),
565
+ digest: buildFactsDigest(projection),
566
+ };
567
+ }