vouchington-tooling 0.1.7 → 0.1.9

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/README.md CHANGED
@@ -47,6 +47,15 @@ vouchington post-review
47
47
  vouchington stage-review-payload optional|required <source> <destination>
48
48
  ```
49
49
 
50
+ For persistent `pnpm-install`, v4 metadata tracks structural inputs separately from the
51
+ `--install-scripts` policy. A warm scripts-enabled tree can therefore toggle
52
+ `true → false → true` without forced reconciliation; a tree first installed with scripts disabled
53
+ uses one script-suppressed verification install followed by `pnpm rebuild --pending --recursive`.
54
+ When only newly pending dependency package IDs remain, it instead rebuilds those exact IDs without
55
+ rerunning first-party workspace hooks.
56
+ The command emits a structured non-secret provenance diagnostic identifying changed structural
57
+ categories, the last script policy, script capability, and native-binary health.
58
+
50
59
  `download-optional-run-artifacts` uses the current Actions run and host. Pattern mode discovers
51
60
  non-expired artifacts across the run, keeps the first result for each name (matching `gh run
52
61
  download`), and extracts each selected name into its own directory. Ordinary absence is reported as
@@ -0,0 +1,22 @@
1
+ export type DependencyUpdate = Record<'dependencyName' | 'directory' | 'newVersion' | 'packageEcosystem' | 'prevVersion' | 'updateType', string>;
2
+ export interface ScriptResult {
3
+ failures: string[];
4
+ infos: string[];
5
+ mutationRequests: Array<{
6
+ body: string;
7
+ headers: Record<string, string>;
8
+ method: string;
9
+ url: string;
10
+ }>;
11
+ restGetCalls: Array<Record<string, unknown>>;
12
+ warnings: string[];
13
+ }
14
+ export declare function update(prevVersion: string, newVersion: string, updateType: string, dependencyName?: string): DependencyUpdate;
15
+ export declare function runPolicy(metadata: DependencyUpdate[] | string | undefined, pullRequestOverrides?: Record<string, unknown>, freshPullRequestOverrides?: Record<string, unknown>, mergeToken?: string | undefined, mutationResult?: {
16
+ jsonError?: Error;
17
+ payload?: unknown;
18
+ status?: number;
19
+ }, dependabot?: {
20
+ directory: string;
21
+ ecosystem: string;
22
+ }, expectedBase?: string, expectedHead?: string, manualRules?: string, confirmedPullRequestOverrides?: Error | Record<string, unknown>, initialRefreshError?: Error): Promise<ScriptResult>;
@@ -0,0 +1,113 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parse as load } from 'yaml';
3
+ const source = readFileSync('.github/actions/dependabot-automerge/action.yml', 'utf8');
4
+ const action = load(source);
5
+ const script = action.runs.steps.find((step) => step.uses?.startsWith('actions/github-script@'))
6
+ ?.with?.script;
7
+ export function update(prevVersion, newVersion, updateType, dependencyName = 'example') {
8
+ return {
9
+ dependencyName,
10
+ directory: '/',
11
+ newVersion,
12
+ packageEcosystem: 'npm',
13
+ prevVersion,
14
+ updateType,
15
+ };
16
+ }
17
+ export async function runPolicy(metadata, pullRequestOverrides = {}, freshPullRequestOverrides = {}, mergeToken = 'merge-token', mutationResult = {}, dependabot = { directory: '/', ecosystem: 'npm' }, expectedBase, expectedHead, manualRules = '[]', confirmedPullRequestOverrides = freshPullRequestOverrides, initialRefreshError) {
18
+ if (!script)
19
+ throw new Error('Dependabot auto-merge script is missing');
20
+ const failures = [];
21
+ const infos = [];
22
+ const mutationRequests = [];
23
+ const restGetCalls = [];
24
+ const warnings = [];
25
+ const eventPullRequest = {
26
+ auto_merge: null,
27
+ base: { ref: 'main', sha: 'a'.repeat(40) },
28
+ draft: false,
29
+ head: {
30
+ ref: 'dependabot/npm_and_yarn/example-1.5.0',
31
+ repo: { full_name: 'example-owner/example-repo' },
32
+ sha: 'b'.repeat(40),
33
+ },
34
+ node_id: 'PR_node_id',
35
+ number: 123,
36
+ merged: false,
37
+ state: 'open',
38
+ title: 'Bump dependencies',
39
+ user: { login: 'dependabot[bot]' },
40
+ ...pullRequestOverrides,
41
+ };
42
+ const github = {
43
+ rest: {
44
+ pulls: {
45
+ async get(args) {
46
+ restGetCalls.push(args);
47
+ if (restGetCalls.length === 1 && initialRefreshError)
48
+ throw initialRefreshError;
49
+ if (restGetCalls.length > 1 && confirmedPullRequestOverrides instanceof Error)
50
+ throw confirmedPullRequestOverrides;
51
+ const overrides = restGetCalls.length === 1 || confirmedPullRequestOverrides instanceof Error
52
+ ? freshPullRequestOverrides
53
+ : confirmedPullRequestOverrides;
54
+ return { data: { ...eventPullRequest, ...overrides } };
55
+ },
56
+ },
57
+ },
58
+ };
59
+ const context = {
60
+ payload: { pull_request: eventPullRequest, repository: { default_branch: 'main' } },
61
+ repo: { owner: 'example-owner', repo: 'example-repo' },
62
+ };
63
+ const core = {
64
+ setFailed(message) {
65
+ failures.push(message);
66
+ },
67
+ info(message) {
68
+ infos.push(message);
69
+ },
70
+ warning(message) {
71
+ warnings.push(message);
72
+ },
73
+ };
74
+ const environment = {
75
+ DEPENDABOT_DIRECTORY: dependabot.directory,
76
+ DEPENDABOT_ECOSYSTEM: dependabot.ecosystem,
77
+ EXPECTED_BASE_SHA: expectedBase ?? String(eventPullRequest.base.sha),
78
+ EXPECTED_HEAD_SHA: expectedHead ?? String(eventPullRequest.head.sha),
79
+ GRAPHQL_URL: 'https://github.example.test/api/graphql',
80
+ MANUAL_UPDATE_RULES: manualRules,
81
+ };
82
+ if (metadata !== undefined)
83
+ environment.UPDATED_DEPENDENCIES_JSON =
84
+ typeof metadata === 'string' ? metadata : JSON.stringify(metadata);
85
+ if (mergeToken !== undefined)
86
+ environment.AUTOMERGE_TOKEN = mergeToken;
87
+ const fetch = async (url, init) => {
88
+ if (typeof init.body !== 'string')
89
+ throw new TypeError('Dependabot auto-merge mutation body must be a string');
90
+ const body = init.body;
91
+ mutationRequests.push({
92
+ body,
93
+ headers: init.headers,
94
+ method: String(init.method),
95
+ url,
96
+ });
97
+ return {
98
+ async json() {
99
+ if (mutationResult.jsonError)
100
+ throw mutationResult.jsonError;
101
+ return (mutationResult.payload ??
102
+ (body.includes('disablePullRequestAutoMerge')
103
+ ? { data: { disablePullRequestAutoMerge: { clientMutationId: null } } }
104
+ : { data: { enablePullRequestAutoMerge: { clientMutationId: null } } }));
105
+ },
106
+ ok: (mutationResult.status ?? 200) < 400,
107
+ status: mutationResult.status ?? 200,
108
+ };
109
+ };
110
+ const AsyncFunction = Object.getPrototypeOf(async () => undefined).constructor;
111
+ await new AsyncFunction('github', 'context', 'core', 'process', 'fetch', script)(github, context, core, { env: environment }, fetch);
112
+ return { failures, infos, mutationRequests, restGetCalls, warnings };
113
+ }
@@ -14,6 +14,8 @@ export declare function createGhPostReviewIo(options: {
14
14
  payloadBytes: Buffer;
15
15
  token: string;
16
16
  exec: GhExec;
17
+ expectedHeadSha?: string;
18
+ expectedBaseSha?: string;
17
19
  }): PostReviewIo;
18
20
  export declare function postReviewFromEnv(env?: NodeJS.ProcessEnv, exec?: GhExec, claudeIo?: import("./claude-token.mts").ClaudeTokenIo): Promise<{
19
21
  posted: boolean;
@@ -33,8 +33,25 @@ export function writePostedOutput(posted, outputPath = process.env.GITHUB_OUTPUT
33
33
  return;
34
34
  appendFileSync(outputPath, `posted=${posted ? 'true' : 'false'}\n`);
35
35
  }
36
+ function readPullRefs(repository, prNumber, exec) {
37
+ let lastError;
38
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
39
+ try {
40
+ return exec([
41
+ 'api',
42
+ `repos/${repository}/pulls/${prNumber}`,
43
+ '--jq',
44
+ '[.head.sha, .base.sha] | @tsv',
45
+ ]).split('\t');
46
+ }
47
+ catch (error) {
48
+ lastError = error;
49
+ }
50
+ }
51
+ throw lastError;
52
+ }
36
53
  export function createGhPostReviewIo(options) {
37
- const { repository, prNumber, payloadBytes, token, exec } = options;
54
+ const { repository, prNumber, payloadBytes, token, exec, expectedHeadSha, expectedBaseSha } = options;
38
55
  return {
39
56
  readFile() {
40
57
  return payloadBytes;
@@ -43,11 +60,34 @@ export function createGhPostReviewIo(options) {
43
60
  rmSync(path, { force: true });
44
61
  },
45
62
  getHeadSha() {
46
- const sha = exec(['api', `repos/${repository}/pulls/${prNumber}`, '--jq', '.head.sha']);
47
- if (!/^[0-9a-f]{40}$/u.test(sha)) {
48
- throw new ReviewPayloadError(`Could not resolve PR head SHA (got "${sha}").`);
63
+ if (!/^[1-9][0-9]*$/u.test(prNumber)) {
64
+ throw new ReviewPayloadError('PR_NUMBER must be a positive integer.');
65
+ }
66
+ if (Boolean(expectedHeadSha) !== Boolean(expectedBaseSha)) {
67
+ throw new ReviewPayloadError('EXPECTED_HEAD_SHA and EXPECTED_BASE_SHA must be provided together.');
68
+ }
69
+ if (expectedHeadSha && !/^[0-9a-f]{40}$/u.test(expectedHeadSha)) {
70
+ throw new ReviewPayloadError('EXPECTED_HEAD_SHA must be a full lowercase commit SHA.');
71
+ }
72
+ if (expectedBaseSha && !/^[0-9a-f]{40}$/u.test(expectedBaseSha)) {
73
+ throw new ReviewPayloadError('EXPECTED_BASE_SHA must be a full lowercase commit SHA.');
74
+ }
75
+ const refs = readPullRefs(repository, prNumber, exec);
76
+ const [headSha = '', baseSha = ''] = refs;
77
+ if (!/^[0-9a-f]{40}$/u.test(headSha)) {
78
+ throw new ReviewPayloadError(`Could not resolve PR head SHA (got "${headSha}").`);
79
+ }
80
+ if (!/^[0-9a-f]{40}$/u.test(baseSha)) {
81
+ throw new ReviewPayloadError(`Could not resolve PR base SHA (got "${baseSha}").`);
82
+ }
83
+ if (expectedHeadSha && headSha !== expectedHeadSha) {
84
+ throw new ReviewPayloadError('PR head changed before posting the selected review.');
85
+ }
86
+ if (expectedBaseSha && baseSha !== expectedBaseSha) {
87
+ throw new ReviewPayloadError('PR base changed before posting the selected review.');
49
88
  }
50
- return sha;
89
+ // Preserve the orchestrator-selected revision as the review commit_id after equality checks.
90
+ return expectedHeadSha || headSha;
51
91
  },
52
92
  listPullFiles() {
53
93
  return parseReviewFilesJson(exec(['api', '--paginate', `repos/${repository}/pulls/${prNumber}/files?per_page=100`]));
@@ -69,6 +109,8 @@ export async function postReviewFromEnv(env = process.env, exec = createGhExec()
69
109
  payloadBytes,
70
110
  token,
71
111
  exec,
112
+ expectedHeadSha: env.EXPECTED_HEAD_SHA ?? '',
113
+ expectedBaseSha: env.EXPECTED_BASE_SHA ?? '',
72
114
  }));
73
115
  const token = resolveReviewPostToken(env);
74
116
  if (token.source === 'github-token')
@@ -2,13 +2,18 @@ import { ReviewPayloadError, indexReviewFiles, parseReviewPayload, remapReviewCo
2
2
  export { MAX_REVIEW_COMMENTS as MAX_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES as MAX_PAYLOAD_BYTES, ReviewPayloadError as PostReviewError, } from '../gha-review-payload/index.mjs';
3
3
  export function runPostReview(payloadPath, io) {
4
4
  try {
5
- let review = parseReviewPayload(io.readFile(payloadPath), io.getHeadSha());
5
+ const selectedHeadSha = io.getHeadSha();
6
+ let review = parseReviewPayload(io.readFile(payloadPath), selectedHeadSha);
6
7
  try {
7
8
  review = remapReviewComments(review, indexReviewFiles(io.listPullFiles()));
8
9
  }
9
10
  catch {
10
11
  // Keep the parsed review when the PR file list is unavailable.
11
12
  }
13
+ // The adapter also revalidates a pinned base; equality catches legacy live-head drift.
14
+ if (io.getHeadSha() !== selectedHeadSha) {
15
+ throw new ReviewPayloadError('PR head changed while preparing the selected review.');
16
+ }
12
17
  const first = io.postReview(review);
13
18
  if (first.ok)
14
19
  return { posted: true };
@@ -6,4 +6,5 @@ export { formatReleaseAgeFailure, isReleaseAgeViolation, parseReleaseAgeViolatio
6
6
  export { flattenReleaseAgeSelectors, packageNameFromPnpmLockKey, pnpmLockPackageKeyMatchesSelector, validateReleaseAgeExemptionGroups, validateReleaseAgePolicy, } from './release-age-policy.mts';
7
7
  export type { ReleaseAgeExemptionGroup, ReleaseAgePermanentExemption, ReleaseAgePolicyConfig, ReleaseAgePolicySnapshot, } from './release-age-policy.mts';
8
8
  export { INSTALL_TERMINATION_FAILED, installExitCode, safeProcessGroup, startInstallHeartbeat, terminateProcessGroup, terminateSafeProcessGroup, } from './process.mts';
9
- export { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mts';
9
+ export { persistentDependencyTreeIsCold } from './metadata.mts';
10
+ export { persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata-legacy.mts';
@@ -4,4 +4,5 @@ export { baseInstallArgs, findWorkspaceLinkMismatches, listWorkspaces, logWorksp
4
4
  export { formatReleaseAgeFailure, isReleaseAgeViolation, parseReleaseAgeViolations, } from './release-age.mjs';
5
5
  export { flattenReleaseAgeSelectors, packageNameFromPnpmLockKey, pnpmLockPackageKeyMatchesSelector, validateReleaseAgeExemptionGroups, validateReleaseAgePolicy, } from './release-age-policy.mjs';
6
6
  export { INSTALL_TERMINATION_FAILED, installExitCode, safeProcessGroup, startInstallHeartbeat, terminateProcessGroup, terminateSafeProcessGroup, } from './process.mjs';
7
- export { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mjs';
7
+ export { persistentDependencyTreeIsCold } from './metadata.mjs';
8
+ export { persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata-legacy.mjs';
@@ -0,0 +1,4 @@
1
+ import { type CommandResult, type InstallOptions } from './support.mts';
2
+ export declare function withScriptPolicy(args: string[], installScripts: boolean): string[];
3
+ export declare function install(args: string[], options: InstallOptions, label: string): Promise<void>;
4
+ export declare function reconcileOrFail(options: InstallOptions, runCapture: (args: string[]) => Promise<CommandResult>): Promise<void>;
@@ -0,0 +1,38 @@
1
+ import { scheduler } from 'node:timers/promises';
2
+ import { runPnpm } from './exec.mjs';
3
+ import { INSTALL_TERMINATION_FAILED } from './process.mjs';
4
+ import { formatReleaseAgeFailure, isReleaseAgeViolation } from './release-age.mjs';
5
+ import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches, } from './support.mjs';
6
+ function fail(message) {
7
+ throw new Error(message);
8
+ }
9
+ export function withScriptPolicy(args, installScripts) {
10
+ return installScripts ? args : [...args, '--ignore-scripts'];
11
+ }
12
+ export async function install(args, options, label) {
13
+ for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
14
+ const result = await runPnpm(args, options);
15
+ if (result.code === 0)
16
+ return;
17
+ if (result.code === INSTALL_TERMINATION_FAILED)
18
+ fail(`${label} could not terminate safely`);
19
+ const output = `${result.output}\n${result.errorOutput ?? ''}`;
20
+ if (isReleaseAgeViolation(output))
21
+ fail(formatReleaseAgeFailure(label, output));
22
+ if (attempt < options.maxAttempts) {
23
+ console.warn(`${label} failed (attempt ${attempt}/${options.maxAttempts}); retrying`);
24
+ await scheduler.wait(5000);
25
+ }
26
+ }
27
+ fail(`${label} failed after ${options.maxAttempts} attempt${options.maxAttempts === 1 ? '' : 's'}`);
28
+ }
29
+ export async function reconcileOrFail(options, runCapture) {
30
+ const forced = ['install', '--frozen-lockfile', '--force', ...baseInstallArgs.slice(2)];
31
+ await install([...forced, '--ignore-scripts', '--ignore-pnpmfile'], options, 'script-free reconciliation');
32
+ await install(withScriptPolicy(forced, options.installScripts), options, 'strict persistent reconciliation');
33
+ const remaining = await findWorkspaceLinkMismatches(runCapture);
34
+ if (remaining.length > 0) {
35
+ logWorkspaceLinkMismatches(remaining);
36
+ fail('persistent reconciliation completed with invalid workspace links');
37
+ }
38
+ }
@@ -0,0 +1,4 @@
1
+ import { type CaptureCommand } from './support.mts';
2
+ export declare function persistentMetadataFingerprint(runCapture: CaptureCommand, installScripts: boolean): Promise<string>;
3
+ export declare function persistentMetadataMatches(fingerprint: string): Promise<boolean>;
4
+ export declare function writePersistentMetadataStamp(fingerprint: string): Promise<void>;
@@ -0,0 +1,81 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import { listWorkspaces, reportGlibcVersionRuntime } from './support.mjs';
5
+ function persistentMetadataStampPath() {
6
+ return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.v3.json');
7
+ }
8
+ function legacySharedStampPath() {
9
+ return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.json');
10
+ }
11
+ function addFingerprintInput(hash, label, value) {
12
+ hash.update(`${label.length}:${label}${value.length}:${value}`);
13
+ }
14
+ async function optionalFile(pathname) {
15
+ try {
16
+ return await readFile(pathname, 'utf8');
17
+ }
18
+ catch (error) {
19
+ if (error.code === 'ENOENT')
20
+ return '';
21
+ throw error;
22
+ }
23
+ }
24
+ function runtimePlatformIdentity() {
25
+ const report = process.report?.getReport();
26
+ return JSON.stringify({
27
+ arch: process.arch,
28
+ glibc: reportGlibcVersionRuntime(report),
29
+ modules: process.versions.modules,
30
+ node: process.version,
31
+ npmConfigArch: process.env.npm_config_arch ?? '',
32
+ npmConfigLibc: process.env.npm_config_libc ?? '',
33
+ npmConfigPlatform: process.env.npm_config_platform ?? '',
34
+ platform: process.platform,
35
+ });
36
+ }
37
+ export async function persistentMetadataFingerprint(runCapture, installScripts) {
38
+ const workspaces = (await listWorkspaces(runCapture)).toSorted((left, right) => left.path.localeCompare(right.path));
39
+ const pnpmVersion = await runCapture(['--version']);
40
+ if (pnpmVersion.code !== 0)
41
+ throw new Error(`pnpm --version failed: ${pnpmVersion.errorOutput?.trim() || pnpmVersion.output.trim() || 'unknown error'}`);
42
+ const hash = createHash('sha256');
43
+ addFingerprintInput(hash, 'runtime', runtimePlatformIdentity());
44
+ addFingerprintInput(hash, 'pnpm', pnpmVersion.output.trim());
45
+ addFingerprintInput(hash, 'installScripts', String(installScripts));
46
+ for (const filename of [
47
+ 'pnpm-lock.yaml',
48
+ 'pnpm-workspace.yaml',
49
+ '.npmrc',
50
+ '.pnpmfile.cjs',
51
+ '.pnpmfile.mjs',
52
+ ])
53
+ addFingerprintInput(hash, filename, await optionalFile(path.join(process.cwd(), filename)));
54
+ for (const workspace of workspaces)
55
+ addFingerprintInput(hash, path.relative(process.cwd(), path.join(workspace.path, 'package.json')), await readFile(path.join(workspace.path, 'package.json'), 'utf8'));
56
+ return hash.digest('hex');
57
+ }
58
+ export async function persistentMetadataMatches(fingerprint) {
59
+ for (const filename of [persistentMetadataStampPath(), legacySharedStampPath()]) {
60
+ try {
61
+ const stamp = JSON.parse(await readFile(filename, 'utf8'));
62
+ if (stamp.version === 3 && stamp.fingerprint === fingerprint)
63
+ return true;
64
+ }
65
+ catch { }
66
+ }
67
+ return false;
68
+ }
69
+ export async function writePersistentMetadataStamp(fingerprint) {
70
+ const stampPath = persistentMetadataStampPath();
71
+ const directory = path.dirname(stampPath);
72
+ const temporary = `${stampPath}.${process.pid}.tmp`;
73
+ await mkdir(directory, { recursive: true });
74
+ try {
75
+ await writeFile(temporary, `${JSON.stringify({ fingerprint, version: 3 })}\n`);
76
+ await rename(temporary, stampPath);
77
+ }
78
+ finally {
79
+ await rm(temporary, { force: true });
80
+ }
81
+ }
@@ -1,5 +1,18 @@
1
1
  import { type CaptureCommand } from './support.mts';
2
- export declare function persistentMetadataFingerprint(runCapture: CaptureCommand, installScripts: boolean): Promise<string>;
3
- export declare function persistentMetadataMatches(fingerprint: string): Promise<boolean>;
2
+ import { type ProvenanceStatus } from './transition.mts';
3
+ declare const componentNames: readonly ['lockfile', 'npm-config', 'pnpm', 'pnpmfiles', 'runtime', 'workspace-config', 'workspace-manifests'];
4
+ type ComponentName = (typeof componentNames)[number];
5
+ type StructuralProvenance = Record<ComponentName, string>;
6
+ export declare function persistentMetadataFingerprintV4(runCapture: CaptureCommand): Promise<{
7
+ lockfile: string;
8
+ 'npm-config': string;
9
+ pnpm: string;
10
+ pnpmfiles: string;
11
+ runtime: string;
12
+ 'workspace-config': string;
13
+ 'workspace-manifests': string;
14
+ }>;
15
+ export declare function persistentMetadataStatusV4(provenance: StructuralProvenance): Promise<ProvenanceStatus>;
16
+ export declare function writePersistentMetadataStampV4(provenance: StructuralProvenance, installScripts: boolean, resetScriptsEnabledCapability: boolean, pendingDependencyBuilds?: string[]): Promise<void>;
4
17
  export declare function persistentDependencyTreeIsCold(): Promise<boolean>;
5
- export declare function writePersistentMetadataStamp(fingerprint: string): Promise<void>;
18
+ export {};
@@ -2,15 +2,28 @@ import { createHash } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, stat, writeFile } from 'node:fs/promises';
3
3
  import path from 'node:path';
4
4
  import { listWorkspaces, reportGlibcVersionRuntime } from './support.mjs';
5
+ import {} from './transition.mjs';
6
+ const componentNames = [
7
+ 'lockfile',
8
+ 'npm-config',
9
+ 'pnpm',
10
+ 'pnpmfiles',
11
+ 'runtime',
12
+ 'workspace-config',
13
+ 'workspace-manifests',
14
+ ];
5
15
  function persistentMetadataStampPath() {
6
16
  return path.join(process.cwd(), 'node_modules', '.pnpm-install-metadata-health.json');
7
17
  }
8
- function fail(message) {
9
- throw new Error(message);
10
- }
11
18
  function addFingerprintInput(hash, label, value) {
12
19
  hash.update(`${label.length}:${label}${value.length}:${value}`);
13
20
  }
21
+ function componentHash(inputs) {
22
+ const hash = createHash('sha256');
23
+ for (const [label, value] of inputs)
24
+ addFingerprintInput(hash, label, value);
25
+ return hash.digest('hex');
26
+ }
14
27
  async function optionalFile(pathname) {
15
28
  try {
16
29
  return await readFile(pathname, 'utf8');
@@ -34,58 +47,112 @@ function runtimePlatformIdentity() {
34
47
  platform: process.platform,
35
48
  });
36
49
  }
37
- export async function persistentMetadataFingerprint(runCapture, installScripts) {
50
+ export async function persistentMetadataFingerprintV4(runCapture) {
38
51
  const workspaces = (await listWorkspaces(runCapture)).toSorted((left, right) => left.path.localeCompare(right.path));
39
52
  const pnpmVersion = await runCapture(['--version']);
40
53
  if (pnpmVersion.code !== 0)
41
- fail(`pnpm --version failed: ${pnpmVersion.errorOutput?.trim() || pnpmVersion.output.trim() || 'unknown error'}`);
42
- const hash = createHash('sha256');
43
- addFingerprintInput(hash, 'runtime', runtimePlatformIdentity());
44
- addFingerprintInput(hash, 'pnpm', pnpmVersion.output.trim());
45
- addFingerprintInput(hash, 'installScripts', String(installScripts));
46
- for (const filename of [
47
- 'pnpm-lock.yaml',
48
- 'pnpm-workspace.yaml',
49
- '.npmrc',
50
- '.pnpmfile.cjs',
51
- '.pnpmfile.mjs',
52
- ])
53
- addFingerprintInput(hash, filename, await optionalFile(path.join(process.cwd(), filename)));
54
- for (const workspace of workspaces)
55
- addFingerprintInput(hash, path.relative(process.cwd(), path.join(workspace.path, 'package.json')), await readFile(path.join(workspace.path, 'package.json'), 'utf8'));
56
- return hash.digest('hex');
54
+ throw new Error(`pnpm --version failed: ${pnpmVersion.errorOutput?.trim() || pnpmVersion.output.trim() || 'unknown error'}`);
55
+ const files = new Map(await Promise.all(['pnpm-lock.yaml', 'pnpm-workspace.yaml', '.npmrc', '.pnpmfile.cjs', '.pnpmfile.mjs'].map(async (filename) => [filename, await optionalFile(path.join(process.cwd(), filename))])));
56
+ const manifests = await Promise.all(workspaces.map(async (workspace) => {
57
+ const filename = path.relative(process.cwd(), path.join(workspace.path, 'package.json'));
58
+ return [filename, await readFile(path.join(workspace.path, 'package.json'), 'utf8')];
59
+ }));
60
+ return {
61
+ lockfile: componentHash([['pnpm-lock.yaml', files.get('pnpm-lock.yaml')]]),
62
+ 'npm-config': componentHash([['.npmrc', files.get('.npmrc')]]),
63
+ pnpm: componentHash([['version', pnpmVersion.output.trim()]]),
64
+ pnpmfiles: componentHash([
65
+ ['.pnpmfile.cjs', files.get('.pnpmfile.cjs')],
66
+ ['.pnpmfile.mjs', files.get('.pnpmfile.mjs')],
67
+ ]),
68
+ runtime: componentHash([['identity', runtimePlatformIdentity()]]),
69
+ 'workspace-config': componentHash([['pnpm-workspace.yaml', files.get('pnpm-workspace.yaml')]]),
70
+ 'workspace-manifests': componentHash(manifests),
71
+ };
57
72
  }
58
- export async function persistentMetadataMatches(fingerprint) {
59
- try {
60
- const stamp = JSON.parse(await readFile(persistentMetadataStampPath(), 'utf8'));
61
- return stamp.version === 3 && stamp.fingerprint === fingerprint;
62
- }
63
- catch {
73
+ function validStamp(value) {
74
+ if (typeof value !== 'object' || value === null)
64
75
  return false;
65
- }
76
+ const stamp = value;
77
+ return (stamp.version === 4 &&
78
+ typeof stamp.lastInvocationInstallScripts === 'boolean' &&
79
+ typeof stamp.scriptsEnabledInstallSucceeded === 'boolean' &&
80
+ typeof stamp.provenance === 'object' &&
81
+ stamp.provenance !== null &&
82
+ componentNames.every((name) => typeof stamp.provenance?.[name] === 'string') &&
83
+ (stamp.pendingDependencyBuilds === undefined ||
84
+ (Array.isArray(stamp.pendingDependencyBuilds) &&
85
+ stamp.pendingDependencyBuilds.every((id) => typeof id === 'string'))));
66
86
  }
67
- export async function persistentDependencyTreeIsCold() {
87
+ async function readPersistentMetadataState() {
68
88
  try {
69
- await stat(path.dirname(persistentMetadataStampPath()));
70
- return false;
89
+ const parsed = JSON.parse(await readFile(persistentMetadataStampPath(), 'utf8'));
90
+ return validStamp(parsed)
91
+ ? { kind: 'stamp', stamp: parsed }
92
+ : { kind: 'unsafe' };
71
93
  }
72
94
  catch (error) {
73
- if (error.code === 'ENOENT')
74
- return true;
75
- /* v8 ignore next -- non-ENOENT stat failures are host-specific */
76
- throw error;
95
+ return error.code === 'ENOENT'
96
+ ? { kind: 'missing' }
97
+ : { kind: 'unsafe' };
77
98
  }
78
99
  }
79
- export async function writePersistentMetadataStamp(fingerprint) {
100
+ async function readPersistentMetadataStamp() {
101
+ const state = await readPersistentMetadataState();
102
+ return state.kind === 'stamp' ? state.stamp : undefined;
103
+ }
104
+ export async function persistentMetadataStatusV4(provenance) {
105
+ const state = await readPersistentMetadataState();
106
+ if (state.kind === 'missing')
107
+ return { kind: 'absent' };
108
+ if (state.kind === 'unsafe')
109
+ return { kind: 'unsafe' };
110
+ const { stamp } = state;
111
+ const changed = componentNames.filter((name) => stamp.provenance[name] !== provenance[name]);
112
+ return changed.length > 0
113
+ ? { kind: 'changed', components: changed }
114
+ : {
115
+ kind: 'matching',
116
+ lastInvocationInstallScripts: stamp.lastInvocationInstallScripts,
117
+ pendingDependencyBuilds: stamp.pendingDependencyBuilds ?? [],
118
+ scriptsEnabledInstallSucceeded: stamp.scriptsEnabledInstallSucceeded,
119
+ };
120
+ }
121
+ export async function writePersistentMetadataStampV4(provenance, installScripts, resetScriptsEnabledCapability, pendingDependencyBuilds) {
122
+ const existing = await readPersistentMetadataStamp();
123
+ const existingMatches = existing && componentNames.every((name) => existing.provenance[name] === provenance[name]);
124
+ const stamp = {
125
+ lastInvocationInstallScripts: installScripts,
126
+ pendingDependencyBuilds: (pendingDependencyBuilds ??
127
+ (existingMatches ? existing.pendingDependencyBuilds : undefined) ??
128
+ []).toSorted(),
129
+ provenance,
130
+ scriptsEnabledInstallSucceeded: installScripts ||
131
+ (!resetScriptsEnabledCapability &&
132
+ Boolean(existingMatches && existing.scriptsEnabledInstallSucceeded)),
133
+ version: 4,
134
+ };
80
135
  const stampPath = persistentMetadataStampPath();
81
136
  const directory = path.dirname(stampPath);
82
137
  const temporary = `${stampPath}.${process.pid}.tmp`;
83
138
  await mkdir(directory, { recursive: true });
84
139
  try {
85
- await writeFile(temporary, `${JSON.stringify({ fingerprint, version: 3 })}\n`);
140
+ await writeFile(temporary, `${JSON.stringify(stamp)}\n`);
86
141
  await rename(temporary, stampPath);
87
142
  }
88
143
  finally {
89
144
  await rm(temporary, { force: true });
90
145
  }
91
146
  }
147
+ export async function persistentDependencyTreeIsCold() {
148
+ try {
149
+ await stat(path.dirname(persistentMetadataStampPath()));
150
+ return false;
151
+ }
152
+ catch (error) {
153
+ if (error.code === 'ENOENT')
154
+ return true;
155
+ /* v8 ignore next -- non-ENOENT stat failures are host-specific */
156
+ throw error;
157
+ }
158
+ }
@@ -0,0 +1,17 @@
1
+ export type PendingBuilds = {
2
+ kind: 'known';
3
+ ids: Set<string>;
4
+ } | {
5
+ kind: 'unknown';
6
+ };
7
+ export type PendingBuildDelta = {
8
+ kind: 'unknown';
9
+ } | {
10
+ kind: 'known';
11
+ dependencyIds: string[];
12
+ workspaceIds: string[];
13
+ };
14
+ export declare function pendingBuilds(): Promise<PendingBuilds>;
15
+ export declare function validDependencyBuildIds(ids: [string, ...string[]]): Promise<[string, ...string[]] | undefined>;
16
+ export declare function clearPendingDependencyBuilds(ids: string[]): Promise<boolean>;
17
+ export declare function pendingBuildDelta(before: PendingBuilds, after: PendingBuilds): Promise<PendingBuildDelta>;
@@ -0,0 +1,87 @@
1
+ import { readFile, rename, rm, writeFile } from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { parse, stringify } from 'yaml';
4
+ export async function pendingBuilds() {
5
+ try {
6
+ const value = parse(await readFile(path.join(process.cwd(), 'node_modules', '.modules.yaml'), 'utf8'));
7
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
8
+ return { kind: 'unknown' };
9
+ const pending = value.pendingBuilds ?? [];
10
+ if (!Array.isArray(pending) || !pending.every((id) => typeof id === 'string'))
11
+ return { kind: 'unknown' };
12
+ return { kind: 'known', ids: new Set(pending) };
13
+ }
14
+ catch {
15
+ return { kind: 'unknown' };
16
+ }
17
+ }
18
+ async function lockfileDependencyIds() {
19
+ try {
20
+ const lockfile = parse(await readFile(path.join(process.cwd(), 'pnpm-lock.yaml'), 'utf8'));
21
+ if (typeof lockfile !== 'object' || lockfile === null || !('packages' in lockfile))
22
+ return undefined;
23
+ const packages = lockfile.packages;
24
+ if (typeof packages !== 'object' || packages === null || Array.isArray(packages))
25
+ return undefined;
26
+ return new Set(Object.keys(packages));
27
+ }
28
+ catch {
29
+ return undefined;
30
+ }
31
+ }
32
+ export async function validDependencyBuildIds(ids) {
33
+ const packages = await lockfileDependencyIds();
34
+ if (!packages || !ids.every((id) => packages.has(id)))
35
+ return undefined;
36
+ const [first, ...remaining] = ids.toSorted();
37
+ return [first, ...remaining];
38
+ }
39
+ export async function clearPendingDependencyBuilds(ids) {
40
+ const filename = path.join(process.cwd(), 'node_modules', '.modules.yaml');
41
+ try {
42
+ const value = parse(await readFile(filename, 'utf8'));
43
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
44
+ return false;
45
+ const record = value;
46
+ if (!Array.isArray(record.pendingBuilds) ||
47
+ !record.pendingBuilds.every((id) => typeof id === 'string'))
48
+ return false;
49
+ const ignored = record.ignoredBuilds ?? [];
50
+ if (!Array.isArray(ignored) ||
51
+ !ignored.every((id) => typeof id === 'string') ||
52
+ ids.some((id) => ignored.includes(id)))
53
+ return false;
54
+ record.pendingBuilds = record.pendingBuilds.filter((id) => !ids.includes(id));
55
+ const temporary = `${filename}.${process.pid}.tmp`;
56
+ try {
57
+ await writeFile(temporary, stringify(record));
58
+ await rename(temporary, filename);
59
+ }
60
+ finally {
61
+ await rm(temporary, { force: true });
62
+ }
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export async function pendingBuildDelta(before, after) {
70
+ if (before.kind !== 'known' || after.kind !== 'known')
71
+ return { kind: 'unknown' };
72
+ const packages = await lockfileDependencyIds();
73
+ if (!packages)
74
+ return { kind: 'unknown' };
75
+ const dependencyIds = [];
76
+ const workspaceIds = [];
77
+ for (const id of after.ids) {
78
+ if (before.ids.has(id))
79
+ continue;
80
+ (packages.has(id) ? dependencyIds : workspaceIds).push(id);
81
+ }
82
+ return {
83
+ kind: 'known',
84
+ dependencyIds: dependencyIds.toSorted(),
85
+ workspaceIds: workspaceIds.toSorted(),
86
+ };
87
+ }
@@ -0,0 +1 @@
1
+ export declare function fakePnpmScript(): string;
@@ -0,0 +1,52 @@
1
+ export function fakePnpmScript() {
2
+ return `#!/usr/bin/env bash
3
+ set -euo pipefail
4
+ if [ "\${1:-}" = m ]; then
5
+ if [ -n "\${PNPM_LIST_WARNING:-}" ]; then printf '%s\\n' "$PNPM_LIST_WARNING" >&2; fi
6
+ printf '%s\\n' "$PNPM_WORKSPACES_JSON"
7
+ exit 0
8
+ fi
9
+ if [ "\${1:-}" = --version ]; then
10
+ printf '%s\\n' "\${PNPM_VERSION:-11.0.0}"
11
+ exit 0
12
+ fi
13
+ printf '%s\\n' "$*" >> "$PNPM_LOG"
14
+ calls=0
15
+ if [ -f "$PNPM_CALLS" ]; then calls="$(cat "$PNPM_CALLS")"; fi
16
+ calls=$((calls + 1))
17
+ printf '%s' "$calls" > "$PNPM_CALLS"
18
+ print_release_age_violation() {
19
+ printf '%s\\n' '✗ Lockfile failed supply-chain policy check (1 entries in 0.1s)'
20
+ printf '%s\\n' '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:'
21
+ printf '%s\\n' ' undici@8.10.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
22
+ }
23
+ if [ "\${PNPM_FAIL_CALL:-0}" = "$calls" ]; then
24
+ if [ -n "\${PNPM_FAIL_RELEASE_AGE:-}" ]; then print_release_age_violation; fi
25
+ exit "\${PNPM_FAIL_CODE:-1}"
26
+ fi
27
+ if [ "\${PNPM_FAIL_RELEASE_AGE_CALL:-0}" = "$calls" ]; then
28
+ print_release_age_violation
29
+ exit 1
30
+ fi
31
+ if [ -n "\${PNPM_SLEEP_SECONDS:-}" ]; then sleep "$PNPM_SLEEP_SECONDS"; fi
32
+ if [ -n "\${PNPM_PENDING_BUILDS:-}" ]; then
33
+ mkdir -p "$PNPM_NODE_MODULES"
34
+ printf 'pendingBuilds: [%s]\\n' "$PNPM_PENDING_BUILDS" > "$PNPM_NODE_MODULES/.modules.yaml"
35
+ fi
36
+ case " $* " in
37
+ *' rebuild '*)
38
+ if [ "\${PNPM_REBUILD_INVALID_LEDGER:-0}" = 1 ]; then
39
+ printf 'pendingBuilds: invalid\n' > "$PNPM_NODE_MODULES/.modules.yaml"
40
+ fi
41
+ if [ "\${PNPM_REBUILD_BREAK_LINK:-0}" = 1 ]; then rm -f "$PNPM_DEPENDENCY_LINK"; fi
42
+ ;;
43
+ *' --force '*)
44
+ if [ "\${PNPM_REPAIR_LINK:-0}" = 1 ]; then
45
+ mkdir -p "$(dirname "$PNPM_DEPENDENCY_LINK")"
46
+ rm -f "$PNPM_DEPENDENCY_LINK"
47
+ ln -s "$PNPM_DEPENDENCY" "$PNPM_DEPENDENCY_LINK"
48
+ fi
49
+ ;;
50
+ esac
51
+ `;
52
+ }
@@ -4,6 +4,7 @@ import { tmpdir } from 'node:os';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { promisify } from 'node:util';
6
6
  import { runPnpmInstallCli } from '../cli/commands/pnpm-install.mjs';
7
+ import { fakePnpmScript } from './pnpm-install-fake-pnpm.test-helpers.mjs';
7
8
  const execFileAsync = promisify(execFile);
8
9
  async function writeJson(path, value) {
9
10
  await mkdir(dirname(path), { recursive: true });
@@ -20,6 +21,7 @@ export async function makeFixture() {
20
21
  await Promise.all([
21
22
  writeJson(join(root, 'package.json'), { name: 'fixture-root', private: true }),
22
23
  writeFile(join(root, 'pnpm-workspace.yaml'), 'packages:\n - packages/*\nminimumReleaseAge: 2880\n'),
24
+ writeFile(join(root, 'pnpm-lock.yaml'), 'lockfileVersion: 9\npackages: {}\n'),
23
25
  writeJson(join(consumer, 'package.json'), {
24
26
  name: '@fixture/consumer',
25
27
  dependencies: { '@fixture/dependency': 'workspace:^' },
@@ -29,46 +31,7 @@ export async function makeFixture() {
29
31
  mkdir(pnpmBin),
30
32
  ]);
31
33
  await symlink(dependency, dependencyLink, 'dir');
32
- await writeFile(join(pnpmBin, 'pnpm'), `#!/usr/bin/env bash
33
- set -euo pipefail
34
- if [ "\${1:-}" = m ]; then
35
- if [ -n "\${PNPM_LIST_WARNING:-}" ]; then printf '%s\\n' "$PNPM_LIST_WARNING" >&2; fi
36
- printf '%s\\n' "$PNPM_WORKSPACES_JSON"
37
- exit 0
38
- fi
39
- if [ "\${1:-}" = --version ]; then
40
- printf '%s\\n' "\${PNPM_VERSION:-11.0.0}"
41
- exit 0
42
- fi
43
- printf '%s\\n' "$*" >> "$PNPM_LOG"
44
- calls=0
45
- if [ -f "$PNPM_CALLS" ]; then calls="$(cat "$PNPM_CALLS")"; fi
46
- calls=$((calls + 1))
47
- printf '%s' "$calls" > "$PNPM_CALLS"
48
- print_release_age_violation() {
49
- printf '%s\\n' '✗ Lockfile failed supply-chain policy check (1 entries in 0.1s)'
50
- printf '%s\\n' '[ERR_PNPM_MINIMUM_RELEASE_AGE_VIOLATION] 1 lockfile entries failed verification:'
51
- printf '%s\\n' ' undici@8.10.0 was published at 2026-08-03T15:06:33.000Z, within the minimumReleaseAge cutoff (2026-08-02T04:48:10.357Z)'
52
- }
53
- if [ "\${PNPM_FAIL_CALL:-0}" = "$calls" ]; then
54
- if [ -n "\${PNPM_FAIL_RELEASE_AGE:-}" ]; then print_release_age_violation; fi
55
- exit "\${PNPM_FAIL_CODE:-1}"
56
- fi
57
- if [ "\${PNPM_FAIL_RELEASE_AGE_CALL:-0}" = "$calls" ]; then
58
- print_release_age_violation
59
- exit 1
60
- fi
61
- if [ -n "\${PNPM_SLEEP_SECONDS:-}" ]; then sleep "$PNPM_SLEEP_SECONDS"; fi
62
- case " $* " in
63
- *' --force '*)
64
- if [ "\${PNPM_REPAIR_LINK:-0}" = 1 ]; then
65
- mkdir -p "$(dirname "$PNPM_DEPENDENCY_LINK")"
66
- rm -f "$PNPM_DEPENDENCY_LINK"
67
- ln -s "$PNPM_DEPENDENCY" "$PNPM_DEPENDENCY_LINK"
68
- fi
69
- ;;
70
- esac
71
- `);
34
+ await writeFile(join(pnpmBin, 'pnpm'), fakePnpmScript());
72
35
  await execFileAsync('chmod', ['+x', join(pnpmBin, 'pnpm')]);
73
36
  const workspaces = [
74
37
  { name: 'fixture-root', path: root },
@@ -83,7 +46,10 @@ esac
83
46
  PNPM_DEPENDENCY: dependency,
84
47
  PNPM_DEPENDENCY_LINK: dependencyLink,
85
48
  PNPM_LOG: pnpmLog,
49
+ PNPM_NODE_MODULES: join(root, 'node_modules'),
50
+ PNPM_PENDING_BUILDS: '',
86
51
  PNPM_REPAIR_LINK: '0',
52
+ PNPM_REBUILD_BREAK_LINK: '0',
87
53
  PNPM_WORKSPACES_JSON: JSON.stringify(workspaces),
88
54
  };
89
55
  return {
@@ -1,83 +1,99 @@
1
- import { scheduler } from 'node:timers/promises';
2
- import { persistentDependencyTreeIsCold, persistentMetadataFingerprint, persistentMetadataMatches, writePersistentMetadataStamp, } from './metadata.mjs';
3
- import { nativeBinariesMatchRuntime } from './native-health.mjs';
1
+ import { persistentDependencyTreeIsCold, persistentMetadataFingerprintV4, persistentMetadataStatusV4, writePersistentMetadataStampV4, } from './metadata.mjs';
4
2
  import { runPnpm } from './exec.mjs';
5
- import { INSTALL_TERMINATION_FAILED } from './process.mjs';
6
- import { formatReleaseAgeFailure, isReleaseAgeViolation } from './release-age.mjs';
3
+ import { nativeBinariesMatchRuntime } from './native-health.mjs';
4
+ import { clearPendingDependencyBuilds, pendingBuildDelta, pendingBuilds, validDependencyBuildIds, } from './pending-builds.mjs';
5
+ import { install, reconcileOrFail, withScriptPolicy } from './install-operations.mjs';
7
6
  import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches, } from './support.mjs';
7
+ import { persistentInstallTransition, persistentProvenanceDiagnostic } from './transition.mjs';
8
8
  // oxfmt-ignore
9
9
  const fail = (message) => { throw new Error(message); };
10
- async function install(args, options, label) {
11
- for (let attempt = 1; attempt <= options.maxAttempts; attempt += 1) {
12
- const attemptResult = await runPnpm(args, options);
13
- if (attemptResult.code === 0)
14
- return;
15
- if (attemptResult.code === INSTALL_TERMINATION_FAILED)
16
- fail(`${label} could not terminate safely`);
17
- const combinedOutput = `${attemptResult.output}\n${attemptResult.errorOutput ?? ''}`;
18
- if (isReleaseAgeViolation(combinedOutput))
19
- fail(formatReleaseAgeFailure(label, combinedOutput));
20
- if (attempt < options.maxAttempts) {
21
- console.warn(`${label} failed (attempt ${attempt}/${options.maxAttempts}); retrying`);
22
- await scheduler.wait(5000);
23
- }
24
- }
25
- fail(`${label} failed after ${options.maxAttempts} attempt${options.maxAttempts === 1 ? '' : 's'}`);
26
- }
27
- function withScriptPolicy(args, installScripts) {
28
- return installScripts ? args : [...args, '--ignore-scripts'];
29
- }
30
- async function reconcileAndFindMismatches(options, runCapture) {
31
- const forced = ['install', '--frozen-lockfile', '--force', ...baseInstallArgs.slice(2)];
32
- // oxfmt-ignore
33
- await install([...forced, '--ignore-scripts', '--ignore-pnpmfile'], options, 'script-free reconciliation');
34
- // oxfmt-ignore
35
- await install(withScriptPolicy(forced, options.installScripts), options, 'strict persistent reconciliation');
36
- return findWorkspaceLinkMismatches(runCapture);
37
- }
38
- async function reconcileOrFail(options, runCapture) {
39
- const remaining = await reconcileAndFindMismatches(options, runCapture);
40
- if (remaining.length > 0) {
41
- logWorkspaceLinkMismatches(remaining);
42
- fail('persistent reconciliation completed with invalid workspace links');
43
- }
44
- }
45
10
  async function persistent(options) {
46
11
  if (options.ephemeralWorkspaces.trim())
47
12
  fail('ephemeral-workspaces is only valid for ephemeral runners');
48
13
  const runCapture = (args) => runPnpm(args, options, true);
49
- const fingerprint = await persistentMetadataFingerprint(runCapture, options.installScripts);
50
- const stamped = await persistentMetadataMatches(fingerprint);
14
+ const fingerprint = await persistentMetadataFingerprintV4(runCapture);
15
+ const provenance = await persistentMetadataStatusV4(fingerprint);
51
16
  const nativesMatch = await nativeBinariesMatchRuntime();
52
- const provenanceOk = stamped && nativesMatch;
53
- // An absent tree has nothing to repair, so one ordinary install below matches the
54
- // reconciled end state. Check first: an install would otherwise make the tree non-cold.
17
+ const provisionalTransition = persistentInstallTransition(provenance, options.installScripts);
18
+ let transition = nativesMatch
19
+ ? provisionalTransition
20
+ : { action: 'reconcile', reason: 'native-health-mismatch' };
21
+ if (transition.action === 'upgrade-dependencies') {
22
+ const ids = await validDependencyBuildIds(transition.pendingDependencyBuilds);
23
+ if (!ids)
24
+ transition = { action: 'upgrade-scripts', reason: 'invalid-pending-dependency-builds' };
25
+ else
26
+ transition = { ...transition, pendingDependencyBuilds: ids };
27
+ }
28
+ const provenanceOk = provenance.kind === 'matching' && transition.action !== 'reconcile' && nativesMatch;
55
29
  const cold = !provenanceOk && (await persistentDependencyTreeIsCold());
56
30
  if (!provenanceOk && !cold) {
57
- console.warn(stamped && !nativesMatch
31
+ const finalTransition = provenance.kind === 'absent'
32
+ ? { action: 'reconcile', reason: 'missing-stamp-populated-tree' }
33
+ : transition;
34
+ console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, finalTransition));
35
+ console.warn(provenance.kind === 'matching' && !nativesMatch
58
36
  ? 'persistent optional native binaries do not match this runtime; reconciling'
59
37
  : 'persistent dependency metadata provenance is missing or changed; reconciling');
60
38
  await reconcileOrFail(options, runCapture);
61
- await writePersistentMetadataStamp(fingerprint);
39
+ await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
62
40
  return 'persistent metadata reconciled';
63
41
  }
64
- if (!stamped)
42
+ if (provenance.kind === 'absent')
65
43
  console.warn('persistent dependency tree is absent; installing cold');
66
- await install(withScriptPolicy([...baseInstallArgs], options.installScripts), options, 'ordinary persistent install');
44
+ const pendingBefore = transition.action === 'ordinary' && !options.installScripts ? await pendingBuilds() : undefined;
45
+ await install(withScriptPolicy([...baseInstallArgs], transition.action.startsWith('upgrade-') ? false : options.installScripts), options, 'ordinary persistent install');
67
46
  const stale = await findWorkspaceLinkMismatches(runCapture);
68
47
  if (stale.length === 0) {
69
- if (!stamped)
70
- await writePersistentMetadataStamp(fingerprint);
71
- return stamped ? 'persistent ordinary' : 'persistent cold';
48
+ if (transition.action.startsWith('upgrade-')) {
49
+ const pendingDependencyBuilds = transition.action === 'upgrade-dependencies'
50
+ ? transition.pendingDependencyBuilds
51
+ : undefined;
52
+ const rebuild = pendingDependencyBuilds === undefined
53
+ ? ['rebuild', '--pending', '--recursive']
54
+ : ['rebuild', '--recursive', '--', ...pendingDependencyBuilds];
55
+ await install(rebuild, options, 'pending scripts rebuild');
56
+ const rebuiltStale = await findWorkspaceLinkMismatches(runCapture);
57
+ if (rebuiltStale.length > 0) {
58
+ logWorkspaceLinkMismatches(rebuiltStale);
59
+ await reconcileOrFail(options, runCapture);
60
+ console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, {
61
+ action: 'reconcile',
62
+ reason: 'workspace-links-stale-after-rebuild',
63
+ }));
64
+ await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
65
+ return 'persistent reconciled';
66
+ }
67
+ if (pendingDependencyBuilds !== undefined &&
68
+ !(await clearPendingDependencyBuilds(pendingDependencyBuilds)))
69
+ fail('dependency rebuild completed but pending build ledger could not be updated safely');
70
+ }
71
+ console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, transition));
72
+ const pendingAfter = pendingBefore ? await pendingBuilds() : undefined;
73
+ const delta = pendingBefore && pendingAfter
74
+ ? await pendingBuildDelta(pendingBefore, pendingAfter)
75
+ : undefined;
76
+ const unsafePending = delta?.kind === 'unknown' || Boolean(delta?.workspaceIds.length);
77
+ const pendingDependencyBuilds = transition.action.startsWith('upgrade-') || unsafePending
78
+ ? []
79
+ : [
80
+ ...new Set([
81
+ ...(provenance.kind === 'matching' ? provenance.pendingDependencyBuilds : []),
82
+ ...(delta?.kind === 'known' ? delta.dependencyIds : []),
83
+ ]),
84
+ ].toSorted();
85
+ await writePersistentMetadataStampV4(fingerprint, options.installScripts, unsafePending, pendingDependencyBuilds);
86
+ return provenance.kind === 'absent' ? 'persistent cold' : 'persistent ordinary';
72
87
  }
73
88
  logWorkspaceLinkMismatches(stale);
89
+ console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, {
90
+ action: 'reconcile',
91
+ reason: 'workspace-links-stale',
92
+ }));
74
93
  await reconcileOrFail(options, runCapture);
75
- await writePersistentMetadataStamp(fingerprint);
94
+ await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
76
95
  return 'persistent reconciled';
77
96
  }
78
- // A path selector's `...` (dependency closure) suffix is silently ignored by pnpm unless the
79
- // path is brace-wrapped, e.g. `{./web}...` — `./web...` installs only `web` itself. Reject the
80
- // unbraced form outright rather than let it resolve to a smaller-than-intended scope.
81
97
  function isUnbracedPathClosureSelector(selector) {
82
98
  return /^\.{0,2}\//.test(selector) && selector.endsWith('...') && !selector.startsWith('{');
83
99
  }
@@ -0,0 +1,23 @@
1
+ export type ProvenanceStatus = {
2
+ kind: 'absent';
3
+ } | {
4
+ kind: 'changed';
5
+ components: string[];
6
+ } | {
7
+ kind: 'matching';
8
+ lastInvocationInstallScripts: boolean;
9
+ pendingDependencyBuilds: string[];
10
+ scriptsEnabledInstallSucceeded: boolean;
11
+ } | {
12
+ kind: 'unsafe';
13
+ };
14
+ export type PersistentInstallTransition = {
15
+ action: 'ordinary' | 'reconcile' | 'upgrade-scripts';
16
+ reason: string;
17
+ } | {
18
+ action: 'upgrade-dependencies';
19
+ pendingDependencyBuilds: [string, ...string[]];
20
+ reason: string;
21
+ };
22
+ export declare function persistentInstallTransition(provenance: ProvenanceStatus, installScripts: boolean): PersistentInstallTransition;
23
+ export declare function persistentProvenanceDiagnostic(provenance: ProvenanceStatus, installScripts: boolean, nativeBinariesMatchRuntime: boolean, transition: PersistentInstallTransition): string;
@@ -0,0 +1,36 @@
1
+ // The matching rows are deliberately explicit: a script-disabled invocation can never erase
2
+ // evidence that this structural tree has already completed a scripts-enabled install.
3
+ export function persistentInstallTransition(provenance, installScripts) {
4
+ if (provenance.kind === 'matching') {
5
+ if (installScripts && !provenance.scriptsEnabledInstallSucceeded)
6
+ return { action: 'upgrade-scripts', reason: 'pending-scripts-rebuild' };
7
+ const [firstPendingDependencyBuild, ...remainingPendingDependencyBuilds] = provenance.pendingDependencyBuilds;
8
+ if (installScripts && firstPendingDependencyBuild !== undefined)
9
+ return {
10
+ action: 'upgrade-dependencies',
11
+ pendingDependencyBuilds: [firstPendingDependencyBuild, ...remainingPendingDependencyBuilds],
12
+ reason: 'pending-dependency-rebuild',
13
+ };
14
+ return { action: 'ordinary', reason: 'matching-structural-provenance' };
15
+ }
16
+ if (provenance.kind === 'absent')
17
+ return { action: 'ordinary', reason: 'missing-stamp' };
18
+ return {
19
+ action: 'reconcile',
20
+ reason: provenance.kind === 'changed' ? 'structural-provenance-changed' : 'unsafe-stamp',
21
+ };
22
+ }
23
+ export function persistentProvenanceDiagnostic(provenance, installScripts, nativeBinariesMatchRuntime, transition) {
24
+ return JSON.stringify({
25
+ action: transition.action,
26
+ changedComponents: provenance.kind === 'changed' ? provenance.components : [],
27
+ event: 'pnpm-install-persistent-provenance',
28
+ installScripts,
29
+ scriptsEnabledInstallSucceeded: provenance.kind === 'matching' ? provenance.scriptsEnabledInstallSucceeded : false,
30
+ lastInvocationInstallScripts: provenance.kind === 'matching' ? provenance.lastInvocationInstallScripts : null,
31
+ pendingDependencyBuildCount: provenance.kind === 'matching' ? provenance.pendingDependencyBuilds.length : 0,
32
+ nativeBinariesMatchRuntime,
33
+ reason: transition.reason,
34
+ state: provenance.kind,
35
+ });
36
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "description": "Vouchington CLI and extractable tooling libraries.",
5
5
  "homepage": "https://github.com/vouchington/vouchington-tooling/tree/main/packages/vouchington-tooling#readme",
6
6
  "bugs": {