636 lines
23 KiB
C#
636 lines
23 KiB
C#
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
// SPDX-FileCopyrightText: 2025 StellaOps Contributors
|
|
|
|
using FluentAssertions;
|
|
using FsCheck;
|
|
using FsCheck.Fluent;
|
|
using FsCheck.Xunit;
|
|
using StellaOps.PolicyDsl;
|
|
using Xunit;
|
|
|
|
namespace StellaOps.PolicyDsl.Tests.Properties;
|
|
|
|
/// <summary>
|
|
/// Property-based tests for Policy DSL parser roundtrips.
|
|
/// Verifies that parse → print → parse produces equivalent documents.
|
|
/// </summary>
|
|
public sealed class PolicyDslRoundtripPropertyTests
|
|
{
|
|
private readonly PolicyCompiler _compiler = new();
|
|
|
|
/// <summary>
|
|
/// Property: Valid policy sources roundtrip through parse → print → parse.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property ValidPolicy_Roundtrips_ThroughParsePrintParse()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicySource(),
|
|
source =>
|
|
{
|
|
// Parse the original source
|
|
var result1 = _compiler.Compile(source);
|
|
|
|
if (!result1.Success || result1.Document is null)
|
|
{
|
|
return true.ToProperty().Label("Skip: Source doesn't parse cleanly");
|
|
}
|
|
|
|
// Print the document back to source
|
|
var printed = PolicyIrPrinter.Print(result1.Document);
|
|
|
|
// Parse the printed source
|
|
var result2 = _compiler.Compile(printed);
|
|
|
|
// Both should succeed
|
|
if (!result2.Success || result2.Document is null)
|
|
{
|
|
return false.ToProperty().Label($"Roundtrip failed: {string.Join("; ", result2.Diagnostics.Select(d => d.Message))}");
|
|
}
|
|
|
|
// Documents should be semantically equivalent
|
|
var equivalent = AreDocumentsEquivalent(result1.Document, result2.Document);
|
|
|
|
return equivalent
|
|
.ToProperty().Label($"Documents should be equivalent after roundtrip");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property: Policy names are preserved through roundtrip.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property PolicyName_PreservedThroughRoundtrip()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicyName(),
|
|
name =>
|
|
{
|
|
var source = $$"""
|
|
policy "{{name}}" syntax "stella-dsl@1" {
|
|
rule test priority 1 {
|
|
when true
|
|
then severity := "low"
|
|
because "test"
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result1 = _compiler.Compile(source);
|
|
if (!result1.Success || result1.Document is null)
|
|
{
|
|
return true.ToProperty().Label("Skip: Name causes parse failure");
|
|
}
|
|
|
|
var printed = PolicyIrPrinter.Print(result1.Document);
|
|
var result2 = _compiler.Compile(printed);
|
|
|
|
if (!result2.Success || result2.Document is null)
|
|
{
|
|
return false.ToProperty().Label("Roundtrip parse failed");
|
|
}
|
|
|
|
return (result1.Document.Name == result2.Document.Name)
|
|
.ToProperty().Label($"Name should be preserved: '{result1.Document.Name}' vs '{result2.Document.Name}'");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property: Rule count is preserved through roundtrip.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property RuleCount_PreservedThroughRoundtrip()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicyWithRules(),
|
|
source =>
|
|
{
|
|
var result1 = _compiler.Compile(source);
|
|
if (!result1.Success || result1.Document is null)
|
|
{
|
|
return true.ToProperty().Label("Skip: Source doesn't parse");
|
|
}
|
|
|
|
var printed = PolicyIrPrinter.Print(result1.Document);
|
|
var result2 = _compiler.Compile(printed);
|
|
|
|
if (!result2.Success || result2.Document is null)
|
|
{
|
|
return false.ToProperty().Label("Roundtrip parse failed");
|
|
}
|
|
|
|
return (result1.Document.Rules.Length == result2.Document.Rules.Length)
|
|
.ToProperty().Label($"Rule count should be preserved: {result1.Document.Rules.Length} vs {result2.Document.Rules.Length}");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property: Metadata keys are preserved through roundtrip.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property MetadataKeys_PreservedThroughRoundtrip()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicyWithMetadata(),
|
|
source =>
|
|
{
|
|
var result1 = _compiler.Compile(source);
|
|
if (!result1.Success || result1.Document is null)
|
|
{
|
|
return true.ToProperty().Label("Skip: Source doesn't parse");
|
|
}
|
|
|
|
var printed = PolicyIrPrinter.Print(result1.Document);
|
|
var result2 = _compiler.Compile(printed);
|
|
|
|
if (!result2.Success || result2.Document is null)
|
|
{
|
|
return false.ToProperty().Label("Roundtrip parse failed");
|
|
}
|
|
|
|
var keysMatch = result1.Document.Metadata.Keys
|
|
.OrderBy(k => k)
|
|
.SequenceEqual(result2.Document.Metadata.Keys.OrderBy(k => k));
|
|
|
|
return keysMatch
|
|
.ToProperty().Label($"Metadata keys should be preserved");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property: Checksum is stable for identical documents.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property Checksum_StableForIdenticalDocuments()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicySource(),
|
|
source =>
|
|
{
|
|
var result1 = _compiler.Compile(source);
|
|
var result2 = _compiler.Compile(source);
|
|
|
|
if (!result1.Success || !result2.Success)
|
|
{
|
|
return true.ToProperty().Label("Skip: Parse failures");
|
|
}
|
|
|
|
return (result1.Checksum == result2.Checksum)
|
|
.ToProperty().Label($"Checksum should be stable: {result1.Checksum} vs {result2.Checksum}");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sprint 8200.0012.0003: Score-based conditions roundtrip correctly.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property ScoreConditions_RoundtripCorrectly()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicyWithScoreConditions(),
|
|
source =>
|
|
{
|
|
var result1 = _compiler.Compile(source);
|
|
if (!result1.Success || result1.Document is null)
|
|
{
|
|
return true.ToProperty().Label("Skip: Score policy doesn't parse");
|
|
}
|
|
|
|
var printed = PolicyIrPrinter.Print(result1.Document);
|
|
var result2 = _compiler.Compile(printed);
|
|
|
|
if (!result2.Success || result2.Document is null)
|
|
{
|
|
return false.ToProperty().Label($"Score policy roundtrip failed: {string.Join("; ", result2.Diagnostics.Select(d => d.Message))}");
|
|
}
|
|
|
|
return AreDocumentsEquivalent(result1.Document, result2.Document)
|
|
.ToProperty().Label("Score policy documents should be equivalent after roundtrip");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sprint 8200.0012.0003: Each score condition type parses successfully.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property IndividualScoreConditions_ParseSuccessfully()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ScoreCondition(),
|
|
condition =>
|
|
{
|
|
var source = $$"""
|
|
policy "ScoreTest" syntax "stella-dsl@1" {
|
|
rule test priority 1 {
|
|
when {{condition}}
|
|
then severity := "high"
|
|
because "Score condition test"
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result = _compiler.Compile(source);
|
|
|
|
return (result.Success && result.Document is not null)
|
|
.ToProperty().Label($"Score condition '{condition}' should parse successfully");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sprint 8200.0012.0003: Score expression structure preserved through roundtrip.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property ScoreExpressionStructure_PreservedThroughRoundtrip()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ScoreCondition(),
|
|
condition =>
|
|
{
|
|
var source = $$"""
|
|
policy "ScoreTest" syntax "stella-dsl@1" {
|
|
rule test priority 1 {
|
|
when {{condition}}
|
|
then severity := "high"
|
|
because "Score test"
|
|
}
|
|
}
|
|
""";
|
|
|
|
var result1 = _compiler.Compile(source);
|
|
if (!result1.Success || result1.Document is null)
|
|
{
|
|
return true.ToProperty().Label($"Skip: Condition '{condition}' doesn't parse");
|
|
}
|
|
|
|
var printed = PolicyIrPrinter.Print(result1.Document);
|
|
var result2 = _compiler.Compile(printed);
|
|
|
|
if (!result2.Success || result2.Document is null)
|
|
{
|
|
return false.ToProperty().Label($"Roundtrip failed for '{condition}'");
|
|
}
|
|
|
|
// Verify rule count matches
|
|
return (result1.Document.Rules.Length == result2.Document.Rules.Length)
|
|
.ToProperty().Label($"Rule count preserved for condition '{condition}'");
|
|
});
|
|
}
|
|
|
|
/// <summary>
|
|
/// Property: Different policies produce different checksums.
|
|
/// </summary>
|
|
[Property(MaxTest = 50)]
|
|
public Property DifferentPolicies_ProduceDifferentChecksums()
|
|
{
|
|
return Prop.ForAll(
|
|
PolicyDslArbs.ValidPolicySource(),
|
|
PolicyDslArbs.ValidPolicySource(),
|
|
(source1, source2) =>
|
|
{
|
|
var result1 = _compiler.Compile(source1);
|
|
var result2 = _compiler.Compile(source2);
|
|
|
|
if (!result1.Success || !result2.Success)
|
|
{
|
|
return true.ToProperty().Label("Skip: Parse failures");
|
|
}
|
|
|
|
// If sources are identical, checksums should match
|
|
if (source1 == source2)
|
|
{
|
|
return (result1.Checksum == result2.Checksum)
|
|
.ToProperty().Label("Identical sources should have same checksum");
|
|
}
|
|
|
|
// Different sources may produce different checksums (not guaranteed if semantically equal)
|
|
return true.ToProperty().Label("Different sources checked");
|
|
});
|
|
}
|
|
|
|
private static bool AreDocumentsEquivalent(PolicyIrDocument doc1, PolicyIrDocument doc2)
|
|
{
|
|
if (doc1.Name != doc2.Name) return false;
|
|
if (doc1.Syntax != doc2.Syntax) return false;
|
|
if (doc1.Rules.Length != doc2.Rules.Length) return false;
|
|
if (doc1.Metadata.Count != doc2.Metadata.Count) return false;
|
|
|
|
// Check metadata keys (values may have different literal representations)
|
|
if (!doc1.Metadata.Keys.SequenceEqual(doc2.Metadata.Keys)) return false;
|
|
|
|
// Check rules (by name and priority)
|
|
var rules1 = doc1.Rules.OrderBy(r => r.Name).ToList();
|
|
var rules2 = doc2.Rules.OrderBy(r => r.Name).ToList();
|
|
|
|
for (var i = 0; i < rules1.Count; i++)
|
|
{
|
|
if (rules1[i].Name != rules2[i].Name) return false;
|
|
if (rules1[i].Priority != rules2[i].Priority) return false;
|
|
}
|
|
|
|
return true;
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Custom FsCheck arbitraries for Policy DSL types.
|
|
/// </summary>
|
|
internal static class PolicyDslArbs
|
|
{
|
|
private static readonly string[] ValidIdentifiers =
|
|
[
|
|
"test", "production", "staging", "baseline", "strict",
|
|
"high_priority", "low_risk", "critical_only", "vex_aware"
|
|
];
|
|
|
|
private static readonly string[] ValidPriorities = ["1", "2", "3", "4", "5", "10", "100"];
|
|
|
|
private static readonly string[] ValidConditions =
|
|
[
|
|
"true",
|
|
"severity == \"critical\"",
|
|
"severity == \"high\"",
|
|
"severity == \"low\"",
|
|
"status == \"blocked\""
|
|
];
|
|
|
|
// Sprint 8200.0012.0003: Score-based conditions for EWS integration
|
|
private static readonly string[] ScoreConditions =
|
|
[
|
|
"score >= 70",
|
|
"score > 80",
|
|
"score <= 50",
|
|
"score < 40",
|
|
"score == 75",
|
|
"score.is_act_now",
|
|
"score.is_schedule_next",
|
|
"score.is_investigate",
|
|
"score.is_watchlist",
|
|
"score.bucket == \"ActNow\"",
|
|
"score.rch > 0.8",
|
|
"score.xpl > 0.7",
|
|
"score.has_flag(\"kev\")",
|
|
"score.has_flag(\"live-signal\")",
|
|
"score.between(60, 80)",
|
|
"score >= 70 and score.is_schedule_next",
|
|
"score > 80 or score.has_flag(\"kev\")",
|
|
"score.rch > 0.8 and score.xpl > 0.7"
|
|
];
|
|
|
|
private static readonly string[] ValidActions =
|
|
[
|
|
"severity := \"info\"",
|
|
"severity := \"low\"",
|
|
"severity := \"medium\"",
|
|
"severity := \"high\"",
|
|
"severity := \"critical\""
|
|
];
|
|
|
|
public static Arbitrary<string> ValidPolicyName() =>
|
|
Arb.From(Gen.Elements(
|
|
"Test Policy",
|
|
"Production Baseline",
|
|
"Staging Rules",
|
|
"Critical Only Policy",
|
|
"VEX-Aware Evaluation"
|
|
));
|
|
|
|
public static Arbitrary<string> ValidPolicySource() =>
|
|
Arb.From(
|
|
from name in Gen.Elements(ValidIdentifiers)
|
|
from ruleCount in Gen.Choose(1, 3)
|
|
from rules in Gen.ArrayOf<string>(GenRule(), ruleCount)
|
|
select BuildPolicy(name, rules));
|
|
|
|
public static Arbitrary<string> ValidPolicyWithRules() =>
|
|
Arb.From(
|
|
from name in Gen.Elements(ValidIdentifiers)
|
|
from ruleCount in Gen.Choose(1, 5)
|
|
from rules in Gen.ArrayOf<string>(GenRule(), ruleCount)
|
|
select BuildPolicy(name, rules));
|
|
|
|
public static Arbitrary<string> ValidPolicyWithMetadata() =>
|
|
Arb.From(
|
|
from name in Gen.Elements(ValidIdentifiers)
|
|
from hasVersion in ArbMap.Default.GeneratorFor<bool>()
|
|
from hasAuthor in ArbMap.Default.GeneratorFor<bool>()
|
|
from rules in Gen.ArrayOf<string>(GenRule(), 1)
|
|
select BuildPolicyWithMetadata(name, hasVersion, hasAuthor, rules));
|
|
|
|
/// <summary>
|
|
/// Sprint 8200.0012.0003: Generates policies with score-based conditions.
|
|
/// </summary>
|
|
public static Arbitrary<string> ValidPolicyWithScoreConditions() =>
|
|
Arb.From(
|
|
from name in Gen.Elements(ValidIdentifiers)
|
|
from ruleCount in Gen.Choose(1, 3)
|
|
from rules in Gen.ArrayOf<string>(GenScoreRule(), ruleCount)
|
|
select BuildPolicy(name, rules));
|
|
|
|
/// <summary>
|
|
/// Sprint 8200.0012.0003: Generates a specific score condition for targeted testing.
|
|
/// </summary>
|
|
public static Arbitrary<string> ScoreCondition() =>
|
|
Arb.From(Gen.Elements(ScoreConditions));
|
|
|
|
private static Gen<string> GenRule()
|
|
{
|
|
return from nameIndex in Gen.Choose(0, ValidIdentifiers.Length - 1)
|
|
from priorityIndex in Gen.Choose(0, ValidPriorities.Length - 1)
|
|
from conditionIndex in Gen.Choose(0, ValidConditions.Length - 1)
|
|
from actionIndex in Gen.Choose(0, ValidActions.Length - 1)
|
|
let name = ValidIdentifiers[nameIndex]
|
|
let priority = ValidPriorities[priorityIndex]
|
|
let condition = ValidConditions[conditionIndex]
|
|
let action = ValidActions[actionIndex]
|
|
select $$"""
|
|
rule {{name}} priority {{priority}} {
|
|
when {{condition}}
|
|
then {{action}}
|
|
because "Generated test rule"
|
|
}
|
|
""";
|
|
}
|
|
|
|
/// <summary>
|
|
/// Sprint 8200.0012.0003: Generates rules with score-based conditions.
|
|
/// </summary>
|
|
private static Gen<string> GenScoreRule()
|
|
{
|
|
return from nameIndex in Gen.Choose(0, ValidIdentifiers.Length - 1)
|
|
from priorityIndex in Gen.Choose(0, ValidPriorities.Length - 1)
|
|
from conditionIndex in Gen.Choose(0, ScoreConditions.Length - 1)
|
|
from actionIndex in Gen.Choose(0, ValidActions.Length - 1)
|
|
let name = ValidIdentifiers[nameIndex]
|
|
let priority = ValidPriorities[priorityIndex]
|
|
let condition = ScoreConditions[conditionIndex]
|
|
let action = ValidActions[actionIndex]
|
|
select $$"""
|
|
rule {{name}} priority {{priority}} {
|
|
when {{condition}}
|
|
then {{action}}
|
|
because "Score-based rule"
|
|
}
|
|
""";
|
|
}
|
|
|
|
private static string BuildPolicy(string name, string[] rules)
|
|
{
|
|
var rulesText = string.Join("\n", rules);
|
|
return $$"""
|
|
policy "{{name}}" syntax "stella-dsl@1" {
|
|
{{rulesText}}
|
|
}
|
|
""";
|
|
}
|
|
|
|
private static string BuildPolicyWithMetadata(string name, bool hasVersion, bool hasAuthor, string[] rules)
|
|
{
|
|
var metadataLines = new List<string>();
|
|
if (hasVersion) metadataLines.Add(" version = \"1.0.0\"");
|
|
if (hasAuthor) metadataLines.Add(" author = \"test\"");
|
|
|
|
var metadata = metadataLines.Count > 0
|
|
? $$"""
|
|
metadata {
|
|
{{string.Join("\n", metadataLines)}}
|
|
}
|
|
"""
|
|
: "";
|
|
|
|
var rulesText = string.Join("\n", rules);
|
|
|
|
return $$"""
|
|
policy "{{name}}" syntax "stella-dsl@1" {
|
|
{{metadata}}
|
|
{{rulesText}}
|
|
}
|
|
""";
|
|
}
|
|
}
|
|
|
|
/// <summary>
|
|
/// Policy IR printer for roundtrip testing.
|
|
/// Generates DSL source from PolicyIrDocument.
|
|
/// </summary>
|
|
internal static class PolicyIrPrinter
|
|
{
|
|
public static string Print(PolicyIrDocument document)
|
|
{
|
|
var sb = new System.Text.StringBuilder();
|
|
|
|
sb.AppendLine($"policy \"{document.Name}\" syntax \"{document.Syntax}\" {{");
|
|
|
|
// Print metadata
|
|
if (document.Metadata.Count > 0)
|
|
{
|
|
sb.AppendLine(" metadata {");
|
|
foreach (var kvp in document.Metadata)
|
|
{
|
|
var valueStr = PrintLiteral(kvp.Value);
|
|
sb.AppendLine($" {kvp.Key} = {valueStr}");
|
|
}
|
|
sb.AppendLine(" }");
|
|
}
|
|
|
|
// Print profiles
|
|
foreach (var profile in document.Profiles)
|
|
{
|
|
sb.AppendLine($" profile {profile.Name} {{");
|
|
foreach (var scalar in profile.Scalars)
|
|
{
|
|
sb.AppendLine($" {scalar.Name} = {PrintLiteral(scalar.Value)}");
|
|
}
|
|
sb.AppendLine(" }");
|
|
}
|
|
|
|
// Print rules
|
|
foreach (var rule in document.Rules)
|
|
{
|
|
sb.AppendLine($" rule {rule.Name} priority {rule.Priority} {{");
|
|
sb.AppendLine($" when {PrintExpression(rule.When)}");
|
|
sb.AppendLine(" then");
|
|
foreach (var action in rule.ThenActions)
|
|
{
|
|
sb.AppendLine($" {PrintAction(action)}");
|
|
}
|
|
sb.AppendLine($" because \"{EscapeString(rule.Because)}\"");
|
|
sb.AppendLine(" }");
|
|
}
|
|
|
|
sb.AppendLine("}");
|
|
|
|
return sb.ToString();
|
|
}
|
|
|
|
private static string PrintLiteral(PolicyIrLiteral literal) => literal switch
|
|
{
|
|
PolicyIrStringLiteral s => $"\"{EscapeString(s.Value)}\"",
|
|
PolicyIrNumberLiteral n => n.Value.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
PolicyIrBooleanLiteral b => b.Value ? "true" : "false",
|
|
PolicyIrListLiteral list => $"[{string.Join(", ", list.Items.Select(PrintLiteral))}]",
|
|
_ => "null"
|
|
};
|
|
|
|
private static string PrintExpression(PolicyExpression expr) => expr switch
|
|
{
|
|
PolicyLiteralExpression lit => PrintExprLiteral(lit.Value),
|
|
PolicyIdentifierExpression id => id.Name,
|
|
PolicyBinaryExpression bin => $"{PrintExpression(bin.Left)} {PrintBinaryOp(bin.Operator)} {PrintExpression(bin.Right)}",
|
|
PolicyUnaryExpression un => $"{PrintUnaryOp(un.Operator)}{PrintExpression(un.Operand)}",
|
|
PolicyMemberAccessExpression mem => $"{PrintExpression(mem.Target)}.{mem.Member}",
|
|
PolicyInvocationExpression call => $"{PrintExpression(call.Target)}({string.Join(", ", call.Arguments.Select(PrintExpression))})",
|
|
PolicyListExpression list => $"[{string.Join(", ", list.Items.Select(PrintExpression))}]",
|
|
PolicyIndexerExpression idx => $"{PrintExpression(idx.Target)}[{PrintExpression(idx.Index)}]",
|
|
_ => "true"
|
|
};
|
|
|
|
private static string PrintBinaryOp(PolicyBinaryOperator op) => op switch
|
|
{
|
|
PolicyBinaryOperator.And => "and",
|
|
PolicyBinaryOperator.Or => "or",
|
|
PolicyBinaryOperator.Equal => "==",
|
|
PolicyBinaryOperator.NotEqual => "!=",
|
|
PolicyBinaryOperator.LessThan => "<",
|
|
PolicyBinaryOperator.LessThanOrEqual => "<=",
|
|
PolicyBinaryOperator.GreaterThan => ">",
|
|
PolicyBinaryOperator.GreaterThanOrEqual => ">=",
|
|
PolicyBinaryOperator.In => "in",
|
|
PolicyBinaryOperator.NotIn => "not in",
|
|
_ => "=="
|
|
};
|
|
|
|
private static string PrintUnaryOp(PolicyUnaryOperator op) => op switch
|
|
{
|
|
PolicyUnaryOperator.Not => "not ",
|
|
_ => ""
|
|
};
|
|
|
|
private static string PrintExprLiteral(object? value) => value switch
|
|
{
|
|
string s => $"\"{EscapeString(s)}\"",
|
|
bool b => b ? "true" : "false",
|
|
decimal d => d.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
int i => i.ToString(),
|
|
double dbl => dbl.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
|
null => "null",
|
|
_ => value.ToString() ?? "null"
|
|
};
|
|
|
|
private static string PrintAction(PolicyIrAction action) => action switch
|
|
{
|
|
PolicyIrAssignmentAction assign => $"{string.Join(".", assign.Target)} := {PrintExpression(assign.Value)}",
|
|
PolicyIrAnnotateAction annotate => $"annotate {string.Join(".", annotate.Target)} with {PrintExpression(annotate.Value)}",
|
|
PolicyIrIgnoreAction ignore => ignore.Because is not null ? $"ignore because \"{EscapeString(ignore.Because)}\"" : "ignore",
|
|
PolicyIrEscalateAction escalate => "escalate",
|
|
PolicyIrRequireVexAction vex => "require vex",
|
|
PolicyIrWarnAction warn => "warn",
|
|
PolicyIrDeferAction defer => "defer",
|
|
_ => "// unknown action"
|
|
};
|
|
|
|
private static string EscapeString(string s)
|
|
{
|
|
return s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r");
|
|
}
|
|
}
|