vouchington-tooling 0.3.5 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/README.md +19 -5
  2. package/dist/cli/commands/prepare-vitest-reports.d.mts +2 -0
  3. package/dist/cli/commands/prepare-vitest-reports.mjs +11 -0
  4. package/dist/cli/commands/vitest-report-attempt.d.mts +2 -0
  5. package/dist/cli/commands/vitest-report-attempt.mjs +11 -0
  6. package/dist/cli/index.mjs +6 -0
  7. package/dist/cli/parse.d.mts +6 -0
  8. package/dist/cli/parse.mjs +4 -0
  9. package/dist/cli/usage.d.mts +1 -1
  10. package/dist/cli/usage.mjs +4 -0
  11. package/dist/gha-claude-post-review/index.d.mts +3 -0
  12. package/dist/gha-claude-post-review/index.mjs +2 -0
  13. package/dist/gha-post-review/github.d.mts +7 -0
  14. package/dist/gha-post-review/github.mjs +20 -5
  15. package/dist/gha-post-review/index.d.mts +5 -1
  16. package/dist/gha-post-review/index.mjs +4 -1
  17. package/dist/gha-post-review/token.d.mts +1 -0
  18. package/dist/gha-post-review/token.mjs +1 -0
  19. package/dist/gha-workspace-policy/docker-workspace-user.d.mts +2 -0
  20. package/dist/gha-workspace-policy/docker-workspace-user.mjs +208 -0
  21. package/dist/gha-workspace-policy/index.d.mts +8 -0
  22. package/dist/gha-workspace-policy/index.mjs +17 -0
  23. package/dist/gha-workspace-policy/shared.d.mts +6 -0
  24. package/dist/gha-workspace-policy/shared.mjs +54 -0
  25. package/dist/gha-workspace-policy/sparse-checkout.d.mts +2 -0
  26. package/dist/gha-workspace-policy/sparse-checkout.mjs +28 -0
  27. package/dist/index.d.mts +8 -2
  28. package/dist/index.mjs +4 -1
  29. package/dist/pnpm-install/install-operations.d.mts +1 -0
  30. package/dist/pnpm-install/install-operations.mjs +26 -6
  31. package/dist/pnpm-install/native-health.d.mts +2 -0
  32. package/dist/pnpm-install/native-health.mjs +22 -6
  33. package/dist/pnpm-install/pending-builds.d.mts +1 -0
  34. package/dist/pnpm-install/pending-builds.mjs +17 -0
  35. package/dist/pnpm-install/pnpm-install-fake-pnpm.test-helpers.mjs +6 -0
  36. package/dist/pnpm-install/pnpm-install-fixture.test-helpers.d.mts +1 -0
  37. package/dist/pnpm-install/pnpm-install-fixture.test-helpers.mjs +17 -0
  38. package/dist/pnpm-install/runner.mjs +21 -5
  39. package/dist/pnpm-install/support.d.mts +1 -0
  40. package/dist/pnpm-install/support.mjs +3 -3
  41. package/dist/vitest-blob-manifest/cli.mjs +2 -4
  42. package/dist/vitest-blob-manifest/report-attempt-cli.d.mts +2 -0
  43. package/dist/vitest-blob-manifest/report-attempt-cli.mjs +32 -0
  44. package/dist/vitest-blob-manifest/reports-cli.d.mts +2 -0
  45. package/dist/vitest-blob-manifest/reports-cli.mjs +55 -0
  46. package/dist/vitest-blob-manifest/run-attempt.d.mts +2 -0
  47. package/dist/vitest-blob-manifest/run-attempt.mjs +11 -0
  48. package/dist/workspace-gates/manifest-version-assertions.d.mts +3 -0
  49. package/dist/workspace-gates/manifest-version-assertions.mjs +100 -0
  50. package/dist/workspace-gates/manifest-version-parser.d.mts +6 -0
  51. package/dist/workspace-gates/manifest-version-parser.mjs +104 -0
  52. package/dist/workspace-gates/manifest-version-patterns.d.mts +9 -0
  53. package/dist/workspace-gates/manifest-version-patterns.mjs +21 -0
  54. package/dist/workspace-gates/policy.mjs +2 -0
  55. package/package.json +13 -3
  56. package/scripts/gha/clean-workspace.sh +48 -2
  57. package/skills/dependabot/SKILL.md +111 -0
  58. package/skills/github-actions-authoring/SKILL.md +10 -0
  59. package/skills/github-actions-checklist/SKILL.md +29 -2
  60. package/skills/manifest.json +24 -17
  61. package/skills/review-ci-logs/SKILL.md +8 -2
  62. package/skills/static-analysis-checklist/SKILL.md +8 -5
