vouchington-tooling 0.20.0 → 0.22.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.
- package/README.md +73 -10
- package/dist/agent-blackboard/snapshot-partition-format.mjs +2 -72
- package/dist/agent-blackboard/snapshot-partition-guards.d.mts +3 -0
- package/dist/agent-blackboard/snapshot-partition-guards.mjs +9 -0
- package/dist/agent-blackboard/snapshot-partition-predicates.d.mts +3 -0
- package/dist/agent-blackboard/snapshot-partition-predicates.mjs +65 -0
- package/dist/api-contract-discovery/header-contract-parser.d.mts +3 -0
- package/dist/api-contract-discovery/header-contract-parser.mjs +93 -0
- package/dist/api-contract-discovery/header-contract-registry.d.mts +3 -0
- package/dist/api-contract-discovery/header-contract-registry.mjs +38 -0
- package/dist/api-contract-discovery/header-contract-types.d.mts +19 -0
- package/dist/api-contract-discovery/header-contract-types.mjs +1 -0
- package/dist/api-contract-discovery/index.d.mts +31 -0
- package/dist/api-contract-discovery/index.mjs +26 -0
- package/dist/api-contract-discovery/query-contract-extraction.d.mts +3 -0
- package/dist/api-contract-discovery/query-contract-extraction.mjs +65 -0
- package/dist/api-contract-discovery/query-contract-literals.d.mts +8 -0
- package/dist/api-contract-discovery/query-contract-literals.mjs +54 -0
- package/dist/api-contract-discovery/query-contract-registry.d.mts +3 -0
- package/dist/api-contract-discovery/query-contract-registry.mjs +68 -0
- package/dist/api-contract-discovery/query-contract-types.d.mts +8 -0
- package/dist/api-contract-discovery/query-contract-types.mjs +1 -0
- package/dist/api-contract-discovery/registered-route-catalog.d.mts +10 -0
- package/dist/api-contract-discovery/registered-route-catalog.mjs +81 -0
- package/dist/api-contract-discovery/registered-route-handler-analysis.d.mts +2 -0
- package/dist/api-contract-discovery/registered-route-handler-analysis.mjs +74 -0
- package/dist/api-contract-discovery/request-contract-implicit.d.mts +17 -0
- package/dist/api-contract-discovery/request-contract-implicit.mjs +73 -0
- package/dist/api-contract-discovery/request-contract-lenient.d.mts +28 -0
- package/dist/api-contract-discovery/request-contract-lenient.mjs +59 -0
- package/dist/api-contract-discovery/request-contract-registry.d.mts +14 -0
- package/dist/api-contract-discovery/request-contract-registry.mjs +61 -0
- package/dist/api-contract-discovery/request-contract-route-analysis.d.mts +25 -0
- package/dist/api-contract-discovery/request-contract-route-analysis.mjs +68 -0
- package/dist/api-contract-discovery/request-contract-types.d.mts +12 -0
- package/dist/api-contract-discovery/request-contract-types.mjs +1 -0
- package/dist/api-contract-discovery/response-contract-ctx-analysis.d.mts +7 -0
- package/dist/api-contract-discovery/response-contract-ctx-analysis.mjs +28 -0
- package/dist/api-contract-discovery/response-contract-error-branch.d.mts +15 -0
- package/dist/api-contract-discovery/response-contract-error-branch.mjs +47 -0
- package/dist/api-contract-discovery/response-contract-implicit-sources.test-helpers.d.mts +145 -0
- package/dist/api-contract-discovery/response-contract-implicit-sources.test-helpers.mjs +145 -0
- package/dist/api-contract-discovery/response-contract-implicit.d.mts +5 -0
- package/dist/api-contract-discovery/response-contract-implicit.mjs +110 -0
- package/dist/api-contract-discovery/response-contract-lenient.d.mts +23 -0
- package/dist/api-contract-discovery/response-contract-lenient.mjs +52 -0
- package/dist/api-contract-discovery/response-contract-media.d.mts +3 -0
- package/dist/api-contract-discovery/response-contract-media.mjs +46 -0
- package/dist/api-contract-discovery/response-contract-registration.d.mts +11 -0
- package/dist/api-contract-discovery/response-contract-registration.mjs +61 -0
- package/dist/api-contract-discovery/response-contract-registry.d.mts +7 -0
- package/dist/api-contract-discovery/response-contract-registry.mjs +76 -0
- package/dist/api-contract-discovery/response-contract-route-analysis.d.mts +13 -0
- package/dist/api-contract-discovery/response-contract-route-analysis.mjs +144 -0
- package/dist/api-contract-discovery/response-contract-status.d.mts +27 -0
- package/dist/api-contract-discovery/response-contract-status.mjs +80 -0
- package/dist/api-contract-discovery/response-contract-types.d.mts +16 -0
- package/dist/api-contract-discovery/response-contract-types.mjs +1 -0
- package/dist/api-contract-discovery/test-setup.test-helpers.d.mts +3 -0
- package/dist/api-contract-discovery/test-setup.test-helpers.mjs +2 -0
- package/dist/ast-grep-examples/companion-parity-compare.d.mts +3 -0
- package/dist/ast-grep-examples/companion-parity-compare.mjs +58 -0
- package/dist/ast-grep-examples/companion-parity.d.mts +2 -1
- package/dist/ast-grep-examples/companion-parity.mjs +2 -58
- package/dist/browser-session-runner/attempt.mjs +29 -48
- package/dist/browser-session-runner/termination.d.mts +11 -0
- package/dist/browser-session-runner/termination.mjs +34 -0
- package/dist/cli/parse-direct-options.d.mts +26 -0
- package/dist/cli/parse-direct-options.mjs +72 -0
- package/dist/cli/parse-forwarded-commands.d.mts +42 -0
- package/dist/cli/parse-forwarded-commands.mjs +29 -0
- package/dist/cli/parse-options.d.mts +1 -2
- package/dist/cli/parse-options.mjs +2 -72
- package/dist/cli/parse.mjs +4 -26
- package/dist/compiler-build/freshness.d.mts +27 -0
- package/dist/compiler-build/freshness.mjs +116 -0
- package/dist/compiler-build/index.d.mts +2 -0
- package/dist/compiler-build/index.mjs +2 -0
- package/dist/compiler-build/settlement.d.mts +10 -0
- package/dist/compiler-build/settlement.mjs +14 -0
- package/dist/config-inventory/call-arguments.d.mts +1 -0
- package/dist/config-inventory/call-arguments.mjs +30 -0
- package/dist/config-inventory/collect.d.mts +3 -0
- package/dist/config-inventory/collect.mjs +43 -0
- package/dist/config-inventory/dynamic-configs.d.mts +2 -0
- package/dist/config-inventory/dynamic-configs.mjs +9 -0
- package/dist/config-inventory/env-expression-readers.d.mts +3 -0
- package/dist/config-inventory/env-expression-readers.mjs +117 -0
- package/dist/config-inventory/env-prefix-readers.d.mts +2 -0
- package/dist/config-inventory/env-prefix-readers.mjs +14 -0
- package/dist/config-inventory/env-reference-matchers.d.mts +3 -0
- package/dist/config-inventory/env-reference-matchers.mjs +9 -0
- package/dist/config-inventory/env-var-accumulator.d.mts +6 -0
- package/dist/config-inventory/env-var-accumulator.mjs +82 -0
- package/dist/config-inventory/env-vars.d.mts +4 -0
- package/dist/config-inventory/env-vars.mjs +108 -0
- package/dist/config-inventory/format.d.mts +2 -0
- package/dist/config-inventory/format.mjs +69 -0
- package/dist/config-inventory/index.d.mts +3 -0
- package/dist/config-inventory/index.mjs +2 -0
- package/dist/config-inventory/package-gates.d.mts +2 -0
- package/dist/config-inventory/package-gates.mjs +54 -0
- package/dist/config-inventory/shared.d.mts +3 -0
- package/dist/config-inventory/shared.mjs +7 -0
- package/dist/config-inventory/types.d.mts +96 -0
- package/dist/config-inventory/types.mjs +1 -0
- package/dist/contract-schema/contract-schema-bounded-array.d.mts +15 -0
- package/dist/contract-schema/contract-schema-bounded-array.mjs +32 -0
- package/dist/contract-schema/contract-schema-type-extractor.d.mts +2 -8
- package/dist/contract-schema/contract-schema-type-extractor.mjs +2 -31
- package/dist/coverage-transport/control-v2-parse-object.d.mts +12 -0
- package/dist/coverage-transport/control-v2-parse-object.mjs +28 -0
- package/dist/coverage-transport/control-v2.mjs +2 -27
- package/dist/dependency-license-policy/audit-directory.d.mts +28 -0
- package/dist/dependency-license-policy/audit-directory.mjs +115 -0
- package/dist/dependency-license-policy/audit-signals.d.mts +12 -0
- package/dist/dependency-license-policy/audit-signals.mjs +44 -0
- package/dist/dependency-license-policy/audit-store.d.mts +12 -0
- package/dist/dependency-license-policy/audit-store.mjs +32 -0
- package/dist/dependency-license-policy/collect.d.mts +3 -1
- package/dist/dependency-license-policy/collect.mjs +16 -21
- package/dist/dependency-license-policy/execute-pnpm.d.mts +10 -0
- package/dist/dependency-license-policy/execute-pnpm.mjs +83 -0
- package/dist/dependency-license-policy/types.d.mts +11 -9
- package/dist/dependency-license-policy/workspace.d.mts +3 -1
- package/dist/dependency-license-policy/workspace.mjs +12 -5
- package/dist/gh-api-shell-quoting/argument-scan.d.mts +7 -0
- package/dist/gh-api-shell-quoting/argument-scan.mjs +35 -0
- package/dist/gh-api-shell-quoting/logical-line.d.mts +3 -0
- package/dist/gh-api-shell-quoting/logical-line.mjs +90 -0
- package/dist/gh-api-shell-quoting/scan.d.mts +2 -4
- package/dist/gh-api-shell-quoting/scan.mjs +1 -119
- package/dist/gh-cli/diff.d.mts +24 -4
- package/dist/gh-cli/diff.mjs +111 -4
- package/dist/gh-cli/index.d.mts +2 -1
- package/dist/gh-cli/index.mjs +1 -1
- package/dist/gha-workspace-policy/docker-options.d.mts +4 -0
- package/dist/gha-workspace-policy/docker-options.mjs +93 -0
- package/dist/gha-workspace-policy/docker-workspace-user.mjs +1 -168
- package/dist/gha-workspace-policy/shell-token-modes.d.mts +10 -0
- package/dist/gha-workspace-policy/shell-token-modes.mjs +31 -0
- package/dist/gha-workspace-policy/shell-tokens.d.mts +1 -0
- package/dist/gha-workspace-policy/shell-tokens.mjs +65 -0
- package/dist/index.d.mts +2 -1
- package/dist/index.mjs +1 -1
- package/dist/openapi-document/contract-schema-types.d.mts +1 -1
- package/dist/openapi-document/openapi-types.d.mts +1 -1
- package/dist/pnpm-install/native-family.d.mts +3 -0
- package/dist/pnpm-install/native-family.mjs +31 -0
- package/dist/pnpm-install/native-health.d.mts +1 -4
- package/dist/pnpm-install/native-health.mjs +2 -31
- package/dist/retrospective-facts/local-merge.d.mts +2 -0
- package/dist/retrospective-facts/local-merge.mjs +21 -0
- package/dist/retrospective-facts/local.mjs +1 -21
- package/dist/retrospective-transcript/claude-blocks.d.mts +3 -0
- package/dist/retrospective-transcript/claude-blocks.mjs +38 -0
- package/dist/retrospective-transcript/claude.mjs +4 -34
- package/dist/retrospective-transcript/codex-payload.d.mts +2 -0
- package/dist/retrospective-transcript/codex-payload.mjs +51 -0
- package/dist/retrospective-transcript/codex-records.d.mts +2 -0
- package/dist/retrospective-transcript/codex-records.mjs +95 -0
- package/dist/retrospective-transcript/codex.mjs +2 -144
- package/dist/retrospective-transcript/index.d.mts +3 -16
- package/dist/retrospective-transcript/index.mjs +4 -48
- package/dist/retrospective-transcript/resolve-transcript-file.d.mts +19 -0
- package/dist/retrospective-transcript/resolve-transcript-file.mjs +50 -0
- package/dist/session-friction/command-segments.d.mts +3 -0
- package/dist/session-friction/command-segments.mjs +94 -0
- package/dist/session-friction/log-file.d.mts +7 -0
- package/dist/session-friction/log-file.mjs +83 -0
- package/dist/session-friction/log.mjs +3 -83
- package/dist/session-friction/normalize.mjs +1 -94
- package/dist/sql-scanner/comments.d.mts +1 -0
- package/dist/sql-scanner/comments.mjs +101 -0
- package/dist/sql-scanner/index.d.mts +1 -1
- package/dist/sql-scanner/index.mjs +1 -100
- package/dist/sql-scanner/literals.mjs +1 -35
- package/dist/sql-scanner/quote-decode.d.mts +2 -0
- package/dist/sql-scanner/quote-decode.mjs +35 -0
- package/dist/vitest-diagnostics/index.d.mts +7 -0
- package/dist/vitest-diagnostics/index.mjs +4 -0
- package/dist/vitest-diagnostics/vitest-fork-exit-error-detail.d.mts +7 -0
- package/dist/vitest-diagnostics/vitest-fork-exit-error-detail.mjs +26 -0
- package/dist/vitest-diagnostics/vitest-fork-exit-records.d.mts +26 -0
- package/dist/vitest-diagnostics/vitest-fork-exit-records.mjs +161 -0
- package/dist/vitest-diagnostics/vitest-fork-exit-sentinel.d.mts +14 -0
- package/dist/vitest-diagnostics/vitest-fork-exit-sentinel.mjs +131 -0
- package/dist/vitest-diagnostics/vitest-fork-leak-detection.d.mts +26 -0
- package/dist/vitest-diagnostics/vitest-fork-leak-detection.mjs +96 -0
- package/dist/vitest-diagnostics/vitest-fork-leak-diagnostics.d.mts +3 -0
- package/dist/vitest-diagnostics/vitest-fork-leak-diagnostics.mjs +14 -0
- package/dist/vitest-diagnostics/vitest-fork-leak-episode.d.mts +37 -0
- package/dist/vitest-diagnostics/vitest-fork-leak-episode.mjs +118 -0
- package/dist/vitest-diagnostics/vitest-worker-exit-diagnostics-errors.d.mts +2 -0
- package/dist/vitest-diagnostics/vitest-worker-exit-diagnostics-errors.mjs +42 -0
- package/dist/vitest-diagnostics/vitest-worker-exit-diagnostics-reporter.d.mts +12 -0
- package/dist/vitest-diagnostics/vitest-worker-exit-diagnostics-reporter.mjs +142 -0
- package/dist/workflow-policy/concurrency-scope.d.mts +4 -0
- package/dist/workflow-policy/concurrency-scope.mjs +66 -0
- package/dist/workflow-policy/concurrency.d.mts +10 -0
- package/dist/workflow-policy/concurrency.mjs +77 -0
- package/dist/workflow-policy/condition-entailment.d.mts +20 -0
- package/dist/workflow-policy/condition-entailment.mjs +132 -0
- package/dist/workflow-policy/condition-split.d.mts +15 -0
- package/dist/workflow-policy/condition-split.mjs +71 -0
- package/dist/workflow-policy/graph.d.mts +2 -0
- package/dist/workflow-policy/graph.mjs +138 -0
- package/dist/workflow-policy/index.d.mts +7 -0
- package/dist/workflow-policy/index.mjs +6 -0
- package/dist/workflow-policy/permission-mismatches.d.mts +3 -0
- package/dist/workflow-policy/permission-mismatches.mjs +52 -0
- package/dist/workflow-policy/permissions.d.mts +16 -0
- package/dist/workflow-policy/permissions.mjs +83 -0
- package/dist/workflow-policy/secret-readiness.d.mts +18 -0
- package/dist/workflow-policy/secret-readiness.mjs +163 -0
- package/dist/workflow-policy/shell-comments.d.mts +7 -0
- package/dist/workflow-policy/shell-comments.mjs +28 -0
- package/dist/workflow-policy/types.d.mts +90 -0
- package/dist/workflow-policy/types.mjs +1 -0
- package/dist/workspace-gates/manifest-version-parser.mjs +1 -52
- package/dist/workspace-gates/manifest-version-skips.d.mts +1 -0
- package/dist/workspace-gates/manifest-version-skips.mjs +52 -0
- package/docs/workflow-and-compiler.md +74 -0
- package/package.json +26 -2
- package/skills/agent-workflow/SKILL.md +4 -4
- package/skills/agent-workflow/references/review-response.md +47 -44
- package/skills/backend-vitest-test-authoring/SKILL.md +9 -6
- package/skills/blackboard/SKILL.md +8 -8
- package/skills/dependabot/SKILL.md +68 -67
- package/skills/dotnet-test-authoring/SKILL.md +11 -12
- package/skills/git-commit-checklist/SKILL.md +3 -3
- package/skills/github-actions-authoring/SKILL.md +40 -38
- package/skills/github-actions-checklist/SKILL.md +93 -85
- package/skills/github-issue/SKILL.md +82 -78
- package/skills/nextjs-vitest-test-authoring/SKILL.md +8 -6
- package/skills/npm-publishing/SKILL.md +42 -38
- package/skills/organize-github-issues/SKILL.md +18 -22
- package/skills/package-json-checklist/SKILL.md +5 -5
- package/skills/planning/SKILL.md +3 -3
- package/skills/playwright-authoring/SKILL.md +10 -9
- package/skills/postgres-node-performance-tuning/SKILL.md +10 -6
- package/skills/postgres-partitioning-uuid-v7/SKILL.md +10 -7
- package/skills/pr-description/SKILL.md +4 -3
- package/skills/retrospective/SKILL.md +14 -15
- package/skills/retrospective-distill/SKILL.md +14 -14
- package/skills/review-ci-logs/SKILL.md +18 -19
- package/skills/review-github-issue-taxonomy/SKILL.md +21 -18
- package/skills/revisit-followups/SKILL.md +3 -3
- package/skills/stacked-prs/SKILL.md +30 -61
- package/skills/static-analysis-checklist/SKILL.md +6 -4
- package/skills/storybook-authoring/SKILL.md +9 -9
- package/skills/swift-test-authoring/SKILL.md +10 -9
- package/skills/test-authoring/SKILL.md +14 -14
- package/skills/test-authoring/references/core-practice.md +12 -11
- package/skills/test-authoring/references/dependency-boundaries.md +36 -35
- package/skills/test-authoring/references/tautological-tests.md +45 -44
- package/skills/vitest-test-authoring/SKILL.md +9 -6
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Shared error-introspection helpers for vitest-worker-exit-diagnostics-reporter.mts. Split out so
|
|
2
|
+
// that file stays focused on the Reporter implementation and its own formatting; these two walk an
|
|
3
|
+
// arbitrary, possibly-circular error-shaped value (Vitest's SerializedError, or anything nested
|
|
4
|
+
// inside it) without assuming a fixed shape, since the "Worker exited unexpectedly" wrapper varies
|
|
5
|
+
// by which layer (pool, tinypool-alike, Node) produced it.
|
|
6
|
+
const diagnosticPropertyNames = ['name', 'message', 'stack', 'code', 'type', 'workerError', 'cause'];
|
|
7
|
+
export function collectStringValues(value, seen = new Set()) {
|
|
8
|
+
if (value == null)
|
|
9
|
+
return [];
|
|
10
|
+
if (typeof value === 'string')
|
|
11
|
+
return [value];
|
|
12
|
+
if (typeof value === 'number' || typeof value === 'boolean' || typeof value === 'bigint') {
|
|
13
|
+
return [String(value)];
|
|
14
|
+
}
|
|
15
|
+
if (typeof value !== 'object' || seen.has(value))
|
|
16
|
+
return [];
|
|
17
|
+
seen.add(value);
|
|
18
|
+
const values = [];
|
|
19
|
+
for (const key of diagnosticKeys(value)) {
|
|
20
|
+
values.push(...collectStringValues(value[key], seen));
|
|
21
|
+
}
|
|
22
|
+
return values;
|
|
23
|
+
}
|
|
24
|
+
export function serializeDiagnosticsError(error, seen = new Set()) {
|
|
25
|
+
if (error == null || typeof error !== 'object')
|
|
26
|
+
return { value: error };
|
|
27
|
+
if (seen.has(error))
|
|
28
|
+
return { circular: true };
|
|
29
|
+
seen.add(error);
|
|
30
|
+
const output = {};
|
|
31
|
+
for (const key of diagnosticKeys(error)) {
|
|
32
|
+
if (!(key in error))
|
|
33
|
+
continue;
|
|
34
|
+
const value = error[key];
|
|
35
|
+
output[key] =
|
|
36
|
+
value == null || typeof value !== 'object' ? value : serializeDiagnosticsError(value, seen);
|
|
37
|
+
}
|
|
38
|
+
return output;
|
|
39
|
+
}
|
|
40
|
+
function diagnosticKeys(source) {
|
|
41
|
+
return new Set([...Object.keys(source), ...diagnosticPropertyNames]);
|
|
42
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { Reporter, SerializedError, TestRunEndReason } from 'vitest/node';
|
|
2
|
+
import { type ForkExitRecordSummary } from './vitest-fork-exit-records.mts';
|
|
3
|
+
type ModuleEvent = {
|
|
4
|
+
kind: 'queued' | 'started' | 'ended';
|
|
5
|
+
moduleId: string;
|
|
6
|
+
};
|
|
7
|
+
export declare function createVitestWorkerExitDiagnosticsReporter(options: {
|
|
8
|
+
recordDirectory: string;
|
|
9
|
+
}): Reporter;
|
|
10
|
+
export declare function isWorkerExitError(error: unknown): boolean;
|
|
11
|
+
export declare function formatWorkerExitDiagnostics(reason: TestRunEndReason, recentModuleEvents: ModuleEvent[], recentStderrLines: string[], unhandledErrors: ReadonlyArray<SerializedError>, unfinishedModules: string[], sentinelSummary: ForkExitRecordSummary): string;
|
|
12
|
+
export {};
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { EOL } from 'node:os';
|
|
2
|
+
import { clearForkExitRecords, formatForkExitSentinelSection, readForkExitRecords, summarizeForkExitRecords, } from './vitest-fork-exit-records.mjs';
|
|
3
|
+
import { collectStringValues, serializeDiagnosticsError, } from './vitest-worker-exit-diagnostics-errors.mjs';
|
|
4
|
+
const MAX_RECENT_MODULE_EVENTS = 8;
|
|
5
|
+
const MAX_RECENT_STDERR_LINES = 12;
|
|
6
|
+
const MAX_UNHANDLED_ERRORS = 2;
|
|
7
|
+
const MAX_UNFINISHED_MODULES = 20;
|
|
8
|
+
const MAX_SERIALIZED_ERROR_CHARS = 900;
|
|
9
|
+
const MAX_STDERR_LINE_CHARS = 500;
|
|
10
|
+
const workerExitSignals = [
|
|
11
|
+
'Error: [vitest-pool]: Worker forks emitted error.',
|
|
12
|
+
'Caused by: Error: Worker exited unexpectedly',
|
|
13
|
+
'Worker exited unexpectedly',
|
|
14
|
+
];
|
|
15
|
+
class VitestWorkerExitDiagnosticsReporter {
|
|
16
|
+
recordDirectory;
|
|
17
|
+
emitted = false;
|
|
18
|
+
recentModuleEvents = [];
|
|
19
|
+
recentStderrLines = [];
|
|
20
|
+
constructor(recordDirectory) {
|
|
21
|
+
this.recordDirectory = recordDirectory;
|
|
22
|
+
// Clear once before forks start. Clearing on watch reruns would unlink the files still held
|
|
23
|
+
// open by reused workers and make their later records invisible.
|
|
24
|
+
clearForkExitRecords(recordDirectory);
|
|
25
|
+
}
|
|
26
|
+
onTestRunStart() {
|
|
27
|
+
this.emitted = false;
|
|
28
|
+
this.recentModuleEvents = [];
|
|
29
|
+
this.recentStderrLines = [];
|
|
30
|
+
}
|
|
31
|
+
onTestModuleQueued(testModule) {
|
|
32
|
+
this.recordModuleEvent('queued', testModule);
|
|
33
|
+
}
|
|
34
|
+
onTestModuleStart(testModule) {
|
|
35
|
+
this.recordModuleEvent('started', testModule);
|
|
36
|
+
}
|
|
37
|
+
onTestModuleEnd(testModule) {
|
|
38
|
+
this.recordModuleEvent('ended', testModule);
|
|
39
|
+
}
|
|
40
|
+
onTestRunEnd(testModules, unhandledErrors, reason) {
|
|
41
|
+
const workerErrors = unhandledErrors.filter((error) => isWorkerExitError(error));
|
|
42
|
+
if (this.emitted || workerErrors.length === 0)
|
|
43
|
+
return;
|
|
44
|
+
this.emitted = true;
|
|
45
|
+
process.stderr.write(formatWorkerExitDiagnostics(reason, this.recentModuleEvents, this.recentStderrLines, workerErrors, unfinishedModuleIds(testModules), summarizeForkExitRecords(readForkExitRecords(this.recordDirectory))));
|
|
46
|
+
}
|
|
47
|
+
// Fired by Vitest's exit() watchdog at the teardownTimeout deadline; see
|
|
48
|
+
// formatTeardownOverrunDiagnostics() for why this stays separate from onTestRunEnd.
|
|
49
|
+
onProcessTimeout() {
|
|
50
|
+
process.stderr.write(`[vitest-worker-timeout] ${formatProcessResources()}${EOL}`);
|
|
51
|
+
}
|
|
52
|
+
onUserConsoleLog(log) {
|
|
53
|
+
if (log.type !== 'stderr')
|
|
54
|
+
return;
|
|
55
|
+
for (const line of log.content.split(/\r?\n/).filter(Boolean)) {
|
|
56
|
+
this.recentStderrLines.push(truncateBounded(line, MAX_STDERR_LINE_CHARS));
|
|
57
|
+
}
|
|
58
|
+
trimHead(this.recentStderrLines, MAX_RECENT_STDERR_LINES);
|
|
59
|
+
}
|
|
60
|
+
recordModuleEvent(kind, testModule) {
|
|
61
|
+
this.recentModuleEvents.push({
|
|
62
|
+
kind,
|
|
63
|
+
moduleId: testModule.relativeModuleId || testModule.moduleId,
|
|
64
|
+
});
|
|
65
|
+
trimHead(this.recentModuleEvents, MAX_RECENT_MODULE_EVENTS);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
export function createVitestWorkerExitDiagnosticsReporter(options) {
|
|
69
|
+
if (!options.recordDirectory)
|
|
70
|
+
throw new Error('recordDirectory is required');
|
|
71
|
+
return new VitestWorkerExitDiagnosticsReporter(options.recordDirectory);
|
|
72
|
+
}
|
|
73
|
+
function formatProcessResources() {
|
|
74
|
+
const memory = process.memoryUsage();
|
|
75
|
+
return `pid=${process.pid} rssMB=${(memory.rss / 1024 / 1024).toFixed(1)} heapUsedMB=${(memory.heapUsed / 1024 / 1024).toFixed(1)}`;
|
|
76
|
+
}
|
|
77
|
+
function formatActiveResources() {
|
|
78
|
+
const counts = new Map();
|
|
79
|
+
for (const name of process.getActiveResourcesInfo())
|
|
80
|
+
counts.set(name, (counts.get(name) ?? 0) + 1);
|
|
81
|
+
return ([...counts]
|
|
82
|
+
.toSorted(([left], [right]) => left.localeCompare(right))
|
|
83
|
+
.map(([name, count]) => `${name}=${count}`)
|
|
84
|
+
.join(' ') || 'none');
|
|
85
|
+
}
|
|
86
|
+
export function isWorkerExitError(error) {
|
|
87
|
+
const haystack = collectStringValues(error).join('\n');
|
|
88
|
+
return workerExitSignals.some((signal) => haystack.includes(signal));
|
|
89
|
+
}
|
|
90
|
+
export function formatWorkerExitDiagnostics(reason, recentModuleEvents, recentStderrLines, unhandledErrors, unfinishedModules, sentinelSummary) {
|
|
91
|
+
const lines = [
|
|
92
|
+
'',
|
|
93
|
+
'[vitest-worker-exit-diagnostics]',
|
|
94
|
+
`reason: ${reason}`,
|
|
95
|
+
`main-process: ${formatProcessResources()}`,
|
|
96
|
+
`active resources: ${formatActiveResources()}`,
|
|
97
|
+
'recent modules:',
|
|
98
|
+
...formatList(recentModuleEvents, (event) => `${event.kind}: ${event.moduleId}`),
|
|
99
|
+
'unfinished modules:',
|
|
100
|
+
...formatList(unfinishedModules.slice(0, MAX_UNFINISHED_MODULES), (moduleId) => moduleId),
|
|
101
|
+
'recent stderr:',
|
|
102
|
+
...formatList(recentStderrLines, (line) => line),
|
|
103
|
+
'unhandled errors:',
|
|
104
|
+
...formatList(unhandledErrors.slice(0, MAX_UNHANDLED_ERRORS), (error) => truncateBounded(JSON.stringify(serializeDiagnosticsError(error)), MAX_SERIALIZED_ERROR_CHARS)),
|
|
105
|
+
...formatForkExitSentinelSection(sentinelSummary),
|
|
106
|
+
];
|
|
107
|
+
if (unfinishedModules.length > MAX_UNFINISHED_MODULES) {
|
|
108
|
+
lines.push(` ... ${unfinishedModules.length - MAX_UNFINISHED_MODULES} more unfinished modules`);
|
|
109
|
+
}
|
|
110
|
+
if (unhandledErrors.length > MAX_UNHANDLED_ERRORS) {
|
|
111
|
+
lines.push(` ... ${unhandledErrors.length - MAX_UNHANDLED_ERRORS} more`);
|
|
112
|
+
}
|
|
113
|
+
return `${lines.join(EOL)}${EOL}`;
|
|
114
|
+
}
|
|
115
|
+
// A module Vitest never reported queued/started/ended for at all (the crash-mid-collection case)
|
|
116
|
+
// won't appear in testModules; a module whose fork died before finishing it will, stuck in
|
|
117
|
+
// 'queued' or 'pending' — that's the signature this section exists to name.
|
|
118
|
+
function unfinishedModuleIds(testModules) {
|
|
119
|
+
const moduleIds = [];
|
|
120
|
+
for (const testModule of testModules) {
|
|
121
|
+
const state = testModule.state();
|
|
122
|
+
if (state === 'queued' || state === 'pending') {
|
|
123
|
+
moduleIds.push(testModule.relativeModuleId || testModule.moduleId);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return moduleIds;
|
|
127
|
+
}
|
|
128
|
+
function formatList(items, label) {
|
|
129
|
+
if (items.length === 0)
|
|
130
|
+
return [' (none recorded)'];
|
|
131
|
+
return items.map((item) => ` - ${label(item)}`);
|
|
132
|
+
}
|
|
133
|
+
function truncateBounded(text, maxChars) {
|
|
134
|
+
if (text.length <= maxChars)
|
|
135
|
+
return text;
|
|
136
|
+
return `${text.slice(0, maxChars - 3)}...`;
|
|
137
|
+
}
|
|
138
|
+
function trimHead(items, maxLength) {
|
|
139
|
+
if (items.length > maxLength) {
|
|
140
|
+
items.splice(0, items.length - maxLength);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
export type ConcurrencyScope = 'fixed-resource' | 'pull-request' | 'ref' | 'sha' | 'run' | 'event' | 'input-resource';
|
|
2
|
+
export type NormalizedConcurrencyScope = Exclude<ConcurrencyScope, 'fixed-resource'> | `unsupported:${string}`;
|
|
3
|
+
export declare function extractConcurrencyScopes(group: string): NormalizedConcurrencyScope[];
|
|
4
|
+
export declare function normalizePolicyScopes(scopes: readonly ConcurrencyScope[]): NormalizedConcurrencyScope[];
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
const scopeOrder = [
|
|
2
|
+
'pull-request',
|
|
3
|
+
'ref',
|
|
4
|
+
'sha',
|
|
5
|
+
'run',
|
|
6
|
+
'event',
|
|
7
|
+
'input-resource',
|
|
8
|
+
];
|
|
9
|
+
const ignoredOwnerContexts = new Set(['github.workflow', 'github.run_attempt']);
|
|
10
|
+
const eventPayloadContexts = new Set([
|
|
11
|
+
'github.event_name',
|
|
12
|
+
'github.event.action',
|
|
13
|
+
'github.event.label.name',
|
|
14
|
+
'github.event.issue.number',
|
|
15
|
+
'github.event.schedule',
|
|
16
|
+
'github.event.workflow_run.event',
|
|
17
|
+
'github.event.workflow_run.id',
|
|
18
|
+
'github.event.workflow_run.workflow_id',
|
|
19
|
+
]);
|
|
20
|
+
function classifyContext(reference) {
|
|
21
|
+
if (ignoredOwnerContexts.has(reference))
|
|
22
|
+
return undefined;
|
|
23
|
+
if (reference === 'github.event.pull_request.number' ||
|
|
24
|
+
reference === 'github.event.workflow_run.pull_requests')
|
|
25
|
+
return 'pull-request';
|
|
26
|
+
if (reference === 'github.event.pull_request.head.sha' ||
|
|
27
|
+
reference === 'github.event.workflow_run.head_sha' ||
|
|
28
|
+
reference === 'github.sha')
|
|
29
|
+
return 'sha';
|
|
30
|
+
if (reference === 'github.ref' ||
|
|
31
|
+
reference === 'github.ref_name' ||
|
|
32
|
+
reference === 'github.event.workflow_run.head_branch')
|
|
33
|
+
return 'ref';
|
|
34
|
+
if (reference === 'github.run_id')
|
|
35
|
+
return 'run';
|
|
36
|
+
if (reference.startsWith('inputs.') ||
|
|
37
|
+
reference === 'github.event.inputs' ||
|
|
38
|
+
reference.startsWith('github.event.inputs.')) {
|
|
39
|
+
return 'input-resource';
|
|
40
|
+
}
|
|
41
|
+
if (eventPayloadContexts.has(reference))
|
|
42
|
+
return 'event';
|
|
43
|
+
return `unsupported:${reference}`;
|
|
44
|
+
}
|
|
45
|
+
function sortScopes(scopes) {
|
|
46
|
+
return [...new Set(scopes)].toSorted((left, right) => {
|
|
47
|
+
const leftIndex = scopeOrder.indexOf(left);
|
|
48
|
+
const rightIndex = scopeOrder.indexOf(right);
|
|
49
|
+
if (leftIndex === -1 || rightIndex === -1) {
|
|
50
|
+
if (leftIndex === rightIndex)
|
|
51
|
+
return left.localeCompare(right);
|
|
52
|
+
return leftIndex === -1 ? 1 : -1;
|
|
53
|
+
}
|
|
54
|
+
return leftIndex - rightIndex;
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
export function extractConcurrencyScopes(group) {
|
|
58
|
+
const references = group.match(/\b(?:github|inputs)(?:\.[A-Za-z_][\w-]*)+/gu) ?? [];
|
|
59
|
+
return sortScopes(references.flatMap((reference) => classifyContext(reference) ?? []));
|
|
60
|
+
}
|
|
61
|
+
export function normalizePolicyScopes(scopes) {
|
|
62
|
+
if (scopes.includes('fixed-resource')) {
|
|
63
|
+
return scopes.length === 1 ? [] : ['unsupported:fixed-resource-combination'];
|
|
64
|
+
}
|
|
65
|
+
return sortScopes(scopes.filter((scope) => scope !== 'fixed-resource'));
|
|
66
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { WorkflowTopology } from './types.mts';
|
|
2
|
+
import { type ConcurrencyScope } from './concurrency-scope.mts';
|
|
3
|
+
type ConcurrencyCancellationBehavior = 'cancel-running' | 'retain-running' | 'conditional';
|
|
4
|
+
export type ConcurrencyPolicy = {
|
|
5
|
+
pending: 'coalesce-latest' | 'fifo';
|
|
6
|
+
cancellation: ConcurrencyCancellationBehavior;
|
|
7
|
+
scope: readonly ConcurrencyScope[];
|
|
8
|
+
};
|
|
9
|
+
export declare function evaluateLockPolicy(topology: WorkflowTopology, unlockedWorkflowReasons: Readonly<Record<string, string>>, semanticPolicy: Readonly<Record<string, ConcurrencyPolicy>>): string[];
|
|
10
|
+
export {};
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { extractConcurrencyScopes, normalizePolicyScopes, } from './concurrency-scope.mjs';
|
|
2
|
+
function cancellation(value) {
|
|
3
|
+
return typeof value === 'string' ? 'conditional' : value ? 'cancel-running' : 'retain-running';
|
|
4
|
+
}
|
|
5
|
+
export function evaluateLockPolicy(topology, unlockedWorkflowReasons, semanticPolicy) {
|
|
6
|
+
const diagnostics = [];
|
|
7
|
+
const owners = [...topology.workflows, ...topology.jobs].filter((owner) => owner.concurrency);
|
|
8
|
+
const actualOwnerIds = new Set(owners.map((owner) => owner.id));
|
|
9
|
+
const declaredOwnerIds = new Set(Object.keys(semanticPolicy));
|
|
10
|
+
for (const id of [...actualOwnerIds].toSorted()) {
|
|
11
|
+
if (!declaredOwnerIds.has(id))
|
|
12
|
+
diagnostics.push(`lock intent missing: ${id}`);
|
|
13
|
+
}
|
|
14
|
+
for (const id of [...declaredOwnerIds].toSorted()) {
|
|
15
|
+
if (!actualOwnerIds.has(id))
|
|
16
|
+
diagnostics.push(`lock intent stale: ${id}`);
|
|
17
|
+
}
|
|
18
|
+
const jobsById = new Map(topology.jobs.map((job) => [job.id, job]));
|
|
19
|
+
for (const workflow of topology.workflows) {
|
|
20
|
+
const unlockedReason = unlockedWorkflowReasons[workflow.path];
|
|
21
|
+
const hasUnlockedJob = !workflow.concurrency &&
|
|
22
|
+
workflow.jobIds.some((jobId) => jobsById.get(jobId)?.concurrency === undefined);
|
|
23
|
+
if (hasUnlockedJob && !unlockedReason)
|
|
24
|
+
diagnostics.push(`lock intent missing: ${workflow.path}`);
|
|
25
|
+
if (!hasUnlockedJob && unlockedReason)
|
|
26
|
+
diagnostics.push(`unlocked reason stale: ${workflow.path}`);
|
|
27
|
+
if (unlockedReason !== undefined && unlockedReason.trim() === '')
|
|
28
|
+
diagnostics.push(`unlocked reason empty: ${workflow.path}`);
|
|
29
|
+
}
|
|
30
|
+
for (const path of Object.keys(unlockedWorkflowReasons).toSorted()) {
|
|
31
|
+
if (!topology.workflows.some((workflow) => workflow.path === path))
|
|
32
|
+
diagnostics.push(`unlocked reason stale: ${path}`);
|
|
33
|
+
}
|
|
34
|
+
for (const owner of owners) {
|
|
35
|
+
const declared = semanticPolicy[owner.id];
|
|
36
|
+
if (!declared || !owner.concurrency)
|
|
37
|
+
continue;
|
|
38
|
+
const cancelInProgress = owner.concurrency.effective.cancelInProgress;
|
|
39
|
+
if (typeof cancelInProgress === 'string' &&
|
|
40
|
+
!/^\$\{\{\s*\S[\s\S]*\}\}$/.test(cancelInProgress.trim()))
|
|
41
|
+
diagnostics.push(`conditional cancel-in-progress expression invalid: ${owner.id}: ${cancelInProgress}`);
|
|
42
|
+
const actualPending = owner.concurrency.effective.queue === 'max' ? 'fifo' : 'coalesce-latest';
|
|
43
|
+
if (actualPending !== declared.pending)
|
|
44
|
+
diagnostics.push(`concurrency pending mismatch: ${owner.id}: expected ${declared.pending}, got ${actualPending}`);
|
|
45
|
+
const actualCancellation = cancellation(owner.concurrency.effective.cancelInProgress);
|
|
46
|
+
if (actualCancellation !== declared.cancellation)
|
|
47
|
+
diagnostics.push(`concurrency cancellation mismatch: ${owner.id}: expected ${declared.cancellation}, got ${actualCancellation}`);
|
|
48
|
+
const actualScopes = extractConcurrencyScopes(owner.concurrency.effective.group);
|
|
49
|
+
const expectedScopes = normalizePolicyScopes(declared.scope);
|
|
50
|
+
if (JSON.stringify(actualScopes) !== JSON.stringify(expectedScopes))
|
|
51
|
+
diagnostics.push(`concurrency scope mismatch: ${owner.id}: expected ${expectedScopes.join(',')}, got ${actualScopes.join(',')}`);
|
|
52
|
+
}
|
|
53
|
+
diagnostics.push(...collisionDiagnostics(owners, topology));
|
|
54
|
+
return [...new Set(diagnostics)].toSorted();
|
|
55
|
+
}
|
|
56
|
+
function collisionDiagnostics(owners, topology) {
|
|
57
|
+
const workflowPathsById = new Map(topology.workflows.map((workflow) => [workflow.id, workflow.path]));
|
|
58
|
+
const ownerWorkflowPaths = new Map([
|
|
59
|
+
...topology.workflows.map((workflow) => [workflow.id, workflow.path]),
|
|
60
|
+
...topology.jobs.map((job) => [job.id, workflowPathsById.get(job.workflowId) ?? job.workflowId]),
|
|
61
|
+
]);
|
|
62
|
+
const groups = new Map();
|
|
63
|
+
for (const owner of owners) {
|
|
64
|
+
const group = owner.concurrency?.effective.group;
|
|
65
|
+
if (!group)
|
|
66
|
+
continue;
|
|
67
|
+
const normalizedGroup = group.toLocaleLowerCase('en-US');
|
|
68
|
+
const workflowPartition = /\$\{\{\s*github\.workflow\s*\}\}/.test(group)
|
|
69
|
+
? ownerWorkflowPaths.get(owner.id)
|
|
70
|
+
: '';
|
|
71
|
+
const key = `${normalizedGroup}\0${workflowPartition}`;
|
|
72
|
+
const entry = groups.get(key) ?? { group: normalizedGroup, ids: [] };
|
|
73
|
+
entry.ids.push(owner.id);
|
|
74
|
+
groups.set(key, entry);
|
|
75
|
+
}
|
|
76
|
+
return [...groups.values()].flatMap(({ group, ids }) => ids.length > 1 ? [`concurrency group collision: ${group}: ${ids.toSorted().join(', ')}`] : []);
|
|
77
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* True when `guardCondition` is guaranteed to hold whenever `consumerCondition` does, so a step
|
|
3
|
+
* gated by `guardCondition` cannot be skipped on any execution path that reaches a step gated by
|
|
4
|
+
* `consumerCondition`. Each of the guard's top-level `&&` clauses must either be one of the
|
|
5
|
+
* consumer's own clauses, or — when the guard clause is itself a `(a || b)` group — the consumer
|
|
6
|
+
* must require one of that group's alternatives. That second case is what a shared trailing gate
|
|
7
|
+
* ANDed onto an OR of per-consumer clauses produces: `(A || B) && G` guarding two steps gated
|
|
8
|
+
* `A && G` and `B && G` respectively — the shape a shared secret takes when it backs two
|
|
9
|
+
* differently-gated consumer steps in the same job.
|
|
10
|
+
*
|
|
11
|
+
* Clauses alone aren't enough: a guard with the implicit `success()`/default status restriction is
|
|
12
|
+
* skipped on any path where an earlier step already failed, so it cannot protect a consumer gated
|
|
13
|
+
* `always()` or `failure()` that still runs on that same path. `other` (e.g. `failure()`,
|
|
14
|
+
* `cancelled()`) is rejected outright as a guard status — this check can't reason about whether the
|
|
15
|
+
* guard and consumer agree on which failure path they run on, and a false "safe" verdict is the real
|
|
16
|
+
* risk (see file header). An `always()` guard has no restriction, so it entails any consumer status.
|
|
17
|
+
* Either side being `ambiguous` (an unparenthesized `&&`/`||` mix — see `hasTopLevelMixedOperators`)
|
|
18
|
+
* rejects entailment outright, since its clauses can't be trusted to reflect real precedence.
|
|
19
|
+
*/
|
|
20
|
+
export declare function conditionEntails(guardCondition: string, consumerCondition: string): boolean;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { splitTopLevel, hasTopLevelMixedOperators } from './condition-split.mjs';
|
|
2
|
+
/**
|
|
3
|
+
* Conservative GitHub Actions `if:` condition comparison.
|
|
4
|
+
* Deliberately conservative: a condition shape this can't reason about is treated as NOT entailed
|
|
5
|
+
* (readiness step rejected) rather than guessed at, since a false "safe" verdict is the real risk.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Splits `expression` on top-level occurrences of `operator` — one inside parens or a quoted
|
|
9
|
+
* string doesn't count as top-level.
|
|
10
|
+
*/
|
|
11
|
+
function stripOuterParens(expression) {
|
|
12
|
+
const trimmed = expression.trim();
|
|
13
|
+
if (!trimmed.startsWith('(') || !trimmed.endsWith(')'))
|
|
14
|
+
return trimmed;
|
|
15
|
+
let depth = 0;
|
|
16
|
+
for (let index = 0; index < trimmed.length; index += 1) {
|
|
17
|
+
if (trimmed[index] === '(')
|
|
18
|
+
depth += 1;
|
|
19
|
+
else if (trimmed[index] === ')') {
|
|
20
|
+
depth -= 1;
|
|
21
|
+
if (depth === 0 && index !== trimmed.length - 1)
|
|
22
|
+
return trimmed;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return trimmed.slice(1, -1).trim();
|
|
26
|
+
}
|
|
27
|
+
const STATUS_CLAUSES = {
|
|
28
|
+
'success()': 'default',
|
|
29
|
+
'always()': 'always',
|
|
30
|
+
'failure()': 'other',
|
|
31
|
+
'cancelled()': 'other',
|
|
32
|
+
};
|
|
33
|
+
/**
|
|
34
|
+
* Recognizes a clause as a status-function check even when wrapped — a single layer of parens
|
|
35
|
+
* (`(cancelled())`) or a leading negation (`!cancelled()`, this repo's common form) — not just an
|
|
36
|
+
* exact bare match. Negation flips a status function's permissiveness in a way this file can't
|
|
37
|
+
* cleanly reason about (`!cancelled()` runs on both success and failure, a strict superset of the
|
|
38
|
+
* default success()-only restriction), so any negated form is conservatively classified `other`
|
|
39
|
+
* regardless of which function it negates — the same "can't reason about it, reject" fallback used
|
|
40
|
+
* elsewhere in this file.
|
|
41
|
+
*/
|
|
42
|
+
function classifyStatusClause(clause) {
|
|
43
|
+
const direct = STATUS_CLAUSES[clause] ?? STATUS_CLAUSES[stripOuterParens(clause)];
|
|
44
|
+
if (direct)
|
|
45
|
+
return direct;
|
|
46
|
+
if (!clause.startsWith('!'))
|
|
47
|
+
return undefined;
|
|
48
|
+
const negated = stripOuterParens(clause.slice(1).trim());
|
|
49
|
+
return STATUS_CLAUSES[negated] ? 'other' : undefined;
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* True when `clause` — or its single paren layer — is a top-level `||` where at least one branch
|
|
53
|
+
* is itself a status function (including wrapped forms, see `classifyStatusClause`). A shape like
|
|
54
|
+
* `always() || vars.FORCE == 'true'` mixes a status restriction into what looks like an ordinary
|
|
55
|
+
* clause — it can't be folded into the condition's one overall `ImpliedStatus`, nor matched as an
|
|
56
|
+
* ordinary clause (which needs an identical consumer-side string). Callers reject it outright.
|
|
57
|
+
*/
|
|
58
|
+
function hasStatusFunctionInOrBranch(clause) {
|
|
59
|
+
const branches = splitTopLevel(stripOuterParens(clause), '||');
|
|
60
|
+
if (branches.length <= 1)
|
|
61
|
+
return false;
|
|
62
|
+
return branches.some((branch) => classifyStatusClause(branch.trim()) !== undefined);
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* True when `expression` contains both a top-level `&&` and a top-level `||` — outside any
|
|
66
|
+
* parens or quoted string. GitHub Actions binds `&&` tighter than `||`, so `A || B && G` means
|
|
67
|
+
* `A || (B && G)`, not `(A || B) && G` — naively AND-splitting such a mix on `&&` would silently
|
|
68
|
+
* misread its grouping and could accept a guard that doesn't actually cover every path the
|
|
69
|
+
* consumer runs on. A condition mixing both operators without full parenthesization is one this
|
|
70
|
+
* file cannot safely reason about — see the conservative-rejection policy in the file header.
|
|
71
|
+
*/
|
|
72
|
+
function parseCondition(condition) {
|
|
73
|
+
const trimmed = condition.trim();
|
|
74
|
+
if (hasTopLevelMixedOperators(trimmed))
|
|
75
|
+
return { status: 'other', clauses: [], ambiguous: true };
|
|
76
|
+
const allClauses = trimmed === '' ? [] : splitTopLevel(trimmed, '&&');
|
|
77
|
+
const statuses = new Set();
|
|
78
|
+
const clauses = [];
|
|
79
|
+
for (const clause of allClauses) {
|
|
80
|
+
if (hasStatusFunctionInOrBranch(clause))
|
|
81
|
+
return { status: 'other', clauses: [], ambiguous: true };
|
|
82
|
+
const status = classifyStatusClause(clause);
|
|
83
|
+
if (status)
|
|
84
|
+
statuses.add(status);
|
|
85
|
+
else
|
|
86
|
+
clauses.push(clause);
|
|
87
|
+
}
|
|
88
|
+
if (statuses.size === 0)
|
|
89
|
+
return { status: 'default', clauses, ambiguous: false };
|
|
90
|
+
if (statuses.size > 1)
|
|
91
|
+
return { status: 'other', clauses, ambiguous: false };
|
|
92
|
+
return { status: [...statuses][0], clauses, ambiguous: false };
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* True when `guardCondition` is guaranteed to hold whenever `consumerCondition` does, so a step
|
|
96
|
+
* gated by `guardCondition` cannot be skipped on any execution path that reaches a step gated by
|
|
97
|
+
* `consumerCondition`. Each of the guard's top-level `&&` clauses must either be one of the
|
|
98
|
+
* consumer's own clauses, or — when the guard clause is itself a `(a || b)` group — the consumer
|
|
99
|
+
* must require one of that group's alternatives. That second case is what a shared trailing gate
|
|
100
|
+
* ANDed onto an OR of per-consumer clauses produces: `(A || B) && G` guarding two steps gated
|
|
101
|
+
* `A && G` and `B && G` respectively — the shape a shared secret takes when it backs two
|
|
102
|
+
* differently-gated consumer steps in the same job.
|
|
103
|
+
*
|
|
104
|
+
* Clauses alone aren't enough: a guard with the implicit `success()`/default status restriction is
|
|
105
|
+
* skipped on any path where an earlier step already failed, so it cannot protect a consumer gated
|
|
106
|
+
* `always()` or `failure()` that still runs on that same path. `other` (e.g. `failure()`,
|
|
107
|
+
* `cancelled()`) is rejected outright as a guard status — this check can't reason about whether the
|
|
108
|
+
* guard and consumer agree on which failure path they run on, and a false "safe" verdict is the real
|
|
109
|
+
* risk (see file header). An `always()` guard has no restriction, so it entails any consumer status.
|
|
110
|
+
* Either side being `ambiguous` (an unparenthesized `&&`/`||` mix — see `hasTopLevelMixedOperators`)
|
|
111
|
+
* rejects entailment outright, since its clauses can't be trusted to reflect real precedence.
|
|
112
|
+
*/
|
|
113
|
+
export function conditionEntails(guardCondition, consumerCondition) {
|
|
114
|
+
const guard = parseCondition(guardCondition);
|
|
115
|
+
if (guard.ambiguous || guard.status === 'other')
|
|
116
|
+
return false;
|
|
117
|
+
const consumer = parseCondition(consumerCondition);
|
|
118
|
+
if (consumer.ambiguous)
|
|
119
|
+
return false;
|
|
120
|
+
if (consumer.status !== 'default' && guard.status !== 'always')
|
|
121
|
+
return false;
|
|
122
|
+
const consumerClauses = new Set(consumer.clauses);
|
|
123
|
+
return guard.clauses.every((guardClause) => {
|
|
124
|
+
if (consumerClauses.has(guardClause))
|
|
125
|
+
return true;
|
|
126
|
+
const inner = stripOuterParens(guardClause);
|
|
127
|
+
if (inner === guardClause)
|
|
128
|
+
return false;
|
|
129
|
+
const branches = splitTopLevel(inner, '||');
|
|
130
|
+
return branches.length > 1 && branches.some((branch) => consumerClauses.has(branch));
|
|
131
|
+
});
|
|
132
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export declare function splitTopLevel(expression: string, operator: '&&' | '||'): string[];
|
|
2
|
+
/** Strips one layer of enclosing parens — only when the opening `(` at index 0 is the match for
|
|
3
|
+
* the closing `)` at the end, not e.g. `(a) && (b)` which merely starts and ends with parens. */
|
|
4
|
+
export declare function hasTopLevelMixedOperators(expression: string): boolean;
|
|
5
|
+
/**
|
|
6
|
+
* Splits `condition` into its non-status `&&` clauses and its implied status restriction. A status
|
|
7
|
+
* function can appear anywhere among the top-level `&&` clauses (e.g. `always() && vars.X == 'true'`),
|
|
8
|
+
* not just as the entire condition, so every clause is checked rather than only a whole-string match —
|
|
9
|
+
* including wrapped forms like `!cancelled()`, see `classifyStatusClause`. Mixing two different status
|
|
10
|
+
* functions in one condition (`success() && failure()`, never valid in practice) is treated as `other`
|
|
11
|
+
* — the same conservative "can't reason about it" fallback as an unrecognized shape elsewhere in this
|
|
12
|
+
* file. A top-level `&&`/`||` mix, or a status function nested inside an OR branch (e.g.
|
|
13
|
+
* `always() || vars.FORCE`, see `hasStatusFunctionInOrBranch`), is flagged `ambiguous` instead, since
|
|
14
|
+
* neither shape is safe to decompose into clauses.
|
|
15
|
+
*/
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
export function splitTopLevel(expression, operator) {
|
|
2
|
+
const segments = [];
|
|
3
|
+
let depth = 0;
|
|
4
|
+
let start = 0;
|
|
5
|
+
let inSingleQuote = false;
|
|
6
|
+
let inDoubleQuote = false;
|
|
7
|
+
for (let index = 0; index < expression.length; index += 1) {
|
|
8
|
+
const char = expression[index];
|
|
9
|
+
if (char === "'" && !inDoubleQuote)
|
|
10
|
+
inSingleQuote = !inSingleQuote;
|
|
11
|
+
else if (char === '"' && !inSingleQuote)
|
|
12
|
+
inDoubleQuote = !inDoubleQuote;
|
|
13
|
+
else if (!inSingleQuote && !inDoubleQuote) {
|
|
14
|
+
if (char === '(')
|
|
15
|
+
depth += 1;
|
|
16
|
+
else if (char === ')')
|
|
17
|
+
depth -= 1;
|
|
18
|
+
else if (depth === 0 && expression.startsWith(operator, index)) {
|
|
19
|
+
segments.push(expression.slice(start, index));
|
|
20
|
+
index += operator.length - 1;
|
|
21
|
+
start = index + 1;
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
segments.push(expression.slice(start));
|
|
26
|
+
const trimmedSegments = [];
|
|
27
|
+
for (const segment of segments) {
|
|
28
|
+
const trimmed = segment.trim();
|
|
29
|
+
if (trimmed.length > 0)
|
|
30
|
+
trimmedSegments.push(trimmed);
|
|
31
|
+
}
|
|
32
|
+
return trimmedSegments;
|
|
33
|
+
}
|
|
34
|
+
/** Strips one layer of enclosing parens — only when the opening `(` at index 0 is the match for
|
|
35
|
+
* the closing `)` at the end, not e.g. `(a) && (b)` which merely starts and ends with parens. */
|
|
36
|
+
export function hasTopLevelMixedOperators(expression) {
|
|
37
|
+
let depth = 0;
|
|
38
|
+
let inSingleQuote = false;
|
|
39
|
+
let inDoubleQuote = false;
|
|
40
|
+
let sawAnd = false;
|
|
41
|
+
let sawOr = false;
|
|
42
|
+
for (let index = 0; index < expression.length; index += 1) {
|
|
43
|
+
const char = expression[index];
|
|
44
|
+
if (char === "'" && !inDoubleQuote)
|
|
45
|
+
inSingleQuote = !inSingleQuote;
|
|
46
|
+
else if (char === '"' && !inSingleQuote)
|
|
47
|
+
inDoubleQuote = !inDoubleQuote;
|
|
48
|
+
else if (!inSingleQuote && !inDoubleQuote) {
|
|
49
|
+
if (char === '(')
|
|
50
|
+
depth += 1;
|
|
51
|
+
else if (char === ')')
|
|
52
|
+
depth -= 1;
|
|
53
|
+
else if (depth === 0 && expression.startsWith('&&', index))
|
|
54
|
+
sawAnd = true;
|
|
55
|
+
else if (depth === 0 && expression.startsWith('||', index))
|
|
56
|
+
sawOr = true;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return sawAnd && sawOr;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Splits `condition` into its non-status `&&` clauses and its implied status restriction. A status
|
|
63
|
+
* function can appear anywhere among the top-level `&&` clauses (e.g. `always() && vars.X == 'true'`),
|
|
64
|
+
* not just as the entire condition, so every clause is checked rather than only a whole-string match —
|
|
65
|
+
* including wrapped forms like `!cancelled()`, see `classifyStatusClause`. Mixing two different status
|
|
66
|
+
* functions in one condition (`success() && failure()`, never valid in practice) is treated as `other`
|
|
67
|
+
* — the same conservative "can't reason about it" fallback as an unrecognized shape elsewhere in this
|
|
68
|
+
* file. A top-level `&&`/`||` mix, or a status function nested inside an OR branch (e.g.
|
|
69
|
+
* `always() || vars.FORCE`, see `hasStatusFunctionInOrBranch`), is flagged `ambiguous` instead, since
|
|
70
|
+
* neither shape is safe to decompose into clauses.
|
|
71
|
+
*/
|