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
7 changes: 7 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,13 @@ indent_style = space
indent_size = 4
dotnet_diagnostic.IDE0005.severity = warning

# Test methods are named with underscores by convention (method_scenario_expected).
# IDE0005 (unused usings) is kept out of the build for tests so it does not require
# GenerateDocumentationFile there; the IDE still tidies usings interactively.
[tests/**/*.cs]
dotnet_diagnostic.CA1707.severity = none
dotnet_diagnostic.IDE0005.severity = none

[*.{csproj,props,targets,slnx,yml,json,md}]
charset = utf-8
end_of_line = lf
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ jobs:
run: dotnet restore ConfigContraband.slnx

- name: Build
run: dotnet build ConfigContraband.slnx --configuration Release --no-restore
run: dotnet build ConfigContraband.slnx --configuration Release --no-restore -p:ContinuousIntegrationBuild=true

- name: Verify formatting
run: dotnet format ConfigContraband.slnx --verify-no-changes --no-restore --verbosity minimal
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/codeql.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ jobs:
run: dotnet restore ConfigContraband.slnx

- name: Build
run: dotnet build ConfigContraband.slnx --configuration Release --no-restore
run: dotnet build ConfigContraband.slnx --configuration Release --no-restore -p:ContinuousIntegrationBuild=true

- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@v4
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/publish.yml
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@ jobs:
run: dotnet restore ConfigContraband.slnx

- name: Build
run: dotnet build ConfigContraband.slnx --configuration Release --no-restore
run: dotnet build ConfigContraband.slnx --configuration Release --no-restore -p:ContinuousIntegrationBuild=true

- name: Verify formatting
run: dotnet format ConfigContraband.slnx --verify-no-changes --no-restore --verbosity minimal
Expand Down
28 changes: 26 additions & 2 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,32 @@

All notable changes to ConfigContraband will be documented in this file.

## Unreleased

## 0.7.21 - 2026-07-21

