This commit is contained in:
StellaOps Bot
2025-11-27 21:10:06 +02:00
parent cfa2274d31
commit 8abbf9574d
106 changed files with 7078 additions and 3197 deletions

View File

@@ -0,0 +1,22 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Diagnostic codes for policy DSL lexing and parsing errors.
/// </summary>
public static class DiagnosticCodes
{
public const string UnexpectedCharacter = "POLICY-DSL-LEX-001";
public const string UnterminatedString = "POLICY-DSL-LEX-002";
public const string InvalidEscapeSequence = "POLICY-DSL-LEX-003";
public const string InvalidNumber = "POLICY-DSL-LEX-004";
public const string UnexpectedToken = "POLICY-DSL-PARSE-001";
public const string DuplicateSection = "POLICY-DSL-PARSE-002";
public const string MissingPolicyHeader = "POLICY-DSL-PARSE-003";
public const string UnsupportedSyntaxVersion = "POLICY-DSL-PARSE-004";
public const string DuplicateRuleName = "POLICY-DSL-PARSE-005";
public const string MissingBecauseClause = "POLICY-DSL-PARSE-006";
public const string MissingTerminator = "POLICY-DSL-PARSE-007";
public const string InvalidAction = "POLICY-DSL-PARSE-008";
public const string InvalidLiteral = "POLICY-DSL-PARSE-009";
public const string UnexpectedSection = "POLICY-DSL-PARSE-010";
}

View File

@@ -0,0 +1,70 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Represents the kind of token in the policy DSL.
/// </summary>
public enum TokenKind
{
EndOfFile = 0,
Identifier,
StringLiteral,
NumberLiteral,
BooleanLiteral,
LeftBrace,
RightBrace,
LeftParen,
RightParen,
LeftBracket,
RightBracket,
Comma,
Semicolon,
Colon,
Arrow, // =>
Assign, // =
Define, // :=
Dot,
KeywordPolicy,
KeywordSyntax,
KeywordMetadata,
KeywordProfile,
KeywordRule,
KeywordMap,
KeywordSource,
KeywordEnv,
KeywordIf,
KeywordThen,
KeywordWhen,
KeywordAnd,
KeywordOr,
KeywordNot,
KeywordPriority,
KeywordElse,
KeywordBecause,
KeywordSettings,
KeywordIgnore,
KeywordUntil,
KeywordEscalate,
KeywordTo,
KeywordRequireVex,
KeywordWarn,
KeywordMessage,
KeywordDefer,
KeywordAnnotate,
KeywordIn,
EqualEqual,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
Unknown,
}
/// <summary>
/// Represents a single token in the policy DSL.
/// </summary>
public readonly record struct DslToken(
TokenKind Kind,
string Text,
SourceSpan Span,
object? Value = null);

View File

