vouchington-tooling 0.0.14 → 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 (79) hide show
  1. package/README.md +13 -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/coverage-transport/constants.d.mts +3 -0
  14. package/dist/coverage-transport/constants.mjs +11 -0
  15. package/dist/coverage-transport/control.d.mts +47 -0
  16. package/dist/coverage-transport/control.mjs +112 -0
  17. package/dist/coverage-transport/http.d.mts +6 -0
  18. package/dist/coverage-transport/http.mjs +99 -0
  19. package/dist/coverage-transport/index.d.mts +6 -0
  20. package/dist/coverage-transport/index.mjs +6 -0
  21. package/dist/coverage-transport/lib.d.mts +17 -0
  22. package/dist/coverage-transport/lib.mjs +84 -0
  23. package/dist/coverage-transport/outcome.d.mts +29 -0
  24. package/dist/coverage-transport/outcome.mjs +70 -0
  25. package/dist/coverage-transport/presign.d.mts +22 -0
  26. package/dist/coverage-transport/presign.mjs +51 -0
  27. package/dist/coverage-transport/vitest-blob-transport.d.mts +5 -0
  28. package/dist/coverage-transport/vitest-blob-transport.mjs +129 -0
  29. package/dist/dockerfile-parse/index.d.mts +4 -0
  30. package/dist/dockerfile-parse/index.mjs +2 -0
  31. package/dist/dockerfile-parse/prewarm-ports.d.mts +12 -0
  32. package/dist/dockerfile-parse/prewarm-ports.mjs +59 -0
  33. package/dist/dockerfile-parse/runtime-cmd.d.mts +11 -0
  34. package/dist/dockerfile-parse/runtime-cmd.mjs +142 -0
  35. package/dist/dockerfile-parse/stages.d.mts +7 -0
  36. package/dist/dockerfile-parse/stages.mjs +18 -0
  37. package/dist/gha-pr-checkpoint/index.d.mts +58 -0
  38. package/dist/gha-pr-checkpoint/index.mjs +100 -0
  39. package/dist/gha-rate-limit/index.d.mts +15 -0
  40. package/dist/gha-rate-limit/index.mjs +69 -0
  41. package/dist/index.d.mts +15 -0
  42. package/dist/index.mjs +8 -0
  43. package/dist/pnpm-deploy/index.d.mts +6 -0
  44. package/dist/pnpm-deploy/index.mjs +3 -0
  45. package/dist/pnpm-deploy/normalize.d.mts +15 -0
  46. package/dist/pnpm-deploy/normalize.mjs +64 -0
  47. package/dist/pnpm-deploy/prune.d.mts +14 -0
  48. package/dist/pnpm-deploy/prune.mjs +48 -0
  49. package/dist/pnpm-deploy/restore.d.mts +31 -0
  50. package/dist/pnpm-deploy/restore.mjs +109 -0
  51. package/dist/scc-complexity/index.d.mts +19 -0
  52. package/dist/scc-complexity/index.mjs +91 -0
  53. package/dist/transient-retry/decision-evaluator.d.mts +2 -0
  54. package/dist/transient-retry/decision-evaluator.mjs +6 -0
  55. package/dist/transient-retry/types.d.mts +3 -3
  56. package/dist/workspace-gates/dependabot.d.mts +3 -0
  57. package/dist/workspace-gates/dependabot.mjs +26 -0
  58. package/dist/workspace-gates/exclude-registry.d.mts +3 -0
  59. package/dist/workspace-gates/exclude-registry.mjs +30 -0
  60. package/dist/workspace-gates/first-party-graph.d.mts +4 -0
  61. package/dist/workspace-gates/first-party-graph.mjs +87 -0
  62. package/dist/workspace-gates/index.d.mts +2 -0
  63. package/dist/workspace-gates/index.mjs +1 -0
  64. package/dist/workspace-gates/options.d.mts +27 -0
  65. package/dist/workspace-gates/options.mjs +15 -0
  66. package/dist/workspace-gates/policy.d.mts +5 -0
  67. package/dist/workspace-gates/policy.mjs +27 -0
  68. package/dist/workspace-gates/test-helpers.d.mts +21 -0
  69. package/dist/workspace-gates/test-helpers.mjs +69 -0
  70. package/dist/workspace-gates/yaml-loader.d.mts +2 -0
  71. package/dist/workspace-gates/yaml-loader.mjs +28 -0
  72. package/dist/workspace-glob.d.mts +1 -0
  73. package/dist/workspace-glob.mjs +46 -0
  74. package/package.json +44 -2
  75. package/scripts/gha/check-cache-size.sh +38 -0
  76. package/scripts/gha/clean-workspace.sh +227 -0
  77. package/scripts/gha/install-github-release.sh +136 -0
  78. package/scripts/gha/load-runner-env.sh +75 -0
  79. package/scripts/gha/make-shard-matrix.sh +30 -0