- Fixed a potential `AD0001` analyzer crash: `BindConfiguration(...)` registration
parsing now handles error-tolerant operations defensively and resolves the
`configSectionPath` argument with a guarded named lookup instead of an unchecked
cast and `.First()`, so the analyzer stays quiet while typing broken code in the IDE.
- Enabled the CI analyzer gate. The CI, publish, and CodeQL workflows now build with
`ContinuousIntegrationBuild=true`, so the .NET analyzers actually run in CI as
`Directory.Build.props` always claimed. Triaged every resulting warning: fixed
CA1305/CA1822/CA1826/CA1859/CA1861 in `src`, enabled `GenerateDocumentationFile`
(activating IDE0005 on build) and corrected the latent XML-doc issues (CS1734/CS0419)
it surfaced, and suppressed CA1707/IDE0005 for idiomatic underscore test naming.
CI-mode builds are now genuinely zero-warning.
- Performance: memoized `OptionsTypeMetadata` per compilation through a
`ConditionalWeakTable`, so one registration requesting the same options graph across
the validation, nested-validation, and unknown-key passes builds it once; the cache is
collected with its compilation and never leaks. Also collapsed CFG009's provenance
scan from seven full block traversals into a single document-order walk.
- Robustness: bounded the appsettings JSON parser nesting depth (64, matching
`System.Text.Json`) so a pathologically nested additional file is treated as malformed
instead of causing an uncatchable `StackOverflowException` in the compiler host.
- Cleanup: removed dead code (an empty partial file, three unused helpers, a vestigial
local and duplicated receiver-chain walk) and unified four byte-identical traversal
predicates into one shared `ExecutionScope.ShouldDescend` guard.
- Completed the showcase to cover all nine rules: added the missing `CFG002`, `CFG008`,
and `CFG009` examples and regenerated the bundled `appsettings.schema.json`.
- Internal maintainability refactor with no diagnostic behavior change. The
5740-line `ConfigContrabandAnalyzer` and 3666-line `OptionsTypeMetadata`
god-objects were split into concern-focused partial files (direct reads,
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ Use it when your app relies on strongly typed options and you want configuration
## Install

```xml
<PackageReference Include="ConfigContraband" Version="0.7.20" PrivateAssets="all" />
<PackageReference Include="ConfigContraband" Version="0.7.21" PrivateAssets="all" />
```

The package includes `buildTransitive` props that pass visible `appsettings.json` and `appsettings.*.json` files to the analyzer automatically. Add the package, build, and let your editor or CI tell you when your options contract and configuration drift apart.
Expand Down
7 changes: 4 additions & 3 deletions analyzer-health.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,9 @@

This file tracks the current ConfigContraband analyzer surface and the next hardening work that is still worth doing. It should stay practical: scores drive priority, notes describe shipped behavior, and gaps should be specific enough to turn into a focused PR.

Last refreshed: 2026-07-16
Package version: `0.7.20`
Base audited commit: `4305538`
Last refreshed: 2026-07-21
Package version: `0.7.21`
Base audited commit: `c570161`

## Scoring Rubric

Expand Down Expand Up @@ -171,6 +171,7 @@ Release readiness is healthy. The iteration-121 verifier passed 669 analyzer/tes
| 2026-07-16 | 119 | `CFG008` | The framework exposes non-generic `ConfigurationBinder.GetValue(IConfiguration, Type, string[, object])` overloads, but direct-read normalization recognized only `GetValue<T>`, so `configuration.GetValue(typeof(int), "Server:Port")` missed a proven conversion failure that the equivalent generic call reported. Two RED regressions expected CFG008 and received none. | Normalize the signed framework non-generic overloads only when the `Type` argument is a direct `typeof(...)` operation. Preserve root/known-section provenance, constant-path, deduplication, same-FQN, and default-side-effect gates; unwrap only built-in conversions when proving a boxed constant default or a direct type target, while dynamic `Type` expressions, user-defined conversions, and effectful defaults stay quiet. A review-driven RED regression proved an effectful user-defined `Type` conversion previously caused a false positive. Seven focused regressions cover instance/static named calls, a known section chain, dynamic types, effectful defaults/conversions, and source shadows. Full suite 664 analyzer/test + 246 core (910 total) passed; Release build completed with zero warnings/errors and formatter verification passed. Patch bump to `0.7.18`; README and CHANGELOG synchronized. | `CFG008` remains `5.00` / P3: the concrete false negative and review-found precision gap were fixed in the same iteration, strengthening direct-read overload parity without changing score math. |
| 2026-07-16 | 120 | `CFG009` | `AnalyzeConnectionStringRead` already composed a statically known receiver section with its relative `ConnectionStrings` namespace, but no focused regression pinned that branch. A future simplification could therefore compare `configuration.GetSection("Tenant").GetConnectionString("Databsae")` against the root `ConnectionStrings` section or accidentally widen stored-section receivers without failing tests. | Add focused instance and static named-call regressions proving `Tenant:ConnectionStrings:Databsae` suggests `Tenant:ConnectionStrings:Database`, plus a conservative guard proving a stored `IConfigurationSection` receiver stays quiet because its origin is invisible. All three tests passed immediately, confirming shipped behavior; no analyzer implementation changed. Full suite 667 analyzer/test + 246 core (913 total) passed; Release build completed with zero warnings/errors and formatter verification passed. Patch bump to `0.7.19`; README and CHANGELOG synchronized. | `CFG009` remains `5.00` / P3: relative connection-string namespace and provenance test depth improve without changing score math or diagnostic behavior. |
| 2026-07-16 | 121 | `CFG008` | The scalar helper already used `NumberStyles.Float` for `float` and `double`, but the runtime-backed parity suite covered only decimal and integral thousands separators; its reusable type resolver could not even construct `System.Single`. A future broadening to permissive number styles could therefore accept `"1,000"` even though `SingleConverter` and `DoubleConverter` throw. | Add direct runtime-converter proofs for both floating-point types, shared scalar-matrix cases, and end-to-end CFG008 options-binding diagnostics. The first focused run failed because the test resolver lacked `System.Single`; adding that mapping completed the missing branch. Full suite 669 analyzer/test + 250 core (919 total) passed; Release build completed with zero warnings/errors and formatter verification passed. Patch bump to `0.7.20`; README and CHANGELOG synchronized. | `CFG008` remains `5.00` / P3: converter-parity test depth improves without changing score math or diagnostic behavior. |
| 2026-07-21 | 122 | INFRA, Release | A full code review found the CI analyzer gate was dead (no workflow passed `ContinuousIntegrationBuild=true`, so .NET analyzers never ran despite the `Directory.Build.props` comment), a latent `AD0001` crash in `BindConfiguration` registration parsing (unchecked operation cast plus `.First()`), two per-registration performance hotspots (un-memoized `OptionsTypeMetadata.Create`, and seven CFG009 provenance traversals per block), an unbounded-recursion appsettings JSON parser, dead code left by the partial split, and a stale showcase missing `CFG002`/`CFG008`/`CFG009`. | Hardened the operation handling with a guarded named-argument lookup; wired `ContinuousIntegrationBuild=true` through the CI/publish/CodeQL builds and triaged every resulting warning (fixed CA1305/CA1822/CA1826/CA1859/CA1861 in src, enabled `GenerateDocumentationFile` and corrected the CS1734/CS0419 it surfaced, suppressed CA1707/IDE0005 for idiomatic test naming) to a genuine zero-warning CI build; memoized `OptionsTypeMetadata` per compilation through a `ConditionalWeakTable` and collapsed the CFG009 provenance scan to one traversal; capped JSON parse depth at 64; removed dead code and unified four duplicate traversal predicates into `ExecutionScope.ShouldDescend`; completed the showcase to all nine rules and regenerated its schema. | No rule scores change: no diagnostic IDs, severities, messages, or code fixes changed. Full suite 680 analyzer + 250 core (930 total) passed, the CI-mode build is zero-warning/zero-error, and formatter verification passed. Patch bump to `0.7.21`. |

## Health Baseline

Expand Down
31 changes: 31 additions & 0 deletions samples/ConfigContraband.Showcase/Program.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;

var services = new ServiceCollection();
Expand All @@ -7,6 +8,12 @@
services.AddOptions<MissingSectionOptions>()
.BindConfiguration("Strpie");

// CFG002: the "RequiredKey" section exists but is missing the [Required] ApiKey key.
services.AddOptions<RequiredKeyOptions>()
.BindConfiguration("RequiredKey")
.ValidateDataAnnotations()
.ValidateOnStart();

// CFG003: custom validation is registered, but startup validation is missing.
services.AddOptions<StartupValidationOptions>()
.BindConfiguration("StartupValidation")
Expand Down Expand Up @@ -34,13 +41,32 @@
.ValidateDataAnnotations()
.ValidateOnStart();

// CFG008: "eighty" cannot be converted to the int Port property; the binder throws at bind time.
services.AddOptions<ConversionOptions>()
.BindConfiguration("Conversion");

_ = services;

// CFG009: a direct read of a section path that is not in appsettings.json throws at runtime.
public sealed class DirectReader
{
public void Read(IConfiguration configuration)
{
_ = configuration.GetRequiredSection("Strpie");
}
}

public sealed class MissingSectionOptions
{
public string ApiKey { get; set; } = "";
}

public sealed class RequiredKeyOptions
{
[Required]
public string ApiKey { get; set; } = "";
}

public sealed class StartupValidationOptions
{
public string Endpoint { get; set; } = "";
Expand Down Expand Up @@ -78,3 +104,8 @@ public sealed class StrictUnknownKeyOptions

public string WebhookSecret { get; set; } = "";
}

public sealed class ConversionOptions
{
public int Port { get; set; }
}
5 changes: 5 additions & 0 deletions samples/ConfigContraband.Showcase/appsettings.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@
"Stripe": {
"ApiKey": "secret"
},
"RequiredKey": {
},
"StartupValidation": {
"Endpoint": "https://example.test"
},
Expand All @@ -21,5 +23,8 @@
"StrictUnknownKey": {
"ApiKey": "secret",
"WebookSecret": "typo"
},
"Conversion": {
"Port": "eighty"
}
}
19 changes: 19 additions & 0 deletions samples/ConfigContraband.Showcase/appsettings.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,17 @@
}
}
},
"RequiredKey": {
"type": "object",
"properties": {
"ApiKey": {
"type": "string"
}
},
"required": [
"ApiKey"
]
},
"StartupValidation": {
"type": "object",
"properties": {
Expand Down Expand Up @@ -67,6 +78,14 @@
"ApiKey"
],
"additionalProperties": false
},
"Conversion": {
"type": "object",
"properties": {
"Port": {
"type": "integer"
}
}
}
}
}
3 changes: 2 additions & 1 deletion src/ConfigContraband.Core/ConfigContraband.Core.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,8 @@
<RootNamespace>ConfigContraband</RootNamespace>
<AssemblyName>ConfigContraband.Core</AssemblyName>
<IsPackable>false</IsPackable>
<NoWarn>$(NoWarn);RS1038;RS1037</NoWarn>
<GenerateDocumentationFile>true</GenerateDocumentationFile>
<NoWarn>$(NoWarn);RS1038;RS1037;CS1591</NoWarn>
</PropertyGroup>