@@ -0,0 +1,582 @@
using System.Collections.Immutable;
using System.Globalization;
using System.Text;
using StellaOps.Policy;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Tokenizes policy DSL source code into a stream of tokens.
/// </summary>
public static class DslTokenizer
{
public static TokenizerResult Tokenize(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var tokens = ImmutableArray.CreateBuilder<DslToken>();
var diagnostics = ImmutableArray.CreateBuilder<PolicyIssue>();
var index = 0;
var line = 1;
var column = 1;
while (index < source.Length)
{
var current = source[index];
if (char.IsWhiteSpace(current))
{
(index, line, column) = AdvanceWhitespace(source, index, line, column);
continue;
}
if (current == '/' && index + 1 < source.Length)
{
if (source[index + 1] == '/')
{
(index, line, column) = SkipSingleLineComment(source, index + 2, line, column + 2);
continue;
}
if (source[index + 1] == '*')
{
(index, line, column) = SkipMultiLineComment(source, index + 2, line, column + 2, diagnostics);
continue;
}
}
var startLocation = new SourceLocation(index, line, column);
switch (current)
{
case '{':
tokens.Add(CreateToken(TokenKind.LeftBrace, "{", startLocation, ref index, ref column));
break;
case '}':
tokens.Add(CreateToken(TokenKind.RightBrace, "}", startLocation, ref index, ref column));
break;
case '(':
tokens.Add(CreateToken(TokenKind.LeftParen, "(", startLocation, ref index, ref column));
break;
case ')':
tokens.Add(CreateToken(TokenKind.RightParen, ")", startLocation, ref index, ref column));
break;
case '[':
tokens.Add(CreateToken(TokenKind.LeftBracket, "[", startLocation, ref index, ref column));
break;
case ']':
tokens.Add(CreateToken(TokenKind.RightBracket, "]", startLocation, ref index, ref column));
break;
case ',':
tokens.Add(CreateToken(TokenKind.Comma, ",", startLocation, ref index, ref column));
break;
case ';':
tokens.Add(CreateToken(TokenKind.Semicolon, ";", startLocation, ref index, ref column));
break;
case ':':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.Define, ":=", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.Colon, ":", startLocation, ref index, ref column));
}
break;
}
case '=':
{
if (Match(source, index + 1, '>'))
{
tokens.Add(CreateToken(TokenKind.Arrow, "=>", startLocation, ref index, ref column, advance: 2));
}
else if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.EqualEqual, "==", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.Assign, "=", startLocation, ref index, ref column));
}
break;
}
case '!':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.NotEqual, "!=", startLocation, ref index, ref column, advance: 2));
}
else
{
ReportUnexpectedCharacter(diagnostics, current, startLocation);
index++;
column++;
}
break;
}
case '<':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.LessThanOrEqual, "<=", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.LessThan, "<", startLocation, ref index, ref column));
}
break;
}
case '>':
{
if (Match(source, index + 1, '='))
{
tokens.Add(CreateToken(TokenKind.GreaterThanOrEqual, ">=", startLocation, ref index, ref column, advance: 2));
}
else
{
tokens.Add(CreateToken(TokenKind.GreaterThan, ">", startLocation, ref index, ref column));
}
break;
}
case '.':
tokens.Add(CreateToken(TokenKind.Dot, ".", startLocation, ref index, ref column));
break;
case '"':
TokenizeString(source, ref index, ref line, ref column, startLocation, tokens, diagnostics);
break;
case '+':
case '-':
{
if (index + 1 < source.Length && char.IsDigit(source[index + 1]))
{
TokenizeNumber(source, ref index, ref line, ref column, startLocation, tokens, diagnostics);
}
else
{
ReportUnexpectedCharacter(diagnostics, current, startLocation);
index++;
column++;
}
break;
}
default:
{
if (char.IsDigit(current))
{
TokenizeNumber(source, ref index, ref line, ref column, startLocation, tokens, diagnostics);
}
else if (IsIdentifierStart(current))
{
TokenizeIdentifierOrKeyword(source, ref index, ref line, ref column, startLocation, tokens);
}
else
{
ReportUnexpectedCharacter(diagnostics, current, startLocation);
index++;
column++;
}
break;
}
}
}
var eofLocation = new SourceLocation(index, line, column);
tokens.Add(new DslToken(TokenKind.EndOfFile, string.Empty, new SourceSpan(eofLocation, eofLocation)));
return new TokenizerResult(tokens.ToImmutable(), diagnostics.ToImmutable());
}
private static void TokenizeString(
string source,
ref int index,
ref int line,
ref int column,
SourceLocation start,
ImmutableArray<DslToken>.Builder tokens,
ImmutableArray<PolicyIssue>.Builder diagnostics)
{
var builder = new StringBuilder();
var i = index + 1;
var currentLine = line;
var currentColumn = column + 1;
while (i < source.Length)
{
var ch = source[i];
if (ch == '"')
{
var end = new SourceLocation(i + 1, currentLine, currentColumn + 1);
index = i + 1;
column = currentColumn + 1;
tokens.Add(new DslToken(TokenKind.StringLiteral, builder.ToString(), new SourceSpan(start, end), builder.ToString()));
return;
}
if (ch == '\\')
{
if (i + 1 >= source.Length)
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnterminatedString, "Unterminated string literal.", $"@{start.Line}:{start.Column}"));
index = source.Length;
line = currentLine;
column = currentColumn;
return;
}
var escape = source[i + 1];
switch (escape)
{
case '\\':
builder.Append('\\');
break;
case '"':
builder.Append('"');
break;
case 'n':
builder.Append('\n');
break;
case 'r':
builder.Append('\r');
break;
case 't':
builder.Append('\t');
break;
default:
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.InvalidEscapeSequence, $"Invalid escape sequence '\\{escape}'.", $"@{currentLine}:{currentColumn}"));
builder.Append(escape);
break;
}
i += 2;
currentColumn += 2;
continue;
}
if (ch == '\r' || ch == '\n')
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnterminatedString, "Unterminated string literal.", $"@{start.Line}:{start.Column}"));
(index, line, column) = AdvanceWhitespace(source, i, currentLine, currentColumn);
return;
}
builder.Append(ch);
i++;
currentColumn++;
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnterminatedString, "Unterminated string literal.", $"@{start.Line}:{start.Column}"));
index = source.Length;
line = currentLine;
column = currentColumn;
}
private static void TokenizeNumber(
string source,
ref int index,
ref int line,
ref int column,
SourceLocation start,
ImmutableArray<DslToken>.Builder tokens,
ImmutableArray<PolicyIssue>.Builder diagnostics)
{
var i = index;
var hasDecimal = false;
if (source[i] == '+' || source[i] == '-')
{
i++;
}
while (i < source.Length)
{
var ch = source[i];
if (char.IsDigit(ch))
{
i++;
continue;
}
if (ch == '.')
{
if (hasDecimal)
{
break;
}
hasDecimal = true;
i++;
continue;
}
break;
}
var percent = false;
if (i < source.Length && source[i] == '%')
{
percent = true;
i++;
}
var text = source.Substring(index, i - index);
if (!decimal.TryParse(text, NumberStyles.AllowLeadingSign | NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var value))
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.InvalidNumber, $"Invalid numeric literal '{text}'.", $"@{start.Line}:{start.Column}"));
index = i;
column += i - index;
return;
}
if (percent)
{
value /= 100m;
}
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(TokenKind.NumberLiteral, text, new SourceSpan(start, end), value));
column += i - index;
index = i;
}
private static void TokenizeIdentifierOrKeyword(
string source,
ref int index,
ref int line,
ref int column,
SourceLocation start,
ImmutableArray<DslToken>.Builder tokens)
{
var i = index + 1;
while (i < source.Length && IsIdentifierPart(source[i]))
{
i++;
}
var text = source.Substring(index, i - index);
var kind = GetKeywordKind(text);
if (kind == TokenKind.BooleanLiteral)
{
var value = string.Equals(text, "true", StringComparison.Ordinal);
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(TokenKind.BooleanLiteral, text, new SourceSpan(start, end), value));
}
else if (kind == TokenKind.Identifier)
{
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(TokenKind.Identifier, text, new SourceSpan(start, end)));
}
else
{
var end = new SourceLocation(i, line, column + (i - index));
tokens.Add(new DslToken(kind, text, new SourceSpan(start, end)));
}
column += i - index;
index = i;
}
private static TokenKind GetKeywordKind(string text)
{
return text switch
{
"policy" => TokenKind.KeywordPolicy,
"syntax" => TokenKind.KeywordSyntax,
"metadata" => TokenKind.KeywordMetadata,
"profile" => TokenKind.KeywordProfile,
"rule" => TokenKind.KeywordRule,
"map" => TokenKind.KeywordMap,
"source" => TokenKind.KeywordSource,
"env" => TokenKind.Identifier,
"if" => TokenKind.KeywordIf,
"then" => TokenKind.KeywordThen,
"when" => TokenKind.KeywordWhen,
"and" => TokenKind.KeywordAnd,
"or" => TokenKind.KeywordOr,
"not" => TokenKind.KeywordNot,
"priority" => TokenKind.KeywordPriority,
"else" => TokenKind.KeywordElse,
"because" => TokenKind.KeywordBecause,
"settings" => TokenKind.KeywordSettings,
"ignore" => TokenKind.KeywordIgnore,
"until" => TokenKind.KeywordUntil,
"escalate" => TokenKind.KeywordEscalate,
"to" => TokenKind.KeywordTo,
"requireVex" => TokenKind.KeywordRequireVex,
"warn" => TokenKind.KeywordWarn,
"message" => TokenKind.KeywordMessage,
"defer" => TokenKind.KeywordDefer,
"annotate" => TokenKind.KeywordAnnotate,
"in" => TokenKind.KeywordIn,
"true" => TokenKind.BooleanLiteral,
"false" => TokenKind.BooleanLiteral,
_ => TokenKind.Identifier,
};
}
private static bool IsIdentifierStart(char ch) => char.IsLetter(ch) || ch == '_';
private static bool IsIdentifierPart(char ch) => char.IsLetterOrDigit(ch) || ch == '_' || ch == '-';
private static (int Index, int Line, int Column) AdvanceWhitespace(string source, int index, int line, int column)
{
var i = index;
var currentLine = line;
var currentColumn = column;
while (i < source.Length)
{
var ch = source[i];
if (ch == '\r')
{
if (i + 1 < source.Length && source[i + 1] == '\n')
{
i += 2;
}
else
{
i++;
}
currentLine++;
currentColumn = 1;
continue;
}
if (ch == '\n')
{
i++;
currentLine++;
currentColumn = 1;
continue;
}
if (!char.IsWhiteSpace(ch))
{
break;
}
i++;
currentColumn++;
}
return (i, currentLine, currentColumn);
}
private static (int Index, int Line, int Column) SkipSingleLineComment(string source, int index, int line, int column)
{
var i = index;
var currentLine = line;
var currentColumn = column;
while (i < source.Length)
{
var ch = source[i];
if (ch == '\r' || ch == '\n')
{
return AdvanceWhitespace(source, i, currentLine, currentColumn);
}
i++;
currentColumn++;
}
return (i, currentLine, currentColumn);
}
private static (int Index, int Line, int Column) SkipMultiLineComment(
string source,
int index,
int line,
int column,
ImmutableArray<PolicyIssue>.Builder diagnostics)
{
var i = index;
var currentLine = line;
var currentColumn = column;
while (i < source.Length)
{
var ch = source[i];
if (ch == '*' && i + 1 < source.Length && source[i + 1] == '/')
{
return (i + 2, currentLine, currentColumn + 2);
}
if (ch == '\r')
{
if (i + 1 < source.Length && source[i + 1] == '\n')
{
i += 2;
}
else
{
i++;
}
currentLine++;
currentColumn = 1;
continue;
}
if (ch == '\n')
{
i++;
currentLine++;
currentColumn = 1;
continue;
}
i++;
currentColumn++;
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnexpectedCharacter, "Unterminated comment block.", $"@{line}:{column}"));
return (source.Length, currentLine, currentColumn);
}
private static DslToken CreateToken(
TokenKind kind,
string text,
SourceLocation start,
ref int index,
ref int column,
int advance = 1)
{
var end = new SourceLocation(index + advance, start.Line, start.Column + advance);
index += advance;
column += advance;
return new DslToken(kind, text, new SourceSpan(start, end));
}
private static void ReportUnexpectedCharacter(
ImmutableArray<PolicyIssue>.Builder diagnostics,
char ch,
SourceLocation location)
{
diagnostics.Add(PolicyIssue.Error(
DiagnosticCodes.UnexpectedCharacter,
$"Unexpected character '{ch}'.",
$"@{location.Line}:{location.Column}"));
}
private static bool Match(string source, int index, char expected) =>
index < source.Length && source[index] == expected;
}
/// <summary>
/// Result of tokenizing a policy DSL source.
/// </summary>
public readonly record struct TokenizerResult(
ImmutableArray<DslToken> Tokens,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,174 @@
using System.Collections.Immutable;
using System.Security.Cryptography;
using StellaOps.Policy;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Compiles policy DSL source code into an intermediate representation.
/// </summary>
public sealed class PolicyCompiler
{
public PolicyCompilationResult Compile(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var parseResult = PolicyParser.Parse(source);
if (parseResult.Document is null)
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
if (parseResult.Diagnostics.Any(static issue => issue.Severity == PolicyIssueSeverity.Error))
{
return new PolicyCompilationResult(
Success: false,
Document: null,
Checksum: null,
CanonicalRepresentation: ImmutableArray<byte>.Empty,
Diagnostics: parseResult.Diagnostics);
}
var irDocument = BuildIntermediateRepresentation(parseResult.Document);
var canonical = PolicyIrSerializer.Serialize(irDocument);
var checksum = Convert.ToHexString(SHA256.HashData(canonical.AsSpan())).ToLowerInvariant();
return new PolicyCompilationResult(
Success: true,
Document: irDocument,
Checksum: checksum,
CanonicalRepresentation: canonical,
Diagnostics: parseResult.Diagnostics);
}
private static PolicyIrDocument BuildIntermediateRepresentation(PolicyDocumentNode node)
{
var metadata = node.Metadata
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var settings = node.Settings
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => ToIrLiteral(kvp.Value), StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyIrProfile>(node.Profiles.Length);
foreach (var profile in node.Profiles)
{
var maps = ImmutableArray.CreateBuilder<PolicyIrProfileMap>();
var envs = ImmutableArray.CreateBuilder<PolicyIrProfileEnv>();
var scalars = ImmutableArray.CreateBuilder<PolicyIrProfileScalar>();
foreach (var item in profile.Items)
{
switch (item)
{
case PolicyProfileMapNode map:
maps.Add(new PolicyIrProfileMap(
map.Name,
map.Entries
.Select(entry => new PolicyIrProfileMapEntry(entry.Source, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileEnvNode env:
envs.Add(new PolicyIrProfileEnv(
env.Name,
env.Entries
.Select(entry => new PolicyIrProfileEnvEntry(entry.Condition, entry.Weight))
.ToImmutableArray()));
break;
case PolicyProfileScalarNode scalar:
scalars.Add(new PolicyIrProfileScalar(scalar.Name, ToIrLiteral(scalar.Value)));
break;
}
}
profiles.Add(new PolicyIrProfile(
profile.Name,
maps.ToImmutable(),
envs.ToImmutable(),
scalars.ToImmutable()));
}
var rules = ImmutableArray.CreateBuilder<PolicyIrRule>(node.Rules.Length);
foreach (var rule in node.Rules)
{
var thenActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ThenActions.Length);
foreach (var action in rule.ThenActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
thenActions.Add(converted);
}
}
var elseActions = ImmutableArray.CreateBuilder<PolicyIrAction>(rule.ElseActions.Length);
foreach (var action in rule.ElseActions)
{
var converted = ToIrAction(action);
if (converted is not null)
{
elseActions.Add(converted);
}
}
rules.Add(new PolicyIrRule(
rule.Name,
rule.Priority,
rule.When,
thenActions.ToImmutable(),
elseActions.ToImmutable(),
rule.Because ?? string.Empty));
}
return new PolicyIrDocument(
node.Name,
node.Syntax,
metadata,
profiles.ToImmutable(),
settings,
rules.ToImmutable());
}
private static PolicyIrLiteral ToIrLiteral(PolicyLiteralValue value) => value switch
{
PolicyStringLiteral s => new PolicyIrStringLiteral(s.Value),
PolicyNumberLiteral n => new PolicyIrNumberLiteral(n.Value),
PolicyBooleanLiteral b => new PolicyIrBooleanLiteral(b.Value),
PolicyListLiteral list => new PolicyIrListLiteral(list.Items.Select(ToIrLiteral).ToImmutableArray()),
_ => new PolicyIrStringLiteral(string.Empty),
};
private static PolicyIrAction? ToIrAction(PolicyActionNode action) => action switch
{
PolicyAssignmentActionNode assign => new PolicyIrAssignmentAction(assign.Target.Segments, assign.Value),
PolicyAnnotateActionNode annotate => new PolicyIrAnnotateAction(annotate.Target.Segments, annotate.Value),
PolicyIgnoreActionNode ignore => new PolicyIrIgnoreAction(ignore.Until, ignore.Because),
PolicyEscalateActionNode escalate => new PolicyIrEscalateAction(escalate.To, escalate.When),
PolicyRequireVexActionNode require => new PolicyIrRequireVexAction(
require.Conditions
.OrderBy(static kvp => kvp.Key, StringComparer.Ordinal)
.ToImmutableSortedDictionary(static kvp => kvp.Key, kvp => kvp.Value, StringComparer.Ordinal)),
PolicyWarnActionNode warn => new PolicyIrWarnAction(warn.Message),
PolicyDeferActionNode defer => new PolicyIrDeferAction(defer.Until),
_ => null,
};
}
/// <summary>
/// Result of compiling a policy DSL source.
/// </summary>
public sealed record PolicyCompilationResult(
bool Success,
PolicyIrDocument? Document,
string? Checksum,
ImmutableArray<byte> CanonicalRepresentation,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,213 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Factory for creating policy evaluation engines from compiled policy documents.
/// </summary>
public sealed class PolicyEngineFactory
{
private readonly PolicyCompiler _compiler = new();
/// <summary>
/// Creates a policy engine from source code.
/// </summary>
/// <param name="source">The policy DSL source code.</param>
/// <returns>A policy engine if compilation succeeds, otherwise null with diagnostics.</returns>
public PolicyEngineResult CreateFromSource(string source)
{
var compilation = _compiler.Compile(source);
if (!compilation.Success || compilation.Document is null)
{
return new PolicyEngineResult(null, compilation.Diagnostics);
}
var engine = new PolicyEngine(compilation.Document, compilation.Checksum!);
return new PolicyEngineResult(engine, compilation.Diagnostics);
}
/// <summary>
/// Creates a policy engine from a pre-compiled IR document.
/// </summary>
/// <param name="document">The compiled policy IR document.</param>
/// <param name="checksum">The policy checksum.</param>
/// <returns>A policy engine.</returns>
public PolicyEngine CreateFromDocument(PolicyIrDocument document, string checksum)
{
return new PolicyEngine(document, checksum);
}
}
/// <summary>
/// Result of creating a policy engine.
/// </summary>
public sealed record PolicyEngineResult(
PolicyEngine? Engine,
System.Collections.Immutable.ImmutableArray<StellaOps.Policy.PolicyIssue> Diagnostics);
/// <summary>
/// A lightweight policy evaluation engine.
/// </summary>
public sealed class PolicyEngine
{
internal PolicyEngine(PolicyIrDocument document, string checksum)
{
Document = document;
Checksum = checksum;
}
/// <summary>
/// Gets the compiled policy document.
/// </summary>
public PolicyIrDocument Document { get; }
/// <summary>
/// Gets the policy checksum (SHA-256 of canonical representation).
/// </summary>
public string Checksum { get; }
/// <summary>
/// Gets the policy name.
/// </summary>
public string Name => Document.Name;
/// <summary>
/// Gets the policy syntax version.
/// </summary>
public string Syntax => Document.Syntax;
/// <summary>
/// Gets the number of rules in the policy.
/// </summary>
public int RuleCount => Document.Rules.Length;
/// <summary>
/// Evaluates the policy against the given signal context.
/// </summary>
/// <param name="context">The signal context to evaluate against.</param>
/// <returns>The evaluation result.</returns>
public PolicyEvaluationResult Evaluate(SignalContext context)
{
if (context is null)
{
throw new ArgumentNullException(nameof(context));
}
var matchedRules = new List<string>();
var actions = new List<EvaluatedAction>();
foreach (var rule in Document.Rules.OrderByDescending(r => r.Priority))
{
var matched = EvaluateExpression(rule.When, context);
if (matched)
{
matchedRules.Add(rule.Name);
foreach (var action in rule.ThenActions)
{
actions.Add(new EvaluatedAction(rule.Name, action, WasElseBranch: false));
}
}
else
{
foreach (var action in rule.ElseActions)
{
actions.Add(new EvaluatedAction(rule.Name, action, WasElseBranch: true));
}
}
}
return new PolicyEvaluationResult(
PolicyName: Name,
PolicyChecksum: Checksum,
MatchedRules: matchedRules.ToArray(),
Actions: actions.ToArray());
}
private static bool EvaluateExpression(PolicyExpression expression, SignalContext context)
{
return expression switch
{
PolicyBinaryExpression binary => EvaluateBinary(binary, context),
PolicyUnaryExpression unary => EvaluateUnary(unary, context),
PolicyLiteralExpression literal => literal.Value is bool b && b,
PolicyIdentifierExpression identifier => context.HasSignal(identifier.Name),
PolicyMemberAccessExpression member => EvaluateMemberAccess(member, context),
_ => false,
};
}
private static bool EvaluateBinary(PolicyBinaryExpression binary, SignalContext context)
{
return binary.Operator switch
{
PolicyBinaryOperator.And => EvaluateExpression(binary.Left, context) && EvaluateExpression(binary.Right, context),
PolicyBinaryOperator.Or => EvaluateExpression(binary.Left, context) || EvaluateExpression(binary.Right, context),
PolicyBinaryOperator.Equal => EvaluateEquality(binary.Left, binary.Right, context, negate: false),
PolicyBinaryOperator.NotEqual => EvaluateEquality(binary.Left, binary.Right, context, negate: true),
_ => false,
};
}
private static bool EvaluateUnary(PolicyUnaryExpression unary, SignalContext context)
{
return unary.Operator switch
{
PolicyUnaryOperator.Not => !EvaluateExpression(unary.Operand, context),
_ => false,
};
}
private static bool EvaluateMemberAccess(PolicyMemberAccessExpression member, SignalContext context)
{
var value = ResolveValue(member.Target, context);
if (value is IDictionary<string, object?> dict)
{
return dict.TryGetValue(member.Member, out var v) && v is bool b && b;
}
return false;
}
private static bool EvaluateEquality(PolicyExpression left, PolicyExpression right, SignalContext context, bool negate)
{
var leftValue = ResolveValue(left, context);
var rightValue = ResolveValue(right, context);
var equal = Equals(leftValue, rightValue);
return negate ? !equal : equal;
}
private static object? ResolveValue(PolicyExpression expression, SignalContext context)
{
return expression switch
{
PolicyLiteralExpression literal => literal.Value,
PolicyIdentifierExpression identifier => context.GetSignal(identifier.Name),
PolicyMemberAccessExpression member => ResolveMemberValue(member, context),
_ => null,
};
}
private static object? ResolveMemberValue(PolicyMemberAccessExpression member, SignalContext context)
{
var target = ResolveValue(member.Target, context);
if (target is IDictionary<string, object?> dict)
{
return dict.TryGetValue(member.Member, out var v) ? v : null;
}
return null;
}
}
/// <summary>
/// Result of evaluating a policy.
/// </summary>
public sealed record PolicyEvaluationResult(
string PolicyName,
string PolicyChecksum,
string[] MatchedRules,
EvaluatedAction[] Actions);
/// <summary>
/// An action that was evaluated as part of policy execution.
/// </summary>
public sealed record EvaluatedAction(
string RuleName,
PolicyIrAction Action,
bool WasElseBranch);

View File

@@ -0,0 +1,64 @@
using System.Collections.Immutable;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Intermediate representation of a compiled policy document.
/// </summary>
public sealed record PolicyIrDocument(
string Name,
string Syntax,
ImmutableSortedDictionary<string, PolicyIrLiteral> Metadata,
ImmutableArray<PolicyIrProfile> Profiles,
ImmutableSortedDictionary<string, PolicyIrLiteral> Settings,
ImmutableArray<PolicyIrRule> Rules);
public abstract record PolicyIrLiteral;
public sealed record PolicyIrStringLiteral(string Value) : PolicyIrLiteral;
public sealed record PolicyIrNumberLiteral(decimal Value) : PolicyIrLiteral;
public sealed record PolicyIrBooleanLiteral(bool Value) : PolicyIrLiteral;
public sealed record PolicyIrListLiteral(ImmutableArray<PolicyIrLiteral> Items) : PolicyIrLiteral;
public sealed record PolicyIrProfile(
string Name,
ImmutableArray<PolicyIrProfileMap> Maps,
ImmutableArray<PolicyIrProfileEnv> Environments,
ImmutableArray<PolicyIrProfileScalar> Scalars);
public sealed record PolicyIrProfileMap(string Name, ImmutableArray<PolicyIrProfileMapEntry> Entries);
public sealed record PolicyIrProfileMapEntry(string Source, decimal Weight);
public sealed record PolicyIrProfileEnv(string Name, ImmutableArray<PolicyIrProfileEnvEntry> Entries);
public sealed record PolicyIrProfileEnvEntry(PolicyExpression Condition, decimal Weight);
public sealed record PolicyIrProfileScalar(string Name, PolicyIrLiteral Value);
public sealed record PolicyIrRule(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyIrAction> ThenActions,
ImmutableArray<PolicyIrAction> ElseActions,
string Because);
public abstract record PolicyIrAction;
public sealed record PolicyIrAssignmentAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrAnnotateAction(ImmutableArray<string> Target, PolicyExpression Value) : PolicyIrAction;
public sealed record PolicyIrIgnoreAction(PolicyExpression? Until, string? Because) : PolicyIrAction;
public sealed record PolicyIrEscalateAction(PolicyExpression? To, PolicyExpression? When) : PolicyIrAction;
public sealed record PolicyIrRequireVexAction(ImmutableSortedDictionary<string, PolicyExpression> Conditions) : PolicyIrAction;
public sealed record PolicyIrWarnAction(PolicyExpression? Message) : PolicyIrAction;
public sealed record PolicyIrDeferAction(PolicyExpression? Until) : PolicyIrAction;

View File

@@ -0,0 +1,418 @@
using System.Buffers;
using System.Collections.Immutable;
using System.Text.Json;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Serializes policy IR documents to a canonical JSON representation for hashing.
/// </summary>
public static class PolicyIrSerializer
{
public static ImmutableArray<byte> Serialize(PolicyIrDocument document)
{
var buffer = new ArrayBufferWriter<byte>();
using var writer = new Utf8JsonWriter(buffer, new JsonWriterOptions
{
Indented = false,
SkipValidation = false
});
WriteDocument(writer, document);
writer.Flush();
return buffer.WrittenSpan.ToArray().ToImmutableArray();
}
private static void WriteDocument(Utf8JsonWriter writer, PolicyIrDocument document)
{
writer.WriteStartObject();
writer.WriteString("name", document.Name);
writer.WriteString("syntax", document.Syntax);
writer.WritePropertyName("metadata");
WriteLiteralDictionary(writer, document.Metadata);
writer.WritePropertyName("profiles");
writer.WriteStartArray();
foreach (var profile in document.Profiles)
{
WriteProfile(writer, profile);
}
writer.WriteEndArray();
writer.WritePropertyName("settings");
WriteLiteralDictionary(writer, document.Settings);
writer.WritePropertyName("rules");
writer.WriteStartArray();
foreach (var rule in document.Rules)
{
WriteRule(writer, rule);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteProfile(Utf8JsonWriter writer, PolicyIrProfile profile)
{
writer.WriteStartObject();
writer.WriteString("name", profile.Name);
writer.WritePropertyName("maps");
writer.WriteStartArray();
foreach (var map in profile.Maps)
{
writer.WriteStartObject();
writer.WriteString("name", map.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in map.Entries)
{
writer.WriteStartObject();
writer.WriteString("source", entry.Source);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("env");
writer.WriteStartArray();
foreach (var env in profile.Environments)
{
writer.WriteStartObject();
writer.WriteString("name", env.Name);
writer.WritePropertyName("entries");
writer.WriteStartArray();
foreach (var entry in env.Entries)
{
writer.WriteStartObject();
writer.WritePropertyName("condition");
WriteExpression(writer, entry.Condition);
writer.WriteNumber("weight", entry.Weight);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WritePropertyName("scalars");
writer.WriteStartArray();
foreach (var scalar in profile.Scalars)
{
writer.WriteStartObject();
writer.WriteString("name", scalar.Name);
writer.WritePropertyName("value");
WriteLiteral(writer, scalar.Value);
writer.WriteEndObject();
}
writer.WriteEndArray();
writer.WriteEndObject();
}
private static void WriteRule(Utf8JsonWriter writer, PolicyIrRule rule)
{
writer.WriteStartObject();
writer.WriteString("name", rule.Name);
writer.WriteNumber("priority", rule.Priority);
writer.WritePropertyName("when");
WriteExpression(writer, rule.When);
writer.WritePropertyName("then");
WriteActions(writer, rule.ThenActions);
writer.WritePropertyName("else");
WriteActions(writer, rule.ElseActions);
writer.WriteString("because", rule.Because);
writer.WriteEndObject();
}
private static void WriteActions(Utf8JsonWriter writer, ImmutableArray<PolicyIrAction> actions)
{
writer.WriteStartArray();
foreach (var action in actions)
{
WriteAction(writer, action);
}
writer.WriteEndArray();
}
private static void WriteAction(Utf8JsonWriter writer, PolicyIrAction action)
{
switch (action)
{
case PolicyIrAssignmentAction assign:
writer.WriteStartObject();
writer.WriteString("type", "assign");
WriteReference(writer, assign.Target);
writer.WritePropertyName("value");
WriteExpression(writer, assign.Value);
writer.WriteEndObject();
break;
case PolicyIrAnnotateAction annotate:
writer.WriteStartObject();
writer.WriteString("type", "annotate");
WriteReference(writer, annotate.Target);
writer.WritePropertyName("value");
WriteExpression(writer, annotate.Value);
writer.WriteEndObject();
break;
case PolicyIrIgnoreAction ignore:
writer.WriteStartObject();
writer.WriteString("type", "ignore");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, ignore.Until);
writer.WriteString("because", ignore.Because ?? string.Empty);
writer.WriteEndObject();
break;
case PolicyIrEscalateAction escalate:
writer.WriteStartObject();
writer.WriteString("type", "escalate");
writer.WritePropertyName("to");
WriteOptionalExpression(writer, escalate.To);
writer.WritePropertyName("when");
WriteOptionalExpression(writer, escalate.When);
writer.WriteEndObject();
break;
case PolicyIrRequireVexAction require:
writer.WriteStartObject();
writer.WriteString("type", "requireVex");
writer.WritePropertyName("conditions");
writer.WriteStartObject();
foreach (var kvp in require.Conditions)
{
writer.WritePropertyName(kvp.Key);
WriteExpression(writer, kvp.Value);
}
writer.WriteEndObject();
writer.WriteEndObject();
break;
case PolicyIrWarnAction warn:
writer.WriteStartObject();
writer.WriteString("type", "warn");
writer.WritePropertyName("message");
WriteOptionalExpression(writer, warn.Message);
writer.WriteEndObject();
break;
case PolicyIrDeferAction defer:
writer.WriteStartObject();
writer.WriteString("type", "defer");
writer.WritePropertyName("until");
WriteOptionalExpression(writer, defer.Until);
writer.WriteEndObject();
break;
}
}
private static void WriteReference(Utf8JsonWriter writer, ImmutableArray<string> segments)
{
writer.WritePropertyName("target");
writer.WriteStartArray();
foreach (var segment in segments)
{
writer.WriteStringValue(segment);
}
writer.WriteEndArray();
}
private static void WriteOptionalExpression(Utf8JsonWriter writer, PolicyExpression? expression)
{
if (expression is null)
{
writer.WriteNullValue();
return;
}
WriteExpression(writer, expression);
}
private static void WriteExpression(Utf8JsonWriter writer, PolicyExpression expression)
{
switch (expression)
{
case PolicyLiteralExpression literal:
writer.WriteStartObject();
writer.WriteString("type", "literal");
writer.WritePropertyName("value");
WriteLiteralValue(writer, literal.Value);
writer.WriteEndObject();
break;
case PolicyListExpression list:
writer.WriteStartObject();
writer.WriteString("type", "list");
writer.WritePropertyName("items");
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteExpression(writer, item);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIdentifierExpression identifier:
writer.WriteStartObject();
writer.WriteString("type", "identifier");
writer.WriteString("name", identifier.Name);
writer.WriteEndObject();
break;
case PolicyMemberAccessExpression member:
writer.WriteStartObject();
writer.WriteString("type", "member");
writer.WritePropertyName("target");
WriteExpression(writer, member.Target);
writer.WriteString("member", member.Member);
writer.WriteEndObject();
break;
case PolicyInvocationExpression invocation:
writer.WriteStartObject();
writer.WriteString("type", "call");
writer.WritePropertyName("target");
WriteExpression(writer, invocation.Target);
writer.WritePropertyName("args");
writer.WriteStartArray();
foreach (var arg in invocation.Arguments)
{
WriteExpression(writer, arg);
}
writer.WriteEndArray();
writer.WriteEndObject();
break;
case PolicyIndexerExpression indexer:
writer.WriteStartObject();
writer.WriteString("type", "indexer");
writer.WritePropertyName("target");
WriteExpression(writer, indexer.Target);
writer.WritePropertyName("index");
WriteExpression(writer, indexer.Index);
writer.WriteEndObject();
break;
case PolicyUnaryExpression unary:
writer.WriteStartObject();
writer.WriteString("type", "unary");
writer.WriteString("op", unary.Operator switch
{
PolicyUnaryOperator.Not => "not",
_ => unary.Operator.ToString().ToLowerInvariant(),
});
writer.WritePropertyName("operand");
WriteExpression(writer, unary.Operand);
writer.WriteEndObject();
break;
case PolicyBinaryExpression binary:
writer.WriteStartObject();
writer.WriteString("type", "binary");
writer.WriteString("op", GetBinaryOperator(binary.Operator));
writer.WritePropertyName("left");
WriteExpression(writer, binary.Left);
writer.WritePropertyName("right");
WriteExpression(writer, binary.Right);
writer.WriteEndObject();
break;
default:
writer.WriteStartObject();
writer.WriteString("type", "unknown");
writer.WriteEndObject();
break;
}
}
private static string GetBinaryOperator(PolicyBinaryOperator op) => op switch
{
PolicyBinaryOperator.And => "and",
PolicyBinaryOperator.Or => "or",
PolicyBinaryOperator.Equal => "eq",
PolicyBinaryOperator.NotEqual => "neq",
PolicyBinaryOperator.LessThan => "lt",
PolicyBinaryOperator.LessThanOrEqual => "lte",
PolicyBinaryOperator.GreaterThan => "gt",
PolicyBinaryOperator.GreaterThanOrEqual => "gte",
PolicyBinaryOperator.In => "in",
PolicyBinaryOperator.NotIn => "not_in",
_ => op.ToString().ToLowerInvariant(),
};
private static void WriteLiteralDictionary(Utf8JsonWriter writer, ImmutableSortedDictionary<string, PolicyIrLiteral> dictionary)
{
writer.WriteStartObject();
foreach (var kvp in dictionary)
{
writer.WritePropertyName(kvp.Key);
WriteLiteral(writer, kvp.Value);
}
writer.WriteEndObject();
}
private static void WriteLiteral(Utf8JsonWriter writer, PolicyIrLiteral literal)
{
switch (literal)
{
case PolicyIrStringLiteral s:
writer.WriteStringValue(s.Value);
break;
case PolicyIrNumberLiteral n:
writer.WriteNumberValue(n.Value);
break;
case PolicyIrBooleanLiteral b:
writer.WriteBooleanValue(b.Value);
break;
case PolicyIrListLiteral list:
writer.WriteStartArray();
foreach (var item in list.Items)
{
WriteLiteral(writer, item);
}
writer.WriteEndArray();
break;
default:
writer.WriteNullValue();
break;
}
}
private static void WriteLiteralValue(Utf8JsonWriter writer, object? value)
{
switch (value)
{
case null:
writer.WriteNullValue();
break;
case string s:
writer.WriteStringValue(s);
break;
case bool b:
writer.WriteBooleanValue(b);
break;
case decimal dec:
writer.WriteNumberValue(dec);
break;
case double dbl:
writer.WriteNumberValue(dbl);
break;
case int i:
writer.WriteNumberValue(i);
break;
default:
writer.WriteStringValue(value.ToString());
break;
}
}
}

View File

@@ -0,0 +1,676 @@
using System.Collections.Immutable;
using StellaOps.Policy;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Parses policy DSL source code into an AST.
/// </summary>
public sealed class PolicyParser
{
private readonly ImmutableArray<DslToken> tokens;
private readonly List<PolicyIssue> diagnostics = new();
private int position;
private PolicyParser(ImmutableArray<DslToken> tokens)
{
this.tokens = tokens;
}
public static PolicyParseResult Parse(string source)
{
if (source is null)
{
throw new ArgumentNullException(nameof(source));
}
var tokenization = DslTokenizer.Tokenize(source);
var parser = new PolicyParser(tokenization.Tokens);
var document = parser.ParseDocument();
var allDiagnostics = tokenization.Diagnostics.AddRange(parser.diagnostics).ToImmutableArray();
return new PolicyParseResult(document, allDiagnostics);
}
private PolicyDocumentNode? ParseDocument()
{
if (!Match(TokenKind.KeywordPolicy))
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.MissingPolicyHeader, "Expected 'policy' declaration.", "policy"));
return null;
}
var nameToken = Consume(TokenKind.StringLiteral, "Policy name must be a string literal.", "policy.name");
var name = nameToken.Value as string ?? nameToken.Text;
Consume(TokenKind.KeywordSyntax, "Expected 'syntax' declaration.", "policy.syntax");
var syntaxToken = Consume(TokenKind.StringLiteral, "Policy syntax must be a string literal.", "policy.syntax.value");
var syntax = syntaxToken.Value as string ?? syntaxToken.Text;
Consume(TokenKind.LeftBrace, "Expected '{' to start policy body.", "policy.body");
var metadataBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var settingsBuilder = ImmutableDictionary.CreateBuilder<string, PolicyLiteralValue>(StringComparer.Ordinal);
var profiles = ImmutableArray.CreateBuilder<PolicyProfileNode>();
var rules = ImmutableArray.CreateBuilder<PolicyRuleNode>();
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
{
if (Match(TokenKind.KeywordMetadata))
{
foreach (var kvp in ParseKeyValueBlock("policy.metadata"))
{
metadataBuilder[kvp.Key] = kvp.Value;
}
continue;
}
if (Match(TokenKind.KeywordSettings))
{
foreach (var kvp in ParseKeyValueBlock("policy.settings"))
{
settingsBuilder[kvp.Key] = kvp.Value;
}
continue;
}
if (Match(TokenKind.KeywordProfile))
{
var profile = ParseProfile();
if (profile is not null)
{
profiles.Add(profile);
}
continue;
}
if (Match(TokenKind.KeywordRule))
{
var rule = ParseRule();
if (rule is not null)
{
rules.Add(rule);
}
continue;
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnexpectedSection, $"Unexpected token '{Current.Text}' in policy body.", "policy.body"));
Advance();
}
var close = Consume(TokenKind.RightBrace, "Expected '}' to close policy definition.", "policy");
if (!string.Equals(syntax, "stella-dsl@1", StringComparison.Ordinal))
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnsupportedSyntaxVersion, $"Unsupported syntax '{syntax}'.", "policy.syntax"));
}
var span = new SourceSpan(tokens[0].Span.Start, close.Span.End);
return new PolicyDocumentNode(
name,
syntax,
metadataBuilder.ToImmutable(),
profiles.ToImmutable(),
settingsBuilder.ToImmutable(),
rules.ToImmutable(),
span);
}
private PolicyProfileNode? ParseProfile()
{
var nameToken = Consume(TokenKind.Identifier, "Profile requires a name.", "policy.profile");
var name = nameToken.Text;
Consume(TokenKind.LeftBrace, "Expected '{' after profile declaration.", $"policy.profile.{name}");
var start = nameToken.Span.Start;
var depth = 1;
while (depth > 0 && !IsAtEnd)
{
if (Match(TokenKind.LeftBrace))
{
depth++;
}
else if (Match(TokenKind.RightBrace))
{
depth--;
}
else
{
Advance();
}
}
var close = Previous;
return new PolicyProfileNode(
name,
ImmutableArray<PolicyProfileItemNode>.Empty,
new SourceSpan(start, close.Span.End));
}
private PolicyRuleNode? ParseRule()
{
var nameToken = Consume(TokenKind.Identifier, "Rule requires a name.", "policy.rule");
var name = nameToken.Text;
var priority = 0;
if (Match(TokenKind.KeywordPriority))
{
var priorityToken = Consume(TokenKind.NumberLiteral, "Priority must be numeric.", $"policy.rule.{name}");
if (priorityToken.Value is decimal dec)
{
priority = (int)Math.Round(dec, MidpointRounding.AwayFromZero);
}
}
Consume(TokenKind.LeftBrace, "Expected '{' to start rule.", $"policy.rule.{name}");
Consume(TokenKind.KeywordWhen, "Rule requires a 'when' clause.", $"policy.rule.{name}");
var when = ParseExpression();
Consume(TokenKind.KeywordThen, "Rule requires a 'then' clause.", $"policy.rule.{name}");
var thenActions = ParseActions(name, "then");
var elseActions = ImmutableArray<PolicyActionNode>.Empty;
if (Match(TokenKind.KeywordElse))
{
elseActions = ParseActions(name, "else");
}
string? because = null;
if (Match(TokenKind.KeywordBecause))
{
var becauseToken = Consume(TokenKind.StringLiteral, "Because clause must be string.", $"policy.rule.{name}.because");
because = becauseToken.Value as string ?? becauseToken.Text;
}
var close = Consume(TokenKind.RightBrace, "Expected '}' to close rule.", $"policy.rule.{name}");
if (because is null)
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.MissingBecauseClause, $"Rule '{name}' missing 'because' clause.", $"policy.rule.{name}"));
}
return new PolicyRuleNode(name, priority, when, thenActions, elseActions, because, new SourceSpan(nameToken.Span.Start, close.Span.End));
}
private ImmutableArray<PolicyActionNode> ParseActions(string ruleName, string clause)
{
var actions = ImmutableArray.CreateBuilder<PolicyActionNode>();
while (!Check(TokenKind.RightBrace) && !Check(TokenKind.KeywordElse) && !Check(TokenKind.KeywordBecause) && !IsAtEnd)
{
if (Check(TokenKind.Identifier))
{
actions.Add(ParseAssignmentAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordAnnotate))
{
actions.Add(ParseAnnotateAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordWarn))
{
actions.Add(ParseWarnAction());
continue;
}
if (Match(TokenKind.KeywordEscalate))
{
actions.Add(ParseEscalateAction());
continue;
}
if (Match(TokenKind.KeywordRequireVex))
{
actions.Add(ParseRequireVexAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordIgnore))
{
actions.Add(ParseIgnoreAction(ruleName, clause));
continue;
}
if (Match(TokenKind.KeywordDefer))
{
actions.Add(ParseDeferAction(ruleName, clause));
continue;
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.InvalidAction, $"Unexpected token '{Current.Text}' in {clause} actions.", $"policy.rule.{ruleName}.{clause}"));
Advance();
}
return actions.ToImmutable();
}
private PolicyActionNode ParseAssignmentAction(string ruleName, string clause)
{
var segments = ImmutableArray.CreateBuilder<string>();
var first = Consume(TokenKind.Identifier, "Assignment target must start with identifier.", $"policy.rule.{ruleName}.{clause}");
segments.Add(first.Text);
while (Match(TokenKind.Dot))
{
segments.Add(Consume(TokenKind.Identifier, "Expected identifier after '.'.", $"policy.rule.{ruleName}.{clause}").Text);
}
Consume(TokenKind.Define, "Expected ':=' in action.", $"policy.rule.{ruleName}.{clause}");
var value = ParseExpression();
Match(TokenKind.Semicolon);
return new PolicyAssignmentActionNode(new PolicyReference(segments.ToImmutable(), new SourceSpan(first.Span.Start, value.Span.End)), value, new SourceSpan(first.Span.Start, value.Span.End));
}
private PolicyActionNode ParseAnnotateAction(string ruleName, string clause)
{
var reference = ParseReference($"policy.rule.{ruleName}.{clause}.annotate");
Consume(TokenKind.Define, "Expected ':=' in annotate action.", $"policy.rule.{ruleName}.{clause}.annotate");
var value = ParseExpression();
Match(TokenKind.Semicolon);
return new PolicyAnnotateActionNode(reference, value, new SourceSpan(reference.Span.Start, value.Span.End));
}
private PolicyActionNode ParseWarnAction()
{
PolicyExpression? message = null;
if (Match(TokenKind.KeywordMessage))
{
message = ParseExpression();
}
Match(TokenKind.Semicolon);
var span = message?.Span ?? Previous.Span;
return new PolicyWarnActionNode(message, span);
}
private PolicyActionNode ParseEscalateAction()
{
PolicyExpression? to = null;
PolicyExpression? when = null;
if (Match(TokenKind.KeywordTo))
{
to = ParseExpression();
}
if (Match(TokenKind.KeywordWhen))
{
when = ParseExpression();
}
Match(TokenKind.Semicolon);
var end = when?.Span.End ?? to?.Span.End ?? Previous.Span.End;
return new PolicyEscalateActionNode(to, when, new SourceSpan(Previous.Span.Start, end));
}
private PolicyActionNode ParseRequireVexAction(string ruleName, string clause)
{
Consume(TokenKind.LeftBrace, "Expected '{' after requireVex.", $"policy.rule.{ruleName}.{clause}.requireVex");
var builder = ImmutableDictionary.CreateBuilder<string, PolicyExpression>(StringComparer.Ordinal);
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
{
var key = Consume(TokenKind.Identifier, "requireVex key must be identifier.", $"policy.rule.{ruleName}.{clause}.requireVex").Text;
Consume(TokenKind.Assign, "Expected '=' in requireVex condition.", $"policy.rule.{ruleName}.{clause}.requireVex");
builder[key] = ParseExpression();
Match(TokenKind.Comma);
}
var close = Consume(TokenKind.RightBrace, "Expected '}' to close requireVex block.", $"policy.rule.{ruleName}.{clause}.requireVex");
Match(TokenKind.Semicolon);
return new PolicyRequireVexActionNode(builder.ToImmutable(), new SourceSpan(close.Span.Start, close.Span.End));
}
private PolicyActionNode ParseIgnoreAction(string ruleName, string clause)
{
PolicyExpression? until = null;
string? because = null;
if (Match(TokenKind.KeywordUntil))
{
until = ParseExpression();
}
if (Match(TokenKind.KeywordBecause))
{
var becauseToken = Consume(TokenKind.StringLiteral, "Ignore 'because' must be string.", $"policy.rule.{ruleName}.{clause}.ignore");
because = becauseToken.Value as string ?? becauseToken.Text;
}
Match(TokenKind.Semicolon);
return new PolicyIgnoreActionNode(until, because, new SourceSpan(Previous.Span.Start, (until?.Span.End ?? Previous.Span.End)));
}
private PolicyActionNode ParseDeferAction(string ruleName, string clause)
{
PolicyExpression? until = null;
if (Match(TokenKind.KeywordUntil))
{
until = ParseExpression();
}
Match(TokenKind.Semicolon);
return new PolicyDeferActionNode(until, new SourceSpan(Previous.Span.Start, (until?.Span.End ?? Previous.Span.End)));
}
private PolicyReference ParseReference(string path)
{
var segments = ImmutableArray.CreateBuilder<string>();
var first = Consume(TokenKind.Identifier, "Expected identifier.", path);
segments.Add(first.Text);
while (Match(TokenKind.Dot))
{
segments.Add(Consume(TokenKind.Identifier, "Expected identifier after '.'.", path).Text);
}
return new PolicyReference(segments.ToImmutable(), first.Span);
}
private Dictionary<string, PolicyLiteralValue> ParseKeyValueBlock(string path)
{
Consume(TokenKind.LeftBrace, "Expected '{'.", path);
var entries = new Dictionary<string, PolicyLiteralValue>(StringComparer.Ordinal);
while (!Check(TokenKind.RightBrace) && !IsAtEnd)
{
var key = Consume(TokenKind.Identifier, "Expected identifier.", path).Text;
Consume(TokenKind.Assign, "Expected '='.", path);
entries[key] = ParseLiteralValue(path);
Match(TokenKind.Semicolon);
}
Consume(TokenKind.RightBrace, "Expected '}'.", path);
return entries;
}
private PolicyLiteralValue ParseLiteralValue(string path)
{
if (Match(TokenKind.StringLiteral))
{
return new PolicyStringLiteral(Previous.Value as string ?? Previous.Text, Previous.Span);
}
if (Match(TokenKind.NumberLiteral))
{
return new PolicyNumberLiteral(Previous.Value is decimal dec ? dec : 0m, Previous.Span);
}
if (Match(TokenKind.BooleanLiteral))
{
return new PolicyBooleanLiteral(Previous.Value is bool b && b, Previous.Span);
}
if (Match(TokenKind.LeftBracket))
{
var start = Previous.Span.Start;
var items = ImmutableArray.CreateBuilder<PolicyLiteralValue>();
while (!Check(TokenKind.RightBracket) && !IsAtEnd)
{
items.Add(ParseLiteralValue(path));
Match(TokenKind.Comma);
}
var close = Consume(TokenKind.RightBracket, "Expected ']' in list literal.", path);
return new PolicyListLiteral(items.ToImmutable(), new SourceSpan(start, close.Span.End));
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.InvalidLiteral, "Invalid literal.", path));
return new PolicyStringLiteral(string.Empty, Current.Span);
}
private PolicyExpression ParseExpression() => ParseOr();
private PolicyExpression ParseOr()
{
var expr = ParseAnd();
while (Match(TokenKind.KeywordOr))
{
var right = ParseAnd();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.Or, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
return expr;
}
private PolicyExpression ParseAnd()
{
var expr = ParseEquality();
while (Match(TokenKind.KeywordAnd))
{
var right = ParseEquality();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.And, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
return expr;
}
private PolicyExpression ParseEquality()
{
var expr = ParseUnary();
while (true)
{
if (Match(TokenKind.EqualEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.Equal, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.NotEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.NotEqual, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.KeywordIn))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.In, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.KeywordNot))
{
if (Match(TokenKind.KeywordIn))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.NotIn, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else
{
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnexpectedToken, "Expected 'in' after 'not'.", "expression.not"));
}
}
else if (Match(TokenKind.LessThan))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.LessThan, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.LessThanOrEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.LessThanOrEqual, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.GreaterThan))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.GreaterThan, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else if (Match(TokenKind.GreaterThanOrEqual))
{
var right = ParseUnary();
expr = new PolicyBinaryExpression(expr, PolicyBinaryOperator.GreaterThanOrEqual, right, new SourceSpan(expr.Span.Start, right.Span.End));
}
else
{
break;
}
}
return expr;
}
private PolicyExpression ParseUnary()
{
if (Match(TokenKind.KeywordNot))
{
var operand = ParseUnary();
return new PolicyUnaryExpression(PolicyUnaryOperator.Not, operand, new SourceSpan(Previous.Span.Start, operand.Span.End));
}
return ParsePrimary();
}
private PolicyExpression ParsePrimary()
{
if (Match(TokenKind.StringLiteral))
{
return new PolicyLiteralExpression(Previous.Value as string ?? Previous.Text, Previous.Span);
}
if (Match(TokenKind.NumberLiteral))
{
return new PolicyLiteralExpression(Previous.Value ?? 0m, Previous.Span);
}
if (Match(TokenKind.BooleanLiteral))
{
return new PolicyLiteralExpression(Previous.Value ?? false, Previous.Span);
}
if (Match(TokenKind.LeftBracket))
{
var start = Previous.Span.Start;
var items = ImmutableArray.CreateBuilder<PolicyExpression>();
while (!Check(TokenKind.RightBracket) && !IsAtEnd)
{
items.Add(ParseExpression());
Match(TokenKind.Comma);
}
var close = Consume(TokenKind.RightBracket, "Expected ']' to close list expression.", "expression.list");
return new PolicyListExpression(items.ToImmutable(), new SourceSpan(start, close.Span.End));
}
if (Match(TokenKind.LeftParen))
{
var expr = ParseExpression();
Consume(TokenKind.RightParen, "Expected ')' to close grouped expression.", "expression.group");
return expr;
}
if (Match(TokenKind.Identifier))
{
return ParseIdentifierExpression(Previous);
}
if (Match(TokenKind.KeywordEnv))
{
return ParseIdentifierExpression(Previous);
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnexpectedToken, $"Unexpected token '{Current.Text}' in expression.", "expression"));
var bad = Advance();
return new PolicyLiteralExpression(null, bad.Span);
}
private PolicyExpression ParseIdentifierExpression(DslToken identifier)
{
PolicyExpression expr = new PolicyIdentifierExpression(identifier.Text, identifier.Span);
while (true)
{
if (Match(TokenKind.Dot))
{
var member = ConsumeIdentifier("Expected identifier after '.'.", "expression.member");
expr = new PolicyMemberAccessExpression(expr, member.Text, new SourceSpan(expr.Span.Start, member.Span.End));
continue;
}
if (Match(TokenKind.LeftParen))
{
var args = ImmutableArray.CreateBuilder<PolicyExpression>();
if (!Check(TokenKind.RightParen))
{
do
{
args.Add(ParseExpression());
}
while (Match(TokenKind.Comma));
}
var close = Consume(TokenKind.RightParen, "Expected ')' to close invocation.", "expression.call");
expr = new PolicyInvocationExpression(expr, args.ToImmutable(), new SourceSpan(expr.Span.Start, close.Span.End));
continue;
}
if (Match(TokenKind.LeftBracket))
{
var indexExpr = ParseExpression();
var close = Consume(TokenKind.RightBracket, "Expected ']' to close indexer.", "expression.indexer");
expr = new PolicyIndexerExpression(expr, indexExpr, new SourceSpan(expr.Span.Start, close.Span.End));
continue;
}
break;
}
return expr;
}
private DslToken ConsumeIdentifier(string message, string path)
{
if (Check(TokenKind.Identifier) || IsKeywordIdentifier(Current.Kind))
{
return Advance();
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnexpectedToken, message, path));
return Advance();
}
private static bool IsKeywordIdentifier(TokenKind kind) =>
kind == TokenKind.KeywordSource;
private bool Match(TokenKind kind)
{
if (Check(kind))
{
Advance();
return true;
}
return false;
}
private bool Check(TokenKind kind) => !IsAtEnd && Current.Kind == kind;
private DslToken Consume(TokenKind kind, string message, string path)
{
if (Check(kind))
{
return Advance();
}
diagnostics.Add(PolicyIssue.Error(DiagnosticCodes.UnexpectedToken, message, path));
return Advance();
}
private DslToken Advance()
{
if (!IsAtEnd)
{
position++;
}
return tokens[position - 1];
}
private bool IsAtEnd => Current.Kind == TokenKind.EndOfFile;
private DslToken Current => tokens[position];
private DslToken Previous => tokens[position - 1];
}
/// <summary>
/// Result of parsing a policy DSL source.
/// </summary>
public readonly record struct PolicyParseResult(
PolicyDocumentNode? Document,
ImmutableArray<PolicyIssue> Diagnostics);