@@ -0,0 +1,28 @@
1
+ import { visitRunSteps } from './shared.mjs';
2
+ const SPARSE_CHECKOUT_KEYS = ['sparse-checkout', 'sparse-checkout-cone-mode'];
3
+ const SPARSE_CHECKOUT_COMMANDS = [
4
+ /\bgit\b[^\n;&|]*\bsparse-checkout\s+(?:init|set|add|reapply)\b/iu,
5
+ /\bgit\b[^\n;&|]*\bconfig\b[^\n;&|]*\bcore\.sparseCheckout(?:Cone)?(?:\s*=\s*|\s+)(?:"|')?(?:true|yes|on|1)(?:"|')?(?=\s|;|$)/iu,
6
+ ];
7
+ export function checkNoSparseCheckoutDocument(file, document, kind, errors) {
8
+ visitRunSteps(document, kind === 'action', (scope, index, step) => {
9
+ const run = step.run;
10
+ const normalizedRun = typeof run === 'string' ? run.replace(/\\\r?\n\s*/gu, ' ') : '';
11
+ if (SPARSE_CHECKOUT_COMMANDS.some((pattern) => pattern.test(normalizedRun))) {
12
+ errors.push(`::error file=${file}::${file}: ${scope} step ${index} enables sparse checkout. ` +
13
+ 'Use a full checkout; commands that disable or unset sparse-checkout state remain allowed.');
14
+ }
15
+ if (typeof step.uses !== 'string' || !step.uses.startsWith('actions/checkout@'))
16
+ return;
17
+ const withValue = step.with;
18
+ if (!withValue || typeof withValue !== 'object')
19
+ return;
20
+ for (const key of SPARSE_CHECKOUT_KEYS) {
21
+ if (!(key in withValue))
22
+ continue;
23
+ errors.push(`::error file=${file}::${file}: ${scope} step ${index} passes "${key}" to ` +
24
+ 'actions/checkout. Persistent runners reuse workspace directories, so leaked ' +
25
+ 'sparse-checkout state can silently narrow a later checkout. Check out the full tree.');
26
+ }
27
+ });
28
+ }
package/dist/index.d.mts CHANGED
@@ -15,6 +15,8 @@ export type { ForeignKey, SqlCreateIndexMetadata, SqlCreateTableColumn, SqlCreat
15
15
  export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mts';
16
16
  export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mts';
17
17
  export type { GhApiExecutor, RuntimeAuditOptions, RuntimeAuditResult, RuntimeAuditWorkflowFilter, RuntimeJobResult, RuntimeSample, } from './gha-runtime-audit/index.mts';
18
+ export { checkGhaWorkspacePolicy } from './gha-workspace-policy/index.mts';
19
+ export type { GhaWorkspacePolicyOptions } from './gha-workspace-policy/index.mts';
18
20
  export { createVitestBlobManifest, createVitestReportAttempt, inspectVitestBlobBundle, parseVitestBlobManifest, parseVitestReportAttempt, readVitestReportAttempts, serializeVitestBlobManifest, serializeVitestReportAttempt, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, VITEST_REPORT_ATTEMPT_PREFIX, VITEST_REPORT_ATTEMPT_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, writeVitestReportAttempt, } from './vitest-blob-manifest/index.mts';
19
21
  export type { InspectedVitestBlobBundle, VitestBlobIdentity, VitestBlobManifest, VitestReportAttempt, VitestReportAttemptIdentity, } from './vitest-blob-manifest/index.mts';
20
22
  export { prepareVitestReports } from './vitest-blob-manifest/reports.mts';
@@ -45,8 +47,12 @@ export { parseAstGrepRuleArgs, runAstGrepRule } from './ast-grep-rule/index.mts'
45
47
  export type { AstGrepRuleInvocation, RunAstGrepRuleOptions } from './ast-grep-rule/index.mts';
46
48
  export { indexReviewFiles, MAX_REVIEW_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES, nearestReviewLine, parsePatchCommentable, parseReviewFilesJson, parseReviewPayload, readRegularReviewPayload, remapReviewComments, ReviewPayloadError, reviewCommentSubject, rewriteSnappedSuggestion, snapReviewNote, stageReviewPayload, writeStagedOutput, } from './gha-review-payload/index.mts';
47
49
  export type { CommentableIndex, CommentableLine, LineKind, PayloadRequirement, ReviewComment, ReviewFile, ReviewSide, SanitizedReview, } from './gha-review-payload/index.mts';
48
- export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, PostReviewError, requireEnv, resolveReviewPostToken, revokeClaudeAppToken, runPostReview, runPostReviewCli, withClaudeAppToken, } from './gha-post-review/index.mts';
49
- export type { ClaudeTokenIo, PostResult, PostReviewIo, PullFile, ReviewPostToken, } from './gha-post-review/index.mts';
50
+ export { PostReviewError, requireEnv, runPostReview, runPostReviewCli, postReviewWithTokenFromEnv, } from './gha-post-review/index.mts';
51
+ export type { PostResult, PostReviewIo, PullFile } from './gha-post-review/index.mts';
52
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
53
+ export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, resolveReviewPostToken, revokeClaudeAppToken, withClaudeAppToken, } from './gha-post-review/index.mts';
54
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
55
+ export type { ClaudeTokenIo, ReviewPostToken } from './gha-post-review/index.mts';
50
56
  export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mts';
51
57
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mts';
52
58
  export type { ExpectedTransportIdentity, ObjectSigner, PresignIdentity, TransportControl, } from './coverage-transport/index.mts';
