Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,10 @@ jobs:
dotnet-version: 8.0.x
global-json-file: global.json

# Locked mode: fail if the resolved graph differs from the committed packages.lock.json, so a
# dependency cannot change between a green PR and the merge without the lockfile changing too.
- name: Restore
run: dotnet restore RuleCraft.slnx
run: dotnet restore RuleCraft.slnx --locked-mode

# The whole solution, so a break in the sample is a build failure and not a surprise later.
- name: Build
Expand Down
17 changes: 13 additions & 4 deletions .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -28,10 +28,19 @@ jobs:
uses: github/codeql-action/init@v4
with:
languages: csharp
# Buildless: CodeQL reads the sources directly, so no SDK setup and no second Release
# build. Switch to `manual` (plus a setup-dotnet + dotnet build step) if analysis of the
# generated/compiled surface ever turns out to need a real build.
build-mode: none
# Traced build (not buildless): this library's whole point is the code it compiles and
# runs, so CodeQL sees more with a real build's dataflow than by reading sources alone.
build-mode: manual

# Both inputs on purpose, same as ci.yml: setup-dotnet installs each, global.json picks the SDK.
- name: Set up .NET
uses: actions/setup-dotnet@v6
with:
dotnet-version: 8.0.x
global-json-file: global.json

- name: Build
run: dotnet build RuleCraft.slnx --configuration Release

- name: Analyze
uses: github/codeql-action/analyze@v4
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ the load-context handling are the design — not incidental plumbing.

```
src/RuleCraft/ the library, shipped as a single DLL
tests/RuleCraft.Tests/ xunit suite (151 tests, no network, no API key)
tests/RuleCraft.Tests/ xunit suite (173 tests, no network, no API key)
samples/RuleCraft.Sample/ ASP.NET Core minimal API + review console
```

Expand Down
5 changes: 5 additions & 0 deletions Directory.Build.props
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,10 @@
<!-- Off by default: only the shipped library owes its consumers IntelliSense.
RuleCraft.csproj turns it on for itself. -->
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!-- A warning in CI is a warning ignored; fail on it. The tree builds clean today. -->
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<!-- Pin the restore graph: CI restores in locked mode, so a dependency cannot change
underfoot between a green PR and the merge. Regenerate with `dotnet restore` after a bump. -->
<RestorePackagesWithLockFile>true</RestorePackagesWithLockFile>
</PropertyGroup>
</Project>
32 changes: 22 additions & 10 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -205,10 +205,11 @@ The engine is thread-safe and meant to be a singleton: resolution is lock-free,
removing rules swaps an immutable snapshot, and mutations of one rule are serialized — an
`Approve` racing a `Reject` cannot leave a rule live but recorded as rejected.

**Only the two generation methods are `async`**, because only they do I/O (the call to the LLM).
Compiling, parsing, testing and approving are CPU-bound and run on the calling thread: an `Approve`
costs a Roslyn compile, and the API says so rather than hiding it behind a `Task` that was never
asynchronous. Wrap those calls in `Task.Run` if a request thread must not block.
**Only rule generation is truly `async`** — it does I/O (the call to the LLM). Compiling, parsing,
testing and approving are CPU-bound and run on the calling thread: an `Approve` costs a Roslyn
compile, and the API says so rather than hiding it behind a `Task` that was never asynchronous. When
a request thread must not block, use the offload wrappers — `AddRuleFromSourceAsync`, `ApproveAsync`,
`EnableAsync` — which run that work on `Task.Run` for you.

`Dispose()` unloads every rule assembly the engine loaded — worth doing if you build engines per
scope (a test suite, say); a singleton normally lives as long as the process.
Expand All @@ -222,7 +223,13 @@ scope (a test suite, say); a singleton normally lives as long as the process.
| **Static** — a class in your repo | `AddStaticRule(new BulkOrderRule())` | already compiled | no | no — git is the gate | re-register at startup |

All three land in the same registry and compete purely by priority — the dispatcher cannot tell
them apart, and `GetRules()` lists them side by side.
them apart, and `GetRules()` lists them side by side. `GetRule(id)` fetches one.

**Where rules live.** By default, a `rules/` folder on disk (`StorePath`), reloaded at startup by
`ReloadFromStore()`. If several instances of your app should share one set of rules — approve on one,
run it on the others — point `RuleEngineOptions.Store` at your own `IRuleStore` (backed by a database,
say) instead of the default file store. Each instance picks up the others' changes on its next
`ReloadFromStore()`.

A **static** rule is just a class implementing `IRule<TContract, TContext>`:

Expand Down Expand Up @@ -461,8 +468,8 @@ concepts. Depth, size and node count are bounded, so a rule cannot burn CPU on e
**Compiled C# rules are not.** .NET has **no in-process sandbox**: approved rule code runs with
the full permissions of your process. The security gate (reference whitelist + semantic-model
analyzer banning `System.IO`, `System.Net`, `System.Reflection`, `System.Diagnostics`, interop,
threading, `Activator`, `Environment`, `unsafe`, `dynamic`, preprocessor directives, …) is a
**guardrail and review aid, not a sandbox**.
threading, `Activator`, `Environment`, `AppContext`, `unsafe`, `dynamic`, preprocessor directives, …)
is a **guardrail and review aid, not a sandbox**.

