@cleocode/core 2026.10.24 → 2026.10.25-rc.ci.696
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/dist/build-provenance.json +2 -2
- package/dist/tasks/done-plan.d.ts.map +1 -1
- package/dist/tasks/done-plan.js +61 -8
- package/dist/tasks/done-plan.js.map +1 -1
- package/dist/tasks/gate-runner.d.ts +26 -0
- package/dist/tasks/gate-runner.d.ts.map +1 -1
- package/dist/tasks/gate-runner.js +323 -68
- package/dist/tasks/gate-runner.js.map +1 -1
- package/package.json +14 -14
- package/templates/CLEO-INJECTION.md +1 -1
- package/templates/CLEO-REFERENCE.md +2 -2
|
@@ -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
|
|
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
|
|
832
|
-
|
|
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
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
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
|
-
|
|
1050
|
+
await revalidateTypedResult(scope, index, gate);
|
|
865
1051
|
}
|
|
866
1052
|
catch (error) {
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
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
|
|
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
|
-
*
|
|
1135
|
+
* Inherited variables every process gate's cache key covers (T14034).
|
|
964
1136
|
*
|
|
965
|
-
*
|
|
966
|
-
*
|
|
967
|
-
*
|
|
968
|
-
*
|
|
969
|
-
* the
|
|
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
|
|
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
|
-
|
|
982
|
-
.
|
|
983
|
-
.
|
|
984
|
-
|
|
985
|
-
|
|
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:
|
|
1025
|
-
|
|
1026
|
-
|
|
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
|
/**
|