03d68e89c6
- Read allowed context from schema definition instead of hardcoded constants - Parse expressions into AST to accurately detect status functions (avoids false positives from string literals) - Export ensureStatusFunction helper that combines checking and wrapping logic - Remove step-if.yml from skipped tests (now passes with accurate detection) - Add tests for if-condition wrapping in hover/completion position mapping
254 lines
9.1 KiB
TypeScript
254 lines
9.1 KiB
TypeScript
import {Lexer, Parser} from "@actions/expressions";
|
|
import {Expr} from "@actions/expressions/ast";
|
|
import {ParseWorkflowResult, WorkflowTemplate, isBasicExpression, isString} from "@actions/workflow-parser";
|
|
import {ErrorPolicy} from "@actions/workflow-parser/model/convert";
|
|
import {ensureStatusFunction} from "@actions/workflow-parser/model/converter/if-condition";
|
|
import {splitAllowedContext} from "@actions/workflow-parser/templates/allowed-context";
|
|
import {BasicExpressionToken} from "@actions/workflow-parser/templates/tokens/basic-expression-token";
|
|
import {StringToken} from "@actions/workflow-parser/templates/tokens/string-token";
|
|
import {TemplateToken} from "@actions/workflow-parser/templates/tokens/template-token";
|
|
import {TokenRange} from "@actions/workflow-parser/templates/tokens/token-range";
|
|
import {File} from "@actions/workflow-parser/workflows/file";
|
|
import {FileProvider} from "@actions/workflow-parser/workflows/file-provider";
|
|
import {TextDocument} from "vscode-languageserver-textdocument";
|
|
import {Diagnostic, DiagnosticSeverity, URI} from "vscode-languageserver-types";
|
|
import {ActionMetadata, ActionReference} from "./action";
|
|
import {ContextProviderConfig} from "./context-providers/config";
|
|
import {Mode, getContext} from "./context-providers/default";
|
|
import {WorkflowContext, getWorkflowContext} from "./context/workflow-context";
|
|
import {wrapDictionary} from "./expression-validation/error-dictionary";
|
|
import {ValidationEvaluator} from "./expression-validation/evaluator";
|
|
import {validatorFunctions} from "./expression-validation/functions";
|
|
import {error} from "./log";
|
|
import {findToken} from "./utils/find-token";
|
|
import {mapRange} from "./utils/range";
|
|
import {fetchOrConvertWorkflowTemplate, fetchOrParseWorkflow} from "./utils/workflow-cache";
|
|
import {validateAction} from "./validate-action";
|
|
import {ValueProviderConfig, ValueProviderKind} from "./value-providers/config";
|
|
import {defaultValueProviders} from "./value-providers/default";
|
|
|
|
export type ValidationConfig = {
|
|
valueProviderConfig?: ValueProviderConfig;
|
|
contextProviderConfig?: ContextProviderConfig;
|
|
actionsMetadataProvider?: ActionsMetadataProvider;
|
|
fileProvider?: FileProvider;
|
|
};
|
|
|
|
export type ActionsMetadataProvider = {
|
|
fetchActionMetadata(action: ActionReference): Promise<ActionMetadata | undefined>;
|
|
};
|
|
|
|
/**
|
|
* Validates a workflow file
|
|
*
|
|
* @param textDocument Document to validate
|
|
* @returns Array of diagnostics
|
|
*/
|
|
export async function validate(textDocument: TextDocument, config?: ValidationConfig): Promise<Diagnostic[]> {
|
|
const file: File = {
|
|
name: textDocument.uri,
|
|
content: textDocument.getText()
|
|
};
|
|
|
|
const diagnostics: Diagnostic[] = [];
|
|
|
|
try {
|
|
const result: ParseWorkflowResult | undefined = fetchOrParseWorkflow(file, textDocument.uri);
|
|
if (!result) {
|
|
return [];
|
|
}
|
|
|
|
if (result.value) {
|
|
// Errors will be updated in the context. Attempt to do the conversion anyway in order to give the user more information
|
|
const template = await fetchOrConvertWorkflowTemplate(result.context, result.value, textDocument.uri, config, {
|
|
fetchReusableWorkflowDepth: config?.fileProvider ? 1 : 0,
|
|
errorPolicy: ErrorPolicy.TryConversion
|
|
});
|
|
|
|
// Validate expressions and value providers
|
|
await additionalValidations(diagnostics, textDocument.uri, template, result.value, config);
|
|
}
|
|
|
|
// For now map parser errors directly to diagnostics
|
|
for (const error of result.context.errors.getErrors()) {
|
|
const range = mapRange(error.range);
|
|
|
|
diagnostics.push({
|
|
message: error.rawMessage,
|
|
range
|
|
});
|
|
}
|
|
} catch (e) {
|
|
error(`Unhandled error while validating: ${(e as Error).message}`);
|
|
}
|
|
|
|
return diagnostics;
|
|
}
|
|
|
|
async function additionalValidations(
|
|
diagnostics: Diagnostic[],
|
|
documentUri: URI,
|
|
template: WorkflowTemplate,
|
|
root: TemplateToken,
|
|
config?: ValidationConfig
|
|
) {
|
|
for (const [parent, token, key] of TemplateToken.traverse(root)) {
|
|
// If the token is a value in a pair, use the key definition for validation
|
|
// If the token has a parent (map, sequence, etc), use this definition for validation
|
|
const validationToken = key || parent || token;
|
|
const validationDefinition = validationToken.definition;
|
|
|
|
// If this is an expression, validate it
|
|
if (isBasicExpression(token) && token.range) {
|
|
await validateExpression(
|
|
diagnostics,
|
|
token,
|
|
validationToken.definitionInfo?.allowedContext || [],
|
|
config?.contextProviderConfig,
|
|
getProviderContext(documentUri, template, root, token.range)
|
|
);
|
|
}
|
|
|
|
// If this is a job-if, step-if, or snapshot-if field (which are strings that should be treated as expressions), validate it
|
|
const definitionKey = token.definition?.key;
|
|
if (
|
|
isString(token) &&
|
|
token.range &&
|
|
(definitionKey === "job-if" || definitionKey === "step-if" || definitionKey === "snapshot-if")
|
|
) {
|
|
// Convert the string to an expression token for validation
|
|
const condition = token.value.trim();
|
|
if (condition) {
|
|
// Ensure the condition has a status function, wrapping if needed
|
|
const finalCondition = ensureStatusFunction(condition, token.definitionInfo);
|
|
|
|
// Create a BasicExpressionToken for validation
|
|
const expressionToken = new BasicExpressionToken(
|
|
token.file,
|
|
token.range,
|
|
finalCondition,
|
|
token.definitionInfo,
|
|
undefined,
|
|
token.source
|
|
);
|
|
|
|
await validateExpression(
|
|
diagnostics,
|
|
expressionToken,
|
|
validationToken.definitionInfo?.allowedContext || [],
|
|
config?.contextProviderConfig,
|
|
getProviderContext(documentUri, template, root, token.range)
|
|
);
|
|
}
|
|
}
|
|
|
|
if (token.definition?.key === "regular-step" && token.range) {
|
|
const context = getProviderContext(documentUri, template, root, token.range);
|
|
await validateAction(diagnostics, token, context.step, config);
|
|
}
|
|
|
|
// Allowed values coming from the schema have already been validated. Only check if
|
|
// a value provider is defined for a token and if it is, validate the values match.
|
|
if (token.range && validationDefinition) {
|
|
const defKey = validationDefinition.key;
|
|
if (defKey === "step-with") {
|
|
// Action inputs should be validated already in validateAction
|
|
continue;
|
|
}
|
|
|
|
if (defKey === "workflow-job-with") {
|
|
// Reusable workflow job inputs are validated by the parser
|
|
continue;
|
|
}
|
|
|
|
// Try a custom value provider first
|
|
let valueProvider = config?.valueProviderConfig?.[defKey];
|
|
if (!valueProvider) {
|
|
// fall back to default
|
|
valueProvider = defaultValueProviders[defKey];
|
|
}
|
|
|
|
if (valueProvider) {
|
|
const customValues = await valueProvider.get(getProviderContext(documentUri, template, root, token.range));
|
|
const caseInsensitive = valueProvider.caseInsensitive ?? false;
|
|
const customValuesMap = new Set(customValues.map(x => (caseInsensitive ? x.label.toLowerCase() : x.label)));
|
|
|
|
if (isString(token)) {
|
|
if (!customValuesMap.has(caseInsensitive ? token.value.toLowerCase() : token.value)) {
|
|
invalidValue(diagnostics, token, valueProvider.kind);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
function invalidValue(diagnostics: Diagnostic[], token: StringToken, kind: ValueProviderKind) {
|
|
switch (kind) {
|
|
case ValueProviderKind.AllowedValues:
|
|
diagnostics.push({
|
|
message: `Value '${token.value}' is not valid`,
|
|
severity: DiagnosticSeverity.Error,
|
|
range: mapRange(token.range)
|
|
});
|
|
break;
|
|
|
|
// no messages for SuggestedValues
|
|
}
|
|
}
|
|
|
|
function getProviderContext(
|
|
documentUri: URI,
|
|
template: WorkflowTemplate,
|
|
root: TemplateToken,
|
|
tokenRange: TokenRange
|
|
): WorkflowContext {
|
|
const {path} = findToken(
|
|
{
|
|
line: tokenRange.start.line - 1,
|
|
character: tokenRange.start.column - 1
|
|
},
|
|
root
|
|
);
|
|
return getWorkflowContext(documentUri, template, path);
|
|
}
|
|
|
|
async function validateExpression(
|
|
diagnostics: Diagnostic[],
|
|
token: BasicExpressionToken,
|
|
allowedContext: string[],
|
|
contextProviderConfig: ContextProviderConfig | undefined,
|
|
workflowContext: WorkflowContext
|
|
) {
|
|
// Validate the expression
|
|
for (const expression of token.originalExpressions || [token]) {
|
|
const {namedContexts, functions} = splitAllowedContext(allowedContext);
|
|
|
|
let expr: Expr | undefined;
|
|
|
|
try {
|
|
const l = new Lexer(expression.expression);
|
|
const lr = l.lex();
|
|
|
|
const p = new Parser(lr.tokens, namedContexts, functions);
|
|
expr = p.parse();
|
|
} catch {
|
|
// Ignore any error here, we should've caught this earlier in the parsing process
|
|
continue;
|
|
}
|
|
|
|
const context = await getContext(namedContexts, contextProviderConfig, workflowContext, Mode.Validation);
|
|
|
|
const e = new ValidationEvaluator(expr, wrapDictionary(context), validatorFunctions);
|
|
e.validate();
|
|
|
|
diagnostics.push(
|
|
...e.errors.map(e => ({
|
|
message: e.message,
|
|
range: mapRange(expression.range),
|
|
severity: e.severity === "error" ? DiagnosticSeverity.Error : DiagnosticSeverity.Warning
|
|
}))
|
|
);
|
|
}
|
|
}
|