package/dist/index.mjs CHANGED
@@ -8,6 +8,7 @@ export { EphemeralListenerAttemptsExhaustedError, isRunnerReservedPort, listenOn
8
8
  export { extractAlterTableAddColumnLocations, extractCreateIndexMetadata, extractCreateTableMetadata, extractDefaultFunction, extractDropIndexMetadata, extractFuncCallArgColumnNames, extractMigrationConstraintMetadata, initSqlAst, lineOfUtf8ByteOffset, MissingSqlAstParserError, parseSql, } from './sql-ast/index.mjs';
9
9
  export { dollarQuoteEnd, lineOf, maskSqlQuotedText, readDollarQuoteDelimiter, readStringLiteral, splitSqlStatements, sqlFragments, stripSqlComments, } from './sql-scanner/index.mjs';
10
10
  export { auditCiJobRuntime, parseWorkflowNameMatch } from './gha-runtime-audit/index.mjs';
11
+ export { checkGhaWorkspacePolicy } from './gha-workspace-policy/index.mjs';
11
12
  export { createVitestBlobManifest, createVitestReportAttempt, inspectVitestBlobBundle, parseVitestBlobManifest, parseVitestReportAttempt, readVitestReportAttempts, serializeVitestBlobManifest, serializeVitestReportAttempt, VITEST_BLOB_MANIFEST_FILENAME, VITEST_BLOB_MANIFEST_VERSION, VITEST_REPORT_ATTEMPT_PREFIX, VITEST_REPORT_ATTEMPT_VERSION, vitestBlobBundlePaths, writeVitestBlobManifest, writeVitestReportAttempt, } from './vitest-blob-manifest/index.mjs';
12
13
  export { prepareVitestReports } from './vitest-blob-manifest/reports.mjs';
13
14
  export { findWorkspaceLinkMismatches, formatReleaseAgeFailure, INSTALL_TERMINATION_FAILED, isReleaseAgeViolation, parseInstallOptions, parseReleaseAgeViolations, runInstallLifecycle, } from './pnpm-install/index.mjs';
@@ -25,7 +26,9 @@ export { escapeSpreadsheetFormula, parseCsvRows, streamCsvRows, stripCsvBom } fr
25
26
  export { MissingResponseBodyError, readResponseBody, readResponseBodyAsBuffer, ResponseBodyTooLargeError, } from './http-body/index.mjs';
26
27
  export { parseAstGrepRuleArgs, runAstGrepRule } from './ast-grep-rule/index.mjs';
27
28
  export { indexReviewFiles, MAX_REVIEW_COMMENTS, MAX_REVIEW_PAYLOAD_BYTES, nearestReviewLine, parsePatchCommentable, parseReviewFilesJson, parseReviewPayload, readRegularReviewPayload, remapReviewComments, ReviewPayloadError, reviewCommentSubject, rewriteSnappedSuggestion, snapReviewNote, stageReviewPayload, writeStagedOutput, } from './gha-review-payload/index.mjs';
28
- export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, PostReviewError, requireEnv, resolveReviewPostToken, revokeClaudeAppToken, runPostReview, runPostReviewCli, withClaudeAppToken, } from './gha-post-review/index.mjs';
29
+ export { PostReviewError, requireEnv, runPostReview, runPostReviewCli, postReviewWithTokenFromEnv, } from './gha-post-review/index.mjs';
30
+ /** @deprecated Import Claude helpers from vouchington-tooling/gha-claude-post-review. */
31
+ export { CLAUDE_OIDC_AUDIENCE, createActionsClaudeTokenIo, mintClaudeAppToken, resolveReviewPostToken, revokeClaudeAppToken, withClaudeAppToken, } from './gha-post-review/index.mjs';
29
32
  export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mjs';
30
33
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mjs';
31
34
  export { EPOCH_PRUNED_AT, normalizeDeployedLayer, pruneDeployedRuntimeDeps, restoreDeployedWorkspacePackages, } from './pnpm-deploy/index.mjs';
@@ -2,3 +2,4 @@ import { type CommandResult, type InstallOptions } from './support.mts';
2
2
  export declare function withScriptPolicy(args: string[], installScripts: boolean): string[];
3
3
  export declare function install(args: string[], options: InstallOptions, label: string): Promise<void>;
4
4
  export declare function reconcileOrFail(options: InstallOptions, runCapture: (args: string[]) => Promise<CommandResult>): Promise<void>;
5
+ export declare function repairIsolatedNativeMismatch(options: InstallOptions, runCapture: (args: string[]) => Promise<CommandResult>, mismatchedNativePaths: string[]): Promise<boolean>;
@@ -1,8 +1,10 @@
1
1
  import { scheduler } from 'node:timers/promises';
2
2
  import { runPnpm } from './exec.mjs';
3
+ import { nativeBinariesMatchRuntime, repairedNativeBinariesMatchRuntime } from './native-health.mjs';
4
+ import { buildLedgersAllowNativeRepair } from './pending-builds.mjs';
3
5
  import { INSTALL_TERMINATION_FAILED } from './process.mjs';
4
6
  import { formatReleaseAgeFailure, isReleaseAgeViolation } from './release-age.mjs';
5
- import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches, } from './support.mjs';
7
+ import { findWorkspaceLinkMismatches, forcedInstallArgs, logWorkspaceLinkMismatches, } from './support.mjs';
6
8
  function fail(message) {
7
9
  throw new Error(message);
8
10
  }
@@ -27,12 +29,30 @@ export async function install(args, options, label) {
27
29
  fail(`${label} failed after ${options.maxAttempts} attempt${options.maxAttempts === 1 ? '' : 's'}`);
28
30
  }
29
31
  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);
