@deeeed/metamask-harness 0.42.0 → 0.44.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/README.md +5 -0
  3. package/adapters/mobile/bridge-runtime/cdp-bridge.cjs +363 -55
  4. package/adapters/mobile/bridge-runtime/console-forwarder.cjs +23 -10
  5. package/adapters/mobile/bridge-runtime/lib/bridge-errors.cjs +2 -0
  6. package/adapters/mobile/bridge-runtime/lib/cdp-broker.cjs +296 -25
  7. package/adapters/mobile/bridge-runtime/lib/config.cjs +14 -4
  8. package/adapters/mobile/bridge-runtime/lib/target-discovery.cjs +4 -2
  9. package/adapters/mobile/reload-app.mjs +99 -1
  10. package/adapters/mobile/start-console-forwarder.sh +5 -1
  11. package/dist/adapters/extension/browser-cdp.js +174 -0
  12. package/dist/adapters/extension/network-observer.js +19 -110
  13. package/dist/adapters/extension/performance-observer.js +75 -0
  14. package/dist/adapters/mobile/frame-metrics.js +45 -0
  15. package/dist/adapters/mobile/performance-observer.js +43 -0
  16. package/dist/adapters/mobile/prepare.js +12 -0
  17. package/dist/adapters/performance/cdp-trace.js +342 -0
  18. package/dist/adapters/performance/js-task-metrics.js +35 -0
  19. package/dist/adapters.js +39 -5
  20. package/dist/artifact-files.js +92 -0
  21. package/dist/async.js +19 -0
  22. package/dist/commands/call.js +63 -4
  23. package/dist/commands/run-engine.js +265 -139
  24. package/dist/commands/run-report.js +68 -0
  25. package/dist/commands/run.js +67 -3
  26. package/dist/execution-provenance.js +342 -0
  27. package/dist/network-observation.js +59 -47
  28. package/dist/performance-observation.js +465 -0
  29. package/dist/run-diagnostics.js +36 -11
  30. package/dist/runner.js +44 -13
  31. package/docs/PERFORMANCE-CAPTURE.md +33 -0
  32. package/docs/RECIPES.md +7 -0
  33. package/library/actions/mobile/perps/performance-capture.mjs +570 -189
  34. package/library/actions/mobile/perps/perps.mjs +122 -0
  35. package/library/actions/mobile/platform/bridge.mjs +43 -8
  36. package/library/actions/mobile/platform/native-session.mjs +1 -1
  37. package/library/actions/mobile/wallet/lock.mjs +1 -4
  38. package/library/actions/mobile/wallet/select_account.mjs +129 -17
  39. package/library/manifests/extension.action-manifest.json +85 -0
  40. package/library/manifests/mobile.action-manifest.json +125 -3
  41. package/library/recipes/mobile/perps/performance.recipe.json +73 -47
  42. package/package.json +1 -1
  43. package/scripts/site-contrast.mjs +43 -27
@@ -1,5 +1,6 @@
1
1
  import fs from "node:fs";
2
2
  import path from "node:path";
3
+ import { execFileSync } from "node:child_process";
3
4
  import { isRecord } from "./parse-args.js";
4
5
  function writeRunReport(result) {
5
6
  const summaryPath = String(result.summaryPath);
@@ -19,6 +20,72 @@ function writeRunReport(result) {
19
20
  preview: lines.filter((line) => line.startsWith("- ")).slice(0, 6).map((line) => line.slice(2))
20
21
  };
21
22
  }
