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
12 changes: 7 additions & 5 deletions docs/architecture/IMPLEMENTATION_STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,11 +476,13 @@ without needing Phase 2's rule `metadata` first.
containing them (like this repo's own `examples/rules/`, all illustrative) isn't itself a
problem. Missing provenance is correctly not implemented - still blocked on §6/§19's
unimplemented `metadata` field.
- Tier 2 "unreachable rules": a top-level assertion whose `CapabilityDescriptor.AppliesTo` doesn't
include the target selector's `Produces` kind. Doesn't recurse into `must_all_match`/
`must_any_match`/`must_none_match`'s nested `assertions:` - those objects aren't recoverable from
the parsed model (only `Kind` survives parsing), only from the raw document, and this check
works from the parsed model since `Kind` is all it needs for the top level.
- Tier 2 "unreachable rules": an assertion whose `CapabilityDescriptor.AppliesTo` doesn't include
the candidate kind it is checked against - the target selector's `Produces` for top-level
assertions and `when:` leaves, and the nested `selector:`'s `Produces` for assertions inside
`must_all_match`/`must_any_match`/`must_none_match`'s `assertions:` (recursively). Top-level
uses the parsed model; nested and `when:` use the raw document (`RuleSetAnalyzer`'s
`NestedAssertionScanner`), since only `Kind` survives parsing. Findings carry a `Location` path
(e.g. `assertions[0].assertions[1]`, `when.and[1]`).
- Tier 2 "exact-duplicate rules": needs the raw source document instead (`RuleFileLoader.ReadDocument`),
for the reason above once parameter values matter - a `Canonicalize` helper recursively
key-sorts each rule's `target`+`assertions` (or `analyzer`) before grouping, so two rules
Expand Down
8 changes: 4 additions & 4 deletions docs/roadmap/HIGH_LEVEL_AI_ASSISTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -638,10 +638,10 @@ them being treated as one piece of work. They split into three tiers:
the target selector's `Produces` kind. Assertions with an empty `AppliesTo` (the quantifier/
existence kinds — `must_all_match`/`must_any_match`/`must_none_match`/`must_have_count`/
`must_exist`/`must_not_exist`, which evaluate a nested selector rather than the outer candidate)
are never flagged, by design. **Known limitation, not fixed**: this only checks the rule's
top-level assertions — it doesn't recurse into those quantifier assertions' own nested
`assertions:`, because the nested selector/assertion objects aren't recoverable from the parsed
model (only `Kind` survives parsing, the same limitation §13 works around for parameter values).
are never flagged, by design. Assertions nested in a quantifier's `assertions:` are checked
against that quantifier's `selector:`, and `when:` leaves against the target, via the raw source
document (the parsed model keeps only `Kind`, the same limitation §13 works around for parameter
values). Resolved — previously a known limitation.
* Exact-duplicate rules (identical `target`+`assertions`, or identical `analyzer`) — reads the raw
source document via `RuleFileLoader.ReadDocument` rather than the parsed model, for the same
reason, and canonicalizes (recursively key-sorts) it first so two rules that differ only in
Expand Down
2 changes: 1 addition & 1 deletion src/CodeGuard.Cli/Support/RuleAnalysisReportWriter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ public static void WriteConsole(RuleAnalysisReport report, TextWriter writer)
writer.WriteLine("Unreachable assertions:");
foreach (var issue in report.UnreachableRules)
{
writer.WriteLine($" - {issue.RuleId}: '{issue.AssertionKind}' cannot apply to a '{issue.TargetKind}' target ({issue.SourceFile})");
writer.WriteLine($" - {issue.RuleId}: '{issue.AssertionKind}' cannot apply to a '{issue.TargetKind}' target at {issue.Location} ({issue.SourceFile})");
}
}

Expand Down
158 changes: 136 additions & 22 deletions src/CodeGuard.Configuration/Analysis/RuleSetAnalyzer.cs
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,22 @@
using CodeGuard.Configuration.Loading;
using CodeGuard.Configuration.Validation;
using CodeGuard.RuleModel.Rules;
using System.Text.Json.Nodes;

namespace CodeGuard.Configuration.Analysis;