32
+ await install([...forcedInstallArgs, '--ignore-scripts', '--ignore-pnpmfile'], options, 'script-free reconciliation');
33
+ await install(withScriptPolicy(forcedInstallArgs, options.installScripts), options, 'strict persistent reconciliation');
34
+ await verifyInstallHealth(runCapture, 'persistent reconciliation');
35
+ }
36
+ async function verifyInstallHealth(runCapture, phase, repairedNativePaths = []) {
37
+ const [nativesMatch, remaining] = await Promise.all([
38
+ repairedNativePaths.length > 0
39
+ ? repairedNativeBinariesMatchRuntime(repairedNativePaths)
40
+ : nativeBinariesMatchRuntime(),
41
+ findWorkspaceLinkMismatches(runCapture),
42
+ ]);
43
+ if (!nativesMatch)
44
+ fail(`${phase} completed with mismatched native binaries`);
34
45
  if (remaining.length > 0) {
35
46
  logWorkspaceLinkMismatches(remaining);
36
- fail('persistent reconciliation completed with invalid workspace links');
47
+ fail(`${phase} completed with invalid workspace links`);
37
48
  }
38
49
  }
50
+ export async function repairIsolatedNativeMismatch(options, runCapture, mismatchedNativePaths) {
51
+ if (!(await buildLedgersAllowNativeRepair()))
52
+ return false;
53
+ if ((await findWorkspaceLinkMismatches(runCapture)).length > 0)
54
+ return false;
55
+ await install(withScriptPolicy(forcedInstallArgs, options.installScripts), options, 'native health reconciliation');
56
+ await verifyInstallHealth(runCapture, 'native health reconciliation', mismatchedNativePaths);
57
+ return true;
58
+ }
@@ -1,4 +1,6 @@
1
1
  export type NativeFamily = 'elf' | 'macho' | 'pe';
2
2
  export declare function nativeFamilyFromMagic(buffer: Buffer): NativeFamily | undefined;
3
3
  export declare function expectedNativeFamily(platform?: NodeJS.Platform): NativeFamily | undefined;
4
+ export declare function mismatchedNativeBinaries(root?: string, platform?: NodeJS.Platform): Promise<string[]>;
4
5
  export declare function nativeBinariesMatchRuntime(root?: string, platform?: NodeJS.Platform): Promise<boolean>;
6
+ export declare function repairedNativeBinariesMatchRuntime(paths: string[], platform?: NodeJS.Platform): Promise<boolean>;
@@ -58,27 +58,43 @@ async function searchRoot(nodeModules) {
58
58
  return nodeModules;
59
59
  }
60
60
  }
61
- export async function nativeBinariesMatchRuntime(root = process.cwd(), platform = process.platform) {
61
+ export async function mismatchedNativeBinaries(root = process.cwd(), platform = process.platform) {
62
62
  const expected = expectedNativeFamily(platform);
63
63
  if (expected === undefined)
64
- return true;
64
+ return [];
65
65
  const nodeModules = path.join(root, 'node_modules');
66
66
  try {
67
67
  const info = await stat(nodeModules);
68
68
  if (!info.isDirectory())
69
- return true;
69
+ return [];
70
70
  }
71
71
  catch {
72
- return true;
72
+ return [];
73
73
  }
74
74
  const cwd = await searchRoot(nodeModules);
75
+ const mismatches = [];
75
76
  for await (const relative of glob('**/*.{node,bin}', { cwd })) {
76
- const magic = await readMagic(path.join(cwd, relative));
77
+ const pathname = path.join(cwd, relative);
78
+ const magic = await readMagic(pathname);
77
79
  if (magic === undefined)
78
80
  continue;
79
81
  const family = nativeFamilyFromMagic(magic);
80
82
  if (family !== undefined && family !== expected)
83
+ mismatches.push(pathname);
84
+ }
85
+ return mismatches;
86
+ }
87
+ export async function nativeBinariesMatchRuntime(root = process.cwd(), platform = process.platform) {
88
+ return (await mismatchedNativeBinaries(root, platform)).length === 0;
89
+ }
90
+ export async function repairedNativeBinariesMatchRuntime(paths, platform = process.platform) {
91
+ const expected = expectedNativeFamily(platform);
92
+ if (expected === undefined)
93
+ return true;
94
+ for (const pathname of paths) {
95
+ const magic = await readMagic(pathname);
96
+ if (magic === undefined || nativeFamilyFromMagic(magic) !== expected)
81
97
  return false;
82
98
  }
83
- return true;
99
+ return nativeBinariesMatchRuntime();
84
100
  }
@@ -12,6 +12,7 @@ export type PendingBuildDelta = {
12
12
  workspaceIds: string[];
13
13
  };
14
14
  export declare function pendingBuilds(): Promise<PendingBuilds>;
15
+ export declare function buildLedgersAllowNativeRepair(): Promise<boolean>;
15
16
  export declare function validDependencyBuildIds(ids: [string, ...string[]]): Promise<[string, ...string[]] | undefined>;
16
17
  export declare function clearPendingDependencyBuilds(ids: string[]): Promise<boolean>;
17
18
  export declare function pendingBuildDelta(before: PendingBuilds, after: PendingBuilds): Promise<PendingBuildDelta>;
@@ -15,6 +15,23 @@ export async function pendingBuilds() {
15
15
  return { kind: 'unknown' };
16
16
  }
17
17
  }