23
+ function indexProductProvenanceArtifact(artifactManifestPath, target, adapter) {
24
+ const artifactsDir = path.dirname(artifactManifestPath);
25
+ let gitRef = "unknown";
26
+ let branch = "unknown";
27
+ let dirty = true;
28
+ try {
29
+ gitRef = execFileSync("git", ["rev-parse", "HEAD"], {
30
+ cwd: target,
31
+ encoding: "utf8",
32
+ stdio: ["ignore", "pipe", "ignore"]
33
+ }).trim();
34
+ branch = execFileSync("git", ["branch", "--show-current"], {
35
+ cwd: target,
36
+ encoding: "utf8",
37
+ stdio: ["ignore", "pipe", "ignore"]
38
+ }).trim() || "detached";
39
+ dirty = execFileSync(
40
+ "git",
41
+ ["status", "--porcelain", "--untracked-files=normal"],
42
+ {
43
+ cwd: target,
44
+ encoding: "utf8",
45
+ stdio: ["ignore", "pipe", "ignore"]
46
+ }
47
+ ).trim().length > 0;
48
+ } catch {
49
+ }
50
+ const provenance = {
51
+ schemaVersion: 1,
52
+ adapter,
53
+ repository: path.basename(path.resolve(target)),
54
+ git_ref: gitRef,
55
+ branch,
56
+ dirty
57
+ };
58
+ fs.writeFileSync(
59
+ path.join(artifactsDir, "product-provenance.json"),
60
+ `${JSON.stringify(provenance, null, 2)}
61
+ `
62
+ );
63
+ let manifest;
64
+ try {
65
+ manifest = JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"));
66
+ } catch {
67
+ return;
68
+ }
69
+ if (!isRecord(manifest)) return;
70
+ const artifacts = Array.isArray(manifest.artifacts) ? manifest.artifacts : [];
71
+ manifest.artifacts = [
72
+ ...artifacts.filter(
73
+ (artifact) => !isRecord(artifact) || artifact.path !== "product-provenance.json"
74
+ ),
75
+ {
76
+ path: "product-provenance.json",
77
+ type: "json",
78
+ label: "Product checkout provenance",
79
+ category: "system",
80
+ metadata: { adapter, git_ref: gitRef, branch, dirty }
81
+ }
82
+ ];
83
+ fs.writeFileSync(
84
+ artifactManifestPath,
85
+ `${JSON.stringify(manifest, null, 2)}
86
+ `
87
+ );
88
+ }
22
89
  function renderRunReport(summary, entries) {
23
90
  const lines = [
24
91
  "# MetaMask Recipe Run",
@@ -120,5 +187,6 @@ function indexRunReportArtifact(artifactManifestPath) {
120
187
  `);
121
188
  }
122
189
  export {
190
+ indexProductProvenanceArtifact,
123
191
  writeRunReport
124
192
  };
@@ -21,6 +21,7 @@ import {
21
21
  executeWithHealBounds,
22
22
  preflightRecipe,
23
23
  prepareHeal,
24
+ persistRunEffects,
24
25
  recoverRunInfra,
25
26
  runRecipe,
26
27
  validateRunRecipeStatic
@@ -30,11 +31,15 @@ import { applyDeviceTargeting } from "./device-target.js";
30
31
  import { getAdapterSurface } from "../adapters/surface.js";
31
32
  import { coreDependencyBlock } from "./core-readiness.js";
32
33
  import { readMobileReleaseArtifactState } from "../adapters/mobile/release-artifact-state.js";
33
- import { writeRunReport } from "./run-report.js";
34
+ import {
35
+ indexProductProvenanceArtifact,
36
+ writeRunReport
37
+ } from "./run-report.js";
34
38
  import { acquireCheckoutLock } from "../checkout-lock.js";
35
39
  import { JsonStreamWriter } from "../json-stream.js";
36
40
  import { formatRunDiagnosticsForHuman, readRunDiagnosticsDocument } from "../run-diagnostics.js";
37
41
  import { recipeTrustFailure } from "../recipe-security.js";
42
+ import { ProvenanceDriftError } from "../execution-provenance.js";
38
43
  import { recordCommandEvidence, redactStructuredValue } from "../command-journal.js";
39
44
  import {
40
45
  actionLibraryContextArgs,
@@ -44,6 +49,9 @@ import {
44
49
  import {
45
50
  startRunNetworkObservation
46
51
  } from "../network-observation.js";
52
+ import {
53
+ startRunPerformanceObservation
54
+ } from "../performance-observation.js";
47
55
  async function validationCapabilityRefusals(adapter, findings, librarySources) {
48
56
  const actionNames = findings.flatMap((finding) => {
49
57
  if (finding.code !== "recipe.action_not_declared_by_manifest") return [];
@@ -72,6 +80,31 @@ async function handleRun(parsed) {
72
80
  stream.complete(exitCode === EXIT.ok ? "pass" : "fail", exitCode);
73
81
  return exitCode;
74
82
  } catch (error) {
83
+ if (error instanceof ProvenanceDriftError) {
84
+ const failure = {
85
+ code: error.code,
86
+ message: error.message,
87
+ userAction: error.userAction,
88
+ provenancePath: error.provenancePath,
89
+ drift: error.drift
90
+ };
91
+ if (stream.enabled) {
92
+ stream.error(failure);
93
+ stream.complete("fail", error.exitCode);
94
+ } else if (optionFlag(parsed.options, "json")) {
95
+ console.log(JSON.stringify({
96
+ schemaVersion: 1,
97
+ command: "run",
98
+ status: "fail",
99
+ error: failure,
100
+ exitCode: error.exitCode
101
+ }, null, 2));
102
+ } else {
103
+ console.error(`\u2717 mm-harness run: ${error.message}`);
104
+ console.error(` Next: ${error.userAction}`);
105
+ }
106
+ return error.exitCode;
107
+ }
75
108
  const trustFailure = recipeTrustFailure(error);
76
109
  if (trustFailure) {
77
110
  if (stream.enabled) {
@@ -231,6 +264,7 @@ async function handleRunInner({ positional, options }, stream) {
231
264
  recordCommandEvidence(artifactsDir);
232
265
  const librarySources = validated.librarySources;
233
266
  let networkObservation;
267
+ let performanceObservation;
234
268
  const runtimeOptions = {
235
269
  ...runtimeOptionsFromCli(options),
236
270
  params,
@@ -239,6 +273,7 @@ async function handleRunInner({ positional, options }, stream) {
239
273
  onActionEvent: ({ nodeId, action, status }) => {
240
274
  stream.node(nodeId, action, status);
241
275
  networkObservation?.onActionEvent({ nodeId, action, status });
276
+ performanceObservation?.onActionEvent({ nodeId, action, status });
242
277
  }
243
278
  };
244
279
  stream.phase("authorize");
@@ -270,6 +305,14 @@ async function handleRunInner({ positional, options }, stream) {
270
305
  return prepared;
271
306
  }
272
307
  const { state, heal } = prepared;
308
+ preflightedExecution = await preflightRecipe(
309
+ adapter,
310
+ validated.recipeFile,
311
+ artifactsDir,
312
+ target,
313
+ optionString(options, "actionManifest"),
314
+ runtimeOptions
315
+ );
273
316
  networkObservation = await startRunNetworkObservation(
274
317
  adapter,
275
318
  target,
@@ -280,6 +323,16 @@ async function handleRunInner({ positional, options }, stream) {
280
323
  watcherPort: runtimeOptions.watcherPort
281
324
  }
282
325
  );
326
+ performanceObservation = startRunPerformanceObservation(
327
+ adapter,
328
+ target,
329
+ artifactsDir,
330
+ process.env,
331
+ {
332
+ cdpPort: runtimeOptions.cdpPort,
333
+ watcherPort: runtimeOptions.watcherPort
334
+ }
335
+ );
283
336
  stream.phase("execute");
284
337
  let executionResult;
285
338
  try {
@@ -296,7 +349,8 @@ async function handleRunInner({ positional, options }, stream) {
296
349
  target,
297
350
  optionString(options, "actionManifest"),
298
351
  runtimeOptions,
299
- execution
352
+ execution,
353
+ state
300
354
  );
301
355
  },
302
356
  adapter,
@@ -307,15 +361,25 @@ async function handleRunInner({ positional, options }, stream) {
307
361
  (code) => stream.phase("recover", { code })
308
362
  );
309
363
  } catch (error) {
310
- await networkObservation?.finalize();
364
+ await networkObservation?.finalize().catch(() => void 0);
311
365
  networkObservation = void 0;
366
+ await performanceObservation?.finalize().catch(() => void 0);
367
+ performanceObservation = void 0;
312
368
  throw error;
313
369
  }
314
370
  const { result, violation } = executionResult;
315
371
  await networkObservation?.finalize(result.artifactManifestPath);
316
372
  networkObservation = void 0;
373
+ await performanceObservation?.finalize(result.artifactManifestPath);
374
+ performanceObservation = void 0;
317
375
  for (const mutation of state.mutations) stream.mutation(mutation);
318
376
  for (const recovery of state.recovered) stream.recovery(recovery);
377
+ persistRunEffects(result.summaryPath, result.artifactManifestPath, state);
378
+ indexProductProvenanceArtifact(
379
+ result.artifactManifestPath,
380
+ target,
381
+ adapter
382
+ );
319
383
  if (violation !== null) {
320
384
  const userAction = violation.userAction ?? `inspect ${shellQuote(result.summaryPath)} and ${shellQuote(result.tracePath)}; fix the application or recipe failure before retrying`;
321
385
  stream.error({
@@ -0,0 +1,342 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import fs, { constants as fsConstants } from "node:fs";
4
+ import path from "node:path";
5
+ import { mobileSourceFingerprint } from "./adapters/mobile/source-freshness.js";
6
+ import { importRecipeProtocol, runnerDir } from "./paths.js";
7
+ const gitContexts = /* @__PURE__ */ new Map();
8
+ let protocolPromise;
9
+ class ProvenanceDriftError extends Error {
10
+ code = "PROVENANCE_DRIFT";
11
+ exitCode = 5;
12
+ userAction;
13
+ provenancePath;
14
+ drift;
15
+ constructor(provenancePath, drift, cause) {
16
+ const fields = [...new Set(drift.map((entry) => entry.field))].join(", ");
17
+ super(
18
+ `Execution inputs changed after preparation (${fields}); evidence is invalid.`,
19
+ cause === void 0 ? void 0 : { cause }
20
+ );
21
+ this.name = "ProvenanceDriftError";
22
+ this.provenancePath = provenancePath;
23
+ this.drift = drift;
24
+ this.userAction = `restore the prepared recipe, library, and product source, then rerun; inspect ${provenancePath}`;
25
+ }
26
+ }
27
+ async function captureExecutionProvenance(input, phase) {
28
+ const protocol = await (protocolPromise ??= importRecipeProtocol());
29
+ const recipeDigest = input.recipePath ? recipeFileDigest(protocol, input.recipePath) : protocol.digestRecipeDocument(input.recipeDocument);
30
+ const libraries = (input.librarySources ?? []).map((source, index) => ({
31
+ name: source.name ?? `library-${index + 1}`,
32
+ ...sourceSnapshot(source.root)
33
+ }));
34
+ const runnerRoot = input.runnerRoot ?? runnerDir;
35
+ return {
36
+ phase,
37
+ capturedAt: (/* @__PURE__ */ new Date()).toISOString(),
38
+ recipeDigest,
39
+ product: sourceSnapshot(
40
+ input.projectRoot,
41
+ input.adapter,
42
+ input.excludedProductRoots
43
+ ),
44
+ runner: sourceSnapshot(runnerRoot, void 0, [], [
45
+ "src",
46
+ "adapters",
47
+ "library",
48
+ "bin",
49
+ "dist",
50
+ "package.json"
51
+ ]),
52
+ libraries,
53
+ helpers: (input.helperPaths ?? []).map((helperPath) => ({
54
+ path: displayPath(input.projectRoot, helperPath),
55
+ sourceFingerprint: fileFingerprint(helperPath)
56
+ }))
57
+ };
58
+ }
59
+ function executionProvenanceDrift(start, current) {
60
+ const drift = [];
61
+ if (current.phase === "start") return drift;
62
+ const phase = current.phase;
63
+ compare(drift, phase, "recipeDigest", start.recipeDigest, current.recipeDigest);
64
+ compareSource(drift, phase, "product", start.product, current.product);
65
+ compareSource(drift, phase, "runner", start.runner, current.runner);
66
+ if (start.libraries.length !== current.libraries.length) {
67
+ compare(
68
+ drift,
69
+ phase,
70
+ "libraries.length",
71
+ String(start.libraries.length),
72
+ String(current.libraries.length)
73
+ );
74
+ }
75
+ const count = Math.min(start.libraries.length, current.libraries.length);
76
+ for (let index = 0; index < count; index += 1) {
77
+ const expected = start.libraries[index];
78
+ const actual = current.libraries[index];
79
+ const prefix = `libraries[${index}]`;
80
+ compare(drift, phase, `${prefix}.name`, expected.name, actual.name);
81
+ compareSource(drift, phase, prefix, expected, actual);
82
+ }
83
+ if (start.helpers.length !== current.helpers.length) {
84
+ compare(
85
+ drift,
86
+ phase,
87
+ "helpers.length",
88
+ String(start.helpers.length),
89
+ String(current.helpers.length)
90
+ );
91
+ }
92
+ const helperCount = Math.min(start.helpers.length, current.helpers.length);
93
+ for (let index = 0; index < helperCount; index += 1) {
94
+ compare(drift, phase, `helpers[${index}].path`, start.helpers[index].path, current.helpers[index].path);
95
+ compare(
96
+ drift,
97
+ phase,
98
+ `helpers[${index}].sourceFingerprint`,
99
+ start.helpers[index].sourceFingerprint,
100
+ current.helpers[index].sourceFingerprint
101
+ );
102
+ }
103
+ return drift;
104
+ }
105
+ function writeExecutionProvenance(artifactsDir, snapshots, drift, artifactManifestPath) {
106
+ const provenancePath = path.join(path.resolve(artifactsDir), "execution-provenance.json");
107
+ const record = {
108
+ schemaVersion: 1,
109
+ valid: drift.length === 0,
110
+ snapshots,
111
+ drift
112
+ };
113
+ fs.mkdirSync(path.dirname(provenancePath), { recursive: true });
114
+ fs.writeFileSync(provenancePath, `${JSON.stringify(record, null, 2)}
115
+ `);
116
+ if (artifactManifestPath) indexProvenanceArtifact(artifactManifestPath, record);
117
+ return provenancePath;
118
+ }
119
+ function recipeFileDigest(protocol, recipePath) {
120
+ const source = readRegularFileNoFollow(recipePath);
121
+ try {
122
+ return protocol.digestRecipeDocument(JSON.parse(source.toString("utf8")));
123
+ } catch {
124
+ return `invalid:sha256:${createHash("sha256").update(source).digest("hex")}`;
125
+ }
126
+ }
127
+ function sourceSnapshot(root, adapter, excludedRoots = [], includedRoots = []) {
128
+ const git = gitContext(root);
129
+ if (!git) {
130
+ return {
131
+ head: null,
132
+ status: "not-a-git-checkout",
133
+ sourceFingerprint: directoryFingerprint(root, excludedRoots, includedRoots)
134
+ };
135
+ }
136
+ const pathspecs = sourcePathspecs(git, excludedRoots, includedRoots);
137
+ const status = gitText(git.topLevel, [
138
+ "status",
139
+ "--porcelain=v1",
140
+ "--untracked-files=normal",
141
+ "--",
142
+ ...pathspecs
143
+ ]);
144
+ return {
145
+ head: gitText(git.topLevel, ["rev-parse", "HEAD"]).trim() || null,
146
+ status,
147
+ sourceFingerprint: adapter === "mobile" && path.resolve(root) === git.topLevel ? mobileSourceFingerprint(root) : nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots)
148
+ };
149
+ }
150
+ function nestedGitSourceFingerprint(root, git, pathspecs, excludedRoots, includedRoots) {
151
+ const gitFingerprint = gitSourceFingerprint(git.topLevel, pathspecs);
152
+ if (git.pathspec === "." && includedRoots.length === 0) return gitFingerprint;
153
+ return createHash("sha256").update(gitFingerprint).update("\0nested-source\0").update(directoryFingerprint(root, excludedRoots, includedRoots)).digest("hex");
154
+ }
155
+ function gitContext(root) {
156
+ const cacheKey = path.resolve(root);
157
+ if (gitContexts.has(cacheKey)) return gitContexts.get(cacheKey) ?? null;
158
+ try {
159
+ const resolved = cacheKey;
160
+ const topLevel = fs.realpathSync(
161
+ gitText(resolved, ["rev-parse", "--show-toplevel"]).trim()
162
+ );
163
+ const relative = path.relative(topLevel, fs.realpathSync(resolved));
164
+ const context = relative === ".." || relative.startsWith(`..${path.sep}`) ? null : { topLevel, pathspec: relative || "." };
165
+ gitContexts.set(cacheKey, context);
166
+ return context;
167
+ } catch {
168
+ gitContexts.set(cacheKey, null);
169
+ return null;
170
+ }
171
+ }
172
+ function gitSourceFingerprint(topLevel, pathspecs) {
173
+ const hash = createHash("sha256");
174
+ hash.update(gitBuffer(topLevel, ["rev-parse", "HEAD"]));
175
+ hash.update("\0diff\0");
176
+ hash.update(
177
+ gitBuffer(topLevel, [
178
+ "diff",
179
+ "--no-ext-diff",
180
+ "--binary",
181
+ "HEAD",
182
+ "--",
183
+ ...pathspecs
184
+ ])
185
+ );
186
+ const untracked = gitText(topLevel, [
187
+ "ls-files",
188
+ "--others",
189
+ "--exclude-standard",
190
+ "-z",
191
+ "--",
192
+ ...pathspecs
193
+ ]).split("\0").filter(Boolean).sort();
194
+ for (const relative of untracked) {
195
+ hash.update(`\0untracked\0${relative}\0`);
196
+ const absolute = path.join(topLevel, relative);
197
+ hash.update(readPathIdentity(absolute));
198
+ }
199
+ return hash.digest("hex");
200
+ }
201
+ function sourcePathspecs(git, excludedRoots, includedRoots) {
202
+ const exclusions = excludedRoots.flatMap((root) => {
203
+ const relative = path.relative(git.topLevel, path.resolve(root));
204
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? [`:(exclude)${relative}`] : [];
205
+ });
206
+ const inclusions = includedRoots.length > 0 ? includedRoots.map((root) => path.join(git.pathspec === "." ? "" : git.pathspec, root)) : [git.pathspec];
207
+ return [...inclusions, ...exclusions];
208
+ }
209
+ function directoryFingerprint(root, excludedRoots, includedRoots) {
210
+ const hash = createHash("sha256");
211
+ const resolvedRoot = path.resolve(root);
212
+ const roots = includedRoots.length > 0 ? includedRoots : [""];
213
+ for (const included of roots) {
214
+ hashDirectory(
215
+ hash,
216
+ resolvedRoot,
217
+ included,
218
+ new Set(excludedRoots.map((entry) => path.resolve(entry)))
219
+ );
220
+ }
221
+ return hash.digest("hex");
222
+ }
223
+ function hashDirectory(hash, root, relative, excludedRoots) {
224
+ const absolute = path.join(root, relative);
225
+ if ([...excludedRoots].some(
226
+ (excluded) => absolute === excluded || absolute.startsWith(`${excluded}${path.sep}`)
227
+ )) return;
228
+ let stat;
229
+ try {
230
+ stat = fs.lstatSync(absolute);
231
+ } catch (error) {
232
+ if (error.code === "ENOENT") {
233
+ hash.update(`absent\0${relative}\0`);
234
+ return;
235
+ }
236
+ throw error;
237
+ }
238
+ if (stat.isDirectory()) {
239
+ hash.update(`directory\0${relative}\0`);
240
+ for (const name of fs.readdirSync(absolute).sort()) {
241
+ if (relative === "" && (name === ".git" || name === "node_modules" || name === "temp")) continue;
242
+ hashDirectory(hash, root, path.join(relative, name), excludedRoots);
243
+ }
244
+ return;
245
+ }
246
+ if (stat.isSymbolicLink()) {
247
+ throw new Error(`Execution provenance source must not be a symbolic link: ${absolute}`);
248
+ }
249
+ hash.update(`file\0${relative}\0`);
250
+ hash.update(readRegularFileNoFollow(absolute));
251
+ hash.update("\0");
252
+ }
253
+ function fileFingerprint(filePath) {
254
+ return createHash("sha256").update(readRegularFileNoFollow(filePath)).digest("hex");
255
+ }
256
+ function readPathIdentity(filePath) {
257
+ const stat = fs.lstatSync(filePath);
258
+ if (stat.isSymbolicLink()) {
259
+ throw new Error(`Execution provenance source must not be a symbolic link: ${filePath}`);
260
+ }
261
+ return readRegularFileNoFollow(filePath);
262
+ }
263
+ function readRegularFileNoFollow(filePath) {
264
+ let fd;
265
+ try {
266
+ fd = fs.openSync(filePath, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
267
+ } catch (error) {
268
+ if (error.code === "ELOOP") {
269
+ throw new Error(`Execution provenance source must not be a symbolic link: ${filePath}`);
270
+ }
271
+ throw error;
272
+ }
273
+ try {
274
+ if (!fs.fstatSync(fd).isFile()) {
275
+ throw new Error(`Execution provenance source is not a regular file: ${filePath}`);
276
+ }
277
+ return fs.readFileSync(fd);
278
+ } finally {
279
+ fs.closeSync(fd);
280
+ }
281
+ }
282
+ function displayPath(projectRoot, filePath) {
283
+ const relative = path.relative(path.resolve(projectRoot), path.resolve(filePath));
284
+ return relative !== ".." && !relative.startsWith(`..${path.sep}`) ? relative || "." : path.resolve(filePath);
285
+ }
286
+ function gitText(cwd, args) {
287
+ return gitBuffer(cwd, args).toString("utf8");
288
+ }
289
+ function gitBuffer(cwd, args) {
290
+ return execFileSync("git", args, {
291
+ cwd,
292
+ encoding: "buffer",
293
+ maxBuffer: 128 * 1024 * 1024,
294
+ stdio: ["ignore", "pipe", "ignore"]
295
+ });
296
+ }
297
+ function compareSource(drift, phase, prefix, start, current) {
298
+ compare(drift, phase, `${prefix}.head`, start.head, current.head);
299
+ compare(drift, phase, `${prefix}.status`, start.status, current.status);
300
+ compare(
301
+ drift,
302
+ phase,
303
+ `${prefix}.sourceFingerprint`,
304
+ start.sourceFingerprint,
305
+ current.sourceFingerprint
306
+ );
307
+ }
308
+ function compare(drift, phase, field, start, current) {
309
+ if (start !== current) drift.push({ phase, field, start, current });
310
+ }
311
+ function indexProvenanceArtifact(artifactManifestPath, record) {
312
+ let manifest;
313
+ try {
314
+ manifest = JSON.parse(fs.readFileSync(artifactManifestPath, "utf8"));
315
+ } catch {
316
+ return;
317
+ }
318
+ if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) return;
319
+ const document = manifest;
320
+ const artifacts = Array.isArray(document.artifacts) ? document.artifacts : [];
321
+ document.artifacts = [
322
+ ...artifacts.filter((artifact) => !artifact || typeof artifact !== "object" || Array.isArray(artifact) || artifact.path !== "execution-provenance.json"),
323
+ {
324
+ path: "execution-provenance.json",
325
+ type: "json",
326
+ label: "Execution provenance",
327
+ category: "system",
328
+ metadata: {
329
+ valid: record.valid,
330
+ errorCode: record.valid ? null : "PROVENANCE_DRIFT"
331
+ }
332
+ }
333
+ ];
334
+ fs.writeFileSync(artifactManifestPath, `${JSON.stringify(document, null, 2)}
335
+ `);
336
+ }
337
+ export {
338
+ ProvenanceDriftError,
339
+ captureExecutionProvenance,
340
+ executionProvenanceDrift,
341
+ writeExecutionProvenance
342
+ };