@@ -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
+ }
@@ -14,5 +14,7 @@ export type DecisionResult = {
14
14
  };
15
15
  export interface EvaluateRulesOptions {
16
16
  afterRuleEvaluated?: (context: RetryContext, rule: RetryRule) => Promise<void> | void;
17
+ /** Defaults to `no-match`. Use `dispatch` when unmatched work should leave the retry engine. */
18
+ unmatchedDecision?: 'no-match' | 'dispatch';
17
19
  }
18
20
  export declare function decide(context: RetryContext, rules: readonly RetryRule[], options?: EvaluateRulesOptions): Promise<DecisionResult>;
@@ -46,11 +46,17 @@ export async function decide(context, rules, options = {}) {
46
46
  if (rule.decision !== undefined && rule.decision !== 'rerun') {
47
47
  return { decision: rule.decision, matchedRule: rule.id };
48
48
  }
49
+ if (rule.retryTarget === undefined) {
50
+ return { decision: 'rerun', matchedRule: rule.id, targetName: '' };
51
+ }
49
52
  const resolved = resolveTargetName(rule.retryTarget, context);
50
53
  if (resolved.targetName === undefined) {
51
54
  return { decision: 'no-match', matchedRule: rule.id, reason: resolved.reason };
52
55
  }
53
56
  return { decision: 'rerun', matchedRule: rule.id, targetName: resolved.targetName };
54
57
  }
58
+ if (options.unmatchedDecision === 'dispatch') {
59
+ return { decision: 'dispatch', matchedRule: '' };
60
+ }
55
61
  return { decision: 'no-match', matchedRule: '', reason: 'no-rule' };
56
62
  }
@@ -9,7 +9,7 @@ export interface RetryContext {
9
9
  /** Targets that the provider resolved for this run. */
10
10
  targetNames?: ReadonlySet<string>;
11
11
  }