The policy resolves most-specific-first — member, then type, then namespace — so it can hand out
`System.Threading.Tasks` (an async contract cannot be implemented without naming `Task`) while
Expand All @@ -479,7 +486,10 @@ are reasons the human approval step is not decoration.
need it — so a catastrophically backtracking pattern in `AppliesTo` is a ReDoS on your hot path,
triggered by whatever data hits it. The test harness times out a candidate that hangs *during
validation*; it cannot help once the rule is live. Review regexes in rule code the way you would
in your own.
in your own. The engine guards its *own* calls into a rule (`AppliesTo`, `Priority`), but the
method you invoke on the resolved implementation runs on your thread with no timeout — wrap it in
`RuleExecution.TryInvoke(() => rule.GetDiscount(order), timeout, out var result, out _)` when a
request must not block on it.
- **Kill the process by recursing.** `StackOverflowException` cannot be caught in .NET. The
`try`/`catch` around every predicate makes a *throwing* rule harmless; a rule that recurses
without a base case takes the process down regardless, and no in-process gate can change that.
Expand Down Expand Up @@ -513,7 +523,9 @@ could equally well deploy code to the box.
- **One `StorePath` per engine.** The default is a `rules/` folder relative to the process, so two
engines over different contracts land in the same one. Each records the contract its rules were
written against, ignores the other's, and logs an error at reload rather than quarantining what
is not its own — but the folder is still shared, and you should not rely on that politeness.
is not its own — but the folder is still shared, and you should not rely on that politeness. To
share one rule set across app instances on purpose, use a custom `IRuleStore` rather than the
default file store.
- `Expression.Compile` is used for JSON field access, so a JSON-rule engine will not survive full
AOT either.
- Source files on disk are hashed; a tampered file is refused at approval and quarantined on
Expand All @@ -528,7 +540,7 @@ could equally well deploy code to the box.
src/RuleCraft/ the library (single DLL): engine, JSON-DSL parser/interpreter,
Roslyn compiler, ALC loading, security analyzer, test harness,
store, LLM generation
tests/RuleCraft.Tests/ xunit suite (151 tests, no network needed)
tests/RuleCraft.Tests/ xunit suite (173 tests, no network needed)
samples/RuleCraft.Sample/ ASP.NET Core minimal API demo + review console
```

Expand Down
65 changes: 65 additions & 0 deletions samples/RuleCraft.Sample/packages.lock.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
{
"version": 1,
"dependencies": {
"net10.0": {
"Anthropic": {
"type": "Direct",
"requested": "[12.36.0, )",
"resolved": "12.36.0",
"contentHash": "xkpZhBg5hJPYfa5smXbtOki8+6RoHn1uikSSrSJPviAlaD0Ac09md0adC9OBZb8xbszQ6J4KSI//ndP95S2zAg==",
"dependencies": {
"Microsoft.Extensions.AI.Abstractions": "10.5.1"
}
},
"Microsoft.Extensions.AI": {
"type": "Direct",
"requested": "[10.8.0, )",
"resolved": "10.8.0",
"contentHash": "QfthL8e2X/Z6jRs4YyVS2+HTApJvhz725l35HYZ9cfwlEuQnpzY89NJoGNmpuNZn4LIvvQOQeZIbu3jggBXgUg==",
"dependencies": {
"Microsoft.Extensions.AI.Abstractions": "10.8.0",
"System.Numerics.Tensors": "10.0.10"
}
},
"Microsoft.CodeAnalysis.Analyzers": {
"type": "Transitive",
"resolved": "5.3.0",
"contentHash": "KuLhbZwB0L8JikL86AE5VWEp3RLNjIcp+j8yz9EJ/UBgRz4+qDEjHg/tluRFbpYpD/e37BqaaNFbQ0vqawBwWQ=="
},
"Microsoft.CodeAnalysis.Common": {
"type": "Transitive",
"resolved": "5.6.0",
"contentHash": "eWYNB5e92PSdkQ0xcmy2aLtrvBXNydnVi0Hj/VjaAely6XBqA3By+ClGAJaj4d16pzQmrXPLLK9RDVuS1Ec9xQ==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "5.3.0"
}
},
"Microsoft.CodeAnalysis.CSharp": {
"type": "Transitive",
"resolved": "5.6.0",
"contentHash": "r1DrKQ/L0xTw03wJrLr36AMQNslyaeEKBFyFmQcOKa8HX3YvmhY//JEUafb6IR/m0gmaUVCfBTWitKJRNb7YAA==",
"dependencies": {
"Microsoft.CodeAnalysis.Analyzers": "5.3.0",
"Microsoft.CodeAnalysis.Common": "[5.6.0]"
}
},
"Microsoft.Extensions.AI.Abstractions": {
"type": "Transitive",
"resolved": "10.8.0",
"contentHash": "1tJZ5sAYrEq1YjNg8GrZSL1tVodsWRfrgybGsZo5DQsWQgLZ+dSR5uFowIS6vb/9zxGBPl7xpYq53QMjNObO9w=="
},
"System.Numerics.Tensors": {
"type": "Transitive",
"resolved": "10.0.10",
"contentHash": "9Ildb4Y9maDztB0ZRGxsNShbpCQ2Tjv2dr4IjskBxpTGhH7aIz1+oC+Hl833ASs/htWwsH6myNe+sRpeyCzeMQ=="
},
"rulecraft": {
"type": "Project",
"dependencies": {
"Microsoft.CodeAnalysis.CSharp": "[5.6.0, )",
"Microsoft.Extensions.AI.Abstractions": "[10.8.0, )"
}
}
}
}
}
Loading
Loading