@cleocode/core 2026.10.24 → 2026.10.25

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.
@@ -28,9 +28,10 @@ import { execFileSync } from 'node:child_process';
28
28
  import { createHash, randomBytes, randomUUID } from 'node:crypto';
29
29
  import { constants, mkdirSync, readFileSync, realpathSync, renameSync, writeFileSync, } from 'node:fs';
30
30
  import { lstat, open, readFile, realpath, stat } from 'node:fs/promises';
31
- import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
31
+ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
32
32
  import { isDeepStrictEqual } from 'node:util';
33
33
  import { acceptanceGateResultSchema, acceptanceGateSchema, testCountReportSchema, testCountSummarySchema, } from '@cleocode/contracts/acceptance-gate-schema.js';
34
+ import { findOnPath } from '@cleocode/paths';
34
35
  import { resolveOriginDefault } from '../git/base-ref.js';
35
36
  import { discoveryEnv, gitToplevel, resolveDeclaredEvidenceGitRoot } from '../git/work-tree.js';
36
37
  import { getProjectRoot } from '../paths.js';
@@ -464,9 +465,17 @@ function gitOut(cwd, args) {
464
465
  /** Digest of a gate's inputs with every path made relative to `tree` (T12625). */
465
466
  function treeInputsHash(tree, invocation, artifacts) {
466
467
  const rel = (path) => relative(tree, path) || '.';
468
+ // T14063: an executable inside the tree (node_modules/.bin) is tree-relative too.
469
+ const exe = invocation?.executable;
467
470
  return createHash('sha256')
468
471
  .update(JSON.stringify({
469
- invocation: invocation ? { ...invocation, cwd: rel(invocation.cwd) } : null,
472
+ invocation: invocation
473
+ ? {
474
+ ...invocation,
475
+ cwd: rel(invocation.cwd),
476
+ ...(exe && isWithin(tree, exe) ? { executable: rel(exe) } : {}),
477
+ }
478
+ : null,
470
479
  artifacts: artifacts.map((a) => ({ ...a, path: rel(a.path) })),
471
480
  }))
472
481
  .digest('hex');
@@ -812,6 +821,178 @@ export function typedResultStaleReason(result, task, gate, row, owner, requirePa
812
821
  return 'binding is stale or belongs to another owner';
813
822
  return null;
814
823
  }
824
+ /** Whether `path` is `dir` or lies under it. */
825
+ function isWithin(dir, path) {
826
+ const rel = relative(dir, path);
827
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
828
+ }
829
+ /**
830
+ * Whether `binding` was verified in `tree` itself (T14063). The bound tree is
831
+ * the invocation's cwd less its recorded tree-relative part; a gate without an
832
+ * invocation was verified here when every input it captured lies in `tree`.
833
+ */
834
+ function boundInTree(binding, tree) {
835
+ if (binding.invocation && binding.tree) {
836
+ const up = binding.tree.cwd === '.' ? [] : binding.tree.cwd.split(/[\\/]/).map(() => '..');
837
+ return resolve(binding.invocation.cwd, ...up) === tree;
838
+ }
839
+ if (binding.invocation)
840
+ return isWithin(tree, binding.invocation.cwd);
841
+ return binding.artifacts.every((artifact) => isWithin(tree, artifact.path));
842
+ }
843
+ /**
844
+ * What moved between a result verified in this tree and the inputs now
845
+ * (T14063): the invocation, the named environment entries and executable, or
846
+ * the input files. Worded to follow "Typed requirement <id> ".
847
+ */
848
+ function describeTypedDrift(bound, current, artifacts, tree) {
849
+ const parts = [];
850
+ const was = bound.invocation;
851
+ if (!was || !current) {
852
+ if (was !== current)
853
+ parts.push('invocation changed (it now runs no process)');
854
+ }
855
+ else {
856
+ const moved = [
857
+ ...(was.command !== current.command ? ['command'] : []),
858
+ ...(!isDeepStrictEqual(was.args, current.args) ? ['arguments'] : []),
859
+ ...(was.cwd !== current.cwd ? ['working directory'] : []),
860
+ ];
861
+ if (moved.length > 0)
862
+ parts.push(`invocation changed (${moved.join(', ')})`);
863
+ else if (was.environmentHash !== current.environmentHash) {
864
+ if (!was.environmentKeys) {
865
+ parts.push('environment changed (verified before T14063, when the whole PATH was an input)');
866
+ }
867
+ else {
868
+ const now = current.environmentKeys ?? {};
869
+ const names = [...new Set([...Object.keys(was.environmentKeys), ...Object.keys(now)])]
870
+ .filter((name) => was.environmentKeys?.[name] !== now[name])
871
+ .sort();
872
+ if (was.executable !== current.executable)
873
+ names.push(`executable ${was.executable ?? '(none)'} -> ${current.executable ?? '(none)'}`);
874
+ parts.push(`environment changed (${names.join(', ') || 'unknown'})`);
875
+ }
876
+ }
877
+ }
878
+ const before = new Map(bound.artifacts.map((a) => [a.path, a.sha256]));
879
+ const after = new Map(artifacts.map((a) => [a.path, a.sha256]));
880
+ const changed = [...new Set([...before.keys(), ...after.keys()])]
881
+ .filter((path) => before.get(path) !== after.get(path))
882
+ .map((path) => relative(tree, path) || path)
883
+ .sort();
884
+ if (changed.length > 0)
885
+ parts.push(`input bytes changed (${changed.join(', ')})`);
886
+ return `${parts.join('; ') || 'inputs changed'} since verification`;
887
+ }
888
+ /** `value` single-quoted for a POSIX shell. */
889
+ function shellArg(value) {
890
+ return `'${value.replace(/'/g, `'\\''`)}'`;
891
+ }
892
+ /**
893
+ * The cheapest command that re-binds a task's typed results in `tree`
894
+ * (T14063): re-record a gate whose evidence runs nothing (`implemented`, when
895
+ * its atoms are commits, files, PRs, decisions and criterion links) with
896
+ * `--run-typed`, which re-runs only the typed gates no current pass covers.
897
+ * Otherwise it re-records testsPassed ({@link reverifyCommand}).
898
+ */
899
+ function rebindCommand(task, tree) {
900
+ const atoms = task.verification?.evidence?.implemented?.atoms ?? [];
901
+ const cheap = atoms.map((atom) => {
902
+ switch (atom.kind) {
903
+ case 'commit':
904
+ return `commit:${atom.sha}`;
905
+ case 'files':
906
+ return `files:${atom.files.map((f) => f.path).join(',')}`;
907
+ case 'decision':
908
+ return `decision:${atom.decisionId}`;
909
+ case 'pr':
910
+ return `pr:${atom.prNumber}`;
911
+ case 'satisfies':
912
+ return `satisfies:${atom.targetTaskId}#${atom.targetAcAlias ?? atom.resolvedAcUuid ?? atom.targetAcId ?? ''}`;
913
+ default:
914
+ return null;
915
+ }
916
+ });
917
+ if (cheap.length === 0 || cheap.some((atom) => atom === null))
918
+ return `${reverifyCommand(task, tree)} --run-typed`;
919
+ return `cd ${shellArg(tree)} && cleo verify ${task.id} --gate implemented --evidence ${shellArg(cheap.join(';'))} --run-typed`;
920
+ }
921
+ /** Revalidate the stored result of the typed gate at `index`; throws when it does not stand. */
922
+ async function revalidateTypedResult(scope, index, gate) {
923
+ const { task, rows, results, execution, root, tree, env, requirePassing } = scope;
924
+ execution.assertActive();
925
+ const matches = results.filter((result) => result.index === index);
926
+ if (matches.length !== 1)
927
+ throw new Error(`Typed requirement ${gate.req ?? index} has no unique verified result`);
928
+ const result = acceptanceGateResultSchema.parse(matches[0]);
929
+ const stale = typedResultStaleReason(result, task, gate, rows[index], { projectId: execution.identity.projectId, projectRoot: root }, requirePassing);
930
+ if (stale)
931
+ throw new Error(`Typed requirement ${gate.req ?? index} ${stale}`);
932
+ const binding = result.binding;
933
+ // A refused command is bound without an invocation (T12718 review), so it
934
+ // revalidates as the unmet `error` it was recorded as.
935
+ const { invocation: resolved } = resolveInvocation(gate, tree, env);
936
+ // T13527: an unmet result recorded because its inputs could not be
937
+ // captured has nothing to re-capture; completion (requirePassing) never
938
+ // reaches here with it, because only a pass stands there.
939
+ let artifacts;
940
+ try {
941
+ artifacts = await snapshotGateInputs(task, gate, resolved, execution, tree);
942
+ }
943
+ catch (error) {
944
+ execution.assertActive();
945
+ if (!requirePassing && result.result !== 'pass')
946
+ return;
947
+ throw error;
948
+ }
949
+ // T14063: a receipt recorded before T14063 is compared in its own shape.
950
+ const invocation = resolved && binding.invocation && !binding.invocation.environmentKeys
951
+ ? legacyInvocation(gate, resolved, env)
952
+ : resolved;
953
+ if (isDeepStrictEqual(binding.invocation, invocation) &&
954
+ isDeepStrictEqual(binding.artifacts, artifacts))
955
+ return;
956
+ const label = gate.req ?? String(index);
957
+ // T12625: a result bound in another tree of this project (a worker's
958
+ // worktree) is accepted by CONTENT, not by path. T14063: never for the tree
959
+ // it was verified in, where a mismatch is drift to name, not a carry.
960
+ if (binding.tree && !boundInTree(binding, tree)) {
961
+ assertTreeEquivalent(task, label, binding.tree, tree, root, invocation, artifacts);
962
+ return;
963
+ }
964
+ // @sync-invariant none:input-shape a typed result whose inputs moved in its own tree is refused before the completion write; nothing is written
965
+ throw new TypedRevalidationError(`Typed requirement ${label} ${describeTypedDrift(binding, invocation, artifacts, tree)}`, rebindCommand(task, tree));
966
+ }
967
+ /**
968
+ * Open a revalidation scope over `task`'s typed results, or throw when its
969
+ * acceptance array and normalized rows disagree.
970
+ */
971
+ function typedRevalidationScope(task, criteria, results, options, requirePassing, cwd) {
972
+ const execution = options.execution ?? worktreeScope.getStore()?.execution;
973
+ if (!execution)
974
+ throw new Error('Typed result revalidation requires a captured execution lifetime');
975
+ const root = resolve(options.projectRoot ?? execution.identity.projectRoot);
976
+ const project = captureProjectScope(root, {
977
+ ...captureProjectScope(root, worktreeScope.getStore()),
978
+ execution,
979
+ });
980
+ const rows = [...criteria].sort((a, b) => a.ordinal - b.ordinal);
981
+ assertCriterionProjection(task, rows);
982
+ return {
983
+ scope: {
984
+ task,
985
+ rows,
986
+ results,
987
+ execution,
988
+ root,
989
+ tree: resolveTypedGateRoot(root, cwd),
990
+ env: { ...(options.env ?? process.env) },
991
+ requirePassing,
992
+ },
993
+ project,
994
+ };
995
+ }
815
996
  /**
816
997
  * Revalidate persisted typed results against current canonical task and input state.
817
998
  * @param task - Current task read under the caller's transaction ownership.
@@ -822,69 +1003,62 @@ export function typedResultStaleReason(result, task, gate, row, owner, requirePa
822
1003
  * @returns Resolves when bound inputs are current and the requested verdict policy holds.
823
1004
  * @remarks Historical unbound results and generic text-AC evidence cannot substitute.
824
1005
  * Filesystem checks are cooperative and cannot make external file writes atomic with SQLite.
1006
+ * A result verified in the completing tree whose inputs moved is refused with a
1007
+ * {@link TypedRevalidationError} naming what moved and the re-bind command (T14063).
825
1008
  * @example
826
1009
  * ```typescript
827
1010
  * await revalidateTaskGateResults(task, rows, task.verification?.gateResults ?? [], { execution });
828
1011
  * ```
829
1012
  */