12
- export type RetryDecision = 'rerun' | 'ignore' | 'fresh-plan' | 'reap-lock';
12
+ export type RetryDecision = 'rerun' | 'ignore' | 'fresh-plan' | 'reap-lock' | 'dispatch';
13
13
  export type RetryTarget = {
14
14
  targetName: string;
15
15
  targetFamily?: never;
@@ -30,8 +30,8 @@ interface RetryRuleBase {
30
30
  }
31
31
  export type RetryRule = (RetryRuleBase & {
32
32
  decision?: 'rerun';
33
- /** Reruns must name a resolved target; full-provider reruns stay private. */
34
- retryTarget: RetryTarget;
33
+ /** When omitted, the match is an untargeted provider-level rerun. */
34
+ retryTarget?: RetryTarget;
35
35
  }) | (RetryRuleBase & {
36
36
  decision: Exclude<RetryDecision, 'rerun'>;
37
37
  retryTarget?: never;
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ import type { ResolvedWorkspaceGatesOptions } from './options.mts';
2
+ import type { AnyObj } from './yaml-loader.mts';
3
+ export declare function checkExcludeRegistry(workspace: AnyObj, errors: string[], options: ResolvedWorkspaceGatesOptions): void;
@@ -0,0 +1,30 @@
1
+ export function checkExcludeRegistry(workspace, errors, options) {
2
+ const raw = workspace['minimumReleaseAgeExclude'];
3
+ const yamlEntries = Array.isArray(raw)
4
+ ? raw.filter((entry) => typeof entry === 'string')
5
+ : [];
6
+ const yamlNames = new Set();
7
+ for (const name of yamlEntries) {
8
+ if (yamlNames.has(name)) {
9
+ errors.push(`::error file=${options.workspaceYamlPath}::${options.workspaceYamlPath}: minimumReleaseAgeExclude duplicates "${name}"`);
10
+ }
11
+ yamlNames.add(name);
12
+ }
13
+ const registryNames = new Set(options.firstPartyNames);
14
+ const temporarySelectors = new Set(options.temporarySelectors);
15
+ for (const name of yamlNames) {
16
+ if (!registryNames.has(name) && !temporarySelectors.has(name)) {
17
+ errors.push(`::error file=${options.workspaceYamlPath}::${options.workspaceYamlPath}: minimumReleaseAgeExclude contains "${name}" which is not in a release-age exemption registry`);
18
+ }
19
+ }
20
+ for (const name of registryNames) {
21
+ if (!yamlNames.has(name)) {
22
+ errors.push(`::error file=${options.workspaceYamlPath}::${options.workspaceYamlPath}: first-party package "${name}" is in the ${options.firstPartyRegistryLabel} but missing from minimumReleaseAgeExclude`);
23
+ }
24
+ }
25
+ for (const selector of temporarySelectors) {
26
+ if (!yamlNames.has(selector)) {
27
+ errors.push(`::error file=${options.workspaceYamlPath}::${options.workspaceYamlPath}: temporary release-age exemption "${selector}" is in the ${options.firstPartyRegistryLabel} but missing from minimumReleaseAgeExclude`);
28
+ }
29
+ }
30
+ }
@@ -0,0 +1,4 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ import type { ResolvedWorkspaceGatesOptions } from './options.mts';
3
+ export declare function checkActiveFirstPartyGraph(ctx: SharedContext, errors: string[], options: ResolvedWorkspaceGatesOptions): Promise<void>;
4
+ export declare function checkTemporaryReleaseAgeSelectorsInLockfile(ctx: SharedContext, errors: string[], temporarySelectors: readonly string[], lockfilePath: string): Promise<void>;
@@ -0,0 +1,87 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { packageNameFromPnpmLockKey, pnpmLockPackageKeyMatchesSelector, } from '../pnpm-install/index.mjs';
4
+ import { loadYaml } from './yaml-loader.mjs';
5
+ const PACKAGE_DEPENDENCY_FIELDS = [
6
+ 'dependencies',
7
+ 'devDependencies',
8
+ 'optionalDependencies',
9
+ 'peerDependencies',
10
+ ];
11
+ async function collectManifestDependencyNames(ctx, errors) {
12
+ const names = new Set();
13
+ const packageJsonFiles = ctx.trackedFiles.filter((file) => file === 'package.json' || file.endsWith('/package.json'));
14
+ for (const relPath of packageJsonFiles) {
15
+ let parsed;
16
+ try {
17
+ parsed = JSON.parse(await readFile(join(ctx.repoRoot, relPath), 'utf8'));
18
+ }
19
+ catch (error) {
20
+ const code = typeof error === 'object' && error !== null && 'code' in error
21
+ ? String(error.code)
22
+ : undefined;
23
+ const reason = code === undefined ? 'parse' : 'read';
24
+ errors.push(`::error file=${relPath}::${relPath}: failed to ${reason} package.json`);
25
+ continue;
26
+ }
27
+ if (typeof parsed !== 'object' || parsed === null)
28
+ continue;
29
+ for (const field of PACKAGE_DEPENDENCY_FIELDS) {
30
+ const dependencies = parsed[field];
31
+ if (typeof dependencies !== 'object' ||
32
+ dependencies === null ||
33
+ Array.isArray(dependencies)) {
34
+ continue;
35
+ }
36
+ for (const name of Object.keys(dependencies))
37
+ names.add(name);
38
+ }
39
+ }
40
+ return names;
41
+ }
42
+ async function collectLockfilePackageKeys(ctx, errors, lockfilePath) {
43
+ if (!ctx.trackedFileSet.has(lockfilePath))
44
+ return [];
45
+ const lockfile = await loadYaml(ctx.repoRoot, lockfilePath, errors);
46
+ const packages = lockfile?.['packages'];
47
+ if (typeof packages !== 'object' || packages === null || Array.isArray(packages))
48
+ return [];
49
+ return Object.keys(packages);
50
+ }
51
+ function collectLockfilePackageNames(packageKeys) {
52
+ const names = new Set();
53
+ for (const key of packageKeys) {
54
+ const packageName = packageNameFromPnpmLockKey(key);
55
+ if (packageName)
56
+ names.add(packageName);
57
+ }
58
+ return names;
59
+ }
60
+ export async function checkActiveFirstPartyGraph(ctx, errors, options) {
61
+ const registryNames = new Set(options.firstPartyNames);
62
+ const manifestDependencyNames = await collectManifestDependencyNames(ctx, errors);
63
+ const lockfilePackageKeys = await collectLockfilePackageKeys(ctx, errors, options.lockfilePath);
64
+ const lockfilePackageNames = collectLockfilePackageNames(lockfilePackageKeys);
65
+ const activeNames = new Set([...manifestDependencyNames, ...lockfilePackageNames]);
66
+ if (options.scopedPrefixes.length > 0) {
67
+ for (const name of activeNames) {
68
+ if (options.scopedPrefixes.some((prefix) => name.startsWith(prefix)) &&
69
+ !registryNames.has(name)) {
70
+ errors.push(`::error::active first-party package "${name}" appears in tracked package manifests or ${options.lockfilePath} but is missing from the ${options.firstPartyRegistryLabel}`);
71
+ }
72
+ }
73
+ }
74
+ for (const name of registryNames) {
75
+ if (!activeNames.has(name)) {
76
+ errors.push(`::error::first-party package "${name}" is registered but absent from tracked package manifests and ${options.lockfilePath}`);
77
+ }
78
+ }
79
+ }
80
+ export async function checkTemporaryReleaseAgeSelectorsInLockfile(ctx, errors, temporarySelectors, lockfilePath) {
81
+ const lockfilePackageKeys = await collectLockfilePackageKeys(ctx, errors, lockfilePath);
82
+ for (const selector of temporarySelectors) {
83
+ if (!lockfilePackageKeys.some((key) => pnpmLockPackageKeyMatchesSelector(key, selector))) {
84
+ errors.push(`::error file=${lockfilePath}::temporary release-age exemption "${selector}" is absent from tracked ${lockfilePath} packages`);
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,2 @@
1
+ export { checkWorkspaceGatesPolicy } from './policy.mts';
2
+ export type { WorkspaceGatesOptions } from './options.mts';
@@ -0,0 +1 @@
1
+ export { checkWorkspaceGatesPolicy } from './policy.mjs';
@@ -0,0 +1,27 @@
1
+ import { type ReleaseAgeExemptionGroup } from '../pnpm-install/index.mts';
2
+ export interface WorkspaceGatesOptions {
3
+ firstPartyNames: readonly string[];
4
+ temporarySelectors?: readonly string[];
5
+ temporaryGroups?: ReadonlyArray<ReleaseAgeExemptionGroup>;
6
+ /** Empty or omitted skips scoped-prefix discovery. */
7
+ scopedPrefixes?: readonly string[];
8
+ /** Default `pnpm-workspace.yaml`. */
9
+ workspaceYamlPath?: string;
10
+ /** Default `.github/dependabot.yml`. */
11
+ dependabotPath?: string;
12
+ /** Default `pnpm-lock.yaml`. */
13
+ lockfilePath?: string;
14
+ /** Used in error messages. Default `first-party registry`. */
15
+ firstPartyRegistryLabel?: string;
16
+ }
17
+ export interface ResolvedWorkspaceGatesOptions {
18
+ firstPartyNames: readonly string[];
19
+ temporarySelectors: readonly string[];
20
+ scopedPrefixes: readonly string[];
21
+ workspaceYamlPath: string;
22
+ dependabotPath: string;
23
+ lockfilePath: string;
24
+ firstPartyRegistryLabel: string;
25
+ }
26
+ export declare function resolveWorkspaceGatesOptions(options: WorkspaceGatesOptions): ResolvedWorkspaceGatesOptions;
27
+ export declare function shouldValidateTemporaryGroups(options: WorkspaceGatesOptions): boolean;
@@ -0,0 +1,15 @@
1
+ import { flattenReleaseAgeSelectors, } from '../pnpm-install/index.mjs';
2
+ export function resolveWorkspaceGatesOptions(options) {
3
+ return {
4
+ firstPartyNames: options.firstPartyNames,
5
+ temporarySelectors: options.temporarySelectors ?? flattenReleaseAgeSelectors(options.temporaryGroups ?? []),
6
+ scopedPrefixes: options.scopedPrefixes ?? [],
7
+ workspaceYamlPath: options.workspaceYamlPath ?? 'pnpm-workspace.yaml',
8
+ dependabotPath: options.dependabotPath ?? '.github/dependabot.yml',
9
+ lockfilePath: options.lockfilePath ?? 'pnpm-lock.yaml',
10
+ firstPartyRegistryLabel: options.firstPartyRegistryLabel ?? 'first-party registry',
11
+ };
12
+ }
13
+ export function shouldValidateTemporaryGroups(options) {
14
+ return options.temporaryGroups !== undefined && options.temporarySelectors === undefined;
15
+ }
@@ -0,0 +1,5 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ import { type WorkspaceGatesOptions } from './options.mts';
3
+ export declare function checkWorkspaceGatesPolicy(ctx: SharedContext, options: WorkspaceGatesOptions): Promise<{
4
+ errors: string[];
5
+ }>;
@@ -0,0 +1,27 @@
1
+ import { validateReleaseAgeExemptionGroups } from '../pnpm-install/index.mjs';
2
+ import { checkDependabotCrosslist } from './dependabot.mjs';
3
+ import { checkExcludeRegistry } from './exclude-registry.mjs';
4
+ import { checkActiveFirstPartyGraph, checkTemporaryReleaseAgeSelectorsInLockfile, } from './first-party-graph.mjs';
5
+ import { resolveWorkspaceGatesOptions, shouldValidateTemporaryGroups, } from './options.mjs';
6
+ import { loadYaml } from './yaml-loader.mjs';
7
+ export async function checkWorkspaceGatesPolicy(ctx, options) {
8
+ const errors = [];
9
+ const resolved = resolveWorkspaceGatesOptions(options);
10
+ if (shouldValidateTemporaryGroups(options) && options.temporaryGroups !== undefined) {
11
+ for (const error of validateReleaseAgeExemptionGroups(options.temporaryGroups)) {
12
+ errors.push(`::error::${error}`);
13
+ }
14
+ }
15
+ const workspace = await loadYaml(ctx.repoRoot, resolved.workspaceYamlPath, errors);
16
+ if (!workspace)
17
+ return { errors };
18
+ checkExcludeRegistry(workspace, errors, resolved);
19
+ const [, , dependabot] = await Promise.all([
20
+ checkActiveFirstPartyGraph(ctx, errors, resolved),
21
+ checkTemporaryReleaseAgeSelectorsInLockfile(ctx, errors, resolved.temporarySelectors, resolved.lockfilePath),
22
+ loadYaml(ctx.repoRoot, resolved.dependabotPath, errors),
23
+ ]);
24
+ if (dependabot)
25
+ checkDependabotCrosslist(dependabot, errors, resolved);
26
+ return { errors };
27
+ }
@@ -0,0 +1,21 @@
1
+ import type { SharedContext } from '../shared-context/index.mts';
2
+ import type { WorkspaceGatesOptions } from './options.mts';
3
+ export declare const FIRST_PARTY_NAMES: readonly ['acme-lib', '@acme/core'];
4
+ export declare const SCOPED_PREFIXES: readonly ['@acme/'];
5
+ export declare const TEMPORARY_SELECTOR = "demo-temporary-package@9.9.9";
6
+ export declare function defaultOptions(overrides?: Partial<WorkspaceGatesOptions>): WorkspaceGatesOptions;
7
+ export declare function stubCtx(repoRoot: string, trackedFiles: readonly string[]): SharedContext;
8
+ export declare function buildWorkspaceYaml(names?: readonly string[]): string;
9
+ export declare function buildDependabotYaml(patterns?: readonly string[]): string;
10
+ export declare function buildPackageJson(names?: readonly string[]): string;
11
+ export declare function buildPnpmLock(keys?: readonly string[]): string;
12
+ export declare function makeTmpDir(testDirs: string[]): Promise<string>;
13
+ export declare function writeTracked(repoRoot: string, files: Record<string, string>): Promise<string[]>;
14
+ export declare function makeCompliantFixture(testDirs: string[], opts?: {
15
+ names?: readonly string[];
16
+ lockKeys?: readonly string[];
17
+ extraFiles?: Record<string, string>;
18
+ }): Promise<{
19
+ dir: string;
20
+ files: string[];
21
+ }>;
@@ -0,0 +1,69 @@
1
+ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises';
2
+ import { tmpdir } from 'node:os';
3
+ import { dirname, join } from 'node:path';
4
+ export const FIRST_PARTY_NAMES = ['acme-lib', '@acme/core'];
5
+ export const SCOPED_PREFIXES = ['@acme/'];
6
+ export const TEMPORARY_SELECTOR = 'demo-temporary-package@9.9.9';
7
+ export function defaultOptions(overrides = {}) {
8
+ return {
9
+ firstPartyNames: FIRST_PARTY_NAMES,
10
+ scopedPrefixes: SCOPED_PREFIXES,
11
+ ...overrides,
12
+ };
13
+ }
14
+ export function stubCtx(repoRoot, trackedFiles) {
15
+ return {
16
+ repoRoot,
17
+ isInsideGitRepo: true,
18
+ trackedFiles,
19
+ trackedFileSet: new Set(trackedFiles),
20
+ };
21
+ }
22
+ export function buildWorkspaceYaml(names = FIRST_PARTY_NAMES) {
23
+ return `minimumReleaseAgeExclude:\n${names.map((name) => ` - '${name}'`).join('\n')}\n`;
24
+ }
25
+ export function buildDependabotYaml(patterns = [...FIRST_PARTY_NAMES]) {
26
+ return [
27
+ 'updates:',
28
+ " - package-ecosystem: 'npm'",
29
+ " directory: '/'",
30
+ ' cooldown:',
31
+ ' exclude:',
32
+ ...patterns.map((pattern) => ` - '${pattern}'`),
33
+ '',
34
+ ].join('\n');
35
+ }
36
+ export function buildPackageJson(names = FIRST_PARTY_NAMES) {
37
+ return `${JSON.stringify({ dependencies: Object.fromEntries(names.map((name) => [name, '^1.0.0'])) }, null, 2)}\n`;
38
+ }
39
+ export function buildPnpmLock(keys = ['acme-lib@1.0.0', '@acme/core@1.0.0']) {
40
+ const packages = keys.map((key) => ` '${key}':\n resolution: {}\n`);
41
+ return `lockfileVersion: '9.0'\npackages:\n${packages.join('')}`;
42
+ }
43
+ export async function makeTmpDir(testDirs) {
44
+ const dir = await mkdtemp(join(tmpdir(), 'workspace-gates-'));
45
+ testDirs.push(dir);
46
+ return dir;
47
+ }
48
+ export async function writeTracked(repoRoot, files) {
49
+ const relPaths = [];
50
+ for (const [relPath, contents] of Object.entries(files)) {
51
+ const abs = join(repoRoot, relPath);
52
+ await mkdir(dirname(abs), { recursive: true });
53
+ await writeFile(abs, contents);
54
+ relPaths.push(relPath);
55
+ }
56
+ return relPaths;
57
+ }
58
+ export async function makeCompliantFixture(testDirs, opts) {
59
+ const names = opts?.names ?? FIRST_PARTY_NAMES;
60
+ const dir = await makeTmpDir(testDirs);
61
+ const files = await writeTracked(dir, {
62
+ 'pnpm-workspace.yaml': buildWorkspaceYaml(names),
63
+ '.github/dependabot.yml': buildDependabotYaml(names),
64
+ 'package.json': buildPackageJson(names),
65
+ 'pnpm-lock.yaml': buildPnpmLock(opts?.lockKeys),
66
+ ...opts?.extraFiles,
67
+ });
68
+ return { dir, files };
69
+ }
@@ -0,0 +1,2 @@
1
+ export type AnyObj = Record<string, unknown>;
2
+ export declare function loadYaml(repoRoot: string, relPath: string, errors: string[]): Promise<AnyObj | null>;
@@ -0,0 +1,28 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { parse as load } from 'yaml';
4
+ function isNodeError(error) {
5
+ return typeof error === 'object' && error !== null && 'code' in error;
6
+ }
7
+ export async function loadYaml(repoRoot, relPath, errors) {
8
+ let content;
9
+ try {
10
+ content = await readFile(join(repoRoot, relPath), 'utf8');
11
+ }
12
+ catch (error) {
13
+ if (!isNodeError(error) || error.code !== 'ENOENT') {
14
+ errors.push(`::error file=${relPath}::${relPath}: failed to read YAML`);
15
+ }
16
+ return null;
17
+ }
18
+ try {
19
+ const parsed = load(content);
20
+ if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed))
21
+ return null;
22
+ return parsed;
23
+ }
24
+ catch {
25
+ errors.push(`::error file=${relPath}::${relPath}: failed to parse YAML`);
26
+ return null;
27
+ }
28
+ }
@@ -0,0 +1 @@
1
+ export declare function expandWorkspaceGlob(root: string, pattern: string): string[];
@@ -0,0 +1,46 @@
1
+ import { readdirSync } from 'node:fs';
2
+ import path from 'node:path';
3
+ export function expandWorkspaceGlob(root, pattern) {
4
+ let currents = [root];
5
+ for (const part of pattern.split('/')) {
6
+ const next = [];
7
+ const star = part.indexOf('*');
8
+ for (const current of currents) {
9
+ if (star === -1) {
10
+ next.push(path.join(current, part));
11
+ continue;
12
+ }
13
+ if (part === '**') {
14
+ next.push(current, ...descendantDirs(current));
15
+ continue;
16
+ }
17
+ const suffix = part.slice(star + 1);
18
+ if (suffix.includes('*'))
19
+ continue;
20
+ const prefix = part.slice(0, star);
21
+ for (const entry of readDirs(current)) {
22
+ if (entry.name.startsWith(prefix) && entry.name.endsWith(suffix)) {
23
+ next.push(path.join(current, entry.name));
24
+ }
25
+ }
26
+ }
27
+ currents = next;
28
+ }
29
+ return currents;
30
+ }
31
+ function descendantDirs(dir) {
32
+ const found = [];
33
+ for (const entry of readDirs(dir)) {
34
+ const child = path.join(dir, entry.name);
35
+ found.push(child, ...descendantDirs(child));
36
+ }
37
+ return found;
38
+ }
39
+ function readDirs(dir) {
40
+ try {
41
+ return readdirSync(dir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && entry.name !== 'node_modules' && entry.name !== '.git');
42
+ }
43
+ catch {
44
+ return [];
45
+ }
46
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "vouchington-tooling",
3
- "version": "0.0.14",
3
+ "version": "0.0.16",
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": {
@@ -130,6 +130,46 @@
130
130
  "import": "./dist/http-link-pagination/index.mjs",
131
131
  "default": "./dist/http-link-pagination/index.mjs"
132
132
  },
133
+ "./coverage-transport": {
134
+ "types": "./dist/coverage-transport/index.d.mts",
135
+ "import": "./dist/coverage-transport/index.mjs",
136
+ "default": "./dist/coverage-transport/index.mjs"
137
+ },
138
+ "./pnpm-deploy": {
139
+ "types": "./dist/pnpm-deploy/index.d.mts",
140
+ "import": "./dist/pnpm-deploy/index.mjs",
141
+ "default": "./dist/pnpm-deploy/index.mjs"
142
+ },
143
+ "./dockerfile-parse": {
144
+ "types": "./dist/dockerfile-parse/index.d.mts",
145
+ "import": "./dist/dockerfile-parse/index.mjs",
146
+ "default": "./dist/dockerfile-parse/index.mjs"
147
+ },
148
+ "./scc-complexity": {
149
+ "types": "./dist/scc-complexity/index.d.mts",
150
+ "import": "./dist/scc-complexity/index.mjs",
151
+ "default": "./dist/scc-complexity/index.mjs"
152
+ },
153
+ "./ci-local": {
154
+ "types": "./dist/ci-local/index.d.mts",
155
+ "import": "./dist/ci-local/index.mjs",
156
+ "default": "./dist/ci-local/index.mjs"
157
+ },
158
+ "./gha-rate-limit": {
159
+ "types": "./dist/gha-rate-limit/index.d.mts",
160
+ "import": "./dist/gha-rate-limit/index.mjs",
161
+ "default": "./dist/gha-rate-limit/index.mjs"
162
+ },
163
+ "./gha-pr-checkpoint": {
164
+ "types": "./dist/gha-pr-checkpoint/index.d.mts",
165
+ "import": "./dist/gha-pr-checkpoint/index.mjs",
166
+ "default": "./dist/gha-pr-checkpoint/index.mjs"
167
+ },
168
+ "./workspace-gates": {
169
+ "types": "./dist/workspace-gates/index.d.mts",
170
+ "import": "./dist/workspace-gates/index.mjs",
171
+ "default": "./dist/workspace-gates/index.mjs"
172
+ },
133
173
  "./package.json": "./package.json"
134
174
  },
135
175
  "publishConfig": {
@@ -143,7 +183,9 @@
143
183
  "dependencies": {
144
184
  "csv-parse": "7.0.2",
145
185
  "csv-stringify": "6.8.2",
146
- "picomatch": "4.0.5"
186
+ "dockerfile-ast": "0.7.1",
187
+ "picomatch": "4.0.5",
188
+ "yaml": "2.9.0"
147
189
  },
148
190
  "devDependencies": {
149
191
  "@types/picomatch": "^4.0.3"