vouchington-tooling 0.0.15 โ†’ 0.0.16

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 (60) hide show
  1. package/README.md +12 -0
  2. package/dist/ci-local/index.d.mts +5 -0
  3. package/dist/ci-local/index.mjs +79 -0
  4. package/dist/ci-local/parse.d.mts +3 -0
  5. package/dist/ci-local/parse.mjs +43 -0
  6. package/dist/ci-local/types.d.mts +41 -0
  7. package/dist/ci-local/types.mjs +1 -0
  8. package/dist/cli/index.mjs +5 -0
  9. package/dist/cli/parse.d.mts +1 -1
  10. package/dist/cli/parse.mjs +5 -0
  11. package/dist/cli/usage.d.mts +1 -1
  12. package/dist/cli/usage.mjs +10 -0
  13. package/dist/dockerfile-parse/index.d.mts +4 -0
  14. package/dist/dockerfile-parse/index.mjs +2 -0
  15. package/dist/dockerfile-parse/prewarm-ports.d.mts +12 -0
  16. package/dist/dockerfile-parse/prewarm-ports.mjs +59 -0
  17. package/dist/dockerfile-parse/runtime-cmd.d.mts +11 -0
  18. package/dist/dockerfile-parse/runtime-cmd.mjs +142 -0
  19. package/dist/dockerfile-parse/stages.d.mts +7 -0
  20. package/dist/dockerfile-parse/stages.mjs +18 -0
  21. package/dist/gha-pr-checkpoint/index.d.mts +58 -0
  22. package/dist/gha-pr-checkpoint/index.mjs +100 -0
  23. package/dist/gha-rate-limit/index.d.mts +15 -0
  24. package/dist/gha-rate-limit/index.mjs +69 -0
  25. package/dist/index.d.mts +13 -0
  26. package/dist/index.mjs +7 -0
  27. package/dist/pnpm-deploy/index.d.mts +6 -0
  28. package/dist/pnpm-deploy/index.mjs +3 -0
  29. package/dist/pnpm-deploy/normalize.d.mts +15 -0
  30. package/dist/pnpm-deploy/normalize.mjs +64 -0
  31. package/dist/pnpm-deploy/prune.d.mts +14 -0
  32. package/dist/pnpm-deploy/prune.mjs +48 -0
  33. package/dist/pnpm-deploy/restore.d.mts +31 -0
  34. package/dist/pnpm-deploy/restore.mjs +109 -0
  35. package/dist/scc-complexity/index.d.mts +19 -0
  36. package/dist/scc-complexity/index.mjs +91 -0
  37. package/dist/workspace-gates/dependabot.d.mts +3 -0
  38. package/dist/workspace-gates/dependabot.mjs +26 -0
  39. package/dist/workspace-gates/exclude-registry.d.mts +3 -0
  40. package/dist/workspace-gates/exclude-registry.mjs +30 -0
  41. package/dist/workspace-gates/first-party-graph.d.mts +4 -0
  42. package/dist/workspace-gates/first-party-graph.mjs +87 -0
  43. package/dist/workspace-gates/index.d.mts +2 -0
  44. package/dist/workspace-gates/index.mjs +1 -0
  45. package/dist/workspace-gates/options.d.mts +27 -0
  46. package/dist/workspace-gates/options.mjs +15 -0
  47. package/dist/workspace-gates/policy.d.mts +5 -0
  48. package/dist/workspace-gates/policy.mjs +27 -0
  49. package/dist/workspace-gates/test-helpers.d.mts +21 -0
  50. package/dist/workspace-gates/test-helpers.mjs +69 -0
  51. package/dist/workspace-gates/yaml-loader.d.mts +2 -0
  52. package/dist/workspace-gates/yaml-loader.mjs +28 -0
  53. package/dist/workspace-glob.d.mts +1 -0
  54. package/dist/workspace-glob.mjs +46 -0
  55. package/package.json +39 -2
  56. package/scripts/gha/check-cache-size.sh +38 -0
  57. package/scripts/gha/clean-workspace.sh +227 -0
  58. package/scripts/gha/install-github-release.sh +136 -0
  59. package/scripts/gha/load-runner-env.sh +75 -0
  60. package/scripts/gha/make-shard-matrix.sh +30 -0