/// <summary>One rule whose only known assertion can never fire against its target's candidate kind.</summary>
public sealed record UnreachableAssertionIssue(string RuleId, string SourceFile, string TargetKind, string AssertionKind);
/// <summary>
/// One assertion that can never fire against the candidate kind it is checked on. <paramref name="TargetKind"/>
/// is the selector kind whose candidates it was checked against (the rule's `target`, or the `selector` of
/// the quantifier it is nested in). <paramref name="Location"/> is the path to the assertion inside the rule
/// document, e.g. <c>assertions[0]</c>, <c>assertions[0].assertions[1]</c> or <c>when.and[1]</c>.
/// </summary>
public sealed record UnreachableAssertionIssue(
string RuleId,
string SourceFile,
string TargetKind,
string AssertionKind,
string Location);

/// <summary>A set of rules whose <c>target</c>+<c>assertions</c> (or <c>analyzer</c>) body is structurally identical.</summary>
public sealed record ExactDuplicateGroup(IReadOnlyList<string> RuleIds, IReadOnlyList<string> SourceFiles);
Expand Down Expand Up @@ -105,13 +116,17 @@ private static bool CoversBothOutcomes(RuleDefinition rule) =>
rule.Tests.Any(t => t.Expect == TestExpectation.Pass) && rule.Tests.Any(t => t.Expect == TestExpectation.Fail);

/// <summary>
/// Flags a top-level assertion that the target selector's candidate kind can never satisfy, using
/// the same <see cref="CapabilityDescriptor.Produces"/>/<see cref="CapabilityDescriptor.AppliesTo"/>
/// metadata `rules discover` reports. Deliberately doesn't recurse into nested selectors/
/// assertions (inside `must_all_match`/`must_any_match`/`must_none_match`/`must_have_count`) -
/// those aren't exposed by the parsed model (only `IAssertion.Kind` survives parsing; nested
/// selector/assertion objects stay in private constructor fields, the same limitation
/// `rules explain --format json` works around by reading the source document instead). An
/// Flags an assertion that the candidate kind it is checked against can never satisfy, using the
/// same <see cref="CapabilityDescriptor.Produces"/>/<see cref="CapabilityDescriptor.AppliesTo"/>
/// metadata `rules discover` reports. Three places are checked:
/// <list type="bullet">
/// <item>top-level assertions, against the target selector (from the parsed model);</item>
/// <item>assertions nested in a quantifier's `assertions:`, against that quantifier's `selector:`;</item>
/// <item>`when:` leaves, against the target selector.</item>
/// </list>
/// The last two need the raw document (<see cref="NestedAssertionScanner"/>): only `IAssertion.Kind`
/// survives parsing, so nested selector/assertion objects are otherwise unrecoverable - the same
/// reason `rules explain --format json` and the exact-duplicate check read the source document. An
/// assertion with an empty `AppliesTo` (the quantifier/existence kinds, which evaluate a nested
/// selector rather than the outer candidate) is never flagged - see that field's own doc comment.
/// </summary>
Expand All @@ -129,30 +144,129 @@ private static IReadOnlyList<UnreachableAssertionIssue> FindUnreachableAssertion
continue; // analyzer-shaped rule - has no selector-driven target to check against.
}

if (!selectorsByKind.TryGetValue(rule.Target.Kind, out var selectorDescriptor)
|| selectorDescriptor.Produces is not { } produces)
{
continue;
}
var document = RuleFileLoader.ReadDocument(sourceFile).AsObject();
var produces = selectorsByKind.TryGetValue(rule.Target.Kind, out var selectorDescriptor)
? selectorDescriptor.Produces
: null;
var scanner = new NestedAssertionScanner(
rule.Id, sourceFile, selectorsByKind, assertionsByKind, issues);