<ItemGroup>
Expand Down
37 changes: 28 additions & 9 deletions src/ConfigContraband.Core/ConfigurationModel.cs
Original file line number Diff line number Diff line change
Expand Up @@ -351,9 +351,11 @@ private static IEnumerable<ConfigurationProperty> EnumerateProperties(Configurat
}
}

private static readonly char[] PathSeparator = { ':' };

private static string[] SplitPath(string sectionPath)
{
return sectionPath.Split(new[] { ':' }, StringSplitOptions.RemoveEmptyEntries);
return sectionPath.Split(PathSeparator, StringSplitOptions.RemoveEmptyEntries);
}

private static bool IsAppSettingsFile(string path)
Expand Down Expand Up @@ -507,6 +509,11 @@ internal static class JsonConfigurationParser

private sealed class Parser
{
// Guards against an uncatchable StackOverflowException on a pathologically
// nested appsettings file: appsettings arrives as arbitrary AdditionalText, so
// the parser must survive any input. Matches System.Text.Json's default ceiling.
private const int MaxDepth = 64;

private readonly string _path;
private readonly SourceText _text;
private readonly bool _strictUnknownConfigurationKeySuppressedByAnalyzerConfig;
Expand All @@ -526,10 +533,10 @@ public Parser(
public ConfigurationNode? ParseRoot()
{
SkipWhitespace();
return Current == '{' ? ParseObject(parentPath: string.Empty) : null;
return Current == '{' ? ParseObject(parentPath: string.Empty, depth: 0) : null;
}

private ConfigurationNode ParseObject(string parentPath)
private ConfigurationNode ParseObject(string parentPath, int depth)
{
var properties = ImmutableArray.CreateBuilder<ConfigurationProperty>();
Read('{');
Expand All @@ -556,7 +563,7 @@ private ConfigurationNode ParseObject(string parentPath)
}

SkipWhitespace();
var parsed = ParseValue(fullPath);
var parsed = ParseValue(fullPath, depth);
properties.Add(new ConfigurationProperty(
key,
fullPath,
Expand Down Expand Up @@ -602,17 +609,29 @@ public ParsedValue(ConfigurationNode node, ScalarKind kind, string? raw, TextSpa
public TextSpan ValueSpan { get; }
}

private ParsedValue ParseValue(string path)
private ParsedValue ParseValue(string path, int depth)
{
SkipWhitespace();
if (Current == '{')
{
return new ParsedValue(ParseObject(path), ScalarKind.None, raw: null, default);
if (depth >= MaxDepth)
{
SkipMalformedValue();
return new ParsedValue(ConfigurationNode.Empty, ScalarKind.None, raw: null, default);
}

return new ParsedValue(ParseObject(path, depth + 1), ScalarKind.None, raw: null, default);
}

if (Current == '[')
{
return new ParsedValue(ParseArray(path), ScalarKind.None, raw: null, default);
if (depth >= MaxDepth)
{
SkipMalformedValue();
return new ParsedValue(ConfigurationNode.Empty, ScalarKind.None, raw: null, default);
}

return new ParsedValue(ParseArray(path, depth + 1), ScalarKind.None, raw: null, default);
}

if (Current == '"')
Expand Down Expand Up @@ -658,7 +677,7 @@ private static ScalarKind ClassifyScalar(string value)
return ScalarKind.Number;
}

private ConfigurationNode ParseArray(string path)
private ConfigurationNode ParseArray(string path, int depth)
{
var properties = ImmutableArray.CreateBuilder<ConfigurationProperty>();
var index = 0;
Expand All @@ -670,7 +689,7 @@ private ConfigurationNode ParseArray(string path)
var itemStart = _position;
var itemKey = index.ToString(System.Globalization.CultureInfo.InvariantCulture);
var itemPath = path + ":" + itemKey;
var parsed = ParseValue(itemPath);
var parsed = ParseValue(itemPath, depth);
properties.Add(new ConfigurationProperty(
itemKey,
itemPath,
Expand Down
20 changes: 20 additions & 0 deletions src/ConfigContraband.Core/ExecutionScope.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace ConfigContraband;

/// <summary>
/// Shared traversal guard for straight-line (same immediate execution scope) analyses. Lambda and
/// local-function bodies are deferred — they run when invoked, not where they are written — so any
/// analysis that tracks what executes at a point in the straight-line flow (binder-options escape
/// proofs, constructor-default proofs, configuration-origin provenance, local builder chains) must
/// not descend into them. The four former per-context descend predicates all reduced to this guard.
/// </summary>
internal static class ExecutionScope
{
public static bool ShouldDescend(SyntaxNode node)
{
return node is not AnonymousFunctionExpressionSyntax and
not LocalFunctionStatementSyntax;
}
}
3 changes: 2 additions & 1 deletion src/ConfigContraband.Core/JsonNode.cs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
using System.Collections.Generic;
using System.Globalization;
using System.Text;

namespace ConfigContraband;
Expand Down Expand Up @@ -73,7 +74,7 @@ private protected static void AppendString(StringBuilder builder, string value)
if (ch < ' ')
{
builder.Append("\\u");
builder.Append(((int)ch).ToString("x4"));
builder.Append(((int)ch).ToString("x4", CultureInfo.InvariantCulture));
}
else
{
Expand Down
Loading
Loading