View File

@@ -0,0 +1,141 @@
using System.Collections.Immutable;
namespace StellaOps.PolicyDsl;
public abstract record SyntaxNode(SourceSpan Span);
public sealed record PolicyDocumentNode(
string Name,
string Syntax,
ImmutableDictionary<string, PolicyLiteralValue> Metadata,
ImmutableArray<PolicyProfileNode> Profiles,
ImmutableDictionary<string, PolicyLiteralValue> Settings,
ImmutableArray<PolicyRuleNode> Rules,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileNode(
string Name,
ImmutableArray<PolicyProfileItemNode> Items,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyProfileItemNode(SourceSpan Span);
public sealed record PolicyProfileMapNode(
string Name,
ImmutableArray<PolicyProfileMapEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileMapEntryNode(
string Source,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileEnvNode(
string Name,
ImmutableArray<PolicyProfileEnvEntryNode> Entries,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyProfileEnvEntryNode(
PolicyExpression Condition,
decimal Weight,
SourceSpan Span) : SyntaxNode(Span);
public sealed record PolicyProfileScalarNode(
string Name,
PolicyLiteralValue Value,
SourceSpan Span) : PolicyProfileItemNode(Span);
public sealed record PolicyRuleNode(
string Name,
int Priority,
PolicyExpression When,
ImmutableArray<PolicyActionNode> ThenActions,
ImmutableArray<PolicyActionNode> ElseActions,
string? Because,
SourceSpan Span) : SyntaxNode(Span);
public abstract record PolicyActionNode(SourceSpan Span);
public sealed record PolicyAssignmentActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyAnnotateActionNode(
PolicyReference Target,
PolicyExpression Value,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyIgnoreActionNode(
PolicyExpression? Until,
string? Because,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyEscalateActionNode(
PolicyExpression? To,
PolicyExpression? When,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyRequireVexActionNode(
ImmutableDictionary<string, PolicyExpression> Conditions,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyWarnActionNode(
PolicyExpression? Message,
SourceSpan Span) : PolicyActionNode(Span);
public sealed record PolicyDeferActionNode(
PolicyExpression? Until,
SourceSpan Span) : PolicyActionNode(Span);
public abstract record PolicyExpression(SourceSpan Span);
public sealed record PolicyLiteralExpression(object? Value, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyListExpression(ImmutableArray<PolicyExpression> Items, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIdentifierExpression(string Name, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyMemberAccessExpression(PolicyExpression Target, string Member, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyInvocationExpression(PolicyExpression Target, ImmutableArray<PolicyExpression> Arguments, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyIndexerExpression(PolicyExpression Target, PolicyExpression Index, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyUnaryExpression(PolicyUnaryOperator Operator, PolicyExpression Operand, SourceSpan Span) : PolicyExpression(Span);
public sealed record PolicyBinaryExpression(PolicyExpression Left, PolicyBinaryOperator Operator, PolicyExpression Right, SourceSpan Span) : PolicyExpression(Span);
public enum PolicyUnaryOperator
{
Not,
}
public enum PolicyBinaryOperator
{
And,
Or,
Equal,
NotEqual,
LessThan,
LessThanOrEqual,
GreaterThan,
GreaterThanOrEqual,
In,
NotIn,
}
public sealed record PolicyReference(ImmutableArray<string> Segments, SourceSpan Span)
{
public override string ToString() => string.Join(".", Segments);
}
public abstract record PolicyLiteralValue(SourceSpan Span);
public sealed record PolicyStringLiteral(string Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyNumberLiteral(decimal Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyBooleanLiteral(bool Value, SourceSpan Span) : PolicyLiteralValue(Span);
public sealed record PolicyListLiteral(ImmutableArray<PolicyLiteralValue> Items, SourceSpan Span) : PolicyLiteralValue(Span);

View File

@@ -0,0 +1,216 @@
namespace StellaOps.PolicyDsl;
/// <summary>
/// Provides signal values for policy evaluation.
/// </summary>
public sealed class SignalContext
{
private readonly Dictionary<string, object?> _signals;
/// <summary>
/// Creates an empty signal context.
/// </summary>
public SignalContext()
{
_signals = new Dictionary<string, object?>(StringComparer.Ordinal);
}
/// <summary>
/// Creates a signal context with initial values.
/// </summary>
/// <param name="signals">Initial signal values.</param>
public SignalContext(IDictionary<string, object?> signals)
{
_signals = new Dictionary<string, object?>(signals, StringComparer.Ordinal);
}
/// <summary>
/// Gets whether a signal exists.
/// </summary>
/// <param name="name">The signal name.</param>
/// <returns>True if the signal exists.</returns>
public bool HasSignal(string name) => _signals.ContainsKey(name);
/// <summary>
/// Gets a signal value.
/// </summary>
/// <param name="name">The signal name.</param>
/// <returns>The signal value, or null if not found.</returns>
public object? GetSignal(string name) => _signals.TryGetValue(name, out var value) ? value : null;
/// <summary>
/// Gets a signal value as a specific type.
/// </summary>
/// <typeparam name="T">The expected type.</typeparam>
/// <param name="name">The signal name.</param>
/// <returns>The signal value, or default if not found or wrong type.</returns>
public T? GetSignal<T>(string name) => _signals.TryGetValue(name, out var value) && value is T t ? t : default;
/// <summary>
/// Sets a signal value.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The signal value.</param>
/// <returns>This context for chaining.</returns>
public SignalContext SetSignal(string name, object? value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Removes a signal.
/// </summary>
/// <param name="name">The signal name.</param>
/// <returns>This context for chaining.</returns>
public SignalContext RemoveSignal(string name)
{
_signals.Remove(name);
return this;
}
/// <summary>
/// Gets all signal names.
/// </summary>
public IEnumerable<string> SignalNames => _signals.Keys;
/// <summary>
/// Gets all signals as a read-only dictionary.
/// </summary>
public IReadOnlyDictionary<string, object?> Signals => _signals;
/// <summary>
/// Creates a copy of this context.
/// </summary>
/// <returns>A new context with the same signals.</returns>
public SignalContext Clone() => new(_signals);
/// <summary>
/// Creates a signal context builder for fluent construction.
/// </summary>
/// <returns>A new builder.</returns>
public static SignalContextBuilder Builder() => new();
}
/// <summary>
/// Builder for creating signal contexts with fluent API.
/// </summary>
public sealed class SignalContextBuilder
{
private readonly Dictionary<string, object?> _signals = new(StringComparer.Ordinal);
/// <summary>
/// Adds a signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The signal value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithSignal(string name, object? value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a boolean signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The boolean value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithFlag(string name, bool value = true)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a numeric signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The numeric value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithNumber(string name, decimal value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a string signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="value">The string value.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithString(string name, string value)
{
_signals[name] = value;
return this;
}
/// <summary>
/// Adds a nested object signal to the context.
/// </summary>
/// <param name="name">The signal name.</param>
/// <param name="properties">The nested properties.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithObject(string name, IDictionary<string, object?> properties)
{
_signals[name] = new Dictionary<string, object?>(properties, StringComparer.Ordinal);
return this;
}
/// <summary>
/// Adds common finding signals.
/// </summary>
/// <param name="severity">The finding severity (e.g., "critical", "high", "medium", "low").</param>
/// <param name="confidence">The confidence score (0.0 to 1.0).</param>
/// <param name="cveId">Optional CVE identifier.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithFinding(string severity, decimal confidence, string? cveId = null)
{
_signals["finding"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["severity"] = severity,
["confidence"] = confidence,
["cve_id"] = cveId,
};
return this;
}
/// <summary>
/// Adds common reachability signals.
/// </summary>
/// <param name="state">The reachability state (e.g., "reachable", "unreachable", "unknown").</param>
/// <param name="confidence">The confidence score (0.0 to 1.0).</param>
/// <param name="hasRuntimeEvidence">Whether there is runtime evidence.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithReachability(string state, decimal confidence, bool hasRuntimeEvidence = false)
{
_signals["reachability"] = new Dictionary<string, object?>(StringComparer.Ordinal)
{
["state"] = state,
["confidence"] = confidence,
["has_runtime_evidence"] = hasRuntimeEvidence,
};
return this;
}
/// <summary>
/// Adds common trust score signals.
/// </summary>
/// <param name="score">The trust score (0.0 to 1.0).</param>
/// <param name="verified">Whether the source is verified.</param>
/// <returns>This builder for chaining.</returns>
public SignalContextBuilder WithTrustScore(decimal score, bool verified = false)
{
_signals["trust_score"] = score;
_signals["trust_verified"] = verified;
return this;
}
/// <summary>
/// Builds the signal context.
/// </summary>
/// <returns>A new signal context with the configured signals.</returns>
public SignalContext Build() => new(_signals);
}

View File

@@ -0,0 +1,97 @@
using System.Diagnostics.CodeAnalysis;
namespace StellaOps.PolicyDsl;
/// <summary>
/// Represents a precise source location within a policy DSL document.
/// </summary>
public readonly struct SourceLocation : IEquatable<SourceLocation>, IComparable<SourceLocation>
{
public SourceLocation(int offset, int line, int column)
{
if (offset < 0)
{
throw new ArgumentOutOfRangeException(nameof(offset));
}
if (line < 1)
{
throw new ArgumentOutOfRangeException(nameof(line));
}
if (column < 1)
{
throw new ArgumentOutOfRangeException(nameof(column));
}
Offset = offset;
Line = line;
Column = column;
}
public int Offset { get; }
public int Line { get; }
public int Column { get; }
public override string ToString() => $"(L{Line}, C{Column})";
public bool Equals(SourceLocation other) =>
Offset == other.Offset && Line == other.Line && Column == other.Column;
public override bool Equals([NotNullWhen(true)] object? obj) =>
obj is SourceLocation other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Offset, Line, Column);
public int CompareTo(SourceLocation other) => Offset.CompareTo(other.Offset);
public static bool operator ==(SourceLocation left, SourceLocation right) => left.Equals(right);
public static bool operator !=(SourceLocation left, SourceLocation right) => !left.Equals(right);
public static bool operator <(SourceLocation left, SourceLocation right) => left.CompareTo(right) < 0;
public static bool operator <=(SourceLocation left, SourceLocation right) => left.CompareTo(right) <= 0;
public static bool operator >(SourceLocation left, SourceLocation right) => left.CompareTo(right) > 0;
public static bool operator >=(SourceLocation left, SourceLocation right) => left.CompareTo(right) >= 0;
}
/// <summary>
/// Represents a start/end location pair within a policy DSL source document.
/// </summary>
public readonly struct SourceSpan : IEquatable<SourceSpan>
{
public SourceSpan(SourceLocation start, SourceLocation end)
{
if (start.Offset > end.Offset)
{
throw new ArgumentException("Start must not be after end.", nameof(start));
}
Start = start;
End = end;
}
public SourceLocation Start { get; }
public SourceLocation End { get; }
public override string ToString() => $"{Start}->{End}";
public bool Equals(SourceSpan other) => Start.Equals(other.Start) && End.Equals(other.End);
public override bool Equals([NotNullWhen(true)] object? obj) => obj is SourceSpan other && Equals(other);
public override int GetHashCode() => HashCode.Combine(Start, End);
public static SourceSpan Combine(SourceSpan first, SourceSpan second)
{
var start = first.Start <= second.Start ? first.Start : second.Start;
var end = first.End >= second.End ? first.End : second.End;
return new SourceSpan(start, end);
}
}

View File

@@ -0,0 +1,20 @@
<?xml version='1.0' encoding='utf-8'?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<LangVersion>preview</LangVersion>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="../__Libraries/StellaOps.Policy/StellaOps.Policy.csproj" />
</ItemGroup>
<ItemGroup>
<InternalsVisibleTo Include="StellaOps.PolicyDsl.Tests" />
<InternalsVisibleTo Include="StellaOps.Policy.Engine" />
<InternalsVisibleTo Include="StellaOps.Policy.Engine.Tests" />
</ItemGroup>
</Project>