vouchington-tooling 0.34.0 → 0.35.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/dist/finite-enum-ripple/compare.mjs +1 -3
- package/dist/finite-enum-ripple/model.d.mts +4 -0
- package/dist/finite-enum-ripple/parser-support.mjs +14 -1
- package/dist/finite-enum-ripple/parsers.mjs +2 -0
- package/dist/finite-enum-ripple/structured-route-check.mjs +1 -1
- package/dist/finite-enum-ripple/union-types.mjs +1 -1
- package/dist/workflow-policy/index.d.mts +1 -3
- package/dist/workflow-policy/index.mjs +0 -2
- package/dist/workflow-policy/types.d.mts +2 -28
- package/docs/workflow-and-compiler.md +7 -16
- package/package.json +1 -1
- package/dist/workflow-policy/concurrency-scope.d.mts +0 -4
- package/dist/workflow-policy/concurrency-scope.mjs +0 -66
- package/dist/workflow-policy/concurrency.d.mts +0 -10
- package/dist/workflow-policy/concurrency.mjs +0 -77
- package/dist/workflow-policy/graph.d.mts +0 -2
- package/dist/workflow-policy/graph.mjs +0 -138
|
@@ -13,8 +13,6 @@ export function checkUnionCreatePageTypes(errors, routeConfigs, pages, readTrack
|
|
|
13
13
|
}
|
|
14
14
|
const content = readTracked(page.file);
|
|
15
15
|
const actualTypes = collectCreatePageLiterals(content, page.file, createPageTypeProperties);
|
|
16
|
-
if (actualTypes.length === 0)
|
|
17
|
-
errors.push(finiteEnumError(page.file, `${label} create page has no inspectable type literal`));
|
|
18
16
|
for (const actualType of actualTypes) {
|
|
19
17
|
if (actualType === expectedType)
|
|
20
18
|
continue;
|
|
@@ -28,7 +26,7 @@ export function checkCollectionPagePathLiterals(pages, errors, group, routeSlugs
|
|
|
28
26
|
continue;
|
|
29
27
|
const content = readTracked(page.file);
|
|
30
28
|
for (const literalPath of collectActivePatternCaptures(content, pathPattern, page.file)) {
|
|
31
|
-
if (literalPath === page.slug || literalPath.startsWith(`${page.slug}/`))
|
|
29
|
+
if (!literalPath || literalPath === page.slug || literalPath.startsWith(`${page.slug}/`))
|
|
32
30
|
continue;
|
|
33
31
|
errors.push(finiteEnumError(page.file, `${group} collection path literal "/${literalPath}" does not match route directory "${page.slug}"`));
|
|
34
32
|
}
|
|
@@ -44,6 +44,8 @@ export interface FiniteEnumRippleConfig {
|
|
|
44
44
|
collection: string;
|
|
45
45
|
};
|
|
46
46
|
collectionLabel: string;
|
|
47
|
+
/** Wording for collection path diagnostics; defaults to collectionLabel. */
|
|
48
|
+
collectionPathLabel?: string;
|
|
47
49
|
ignoredNavigationPaths: readonly string[];
|
|
48
50
|
collectionPathLiteralPattern: RegExp;
|
|
49
51
|
navigationPathLiteralPattern: RegExp;
|
|
@@ -68,6 +70,8 @@ export interface FiniteEnumRippleConfig {
|
|
|
68
70
|
factory: string;
|
|
69
71
|
};
|
|
70
72
|
collectionLabel: string;
|
|
73
|
+
/** Wording for collection path diagnostics; defaults to collectionLabel. */
|
|
74
|
+
collectionPathLabel?: string;
|
|
71
75
|
createPageTypeProperties: readonly string[];
|
|
72
76
|
collectionPathLiteralPattern: RegExp;
|
|
73
77
|
};
|
|
@@ -42,7 +42,7 @@ export function parseRouteConfigEntries(content, name, file, pluralPathProperty,
|
|
|
42
42
|
}
|
|
43
43
|
export function parseStringArray(content, property, file) {
|
|
44
44
|
const array = getPropertyValue(content, property);
|
|
45
|
-
if (!array)
|
|
45
|
+
if (!array || isIntrinsicUndefined(array))
|
|
46
46
|
return [];
|
|
47
47
|
if (!ts.isArrayLiteralExpression(array))
|
|
48
48
|
throw new Error(`${file}: ${property} must be a literal string array`);
|
|
@@ -84,3 +84,16 @@ function unwrapTypeNode(type) {
|
|
|
84
84
|
current = current.type;
|
|
85
85
|
return current;
|
|
86
86
|
}
|
|
87
|
+
function isIntrinsicUndefined(expression) {
|
|
88
|
+
if (!ts.isIdentifier(expression))
|
|
89
|
+
return false;
|
|
90
|
+
const source = expression.getSourceFile();
|
|
91
|
+
const options = { noLib: true, noResolve: true };
|
|
92
|
+
const host = ts.createCompilerHost(options);
|
|
93
|
+
host.getSourceFile = () => source;
|
|
94
|
+
const checker = ts.createProgram([source.fileName], options, host).getTypeChecker();
|
|
95
|
+
const symbol = checker.getSymbolAtLocation(expression);
|
|
96
|
+
return (symbol !== undefined &&
|
|
97
|
+
symbol.declarations?.length === 0 &&
|
|
98
|
+
checker.getTypeAtLocation(expression).flags === ts.TypeFlags.Undefined);
|
|
99
|
+
}
|
|
@@ -104,6 +104,8 @@ export function parseStructuredRouteFactoryArgs(content, file, callPattern) {
|
|
|
104
104
|
matcher.lastIndex = 0;
|
|
105
105
|
if (!matcher.test(getCallExpressionName(call.expression) ?? ''))
|
|
106
106
|
continue;
|
|
107
|
+
if (call.arguments.length === 0)
|
|
108
|
+
continue;
|
|
107
109
|
const slug = getStringLiteralValue(call.arguments[0]);
|
|
108
110
|
if (!slug)
|
|
109
111
|
throw new Error(`${file}: configured route factory call needs a string literal slug`);
|
|
@@ -87,7 +87,7 @@ export function checkStructuredRouteConfigs(errors, files, readTracked, config,
|
|
|
87
87
|
expectedFile: routeConfigsPath,
|
|
88
88
|
expectedValues: routeConfigs.map((routeConfig) => routeConfig.pluralPath),
|
|
89
89
|
});
|
|
90
|
-
checkCollectionPagePathLiterals(files.structuredCollectionPages, errors, config.collectionLabel, new Set(routeConfigs.map((routeConfig) => routeConfig.pluralPath)), readTracked, config.collectionPathLiteralPattern);
|
|
90
|
+
checkCollectionPagePathLiterals(files.structuredCollectionPages, errors, config.collectionPathLabel ?? config.collectionLabel, new Set(routeConfigs.map((routeConfig) => routeConfig.pluralPath)), readTracked, config.collectionPathLiteralPattern);
|
|
91
91
|
checkStructuredCollectionComponentPathLiterals(errors, routeConfigs, files.structuredComponentFiles, new Set(config.ignoredNavigationPaths), readTracked, config.collectionLabel, config.navigationPathLiteralPattern);
|
|
92
92
|
}
|
|
93
93
|
}
|
|
@@ -123,7 +123,7 @@ export function checkUnionTypes(errors, files, readTracked, config) {
|
|
|
123
123
|
expectedValues: routeConfigs.map((routeConfig) => routeConfig.pluralPath),
|
|
124
124
|
});
|
|
125
125
|
checkUnionCreatePageTypes(errors, effectiveSingleTypeRouteConfigs, files.unionCreatePages, readTracked, config.createPageTypeProperties, config.collectionLabel);
|
|
126
|
-
checkCollectionPagePathLiterals(files.unionCollectionPages, errors, config.collectionLabel, new Set(effectiveSingleTypeRouteConfigs.map((routeConfig) => routeConfig.pluralPath)), readTracked, config.collectionPathLiteralPattern);
|
|
126
|
+
checkCollectionPagePathLiterals(files.unionCollectionPages, errors, config.collectionPathLabel ?? config.collectionLabel, new Set(effectiveSingleTypeRouteConfigs.map((routeConfig) => routeConfig.pluralPath)), readTracked, config.collectionPathLiteralPattern);
|
|
127
127
|
}
|
|
128
128
|
checkUnionFactories(errors, files.unionDetailPages, readTracked, config, slugToType, routeSlugs);
|
|
129
129
|
}
|
|
@@ -1,7 +1,5 @@
|
|
|
1
|
-
export { evaluateGraphPolicy } from './graph.mts';
|
|
2
|
-
export { evaluateLockPolicy, type ConcurrencyPolicy } from './concurrency.mts';
|
|
3
1
|
export { conditionEntails } from './condition-entailment.mts';
|
|
4
2
|
export { unprovisionedSecretsWithoutReadinessStep } from './secret-readiness.mts';
|
|
5
3
|
export { callerCalleePermissionMismatches, type CallerCalleePermissionPolicy, } from './permission-mismatches.mts';
|
|
6
4
|
export { missingTopLevelPermissionPaths, parsePermissions, parseWorkflow, requiredWorkflowPermissions, type Workflow, } from './permissions.mts';
|
|
7
|
-
export type { WorkflowTopology, WorkflowTopologyIndex,
|
|
5
|
+
export type { WorkflowTopology, WorkflowTopologyIndex, WorkflowNode, WorkflowJobNode, WorkflowStep, } from './types.mts';
|
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
export { evaluateGraphPolicy } from './graph.mjs';
|
|
2
|
-
export { evaluateLockPolicy } from './concurrency.mjs';
|
|
3
1
|
export { conditionEntails } from './condition-entailment.mjs';
|
|
4
2
|
export { unprovisionedSecretsWithoutReadinessStep } from './secret-readiness.mjs';
|
|
5
3
|
export { callerCalleePermissionMismatches, } from './permission-mismatches.mjs';
|
|
@@ -9,7 +9,7 @@ export interface WorkflowStep {
|
|
|
9
9
|
run?: string;
|
|
10
10
|
secretReferences?: readonly string[];
|
|
11
11
|
}
|
|
12
|
-
|
|
12
|
+
interface WorkflowConcurrency {
|
|
13
13
|
effective: {
|
|
14
14
|
group: string;
|
|
15
15
|
cancelInProgress: boolean | string;
|
|
@@ -61,30 +61,4 @@ export interface WorkflowTopologyIndex {
|
|
|
61
61
|
match?: string;
|
|
62
62
|
}[];
|
|
63
63
|
}
|
|
64
|
-
export
|
|
65
|
-
export type StepSelector = {
|
|
66
|
-
id?: string;
|
|
67
|
-
uses?: string;
|
|
68
|
-
name?: string;
|
|
69
|
-
};
|
|
70
|
-
export type WorkflowTopologyPolicy = {
|
|
71
|
-
jobInventory: Readonly<Record<string, readonly string[]>>;
|
|
72
|
-
requiredJobs: readonly string[];
|
|
73
|
-
forbiddenJobs: readonly string[];
|
|
74
|
-
requiredDirectEdges: readonly EdgeRule[];
|
|
75
|
-
forbiddenDirectEdges: readonly EdgeRule[];
|
|
76
|
-
requiredTransitiveEdges: readonly EdgeRule[];
|
|
77
|
-
forbiddenTransitiveEdges: readonly EdgeRule[];
|
|
78
|
-
requiredArtifactEdges: readonly {
|
|
79
|
-
from: string;
|
|
80
|
-
to: string;
|
|
81
|
-
name: string;
|
|
82
|
-
match?: string;
|
|
83
|
-
}[];
|
|
84
|
-
exactFanIns: Readonly<Record<string, readonly string[]>>;
|
|
85
|
-
exactCallerJobs: Readonly<Record<string, readonly string[]>>;
|
|
86
|
-
stepOrders: readonly {
|
|
87
|
-
jobId: string;
|
|
88
|
-
steps: readonly StepSelector[];
|
|
89
|
-
}[];
|
|
90
|
-
};
|
|
64
|
+
export {};
|
|
@@ -1,33 +1,24 @@
|
|
|
1
1
|
# Workflow and compiler primitives
|
|
2
2
|
|
|
3
|
-
`workflow-policy`
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
concurrency intent separately. Its scope classifier recognizes GitHub expression references and
|
|
9
|
-
reports unsupported contexts.
|
|
10
|
-
|
|
11
|
-
Inventory compares workflow `jobIds` against each corresponding job's `key`; job IDs may use any
|
|
12
|
-
format. Fan-in and caller comparisons ignore ordering of index results.
|
|
3
|
+
`workflow-policy` checks parsed GitHub Actions topology against data supplied by the consumer. It
|
|
4
|
+
has no workflow inventory or secret names of its own. Supply a topology shaped like
|
|
5
|
+
`WorkflowTopology`; a parser may expose additional fields. Topology, job-graph and concurrency
|
|
6
|
+
policy lives in the `no-mistakes` `workflow-topology-policy` rule, not here. This breaking minor removed `evaluateGraphPolicy`, `evaluateLockPolicy`,
|
|
7
|
+
`ConcurrencyPolicy` and `WorkflowTopologyPolicy`; migrate to that rule.
|
|
13
8
|
|
|
14
9
|
```ts
|
|
15
10
|
import {
|
|
16
11
|
conditionEntails,
|
|
17
|
-
evaluateGraphPolicy,
|
|
18
|
-
evaluateLockPolicy,
|
|
19
12
|
callerCalleePermissionMismatches,
|
|
20
13
|
parseWorkflow,
|
|
21
14
|
unprovisionedSecretsWithoutReadinessStep,
|
|
22
15
|
} from 'vouchington-tooling/workflow-policy'
|
|
23
16
|
|
|
24
|
-
const diagnostics =
|
|
25
|
-
diagnostics.push(...evaluateLockPolicy(topology, unlockedReasons, concurrencyIntent))
|
|
26
|
-
diagnostics.push(
|
|
17
|
+
const diagnostics = [
|
|
27
18
|
...unprovisionedSecretsWithoutReadinessStep(topology, {
|
|
28
19
|
RELEASE_KEY: { provisioned: false },
|
|
29
20
|
}),
|
|
30
|
-
|
|
21
|
+
]
|
|
31
22
|
const documents = Object.fromEntries(
|
|
32
23
|
Object.entries(workflowSources).map(([path, yaml]) => [path, parseWorkflow(yaml)]),
|
|
33
24
|
)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "vouchington-tooling",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.35.0",
|
|
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": {
|
|
@@ -1,4 +0,0 @@
|
|
|
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[];
|
|
@@ -1,66 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,10 +0,0 @@
|
|
|
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 {};
|
|
@@ -1,77 +0,0 @@
|
|
|
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
|
-
}
|
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
export function evaluateGraphPolicy(topology, index, policy) {
|
|
2
|
-
const diagnostics = [
|
|
3
|
-
...inventoryDiagnostics(topology, policy),
|
|
4
|
-
...jobPresenceDiagnostics(index, policy),
|
|
5
|
-
...edgeDiagnostics(index, policy.requiredDirectEdges, true, true),
|
|
6
|
-
...edgeDiagnostics(index, policy.forbiddenDirectEdges, true, false),
|
|
7
|
-
...edgeDiagnostics(index, policy.requiredTransitiveEdges, false, true),
|
|
8
|
-
...edgeDiagnostics(index, policy.forbiddenTransitiveEdges, false, false),
|
|
9
|
-
...artifactEdgeDiagnostics(index, policy),
|
|
10
|
-
...fanInDiagnostics(index, policy),
|
|
11
|
-
...callerDiagnostics(index, policy),
|
|
12
|
-
...stepOrderDiagnostics(index, policy),
|
|
13
|
-
];
|
|
14
|
-
return diagnostics.toSorted();
|
|
15
|
-
}
|
|
16
|
-
function artifactEdgeDiagnostics(index, policy) {
|
|
17
|
-
return policy.requiredArtifactEdges.flatMap((rule) => {
|
|
18
|
-
const label = `${rule.from} -> ${rule.to}: ${rule.name}${rule.match ? ` [${rule.match}]` : ''}`;
|
|
19
|
-
if (!index.jobsById.has(rule.from) || !index.jobsById.has(rule.to))
|
|
20
|
-
return [`required artifact edge missing: ${label}`];
|
|
21
|
-
const present = index
|
|
22
|
-
.artifactConsumersForProducerJob(rule.from)
|
|
23
|
-
.some((edge) => edge.to === rule.to &&
|
|
24
|
-
edge.name === rule.name &&
|
|
25
|
-
(rule.match === undefined || edge.match === rule.match));
|
|
26
|
-
return present ? [] : [`required artifact edge missing: ${label}`];
|
|
27
|
-
});
|
|
28
|
-
}
|
|
29
|
-
function inventoryDiagnostics(topology, policy) {
|
|
30
|
-
const jobsById = new Map(topology.jobs.map((job) => [job.id, job]));
|
|
31
|
-
const actual = new Map(topology.workflows.map((workflow) => [
|
|
32
|
-
workflow.path,
|
|
33
|
-
workflow.jobIds
|
|
34
|
-
.map((id) => jobsById.get(id)?.key ?? id)
|
|
35
|
-
.toSorted((a, b) => a.localeCompare(b)),
|
|
36
|
-
]));
|
|
37
|
-
const diagnostics = [];
|
|
38
|
-
for (const [path, keys] of actual) {
|
|
39
|
-
const expected = policy.jobInventory[path];
|
|
40
|
-
if (!expected)
|
|
41
|
-
diagnostics.push(`workflow inventory missing: ${path}`);
|
|
42
|
-
else if (JSON.stringify(keys) !== JSON.stringify([...expected].toSorted()))
|
|
43
|
-
diagnostics.push(`job inventory mismatch: ${path}: expected ${[...expected].toSorted().join(', ')}, got ${keys.join(', ')}`);
|
|
44
|
-
}
|
|
45
|
-
for (const path of Object.keys(policy.jobInventory).toSorted()) {
|
|
46
|
-
if (!actual.has(path))
|
|
47
|
-
diagnostics.push(`workflow inventory stale: ${path}`);
|
|
48
|
-
}
|
|
49
|
-
return diagnostics;
|
|
50
|
-
}
|
|
51
|
-
function jobPresenceDiagnostics(index, policy) {
|
|
52
|
-
const diagnostics = [];
|
|
53
|
-
for (const id of policy.requiredJobs)
|
|
54
|
-
if (!index.jobsById.has(id))
|
|
55
|
-
diagnostics.push(`required job missing: ${id}`);
|
|
56
|
-
for (const id of policy.forbiddenJobs)
|
|
57
|
-
if (index.jobsById.has(id))
|
|
58
|
-
diagnostics.push(`forbidden job present: ${id}`);
|
|
59
|
-
return diagnostics;
|
|
60
|
-
}
|
|
61
|
-
function edgeDiagnostics(index, rules, direct, required) {
|
|
62
|
-
return rules.flatMap(([from, to]) => {
|
|
63
|
-
if (!index.jobsById.has(from) || !index.jobsById.has(to))
|
|
64
|
-
return required
|
|
65
|
-
? [`required ${direct ? 'direct' : 'transitive'} edge missing: ${from} -> ${to}`]
|
|
66
|
-
: [];
|
|
67
|
-
const downstream = direct
|
|
68
|
-
? index.directDownstreamJobIds(from)
|
|
69
|
-
: index.transitiveDownstreamJobIds(from);
|
|
70
|
-
const present = downstream.includes(to);
|
|
71
|
-
return present === required
|
|
72
|
-
? []
|
|
73
|
-
: [
|
|
74
|
-
`${required ? 'required' : 'forbidden'} ${direct ? 'direct' : 'transitive'} edge ${required ? 'missing' : 'present'}: ${from} -> ${to}`,
|
|
75
|
-
];
|
|
76
|
-
});
|
|
77
|
-
}
|
|
78
|
-
function fanInDiagnostics(index, policy) {
|
|
79
|
-
return Object.entries(policy.exactFanIns).flatMap(([jobId, expected]) => {
|
|
80
|
-
if (!index.jobsById.has(jobId))
|
|
81
|
-
return [`exact fan-in target missing: ${jobId}`];
|
|
82
|
-
const actual = [...index.directUpstreamJobIds(jobId)].toSorted();
|
|
83
|
-
return JSON.stringify(actual) === JSON.stringify([...expected].toSorted())
|
|
84
|
-
? []
|
|
85
|
-
: [
|
|
86
|
-
`exact fan-in mismatch: ${jobId}: expected ${[...expected].toSorted().join(', ')}, got ${actual.join(', ')}`,
|
|
87
|
-
];
|
|
88
|
-
});
|
|
89
|
-
}
|
|
90
|
-
function callerDiagnostics(index, policy) {
|
|
91
|
-
const callablePaths = [];
|
|
92
|
-
for (const workflow of index.workflowsByPath.values())
|
|
93
|
-
if (workflow.callable)
|
|
94
|
-
callablePaths.push(workflow.path);
|
|
95
|
-
callablePaths.sort((left, right) => left.localeCompare(right));
|
|
96
|
-
const declaredPaths = Object.keys(policy.exactCallerJobs).toSorted();
|
|
97
|
-
const diagnostics = [];
|
|
98
|
-
for (const path of callablePaths)
|
|
99
|
-
if (!policy.exactCallerJobs[path])
|
|
100
|
-
diagnostics.push(`caller allowlist missing: ${path}`);
|
|
101
|
-
for (const path of declaredPaths)
|
|
102
|
-
if (!callablePaths.includes(path))
|
|
103
|
-
diagnostics.push(`caller allowlist stale: ${path}`);
|
|
104
|
-
diagnostics.push(...Object.entries(policy.exactCallerJobs).flatMap(([path, expected]) => {
|
|
105
|
-
if (!index.workflowsByPath.get(path)?.callable)
|
|
106
|
-
return [];
|
|
107
|
-
const actual = [...index.directCallerJobIds(path)].toSorted();
|
|
108
|
-
return JSON.stringify(actual) === JSON.stringify([...expected].toSorted())
|
|
109
|
-
? []
|
|
110
|
-
: [
|
|
111
|
-
`caller allowlist mismatch: ${path}: expected ${[...expected].toSorted().join(', ')}, got ${actual.join(', ')}`,
|
|
112
|
-
];
|
|
113
|
-
}));
|
|
114
|
-
return diagnostics;
|
|
115
|
-
}
|
|
116
|
-
function matches(selector, step) {
|
|
117
|
-
return ((selector.id === undefined || selector.id === step.id) &&
|
|
118
|
-
(selector.uses === undefined || selector.uses === step.uses) &&
|
|
119
|
-
(selector.name === undefined || selector.name === step.name));
|
|
120
|
-
}
|
|
121
|
-
function stepOrderDiagnostics(index, policy) {
|
|
122
|
-
return policy.stepOrders.flatMap((rule) => {
|
|
123
|
-
const job = index.jobsById.get(rule.jobId);
|
|
124
|
-
if (!job)
|
|
125
|
-
return [`step-order job missing: ${rule.jobId}`];
|
|
126
|
-
let prior = -1;
|
|
127
|
-
for (const selector of rule.steps) {
|
|
128
|
-
const step = job.steps.find((candidate) => matches(selector, candidate));
|
|
129
|
-
const label = selector.id ?? selector.uses ?? selector.name ?? '<step>';
|
|
130
|
-
if (!step)
|
|
131
|
-
return [`required ordered step missing: ${rule.jobId}: ${label}`];
|
|
132
|
-
if (step.index <= prior)
|
|
133
|
-
return [`required step order invalid: ${rule.jobId}: ${label}`];
|
|
134
|
-
prior = step.index;
|
|
135
|
-
}
|
|
136
|
-
return [];
|
|
137
|
-
});
|
|
138
|
-
}
|