830
1013
  export async function revalidateTaskGateResults(task, criteria, results, options, requirePassing = true) {
831
- const execution = options.execution ?? worktreeScope.getStore()?.execution;
832
- if (!execution)
833
- throw new Error('Typed result revalidation requires a captured execution lifetime');
834
- const root = resolve(options.projectRoot ?? execution.identity.projectRoot);
835
- const scope = captureProjectScope(root, {
836
- ...captureProjectScope(root, worktreeScope.getStore()),
837
- execution,
838
- });
839
- const rows = [...criteria].sort((a, b) => a.ordinal - b.ordinal);
840
- assertCriterionProjection(task, rows);
841
- const env = { ...(options.env ?? process.env) };
842
- const tree = resolveTypedGateRoot(root);
843
- await worktreeScope.run(scope, async () => {
1014
+ const { scope, project } = typedRevalidationScope(task, criteria, results, options, requirePassing);
1015
+ await worktreeScope.run(project, async () => {
844
1016
  for (const [index, gate] of (task.acceptance ?? []).entries()) {
845
1017
  if (typeof gate === 'string' || (requirePassing && gate.advisory))
846
1018
  continue;
847
- execution.assertActive();
848
- const matches = results.filter((result) => result.index === index);
849
- if (matches.length !== 1)
850
- throw new Error(`Typed requirement ${gate.req ?? index} has no unique verified result`);
851
- const result = acceptanceGateResultSchema.parse(matches[0]);
852
- const stale = typedResultStaleReason(result, task, gate, rows[index], { projectId: execution.identity.projectId, projectRoot: root }, requirePassing);
853
- if (stale)
854
- throw new Error(`Typed requirement ${gate.req ?? index} ${stale}`);
855
- const binding = result.binding;
856
- // A refused command is bound without an invocation (T12718 review), so it
857
- // revalidates as the unmet `error` it was recorded as.
858
- const { invocation } = resolveInvocation(gate, tree, env);
859
- // T13527: an unmet result recorded because its inputs could not be
860
- // captured has nothing to re-capture; completion (requirePassing) never
861
- // reaches here with it, because only a pass stands there.
862
- let artifacts;
1019
+ await revalidateTypedResult(scope, index, gate);
1020
+ }
1021
+ });
1022
+ }
1023
+ /**
1024
+ * Run completion's input revalidation over every stored typed PASS without
1025
+ * refusing (T14063), so `cleo done --plan` never reports a pass that
1026
+ * `cleo complete` would refuse.
1027
+ *
1028
+ * @param task - The task as stored.
1029
+ * @param criteria - Its normalized AC rows.
1030
+ * @param options - Execution lifetime, store root and environment, as completion passes them.
1031
+ * @param cwd - Directory `cleo complete` would run in; it selects the tree.
1032
+ * @returns The refusal per acceptance index; an index absent from the map stands.
1033
+ * @example
1034
+ * ```typescript
1035
+ * const drift = await typedResultDrift(task, rows, { execution, projectRoot }, runFrom);
1036
+ * ```
1037
+ * @task T14063
1038
+ */
1039
+ export async function typedResultDrift(task, criteria, options, cwd) {
1040
+ const results = task.verification?.gateResults ?? [];
1041
+ const { scope, project } = typedRevalidationScope(task, criteria, results, options, true, cwd);
1042
+ const drift = new Map();
1043
+ await worktreeScope.run(project, async () => {
1044
+ for (const [index, gate] of (task.acceptance ?? []).entries()) {
1045
+ if (typeof gate === 'string' || gate.advisory)
1046
+ continue;
1047
+ if (!results.some((r) => r.index === index && r.result === 'pass'))
1048
+ continue;
863
1049
  try {
864
- artifacts = await snapshotGateInputs(task, gate, invocation, execution, tree);
1050
+ await revalidateTypedResult(scope, index, gate);
865
1051
  }
866
1052
  catch (error) {
867
- execution.assertActive();
868
- if (!requirePassing && result.result !== 'pass')
869
- continue;
870
- throw error;
871
- }
872
- const exact = isDeepStrictEqual(binding.invocation, invocation) &&
873
- isDeepStrictEqual(binding.artifacts, artifacts);
874
- if (exact)
875
- continue;
876
- // T12625: a result bound in another tree of this project (a worker's
877
- // worktree) is accepted by CONTENT, not by path. Pre-T12625 receipts carry
878
- // no tree binding and keep the exact path-bound refusals below.
879
- if (binding.tree) {
880
- assertTreeEquivalent(task, gate.req ?? String(index), binding.tree, tree, root, invocation, artifacts);
881
- continue;
1053
+ const message = error instanceof Error ? error.message : String(error);
1054
+ drift.set(index, {
1055
+ reason: message.replace(/^Typed requirement \S+ /, ''),
1056
+ ...(error instanceof TypedRevalidationError ? { fix: error.fix } : {}),
1057
+ });
882
1058
  }
883
- if (!isDeepStrictEqual(binding.invocation, invocation))
884
- throw new Error(`Typed requirement ${gate.req ?? index} invocation or environment changed`);
885
- throw new Error(`Typed requirement ${gate.req ?? index} input bytes changed after verification`);
886
1059
  }
887
1060
  });
1061
+ return drift;
888
1062
  }
889
1063
  /**
890
1064
  * Validate completion authority from current typed inputs and the canonical verification receipt.
@@ -952,37 +1126,99 @@ function gateHeavyOverlay(merged, cwd, argv) {
952
1126
  const ceiling = vitestWorkerCeiling(cwd, argv) ?? undefined;
953
1127
  return planHeavyToolEnv('test', merged, undefined, ceiling).overlay;
954
1128
  }
955
- /** Effective child environment a gate launches with; its cache key is {@link gateEnvironmentHash}. */
1129
+ /** Effective child environment a gate launches with; its cache key is {@link gateEnvironmentDigest}. */
956
1130
  function gateEnvironment(env, overlay, cwd, argv) {
957
1131
  const merged = { ...env, ...overlay };
958
1132
  return { ...merged, ...gateHeavyOverlay(merged, cwd, argv) };
959
1133
  }
960
- /** Inherited variables every process gate's cache key covers (T14034). */
961
- const GATE_HASHED_ENV_KEYS = ['PATH', 'NODE_OPTIONS', 'CI', 'LANG'];
962
1134
  /**
963
- * Digest of the environment a gate's cached pass depends on (T14034).
1135
+ * Inherited variables every process gate's cache key covers (T14034).
964
1136
  *
965
- * Hashing the whole launch env made a `--run` pass unusable from any other
966
- * shell (`PWD`, `SHLVL`, `TERM_SESSION_ID`, harness ids differ), so evidence
967
- * writes refused with `E_GATE_NOT_CACHED`. Only the gate's `env` keys, its
968
- * declared `envKeys`, the heavy-run overlay and a fixed tool-input set count;
969
- * the launch env stays whole.
1137
+ * T14063: `PATH` is not one of them. It differs between shells for reasons that
1138
+ * never reach the gate (mise, nvm and direnv shims, `cleo run`, a harness
1139
+ * prepending its own bin), and a pass verified in one shell was refused at
1140
+ * completion from another. What `PATH` decides for the gate is which program
1141
+ * runs, so the resolved executable is hashed instead ({@link resolveGateExecutable});
1142
+ * a gate whose result depends on the whole search path lists `PATH` in
1143
+ * `envKeys`. The rest change results: `NODE_OPTIONS` sets the V8 flags of
1144
+ * every node process the gate starts, `CI` switches test runners (vitest
1145
+ * refuses `.only` and suites branch on it), and `LANG`/`LC_*` set the locale
1146
+ * that ICU collation, `Intl` formatting and tool messages follow.
1147
+ */
1148
+ const GATE_HASHED_ENV_KEYS = ['NODE_OPTIONS', 'CI', 'LANG'];
1149
+ /**
1150
+ * Real path of the program a gate's `command` runs, searched on the gate's own
1151
+ * launch `PATH` (T14063), or `undefined` when nothing resolves. A command
1152
+ * naming a path resolves against the gate's `cwd`, as the launch does.
970
1153
  */
971
- function gateEnvironmentHash(env, overlay, envKeys, cwd, argv) {
1154
+ function resolveGateExecutable(command, cwd, env) {
1155
+ const named = /[\\/]/.test(command) ? resolve(cwd, command) : command;
1156
+ const found = findOnPath(named, { env });
1157
+ if (found === null)
1158
+ return undefined;
1159
+ try {
1160
+ return realpathSync(found);
1161
+ }
1162
+ catch {
1163
+ return found;
1164
+ }
1165
+ }
1166
+ /**
1167
+ * A gate's effective launch environment and, sorted by name, the entries its
1168
+ * digest covers: the fixed tool inputs, `LC_*`, the gate's `env` keys, its
1169
+ * `envKeys`, the heavy-run overlay and any `extra` names.
1170
+ */
1171
+ function hashedGateEnvironment(env, overlay, envKeys, cwd, argv, extra = []) {
972
1172
  const merged = { ...env, ...overlay };
973
1173
  const heavy = gateHeavyOverlay(merged, cwd, argv);
974
1174
  const effective = { ...merged, ...heavy };
975
1175
  const keys = new Set([
1176
+ ...extra,
976
1177
  ...GATE_HASHED_ENV_KEYS,
977
1178
  ...Object.keys(overlay ?? {}),
978
1179
  ...(envKeys ?? []),
979
1180
  ...Object.keys(heavy),
980
1181
  ]);
981
- return createHash('sha256')
982
- .update(JSON.stringify(Object.entries(effective)
983
- .filter(([key, value]) => value !== undefined && (keys.has(key) || key.startsWith('LC_')))
984
- .sort(([a], [b]) => a.localeCompare(b))))
985
- .digest('hex');
1182
+ const entries = Object.entries(effective)
1183
+ .filter((entry) => entry[1] !== undefined && (keys.has(entry[0]) || entry[0].startsWith('LC_')))
1184
+ .sort(([a], [b]) => a.localeCompare(b));
1185
+ return { effective, entries };
1186
+ }
1187
+ /**
1188
+ * Digest of the environment a gate's cached pass depends on (T14034).
1189
+ *
1190
+ * Hashing the whole launch env made a `--run` pass unusable from any other
1191
+ * shell (`PWD`, `SHLVL`, `TERM_SESSION_ID`, harness ids differ), so evidence
1192
+ * writes refused with `E_GATE_NOT_CACHED`. Only the gate's `env` keys, its
1193
+ * declared `envKeys`, the heavy-run overlay, a fixed tool-input set and the
1194
+ * resolved executable count (T14063); the launch env stays whole.
1195
+ */
1196
+ function gateEnvironmentDigest(env, overlay, envKeys, command, root, cwd, argv) {
1197
+ const { effective, entries } = hashedGateEnvironment(env, overlay, envKeys, cwd, argv);
1198
+ const executable = resolveGateExecutable(command, cwd, effective);
1199
+ // Hashed relative to the tree when inside it, so another worktree of the
1200
+ // project, with its own node_modules, digests the same (T12625 carry).
1201
+ const hashed = executable && isWithin(root, executable) ? relative(root, executable) : executable;
1202
+ return {
1203
+ hash: createHash('sha256')
1204
+ .update(JSON.stringify({ executable: hashed ?? null, entries }))
1205
+ .digest('hex'),
1206
+ ...(executable ? { executable } : {}),
1207
+ keys: Object.fromEntries(entries.map((entry) => [
1208
+ entry[0],
1209
+ createHash('sha256').update(JSON.stringify(entry)).digest('hex'),
1210
+ ])),
1211
+ };
1212
+ }
1213
+ /**
1214
+ * The environment digest receipts recorded before T14063 carry: the same
1215
+ * entries plus the whole `PATH`, and no executable. Completion compares such a
1216
+ * receipt against it, so a task verified before the upgrade still completes
1217
+ * from an unchanged shell.
1218
+ */
1219
+ function legacyEnvironmentHash(env, overlay, envKeys, cwd, argv) {
1220
+ const { entries } = hashedGateEnvironment(env, overlay, envKeys, cwd, argv, ['PATH']);
1221
+ return createHash('sha256').update(JSON.stringify(entries)).digest('hex');
986
1222
  }
987
1223
  /**
988
1224
  * Resolve the actual executable inputs once using the same rules as process
@@ -1017,13 +1253,32 @@ export function executableInvocation(gate, root, env) {
1017
1253
  : []);
1018
1254
  const cwd = resolveCwd(root, gate.cwd);
1019
1255
  const argv = [command, ...args];
1256
+ const digest = gate.kind === 'lint'
1257
+ ? gateEnvironmentDigest(env, undefined, undefined, command, root, cwd, argv)
1258
+ : gateEnvironmentDigest(env, gate.env, gate.envKeys, command, root, cwd, argv);
1020
1259
  return {
1021
1260
  command,
1022
1261
  args: [...args],
1023
1262
  cwd,
1024
- environmentHash: gate.kind === 'lint'
1025
- ? gateEnvironmentHash(env, undefined, undefined, cwd, argv)
1026
- : gateEnvironmentHash(env, gate.env, gate.envKeys, cwd, argv),
1263
+ environmentHash: digest.hash,
1264
+ ...(digest.executable ? { executable: digest.executable } : {}),
1265
+ environmentKeys: digest.keys,
1266
+ };
1267
+ }
1268
+ /**
1269
+ * `current` in the shape a receipt recorded before T14063 carries: no
1270
+ * executable or per-key digests, and the environment hash that still covered
1271
+ * the whole `PATH` ({@link legacyEnvironmentHash}).
1272
+ */
1273
+ function legacyInvocation(gate, current, env) {
1274
+ const argv = [current.command, ...current.args];
1275
+ return {
1276
+ command: current.command,
1277
+ args: current.args,
1278
+ cwd: current.cwd,
1279
+ environmentHash: gate.kind === 'test' || gate.kind === 'command'
1280
+ ? legacyEnvironmentHash(env, gate.env, gate.envKeys, current.cwd, argv)
1281
+ : legacyEnvironmentHash(env, undefined, undefined, current.cwd, argv),
1027
1282
  };
1028
1283
  }
1029
1284
  /**