foreach (var assertion in rule.Assertions)
for (var index = 0; index < rule.Assertions.Count; index++)
{
if (!assertionsByKind.TryGetValue(assertion.Kind, out var assertionDescriptor)
|| assertionDescriptor.AppliesTo.Count == 0)
var assertion = rule.Assertions[index];
var location = $"assertions[{index}]";
if (produces is { } candidate
&& assertionsByKind.TryGetValue(assertion.Kind, out var assertionDescriptor)
&& assertionDescriptor.AppliesTo.Count > 0
&& !assertionDescriptor.AppliesTo.Contains(candidate))
{
continue;
issues.Add(new UnreachableAssertionIssue(
rule.Id, sourceFile, rule.Target.Kind, assertion.Kind, location));
}

if (!assertionDescriptor.AppliesTo.Contains(produces))
{
issues.Add(new UnreachableAssertionIssue(rule.Id, sourceFile, rule.Target.Kind, assertion.Kind));
}
scanner.ScanNested(document["assertions"]?[index], location);
}

scanner.ScanCondition(document["when"], "when", rule.Target.Kind, produces);
}

return issues;
}

/// <summary>
/// Walks one rule's raw document for the assertions the parsed model can't expose: those nested in a
/// quantifier's `assertions:` list, and `when:` leaves. Kinds missing from the catalog are skipped
/// silently - unknown kinds are rejected by schema validation and `rules validate`, not by this check.
/// </summary>
private sealed class NestedAssertionScanner(
string ruleId,
string sourceFile,
IReadOnlyDictionary<string, CapabilityDescriptor> selectorsByKind,
IReadOnlyDictionary<string, CapabilityDescriptor> assertionsByKind,
List<UnreachableAssertionIssue> issues)
{
/// <summary>Descends into a top-level assertion's nested selector/assertions, without re-checking the assertion itself.</summary>
public void ScanNested(JsonNode? assertionNode, string location) =>
ScanAssertion(assertionNode, location, candidateKind: null, produces: null);

/// <summary>Walks a `when:` tree - `and`/`or`/`not` combinators down to assertion leaves, each checked against the target.</summary>
public void ScanCondition(JsonNode? conditionNode, string location, string targetKind, CandidateKind? produces)
{
if (conditionNode is not JsonObject { Count: 1 } node)
{
return;
}

var (key, value) = node.Single();
switch (key)
{
case "and" or "or":
var children = value?.AsArray();
for (var index = 0; children is not null && index < children.Count; index++)
{
ScanCondition(children[index], $"{location}.{key}[{index}]", targetKind, produces);
}

break;
case "not":
ScanCondition(value, $"{location}.not", targetKind, produces);
break;
default:
ScanAssertion(node, location, targetKind, produces);
break;
}
}

/// <summary>
/// Checks one `{ kind: params }` assertion against <paramref name="produces"/> when given (a `when:`
/// leaf, or an assertion nested in a quantifier), then recurses into its own nested `selector:` and
/// `assertions:` if its descriptor declares an <see cref="ParameterType.AssertionList"/> parameter.
/// </summary>
private void ScanAssertion(JsonNode? assertionNode, string location, string? candidateKind, CandidateKind? produces)
{
if (assertionNode is not JsonObject { Count: 1 } node)
{
return;
}

var (kind, parameters) = node.Single();
if (!assertionsByKind.TryGetValue(kind, out var descriptor))
{
return;
}

if (produces is { } candidate
&& candidateKind is not null
&& descriptor.AppliesTo.Count > 0
&& !descriptor.AppliesTo.Contains(candidate))
{
issues.Add(new UnreachableAssertionIssue(ruleId, sourceFile, candidateKind, kind, location));
}

if (parameters is not JsonObject parameterObject
|| !descriptor.Parameters.Any(p => p.Type == ParameterType.AssertionList))
{
return;
}

var nestedSelectorKind = parameterObject["selector"]?["kind"]?.GetValue<string>();
var nestedProduces = nestedSelectorKind is not null
&& selectorsByKind.TryGetValue(nestedSelectorKind, out var selectorDescriptor)
? selectorDescriptor.Produces
: null;

var nested = parameterObject["assertions"]?.AsArray();
for (var index = 0; nested is not null && index < nested.Count; index++)
{
ScanAssertion(
nested[index],
$"{location}.assertions[{index}]",
nestedSelectorKind,
nestedProduces);
}
}
}

/// <summary>
/// Groups rules whose enforceable body (<see cref="RuleBodyCanonicalizer.ExtractEnforceableBody"/>)
/// is structurally identical. Needs the raw source document, not the parsed model -
Expand Down
Loading
Loading