18
+ export async function buildLedgersAllowNativeRepair() {
19
+ try {
20
+ const value = parse(await readFile(path.join(process.cwd(), 'node_modules', '.modules.yaml'), 'utf8'));
21
+ if (typeof value !== 'object' || value === null || Array.isArray(value))
22
+ return false;
23
+ const record = value;
24
+ const ignored = record.ignoredBuilds;
25
+ const pending = record.pendingBuilds ?? [];
26
+ return (Array.isArray(ignored) &&
27
+ ignored.length === 0 &&
28
+ Array.isArray(pending) &&
29
+ pending.length === 0);
30
+ }
31
+ catch {
32
+ return false;
33
+ }
34
+ }
18
35
  async function lockfileDependencyIds() {
19
36
  try {
20
37
  const lockfile = parse(await readFile(path.join(process.cwd(), 'pnpm-lock.yaml'), 'utf8'));
@@ -41,6 +41,12 @@ case " $* " in
41
41
  if [ "\${PNPM_REBUILD_BREAK_LINK:-0}" = 1 ]; then rm -f "$PNPM_DEPENDENCY_LINK"; fi
42
42
  ;;
43
43
  *' --force '*)
44
+ if [ "\${PNPM_DELETE_NATIVE:-0}" = 1 ]; then
45
+ rm -f "$PNPM_NATIVE_ADDON"
46
+ elif [ "\${PNPM_REPAIR_NATIVE:-0}" = 1 ]; then
47
+ cp "$PNPM_NATIVE_REPLACEMENT" "$PNPM_NATIVE_ADDON"
48
+ fi
49
+ if [ "\${PNPM_FORCE_BREAK_LINK:-0}" = 1 ]; then rm -f "$PNPM_DEPENDENCY_LINK"; fi
44
50
  if [ "\${PNPM_REPAIR_LINK:-0}" = 1 ]; then
45
51
  mkdir -p "$(dirname "$PNPM_DEPENDENCY_LINK")"
46
52
  rm -f "$PNPM_DEPENDENCY_LINK"
@@ -14,6 +14,7 @@ export declare function makeFixture(): Promise<{
14
14
  root: string;
15
15
  summary: string;
16
16
  }>;
17
+ export declare function configureNativeRepair(fixture: Awaited<ReturnType<typeof makeFixture>>, addon: string): Promise<void>;
17
18
  export declare function runInstaller(fixture: Awaited<ReturnType<typeof makeFixture>>, options?: FixtureOptions): Promise<{
18
19
  stdout: string;
19
20
  stderr: string;
@@ -45,9 +45,14 @@ export async function makeFixture() {
45
45
  PNPM_CALLS: join(root, 'pnpm.calls'),
46
46
  PNPM_DEPENDENCY: dependency,
47
47
  PNPM_DEPENDENCY_LINK: dependencyLink,
48
+ PNPM_DELETE_NATIVE: '0',
48
49
  PNPM_LOG: pnpmLog,
49
50
  PNPM_NODE_MODULES: join(root, 'node_modules'),
51
+ PNPM_NATIVE_ADDON: '',
52
+ PNPM_NATIVE_REPLACEMENT: '',
50
53
  PNPM_PENDING_BUILDS: '',
54
+ PNPM_FORCE_BREAK_LINK: '0',
55
+ PNPM_REPAIR_NATIVE: '0',
51
56
  PNPM_REPAIR_LINK: '0',
52
57
  PNPM_REBUILD_BREAK_LINK: '0',
53
58
  PNPM_WORKSPACES_JSON: JSON.stringify(workspaces),
@@ -62,6 +67,18 @@ export async function makeFixture() {
62
67
  summary,
63
68
  };
64
69
  }
70
+ export async function configureNativeRepair(fixture, addon) {
71
+ const replacement = join(fixture.root, 'native-replacement.node');
72
+ const magic = process.platform === 'darwin'
73
+ ? Buffer.from([0xcf, 0xfa, 0xed, 0xfe])
74
+ : process.platform === 'win32'
75
+ ? Buffer.from([0x4d, 0x5a])
76
+ : Buffer.from([0x7f, 0x45, 0x4c, 0x46]);
77
+ await writeFile(replacement, magic);
78
+ fixture.env.PNPM_NATIVE_ADDON = addon;
79
+ fixture.env.PNPM_NATIVE_REPLACEMENT = replacement;
80
+ fixture.env.PNPM_REPAIR_NATIVE = '1';
81
+ }
65
82
  export async function runInstaller(fixture, options = {}) {
66
83
  const lifecycle = options.lifecycle ?? 'persistent';
67
84
  const installScripts = options.installScripts ?? true;
@@ -1,9 +1,12 @@
1
1
  import { persistentDependencyTreeIsCold, persistentMetadataFingerprintV4, persistentMetadataStatusV4, writePersistentMetadataStampV4, } from './metadata.mjs';
2
2
  import { runPnpm } from './exec.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';
6
- import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches, } from './support.mjs';
3
+ import { mismatchedNativeBinaries } from './native-health.mjs';
4
+ // oxfmt-ignore
5
+ import { clearPendingDependencyBuilds, pendingBuildDelta, pendingBuilds, validDependencyBuildIds } from './pending-builds.mjs';
6
+ // oxfmt-ignore
7
+ import { install, reconcileOrFail, repairIsolatedNativeMismatch, withScriptPolicy } from './install-operations.mjs';
8
+ // oxfmt-ignore
9
+ import { baseInstallArgs, findWorkspaceLinkMismatches, logWorkspaceLinkMismatches } from './support.mjs';
7
10
  import { persistentInstallTransition, persistentProvenanceDiagnostic } from './transition.mjs';
8
11
  // oxfmt-ignore
9
12
  const fail = (message) => { throw new Error(message); };
@@ -13,7 +16,20 @@ async function persistent(options) {
13
16
  const runCapture = (args) => runPnpm(args, options, true);
14
17
  const fingerprint = await persistentMetadataFingerprintV4(runCapture);
15
18
  const provenance = await persistentMetadataStatusV4(fingerprint);
16
- const nativesMatch = await nativeBinariesMatchRuntime();
19
+ const mismatchedNatives = await mismatchedNativeBinaries();
20
+ const nativesMatch = mismatchedNatives.length === 0;
21
+ const repairedNativeMismatch = !nativesMatch &&
22
+ provenance.kind === 'matching' &&
23
+ (await repairIsolatedNativeMismatch(options, runCapture, mismatchedNatives));
24
+ if (repairedNativeMismatch) {
25
+ console.warn('persistent optional native binaries do not match this runtime; reconciled');
26
+ console.warn(persistentProvenanceDiagnostic(provenance, options.installScripts, nativesMatch, {
27
+ action: 'reconcile',
28
+ reason: 'native-health-mismatch',
29
+ }));
30
+ await writePersistentMetadataStampV4(fingerprint, options.installScripts, true, []);
31
+ return 'persistent native health reconciled';
32
+ }
17
33
  const provisionalTransition = persistentInstallTransition(provenance, options.installScripts);
18
34
  let transition = nativesMatch
19
35
  ? provisionalTransition
@@ -23,6 +23,7 @@ export type Workspace = {
23
23
  path: string;
24
24
  };
25
25
  export declare const baseInstallArgs: string[];
26
+ export declare const forcedInstallArgs: string[];
26
27
  export declare function parseInstallOptions(argv: string[]): InstallOptions;
27
28
  export declare function listWorkspaces(runCapture: CaptureCommand): Promise<Workspace[]>;
28
29
  export declare function findWorkspaceLinkMismatches(runCapture: CaptureCommand): Promise<WorkspaceLinkMismatch[]>;
@@ -1,12 +1,12 @@
1
1
  import { readFile, realpath } from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- export const baseInstallArgs = [
4
- 'install',
5
- '--frozen-lockfile',
3
+ const commonInstallArgs = [
6
4
  '--prefer-offline',
7
5
  '--prod=false',
8
6
  '--config.disallow-workspace-cycles=false',
9
7
  ];
8
+ export const baseInstallArgs = ['install', '--frozen-lockfile', ...commonInstallArgs];
9
+ export const forcedInstallArgs = ['install', '--frozen-lockfile', '--force', ...commonInstallArgs];
10
10
  const usage = 'usage: vouchington pnpm-install --runner-lifecycle persistent|ephemeral|ephemeral-full --install-scripts true|false [--ephemeral-workspaces <newline-separated selectors>] [--command-timeout-seconds <nonnegative integer>] [--max-attempts <positive integer>]';
11
11
  const MAX_COMMAND_TIMEOUT_SECONDS = 3600;
12
12
  const MAX_ATTEMPTS = 10;
@@ -2,6 +2,7 @@
2
2
  import { execFileSync } from 'node:child_process';
3
3
  import { fileURLToPath } from 'node:url';
4
4
  import { writeVitestBlobManifest } from './index.mjs';
5
+ import { parseGitHubRunAttempt } from './run-attempt.mjs';
5
6
  function failRepository() {
6
7
  throw new Error('GITHUB_REPOSITORY is required');
7
8
  }
@@ -15,10 +16,7 @@ export function runVitestBlobManifestCli(args, env = process.env, revision = exe
15
16
  if (!suite || extra.length > 0 || !runId || !rawAttempt) {
16
17
  throw new Error('Usage: vouchington vitest-blob-manifest <suite> [reports-directory]');
17
18
  }
18
- const runAttempt = Number(rawAttempt);
19
- if (!Number.isSafeInteger(runAttempt) || runAttempt < 1) {
20
- throw new Error('GITHUB_RUN_ATTEMPT must be a positive integer');
21
- }
19
+ const runAttempt = parseGitHubRunAttempt(rawAttempt);
22
20
  writeVitestBlobManifest(directory, {
23
21
  suite,
24
22
  repository: env.GITHUB_REPOSITORY || failRepository(),
@@ -0,0 +1,2 @@
1
+ /** Writes or reads authenticated-for-run Vitest report-attempt markers. */
2
+ export declare function runVitestReportAttemptCli(args: readonly string[], env?: NodeJS.ProcessEnv, log?: (line: string) => void): void;
@@ -0,0 +1,32 @@
1
+ import { readVitestReportAttempts, writeVitestReportAttempt, } from './report-attempt.mjs';
2
+ import { parseGitHubRunAttempt } from './run-attempt.mjs';
3
+ function required(env, name) {
4
+ const value = env[name];
5
+ if (!value)
6
+ throw new Error(`${name} is required`);
7
+ return value;
8
+ }
9
+ function identity(env) {
10
+ return {
11
+ repository: required(env, 'GITHUB_REPOSITORY'),
12
+ revision: required(env, 'GITHUB_SHA'),
13
+ runId: required(env, 'GITHUB_RUN_ID'),
14
+ attempt: parseGitHubRunAttempt(env.GITHUB_RUN_ATTEMPT),
15
+ };
16
+ }
17
+ /** Writes or reads authenticated-for-run Vitest report-attempt markers. */
18
+ export function runVitestReportAttemptCli(args, env = process.env, log = (line) => process.stdout.write(`${line}\n`)) {
19
+ const [command, path, suite, ...extra] = args;
20
+ if (extra.length > 0 || !path || (command !== 'write' && command !== 'read'))
21
+ throw new Error('Usage: vouchington vitest-report-attempt <write DIRECTORY SUITE|read ROOT>');
22
+ const current = identity(env);
23
+ if (command === 'write') {
24
+ if (!suite)
25
+ throw new Error('Usage: vouchington vitest-report-attempt <write DIRECTORY SUITE|read ROOT>');
26
+ writeVitestReportAttempt(path, suite, current);
27
+ return;
28
+ }
29
+ if (suite)
30
+ throw new Error('Usage: vouchington vitest-report-attempt <write DIRECTORY SUITE|read ROOT>');
31
+ log(JSON.stringify(readVitestReportAttempts(path, current)));
32
+ }
@@ -0,0 +1,2 @@
1
+ /** Validates GitHub run context, then prepares the selected report JSON files. */
2
+ export declare function runPrepareVitestReportsCli(args: readonly string[], env?: NodeJS.ProcessEnv, log?: (line: string) => void): void;
@@ -0,0 +1,55 @@
1
+ import { VITEST_SUITE_PATTERN } from './constants.mjs';
2
+ import { prepareVitestReports } from './reports.mjs';
3
+ import { parseGitHubRunAttempt } from './run-attempt.mjs';
4
+ function required(env, name) {
5
+ const value = env[name];
6
+ if (!value)
7
+ throw new Error(`${name} is required`);
8
+ return value;
9
+ }
10
+ function parseContext(raw, attempt) {
11
+ const parsed = JSON.parse(raw);
12
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
13
+ throw new Error('Vitest report expectation context must be an object');
14
+ const context = parsed;
15
+ if (Object.keys(context).toSorted().join('\0') !== ['attempt', 'suites', 'version'].join('\0') ||
16
+ context.version !== 'vitest-report-expectations:v2' ||
17
+ context.attempt !== attempt ||
18
+ !Array.isArray(context.suites) ||
19
+ context.suites.some((expectation) => typeof expectation !== 'object' ||
20
+ expectation === null ||
21
+ Array.isArray(expectation) ||
22
+ Object.keys(expectation).toSorted().join('\0') !== ['minimumAttempt', 'suite'].join('\0') ||
23
+ typeof expectation.suite !== 'string' ||
24
+ !VITEST_SUITE_PATTERN.test(expectation.suite) ||
25
+ !Number.isSafeInteger(expectation.minimumAttempt) ||
26
+ Number(expectation.minimumAttempt) < 1 ||
27
+ Number(expectation.minimumAttempt) > attempt))
28
+ throw new Error('Vitest report expectation context has an invalid schema');
29
+ const typed = context;
30
+ const suites = typed.suites.map((expectation) => expectation.suite);
31
+ if (new Set(suites).size !== suites.length || suites.join('\0') !== suites.toSorted().join('\0'))
32
+ throw new Error('Vitest report expectation suites must be unique and sorted');
33
+ return typed;
34
+ }
35
+ /** Validates GitHub run context, then prepares the selected report JSON files. */
36
+ export function runPrepareVitestReportsCli(args, env = process.env, log = (line) => process.stdout.write(`${line}\n`)) {
37
+ const [primaryDir = './vitest-blob-primary', fallbackDir = './vitest-blob-fallback', outputDir = './vitest-blob-reports/merge-input', ...extra] = args;
38
+ if (extra.length > 0)
39
+ throw new Error('Expected at most three Vitest report directories');
40
+ const currentAttempt = parseGitHubRunAttempt(env.GITHUB_RUN_ATTEMPT);
41
+ const result = prepareVitestReports({
42
+ primaryDir,
43
+ fallbackDir,
44
+ outputDir,
45
+ expectedSuites: parseContext(required(env, 'VITEST_REPORT_EXPECTATIONS'), currentAttempt)
46
+ .suites,
47
+ repository: required(env, 'GITHUB_REPOSITORY'),
48
+ revision: required(env, 'GITHUB_SHA'),
49
+ run: { id: required(env, 'GITHUB_RUN_ID'), currentAttempt },
50
+ });
51
+ for (const rejected of result.rejectedSources)
52
+ log(`::warning::Rejected Vitest ${rejected.source} report source: ${rejected.reason}`);
53
+ for (const selected of result.selected)
54
+ log(`Selected Vitest report ${selected.suite} from attempt ${selected.attempt} (${selected.sources.join('+')})`);
55
+ }
@@ -0,0 +1,2 @@
1
+ /** Reads a GitHub Actions attempt as a canonical, positive decimal integer. */
2
+ export declare function parseGitHubRunAttempt(rawAttempt: string | undefined): number;
@@ -0,0 +1,11 @@
1
+ /** Reads a GitHub Actions attempt as a canonical, positive decimal integer. */
2
+ export function parseGitHubRunAttempt(rawAttempt) {
3
+ if (!rawAttempt)
4
+ throw new Error('GITHUB_RUN_ATTEMPT is required');
5
+ if (!/^[1-9][0-9]*$/.test(rawAttempt))
6
+ throw new Error('GITHUB_RUN_ATTEMPT must be a positive integer');
7
+ const attempt = Number(rawAttempt);
8
+ if (!Number.isSafeInteger(attempt))
9
+ throw new Error('GITHUB_RUN_ATTEMPT must be a positive integer');
10
+ return attempt;
11
+ }
@@ -0,0 +1,3 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ /** Keeps Dependabot updates independent from literal dependency-version test assertions. */
3
+ export declare function checkManifestDependencyVersionAssertions(ctx: SharedContext, errors: string[]): void;
@@ -0,0 +1,100 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { findExpectations } from './manifest-version-parser.mjs';
4
+ import { buildDependencyMatchers, SEMVER_LITERAL } from './manifest-version-patterns.mjs';
5
+ const TEST_SOURCE_FILE = /(?:^|\/)[^/]+\.(?:test|spec)\.[cm]?[jt]sx?$/u;
6
+ const PACKAGE_JSON_FILE = /(?:^|\/)package\.json$/u;
7
+ const TEST_OR_FIXTURE_DIRECTORY = /(?:^|\/)(?:test|tests|__tests__|fixture|fixtures|__fixtures__)(?:\/|$)/u;
8
+ const DEPENDENCY_FIELD = /\b(?:dependencies|devDependencies|optionalDependencies|peerDependencies)\b/u;
9
+ const STRING_LITERAL = /'([^'\\]*(?:\\.[^'\\]*)*)'|"([^"\\]*(?:\\.[^"\\]*)*)"|`([^`\\$]*(?:\\.[^`\\$]*)*)`/gu;
10
+ function readTrackedSource(ctx, file) {
11
+ try {
12
+ return ctx.readTrackedFile
13
+ ? ctx.readTrackedFile(file)
14
+ : readFileSync(join(ctx.repoRoot, file), 'utf8');
15
+ }
16
+ catch {
17
+ return null;
18
+ }
19
+ }
20
+ function dependencyNames(ctx) {
21
+ const names = new Set();
22
+ for (const file of ctx.trackedFiles) {
23
+ if (!PACKAGE_JSON_FILE.test(file) || TEST_OR_FIXTURE_DIRECTORY.test(file))
24
+ continue;
25
+ const source = readTrackedSource(ctx, file);
26
+ if (source === null)
27
+ continue;
28
+ let manifest;
29
+ try {
30
+ manifest = JSON.parse(source);
31
+ }
32
+ catch {
33
+ continue;
34
+ }
35
+ if (typeof manifest !== 'object' || manifest === null || Array.isArray(manifest))
36
+ continue;
37
+ for (const field of [
38
+ 'dependencies',
39
+ 'devDependencies',
40
+ 'optionalDependencies',
41
+ 'peerDependencies',
42
+ ]) {
43
+ const dependencies = manifest[field];
44
+ if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies))
45
+ continue;
46
+ for (const name of Object.keys(dependencies))
47
+ names.add(name);
48
+ }
49
+ }
50
+ return names;
51
+ }
52
+ function literalValues(source) {
53
+ return [...source.matchAll(STRING_LITERAL)].map((match) => (match[1] ?? match[2] ?? match[3]));
54
+ }
55
+ function expectedDependencyNames(values, names) {
56
+ return values.some((value) => SEMVER_LITERAL.test(value))
57
+ ? values.filter((value) => names.has(value))
58
+ : [];
59
+ }
60
+ /** Keeps Dependabot updates independent from literal dependency-version test assertions. */
61
+ export function checkManifestDependencyVersionAssertions(ctx, errors) {
62
+ const names = dependencyNames(ctx);
63
+ if (names.size === 0)
64
+ return;
65
+ const matchers = buildDependencyMatchers(names);
66
+ for (const file of ctx.trackedFiles) {
67
+ if (!TEST_SOURCE_FILE.test(file))
68
+ continue;
69
+ const source = readTrackedSource(ctx, file);
70
+ if (source === null) {
71
+ errors.push(`::error file=${file}::${file}: failed to read test source for manifest dependency version assertions`);
72
+ continue;
73
+ }
74
+ for (const expectation of findExpectations(source)) {
75
+ const asserted = new Set();
76
+ if (DEPENDENCY_FIELD.test(expectation.expression)) {
77
+ const values = literalValues(expectation.expected);
78
+ if (values.length === 1 && SEMVER_LITERAL.test(values[0])) {
79
+ for (const matcher of matchers)
80
+ if (matcher.member.test(expectation.expression))
81
+ asserted.add(matcher.name);
82
+ }
83
+ for (const name of expectedDependencyNames(values, names))
84
+ asserted.add(name);
85
+ for (const matcher of matchers)
86
+ if (matcher.objectValue.test(expectation.expected))
87
+ asserted.add(matcher.name);
88
+ }
89
+ for (const value of literalValues(expectation.expected)) {
90
+ for (const matcher of matchers)
91
+ if (matcher.packageSpec.test(value))
92
+ asserted.add(matcher.name);
93
+ }
94
+ for (const name of asserted) {
95
+ const line = source.slice(0, expectation.index).split('\n').length;
96
+ errors.push(`::error file=${file},line=${line}::${file}:${line}: tests must not assert the exact version of declared dependency "${name}"; assert dependency membership or derive the value from the manifest instead`);
97
+ }
98
+ }
99
+ }
100
+ }
@@ -0,0 +1,6 @@
1
+ export type Expectation = {
2
+ expression: string;
3
+ expected: string;
4
+ index: number;
5
+ };
6
+ export declare function findExpectations(source: string): Expectation[];