@@ -0,0 +1,100 @@
1
+ export const CHECKPOINT_MARKER = 'pr-checkpoint:v1';
2
+ /**
3
+ * Anti-forgery predicate: a checkpoint HTML comment is plain text any PR commenter can paste,
4
+ * so `parseCheckpoint` alone proves shape, not provenance. This proves the comment itself was
5
+ * authored by the expected App identity.
6
+ */
7
+ export function isTrustedCheckpointComment(comment, context) {
8
+ return (comment.user?.login === context.actor &&
9
+ comment.user?.type === (context.userType ?? 'Bot') &&
10
+ comment.performed_via_github_app?.slug === (context.appSlug ?? 'github-actions'));
11
+ }
12
+ export function renderCheckpoint(checkpoint, options = {}) {
13
+ const marker = options.marker ?? checkpoint.marker;
14
+ const payload = Buffer.from(JSON.stringify({ ...checkpoint, marker }), 'utf8').toString('base64url');
15
+ const session = checkpoint.sessionUrl
16
+ ? `[${checkpoint.sessionId}](${checkpoint.sessionUrl})`
17
+ : `\`${checkpoint.sessionId || 'pending'}\``;
18
+ return [
19
+ `๐Ÿ‘€ [View the automation workflow run](${checkpoint.runUrl}).`,
20
+ '',
21
+ `Status: \`${checkpoint.status}\` ยท Session: ${session}`,
22
+ '',
23
+ `<!-- ${marker} ${payload} -->`,
24
+ ].join('\n');
25
+ }
26
+ export function parseCheckpoint(body, options = {}) {
27
+ const marker = options.marker ?? CHECKPOINT_MARKER;
28
+ const match = body.match(new RegExp(`<!-- ${escapeRegExp(marker)} ([A-Za-z0-9_-]+) -->`, 'u'));
29
+ if (!match?.[1])
30
+ return undefined;
31
+ try {
32
+ return validateCheckpoint(JSON.parse(Buffer.from(match[1], 'base64url').toString('utf8')), options);
33
+ }
34
+ catch {
35
+ return undefined;
36
+ }
37
+ }
38
+ export function validateCheckpoint(value, options = {}) {
39
+ if (!value || typeof value !== 'object')
40
+ return undefined;
41
+ const checkpoint = value;
42
+ const marker = options.marker ?? CHECKPOINT_MARKER;
43
+ if (checkpoint.marker !== marker ||
44
+ !Number.isInteger(checkpoint.pr) ||
45
+ (checkpoint.triggerCommentId !== undefined && !Number.isInteger(checkpoint.triggerCommentId)) ||
46
+ typeof checkpoint.startSha !== 'string' ||
47
+ !/^[0-9a-f]{40}$/u.test(checkpoint.startSha) ||
48
+ typeof checkpoint.sessionStartSha !== 'string' ||
49
+ !/^[0-9a-f]{40}$/u.test(checkpoint.sessionStartSha) ||
50
+ typeof checkpoint.runId !== 'string' ||
51
+ !/^[0-9]+$/u.test(checkpoint.runId) ||
52
+ typeof checkpoint.resumeSourceRunId !== 'string' ||
53
+ (checkpoint.resumeSourceRunId !== '' && !/^[0-9]+$/u.test(checkpoint.resumeSourceRunId)) ||
54
+ typeof checkpoint.repository !== 'string' ||
55
+ typeof checkpoint.headRef !== 'string' ||
56
+ typeof checkpoint.runUrl !== 'string' ||
57
+ typeof checkpoint.actor !== 'string' ||
58
+ typeof checkpoint.sessionId !== 'string' ||
59
+ typeof checkpoint.createdAt !== 'string' ||
60
+ !Number.isFinite(Date.parse(checkpoint.createdAt)) ||
61
+ typeof checkpoint.updatedAt !== 'string' ||
62
+ !Number.isFinite(Date.parse(checkpoint.updatedAt)) ||
63
+ typeof checkpoint.status !== 'string' ||
64
+ ![
65
+ 'queued',
66
+ 'running',
67
+ 'awaiting_verification',
68
+ 'failed',
69
+ 'deadline',
70
+ 'unresumable',
71
+ 'complete',
72
+ ].includes(checkpoint.status)) {
73
+ return undefined;
74
+ }
75
+ if (checkpoint.sessionId &&
76
+ options.sessionIdPattern &&
77
+ !matchesPattern(options.sessionIdPattern, checkpoint.sessionId)) {
78
+ return undefined;
79
+ }
80
+ if (checkpoint.sessionUrl !== undefined &&
81
+ (!URL.canParse(checkpoint.sessionUrl) || !checkpoint.sessionUrl.startsWith('https:'))) {
82
+ return undefined;
83
+ }
84
+ return checkpoint;
85
+ }
86
+ export function sortedCheckpointCandidates(comments, options = {}) {
87
+ return comments
88
+ .map((comment) => ({ comment, checkpoint: parseCheckpoint(comment.body ?? '', options) }))
89
+ .filter((candidate) => candidate.checkpoint !== undefined)
90
+ .toSorted((left, right) => {
91
+ const timestampOrder = String(right.comment.created_at ?? '').localeCompare(String(left.comment.created_at ?? ''));
92
+ return timestampOrder || right.comment.id - left.comment.id;
93
+ });
94
+ }
95
+ function matchesPattern(pattern, value) {
96
+ return new RegExp(pattern.source, pattern.flags.replaceAll(/[gy]/g, '')).test(value);
97
+ }
98
+ function escapeRegExp(value) {
99
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
100
+ }
@@ -0,0 +1,15 @@
1
+ export declare const MAX_RATE_LIMIT_WAIT_MS = 120000;
2
+ export declare class GitHubRateLimitError extends Error {
3
+ readonly code: string;
4
+ readonly httpStatus?: number | undefined;
5
+ readonly retryAfter?: string | undefined;
6
+ readonly rateLimitRemaining?: string | undefined;
7
+ readonly rateLimitReset?: string | undefined;
8
+ constructor(code: string, message: string, httpStatus?: number, retryAfter?: string, rateLimitRemaining?: string, rateLimitReset?: string);
9
+ }
10
+ export declare function isRetryableCancellationError(error: unknown): boolean;
11
+ export declare function rateLimitDelay(error: GitHubRateLimitError): number;
12
+ export declare function isRateLimited(error: unknown): error is GitHubRateLimitError;
13
+ export declare function reserveRateLimitDelay(used: {
14
+ milliseconds: number;
15
+ }, delay: number): void;
@@ -0,0 +1,69 @@
1
+ const MAX_DELAY_MS = 60_000;
2
+ export const MAX_RATE_LIMIT_WAIT_MS = 120_000;
3
+ export class GitHubRateLimitError extends Error {
4
+ code;
5
+ httpStatus;
6
+ retryAfter;
7
+ rateLimitRemaining;
8
+ rateLimitReset;
9
+ constructor(code, message, httpStatus, retryAfter, rateLimitRemaining, rateLimitReset) {
10
+ super(message);
11
+ this.code = code;
12
+ this.httpStatus = httpStatus;
13
+ this.retryAfter = retryAfter;
14
+ this.rateLimitRemaining = rateLimitRemaining;
15
+ this.rateLimitReset = rateLimitReset;
16
+ this.name = 'GitHubRateLimitError';
17
+ }
18
+ }
19
+ function invalid() {
20
+ throw new GitHubRateLimitError('INVALID_RESPONSE', 'GitHub rate-limit response is invalid');
21
+ }
22
+ function rateLimit403(error) {
23
+ return (error.httpStatus === 403 && (error.retryAfter !== undefined || error.rateLimitRemaining === '0'));
24
+ }
25
+ export function isRetryableCancellationError(error) {
26
+ return (error instanceof GitHubRateLimitError &&
27
+ (error.code === 'REQUEST_FAILED' ||
28
+ (error.code === 'HTTP_STATUS' &&
29
+ (error.httpStatus === 429 ||
30
+ rateLimit403(error) ||
31
+ ((error.httpStatus ?? 0) >= 500 && (error.httpStatus ?? 0) <= 599)))));
32
+ }
33
+ export function rateLimitDelay(error) {
34
+ if (error.retryAfter !== undefined) {
35
+ if (!/^\d+$/u.test(error.retryAfter))
36
+ invalid();
37
+ const milliseconds = Number(error.retryAfter) * 1000;
38
+ if (!Number.isSafeInteger(milliseconds) || milliseconds > MAX_DELAY_MS)
39
+ invalid();
40
+ return milliseconds;
41
+ }
42
+ if (error.rateLimitRemaining !== undefined && !/^\d+$/u.test(error.rateLimitRemaining))
43
+ invalid();
44
+ if (error.rateLimitRemaining !== '0') {
45
+ if (error.httpStatus !== 429)
46
+ invalid();
47
+ return MAX_DELAY_MS;
48
+ }
49
+ if (!error.rateLimitReset || !/^\d+$/u.test(error.rateLimitReset))
50
+ invalid();
51
+ const reset = Number(error.rateLimitReset);
52
+ if (!Number.isSafeInteger(reset))
53
+ invalid();
54
+ const milliseconds = Math.max(0, reset * 1000 - Date.now());
55
+ if (milliseconds > MAX_DELAY_MS)
56
+ invalid();
57
+ return milliseconds;
58
+ }
59
+ export function isRateLimited(error) {
60
+ return (error instanceof GitHubRateLimitError &&
61
+ error.code === 'HTTP_STATUS' &&
62
+ (error.httpStatus === 429 || rateLimit403(error)));
63
+ }
64
+ export function reserveRateLimitDelay(used, delay) {
65
+ if (used.milliseconds + delay > MAX_RATE_LIMIT_WAIT_MS) {
66
+ throw new GitHubRateLimitError('RATE_LIMIT_WAIT_EXHAUSTED', 'GitHub rate-limit wait budget is exhausted');
67
+ }
68
+ used.milliseconds += delay;
69
+ }
package/dist/index.d.mts CHANGED
@@ -36,3 +36,16 @@ export type { CommentableIndex, CommentableLine, LineKind, PayloadRequirement, R
36
36
  export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mts';
37
37
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mts';
38
38
  export type { ExpectedTransportIdentity, ObjectSigner, PresignIdentity, TransportControl, } from './coverage-transport/index.mts';
39
+ export { EPOCH_PRUNED_AT, normalizeDeployedLayer, pruneDeployedRuntimeDeps, restoreDeployedWorkspacePackages, } from './pnpm-deploy/index.mts';
40
+ export type { NormalizeDeployedLayerResult, PruneResult, RestoreWorkspacePackagesOptions, } from './pnpm-deploy/index.mts';
41
+ export { parseDockerfilePrewarmStages, parseDockerfileRuntimeImages, } from './dockerfile-parse/index.mts';
42
+ export type { DockerfilePrewarmStage, DockerfileRuntimeImage, ParseDockerfilePrewarmOptions, ParseDockerfileRuntimeImagesOptions, } from './dockerfile-parse/index.mts';
43
+ export { buildSccArgs, checkSccComplexity, parseSccComplexityViolations, SCC_COMPLEXITY_LIMIT, } from './scc-complexity/index.mts';
44
+ export type { SccComplexityOptions, SccComplexityViolation } from './scc-complexity/index.mts';
45
+ export { assertWorkflowCommandDrift, parseCiLocalArgs, runCiLocal } from './ci-local/index.mts';
46
+ export type { CiLocalCommand, CiLocalSpawn, CiLocalSpawnOptions, CiLocalSpawnResult, CiLocalTarget, RunCiLocalOptions, } from './ci-local/index.mts';
47
+ export { GitHubRateLimitError, isRateLimited, isRetryableCancellationError, MAX_RATE_LIMIT_WAIT_MS, rateLimitDelay, reserveRateLimitDelay, } from './gha-rate-limit/index.mts';
48
+ export { CHECKPOINT_MARKER, isTrustedCheckpointComment, parseCheckpoint, renderCheckpoint, sortedCheckpointCandidates, validateCheckpoint, } from './gha-pr-checkpoint/index.mts';
49
+ export type { Checkpoint, CheckpointCodecOptions, GitHubComment, } from './gha-pr-checkpoint/index.mts';
50
+ export { checkWorkspaceGatesPolicy } from './workspace-gates/index.mts';
51
+ export type { WorkspaceGatesOptions } from './workspace-gates/index.mts';
package/dist/index.mjs CHANGED
@@ -21,3 +21,10 @@ export { parseAstGrepRuleArgs, runAstGrepRule } from './ast-grep-rule/index.mjs'
21
21
  export { bodyOnlyReviewFallback, 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';
22
22
  export { nextPageCursorFromLinkHeader, nextPageUrlFromLinkHeader, validatePaginationRequestUrl, } from './http-link-pagination/index.mjs';
23
23
  export { cmdDownloadCoverage, cmdDownloadVitestBlobs, cmdUpload, mintPresignedControl, transportObjectKeys, } from './coverage-transport/index.mjs';
24
+ export { EPOCH_PRUNED_AT, normalizeDeployedLayer, pruneDeployedRuntimeDeps, restoreDeployedWorkspacePackages, } from './pnpm-deploy/index.mjs';
25
+ export { parseDockerfilePrewarmStages, parseDockerfileRuntimeImages, } from './dockerfile-parse/index.mjs';
26
+ export { buildSccArgs, checkSccComplexity, parseSccComplexityViolations, SCC_COMPLEXITY_LIMIT, } from './scc-complexity/index.mjs';
27
+ export { assertWorkflowCommandDrift, parseCiLocalArgs, runCiLocal } from './ci-local/index.mjs';
28
+ export { GitHubRateLimitError, isRateLimited, isRetryableCancellationError, MAX_RATE_LIMIT_WAIT_MS, rateLimitDelay, reserveRateLimitDelay, } from './gha-rate-limit/index.mjs';
29
+ export { CHECKPOINT_MARKER, isTrustedCheckpointComment, parseCheckpoint, renderCheckpoint, sortedCheckpointCandidates, validateCheckpoint, } from './gha-pr-checkpoint/index.mjs';
30
+ export { checkWorkspaceGatesPolicy } from './workspace-gates/index.mjs';
@@ -0,0 +1,6 @@
1
+ export { pruneDeployedRuntimeDeps, runPruneDeployedRuntimeDepsCli } from './prune.mts';
2
+ export type { PruneResult } from './prune.mts';
3
+ export { EPOCH_PRUNED_AT, normalizeDeployedLayer, runNormalizeDeployedLayerCli, } from './normalize.mts';
4
+ export type { NormalizeDeployedLayerResult } from './normalize.mts';
5
+ export { restoreDeployedWorkspacePackages, runRestoreDeployedWorkspacePackagesCli, } from './restore.mts';
6
+ export type { RestoreWorkspacePackagesOptions } from './restore.mts';
@@ -0,0 +1,3 @@
1
+ export { pruneDeployedRuntimeDeps, runPruneDeployedRuntimeDepsCli } from './prune.mjs';
2
+ export { EPOCH_PRUNED_AT, normalizeDeployedLayer, runNormalizeDeployedLayerCli, } from './normalize.mjs';
3
+ export { restoreDeployedWorkspacePackages, runRestoreDeployedWorkspacePackagesCli, } from './restore.mjs';
@@ -0,0 +1,15 @@
1
+ export declare const EPOCH_PRUNED_AT = "Thu, 01 Jan 1970 00:00:00 GMT";
2
+ export interface NormalizeDeployedLayerResult {
3
+ prunedAtPinned: boolean;
4
+ entriesTouched: number;
5
+ }
6
+ export declare function normalizeDeployedLayer(prodDir: string): NormalizeDeployedLayerResult;
7
+ interface NormalizeCliOptions {
8
+ args: readonly string[];
9
+ env: NodeJS.ProcessEnv;
10
+ isMain: boolean;
11
+ normalize?: typeof normalizeDeployedLayer;
12
+ stdout: (message: string) => void;
13
+ }
14
+ export declare function runNormalizeDeployedLayerCli({ args, env, isMain, normalize, stdout, }: NormalizeCliOptions): void;
15
+ export {};
@@ -0,0 +1,64 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ export const EPOCH_PRUNED_AT = 'Thu, 01 Jan 1970 00:00:00 GMT';
4
+ export function normalizeDeployedLayer(prodDir) {
5
+ return {
6
+ prunedAtPinned: pinPrunedAt(prodDir),
7
+ entriesTouched: clampTreeMtimes(prodDir),
8
+ };
9
+ }
10
+ function pinPrunedAt(prodDir) {
11
+ const modulesPath = path.join(prodDir, 'node_modules', '.modules.yaml');
12
+ if (!fs.existsSync(modulesPath)) {
13
+ throw new Error(`${modulesPath} is missing; expected pnpm deploy to write it`);
14
+ }
15
+ // pnpm 11.13.1 writeModulesManifest serializes this file with JSON.stringify
16
+ // despite the .yaml name. A pnpm bump that switches to YAML must update this parse.
17
+ const parsed = JSON.parse(fs.readFileSync(modulesPath, 'utf8'));
18
+ if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
19
+ throw new Error(`${modulesPath} is not a JSON object`);
20
+ }
21
+ const manifest = parsed;
22
+ manifest.prunedAt = EPOCH_PRUNED_AT;
23
+ fs.writeFileSync(modulesPath, `${JSON.stringify(manifest, null, 2)}\n`);
24
+ return true;
25
+ }
26
+ function clampTreeMtimes(root) {
27
+ let entriesTouched = 0;
28
+ function visit(entryPath, isDirectory) {
29
+ if (isDirectory) {
30
+ for (const entry of fs.readdirSync(entryPath, { withFileTypes: true })) {
31
+ visit(path.join(entryPath, entry.name), entry.isDirectory());
32
+ }
33
+ }
34
+ else {
35
+ const stats = fs.lstatSync(entryPath);
36
+ if (stats.isFile() && stats.nlink > 1) {
37
+ const copyPath = `${entryPath}.${process.pid}.copy`;
38
+ fs.copyFileSync(entryPath, copyPath);
39
+ fs.renameSync(copyPath, entryPath);
40
+ }
41
+ }
42
+ fs.lutimesSync(entryPath, 0, 0);
43
+ entriesTouched += 1;
44
+ }
45
+ visit(root, fs.lstatSync(root).isDirectory());
46
+ return entriesTouched;
47
+ }
48
+ function resolveCliProdDirs(args, env) {
49
+ if (args.length > 0)
50
+ return [...args];
51
+ const prodDir = env.PROD_DIR;
52
+ if (!prodDir)
53
+ throw new Error('PROD_DIR or a directory argument is required');
54
+ return [prodDir];
55
+ }
56
+ export function runNormalizeDeployedLayerCli({ args, env, isMain, normalize = normalizeDeployedLayer, stdout, }) {
57
+ if (!isMain)
58
+ return;
59
+ for (const prodDir of resolveCliProdDirs(args, env)) {
60
+ const result = normalize(prodDir);
61
+ const prunedAt = result.prunedAtPinned ? 'prunedAt pinned' : 'prunedAt absent';
62
+ stdout(`Normalized deployed layer at ${prodDir} (${prunedAt}, ${result.entriesTouched} entries)`);
63
+ }
64
+ }
@@ -0,0 +1,14 @@
1
+ export interface PruneResult {
2
+ bytesRemoved: number;
3
+ filesRemoved: number;
4
+ }
5
+ export declare function pruneDeployedRuntimeDeps(prodDir: string): PruneResult;
6
+ interface PruneCliOptions {
7
+ args: readonly string[];
8
+ env: NodeJS.ProcessEnv;
9
+ isMain: boolean;
10
+ prune?: typeof pruneDeployedRuntimeDeps;
11
+ stdout: (message: string) => void;
12
+ }
13
+ export declare function runPruneDeployedRuntimeDepsCli({ args, env, isMain, prune, stdout, }: PruneCliOptions): void;
14
+ export {};
@@ -0,0 +1,48 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ const DECLARATION_SUFFIXES = ['.d.ts', '.d.mts', '.d.cts', '.d.ts.map', '.d.mts.map', '.d.cts.map'];
4
+ function shouldPruneRuntimeDependencyFile(filePath) {
5
+ return (DECLARATION_SUFFIXES.some((suffix) => filePath.endsWith(suffix)) ||
6
+ filePath.endsWith('.tsbuildinfo'));
7
+ }
8
+ function pruneDirectory(dir) {
9
+ const result = { bytesRemoved: 0, filesRemoved: 0 };
10
+ for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
11
+ const entryPath = path.join(dir, entry.name);
12
+ if (entry.isDirectory()) {
13
+ const child = pruneDirectory(entryPath);
14
+ result.bytesRemoved += child.bytesRemoved;
15
+ result.filesRemoved += child.filesRemoved;
16
+ continue;
17
+ }
18
+ if (!entry.isFile() || !shouldPruneRuntimeDependencyFile(entryPath))
19
+ continue;
20
+ const { size } = fs.statSync(entryPath);
21
+ fs.rmSync(entryPath, { force: true });
22
+ result.bytesRemoved += size;
23
+ result.filesRemoved += 1;
24
+ }
25
+ return result;
26
+ }
27
+ export function pruneDeployedRuntimeDeps(prodDir) {
28
+ const pnpmStore = path.join(prodDir, 'node_modules', '.pnpm');
29
+ if (!fs.existsSync(pnpmStore))
30
+ return { bytesRemoved: 0, filesRemoved: 0 };
31
+ return pruneDirectory(pnpmStore);
32
+ }
33
+ function resolveCliProdDirs(args, env) {
34
+ if (args.length > 0)
35
+ return [...args];
36
+ const prodDir = env.PROD_DIR;
37
+ if (!prodDir)
38
+ throw new Error('PROD_DIR or a directory argument is required');
39
+ return [prodDir];
40
+ }
41
+ export function runPruneDeployedRuntimeDepsCli({ args, env, isMain, prune = pruneDeployedRuntimeDeps, stdout, }) {
42
+ if (!isMain)
43
+ return;
44
+ for (const prodDir of resolveCliProdDirs(args, env)) {
45
+ const result = prune(prodDir);
46
+ stdout(`Pruned ${result.filesRemoved} runtime dependency artifact(s) from ${prodDir} (${result.bytesRemoved} bytes)`);
47
+ }
48
+ }
@@ -0,0 +1,31 @@
1
+ export interface RestoreWorkspacePackagesOptions {
2
+ backendDir?: string;
3
+ prodDir: string;
4
+ workspaceRoot?: string;
5
+ }
6
+ /**
7
+ * Node's --experimental-strip-types refuses to load TypeScript whose real path
8
+ * contains a node_modules segment, but pnpm deploy places workspace packages
9
+ * (shipped as TypeScript source) inside the virtual store at
10
+ * node_modules/.pnpm/<store-dir>/node_modules/<name>.
11
+ *
12
+ * Relocate every workspace-package copy outside node_modules while preserving
13
+ * pnpm's resolution topology, without enumerating dependencies:
14
+ *
15
+ * - move the content dir to workspace-packages/<store-dir>/<name>
16
+ * - leave a relative symlink at the original virtual-store location, so every
17
+ * pnpm-created link (top-level node_modules entries, sibling links from other
18
+ * store dirs) keeps resolving through it
19
+ * - add a node_modules backlink inside the moved dir pointing at the package's
20
+ * original virtual-store sibling directory, so the package's own imports
21
+ * resolve exactly as pnpm laid them out
22
+ */
23
+ export declare function restoreDeployedWorkspacePackages({ backendDir, prodDir, workspaceRoot, }: RestoreWorkspacePackagesOptions): void;
24
+ interface RestoreCliOptions {
25
+ args: readonly string[];
26
+ env: NodeJS.ProcessEnv;
27
+ isMain: boolean;
28
+ restore?: typeof restoreDeployedWorkspacePackages;
29
+ }
30
+ export declare function runRestoreDeployedWorkspacePackagesCli({ args, env, isMain, restore, }: RestoreCliOptions): void;
31
+ export {};
@@ -0,0 +1,109 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { expandWorkspaceGlob } from '../workspace-glob.mjs';
4
+ function packagePath(packageName) {
5
+ return path.join(...packageName.split('/'));
6
+ }
7
+ function linkTarget(fromDir, toPath) {
8
+ return path.relative(fromDir, toPath);
9
+ }
10
+ function readWorkspacePackageNames(backendDir) {
11
+ const backendPackage = JSON.parse(fs.readFileSync(path.join(backendDir, 'package.json'), 'utf8'));
12
+ const workspaces = Array.isArray(backendPackage.workspaces) ? backendPackage.workspaces : [];
13
+ const names = new Set();
14
+ for (const workspace of workspaces.flatMap((pattern) => expandWorkspaceGlob(backendDir, pattern))) {
15
+ const packageJson = path.join(workspace, 'package.json');
16
+ if (!fs.existsSync(packageJson))
17
+ continue;
18
+ const { name } = JSON.parse(fs.readFileSync(packageJson, 'utf8'));
19
+ if (name)
20
+ names.add(name);
21
+ }
22
+ return names;
23
+ }
24
+ function mergeSibling(backlink, storeModules, sibling) {
25
+ const siblingLink = path.join(backlink, sibling);
26
+ const source = path.join(storeModules, sibling);
27
+ const existing = fs.lstatSync(siblingLink, { throwIfNoEntry: false });
28
+ if (!existing) {
29
+ fs.symlinkSync(linkTarget(backlink, source), siblingLink, 'dir');
30
+ return;
31
+ }
32
+ if (existing.isSymbolicLink() || !existing.isDirectory())
33
+ return;
34
+ const sourceStats = fs.statSync(source, { throwIfNoEntry: false });
35
+ if (!sourceStats?.isDirectory())
36
+ return;
37
+ for (const child of fs.readdirSync(source))
38
+ mergeSibling(siblingLink, source, child);
39
+ }
40
+ /**
41
+ * Node's --experimental-strip-types refuses to load TypeScript whose real path
42
+ * contains a node_modules segment, but pnpm deploy places workspace packages
43
+ * (shipped as TypeScript source) inside the virtual store at
44
+ * node_modules/.pnpm/<store-dir>/node_modules/<name>.
45
+ *
46
+ * Relocate every workspace-package copy outside node_modules while preserving
47
+ * pnpm's resolution topology, without enumerating dependencies:
48
+ *
49
+ * - move the content dir to workspace-packages/<store-dir>/<name>
50
+ * - leave a relative symlink at the original virtual-store location, so every
51
+ * pnpm-created link (top-level node_modules entries, sibling links from other
52
+ * store dirs) keeps resolving through it
53
+ * - add a node_modules backlink inside the moved dir pointing at the package's
54
+ * original virtual-store sibling directory, so the package's own imports
55
+ * resolve exactly as pnpm laid them out
56
+ */
57
+ export function restoreDeployedWorkspacePackages({ backendDir, prodDir, workspaceRoot, }) {
58
+ if (!prodDir)
59
+ throw new Error('prodDir is required');
60
+ if (!backendDir)
61
+ throw new Error('backendDir is required');
62
+ const resolvedWorkspaceRoot = workspaceRoot ?? path.join(prodDir, 'workspace-packages');
63
+ const workspacePackageNames = readWorkspacePackageNames(backendDir);
64
+ const pnpmRoot = path.join(prodDir, 'node_modules', '.pnpm');
65
+ if (!fs.existsSync(pnpmRoot))
66
+ return;
67
+ for (const storeEntry of fs.readdirSync(pnpmRoot, { withFileTypes: true })) {
68
+ if (!storeEntry.isDirectory() || storeEntry.name === 'node_modules')
69
+ continue;
70
+ const storeModules = path.join(pnpmRoot, storeEntry.name, 'node_modules');
71
+ for (const name of workspacePackageNames) {
72
+ const contentDir = path.join(storeModules, packagePath(name));
73
+ const stats = fs.lstatSync(contentDir, { throwIfNoEntry: false });
74
+ if (!stats?.isDirectory())
75
+ continue;
76
+ const relocated = path.join(resolvedWorkspaceRoot, storeEntry.name, packagePath(name));
77
+ fs.rmSync(relocated, { recursive: true, force: true });
78
+ fs.mkdirSync(path.dirname(relocated), { recursive: true });
79
+ // copy+delete, not rename: renaming a directory out of a lower Docker
80
+ // overlayfs layer fails with EXDEV
81
+ fs.cpSync(contentDir, relocated, { recursive: true, verbatimSymlinks: true });
82
+ fs.rmSync(contentDir, { recursive: true, force: true });
83
+ fs.symlinkSync(linkTarget(path.dirname(contentDir), relocated), contentDir, 'dir');
84
+ const backlink = path.join(relocated, 'node_modules');
85
+ const backlinkStats = fs.lstatSync(backlink, { throwIfNoEntry: false });
86
+ if (!backlinkStats) {
87
+ fs.symlinkSync(linkTarget(relocated, storeModules), backlink, 'dir');
88
+ }
89
+ else if (backlinkStats.isDirectory()) {
90
+ // pnpm deploy leaves a real node_modules (containing .bin) inside some
91
+ // injected packages; merge sibling links into it instead of replacing it
92
+ for (const sibling of fs.readdirSync(storeModules)) {
93
+ mergeSibling(backlink, storeModules, sibling);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ }
99
+ export function runRestoreDeployedWorkspacePackagesCli({ args, env, isMain, restore = restoreDeployedWorkspacePackages, }) {
100
+ if (!isMain)
101
+ return;
102
+ const prodDir = args[0] ?? env.PROD_DIR;
103
+ const backendDir = args[1] ?? env.BACKEND_DIR;
104
+ if (!prodDir)
105
+ throw new Error('PROD_DIR or a directory argument is required');
106
+ if (!backendDir)
107
+ throw new Error('BACKEND_DIR or a backend directory argument is required');
108
+ restore({ prodDir, backendDir });
109
+ }
@@ -0,0 +1,19 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ export declare const SCC_COMPLEXITY_LIMIT = 50;
3
+ export interface SccComplexityOptions {
4
+ limit?: number;
5
+ includeExt?: string;
6
+ excludeDir?: string;
7
+ notMatch?: string;
8
+ tmpdirPrefix?: string;
9
+ command?: string;
10
+ }
11
+ export interface SccComplexityViolation {
12
+ file: string;
13
+ complexity: number;
14
+ }
15
+ export declare function buildSccArgs(options?: SccComplexityOptions): string[];
16
+ export declare function checkSccComplexity(ctx: SharedContext, options?: SccComplexityOptions, runScc?: (outputPath: string) => Promise<string>): Promise<{
17
+ errors: string[];
18
+ }>;
19
+ export declare function parseSccComplexityViolations(json: string, trackedFileSet: ReadonlySet<string>, limit?: number): SccComplexityViolation[];
@@ -0,0 +1,91 @@
1
+ import { execFile } from 'node:child_process';
2
+ import { mkdtemp, readFile, rm } from 'node:fs/promises';
3
+ import { tmpdir } from 'node:os';
4
+ import { join } from 'node:path';
5
+ import { promisify } from 'node:util';
6
+ const execFileAsync = promisify(execFile);
7
+ export const SCC_COMPLEXITY_LIMIT = 50;
8
+ const DEFAULT_INCLUDE_EXT = 'js,mts,jsx,ts,tsx';
9
+ const DEFAULT_EXCLUDE_DIR = '.git,fixtures,__tests__,test-helpers';
10
+ const DEFAULT_NOT_MATCH = String.raw `\.(test|spec)\.`;
11
+ const DEFAULT_TMPDIR_PREFIX = 'scc-complexity-';
12
+ export function buildSccArgs(options = {}) {
13
+ return [
14
+ '--format',
15
+ 'json',
16
+ '--include-ext',
17
+ options.includeExt ?? DEFAULT_INCLUDE_EXT,
18
+ '--by-file',
19
+ '--sort',
20
+ 'complexity',
21
+ '--exclude-dir',
22
+ options.excludeDir ?? DEFAULT_EXCLUDE_DIR,
23
+ '--not-match',
24
+ options.notMatch ?? DEFAULT_NOT_MATCH,
25
+ '--no-cocomo',
26
+ ];
27
+ }
28
+ export async function checkSccComplexity(ctx, options = {}, runScc) {
29
+ if (!ctx.isInsideGitRepo) {
30
+ return { errors: [`::error::${ctx.repoRoot} is not inside a git repository`] };
31
+ }
32
+ const dir = await mkdtemp(join(tmpdir(), options.tmpdirPrefix ?? DEFAULT_TMPDIR_PREFIX));
33
+ const outputPath = join(dir, 'scc.json');
34
+ const limit = options.limit ?? SCC_COMPLEXITY_LIMIT;
35
+ try {
36
+ const resolve = runScc ?? ((path) => runSccJson(ctx.repoRoot, path, options));
37
+ const json = await resolve(outputPath);
38
+ const violations = parseSccComplexityViolations(json, ctx.trackedFileSet, limit);
39
+ return { errors: violations.map((violation) => formatViolation(violation, limit)) };
40
+ }
41
+ catch (error) {
42
+ const message = error instanceof Error ? error.message : String(error);
43
+ return { errors: [`::error::scc-complexity failed: ${message}`] };
44
+ }
45
+ finally {
46
+ await rm(dir, { force: true, recursive: true });
47
+ }
48
+ }
49
+ export function parseSccComplexityViolations(json, trackedFileSet, limit = SCC_COMPLEXITY_LIMIT) {
50
+ const parsed = JSON.parse(json);
51
+ if (!Array.isArray(parsed))
52
+ throw new Error('scc JSON output must be an array');
53
+ const violations = [];
54
+ for (const language of parsed) {
55
+ if (!Array.isArray(language.Files))
56
+ continue;
57
+ for (const file of language.Files) {
58
+ if (typeof file.Location !== 'string')
59
+ continue;
60
+ if (!trackedFileSet.has(file.Location))
61
+ continue;
62
+ if (typeof file.Complexity !== 'number')
63
+ continue;
64
+ if (file.Complexity <= limit)
65
+ continue;
66
+ violations.push({ file: file.Location, complexity: file.Complexity });
67
+ }
68
+ }
69
+ return violations.toSorted((a, b) => b.complexity - a.complexity || a.file.localeCompare(b.file));
70
+ }
71
+ async function runSccJson(repoRoot, outputPath, options) {
72
+ try {
73
+ await execFileAsync(options.command ?? 'scc', [...buildSccArgs(options), '--output', outputPath], {
74
+ cwd: repoRoot,
75
+ maxBuffer: 1024 * 1024,
76
+ });
77
+ }
78
+ catch (error) {
79
+ if (isNodeSystemError(error) && error.code === 'ENOENT') {
80
+ throw new Error('scc executable not found; install with mise install', { cause: error });
81
+ }
82
+ throw error;
83
+ }
84
+ return readFile(outputPath, 'utf8');
85
+ }
86
+ function formatViolation({ file, complexity }, limit) {
87
+ return `::error file=${file}::${file}: scc complexity ${complexity} exceeds ${limit}; simplify or split this file`;
88
+ }
89
+ function isNodeSystemError(error) {
90
+ return error instanceof Error && 'code' in error;
91
+ }
@@ -0,0 +1,3 @@
1
+ import type { ResolvedWorkspaceGatesOptions } from './options.mts';
2
+ import type { AnyObj } from './yaml-loader.mts';
3
+ export declare function checkDependabotCrosslist(dependabot: AnyObj, errors: string[], options: ResolvedWorkspaceGatesOptions): void;
@@ -0,0 +1,26 @@
1
+ import picomatch from 'picomatch';
2
+ function isRecord(value) {
3
+ return typeof value === 'object' && value !== null && !Array.isArray(value);
4
+ }
5
+ export function checkDependabotCrosslist(dependabot, errors, options) {
6
+ const updates = Array.isArray(dependabot['updates']) ? dependabot['updates'] : [];
7
+ const npmUpdate = updates.find((entry) => isRecord(entry) && entry['package-ecosystem'] === 'npm' && entry['directory'] === '/');
8
+ if (!npmUpdate)
9
+ return;
10
+ const cooldown = npmUpdate['cooldown'];
11
+ const rawExclude = isRecord(cooldown) && Array.isArray(cooldown['exclude'])
12
+ ? cooldown['exclude']
13
+ : [];
14
+ for (const entry of rawExclude) {
15
+ if (typeof entry !== 'string') {
16
+ errors.push(`::error file=${options.dependabotPath}::${options.dependabotPath}: npm cooldown.exclude entry ${JSON.stringify(entry)} must be a string glob pattern`);
17
+ }
18
+ }
19
+ const patterns = rawExclude.filter((entry) => typeof entry === 'string');
20
+ const matchers = patterns.map((pattern) => picomatch(pattern));
21
+ for (const name of options.firstPartyNames) {
22
+ if (!matchers.some((match) => match(name))) {
23
+ errors.push(`::error file=${options.dependabotPath}::${options.dependabotPath}: "${name}" is in ${options.workspaceYamlPath} minimumReleaseAgeExclude but not covered by npm cooldown.exclude โ€” add it or a matching glob`);
24
+ }
25
+ }
26
+ }